tui-tools

Guides

A company tailnet with Keycloak single sign-on

A local CA from tui-cert, Keycloak served over https with a certificate from it, and a Headscale control plane set up with tui-tailscale that admits only the members of one Keycloak group. Run end to end in the family lab and told through the screens it produced, including the login that was refused.

tui-tailscaletui-certtui-firewall

This is a case, told through the screens of a real run. On 2026-09-25 the path below ran end to end in the family lab, on two virtual machines, with tui-tailscale v1.0.1, tui-cert v0.3.1 and tui-firewall v0.5.0, against Headscale 0.29.3 from pkgs.tui.tools, Tailscale 1.102.4 and Keycloak 26.7.4. The control plane was Ubuntu 24.04; the laptop was Omarchy Server 4.0.1, an Arch-based system.

Every terminal image is a frame the tool drew during the run. The TUIs ran in a 120 by 34 tmux window, each frame was saved with tmux capture-pane -e, and tui-kit v0.4.3’s render-screenshots.py --from-ansi turned it into the image, the same renderer the tools’ README screenshots come from. Dialog frames are cropped to the rows of the dialog; nothing in them is edited. The browser images are screenshots from the same run. Secrets appear as placeholders such as <ADMIN_PASSWORD>, and none of them is in any frame.

What you end up with

A small company wants its own tailnet. People join from their laptops by signing in with their company account, only the members of one group get in, nothing depends on a public certificate authority, and nothing skips TLS verification.

Piece Where What it is
CA lab-ca control plane a local certificate authority kept by tui-cert; every machine that takes part trusts it
Keycloak https://sso.lab.internal:18443 the identity provider, realm company, with a certificate from lab-ca
Headscale https://vpn.lab.internal:19443 the control plane, driven by tui-tailscale, with its own certificate from lab-ca
Group vpn-users in Keycloak the allow list: alice is a member, bob is not
company.internal MagicDNS the names nodes get, such as laptop.company.internal

The control plane is also the first node and routes a subnet to everyone who accepts routes. At the end alice’s laptop is on the tailnet, and bob, who has a working Keycloak account, is turned away at the browser.

How the lab differs from a real network

  • Both machines are QEMU guests behind separate user-mode NATs. They reach the lab host as 10.0.2.2, and the host forwards ports 18443 and 19443 into the control plane over ssh. That is why those ports, and why the certificates carry 10.0.2.2.
  • sso.lab.internal and vpn.lab.internal are lines in /etc/hosts on both guests. On a real network they are records in your internal DNS.
  • UDP does not cross an ssh forward, so the two nodes never connect directly and their traffic goes through a DERP relay. With 41641/udp reachable, nodes connect directly and the relay is only a fallback.
  • The traffic reaches Headscale through a forward that ends on the guest’s loopback, so the host firewall is never in its path. The firewall rules below are the ones a real host needs.
  • The person at the laptop is a headless Chromium driven by a short Playwright script that fills in Keycloak’s form. It uses the system trust store and never ignores a certificate error, so it verifies exactly what a person’s browser would.

Part one: the CA and Keycloak

1. Create the CA (tui-cert, 5, then N)

On the control plane, after the one-time repository setup, sudo apt-get install -y tui-cert. Screen 5 lists local CAs and N creates one: the name lab-ca, with the key and the ten-year validity left at their defaults.

tui-cert's form for a new local certificate authority, with the name lab-ca, key ec:prime256v1 and 3650 days of validity
N on screen 5: the new CA form, named lab-ca, ECDSA P-256 for ten years
tui-cert's confirm dialog for creating the CA lab-ca, listing the files it writes, a warning about the CA key and the openssl commands
Enter: the preview of the openssl commands that create lab-ca, with pathlen:0 and the key at mode 600

2. Issue Keycloak’s certificate (e)

e on lab-ca issues a server certificate for sso.lab.internal, with 10.0.2.2 as a second name. Keycloak runs in a rootful podman container as uid 1000, which on this guest is the lab account, so lab:lab owns the pair and the container can read its key.

tui-cert's confirm dialog for issuing sso.lab.internal from lab-ca, with the names, the owner and the openssl command lines
e, then Enter: sso.lab.internal and IP 10.0.2.2, owned by lab:lab, and the note that this machine does not trust lab-ca yet

3. Trust the CA on the control plane (t)

Headscale will run here and fetch Keycloak’s discovery document over https, so this machine trusts lab-ca too.

tui-cert's dialog for trusting lab-ca on Ubuntu, with the fingerprint, the trust store path and the update-ca-certificates command
t on screen 5: trusting lab-ca through Debian’s trust store, and what that means for every program on the machine
tui-cert's CA screen showing lab-ca as trusted with one issued certificate
After y: lab-ca trusted, one certificate issued. The status line still quotes the first line of update-ca-certificates (tui-cert#24)

4. Run Keycloak as a quadlet

Keycloak is not a family tool: podman runs it and systemd runs podman through a quadlet, /etc/containers/systemd/keycloak.container. The certificate pair is mounted read-only, http is off, and --hostname is the full external URL, so the issuer is the same for the browser, Headscale and curl. dev-file is Keycloak’s embedded database, fine for a lab and not for production.

# Keycloak for the company tailnet case, run by podman through systemd (quadlet).
[Unit]
Description=Keycloak (realm company)
After=network-online.target
Wants=network-online.target

[Container]
ContainerName=keycloak
Image=quay.io/keycloak/keycloak:26.7.4
Exec=start --db=dev-file --hostname=https://sso.lab.internal:18443 --https-port=18443 --http-enabled=false --https-certificate-file=/etc/kc-tls/fullchain.pem --https-certificate-key-file=/etc/kc-tls/privkey.pem --health-enabled=true
PublishPort=18443:18443
Volume=/etc/tui-cert/issued/sso.lab.internal:/etc/kc-tls:ro
Volume=keycloak-data:/opt/keycloak/data
EnvironmentFile=/etc/keycloak/admin.env

[Service]
Restart=always
TimeoutStartSec=300

[Install]
WantedBy=multi-user.target default.target
$ sudo install -m 600 /dev/stdin /etc/keycloak/admin.env    # KC_BOOTSTRAP_ADMIN_PASSWORD=<ADMIN_PASSWORD>
$ sudo install -m 644 /tmp/keycloak.container /etc/containers/systemd/keycloak.container
$ echo "10.0.2.2 sso.lab.internal vpn.lab.internal" | sudo tee -a /etc/hosts
$ sudo systemctl daemon-reload && sudo systemctl start keycloak

5. The realm, the client and the group

The realm was built with Keycloak’s admin REST API and curl; the admin console does the same with forms. Realm company, group vpn-users, user alice in the group, user bob in none, and a confidential client headscale for the authorization-code flow with the redirect URI https://vpn.lab.internal:19443/oidc/callback. The client needs one protocol mapper, because Keycloak puts group membership in a token only when a mapper adds it, and Headscale can only check groups the token claims:

"protocolMappers": [{
  "name": "groups",
  "protocol": "openid-connect",
  "protocolMapper": "oidc-group-membership-mapper",
  "config": {
    "claim.name": "groups",
    "full.path": "false",
    "id.token.claim": "true",
    "access.token.claim": "true",
    "userinfo.token.claim": "true"
  }
}]

The client secret went from the API straight into /etc/keycloak/headscale-client-secret, mode 600, and was never printed. The ID tokens show the difference that decides everything later: alice’s carries "groups": ["vpn-users"], and bob’s has no groups claim at all ("groups": null in the decoded payload).

6. The laptop trusts the CA (tui-cert on the laptop, t)

The laptop’s browser will meet both Keycloak and Headscale, so it trusts lab-ca first. The CA certificate, never its key, was copied to the laptop and its fingerprint compared with the one tui-cert shows on the control plane. Before the trust, the laptop refuses Keycloak:

$ openssl x509 -noout -fingerprint -sha256 -in /etc/tui-cert/ca/lab-ca/ca.crt
sha256 Fingerprint=B7:A8:BC:75:23:53:86:EA:32:F9:47:C8:B9:8C:F0:6B:A9:85:BC:65:14:0A:CB:38:8A:79:D2:D1:31:BA:27:F7
$ curl -sS https://sso.lab.internal:18443/realms/company/.well-known/openid-configuration
curl: (60) SSL certificate OpenSSL verify result: self-signed certificate in certificate chain (19)
tui-cert's dialog on Omarchy Server for trusting lab-ca with trust anchor --store
t on the laptop: the same fingerprint, trusted the Arch way with trust anchor
tui-cert's CA screen on the laptop showing lab-ca (cert only) as trusted
After y: lab-ca listed as cert only (the key stays on the control plane) and trusted
$ curl -sS https://sso.lab.internal:18443/realms/company/.well-known/openid-configuration | jq -r .issuer
https://sso.lab.internal:18443/realms/company

Part two: Headscale with Keycloak as the identity provider

7. Install headscale (tui-tailscale, 3, then i)

sudo apt-get install -y tui-tailscale on the control plane, then the users screen, 3. With no headscale on the host, the readiness line names the first step and the commands it will run. Since 1.0.1 apt runs with DEBIAN_FRONTEND=noninteractive and NEEDRESTART_MODE=a, so the install no longer hangs on needrestart (tui-tailscale#23, fixed); here it finished in 15 seconds.

tui-tailscale's users screen on Ubuntu saying headscale is not installed and listing the apt commands that install it
3: the users screen with no headscale, and the noninteractive apt commands i will run
tui-tailscale's confirm dialog for installing headscale with apt-get
i: the install preview, from the tui-tools repository
tui-tailscale's control-plane panel after installing headscale, with the next step, the config values and the relays line
After y: the control-plane panel, next step S. The relays line says this tailnet relays through Tailscale’s public DERP servers (tui-tailscale#27, fixed)

8. Issue Headscale’s certificate (tui-cert, e)

Back in tui-cert, e on lab-ca again for vpn.lab.internal and 10.0.2.2, owned by headscale:headscale. The package created that account, which is why headscale was installed first.

tui-cert's form for issuing a server certificate for vpn.lab.internal, owned by headscale:headscale
e: vpn.lab.internal with IP 10.0.2.2, owner headscale:headscale

9. Serve it with its own certificate (S)

S asks for the transport first, then server_url, then offers the pairs tui-cert issued on this machine instead of asking for paths.

tui-tailscale's server settings picker with own certificate selected among plain http, Let's Encrypt and reverse proxy
S: the transport, own certificate
tui-tailscale's server_url step with https://vpn.lab.internal:19443 typed in
server_url https://vpn.lab.internal:19443, which is also where the IdP sends the browser back
tui-tailscale's list of certificate pairs issued by lab-ca, with vpn.lab.internal selected
The pairs lab-ca issued, by name; the vpn.lab.internal pair picked

The diff shows only the lines that change. Since tui-kit v0.4.2 it keeps their indentation (base_domain sits under dns:) and quotes the sh -c argument (tui-kit#30 and tui-kit#31, fixed).

tui-tailscale's diff of /etc/headscale/config.yaml with the TLS paths and base_domain, followed by the quoted write command
Enter, scrolled to the end: server_url, listen_addr, the certificate paths and base_domain, and the quoted sh -c that writes the file
tui-tailscale's dialog to run systemctl enable --now headscale
y: the unit was disabled, so the last step enables and starts it
tui-tailscale's control-plane panel with headscale active, the own-certificate transport and the next step about the closed port
y: headscale runs with its own certificate. Next step: 19443/tcp closed (tui-firewall is not installed yet, so nftables is read directly)
$ curl -sS https://vpn.lab.internal:19443/health
{"status":"pass"}
$ headscale version | head -1
headscale version v0.29.3+dirty

curl verifies the certificate against the system store. The +dirty comes from the package and is still open against the packaging.

10. Keycloak as the identity provider (O)

O starts with a provider picker. Generic OIDC takes any OpenID Connect provider: the issuer https://sso.lab.internal:18443/realms/company, the client ID, the secret, and the allow lists.

tui-tailscale's identity provider picker with generic OIDC selected
O: generic OIDC rather than the Google preset
tui-tailscale's client id step with headscale typed and the redirect URI to register
Client id headscale, and the redirect URI to register in the IdP

The secret was pasted from the root-only file through a tmux buffer, so it was never typed or echoed.

tui-tailscale's client secret step with the value masked by asterisks
The client secret, masked. It goes to a mode 600 file and is never shown again
tui-tailscale's allowed groups step with vpn-users typed and the explanation of how the allow lists combine
allowed_groups vpn-users, with domains and users left empty. The hint is why step 5 needed the mapper

Before writing, the tool fetched the issuer’s discovery document from this machine, the one that will have to reach Keycloak.

tui-tailscale's dialog to write the OIDC client secret file, reporting that the issuer answered correctly
Step 1 of 3: the issuer answered; the secret travels on standard input, not on the command line
tui-tailscale's diff adding the indented oidc block with issuer, client id, secret path, scope and allowed_groups vpn-users
Step 2 of 3: the oidc block, indented as it lands in config.yaml, with PKCE on
tui-tailscale's control-plane panel after the OIDC change, showing the issuer, client id, secret set and allowed groups vpn-users
Step 3 restarted headscale: OIDC set, groups vpn-users, PKCE on, secret set

11. Open the ports (f, into tui-firewall)

The readiness line said 19443/tcp was closed. f offers to install tui-firewall when it is missing and hands over to it right after (tui-tailscale#25, fixed).

tui-tailscale's dialog to install tui-firewall with apt-get
f without tui-firewall: installing it changes no rule

In tui-firewall, a adds 19443/tcp for the control port that laptops, browsers and nodes reach, and 41641/udp for direct connections between nodes. The add preview for a rule with a comment is not shell-quoted yet (tui-firewall#26, open).

tui-firewall's add rule form for port 19443 tcp with the comment headscale control (tailnet)
f, then a: allow 19443/tcp with a comment
tui-firewall's ufw rule list with 19443/tcp and 41641/udp allowed
Both rules added, for IPv4 and IPv6, on ufw
tui-tailscale's control-plane panel after returning from tui-firewall, with the next step no node yet
q: back in tui-tailscale, the ports read through tui-firewall are open and the next step is the first node (tui-tailscale#24, fixed)

12. The control plane joins as a subnet router

The control plane joins with a pre-auth key: it is a server, and nobody signs in to Keycloak as it. n on the users screen created user infra, and n on the pre-auth keys screen a key for it that expires in an hour. The key is shown once, on the status line; no frame with it was kept. On a 120-column terminal it was cut at the edge (tui-tailscale#30, open).

1, i installed Tailscale from its own apt repository, then j: the login server this host serves, the key, hostname control, and 10.88.0.0/16 to advertise. That is the podman bridge where Keycloak’s container lives; on a real network it would be the office LAN.

tui-tailscale's join dialog for the control plane with the key file, sysctl settings and tailscale up command
j: the key goes through a root-only file under /run, IP forwarding is made permanent, and 10.88.0.0/16 is advertised
tui-tailscale's dialog approving 10.88.0.0/16 for node 1
4, then r: approving the route the node advertises
tui-tailscale's nodes screen with control online, 1/1 routes approved and the readiness line ready
Ready: the route approved, and the readiness line adds that relays go through Tailscale’s public DERP servers

Part three: people join

13. alice joins from the laptop

On the laptop, sudo pacman -S tui-tools/tui-tailscale, i for Tailscale from Arch’s repositories, then j: the login server, no pre-auth key, hostname laptop, and accept routes. The join checks the server’s certificate first; lab-ca is already trusted, so there is no CA step.

tui-tailscale's join dialog on the laptop with tailscale up and --accept-routes, without a pre-auth key
j with an empty key: tailscale will print a login URL
tui-tailscale's log in to finish joining dialog with the registration URL printed below it
y: the login URL, on its own line outside the frame so it copies whole

Opened in the browser as alice, the URL goes to Headscale, then Keycloak asks for her password and sends her back. Headscale 0.29 then asks her to confirm that the device is hers.

Headscale's confirm node registration page for hostname laptop, registered to Alice Example
Headscale after the Keycloak login: confirm node registration for laptop, registered to Alice Example
Headscale's node registered page for Alice Example
After Confirm registration: node registered as Alice Example

The laptop’s node screen turned to running on its own, since it re-reads while a login is pending. Both laptop frames lack the title row, because at 120 columns the header wraps and pushes it off the top (tui-tailscale#31, open).

tui-tailscale's node screen on the laptop, running and online as alice@company.example with address 100.64.0.2
The laptop’s node screen: running as alice@company.example, laptop.company.internal (title row missing, tui-tailscale#31)
tui-tailscale's peers screen on the laptop listing control with the route 10.88.0.0/16
The peers screen: control, serving 10.88.0.0/16 (title row missing, tui-tailscale#31)
$ tailscale ping -c 3 control
pong from control (100.64.0.1) via DERP(sao) in 49ms
pong from control (100.64.0.1) via DERP(sao) in 53ms
pong from control (100.64.0.1) via DERP(sao) in 49ms
$ ping -c 2 10.88.0.1
2 packets transmitted, 2 received, 0% packet loss, time 1001ms

The subnet answers through the approved route, relayed by DERP(sao), Tailscale’s public relay in São Paulo, as the relays line warned. A DERP server only forwards traffic that is already encrypted end to end, as Tailscale’s DERP documentation explains, but a company that self-hosts will want to know which servers its traffic crosses.

tui-tailscale's nodes screen on the control plane with laptop owned by alice and online
On the control plane, 4: laptop, owned by alice, online
tui-tailscale's users screen listing infra and alice, whose provider is oidc and email alice@company.example
3: alice was created by her first login, provider oidc, with her Keycloak email

14. bob is refused

The laptop logs out and joins again with the same answers.

tui-tailscale's dialog to run tailscale logout
L: log out of the tailnet

This time bob signs in. Keycloak accepts his password, and Headscale says no.

Keycloak's sign in page for the Company realm with the username bob and a masked password
Keycloak’s sign-in page, as bob
Headscale's error page reading Unauthorized, you are not authorized, please contact your administrator
Headscale refuses: bob’s token claims no allowed group

The page tells bob nothing, which is right for him. Headscale’s log tells the administrator why:

ERR user msg: unauthorised group error="authenticated principal is not in any allowed group" code=401
INF http request method=GET path=/oidc/callback status=401

No user and no node were created for him. On the control plane, the attempt shows as refused by the policy, and R will not register it, so nobody can register the device around allowed_groups by mistake (tui-tailscale#26, fixed).

tui-tailscale's nodes screen with a red line saying a node was refused by the identity provider's policy and R will not register it
The nodes screen after bob: refused by the identity provider’s policy, R will not register it. alice’s laptop shows expired after her logout

The laptop logged out at the end, and both guests were restored to the snapshots they had before the run.

What we found

A first run of this case, on tui-tailscale 1.0.0, filed the issues below. These seven are fixed in tui-tailscale 1.0.1 and tui-kit v0.4.2, and this run shows each fix where it applies:

  • tui-tailscale#23: the apt install hung on needrestart (step 7).
  • tui-tailscale#24: the ports read as closed on a ufw host whose ports were open (step 11).
  • tui-tailscale#25: tui-firewall installed during a session was still treated as missing (step 11).
  • tui-tailscale#26: R was offered for a registration the IdP had refused (step 14).
  • tui-tailscale#27: nothing said the tailnet relays through Tailscale’s public DERP servers (steps 7 and 12).
  • tui-kit#30: diffs lost their indentation (steps 9 and 10).
  • tui-kit#31: command previews were not shell-quoted (steps 9 and 10).

Still open, from both runs:

  • tui-tailscale#30: the shown-once pre-auth key is cut at the edge of a 120-column terminal.
  • tui-tailscale#31: at 120 columns the node screen’s header wraps and pushes the title row off the top.
  • tui-tailscale#32: f could hand the closed ports to tui-firewall instead of an empty rule list.
  • tui-firewall#26: rule previews with a comment are not shell-quoted.
  • tui-kit#36: render-screenshots --from-ansi cannot crop columns, so cropped dialogs stay off-center in these images.
  • tui-cert#24: export without ssh, the trust status line on Ubuntu, numeric owners for containers, and a quick way to clear prefilled fields.
  • tui-tools/pkgs#13, in the family’s packaging repository, which is private: the headscale package reports v0.29.3+dirty.

What the run proves: a Headscale control plane set up entirely through tui-tailscale, with a certificate from a tui-cert CA, admitted a Keycloak user in the allowed group through a browser login with verification on, routed a subnet to her, and refused a Keycloak user outside the group. What it does not prove: behaviour where nodes reach each other over UDP, names served by real DNS, traffic that crosses the host firewall, or any identity provider other than Keycloak 26.7.4.