v0.8.7-beta.5 is out · 5 Novsee the release
Browse guides / Networking
DOCS

Networking and identity

docs/networking.md on GitHub

Aether needs exactly one thing from your network: the CLI must be able to reach the server's SSH port. Git transport, the control channel, event streams, PTY attach, the dashboard forward and ssh into a run (terminal.md) all multiplex over that one connection. The server opens no HTTP port unless you set web-port, and that one listens on the host's tailnet addresses only - see The dashboard. Through an edge the server opens no port at all: it dials out.

How you make that port reachable is up to you. Tailscale is the recommended answer, and it is also the recommended identity layer, because it removes SSH key management entirely. When neither side can reach the other, an edge relays the SSH connection instead: server and client both dial out to it, and you sign in with GitHub - see Through an edge.


Tailscale: the keyless path

Put the server on your tailnet and joining is three words long:

CODE
aether link my-server

No key generation. No authorized_keys. No invite code. Nothing to copy between machines.

Setting it up

On the server box:

CODE
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
tailscale status --self

tailscale status --self prints the machine's tailnet hostname (my-server.tailnet-name.ts.net); that is what you hand teammates. Then install the server as usual (sudo aether-server setup; see install.md) - it detects the tailscaled socket and reports the tailnet hostname itself.

The server checks for the tailscaled socket at startup. If it is there, tailnet identity is on; if not, the server is key-only. Start Tailscale before the server, and restart the server if you add Tailscale later.

On your machine, join the same tailnet and link:

CODE
sudo tailscale up
aether link my-server

A bare hostname picks up the default port :2222, so the MagicDNS name is the whole address. On first contact the CLI records the server's host key in ~/.ssh/known_hosts and prints its fingerprint - which is also why plain git push aether main works afterwards with no further setup.

How it works

When a connection arrives, the server asks the local Tailscale daemon who is on the other end - a WhoIs lookup against the connection's source address, the same mechanism Tailscale SSH uses. Tailscale already authenticated that person against your identity provider, so Aether reuses the answer as the member identity. The tailnet login becomes the member; the part before the @ becomes the default display name.

Aether does not take over port 22 and does not use Tailscale SSH itself (which targets OS accounts on the host). It keeps its own embedded SSH server and borrows only the identity mechanism.

Joining and approval

  • First contact creates the admin. The first tailnet identity to link a fresh server is registered as an admin, not pending. Solo developers never see a join step.
  • Everyone after that joins pending. They are registered as collaborators with Pending set, and can authenticate but not act until an admin runs aether member approve <member-id>. Until then commands fail with membership pending admin approval. That is the role they join with; an admin can change it afterwards with aether member role (see teams.md).
  • --tailnet-auto-join removes the approval step, for teams whose tailnet boundary already is the team boundary.
  • Revocation follows the tailnet. Remove someone from the tailnet, or deny them the server in your Tailscale ACLs, and they are locked out of Aether. aether member remove <member-id> is the in-Aether equivalent.

Details of the team flow are in teams.md.

The trust boundary

WhoIs names the device's owner, not the person at the keyboard. Any process or OS user on that machine can open a connection attributed to them. This is the same boundary Tailscale SSH has, and it is fine for single-user laptops - the common case.

Two guards for when it is not:

  • Tagged nodes get no identity. CI runners and shared boxes should be tagged. A tagged node is refused tailnet identity outright (tagged tailnet node; key authentication required) and must use a key.
  • --tailnet-require-key demands a registered SSH key in addition to WhoIs on every tailnet connection. Use it when you cannot guarantee single-user devices.

When tailscaled is not there

Every failure falls back to key authentication rather than locking anyone out:

Message on the client What happened
tailnet identity unavailable; key authentication required The lookup failed - the connection did not arrive over the tailnet, or tailscaled is down. Informational; the key path then runs normally.
tagged tailnet node; key authentication required The connecting node is tagged.
tailnet login <x> must also present a registered SSH key --tailnet-require-key is set.

You will see the first message routinely on a tailnet-enabled server whenever something connects over loopback or the LAN. It is not an error.

Members who have only a tailnet identity and no registered key cannot open new connections while tailscaled is down. Runs already going and PTYs already attached are unaffected.

The server's user must be able to read /var/run/tailscale/tailscaled.sock. Running as root (the shipped systemd unit) always can; for an unprivileged server user, sudo tailscale set --operator=<user>.


Through an edge

An edge is a relay both the server and your client dial out to, so neither needs an open port, a tailnet or a copied SSH key. The project runs one at https://edge.onaether.dev, and aether-server setup turns it on when the host has no tailscaled. SSH still runs end to end: the edge splices bytes and cannot read them. You sign in once with GitHub and link the server with the link you were given, which the desktop app also opens:

CODE
aether login
aether link --claim <code>        # the owner, with the code inside the claim link aether-server setup printed
aether link '<invite link>'       # anyone an admin invited, with the link the admin sent

A bare name such as my-server is always an SSH address, as on a tailnet; only a server id, a claim code or a join link goes through the edge.

The server's host key is checked against its id on every connection, and each device holds its own key. The server's owner chooses, on the machine, whether signing in is enough to admit a new device (edge-access account) or whether each new device waits for a person to approve it (approved-devices, the default when the setting is missing). Tailnet identities and SSH keys work the same under both. The edge serves no dashboard in this release: a phone reaches it over the tailnet (The dashboard) or through the Android app's Edge mode, and a computer through aether gui. edge.md covers the two policies, claiming, what the edge can see, and running your own; invitations and device approval are in teams.md.


Plain LAN, VPN, or a cloud box

No tailnet? Everything still works; you manage identity with SSH keys and invite codes instead.

The admin

CODE
aether link 192.168.1.50:2222

The first key to link a fresh server is registered as the admin. The CLI uses ~/.ssh/id_ed25519 by default and also offers any key loaded in your ssh-agent. With no key to offer (no id_ed25519, id_ecdsa or id_rsa in ~/.ssh and none in the agent), aether link creates ~/.ssh/id_ed25519 and its .pub itself. For a key at another path:

CODE
aether link 192.168.1.50:2222 --key ~/.ssh/aether_ed25519

link saves that path in ~/.config/aether/config.json, and re-linking without --key keeps it. Every command that dials the server's control connection - aether runs, attach, workspace, gui, and the daemon the dashboard installs - reads it from there. Two paths do not:

  • git. aether pull and git push aether shell out to the system ssh client, which follows ~/.ssh/config. Point it at the same key with an IdentityFile line for the server host.
  • aether daemon run and aether daemon install on the command line, which take their own --key and otherwise default to ~/.ssh/id_ed25519.

A --key path that does not exist fails before the dial, rather than falling back to the agent. The CLI never prompts for a passphrase: to use a passphrase-protected key, ssh-add it first. Without a usable key the handshake fails with attempted methods [none], followed by the reason the key it found was rejected - unreadable, unparseable, or passphrase-protected.

Everyone else

Unknown keys are refused (no Aether member for this key). An admin mints a one-time code:

CODE
aether invite --ttl 3600
CODE
<invite-code>
expires <expiry-time>

The teammate redeems it once, which registers their key as a collaborator and burns the code:

CODE
aether link <server-host> --invite <invite-code> --name "Example"
CODE
linked to <server-host> as Example (collaborator)

Invites default to a 24-hour TTL (--ttl is in seconds). Nobody needs shell access to the server box to join.

Exposure

Only the SSH port has to be reachable. Behind a VPN or on a private LAN, bind it normally. On a public cloud box, put the port behind a firewall or a VPN rather than opening it to the internet - Aether is an SSH server with a container runtime behind it.

Both identity paths share one member table, so a tailnet server can still hand out invites to someone connecting from outside the tailnet.


The dashboard

There are two ways to reach the dashboard, and they are independent of each other.

From your own machine. aether gui serves it from your laptop, bound to 127.0.0.1, over the same SSH connection the CLI uses. Nothing on the server listens for it, and nothing about it changes the server's network shape. See local-gateway.md.

From the server, for phones. Set web-port and the server hosts the dashboard itself, over HTTPS, on every tailnet address of the host (IPv4 and IPv6):

CODE
sudo aether-server config set web-port 443
sudo systemctl restart aether-server

Any device already on the tailnet - a phone, a tablet, a borrowed laptop - then opens the server's MagicDNS name and is already signed in:

CODE
https://my-server.tailnet-name.ts.net/

No token or install is needed to open this URL. Port 443 gives that bare URL; any other port appends :<port>. The server-hosted dashboard has no machine-local verbs or onboarding wizard, but its authenticated Files view can edit the shared member home. Use local aether gui for the directory picker and other local filesystem or repository actions. The startup line names what it bound:

CODE
aether-server <version> serving SSH on :2222 and the dashboard on https://my-server.tailnet-name.ts.net/ (data dir /var/lib/aether)

On Android, the app on every release in its Tailscale mode wraps this same URL full screen. It is a WebView and nothing more, so everything below holds for it unchanged, except notifications, which a WebView cannot receive. Its Aether Edge mode needs no tailnet and no web-port: it runs the local gateway on the phone and reaches the server through the edge.

web-port defaults to 0, which leaves the server SSH-only. aether-server setup asks for it on a tailnet host when --tailnet-require-key is off; aether-server install --web-port 443 and aether-server config set web-port 443 set it without questions (install.md).

Add it to your home screen

The dashboard ships a web app manifest, so a phone can keep it as an app instead of a tab. The installed app opens full screen with no browser chrome: the dashboard draws its own top bar, so nothing is lost.

  • Android. Open the server URL in Chrome and pick Install app from the ⋮ menu.
  • iPhone. Open the server URL in Safari and pick Share > Add to Home Screen.

Install the server URL, not an aether gui one. aether gui puts a per-process bearer token in the URL, and the home-screen icon starts the app at the site root with no query string - so a saved aether gui app opens unauthenticated, and the token is dead the next time aether gui restarts anyway. The server-hosted dashboard has no token to lose: every request is identified by tailnet WhoIs, so the installed app is signed in whenever the phone is on the tailnet.

There is no offline mode. The dashboard is served out of the server binary and has to change the moment the binary does, so nothing is cached; away from the tailnet the installed app shows the same connection error the browser does. The one service worker the dashboard ships, /sw.js, shows notifications and answers no requests.

Notifications

The server-hosted dashboard can notify a device when one of your runs needs you. On that device, open Settings and turn on Notify this device when a run needs me. The browser asks for permission once. Each browser subscribes on its own, and turning the switch off unsubscribes that one. Send test notification sends one through the whole path and shows the push service's own error when it fails.

A notification goes to a run's owner when the run starts needing them, by the rule Needs you applies:

The run The notification says
asks for a permission Permission: <action>, or Permission requested
asks a question Question from the agent
reports that it is blocked Blocked: <summary>
asks you with report --outcome needs-input the question
needs you another way Waiting for your reply, or the usage-limit reason
loses its Enhanced session Enhanced could not start: <error> or Agent disconnected: <cause>
fails a turn or reports failure Turn failed: <error>, Agent failed or Agent reported failure, review the result
reports success Agent reported success, review the result, once each time the review becomes ready
ends failed with a result nobody has seen Failed, review the result

A run that is working, waiting, stalled, waiting on its own agents, CI or a process, or whose success you marked complete or put off for later does not notify. A Background run does not notify for a question, a paused run notifies once it is resumed, and a swarm worker, whose integrator answers for it, notifies only when blocked. A teammate's question, a queued message and the swarm rows do not notify. A success is sent with Urgency: normal, which a device saving battery may hold back; everything else with Urgency: high.

  • The title is the run's title, or the first line of its task. Tapping the notification opens that run, in a dashboard window that is already open when there is one.
  • One notification per run: a newer one replaces the older.
  • A standing state is announced once, and nothing is sent for one that has cleared. A notification already on a device stays until it is dismissed.
  • While you are using a dashboard on any device, notifications wait. Moving the pointer, typing and scrolling count as using it. A notification is sent once you have left every dashboard alone for a minute, if the run still needs you.
  • Runs the assistant launched or messaged for you also notify you when they finish, and when a run you do not own needs you, with the same one-per-run topic and the same wait.
  • aether handoff moves a run's notifications to its new owner, who is told what the run needs at that moment.

It needs:

  • The dashboard your server hosts. aether gui serves from a loopback port behind a per-process token, where a notification could not open the run later, so its Settings say so instead of offering the switch. The desktop app shows its own notification while it is open.
  • A browser with Web Push. Chrome, Edge and Firefox on a computer or on Android, and Safari on a Mac. On an iPhone or iPad it works only in the dashboard added to the home screen, on iOS 16.4 or later; in a Safari tab the switch says so. The Android app cannot receive notifications at all: on an Android phone, install the dashboard from Chrome as above and turn the switch on there.
  • Outbound HTTPS from the server to the browser vendors' push services, on port 443: fcm.googleapis.com for Chrome and most Chromium browsers, updates.push.services.mozilla.com for Firefox, web.push.apple.com for Safari, and *.notify.windows.com for Edge. The server connects directly, not through an HTTPS_PROXY, and only to public addresses: an endpoint on the server's own host, LAN or tailnet is refused.

The server signs every message with a key it creates on first start, <data-dir>/push/vapid_key.pem, mode 0600. Each subscription is bound to that key. If the file is lost, turn the switch off and on again on each device.

A notification the push service does not take is logged as push: notification not delivered with the service's answer, and never delays the run. A device the push service reports gone is forgotten, and its switch reads off the next time Settings opens. What the push service receives is in privacy.md.

What it needs

  • tailscaled on the server host, running before the server starts. It is the same daemon and the same unix socket (/var/run/tailscale/tailscaled.sock) the WhoIs identity path uses.
  • MagicDNS and HTTPS certificates enabled for the tailnet, in the Tailscale admin console on the DNS page. The certificate and key for the node's MagicDNS name come from tailscaled, which issues them through Let's Encrypt and renews them; the server re-fetches hourly in the background and keeps serving the cached pair if a refresh fails.
  • Permission to fetch that certificate, which is root or the tailscaled operator (sudo tailscale set --operator=<user>). The shipped systemd unit runs as root.

Plain HTTP is never offered - there is no redirect and no cleartext port. A server that cannot get the certificate does not start.

Who you are on it

Every request is identified the same way an SSH connection is: a WhoIs lookup on the request's source address, resolved afresh per request. There is no session and no token, so nothing can outlive the tailnet's answer - remove someone from the tailnet, or deny them the server in your ACLs, and their next request is refused.

That means the joining, approval and capability rules above apply unchanged. The first identity to reach a fresh server becomes the admin; later ones join pending unless --tailnet-auto-join, and a pending member's dashboard shows membership pending admin approval on every call until an admin runs aether member approve <member-id>. Every call then passes the same capability checks a CLI call does.

The trust boundary is the same one, and it now covers the browser. WhoIs names the device's owner, not the person holding the phone. Tagged nodes are refused here too. --tailnet-require-key and web-port are mutually exclusive: HTTP cannot present a key, so a server set to require one refuses to start with the dashboard on.

Key-only and invite-code servers - anything with no tailscaled, including one reached through an edge - have no server-hosted dashboard in this release. On a computer aether gui serves it; on a phone the Android app's Edge mode does, for a server reached through an edge.

Android DNS

Go's pure resolver reads /etc/resolv.conf, which Android does not have. The Android app in Edge mode (permission ACCESS_NETWORK_STATE) writes the active network's DNS servers to resolv.conf in its private files directory, rewrites it on network change, and starts the bundled CLI with AETHER_RESOLV_CONF set to that path. The CLI honours the variable only when /etc/resolv.conf is absent, so it changes nothing on a computer.

Where it refuses

The server refuses to start rather than serving something it cannot identify or encrypt. Each message is printed on stderr and lands in journalctl -u aether-server:

Message What to do
the dashboard identifies members by tailnet WhoIs and tailscaled was not running when the server started (no /var/run/tailscale/tailscaled.sock); start Tailscale before the server, or set web-port to 0 Start tailscaled first, then the server.
tailnet-require-key demands an SSH key on every tailnet connection and the dashboard cannot present one; set one of tailnet-require-key and web-port off Pick one: keyed tailnet connections, or the dashboard.
tailscaled did not report this node: status has no DNS name Enable MagicDNS for the tailnet.
servergw: tailscaled reports no tailnet address for <name> The node is not up on the tailnet; tailscale status --self.
servergw: HTTPS certificate for <name>: <error>; enable MagicDNS and HTTPS certificates for the tailnet in the Tailscale admin console (DNS page), or set web-port to 0 Enable HTTPS certificates, or run as root or the tailscaled operator. The error tailscaled gave is quoted in place of <error>.
servergw: listen: no tailnet address could be bound: <errors> The port is taken on every tailnet address, or binding it needs privileges the server does not have. One address that will not bind - the IPv6 one on a host with IPv6 disabled - is only a warning in the journal (servergw: tailnet address not bound); the others still serve.

Once it is serving, a request that cannot be identified is refused per request, with the JSON error body the dashboard shows:

Status Message What happened
403 tagged tailnet node; the dashboard identifies members by their tailnet login and a tagged node has none The request came from a tagged node. Tagged nodes use a key over SSH; they have no dashboard.
503 tailnet identity unavailable: <error> The lookup failed - tailscaled is down, or the source address is not on the tailnet.

Why it is off by default

The listener has hard prerequisites (tailscaled, MagicDNS, HTTPS certificates) that an existing install may not have, and a server that failed to start after an upgrade would be the wrong way to find that out. So web-port defaults to 0 and turning it on is a deliberate act. aether-server setup offers 443 by default on a host that already has tailscaled, because a phone reaching the dashboard is the point of putting the server on a tailnet, and a tailnet that is not ready says exactly what to enable on the first start.


Other tunnels

The reachability seam inside the server covers announcement and address discovery - Tailscale first-class, plain host/port always available, and the edge for servers nothing can dial. Anything else that gets a TCP port from your laptop to the server box works too; Tailscale and the edge are the paths with identity built in.