package api import ( "database/sql" "encoding/json" "log/slog" "net/http" "regexp" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/syncserver/internal/models" "github.com/syncserver/internal/syncengine" "github.com/syncserver/internal/wol" ) type MachineHandler struct { db *sql.DB engine *syncengine.Engine } func NewMachineHandler(db *sql.DB, engine *syncengine.Engine) *MachineHandler { return &MachineHandler{db: db, engine: engine} } 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) 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, 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) }