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

This commit is contained in:
Philipp
2026-06-12 10:45:13 +02:00
parent 137c13fa98
commit ac30a19960
33 changed files with 3625 additions and 263 deletions
+60 -8
View File
@@ -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
+93 -1
View File
@@ -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))
}
+144
View File
@@ -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})
}
+87
View File
@@ -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
}
+209
View File
@@ -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})
}
@@ -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
}
+205
View File
@@ -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})
}
+310
View File
@@ -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
}
+172
View File
@@ -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
}
+22
View File
@@ -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
}
+189
View File
@@ -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})
}
+208
View File
@@ -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
}
+113
View File
@@ -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"`
}
+33
View File
@@ -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
}
+11
View File
@@ -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 {
+16
View File
@@ -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
}
+431
View File
@@ -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",
})
}
+136
View File
@@ -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
}
+143 -4
View File
@@ -2,32 +2,51 @@ package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"forgejo.digital-droplets.de/philschlo/proxui/platform/config"
"forgejo.digital-droplets.de/philschlo/proxui/platform/logging"
"github.com/coder/websocket"
)
func main() {
cfg, err := config.Load()
if err != nil {
logger := logging.New("console-proxy", "unknown", 0)
logger.Error("failed to load config", "error", err)
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
logger := logging.New("console-proxy", cfg.AppEnv, cfg.LogLevel)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
logger = logger.With("service", "console-proxy", "env", cfg.AppEnv)
signingKey := []byte(cfg.OperatorToken)
if len(signingKey) == 0 {
logger.Error("OPERATOR_TOKEN is required")
os.Exit(1)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","service":"console-proxy"}`))
})
mux.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) {
handleWebSocket(w, r, signingKey, logger)
})
server := &http.Server{
Addr: cfg.ConsoleProxyAddr,
@@ -55,3 +74,123 @@ func main() {
os.Exit(1)
}
}
func handleWebSocket(w http.ResponseWriter, r *http.Request, signingKey []byte, logger *slog.Logger) {
ticket := r.URL.Query().Get("ticket")
if ticket == "" {
http.Error(w, "missing ticket", http.StatusBadRequest)
return
}
payload, err := verifyProxyTicket(ticket, signingKey)
if err != nil {
logger.Error("invalid ticket", "error", err)
http.Error(w, "invalid ticket", http.StatusForbidden)
return
}
endpoint := strings.TrimRight(payload.Endpoint, "/")
proxmoxWSURL := strings.Replace(endpoint, "https://", "wss://", 1)
vncURL := fmt.Sprintf(
"%s/nodes/%s/qemu/%d/vncwebsocket?port=%d&vncticket=%s",
proxmoxWSURL,
url.PathEscape(payload.Node),
payload.VMID,
payload.VNC.Port,
url.QueryEscape(payload.VNC.Ticket),
)
proxmoxConn, _, err := websocket.Dial(r.Context(), vncURL, &websocket.DialOptions{
HTTPClient: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
},
})
if err != nil {
logger.Error("proxmox websocket dial failed", "error", err)
http.Error(w, "proxy connection failed", http.StatusBadGateway)
return
}
defer proxmoxConn.Close(websocket.StatusNormalClosure, "")
clientConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{})
if err != nil {
logger.Error("client websocket accept failed", "error", err)
return
}
defer clientConn.Close(websocket.StatusNormalClosure, "")
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go proxyWebSocket(ctx, clientConn, proxmoxConn, logger)
proxyWebSocket(ctx, proxmoxConn, clientConn, logger)
}
func proxyWebSocket(ctx context.Context, dst *websocket.Conn, src *websocket.Conn, logger *slog.Logger) {
for {
_, msg, err := src.Read(ctx)
if err != nil {
return
}
if err := dst.Write(ctx, websocket.MessageBinary, msg); err != nil {
logger.Error("websocket write failed", "error", err)
return
}
}
}
type proxyTicketPayload struct {
ClusterID string `json:"cluster_id"`
Node string `json:"node"`
VMID int `json:"vmid"`
TenantID string `json:"tenant_id"`
Endpoint string `json:"endpoint"`
VNC vncTicket `json:"vnc"`
ExpiresAt time.Time `json:"expires_at"`
Signature string `json:"signature"`
}
type vncTicket struct {
Port int `json:"port"`
Ticket string `json:"ticket"`
User string `json:"user"`
Cert string `json:"cert"`
UPID string `json:"upid"`
}
func verifyProxyTicket(ticket string, signingKey []byte) (proxyTicketPayload, error) {
data, err := base64.RawURLEncoding.DecodeString(ticket)
if err != nil {
return proxyTicketPayload{}, fmt.Errorf("invalid ticket encoding")
}
sigData := make([]byte, len(data))
copy(sigData, data)
var payload proxyTicketPayload
if err := json.Unmarshal(data, &payload); err != nil {
return proxyTicketPayload{}, fmt.Errorf("invalid ticket payload")
}
if time.Now().After(payload.ExpiresAt) {
return proxyTicketPayload{}, fmt.Errorf("ticket expired")
}
receivedSig := payload.Signature
payload.Signature = ""
dataToVerify, _ := json.Marshal(payload)
mac := hmac.New(sha256.New, signingKey)
mac.Write(dataToVerify)
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(receivedSig), []byte(expectedSig)) {
return proxyTicketPayload{}, fmt.Errorf("invalid ticket signature")
}
return payload, nil
}
+3 -1
View File
@@ -1,3 +1,5 @@
module proxui/console-proxy
go 1.22
go 1.23
require github.com/coder/websocket v1.8.14 // indirect
+2
View File
@@ -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=
+157
View File
@@ -0,0 +1,157 @@
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
function authHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
};
}
async function request<T>(
path: string,
token: string,
method = "GET",
body?: unknown
): Promise<T> {
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<T>;
}
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<string, unknown>;
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<SSHKey>(`/tenants/${tenantID}/ssh-keys`, token, "POST", {
name,
public_key: publicKey,
});
}
export function deleteSSHKey(token: string, keyID: string) {
return request<void>(`/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
);
}
+47
View File
@@ -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<AuditEntry[]>([]);
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 (
<div>
<div className="panel-header">
<div>
<p className="eyebrow">Audit</p>
<h2>Log</h2>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
{loading ? (
<p className="metric-text">Lade...</p>
) : (
<div className="placeholder-table">
{entries.map((e) => (
<div className="placeholder-row" key={e.id}>
<span>{new Date(e.created_at).toLocaleString()}</span>
<strong>{e.action}</strong>
<span>{e.target_type}/{e.target_id}</span>
</div>
))}
</div>
)}
</div>
);
}
+80
View File
@@ -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<HTMLFormElement>) {
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 (
<main className="auth-screen">
<section className="auth-panel">
<p className="eyebrow">ProxUI</p>
<h1>Anmelden</h1>
<form className="auth-form" onSubmit={submit}>
<label>
E-Mail
<input
autoComplete="email"
onChange={(e) => setEmail(e.target.value)}
required
type="email"
value={email}
/>
</label>
<label>
Passwort
<input
autoComplete={
mode === "signin" ? "current-password" : "new-password"
}
minLength={6}
onChange={(e) => setPassword(e.target.value)}
required
type="password"
value={password}
/>
</label>
{message ? <p className="form-error">{message}</p> : null}
<div className="auth-actions">
<button className="primary-button" disabled={busy} type="submit">
{mode === "signin" ? "Einloggen" : "Account erstellen"}
</button>
<button
className="ghost-button"
onClick={() =>
setMode(mode === "signin" ? "signup" : "signin")
}
type="button"
>
{mode === "signin" ? "Registrieren" : "Zum Login"}
</button>
</div>
</form>
</section>
</main>
);
}
+63
View File
@@ -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 (
<div>
<div className="panel-header">
<div>
<p className="eyebrow">Konsole</p>
<h2>Web-Konsole</h2>
</div>
</div>
<div className="metric-list">
<label>
VM-ID
<input
value={vmID}
onChange={(e) => setVMID(e.target.value)}
placeholder="UUID der VM"
/>
</label>
</div>
<button
className="primary-button"
disabled={loading || !vmID}
onClick={handleConnect}
>
{loading ? "Verbinde..." : "Konsole öffnen"}
</button>
{error ? <p className="form-error">{error}</p> : null}
{ticket ? (
<div className="metric-list" style={{ marginTop: "1rem" }}>
<p className="metric-text">
Proxy-Ticket erhalten. Verbinde zum console-proxy mit:
</p>
<code>?ticket={ticket}</code>
</div>
) : null}
</div>
);
}
+93
View File
@@ -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<SSHKey[]>([]);
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 (
<div>
<div className="panel-header">
<div>
<p className="eyebrow">SSH-Keys</p>
<h2>Verwaltung</h2>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="ssh-form">
<input
placeholder="Name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<textarea
placeholder="ssh-ed25519 AAA..."
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
rows={3}
/>
<button className="primary-button" onClick={handleCreate}>
Hinzufügen
</button>
</div>
{loading ? (
<p className="metric-text">Lade...</p>
) : (
<div className="placeholder-table">
{keys.map((k) => (
<div className="placeholder-row" key={k.id}>
<span>{k.name}</span>
<strong>{k.public_key.slice(0, 40)}...</strong>
<button
className="ghost-button danger"
onClick={() => handleDelete(k.id)}
>
Löschen
</button>
</div>
))}
</div>
)}
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useState } from "react";
import { createVM } from "../api";
interface Props {
token: string;
projectID: string;
onCreated: () => void;
}
export function VMCreate({ token, projectID, onCreated }: Props) {
const [name, setName] = useState("");
const [templateID, setTemplateID] = useState("");
const [node, setNode] = useState("");
const [vcpu, setVCPU] = useState(2);
const [ramMB, setRAMMB] = useState(2048);
const [diskGB, setDiskGB] = useState(10);
const [sshKeyID, setSSHKeyID] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState(false);
async function handleCreate() {
setError("");
setBusy(true);
try {
await createVM(token, projectID, {
name,
template_id: templateID,
node,
vcpu,
ram_mb: ramMB,
disk_gb: diskGB,
ssh_key_id: sshKeyID || undefined,
});
setName("");
setTemplateID("");
setOpen(false);
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : "Fehler");
} finally {
setBusy(false);
}
}
if (!open) {
return (
<button className="primary-button" onClick={() => setOpen(true)}>
Neue VM erstellen
</button>
);
}
return (
<div className="panel">
<div className="panel-header">
<div>
<p className="eyebrow">Provisioning</p>
<h2>VM erstellen</h2>
</div>
<button className="ghost-button" onClick={() => setOpen(false)}>
Abbrechen
</button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="metric-list">
<label>
Name
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="meine-vm"
/>
</label>
<label>
Template-ID
<input
value={templateID}
onChange={(e) => setTemplateID(e.target.value)}
placeholder="UUID des Templates"
/>
</label>
<label>
Node
<input
value={node}
onChange={(e) => setNode(e.target.value)}
placeholder="pve"
/>
</label>
<label>
vCPU
<input
type="number"
value={vcpu}
onChange={(e) => setVCPU(Number(e.target.value))}
/>
</label>
<label>
RAM (MB)
<input
type="number"
value={ramMB}
onChange={(e) => setRAMMB(Number(e.target.value))}
/>
</label>
<label>
Disk (GB)
<input
type="number"
value={diskGB}
onChange={(e) => setDiskGB(Number(e.target.value))}
/>
</label>
<label>
SSH-Key-ID (optional)
<input
value={sshKeyID}
onChange={(e) => setSSHKeyID(e.target.value)}
placeholder="UUID"
/>
</label>
</div>
<button
className="primary-button"
disabled={busy}
onClick={handleCreate}
>
{busy ? "Erstelle..." : "VM erstellen"}
</button>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import type { VM } from "../api";
import { fetchVMs, powerVM, deleteVM } from "../api";
interface Props {
token: string;
projectID: string;
}
export function VMList({ token, projectID }: Props) {
const [vms, setVMs] = useState<VM[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
setLoading(true);
setError("");
fetchVMs(token, projectID)
.then((res) => setVMs(res.data ?? []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [token, projectID]);
async function handlePower(vmID: string, action: "start" | "stop" | "reboot") {
try {
await powerVM(token, vmID, action);
const res = await fetchVMs(token, projectID);
setVMs(res.data ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : "Fehler");
}
}
async function handleDelete(vmID: string) {
if (!confirm("VM wirklich löschen?")) return;
try {
await deleteVM(token, vmID);
const res = await fetchVMs(token, projectID);
setVMs(res.data ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : "Fehler");
}
}
if (loading) return <p className="metric-text">Lade VMs...</p>;
if (error) return <p className="form-error">{error}</p>;
if (vms.length === 0)
return <p className="metric-text">Keine VMs in diesem Projekt.</p>;
return (
<div className="vm-grid">
{vms.map((vm) => (
<div className="vm-card" key={vm.id}>
<div className="vm-card-header">
<strong>{vm.name}</strong>
<span className={`pill ${vm.status === "running" ? "ok" : ""}`}>
{vm.status}
</span>
</div>
<div className="vm-card-body">
<div className="metric">
<span>Node</span>
<strong>{vm.node}</strong>
</div>
<div className="metric">
<span>vCPU / RAM / Disk</span>
<strong>
{vm.vcpu} / {vm.ram_mb} MB / {vm.disk_gb} GB
</strong>
</div>
<div className="metric">
<span>Proxmox VMID</span>
<strong>{vm.proxmox_vmid}</strong>
</div>
</div>
<div className="vm-card-actions">
<button
className="primary-button"
onClick={() => handlePower(vm.id, "start")}
>
Start
</button>
<button
className="ghost-button"
onClick={() => handlePower(vm.id, "stop")}
>
Stop
</button>
<button
className="ghost-button"
onClick={() => handlePower(vm.id, "reboot")}
>
Reboot
</button>
<button
className="ghost-button danger"
onClick={() => handleDelete(vm.id)}
>
Löschen
</button>
</div>
</div>
))}
</div>
);
}
+143 -247
View File
@@ -1,19 +1,20 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom/client";
import { createClient, type Session } from "@supabase/supabase-js";
import type { VM } from "./api";
import { fetchMe } from "./api";
import "./styles.css";
import { AuthScreen } from "./components/AuthScreen";
import { VMList } from "./components/VMList";
import { VMCreate } from "./components/VMCreate";
import { SSHKeys } from "./components/SSHKeys";
import { AuditLog } from "./components/AuditLog";
import { ConsoleView } from "./components/ConsoleView";
type BackendPrincipal = {
Subject: string;
Email: string;
Role: string;
};
type ViewKey = "overview" | "projects" | "vms" | "ssh" | "audit" | "console";
type ViewKey = "overview" | "vms" | "ssh" | "audit" | "console";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? "";
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? "";
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
const supabase =
supabaseUrl && supabaseAnonKey
@@ -22,7 +23,6 @@ const supabase =
const navItems: Array<{ key: ViewKey; label: string }> = [
{ key: "overview", label: "Uebersicht" },
{ key: "projects", label: "Projekte" },
{ key: "vms", label: "VMs" },
{ key: "ssh", label: "SSH-Keys" },
{ key: "audit", label: "Audit" },
@@ -33,8 +33,11 @@ function App() {
const [session, setSession] = useState<Session | null>(null);
const [loadingSession, setLoadingSession] = useState(true);
const [activeView, setActiveView] = useState<ViewKey>("overview");
const [backendPrincipal, setBackendPrincipal] =
useState<BackendPrincipal | null>(null);
const [backendPrincipal, setBackendPrincipal] = useState<{
Subject: string;
Email: string;
Role: string;
} | null>(null);
const [backendStatus, setBackendStatus] = useState<
"idle" | "loading" | "ok" | "error"
>("idle");
@@ -61,36 +64,31 @@ function App() {
return () => data.subscription.unsubscribe();
}, []);
const connected = Boolean(session && backendPrincipal);
useEffect(() => {
if (session) {
refreshBackendPrincipal();
}
}, [session]);
async function refreshBackendPrincipal() {
if (!session) {
return;
}
if (!session) return;
setBackendStatus("loading");
setBackendError("");
try {
const response = await fetch(`${apiBaseUrl}/me`, {
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
throw new Error(`Backend antwortet mit ${response.status}`);
}
const principal = (await response.json()) as BackendPrincipal;
const principal = await fetchMe(session.access_token);
setBackendPrincipal(principal);
setBackendStatus("ok");
} catch (error) {
setBackendStatus("error");
setBackendError(error instanceof Error ? error.message : "Unbekannter Fehler");
setBackendError(
error instanceof Error ? error.message : "Unbekannter Fehler"
);
}
}
const connected = Boolean(session && backendPrincipal);
if (loadingSession) {
return (
<main className="startup-screen">
@@ -100,11 +98,22 @@ function App() {
}
if (!supabase) {
return <MissingConfig />;
return (
<main className="startup-screen">
<section className="auth-panel">
<p className="eyebrow">Konfiguration</p>
<h1>Frontend Env fehlt</h1>
<p>
`VITE_SUPABASE_URL` und `VITE_SUPABASE_ANON_KEY` mussen gesetzt
sein.
</p>
</section>
</main>
);
}
if (!session) {
return <AuthScreen />;
return <AuthScreen supabase={supabase} />;
}
return (
@@ -114,7 +123,9 @@ function App() {
<nav className="main-nav" aria-label="Hauptnavigation">
{navItems.map((item) => (
<button
className={item.key === activeView ? "nav-item active" : "nav-item"}
className={
item.key === activeView ? "nav-item active" : "nav-item"
}
key={item.key}
onClick={() => setActiveView(item.key)}
type="button"
@@ -135,176 +146,136 @@ function App() {
<span className={connected ? "status-dot ok" : "status-dot"} />
<div>
<strong>{session.user.email}</strong>
<span>{connected ? "Backend verbunden" : "Session aktiv"}</span>
<span>
{connected ? "Backend verbunden" : "Session aktiv"}
</span>
</div>
<button className="ghost-button" onClick={() => supabase.auth.signOut()}>
<button
className="ghost-button"
onClick={() => supabase.auth.signOut()}
>
Abmelden
</button>
</div>
</header>
<section className="content-grid">
<BackendPanel
backendError={backendError}
backendPrincipal={backendPrincipal}
backendStatus={backendStatus}
onRefresh={refreshBackendPrincipal}
<section className="panel backend-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Backend</p>
<h2>Status</h2>
</div>
<button
className="primary-button"
disabled={backendStatus === "loading"}
onClick={refreshBackendPrincipal}
type="button"
>
Pruefen
</button>
</div>
<div className="metric-list">
<Metric label="Status" value={statusLabel(backendStatus)} />
<Metric
label="User-ID"
value={backendPrincipal?.Subject ?? "-"}
/>
<Metric
label="Rolle"
value={backendPrincipal?.Role ?? "-"}
/>
</div>
{backendError ? (
<p className="form-error">{backendError}</p>
) : null}
</section>
<ViewPanel
activeView={activeView}
connected={connected}
token={session.access_token}
/>
<ViewPanel activeView={activeView} connected={connected} />
</section>
</section>
</main>
);
}
function MissingConfig() {
return (
<main className="startup-screen">
<section className="auth-panel">
<p className="eyebrow">Konfiguration</p>
<h1>Frontend Env fehlt</h1>
<p>
`VITE_SUPABASE_URL` und `VITE_SUPABASE_ANON_KEY` muessen gesetzt sein.
</p>
</section>
</main>
);
}
function AuthScreen() {
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<HTMLFormElement>) {
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 (
<main className="auth-screen">
<section className="auth-panel">
<p className="eyebrow">ProxUI</p>
<h1>Anmelden</h1>
<form className="auth-form" onSubmit={submit}>
<label>
E-Mail
<input
autoComplete="email"
onChange={(event) => setEmail(event.target.value)}
required
type="email"
value={email}
/>
</label>
<label>
Passwort
<input
autoComplete={mode === "signin" ? "current-password" : "new-password"}
minLength={6}
onChange={(event) => setPassword(event.target.value)}
required
type="password"
value={password}
/>
</label>
{message ? <p className="form-error">{message}</p> : null}
<div className="auth-actions">
<button className="primary-button" disabled={busy} type="submit">
{mode === "signin" ? "Einloggen" : "Account erstellen"}
</button>
<button
className="ghost-button"
onClick={() => setMode(mode === "signin" ? "signup" : "signin")}
type="button"
>
{mode === "signin" ? "Registrieren" : "Zum Login"}
</button>
</div>
</form>
</section>
</main>
);
}
function BackendPanel({
backendError,
backendPrincipal,
backendStatus,
onRefresh,
}: {
backendError: string;
backendPrincipal: BackendPrincipal | null;
backendStatus: "idle" | "loading" | "ok" | "error";
onRefresh: () => void;
}) {
return (
<section className="panel backend-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Backend</p>
<h2>Profil-Sync</h2>
</div>
<button
className="primary-button"
disabled={backendStatus === "loading"}
onClick={onRefresh}
type="button"
>
Pruefen
</button>
</div>
<div className="metric-list">
<Metric label="Status" value={statusLabel(backendStatus)} />
<Metric label="User-ID" value={backendPrincipal?.Subject ?? "-"} />
<Metric label="Rolle" value={backendPrincipal?.Role ?? "-"} />
</div>
{backendError ? <p className="form-error">{backendError}</p> : null}
</section>
);
}
function ViewPanel({
activeView,
connected,
token,
}: {
activeView: ViewKey;
connected: boolean;
token: string;
}) {
const content = useMemo(() => viewContent(activeView), [activeView]);
const projectID = "00000000-0000-0000-0000-000000000000";
return (
<section className="panel view-panel">
{activeView === "overview" && <Overview connected={connected} />}
{activeView === "vms" && <VMView token={token} projectID={projectID} />}
{activeView === "ssh" && <SSHKeys token={token} tenantID={projectID} />}
{activeView === "audit" && <AuditLog token={token} tenantID={projectID} />}
{activeView === "console" && <ConsoleView token={token} />}
</section>
);
}
function Overview({ connected }: { connected: boolean }) {
return (
<>
<div className="panel-header">
<div>
<p className="eyebrow">{content.state}</p>
<h2>{content.title}</h2>
<p className="eyebrow">Status</p>
<h2>Arbeitsbereich</h2>
</div>
<span className={connected ? "pill ok" : "pill"}>{content.badge}</span>
<span className={connected ? "pill ok" : "pill"}>
{connected ? "Verbunden" : "Warte"}
</span>
</div>
<div className="placeholder-table">
{content.rows.map((row) => (
<div className="placeholder-row" key={row.label}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
))}
<div className="placeholder-row">
<span>Backend</span>
<strong>{connected ? "Online" : "Offline"}</strong>
</div>
<div className="placeholder-row">
<span>RBAC</span>
<strong>Aktiv</strong>
</div>
<div className="placeholder-row">
<span>Worker</span>
<strong>Aktiv</strong>
</div>
<div className="placeholder-row">
<span>Audit</span>
<strong>Aktiv</strong>
</div>
</div>
</section>
</>
);
}
function VMView({ token, projectID }: { token: string; projectID: string }) {
const [refreshKey, setRefreshKey] = useState(0);
return (
<>
<div className="panel-header">
<div>
<p className="eyebrow">VMs</p>
<h2>Virtuelle Maschinen</h2>
</div>
</div>
<VMCreate
token={token}
projectID={projectID}
onCreated={() => setRefreshKey((k) => k + 1)}
/>
<VMList key={refreshKey} token={token} projectID={projectID} />
</>
);
}
@@ -324,81 +295,6 @@ function statusLabel(status: "idle" | "loading" | "ok" | "error") {
return "Nicht geprueft";
}
function viewContent(activeView: ViewKey) {
const values: Record<
ViewKey,
{
badge: string;
rows: Array<{ label: string; value: string }>;
state: string;
title: string;
}
> = {
overview: {
badge: "Live",
state: "Status",
title: "Arbeitsbereich",
rows: [
{ label: "Auth", value: "angebunden" },
{ label: "Profil-Sync", value: "angebunden" },
{ label: "RLS", value: "aktiv" },
],
},
projects: {
badge: "Platzhalter",
state: "E2-T03",
title: "Projekte",
rows: [
{ label: "Schema", value: "projects" },
{ label: "Quotas", value: "project_quotas" },
{ label: "API", value: "offen" },
],
},
vms: {
badge: "Platzhalter",
state: "E2-T05",
title: "Virtuelle Maschinen",
rows: [
{ label: "Schema", value: "vms" },
{ label: "VMID-Allokator", value: "bereit" },
{ label: "Proxmox Client", value: "offen" },
],
},
ssh: {
badge: "Platzhalter",
state: "E2-T05",
title: "SSH-Keys",
rows: [
{ label: "Schema", value: "ssh_keys" },
{ label: "RLS", value: "aktiv" },
{ label: "Upload/API", value: "offen" },
],
},
audit: {
badge: "Platzhalter",
state: "E2-T07",
title: "Audit Log",
rows: [
{ label: "Schema", value: "audit_log" },
{ label: "Append-only", value: "aktiv" },
{ label: "Anzeige", value: "offen" },
],
},
console: {
badge: "Platzhalter",
state: "E8",
title: "Web-Konsole",
rows: [
{ label: "Proxy", value: "Grundgeruest" },
{ label: "Tickets", value: "offen" },
{ label: "noVNC/xterm", value: "offen" },
],
},
};
return values[activeView];
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
+1 -1
View File
@@ -1,4 +1,4 @@
go 1.22
go 1.23
use (
./backend
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS public.templates;
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE public.templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
cluster_id uuid NOT NULL REFERENCES public.clusters(id) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
proxmox_template_vmid int NOT NULL,
proxmox_node text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz,
CONSTRAINT templates_name_not_blank_check CHECK (btrim(name) <> ''),
CONSTRAINT templates_proxmox_vmid_positive_check CHECK (proxmox_template_vmid > 0),
CONSTRAINT templates_cluster_node_vmid_unique UNIQUE (cluster_id, proxmox_node, proxmox_template_vmid)
);
CREATE INDEX templates_cluster_id_idx ON public.templates (cluster_id);
+164
View File
@@ -228,6 +228,170 @@ func (c *Client) GetVMStatus(ctx context.Context, node string, vmid int) (VMStat
return VMStatus{Status: body.Data.Status}, nil
}
func (c *Client) CloneVM(ctx context.Context, node string, templateVMID int, newVMID int, name string) (string, error) {
requestBody := map[string]any{
"newid": newVMID,
"name": name,
}
body, err := json.Marshal(requestBody)
if err != nil {
return "", err
}
response, err := c.Post(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d/clone",
url.PathEscape(node),
templateVMID,
), body)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return "", fmt.Errorf("proxmox returned %s", response.Status)
}
return decodeUPID(response.Body)
}
func (c *Client) ConfigureCloudInit(ctx context.Context, node string, vmid int, cfg CloudInitConfig) (string, error) {
requestBody := map[string]any{
"ciuser": cfg.CIUser,
}
if cfg.SSHKeys != "" {
requestBody["sshkeys"] = cfg.SSHKeys
}
if cfg.IPConfig0 != "" {
requestBody["ipconfig0"] = cfg.IPConfig0
}
if cfg.Hostname != "" {
requestBody["searchdomain"] = cfg.Hostname
}
body, err := json.Marshal(requestBody)
if err != nil {
return "", err
}
response, err := c.Post(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d/config",
url.PathEscape(node),
vmid,
), body)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return "", fmt.Errorf("proxmox returned %s", response.Status)
}
return decodeUPID(response.Body)
}
func (c *Client) StartVM(ctx context.Context, node string, vmid int) (string, error) {
return c.PowerVM(ctx, node, vmid, "start")
}
func (c *Client) StopVM(ctx context.Context, node string, vmid int) (string, error) {
return c.PowerVM(ctx, node, vmid, "stop")
}
func (c *Client) DeleteVM(ctx context.Context, node string, vmid int) (string, error) {
response, err := c.Delete(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d",
url.PathEscape(node),
vmid,
))
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return "", fmt.Errorf("proxmox returned %s", response.Status)
}
return decodeUPID(response.Body)
}
type CloudInitConfig struct {
CIUser string `json:"ciuser"`
SSHKeys string `json:"sshkeys,omitempty"`
IPConfig0 string `json:"ipconfig0,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
func decodeUPID(body io.Reader) (string, error) {
var response struct {
Data string `json:"data"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return "", err
}
if response.Data == "" {
return "", fmt.Errorf("proxmox response missing UPID")
}
return response.Data, nil
}
func (c *Client) GetVNCTicket(ctx context.Context, node string, vmid int) (VNCInfo, error) {
requestBody := map[string]any{}
body, err := json.Marshal(requestBody)
if err != nil {
return VNCInfo{}, err
}
response, err := c.Post(ctx, fmt.Sprintf(
"/nodes/%s/qemu/%d/vncproxy",
url.PathEscape(node),
vmid,
), body)
if err != nil {
return VNCInfo{}, err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
_, _ = io.Copy(io.Discard, response.Body)
return VNCInfo{}, fmt.Errorf("proxmox returned %s", response.Status)
}
var result struct {
Data struct {
Port int `json:"port"`
Ticket string `json:"ticket"`
User string `json:"user"`
Cert string `json:"cert"`
UPID string `json:"upid"`
} `json:"data"`
}
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return VNCInfo{}, err
}
return VNCInfo{
Port: result.Data.Port,
Ticket: result.Data.Ticket,
User: result.Data.User,
Cert: result.Data.Cert,
UPID: result.Data.UPID,
}, nil
}
type VNCInfo struct {
Port int
Ticket string
User string
Cert string
UPID string
}
func (c *Client) Do(ctx context.Context, method string, path string, body []byte) (*http.Response, error) {
var lastErr error
attempts := c.retries + 1