Bump version to 1.0.9

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.
This commit is contained in:
2026-07-08 19:19:50 -04:00
parent 25a31af64c
commit 5d5b6c99a7
13 changed files with 280 additions and 48 deletions
+1 -1
View File
@@ -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
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -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")
+38 -1
View File
@@ -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 {
+1
View File
@@ -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) {
+14 -8
View File
@@ -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)
}
}
}
+1 -22
View File
@@ -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
}
+27 -8
View File
@@ -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
}
+101 -2
View File
@@ -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:
}
}
BIN
View File
Binary file not shown.
+5 -1
View File
@@ -63,7 +63,11 @@ export interface ErrorCodeInfo {
const ERROR_CODES: Record<string, ErrorCodeInfo> = {
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',
+91 -4
View File
@@ -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() {
<TableHead>Host</TableHead>
<TableHead>SSH Key</TableHead>
<TableHead>WoL</TableHead>
<TableHead>WoL Timeout</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
@@ -217,6 +231,15 @@ export default function Machines() {
<span className="text-fg-subtle text-xs">No</span>
)}
</TableCell>
<TableCell>
{m.wol_enabled ? (
<span className="text-xs text-fg-muted">
{m.wake_timeout_seconds}s
</span>
) : (
<span className="text-fg-subtle text-xs"></span>
)}
</TableCell>
<TableCell>
<StatusBadge status={m.status} />
</TableCell>
@@ -230,6 +253,16 @@ export default function Machines() {
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{m.wol_enabled && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleTestWol(m)}
title="Test Wake-on-LAN"
>
<Zap className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
@@ -364,6 +397,60 @@ export default function Machines() {
Enable Wake-on-LAN
</Label>
</div>
{form.wol_enabled && (
<div className="space-y-3 p-3 border border-border rounded-card bg-surface-raised">
<p className="text-xs text-fg-subtle font-medium">Wake-on-LAN Settings</p>
<div className="space-y-1.5">
<Label htmlFor="broadcast_addr">Broadcast Address</Label>
<Input
id="broadcast_addr"
placeholder="255.255.255.255"
value={form.broadcast_addr}
onChange={e =>
setForm({ ...form, broadcast_addr: e.target.value })
}
/>
<p className="text-xs text-fg-subtle">
Leave blank to use 255.255.255.255. Set to your subnet broadcast
address if the server has multiple interfaces.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="wake_timeout_seconds">Wake timeout (s)</Label>
<Input
id="wake_timeout_seconds"
type="number"
min={10}
max={600}
value={form.wake_timeout_seconds}
onChange={e =>
setForm({ ...form, wake_timeout_seconds: Number(e.target.value) })
}
/>
<p className="text-xs text-fg-subtle">
HP Microservers typically need ~180s.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="wake_check_interval_seconds">Check interval (s)</Label>
<Input
id="wake_check_interval_seconds"
type="number"
min={1}
max={60}
value={form.wake_check_interval_seconds}
onChange={e =>
setForm({ ...form, wake_check_interval_seconds: Number(e.target.value) })
}
/>
<p className="text-xs text-fg-subtle">
How often to poll SSH readiness.
</p>
</div>
</div>
</div>
)}
</ModalBody>
<ModalFooter>
<Button