terminal: add multi-tmux-session lobby on term.viktorbarzin.me (additive)
New hostname term.viktorbarzin.me serves a session-picker UI that lists, creates, and kills tmux sessions. Visiting ?arg=<name> attaches to that session (auto-creates via tmux -A). Builds on a fresh ttyd instance (7685) plus a tmux-api Go binary (7684) on the DevVM, both running as User=wizard alongside (not replacing) the existing ttyd.service (7681), ttyd-ro.service (7682), and clipboard-upload (7683). Cutover of terminal.viktorbarzin.me to the multi-session setup is deferred. Terraform diff is purely additive — terminal-multi/tmux-api Service + Endpoints + ingress_multi (term.viktorbarzin.me, Authentik-gated) + an IngressRoute that path-prefixes /api/sessions/* to tmux-api with the matching strip-prefix Middleware. DevVM-side units ship under files/devvm/ with a README — manual scp + systemctl install (see files/devvm/README.md). ttyd 1.7.7 already deployed there (≥1.7 needed for -a). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5754cfb56f
commit
a724169b0e
8 changed files with 869 additions and 0 deletions
3
stacks/terminal/tmux-api/go.mod
Normal file
3
stacks/terminal/tmux-api/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module tmux-api
|
||||
|
||||
go 1.21
|
||||
104
stacks/terminal/tmux-api/main.go
Normal file
104
stacks/terminal/tmux-api/main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue