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 and the dashboard forward 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.
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.
Tailscale: the keyless path
Put the server on your tailnet and joining is three words long:
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:
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:
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
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>.
Plain LAN, VPN, or a cloud box
No tailnet? Everything still works; you manage identity with SSH keys and invite codes instead.
The admin
ssh-keygen -t ed25519 # if you do not already have a key
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.
For a key at another path:
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 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-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:
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 wraps this same URL full screen. It is a WebView and nothing more, so everything below holds for it unchanged.
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 title bar and status 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 and no service worker. 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.
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 - have no
phone dashboard in this release. aether gui is the whole story there.
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 leaves room for a tunnel adapter later. Aether ships no relay infrastructure of its own and does not plan to. Anything that gets a TCP port from your laptop to the server box works today; only Tailscale gets the keyless identity integration.


