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_ALLOWto people you know. With open signups (*), watchlink-relay admin usersand the login log, and read abuse reports. - Cut someone off at once with
link-relay admin block <user>; see who is connected withlink-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--emailwhen the Caddyfile changed: the updater replaces only the relay binary, and bootstrap keeps the old Caddyfile without--email. Run it fromlink-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.shcomes from the operator's checkout, piped over SSH. The releases repo doesn't publish it.- The release key.
bootstrap.shcarries the public key (a test keeps it the same assrc/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 importkeeps the server's own. - The version floor. With
--min-version(whichcreate-droplet.shandrebuild.shpass 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.shchecks the release'sSHA256SUMS.sigagainst its key withssh-keygen -Y verify, then each file againstSHA256SUMS, before it installs any of them. - The relay binary.
link-relay-updateinstalls a build only if its sum is in aSHA256SUMSsigned by a key in/etc/link/release-signers. --from DIRis 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/usersand/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.
