package api import ( "context" "database/sql" "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" "github.com/syncserver/internal/wol" ) type MachineHandler struct { db *sql.DB engine *syncengine.Engine cfg *config.Config } 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}$`) func (h *MachineHandler) List(w http.ResponseWriter, r *http.Request) { repo := models.NewMachineRepository(h.db) ms, err := repo.GetAll() if err != nil { slog.Error("failed to fetch machines", "error", err) writeError(w, http.StatusInternalServerError, "failed to fetch machines") return } out := make([]MachineResponse, len(ms)) for i, m := range ms { out[i] = machineToResp(m) } writeJSON(w, out) } func (h *MachineHandler) Get(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 } writeJSON(w, machineToResp(*m)) } func (h *MachineHandler) Create(w http.ResponseWriter, r *http.Request) { var req MachineRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Name == "" || req.Host == "" { writeError(w, http.StatusBadRequest, "name and host are required") return } if req.Port <= 0 || req.Port > 65535 { writeError(w, http.StatusBadRequest, "invalid port") return } if req.SSHUser == "" { req.SSHUser = "root" } if req.WakeTimeoutSeconds <= 0 { req.WakeTimeoutSeconds = 180 } if req.WakeCheckIntervalSeconds <= 0 { req.WakeCheckIntervalSeconds = 5 } if req.WoLEnabled && req.MACAddress != nil && !macRegex.MatchString(*req.MACAddress) { writeError(w, http.StatusBadRequest, "invalid mac_address format (expected AA:BB:CC:DD:EE:FF)") return } m := &models.Machine{ Name: req.Name, Host: req.Host, Port: req.Port, SSHUser: req.SSHUser, SSHKeyID: req.SSHKeyID, MACAddress: req.MACAddress, WoLEnabled: req.WoLEnabled, BroadcastAddr: req.BroadcastAddr, WakeTimeoutSeconds: req.WakeTimeoutSeconds, WakeCheckIntervalSeconds: req.WakeCheckIntervalSeconds, FingerprintConfirmed: false, Status: "unknown", } repo := models.NewMachineRepository(h.db) id, err := repo.Create(m) if err != nil { slog.Error("failed to create machine", "name", req.Name, "host", req.Host, "error", err) writeError(w, http.StatusInternalServerError, "failed to create machine") return } m.ID = id w.Header().Set("Location", "/api/machines/"+strconv.FormatInt(id, 10)) writeJSON(w, machineToResp(*m), http.StatusCreated) } func (h *MachineHandler) Update(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 } var req MachineRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Name == "" || req.Host == "" { writeError(w, http.StatusBadRequest, "name and host are required") return } if req.Port <= 0 || req.Port > 65535 { writeError(w, http.StatusBadRequest, "invalid port") return } if req.SSHUser == "" { req.SSHUser = "root" } if req.WoLEnabled && req.MACAddress != nil && !macRegex.MatchString(*req.MACAddress) { writeError(w, http.StatusBadRequest, "invalid mac_address format") return } repo := models.NewMachineRepository(h.db) existing, 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 } existing.Name = req.Name existing.Host = req.Host existing.Port = req.Port existing.SSHUser = req.SSHUser existing.SSHKeyID = req.SSHKeyID existing.MACAddress = req.MACAddress existing.WoLEnabled = req.WoLEnabled existing.BroadcastAddr = req.BroadcastAddr if req.WakeTimeoutSeconds > 0 { existing.WakeTimeoutSeconds = req.WakeTimeoutSeconds } if req.WakeCheckIntervalSeconds > 0 { existing.WakeCheckIntervalSeconds = req.WakeCheckIntervalSeconds } if err := repo.Update(existing); err != nil { slog.Error("failed to update machine", "id", id, "error", err) writeError(w, http.StatusInternalServerError, "failed to update machine") return } writeJSON(w, machineToResp(*existing)) } func (h *MachineHandler) TestWoL(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 } if !m.WoLEnabled || m.MACAddress == nil { writeError(w, http.StatusBadRequest, "WoL is not enabled for this machine or MAC address is missing") return } mac, err := wol.ParseMAC(*m.MACAddress) if err != nil { writeError(w, http.StatusBadRequest, "invalid MAC address") return } bcast := "" if m.BroadcastAddr != nil { bcast = *m.BroadcastAddr } if err := wol.Send(mac, bcast); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, map[string]interface{}{"ok": true, "sent": 3}) } func (h *MachineHandler) TestConnection(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 } } result, err := sshmanager.TestSSHConnection( context.Background(), m.Host, m.Port, m.SSHUser, privKeyPath, knownHostsPath, m.FingerprintConfirmed, ) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, TestConnectionResponse{ Success: result.Success, Output: result.Output, Error: result.Error, Fingerprint: result.Fingerprint, }) } func (h *MachineHandler) ApproveFingerprint(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 { Fingerprint string `json:"fingerprint"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Fingerprint == "" { writeError(w, http.StatusBadRequest, "fingerprint is required") return } if err := repo.UpdateFingerprint(id, true, req.Fingerprint); err != nil { slog.Error("failed to update fingerprint", "id", id, "error", err) writeError(w, http.StatusInternalServerError, "failed to update fingerprint") return } sshDir := filepath.Join("/var/lib/syncserver", "ssh") if err := sshmanager.AddKnownHost(sshDir, m.Host, m.Port, []byte(req.Fingerprint)); err != nil { slog.Warn("failed to add known_host entry", "host", m.Host, "error", err) } m.FingerprintConfirmed = true m.HostKeyFingerprint = &req.Fingerprint 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") return } go func() { defer func() { if r := recover(); r != nil { slog.Error("ProbeAllMachines panicked", "panic", r) } }() h.engine.ProbeAllMachines() }() w.WriteHeader(http.StatusAccepted) writeJSON(w, map[string]string{"status": "probing"}) } func (h *MachineHandler) Delete(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) if err := repo.Delete(id); err != nil { slog.Error("failed to delete machine", "id", id, "error", err) writeError(w, http.StatusInternalServerError, "failed to delete machine") return } w.WriteHeader(http.StatusNoContent) } func machineToResp(m models.Machine) MachineResponse { var status string if m.LastSeenAt != nil { status = m.Status + " (last seen " + m.LastSeenAt.Format(time.RFC3339) + ")" } else { status = m.Status } return MachineResponse{ ID: m.ID, Name: m.Name, Host: m.Host, Port: m.Port, SSHUser: m.SSHUser, SSHKeyID: m.SSHKeyID, MACAddress: m.MACAddress, WoLEnabled: m.WoLEnabled, BroadcastAddr: m.BroadcastAddr, WakeTimeoutSeconds: m.WakeTimeoutSeconds, WakeCheckIntervalSeconds: m.WakeCheckIntervalSeconds, FingerprintConfirmed: m.FingerprintConfirmed, HostKeyFingerprint: m.HostKeyFingerprint, Status: status, } } func writeError(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(ErrorResponse{Error: msg}) } func writeJSON(w http.ResponseWriter, data interface{}, codes ...int) { w.Header().Set("Content-Type", "application/json") if len(codes) > 0 { w.WriteHeader(codes[0]) } json.NewEncoder(w).Encode(data) }