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:
aether link my-serverNo key generation. No authorized_keys. No invite code. Nothing to copy
between machines.
Setting it up
On the server box:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
tailscale status --selftailscale 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:
sudo tailscale up
aether link my-serverA 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
Pendingset, and can authenticate but not act until an admin runsaether member approve <member-id>. Until then commands fail withmembership pending admin approval. That is the role they join with; an admin can change it afterwards withaether member role(see teams.md). --tailnet-auto-joinremoves 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-keydemands 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:
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 sentA 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
aether link 192.168.1.50:2222The 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:
aether link 192.168.1.50:2222 --key ~/.ssh/aether_ed25519link 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 pullandgit push aethershell out to the systemsshclient, which follows~/.ssh/config. Point it at the same key with anIdentityFileline for the server host. aether daemon runandaether daemon installon the command line, which take their own--keyand 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:
aether invite --ttl 3600<invite-code>
expires <expiry-time>The teammate redeems it once, which registers their key as a collaborator and burns the code:
aether link <server-host> --invite <invite-code> --name "Example"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):
sudo aether-server config set web-port 443
sudo systemctl restart aether-serverAny device already on the tailnet - a phone, a tablet, a borrowed laptop - then opens the server's MagicDNS name and is already signed in:
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:
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 handoffmoves 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 guiserves 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.comfor Chrome and most Chromium browsers,updates.push.services.mozilla.comfor Firefox,web.push.apple.comfor Safari, and*.notify.windows.comfor Edge. The server connects directly, not through anHTTPS_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.