terminal: per-Authentik-user OS-user isolation; deny unmapped users

Restores the kernel-level isolation the pre-cutover ttyd-session.sh had,
but keeps the multi-session lobby UX:

- ttyd.service gets `-H X-authentik-username` back. `tmux-attach.sh` reads
  $TTYD_USER, looks up the local part in /etc/ttyd-user-map, denies the
  connection (no fallback to wizard) if there's no mapping, otherwise
  `sudo -n -H -u <os_user> tmux …`. Each Authentik identity → its own
  Unix user → its own `/tmp/tmux-<uid>/default` socket.
- tmux-api scopes every request to the same OS user via the same header.
  Adds /whoami so the lobby HTML can preflight access and render
  "logged in as <os_user> (<authentik>)" instead of leaving the user to
  discover the deny via a reconnect loop.
- Commits /etc/ttyd-user-map and the matching /etc/sudoers.d/ttyd-users
  fragment under files/devvm/ so future operators see one canonical
  source of truth. Current mappings: vbarzin → wizard, emil.barzin → emo.

Adding a user is now: append a line to ttyd-user-map + a NOPASSWD
sudoers line + `useradd -m`. README walks through it.

No Terraform changes — this is all DevVM-side + lobby JS.
This commit is contained in:
Viktor Barzin 2026-05-13 19:25:55 +00:00 committed by Viktor Barzin
parent aff4f67671
commit 9fce3c7b09
7 changed files with 316 additions and 65 deletions

View file

@ -1,22 +1,52 @@
# DevVM terminal files
These files configure ttyd + tmux-api on the DevVM (`10.0.10.10`). ttyd
serves the multi-session lobby (and per-session attach via `?arg=<name>`)
on port 7681; tmux-api is a small Go REST API on 7684 that powers the
lobby's list/kill actions.
ttyd + tmux-api on the DevVM (`10.0.10.10`). ttyd serves the multi-session
lobby on port 7681 and attaches each Authentik identity into its own OS
user's tmux server. tmux-api (port 7684) backs the lobby's list/kill
actions, scoped to the same OS user.
`terminal-ro.service` (port 7682, single read-only session) and
`clipboard-upload` (port 7683) are unchanged by these files.
## Per-user isolation
The Authentik forward-auth middleware injects `X-authentik-username` on
every authenticated request:
1. **ttyd** is started with `-H X-authentik-username`, so the header value
lands as `$TTYD_USER` in each launched `tmux-attach.sh` invocation.
2. **`tmux-attach.sh`** looks up `$TTYD_USER` in `/etc/ttyd-user-map`,
denies the connection if there is no mapping, and otherwise
`sudo -n -H -u <os_user> /usr/bin/tmux …`.
3. **`tmux-api`** reads `X-authentik-username` on every request and runs
tmux as the mapped OS user too — so the lobby's session list is the
intersection of "your Authentik identity" and "what tmux on that OS
user's socket reports".
Different Authentik identities map to different Unix users, which means
different `/tmp/tmux-<uid>/default` sockets — kernel-level isolation,
not "the API filtered the list".
Adding a new user:
1. Append a line to `/etc/ttyd-user-map` (canonical at
`files/devvm/ttyd-user-map`).
2. Append `wizard ALL=(<os_user>) NOPASSWD: /usr/bin/tmux` to
`/etc/sudoers.d/ttyd-users` (canonical at
`files/devvm/sudoers.d-ttyd-users`).
3. Ensure the OS user exists (`useradd -m <os_user>`).
## Layout
| Source | Destination on DevVM |
|--------|----------------------|
| `tmux-attach.sh` | `/usr/local/bin/tmux-attach.sh` (chmod 0755) |
| `ttyd.service` | `/etc/systemd/system/ttyd.service` |
| `tmux-api.service` | `/etc/systemd/system/tmux-api.service` |
| `../index.html` (one level up) | `/usr/local/share/ttyd/index.html` |
| `../../tmux-api/` binary, built `GOOS=linux GOARCH=amd64` | `/usr/local/bin/tmux-api` (chmod 0755) |
| Source | Destination on DevVM | Mode |
|--------|----------------------|------|
| `tmux-attach.sh` | `/usr/local/bin/tmux-attach.sh` | 0755 |
| `ttyd.service` | `/etc/systemd/system/ttyd.service` | 0644 |
| `tmux-api.service` | `/etc/systemd/system/tmux-api.service` | 0644 |
| `ttyd-user-map` | `/etc/ttyd-user-map` | 0644 |
| `sudoers.d-ttyd-users` | `/etc/sudoers.d/ttyd-users` | 0440, root:root |
| `../index.html` (one dir up) | `/usr/local/share/ttyd/index.html` | 0644 |
| `../../tmux-api/` Go binary | `/usr/local/bin/tmux-api` | 0755 |
## Apply
@ -28,12 +58,19 @@ DEVVM=10.0.10.10 # SSH config provides the user
# 1. Build the tmux-api binary for linux/amd64
( cd infra/stacks/terminal/tmux-api && GOOS=linux GOARCH=amd64 go build -o /tmp/tmux-api . )
# 2. HTML page + wrapper script
scp infra/stacks/terminal/files/index.html $DEVVM:/tmp/index.html
scp infra/stacks/terminal/files/devvm/tmux-attach.sh $DEVVM:/tmp/tmux-attach.sh
ssh $DEVVM "sudo install -m 0644 /tmp/index.html /usr/local/share/ttyd/index.html && \
sudo install -m 0755 /tmp/tmux-attach.sh /usr/local/bin/tmux-attach.sh && \
rm /tmp/index.html /tmp/tmux-attach.sh"
# 2. HTML + config files
scp infra/stacks/terminal/files/index.html $DEVVM:/tmp/index.html
scp infra/stacks/terminal/files/devvm/tmux-attach.sh $DEVVM:/tmp/tmux-attach.sh
scp infra/stacks/terminal/files/devvm/ttyd-user-map $DEVVM:/tmp/ttyd-user-map
scp infra/stacks/terminal/files/devvm/sudoers.d-ttyd-users $DEVVM:/tmp/sudoers.d-ttyd-users
ssh $DEVVM "
sudo install -m 0644 /tmp/index.html /usr/local/share/ttyd/index.html
sudo install -m 0755 /tmp/tmux-attach.sh /usr/local/bin/tmux-attach.sh
sudo install -m 0644 /tmp/ttyd-user-map /etc/ttyd-user-map
sudo install -m 0440 -o root -g root /tmp/sudoers.d-ttyd-users /etc/sudoers.d/ttyd-users
sudo visudo -cf /etc/sudoers.d/ttyd-users
rm /tmp/index.html /tmp/tmux-attach.sh /tmp/ttyd-user-map /tmp/sudoers.d-ttyd-users
"
# 3. tmux-api binary
scp /tmp/tmux-api $DEVVM:/tmp/tmux-api
@ -42,32 +79,30 @@ ssh $DEVVM "sudo install -m 0755 /tmp/tmux-api /usr/local/bin/tmux-api && rm /tm
# 4. systemd units
scp infra/stacks/terminal/files/devvm/ttyd.service $DEVVM:/tmp/
scp infra/stacks/terminal/files/devvm/tmux-api.service $DEVVM:/tmp/
ssh $DEVVM "sudo mv /tmp/ttyd.service /etc/systemd/system/ && \
sudo mv /tmp/tmux-api.service /etc/systemd/system/ && \
sudo systemctl daemon-reload && \
sudo systemctl enable --now tmux-api && \
sudo systemctl restart ttyd"
ssh $DEVVM "
sudo mv /tmp/ttyd.service /etc/systemd/system/
sudo mv /tmp/tmux-api.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now tmux-api
sudo systemctl restart ttyd
"
# 5. Sanity checks
ssh $DEVVM "systemctl status ttyd tmux-api --no-pager"
ssh $DEVVM "curl -sf localhost:7684/sessions"
ssh $DEVVM "curl -sf localhost:7681/ | head -5"
ssh $DEVVM "systemctl is-active terminal-ro" # unrelated unit, unaffected
ssh $DEVVM "curl -sf -H 'X-Authentik-Username: vbarzin' localhost:7684/whoami"
ssh $DEVVM "curl -sf -H 'X-Authentik-Username: emil.barzin' localhost:7684/whoami"
ssh $DEVVM "curl -si -H 'X-Authentik-Username: nobody' localhost:7684/whoami | head -3"
```
## Notes
- **`User=wizard`** — single Unix user owns the tmux server. Sessions are
shared across every browser tab that attaches.
- **ttyd version** must be ≥ 1.7 for the `-a` flag (allow URL args → argv).
The DevVM currently has 1.7.7.
- **Argv flow**: `?arg=foo` on the URL → ttyd appends `foo` as `$1` to
`tmux-attach.sh` → the wrapper regex-validates and runs
`tmux new-session -A -s "$name"`. ttyd uses argv (never a shell string),
so there is no injection path.
- **No external exposure of 7684/7681** — the DevVM is reachable only from
the cluster (`10.0.10.10` is on the internal VLAN). Authentik forward-auth
on the ingress is the access gate.
- **ttyd ≥ 1.7** required for the `-a` flag (URL args → argv). DevVM has 1.7.7.
- **Argv flow**: `?arg=foo` → ttyd appends `foo` as `$1` to `tmux-attach.sh`
→ wrapper regex-validates and runs `tmux new-session -A -s "$name"`. ttyd
uses argv, never a shell string — no injection path.
- **No external exposure of 7681/7684** — DevVM is internal-VLAN-only;
Authentik forward-auth is the access gate.
- **Cutover history**`term.viktorbarzin.me` and `ttyd-multi.service`
(port 7685) were the staging surface for this design. Both were retired
in the same commit that promoted the multi-session config to port 7681.
(port 7685) were the staging surface for this design; both retired
when the multi-session config was promoted to port 7681. The
per-Authentik-user isolation followed in a separate change.

View file

@ -0,0 +1,13 @@
# Install at /etc/sudoers.d/ttyd-users (mode 0440, owner root:root).
#
# wizard (the user running ttyd.service + tmux-api.service) needs to run
# tmux as the OS user that backs each Authentik identity. Narrow the
# NOPASSWD grant to the tmux binary only, scoped to each named target user
# — never `(ALL)`.
#
# Add one line per OS user listed on the right-hand side of
# /etc/ttyd-user-map. The mapping file is the source of truth for which
# Authentik usernames are accepted; this file is the kernel-level grant
# that makes the per-user attach actually work.
wizard ALL=(emo) NOPASSWD: /usr/bin/tmux

View file

@ -1,12 +1,53 @@
#!/usr/bin/env bash
# Invoked by ttyd-multi.service. ttyd's -a flag forwards ?arg=<value> as $1.
# Defence-in-depth: ttyd uses argv (never shell strings) and we re-validate
# here before handing the name to tmux as a quoted argv slot.
# Invoked by ttyd.service per WebSocket connection. ttyd's `-a` flag
# forwards `?arg=<value>` as $1; `-H X-authentik-username` sets
# $TTYD_USER to the Authentik identity.
#
# We map TTYD_USER → OS user via /etc/ttyd-user-map and sudo into that
# user before running tmux, so each Authentik identity gets its own
# kernel-isolated tmux server (one socket per uid). Authentik users
# without a mapping are denied — no fallback to a shared account.
set -euo pipefail
name="${1:-main}"
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]{1,32}$ ]]; then
name=main
MAP=/etc/ttyd-user-map
NAME_RE='^[a-zA-Z0-9_-]{1,32}$'
auth_user="${TTYD_USER:-}"
auth_local="${auth_user%%@*}"
os_user=""
if [[ -n "$auth_local" && -r "$MAP" ]]; then
os_user=$(awk -F= -v k="$auth_local" '
/^[[:space:]]*(#|$)/ {next}
$1==k {sub(/:.*$/, "", $2); print $2; exit}
' "$MAP")
fi
exec tmux new-session -A -s "$name" -c /home/wizard/code
if [[ -z "$os_user" ]] || ! id "$os_user" >/dev/null 2>&1; then
cat <<EOF
Access denied
─────────────
No terminal account for Authentik user '${auth_user:-<missing header>}'.
This DevVM maps Authentik identities to OS users via
/etc/ttyd-user-map. Ask Viktor to add a mapping (and a matching
/etc/sudoers.d/ttyd-users entry) if you should have access.
EOF
sleep 10
exit 1
fi
# Session name from URL ?arg=<name>; default to the OS user's own name.
name="${1:-$os_user}"
[[ "$name" =~ $NAME_RE ]] || name="$os_user"
home_dir=$(getent passwd "$os_user" | cut -d: -f6)
home_dir="${home_dir:-/}"
if [[ "$os_user" == "$(id -un)" ]]; then
exec tmux new-session -A -s "$name" -c "$home_dir"
else
exec sudo -n -H -u "$os_user" tmux new-session -A -s "$name" -c "$home_dir"
fi

View file

@ -0,0 +1,12 @@
# Authentik username (X-authentik-username header value, local part before @)
# → OS user on this DevVM.
#
# Format: "<authentik_username>=<os_user>" — one mapping per line.
# Lines starting with # and blank lines are ignored.
# Authentik users WITHOUT a mapping here are denied (no default fallback).
#
# Adding a new user: append a mapping + extend /etc/sudoers.d/ttyd-users so
# wizard can `sudo -n -u <os_user> /usr/bin/tmux ...` without a password.
vbarzin=wizard
emil.barzin=emo

View file

@ -1,9 +1,9 @@
[Unit]
Description=ttyd Terminal Service (multi-session lobby + attach on port 7681)
Description=ttyd Terminal Service (multi-session lobby + per-Authentik-user attach on port 7681)
After=network.target
[Service]
ExecStart=/usr/local/bin/ttyd -W -a -t enableClipboard=true -I /usr/local/share/ttyd/index.html -p 7681 /usr/local/bin/tmux-attach.sh
ExecStart=/usr/local/bin/ttyd -W -a -H X-authentik-username -t enableClipboard=true -I /usr/local/share/ttyd/index.html -p 7681 /usr/local/bin/tmux-attach.sh
Restart=always
User=wizard

View file

@ -109,9 +109,10 @@
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
<script>
(function() {
(async function() {
const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
const SESSIONS_API = '/api/sessions/sessions';
const WHOAMI_API = '/api/sessions/whoami';
const params = new URLSearchParams(location.search);
const rawArg = params.get('arg');
const validArg = rawArg && NAME_RE.test(rawArg) ? rawArg : null;
@ -135,6 +136,46 @@
return e;
}
function showAccessDenied(detail) {
document.getElementById('terminal').classList.add('hidden');
document.getElementById('paste-btn').classList.add('hidden');
document.getElementById('img-btn').classList.add('hidden');
const lobby = document.getElementById('lobby');
clearChildren(lobby);
const h = document.createElement('h1');
h.className = 'lobby-header';
h.textContent = 'Access denied';
lobby.appendChild(h);
const p = document.createElement('p');
p.className = 'lobby-sub';
p.textContent = detail || 'You do not have a terminal account on this server.';
lobby.appendChild(p);
const p2 = document.createElement('p');
p2.className = 'lobby-sub';
p2.textContent = 'Authentik logs you in; access here requires an OS-user mapping in /etc/ttyd-user-map. Ask Viktor to add one.';
lobby.appendChild(p2);
lobby.classList.add('visible');
document.title = 'access denied';
}
// Preflight: ask tmux-api who we are. 403 = unmapped Authentik user → deny.
let whoami = null;
try {
const resp = await fetch(WHOAMI_API, { credentials: 'same-origin' });
if (resp.status === 401 || resp.status === 403) {
showAccessDenied((await resp.text()).trim());
return;
}
if (!resp.ok) {
showAccessDenied('Preflight failed: HTTP ' + resp.status);
return;
}
whoami = await resp.json();
} catch (err) {
showAccessDenied('Preflight failed: ' + err.message);
return;
}
if (!validArg) {
// ============================================================
// LOBBY MODE — no valid ?arg=, show session picker
@ -143,7 +184,9 @@
document.getElementById('paste-btn').classList.add('hidden');
document.getElementById('img-btn').classList.add('hidden');
document.getElementById('lobby').classList.add('visible');
document.title = 'tmux sessions';
document.title = 'tmux sessions (' + whoami.osUser + ')';
document.querySelector('#lobby .lobby-sub').textContent =
'Logged in as ' + whoami.osUser + ' (' + whoami.authentik + '). Sessions are kernel-isolated per Unix user; you only see your own.';
const listEl = document.getElementById('session-list');
const newNameEl = document.getElementById('new-name');
@ -300,7 +343,7 @@
term.open(document.getElementById('terminal'));
fitAddon.fit();
document.title = 'tmux: ' + validArg;
document.title = 'tmux: ' + whoami.osUser + '/' + validArg;
function sendInput(data) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;

View file

@ -1,19 +1,37 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/user"
"regexp"
"strconv"
"strings"
)
const listenAddr = "0.0.0.0:7684"
const (
listenAddr = "0.0.0.0:7684"
mapPath = "/etc/ttyd-user-map"
authHeader = "X-Authentik-Username"
tmuxBinary = "/usr/bin/tmux"
sudoBinary = "/usr/bin/sudo"
tmuxListFmt = "#{session_name}|#{session_attached}|#{session_activity}|#{session_created}"
)
var sessionNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`)
var selfUser = func() string {
if u, err := user.Current(); err == nil {
return u.Username
}
return ""
}()
type Session struct {
Name string `json:"name"`
Attached int `json:"attached"`
@ -21,33 +39,121 @@ type Session struct {
Created int64 `json:"created"`
}
// loadUserMap reads /etc/ttyd-user-map → map[authentik_local]os_user.
// Format: "<auth>=<os_user>[:<cwd>]" per line. Comments (#) and blanks ignored.
// Re-read on every request — file is small and changes are rare.
func loadUserMap() map[string]string {
m := map[string]string{}
f, err := os.Open(mapPath)
if err != nil {
log.Printf("loadUserMap: %v", err)
return m
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.IndexByte(line, '=')
if eq <= 0 {
continue
}
auth := strings.TrimSpace(line[:eq])
rhs := strings.TrimSpace(line[eq+1:])
if c := strings.IndexByte(rhs, ':'); c > 0 {
rhs = rhs[:c]
}
if auth != "" && rhs != "" {
m[auth] = rhs
}
}
return m
}
// resolveOSUser → mapped OS user from the Authentik header, or "" after
// writing the appropriate 401/403/500 to w.
func resolveOSUser(w http.ResponseWriter, r *http.Request) string {
authUser := r.Header.Get(authHeader)
if authUser == "" {
http.Error(w, "missing "+authHeader, http.StatusUnauthorized)
return ""
}
local := authUser
if i := strings.IndexByte(local, '@'); i > 0 {
local = local[:i]
}
osUser := loadUserMap()[local]
if osUser == "" {
http.Error(w, fmt.Sprintf("no terminal account for '%s'", authUser), http.StatusForbidden)
return ""
}
if _, err := user.Lookup(osUser); err != nil {
log.Printf("mapped OS user %q missing on this host: %v", osUser, err)
http.Error(w, "mapped OS user missing on this host", http.StatusInternalServerError)
return ""
}
return osUser
}
// tmuxCmd builds an exec.Cmd that runs `tmux <args...>` AS osUser. When
// osUser is the current process owner, sudo is skipped; otherwise we use
// `sudo -n -u <user> tmux ...` (passwordless grant via /etc/sudoers.d/ttyd-users).
func tmuxCmd(osUser string, args ...string) *exec.Cmd {
if osUser == selfUser {
return exec.Command(tmuxBinary, args...)
}
full := append([]string{"-n", "-u", osUser, tmuxBinary}, args...)
return exec.Command(sudoBinary, full...)
}
func main() {
http.HandleFunc("/sessions", handleSessions)
http.HandleFunc("/sessions/", handleSessionByName)
http.HandleFunc("/whoami", handleWhoami)
http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("ok"))
})
log.Printf("tmux-api listening on %s", listenAddr)
log.Printf("tmux-api listening on %s (self=%s)", listenAddr, selfUser)
log.Fatal(http.ListenAndServe(listenAddr, nil))
}
// /whoami → {authentik, osUser}. Used by the lobby HTML to render the
// current identity and to preflight access before opening a session.
func handleWhoami(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
authUser := r.Header.Get(authHeader)
osUser := resolveOSUser(w, r)
if osUser == "" {
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"authentik": authUser,
"osUser": osUser,
})
}
func handleSessions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
osUser := resolveOSUser(w, r)
if osUser == "" {
return
}
out, err := exec.Command(
"tmux", "list-sessions", "-F",
"#{session_name}|#{session_attached}|#{session_activity}|#{session_created}",
).Output()
out, err := tmuxCmd(osUser, "list-sessions", "-F", tmuxListFmt).Output()
w.Header().Set("Content-Type", "application/json")
// tmux exits non-zero when no server is running or no sessions exist.
// Treat both as "empty list" rather than a 500.
if err != nil {
// tmux exits non-zero when no server is running or there are no
// sessions for this uid — treat both as an empty list.
w.Write([]byte("[]"))
return
}
@ -71,32 +177,33 @@ func handleSessions(w http.ResponseWriter, r *http.Request) {
Created: created,
})
}
json.NewEncoder(w).Encode(sessions)
}
func handleSessionByName(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/sessions/")
name = strings.TrimSuffix(name, "/")
if !sessionNameRe.MatchString(name) {
http.Error(w, "invalid session name", http.StatusBadRequest)
return
}
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
osUser := resolveOSUser(w, r)
if osUser == "" {
return
}
out, err := exec.Command("tmux", "kill-session", "-t", name).CombinedOutput()
out, err := tmuxCmd(osUser, "kill-session", "-t", name).CombinedOutput()
if err != nil {
msg := string(out)
if strings.Contains(msg, "can't find session") || strings.Contains(msg, "no server running") {
http.Error(w, "session not found", http.StatusNotFound)
return
}
log.Printf("kill-session %s failed: %v: %s", name, err, msg)
log.Printf("kill-session %s as %s failed: %v: %s", name, osUser, err, msg)
http.Error(w, "kill-session failed", http.StatusInternalServerError)
return
}