diff --git a/stacks/terminal/files/devvm/README.md b/stacks/terminal/files/devvm/README.md index 7635711d..15edcf33 100644 --- a/stacks/terminal/files/devvm/README.md +++ b/stacks/terminal/files/devvm/README.md @@ -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=`) -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 /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-/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=() 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 `). + ## 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. diff --git a/stacks/terminal/files/devvm/sudoers.d-ttyd-users b/stacks/terminal/files/devvm/sudoers.d-ttyd-users new file mode 100644 index 00000000..144608f2 --- /dev/null +++ b/stacks/terminal/files/devvm/sudoers.d-ttyd-users @@ -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 diff --git a/stacks/terminal/files/devvm/tmux-attach.sh b/stacks/terminal/files/devvm/tmux-attach.sh index afd842a3..29ad7844 100644 --- a/stacks/terminal/files/devvm/tmux-attach.sh +++ b/stacks/terminal/files/devvm/tmux-attach.sh @@ -1,12 +1,53 @@ #!/usr/bin/env bash -# Invoked by ttyd-multi.service. ttyd's -a flag forwards ?arg= 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=` 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 <}'. + + 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=; 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 diff --git a/stacks/terminal/files/devvm/ttyd-user-map b/stacks/terminal/files/devvm/ttyd-user-map new file mode 100644 index 00000000..114af337 --- /dev/null +++ b/stacks/terminal/files/devvm/ttyd-user-map @@ -0,0 +1,12 @@ +# Authentik username (X-authentik-username header value, local part before @) +# → OS user on this DevVM. +# +# Format: "=" — 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 /usr/bin/tmux ...` without a password. + +vbarzin=wizard +emil.barzin=emo diff --git a/stacks/terminal/files/devvm/ttyd.service b/stacks/terminal/files/devvm/ttyd.service index b7510ce2..a07867d6 100644 --- a/stacks/terminal/files/devvm/ttyd.service +++ b/stacks/terminal/files/devvm/ttyd.service @@ -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 diff --git a/stacks/terminal/files/index.html b/stacks/terminal/files/index.html index 712d85ed..47084572 100644 --- a/stacks/terminal/files/index.html +++ b/stacks/terminal/files/index.html @@ -109,9 +109,10 @@