5d5b6c99a7
Fix Wake-on-LAN: set SO_BROADCAST on UDP socket, send 3 magic packets, expose broadcast_addr and wake timeout fields in UI, add Test Wake endpoint, surface send errors in job status, bump default wake timeout to 180s.
264 lines
7.3 KiB
Go
264 lines
7.3 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/syncserver/internal/models"
|
|
"github.com/syncserver/internal/wol"
|
|
)
|
|
|
|
type MachineHandler struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewMachineHandler(db *sql.DB) *MachineHandler {
|
|
return &MachineHandler{db: db}
|
|
}
|
|
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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) 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 {
|
|
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)
|
|
}
|