diff --git a/Makefile b/Makefile index 6b530f7..cba36f5 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=syncserver -VERSION?=1.0.8 +VERSION?=1.0.9 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/bin/syncserver-linux-amd64 b/bin/syncserver-linux-amd64 index 109adbb..1a0a2aa 100755 Binary files a/bin/syncserver-linux-amd64 and b/bin/syncserver-linux-amd64 differ diff --git a/bin/syncserver-linux-arm64 b/bin/syncserver-linux-arm64 index 52fbb68..7652ae7 100755 Binary files a/bin/syncserver-linux-arm64 and b/bin/syncserver-linux-arm64 differ diff --git a/cmd/server/main.go b/cmd/server/main.go index bfda71a..4ed1b01 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.8" +var version = "1.0.9" func main() { cfgPath := flag.String("config", "", "Path to config.yaml") diff --git a/internal/api/handlers_machines.go b/internal/api/handlers_machines.go index b6be959..8d398a3 100644 --- a/internal/api/handlers_machines.go +++ b/internal/api/handlers_machines.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/syncserver/internal/models" + "github.com/syncserver/internal/wol" ) type MachineHandler struct { @@ -74,7 +75,7 @@ func (h *MachineHandler) Create(w http.ResponseWriter, r *http.Request) { req.SSHUser = "root" } if req.WakeTimeoutSeconds <= 0 { - req.WakeTimeoutSeconds = 120 + req.WakeTimeoutSeconds = 180 } if req.WakeCheckIntervalSeconds <= 0 { req.WakeCheckIntervalSeconds = 5 @@ -172,6 +173,42 @@ func (h *MachineHandler) Update(w http.ResponseWriter, r *http.Request) { 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 { diff --git a/internal/api/router.go b/internal/api/router.go index 9a68183..cbd3c1e 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -52,6 +52,7 @@ func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Serve r.Get("/{id}", machineHandler.Get) r.Put("/{id}", machineHandler.Update) r.Delete("/{id}", machineHandler.Delete) + r.Post("/{id}/test-wol", machineHandler.TestWoL) }) r.With(auth.RequireAuth).Route("/sync-pairs", func(r chi.Router) { diff --git a/internal/syncengine/engine.go b/internal/syncengine/engine.go index 857acc6..534e674 100644 --- a/internal/syncengine/engine.go +++ b/internal/syncengine/engine.go @@ -136,20 +136,26 @@ func (e *Engine) Run(ctx context.Context, jobID int64, pairID int64) error { if targetMachine.BroadcastAddr != nil { bcast = *targetMachine.BroadcastAddr } - if err := wol.Send(targetMachine.Host, mac, bcast); err != nil { - slog.Warn("WoL failed", "host", targetMachine.Host, "error", err) + wolErr := wol.Send(mac, bcast) + if wolErr != nil { + slog.Warn("WoL send failed", "host", targetMachine.Host, "error", wolErr) } else { slog.Info("WoL magic packet sent", "host", targetMachine.Host, "mac", *targetMachine.MACAddress) } timeout := time.Duration(targetMachine.WakeTimeoutSeconds) * time.Second interval := time.Duration(targetMachine.WakeCheckIntervalSeconds) * time.Second - if err := wol.WaitUntilReady(jobCtx, targetMachine.Host, remotePort, timeout, interval, false); err != nil { - e.setJobStatus(jobID, "failed") - e.setJobError(jobID, "wol_timeout", err.Error()) - e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "failed", Line: err.Error()}) - return fmt.Errorf("machine not ready: %w", err) - } + if err := wol.WaitUntilReady(jobCtx, targetMachine.Host, remotePort, timeout, interval); err != nil { + e.setJobStatus(jobID, "failed") + if wolErr != nil { + e.setJobError(jobID, "wol_send_failed", wolErr.Error()) + e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "failed", Line: fmt.Sprintf("WoL send failed: %v", wolErr)}) + return fmt.Errorf("WoL send failed: %w", wolErr) + } + e.setJobError(jobID, "wol_timeout", err.Error()) + e.emit(Event{Type: "status", JobID: jobID, Key: "status", Value: "failed", Line: err.Error()}) + return fmt.Errorf("machine not ready: %w", err) + } } } diff --git a/internal/wol/waiter.go b/internal/wol/waiter.go index 996a48e..affa7df 100644 --- a/internal/wol/waiter.go +++ b/internal/wol/waiter.go @@ -4,13 +4,12 @@ import ( "context" "fmt" "net" - "os/exec" "time" ) var ErrTimeout = fmt.Errorf("timeout waiting for machine to respond") -func WaitUntilReady(ctx context.Context, host string, sshPort int, maxWait, checkInterval time.Duration, usePing bool) error { +func WaitUntilReady(ctx context.Context, host string, sshPort int, maxWait, checkInterval time.Duration) error { deadline := time.Now().Add(maxWait) interval := checkInterval @@ -32,13 +31,6 @@ func WaitUntilReady(ctx context.Context, host string, sshPort int, maxWait, chec return nil } - if usePing { - cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", host) - if err := cmd.Run(); err == nil { - return nil - } - } - if time.Now().After(deadline) { return ErrTimeout } @@ -53,16 +45,3 @@ func WaitUntilReady(ctx context.Context, host string, sshPort int, maxWait, chec } } } - -func IsHostReachable(host string, port int, timeout time.Duration) bool { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - addr := fmt.Sprintf("%s:%d", host, port) - dialer := net.Dialer{Timeout: timeout} - conn, err := dialer.DialContext(ctx, "tcp", addr) - if err == nil { - conn.Close() - return true - } - return false -} diff --git a/internal/wol/wol.go b/internal/wol/wol.go index fdf4e4a..4cfee8a 100644 --- a/internal/wol/wol.go +++ b/internal/wol/wol.go @@ -5,6 +5,7 @@ import ( "net" "regexp" "strings" + "syscall" "time" ) @@ -45,7 +46,7 @@ func BuildMagicPacket(mac [6]byte) []byte { return packet } -func Send(addr string, mac [6]byte, broadcastAddr string) error { +func Send(mac [6]byte, broadcastAddr string) error { packet := BuildMagicPacket(mac) udpAddr := &net.UDPAddr{ @@ -65,16 +66,34 @@ func Send(addr string, mac [6]byte, broadcastAddr string) error { } defer conn.Close() + sc, err := conn.SyscallConn() + if err != nil { + return fmt.Errorf("getting syscall conn: %w", err) + } + var setErr error + if err := sc.Control(func(fd uintptr) { + setErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1) + }); err != nil { + return fmt.Errorf("calling control: %w", err) + } + if setErr != nil { + return fmt.Errorf("enabling broadcast: %w", setErr) + } if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil { - return fmt.Errorf("setting broadcast mode: %w", err) + return fmt.Errorf("setting write deadline: %w", err) } - n, err := conn.Write(packet) - if err != nil { - return fmt.Errorf("sending magic packet: %w", err) - } - if n != len(packet) { - return fmt.Errorf("incomplete write: sent %d/%d bytes", n, len(packet)) + for i := 0; i < 3; i++ { + n, err := conn.Write(packet) + if err != nil { + return fmt.Errorf("sending magic packet: %w", err) + } + if n != len(packet) { + return fmt.Errorf("incomplete write: sent %d/%d bytes", n, len(packet)) + } + if i < 2 { + time.Sleep(100 * time.Millisecond) + } } return nil } diff --git a/internal/wol/wol_test.go b/internal/wol/wol_test.go index 94a1a47..cda7b5d 100644 --- a/internal/wol/wol_test.go +++ b/internal/wol/wol_test.go @@ -1,13 +1,16 @@ package wol import ( + "net" + "syscall" "testing" + "time" ) func TestParseMAC(t *testing.T) { tests := []struct { - input string - wantOK bool + input string + wantOK bool wantBytes [6]byte }{ {"AA:BB:CC:DD:EE:FF", true, [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}}, @@ -70,3 +73,99 @@ func TestBuildMagicPacket(t *testing.T) { } } } + +func TestSendInvalidBroadcast(t *testing.T) { + mac, _ := ParseMAC("aa:bb:cc:dd:ee:ff") + err := Send(mac, "not-an-ip") + if err == nil { + t.Error("Send with invalid broadcast address expected error, got nil") + } +} + +func TestSendThreePacketsToListener(t *testing.T) { + mac, _ := ParseMAC("aa:bb:cc:dd:ee:ff") + ln, err := net.ListenPacket("udp", "127.0.0.1:9") + if err != nil { + t.Skipf("skipping test (need port 9, likely requires root): %v", err) + } + defer ln.Close() + + err = Send(mac, "127.0.0.1") + if err != nil { + t.Errorf("Send to 127.0.0.1: expected nil, got %v", err) + } +} + +func TestSendPacketReceived(t *testing.T) { + mac, _ := ParseMAC("aa:bb:cc:dd:ee:ff") + ln, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Skipf("skipping receive test: %v", err) + } + defer ln.Close() + addr := ln.LocalAddr().(*net.UDPAddr) + + deadline := time.Now().Add(5 * time.Second) + errCh := make(chan error, 1) + go func() { + time.Sleep(500 * time.Millisecond) + conn, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: addr.Port}) + if err != nil { + errCh <- err + return + } + sc, err := conn.SyscallConn() + if err != nil { + conn.Close() + errCh <- err + return + } + if err := sc.Control(func(fd uintptr) { + err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1) + }); err != nil { + conn.Close() + errCh <- err + return + } + conn.SetWriteDeadline(time.Now().Add(3 * time.Second)) + packet := BuildMagicPacket(mac) + for i := 0; i < 3; i++ { + conn.Write(packet) + time.Sleep(100 * time.Millisecond) + } + conn.Close() + errCh <- nil + }() + + expected := BuildMagicPacket(mac) + buf := make([]byte, 102) + rcvd := 0 + for rcvd < 3 { + ln.SetReadDeadline(deadline) + n, _, err := ln.ReadFrom(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + break + } + t.Fatalf("unexpected error reading packet %d: %v", rcvd+1, err) + } + if n != len(expected) { + t.Errorf("packet %d: got length %d, want %d", rcvd+1, n, len(expected)) + continue + } + if string(buf[:n]) != string(expected[:n]) { + t.Errorf("packet %d content mismatch", rcvd+1) + } + rcvd++ + } + if rcvd != 3 { + t.Errorf("expected 3 packets, received %d", rcvd) + } + select { + case sendErr := <-errCh: + if sendErr != nil { + t.Logf("sender goroutine error (ignored): %v", sendErr) + } + default: + } +} diff --git a/server b/server index a63c501..10615e0 100755 Binary files a/server and b/server differ diff --git a/web/src/lib/status.ts b/web/src/lib/status.ts index 9f8b825..e70d26e 100644 --- a/web/src/lib/status.ts +++ b/web/src/lib/status.ts @@ -63,7 +63,11 @@ export interface ErrorCodeInfo { const ERROR_CODES: Record = { wol_timeout: { title: "Machine didn't wake up", - hint: 'Check Wake-on-LAN settings, MAC address, and network connectivity', + hint: 'Waited for machine to respond and got no SSH reply. Make sure the server and the target machine are on the same L2 broadcast domain. For HP Microservers and similar hardware, increase the Wake timeout in the machine settings (180s or more).', + }, + wol_send_failed: { + title: 'Wake-on-LAN packet failed to send', + hint: 'The magic packet could not be sent. Check the server logs for details. The server must be on the same broadcast domain as the target machine NIC and must have NET_ADMIN or CAP_NET_RAW capability.', }, rsync_error: { title: 'rsync failed', diff --git a/web/src/pages/Machines.tsx b/web/src/pages/Machines.tsx index e302f75..b38aff2 100644 --- a/web/src/pages/Machines.tsx +++ b/web/src/pages/Machines.tsx @@ -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 } from 'lucide-react'; +import { Pencil, Trash2, Plus, Server, Zap } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -39,6 +39,7 @@ type MachineForm = { ssh_key_id: number | null; mac_address: string; wol_enabled: boolean; + broadcast_addr: string; wake_timeout_seconds: number; wake_check_interval_seconds: number; }; @@ -52,7 +53,8 @@ const defaultForm: MachineForm = { ssh_key_id: null, mac_address: '', wol_enabled: false, - wake_timeout_seconds: 120, + broadcast_addr: '', + wake_timeout_seconds: 180, wake_check_interval_seconds: 5, }; @@ -94,8 +96,9 @@ export default function Machines() { ssh_key_id: m.ssh_key_id, mac_address: m.mac_address || '', wol_enabled: m.wol_enabled, - wake_timeout_seconds: m.wake_timeout_seconds, - wake_check_interval_seconds: m.wake_check_interval_seconds, + broadcast_addr: m.broadcast_addr || '', + wake_timeout_seconds: m.wake_timeout_seconds || 180, + wake_check_interval_seconds: m.wake_check_interval_seconds || 5, }); setModalOpen(true); } @@ -124,6 +127,7 @@ export default function Machines() { ssh_key_id: form.ssh_key_id, mac_address: form.mac_address || null, wol_enabled: Boolean(form.wol_enabled), + broadcast_addr: form.broadcast_addr || null, wake_timeout_seconds: Number(form.wake_timeout_seconds), wake_check_interval_seconds: Number(form.wake_check_interval_seconds), }; @@ -153,6 +157,15 @@ export default function Machines() { } } + async function handleTestWol(m: Machine) { + try { + await api<{ ok: boolean; sent: number }>(`/api/machines/${m.id}/test-wol`, { method: 'POST' }); + toast.success(`Magic packet sent (${m.mac_address})`); + } catch (e: unknown) { + toast.error(`Wake failed: ${(e as Error).message}`); + } + } + function keyLabel(id: number | null) { if (!id) return 'Server Key'; const k = sshKeys.find(k => k.id === id); @@ -194,6 +207,7 @@ export default function Machines() { Host SSH Key WoL + WoL Timeout Status Actions @@ -217,6 +231,15 @@ export default function Machines() { No )} + + {m.wol_enabled ? ( + + {m.wake_timeout_seconds}s + + ) : ( + + )} + @@ -230,6 +253,16 @@ export default function Machines() { > + {m.wol_enabled && ( + + )}