feat: complete MVP epics E7-E10 (provisioning, console proxy, audit, frontend)
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user