Skip to content

Commit a4132a9

Browse files
committed
cmd/wol-proxy: add wol-proxy
1 parent 9fc0431 commit a4132a9

File tree

4 files changed

+278
-1
lines changed

4 files changed

+278
-1
lines changed

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,11 @@ release:
8686
echo "tagging new version: $$new_tag"; \
8787
git tag "$$new_tag";
8888

89+
GOOS ?= $(shell go env GOOS 2>/dev/null || echo linux)
90+
GOARCH ?= $(shell go env GOARCH 2>/dev/null || echo amd64)
91+
wol-proxy: $(BUILD_DIR)
92+
@echo "Building wol-proxy"
93+
go build -o $(BUILD_DIR)/wol-proxy-$(GOOS)-$(GOARCH) cmd/wol-proxy/wol-proxy.go
94+
8995
# Phony targets
90-
.PHONY: all clean ui mac linux windows simple-responder simple-responder-windows test test-all test-dev
96+
.PHONY: all clean ui mac linux windows simple-responder simple-responder-windows test test-all test-dev wol-proxy

cmd/wol-proxy/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# wol-proxy
2+
3+
wol-proxy automatically wakes up a suspended llama-swap server using Wake-on-LAN when requests are received.
4+
5+
When a request arrives and llama-swap is unavailable, wol-proxy sends a WOL packet and holds the request until the server becomes available. If the server doesn't respond within the timeout period (default: 60 seconds), the request is dropped.
6+
7+
This utility helps conserve energy by allowing GPU-heavy servers to remain suspended when idle, as they can consume hundreds of watts even when not actively processing requests.
8+
9+
## Usage
10+
11+
```shell
12+
# minimal
13+
$ ./wol-proxy -mac 88:88:88:88:88:87:88 -upstream http://192.168.1.13:8080
14+
15+
# everything
16+
$ ./wol-proxy -mac 88:88:88:88:87:88 -upstream http://192.168.1.13:8080 \
17+
# use debug log level
18+
-log debug \
19+
# altenerative listening port
20+
-listen localhost:9999 \
21+
# seconds to hold requests waiting for upstream to be ready
22+
-timeout 30
23+
```
24+
25+
## API
26+
27+
`GET /status` - that's it. Everything else is proxied to the upstream server.

cmd/wol-proxy/wol-proxy.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"flag"
7+
"fmt"
8+
"log/slog"
9+
"net"
10+
"net/http"
11+
"net/http/httputil"
12+
"net/url"
13+
"os"
14+
"os/signal"
15+
"sync"
16+
"time"
17+
)
18+
19+
var (
20+
flagMac = flag.String("mac", "", "mac address to send WoL packet to")
21+
flagUpstream = flag.String("upstream", "", "upstream proxy address to send requests to")
22+
flagListen = flag.String("listen", ":8080", "listen address to listen on")
23+
flagLog = flag.String("log", "info", "log level (debug, info, warn, error)")
24+
flagTimeout = flag.Int("timeout", 60, "seconds requests wait for upstream response before failing")
25+
)
26+
27+
func main() {
28+
flag.Parse()
29+
30+
switch *flagLog {
31+
case "debug":
32+
slog.SetLogLoggerLevel(slog.LevelDebug)
33+
case "info":
34+
slog.SetLogLoggerLevel(slog.LevelInfo)
35+
case "warn":
36+
slog.SetLogLoggerLevel(slog.LevelWarn)
37+
case "error":
38+
slog.SetLogLoggerLevel(slog.LevelError)
39+
default:
40+
slog.Error("invalid log level", "logLevel", *flagLog)
41+
}
42+
43+
// Validate flags
44+
if *flagListen == "" {
45+
slog.Error("listen address is required")
46+
return
47+
}
48+
49+
if *flagMac == "" {
50+
slog.Error("mac address is required")
51+
return
52+
}
53+
54+
if *flagTimeout < 1 {
55+
slog.Error("timeout must be greater than 0")
56+
return
57+
}
58+
59+
var upstreamURL *url.URL
60+
var err error
61+
// validate mac address
62+
if _, err = net.ParseMAC(*flagMac); err != nil {
63+
slog.Error("invalid mac address", "error", err)
64+
}
65+
66+
if *flagUpstream == "" {
67+
slog.Error("upstream proxy address is required")
68+
return
69+
} else {
70+
upstreamURL, err = url.ParseRequestURI(*flagUpstream)
71+
if err != nil {
72+
slog.Error("error parsing upstream url", "error", err)
73+
return
74+
}
75+
}
76+
77+
proxy := newProxy(upstreamURL)
78+
server := &http.Server{
79+
Addr: *flagListen,
80+
Handler: proxy,
81+
}
82+
83+
// start the server
84+
go func() {
85+
slog.Info("server starting on", "address", *flagListen)
86+
if err := server.ListenAndServe(); err != nil {
87+
slog.Error("error starting server", "error", err)
88+
}
89+
}()
90+
91+
// catch exit signal
92+
// signal handler
93+
sigChan := make(chan os.Signal, 1)
94+
signal.Notify(sigChan, os.Interrupt)
95+
96+
// wait for signal
97+
<-sigChan
98+
}
99+
100+
type upstreamStatus string
101+
102+
const (
103+
notready upstreamStatus = "not ready"
104+
ready upstreamStatus = "ready"
105+
)
106+
107+
type proxyServer struct {
108+
upstreamProxy *httputil.ReverseProxy
109+
failCount int
110+
statusMutex sync.RWMutex
111+
status upstreamStatus
112+
}
113+
114+
func newProxy(url *url.URL) *proxyServer {
115+
p := httputil.NewSingleHostReverseProxy(url)
116+
proxy := &proxyServer{
117+
upstreamProxy: p,
118+
status: notready,
119+
failCount: 0,
120+
}
121+
122+
// start a goroutien to check upstream status
123+
go func() {
124+
checkUrl := url.Scheme + "://" + url.Host + "/wol-health"
125+
client := &http.Client{Timeout: time.Second}
126+
for {
127+
<-time.After(2 * time.Second)
128+
129+
slog.Debug("checking upstream status at", "url", checkUrl)
130+
resp, err := client.Get(checkUrl)
131+
if err == nil && resp.StatusCode == 200 {
132+
slog.Debug("upstream status: ready")
133+
proxy.setStatus(ready)
134+
proxy.failCount = 0
135+
} else {
136+
slog.Debug("upstream status: notready", "error", err)
137+
proxy.setStatus(notready)
138+
proxy.failCount += 1
139+
}
140+
141+
}
142+
}()
143+
144+
return proxy
145+
}
146+
147+
func (p *proxyServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
148+
if r.Method == "GET" && r.URL.Path == "/status" {
149+
w.Header().Set("Content-Type", "text/plain")
150+
w.WriteHeader(200)
151+
fmt.Fprintf(w, "status: %s\n", string(p.status))
152+
fmt.Fprintf(w, "failures: %d\n", p.failCount)
153+
return
154+
}
155+
156+
if p.getStatus() == notready {
157+
slog.Info("upstream not ready, sending magic packet", "mac", *flagMac)
158+
sendMagicPacket(*flagMac)
159+
timeout, cancel := context.WithTimeout(context.Background(), time.Duration(*flagTimeout)*time.Second)
160+
defer cancel()
161+
for {
162+
select {
163+
case <-timeout.Done():
164+
slog.Info("timeout waiting for upstream to be ready")
165+
http.Error(w, "timeout", http.StatusRequestTimeout)
166+
return
167+
default:
168+
if p.getStatus() == ready {
169+
break
170+
}
171+
// prevent busy waiting
172+
<-time.After(100 * time.Millisecond)
173+
}
174+
175+
// break the loop
176+
if p.getStatus() == ready {
177+
break
178+
}
179+
}
180+
}
181+
182+
p.upstreamProxy.ServeHTTP(w, r)
183+
}
184+
185+
func (p *proxyServer) getStatus() upstreamStatus {
186+
p.statusMutex.RLock()
187+
defer p.statusMutex.RUnlock()
188+
return p.status
189+
}
190+
191+
func (p *proxyServer) setStatus(status upstreamStatus) {
192+
p.statusMutex.Lock()
193+
defer p.statusMutex.Unlock()
194+
p.status = status
195+
}
196+
197+
func sendMagicPacket(macAddr string) error {
198+
hwAddr, err := net.ParseMAC(macAddr)
199+
if err != nil {
200+
return err
201+
}
202+
203+
if len(hwAddr) != 6 {
204+
return errors.New("invalid MAC address")
205+
}
206+
207+
// Create the magic packet.
208+
packet := make([]byte, 102)
209+
// Add 6 bytes of 0xFF.
210+
for i := 0; i < 6; i++ {
211+
packet[i] = 0xFF
212+
}
213+
// Repeat the MAC address 16 times.
214+
for i := 1; i <= 16; i++ {
215+
copy(packet[i*6:], hwAddr)
216+
}
217+
218+
// Send the packet using UDP.
219+
addr := net.UDPAddr{
220+
IP: net.IPv4bcast,
221+
Port: 9,
222+
}
223+
conn, err := net.DialUDP("udp", nil, &addr)
224+
if err != nil {
225+
return err
226+
}
227+
defer conn.Close()
228+
229+
_, err = conn.Write(packet)
230+
return err
231+
}

proxy/proxymanager.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,15 @@ func New(config config.Config) *ProxyManager {
131131
}
132132

133133
func (pm *ProxyManager) setupGinEngine() {
134+
134135
pm.ginEngine.Use(func(c *gin.Context) {
136+
137+
// don't log the Wake on Lan proxy health check
138+
if c.Request.URL.Path == "/wol-health" {
139+
c.Next()
140+
return
141+
}
142+
135143
// Start timer
136144
start := time.Now()
137145

@@ -235,6 +243,11 @@ func (pm *ProxyManager) setupGinEngine() {
235243
c.String(http.StatusOK, "OK")
236244
})
237245

246+
// see cmd/wol-proxy/wol-proxy.go, not logged
247+
pm.ginEngine.GET("/wol-health", func(c *gin.Context) {
248+
c.String(http.StatusOK, "OK")
249+
})
250+
238251
pm.ginEngine.GET("/favicon.ico", func(c *gin.Context) {
239252
if data, err := reactStaticFS.ReadFile("ui_dist/favicon.ico"); err == nil {
240253
c.Data(http.StatusOK, "image/x-icon", data)

0 commit comments

Comments
 (0)