From d6049ff7a07f75e9c95a2821e8a0c8e021832905 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 13 May 2026 21:10:56 +0000 Subject: [PATCH] terminal: extract app code to viktor/terminal-lobby on Forgejo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- stacks/terminal/clipboard-upload/go.mod | 3 - stacks/terminal/clipboard-upload/main.go | 94 -- stacks/terminal/files/devvm/README.md | 108 -- .../terminal/files/devvm/sudoers.d-ttyd-users | 13 - stacks/terminal/files/devvm/tmux-api.service | 11 - stacks/terminal/files/devvm/tmux-attach.sh | 53 - stacks/terminal/files/devvm/ttyd-user-map | 12 - stacks/terminal/files/devvm/ttyd.service | 11 - stacks/terminal/files/index.html | 950 ------------------ stacks/terminal/main.tf | 23 +- stacks/terminal/tmux-api/go.mod | 3 - stacks/terminal/tmux-api/main.go | 265 ----- 12 files changed, 16 insertions(+), 1530 deletions(-) delete mode 100644 stacks/terminal/clipboard-upload/go.mod delete mode 100644 stacks/terminal/clipboard-upload/main.go delete mode 100644 stacks/terminal/files/devvm/README.md delete mode 100644 stacks/terminal/files/devvm/sudoers.d-ttyd-users delete mode 100644 stacks/terminal/files/devvm/tmux-api.service delete mode 100644 stacks/terminal/files/devvm/tmux-attach.sh delete mode 100644 stacks/terminal/files/devvm/ttyd-user-map delete mode 100644 stacks/terminal/files/devvm/ttyd.service delete mode 100644 stacks/terminal/files/index.html delete mode 100644 stacks/terminal/tmux-api/go.mod delete mode 100644 stacks/terminal/tmux-api/main.go diff --git a/stacks/terminal/clipboard-upload/go.mod b/stacks/terminal/clipboard-upload/go.mod deleted file mode 100644 index 2c842e6e..00000000 --- a/stacks/terminal/clipboard-upload/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module clipboard-upload - -go 1.25.0 diff --git a/stacks/terminal/clipboard-upload/main.go b/stacks/terminal/clipboard-upload/main.go deleted file mode 100644 index 99ba9367..00000000 --- a/stacks/terminal/clipboard-upload/main.go +++ /dev/null @@ -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}) -} diff --git a/stacks/terminal/files/devvm/README.md b/stacks/terminal/files/devvm/README.md deleted file mode 100644 index 15edcf33..00000000 --- a/stacks/terminal/files/devvm/README.md +++ /dev/null @@ -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 /usr/bin/tmux …`. -3. **`tmux-api`** reads `X-authentik-username` on every request and runs - tmux as the mapped OS user too — so the lobby's session list is the - intersection of "your Authentik identity" and "what tmux on that OS - user's socket reports". - -Different Authentik identities map to different Unix users, which means -different `/tmp/tmux-/default` sockets — kernel-level isolation, -not "the API filtered the list". - -Adding a new user: - -1. Append a line to `/etc/ttyd-user-map` (canonical at - `files/devvm/ttyd-user-map`). -2. Append `wizard ALL=() NOPASSWD: /usr/bin/tmux` to - `/etc/sudoers.d/ttyd-users` (canonical at - `files/devvm/sudoers.d-ttyd-users`). -3. Ensure the OS user exists (`useradd -m `). - -## Layout - -| Source | Destination on DevVM | 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. diff --git a/stacks/terminal/files/devvm/sudoers.d-ttyd-users b/stacks/terminal/files/devvm/sudoers.d-ttyd-users deleted file mode 100644 index 144608f2..00000000 --- a/stacks/terminal/files/devvm/sudoers.d-ttyd-users +++ /dev/null @@ -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 diff --git a/stacks/terminal/files/devvm/tmux-api.service b/stacks/terminal/files/devvm/tmux-api.service deleted file mode 100644 index b25eeda4..00000000 --- a/stacks/terminal/files/devvm/tmux-api.service +++ /dev/null @@ -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 diff --git a/stacks/terminal/files/devvm/tmux-attach.sh b/stacks/terminal/files/devvm/tmux-attach.sh deleted file mode 100644 index 29ad7844..00000000 --- a/stacks/terminal/files/devvm/tmux-attach.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -# Invoked by ttyd.service per WebSocket connection. ttyd's `-a` flag -# forwards `?arg=` as $1; `-H X-authentik-username` sets -# $TTYD_USER to the Authentik identity. -# -# We map TTYD_USER → OS user via /etc/ttyd-user-map and sudo into that -# user before running tmux, so each Authentik identity gets its own -# kernel-isolated tmux server (one socket per uid). Authentik users -# without a mapping are denied — no fallback to a shared account. -set -euo pipefail - -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 <}'. - - This DevVM maps Authentik identities to OS users via - /etc/ttyd-user-map. Ask Viktor to add a mapping (and a matching - /etc/sudoers.d/ttyd-users entry) if you should have access. - -EOF - sleep 10 - exit 1 -fi - -# Session name from URL ?arg=; default to the OS user's own name. -name="${1:-$os_user}" -[[ "$name" =~ $NAME_RE ]] || name="$os_user" - -home_dir=$(getent passwd "$os_user" | cut -d: -f6) -home_dir="${home_dir:-/}" - -if [[ "$os_user" == "$(id -un)" ]]; then - exec tmux new-session -A -s "$name" -c "$home_dir" -else - exec sudo -n -H -u "$os_user" tmux new-session -A -s "$name" -c "$home_dir" -fi diff --git a/stacks/terminal/files/devvm/ttyd-user-map b/stacks/terminal/files/devvm/ttyd-user-map deleted file mode 100644 index 114af337..00000000 --- a/stacks/terminal/files/devvm/ttyd-user-map +++ /dev/null @@ -1,12 +0,0 @@ -# Authentik username (X-authentik-username header value, local part before @) -# → OS user on this DevVM. -# -# Format: "=" — one mapping per line. -# Lines starting with # and blank lines are ignored. -# Authentik users WITHOUT a mapping here are denied (no default fallback). -# -# Adding a new user: append a mapping + extend /etc/sudoers.d/ttyd-users so -# wizard can `sudo -n -u /usr/bin/tmux ...` without a password. - -vbarzin=wizard -emil.barzin=emo diff --git a/stacks/terminal/files/devvm/ttyd.service b/stacks/terminal/files/devvm/ttyd.service deleted file mode 100644 index a07867d6..00000000 --- a/stacks/terminal/files/devvm/ttyd.service +++ /dev/null @@ -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 diff --git a/stacks/terminal/files/index.html b/stacks/terminal/files/index.html deleted file mode 100644 index 28f5431e..00000000 --- a/stacks/terminal/files/index.html +++ /dev/null @@ -1,950 +0,0 @@ - - - - - - Terminal - - - - -
-
-
- -
-
Pick a session, or create one above.
- -
-
-
-
- - - - - - - - - - - diff --git a/stacks/terminal/main.tf b/stacks/terminal/main.tf index 6b990e41..b6ae160c 100644 --- a/stacks/terminal/main.tf +++ b/stacks/terminal/main.tf @@ -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/, +# POST /sessions//rename, GET /whoami # Service+Endpoints → tmux-api on the DevVM (port 7684). resource "kubernetes_service" "tmux_api" { diff --git a/stacks/terminal/tmux-api/go.mod b/stacks/terminal/tmux-api/go.mod deleted file mode 100644 index 94f20b35..00000000 --- a/stacks/terminal/tmux-api/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module tmux-api - -go 1.21 diff --git a/stacks/terminal/tmux-api/main.go b/stacks/terminal/tmux-api/main.go deleted file mode 100644 index 0414dd61..00000000 --- a/stacks/terminal/tmux-api/main.go +++ /dev/null @@ -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: "=[:]" 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 ` AS osUser. When -// osUser is the current process owner, sudo is skipped; otherwise we use -// `sudo -n -u 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) -}