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:
parent
aff4f67671
commit
9fce3c7b09
7 changed files with 316 additions and 65 deletions
|
|
@ -1,22 +1,52 @@
|
||||||
# DevVM terminal files
|
# DevVM terminal files
|
||||||
|
|
||||||
These files configure ttyd + tmux-api on the DevVM (`10.0.10.10`). ttyd
|
ttyd + tmux-api on the DevVM (`10.0.10.10`). ttyd serves the multi-session
|
||||||
serves the multi-session lobby (and per-session attach via `?arg=<name>`)
|
lobby on port 7681 and attaches each Authentik identity into its own OS
|
||||||
on port 7681; tmux-api is a small Go REST API on 7684 that powers the
|
user's tmux server. tmux-api (port 7684) backs the lobby's list/kill
|
||||||
lobby's list/kill actions.
|
actions, scoped to the same OS user.
|
||||||
|
|
||||||
`terminal-ro.service` (port 7682, single read-only session) and
|
`terminal-ro.service` (port 7682, single read-only session) and
|
||||||
`clipboard-upload` (port 7683) are unchanged by these files.
|
`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
|
## Layout
|
||||||
|
|
||||||
| Source | Destination on DevVM |
|
| Source | Destination on DevVM | Mode |
|
||||||
|--------|----------------------|
|
|--------|----------------------|------|
|
||||||
| `tmux-attach.sh` | `/usr/local/bin/tmux-attach.sh` (chmod 0755) |
|
| `tmux-attach.sh` | `/usr/local/bin/tmux-attach.sh` | 0755 |
|
||||||
| `ttyd.service` | `/etc/systemd/system/ttyd.service` |
|
| `ttyd.service` | `/etc/systemd/system/ttyd.service` | 0644 |
|
||||||
| `tmux-api.service` | `/etc/systemd/system/tmux-api.service` |
|
| `tmux-api.service` | `/etc/systemd/system/tmux-api.service` | 0644 |
|
||||||
| `../index.html` (one level up) | `/usr/local/share/ttyd/index.html` |
|
| `ttyd-user-map` | `/etc/ttyd-user-map` | 0644 |
|
||||||
| `../../tmux-api/` binary, built `GOOS=linux GOARCH=amd64` | `/usr/local/bin/tmux-api` (chmod 0755) |
|
| `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
|
## Apply
|
||||||
|
|
||||||
|
|
@ -28,12 +58,19 @@ DEVVM=10.0.10.10 # SSH config provides the user
|
||||||
# 1. Build the tmux-api binary for linux/amd64
|
# 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 . )
|
( cd infra/stacks/terminal/tmux-api && GOOS=linux GOARCH=amd64 go build -o /tmp/tmux-api . )
|
||||||
|
|
||||||
# 2. HTML page + wrapper script
|
# 2. HTML + config files
|
||||||
scp infra/stacks/terminal/files/index.html $DEVVM:/tmp/index.html
|
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/tmux-attach.sh $DEVVM:/tmp/tmux-attach.sh
|
||||||
ssh $DEVVM "sudo install -m 0644 /tmp/index.html /usr/local/share/ttyd/index.html && \
|
scp infra/stacks/terminal/files/devvm/ttyd-user-map $DEVVM:/tmp/ttyd-user-map
|
||||||
sudo install -m 0755 /tmp/tmux-attach.sh /usr/local/bin/tmux-attach.sh && \
|
scp infra/stacks/terminal/files/devvm/sudoers.d-ttyd-users $DEVVM:/tmp/sudoers.d-ttyd-users
|
||||||
rm /tmp/index.html /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
|
||||||
|
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
|
# 3. tmux-api binary
|
||||||
scp /tmp/tmux-api $DEVVM:/tmp/tmux-api
|
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
|
# 4. systemd units
|
||||||
scp infra/stacks/terminal/files/devvm/ttyd.service $DEVVM:/tmp/
|
scp infra/stacks/terminal/files/devvm/ttyd.service $DEVVM:/tmp/
|
||||||
scp infra/stacks/terminal/files/devvm/tmux-api.service $DEVVM:/tmp/
|
scp infra/stacks/terminal/files/devvm/tmux-api.service $DEVVM:/tmp/
|
||||||
ssh $DEVVM "sudo mv /tmp/ttyd.service /etc/systemd/system/ && \
|
ssh $DEVVM "
|
||||||
sudo mv /tmp/tmux-api.service /etc/systemd/system/ && \
|
sudo mv /tmp/ttyd.service /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload && \
|
sudo mv /tmp/tmux-api.service /etc/systemd/system/
|
||||||
sudo systemctl enable --now tmux-api && \
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl restart ttyd"
|
sudo systemctl enable --now tmux-api
|
||||||
|
sudo systemctl restart ttyd
|
||||||
|
"
|
||||||
|
|
||||||
# 5. Sanity checks
|
# 5. Sanity checks
|
||||||
ssh $DEVVM "systemctl status ttyd tmux-api --no-pager"
|
ssh $DEVVM "systemctl status ttyd tmux-api --no-pager"
|
||||||
ssh $DEVVM "curl -sf localhost:7684/sessions"
|
ssh $DEVVM "curl -sf -H 'X-Authentik-Username: vbarzin' localhost:7684/whoami"
|
||||||
ssh $DEVVM "curl -sf localhost:7681/ | head -5"
|
ssh $DEVVM "curl -sf -H 'X-Authentik-Username: emil.barzin' localhost:7684/whoami"
|
||||||
ssh $DEVVM "systemctl is-active terminal-ro" # unrelated unit, unaffected
|
ssh $DEVVM "curl -si -H 'X-Authentik-Username: nobody' localhost:7684/whoami | head -3"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **`User=wizard`** — single Unix user owns the tmux server. Sessions are
|
- **ttyd ≥ 1.7** required for the `-a` flag (URL args → argv). DevVM has 1.7.7.
|
||||||
shared across every browser tab that attaches.
|
- **Argv flow**: `?arg=foo` → ttyd appends `foo` as `$1` to `tmux-attach.sh`
|
||||||
- **ttyd version** must be ≥ 1.7 for the `-a` flag (allow URL args → argv).
|
→ wrapper regex-validates and runs `tmux new-session -A -s "$name"`. ttyd
|
||||||
The DevVM currently has 1.7.7.
|
uses argv, never a shell string — no injection path.
|
||||||
- **Argv flow**: `?arg=foo` on the URL → ttyd appends `foo` as `$1` to
|
- **No external exposure of 7681/7684** — DevVM is internal-VLAN-only;
|
||||||
`tmux-attach.sh` → the wrapper regex-validates and runs
|
Authentik forward-auth is the access gate.
|
||||||
`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.
|
|
||||||
- **Cutover history** — `term.viktorbarzin.me` and `ttyd-multi.service`
|
- **Cutover history** — `term.viktorbarzin.me` and `ttyd-multi.service`
|
||||||
(port 7685) were the staging surface for this design. Both were retired
|
(port 7685) were the staging surface for this design; both retired
|
||||||
in the same commit that promoted the multi-session config to port 7681.
|
when the multi-session config was promoted to port 7681. The
|
||||||
|
per-Authentik-user isolation followed in a separate change.
|
||||||
|
|
|
||||||
13
stacks/terminal/files/devvm/sudoers.d-ttyd-users
Normal file
13
stacks/terminal/files/devvm/sudoers.d-ttyd-users
Normal 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
|
||||||
|
|
@ -1,12 +1,53 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Invoked by ttyd-multi.service. ttyd's -a flag forwards ?arg=<value> as $1.
|
# Invoked by ttyd.service per WebSocket connection. ttyd's `-a` flag
|
||||||
# Defence-in-depth: ttyd uses argv (never shell strings) and we re-validate
|
# forwards `?arg=<value>` as $1; `-H X-authentik-username` sets
|
||||||
# here before handing the name to tmux as a quoted argv slot.
|
# $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
|
set -euo pipefail
|
||||||
|
|
||||||
name="${1:-main}"
|
MAP=/etc/ttyd-user-map
|
||||||
if ! [[ "$name" =~ ^[a-zA-Z0-9_-]{1,32}$ ]]; then
|
NAME_RE='^[a-zA-Z0-9_-]{1,32}$'
|
||||||
name=main
|
|
||||||
|
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
|
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
|
||||||
|
|
|
||||||
12
stacks/terminal/files/devvm/ttyd-user-map
Normal file
12
stacks/terminal/files/devvm/ttyd-user-map
Normal 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
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
[Unit]
|
[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
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[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
|
Restart=always
|
||||||
User=wizard
|
User=wizard
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-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 src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(async function() {
|
||||||
const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
|
const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
|
||||||
const SESSIONS_API = '/api/sessions/sessions';
|
const SESSIONS_API = '/api/sessions/sessions';
|
||||||
|
const WHOAMI_API = '/api/sessions/whoami';
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const rawArg = params.get('arg');
|
const rawArg = params.get('arg');
|
||||||
const validArg = rawArg && NAME_RE.test(rawArg) ? rawArg : null;
|
const validArg = rawArg && NAME_RE.test(rawArg) ? rawArg : null;
|
||||||
|
|
@ -135,6 +136,46 @@
|
||||||
return e;
|
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) {
|
if (!validArg) {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// LOBBY MODE — no valid ?arg=, show session picker
|
// LOBBY MODE — no valid ?arg=, show session picker
|
||||||
|
|
@ -143,7 +184,9 @@
|
||||||
document.getElementById('paste-btn').classList.add('hidden');
|
document.getElementById('paste-btn').classList.add('hidden');
|
||||||
document.getElementById('img-btn').classList.add('hidden');
|
document.getElementById('img-btn').classList.add('hidden');
|
||||||
document.getElementById('lobby').classList.add('visible');
|
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 listEl = document.getElementById('session-list');
|
||||||
const newNameEl = document.getElementById('new-name');
|
const newNameEl = document.getElementById('new-name');
|
||||||
|
|
@ -300,7 +343,7 @@
|
||||||
term.open(document.getElementById('terminal'));
|
term.open(document.getElementById('terminal'));
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
|
|
||||||
document.title = 'tmux: ' + validArg;
|
document.title = 'tmux: ' + whoami.osUser + '/' + validArg;
|
||||||
|
|
||||||
function sendInput(data) {
|
function sendInput(data) {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,37 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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 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 {
|
type Session struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Attached int `json:"attached"`
|
Attached int `json:"attached"`
|
||||||
|
|
@ -21,33 +39,121 @@ type Session struct {
|
||||||
Created int64 `json:"created"`
|
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() {
|
func main() {
|
||||||
http.HandleFunc("/sessions", handleSessions)
|
http.HandleFunc("/sessions", handleSessions)
|
||||||
http.HandleFunc("/sessions/", handleSessionByName)
|
http.HandleFunc("/sessions/", handleSessionByName)
|
||||||
|
http.HandleFunc("/whoami", handleWhoami)
|
||||||
http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Write([]byte("ok"))
|
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))
|
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) {
|
func handleSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
osUser := resolveOSUser(w, r)
|
||||||
|
if osUser == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
out, err := exec.Command(
|
out, err := tmuxCmd(osUser, "list-sessions", "-F", tmuxListFmt).Output()
|
||||||
"tmux", "list-sessions", "-F",
|
|
||||||
"#{session_name}|#{session_attached}|#{session_activity}|#{session_created}",
|
|
||||||
).Output()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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 {
|
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("[]"))
|
w.Write([]byte("[]"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -71,32 +177,33 @@ func handleSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
Created: created,
|
Created: created,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(sessions)
|
json.NewEncoder(w).Encode(sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSessionByName(w http.ResponseWriter, r *http.Request) {
|
func handleSessionByName(w http.ResponseWriter, r *http.Request) {
|
||||||
name := strings.TrimPrefix(r.URL.Path, "/sessions/")
|
name := strings.TrimPrefix(r.URL.Path, "/sessions/")
|
||||||
name = strings.TrimSuffix(name, "/")
|
name = strings.TrimSuffix(name, "/")
|
||||||
|
|
||||||
if !sessionNameRe.MatchString(name) {
|
if !sessionNameRe.MatchString(name) {
|
||||||
http.Error(w, "invalid session name", http.StatusBadRequest)
|
http.Error(w, "invalid session name", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method != http.MethodDelete {
|
if r.Method != http.MethodDelete {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
msg := string(out)
|
msg := string(out)
|
||||||
if strings.Contains(msg, "can't find session") || strings.Contains(msg, "no server running") {
|
if strings.Contains(msg, "can't find session") || strings.Contains(msg, "no server running") {
|
||||||
http.Error(w, "session not found", http.StatusNotFound)
|
http.Error(w, "session not found", http.StatusNotFound)
|
||||||
return
|
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)
|
http.Error(w, "kill-session failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue