Wake: a target may list several broadcast addresses
Config keeps broadcast (one) and adds broadcasts (a list); exactly one must be present. Wake.Addresses() returns Broadcast then Broadcasts; checkWake errors on both-set, neither-or-empty, and non-host:port entries. Waker sends to every address in order, logging/past a failure so one dead address does not stop the others, and returns false only when none could be sent. Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -7,11 +7,24 @@ import (
|
||||
)
|
||||
|
||||
// Wake is the magic-wake pattern sent to a host to rouse it: its MAC, the
|
||||
// broadcast address to aim at, and how long to wait for the answer.
|
||||
// broadcast address(s) to aim at, and how long to wait for the answer. A host
|
||||
// that roams between networks names several, so Broadcast (one) and Broadcasts
|
||||
// (a list) are alternatives: exactly one must be set.
|
||||
type Wake struct {
|
||||
MAC string `toml:"mac"`
|
||||
Broadcast string `toml:"broadcast"`
|
||||
Wait Duration `toml:"wait"`
|
||||
MAC string `toml:"mac"`
|
||||
Broadcast string `toml:"broadcast"`
|
||||
Broadcasts []string `toml:"broadcasts"`
|
||||
Wait Duration `toml:"wait"`
|
||||
}
|
||||
|
||||
// Addresses is Broadcast (when set) followed by Broadcasts: the ordered list to
|
||||
// send wake packets to, never empty for a parsed config.
|
||||
func (w *Wake) Addresses() []string {
|
||||
addrs := make([]string, 0, 1+len(w.Broadcasts))
|
||||
if w.Broadcast != "" {
|
||||
addrs = append(addrs, w.Broadcast)
|
||||
}
|
||||
return append(addrs, w.Broadcasts...)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -40,8 +53,19 @@ func (c *Config) checkWake() *Error {
|
||||
return &Error{Field: wakeField + ".mac", Msg: "must be a MAC address"}
|
||||
}
|
||||
|
||||
if _, _, err := net.SplitHostPort(w.Broadcast); err != nil || w.Broadcast == "" {
|
||||
broadcastSet := w.Broadcast != ""
|
||||
broadcastsSet := len(w.Broadcasts) > 0
|
||||
switch {
|
||||
case broadcastSet && broadcastsSet:
|
||||
return &Error{Field: wakeField + ".broadcasts", Msg: "choose broadcast or broadcasts, not both"}
|
||||
case !broadcastSet && !broadcastsSet:
|
||||
return &Error{Field: wakeField + ".broadcast", Msg: "must be a non-empty host:port"}
|
||||
default:
|
||||
for _, a := range w.Broadcasts {
|
||||
if _, _, err := net.SplitHostPort(a); err != nil || a == "" {
|
||||
return &Error{Field: wakeField + ".broadcasts", Msg: "must be a non-empty host:port"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if w.Wait.Duration == 0 {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package wake_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/crossbar/internal/wake"
|
||||
)
|
||||
|
||||
// listener returns a UDP socket on 127.0.0.1 and a channel that gets one value per datagram.
|
||||
func listener(t *testing.T) (string, <-chan []byte) {
|
||||
t.Helper()
|
||||
pc, err := net.ListenPacket("udp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { pc.Close() })
|
||||
got := make(chan []byte, 4)
|
||||
go func() {
|
||||
buf := make([]byte, 256)
|
||||
for {
|
||||
n, _, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b := make([]byte, n)
|
||||
copy(b, buf[:n])
|
||||
got <- b
|
||||
}
|
||||
}()
|
||||
return pc.LocalAddr().String(), got
|
||||
}
|
||||
|
||||
func expectPacket(t *testing.T, name string, got <-chan []byte) {
|
||||
t.Helper()
|
||||
select {
|
||||
case b := <-got:
|
||||
if len(b) != 102 {
|
||||
t.Errorf("%s: got %d bytes, want a 102-byte magic packet", name, len(b))
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Errorf("%s: no packet within two seconds", name)
|
||||
}
|
||||
}
|
||||
|
||||
// A target may name several broadcast addresses (a host that roams between two networks): the
|
||||
// packet goes to every one of them, and one address that cannot be resolved does not stop the
|
||||
// others.
|
||||
func TestWakeSendsToEveryBroadcast(t *testing.T) {
|
||||
a, gotA := listener(t)
|
||||
b, gotB := listener(t)
|
||||
h := &fakeHealth{after: 1 << 30} // never healthy
|
||||
w := wake.New(map[string]wake.Target{"titan": {MAC: "aa:bb:cc:dd:ee:ff", Broadcasts: []string{a, "256.1.1.1:9", b}, Wait: 300 * time.Millisecond}}, h)
|
||||
w.PollEvery(20 * time.Millisecond)
|
||||
if w.Wake(t.Context(), "titan") {
|
||||
t.Errorf("Wake must report false when the host never comes up")
|
||||
}
|
||||
expectPacket(t, "first address", gotA)
|
||||
expectPacket(t, "third address, after an unresolvable second", gotB)
|
||||
}
|
||||
|
||||
// The single-address form keeps working, alone or together with the list.
|
||||
func TestWakeBroadcastAndBroadcastsCombine(t *testing.T) {
|
||||
a, gotA := listener(t)
|
||||
b, gotB := listener(t)
|
||||
h := &fakeHealth{after: 1 << 30} // never healthy
|
||||
w := wake.New(map[string]wake.Target{"titan": {MAC: "aa:bb:cc:dd:ee:ff", Broadcast: a, Broadcasts: []string{b}, Wait: 300 * time.Millisecond}}, h)
|
||||
w.PollEvery(20 * time.Millisecond)
|
||||
w.Wake(t.Context(), "titan")
|
||||
expectPacket(t, "Broadcast", gotA)
|
||||
expectPacket(t, "Broadcasts[0]", gotB)
|
||||
}
|
||||
+41
-8
@@ -7,15 +7,25 @@ package wake
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Target describes how to wake one named host.
|
||||
// log reports a broadcast that fails to resolve or send. Wake continues past
|
||||
// such failures (rule: one dead address must not stop the others), so this is
|
||||
// the only place the package logs; the address and error are not request data.
|
||||
var log = slog.New(slog.Default().Handler())
|
||||
|
||||
// Target describes how to wake one named host. Broadcast is the single-address
|
||||
// form (as before); Broadcasts names more than one (a host that roams between
|
||||
// networks). Wake sends to Broadcast (if set) and then each of Broadcasts.
|
||||
type Target struct {
|
||||
MAC, Broadcast string // MAC "aa:bb:cc:dd:ee:ff" (any separator, any case); Broadcast "host:port"
|
||||
Wait time.Duration
|
||||
MAC string
|
||||
Broadcast string
|
||||
Broadcasts []string
|
||||
Wait time.Duration
|
||||
}
|
||||
|
||||
// Health reports whether a named host is currently healthy. Implementations must
|
||||
@@ -67,6 +77,26 @@ func Send(mac, broadcast string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendAll emits one magic packet for mac to broadcast (if non-empty) and then
|
||||
// to each address in the rest, in order. An address that fails to resolve or
|
||||
// send is logged and does not stop the others; it reports whether at least one
|
||||
// packet went out.
|
||||
func sendAll(mac, broadcast string, rest []string) bool {
|
||||
addrs := append([]string{broadcast}, rest...)
|
||||
sent := false
|
||||
for _, addr := range addrs {
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if err := Send(mac, addr); err != nil {
|
||||
log.Error("wake broadcast failed", "addr", addr, "err", err)
|
||||
continue
|
||||
}
|
||||
sent = true
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
// Waker wakes named hosts at most once per wait window and waits for the health
|
||||
// table to report them healthy. It is safe for concurrent Wake calls.
|
||||
type Waker struct {
|
||||
@@ -95,10 +125,13 @@ func (w *Waker) PollEvery(d time.Duration) {
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// Wake sends a magic packet for host if none was sent in the last Wait, then
|
||||
// polls health until the host is healthy, the wait elapses, or ctx is done. It
|
||||
// returns true only when the host becomes healthy, and false for an unknown
|
||||
// host, on timeout, or when ctx ends first.
|
||||
// Wake sends a magic packet for host to every broadcast address — Broadcast
|
||||
// (if set) then each of Broadcasts, in order — if none was sent in the last
|
||||
// Wait, then polls health until the host is healthy, the wait elapses, or ctx is
|
||||
// done. A broadcast that fails to resolve or send is logged and does not stop
|
||||
// the others; Wake returns true only when the host becomes healthy, and false
|
||||
// for an unknown host, on timeout, when ctx ends first, or when no address
|
||||
// could be sent to.
|
||||
func (w *Waker) Wake(ctx context.Context, host string) bool {
|
||||
w.mu.Lock()
|
||||
target, ok := w.targets[host]
|
||||
@@ -110,7 +143,7 @@ func (w *Waker) Wake(ctx context.Context, host string) bool {
|
||||
if last, sent := w.lastSent[host]; !sent || now.Sub(last) >= target.Wait {
|
||||
w.lastSent[host] = now
|
||||
w.mu.Unlock()
|
||||
if err := Send(target.MAC, target.Broadcast); err != nil {
|
||||
if !sendAll(target.MAC, target.Broadcast, target.Broadcasts) {
|
||||
return false
|
||||
}
|
||||
w.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user