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.
This commit is contained in:
parent
aff4f67671
commit
9fce3c7b09
7 changed files with 316 additions and 65 deletions
|
|
@ -1,19 +1,37 @@
|
|||
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"
|
||||
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"`
|
||||
|
|
@ -21,33 +39,121 @@ type Session struct {
|
|||
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", listenAddr)
|
||||
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 := exec.Command(
|
||||
"tmux", "list-sessions", "-F",
|
||||
"#{session_name}|#{session_attached}|#{session_activity}|#{session_created}",
|
||||
).Output()
|
||||
|
||||
out, err := tmuxCmd(osUser, "list-sessions", "-F", tmuxListFmt).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 {
|
||||
// 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
|
||||
}
|
||||
|
|
@ -71,32 +177,33 @@ func handleSessions(w http.ResponseWriter, r *http.Request) {
|
|||
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 := exec.Command("tmux", "kill-session", "-t", name).CombinedOutput()
|
||||
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 failed: %v: %s", name, err, msg)
|
||||
log.Printf("kill-session %s as %s failed: %v: %s", name, osUser, err, msg)
|
||||
http.Error(w, "kill-session failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue