Files
proxui/backend/internal/console/handler.go
T

209 lines
5.2 KiB
Go

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})
}