2026-08-16 15:59:09 -03:00

443 lines
14 KiB
Go

// Hermes chat tenant router assigns each Keycloak subject to one isolated
// Hermes pod and exposes only the user-facing parts of Hermes WebUI.
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
type linkRecord struct {
Slot int `json:"slot"`
ExpiresAt int64 `json:"expires_at"`
}
type telegramTopic struct {
Label string `json:"label"`
LastUsed int64 `json:"last_used"`
}
type telegramTopicState struct {
Active string `json:"active,omitempty"`
Topics map[string]telegramTopic `json:"topics,omitempty"`
}
type tenantState struct {
Salt string `json:"salt"`
Assignments map[string]int `json:"assignments"`
Telegram map[string]int `json:"telegram,omitempty"`
TelegramTopics map[string]telegramTopicState `json:"telegram_topics,omitempty"`
LinkCodes map[string]linkRecord `json:"link_codes,omitempty"`
TelegramOffset int64 `json:"telegram_offset,omitempty"`
}
type tenantRouter struct {
mu sync.Mutex
state tenantState
statePath string
slots int
backendURL func(int) string
backendAPIURL func(int) string
backendMediaURL func(int) string
now func() time.Time
telegram *telegramBot
}
var deniedPrefixes = []string{
"/api/admin", "/api/commands/exec", "/api/config", "/api/console", "/api/credentials",
"/api/cron", "/api/curator", "/api/dashboard/config", "/api/env",
"/api/escape", "/api/extensions", "/api/file", "/api/folder",
"/api/gateway", "/api/git", "/api/git-info", "/api/health/restart", "/api/kanban", "/api/logs", "/api/mcp",
"/api/messaging", "/api/notes", "/api/ops",
"/api/oauth", "/api/onboarding/oauth", "/api/pairing", "/api/plugins", "/api/providers",
"/api/rollback", "/api/shutdown", "/api/terminal",
"/api/tools", "/api/updates", "/api/webhooks", "/api/wiki",
"/api/workspace", "/api/workspaces",
}
const retiredServiceWorker = `self.addEventListener("install",()=>self.skipWaiting());self.addEventListener("activate",event=>event.waitUntil((async()=>{for(const key of await caches.keys())await caches.delete(key);await self.registration.unregister();for(const client of await self.clients.matchAll({type:"window"}))await client.navigate(client.url)})()));`
const (
trustedTenantHeader = "X-Hermes-Tenant-Identity"
tenantSessionCookie = "hermes_chat_session"
tenantProfileCookie = "hermes_chat_profile"
)
func newTenantRouter(statePath string, slots int, backendURL func(int) string) (*tenantRouter, error) {
if slots < 1 {
return nil, fmt.Errorf("tenant slots must be positive")
}
router := &tenantRouter{
statePath: statePath,
slots: slots,
backendURL: backendURL,
now: time.Now,
state: tenantState{
Assignments: map[string]int{},
Telegram: map[string]int{},
TelegramTopics: map[string]telegramTopicState{},
LinkCodes: map[string]linkRecord{},
},
}
content, err := os.ReadFile(statePath)
if err == nil {
if err := json.Unmarshal(content, &router.state); err != nil {
return nil, fmt.Errorf("decode tenant state: %w", err)
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("read tenant state: %w", err)
}
if router.state.Assignments == nil {
router.state.Assignments = map[string]int{}
}
if router.state.Telegram == nil {
router.state.Telegram = map[string]int{}
}
if router.state.TelegramTopics == nil {
router.state.TelegramTopics = map[string]telegramTopicState{}
}
if router.state.LinkCodes == nil {
router.state.LinkCodes = map[string]linkRecord{}
}
if router.state.Salt == "" {
salt := make([]byte, 32)
if _, err := rand.Read(salt); err != nil {
return nil, fmt.Errorf("create tenant salt: %w", err)
}
router.state.Salt = hex.EncodeToString(salt)
if err := router.saveLocked(); err != nil {
return nil, err
}
}
return router, nil
}
func (router *tenantRouter) saveLocked() error {
content, err := json.MarshalIndent(router.state, "", " ")
if err != nil {
return fmt.Errorf("encode tenant state: %w", err)
}
if err := os.MkdirAll(filepath.Dir(router.statePath), 0700); err != nil {
return fmt.Errorf("create tenant state directory: %w", err)
}
temporary := router.statePath + ".tmp"
if err := os.WriteFile(temporary, append(content, '\n'), 0600); err != nil {
return fmt.Errorf("write tenant state: %w", err)
}
if err := os.Rename(temporary, router.statePath); err != nil {
return fmt.Errorf("replace tenant state: %w", err)
}
return nil
}
func (router *tenantRouter) identityHash(kind, value string) string {
digest := sha256.Sum256([]byte(router.state.Salt + "\x00" + kind + "\x00" + value))
return hex.EncodeToString(digest[:])
}
func (router *tenantRouter) slotFor(subject string) (int, error) {
router.mu.Lock()
defer router.mu.Unlock()
identity := router.identityHash("keycloak", subject)
if slot, ok := router.state.Assignments[identity]; ok {
return slot, nil
}
used := make(map[int]bool, len(router.state.Assignments))
for _, slot := range router.state.Assignments {
used[slot] = true
}
for slot := 0; slot < router.slots; slot++ {
if used[slot] {
continue
}
router.state.Assignments[identity] = slot
if err := router.saveLocked(); err != nil {
delete(router.state.Assignments, identity)
return 0, err
}
return slot, nil
}
return 0, fmt.Errorf("all isolated chat slots are assigned")
}
func (router *tenantRouter) createLink(subject string) (string, time.Time, error) {
slot, err := router.slotFor(subject)
if err != nil {
return "", time.Time{}, err
}
raw := make([]byte, 5)
if _, err := rand.Read(raw); err != nil {
return "", time.Time{}, fmt.Errorf("create link code: %w", err)
}
code := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
expires := router.now().Add(10 * time.Minute)
router.mu.Lock()
defer router.mu.Unlock()
for digest, record := range router.state.LinkCodes {
if record.Slot == slot || record.ExpiresAt <= router.now().Unix() {
delete(router.state.LinkCodes, digest)
}
}
digest := router.identityHash("link", strings.ToUpper(code))
router.state.LinkCodes[digest] = linkRecord{Slot: slot, ExpiresAt: expires.Unix()}
if err := router.saveLocked(); err != nil {
delete(router.state.LinkCodes, digest)
return "", time.Time{}, err
}
return code, expires, nil
}
func (router *tenantRouter) consumeLink(telegramUser, code string) (int, error) {
router.mu.Lock()
defer router.mu.Unlock()
digest := router.identityHash("link", strings.ToUpper(strings.TrimSpace(code)))
record, ok := router.state.LinkCodes[digest]
if !ok || record.ExpiresAt <= router.now().Unix() {
delete(router.state.LinkCodes, digest)
return 0, fmt.Errorf("link code is invalid or expired")
}
delete(router.state.LinkCodes, digest)
identity := router.identityHash("telegram", telegramUser)
if previousSlot, linked := router.state.Telegram[identity]; linked && previousSlot != record.Slot {
// A link moved to a different tenant must not carry topic labels or the
// active conversation selector across that isolation boundary.
delete(router.state.TelegramTopics, identity)
}
router.state.Telegram[identity] = record.Slot
if err := router.saveLocked(); err != nil {
return 0, err
}
return record.Slot, nil
}
func (router *tenantRouter) telegramSlot(telegramUser string) (int, bool) {
router.mu.Lock()
defer router.mu.Unlock()
slot, ok := router.state.Telegram[router.identityHash("telegram", telegramUser)]
return slot, ok
}
func (router *tenantRouter) telegramLinked(subject string) (bool, error) {
slot, err := router.slotFor(subject)
if err != nil {
return false, err
}
router.mu.Lock()
defer router.mu.Unlock()
for _, linkedSlot := range router.state.Telegram {
if linkedSlot == slot {
return true, nil
}
}
return false, nil
}
func (router *tenantRouter) unlinkTelegram(subject string) error {
slot, err := router.slotFor(subject)
if err != nil {
return err
}
router.mu.Lock()
defer router.mu.Unlock()
for identity, linkedSlot := range router.state.Telegram {
if linkedSlot == slot {
delete(router.state.Telegram, identity)
delete(router.state.TelegramTopics, identity)
}
}
for digest, record := range router.state.LinkCodes {
if record.Slot == slot {
delete(router.state.LinkCodes, digest)
}
}
return router.saveLocked()
}
func authenticatedSubject(request *http.Request) string {
for _, header := range []string{"X-Forwarded-User", "X-Auth-Request-User"} {
if subject := strings.TrimSpace(request.Header.Get(header)); subject != "" {
return subject
}
}
return ""
}
func safeLoginNext(request *http.Request) string {
next := strings.TrimSpace(request.URL.Query().Get("next"))
if next == "" || len(next) > 2048 || !strings.HasPrefix(next, "/") ||
strings.HasPrefix(next, "//") || strings.ContainsAny(next, "\\\r\n") {
return "/"
}
parsed, err := url.ParseRequestURI(next)
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.Path == "/login" || strings.HasPrefix(parsed.Path, "/login/") {
return "/"
}
return next
}
func preserveTenantCookies(request *http.Request) []*http.Cookie {
var preserved []*http.Cookie
for _, cookie := range request.Cookies() {
if cookie.Name == tenantSessionCookie || cookie.Name == tenantProfileCookie {
preserved = append(preserved, cookie)
}
}
return preserved
}
func privateWorkspaceReadAllowed(method, path string) bool {
if method != http.MethodGet {
return false
}
switch path {
case "/api/workspaces", "/api/workspaces/suggest", "/api/file", "/api/file/raw", "/api/folder/download",
"/api/logs", "/api/rollback/list", "/api/rollback/diff":
return true
default:
return false
}
}
func pathDenied(method, path string) bool {
// Each Keycloak identity is already pinned to a dedicated WebUI and PVC.
// Let that user discover and read files inside the backend-validated private
// Home workspace, while keeping workspace registration/mutation and escape
// APIs behind the chat administration boundary.
if privateWorkspaceReadAllowed(method, path) {
return false
}
for _, prefix := range deniedPrefixes {
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return true
}
}
return false
}
func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/healthz" {
writer.Header().Set("Content-Type", "text/plain")
_, _ = writer.Write([]byte("ok\n"))
return
}
if request.Method == http.MethodGet && request.URL.Path == "/sw.js" {
writer.Header().Set("Content-Type", "application/javascript; charset=utf-8")
writer.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
writer.Header().Set("Clear-Site-Data", `"cache", "storage"`)
writer.Header().Set("Service-Worker-Allowed", "/")
_, _ = writer.Write([]byte(retiredServiceWorker))
return
}
subject := authenticatedSubject(request)
if subject == "" {
http.Error(writer, "authenticated identity required", http.StatusUnauthorized)
return
}
// Keycloak is the public login authority. Never expose the tenant WebUI's
// redundant password screen after a successful SSO handoff or when a user
// follows a stale /login bookmark.
if request.Method == http.MethodGet && request.URL.Path == "/login" {
http.Redirect(writer, request, safeLoginNext(request), http.StatusFound)
return
}
if router.serveTelegramWeb(writer, request, subject) {
return
}
if pathDenied(request.Method, request.URL.Path) {
http.Error(writer, "chat administration is disabled", http.StatusForbidden)
return
}
slot, err := router.slotFor(subject)
if err != nil {
http.Error(writer, err.Error(), http.StatusServiceUnavailable)
return
}
target, err := url.Parse(router.backendURL(slot))
if err != nil {
http.Error(writer, "invalid tenant backend", http.StatusInternalServerError)
return
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ModifyResponse = injectChatBridge
proxy.ErrorHandler = func(writer http.ResponseWriter, _ *http.Request, proxyErr error) {
log.Printf("tenant slot %d unavailable", slot)
http.Error(writer, "private chat runtime is starting", http.StatusBadGateway)
}
originalDirector := proxy.Director
proxy.Director = func(outbound *http.Request) {
originalDirector(outbound)
cookies := preserveTenantCookies(outbound)
for _, header := range []string{
"Authorization", "Cookie", "X-Auth-Request-Access-Token",
"X-Auth-Request-Email", "X-Auth-Request-Groups", "X-Auth-Request-User",
"X-Forwarded-Access-Token", "X-Forwarded-Email", "X-Forwarded-Groups",
"X-Forwarded-Preferred-Username", "X-Forwarded-User",
} {
outbound.Header.Del(header)
}
// Each Keycloak subject is already mapped to exactly one isolated pod.
// Give that pod a non-sensitive, router-asserted identity while retaining
// only its own WebUI cookies; OAuth credentials never reach the backend.
outbound.Header.Set(trustedTenantHeader, fmt.Sprintf("slot-%d", slot))
for _, cookie := range cookies {
outbound.AddCookie(cookie)
}
outbound.Header.Del("Accept-Encoding")
}
proxy.ServeHTTP(writer, request)
}
func main() {
slots, err := strconv.Atoi(os.Getenv("TENANT_SLOTS"))
if err != nil || slots < 1 {
log.Fatal("TENANT_SLOTS must be a positive integer")
}
statePath := os.Getenv("TENANT_STATE_PATH")
if statePath == "" {
statePath = "/state/tenants.json"
}
router, err := newTenantRouter(statePath, slots, func(slot int) string {
return fmt.Sprintf("http://hermes-chat-tenant-%d.hermes-chat-tenant.hermes.svc.cluster.local:8787", slot)
})
if err != nil {
log.Fatal(err)
}
router.backendAPIURL = func(slot int) string {
return fmt.Sprintf("http://hermes-chat-tenant-%d.hermes-chat-tenant.hermes.svc.cluster.local:8642", slot)
}
router.backendMediaURL = func(slot int) string {
return fmt.Sprintf("http://hermes-chat-tenant-%d.hermes-chat-tenant.hermes.svc.cluster.local:8788", slot)
}
telegramConfig, err := readTelegramConfig(os.Getenv("TELEGRAM_CONFIG_PATH"))
if err != nil {
log.Printf("Telegram is disabled: configuration is unavailable")
} else if telegramConfig.BotToken != "" && telegramConfig.RelayKey != "" {
router.telegram = newTelegramBot(telegramConfig, router)
go router.telegram.run()
log.Printf("Telegram transport enabled")
} else {
log.Printf("Telegram is prepared but disabled until bot_token is set in Vault")
}
server := &http.Server{
Addr: ":8080",
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("Hermes chat tenant router ready with %d isolated slots", slots)
log.Fatal(server.ListenAndServe())
}