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 # 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.

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 #!/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

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] [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

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-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;

View file

@ -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
} }