Security

How a relay server is hardened, and what its operator should keep doing. Setting it up is in the runbook.

Operator checklist

  • Keep the release signing key's private half off the server, in a password manager. Rotate it as in the runbook.
  • Keep LINK_GITHUB_ALLOW to people you know. With open signups (*), watch link-relay admin users and the login log, and read abuse reports.
  • Cut someone off at once with link-relay admin block <user>; see who is connected with link-relay admin tunnels.
  • Set up off-site backups. They're off by default, and without them a lost server loses every account. Add a second recipient key kept apart from your laptop, and check that a restore works.
  • Re-run bootstrap after a change to src/server/deploy/files, with --email when the Caddyfile changed: the updater replaces only the relay binary, and bootstrap keeps the old Caddyfile without --email. Run it from link-core/ in your own checkout (ssh root@host 'bash -s -- …' < src/server/deploy/bootstrap.sh), never from a download.
  • Forward dev@local.link to someone who reads it. It's the contact on the terms page.

What a server installs

Everything a server runs as root comes from the operator's checkout or is signed with the release key:

  • bootstrap.sh comes from the operator's checkout, piped over SSH. The releases repo doesn't publish it.
  • The release key. bootstrap.sh carries the public key (a test keeps it the same as src/shared/release-signers) and installs that as /etc/link/release-signers, after restoring any snapshot. Neither a release nor a snapshot supplies it: link-state import keeps the server's own.
  • The version floor. With --min-version (which create-droplet.sh and rebuild.sh pass from the checkout), bootstrap refuses a release whose relay is older, and keeps the highest version given in /etc/link/min-relay-version. The updater installs nothing older, whether or not a relay is installed yet. Without it, a fresh server takes any signed release, older ones too.
  • The deploy files (link-relay-update, link-state, the systemd units, the Caddyfile). bootstrap.sh checks the release's SHA256SUMS.sig against its key with ssh-keygen -Y verify, then each file against SHA256SUMS, before it installs any of them.
  • The relay binary. link-relay-update installs a build only if its sum is in a SHA256SUMS signed by a key in /etc/link/release-signers.
  • --from DIR is for trying unreleased deploy files: bootstrap installs that folder on the server as it is, and a relay binary in it, with no signature check. Use only a folder you copied there yourself.

The relay server

bootstrap.sh hardens the host on every run:

  • Firewall. ufw denies all incoming traffic except SSH, 80 and 443. The relay itself listens on 127.0.0.1 only.
  • SSH with keys only, in /etc/ssh/sshd_config.d/10-link.conf. It's applied only when root already has a key in /root/.ssh/authorized_keys, so it can't lock the operator out.
  • fail2ban bans an address for an hour after 5 failed SSH logins.
  • Security updates install daily and reboot at 04:00 UTC when a new kernel needs it. Tunnels reconnect by themselves.

The relay process runs as a systemd DynamicUser and listens on loopback only, with only Unix and IP sockets. It gets the users file as a private copy (LoadCredential), so the file stays root-only. Its unit caps its memory (75% of the machine's) and threads (1,024), and allows only the system calls a network service makes (@system-service: no mounts, kernel modules, ptrace, clock changes or reboots). It may hold 65,536 open files, and cuts off request bodies sent slowly before a login (see the tunnel's security), so slow senders can't use up its connections.

Certificates. Caddy issues certificates on demand, but first asks the relay (/_link/tls-ask). The relay approves only the bare domain, tunnels connected right now and the hosts of users with share links, so nobody can use up Let's Encrypt's weekly limit for local.link by inventing hostnames. Each user gets certificates for at most 7 new hosts a day (49 a week, under the limit of 50), so one user can't use it up by opening and visiting new tunnel names either; past that, a new tunnel's visitors get a TLS error until the next day. Several users together still can. The relay keeps the hosts it approved, and the day's counts, in /var/lib/private/link/certificates, so a restart doesn't reset them. A host approved before is approved again without counting, since Caddy asks again for hosts it has a certificate for after a restart and before each renewal; one nobody visits for 100 days is forgotten.

Secrets on disk:

  • /etc/link/relay.env: settings, mode 600, with the GitHub OAuth app's client secret (LINK_GITHUB_CLIENT_SECRET) when sign-in is on.
  • /etc/link/users and /var/lib/private/link/users: token hashes, root-only.
  • /var/lib/private/link/shares, and its snapshots: share links' signed URLs, each a bearer credential to a user's file until it expires (7 days at most), and their passwords' salted hashes, root-only.
  • /etc/link/backup.env: bucket credentials, mode 600.

Backups. By default a server has only its nightly snapshots in /var/backups/link (root-only, 7 kept), and they die with it. Off-site backups are opt-in, with /etc/link/backup.env (see CONFIGURATION.md). Each snapshot is then encrypted with age to the operator's public keys before upload, so neither the server nor the bucket can read old backups. Link's tests upload only to a local S3 stand-in, not a real provider: the setup's test upload and restore check are what prove it works with yours. link-state import refuses archives with links, special files or paths outside the state folders, and keeps the server's release key and version floor whatever the archive holds.

age doesn't prove who encrypted a snapshot, and the recipients' keys are public, so a snapshot is only as trustworthy as its bucket. Anyone who can write there (the bucket's key is in /etc/link/backup.env on every server, and the provider) can upload one with their own users, tokens and settings, and a restore takes them.

A new droplet's first SSH. create-droplet.sh accepts the new droplet's host key on first contact, since the cloud doesn't publish it. Someone on the network path at that moment could stand in for the droplet and get bootstrap's arguments and the snapshot it copies.