package console import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "time" "forgejo.digital-droplets.de/philschlo/proxui/platform/cluster" "forgejo.digital-droplets.de/philschlo/proxui/platform/proxmox" "proxui/backend/internal/auth" "proxui/backend/internal/rbac" ) type ClusterRepository interface { GetCluster(ctx context.Context, id string) (cluster.Cluster, bool, error) } type VMRepository interface { GetVM(ctx context.Context, profileID string, vmID string) (VMInfo, bool, error) } type VMInfo struct { ID string TenantID string ClusterID string ProxmoxVMID int Node string MembershipRole string } type ProxyClientFactory func(cluster.Cluster) (ProxyClient, error) type ProxyClient interface { GetVNCTicket(ctx context.Context, node string, vmid int) (proxmox.VNCInfo, error) } type Handler struct { clusters ClusterRepository vmRepo VMRepository clientFactory ProxyClientFactory signingKey []byte } func NewHandler(clusters ClusterRepository, vmRepo VMRepository, clientFactory ProxyClientFactory, signingKey string) Handler { return Handler{ clusters: clusters, vmRepo: vmRepo, clientFactory: clientFactory, signingKey: []byte(signingKey), } } func (h Handler) CreateConsoleTicket(w http.ResponseWriter, r *http.Request) { principal, ok := auth.PrincipalFromRequest(r) if !ok { writeError(w, http.StatusUnauthorized, "unauthorized") return } vmID := r.PathValue("vmID") if vmID == "" { writeError(w, http.StatusBadRequest, "vm_id_required") return } vm, found, err := h.vmRepo.GetVM(r.Context(), principal.Subject, vmID) if err != nil { writeError(w, http.StatusInternalServerError, "vm_get_failed") return } if !found { writeError(w, http.StatusNotFound, "vm_not_found") return } if !rbac.Can(rbac.Role(vm.MembershipRole), rbac.ActionVMConsole) { writeError(w, http.StatusForbidden, "forbidden") return } cluster, found, err := h.clusters.GetCluster(r.Context(), vm.ClusterID) if err != nil { writeError(w, http.StatusInternalServerError, "cluster_get_failed") return } if !found { writeError(w, http.StatusBadGateway, "cluster_not_found") return } client, err := h.clientFactory(cluster) if err != nil { writeError(w, http.StatusBadGateway, "proxmox_client_failed") return } vncInfo, err := client.GetVNCTicket(r.Context(), vm.Node, vm.ProxmoxVMID) if err != nil { writeError(w, http.StatusBadGateway, "proxmox_vnc_ticket_failed") return } proxyTicket, err := h.signProxyTicket(proxyTicketPayload{ ClusterID: vm.ClusterID, Node: vm.Node, VMID: vm.ProxmoxVMID, TenantID: vm.TenantID, Endpoint: cluster.APIEndpoint, VNC: vncTicket{VNCInfo: vncInfo}, ExpiresAt: time.Now().Add(15 * time.Minute), }) if err != nil { writeError(w, http.StatusInternalServerError, "ticket_sign_failed") return } writeJSON(w, http.StatusOK, consoleResponse{ Ticket: proxyTicket, }) } type vncTicket struct { proxmox.VNCInfo } 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"` } func (h Handler) signProxyTicket(payload proxyTicketPayload) (string, error) { payload.Signature = "" data, err := json.Marshal(payload) if err != nil { return "", err } mac := hmac.New(sha256.New, h.signingKey) mac.Write(data) payload.Signature = base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) result, err := json.Marshal(payload) if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(result), nil } func VerifyProxyTicket(ticket string, signingKey []byte) (proxyTicketPayload, error) { data, err := base64.RawURLEncoding.DecodeString(ticket) if err != nil { return proxyTicketPayload{}, fmt.Errorf("invalid ticket encoding") } 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") } payload.Signature = receivedSig return payload, nil } type consoleResponse struct { Ticket string `json:"ticket"` } func DefaultProxyClientFactory(cluster cluster.Cluster) (ProxyClient, error) { return proxmox.NewClient(cluster) } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) }