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
|
||||
|
||||
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.
|
||||
|
|
|
|||
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
|
||||
# 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
|
||||
|
|
|
|||
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]
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue