terminal: extract app code to viktor/terminal-lobby on Forgejo

The lobby has grown enough (frontend, two Go services, devvm units +
scripts + config) that it earns its own repo. Code now lives at
https://forgejo.viktorbarzin.me/viktor/terminal-lobby with
scripts/deploy.sh covering the manual deploy until CI activation
lands (Woodpecker forge_id=2 activation still 500s; Forgejo Actions
not yet enabled).

This stack now owns only the K8s side — Services, Endpoints,
IngressRoutes, middlewares. main.tf comment block updated to point
at the new repo and the full DevVM port map.

Removed:
- stacks/terminal/files/        (index.html + DevVM artefacts)
- stacks/terminal/tmux-api/     (Go service)
- stacks/terminal/clipboard-upload/ (Go service)
This commit is contained in:
Viktor Barzin 2026-05-13 21:10:56 +00:00 committed by Viktor Barzin
parent c135c04c79
commit d6049ff7a0
12 changed files with 16 additions and 1530 deletions

View file

@ -1,3 +0,0 @@
module clipboard-upload
go 1.25.0

View file

@ -1,94 +0,0 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const (
uploadDir = "/tmp/clipboard-images"
maxUpload = 10 << 20 // 10MB
listenAddr = "0.0.0.0:7683"
)
func main() {
if err := os.MkdirAll(uploadDir, 0755); err != nil {
log.Fatalf("Failed to create upload dir: %v", err)
}
http.HandleFunc("/upload", handleUpload)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
log.Printf("Clipboard upload service listening on %s, saving to %s", listenAddr, uploadDir)
log.Fatal(http.ListenAndServe(listenAddr, nil))
}
func handleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
if err := r.ParseMultipartForm(maxUpload); err != nil {
http.Error(w, "File too large (max 10MB)", http.StatusRequestEntityTooLarge)
return
}
file, header, err := r.FormFile("image")
if err != nil {
http.Error(w, "Missing 'image' field", http.StatusBadRequest)
return
}
defer file.Close()
ct := header.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "image/") {
http.Error(w, "Not an image", http.StatusBadRequest)
return
}
ext := ".png"
switch ct {
case "image/jpeg":
ext = ".jpg"
case "image/gif":
ext = ".gif"
case "image/webp":
ext = ".webp"
}
randBytes := make([]byte, 4)
rand.Read(randBytes)
filename := fmt.Sprintf("%s-%s%s", time.Now().Format("20060102-150405"), hex.EncodeToString(randBytes), ext)
destPath := filepath.Join(uploadDir, filename)
dest, err := os.Create(destPath)
if err != nil {
http.Error(w, "Failed to save", http.StatusInternalServerError)
return
}
defer dest.Close()
if _, err := io.Copy(dest, file); err != nil {
os.Remove(destPath)
http.Error(w, "Failed to save", http.StatusInternalServerError)
return
}
log.Printf("Saved clipboard image: %s (%s, %d bytes)", destPath, ct, header.Size)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"path": destPath})
}

View file

@ -1,108 +0,0 @@
# DevVM terminal files
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 | 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
From the workstation (`infra/` repo root):
```bash
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 + 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
ssh $DEVVM "sudo install -m 0755 /tmp/tmux-api /usr/local/bin/tmux-api && rm /tmp/tmux-api"
# 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
"
# 5. Sanity checks
ssh $DEVVM "systemctl status ttyd tmux-api --no-pager"
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
- **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 retired
when the multi-session config was promoted to port 7681. The
per-Authentik-user isolation followed in a separate change.

View file

@ -1,13 +0,0 @@
# 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,11 +0,0 @@
[Unit]
Description=tmux-api (port 7684) - REST API for listing and killing tmux sessions
After=network.target
[Service]
ExecStart=/usr/local/bin/tmux-api
Restart=always
User=wizard
[Install]
WantedBy=multi-user.target

View file

@ -1,53 +0,0 @@
#!/usr/bin/env bash
# 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
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
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

@ -1,12 +0,0 @@
# 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,11 +0,0 @@
[Unit]
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 -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
[Install]
WantedBy=multi-user.target

View file

@ -1,950 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terminal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<style>
/* Themes — set on <body> as theme-{carbon,slate,mono,ink}. Slate is the default. */
body.theme-carbon {
--bg-page: #0c0c0a;
--bg-sidebar: #131311;
--bg-card: #1a1916;
--bg-card-hover: #22211d;
--text-primary: #e8e3d6;
--text-muted: #8a8474;
--border: #2a2826;
--border-strong: #3a3733;
--accent: #d4a574;
--danger: #c47a5a;
--success: #88a888;
--terminal-bg: #0c0c0a;
--terminal-fg: #e8e3d6;
--terminal-cursor: #d4a574;
--terminal-selection: rgba(212,165,116,0.22);
}
body.theme-slate {
--bg-page: #0d1117;
--bg-sidebar: #11151c;
--bg-card: #161b22;
--bg-card-hover: #1c2128;
--text-primary: #e6e8eb;
--text-muted: #7d8590;
--border: #1f242d;
--border-strong: #30363d;
--accent: #4493f8;
--danger: #f47067;
--success: #56d364;
--terminal-bg: #0d1117;
--terminal-fg: #e6e8eb;
--terminal-cursor: #4493f8;
--terminal-selection: rgba(68,147,248,0.22);
}
body.theme-mono {
--bg-page: #0d0d0d;
--bg-sidebar: #131313;
--bg-card: #1a1a1a;
--bg-card-hover: #232323;
--text-primary: #e0e0e0;
--text-muted: #7d7d7d;
--border: #2a2a2a;
--border-strong: #3a3a3a;
--accent: #e8e8e8;
--danger: #cc7070;
--success: #9ccb9c;
--terminal-bg: #0d0d0d;
--terminal-fg: #e0e0e0;
--terminal-cursor: #e8e8e8;
--terminal-selection: rgba(255,255,255,0.18);
}
body.theme-ink {
--bg-page: #faf7f2;
--bg-sidebar: #f1ebda;
--bg-card: #ffffff;
--bg-card-hover: #f4eee0;
--text-primary: #1d1b18;
--text-muted: #6e6862;
--border: #d8d2c4;
--border-strong: #b8b0a0;
--accent: #b5482d;
--danger: #b5482d;
--success: #3d6b3d;
--terminal-bg: #faf7f2;
--terminal-fg: #1d1b18;
--terminal-cursor: #b5482d;
--terminal-selection: rgba(181,72,45,0.18);
}
html, body {
margin: 0; padding: 0; height: 100%; overflow: hidden;
background: var(--bg-page, #0d1117);
color: var(--text-primary, #e6e8eb);
}
#terminal { height: 100%; width: 100%; }
.hidden { display: none !important; }
#toast {
position: fixed; top: 16px; right: 16px; z-index: 9999;
background: var(--bg-card); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 8px;
padding: 10px 18px; font-family: monospace;
font-size: 14px; opacity: 0; transition: opacity 0.3s;
pointer-events: none; max-width: 500px; word-break: break-all;
}
#toast.visible { opacity: 1; }
#toast.error { color: var(--danger); border-color: var(--danger); }
#toast.success { color: var(--success); border-color: var(--success); }
#paste-btn, #img-btn {
position: fixed; bottom: 24px; z-index: 9999;
width: 48px; height: 48px; border-radius: 12px;
background: color-mix(in srgb, var(--accent) 22%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--text-primary); font-size: 22px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
transition: background 0.2s, transform 0.1s;
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
}
#paste-btn { right: 24px; }
#img-btn { right: 80px; }
#paste-btn:hover, #img-btn:hover {
background: color-mix(in srgb, var(--accent) 35%, transparent);
}
#paste-btn:active, #img-btn:active { transform: scale(0.92); }
#img-input { display: none; }
/* Lobby */
#lobby {
display: none; height: 100vh; box-sizing: border-box;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
color: var(--text-primary); background: var(--bg-sidebar);
}
#lobby.visible { display: block; }
#lobby.access-denied { padding: 32px; overflow-y: auto; }
#lobby-shell {
display: grid; grid-template-columns: 260px 1fr; height: 100vh;
}
#lobby-sidebar {
padding: 20px 14px; overflow-y: auto;
border-right: 1px solid var(--border);
background: var(--bg-sidebar); min-width: 0;
display: flex; flex-direction: column;
}
#lobby-content {
position: relative; background: var(--terminal-bg);
min-width: 0; height: 100vh; overflow: hidden;
}
#session-frame { width: 100%; height: 100%; border: 0; display: block; }
#session-frame.hidden { display: none; }
#lobby-empty {
color: var(--text-muted); display: flex; align-items: center; justify-content: center;
height: 100%; font-style: italic; padding: 24px; text-align: center;
}
#lobby-empty.hidden { display: none; }
.lobby-header { font-size: 18px; color: var(--accent); margin: 0 0 4px 0; font-weight: 600; }
.lobby-sub { color: var(--text-muted); font-size: 12px; margin: 0 0 18px 0; line-height: 1.5; }
.new-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
.new-row input {
box-sizing: border-box; padding: 8px 12px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg-card);
color: var(--text-primary); font-family: inherit; font-size: 13px;
}
.new-row input:focus { outline: none; border-color: var(--accent); }
.new-row button {
padding: 8px 12px; border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--text-primary); font-family: inherit; font-size: 13px; cursor: pointer;
}
.new-row button:hover {
background: color-mix(in srgb, var(--accent) 30%, transparent);
}
.session-list { display: flex; flex-direction: column; gap: 6px; }
.session-card {
display: flex; align-items: center; justify-content: space-between;
background: var(--bg-card); border: 1px solid var(--border);
border-left: 3px solid transparent; border-radius: 8px;
padding: 10px 12px; cursor: pointer;
transition: background 0.15s, border-color 0.15s;
font-family: inherit; color: var(--text-primary); text-align: left;
}
.session-card:hover { background: var(--bg-card-hover); }
.session-card:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: -2px;
}
.session-card.active {
background: var(--bg-card-hover);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
border-left-color: var(--accent);
}
.session-meta {
display: flex; flex-direction: column; gap: 2px;
min-width: 0; flex: 1;
}
.session-name {
font-size: 14px; font-weight: 600; color: var(--text-primary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.session-detail { font-size: 11px; color: var(--text-muted); }
.session-detail.attached { color: var(--success); }
.session-actions { display: flex; gap: 6px; flex-shrink: 0; }
.session-actions button {
padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border);
background: var(--bg-card-hover); color: var(--text-primary);
font-family: inherit; font-size: 11px; cursor: pointer;
}
.session-actions button.rename {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}
.session-actions button.kill {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 45%, transparent);
}
.session-actions button:hover { filter: brightness(1.25); }
.session-card.dragging { opacity: 0.4; cursor: grabbing; }
.empty { color: var(--text-muted); font-style: italic; padding: 12px 0; font-size: 12px; }
/* Theme picker — sits at the bottom of the sidebar */
.theme-picker {
margin-top: auto; padding-top: 14px;
border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-muted);
}
.theme-picker-label {
display: block; margin-bottom: 6px;
text-transform: uppercase; letter-spacing: 0.06em;
}
.theme-options { display: flex; gap: 4px; }
.theme-options button {
flex: 1; padding: 5px 4px; border-radius: 6px;
background: var(--bg-card); color: var(--text-muted);
border: 1px solid var(--border); font-family: inherit;
font-size: 11px; cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.theme-options button:hover {
background: var(--bg-card-hover); color: var(--text-primary);
}
.theme-options button.active {
border-color: var(--accent); color: var(--accent);
background: var(--bg-card-hover);
}
@media (max-width: 720px) {
#lobby-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 60vh;
}
#lobby-sidebar {
border-right: 0; border-bottom: 1px solid var(--border);
max-height: 40vh;
}
#lobby-content { height: 60vh; }
}
</style>
</head>
<body>
<div id="terminal"></div>
<div id="lobby">
<div id="lobby-shell">
<aside id="lobby-sidebar">
<h1 class="lobby-header">tmux sessions</h1>
<p class="lobby-sub">Pick a session or create one. Sessions persist after you close the tab.</p>
<div class="new-row">
<input id="new-name" type="text" placeholder="new session name" maxlength="32" autocomplete="off">
<button id="new-btn">Create &amp; Open</button>
</div>
<div id="session-list" class="session-list"></div>
<div id="theme-picker" class="theme-picker">
<span class="theme-picker-label">Theme</span>
<div class="theme-options">
<button type="button" data-theme="carbon">Carbon</button>
<button type="button" data-theme="slate">Slate</button>
<button type="button" data-theme="mono">Mono</button>
<button type="button" data-theme="ink">Ink</button>
</div>
</div>
</aside>
<main id="lobby-content">
<div id="lobby-empty">Pick a session, or create one above.</div>
<iframe id="session-frame" class="hidden" referrerpolicy="same-origin" allow="clipboard-read; clipboard-write" title="terminal session"></iframe>
</main>
</div>
</div>
<div id="toast"></div>
<button id="img-btn" title="Upload image">&#128247;</button>
<button id="paste-btn" title="Paste from clipboard">&#128203;</button>
<input type="file" id="img-input" accept="image/*">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.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>
(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;
// Theme — shared by lobby + terminal-mode iframe (same-origin localStorage).
const THEMES = ['carbon', 'slate', 'mono', 'ink'];
const THEME_KEY = 'tmux-theme';
function getTheme() {
try {
const t = localStorage.getItem(THEME_KEY);
return THEMES.includes(t) ? t : 'slate';
} catch (e) { return 'slate'; }
}
function applyTheme(t) {
THEMES.forEach(x => document.body.classList.remove('theme-' + x));
document.body.classList.add('theme-' + t);
}
function readTerminalTheme() {
const cs = getComputedStyle(document.body);
const v = k => cs.getPropertyValue(k).trim();
return {
background: v('--terminal-bg') || '#0d1117',
foreground: v('--terminal-fg') || '#e6e8eb',
cursor: v('--terminal-cursor') || '#4493f8',
selectionBackground: v('--terminal-selection') || 'rgba(68,147,248,0.22)'
};
}
applyTheme(getTheme());
function showToast(msg, type, duration) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'visible' + (type ? ' ' + type : '');
clearTimeout(el._timer);
el._timer = setTimeout(() => { el.className = ''; }, duration || 3000);
}
function clearChildren(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function emptyState(text) {
const e = document.createElement('div');
e.className = 'empty';
e.textContent = text;
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', 'access-denied');
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=, render sidebar + iframe shell
// ============================================================
document.getElementById('terminal').classList.add('hidden');
document.getElementById('paste-btn').classList.add('hidden');
document.getElementById('img-btn').classList.add('hidden');
document.getElementById('lobby').classList.add('visible');
const baseTitle = 'tmux sessions (' + whoami.osUser + ')';
document.title = baseTitle;
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');
const newBtnEl = document.getElementById('new-btn');
const frameEl = document.getElementById('session-frame');
const emptyEl = document.getElementById('lobby-empty');
let currentActive = null;
let dragSrcCard = null;
let isDragging = false;
const ORDER_KEY = 'tmux-session-order-' + whoami.osUser;
function relativeTime(epochSec) {
if (!epochSec) return '';
const diff = Math.floor(Date.now() / 1000) - epochSec;
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
function getSavedOrder() {
try {
const raw = localStorage.getItem(ORDER_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr.filter(n => typeof n === 'string') : [];
} catch (e) { return []; }
}
function saveOrder(names) {
try { localStorage.setItem(ORDER_KEY, JSON.stringify(names)); } catch (e) {}
}
function captureOrderFromDom() {
return Array.from(listEl.querySelectorAll('.session-card')).map(c => c.dataset.name);
}
function applySavedOrder(sessions) {
const saved = getSavedOrder();
const byName = new Map(sessions.map(s => [s.name, s]));
const out = [];
for (const name of saved) {
const s = byName.get(name);
if (s) { out.push(s); byName.delete(name); }
}
for (const s of sessions) {
if (byName.has(s.name)) out.push(s);
}
return out;
}
function renameInOrder(oldName, newName) {
const order = getSavedOrder();
const i = order.indexOf(oldName);
if (i < 0) return;
order[i] = newName;
saveOrder(order);
}
function updateActiveCard() {
listEl.querySelectorAll('.session-card').forEach(c => {
if (c.dataset.name === currentActive) c.classList.add('active');
else c.classList.remove('active');
});
}
function activateSession(name) {
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
if (currentActive === name) { updateActiveCard(); return; }
currentActive = name;
emptyEl.classList.add('hidden');
frameEl.classList.remove('hidden');
frameEl.src = '/?arg=' + encodeURIComponent(name);
if (location.hash.slice(1) !== name) {
history.replaceState(null, '', '#' + name);
}
document.title = 'tmux: ' + whoami.osUser + '/' + name;
updateActiveCard();
}
function deactivateSession() {
if (!currentActive && frameEl.classList.contains('hidden')) return;
currentActive = null;
frameEl.classList.add('hidden');
emptyEl.classList.remove('hidden');
frameEl.src = 'about:blank';
if (location.hash) {
history.replaceState(null, '', location.pathname + location.search);
}
document.title = baseTitle;
updateActiveCard();
}
async function killSession(name) {
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
if (!confirm('Kill tmux session "' + name + '"? Any running processes inside it will be terminated.')) return;
try {
const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(name), {
method: 'DELETE', credentials: 'same-origin'
});
if (resp.ok) {
showToast('Killed ' + name, 'success');
} else if (resp.status === 404) {
showToast('Session not found', 'error');
} else {
showToast('Kill failed: HTTP ' + resp.status, 'error');
}
if (currentActive === name) deactivateSession();
renderLobby();
} catch (err) {
showToast('Kill error: ' + err.message, 'error');
}
}
async function renameSession(oldName) {
if (!NAME_RE.test(oldName)) { showToast('Invalid name', 'error'); return; }
const input = prompt('Rename "' + oldName + '" to:', oldName);
if (input === null) return;
const newName = input.trim();
if (!NAME_RE.test(newName)) {
showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
return;
}
if (newName === oldName) return;
try {
const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(oldName) + '/rename', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
if (resp.status === 204) {
renameInOrder(oldName, newName);
showToast('Renamed to ' + newName, 'success');
if (currentActive === oldName) {
currentActive = newName;
frameEl.src = '/?arg=' + encodeURIComponent(newName);
history.replaceState(null, '', '#' + newName);
document.title = 'tmux: ' + whoami.osUser + '/' + newName;
}
renderLobby();
} else if (resp.status === 404) {
showToast('Session not found', 'error');
} else if (resp.status === 409) {
showToast('Name "' + newName + '" already taken', 'error', 4000);
} else if (resp.status === 400) {
showToast('Invalid name', 'error');
} else {
showToast('Rename failed: HTTP ' + resp.status, 'error');
}
} catch (err) {
showToast('Rename error: ' + err.message, 'error');
}
}
function renderCard(s) {
const card = document.createElement('div');
card.className = 'session-card';
card.dataset.name = s.name;
card.setAttribute('role', 'button');
card.setAttribute('tabindex', '0');
card.setAttribute('aria-label', 'Activate session ' + s.name);
card.draggable = true;
if (s.name === currentActive) card.classList.add('active');
const meta = document.createElement('div');
meta.className = 'session-meta';
const name = document.createElement('div');
name.className = 'session-name';
name.textContent = s.name;
meta.appendChild(name);
const detail = document.createElement('div');
detail.className = 'session-detail' + (s.attached > 0 ? ' attached' : '');
const attachedText = s.attached > 0 ? (s.attached + ' attached') : 'idle';
detail.textContent = attachedText + ' · ' + relativeTime(s.lastActivity);
meta.appendChild(detail);
card.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'session-actions';
const renameBtn = document.createElement('button');
renameBtn.className = 'rename';
renameBtn.textContent = 'Rename';
renameBtn.draggable = false;
renameBtn.addEventListener('click', (e) => {
e.stopPropagation();
renameSession(s.name);
});
actions.appendChild(renameBtn);
const killBtn = document.createElement('button');
killBtn.className = 'kill';
killBtn.textContent = 'Kill';
killBtn.draggable = false;
killBtn.addEventListener('click', (e) => {
e.stopPropagation();
killSession(s.name);
});
actions.appendChild(killBtn);
card.appendChild(actions);
card.addEventListener('click', () => activateSession(s.name));
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activateSession(s.name);
}
});
card.addEventListener('dragstart', (e) => {
dragSrcCard = card;
isDragging = true;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', s.name);
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
if (dragSrcCard === card) saveOrder(captureOrderFromDom());
dragSrcCard = null;
isDragging = false;
});
card.addEventListener('dragover', (e) => {
if (!dragSrcCard || dragSrcCard === card) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = card.getBoundingClientRect();
const after = (e.clientY - rect.top) > rect.height / 2;
if (after) card.after(dragSrcCard);
else card.before(dragSrcCard);
});
return card;
}
async function renderLobby() {
if (isDragging) return;
try {
const resp = await fetch(SESSIONS_API, { credentials: 'same-origin' });
if (!resp.ok) {
clearChildren(listEl);
listEl.appendChild(emptyState('Failed to load sessions: HTTP ' + resp.status));
return;
}
const sessions = await resp.json();
if (currentActive && !sessions.some(s => s.name === currentActive)) {
deactivateSession();
}
clearChildren(listEl);
if (sessions.length === 0) {
listEl.appendChild(emptyState('No sessions yet.'));
return;
}
const ordered = applySavedOrder(sessions);
for (const s of ordered) listEl.appendChild(renderCard(s));
} catch (err) {
clearChildren(listEl);
listEl.appendChild(emptyState('Error: ' + err.message));
}
}
newBtnEl.addEventListener('click', () => {
const name = newNameEl.value.trim();
if (!NAME_RE.test(name)) {
showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
return;
}
newNameEl.value = '';
activateSession(name);
});
newNameEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') newBtnEl.click();
});
window.addEventListener('hashchange', () => {
const hash = location.hash.slice(1);
if (hash && NAME_RE.test(hash)) {
if (hash !== currentActive) activateSession(hash);
} else if (currentActive) {
deactivateSession();
}
});
// Theme picker — clicking a swatch swaps body class, persists, and
// reloads the iframe so the inner terminal re-themes too.
const themePickerEl = document.getElementById('theme-picker');
function paintThemeButtons() {
const active = getTheme();
themePickerEl.querySelectorAll('button[data-theme]').forEach(b => {
b.classList.toggle('active', b.dataset.theme === active);
});
}
themePickerEl.querySelectorAll('button[data-theme]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.theme;
if (!THEMES.includes(t)) return;
try { localStorage.setItem(THEME_KEY, t); } catch (e) {}
applyTheme(t);
paintThemeButtons();
if (currentActive && frameEl.src) {
// Reload iframe so xterm picks up the new theme.
const url = frameEl.src;
frameEl.src = 'about:blank';
setTimeout(() => { frameEl.src = url; }, 0);
}
});
});
paintThemeButtons();
renderLobby();
setInterval(renderLobby, 5000);
const initialHash = location.hash.slice(1);
if (initialHash && NAME_RE.test(initialHash)) {
activateSession(initialHash);
}
return;
}
// ============================================================
// TERMINAL MODE — valid ?arg=<session>, attach via ttyd
// ============================================================
// ttyd binary protocol
const MSG_OUTPUT = 0x30; // '0' - terminal output
const MSG_SET_PREFS = 0x31; // '1' - JSON preferences
const MSG_SET_TITLE = 0x32; // '2' - window title
const MSG_INPUT = '0'; // client → server: input data
const MSG_RESIZE = '1'; // client → server: {columns, rows}
let ws = null;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const term = new Terminal({
cursorBlink: true,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 15,
theme: readTerminalTheme(),
allowProposedApi: true
});
const fitAddon = new FitAddon.FitAddon();
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(fitAddon);
term.loadAddon(webLinksAddon);
try {
const webglAddon = new WebglAddon.WebglAddon();
webglAddon.onContextLoss(() => { webglAddon.dispose(); });
term.loadAddon(webglAddon);
} catch (e) {
console.warn('WebGL addon failed:', e);
}
term.open(document.getElementById('terminal'));
fitAddon.fit();
document.title = 'tmux: ' + whoami.osUser + '/' + validArg;
function sendInput(data) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const payload = textEncoder.encode(data);
const buf = new Uint8Array(payload.length + 1);
buf[0] = MSG_INPUT.charCodeAt(0);
buf.set(payload, 1);
ws.send(buf.buffer);
}
function sendResize() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const json = JSON.stringify({ columns: term.cols, rows: term.rows });
const payload = textEncoder.encode(json);
const buf = new Uint8Array(payload.length + 1);
buf[0] = MSG_RESIZE.charCodeAt(0);
buf.set(payload, 1);
ws.send(buf.buffer);
}
term.onData(sendInput);
term.onBinary((data) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const bytes = new Uint8Array(data.length + 1);
bytes[0] = MSG_INPUT.charCodeAt(0);
for (let i = 0; i < data.length; i++) bytes[i + 1] = data.charCodeAt(i);
ws.send(bytes.buffer);
});
term.onResize(() => sendResize());
window.addEventListener('resize', () => fitAddon.fit());
term.attachCustomKeyEventHandler((e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && term.hasSelection()) {
navigator.clipboard.writeText(term.getSelection());
return false;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
return false; // let browser paste event fire
}
return true;
});
document.addEventListener('paste', async (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
e.stopPropagation();
const blob = item.getAsFile();
if (!blob) { showToast('Failed to read image', 'error'); return; }
showToast('Uploading image...', '');
try {
const formData = new FormData();
formData.append('image', blob);
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
const { path } = await resp.json();
sendInput(path);
showToast('Pasted: ' + path, 'success', 4000);
} catch (err) {
showToast('Upload error: ' + err.message, 'error', 5000);
}
return;
}
}
const text = e.clipboardData.getData('text');
if (text) {
e.preventDefault();
sendInput(text);
}
}, true);
document.getElementById('paste-btn').addEventListener('click', async () => {
try {
if (navigator.clipboard.read) {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageType = item.types.find(t => t.startsWith('image/'));
if (imageType) {
const blob = await item.getType(imageType);
showToast('Uploading image...', '');
const formData = new FormData();
formData.append('image', blob);
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
const { path } = await resp.json();
sendInput(path);
showToast('Pasted: ' + path, 'success', 4000);
return;
}
if (item.types.includes('text/plain')) {
const blob = await item.getType('text/plain');
const text = await blob.text();
if (text) { sendInput(text); return; }
}
}
} else {
const text = await navigator.clipboard.readText();
if (text) sendInput(text);
}
} catch (err) {
showToast('Clipboard access denied', 'error', 3000);
console.error('Clipboard read failed:', err);
}
term.focus();
});
document.getElementById('img-btn').addEventListener('click', () => {
document.getElementById('img-input').click();
});
document.getElementById('img-input').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
e.target.value = ''; // reset so same file can be re-selected
showToast('Uploading image...', '');
try {
const formData = new FormData();
formData.append('image', file);
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
const { path } = await resp.json();
sendInput(path);
showToast('Pasted: ' + path, 'success', 4000);
} catch (err) {
showToast('Upload error: ' + err.message, 'error', 5000);
}
term.focus();
});
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const argSuffix = '?arg=' + encodeURIComponent(validArg);
const base = location.pathname.replace(/\/+$/, '');
const wsUrl = proto + '//' + location.host + base + '/ws' + argSuffix;
fetch(base + '/token' + argSuffix, { credentials: 'same-origin' })
.then(r => r.json())
.then(tokenData => {
const token = tokenData.token || '';
ws = new WebSocket(wsUrl, ['tty']);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
console.log('Connected to ttyd (session: ' + validArg + ')');
const initMsg = JSON.stringify({
AuthToken: token,
columns: term.cols,
rows: term.rows
});
ws.send(initMsg);
};
ws.onmessage = (event) => {
const data = event.data;
if (data instanceof ArrayBuffer) {
const view = new Uint8Array(data);
if (view.length < 1) return;
const msgType = view[0];
const payload = view.slice(1);
switch (msgType) {
case MSG_OUTPUT:
term.write(payload);
break;
case MSG_SET_PREFS:
try {
const prefs = JSON.parse(textDecoder.decode(payload));
console.log('ttyd prefs:', prefs);
} catch (e) {}
break;
case MSG_SET_TITLE:
document.title = textDecoder.decode(payload);
break;
}
}
};
ws.onclose = () => {
term.write('\r\n\x1b[31mDisconnected. Reconnecting...\x1b[0m\r\n');
setTimeout(connect, 3000);
};
ws.onerror = (e) => console.error('WebSocket error:', e);
})
.catch(err => {
console.error('Token fetch failed:', err);
term.write('\r\n\x1b[31mFailed to connect. Retrying...\x1b[0m\r\n');
setTimeout(connect, 3000);
});
}
connect();
})();
</script>
</body>
</html>

View file

@ -217,15 +217,24 @@ module "ingress_ro" {
}
}
# === Multi-session lobby cutover on terminal.viktorbarzin.me ===
# === Multi-session lobby on terminal.viktorbarzin.me ===
#
# The `terminal` Service+Endpoints (port 7681) above now backs the
# multi-session lobby ttyd.service on the DevVM runs tmux-attach.sh with
# `-a`, serving index.html (the lobby HTML, ex-index-multi.html). DevVM-side
# units ship from files/devvm/ see files/devvm/README.md.
# Application code (frontend, tmux-api, clipboard-upload, DevVM
# systemd units / scripts / config) lives in a separate Forgejo repo:
# https://forgejo.viktorbarzin.me/viktor/terminal-lobby
#
# The lobby's REST API (`/api/sessions/*`) is reverse-proxied to a small Go
# binary on port 7684 via the IngressRoute below.
# That repo's ./scripts/deploy.sh ships everything to wizard@10.0.10.10
# and restarts ttyd / ttyd-ro / tmux-api / clipboard-upload. This stack
# only owns the Kubernetes side: Services, Endpoints pointing at
# 10.0.10.10:{7681,7682,7683,7684}, the IngressRoutes, and the Traefik
# middlewares that gate everything behind Authentik forward-auth.
#
# Service map (DevVM):
# ttyd :7681 serves lobby + xterm WS
# ttyd-ro :7682 read-only mirror at terminal-ro.viktorbarzin.me
# clipboard-upload :7683 POST /upload, returns saved path
# tmux-api :7684 GET /sessions, DELETE /sessions/<n>,
# POST /sessions/<n>/rename, GET /whoami
# Service+Endpoints tmux-api on the DevVM (port 7684).
resource "kubernetes_service" "tmux_api" {

View file

@ -1,3 +0,0 @@
module tmux-api
go 1.21

View file

@ -1,265 +0,0 @@
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"
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"`
LastActivity int64 `json:"lastActivity"`
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 (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 := tmuxCmd(osUser, "list-sessions", "-F", tmuxListFmt).Output()
w.Header().Set("Content-Type", "application/json")
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
}
sessions := make([]Session, 0)
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
if line == "" {
continue
}
parts := strings.Split(line, "|")
if len(parts) != 4 {
continue
}
attached, _ := strconv.Atoi(parts[1])
activity, _ := strconv.ParseInt(parts[2], 10, 64)
created, _ := strconv.ParseInt(parts[3], 10, 64)
sessions = append(sessions, Session{
Name: parts[0],
Attached: attached,
LastActivity: activity,
Created: created,
})
}
json.NewEncoder(w).Encode(sessions)
}
func handleSessionByName(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/sessions/")
path = strings.TrimSuffix(path, "/")
parts := strings.Split(path, "/")
name := parts[0]
if !sessionNameRe.MatchString(name) {
http.Error(w, "invalid session name", http.StatusBadRequest)
return
}
osUser := resolveOSUser(w, r)
if osUser == "" {
return
}
if len(parts) == 1 {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
killSession(w, osUser, name)
return
}
if len(parts) == 2 && parts[1] == "rename" {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
renameSession(w, r, osUser, name)
return
}
http.Error(w, "not found", http.StatusNotFound)
}
func killSession(w http.ResponseWriter, osUser, name string) {
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 as %s failed: %v: %s", name, osUser, err, msg)
http.Error(w, "kill-session failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func renameSession(w http.ResponseWriter, r *http.Request, osUser, oldName string) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
newName := strings.TrimSpace(body.Name)
if !sessionNameRe.MatchString(newName) {
http.Error(w, "invalid new name", http.StatusBadRequest)
return
}
if newName == oldName {
w.WriteHeader(http.StatusNoContent)
return
}
out, err := tmuxCmd(osUser, "rename-session", "-t", oldName, newName).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
}
if strings.Contains(msg, "duplicate session") || strings.Contains(msg, "session already exists") {
http.Error(w, "target name already exists", http.StatusConflict)
return
}
log.Printf("rename-session %s→%s as %s failed: %v: %s", oldName, newName, osUser, err, msg)
http.Error(w, "rename-session failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}