Files
Anand ad43bec538 Merge PBS remotes + cross-cluster migration (backend) and UI modernization pass (frontend)
- internal/pbs: PBS client, datastores/namespaces/snapshots/prune/GC/sync/verify jobs
- connections: type column (pve|pbs), PBS-aware client resolution
- pve/guests: RemoteMigrateGuest + POST .../remote-migrate endpoint for true cross-cluster live migration
- web: shared ListSearch/Textarea primitives, DataTable search modernized
2026-09-10 11:31:57 +05:30

364 lines
12 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"ferrum/internal/pbs"
"ferrum/internal/pve"
)
type connectionDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "pve" | "pbs"
Host string `json:"host"`
Port int `json:"port"`
AuthType string `json:"authType"`
Username string `json:"username,omitempty"`
TokenID string `json:"tokenId,omitempty"`
VerifyTLS bool `json:"verifyTls"`
BehindReverseProxy bool `json:"behindReverseProxy"`
CreatedAt string `json:"createdAt"`
}
type createConnectionRequest struct {
Name string `json:"name"`
Type string `json:"type,omitempty"` // "pve" (default) | "pbs"
Host string `json:"host"`
Port int `json:"port"`
AuthType string `json:"authType"` // "token" | "password"
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
TokenID string `json:"tokenId,omitempty"`
TokenSecret string `json:"tokenSecret,omitempty"`
VerifyTLS bool `json:"verifyTls"`
BehindReverseProxy bool `json:"behindReverseProxy"`
}
func (s *Server) listConnections(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.QueryContext(r.Context(), `
SELECT id, name, type, host, port, auth_type, COALESCE(username,''), COALESCE(token_id,''), verify_tls, behind_reverse_proxy, created_at
FROM connections ORDER BY name`)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
defer rows.Close()
out := []connectionDTO{}
for rows.Next() {
var c connectionDTO
var verify, reverse int
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Host, &c.Port, &c.AuthType, &c.Username, &c.TokenID, &verify, &reverse, &c.CreatedAt); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
c.VerifyTLS = verify == 1
c.BehindReverseProxy = reverse == 1
out = append(out, c)
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) createConnection(w http.ResponseWriter, r *http.Request) {
var req createConnectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Type == "" {
req.Type = "pve"
}
if req.Port == 0 {
req.Port = defaultPortFor(req.Type)
}
if err := validateConnectionRequest(&req); err != nil {
writeErrorMsg(w, http.StatusBadRequest, err.Error())
return
}
var tokenSecretEnc, passwordEnc string
var err error
if req.AuthType == "token" {
if tokenSecretEnc, err = s.secrets.Encrypt(req.TokenSecret); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
} else {
req.AuthType = "password"
if passwordEnc, err = s.secrets.Encrypt(req.Password); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
}
id := uuid.NewString()
now := time.Now().UTC().Format(time.RFC3339)
_, err = s.db.ExecContext(r.Context(), `
INSERT INTO connections (id, name, type, host, port, auth_type, token_id, token_secret_enc, username, password_enc, verify_tls, behind_reverse_proxy, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, req.Name, req.Type, req.Host, req.Port, req.AuthType, req.TokenID, tokenSecretEnc, req.Username, passwordEnc,
boolToInt(req.VerifyTLS), boolToInt(req.BehindReverseProxy), now, now,
)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.audit(r, "connections.create", "connections", req.Name)
slog.Info("connection created", "id", id, "name", req.Name, "type", req.Type, "host", req.Host, "authType", req.AuthType)
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
}
// defaultPortFor returns the conventional API port for a connection type
// when the caller didn't specify one — 8006 for PVE, 8007 for PBS.
func defaultPortFor(connType string) int {
if connType == "pbs" {
return 8007
}
return 8006
}
// updateConnectionRequest mirrors createConnectionRequest but every field is
// optional — only fields the caller sends are changed, so editing just the
// name doesn't force re-entering a password/token.
type updateConnectionRequest struct {
Name *string `json:"name,omitempty"`
Type *string `json:"type,omitempty"`
Host *string `json:"host,omitempty"`
Port *int `json:"port,omitempty"`
AuthType *string `json:"authType,omitempty"`
Username *string `json:"username,omitempty"`
Password *string `json:"password,omitempty"`
TokenID *string `json:"tokenId,omitempty"`
TokenSecret *string `json:"tokenSecret,omitempty"`
VerifyTLS *bool `json:"verifyTls,omitempty"`
BehindReverseProxy *bool `json:"behindReverseProxy,omitempty"`
}
func (s *Server) updateConnection(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req updateConnectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
sets := []string{}
args := []any{}
set := func(col string, val any) {
sets = append(sets, col+" = ?")
args = append(args, val)
}
if req.Name != nil {
set("name", *req.Name)
}
if req.Type != nil {
if *req.Type != "pve" && *req.Type != "pbs" {
writeErrorMsg(w, http.StatusBadRequest, `type must be "pve" or "pbs"`)
return
}
set("type", *req.Type)
}
if req.Host != nil {
set("host", *req.Host)
}
if req.Port != nil {
set("port", *req.Port)
}
if req.AuthType != nil {
if *req.AuthType != "token" && *req.AuthType != "password" {
writeErrorMsg(w, http.StatusBadRequest, `authType must be "token" or "password"`)
return
}
set("auth_type", *req.AuthType)
}
if req.Port != nil && (*req.Port < 1 || *req.Port > 65535) {
writeErrorMsg(w, http.StatusBadRequest, "port must be between 1 and 65535")
return
}
if req.Username != nil {
set("username", *req.Username)
}
if req.TokenID != nil {
set("token_id", *req.TokenID)
}
if req.VerifyTLS != nil {
set("verify_tls", boolToInt(*req.VerifyTLS))
}
if req.BehindReverseProxy != nil {
set("behind_reverse_proxy", boolToInt(*req.BehindReverseProxy))
}
if req.Password != nil && *req.Password != "" {
enc, err := s.secrets.Encrypt(*req.Password)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
set("password_enc", enc)
}
if req.TokenSecret != nil && *req.TokenSecret != "" {
enc, err := s.secrets.Encrypt(*req.TokenSecret)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
set("token_secret_enc", enc)
}
if len(sets) == 0 {
writeErrorMsg(w, http.StatusBadRequest, "no fields to update")
return
}
set("updated_at", time.Now().UTC().Format(time.RFC3339))
query := "UPDATE connections SET "
for i, s := range sets {
if i > 0 {
query += ", "
}
query += s
}
query += " WHERE id = ?"
args = append(args, id)
if _, err := s.db.ExecContext(r.Context(), query, args...); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
// Drop any cached ticket so edited credentials (or a demoted verify-TLS
// setting) take effect immediately instead of up to ticketTTL later.
s.connections.Invalidate(id)
s.audit(r, "connections.update", "connections", id)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) deleteConnection(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM connections WHERE id = ?`, id); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
// Deleting a connection is meant to revoke access to it now — drop the
// cached client so a stale ticket can't keep authenticating afterward.
s.connections.Invalidate(id)
s.audit(r, "connections.delete", "connections", id)
slog.Info("connection deleted", "id", id)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) testConnection(w http.ResponseWriter, r *http.Request) {
var req createConnectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Type == "" {
req.Type = "pve"
}
if req.Port == 0 {
req.Port = defaultPortFor(req.Type)
}
if err := validateConnectionRequest(&req); err != nil {
writeErrorMsg(w, http.StatusBadRequest, err.Error())
return
}
if req.Type == "pbs" {
client := pbs.New(req.Host, req.Port, pbs.WithInsecureSkipVerify(!req.VerifyTLS))
if req.AuthType == "token" {
client.WithAPIToken(req.TokenID, req.TokenSecret)
} else if err := client.Login(r.Context(), req.Username, req.Password); err != nil {
writeErrorMsg(w, http.StatusBadGateway, "login failed: "+err.Error())
return
}
version, err := client.Version(r.Context())
if err != nil {
writeErrorMsg(w, http.StatusBadGateway, "connection failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": version.Version})
return
}
client := pve.New(req.Host, req.Port, pve.WithInsecureSkipVerify(!req.VerifyTLS))
if req.AuthType == "token" {
client.WithAPIToken(req.TokenID, req.TokenSecret)
} else if err := client.Login(r.Context(), req.Username, req.Password); err != nil {
writeErrorMsg(w, http.StatusBadGateway, "login failed: "+err.Error())
return
}
version, err := client.Version(r.Context())
if err != nil {
writeErrorMsg(w, http.StatusBadGateway, "connection failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": version.Version})
}
// validateConnectionRequest enforces the request shape shared by create and
// test: a name, a host, a sane port, and a known auth type with credentials.
func validateConnectionRequest(req *createConnectionRequest) error {
if req.Name == "" || req.Host == "" {
return fmt.Errorf("name and host are required")
}
if req.Type != "" && req.Type != "pve" && req.Type != "pbs" {
return fmt.Errorf(`type must be "pve" or "pbs"`)
}
if req.Port < 1 || req.Port > 65535 {
return fmt.Errorf("port must be between 1 and 65535")
}
if req.AuthType != "token" && req.AuthType != "password" {
return fmt.Errorf(`authType must be "token" or "password"`)
}
if req.AuthType == "token" && (req.TokenID == "" || req.TokenSecret == "") {
return fmt.Errorf("tokenId and tokenSecret are required for token auth")
}
if req.AuthType == "password" && req.Username == "" {
return fmt.Errorf("username is required for password auth")
}
return nil
}
// clientFor builds an authenticated pve.Client for a stored connection,
// reusing the resolver's cached ticket when one is live.
func (s *Server) clientFor(ctx context.Context, id string) (*pve.Client, error) {
return s.connections.ClientFor(ctx, id)
}
// pbsClientFor builds an authenticated pbs.Client for a stored connection,
// rejecting one that isn't type=pbs so a pve-typed connection id can't be
// used to reach the PBS handlers (and vice versa via clientFor).
func (s *Server) pbsClientFor(ctx context.Context, id string) (*pbs.Client, error) {
connType, err := s.connectionType(ctx, id)
if err != nil {
return nil, err
}
if connType != "pbs" {
return nil, fmt.Errorf("connection %s is not a PBS connection", id)
}
return s.connections.PBSClientFor(ctx, id)
}
// connectionType looks up a stored connection's type ("pve" | "pbs").
func (s *Server) connectionType(ctx context.Context, id string) (string, error) {
var connType string
err := s.db.QueryRowContext(ctx, `SELECT type FROM connections WHERE id = ?`, id).Scan(&connType)
return connType, err
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}