feat: complete MVP epics E7-E10 (provisioning, console proxy, audit, frontend)

This commit is contained in:
Philipp
2026-06-12 10:45:13 +02:00
parent 137c13fa98
commit ac30a19960
33 changed files with 3625 additions and 263 deletions
+143 -4
View File
@@ -2,32 +2,51 @@ package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"forgejo.digital-droplets.de/philschlo/proxui/platform/config"
"forgejo.digital-droplets.de/philschlo/proxui/platform/logging"
"github.com/coder/websocket"
)
func main() {
cfg, err := config.Load()
if err != nil {
logger := logging.New("console-proxy", "unknown", 0)
logger.Error("failed to load config", "error", err)
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
logger := logging.New("console-proxy", cfg.AppEnv, cfg.LogLevel)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
logger = logger.With("service", "console-proxy", "env", cfg.AppEnv)
signingKey := []byte(cfg.OperatorToken)
if len(signingKey) == 0 {
logger.Error("OPERATOR_TOKEN is required")
os.Exit(1)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","service":"console-proxy"}`))
})
mux.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) {
handleWebSocket(w, r, signingKey, logger)
})
server := &http.Server{
Addr: cfg.ConsoleProxyAddr,
@@ -55,3 +74,123 @@ func main() {
os.Exit(1)
}
}
func handleWebSocket(w http.ResponseWriter, r *http.Request, signingKey []byte, logger *slog.Logger) {
ticket := r.URL.Query().Get("ticket")
if ticket == "" {
http.Error(w, "missing ticket", http.StatusBadRequest)
return
}
payload, err := verifyProxyTicket(ticket, signingKey)
if err != nil {
logger.Error("invalid ticket", "error", err)
http.Error(w, "invalid ticket", http.StatusForbidden)
return
}
endpoint := strings.TrimRight(payload.Endpoint, "/")
proxmoxWSURL := strings.Replace(endpoint, "https://", "wss://", 1)
vncURL := fmt.Sprintf(
"%s/nodes/%s/qemu/%d/vncwebsocket?port=%d&vncticket=%s",
proxmoxWSURL,
url.PathEscape(payload.Node),
payload.VMID,
payload.VNC.Port,
url.QueryEscape(payload.VNC.Ticket),
)
proxmoxConn, _, err := websocket.Dial(r.Context(), vncURL, &websocket.DialOptions{
HTTPClient: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
},
})
if err != nil {
logger.Error("proxmox websocket dial failed", "error", err)
http.Error(w, "proxy connection failed", http.StatusBadGateway)
return
}
defer proxmoxConn.Close(websocket.StatusNormalClosure, "")
clientConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{})
if err != nil {
logger.Error("client websocket accept failed", "error", err)
return
}
defer clientConn.Close(websocket.StatusNormalClosure, "")
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go proxyWebSocket(ctx, clientConn, proxmoxConn, logger)
proxyWebSocket(ctx, proxmoxConn, clientConn, logger)
}
func proxyWebSocket(ctx context.Context, dst *websocket.Conn, src *websocket.Conn, logger *slog.Logger) {
for {
_, msg, err := src.Read(ctx)
if err != nil {
return
}
if err := dst.Write(ctx, websocket.MessageBinary, msg); err != nil {
logger.Error("websocket write failed", "error", err)
return
}
}
}
type proxyTicketPayload struct {
ClusterID string `json:"cluster_id"`
Node string `json:"node"`
VMID int `json:"vmid"`
TenantID string `json:"tenant_id"`
Endpoint string `json:"endpoint"`
VNC vncTicket `json:"vnc"`
ExpiresAt time.Time `json:"expires_at"`
Signature string `json:"signature"`
}
type vncTicket struct {
Port int `json:"port"`
Ticket string `json:"ticket"`
User string `json:"user"`
Cert string `json:"cert"`
UPID string `json:"upid"`
}
func verifyProxyTicket(ticket string, signingKey []byte) (proxyTicketPayload, error) {
data, err := base64.RawURLEncoding.DecodeString(ticket)
if err != nil {
return proxyTicketPayload{}, fmt.Errorf("invalid ticket encoding")
}
sigData := make([]byte, len(data))
copy(sigData, data)
var payload proxyTicketPayload
if err := json.Unmarshal(data, &payload); err != nil {
return proxyTicketPayload{}, fmt.Errorf("invalid ticket payload")
}
if time.Now().After(payload.ExpiresAt) {
return proxyTicketPayload{}, fmt.Errorf("ticket expired")
}
receivedSig := payload.Signature
payload.Signature = ""
dataToVerify, _ := json.Marshal(payload)
mac := hmac.New(sha256.New, signingKey)
mac.Write(dataToVerify)
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(receivedSig), []byte(expectedSig)) {
return proxyTicketPayload{}, fmt.Errorf("invalid ticket signature")
}
return payload, nil
}