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.
Share links' signed URLs and passwords
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.