diff --git a/internal/api/handlers_machines.go b/internal/api/handlers_machines.go index 5225be1..6baf583 100644 --- a/internal/api/handlers_machines.go +++ b/internal/api/handlers_machines.go @@ -6,12 +6,14 @@ import ( "encoding/json" "log/slog" "net/http" + "os" "path/filepath" "regexp" "strconv" "time" "github.com/go-chi/chi/v5" + "github.com/syncserver/internal/config" "github.com/syncserver/internal/models" "github.com/syncserver/internal/sshmanager" "github.com/syncserver/internal/syncengine" @@ -21,10 +23,11 @@ import ( type MachineHandler struct { db *sql.DB engine *syncengine.Engine + cfg *config.Config } -func NewMachineHandler(db *sql.DB, engine *syncengine.Engine) *MachineHandler { - return &MachineHandler{db: db, engine: engine} +func NewMachineHandler(db *sql.DB, engine *syncengine.Engine, cfg *config.Config) *MachineHandler { + return &MachineHandler{db: db, engine: engine, cfg: cfg} } var macRegex = regexp.MustCompile(`^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$`) @@ -318,6 +321,118 @@ func (h *MachineHandler) ApproveFingerprint(w http.ResponseWriter, r *http.Reque writeJSON(w, machineToResp(*m)) } +func (h *MachineHandler) DeployKeys(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 + } + + var req struct { + KnownHostsHost string `json:"known_hosts_host"` + IncludeServerKey bool `json:"include_server_key"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + serverKeyPath := h.cfg.SSHDir() + "/id_ed25519" + serverPubKeyPath := h.cfg.SSHDir() + "/id_ed25519.pub" + + if m.SSHKeyID != nil { + sshKeyRepo := models.NewSSHKeyRepository(h.db) + sshKey, err := sshKeyRepo.GetByID(*m.SSHKeyID) + if err == nil && sshKey.PrivateKeyPath != "" { + serverKeyPath = sshKey.PrivateKeyPath + } + } + + serverPubKey := "" + if req.IncludeServerKey { + data, err := os.ReadFile(serverPubKeyPath) + if err == nil { + serverPubKey = string(data) + } + } + + pairRepo := models.NewSyncPairRepository(h.db) + allPairs, err := pairRepo.GetAll() + if err != nil { + slog.Warn("failed to fetch sync pairs for auto-detect", "error", err) + } + + var keys []sshmanager.DeployKey + + for _, pair := range allPairs { + if pair.SourceMachineID != nil && *pair.SourceMachineID == m.ID { + if pair.DestMachineID != nil { + destMachine, err := repo.GetByID(*pair.DestMachineID) + if err == nil && destMachine.SSHKeyID != nil { + skRepo := models.NewSSHKeyRepository(h.db) + sk, err := skRepo.GetByID(*destMachine.SSHKeyID) + if err == nil && sk.PrivateKeyPath != "" { + keys = append(keys, sshmanager.DeployKey{ + LocalPath: sk.PrivateKeyPath, + RemotePath: "/var/lib/syncserver/ssh/keys/" + filepath.Base(sk.PrivateKeyPath), + Mode: 0600, + }) + } + } + } + } + if pair.DestMachineID != nil && *pair.DestMachineID == m.ID { + if pair.SourceMachineID != nil { + srcMachine, err := repo.GetByID(*pair.SourceMachineID) + if err == nil && srcMachine.SSHKeyID != nil { + skRepo := models.NewSSHKeyRepository(h.db) + sk, err := skRepo.GetByID(*srcMachine.SSHKeyID) + if err == nil && sk.PrivateKeyPath != "" { + keys = append(keys, sshmanager.DeployKey{ + LocalPath: sk.PrivateKeyPath, + RemotePath: "/var/lib/syncserver/ssh/keys/" + filepath.Base(sk.PrivateKeyPath), + Mode: 0600, + }) + } + } + } + } + } + + if len(keys) == 0 { + slog.Info("no keys auto-detected for machine, using empty key list", "machine", m.Name) + } + + result, err := sshmanager.DeployKeysToMachine( + context.Background(), + serverKeyPath, + serverPubKey, + m.Host, + m.Port, + m.SSHUser, + keys, + req.KnownHostsHost, + req.IncludeServerKey, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, result) +} + func (h *MachineHandler) Refresh(w http.ResponseWriter, r *http.Request) { if h.engine == nil { writeError(w, http.StatusInternalServerError, "engine not available") diff --git a/internal/api/router.go b/internal/api/router.go index 7e4ea23..b893531 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -34,7 +34,7 @@ func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Serve s := &Server{router: r, cfg: cfg, engine: engine} authHandler := NewAuthHandler(db) - machineHandler := NewMachineHandler(db, engine) + machineHandler := NewMachineHandler(db, engine, cfg) syncPairHandler := NewSyncPairHandler(db) jobHandler := NewJobHandler(db, engine) sseHandler := NewSSEHandler(engine) @@ -57,6 +57,7 @@ func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Serve r.Post("/{id}/test-wol", machineHandler.TestWoL) r.Post("/{id}/test-connection", machineHandler.TestConnection) r.Post("/{id}/approve-fingerprint", machineHandler.ApproveFingerprint) + r.Post("/{id}/deploy-keys", machineHandler.DeployKeys) }) r.With(auth.RequireAuth).Route("/sync-pairs", func(r chi.Router) { diff --git a/internal/sshmanager/deploy.go b/internal/sshmanager/deploy.go new file mode 100644 index 0000000..9f2569c --- /dev/null +++ b/internal/sshmanager/deploy.go @@ -0,0 +1,175 @@ +package sshmanager + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +type DeployKey struct { + LocalPath string + RemotePath string + Mode uint32 +} + +type DeployResult struct { + Success bool + Messages []string + Errors []string +} + +func DeployKeysToMachine(ctx context.Context, serverKeyPath, serverPubKey string, host string, port int, user string, keys []DeployKey, knownHostsHost string, addServerPubKey bool) (*DeployResult, error) { + result := &DeployResult{Success: true} + + addr := fmt.Sprintf("%s:%d", host, port) + + keyData, err := os.ReadFile(serverKeyPath) + if err != nil { + return nil, fmt.Errorf("reading server key: %w", err) + } + signer, err := ssh.ParsePrivateKey(keyData) + if err != nil { + return nil, fmt.Errorf("parsing server key: %w", err) + } + + hostKeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) error { + return nil + } + + cfg := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: hostKeyCallback, + Timeout: 10 * time.Second, + } + + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + conn, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + return nil, fmt.Errorf("connecting to %s: %w", addr, err) + } + defer conn.Close() + + remoteSSHDir := "/var/lib/syncserver/ssh" + remoteKeysDir := filepath.Join(remoteSSHDir, "keys") + + session, err := conn.NewSession() + if err != nil { + return nil, fmt.Errorf("creating session: %w", err) + } + defer session.Close() + + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + + if err := session.Run(fmt.Sprintf("mkdir -p %s && chmod 700 %s", remoteKeysDir, remoteKeysDir)); err != nil { + return nil, fmt.Errorf("creating remote ssh dir: %s %w", stderr.String(), err) + } + result.Messages = append(result.Messages, fmt.Sprintf("Created %s on %s", remoteKeysDir, host)) + + for _, k := range keys { + keyContent, err := os.ReadFile(k.LocalPath) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("reading local key %s: %v", k.LocalPath, err)) + result.Success = false + continue + } + + mode := k.Mode + if mode == 0 { + mode = 0600 + } + + sess2, err := conn.NewSession() + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("session for key upload: %v", err)) + result.Success = false + continue + } + defer sess2.Close() + + sess2.Stdout = &stdout + sess2.Stderr = &stderr + + cmd := fmt.Sprintf("cat > %s && chmod 0%o %s", k.RemotePath, mode, k.RemotePath) + if err := sess2.Start(cmd); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("starting command for %s: %v", k.RemotePath, err)) + result.Success = false + continue + } + + stdin, err := sess2.StdinPipe() + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("stdin pipe for %s: %v", k.RemotePath, err)) + result.Success = false + continue + } + + _, err = stdin.Write(keyContent) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("writing key %s: %v", k.RemotePath, err)) + result.Success = false + continue + } + stdin.Close() + + if err := sess2.Wait(); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("uploading %s: %v (stderr: %s)", k.RemotePath, err, stderr.String())) + result.Success = false + continue + } + + result.Messages = append(result.Messages, fmt.Sprintf("Uploaded %s to %s:%s", filepath.Base(k.LocalPath), host, k.RemotePath)) + } + + if knownHostsHost != "" { + session2, err := conn.NewSession() + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("session for ssh-keyscan: %v", err)) + result.Success = false + } else { + session2.Stdout = &stdout + session2.Stderr = &stderr + err := session2.Run(fmt.Sprintf("ssh-keyscan -H -p %d %s 2>/dev/null >> %s/known_hosts", port, knownHostsHost, remoteSSHDir)) + session2.Close() + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("ssh-keyscan %s: %v (stderr: %s)", knownHostsHost, err, stderr.String())) + result.Success = false + } else { + result.Messages = append(result.Messages, fmt.Sprintf("Populated known_hosts with %s:%d", knownHostsHost, port)) + } + } + } + + if addServerPubKey && serverPubKey != "" { + session3, err := conn.NewSession() + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("session for authorized_keys: %v", err)) + result.Success = false + } else { + session3.Stdout = &stdout + session3.Stderr = &stderr + pubKeyClean := strings.TrimSpace(serverPubKey) + err := session3.Run(fmt.Sprintf("mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys", pubKeyClean)) + session3.Close() + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("adding to authorized_keys: %v (stderr: %s)", err, stderr.String())) + result.Success = false + } else { + result.Messages = append(result.Messages, fmt.Sprintf("Added server public key to %s@%s:~/.ssh/authorized_keys", user, host)) + } + } + } + + return result, nil +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ddb1952..1c344d2 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -116,3 +116,21 @@ export interface SettingsInfo { data_dir: string; ssh_pub_key: string; } + +export interface DeployKeysResponse { + success: boolean; + messages: string[]; + errors: string[]; +} + +export interface DeployKeysOptions { + known_hosts_host?: string; + include_server_key?: boolean; +} + +export async function deployKeys(machineId: number, options?: DeployKeysOptions): Promise { + return api(`/api/machines/${machineId}/deploy-keys`, { + method: 'POST', + body: options ?? { include_server_key: true }, + }); +} diff --git a/web/src/pages/Machines.tsx b/web/src/pages/Machines.tsx index fbe8494..be37896 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 } from '../api/client'; +import { api, Machine, SSHKey, TestConnectionResponse, deployKeys, DeployKeysResponse } from '../api/client'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Label } from '@/components/ui/Label'; @@ -26,7 +26,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 } from 'lucide-react'; +import { Pencil, Trash2, Plus, Server, Zap, Cable, KeyRound } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { subscribeMachineStatus } from '@/lib/sse'; @@ -68,6 +68,7 @@ export default function Machines() { const [loading, setLoading] = useState(false); 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 }); useEffect(() => { load(); @@ -213,6 +214,20 @@ export default function Machines() { setConnModal({ machine: null, result: null, loading: false }); } + async function handleDeployKeys(m: Machine) { + setDeployModal({ machine: m, result: null, loading: true }); + try { + const result = await deployKeys(m.id, { include_server_key: true }); + setDeployModal({ machine: m, result, loading: false }); + } catch (e: unknown) { + setDeployModal({ machine: m, result: { success: false, messages: [], errors: [(e as Error).message] }, loading: false }); + } + } + + function closeDeployModal() { + setDeployModal({ machine: null, result: null, loading: false }); + } + function keyLabel(id: number | null) { if (!id) return 'Server Key'; const k = sshKeys.find(k => k.id === id); @@ -315,6 +330,14 @@ export default function Machines() { > + {m.wol_enabled && ( + + + ); }