diff --git a/Makefile b/Makefile index 7f13bfb..e460f1d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=syncserver -VERSION?=1.0.42 +VERSION?=1.0.43 GO?=go LDFLAGS=-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_FLAGS=CGO_ENABLED=0 diff --git a/cmd/server/main.go b/cmd/server/main.go index e48074a..c1b901b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,7 +20,7 @@ import ( "github.com/syncserver/internal/syncengine" ) -var version = "1.0.42" +var version = "1.0.43" func main() { cfgPath := flag.String("config", "", "Path to config.yaml") diff --git a/internal/api/dto.go b/internal/api/dto.go index 383742c..7d0bf36 100644 --- a/internal/api/dto.go +++ b/internal/api/dto.go @@ -11,6 +11,7 @@ type MachineRequest struct { BroadcastAddr *string `json:"broadcast_addr"` WakeTimeoutSeconds int `json:"wake_timeout_seconds"` WakeCheckIntervalSeconds int `json:"wake_check_interval_seconds"` + ShutdownCommand string `json:"shutdown_command,omitempty"` } type MachineResponse struct { @@ -30,13 +31,20 @@ type MachineResponse struct { Status string `json:"status"` LastSeenAt *string `json:"last_seen_at"` CreatedAt string `json:"created_at"` + ShutdownCommand string `json:"shutdown_command"` } type TestConnectionResponse struct { - Success bool `json:"success"` - Output string `json:"output,omitempty"` - Error string `json:"error,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` + Success bool `json:"success"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +type ShutdownResponse struct { + Success bool `json:"success"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` } type SyncPairRequest struct { diff --git a/internal/api/handlers_machines.go b/internal/api/handlers_machines.go index 3c40c93..a7cba93 100644 --- a/internal/api/handlers_machines.go +++ b/internal/api/handlers_machines.go @@ -96,6 +96,10 @@ func (h *MachineHandler) Create(w http.ResponseWriter, r *http.Request) { return } + shutdownCmd := req.ShutdownCommand + if shutdownCmd == "" { + shutdownCmd = "shutdown now" + } m := &models.Machine{ Name: req.Name, Host: req.Host, @@ -109,6 +113,7 @@ func (h *MachineHandler) Create(w http.ResponseWriter, r *http.Request) { WakeCheckIntervalSeconds: req.WakeCheckIntervalSeconds, FingerprintConfirmed: false, Status: "unknown", + ShutdownCommand: shutdownCmd, } repo := models.NewMachineRepository(h.db) @@ -178,6 +183,9 @@ func (h *MachineHandler) Update(w http.ResponseWriter, r *http.Request) { if req.WakeCheckIntervalSeconds > 0 { existing.WakeCheckIntervalSeconds = req.WakeCheckIntervalSeconds } + if req.ShutdownCommand != "" { + existing.ShutdownCommand = req.ShutdownCommand + } if err := repo.Update(existing); err != nil { slog.Error("failed to update machine", "id", id, "error", err) @@ -224,6 +232,58 @@ func (h *MachineHandler) TestWoL(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{"ok": true, "sent": 3}) } +func (h *MachineHandler) Shutdown(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + repo := models.NewMachineRepository(h.db) + m, err := repo.GetByID(id) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "machine not found") + return + } + if err != nil { + slog.Error("failed to fetch machine", "id", id, "error", err) + writeError(w, http.StatusInternalServerError, "failed to fetch machine") + return + } + + knownHostsPath, err := sshmanager.EnsureKnownHosts(filepath.Join("/var/lib/syncserver", "ssh")) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to ensure known_hosts") + return + } + + privKeyPath := filepath.Join("/var/lib/syncserver", "ssh", "id_ed25519") + if m.SSHKeyID != nil { + sshKeyRepo := models.NewSSHKeyRepository(h.db) + sshKey, err := sshKeyRepo.GetByID(*m.SSHKeyID) + if err == nil && sshKey.PrivateKeyPath != "" { + privKeyPath = sshKey.PrivateKeyPath + } + } + + cmd := m.ShutdownCommand + if cmd == "" { + cmd = "shutdown now" + } + + slog.Info("shutdown requested", "machine_id", id, "name", m.Name, "command", cmd) + result, err := sshmanager.RunRemoteCommand( + context.Background(), m.Host, m.Port, m.SSHUser, + privKeyPath, knownHostsPath, m.FingerprintConfirmed, cmd, + ) + + if err != nil { + slog.Error("shutdown failed", "machine_id", id, "error", err) + writeJSON(w, ShutdownResponse{Success: false, Error: err.Error()}) + return + } + writeJSON(w, ShutdownResponse{Success: result.Success, Output: result.Output, Error: result.Error}) +} + func (h *MachineHandler) TestConnection(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { @@ -466,6 +526,7 @@ func machineToResp(m models.Machine) MachineResponse { Status: m.Status, LastSeenAt: lastSeen, CreatedAt: m.CreatedAt.Format(time.RFC3339), + ShutdownCommand: m.ShutdownCommand, } } diff --git a/internal/api/router.go b/internal/api/router.go index b893531..fcafe88 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -55,6 +55,7 @@ func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Serve r.Put("/{id}", machineHandler.Update) r.Delete("/{id}", machineHandler.Delete) r.Post("/{id}/test-wol", machineHandler.TestWoL) + r.Post("/{id}/shutdown", machineHandler.Shutdown) r.Post("/{id}/test-connection", machineHandler.TestConnection) r.Post("/{id}/approve-fingerprint", machineHandler.ApproveFingerprint) r.Post("/{id}/deploy-keys", machineHandler.DeployKeys) diff --git a/internal/db/migrations/0005_machine_shutdown_command.sql b/internal/db/migrations/0005_machine_shutdown_command.sql new file mode 100644 index 0000000..09a7c01 --- /dev/null +++ b/internal/db/migrations/0005_machine_shutdown_command.sql @@ -0,0 +1,3 @@ +-- 0005_machine_shutdown_command.sql + +ALTER TABLE machines ADD COLUMN shutdown_command TEXT NOT NULL DEFAULT 'shutdown now'; diff --git a/internal/models/machine.go b/internal/models/machine.go index d34e1f9..c5ad5aa 100644 --- a/internal/models/machine.go +++ b/internal/models/machine.go @@ -22,6 +22,7 @@ type Machine struct { Status string `db:"status" json:"status"` LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at"` CreatedAt time.Time `db:"created_at" json:"created_at"` + ShutdownCommand string `db:"shutdown_command" json:"shutdown_command"` } type MachineRepository struct { @@ -36,11 +37,12 @@ func (r *MachineRepository) Create(m *Machine) (int64, error) { res, err := r.db.Exec(` INSERT INTO machines (name, host, port, ssh_user, ssh_key_id, mac_address, wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds, - fingerprint_confirmed, host_key_fingerprint, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + fingerprint_confirmed, host_key_fingerprint, status, shutdown_command) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress, boolToInt(m.WoLEnabled), m.BroadcastAddr, m.WakeTimeoutSeconds, m.WakeCheckIntervalSeconds, boolToInt(m.FingerprintConfirmed), m.HostKeyFingerprint, m.Status, + m.ShutdownCommand, ) if err != nil { return 0, err @@ -52,7 +54,7 @@ func (r *MachineRepository) GetAll() ([]Machine, error) { rows, err := r.db.Query(` SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address, wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds, - fingerprint_confirmed, host_key_fingerprint, status, last_seen_at, created_at + fingerprint_confirmed, host_key_fingerprint, status, last_seen_at, created_at, shutdown_command FROM machines ORDER BY name`) if err != nil { return nil, err @@ -69,7 +71,7 @@ func (r *MachineRepository) GetAll() ([]Machine, error) { err := rows.Scan(&m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID, &mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds, &m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed, - &hostKeyFP, &m.Status, &lastSeen, &m.CreatedAt) + &hostKeyFP, &m.Status, &lastSeen, &m.CreatedAt, &m.ShutdownCommand) if err != nil { return nil, err } @@ -103,12 +105,12 @@ func (r *MachineRepository) GetByID(id int64) (*Machine, error) { err := r.db.QueryRow(` SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address, wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds, - fingerprint_confirmed, host_key_fingerprint, status, last_seen_at, created_at + fingerprint_confirmed, host_key_fingerprint, status, last_seen_at, created_at, shutdown_command FROM machines WHERE id = ?`, id).Scan( &m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID, &mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds, &m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed, - &hostKeyFP, &m.Status, &lastSeen, &m.CreatedAt) + &hostKeyFP, &m.Status, &lastSeen, &m.CreatedAt, &m.ShutdownCommand) if err != nil { return nil, err } @@ -135,12 +137,13 @@ func (r *MachineRepository) Update(m *Machine) error { _, err := r.db.Exec(` UPDATE machines SET name=?, host=?, port=?, ssh_user=?, ssh_key_id=?, mac_address=?, wol_enabled=?, broadcast_addr=?, wake_timeout_seconds=?, - wake_check_interval_seconds=?, fingerprint_confirmed=?, host_key_fingerprint=?, status=?, last_seen_at=? + wake_check_interval_seconds=?, fingerprint_confirmed=?, host_key_fingerprint=?, status=?, last_seen_at=?, + shutdown_command=? WHERE id=?`, m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress, boolToInt(m.WoLEnabled), m.BroadcastAddr, m.WakeTimeoutSeconds, m.WakeCheckIntervalSeconds, boolToInt(m.FingerprintConfirmed), m.HostKeyFingerprint, - m.Status, m.LastSeenAt, m.ID, + m.Status, m.LastSeenAt, m.ShutdownCommand, m.ID, ) return err } diff --git a/internal/sshmanager/shutdown.go b/internal/sshmanager/shutdown.go new file mode 100644 index 0000000..d049ce4 --- /dev/null +++ b/internal/sshmanager/shutdown.go @@ -0,0 +1,59 @@ +package sshmanager + +import ( + "bytes" + "context" + "fmt" + "time" +) + +type ShutdownResult struct { + Success bool + Output string + Error string +} + +func RunRemoteCommand(ctx context.Context, host string, port int, user, privKeyPath, knownHostsPath string, strictHostKeyChecking bool, command string) (*ShutdownResult, error) { + conn, _, err := dialSSH(ctx, host, port, user, privKeyPath, knownHostsPath, strictHostKeyChecking) + if err != nil { + return &ShutdownResult{Success: false, Error: err.Error()}, nil + } + defer conn.Close() + + session, err := conn.NewSession() + if err != nil { + return &ShutdownResult{Success: false, Error: fmt.Sprintf("session: %v", err)}, nil + } + defer session.Close() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- session.Run(command) + }() + + select { + case err := <-done: + if err != nil { + return &ShutdownResult{ + Success: false, + Error: fmt.Sprintf("exec: %v, stderr: %s", err, stderr.String()), + }, nil + } + return &ShutdownResult{ + Success: true, + Output: stdout.String(), + }, nil + case <-ctx.Done(): + return &ShutdownResult{ + Success: false, + Error: "command timed out after 15 seconds (machine may be shutting down)", + }, nil + } +} diff --git a/internal/sshmanager/testconn.go b/internal/sshmanager/testconn.go index 076b056..179091c 100644 --- a/internal/sshmanager/testconn.go +++ b/internal/sshmanager/testconn.go @@ -16,24 +16,24 @@ import ( ) type ConnResult struct { - Success bool - Output string - Error string + Success bool + Output string + Error string Fingerprint string } -func TestSSHConnection(ctx context.Context, host string, port int, user, privKeyPath, knownHostsPath string, strictHostKeyChecking bool) (*ConnResult, error) { +func dialSSH(ctx context.Context, host string, port int, user, privKeyPath, knownHostsPath string, strictHostKeyChecking bool) (*ssh.Client, string, error) { addr := fmt.Sprintf("%s:%d", host, port) auths := []ssh.AuthMethod{} if privKeyPath != "" { key, err := os.ReadFile(privKeyPath) if err != nil { - return nil, fmt.Errorf("reading private key: %w", err) + return nil, "", fmt.Errorf("reading private key: %w", err) } signer, err := ssh.ParsePrivateKey(key) if err != nil { - return nil, fmt.Errorf("parsing private key: %w", err) + return nil, "", fmt.Errorf("parsing private key: %w", err) } auths = append(auths, ssh.PublicKeys(signer)) } @@ -71,23 +71,27 @@ func TestSSHConnection(ctx context.Context, host string, port int, user, privKey conn, err := ssh.Dial("tcp", addr, cfg) if err != nil { if strings.Contains(err.Error(), "known_hosts") || strings.Contains(err.Error(), "host key") { - return &ConnResult{ - Success: false, - Error: fmt.Sprintf("host key verification failed: %v", err), - Fingerprint: capturedFingerprint, - }, nil + return nil, capturedFingerprint, fmt.Errorf("host key verification failed: %v", err) } + return nil, capturedFingerprint, fmt.Errorf("connection failed: %v", err) + } + return conn, capturedFingerprint, nil +} + +func TestSSHConnection(ctx context.Context, host string, port int, user, privKeyPath, knownHostsPath string, strictHostKeyChecking bool) (*ConnResult, error) { + conn, fingerprint, err := dialSSH(ctx, host, port, user, privKeyPath, knownHostsPath, strictHostKeyChecking) + if err != nil { return &ConnResult{ Success: false, - Error: fmt.Sprintf("connection failed: %v", err), - Fingerprint: capturedFingerprint, + Error: err.Error(), + Fingerprint: fingerprint, }, nil } defer conn.Close() session, err := conn.NewSession() if err != nil { - return &ConnResult{Success: false, Error: fmt.Sprintf("session: %v", err), Fingerprint: capturedFingerprint}, nil + return &ConnResult{Success: false, Error: fmt.Sprintf("session: %v", err), Fingerprint: fingerprint}, nil } defer session.Close() @@ -99,13 +103,13 @@ func TestSSHConnection(ctx context.Context, host string, port int, user, privKey return &ConnResult{ Success: false, Error: fmt.Sprintf("exec: %v, stderr: %s", err, stderr.String()), - Fingerprint: capturedFingerprint, + Fingerprint: fingerprint, }, nil } return &ConnResult{ Success: true, Output: stdout.String(), - Fingerprint: capturedFingerprint, + Fingerprint: fingerprint, }, nil } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8ad49ce..ae0a7fa 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -55,6 +55,7 @@ export interface Machine { status: string; last_seen_at: string | null; created_at: string; + shutdown_command: string; } export interface TestConnectionResponse { @@ -135,3 +136,15 @@ export async function deployKeys(machineId: number, options?: DeployKeysOptions) body: options ?? {}, }); } + +export interface ShutdownResponse { + success: boolean; + output?: string; + error?: string; +} + +export async function shutdownMachine(machineId: number): Promise { + return api(`/api/machines/${machineId}/shutdown`, { + method: 'POST', + }); +} diff --git a/web/src/pages/Machines.tsx b/web/src/pages/Machines.tsx index 515e7c1..88bcdcb 100644 --- a/web/src/pages/Machines.tsx +++ b/web/src/pages/Machines.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { api, Machine, SSHKey, TestConnectionResponse, deployKeys, DeployKeysResponse } from '../api/client'; +import { api, Machine, SSHKey, TestConnectionResponse, deployKeys, DeployKeysResponse, shutdownMachine, ShutdownResponse } from '../api/client'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Label } from '@/components/ui/Label'; @@ -27,7 +27,7 @@ import { import { EmptyState } from '@/components/ui/EmptyState'; import { CopyButton } from '@/components/ui/CopyButton'; import { Card } from '@/components/ui/Card'; -import { Pencil, Trash2, Plus, Server, Zap, Cable, KeyRound } from 'lucide-react'; +import { Pencil, Trash2, Plus, Server, Zap, Cable, KeyRound, Power } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { subscribeMachineStatus } from '@/lib/sse'; @@ -44,6 +44,7 @@ type MachineForm = { broadcast_addr: string; wake_timeout_seconds: number; wake_check_interval_seconds: number; + shutdown_command: string; }; const defaultForm: MachineForm = { @@ -58,6 +59,7 @@ const defaultForm: MachineForm = { broadcast_addr: '', wake_timeout_seconds: 180, wake_check_interval_seconds: 5, + shutdown_command: 'shutdown now', }; export default function Machines() { @@ -70,6 +72,7 @@ export default function Machines() { const [probing, setProbing] = useState(false); const [connModal, setConnModal] = useState<{ machine: Machine | null; result: TestConnectionResponse | null; loading: boolean }>({ machine: null, result: null, loading: false }); const [deployModal, setDeployModal] = useState<{ machine: Machine | null; result: DeployKeysResponse | null; loading: boolean }>({ machine: null, result: null, loading: false }); + const [shutdownModal, setShutdownModal] = useState<{ machine: Machine | null; result: ShutdownResponse | null; loading: boolean }>({ machine: null, result: null, loading: false }); useEffect(() => { load(); @@ -119,6 +122,7 @@ export default function Machines() { broadcast_addr: m.broadcast_addr || '', wake_timeout_seconds: m.wake_timeout_seconds || 180, wake_check_interval_seconds: m.wake_check_interval_seconds || 5, + shutdown_command: m.shutdown_command || 'shutdown now', }); setModalOpen(true); } @@ -150,6 +154,7 @@ export default function Machines() { broadcast_addr: form.broadcast_addr || null, wake_timeout_seconds: Number(form.wake_timeout_seconds), wake_check_interval_seconds: Number(form.wake_check_interval_seconds), + shutdown_command: form.shutdown_command || 'shutdown now', }; await api(form.id ? `/api/machines/${form.id}` : '/api/machines', { method: form.id ? 'PUT' : 'POST', @@ -355,6 +360,15 @@ export default function Machines() { )} + + {!shutdownModal.result && ( + + )} + + + + !v && closeConnModal()}>