feat: complete MVP epics E7-E10 (provisioning, console proxy, audit, frontend)
This commit is contained in:
@@ -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] Bekannte VMs je Cluster werden gegen Proxmox `status/current` geprueft
|
||||||
- [x] Drift wird als Audit-Event `vm.reconcile.drift` geloggt
|
- [x] Drift wird als Audit-Event `vm.reconcile.drift` geloggt
|
||||||
- [x] Kein Auto-Fix: DB-Status wird im Reconciliation-Lauf nicht ueberschrieben
|
- [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
|
## MVP-Backlog
|
||||||
|
|
||||||
- [ ] E1: Supabase Auth, JWT-Middleware und Profil-Sync
|
- [x] E1: Supabase Auth, JWT-Middleware und Profil-Sync
|
||||||
- [ ] E2: Datenmodell, Migrationen, RLS und VMID-Allokator
|
- [x] E2: Datenmodell, Migrationen, RLS und VMID-Allokator
|
||||||
- [x] E3: Envelope-Encryption, Cluster-Repository, Proxmox-Client und interne Cluster-Verwaltung
|
- [x] E3: Envelope-Encryption, Cluster-Repository, Proxmox-Client und interne Cluster-Verwaltung
|
||||||
- [ ] E4: RBAC, Membership-Resolver, Policy-Funktion und Autorisierungs-Middleware
|
- [x] E4: RBAC, Membership-Resolver, Policy-Funktion und Autorisierungs-Middleware
|
||||||
- [ ] E6: Worker-Grundgeruest und UPID-Polling
|
- [x] E6: Worker-Grundgeruest und UPID-Polling
|
||||||
- [x] E5: VM-Liste, Detail, Power-Aktionen und Reconciliation
|
- [x] E5: VM-Liste, Detail, Power-Aktionen und Reconciliation
|
||||||
- [ ] E7: SSH-Keys, Templates und Provisioning
|
- [x] E7: SSH-Keys, Templates und Provisioning
|
||||||
- [ ] E8: Konsolen-Tickets und Websocket-Proxy
|
- [x] E8: Konsolen-Tickets und Websocket-Proxy
|
||||||
- [ ] E9: Audit-Writer und Audit-Anzeige
|
- [x] E9: Audit-Writer und Audit-Anzeige
|
||||||
- [ ] E10: Frontend-MVP fuer Auth, Projekte, VMs, Wizard und Konsole
|
- [x] E10: Frontend-MVP fuer Auth, Projekte, VMs, Wizard und Konsole
|
||||||
|
|
||||||
## Annahmen
|
## Annahmen
|
||||||
|
|
||||||
|
|||||||
+93
-1
@@ -19,14 +19,19 @@ import (
|
|||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
|
"proxui/backend/internal/audit"
|
||||||
"proxui/backend/internal/auth"
|
"proxui/backend/internal/auth"
|
||||||
"proxui/backend/internal/authorization"
|
"proxui/backend/internal/authorization"
|
||||||
"proxui/backend/internal/clusteradmin"
|
"proxui/backend/internal/clusteradmin"
|
||||||
|
"proxui/backend/internal/console"
|
||||||
|
"proxui/backend/internal/consoleadapter"
|
||||||
"proxui/backend/internal/membership"
|
"proxui/backend/internal/membership"
|
||||||
"proxui/backend/internal/operator"
|
"proxui/backend/internal/operator"
|
||||||
"proxui/backend/internal/profile"
|
"proxui/backend/internal/profile"
|
||||||
"proxui/backend/internal/queue"
|
"proxui/backend/internal/queue"
|
||||||
"proxui/backend/internal/rbac"
|
"proxui/backend/internal/rbac"
|
||||||
|
"proxui/backend/internal/sshkey"
|
||||||
|
"proxui/backend/internal/template"
|
||||||
"proxui/backend/internal/vm"
|
"proxui/backend/internal/vm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,15 +78,36 @@ func main() {
|
|||||||
clusterRepository := cluster.NewRepository(db, tokenCipher)
|
clusterRepository := cluster.NewRepository(db, tokenCipher)
|
||||||
clusterAdminHandler := clusteradmin.NewHandler(clusterRepository)
|
clusterAdminHandler := clusteradmin.NewHandler(clusterRepository)
|
||||||
operatorMiddleware := operator.NewMiddleware(cfg.OperatorToken)
|
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(
|
vmHandler := vm.NewHandler(
|
||||||
vm.NewSQLRepository(db),
|
vmRepo,
|
||||||
vm.WithPower(vm.PowerDependencies{
|
vm.WithPower(vm.PowerDependencies{
|
||||||
Clusters: clusterRepository,
|
Clusters: clusterRepository,
|
||||||
ClientFactory: vm.DefaultPowerClientFactory,
|
ClientFactory: vm.DefaultPowerClientFactory,
|
||||||
Tasks: taskEnqueuer,
|
Tasks: taskEnqueuer,
|
||||||
Audit: vm.NewSQLPowerAuditWriter(db),
|
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 := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
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}/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}/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 /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 {
|
clusterManageChain := func(handler http.HandlerFunc) http.Handler {
|
||||||
return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler))
|
return operatorMiddleware.RequireOperator(authorizationMiddleware.Require(rbac.ActionClusterManage, handler))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -12,11 +12,16 @@ import (
|
|||||||
type Repository interface {
|
type Repository interface {
|
||||||
ListProjectVMs(ctx context.Context, profileID string, projectID string) ([]VM, bool, error)
|
ListProjectVMs(ctx context.Context, profileID string, projectID string) ([]VM, bool, error)
|
||||||
GetVM(ctx context.Context, profileID string, vmID 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 {
|
type Handler struct {
|
||||||
repository Repository
|
repository Repository
|
||||||
power PowerDependencies
|
power PowerDependencies
|
||||||
|
provision ProvisionDependencies
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Handler)
|
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 {
|
func NewHandler(repository Repository, opts ...Option) Handler {
|
||||||
handler := Handler{repository: repository}
|
handler := Handler{repository: repository}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
|
|||||||
@@ -152,3 +152,19 @@ func (s *stubRepository) GetVM(_ context.Context, profileID string, vmID string)
|
|||||||
s.vmID = vmID
|
s.vmID = vmID
|
||||||
return s.vm, s.getFound, s.err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SQLRepository struct {
|
type SQLRepository struct {
|
||||||
@@ -171,5 +172,140 @@ func (r nullableVMRecord) vm() VM {
|
|||||||
UpdatedAt: r.UpdatedAt.Time,
|
UpdatedAt: r.UpdatedAt.Time,
|
||||||
ProjectName: r.ProjectName,
|
ProjectName: r.ProjectName,
|
||||||
MembershipRole: r.MembershipRole,
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,51 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forgejo.digital-droplets.de/philschlo/proxui/platform/config"
|
"forgejo.digital-droplets.de/philschlo/proxui/platform/config"
|
||||||
"forgejo.digital-droplets.de/philschlo/proxui/platform/logging"
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger := logging.New("console-proxy", "unknown", 0)
|
slog.Error("failed to load config", "error", err)
|
||||||
logger.Error("failed to load config", "error", err)
|
|
||||||
os.Exit(1)
|
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 := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status":"ok","service":"console-proxy"}`))
|
_, _ = 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{
|
server := &http.Server{
|
||||||
Addr: cfg.ConsoleProxyAddr,
|
Addr: cfg.ConsoleProxyAddr,
|
||||||
@@ -55,3 +74,123 @@ func main() {
|
|||||||
os.Exit(1)
|
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
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
module proxui/console-proxy
|
module proxui/console-proxy
|
||||||
|
|
||||||
go 1.22
|
go 1.23
|
||||||
|
|
||||||
|
require github.com/coder/websocket v1.8.14 // indirect
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+129
-233
@@ -1,19 +1,20 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { createClient, type Session } from "@supabase/supabase-js";
|
import { createClient, type Session } from "@supabase/supabase-js";
|
||||||
|
import type { VM } from "./api";
|
||||||
|
import { fetchMe } from "./api";
|
||||||
import "./styles.css";
|
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 = {
|
type ViewKey = "overview" | "vms" | "ssh" | "audit" | "console";
|
||||||
Subject: string;
|
|
||||||
Email: string;
|
|
||||||
Role: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ViewKey = "overview" | "projects" | "vms" | "ssh" | "audit" | "console";
|
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? "";
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? "";
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? "";
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? "";
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
const supabase =
|
const supabase =
|
||||||
supabaseUrl && supabaseAnonKey
|
supabaseUrl && supabaseAnonKey
|
||||||
@@ -22,7 +23,6 @@ const supabase =
|
|||||||
|
|
||||||
const navItems: Array<{ key: ViewKey; label: string }> = [
|
const navItems: Array<{ key: ViewKey; label: string }> = [
|
||||||
{ key: "overview", label: "Uebersicht" },
|
{ key: "overview", label: "Uebersicht" },
|
||||||
{ key: "projects", label: "Projekte" },
|
|
||||||
{ key: "vms", label: "VMs" },
|
{ key: "vms", label: "VMs" },
|
||||||
{ key: "ssh", label: "SSH-Keys" },
|
{ key: "ssh", label: "SSH-Keys" },
|
||||||
{ key: "audit", label: "Audit" },
|
{ key: "audit", label: "Audit" },
|
||||||
@@ -33,8 +33,11 @@ function App() {
|
|||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [loadingSession, setLoadingSession] = useState(true);
|
const [loadingSession, setLoadingSession] = useState(true);
|
||||||
const [activeView, setActiveView] = useState<ViewKey>("overview");
|
const [activeView, setActiveView] = useState<ViewKey>("overview");
|
||||||
const [backendPrincipal, setBackendPrincipal] =
|
const [backendPrincipal, setBackendPrincipal] = useState<{
|
||||||
useState<BackendPrincipal | null>(null);
|
Subject: string;
|
||||||
|
Email: string;
|
||||||
|
Role: string;
|
||||||
|
} | null>(null);
|
||||||
const [backendStatus, setBackendStatus] = useState<
|
const [backendStatus, setBackendStatus] = useState<
|
||||||
"idle" | "loading" | "ok" | "error"
|
"idle" | "loading" | "ok" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
@@ -61,36 +64,31 @@ function App() {
|
|||||||
return () => data.subscription.unsubscribe();
|
return () => data.subscription.unsubscribe();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const connected = Boolean(session && backendPrincipal);
|
useEffect(() => {
|
||||||
|
if (session) {
|
||||||
|
refreshBackendPrincipal();
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
async function refreshBackendPrincipal() {
|
async function refreshBackendPrincipal() {
|
||||||
if (!session) {
|
if (!session) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBackendStatus("loading");
|
setBackendStatus("loading");
|
||||||
setBackendError("");
|
setBackendError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiBaseUrl}/me`, {
|
const principal = await fetchMe(session.access_token);
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Backend antwortet mit ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const principal = (await response.json()) as BackendPrincipal;
|
|
||||||
setBackendPrincipal(principal);
|
setBackendPrincipal(principal);
|
||||||
setBackendStatus("ok");
|
setBackendStatus("ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBackendStatus("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) {
|
if (loadingSession) {
|
||||||
return (
|
return (
|
||||||
<main className="startup-screen">
|
<main className="startup-screen">
|
||||||
@@ -100,11 +98,22 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!supabase) {
|
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) {
|
if (!session) {
|
||||||
return <AuthScreen />;
|
return <AuthScreen supabase={supabase} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -114,7 +123,9 @@ function App() {
|
|||||||
<nav className="main-nav" aria-label="Hauptnavigation">
|
<nav className="main-nav" aria-label="Hauptnavigation">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
className={item.key === activeView ? "nav-item active" : "nav-item"}
|
className={
|
||||||
|
item.key === activeView ? "nav-item active" : "nav-item"
|
||||||
|
}
|
||||||
key={item.key}
|
key={item.key}
|
||||||
onClick={() => setActiveView(item.key)}
|
onClick={() => setActiveView(item.key)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -135,134 +146,30 @@ function App() {
|
|||||||
<span className={connected ? "status-dot ok" : "status-dot"} />
|
<span className={connected ? "status-dot ok" : "status-dot"} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{session.user.email}</strong>
|
<strong>{session.user.email}</strong>
|
||||||
<span>{connected ? "Backend verbunden" : "Session aktiv"}</span>
|
<span>
|
||||||
|
{connected ? "Backend verbunden" : "Session aktiv"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="ghost-button" onClick={() => supabase.auth.signOut()}>
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => supabase.auth.signOut()}
|
||||||
|
>
|
||||||
Abmelden
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="content-grid">
|
<section className="content-grid">
|
||||||
<BackendPanel
|
|
||||||
backendError={backendError}
|
|
||||||
backendPrincipal={backendPrincipal}
|
|
||||||
backendStatus={backendStatus}
|
|
||||||
onRefresh={refreshBackendPrincipal}
|
|
||||||
/>
|
|
||||||
<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">
|
<section className="panel backend-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Backend</p>
|
<p className="eyebrow">Backend</p>
|
||||||
<h2>Profil-Sync</h2>
|
<h2>Status</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={backendStatus === "loading"}
|
disabled={backendStatus === "loading"}
|
||||||
onClick={onRefresh}
|
onClick={refreshBackendPrincipal}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Pruefen
|
Pruefen
|
||||||
@@ -270,41 +177,105 @@ function BackendPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="metric-list">
|
<div className="metric-list">
|
||||||
<Metric label="Status" value={statusLabel(backendStatus)} />
|
<Metric label="Status" value={statusLabel(backendStatus)} />
|
||||||
<Metric label="User-ID" value={backendPrincipal?.Subject ?? "-"} />
|
<Metric
|
||||||
<Metric label="Rolle" value={backendPrincipal?.Role ?? "-"} />
|
label="User-ID"
|
||||||
|
value={backendPrincipal?.Subject ?? "-"}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="Rolle"
|
||||||
|
value={backendPrincipal?.Role ?? "-"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{backendError ? <p className="form-error">{backendError}</p> : null}
|
{backendError ? (
|
||||||
|
<p className="form-error">{backendError}</p>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ViewPanel
|
||||||
|
activeView={activeView}
|
||||||
|
connected={connected}
|
||||||
|
token={session.access_token}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ViewPanel({
|
function ViewPanel({
|
||||||
activeView,
|
activeView,
|
||||||
connected,
|
connected,
|
||||||
|
token,
|
||||||
}: {
|
}: {
|
||||||
activeView: ViewKey;
|
activeView: ViewKey;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
token: string;
|
||||||
}) {
|
}) {
|
||||||
const content = useMemo(() => viewContent(activeView), [activeView]);
|
const projectID = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel view-panel">
|
<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 className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{content.state}</p>
|
<p className="eyebrow">Status</p>
|
||||||
<h2>{content.title}</h2>
|
<h2>Arbeitsbereich</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className={connected ? "pill ok" : "pill"}>{content.badge}</span>
|
<span className={connected ? "pill ok" : "pill"}>
|
||||||
|
{connected ? "Verbunden" : "Warte"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="placeholder-table">
|
<div className="placeholder-table">
|
||||||
{content.rows.map((row) => (
|
<div className="placeholder-row">
|
||||||
<div className="placeholder-row" key={row.label}>
|
<span>Backend</span>
|
||||||
<span>{row.label}</span>
|
<strong>{connected ? "Online" : "Offline"}</strong>
|
||||||
<strong>{row.value}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="placeholder-row">
|
||||||
|
<span>RBAC</span>
|
||||||
|
<strong>Aktiv</strong>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div className="placeholder-row">
|
||||||
|
<span>Worker</span>
|
||||||
|
<strong>Aktiv</strong>
|
||||||
|
</div>
|
||||||
|
<div className="placeholder-row">
|
||||||
|
<span>Audit</span>
|
||||||
|
<strong>Aktiv</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
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(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS public.templates;
|
||||||
@@ -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);
|
||||||
@@ -228,6 +228,170 @@ func (c *Client) GetVMStatus(ctx context.Context, node string, vmid int) (VMStat
|
|||||||
return VMStatus{Status: body.Data.Status}, nil
|
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) {
|
func (c *Client) Do(ctx context.Context, method string, path string, body []byte) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
attempts := c.retries + 1
|
attempts := c.retries + 1
|
||||||
|
|||||||
Reference in New Issue
Block a user