mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 16:01:16 +00:00
eac99628bb
Reserve dedicated worker allocation for the control plane, auto-promote spare capacity when needed, and keep risky or quarantined mailboxes off clean shared workers.
693 lines
21 KiB
Go
693 lines
21 KiB
Go
// Admin endpoints for the SSH-managed worker lifecycle.
|
|
//
|
|
// Flow:
|
|
// 1. Admin POSTs to /admin/workers with host/port/user + name.
|
|
// Backend generates an ed25519 keypair, encrypts the private key via the
|
|
// cipher service under the platform identity, stores the row in
|
|
// `pending` state, and returns the public key to paste into the VPS's
|
|
// ~/.ssh/authorized_keys.
|
|
// 2. Admin pastes the pubkey, then POSTs /admin/workers/:id/test.
|
|
// Backend opens an SSH session and runs `true`. First successful connect
|
|
// pins the host fingerprint (TOFU).
|
|
// 3. Admin POSTs /admin/workers/:id/install. Backend uploads the project's
|
|
// install-worker.sh + a per-worker env file and runs the installer.
|
|
// install_state moves pending → provisioning → installed.
|
|
// 4. Admin can then restart / update / uninstall / rotate-keys / get logs /
|
|
// get a live status snapshot.
|
|
//
|
|
// The encrypted private key is never returned over the API.
|
|
|
|
package handler
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/worker_orchestrator"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// DTOs
|
|
|
|
type adminCreateWorkerRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Notes string `json:"notes"`
|
|
WorkerType string `json:"worker_type" binding:"required,oneof=shared dedicated"`
|
|
FreeTier bool `json:"free_tier"`
|
|
SSHHost string `json:"ssh_host" binding:"required"`
|
|
SSHPort int `json:"ssh_port"`
|
|
SSHUser string `json:"ssh_user"`
|
|
GenerateEnrollURL bool `json:"generate_enrollment_token"`
|
|
}
|
|
|
|
type adminCreateWorkerResponse struct {
|
|
*models.Worker
|
|
// SSHPublicKey is what the admin pastes into the VPS's authorized_keys.
|
|
SSHPublicKey string `json:"ssh_public_key"`
|
|
EnrollmentToken string `json:"enrollment_token,omitempty"`
|
|
EnrollmentTokenTTL int `json:"enrollment_token_ttl_seconds,omitempty"`
|
|
}
|
|
|
|
// handlers
|
|
|
|
// AdminCreateWorker creates a new SSH-managed worker.
|
|
//
|
|
// POST /admin/workers
|
|
func (h *Handler) AdminCreateWorker(c *gin.Context) {
|
|
if h.WorkerOrchestrator == nil || h.WorkerRepo == nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "worker orchestrator not configured"))
|
|
return
|
|
}
|
|
|
|
var req adminCreateWorkerRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
// A worker is never born dedicated: dedicated capacity is allocated by the
|
|
// control plane (by promoting a spare shared worker). Admins create shared
|
|
// workers and may convert one later via AdminConvertWorkerToDedicated.
|
|
if !repository.IsClientRequestableTier(req.WorkerType) {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "dedicated workers cannot be created directly; dedicated capacity is allocated automatically by the control plane — create a shared worker and convert it if needed"))
|
|
return
|
|
}
|
|
if req.SSHPort == 0 {
|
|
req.SSHPort = 22
|
|
}
|
|
if req.SSHUser == "" {
|
|
req.SSHUser = "root"
|
|
}
|
|
|
|
pub, priv, err := keypairGen()
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "failed to generate keypair"))
|
|
return
|
|
}
|
|
encPriv, err := h.WorkerOrchestrator.EncryptPrivateKey(c.Request.Context(), priv)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "failed to encrypt key"))
|
|
return
|
|
}
|
|
|
|
workerID := uuid.New()
|
|
|
|
var (
|
|
enrollToken string
|
|
enrollHash string
|
|
enrollExpiresAt *time.Time
|
|
)
|
|
if req.GenerateEnrollURL {
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "failed to generate enrollment token"))
|
|
return
|
|
}
|
|
enrollToken = "wmenroll_" + hex.EncodeToString(raw)
|
|
sum := sha256.Sum256([]byte(enrollToken))
|
|
enrollHash = hex.EncodeToString(sum[:])
|
|
exp := time.Now().Add(2 * time.Hour)
|
|
enrollExpiresAt = &exp
|
|
}
|
|
|
|
if err := h.WorkerRepo.CreateWorker(c.Request.Context(), repository.CreateWorkerInput{
|
|
ID: workerID,
|
|
Name: req.Name,
|
|
Notes: req.Notes,
|
|
IPAddr: req.SSHHost,
|
|
WorkerType: models.WorkerType(req.WorkerType),
|
|
FreeTier: req.FreeTier,
|
|
SSHHost: req.SSHHost,
|
|
SSHPort: req.SSHPort,
|
|
SSHUser: req.SSHUser,
|
|
SSHPublicKey: pub,
|
|
SSHPrivateKeyEncrypted: encPriv,
|
|
EnrollmentTokenHash: enrollHash,
|
|
EnrollmentTokenExpires: enrollExpiresAt,
|
|
}); err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "failed to create worker: "+err.Error()))
|
|
return
|
|
}
|
|
|
|
w, xerr := h.fetchWorker(c, workerID)
|
|
if xerr != nil {
|
|
return
|
|
}
|
|
|
|
resp := adminCreateWorkerResponse{
|
|
Worker: w,
|
|
SSHPublicKey: pub,
|
|
}
|
|
if enrollToken != "" {
|
|
resp.EnrollmentToken = enrollToken
|
|
resp.EnrollmentTokenTTL = int(2 * time.Hour / time.Second)
|
|
}
|
|
h.audit(c, models.AuditActionCreate, models.AuditEntityWorker, &workerID, map[string]string{
|
|
"name": req.Name,
|
|
"ssh_host": req.SSHHost,
|
|
"tier": req.WorkerType,
|
|
})
|
|
c.JSON(http.StatusCreated, resp)
|
|
}
|
|
|
|
// AdminListSSHWorkers lists all workers with their install state and last_seen.
|
|
//
|
|
// GET /admin/workers/managed
|
|
func (h *Handler) AdminListSSHWorkers(c *gin.Context) {
|
|
if h.WorkerRepo == nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "worker repo not configured"))
|
|
return
|
|
}
|
|
workers, err := h.WorkerRepo.ListWorkersDetail(c.Request.Context())
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
// Hydrate tags in one round-trip so the UI gets them in the list response.
|
|
ptrs := make([]*models.Worker, len(workers))
|
|
for i := range workers {
|
|
ptrs[i] = &workers[i]
|
|
}
|
|
_ = h.WorkerRepo.HydrateWorkerTags(c.Request.Context(), ptrs)
|
|
c.JSON(http.StatusOK, gin.H{"data": workers})
|
|
}
|
|
|
|
// AdminGetSSHWorker returns full worker detail.
|
|
//
|
|
// GET /admin/workers/:id/managed
|
|
func (h *Handler) AdminGetSSHWorker(c *gin.Context) {
|
|
w, xerr := h.parseAndFetch(c)
|
|
if xerr != nil {
|
|
return
|
|
}
|
|
tags, err := h.WorkerRepo.GetWorkerTags(c.Request.Context(), w.ID)
|
|
if err == nil {
|
|
w.Tags = tags
|
|
}
|
|
c.JSON(http.StatusOK, w)
|
|
}
|
|
|
|
type setTagsBody struct {
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
// AdminSetWorkerTags replaces a worker's tag set.
|
|
//
|
|
// PUT /admin/workers/:id/tags
|
|
func (h *Handler) AdminSetWorkerTags(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body setTagsBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
// Normalize: trim, lowercase, dedupe, drop empties. The DB constraint
|
|
// rejects garbage tags too; this is just to avoid 500s on the happy path.
|
|
seen := map[string]struct{}{}
|
|
tags := make([]string, 0, len(body.Tags))
|
|
for _, t := range body.Tags {
|
|
t = strings.ToLower(strings.TrimSpace(t))
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[t]; ok {
|
|
continue
|
|
}
|
|
seen[t] = struct{}{}
|
|
tags = append(tags, t)
|
|
}
|
|
if err := h.WorkerRepo.SetWorkerTags(c.Request.Context(), id, tags); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, err.Error()))
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionUpdate, models.AuditEntityWorker, &id, map[string]string{
|
|
"tags": strings.Join(tags, ","),
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"ok": true, "tags": tags})
|
|
}
|
|
|
|
// AdminListWorkerTags returns every distinct tag in use, for autocomplete.
|
|
//
|
|
// GET /admin/workers/tags
|
|
func (h *Handler) AdminListWorkerTags(c *gin.Context) {
|
|
tags, err := h.WorkerRepo.ListAllWorkerTags(c.Request.Context())
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": tags})
|
|
}
|
|
|
|
// AdminTestWorker runs a no-op SSH command. Pins the host fingerprint on
|
|
// first success.
|
|
//
|
|
// POST /admin/workers/:id/test
|
|
func (h *Handler) AdminTestWorker(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.WorkerOrchestrator.TestConnection(c.Request.Context(), id); err != nil {
|
|
h.audit(c, models.AuditActionTest, models.AuditEntityWorker, &id, map[string]string{"ok": "false", "error": err.Error()})
|
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionTest, models.AuditEntityWorker, &id, map[string]string{"ok": "true"})
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// AdminInstallWorker uploads installer + env file and runs it.
|
|
//
|
|
// POST /admin/workers/:id/install
|
|
func (h *Handler) AdminInstallWorker(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.WorkerOrchestrator.Install(c.Request.Context(), id); err != nil {
|
|
h.audit(c, models.AuditActionInstall, models.AuditEntityWorker, &id, map[string]string{"ok": "false", "error": err.Error()})
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionInstall, models.AuditEntityWorker, &id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
func (h *Handler) AdminRestartWorker(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.WorkerOrchestrator.Restart(c.Request.Context(), id); err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionRestart, models.AuditEntityWorker, &id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
func (h *Handler) AdminUpdateWorkerImage(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.WorkerOrchestrator.Update(c.Request.Context(), id); err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionUpgrade, models.AuditEntityWorker, &id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
func (h *Handler) AdminUninstallWorker(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.WorkerOrchestrator.Uninstall(c.Request.Context(), id); err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionUninstall, models.AuditEntityWorker, &id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
func (h *Handler) AdminWorkerStatusLive(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
st, err := h.WorkerOrchestrator.Status(c.Request.Context(), id)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, st)
|
|
}
|
|
|
|
func (h *Handler) AdminWorkerLogs(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
lines, _ := strconv.Atoi(c.Query("lines"))
|
|
logs, err := h.WorkerOrchestrator.TailLogs(c.Request.Context(), id, lines)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
|
}
|
|
|
|
func (h *Handler) AdminRotateWorkerKeys(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
newPub, err := h.WorkerOrchestrator.RotateKeys(c.Request.Context(), id)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionRotateKeys, models.AuditEntityWorker, &id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"ssh_public_key": newPub})
|
|
}
|
|
|
|
func (h *Handler) AdminSystemUpdate(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
r, err := h.WorkerOrchestrator.SystemUpdate(c.Request.Context(), id)
|
|
if err != nil {
|
|
// Return the partial output to the client so they can see what failed
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
meta := map[string]string{}
|
|
if r != nil && r.RebootRequired {
|
|
meta["reboot_required"] = "true"
|
|
}
|
|
h.audit(c, models.AuditActionSystemUpdate, models.AuditEntityWorker, &id, meta)
|
|
c.JSON(http.StatusOK, r)
|
|
}
|
|
|
|
func (h *Handler) AdminRebootWorker(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.WorkerOrchestrator.RebootWorker(c.Request.Context(), id); err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionReboot, models.AuditEntityWorker, &id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// AdminConvertWorkerToDedicated drains a shared worker's accounts to a
|
|
// target worker, flips its worker_type to dedicated, and binds it to a
|
|
// user/org via dedicated_worker_assignments.
|
|
//
|
|
// Body:
|
|
//
|
|
// {
|
|
// "user_id": "uuid", // the org/user that gets exclusive use
|
|
// "subscription_id": "uuid", // their active sub
|
|
// "drain_to_worker_id": "uuid|null" // optional: target for evicted accounts.
|
|
// // null = let assignment service pick
|
|
// // per-account
|
|
// }
|
|
//
|
|
// The whole operation is sequential, not transactional across services —
|
|
// if a step fails mid-flight the worker is left in a half-converted state
|
|
// and the admin needs to investigate. That's acceptable because the steps
|
|
// are individually idempotent: re-running the endpoint with the same
|
|
// inputs converges.
|
|
type convertToDedicatedBody struct {
|
|
UserID string `json:"user_id" binding:"required"`
|
|
SubscriptionID string `json:"subscription_id" binding:"required"`
|
|
DrainToWorkerID *string `json:"drain_to_worker_id"`
|
|
}
|
|
|
|
func (h *Handler) AdminConvertWorkerToDedicated(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var body convertToDedicatedBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
userID, err := uuid.Parse(body.UserID)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid user_id"))
|
|
return
|
|
}
|
|
subID, err := uuid.Parse(body.SubscriptionID)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid subscription_id"))
|
|
return
|
|
}
|
|
|
|
w, err := h.WorkerRepo.GetWorkerDetail(c.Request.Context(), id)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
if w == nil {
|
|
errx.JSON(c, errx.New(errx.NotFound, "worker not found"))
|
|
return
|
|
}
|
|
if w.WorkerType == models.WorkerTypeDedicated {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "worker is already dedicated"))
|
|
return
|
|
}
|
|
|
|
// Step 1: drain existing accounts to a target. If drain_to_worker_id is
|
|
// supplied, move them all there; otherwise we leave reassignment to the
|
|
// assignment service which picks per-account based on the source org.
|
|
accountIDs, err := h.WorkerRepo.GetEmailAccountsByWorkerID(c.Request.Context(), id)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "list accounts: "+err.Error()))
|
|
return
|
|
}
|
|
movedTo := ""
|
|
if body.DrainToWorkerID != nil && *body.DrainToWorkerID != "" {
|
|
targetID, perr := uuid.Parse(*body.DrainToWorkerID)
|
|
if perr != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid drain_to_worker_id"))
|
|
return
|
|
}
|
|
if targetID == id {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "drain target must be a different worker"))
|
|
return
|
|
}
|
|
for _, aid := range accountIDs {
|
|
if err := h.WorkerRepo.UpdateEmailAccountWorker(c.Request.Context(), aid, targetID); err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "drain "+aid.String()+": "+err.Error()))
|
|
return
|
|
}
|
|
_ = h.WorkerRepo.IncrementAccountCount(c.Request.Context(), targetID)
|
|
_ = h.WorkerRepo.DecrementAccountCount(c.Request.Context(), id)
|
|
}
|
|
movedTo = targetID.String()
|
|
} else if len(accountIDs) > 0 {
|
|
// We don't auto-pick targets here because the right per-account choice
|
|
// depends on each account's owning org. If the admin wants that, they
|
|
// should pick a single drain target.
|
|
errx.JSON(c, errx.New(errx.BadRequest, "worker has accounts; supply drain_to_worker_id to evict them first"))
|
|
return
|
|
}
|
|
|
|
// Step 2: flip worker_type to dedicated.
|
|
if err := h.WorkerRepo.SetWorkerType(c.Request.Context(), id, models.WorkerTypeDedicated); err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "set type: "+err.Error()))
|
|
return
|
|
}
|
|
|
|
// Step 3: bind to user/org. Idempotent via CreateDedicatedAssignmentIfNotExists.
|
|
created, err := h.WorkerRepo.CreateDedicatedAssignmentIfNotExists(c.Request.Context(), &models.DedicatedWorkerAssignment{
|
|
ID: uuid.New(),
|
|
WorkerID: id,
|
|
UserID: userID,
|
|
SubscriptionID: subID,
|
|
AssignedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "create assignment: "+err.Error()))
|
|
return
|
|
}
|
|
|
|
h.audit(c, "convert_to_dedicated", models.AuditEntityWorker, &id, map[string]string{
|
|
"user_id": userID.String(),
|
|
"subscription_id": subID.String(),
|
|
"drained_to": movedTo,
|
|
"accounts_moved": itoa(len(accountIDs)),
|
|
"new_assignment": boolStr(created),
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"ok": true,
|
|
"accounts_drained": len(accountIDs),
|
|
"new_assignment": created,
|
|
})
|
|
}
|
|
|
|
func itoa(n int) string { return strconv.Itoa(n) }
|
|
func boolStr(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|
|
|
|
type preflightBody struct {
|
|
Host string `json:"host" binding:"required"`
|
|
Port int `json:"port"`
|
|
}
|
|
|
|
type preflightResult struct {
|
|
OK bool `json:"ok"`
|
|
LatencyMS int64 `json:"latency_ms,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// AdminPreflightWorker checks TCP reachability of host:port before the
|
|
// admin commits to creating a worker row. Catches typos and firewall
|
|
// problems while the wizard is still open — much better UX than failing
|
|
// later at the SSH test step.
|
|
//
|
|
// Note: this does NOT attempt SSH handshake. We don't have credentials at
|
|
// this stage. A successful TCP dial just means "something is listening
|
|
// there"; the SSH test runs after the worker is created and the admin has
|
|
// pasted the generated pubkey.
|
|
func (h *Handler) AdminPreflightWorker(c *gin.Context) {
|
|
var body preflightBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
port := body.Port
|
|
if port == 0 {
|
|
port = 22
|
|
}
|
|
addr := net.JoinHostPort(body.Host, strconv.Itoa(port))
|
|
|
|
start := time.Now()
|
|
conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(c.Request.Context(), "tcp", addr)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, preflightResult{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
_ = conn.Close()
|
|
c.JSON(http.StatusOK, preflightResult{OK: true, LatencyMS: time.Since(start).Milliseconds()})
|
|
}
|
|
|
|
type setRiskPoolBody struct {
|
|
RiskPool string `json:"risk_pool" binding:"required,oneof=clean risky quarantine"`
|
|
}
|
|
|
|
// AdminSetWorkerRiskPool moves a shared worker into a different risk pool.
|
|
// The rebalancer will redistribute mailboxes on the next tick — admins
|
|
// don't need to migrate accounts manually after this.
|
|
func (h *Handler) AdminSetWorkerRiskPool(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body setRiskPoolBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
if err := h.WorkerRepo.SetWorkerRiskPool(c.Request.Context(), id, models.WorkerRiskPool(body.RiskPool)); err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionUpdate, models.AuditEntityWorker, &id, map[string]string{
|
|
"risk_pool": body.RiskPool,
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
func (h *Handler) AdminDeleteSSHWorker(c *gin.Context) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Best-effort uninstall first. Ignore errors — the row deletion happens
|
|
// regardless so an unreachable worker doesn't leave orphan records.
|
|
_ = h.WorkerOrchestrator.Uninstall(c.Request.Context(), id)
|
|
if err := h.WorkerRepo.DeleteWorker(c.Request.Context(), id); err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionDelete, models.AuditEntityWorker, &id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// helpers
|
|
|
|
func (h *Handler) parseID(c *gin.Context) (uuid.UUID, bool) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid worker ID"))
|
|
return uuid.Nil, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// audit fires an admin audit log entry. Writes to admin_audit_log (the
|
|
// table the /admin/audit-logs viewer queries) so every action on workers,
|
|
// credentials, and releases is browsable alongside ban/unban/etc.
|
|
//
|
|
// Fire-and-forget — AdminService spawns its own goroutine so the response
|
|
// is never blocked. Safe to call with nil entityID and/or nil metadata.
|
|
func (h *Handler) audit(c *gin.Context, action models.AuditAction, entity models.AuditEntityType, entityID *uuid.UUID, metadata map[string]string) {
|
|
if h.AdminService == nil {
|
|
return
|
|
}
|
|
adminID := middleware.GetAdminUserID(c)
|
|
if adminID == nil {
|
|
return
|
|
}
|
|
var details map[string]any
|
|
if len(metadata) > 0 {
|
|
details = make(map[string]any, len(metadata))
|
|
for k, v := range metadata {
|
|
details[k] = v
|
|
}
|
|
}
|
|
h.AdminService.LogAdminAction(
|
|
c.Request.Context(),
|
|
*adminID,
|
|
string(action),
|
|
string(entity),
|
|
entityID,
|
|
details,
|
|
c.ClientIP(),
|
|
c.GetHeader("User-Agent"),
|
|
)
|
|
}
|
|
|
|
func (h *Handler) parseAndFetch(c *gin.Context) (*models.Worker, error) {
|
|
id, ok := h.parseID(c)
|
|
if !ok {
|
|
return nil, errors.New("bad id")
|
|
}
|
|
return h.fetchWorker(c, id)
|
|
}
|
|
|
|
func (h *Handler) fetchWorker(c *gin.Context, id uuid.UUID) (*models.Worker, error) {
|
|
w, err := h.WorkerRepo.GetWorkerDetail(c.Request.Context(), id)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, err.Error()))
|
|
return nil, err
|
|
}
|
|
if w == nil {
|
|
errx.JSON(c, errx.New(errx.NotFound, "worker not found"))
|
|
return nil, errors.New("not found")
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
func keypairGen() (pub, priv string, err error) {
|
|
return worker_orchestrator.GenerateKeypair()
|
|
}
|