infra/stacks/terminal/tmux-api/main.go
Viktor Barzin f63f10f7fa terminal: per-Authentik-user OS-user isolation; deny unmapped users
Restores the kernel-level isolation the pre-cutover ttyd-session.sh had,
but keeps the multi-session lobby UX:

- ttyd.service gets `-H X-authentik-username` back. `tmux-attach.sh` reads
  $TTYD_USER, looks up the local part in /etc/ttyd-user-map, denies the
  connection (no fallback to wizard) if there's no mapping, otherwise
  `sudo -n -H -u <os_user> tmux …`. Each Authentik identity → its own
  Unix user → its own `/tmp/tmux-<uid>/default` socket.
- tmux-api scopes every request to the same OS user via the same header.
  Adds /whoami so the lobby HTML can preflight access and render
  "logged in as <os_user> (<authentik>)" instead of leaving the user to
  discover the deny via a reconnect loop.
- Commits /etc/ttyd-user-map and the matching /etc/sudoers.d/ttyd-users
  fragment under files/devvm/ so future operators see one canonical
  source of truth. Current mappings: vbarzin → wizard, emil.barzin → emo.

Adding a user is now: append a line to ttyd-user-map + a NOPASSWD
sudoers line + `useradd -m`. README walks through it.

No Terraform changes — this is all DevVM-side + lobby JS.
2026-05-13 19:25:55 +00:00

211 lines
5.7 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) {
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
}
osUser := resolveOSUser(w, r)
if osUser == "" {
return
}
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)
}