fix: restore tree dropped by 6d224861; land stem95su gdrive-sync (10m) [ci skip]
6d224861 came from a --no-checkout worktree whose empty index made the
commit drop every file except two. This restores 05b50d2b's full tree and
correctly adds stacks/stem95su/gdrive-sync.tf + the service-catalog stem95su
entry. Forward-only (parent=6d224861, no force-push); [ci skip] since the
live infra was never applied from the broken commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6d224861c4
commit
fd0f4a0365
1166 changed files with 358546 additions and 0 deletions
3
scripts/t3-dispatch/go.mod
Normal file
3
scripts/t3-dispatch/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module t3-dispatch
|
||||
|
||||
go 1.22
|
||||
139
scripts/t3-dispatch/main.go
Normal file
139
scripts/t3-dispatch/main.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// t3-dispatch: per-user dispatch + auto-pair for t3code.
|
||||
// Sits behind Traefik+Authentik (which injects X-authentik-username) and routes
|
||||
// each authenticated user to their own `t3 serve` instance. On a user's first
|
||||
// visit (no t3 session cookie) it mints a pairing token for that user's instance
|
||||
// and exchanges it for the session cookie, which it injects into the browser —
|
||||
// so an Authentik login lands straight in the user's workspace.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type entry struct {
|
||||
OsUser string `json:"os_user"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
const (
|
||||
cookieName = "t3_session" // discovered: apps/server/src/auth/utils.ts (web mode)
|
||||
listenAddr = ":3780"
|
||||
dispatchFile = "/etc/t3-serve/dispatch.json"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
table map[string]entry
|
||||
)
|
||||
|
||||
func loadTable() error {
|
||||
b, err := os.ReadFile(dispatchFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := map[string]entry{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
mu.Lock()
|
||||
table = m
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookup(ak string) (entry, bool) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
e, ok := table[ak]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// autoPair mints a one-time pairing token for the user's instance (as that OS
|
||||
// user, via the scoped sudoers entry) and exchanges it at the instance's
|
||||
// /api/auth/bootstrap, relaying the returned t3_session Set-Cookie to the browser.
|
||||
func autoPair(e entry, w http.ResponseWriter, r *http.Request) {
|
||||
// t3-mint (root, via scoped sudoers) validates the OS user is in
|
||||
// /etc/ttyd-user-map, then mints as that user. The dispatch service itself
|
||||
// runs unprivileged and can invoke nothing else.
|
||||
out, err := exec.Command("sudo", "-n", "/usr/local/bin/t3-mint", e.OsUser).Output()
|
||||
if err != nil {
|
||||
log.Printf("mint for %s failed: %v", e.OsUser, err)
|
||||
http.Error(w, "pairing mint failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var pc struct {
|
||||
Credential string `json:"credential"` // CLI returns the token under "credential"
|
||||
}
|
||||
if err := json.Unmarshal(out, &pc); err != nil || pc.Credential == "" {
|
||||
http.Error(w, "unparseable pairing output", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"credential": pc.Credential})
|
||||
resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/api/auth/bootstrap", e.Port),
|
||||
"application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "bootstrap request failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("bootstrap for %s returned %d", e.OsUser, resp.StatusCode)
|
||||
http.Error(w, "bootstrap rejected", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
for _, c := range resp.Cookies() {
|
||||
http.SetCookie(w, c) // relays t3_session (HttpOnly; Path=/; SameSite=Lax)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func handler(w http.ResponseWriter, r *http.Request) {
|
||||
ak := r.Header.Get("X-authentik-username")
|
||||
// Authentik injects the full email (e.g. vbarzin@gmail.com); /etc/ttyd-user-map
|
||||
// (and thus dispatch.json) keys on the local part. Strip @domain, matching the
|
||||
// terminal stack's tmux-attach.sh (`${auth_user%%@*}`).
|
||||
if i := strings.IndexByte(ak, '@'); i >= 0 {
|
||||
ak = ak[:i]
|
||||
}
|
||||
e, ok := lookup(ak)
|
||||
if !ok {
|
||||
http.Error(w, "no t3 instance provisioned for this user", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if _, err := r.Cookie(cookieName); err != nil {
|
||||
autoPair(e, w, r)
|
||||
return
|
||||
}
|
||||
// Steady state: reverse-proxy (incl. WebSocket upgrade) to the user's instance.
|
||||
target, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", e.Port))
|
||||
httputil.NewSingleHostReverseProxy(target).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := loadTable(); err != nil {
|
||||
log.Fatalf("load %s: %v", dispatchFile, err)
|
||||
}
|
||||
go func() {
|
||||
for range time.Tick(60 * time.Second) {
|
||||
if err := loadTable(); err != nil {
|
||||
log.Printf("reload %s: %v", dispatchFile, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok\n")) })
|
||||
mux.HandleFunc("/", handler)
|
||||
log.Printf("t3-dispatch listening on %s", listenAddr)
|
||||
log.Fatal(http.ListenAndServe(listenAddr, mux))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue