diff --git a/stacks/terminal/files/devvm/README.md b/stacks/terminal/files/devvm/README.md new file mode 100644 index 00000000..f4b6073e --- /dev/null +++ b/stacks/terminal/files/devvm/README.md @@ -0,0 +1,67 @@ +# DevVM terminal-multi files + +These files configure the multi-session terminal on the DevVM (`10.0.10.10`). +They install **alongside** the existing `ttyd.service` (port 7681) and +`ttyd-ro.service` (port 7682) — the existing units are **not** modified. + +## Layout + +| Source | Destination on DevVM | +|--------|----------------------| +| `tmux-attach.sh` | `/usr/local/bin/tmux-attach.sh` (chmod 0755) | +| `ttyd-multi.service` | `/etc/systemd/system/ttyd-multi.service` | +| `tmux-api.service` | `/etc/systemd/system/tmux-api.service` | +| `../index-multi.html` (one level up) | `/usr/local/share/ttyd/index-multi.html` | +| `../../tmux-api/` binary, built `GOOS=linux GOARCH=amd64` | `/usr/local/bin/tmux-api` (chmod 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 page + wrapper script +scp infra/stacks/terminal/files/index-multi.html $DEVVM:/tmp/index-multi.html +scp infra/stacks/terminal/files/devvm/tmux-attach.sh $DEVVM:/tmp/tmux-attach.sh +ssh $DEVVM "sudo install -m 0644 /tmp/index-multi.html /usr/local/share/ttyd/index-multi.html && \ + sudo install -m 0755 /tmp/tmux-attach.sh /usr/local/bin/tmux-attach.sh && \ + rm /tmp/index-multi.html /tmp/tmux-attach.sh" + +# 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-multi.service $DEVVM:/tmp/ +scp infra/stacks/terminal/files/devvm/tmux-api.service $DEVVM:/tmp/ +ssh $DEVVM "sudo mv /tmp/ttyd-multi.service /etc/systemd/system/ && \ + sudo mv /tmp/tmux-api.service /etc/systemd/system/ && \ + sudo systemctl daemon-reload && \ + sudo systemctl enable --now ttyd-multi tmux-api" + +# 5. Sanity checks +ssh $DEVVM "systemctl status ttyd-multi tmux-api --no-pager" +ssh $DEVVM "curl -sf localhost:7684/sessions" +ssh $DEVVM "curl -sf localhost:7685/ | head -5" +ssh $DEVVM "systemctl is-active ttyd ttyd-ro" # existing units untouched +``` + +## Notes + +- **`User=wizard`** matches the existing `ttyd.service` so the new services + share the same tmux server (one socket per Unix user). Sessions created + via either `terminal.viktorbarzin.me` or `term.viktorbarzin.me` are + cross-visible. This is intentional. +- **ttyd version** is `1.7.7` on the DevVM — the `-a` flag (allow URL args + → argv) requires ≥ 1.7. +- **Argv flow**: `?arg=foo` on the URL → ttyd appends `foo` as `$1` to + `tmux-attach.sh` → the wrapper regex-validates and runs + `tmux new-session -A -s "$name"`. ttyd uses argv (never a shell string), + so there is no injection path. +- **No external exposure of 7684/7685** — 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. diff --git a/stacks/terminal/files/devvm/tmux-api.service b/stacks/terminal/files/devvm/tmux-api.service new file mode 100644 index 00000000..b25eeda4 --- /dev/null +++ b/stacks/terminal/files/devvm/tmux-api.service @@ -0,0 +1,11 @@ +[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 new file mode 100644 index 00000000..afd842a3 --- /dev/null +++ b/stacks/terminal/files/devvm/tmux-attach.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Invoked by ttyd-multi.service. ttyd's -a flag forwards ?arg= as $1. +# Defence-in-depth: ttyd uses argv (never shell strings) and we re-validate +# here before handing the name to tmux as a quoted argv slot. +set -euo pipefail + +name="${1:-main}" +if ! [[ "$name" =~ ^[a-zA-Z0-9_-]{1,32}$ ]]; then + name=main +fi + +exec tmux new-session -A -s "$name" -c /home/wizard/code diff --git a/stacks/terminal/files/devvm/ttyd-multi.service b/stacks/terminal/files/devvm/ttyd-multi.service new file mode 100644 index 00000000..60d3e5d5 --- /dev/null +++ b/stacks/terminal/files/devvm/ttyd-multi.service @@ -0,0 +1,11 @@ +[Unit] +Description=ttyd multi-session (port 7685) - tmux session lobby + per-session attach +After=network.target + +[Service] +ExecStart=/usr/local/bin/ttyd -W -a -t enableClipboard=true -I /usr/local/share/ttyd/index-multi.html -p 7685 /usr/local/bin/tmux-attach.sh +Restart=always +User=wizard + +[Install] +WantedBy=multi-user.target diff --git a/stacks/terminal/files/index-multi.html b/stacks/terminal/files/index-multi.html new file mode 100644 index 00000000..712d85ed --- /dev/null +++ b/stacks/terminal/files/index-multi.html @@ -0,0 +1,506 @@ + + + + + + Terminal + + + + +
+
+

tmux sessions

+

Pick an existing session or create a new one. Sessions persist after you close the tab.

+
+ + +
+
+
+
+ + + + + + + + + + + diff --git a/stacks/terminal/main.tf b/stacks/terminal/main.tf index 53f5b9ad..4b7cbd5c 100644 --- a/stacks/terminal/main.tf +++ b/stacks/terminal/main.tf @@ -216,3 +216,158 @@ module "ingress_ro" { "gethomepage.dev/pod-selector" = "" } } + +# === Multi-session terminal: term.viktorbarzin.me === +# +# Additive lobby UX on a fresh hostname + ports — does not touch the existing +# terminal.viktorbarzin.me (7681), terminal-ro.viktorbarzin.me (7682) or +# /clipboard/* (7683) wiring above. DevVM-side units (ttyd-multi.service on +# port 7685, tmux-api.service on port 7684) ship from +# files/devvm/ — see files/devvm/README.md. + +# Service+Endpoints → ttyd-multi on the DevVM (port 7685). +resource "kubernetes_service" "terminal_multi" { + metadata { + name = "terminal-multi" + namespace = kubernetes_namespace.terminal.metadata[0].name + labels = { + app = "terminal-multi" + } + } + + spec { + port { + name = "http" + port = 80 + target_port = 7685 + } + } +} + +resource "kubernetes_endpoints" "terminal_multi" { + metadata { + name = "terminal-multi" + namespace = kubernetes_namespace.terminal.metadata[0].name + } + + subset { + address { + ip = "10.0.10.10" + } + port { + name = "http" + port = 7685 + } + } +} + +# Service+Endpoints → tmux-api on the DevVM (port 7684). +resource "kubernetes_service" "tmux_api" { + metadata { + name = "tmux-api" + namespace = kubernetes_namespace.terminal.metadata[0].name + labels = { + app = "tmux-api" + } + } + + spec { + port { + name = "http" + port = 80 + target_port = 7684 + } + } +} + +resource "kubernetes_endpoints" "tmux_api" { + metadata { + name = "tmux-api" + namespace = kubernetes_namespace.terminal.metadata[0].name + } + + subset { + address { + ip = "10.0.10.10" + } + port { + name = "http" + port = 7684 + } + } +} + +# Public ingress for the lobby + per-session attach. +# Hostname: term.viktorbarzin.me (via `host = "term"` override). +module "ingress_multi" { + source = "../../modules/kubernetes/ingress_factory" + dns_type = "proxied" + namespace = kubernetes_namespace.terminal.metadata[0].name + name = "terminal-multi" + host = "term" + tls_secret_name = var.tls_secret_name + auth = "required" + extra_annotations = { + "gethomepage.dev/enabled" = "true" + "gethomepage.dev/name" = "Terminal (Multi)" + "gethomepage.dev/description" = "Multi-session tmux lobby (ttyd)" + "gethomepage.dev/icon" = "mdi-console" + "gethomepage.dev/group" = "Infrastructure" + "gethomepage.dev/pod-selector" = "" + } +} + +# IngressRoute: /api/sessions/* on term.viktorbarzin.me → tmux-api service. +# Path-prefixed routes beat the catch-all module ingress above by +# specificity, so the lobby HTML reaches tmux-api directly while everything +# else flows to ttyd-multi. +resource "kubernetes_manifest" "tmux_api_ingressroute" { + manifest = { + apiVersion = "traefik.io/v1alpha1" + kind = "IngressRoute" + metadata = { + name = "tmux-api" + namespace = kubernetes_namespace.terminal.metadata[0].name + } + spec = { + entryPoints = ["websecure"] + routes = [{ + match = "Host(`term.viktorbarzin.me`) && PathPrefix(`/api/sessions/`)" + kind = "Rule" + middlewares = [ + { + name = "authentik-forward-auth" + namespace = "traefik" + }, + { + name = "tmux-api-strip-prefix" + namespace = kubernetes_namespace.terminal.metadata[0].name + } + ] + services = [{ + name = "tmux-api" + port = 80 + }] + }] + tls = { + secretName = var.tls_secret_name + } + } + } +} + +resource "kubernetes_manifest" "tmux_api_strip_prefix" { + manifest = { + apiVersion = "traefik.io/v1alpha1" + kind = "Middleware" + metadata = { + name = "tmux-api-strip-prefix" + namespace = kubernetes_namespace.terminal.metadata[0].name + } + spec = { + stripPrefix = { + prefixes = ["/api/sessions"] + } + } + } +} diff --git a/stacks/terminal/tmux-api/go.mod b/stacks/terminal/tmux-api/go.mod new file mode 100644 index 00000000..94f20b35 --- /dev/null +++ b/stacks/terminal/tmux-api/go.mod @@ -0,0 +1,3 @@ +module tmux-api + +go 1.21 diff --git a/stacks/terminal/tmux-api/main.go b/stacks/terminal/tmux-api/main.go new file mode 100644 index 00000000..0a567037 --- /dev/null +++ b/stacks/terminal/tmux-api/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "os/exec" + "regexp" + "strconv" + "strings" +) + +const listenAddr = "0.0.0.0:7684" + +var sessionNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`) + +type Session struct { + Name string `json:"name"` + Attached int `json:"attached"` + LastActivity int64 `json:"lastActivity"` + Created int64 `json:"created"` +} + +func main() { + http.HandleFunc("/sessions", handleSessions) + http.HandleFunc("/sessions/", handleSessionByName) + http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("ok")) + }) + + log.Printf("tmux-api listening on %s", listenAddr) + log.Fatal(http.ListenAndServe(listenAddr, nil)) +} + +func handleSessions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET only", http.StatusMethodNotAllowed) + return + } + + out, err := exec.Command( + "tmux", "list-sessions", "-F", + "#{session_name}|#{session_attached}|#{session_activity}|#{session_created}", + ).Output() + + w.Header().Set("Content-Type", "application/json") + + // tmux exits non-zero when no server is running or no sessions exist. + // Treat both as "empty list" rather than a 500. + if err != nil { + 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) { + name := strings.TrimPrefix(r.URL.Path, "/sessions/") + name = strings.TrimSuffix(name, "/") + + if !sessionNameRe.MatchString(name) { + http.Error(w, "invalid session name", http.StatusBadRequest) + return + } + + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + out, err := exec.Command("tmux", "kill-session", "-t", name).CombinedOutput() + if err != nil { + msg := string(out) + if strings.Contains(msg, "can't find session") || strings.Contains(msg, "no server running") { + http.Error(w, "session not found", http.StatusNotFound) + return + } + log.Printf("kill-session %s failed: %v: %s", name, err, msg) + http.Error(w, "kill-session failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +}