Skip to content

Commit c07179d

Browse files
authored
cmd/wol-proxy: add wol-proxy (#352)
add a wake-on-lan proxy for llama-swap. When the target llama-swap server is unreachable it will send hold a request, send a WoL packet and proxy the request when llama-swap is available.
1 parent 7ff5063 commit c07179d

File tree

4 files changed

+297
-1
lines changed

4 files changed

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

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)