Backend: POST /sessions/<name>/rename in tmux-api runs tmux rename-session as the mapped OS user. 400 on bad name, 404 on missing source, 409 on duplicate target, 401 on missing auth header. Frontend: - Rename button per card → prompt() dialog, validates against the shared regex. Updates currentActive + hash + iframe.src if the renamed session was active. - Session order is now user-driven, persisted in localStorage keyed per osUser. New sessions append at the bottom. The previous sort-by-lastActivity is gone. - HTML5 drag-and-drop reorders cards live during dragover; dragend captures the DOM order into localStorage. - Polling renderLobby is suppressed while a drag is in flight so the 5s tick doesn't yank the list out from under the user.
265 lines
7.3 KiB
Go
265 lines
7.3 KiB
Go
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)
|
|
}
|