Security

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

Operator checklist

What the operator keeps doing, since no script does it for them.

The release key

Keep the release signing key's private half off the server, in a password manager. Rotate it as in the runbook.

Who can open tunnels

With an account service (LINK_ACCOUNTS_URL), anyone with an account there can open tunnels: watch link-relay admin user list and link-relay admin tunnel list, and read abuse reports. Cut someone off at once with link-relay admin user block <user>.

Whom it trusts

Name only an account service you trust, since it says who every token is, and set its public key (LINK_ACCOUNTS_KEY) from a source you trust: whoever holds the matching private key can sign in as anyone.

Backups

Set up off-site backups. They're off by default, and without them a lost server loses the users file. Add a second recipient key kept apart from your laptop, and check that a restore works.

Bootstrap after a change

Re-run bootstrap after a change to plugins/networking/relay/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-public/core/ in your own checkout (ssh root@host 'bash -s -- …' < plugins/networking/relay/deploy/bootstrap.sh), never from a download.

The abuse contact

Forward so@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

It 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 scripts/release/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

Bootstrap refuses a release whose relay is older than the highest of --min-version (which create-droplet.sh and rebuild.sh pass from the checkout), the floor it kept before and the relay already installed, and keeps that version in /etc/link/min-relay-version.

What the floor holds

The updater installs nothing older, whether or not a relay is installed yet. A fresh server given no --min-version takes any signed release, older ones too.

The deploy files

link-relay-update, link-state, the systemd units and 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

It's 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, and the relay's systemd unit confines the relay itself.

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

It's set in /etc/ssh/sshd_config.d/10-link.conf, and 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

They install daily and reboot at 04:00 UTC when a new kernel needs it. Tunnels reconnect by themselves.

The relay's process

The relay 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 limits

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, their proven custom domains and the hosts of users with share links, so nobody can use up Let's Encrypt's weekly limit for the relay's domain by inventing hostnames.

Certificates a user may get

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.

Hosts it approved

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.

Custom domains

A user can serve a tunnel at a domain of their own (lnk tunnel open --domain app.example.com) once they prove it's theirs.

Proof

A TXT record at _link.<domain> holding link-tunnels=<user>.<relay domain>, read over DNS over HTTPS (Cloudflare's by default, LINK_DNS_OVER_HTTPS), so nobody between the relay and the resolver can forge the answer. The resolver itself is trusted: it could prove any domain for anyone.

How long a proof lasts

The relay reads the record when a tunnel asks for a domain its user doesn't hold, at most 20 times an hour per user, and keeps the proof in /var/lib/private/link/domains until another user proves the domain, the user replaces it, or no tunnel asks for it for 100 days. Removing the record later doesn't end the proof: whoever controls the domain's DNS can move it by proving it for another user, or send its visitors elsewhere.

What it reaches

A custom domain's requests go only to the one connected tunnel that asked for it, whose user holds the proof: never a newer connection on that tunnel that didn't ask for it, another user's tunnel, share links or the relay's own paths.

What it passes

They pass the same password, warning page, limits and host-only cookies as the tunnel's own host. A protected tunnel takes requests from its custom domain's pages as its own, and from no other site.

What it can't be

Never the relay's domain or a name under it, an address or a single label, so a user can't claim another user's host. --account tunnels can't have one: the sign-in sets its cookie on the tunnel's own host.

Its certificates

/_link/tls-ask approves a custom domain only while the tunnel that asked for it is connected, counted in its user's 7 new hosts a day. Its certificates count against Let's Encrypt's limits for the user's domain, and the relay's account's.

How many

One per user (LINK_DOMAINS_PER_USER; 0 turns them off). Proving another lets go of the least recently used one that no tunnel serves now.

Its secrets

The relay's own, shared with no user. Users' credentials (operator tokens, access tokens, visitors' passes) are on Credentials. The relay holds no key that makes a user's token: it checks access tokens with the account service's public keys and operator tokens with their hashes. Accounts' tokens never touch its disk.

A stopped account

Every 30 seconds the relay fetches the account service's list of stopped accounts (GET /api/v1/stopped), keeping the last one while the service can't be reached, so the service is never in a request's path. It refuses an account's token made before its stop (lnk iam stop), and as the list arrives closes what such a token opened: a tunnel's session, and a visitor's sign-in, whose cookie holds when its token was made. A stop takes hold here within 30 seconds, not a token's 5 minutes or a cookie's day.

The users file

Each of the operator's users' tokens, as its SHA-256: the relay checks tokens with it, and a leak of it lets no one connect. It's /etc/link/users, root-only, given to the relay as a private copy (LoadCredential), and lasts while a line is in it.

The warning key and the password key

HMAC keys in the relay's memory, made at each start. The warning key signs the cookie that lets a visitor past the warning page. The password key remembers a share link's password once given right, so a visitor coming back costs no PBKDF2.

The pass key

An HMAC key in the relay's memory, made at each start, so a restart signs every visitor out. It signs the sign-in cookies of visitors to tunnels that let in only some people, made once the relay checked their access token. Whoever had it could let themselves in to any such tunnel.

Each signed URL is a bearer credential to one of a user's files until it expires, 7 days at most, and each password a salted PBKDF2 hash, for the link's life. Both are in /var/lib/private/link/shares, root-only, and its snapshots.

The backup bucket's key

It writes snapshots to the off-site bucket, from /etc/link/backup.env, mode 600, until the operator replaces it.

Caddy's keys

Its ACME account's key and each certificate's private key, in Caddy's data folder, root-only, for Let's Encrypt's life: a certificate's 90 days.

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 (Configuration). 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.

What proves they work

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.

What an import refuses

link-state import refuses archives with links, special files or paths outside the state folders. It unpacks a snapshot into a folder of its own first, every file root's and with no setuid, setgid or sticky bit, and puts it in place only once it's whole, so one that fails partway changes nothing.

What an import keeps

Whatever the archive holds, it keeps the server's release key, version floor and backup settings (backup.env, backup-recipients), and of its relay.env takes only the relay's own settings (LINK_DOMAIN, LINK_ACCOUNTS_URL, LINK_ACCOUNTS_KEY, LINK_NOTICE, LINK_WEBSITE), never another variable of the relay's process.

A snapshot is only as trustworthy as its bucket

age doesn't prove who encrypted a snapshot, and the recipients' keys are public. Anyone who can write to the bucket (its key is in /etc/link/backup.env on every server, and the provider) can upload one with their own users, tokens and settings, the account service whose logins the relay takes among them, 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.

Operational measures

The relay exports aggregate connection counts, request counts and encoded agent traffic bytes, with its role and deployment ID. They contain no account, tunnel, visitor, path or body. Export credentials come from a private header file and are sent only to the configured OTLP endpoint, over HTTPS except to this machine's loopback. A host's ID distinguishes deployments that share a domain.