diff --git a/TODO.md b/TODO.md index d1d4fd8..151a5b0 100644 --- a/TODO.md +++ b/TODO.md @@ -157,19 +157,71 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] Bekannte VMs je Cluster werden gegen Proxmox `status/current` geprueft - [x] Drift wird als Audit-Event `vm.reconcile.drift` geloggt - [x] Kein Auto-Fix: DB-Status wird im Reconciliation-Lauf nicht ueberschrieben +- [x] E7-T01: SSH-Key-Verwaltung + - [x] `GET /tenants/{tenantID}/ssh-keys` (Liste mit RBAC `ssh_key.read`) + - [x] `POST /tenants/{tenantID}/ssh-keys` (Erstellen mit RBAC `ssh_key.manage`) + - [x] `GET /ssh-keys/{keyID}` (Detail mit Membership-Check und Tenant-Abgleich) + - [x] `DELETE /ssh-keys/{keyID}` (Löschen mit RBAC `ssh_key.manage`) + - [x] Repository, Handler und Tests angelegt + - [x] Routen in `main.go` verdrahtet und Build/Tests verifiziert +- [x] E7-T02: Template-Verwaltung + - [x] Migration `0010_templates` mit `templates`-Tabelle (id, cluster_id, name, description, proxmox_template_vmid, proxmox_node, timestamps) + - [x] `GET /internal/templates` (Liste aller Templates) + - [x] `POST /internal/templates` (Template anlegen inkl. On-Conflict-Upsert) + - [x] `PUT /internal/templates/{templateID}` (Template aktualisieren mit partiellen Feldern) + - [x] `DELETE /internal/templates/{templateID}` (Template loeschen) + - [x] Alle Endpunkte mit Operator-Token und `cluster.manage` Permission gesichert + - [x] Repository, Handler und Tests angelegt + - [x] Build und alle Tests gruen +- [x] E7-T03: VM-Erstellung (Clone + cloud-init) + - [x] Proxmox-Client erweitert: `CloneVM`, `ConfigureCloudInit`, `StartVM`, `CloudInitConfig` + - [x] Repository erweitert: `GetProjectInfo` (mit Membership-Rolle), `CheckQuota`, `ReserveNextVMID`, `InsertVM` + - [x] `POST /projects/{projectID}/vms` Handler mit: Auth, Project-Info (mit Membership/RBAC), Template-Lookup, Quota-Check, VMID-Reservierung, DB-Insert, Clone, CloudInit, Start, Task-Enqueue, Audit-Write + - [x] `SSHKeyResolver` Interface und Adapter im sshkey-Package + - [x] `ProvisionResolver` Adapter im template-Package + - [x] `ProvisionDependencies` mit ClusterRepo, TemplateRepo, ClientFactory, Tasks, Audit, SSHKeyLookup + - [x] Route in `main.go` verdrahtet + - [x] Build und alle Tests gruen +- [x] E7-T04: VM löschen + - [x] Proxmox-Client: `StopVM`, `DeleteVM` + - [x] `DELETE /vms/{vmID}` Handler: Auth/Membership per GetVM, RBAC `vm.delete`, Stop+Delete via Proxmox, Task-Enqueue, Audit + - [x] Route in `main.go` verdrahtet + - [x] Build und alle Tests gruen +- [x] E8-T01: Konsolen-Ticket-Endpunkt + - [x] `POST /vms/{vmID}/console` mit RBAC `vm.console` + - [x] HMAC-signiertes Proxy-Ticket mit Cluster, Node, VMID, VNC-Info, Endpoint, Expiry + - [x] `console` Package mit Handler, Ticket-Signer und Verifikations-Funktion +- [x] E8-T02: Websocket-Proxy-Dienst + - [x] `console-proxy` mit `GET /ws?ticket=...` + - [x] Ticket-Validierung, WebSocket-Dial zu Proxmox VNC + - [x] Bidirektionales Proxy zwischen Client und Proxmox + - [x] Build und alle Tests gruen +- [x] E9-T02: Audit-Anzeige (read) + - [x] `GET /tenants/{tenantID}/audit` mit Paginierung, Membership + RBAC `audit.read` + - [x] Nur owner/admin haben Zugriff (viewer/member → 403) + - [x] Repository, Handler und Tests angelegt + - [x] Route in `main.go` verdrahtet und alle Tests gruen +- [x] E10: Frontend-MVP + - [x] Mehrere Dateien: `api.ts` (API-Client), Komponenten (`AuthScreen`, `VMList`, `VMCreate`, `SSHKeys`, `AuditLog`, `ConsoleView`) + - [x] VM-Übersicht mit Power-Buttons (Start/Stop/Reboot) und Löschen + - [x] VM-Erstellungs-Wizard mit Template, Ressourcen, SSH-Key + - [x] SSH-Key-Verwaltung (Anzeigen, Hinzufügen, Löschen) + - [x] Audit-Log-Ansicht + - [x] Konsole-Ansicht mit Proxy-Ticket-Abruf + - [x] Build (`npm run build`) erfolgreich ## MVP-Backlog -- [ ] E1: Supabase Auth, JWT-Middleware und Profil-Sync -- [ ] E2: Datenmodell, Migrationen, RLS und VMID-Allokator +- [x] E1: Supabase Auth, JWT-Middleware und Profil-Sync +- [x] E2: Datenmodell, Migrationen, RLS und VMID-Allokator - [x] E3: Envelope-Encryption, Cluster-Repository, Proxmox-Client und interne Cluster-Verwaltung -- [ ] E4: RBAC, Membership-Resolver, Policy-Funktion und Autorisierungs-Middleware -- [ ] E6: Worker-Grundgeruest und UPID-Polling +- [x] E4: RBAC, Membership-Resolver, Policy-Funktion und Autorisierungs-Middleware +- [x] E6: Worker-Grundgeruest und UPID-Polling - [x] E5: VM-Liste, Detail, Power-Aktionen und Reconciliation -- [ ] E7: SSH-Keys, Templates und Provisioning -- [ ] E8: Konsolen-Tickets und Websocket-Proxy -- [ ] E9: Audit-Writer und Audit-Anzeige -- [ ] E10: Frontend-MVP fuer Auth, Projekte, VMs, Wizard und Konsole +- [x] E7: SSH-Keys, Templates und Provisioning +- [x] E8: Konsolen-Tickets und Websocket-Proxy +- [x] E9: Audit-Writer und Audit-Anzeige +- [x] E10: Frontend-MVP fuer Auth, Projekte, VMs, Wizard und Konsole ## Annahmen diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 24e75f7..06aff12 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -19,14 +19,19 @@ import ( "github.com/hibiken/asynq" _ "github.com/jackc/pgx/v5/stdlib" + "proxui/backend/internal/audit" "proxui/backend/internal/auth" "proxui/backend/internal/authorization" "proxui/backend/internal/clusteradmin" + "proxui/backend/internal/console" + "proxui/backend/internal/consoleadapter" "proxui/backend/internal/membership" "proxui/backend/internal/operator" "proxui/backend/internal/profile" "proxui/backend/internal/queue" "proxui/backend/internal/rbac" + "proxui/backend/internal/sshkey" + "proxui/backend/internal/template" "proxui/backend/internal/vm" ) @@ -73,15 +78,36 @@ func main() { clusterRepository := cluster.NewRepository(db, tokenCipher) clusterAdminHandler := clusteradmin.NewHandler(clusterRepository) operatorMiddleware := operator.NewMiddleware(cfg.OperatorToken) + sshKeyHandler := sshkey.NewHandler(sshkey.NewSQLRepository(db)) + sshKeyResolver := sshkey.NewPublicKeyResolver(sshkey.NewSQLRepository(db)) + templateHandler := template.NewHandler(template.NewSQLRepository(db)) + templateResolver := template.NewProvisionResolver(template.NewSQLRepository(db)) + + vmRepo := vm.NewSQLRepository(db) vmHandler := vm.NewHandler( - vm.NewSQLRepository(db), + vmRepo, vm.WithPower(vm.PowerDependencies{ Clusters: clusterRepository, ClientFactory: vm.DefaultPowerClientFactory, Tasks: taskEnqueuer, Audit: vm.NewSQLPowerAuditWriter(db), }), + vm.WithProvision(vm.ProvisionDependencies{ + ClusterRepo: clusterRepository, + TemplateRepo: templateResolver, + ClientFactory: vm.DefaultProvisionClientFactory, + Tasks: taskEnqueuer, + Audit: vm.NewSQLProvisionAuditWriter(db), + SSHKeyLookup: sshKeyResolver, + }), ) + consoleHandler := console.NewHandler( + clusterRepository, + consoleadapter.NewVMRepository(vmRepo), + console.DefaultProxyClientFactory, + cfg.OperatorToken, + ) + auditHandler := audit.NewHandler(audit.NewSQLRepository(db)) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { @@ -121,6 +147,72 @@ func main() { mux.Handle("POST /vms/{vmID}/start", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.StartVM)))) mux.Handle("POST /vms/{vmID}/stop", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.StopVM)))) mux.Handle("POST /vms/{vmID}/reboot", authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.RebootVM)))) + mux.Handle( + "POST /projects/{projectID}/vms", + authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.CreateVM))), + ) + mux.Handle( + "DELETE /vms/{vmID}", + authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(vmHandler.DeleteVM))), + ) + mux.Handle( + "POST /vms/{vmID}/console", + authMiddleware.RequireAuth(profileMiddleware.EnsureProfile(http.HandlerFunc(consoleHandler.CreateConsoleTicket))), + ) + mux.Handle( + "GET /tenants/{tenantID}/ssh-keys", + authMiddleware.RequireAuth( + profileMiddleware.EnsureProfile( + membershipMiddleware.RequireTenantMembership( + pathTenantID, + authorizationMiddleware.Require(rbac.ActionSSHKeyRead, http.HandlerFunc(sshKeyHandler.ListTenantKeys)), + ), + ), + ), + ) + mux.Handle( + "POST /tenants/{tenantID}/ssh-keys", + authMiddleware.RequireAuth( + profileMiddleware.EnsureProfile( + membershipMiddleware.RequireTenantMembership( + pathTenantID, + authorizationMiddleware.Require(rbac.ActionSSHKeyManage, http.HandlerFunc(sshKeyHandler.CreateKey)), + ), + ), + ), + ) + mux.Handle( + "GET /ssh-keys/{keyID}", + authMiddleware.RequireAuth( + profileMiddleware.EnsureProfile(http.HandlerFunc(sshKeyHandler.GetKey)), + ), + ) + mux.Handle( + "DELETE /ssh-keys/{keyID}", + authMiddleware.RequireAuth( + profileMiddleware.EnsureProfile( + authorizationMiddleware.Require(rbac.ActionSSHKeyManage, http.HandlerFunc(sshKeyHandler.DeleteKey)), + ), + ), + ) + auditRoute := func(handler http.HandlerFunc) http.Handler { + return authMiddleware.RequireAuth( + profileMiddleware.EnsureProfile( + membershipMiddleware.RequireTenantMembership( + pathTenantID, + authorizationMiddleware.Require(rbac.ActionAuditRead, handler), + ), + ), + ) + } + mux.Handle("GET /tenants/{tenantID}/audit", auditRoute(auditHandler.ListTenantAudit)) + templateManageChain := func(handler http.HandlerFunc) http.Handler { + return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler)) + } + mux.Handle("GET /internal/templates", templateManageChain(templateHandler.ListTemplates)) + mux.Handle("POST /internal/templates", templateManageChain(templateHandler.CreateTemplate)) + mux.Handle("PUT /internal/templates/{templateID}", templateManageChain(templateHandler.UpdateTemplate)) + mux.Handle("DELETE /internal/templates/{templateID}", templateManageChain(templateHandler.DeleteTemplate)) clusterManageChain := func(handler http.HandlerFunc) http.Handler { return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler)) } diff --git a/backend/internal/audit/handler.go b/backend/internal/audit/handler.go new file mode 100644 index 0000000..4d130c4 --- /dev/null +++ b/backend/internal/audit/handler.go @@ -0,0 +1,144 @@ +package audit + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "time" + + "proxui/backend/internal/auth" + "proxui/backend/internal/membership" + "proxui/backend/internal/rbac" +) + +type Repository interface { + ListTenantAudit(ctx context.Context, tenantID string, limit int, offset int) ([]Entry, error) +} + +type SQLRepository struct { + db *sql.DB +} + +func NewSQLRepository(db *sql.DB) SQLRepository { + return SQLRepository{db: db} +} + +func (r SQLRepository) ListTenantAudit(ctx context.Context, tenantID string, limit int, offset int) ([]Entry, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + + rows, err := r.db.QueryContext(ctx, ` + select + id::text, + tenant_id::text, + coalesce(profile_id::text, ''), + action, + target_type, + coalesce(target_id::text, ''), + metadata, + created_at + from public.audit_log + where tenant_id = $1 + order by created_at desc + limit $2 offset $3 + `, tenantID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []Entry + for rows.Next() { + var e Entry + var metaBytes []byte + if err := rows.Scan(&e.ID, &e.TenantID, &e.ProfileID, &e.Action, &e.TargetType, &e.TargetID, &metaBytes, &e.CreatedAt); err != nil { + return nil, err + } + if metaBytes != nil { + e.Metadata = metaBytes + } else { + e.Metadata = json.RawMessage("{}") + } + entries = append(entries, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return entries, nil +} + +type Entry struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ProfileID string `json:"profile_id"` + Action string `json:"action"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + Metadata json.RawMessage `json:"metadata"` + CreatedAt time.Time `json:"created_at"` +} + +type Handler struct { + repository Repository +} + +func NewHandler(repository Repository) Handler { + return Handler{repository: repository} +} + +func (h Handler) ListTenantAudit(w http.ResponseWriter, r *http.Request) { + _, ok := auth.PrincipalFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + tenantID := r.PathValue("tenantID") + if tenantID == "" { + writeError(w, http.StatusBadRequest, "tenant_id_required") + return + } + + membershipID, ok := membership.FromRequest(r) + if !ok { + writeError(w, http.StatusForbidden, "forbidden") + return + } + if membershipID.TenantID != tenantID { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionAuditRead) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + limit := 50 + offset := 0 + + entries, err := h.repository.ListTenantAudit(r.Context(), tenantID, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, "audit_list_failed") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "data": entries, + "limit": limit, + "offset": offset, + }) +} + +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}) +} \ No newline at end of file diff --git a/backend/internal/audit/handler_test.go b/backend/internal/audit/handler_test.go new file mode 100644 index 0000000..454ec5d --- /dev/null +++ b/backend/internal/audit/handler_test.go @@ -0,0 +1,87 @@ +package audit + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "proxui/backend/internal/auth" + "proxui/backend/internal/membership" + "proxui/backend/internal/rbac" +) + +func TestListTenantAuditReturnsEntries(t *testing.T) { + repository := &stubRepository{ + entries: []Entry{{ + ID: "audit-1", + TenantID: "tenant-1", + ProfileID: "profile-1", + Action: "vm.power.start", + TargetType: "vm", + TargetID: "vm-1", + Metadata: json.RawMessage(`{"cluster_id":"c-1"}`), + CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC), + }}, + } + handler := NewHandler(repository) + req := requestWithPrincipalAndMembership(http.MethodGet, "/tenants/tenant-1/audit", "tenant-1", "owner") + req.SetPathValue("tenantID", "tenant-1") + rec := httptest.NewRecorder() + + handler.ListTenantAudit(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var response struct { + Data []Entry `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(response.Data) != 1 { + t.Fatalf("len(data) = %d, want 1", len(response.Data)) + } + if response.Data[0].ID != "audit-1" { + t.Fatalf("entry ID = %q, want audit-1", response.Data[0].ID) + } +} + +func TestListTenantAuditReturnsForbiddenForViewer(t *testing.T) { + handler := NewHandler(&stubRepository{}) + req := requestWithPrincipalAndMembership(http.MethodGet, "/tenants/tenant-1/audit", "tenant-1", "viewer") + req.SetPathValue("tenantID", "tenant-1") + rec := httptest.NewRecorder() + + handler.ListTenantAudit(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func requestWithPrincipalAndMembership(method string, target string, tenantID string, role string) *http.Request { + req := httptest.NewRequest(method, target, nil) + ctx := auth.ContextWithPrincipal(req.Context(), auth.Principal{ + Subject: "profile-1", + Email: "user@example.test", + Role: "authenticated", + }) + ctx = membership.ContextWithMembership(ctx, membership.Membership{ + TenantID: tenantID, + Role: rbac.Role(role), + }) + return req.WithContext(ctx) +} + +type stubRepository struct { + entries []Entry + err error +} + +func (s *stubRepository) ListTenantAudit(_ context.Context, _ string, _ int, _ int) ([]Entry, error) { + return s.entries, s.err +} \ No newline at end of file diff --git a/backend/internal/console/handler.go b/backend/internal/console/handler.go new file mode 100644 index 0000000..9f39380 --- /dev/null +++ b/backend/internal/console/handler.go @@ -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}) +} \ No newline at end of file diff --git a/backend/internal/consoleadapter/vm_repository.go b/backend/internal/consoleadapter/vm_repository.go new file mode 100644 index 0000000..8fe8756 --- /dev/null +++ b/backend/internal/consoleadapter/vm_repository.go @@ -0,0 +1,34 @@ +package consoleadapter + +import ( + "context" + + "proxui/backend/internal/console" + "proxui/backend/internal/vm" +) + +type VMRepository struct { + repo vm.SQLRepository +} + +func NewVMRepository(repo vm.SQLRepository) VMRepository { + return VMRepository{repo: repo} +} + +func (r VMRepository) GetVM(ctx context.Context, profileID string, vmID string) (console.VMInfo, bool, error) { + result, found, err := r.repo.GetVM(ctx, profileID, vmID) + if err != nil { + return console.VMInfo{}, false, err + } + if !found { + return console.VMInfo{}, false, nil + } + return console.VMInfo{ + ID: result.ID, + TenantID: result.TenantID, + ClusterID: result.ClusterID, + ProxmoxVMID: result.ProxmoxVMID, + Node: result.Node, + MembershipRole: result.MembershipRole, + }, true, nil +} \ No newline at end of file diff --git a/backend/internal/sshkey/handler.go b/backend/internal/sshkey/handler.go new file mode 100644 index 0000000..b6ae923 --- /dev/null +++ b/backend/internal/sshkey/handler.go @@ -0,0 +1,205 @@ +package sshkey + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "proxui/backend/internal/auth" + "proxui/backend/internal/membership" + "proxui/backend/internal/rbac" +) + +type Repository interface { + List(ctx context.Context, profileID string, tenantID string) ([]SSHKey, bool, error) + Get(ctx context.Context, profileID string, keyID string) (SSHKey, bool, error) + Create(ctx context.Context, profileID string, tenantID string, name string, publicKey string) (SSHKey, bool, error) + Delete(ctx context.Context, profileID string, keyID string) (bool, bool, error) +} + +type Handler struct { + repository Repository +} + +func NewHandler(repository Repository) Handler { + return Handler{repository: repository} +} + +func (h Handler) ListTenantKeys(w http.ResponseWriter, r *http.Request) { + principal, ok := auth.PrincipalFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + tenantID := r.PathValue("tenantID") + if tenantID == "" { + writeError(w, http.StatusBadRequest, "tenant_id_required") + return + } + + keys, found, err := h.repository.List(r.Context(), principal.Subject, tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, "ssh_keys_list_failed") + return + } + if !found { + writeError(w, http.StatusNotFound, "tenant_not_found") + return + } + + writeJSON(w, http.StatusOK, map[string][]SSHKey{"data": keys}) +} + +func (h Handler) GetKey(w http.ResponseWriter, r *http.Request) { + principal, ok := auth.PrincipalFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + keyID := r.PathValue("keyID") + if keyID == "" { + writeError(w, http.StatusBadRequest, "key_id_required") + return + } + + key, found, err := h.repository.Get(r.Context(), principal.Subject, keyID) + if err != nil { + writeError(w, http.StatusInternalServerError, "ssh_key_get_failed") + return + } + if !found { + writeError(w, http.StatusNotFound, "ssh_key_not_found") + return + } + + membershipID, _ := membership.FromRequest(r) + if membershipID.TenantID != key.TenantID { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionSSHKeyRead) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + writeJSON(w, http.StatusOK, key) +} + +func (h Handler) CreateKey(w http.ResponseWriter, r *http.Request) { + principal, ok := auth.PrincipalFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + tenantID := r.PathValue("tenantID") + if tenantID == "" { + writeError(w, http.StatusBadRequest, "tenant_id_required") + return + } + + membershipID, _ := membership.FromRequest(r) + if membershipID.TenantID != tenantID { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionSSHKeyManage) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + var req createRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name_required") + return + } + + req.PublicKey = strings.TrimSpace(req.PublicKey) + if req.PublicKey == "" { + writeError(w, http.StatusBadRequest, "public_key_required") + return + } + + key, found, err := h.repository.Create(r.Context(), principal.Subject, tenantID, req.Name, req.PublicKey) + if err != nil { + writeError(w, http.StatusInternalServerError, "ssh_key_create_failed") + return + } + if !found { + writeError(w, http.StatusNotFound, "tenant_not_found") + return + } + + writeJSON(w, http.StatusCreated, key) +} + +func (h Handler) DeleteKey(w http.ResponseWriter, r *http.Request) { + principal, ok := auth.PrincipalFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + keyID := r.PathValue("keyID") + if keyID == "" { + writeError(w, http.StatusBadRequest, "key_id_required") + return + } + + deleted, member, err := h.repository.Delete(r.Context(), principal.Subject, keyID) + if err != nil { + writeError(w, http.StatusInternalServerError, "ssh_key_delete_failed") + return + } + if !member { + writeError(w, http.StatusNotFound, "ssh_key_not_found") + return + } + if !deleted { + writeError(w, http.StatusNotFound, "ssh_key_not_found") + return + } + + membershipID, _ := membership.FromRequest(r) + if !rbac.Can(rbac.Role(membershipID.Role), rbac.ActionSSHKeyManage) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +type SSHKey struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + PublicKey string `json:"public_key"` + CreatedAt time.Time `json:"created_at"` +} + +type createRequest struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` +} + +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}) +} \ No newline at end of file diff --git a/backend/internal/sshkey/handler_test.go b/backend/internal/sshkey/handler_test.go new file mode 100644 index 0000000..e66195b --- /dev/null +++ b/backend/internal/sshkey/handler_test.go @@ -0,0 +1,310 @@ +package sshkey + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "proxui/backend/internal/auth" + "proxui/backend/internal/membership" + "proxui/backend/internal/rbac" +) + +func TestListTenantKeysReturnsKeys(t *testing.T) { + repository := &stubRepository{ + listFound: true, + keys: []SSHKey{{ + ID: "key-1", + TenantID: "tenant-1", + Name: "laptop", + PublicKey: "ssh-ed25519 AAAAC3...", + CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC), + }}, + } + handler := NewHandler(repository) + req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys") + req.SetPathValue("tenantID", "tenant-1") + rec := httptest.NewRecorder() + + handler.ListTenantKeys(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if repository.profileID != "profile-1" { + t.Fatalf("profileID = %q, want profile-1", repository.profileID) + } + if repository.tenantID != "tenant-1" { + t.Fatalf("tenantID = %q, want tenant-1", repository.tenantID) + } + + var response struct { + Data []SSHKey `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(response.Data) != 1 { + t.Fatalf("len(data) = %d, want 1", len(response.Data)) + } + if response.Data[0].ID != "key-1" { + t.Fatalf("key ID = %q, want key-1", response.Data[0].ID) + } +} + +func TestListTenantKeysReturnsEmptyListForTenantWithNoKeys(t *testing.T) { + handler := NewHandler(&stubRepository{listFound: true, keys: []SSHKey{}}) + req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys") + req.SetPathValue("tenantID", "tenant-1") + rec := httptest.NewRecorder() + + handler.ListTenantKeys(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var response struct { + Data []SSHKey `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if response.Data == nil { + t.Fatal("data should be an empty slice, not nil") + } +} + +func TestListTenantKeysReturnsNotFound(t *testing.T) { + handler := NewHandler(&stubRepository{listFound: false}) + req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys") + req.SetPathValue("tenantID", "tenant-1") + rec := httptest.NewRecorder() + + handler.ListTenantKeys(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestGetKeyReturnsKey(t *testing.T) { + repository := &stubRepository{ + getFound: true, + key: SSHKey{ + ID: "key-1", + TenantID: "tenant-1", + Name: "laptop", + PublicKey: "ssh-ed25519 AAAAC3...", + }, + } + handler := NewHandler(repository) + req := requestWithPrincipalAndMembership(http.MethodGet, "/ssh-keys/key-1", "tenant-1", "owner") + req.SetPathValue("keyID", "key-1") + rec := httptest.NewRecorder() + + handler.GetKey(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if repository.keyID != "key-1" { + t.Fatalf("keyID = %q, want key-1", repository.keyID) + } + var response SSHKey + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if response.ID != "key-1" { + t.Fatalf("key ID = %q, want key-1", response.ID) + } +} + +func TestGetKeyReturnsNotFound(t *testing.T) { + handler := NewHandler(&stubRepository{getFound: false}) + req := requestWithPrincipalAndMembership(http.MethodGet, "/ssh-keys/key-1", "tenant-1", "owner") + req.SetPathValue("keyID", "key-1") + rec := httptest.NewRecorder() + + handler.GetKey(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestCreateKeyReturnsCreated(t *testing.T) { + repository := &stubRepository{ + createFound: true, + createResult: SSHKey{ + ID: "key-1", + TenantID: "tenant-1", + Name: "laptop", + PublicKey: "ssh-ed25519 AAAAC3...", + }, + } + handler := NewHandler(repository) + body := `{"name":"laptop","public_key":"ssh-ed25519 AAAAC3..."}` + req := requestWithPrincipalAndMembership(http.MethodPost, "/tenants/tenant-1/ssh-keys", "tenant-1", "owner") + req.SetPathValue("tenantID", "tenant-1") + req.Body = io.NopCloser(strings.NewReader(body)) + rec := httptest.NewRecorder() + + handler.CreateKey(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated) + } + var response SSHKey + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if response.ID != "key-1" { + t.Fatalf("key ID = %q, want key-1", response.ID) + } +} + +func TestCreateKeyReturnsForbiddenForViewer(t *testing.T) { + handler := NewHandler(&stubRepository{}) + body := `{"name":"laptop","public_key":"ssh-ed25519 AAAAC3..."}` + req := requestWithPrincipalAndMembership(http.MethodPost, "/tenants/tenant-1/ssh-keys", "tenant-1", "viewer") + req.SetPathValue("tenantID", "tenant-1") + req.Body = io.NopCloser(strings.NewReader(body)) + rec := httptest.NewRecorder() + + handler.CreateKey(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestDeleteKeyReturnsNoContent(t *testing.T) { + repository := &stubRepository{ + deleteResult: true, + deleteMember: true, + } + handler := NewHandler(repository) + req := requestWithPrincipalAndMembership(http.MethodDelete, "/ssh-keys/key-1", "tenant-1", "owner") + req.SetPathValue("keyID", "key-1") + rec := httptest.NewRecorder() + + handler.DeleteKey(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + if repository.deleteKeyID != "key-1" { + t.Fatalf("keyID = %q, want key-1", repository.deleteKeyID) + } +} + +func TestDeleteKeyReturnsNotFound(t *testing.T) { + repo := &stubRepository{deleteResult: false, deleteMember: true} + handler := NewHandler(repo) + req := requestWithPrincipalAndMembership(http.MethodDelete, "/ssh-keys/key-1", "tenant-1", "owner") + req.SetPathValue("keyID", "key-1") + rec := httptest.NewRecorder() + + handler.DeleteKey(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestCreateKeyRejectsEmptyName(t *testing.T) { + handler := NewHandler(&stubRepository{}) + body := `{"name":" ","public_key":"ssh-ed25519 AAAAC3..."}` + req := requestWithPrincipalAndMembership(http.MethodPost, "/tenants/tenant-1/ssh-keys", "tenant-1", "owner") + req.SetPathValue("tenantID", "tenant-1") + req.Body = io.NopCloser(strings.NewReader(body)) + rec := httptest.NewRecorder() + + handler.CreateKey(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestListTenantKeysServerError(t *testing.T) { + handler := NewHandler(&stubRepository{err: errors.New("db failed")}) + req := requestWithPrincipal(http.MethodGet, "/tenants/tenant-1/ssh-keys") + req.SetPathValue("tenantID", "tenant-1") + rec := httptest.NewRecorder() + + handler.ListTenantKeys(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) + } +} + +func requestWithPrincipal(method string, target string) *http.Request { + req := httptest.NewRequest(method, target, nil) + return req.WithContext(auth.ContextWithPrincipal(req.Context(), auth.Principal{ + Subject: "profile-1", + Email: "user@example.test", + Role: "authenticated", + })) +} + +func requestWithPrincipalAndMembership(method string, target string, tenantID string, role string) *http.Request { + req := httptest.NewRequest(method, target, nil) + ctx := auth.ContextWithPrincipal(req.Context(), auth.Principal{ + Subject: "profile-1", + Email: "user@example.test", + Role: "authenticated", + }) + ctx = membership.ContextWithMembership(ctx, membership.Membership{ + TenantID: tenantID, + Role: rbac.Role(role), + }) + return req.WithContext(ctx) +} + +type stubRepository struct { + profileID string + tenantID string + keyID string + deleteKeyID string + keys []SSHKey + key SSHKey + createResult SSHKey + listFound bool + getFound bool + createFound bool + deleteResult bool + deleteMember bool + err error +} + +func (s *stubRepository) List(_ context.Context, profileID string, tenantID string) ([]SSHKey, bool, error) { + s.profileID = profileID + s.tenantID = tenantID + return s.keys, s.listFound, s.err +} + +func (s *stubRepository) Get(_ context.Context, profileID string, keyID string) (SSHKey, bool, error) { + s.profileID = profileID + s.keyID = keyID + return s.key, s.getFound, s.err +} + +func (s *stubRepository) Create(_ context.Context, profileID string, tenantID string, name string, publicKey string) (SSHKey, bool, error) { + s.profileID = profileID + s.tenantID = tenantID + return s.createResult, s.createFound, s.err +} + +func (s *stubRepository) Delete(_ context.Context, profileID string, keyID string) (bool, bool, error) { + s.profileID = profileID + s.deleteKeyID = keyID + return s.deleteResult, s.deleteMember, s.err +} \ No newline at end of file diff --git a/backend/internal/sshkey/repository.go b/backend/internal/sshkey/repository.go new file mode 100644 index 0000000..a948b17 --- /dev/null +++ b/backend/internal/sshkey/repository.go @@ -0,0 +1,172 @@ +package sshkey + +import ( + "context" + "database/sql" + "errors" +) + +type SQLRepository struct { + db *sql.DB +} + +func NewSQLRepository(db *sql.DB) SQLRepository { + return SQLRepository{db: db} +} + +func (r SQLRepository) List(ctx context.Context, profileID string, tenantID string) ([]SSHKey, bool, error) { + rows, err := r.db.QueryContext(ctx, ` + select + sk.id::text, + sk.tenant_id::text, + sk.name, + sk.public_key, + sk.created_at + from public.tenants t + join public.memberships m + on m.tenant_id = t.id + and m.profile_id = $1 + join public.ssh_keys sk + on sk.tenant_id = t.id + where t.id = $2 + order by sk.created_at desc, sk.name asc + `, profileID, tenantID) + if err != nil { + return nil, false, err + } + defer rows.Close() + + var keys []SSHKey + for rows.Next() { + var k SSHKey + if err := rows.Scan(&k.ID, &k.TenantID, &k.Name, &k.PublicKey, &k.CreatedAt); err != nil { + return nil, false, err + } + keys = append(keys, k) + } + if err := rows.Err(); err != nil { + return nil, false, err + } + + if len(keys) == 0 { + exists, err := r.tenantExists(ctx, profileID, tenantID) + if err != nil { + return nil, false, err + } + return keys, exists, nil + } + + return keys, true, nil +} + +func (r SQLRepository) Get(ctx context.Context, profileID string, keyID string) (SSHKey, bool, error) { + var k SSHKey + err := r.db.QueryRowContext(ctx, ` + select + sk.id::text, + sk.tenant_id::text, + sk.name, + sk.public_key, + sk.created_at + from public.ssh_keys sk + join public.memberships m + on m.tenant_id = sk.tenant_id + and m.profile_id = $1 + where sk.id = $2 + `, profileID, keyID).Scan(&k.ID, &k.TenantID, &k.Name, &k.PublicKey, &k.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return SSHKey{}, false, nil + } + if err != nil { + return SSHKey{}, false, err + } + + return k, true, nil +} + +func (r SQLRepository) Create(ctx context.Context, profileID string, tenantID string, name string, publicKey string) (SSHKey, bool, error) { + exists, err := r.tenantMemberExists(ctx, profileID, tenantID) + if err != nil { + return SSHKey{}, false, err + } + if !exists { + return SSHKey{}, false, nil + } + + var k SSHKey + err = r.db.QueryRowContext(ctx, ` + insert into public.ssh_keys (tenant_id, name, public_key) + values ($1, $2, $3) + returning id::text, tenant_id::text, name, public_key, created_at + `, tenantID, name, publicKey).Scan(&k.ID, &k.TenantID, &k.Name, &k.PublicKey, &k.CreatedAt) + if err != nil { + return SSHKey{}, false, err + } + + return k, true, nil +} + +func (r SQLRepository) Delete(ctx context.Context, profileID string, keyID string) (bool, bool, error) { + var tenantID string + err := r.db.QueryRowContext(ctx, ` + select sk.tenant_id::text + from public.ssh_keys sk + where sk.id = $1 + `, keyID).Scan(&tenantID) + if errors.Is(err, sql.ErrNoRows) { + return false, false, nil + } + if err != nil { + return false, false, err + } + + member, err := r.tenantMemberExists(ctx, profileID, tenantID) + if err != nil { + return false, false, err + } + if !member { + return false, true, nil + } + + res, err := r.db.ExecContext(ctx, ` + delete from public.ssh_keys + where id = $1 + `, keyID) + if err != nil { + return false, false, err + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return false, false, err + } + + return rowsAffected > 0, true, nil +} + +func (r SQLRepository) tenantExists(ctx context.Context, profileID string, tenantID string) (bool, error) { + var exists bool + err := r.db.QueryRowContext(ctx, ` + select exists( + select 1 + from public.tenants t + join public.memberships m + on m.tenant_id = t.id + and m.profile_id = $1 + where t.id = $2 + ) + `, profileID, tenantID).Scan(&exists) + return exists, err +} + +func (r SQLRepository) tenantMemberExists(ctx context.Context, profileID string, tenantID string) (bool, error) { + var exists bool + err := r.db.QueryRowContext(ctx, ` + select exists( + select 1 + from public.memberships + where profile_id = $1 + and tenant_id = $2 + ) + `, profileID, tenantID).Scan(&exists) + return exists, err +} \ No newline at end of file diff --git a/backend/internal/sshkey/resolver.go b/backend/internal/sshkey/resolver.go new file mode 100644 index 0000000..b960d11 --- /dev/null +++ b/backend/internal/sshkey/resolver.go @@ -0,0 +1,22 @@ +package sshkey + +import "context" + +type PublicKeyResolver struct { + repository Repository +} + +func NewPublicKeyResolver(repository Repository) PublicKeyResolver { + return PublicKeyResolver{repository: repository} +} + +func (r PublicKeyResolver) GetPublicKey(ctx context.Context, profileID string, keyID string) (string, bool, error) { + key, found, err := r.repository.Get(ctx, profileID, keyID) + if err != nil { + return "", false, err + } + if !found { + return "", false, nil + } + return key.PublicKey, true, nil +} \ No newline at end of file diff --git a/backend/internal/template/handler.go b/backend/internal/template/handler.go new file mode 100644 index 0000000..b1a1849 --- /dev/null +++ b/backend/internal/template/handler.go @@ -0,0 +1,189 @@ +package template + +import ( + "context" + "encoding/json" + "net/http" + "strings" +) + +type Repository interface { + List(ctx context.Context) ([]Template, error) + Get(ctx context.Context, id string) (Template, bool, error) + Upsert(ctx context.Context, t Template) (Template, error) + Delete(ctx context.Context, id string) (bool, error) +} + +type Handler struct { + repository Repository +} + +func NewHandler(repository Repository) Handler { + return Handler{repository: repository} +} + +func (h Handler) ListTemplates(w http.ResponseWriter, r *http.Request) { + templates, err := h.repository.List(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "templates_list_failed") + return + } + + writeJSON(w, http.StatusOK, map[string][]Template{"data": templates}) +} + +func (h Handler) CreateTemplate(w http.ResponseWriter, r *http.Request) { + var request createTemplateRequest + if !decodeJSON(w, r, &request) { + return + } + + request.Name = strings.TrimSpace(request.Name) + if request.Name == "" { + writeError(w, http.StatusBadRequest, "name_required") + return + } + if request.ClusterID == "" { + writeError(w, http.StatusBadRequest, "cluster_id_required") + return + } + if request.ProxmoxTemplateVMID <= 0 { + writeError(w, http.StatusBadRequest, "proxmox_template_vmid_required") + return + } + + saved, err := h.repository.Upsert(r.Context(), Template{ + ClusterID: request.ClusterID, + Name: request.Name, + Description: request.Description, + ProxmoxTemplateVMID: request.ProxmoxTemplateVMID, + ProxmoxNode: request.ProxmoxNode, + }) + if err != nil { + writeError(w, http.StatusBadRequest, "template_upsert_failed") + return + } + + writeJSON(w, http.StatusCreated, saved) +} + +func (h Handler) UpdateTemplate(w http.ResponseWriter, r *http.Request) { + templateID := r.PathValue("templateID") + if templateID == "" { + writeError(w, http.StatusBadRequest, "template_id_required") + return + } + + existing, found, err := h.repository.Get(r.Context(), templateID) + if err != nil { + writeError(w, http.StatusInternalServerError, "template_get_failed") + return + } + if !found { + writeError(w, http.StatusNotFound, "template_not_found") + return + } + + var request updateTemplateRequest + if !decodeJSON(w, r, &request) { + return + } + + if request.ClusterID == nil { + request.ClusterID = &existing.ClusterID + } + if request.Name == nil { + request.Name = &existing.Name + } else { + trimmed := strings.TrimSpace(*request.Name) + request.Name = &trimmed + if *request.Name == "" { + writeError(w, http.StatusBadRequest, "name_required") + return + } + } + if request.Description == nil { + request.Description = &existing.Description + } + if request.ProxmoxTemplateVMID == nil { + request.ProxmoxTemplateVMID = &existing.ProxmoxTemplateVMID + } else if *request.ProxmoxTemplateVMID <= 0 { + writeError(w, http.StatusBadRequest, "proxmox_template_vmid_required") + return + } + if request.ProxmoxNode == nil { + request.ProxmoxNode = &existing.ProxmoxNode + } + + saved, err := h.repository.Upsert(r.Context(), Template{ + ID: existing.ID, + ClusterID: *request.ClusterID, + Name: *request.Name, + Description: *request.Description, + ProxmoxTemplateVMID: *request.ProxmoxTemplateVMID, + ProxmoxNode: *request.ProxmoxNode, + }) + if err != nil { + writeError(w, http.StatusBadRequest, "template_upsert_failed") + return + } + + writeJSON(w, http.StatusOK, saved) +} + +func (h Handler) DeleteTemplate(w http.ResponseWriter, r *http.Request) { + templateID := r.PathValue("templateID") + if templateID == "" { + writeError(w, http.StatusBadRequest, "template_id_required") + return + } + + deleted, err := h.repository.Delete(r.Context(), templateID) + if err != nil { + writeError(w, http.StatusInternalServerError, "template_delete_failed") + return + } + if !deleted { + writeError(w, http.StatusNotFound, "template_not_found") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +type createTemplateRequest struct { + ClusterID string `json:"cluster_id"` + Name string `json:"name"` + Description string `json:"description"` + ProxmoxTemplateVMID int `json:"proxmox_template_vmid"` + ProxmoxNode string `json:"proxmox_node"` +} + +type updateTemplateRequest struct { + ClusterID *string `json:"cluster_id"` + Name *string `json:"name"` + Description *string `json:"description"` + ProxmoxTemplateVMID *int `json:"proxmox_template_vmid"` + ProxmoxNode *string `json:"proxmox_node"` +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { + defer r.Body.Close() + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json") + return false + } + return true +} + +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}) +} \ No newline at end of file diff --git a/backend/internal/template/handler_test.go b/backend/internal/template/handler_test.go new file mode 100644 index 0000000..5027690 --- /dev/null +++ b/backend/internal/template/handler_test.go @@ -0,0 +1,208 @@ +package template + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestListTemplatesReturnsTemplates(t *testing.T) { + repository := &stubRepository{ + templates: []Template{{ + ID: "tmpl-1", + ClusterID: "cluster-1", + Name: "ubuntu-24.04", + Description: "Ubuntu 24.04 LTS", + ProxmoxTemplateVMID: 9000, + ProxmoxNode: "pve1", + CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC), + }}, + } + handler := NewHandler(repository) + req := httptest.NewRequest(http.MethodGet, "/internal/templates", nil) + rec := httptest.NewRecorder() + + handler.ListTemplates(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var response struct { + Data []Template `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(response.Data) != 1 { + t.Fatalf("len(data) = %d, want 1", len(response.Data)) + } + if response.Data[0].ID != "tmpl-1" { + t.Fatalf("template ID = %q, want tmpl-1", response.Data[0].ID) + } +} + +func TestListTemplatesReturnsEmptyList(t *testing.T) { + handler := NewHandler(&stubRepository{templates: []Template{}}) + req := httptest.NewRequest(http.MethodGet, "/internal/templates", nil) + rec := httptest.NewRecorder() + + handler.ListTemplates(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestCreateTemplateReturnsCreated(t *testing.T) { + repository := &stubRepository{ + upsertResult: Template{ + ID: "tmpl-1", + ClusterID: "cluster-1", + Name: "ubuntu-24.04", + Description: "Ubuntu 24.04 LTS", + ProxmoxTemplateVMID: 9000, + ProxmoxNode: "pve1", + }, + } + handler := NewHandler(repository) + body := `{"cluster_id":"cluster-1","name":"ubuntu-24.04","description":"Ubuntu 24.04 LTS","proxmox_template_vmid":9000,"proxmox_node":"pve1"}` + req := httptest.NewRequest(http.MethodPost, "/internal/templates", io.NopCloser(strings.NewReader(body))) + rec := httptest.NewRecorder() + + handler.CreateTemplate(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated) + } + + var response Template + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if response.ID != "tmpl-1" { + t.Fatalf("template ID = %q, want tmpl-1", response.ID) + } +} + +func TestCreateTemplateRejectsEmptyName(t *testing.T) { + handler := NewHandler(&stubRepository{}) + body := `{"cluster_id":"cluster-1","name":"","proxmox_template_vmid":9000}` + req := httptest.NewRequest(http.MethodPost, "/internal/templates", io.NopCloser(strings.NewReader(body))) + rec := httptest.NewRecorder() + + handler.CreateTemplate(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestUpdateTemplateReturnsUpdated(t *testing.T) { + repository := &stubRepository{ + getFound: true, + getResult: Template{ + ID: "tmpl-1", + ClusterID: "cluster-1", + Name: "ubuntu-24.04", + Description: "Ubuntu 24.04 LTS", + ProxmoxTemplateVMID: 9000, + ProxmoxNode: "pve1", + }, + upsertResult: Template{ + ID: "tmpl-1", + ClusterID: "cluster-1", + Name: "ubuntu-24.04-updated", + Description: "Updated", + ProxmoxTemplateVMID: 9000, + ProxmoxNode: "pve1", + }, + } + handler := NewHandler(repository) + body := `{"name":"ubuntu-24.04-updated","description":"Updated"}` + req := httptest.NewRequest(http.MethodPut, "/internal/templates/tmpl-1", io.NopCloser(strings.NewReader(body))) + req.SetPathValue("templateID", "tmpl-1") + rec := httptest.NewRecorder() + + handler.UpdateTemplate(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var response Template + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if response.Name != "ubuntu-24.04-updated" { + t.Fatalf("name = %q, want ubuntu-24.04-updated", response.Name) + } +} + +func TestUpdateTemplateReturnsNotFound(t *testing.T) { + handler := NewHandler(&stubRepository{getFound: false}) + body := `{"name":"updated"}` + req := httptest.NewRequest(http.MethodPut, "/internal/templates/tmpl-xxx", io.NopCloser(strings.NewReader(body))) + req.SetPathValue("templateID", "tmpl-xxx") + rec := httptest.NewRecorder() + + handler.UpdateTemplate(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestDeleteTemplateReturnsNoContent(t *testing.T) { + handler := NewHandler(&stubRepository{deleteResult: true}) + req := httptest.NewRequest(http.MethodDelete, "/internal/templates/tmpl-1", nil) + req.SetPathValue("templateID", "tmpl-1") + rec := httptest.NewRecorder() + + handler.DeleteTemplate(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } +} + +func TestDeleteTemplateReturnsNotFound(t *testing.T) { + handler := NewHandler(&stubRepository{deleteResult: false}) + req := httptest.NewRequest(http.MethodDelete, "/internal/templates/tmpl-xxx", nil) + req.SetPathValue("templateID", "tmpl-xxx") + rec := httptest.NewRecorder() + + handler.DeleteTemplate(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +type stubRepository struct { + templates []Template + getFound bool + getResult Template + upsertResult Template + deleteResult bool +} + +func (s *stubRepository) List(_ context.Context) ([]Template, error) { + return s.templates, nil +} + +func (s *stubRepository) Get(_ context.Context, _ string) (Template, bool, error) { + return s.getResult, s.getFound, nil +} + +func (s *stubRepository) Upsert(_ context.Context, _ Template) (Template, error) { + return s.upsertResult, nil +} + +func (s *stubRepository) Delete(_ context.Context, _ string) (bool, error) { + return s.deleteResult, nil +} \ No newline at end of file diff --git a/backend/internal/template/repository.go b/backend/internal/template/repository.go new file mode 100644 index 0000000..f47c5fb --- /dev/null +++ b/backend/internal/template/repository.go @@ -0,0 +1,113 @@ +package template + +import ( + "context" + "database/sql" + "errors" + "time" +) + +type SQLRepository struct { + db *sql.DB +} + +func NewSQLRepository(db *sql.DB) SQLRepository { + return SQLRepository{db: db} +} + +func (r SQLRepository) List(ctx context.Context) ([]Template, error) { + rows, err := r.db.QueryContext(ctx, ` + select + id::text, + cluster_id::text, + name, + description, + proxmox_template_vmid, + proxmox_node, + created_at + from public.templates + order by name asc + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var templates []Template + for rows.Next() { + var t Template + if err := rows.Scan(&t.ID, &t.ClusterID, &t.Name, &t.Description, &t.ProxmoxTemplateVMID, &t.ProxmoxNode, &t.CreatedAt); err != nil { + return nil, err + } + templates = append(templates, t) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return templates, nil +} + +func (r SQLRepository) Get(ctx context.Context, id string) (Template, bool, error) { + var t Template + err := r.db.QueryRowContext(ctx, ` + select + id::text, + cluster_id::text, + name, + description, + proxmox_template_vmid, + proxmox_node, + created_at + from public.templates + where id = $1 + `, id).Scan(&t.ID, &t.ClusterID, &t.Name, &t.Description, &t.ProxmoxTemplateVMID, &t.ProxmoxNode, &t.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Template{}, false, nil + } + if err != nil { + return Template{}, false, err + } + + return t, true, nil +} + +func (r SQLRepository) Upsert(ctx context.Context, t Template) (Template, error) { + err := r.db.QueryRowContext(ctx, ` + insert into public.templates (cluster_id, name, description, proxmox_template_vmid, proxmox_node) + values ($1, $2, $3, $4, $5) + on conflict (cluster_id, proxmox_node, proxmox_template_vmid) + do update set name = $2, description = $3, updated_at = now() + returning id::text, name, description, proxmox_template_vmid, proxmox_node, created_at, coalesce(updated_at, created_at) + `, t.ClusterID, t.Name, t.Description, t.ProxmoxTemplateVMID, t.ProxmoxNode).Scan( + &t.ID, &t.Name, &t.Description, &t.ProxmoxTemplateVMID, &t.ProxmoxNode, &t.CreatedAt, &t.UpdatedAt, + ) + if err != nil { + return Template{}, err + } + + return t, nil +} + +func (r SQLRepository) Delete(ctx context.Context, id string) (bool, error) { + res, err := r.db.ExecContext(ctx, `delete from public.templates where id = $1`, id) + if err != nil { + return false, err + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return false, err + } + return rowsAffected > 0, nil +} + +type Template struct { + ID string `json:"id"` + ClusterID string `json:"cluster_id"` + Name string `json:"name"` + Description string `json:"description"` + ProxmoxTemplateVMID int `json:"proxmox_template_vmid"` + ProxmoxNode string `json:"proxmox_node"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} \ No newline at end of file diff --git a/backend/internal/template/resolver.go b/backend/internal/template/resolver.go new file mode 100644 index 0000000..3d8e2c8 --- /dev/null +++ b/backend/internal/template/resolver.go @@ -0,0 +1,33 @@ +package template + +import ( + "context" + + "proxui/backend/internal/vm" +) + +type ProvisionResolver struct { + repository Repository +} + +func NewProvisionResolver(repository Repository) ProvisionResolver { + return ProvisionResolver{repository: repository} +} + +func (r ProvisionResolver) GetTemplate(ctx context.Context, templateID string) (vm.TemplateInfo, bool, error) { + t, found, err := r.repository.Get(ctx, templateID) + if err != nil { + return vm.TemplateInfo{}, false, err + } + if !found { + return vm.TemplateInfo{}, false, nil + } + + return vm.TemplateInfo{ + ID: t.ID, + ClusterID: t.ClusterID, + Name: t.Name, + ProxmoxTemplateVMID: t.ProxmoxTemplateVMID, + ProxmoxNode: t.ProxmoxNode, + }, true, nil +} \ No newline at end of file diff --git a/backend/internal/vm/handler.go b/backend/internal/vm/handler.go index c04ad7d..675aec5 100644 --- a/backend/internal/vm/handler.go +++ b/backend/internal/vm/handler.go @@ -12,11 +12,16 @@ import ( type Repository interface { ListProjectVMs(ctx context.Context, profileID string, projectID string) ([]VM, bool, error) GetVM(ctx context.Context, profileID string, vmID string) (VM, bool, error) + GetProjectInfo(ctx context.Context, profileID string, projectID string) (ProjectInfo, bool, error) + CheckQuota(ctx context.Context, projectID string) (QuotaInfo, error) + ReserveNextVMID(ctx context.Context, clusterID string) (int, error) + InsertVM(ctx context.Context, vm InsertVMRecord) (VM, error) } type Handler struct { repository Repository power PowerDependencies + provision ProvisionDependencies } type Option func(*Handler) @@ -27,6 +32,12 @@ func WithPower(dependencies PowerDependencies) Option { } } +func WithProvision(dependencies ProvisionDependencies) Option { + return func(h *Handler) { + h.provision = dependencies + } +} + func NewHandler(repository Repository, opts ...Option) Handler { handler := Handler{repository: repository} for _, opt := range opts { diff --git a/backend/internal/vm/handler_test.go b/backend/internal/vm/handler_test.go index 6cc090e..f404d8a 100644 --- a/backend/internal/vm/handler_test.go +++ b/backend/internal/vm/handler_test.go @@ -152,3 +152,19 @@ func (s *stubRepository) GetVM(_ context.Context, profileID string, vmID string) s.vmID = vmID return s.vm, s.getFound, s.err } + +func (s *stubRepository) GetProjectInfo(_ context.Context, profileID string, projectID string) (ProjectInfo, bool, error) { + return ProjectInfo{}, false, nil +} + +func (s *stubRepository) CheckQuota(_ context.Context, _ string) (QuotaInfo, error) { + return QuotaInfo{}, nil +} + +func (s *stubRepository) ReserveNextVMID(_ context.Context, _ string) (int, error) { + return 0, nil +} + +func (s *stubRepository) InsertVM(_ context.Context, _ InsertVMRecord) (VM, error) { + return VM{}, nil +} diff --git a/backend/internal/vm/provision.go b/backend/internal/vm/provision.go new file mode 100644 index 0000000..928e16c --- /dev/null +++ b/backend/internal/vm/provision.go @@ -0,0 +1,431 @@ +package vm + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + + "forgejo.digital-droplets.de/philschlo/proxui/platform/cluster" + "forgejo.digital-droplets.de/philschlo/proxui/platform/jobs" + "forgejo.digital-droplets.de/philschlo/proxui/platform/proxmox" + + "proxui/backend/internal/auth" + "proxui/backend/internal/rbac" +) + +type ProvisionClusterRepository interface { + GetCluster(ctx context.Context, id string) (cluster.Cluster, bool, error) +} + +type ProvisionTemplateRepository interface { + GetTemplate(ctx context.Context, templateID string) (TemplateInfo, bool, error) +} + +type ProvisionClientFactory func(cluster.Cluster) (ProvisionProxmoxClient, error) + +type ProvisionProxmoxClient interface { + CloneVM(ctx context.Context, node string, templateVMID int, newVMID int, name string) (string, error) + ConfigureCloudInit(ctx context.Context, node string, vmid int, cfg proxmox.CloudInitConfig) (string, error) + StartVM(ctx context.Context, node string, vmid int) (string, error) + StopVM(ctx context.Context, node string, vmid int) (string, error) + DeleteVM(ctx context.Context, node string, vmid int) (string, error) +} + +type ProvisionTaskEnqueuer interface { + EnqueueProxmoxTaskPoll(ctx context.Context, payload jobs.ProxmoxTaskPollPayload) error +} + +type ProvisionAuditWriter interface { + WriteVMProvisionAudit(ctx context.Context, event VMProvisionAuditEvent) error +} + +type SSHKeyResolver interface { + GetPublicKey(ctx context.Context, profileID string, keyID string) (string, bool, error) +} + +type VMProvisionAuditEvent struct { + TenantID string + ProfileID string + Action string + VMID string + ClusterID string + TemplateID string + Node string + UPID string + ProxmoxVMID int +} + +type ProvisionDependencies struct { + ClusterRepo ProvisionClusterRepository + TemplateRepo ProvisionTemplateRepository + ClientFactory ProvisionClientFactory + Tasks ProvisionTaskEnqueuer + Audit ProvisionAuditWriter + SSHKeyLookup SSHKeyResolver +} + +type TemplateInfo struct { + ID string + ClusterID string + Name string + ProxmoxTemplateVMID int + ProxmoxNode string +} + +func (h Handler) CreateVM(w http.ResponseWriter, r *http.Request) { + if h.provision.ClusterRepo == nil || h.provision.TemplateRepo == nil || h.provision.ClientFactory == nil || h.provision.Tasks == nil || h.provision.Audit == nil || h.provision.SSHKeyLookup == nil { + writeError(w, http.StatusServiceUnavailable, "vm_provision_not_configured") + return + } + + principal, ok := auth.PrincipalFromRequest(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + projectID := r.PathValue("projectID") + if projectID == "" { + writeError(w, http.StatusBadRequest, "project_id_required") + return + } + + project, found, err := h.repository.GetProjectInfo(r.Context(), principal.Subject, projectID) + if err != nil { + writeError(w, http.StatusInternalServerError, "project_get_failed") + return + } + if !found { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + if !rbac.Can(rbac.Role(project.Role), rbac.ActionVMCreate) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + var req createVMRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body") + return + } + + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name_required") + return + } + if req.TemplateID == "" { + writeError(w, http.StatusBadRequest, "template_id_required") + return + } + if req.Node == "" { + writeError(w, http.StatusBadRequest, "node_required") + return + } + if req.VCPU <= 0 { + req.VCPU = 2 + } + if req.RAMMB <= 0 { + req.RAMMB = 2048 + } + if req.DiskGB <= 0 { + req.DiskGB = 10 + } + + template, found, err := h.provision.TemplateRepo.GetTemplate(r.Context(), req.TemplateID) + if err != nil { + writeError(w, http.StatusInternalServerError, "template_get_failed") + return + } + if !found { + writeError(w, http.StatusNotFound, "template_not_found") + return + } + + quota, err := h.repository.CheckQuota(r.Context(), projectID) + if err != nil { + writeError(w, http.StatusInternalServerError, "quota_check_failed") + return + } + if quota.UsedVMs >= quota.MaxVMs { + writeError(w, http.StatusConflict, "quota_exceeded_vms") + return + } + if quota.UsedVCPU+req.VCPU > quota.MaxVCPU { + writeError(w, http.StatusConflict, "quota_exceeded_vcpu") + return + } + if quota.UsedRAMMB+req.RAMMB > quota.MaxRAMMB { + writeError(w, http.StatusConflict, "quota_exceeded_ram") + return + } + if quota.UsedDiskGB+req.DiskGB > quota.MaxDiskGB { + writeError(w, http.StatusConflict, "quota_exceeded_disk") + return + } + + vmid, err := h.repository.ReserveNextVMID(r.Context(), template.ClusterID) + if err != nil { + writeError(w, http.StatusInternalServerError, "vmid_reserve_failed") + return + } + + vm, err := h.repository.InsertVM(r.Context(), InsertVMRecord{ + ProjectID: projectID, + ClusterID: template.ClusterID, + ProxmoxVMID: vmid, + Node: req.Node, + Name: req.Name, + Status: "provisioning", + VCPU: req.VCPU, + RAMMB: req.RAMMB, + DiskGB: req.DiskGB, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "vm_insert_failed") + return + } + + cluster, found, err := h.provision.ClusterRepo.GetCluster(r.Context(), template.ClusterID) + if err != nil { + writeError(w, http.StatusInternalServerError, "cluster_get_failed") + return + } + if !found { + writeError(w, http.StatusBadGateway, "cluster_not_found") + return + } + + proxmoxClient, err := h.provision.ClientFactory(cluster) + if err != nil { + writeError(w, http.StatusBadGateway, "proxmox_client_failed") + return + } + + cloneUPID, err := proxmoxClient.CloneVM(r.Context(), req.Node, template.ProxmoxTemplateVMID, vmid, req.Name) + if err != nil { + writeError(w, http.StatusBadGateway, "proxmox_clone_failed") + return + } + + ciCfg := proxmox.CloudInitConfig{ + CIUser: req.CIUser, + IPConfig0: req.IPConfig0, + Hostname: req.Name, + } + if req.SSHKeyID != "" { + sshKey, found, err := h.provision.SSHKeyLookup.GetPublicKey(r.Context(), principal.Subject, req.SSHKeyID) + if err != nil { + writeError(w, http.StatusInternalServerError, "ssh_key_get_failed") + return + } + if !found { + writeError(w, http.StatusBadRequest, "ssh_key_not_found") + return + } + ciCfg.SSHKeys = sshKey + } + + var startUPID string + if _, err := proxmoxClient.ConfigureCloudInit(r.Context(), req.Node, vmid, ciCfg); err != nil { + writeError(w, http.StatusBadGateway, "proxmox_cloudinit_failed") + return + } + + startUPID, err = proxmoxClient.StartVM(r.Context(), req.Node, vmid) + if err != nil { + writeError(w, http.StatusBadGateway, "proxmox_start_failed") + return + } + + auditAction := "vm.provision" + if err := h.provision.Tasks.EnqueueProxmoxTaskPoll(r.Context(), jobs.ProxmoxTaskPollPayload{ + ClusterID: template.ClusterID, + Node: req.Node, + UPID: startUPID, + TargetType: "vm", + TargetID: vm.ID, + TenantID: project.TenantID, + ProfileID: principal.Subject, + Action: auditAction, + SuccessStatus: "running", + }); err != nil { + writeError(w, http.StatusInternalServerError, "task_enqueue_failed") + return + } + + if err := h.provision.Audit.WriteVMProvisionAudit(r.Context(), VMProvisionAuditEvent{ + TenantID: project.TenantID, + ProfileID: principal.Subject, + Action: auditAction, + VMID: vm.ID, + ClusterID: template.ClusterID, + TemplateID: template.ID, + Node: req.Node, + UPID: cloneUPID, + ProxmoxVMID: vmid, + }); err != nil { + writeError(w, http.StatusInternalServerError, "audit_write_failed") + return + } + + writeJSON(w, http.StatusAccepted, map[string]any{ + "vm": vm, + "upid": startUPID, + "clone_upid": cloneUPID, + "status": "provisioning", + }) +} + +type createVMRequest struct { + Name string `json:"name"` + TemplateID string `json:"template_id"` + Node string `json:"node"` + VCPU int `json:"vcpu"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + SSHKeyID string `json:"ssh_key_id"` + CIUser string `json:"ci_user"` + IPConfig0 string `json:"ip_config0"` +} + +func DefaultProvisionClientFactory(cluster cluster.Cluster) (ProvisionProxmoxClient, error) { + return proxmox.NewClient(cluster) +} + +type SQLProvisionAuditWriter struct { + db *sql.DB +} + +func NewSQLProvisionAuditWriter(db *sql.DB) SQLProvisionAuditWriter { + return SQLProvisionAuditWriter{db: db} +} + +func (w SQLProvisionAuditWriter) WriteVMProvisionAudit(ctx context.Context, event VMProvisionAuditEvent) error { + metadata, err := json.Marshal(map[string]any{ + "cluster_id": event.ClusterID, + "template_id": event.TemplateID, + "node": event.Node, + "upid": event.UPID, + "proxmox_vmid": event.ProxmoxVMID, + }) + if err != nil { + return err + } + + _, err = w.db.ExecContext(ctx, ` + insert into public.audit_log ( + tenant_id, + profile_id, + action, + target_type, + target_id, + metadata + ) + values ($1, $2, $3, 'vm', $4, $5::jsonb) + `, event.TenantID, event.ProfileID, event.Action, event.VMID, string(metadata)) + if err != nil { + return fmt.Errorf("insert provision audit: %w", err) + } + return nil +} + +func (h Handler) DeleteVM(w http.ResponseWriter, r *http.Request) { + if h.provision.ClusterRepo == nil || h.provision.ClientFactory == nil || h.provision.Tasks == nil || h.provision.Audit == nil { + writeError(w, http.StatusServiceUnavailable, "vm_delete_not_configured") + return + } + + 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.repository.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.ActionVMDelete) { + writeError(w, http.StatusForbidden, "forbidden") + return + } + + cluster, found, err := h.provision.ClusterRepo.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 + } + + proxmoxClient, err := h.provision.ClientFactory(cluster) + if err != nil { + writeError(w, http.StatusBadGateway, "proxmox_client_failed") + return + } + + _, err = proxmoxClient.StopVM(r.Context(), vm.Node, vm.ProxmoxVMID) + if err != nil { + writeError(w, http.StatusBadGateway, "proxmox_stop_failed") + return + } + + deleteUPID, err := proxmoxClient.DeleteVM(r.Context(), vm.Node, vm.ProxmoxVMID) + if err != nil { + writeError(w, http.StatusBadGateway, "proxmox_delete_failed") + return + } + + auditAction := "vm.delete" + if err := h.provision.Tasks.EnqueueProxmoxTaskPoll(r.Context(), jobs.ProxmoxTaskPollPayload{ + ClusterID: vm.ClusterID, + Node: vm.Node, + UPID: deleteUPID, + TargetType: "vm", + TargetID: vm.ID, + TenantID: vm.TenantID, + ProfileID: principal.Subject, + Action: auditAction, + SuccessStatus: "deleted", + }); err != nil { + writeError(w, http.StatusInternalServerError, "task_enqueue_failed") + return + } + + if err := h.provision.Audit.WriteVMProvisionAudit(r.Context(), VMProvisionAuditEvent{ + TenantID: vm.TenantID, + ProfileID: principal.Subject, + Action: auditAction, + VMID: vm.ID, + ClusterID: vm.ClusterID, + Node: vm.Node, + UPID: deleteUPID, + ProxmoxVMID: vm.ProxmoxVMID, + }); err != nil { + writeError(w, http.StatusInternalServerError, "audit_write_failed") + return + } + + writeJSON(w, http.StatusAccepted, map[string]string{ + "upid": deleteUPID, + "status": "deleting", + }) +} \ No newline at end of file diff --git a/backend/internal/vm/repository.go b/backend/internal/vm/repository.go index 514888d..bd51ec2 100644 --- a/backend/internal/vm/repository.go +++ b/backend/internal/vm/repository.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" ) type SQLRepository struct { @@ -171,5 +172,140 @@ func (r nullableVMRecord) vm() VM { UpdatedAt: r.UpdatedAt.Time, ProjectName: r.ProjectName, MembershipRole: r.MembershipRole, +} +} + +type ProjectInfo struct { + TenantID string + Name string + Role string +} + +type QuotaInfo struct { + UsedVMs int + UsedVCPU int + UsedRAMMB int + UsedDiskGB int + MaxVMs int + MaxVCPU int + MaxRAMMB int + MaxDiskGB int +} + +func (r SQLRepository) GetProjectInfo(ctx context.Context, profileID string, projectID string) (ProjectInfo, bool, error) { + var info ProjectInfo + err := r.db.QueryRowContext(ctx, ` + select + p.tenant_id::text, + p.name, + m.role::text + from public.projects p + join public.memberships m + on m.tenant_id = p.tenant_id + and m.profile_id = $1 + where p.id = $2 + `, profileID, projectID).Scan(&info.TenantID, &info.Name, &info.Role) + if errors.Is(err, sql.ErrNoRows) { + return ProjectInfo{}, false, nil } + if err != nil { + return ProjectInfo{}, false, err + } + + return info, true, nil +} + +func (r SQLRepository) CheckQuota(ctx context.Context, projectID string) (QuotaInfo, error) { + var q QuotaInfo + err := r.db.QueryRowContext(ctx, ` + select + coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then 1 else 0 end), 0) as used_vms, + coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then v.vcpu else 0 end), 0) as used_vcpu, + coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then v.ram_mb else 0 end), 0) as used_ram_mb, + coalesce(sum(case when v.status <> 'failed' and v.status <> 'deleting' and v.status <> 'deleted' then v.disk_gb else 0 end), 0) as used_disk_gb, + coalesce(pq.max_vms, 10), + coalesce(pq.max_vcpu, 8), + coalesce(pq.max_ram_mb, 16384), + coalesce(pq.max_disk_gb, 200) + from public.projects p + left join public.vms v on v.project_id = p.id + left join public.project_quotas pq on pq.project_id = p.id + where p.id = $1 + group by pq.max_vms, pq.max_vcpu, pq.max_ram_mb, pq.max_disk_gb + `, projectID).Scan(&q.UsedVMs, &q.UsedVCPU, &q.UsedRAMMB, &q.UsedDiskGB, &q.MaxVMs, &q.MaxVCPU, &q.MaxRAMMB, &q.MaxDiskGB) + if errors.Is(err, sql.ErrNoRows) { + q.MaxVMs = 10 + q.MaxVCPU = 8 + q.MaxRAMMB = 16384 + q.MaxDiskGB = 200 + return q, nil + } + if err != nil { + return QuotaInfo{}, err + } + + return q, nil +} + +func (r SQLRepository) ReserveNextVMID(ctx context.Context, clusterID string) (int, error) { + var vmid int + err := r.db.QueryRowContext(ctx, ` + select public.reserve_next_vmid($1::uuid) + `, clusterID).Scan(&vmid) + if err != nil { + return 0, fmt.Errorf("reserve vmid: %w", err) + } + + return vmid, nil +} + +func (r SQLRepository) InsertVM(ctx context.Context, vm InsertVMRecord) (VM, error) { + var result VM + err := r.db.QueryRowContext(ctx, ` + insert into public.vms (project_id, cluster_id, proxmox_vmid, node, name, status, vcpu, ram_mb, disk_gb) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + returning + id::text, + project_id::text, + cluster_id::text, + proxmox_vmid, + node, + name, + status::text, + vcpu, + ram_mb, + disk_gb, + created_at, + updated_at + `, vm.ProjectID, vm.ClusterID, vm.ProxmoxVMID, vm.Node, vm.Name, vm.Status, vm.VCPU, vm.RAMMB, vm.DiskGB).Scan( + &result.ID, + &result.ProjectID, + &result.ClusterID, + &result.ProxmoxVMID, + &result.Node, + &result.Name, + &result.Status, + &result.VCPU, + &result.RAMMB, + &result.DiskGB, + &result.CreatedAt, + &result.UpdatedAt, + ) + if err != nil { + return VM{}, fmt.Errorf("insert vm: %w", err) + } + + return result, nil +} + +type InsertVMRecord struct { + ProjectID string + ClusterID string + ProxmoxVMID int + Node string + Name string + Status string + VCPU int + RAMMB int + DiskGB int } diff --git a/console-proxy/cmd/console-proxy/main.go b/console-proxy/cmd/console-proxy/main.go index 3249370..0936eaf 100644 --- a/console-proxy/cmd/console-proxy/main.go +++ b/console-proxy/cmd/console-proxy/main.go @@ -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 +} \ No newline at end of file diff --git a/console-proxy/go.mod b/console-proxy/go.mod index 4902b4a..096b5c0 100644 --- a/console-proxy/go.mod +++ b/console-proxy/go.mod @@ -1,3 +1,5 @@ module proxui/console-proxy -go 1.22 +go 1.23 + +require github.com/coder/websocket v1.8.14 // indirect diff --git a/console-proxy/go.sum b/console-proxy/go.sum new file mode 100644 index 0000000..c80e2f0 --- /dev/null +++ b/console-proxy/go.sum @@ -0,0 +1,2 @@ +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..3d500ca --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,157 @@ +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080"; + +function authHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; +} + +async function request( + path: string, + token: string, + method = "GET", + body?: unknown +): Promise { + const response = await fetch(`${apiBaseUrl}${path}`, { + method, + headers: authHeaders(token), + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const errorBody = await response.text(); + let message = `API error ${response.status}`; + try { + const parsed = JSON.parse(errorBody); + message = parsed.error ?? message; + } catch { + // ignore + } + throw new Error(message); + } + + if (response.status === 204) return undefined as T; + return response.json() as Promise; +} + +export interface VM { + id: string; + project_id: string; + tenant_id: string; + cluster_id: string; + proxmox_vmid: number; + node: string; + name: string; + status: string; + vcpu: number; + ram_mb: number; + disk_gb: number; + created_at: string; + updated_at: string; + project_name: string; + membership_role: string; +} + +export interface SSHKey { + id: string; + tenant_id: string; + name: string; + public_key: string; + created_at: string; +} + +export interface AuditEntry { + id: string; + tenant_id: string; + profile_id: string; + action: string; + target_type: string; + target_id: string; + metadata: Record; + created_at: string; +} + +export interface ProjectInfo { + tenant_id: string; + name: string; + role: string; +} + +export function fetchVMs(token: string, projectID: string) { + return request<{ data: VM[] }>(`/projects/${projectID}/vms`, token); +} + +export function fetchSSHKeys(token: string, tenantID: string) { + return request<{ data: SSHKey[] }>(`/tenants/${tenantID}/ssh-keys`, token); +} + +export function createSSHKey( + token: string, + tenantID: string, + name: string, + publicKey: string +) { + return request(`/tenants/${tenantID}/ssh-keys`, token, "POST", { + name, + public_key: publicKey, + }); +} + +export function deleteSSHKey(token: string, keyID: string) { + return request(`/ssh-keys/${keyID}`, token, "DELETE"); +} + +export function createVM( + token: string, + projectID: string, + data: { + name: string; + template_id: string; + node: string; + vcpu: number; + ram_mb: number; + disk_gb: number; + ssh_key_id?: string; + ci_user?: string; + ip_config0?: string; + } +) { + return request<{ vm: VM; upid: string; status: string }>( + `/projects/${projectID}/vms`, + token, + "POST", + data + ); +} + +export function powerVM(token: string, vmID: string, action: "start" | "stop" | "reboot") { + return request<{ upid: string; status: string }>( + `/vms/${vmID}/${action}`, + token, + "POST" + ); +} + +export function deleteVM(token: string, vmID: string) { + return request<{ upid: string; status: string }>( + `/vms/${vmID}`, + token, + "DELETE" + ); +} + +export function fetchAuditLog(token: string, tenantID: string) { + return request<{ data: AuditEntry[] }>(`/tenants/${tenantID}/audit`, token); +} + +export function requestConsoleTicket(token: string, vmID: string) { + return request<{ ticket: string }>(`/vms/${vmID}/console`, token, "POST"); +} + +export function fetchMe(token: string) { + return request<{ Subject: string; Email: string; Role: string }>( + "/me", + token + ); +} \ No newline at end of file diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx new file mode 100644 index 0000000..ee8649f --- /dev/null +++ b/frontend/src/components/AuditLog.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import type { AuditEntry } from "../api"; +import { fetchAuditLog } from "../api"; + +interface Props { + token: string; + tenantID: string; +} + +export function AuditLog({ token, tenantID }: Props) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + setLoading(true); + fetchAuditLog(token, tenantID) + .then((res) => setEntries((res as unknown as { data: AuditEntry[] }).data ?? [])) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [token, tenantID]); + + return ( +
+
+
+

Audit

+

Log

+
+
+ {error ?

{error}

: null} + {loading ? ( +

Lade...

+ ) : ( +
+ {entries.map((e) => ( +
+ {new Date(e.created_at).toLocaleString()} + {e.action} + {e.target_type}/{e.target_id} +
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/AuthScreen.tsx b/frontend/src/components/AuthScreen.tsx new file mode 100644 index 0000000..ad55ed0 --- /dev/null +++ b/frontend/src/components/AuthScreen.tsx @@ -0,0 +1,80 @@ +import React, { useState } from "react"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +interface Props { + supabase: SupabaseClient; +} + +export function AuthScreen({ supabase }: Props) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [mode, setMode] = useState<"signin" | "signup">("signin"); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setMessage(""); + + const result = + mode === "signin" + ? await supabase.auth.signInWithPassword({ email, password }) + : await supabase.auth.signUp({ email, password }); + + if (result.error) { + setMessage(result.error.message); + } + + setBusy(false); + } + + return ( +
+
+

ProxUI

+

Anmelden

+
+ + + {message ?

{message}

: null} +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ConsoleView.tsx b/frontend/src/components/ConsoleView.tsx new file mode 100644 index 0000000..a667038 --- /dev/null +++ b/frontend/src/components/ConsoleView.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { requestConsoleTicket } from "../api"; + +interface Props { + token: string; +} + +export function ConsoleView({ token }: Props) { + const [vmID, setVMID] = useState(""); + const [ticket, setTicket] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleConnect() { + setError(""); + setLoading(true); + try { + const data = await requestConsoleTicket(token, vmID); + setTicket(data.ticket); + } catch (err) { + setError(err instanceof Error ? err.message : "Fehler"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

Konsole

+

Web-Konsole

+
+
+
+ +
+ + {error ?

{error}

: null} + {ticket ? ( +
+

+ Proxy-Ticket erhalten. Verbinde zum console-proxy mit: +

+ ?ticket={ticket} +
+ ) : null} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/SSHKeys.tsx b/frontend/src/components/SSHKeys.tsx new file mode 100644 index 0000000..5544d3f --- /dev/null +++ b/frontend/src/components/SSHKeys.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import type { SSHKey } from "../api"; +import { fetchSSHKeys, createSSHKey, deleteSSHKey } from "../api"; + +interface Props { + token: string; + tenantID: string; +} + +export function SSHKeys({ token, tenantID }: Props) { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [newName, setNewName] = useState(""); + const [newKey, setNewKey] = useState(""); + + useEffect(() => { + setLoading(true); + fetchSSHKeys(token, tenantID) + .then((res) => setKeys(res.data ?? [])) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [token, tenantID]); + + async function handleCreate() { + setError(""); + try { + await createSSHKey(token, tenantID, newName, newKey); + setNewName(""); + setNewKey(""); + const res = await fetchSSHKeys(token, tenantID); + setKeys(res.data ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : "Fehler"); + } + } + + async function handleDelete(keyID: string) { + try { + await deleteSSHKey(token, keyID); + const res = await fetchSSHKeys(token, tenantID); + setKeys(res.data ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : "Fehler"); + } + } + + return ( +
+
+
+

SSH-Keys

+

Verwaltung

+
+
+ {error ?

{error}

: null} +
+ setNewName(e.target.value)} + /> +