Admin console
Sign in to the built-in admin console through your identity provider, invite users, end console sessions, find each page, and build it from source.
Slopscale ships a web console at /console/ on the server’s own address. It
lists machines, users and keys, edits the policy, approves devices and users,
shares nodes and marks the global exit node from a browser, the same things
the CLI and the API do.
The console is a static, client-rendered application embedded in the
slopscale binary. It keeps no state on the server and needs no extra process:
open https://<your server>/console/ and it loads. The server’s front page,
https://<your server>/, sends a browser there too, and the console’s old
address, /admin/, still works: a link made before 0.31 is redirected to the
same page under /console/.
Signing in
The console signs in only through the identity provider: the
sign-in page has one button, Continue with Google (or the provider’s name),
and nothing else. The browser is sent through the provider and comes back
signed in as the matching slopscale user, holding a session cookie that lasts
seven days; Sign out in the account menu ends it. The provider’s redirect
URI is the same /oidc/callback as for device logins, so nothing more has to
be registered.
The client ID and secret go either in the configuration file or in the environment, whichever suits the deployment:
oidc:
issuer: https://accounts.google.com
client_id: 1234567890-abc.apps.googleusercontent.com
client_secret: GOCSPX-...
$ export SLOPSCALE_OIDC_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
$ export SLOPSCALE_OIDC_CLIENT_SECRET=GOCSPX-...
Every key of the configuration can be set this way: SLOPSCALE_ followed by
the key path with dots replaced by underscores.
A user who signs in for the first time is created the same way as on a device
login, including user approval when it is on: until an
administrator approves them, the sign-in page says so and opens no session.
The first user of an empty server becomes its owner. Anyone else who should
administer the server from day one is named in oidc.admin_users: an
address on that list is made an admin the moment it signs in, so nobody has
to hand out roles over the CLI first.
oidc:
admin_users:
- alice@example.com
$ export SLOPSCALE_OIDC_ADMIN_USERS="alice@example.com bob@example.com"
The list is checked on every sign-in and only ever promotes a member; the
owner and users who already hold a role keep it, and removing an address
does not demote anyone (use slopscale users set-role for that). The
promotion is written to the audit log as a system user.role.set.
Without an identity provider the console cannot sign anyone in and says so; the CLI and the API keep working with API keys.
What the console can show and change is decided by the signed-in user’s current role, read on every request:
- A role change takes effect at once, and deleting the user ends their sessions. So does Sign out everywhere.
- An auditor sees everything and can change nothing, an
it-admincannot approve routes, and so on. Pages the user cannot read are hidden; actions they cannot take are disabled. - A member sees the overview, their own machines and the ones shared with them, their own API keys, their access requests and their console sessions. They can rename, expire, remove and share a machine of their own, and nothing else; see what a member gets.
Everything the console changes is written to the audit log with the signed-in user as the actor.
Sessions
A sign-in opens a session that lasts seven days. There is no sliding renewal, so a stolen cookie is bounded the same way as a fresh one, and Sign out in the account menu ends the session it was made from.
Every session is listed under Sign-ins with the user it belongs to, when it was opened, when it last made a request, and the address and browser it came from. An administrator sees every session and can end any of them; a member sees and ends only their own. Ending a session takes effect on that browser’s next request: it is asked to sign in again.
$ curl -H "Authorization: Bearer $KEY" https://<your server>/api/v1/auth/sessions
$ curl -X DELETE -H "Authorization: Bearer $KEY" \
https://<your server>/api/v1/auth/sessions/7
slopscale sessions list prints the same table, --user <id> narrows it to one
user, and slopscale sessions end <id> ends a single session:
$ slopscale sessions list --user 3
$ slopscale sessions end 7
DELETE /api/v1/user/{id}/sessions, Sign out everywhere on the user’s page,
or slopscale users sign-out --identifier <id> signs one user out of every
browser, which is what to reach for when a laptop goes missing; it changes
nothing else about the account. All three are recorded in the
audit log as console.logout, session.end and
user.sessions.end.
The address a session records is the one the request came from after
trusted_proxies was applied, so a deployment behind a reverse proxy shows the
browser’s address rather than the proxy’s. A session opened before this version
shows neither an address nor a browser.
Inviting users
An administrator can invite someone by email instead of waiting for them to
find the sign-in page. Users → Invite asks for the address, the
role and any groups the person should join,
and hands back a link; slopscale invites create does the same from the CLI:
$ slopscale invites create --email ada@example.com --role admin --group 3 --expiry 72h
$ curl -X POST -H "Authorization: Bearer $KEY" \
-d '{"email":"ada@example.com","role":"admin","groupIds":["3"],"expiry":"72h"}' \
https://<your server>/api/v1/invite
--group is repeatable and --expiry defaults to 168h, a week, with 720h
the longest the server accepts. slopscale invites list shows which
invitations are still pending, slopscale invites resend <id> mints a fresh
link for one, and slopscale invites delete <id> withdraws it.
The link is https://<your server>/console/login?invite=<token> and is shown
once: the server keeps only a hash of the token, as it does for a session
cookie or a pre-auth key. When notifications.smtp is
configured the link is also mailed to the address, and the response says
whether that worked (emailSent, with emailError when it did not). A mail
that cannot be sent does not fail the invitation. The link works either way
and can be passed on by hand.
An invitation is consumed by the first login it fits, in either of two ways:
- the person opens the link, which carries the token through the identity provider and back, or
- the person signs in by themselves and the identity provider vouches for an email that matches a pending invitation.
Either way the user is created approved, even while user approval is on, because an administrator already vouched for the address, with the invited role and groups. An account that already exists keeps the role and groups it has and the invitation stays pending; an invitation cannot promote someone who is already signed up.
Invitations expire: seven days by default, thirty at most. An expired, revoked or already-used link says so on the sign-in page rather than signing the person in without the role they were promised.
$ curl -H "Authorization: Bearer $KEY" https://<your server>/api/v1/invite
$ curl -X POST -H "Authorization: Bearer $KEY" -d '{}' \
https://<your server>/api/v1/invite/4/resend
$ curl -X DELETE -H "Authorization: Bearer $KEY" https://<your server>/api/v1/invite/4
Re-sending mints a new token and a new expiry, so the link in the previous mail stops working. Revoking deletes the invitation.
An invitation cannot hand out ownership: the tailnet has exactly one owner and that role moves only by transfer. The invited address must be free of both an existing user and another pending invitation, otherwise the request is refused.
Creating, re-sending and revoking are recorded in the
audit log as user.invite.create, user.invite.resend and
user.invite.delete; accepting is recorded as user.invite.accept against
the user it created.
Getting around
The sidebar starts with the overview and is then grouped by what you are doing. Tailnet holds machines and users. Access holds the access controls, the keys, the page to ask for temporary access and the console sign-ins; a member sees their own machines, their own API keys and their own sign-ins. Connectivity holds networks, routes, services, apps, DNS and relays. Logs holds the audit log and the SSH session recordings. Settings holds the tailnet settings, the server page and the integrations, and only a role that may read them sees it. An item with several parts, such as Access controls, DNS, Relays, Keys or Integrations, opens into a page per part, each with its own address. A count next to Machines, Users, Routes and Requests says how many are waiting for approval. The arrow in the sidebar footer collapses it to an icon rail. Quick search, or ⌘ K / Ctrl K, jumps to any page, machine or user by name. The top bar shows breadcrumbs for where you are, the light/dark switch and the account menu with Sign out.
Pages
- Overview: counts, machines and users waiting for approval (approve them in place), the machines seen most recently, and a getting-started checklist while the tailnet is empty.
- Machines: every node with its owner or tags, addresses, status and routes. Search by name, address, user or tag and filter by status, user or tag; the filters live in the URL, so a filtered list can be shared. Tick rows to approve, expire or delete them in one go. The list refreshes on its own every fifteen seconds while the tab is open. Each machine has a detail page with its keys, routes and services (approve with a switch), sharing and the global exit node switch with its failover priority, plus rename, tag, expire and remove. For an online machine running Tailscale SSH, an SSH button opens a terminal in the browser; see SSH from the console. For client updates, health diagnostics, live preferences and attestation, see Managing a machine’s client. A warning the client reports about itself, such as a subnet router whose kernel drops forwarded packets, shows at the top of the page until the client stops reporting it or goes offline. Add machine mints a pre-auth key and hands over the join command for Linux, macOS, Windows and Docker, next to a QR code carrying the same line so a phone or a machine without a shared clipboard can pick it up; the iOS and Android tab gives the server address and where the app takes it.
- Users: create, invite, rename, approve, change the role and delete users. Each user has Sign out everywhere, which ends every console session of theirs. Invite mints a link for an address, with the role and groups the person gets on their first login.
- Keys: a page each for pre-auth keys (create with reusable, ephemeral, pre-authorized and tags; expire; delete), API keys and OAuth clients for the v2 API (create with scopes and tags; revoke; Rotate secret mints a new secret for an API key that keeps its id and scopes). New keys and client secrets are shown once, with a copy button. The OAuth clients page also holds federated identities: New federated identity registers the issuer, audience and subject a workload’s own OpenID token carries, with optional claim rules, and the scopes and tags it gets; the page then shows the token exchange call and a GitHub Actions snippet to copy, since no secret exists. A Kind column and filter tell the two apart, and the row menu edits a client’s scopes and tags or an identity’s trust conditions in place.
- Access controls: a page each for the rules, groups and postures, the access graph showing who can reach what, the queue of access requests, and the policy file in an editor that colours HuJSON, underlines what the server would refuse as you type (an unknown key, a group no section defines, an autogroup on the wrong side, a bad port), completes section names, rule keys and the names the file defines, explains a key or an autogroup on hover, and sends the draft to the server for the checks only it can do once typing pauses. The band above the file counts what is wrong. Check validates the draft against the server without saving; Save applies it. Leaving the page with unsaved changes asks first. The posture editor colours each expression the same way and underlines a parse error on the line it is on.
- Networks: networks that hand subnets and exit nodes to groups.
- Routes: every route any machine advertises, with approval for the ones no network owns.
- Services: services with their addresses, DNS name and hosts, and a switch to approve each host.
- Apps: apps reached through app connectors, with their domains, connector selectors, static routes, and the connector nodes with their learned and pending routes.
- DNS: a page each for nameservers with MagicDNS and search domains, split DNS for every machine or for some groups, and extra records, all changed at runtime; see DNS.
- Relays: a page each for the DERP map machines receive, the map sources and refetch schedule, the relays you run yourself, the embedded relay, and relay latency reporting measured round trips and home regions across the tailnet.
- Integrations: a page each for device posture integrations (CrowdStrike Falcon, SentinelOne, Microsoft Intune, Jamf Pro, Kandji, Kolide), webhooks (endpoints that receive signed event notifications, with their subscriptions and last delivery; see Webhooks), and log streams, which ship the audit log to a SIEM in batches.
- Audit log: who changed what, newest first, with filters by action, user and time, and Export as CSV or JSON with the same filters; see Audit log.
- Sign-ins: the console sessions with End and Sign out everywhere next to the signed-in credential’s role and scopes.
- Settings: a Tailnet page for the tailnet switches
(device and user approval, device trust, the key expiry cap,
SSH recording, and the state of tailnet lock with
Switch off), and a Server page for the server’s build, addresses, DERP
regions and config file values with a Maintenance section holding the IP
address backfill (
slopscale nodes backfillips).
SSH from the console
The machine page’s SSH button opens a terminal in the browser for an online
machine that runs Tailscale SSH (tailscale set --ssh, which the machine
record reports as sshServer).
The console runs Tailscale’s in-browser client (WebAssembly), which joins the
tailnet as an ephemeral machine owned by the signed-in user with a one-time key
valid five minutes (POST /api/v1/ssh-session). Because the client connects as
a machine of that user, the SSH policy decides whether the
session is allowed exactly as for any other machine of theirs:
autogroup:member and user rules match the signed-in user, while a tagged
machine needs a rule that admits the user. The ephemeral machine disappears
when the tab closes. A user whose role does not read devices can only open a
session to a machine of their own or one a machine of theirs already sees
under the policy; any other machine is reported as not found.
The SSH page offers the logins the machine suggests. They are a hint, not an authorisation, the SSH policy still decides, and when the machine names exactly one, it fills an empty field.
Connecting from the browser needs the target machine’s DERP relays
to be reachable over websockets (wss:). The
embedded relay supports it; a relay that does not
answers with a connection that never comes up.
A console built without make wasm shows a notice on the terminal page.
Managing a machine’s client
The console can query a connected machine and change what its client does; see device management. Every one of these questions goes to the machine over its control connection. A machine that is offline, or one with remote configuration off, answers 409; a client that refused answers 502 with its message; a machine that did not answer in time gives 504. The console shows these in place rather than as a notification.
A machine’s Overview lists hardware attestation as
Attested, Lost or Not attested. Lost means the machine once proved itself with
its hardware attestation key and its last map request did not; hovering the
state shows when it was attested and when the key last changed. A machine whose
client reported a TPM gets a TPM row with the manufacturer, vendor and firmware
version. The Remote configuration row says whether the machine ran
tailscale set --remote-config, which hands its local API to the tailnet admin.
Actions → Reset hardware attestation clears what the key proved without
touching the client. The next map request the machine signs starts the record
again; until then node:hardwareAttested is false and any posture checking it
fails. The machines list filters by attestation (Attested / Not attested). The
filter appears once some machine reports an attestation key and rides in the URL
as ?attested=.
The Client row offers Update now while the machine is connected, a newer stable
client exists and the caller has the devices:core scope. The control plane
cannot push an update: the client decides and refuses unless its owner allowed
it with tailscale set --auto-update=true, and the console reports what it
answered. The bulk bar’s Update clients asks the ticked machines that are
connected and behind, in one request, and reports “Started on N, M refused” with
a dialog listing what each client said.
The Client health section shows the warnings the client would show its own
user, with the client’s severity, how long it has been broken and whether
traffic is affected. Refresh asks again. An offline machine is not asked. The
Diagnostics block under Client health downloads the client’s own dumps
(preferences, network map, metrics, goroutines, socket stats and the tailnet
lock log) as the client wrote them. It needs devices:core.
The Preferences section reads the machine’s own settings live: advertised routes, whether it accepts routes and DNS, the exit node it uses, Tailscale SSH, shields, posture reporting and auto-update. Editing them needs remote configuration on; without it the section shows the command that turns it on and has no Edit button. Only the fields changed are sent, so a setting the machine’s owner changed meanwhile is left alone.
Connectivity gains a TLS certificate row for a machine that runs Funnel or announces a service: Valid, Missing, Expired, or what the client said went wrong. An app connector’s page gains Learned routes: the addresses the machine has resolved for the domains it answers for, refreshable.
Building from source
Release binaries and container images include the console. When building from source, build it first so the binary embeds it:
$ make web
$ make build
make web needs bun (the Nix development shell provides it).
make web now also runs make wasm, which compiles
tailscale.com/cmd/tsconnect/wasm with the Go toolchain (about 30 MB raw,
7 MB gzipped, embedded gzipped), and the client is served under
/console/tsconnect/. Running make wasm alone builds only the WebAssembly
client. A binary built without make web still serves /console/, with a page
saying the console is missing, and the API works as usual. A console built
without make wasm shows a notice on the terminal page.
The console lives in web/: React with the TanStack router, query and table
libraries and Cloudflare’s Kumo design system (Base UI
components and Tailwind CSS), checked
by TypeScript, oxlint and oxfmt. Its API types are generated from the server’s
OpenAPI document by make web-generate and committed. bun run dev in web/
starts a development server that proxies /api and /oidc to a slopscale on
http://127.0.0.1:8080 (set SLOPSCALE_URL to point elsewhere).
Signing in without Google
The console only signs in through an identity provider, so development and
tests need one that asks no questions. go run ./cmd/dev starts a slopscale
with a mock OpenID Connect provider running inside the same process: every
sign-in comes back as jane.doe@example.com, who is listed in that server’s
oidc.admin_users and therefore opens the console as an admin.
$ go run ./cmd/dev # server on :8080, provider on :9100
$ open http://127.0.0.1:8080/console/ # Continue with single sign-on
To sign in from the Vite development server instead, start slopscale with
its public URL set to Vite’s origin, so the provider sends the browser back
there: go run ./cmd/dev -server-url http://localhost:5173, then
bun run dev in web/ and open http://localhost:5173/console/.
make test-e2e runs the same flow in a browser: it builds the console,
starts cmd/dev, signs in through the mock provider, checks the audit log
and signs out (Playwright, web/e2e/). The Go side is covered by the
TestConsoleLogin* tests in hscontrol/servertest, which drive a mock
provider without a browser.
Serving behind a reverse proxy
The console lives under /console/ on the same origin as the API, so a
reverse proxy that forwards the whole host
needs no extra rules. It sends a Content-Security-Policy that allows its own
origin, 'wasm-unsafe-eval' for the in-browser client, and connect-src wss:
so the client can reach DERP relays over WebSockets; do not embed it in another
site’s frame.
If you prefer the console not to be reachable, block /console/ and /admin/
at the proxy.
Everything it does is also available through the API with the same key, so
this only removes the page, not the capability.