From edf9fadfea4e3009655813d6f5b5e8a09978d88f Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sat, 20 Dec 2025 21:59:58 +0000 Subject: [PATCH 01/14] feat: add multi-device support with YAML config - Replace env vars with YAML configuration (config.yaml or WOL_CONFIG env var) - Update /wake endpoint to support ?device=name or direct ?mac=&ip= parameters - Remove godotenv dependency, add gopkg.in/yaml.v3 - Update README with new configuration examples BREAKING CHANGE: Environment variables (WOL_MAC, WOL_IP, etc.) no longer supported. Use YAML config or new API parameters. --- .cspell.json | 4 +- .gitignore | 1 + README.md | 205 ++++++++++++++++++++++++++++++++++++-------- config.example.yaml | 21 +++++ config.go | 87 +++++++++++++------ dockerfile | 8 +- go.mod | 2 +- main.go | 65 +++++++++++--- 8 files changed, 309 insertions(+), 84 deletions(-) create mode 100644 config.example.yaml diff --git a/.cspell.json b/.cspell.json index e806ce6..6e42cde 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,8 +4,6 @@ "words": [ "daltonbr", "dylib", - "godotenv", - "joho", - "wolserver", + "wolserver" ] } diff --git a/.gitignore b/.gitignore index deb6db0..9978709 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # Build outputs /wolserver +wol-server # Go module / workspace go.work diff --git a/README.md b/README.md index e902771..b6d145e 100644 --- a/README.md +++ b/README.md @@ -2,32 +2,70 @@ πŸ”§ Project Overview -This is a lightweight Go HTTP server that sends a Wake-on-LAN (WOL) magic packet to power on a remote machine on your local network. +A lightweight Go HTTP server that sends Wake-on-LAN (WOL) magic packets to power on remote machines on your local network. Supports multiple devices through YAML configuration or direct API parameters. πŸ“¦ How It Works -- The server exposes an HTTP endpoint: +The server exposes HTTP endpoints: ```bash -GET /wake +GET /wake?device= # Wake a configured device +GET /wake?mac=XX:XX:XX:XX:XX:XX&ip=... # Wake using direct parameters +GET /status # Check server status ``` -- When accessed, it reads environment variables to determine: -- The target MAC address -- The destination IP and port -- Whether to use broadcast -- It then sends a WOL magic packet to wake the device +When accessed, it sends a WOL magic packet to wake the specified device. -🌱 Environment Variables +βš™οΈ Configuration -Set these before running the server: +## Option 1: Environment Variable (Recommended for Docker) -| Variable | Example | Description | -| -------- | ------- | ----------- | -| WOL_MAC | 00:11:22:33:44:55 | Target machine's MAC address (required) | -| WOL_IP | 192.168.1.255 | Target machine's IP or broadcast (optional, default: 192.168.1.255) | -| WOL_PORT | 9 | UDP port for WOL (optional, default: 9) | -| WOL_BROADCAST | `true` or `false` | Use broadcast or direct IP (optional) | +Set the `WOL_CONFIG` environment variable with YAML configuration: + +```yaml +devices: + desktop: + mac: "00:11:22:33:44:55" + ip: "192.168.1.255" + port: 9 + broadcast: true + + gaming-pc: + mac: "aa:bb:cc:dd:ee:ff" + ip: "192.168.1.100" + port: 9 + broadcast: false +``` + +## Option 2: Config File (For Bare Metal) + +Create a YAML config file at `$XDG_CONFIG_HOME/wol-server/config.yaml` or `~/.config/wol-server/config.yaml`: + +```yaml +devices: + desktop: + mac: "00:11:22:33:44:55" + ip: "192.168.1.255" + port: 9 + broadcast: true + + laptop: + mac: "11:22:33:44:55:66" + ip: "192.168.1.255" + # port defaults to 9 if not specified + # broadcast defaults to false if not specified +``` + +See [`config.example.yaml`](config.example.yaml) for a complete example. + +## Configuration Fields + +| Field | Type | Description | Default | +| ----- | ---- | ----------- | ------- | +| mac | string | Target machine's MAC address (required) | - | +| ip | string | Target IP or broadcast address (required) | - | +| port | integer | UDP port for WOL | 9 | +| broadcast | boolean | Use broadcast mode | true | ▢️ Running @@ -41,40 +79,141 @@ services: image: ghcr.io/daltonbr/wol-server:latest network_mode: "host" environment: - ENV: production - WOL_MAC: "00:11:22:33:44:55" # Replace with your actual MAC address - WOL_IP: "192.168.1.255" # Your broadcast IP or direct device - WOL_PORT: "9" # Usually 7 or 9 - WOL_BROADCAST: "true" # Set false if using directed IP (not supported yet) + WOL_CONFIG: | + devices: + desktop: + mac: "00:11:22:33:44:55" + ip: "192.168.1.255" + port: 9 + broadcast: true + + laptop: + mac: "aa:bb:cc:dd:ee:ff" + ip: "192.168.1.100" restart: unless-stopped + + # Alternative: Mount a config file instead of using WOL_CONFIG env var + # volumes: + # - type: bind + # source: ~/.config/wol-server/config.yaml # Path on your host machine + # target: /root/.config/wol-server/config.yaml # Path inside container + # read_only: true ``` > [!TIP] -> Docker’s default bridge network does not allow broadcast e.g.`(192.168.1.255)` to go through - that is why we need `network-mode` set to `host`. +> Docker's default bridge network does not allow broadcast (e.g., `192.168.1.255`) to go through - that's why we need `network_mode` set to `host`. + +## Local Development + +```bash +# Place config.yaml in ~/.config/wol-server/config.yaml +mkdir -p ~/.config/wol-server +cp config.example.yaml ~/.config/wol-server/config.yaml +# Edit config.yaml with your device details + +# Run the server +go run . +``` -## How to use +## How to Use -You need to enable wake-on-lan on your Network card and probably enable this on your BIOS setup (usually is disabled to save energy) -[Check this guide for more details](https://www.windowscentral.com/software-apps/windows-11/how-to-enable-wake-on-lan-on-windows-11). +You need to enable Wake-on-LAN on your network card and BIOS (usually disabled to save energy). +[Check this guide for details](https://www.windowscentral.com/software-apps/windows-11/how-to-enable-wake-on-lan-on-windows-11). -Turn on PC - send the magical packet +**Wake a configured device:** ```http -http://localhost:5000/wake +GET http://localhost:5000/wake?device=desktop ``` -Check status +**Wake using direct parameters (no config needed):** ```http -http://localhost:5000/status +GET http://localhost:5000/wake?mac=00:11:22:33:44:55&ip=192.168.1.255&port=9&broadcast=true +``` + +**Check server status:** + +```http +GET http://localhost:5000/status +``` + +### API Parameters + +**Using configured device:** + +- `device` - Device name from config (required) + +**Using direct parameters:** + +- `mac` - MAC address in colon format (required) +- `ip` - Target IP or broadcast address (required) Usually `192.168.255` +- `port` - UDP port (optional, default: 9) +- `broadcast` - "true" or "false" (optional, default: true) + +πŸ“€ Building & Publishing + +## Local Build + +**Native binary for your platform:** + +```bash +go build -o wol-server +``` + +**Cross-compile for specific platforms:** + +```bash +# macOS ARM (Apple Silicon) +GOOS=darwin GOARCH=arm64 go build -o wol-server-darwin-arm64 + +# macOS Intel +GOOS=darwin GOARCH=amd64 go build -o wol-server-darwin-amd64 + +# Linux ARM64 (Raspberry Pi, etc.) +GOOS=linux GOARCH=arm64 go build -o wol-server-linux-arm64 + +# Linux AMD64 +GOOS=linux GOARCH=amd64 go build -o wol-server-linux-amd64 +``` + +## Docker Images + +**Build for specific platform:** + +```bash +# For ARM64 (Apple Silicon, Raspberry Pi, AWS Graviton) +docker build --platform linux/arm64 -t wol-server . + +# For AMD64 (Intel/AMD x86_64) +docker build --platform linux/amd64 -t wol-server . + +# For both (multi-arch) +docker buildx build --platform linux/amd64,linux/arm64 -t wol-server . ``` -πŸ“€ Publishing (GHCR) +## Publishing to GHCR -To build and publish a multi-arch Docker image (specifically `linux/amd64`) to GitHub Container Registry (GHCR): +**Multi-architecture build and publish (Recommended):** + +Builds and pushes both `linux/amd64` and `linux/arm64` under a single tag. + +```bash +# Login to GHCR first +docker login ghcr.io + +# Build and push multi-arch image +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t ghcr.io/daltonbr/wol-server:latest \ + -t ghcr.io/daltonbr/wol-server: \ + --push . +``` + +**Single platform publish:** ```bash -# Build for linux/amd64 +# Build for specific platform docker build --platform linux/amd64 -t wol-server . # Tag with GHCR path and version @@ -86,8 +225,6 @@ docker push ghcr.io/daltonbr/wol-server:latest docker push ghcr.io/daltonbr/wol-server: ``` -> πŸ’‘ You must be logged in to GHCR with a Personal Access Token (PAT) that has `write:packages` permission: - > πŸ’‘ You must be logged in to GHCR using your GitHub username and a Personal Access Token (PAT) with `write:packages` permission: > > ```bash diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..a23fa3a --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,21 @@ +# Example WOL Server Configuration +# Copy this to ~/.config/wol-server/config.yaml or use WOL_CONFIG env var + +devices: + desktop: + mac: "00:11:22:33:44:55" + ip: "192.168.1.255" + port: 9 + broadcast: true + + gaming-pc: + mac: "aa:bb:cc:dd:ee:ff" + ip: "192.168.1.100" + port: 9 + broadcast: false + + laptop: + mac: "11:22:33:44:55:66" + ip: "192.168.1.255" + # port defaults to 9 if not specified + # broadcast defaults to false if not specified diff --git a/config.go b/config.go index 3fd0525..332687d 100644 --- a/config.go +++ b/config.go @@ -1,11 +1,26 @@ package main import ( - "errors" + "fmt" "os" - "strconv" + "path/filepath" + + "gopkg.in/yaml.v3" ) +// DeviceConfig defines the configuration for a single device. +type DeviceConfig struct { + MAC string `yaml:"mac"` + IP string `yaml:"ip"` + Port int `yaml:"port"` + Broadcast bool `yaml:"broadcast"` +} + +// Config defines the YAML structure for all devices. +type Config struct { + Devices map[string]DeviceConfig `yaml:"devices"` +} + // WOLConfig defines the configuration for sending a Wake-on-LAN magic packet. type WOLConfig struct { // MACAddress is the target device's MAC address in colon format (e.g. "00:11:22:33:44:55"). @@ -22,43 +37,61 @@ type WOLConfig struct { // Broadcast indicates whether to use UDP broadcast (true) or direct IP (false). Broadcast bool +} - // Alias is an optional name for the device (e.g. "Glados"). - // Useful if managing multiple configs later. - // Optional: not used in logic yet. - // Alias string +var globalConfig Config - // SendPing indicates whether a ping should be sent after the WOL packet - // to check if the device is responding. Optional feature for future use. - // SendPing bool +// loadConfig loads configuration from WOL_CONFIG env var or config file +func loadConfig() error { + // Try loading from WOL_CONFIG environment variable first + if configYaml := os.Getenv("WOL_CONFIG"); configYaml != "" { + if err := yaml.Unmarshal([]byte(configYaml), &globalConfig); err != nil { + return fmt.Errorf("failed to parse WOL_CONFIG env var: %w", err) + } + return nil + } - // PingTimeout defines the timeout in seconds when SendPing is enabled. - // PingTimeout int -} + // Fall back to config file + configDir := os.Getenv("XDG_CONFIG_HOME") + if configDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + configDir = filepath.Join(homeDir, ".config") + } + + configPath := filepath.Join(configDir, "wol-server", "config.yaml") -func loadConfig() (WOLConfig, error) { - mac := os.Getenv("WOL_MAC") - ip := os.Getenv("WOL_IP") - portStr := os.Getenv("WOL_PORT") - broadcast := os.Getenv("WOL_BROADCAST") == "true" + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config file %s: %w", configPath, err) + } - if mac == "" { - return WOLConfig{}, errors.New("WOL_MAC environment variable is required") + if err := yaml.Unmarshal(data, &globalConfig); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) } - if ip == "" { - return WOLConfig{}, errors.New("WOL_IP environment variable is required") + return nil +} + +// getDeviceConfig retrieves a device configuration by name +func getDeviceConfig(deviceName string) (WOLConfig, error) { + device, exists := globalConfig.Devices[deviceName] + if !exists { + return WOLConfig{}, fmt.Errorf("device '%s' not found in config", deviceName) } - port := 9 // default - if p, err := strconv.Atoi(portStr); err == nil { - port = p + // Apply defaults + port := device.Port + if port == 0 { + port = 9 } return WOLConfig{ - MACAddress: mac, - IPAddress: ip, + MACAddress: device.MAC, + IPAddress: device.IP, Port: port, - Broadcast: broadcast, + Broadcast: device.Broadcast, }, nil } diff --git a/dockerfile b/dockerfile index 32dd2a9..fc4e549 100644 --- a/dockerfile +++ b/dockerfile @@ -13,13 +13,9 @@ LABEL org.opencontainers.image.source="https://github.com/daltonbr/wol-server" RUN apk --no-cache add ca-certificates WORKDIR /root/ -COPY --from=builder /app/wol-server . -# Default environment variables (overridden by docker-compose/.env) -ENV WOL_MAC="" -ENV WOL_IP="" -ENV WOL_PORT="9" -ENV WOL_BROADCAST="true" +# Copy binary +COPY --from=builder /app/wol-server . EXPOSE 5000 diff --git a/go.mod b/go.mod index 4f4d0bc..9b0eda3 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/daltonbr/wol-server go 1.25.5 -require github.com/joho/godotenv v1.5.1 +require gopkg.in/yaml.v3 v3.0.1 diff --git a/main.go b/main.go index 227fa25..ac86db8 100644 --- a/main.go +++ b/main.go @@ -3,19 +3,15 @@ package main import ( "fmt" "log" + "net" "net/http" - "os" - - "github.com/joho/godotenv" + "strconv" ) func main() { - - // Load .env only if not in production - if os.Getenv("ENV") != "production" { - if err := godotenv.Load(); err != nil { - log.Printf("No .env file loaded (development only): %v", err) - } + // Load config on startup + if err := loadConfig(); err != nil { + log.Fatalf("Failed to load config: %v", err) } http.HandleFunc("/wake", handler) @@ -25,10 +21,53 @@ func main() { } func handler(w http.ResponseWriter, r *http.Request) { - config, err := loadConfig() - if err != nil { - log.Printf("Error loading config: %v", err) - http.Error(w, "Failed to load configuration", http.StatusInternalServerError) + query := r.URL.Query() + + var config WOLConfig + var err error + + // Check if device parameter is provided (lookup in config) + if deviceName := query.Get("device"); deviceName != "" { + config, err = getDeviceConfig(deviceName) + if err != nil { + log.Printf("Error getting device config: %v", err) + http.Error(w, fmt.Sprintf("Device not found: %s", deviceName), http.StatusNotFound) + return + } + } else if mac := query.Get("mac"); mac != "" { + // Direct parameters provided + ip := query.Get("ip") + if ip == "" { + http.Error(w, "Missing required parameter: ip", http.StatusBadRequest) + return + } + + port := 9 // default + if portStr := query.Get("port"); portStr != "" { + if p, err := strconv.Atoi(portStr); err == nil { + port = p + } + } + + broadcast := true // default + if broadcastStr := query.Get("broadcast"); broadcastStr != "" { + broadcast = broadcastStr == "true" + } + + // Validate MAC address format + if _, err := net.ParseMAC(mac); err != nil { + http.Error(w, fmt.Sprintf("Invalid MAC address: %s", mac), http.StatusBadRequest) + return + } + + config = WOLConfig{ + MACAddress: mac, + IPAddress: ip, + Port: port, + Broadcast: broadcast, + } + } else { + http.Error(w, "Missing required parameter: either 'device' or 'mac' and 'ip' must be provided", http.StatusBadRequest) return } From c9d63d74ed74695c17596a55d0299b730fbdcb0f Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sat, 20 Dec 2025 22:24:07 +0000 Subject: [PATCH 02/14] add initial tests batch --- README.md | 19 ++++++ config_test.go | 165 +++++++++++++++++++++++++++++++++++++++++++++++ handlers_test.go | 117 +++++++++++++++++++++++++++++++++ wol_test.go | 103 +++++++++++++++++++++++++++++ 4 files changed, 404 insertions(+) create mode 100644 config_test.go create mode 100644 handlers_test.go create mode 100644 wol_test.go diff --git a/README.md b/README.md index b6d145e..57e5f94 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,25 @@ GET http://localhost:5000/status - `port` - UDP port (optional, default: 9) - `broadcast` - "true" or "false" (optional, default: true) +πŸ§ͺ Testing + +Run all tests: +```bash +go test ./... +``` + +Run tests with verbose output: + +```bash +go test -v ./... +``` + +Run a specific test: + +```bash +go test -run TestName ./... +``` + πŸ“€ Building & Publishing ## Local Build diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..2946c47 --- /dev/null +++ b/config_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigFromEnv(t *testing.T) { + // Save and restore original env var + oldEnv := os.Getenv("WOL_CONFIG") + defer os.Setenv("WOL_CONFIG", oldEnv) + + yamlConfig := ` +devices: + test-device: + mac: "00:11:22:33:44:55" + ip: "192.168.1.255" + port: 9 + broadcast: true +` + + os.Setenv("WOL_CONFIG", yamlConfig) + + err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() failed: %v", err) + } + + if len(globalConfig.Devices) != 1 { + t.Errorf("Expected 1 device, got %d", len(globalConfig.Devices)) + } + + device, exists := globalConfig.Devices["test-device"] + if !exists { + t.Fatal("test-device not found in config") + } + + if device.MAC != "00:11:22:33:44:55" { + t.Errorf("Expected MAC 00:11:22:33:44:55, got %s", device.MAC) + } + + if device.IP != "192.168.1.255" { + t.Errorf("Expected IP 192.168.1.255, got %s", device.IP) + } + + if device.Port != 9 { + t.Errorf("Expected port 9, got %d", device.Port) + } + + if !device.Broadcast { + t.Error("Expected broadcast true, got false") + } +} + +func TestLoadConfigFromFile(t *testing.T) { + // Unset env var to force file loading + oldEnv := os.Getenv("WOL_CONFIG") + os.Unsetenv("WOL_CONFIG") + defer os.Setenv("WOL_CONFIG", oldEnv) + + // Create temp config file + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "wol-server") + err := os.MkdirAll(configDir, 0755) + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + configPath := filepath.Join(configDir, "config.yaml") + yamlContent := ` +devices: + file-device: + mac: "aa:bb:cc:dd:ee:ff" + ip: "192.168.1.100" + port: 7 + broadcast: false +` + + err = os.WriteFile(configPath, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Set XDG_CONFIG_HOME to temp dir + oldXDG := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tmpDir) + defer os.Setenv("XDG_CONFIG_HOME", oldXDG) + + err = loadConfig() + if err != nil { + t.Fatalf("loadConfig() failed: %v", err) + } + + device, exists := globalConfig.Devices["file-device"] + if !exists { + t.Fatal("file-device not found in config") + } + + if device.MAC != "aa:bb:cc:dd:ee:ff" { + t.Errorf("Expected MAC aa:bb:cc:dd:ee:ff, got %s", device.MAC) + } +} + +func TestLoadConfigInvalidYAML(t *testing.T) { + oldEnv := os.Getenv("WOL_CONFIG") + defer os.Setenv("WOL_CONFIG", oldEnv) + + os.Setenv("WOL_CONFIG", "invalid: yaml: content: [") + + err := loadConfig() + if err == nil { + t.Fatal("Expected error for invalid YAML, got nil") + } +} + +func TestGetDeviceConfig(t *testing.T) { + // Setup global config + globalConfig = Config{ + Devices: map[string]DeviceConfig{ + "desktop": { + MAC: "00:11:22:33:44:55", + IP: "192.168.1.255", + Port: 9, + Broadcast: true, + }, + "laptop": { + MAC: "aa:bb:cc:dd:ee:ff", + IP: "192.168.1.100", + Port: 0, // Test default port + Broadcast: false, + }, + }, + } + + // Test existing device + config, err := getDeviceConfig("desktop") + if err != nil { + t.Fatalf("getDeviceConfig failed: %v", err) + } + + if config.MACAddress != "00:11:22:33:44:55" { + t.Errorf("Expected MAC 00:11:22:33:44:55, got %s", config.MACAddress) + } + + if config.Port != 9 { + t.Errorf("Expected port 9, got %d", config.Port) + } + + // Test device with default port + config, err = getDeviceConfig("laptop") + if err != nil { + t.Fatalf("getDeviceConfig failed: %v", err) + } + + if config.Port != 9 { + t.Errorf("Expected default port 9, got %d", config.Port) + } + + // Test non-existent device + _, err = getDeviceConfig("nonexistent") + if err == nil { + t.Error("Expected error for non-existent device, got nil") + } +} diff --git a/handlers_test.go b/handlers_test.go new file mode 100644 index 0000000..a324366 --- /dev/null +++ b/handlers_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestStatusHandler(t *testing.T) { + req := httptest.NewRequest("GET", "/status", nil) + w := httptest.NewRecorder() + + statusHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + body := w.Body.String() + if body != "WOL service is running" { + t.Errorf("Expected 'WOL service is running', got %s", body) + } +} + +func TestHandlerMissingParameters(t *testing.T) { + req := httptest.NewRequest("GET", "/wake", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", resp.StatusCode) + } +} + +func TestHandlerDeviceNotFound(t *testing.T) { + // Setup empty config + globalConfig = Config{ + Devices: map[string]DeviceConfig{}, + } + + req := httptest.NewRequest("GET", "/wake?device=nonexistent", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", resp.StatusCode) + } +} + +func TestHandlerInvalidMAC(t *testing.T) { + req := httptest.NewRequest("GET", "/wake?mac=invalid&ip=192.168.1.255", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid MAC, got %d", resp.StatusCode) + } +} + +func TestHandlerMissingIP(t *testing.T) { + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected status 400 for missing IP, got %d", resp.StatusCode) + } +} + +func TestHandlerDirectParamsDefaults(t *testing.T) { + // Note: This test will attempt to send a WOL packet but will likely fail + // since we're not on the same network. We're mainly testing parameter parsing. + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip=127.0.0.1", nil) + w := httptest.NewRecorder() + + handler(w, req) + + // May get 200 or 500 depending on network + // We just want to ensure it doesn't return 400 (bad request) + resp := w.Result() + if resp.StatusCode == http.StatusBadRequest { + t.Errorf("Expected successful parameter parsing, got 400: %s", w.Body.String()) + } +} + +func TestHandlerDirectParamsWithPort(t *testing.T) { + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip=127.0.0.1&port=7", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode == http.StatusBadRequest { + t.Errorf("Expected successful parameter parsing with port, got 400: %s", w.Body.String()) + } +} + +func TestHandlerDirectParamsWithBroadcast(t *testing.T) { + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip=127.0.0.1&broadcast=false", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode == http.StatusBadRequest { + t.Errorf("Expected successful parameter parsing with broadcast, got 400: %s", w.Body.String()) + } +} diff --git a/wol_test.go b/wol_test.go new file mode 100644 index 0000000..5e2f81a --- /dev/null +++ b/wol_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "net" + "testing" +) + +func TestBuildMagicPacket(t *testing.T) { + config := WOLConfig{ + MACAddress: "00:11:22:33:44:55", + IPAddress: "192.168.1.255", + Port: 9, + Broadcast: true, + } + + hardwareAddr, err := net.ParseMAC(config.MACAddress) + if err != nil { + t.Fatalf("Failed to parse MAC: %v", err) + } + + // Build magic packet + packet := make([]byte, 6+16*6) + for i := 0; i < 6; i++ { + packet[i] = 0xFF + } + for i := 0; i < 16; i++ { + copy(packet[6+i*6:], hardwareAddr) + } + + // Verify packet structure + if len(packet) != 102 { + t.Errorf("Expected packet length 102, got %d", len(packet)) + } + + // Verify header (6 bytes of 0xFF) + for i := 0; i < 6; i++ { + if packet[i] != 0xFF { + t.Errorf("Expected packet[%d] = 0xFF, got 0x%02X", i, packet[i]) + } + } + + // Verify MAC address repeated 16 times + for i := 0; i < 16; i++ { + start := 6 + i*6 + for j := 0; j < 6; j++ { + if packet[start+j] != hardwareAddr[j] { + t.Errorf("MAC byte mismatch at repetition %d, byte %d", i, j) + } + } + } +} + +func TestParseMACAddress(t *testing.T) { + tests := []struct { + name string + macStr string + wantError bool + }{ + {"Valid colon format", "00:11:22:33:44:55", false}, + {"Valid lowercase", "aa:bb:cc:dd:ee:ff", false}, + {"Valid uppercase", "AA:BB:CC:DD:EE:FF", false}, + {"Valid dash format", "00-11-22-33-44-55", false}, // Go's ParseMAC accepts this + {"Too short", "00:11:22:33:44", true}, + {"Too long", "00:11:22:33:44:55:66", true}, + {"Invalid characters", "gg:11:22:33:44:55", true}, + {"Empty string", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := net.ParseMAC(tt.macStr) + if tt.wantError && err == nil { + t.Errorf("Expected error for MAC %s, got nil", tt.macStr) + } + if !tt.wantError && err != nil { + t.Errorf("Unexpected error for MAC %s: %v", tt.macStr, err) + } + }) + } +} + +func TestWOLConfigDefaults(t *testing.T) { + // Test that getDeviceConfig applies port defaults correctly + globalConfig = Config{ + Devices: map[string]DeviceConfig{ + "test": { + MAC: "00:11:22:33:44:55", + IP: "192.168.1.255", + Port: 0, // Should default to 9 + Broadcast: true, + }, + }, + } + + config, err := getDeviceConfig("test") + if err != nil { + t.Fatalf("getDeviceConfig failed: %v", err) + } + + if config.Port != 9 { + t.Errorf("Expected default port 9, got %d", config.Port) + } +} From 82645d89fdcecd4186f28e961eb517ddd6c85a02 Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sat, 20 Dec 2025 22:27:40 +0000 Subject: [PATCH 03/14] extract magic packet creation and update tests --- wol.go | 19 +++++++++++++++---- wol_test.go | 28 +++++++++++----------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/wol.go b/wol.go index ce2012d..304bca6 100644 --- a/wol.go +++ b/wol.go @@ -6,13 +6,15 @@ import ( "net" ) -func sendMagicPacket(config WOLConfig) error { - hardwareAddr, err := net.ParseMAC(config.MACAddress) +// buildMagicPacket constructs a Wake-on-LAN magic packet for the given MAC address. +// The packet consists of 6 bytes of 0xFF followed by the MAC address repeated 16 times. +func buildMagicPacket(macAddress string) ([]byte, error) { + hardwareAddr, err := net.ParseMAC(macAddress) if err != nil { - return fmt.Errorf("invalid MAC address: %w", err) + return nil, fmt.Errorf("invalid MAC address: %w", err) } - // Build magic packet + // Build magic packet: 6 bytes of 0xFF + MAC repeated 16 times packet := make([]byte, 6+16*6) for i := 0; i < 6; i++ { packet[i] = 0xFF @@ -21,6 +23,15 @@ func sendMagicPacket(config WOLConfig) error { copy(packet[6+i*6:], hardwareAddr) } + return packet, nil +} + +func sendMagicPacket(config WOLConfig) error { + packet, err := buildMagicPacket(config.MACAddress) + if err != nil { + return err + } + // Set destination addr := net.UDPAddr{ IP: net.ParseIP(config.IPAddress), diff --git a/wol_test.go b/wol_test.go index 5e2f81a..afff3ca 100644 --- a/wol_test.go +++ b/wol_test.go @@ -6,25 +6,11 @@ import ( ) func TestBuildMagicPacket(t *testing.T) { - config := WOLConfig{ - MACAddress: "00:11:22:33:44:55", - IPAddress: "192.168.1.255", - Port: 9, - Broadcast: true, - } + macAddress := "00:11:22:33:44:55" - hardwareAddr, err := net.ParseMAC(config.MACAddress) + packet, err := buildMagicPacket(macAddress) if err != nil { - t.Fatalf("Failed to parse MAC: %v", err) - } - - // Build magic packet - packet := make([]byte, 6+16*6) - for i := 0; i < 6; i++ { - packet[i] = 0xFF - } - for i := 0; i < 16; i++ { - copy(packet[6+i*6:], hardwareAddr) + t.Fatalf("buildMagicPacket failed: %v", err) } // Verify packet structure @@ -40,6 +26,7 @@ func TestBuildMagicPacket(t *testing.T) { } // Verify MAC address repeated 16 times + hardwareAddr, _ := net.ParseMAC(macAddress) for i := 0; i < 16; i++ { start := 6 + i*6 for j := 0; j < 6; j++ { @@ -50,6 +37,13 @@ func TestBuildMagicPacket(t *testing.T) { } } +func TestBuildMagicPacketInvalidMAC(t *testing.T) { + _, err := buildMagicPacket("invalid-mac") + if err == nil { + t.Error("Expected error for invalid MAC address, got nil") + } +} + func TestParseMACAddress(t *testing.T) { tests := []struct { name string From 7f9edc94020c436b81ee256e4f2cc17faa9b03a2 Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sat, 20 Dec 2025 23:08:25 +0000 Subject: [PATCH 04/14] split documentation for simplicity --- README.md | 249 +++++++++++++++----------------------------- docs/DEVELOPMENT.md | 130 +++++++++++++++++++++++ 2 files changed, 212 insertions(+), 167 deletions(-) create mode 100644 docs/DEVELOPMENT.md diff --git a/README.md b/README.md index 57e5f94..8df8fc2 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,16 @@ # WOL-Server -πŸ”§ Project Overview +A lightweight HTTP server that sends Wake-on-LAN (WOL) magic packets to power on remote machines on your local network. Supports multiple devices through YAML configuration or direct API parameters. -A lightweight Go HTTP server that sends Wake-on-LAN (WOL) magic packets to power on remote machines on your local network. Supports multiple devices through YAML configuration or direct API parameters. +## Quick Start -πŸ“¦ How It Works - -The server exposes HTTP endpoints: - -```bash -GET /wake?device= # Wake a configured device -GET /wake?mac=XX:XX:XX:XX:XX:XX&ip=... # Wake using direct parameters -GET /status # Check server status -``` - -When accessed, it sends a WOL magic packet to wake the specified device. - -βš™οΈ Configuration - -## Option 1: Environment Variable (Recommended for Docker) - -Set the `WOL_CONFIG` environment variable with YAML configuration: - -```yaml -devices: - desktop: - mac: "00:11:22:33:44:55" - ip: "192.168.1.255" - port: 9 - broadcast: true - - gaming-pc: - mac: "aa:bb:cc:dd:ee:ff" - ip: "192.168.1.100" - port: 9 - broadcast: false -``` - -## Option 2: Config File (For Bare Metal) - -Create a YAML config file at `$XDG_CONFIG_HOME/wol-server/config.yaml` or `~/.config/wol-server/config.yaml`: - -```yaml -devices: - desktop: - mac: "00:11:22:33:44:55" - ip: "192.168.1.255" - port: 9 - broadcast: true - - laptop: - mac: "11:22:33:44:55:66" - ip: "192.168.1.255" - # port defaults to 9 if not specified - # broadcast defaults to false if not specified -``` - -See [`config.example.yaml`](config.example.yaml) for a complete example. - -## Configuration Fields - -| Field | Type | Description | Default | -| ----- | ---- | ----------- | ------- | -| mac | string | Target machine's MAC address (required) | - | -| ip | string | Target IP or broadcast address (required) | - | -| port | integer | UDP port for WOL | 9 | -| broadcast | boolean | Use broadcast mode | true | - -▢️ Running - -## Docker - -`docker-compose.yml` +### Docker (Recommended) ```yml services: wol-server: image: ghcr.io/daltonbr/wol-server:latest - network_mode: "host" + network_mode: "host" # Required for WOL broadcast on Linux environment: WOL_CONFIG: | devices: @@ -92,160 +25,142 @@ services: ip: "192.168.1.100" restart: unless-stopped + # macOS users: network_mode: "host" doesn't work on Docker Desktop + # Use port mapping instead and comment out network_mode above: + # ports: + # - "5000:5000" + # Alternative: Mount a config file instead of using WOL_CONFIG env var # volumes: # - type: bind - # source: ~/.config/wol-server/config.yaml # Path on your host machine - # target: /root/.config/wol-server/config.yaml # Path inside container + # source: ~/.config/wol-server/config.yaml + # target: /root/.config/wol-server/config.yaml # read_only: true ``` -> [!TIP] -> Docker's default bridge network does not allow broadcast (e.g., `192.168.1.255`) to go through - that's why we need `network_mode` set to `host`. - -## Local Development +### Native Binary ```bash -# Place config.yaml in ~/.config/wol-server/config.yaml +# Install config mkdir -p ~/.config/wol-server cp config.example.yaml ~/.config/wol-server/config.yaml # Edit config.yaml with your device details -# Run the server +```bash +# Run go run . ``` -## How to Use - -You need to enable Wake-on-LAN on your network card and BIOS (usually disabled to save energy). -[Check this guide for details](https://www.windowscentral.com/software-apps/windows-11/how-to-enable-wake-on-lan-on-windows-11). +## Usage **Wake a configured device:** -```http -GET http://localhost:5000/wake?device=desktop +```bash +curl http://localhost:5000/wake?device=desktop ``` **Wake using direct parameters (no config needed):** -```http -GET http://localhost:5000/wake?mac=00:11:22:33:44:55&ip=192.168.1.255&port=9&broadcast=true +```bash +curl "http://localhost:5000/wake?mac=00:11:22:33:44:55&ip=192.168.1.255" ``` **Check server status:** -```http -GET http://localhost:5000/status +```bash +curl http://localhost:5000/status ``` -### API Parameters +## Configuration -**Using configured device:** +### Option 1: Environment Variable (Docker) -- `device` - Device name from config (required) +Set `WOL_CONFIG` with YAML content (see docker-compose example above). -**Using direct parameters:** +### Option 2: Config File (Bare Metal) -- `mac` - MAC address in colon format (required) -- `ip` - Target IP or broadcast address (required) Usually `192.168.255` -- `port` - UDP port (optional, default: 9) -- `broadcast` - "true" or "false" (optional, default: true) - -πŸ§ͺ Testing +Create `~/.config/wol-server/config.yaml`: -Run all tests: -```bash -go test ./... +```yaml +devices: + desktop: + mac: "00:11:22:33:44:55" + ip: "192.168.1.255" + port: 9 # optional, default: 9 + broadcast: true # optional, default: false + + laptop: + mac: "11:22:33:44:55:66" + ip: "192.168.1.255" ``` -Run tests with verbose output: +See [`config.example.yaml`](config.example.yaml) for more examples. -```bash -go test -v ./... -``` +### Configuration Fields -Run a specific test: +| Field | Type | Description | Default | +| ----- | ---- | ----------- | ------- | +| mac | string | Target machine's MAC address (required) | - | +| ip | string | Target IP or broadcast address (required) | - | +| port | integer | UDP port for WOL | 9 | +| broadcast | boolean | Use broadcast mode | false | -```bash -go test -run TestName ./... -``` +## API Reference -πŸ“€ Building & Publishing +### Endpoints -## Local Build +**GET /wake** -**Native binary for your platform:** +Wake a device using either a configured device name or direct parameters. -```bash -go build -o wol-server -``` +**Query Parameters:** -**Cross-compile for specific platforms:** +Using configured device: -```bash -# macOS ARM (Apple Silicon) -GOOS=darwin GOARCH=arm64 go build -o wol-server-darwin-arm64 +- `device` - Device name from config (required) -# macOS Intel -GOOS=darwin GOARCH=amd64 go build -o wol-server-darwin-amd64 +Using direct parameters: -# Linux ARM64 (Raspberry Pi, etc.) -GOOS=linux GOARCH=arm64 go build -o wol-server-linux-arm64 +- `mac` - MAC address in colon format (required) +- `ip` - Target IP or broadcast address (required) +- `port` - UDP port (optional, default: 9) +- `broadcast` - "true" or "false" (optional, default: true) -# Linux AMD64 -GOOS=linux GOARCH=amd64 go build -o wol-server-linux-amd64 -``` +**Examples:** -## Docker Images +```bash +# Using device name +GET /wake?device=desktop -**Build for specific platform:** +# Using direct parameters +GET /wake?mac=00:11:22:33:44:55&ip=192.168.1.255&port=9&broadcast=true +``` -```bash -# For ARM64 (Apple Silicon, Raspberry Pi, AWS Graviton) -docker build --platform linux/arm64 -t wol-server . +**GET /status** -# For AMD64 (Intel/AMD x86_64) -docker build --platform linux/amd64 -t wol-server . +Check if the server is running. -# For both (multi-arch) -docker buildx build --platform linux/amd64,linux/arm64 -t wol-server . -``` +**Response:** `WOL service is running` -## Publishing to GHCR +## Prerequisites -**Multi-architecture build and publish (Recommended):** +Your target machine must have: -Builds and pushes both `linux/amd64` and `linux/arm64` under a single tag. +- Wake-on-LAN enabled in BIOS/UEFI +- Wake-on-LAN enabled in network adapter settings +- Connected to power (or battery with WOL support) -```bash -# Login to GHCR first -docker login ghcr.io - -# Build and push multi-arch image -docker buildx build \ - --platform linux/amd64,linux/arm64 \ - -t ghcr.io/daltonbr/wol-server:latest \ - -t ghcr.io/daltonbr/wol-server: \ - --push . -``` +[Guide: How to enable Wake-on-LAN on Windows 11](https://www.windowscentral.com/software-apps/windows-11/how-to-enable-wake-on-lan-on-windows-11) -**Single platform publish:** +## Alternative: Volume Mount Config -```bash -# Build for specific platform -docker build --platform linux/amd64 -t wol-server . +Instead of using `WOL_CONFIG` env var, you can mount a config file (see commented example in docker-compose above). -# Tag with GHCR path and version -docker tag wol-server ghcr.io/daltonbr/wol-server:latest -docker tag wol-server ghcr.io/daltonbr/wol-server: +## Development -# Push to GHCR -docker push ghcr.io/daltonbr/wol-server:latest -docker push ghcr.io/daltonbr/wol-server: -``` +See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for: -> πŸ’‘ You must be logged in to GHCR using your GitHub username and a Personal Access Token (PAT) with `write:packages` permission: -> -> ```bash -> docker login ghcr.io -> ``` +- Building from source (requires Go 1.25.5+) +- Running tests +- Cross-compilation +- Publishing releases diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..f493ae8 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,130 @@ +# Development Guide + +## Prerequisites + +- Go 1.25.5 or later +- Docker (for containerized builds) + +## Local Development + +**Setup:** + +```bash +# Clone the repository +git clone https://github.com/daltonbr/wol-server.git +cd wol-server + +# Create local config +mkdir -p ~/.config/wol-server +cp config.example.yaml ~/.config/wol-server/config.yaml +# Edit config.yaml with your device details + +# Run the server +go run . +``` + +**Server runs on:** `http://localhost:5000` + +## Testing + +Run all tests: + +```bash +go test ./... +``` + +Run tests with verbose output: + +```bash +go test -v ./... +``` + +Run a specific test: + +```bash +go test -run TestName ./... +``` + +## Building + +### Native Binary + +**Build for your platform:** + +```bash +go build -o wol-server +``` + +**Cross-compile for specific platforms:** + +```bash +# macOS ARM (Apple Silicon) +GOOS=darwin GOARCH=arm64 go build -o wol-server-darwin-arm64 + +# macOS Intel +GOOS=darwin GOARCH=amd64 go build -o wol-server-darwin-amd64 + +# Linux ARM64 (Raspberry Pi, etc.) +GOOS=linux GOARCH=arm64 go build -o wol-server-linux-arm64 + +# Linux AMD64 +GOOS=linux GOARCH=amd64 go build -o wol-server-linux-amd64 +``` + +### Docker Images + +**Build for specific platform:** + +```bash +# For ARM64 (Apple Silicon, Raspberry Pi, AWS Graviton) +docker build --platform linux/arm64 -t wol-server . + +# For AMD64 (Intel/AMD x86_64) +docker build --platform linux/amd64 -t wol-server . + +# For local testing (auto-detects your platform) +docker build -t wol-server:local . +``` + +**Run locally built image:** + +```bash +docker run --rm -p 5000:5000 \ + -e WOL_CONFIG="$(cat ~/.config/wol-server/config.yaml)" \ + wol-server:local +``` + +## Publishing to GHCR + +### Multi-architecture Build (Recommended) + +Builds and pushes both `linux/amd64` and `linux/arm64` under a single tag: + +```bash +# Login to GHCR first +docker login ghcr.io + +# Build and push multi-arch image +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t ghcr.io/daltonbr/wol-server:latest \ + -t ghcr.io/daltonbr/wol-server:v2.0.0 \ + --push . +``` + +### Single Platform Build + +```bash +# Build for specific platform +docker build --platform linux/amd64 -t wol-server . + +# Tag with GHCR path and version +docker tag wol-server ghcr.io/daltonbr/wol-server:latest +docker tag wol-server ghcr.io/daltonbr/wol-server:v2.0.0 + +# Push to GHCR +docker push ghcr.io/daltonbr/wol-server:latest +docker push ghcr.io/daltonbr/wol-server:v2.0.0 +``` + +> πŸ’‘ You must be logged in to GHCR using your GitHub username and a Personal Access Token (PAT) with `write:packages` permission. From 3b66fda34980608abd78858925342e05de718183 Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sat, 20 Dec 2025 23:20:57 +0000 Subject: [PATCH 05/14] removing broadcast flag --- README.md | 6 +----- config.example.yaml | 3 --- config.go | 15 +++++---------- config_test.go | 8 -------- handlers_test.go | 12 ------------ main.go | 11 ----------- wol.go | 5 ----- wol_test.go | 7 +++---- 8 files changed, 9 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 8df8fc2..8536875 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ services: mac: "00:11:22:33:44:55" ip: "192.168.1.255" port: 9 - broadcast: true laptop: mac: "aa:bb:cc:dd:ee:ff" @@ -87,7 +86,6 @@ devices: mac: "00:11:22:33:44:55" ip: "192.168.1.255" port: 9 # optional, default: 9 - broadcast: true # optional, default: false laptop: mac: "11:22:33:44:55:66" @@ -103,7 +101,6 @@ See [`config.example.yaml`](config.example.yaml) for more examples. | mac | string | Target machine's MAC address (required) | - | | ip | string | Target IP or broadcast address (required) | - | | port | integer | UDP port for WOL | 9 | -| broadcast | boolean | Use broadcast mode | false | ## API Reference @@ -124,7 +121,6 @@ Using direct parameters: - `mac` - MAC address in colon format (required) - `ip` - Target IP or broadcast address (required) - `port` - UDP port (optional, default: 9) -- `broadcast` - "true" or "false" (optional, default: true) **Examples:** @@ -133,7 +129,7 @@ Using direct parameters: GET /wake?device=desktop # Using direct parameters -GET /wake?mac=00:11:22:33:44:55&ip=192.168.1.255&port=9&broadcast=true +GET /wake?mac=00:11:22:33:44:55&ip=192.168.1.255&port=9 ``` **GET /status** diff --git a/config.example.yaml b/config.example.yaml index a23fa3a..0ef5eea 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -6,16 +6,13 @@ devices: mac: "00:11:22:33:44:55" ip: "192.168.1.255" port: 9 - broadcast: true gaming-pc: mac: "aa:bb:cc:dd:ee:ff" ip: "192.168.1.100" port: 9 - broadcast: false laptop: mac: "11:22:33:44:55:66" ip: "192.168.1.255" # port defaults to 9 if not specified - # broadcast defaults to false if not specified diff --git a/config.go b/config.go index 332687d..57d84b1 100644 --- a/config.go +++ b/config.go @@ -10,10 +10,9 @@ import ( // DeviceConfig defines the configuration for a single device. type DeviceConfig struct { - MAC string `yaml:"mac"` - IP string `yaml:"ip"` - Port int `yaml:"port"` - Broadcast bool `yaml:"broadcast"` + MAC string `yaml:"mac"` + IP string `yaml:"ip"` + Port int `yaml:"port"` } // Config defines the YAML structure for all devices. @@ -27,16 +26,13 @@ type WOLConfig struct { MACAddress string // IPAddress is the destination IP to send the WOL packet to. - // Use "255.255.255.255" for global broadcast, - // or the specific device's local IP for directed WOL (e.g. 192.168.1.255:9) (subnet broadcast) + // Use a broadcast address (e.g., 192.168.1.255) for standard WOL, + // or a specific device IP for directed WOL. IPAddress string // Port is the destination UDP port for the WOL packet. // Common values are 9 (default) or 7. Port int - - // Broadcast indicates whether to use UDP broadcast (true) or direct IP (false). - Broadcast bool } var globalConfig Config @@ -92,6 +88,5 @@ func getDeviceConfig(deviceName string) (WOLConfig, error) { MACAddress: device.MAC, IPAddress: device.IP, Port: port, - Broadcast: device.Broadcast, }, nil } diff --git a/config_test.go b/config_test.go index 2946c47..a2c55db 100644 --- a/config_test.go +++ b/config_test.go @@ -17,7 +17,6 @@ devices: mac: "00:11:22:33:44:55" ip: "192.168.1.255" port: 9 - broadcast: true ` os.Setenv("WOL_CONFIG", yamlConfig) @@ -47,10 +46,6 @@ devices: if device.Port != 9 { t.Errorf("Expected port 9, got %d", device.Port) } - - if !device.Broadcast { - t.Error("Expected broadcast true, got false") - } } func TestLoadConfigFromFile(t *testing.T) { @@ -74,7 +69,6 @@ devices: mac: "aa:bb:cc:dd:ee:ff" ip: "192.168.1.100" port: 7 - broadcast: false ` err = os.WriteFile(configPath, []byte(yamlContent), 0644) @@ -122,13 +116,11 @@ func TestGetDeviceConfig(t *testing.T) { MAC: "00:11:22:33:44:55", IP: "192.168.1.255", Port: 9, - Broadcast: true, }, "laptop": { MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.100", Port: 0, // Test default port - Broadcast: false, }, }, } diff --git a/handlers_test.go b/handlers_test.go index a324366..ff70436 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -103,15 +103,3 @@ func TestHandlerDirectParamsWithPort(t *testing.T) { t.Errorf("Expected successful parameter parsing with port, got 400: %s", w.Body.String()) } } - -func TestHandlerDirectParamsWithBroadcast(t *testing.T) { - req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip=127.0.0.1&broadcast=false", nil) - w := httptest.NewRecorder() - - handler(w, req) - - resp := w.Result() - if resp.StatusCode == http.StatusBadRequest { - t.Errorf("Expected successful parameter parsing with broadcast, got 400: %s", w.Body.String()) - } -} diff --git a/main.go b/main.go index ac86db8..3f6b3a0 100644 --- a/main.go +++ b/main.go @@ -9,11 +9,6 @@ import ( ) func main() { - // Load config on startup - if err := loadConfig(); err != nil { - log.Fatalf("Failed to load config: %v", err) - } - http.HandleFunc("/wake", handler) http.HandleFunc("/status", statusHandler) fmt.Println("Server is listening on port :5000") @@ -49,11 +44,6 @@ func handler(w http.ResponseWriter, r *http.Request) { } } - broadcast := true // default - if broadcastStr := query.Get("broadcast"); broadcastStr != "" { - broadcast = broadcastStr == "true" - } - // Validate MAC address format if _, err := net.ParseMAC(mac); err != nil { http.Error(w, fmt.Sprintf("Invalid MAC address: %s", mac), http.StatusBadRequest) @@ -64,7 +54,6 @@ func handler(w http.ResponseWriter, r *http.Request) { MACAddress: mac, IPAddress: ip, Port: port, - Broadcast: broadcast, } } else { http.Error(w, "Missing required parameter: either 'device' or 'mac' and 'ip' must be provided", http.StatusBadRequest) diff --git a/wol.go b/wol.go index 304bca6..5c4e0d9 100644 --- a/wol.go +++ b/wol.go @@ -47,11 +47,6 @@ func sendMagicPacket(config WOLConfig) error { } defer conn.Close() - // Enable broadcast (important!) - if err := conn.SetWriteBuffer(len(packet)); err != nil { - return fmt.Errorf("set write buffer failed: %w", err) - } - if _, err := conn.Write(packet); err != nil { return fmt.Errorf("write to UDP failed: %w", err) } diff --git a/wol_test.go b/wol_test.go index afff3ca..9dee15d 100644 --- a/wol_test.go +++ b/wol_test.go @@ -78,10 +78,9 @@ func TestWOLConfigDefaults(t *testing.T) { globalConfig = Config{ Devices: map[string]DeviceConfig{ "test": { - MAC: "00:11:22:33:44:55", - IP: "192.168.1.255", - Port: 0, // Should default to 9 - Broadcast: true, + MAC: "00:11:22:33:44:55", + IP: "192.168.1.255", + Port: 0, // Should default to 9 }, }, } From a078740332bb857cfe05726585df3e6b85bbd850 Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sat, 20 Dec 2025 23:33:47 +0000 Subject: [PATCH 06/14] add test for port parameter --- handlers_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 7 +++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/handlers_test.go b/handlers_test.go index ff70436..97e92bf 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -3,6 +3,7 @@ package main import ( "net/http" "net/http/httptest" + "strings" "testing" ) @@ -103,3 +104,51 @@ func TestHandlerDirectParamsWithPort(t *testing.T) { t.Errorf("Expected successful parameter parsing with port, got 400: %s", w.Body.String()) } } + +func TestWakeHandlerInvalidPort(t *testing.T) { + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip=127.0.0.1&port=abc", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected 400 for invalid port, got %d", resp.StatusCode) + } + + if !strings.Contains(w.Body.String(), "Invalid port value") { + t.Errorf("Expected error message about invalid port, got: %s", w.Body.String()) + } +} + +func TestWakeHandlerValidPort(t *testing.T) { + globalConfig = Config{ + Devices: map[string]DeviceConfig{}, + } + + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip=127.0.0.1&port=7", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode == http.StatusBadRequest { + t.Errorf("Expected success for valid port, got 400: %s", w.Body.String()) + } +} + +func TestWakeHandlerDefaultPort(t *testing.T) { + globalConfig = Config{ + Devices: map[string]DeviceConfig{}, + } + + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip=127.0.0.1", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode == http.StatusBadRequest { + t.Errorf("Expected success with default port, got 400: %s", w.Body.String()) + } +} diff --git a/main.go b/main.go index 3f6b3a0..50388c9 100644 --- a/main.go +++ b/main.go @@ -39,9 +39,12 @@ func handler(w http.ResponseWriter, r *http.Request) { port := 9 // default if portStr := query.Get("port"); portStr != "" { - if p, err := strconv.Atoi(portStr); err == nil { - port = p + p, err := strconv.Atoi(portStr) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid port value: %s", portStr), http.StatusBadRequest) + return } + port = p } // Validate MAC address format From f8bc671626811964ec902a6c80847598c81df43f Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sat, 20 Dec 2025 23:41:10 +0000 Subject: [PATCH 07/14] add IP validation --- README.md | 4 +-- handlers_test.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 6 +++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8536875..9a378fa 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ See [`config.example.yaml`](config.example.yaml) for more examples. ### Endpoints -**GET /wake** +#### `GET /wake` Wake a device using either a configured device name or direct parameters. @@ -132,7 +132,7 @@ GET /wake?device=desktop GET /wake?mac=00:11:22:33:44:55&ip=192.168.1.255&port=9 ``` -**GET /status** +#### `GET /status` Check if the server is running. diff --git a/handlers_test.go b/handlers_test.go index 97e92bf..6c89caf 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -152,3 +152,68 @@ func TestWakeHandlerDefaultPort(t *testing.T) { t.Errorf("Expected success with default port, got 400: %s", w.Body.String()) } } + +func TestWakeHandlerInvalidIP(t *testing.T) { + globalConfig = Config{ + Devices: map[string]DeviceConfig{}, + } + + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip=256.256.256.256", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected 400 for invalid IP, got %d", resp.StatusCode) + } + + if !strings.Contains(w.Body.String(), "Invalid IP address") { + t.Errorf("Expected error message about invalid IP, got: %s", w.Body.String()) + } +} + +func TestWakeHandlerMalformedIP(t *testing.T) { + globalConfig = Config{ + Devices: map[string]DeviceConfig{}, + } + + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip=not.an.ip.address", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected 400 for malformed IP, got %d", resp.StatusCode) + } + + if !strings.Contains(w.Body.String(), "Invalid IP address") { + t.Errorf("Expected error message about invalid IP, got: %s", w.Body.String()) + } +} + +func TestWakeHandlerValidIPv4(t *testing.T) { + globalConfig = Config{ + Devices: map[string]DeviceConfig{}, + } + + testCases := []string{ + "192.168.1.255", + "10.0.0.1", + "172.16.0.100", + "127.0.0.1", + } + + for _, ip := range testCases { + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip="+ip, nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode == http.StatusBadRequest { + t.Errorf("Expected success for valid IP %s, got 400: %s", ip, w.Body.String()) + } + } +} diff --git a/main.go b/main.go index 50388c9..fdf8f71 100644 --- a/main.go +++ b/main.go @@ -53,6 +53,12 @@ func handler(w http.ResponseWriter, r *http.Request) { return } + // Validate IP address format + if net.ParseIP(ip) == nil { + http.Error(w, fmt.Sprintf("Invalid IP address: %s", ip), http.StatusBadRequest) + return + } + config = WOLConfig{ MACAddress: mac, IPAddress: ip, From 46e99af1a533edff62821c0cef7ac23d6fc8f7b3 Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sat, 20 Dec 2025 23:47:48 +0000 Subject: [PATCH 08/14] globalConfig is loaded once --- config.go | 3 +++ main.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/config.go b/config.go index 57d84b1..caf60ae 100644 --- a/config.go +++ b/config.go @@ -35,6 +35,9 @@ type WOLConfig struct { Port int } +// globalConfig is loaded once at startup and is read-only during normal operation. +// This is safe for concurrent access since it's never modified after initialization. +// Tests may modify this variable for test isolation. var globalConfig Config // loadConfig loads configuration from WOL_CONFIG env var or config file diff --git a/main.go b/main.go index fdf8f71..4fdebcf 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,12 @@ import ( ) func main() { + // Load configuration once at startup + if err := loadConfig(); err != nil { + log.Printf("Warning: Failed to load config: %v", err) + log.Println("Server will only accept direct parameters (mac/ip/port)") + } + http.HandleFunc("/wake", handler) http.HandleFunc("/status", statusHandler) fmt.Println("Server is listening on port :5000") From cf3df7c6ba5062f9b6fea4ff58fbf7af7a6927cf Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sat, 20 Dec 2025 23:51:09 +0000 Subject: [PATCH 09/14] validate MAC and IP in getDeviceConfig --- config.go | 11 +++++++++++ config_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/config.go b/config.go index caf60ae..d5cc156 100644 --- a/config.go +++ b/config.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net" "os" "path/filepath" @@ -87,6 +88,16 @@ func getDeviceConfig(deviceName string) (WOLConfig, error) { port = 9 } + // Validate MAC address format + if _, err := net.ParseMAC(device.MAC); err != nil { + return WOLConfig{}, fmt.Errorf("invalid MAC address '%s' for device '%s': %w", device.MAC, deviceName, err) + } + + // Validate IP address format + if net.ParseIP(device.IP) == nil { + return WOLConfig{}, fmt.Errorf("invalid IP address '%s' for device '%s'", device.IP, deviceName) + } + return WOLConfig{ MACAddress: device.MAC, IPAddress: device.IP, diff --git a/config_test.go b/config_test.go index a2c55db..bcc94ad 100644 --- a/config_test.go +++ b/config_test.go @@ -48,6 +48,40 @@ devices: } } +func TestGetDeviceConfigInvalidMAC(t *testing.T) { + globalConfig = Config{ + Devices: map[string]DeviceConfig{ + "bad-mac": { + MAC: "invalid-mac", + IP: "192.168.1.100", + Port: 9, + }, + }, + } + + _, err := getDeviceConfig("bad-mac") + if err == nil { + t.Fatal("Expected error for invalid MAC, got nil") + } +} + +func TestGetDeviceConfigInvalidIP(t *testing.T) { + globalConfig = Config{ + Devices: map[string]DeviceConfig{ + "bad-ip": { + MAC: "00:11:22:33:44:55", + IP: "999.999.999.999", + Port: 9, + }, + }, + } + + _, err := getDeviceConfig("bad-ip") + if err == nil { + t.Fatal("Expected error for invalid IP, got nil") + } +} + func TestLoadConfigFromFile(t *testing.T) { // Unset env var to force file loading oldEnv := os.Getenv("WOL_CONFIG") From 8e35955a65e38d29594105def37311d1011f98b0 Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sun, 21 Dec 2025 00:10:50 +0000 Subject: [PATCH 10/14] minor doc formating fix --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9a378fa..89eb5d0 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ services: mkdir -p ~/.config/wol-server cp config.example.yaml ~/.config/wol-server/config.yaml # Edit config.yaml with your device details +```` ```bash # Run From cc441ed95674c3656eb93379c59deac7d4153d80 Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sun, 21 Dec 2025 00:13:59 +0000 Subject: [PATCH 11/14] add port range validation --- config.go | 5 +++++ config_test.go | 36 ++++++++++++++++++++++++++++++++++++ handlers_test.go | 34 ++++++++++++++++++++++++++++++++++ main.go | 4 ++++ 4 files changed, 79 insertions(+) diff --git a/config.go b/config.go index d5cc156..62df125 100644 --- a/config.go +++ b/config.go @@ -88,6 +88,11 @@ func getDeviceConfig(deviceName string) (WOLConfig, error) { port = 9 } + // Validate port range + if port < 1 || port > 65535 { + return WOLConfig{}, fmt.Errorf("invalid port %d for device '%s': must be between 1 and 65535", port, deviceName) + } + // Validate MAC address format if _, err := net.ParseMAC(device.MAC); err != nil { return WOLConfig{}, fmt.Errorf("invalid MAC address '%s' for device '%s': %w", device.MAC, deviceName, err) diff --git a/config_test.go b/config_test.go index bcc94ad..f92a5e0 100644 --- a/config_test.go +++ b/config_test.go @@ -82,6 +82,42 @@ func TestGetDeviceConfigInvalidIP(t *testing.T) { } } +func TestGetDeviceConfigInvalidPortRange(t *testing.T) { + testCases := []struct { + name string + port int + }{ + {"negative port", -1}, + {"zero port after default", 0}, // This should use default 9, so won't fail + {"port too high", 65536}, + {"port way too high", 99999}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.port == 0 { + // Port 0 should use default, so skip this test case + t.Skip("Port 0 uses default value") + } + + globalConfig = Config{ + Devices: map[string]DeviceConfig{ + "bad-port": { + MAC: "00:11:22:33:44:55", + IP: "192.168.1.100", + Port: tc.port, + }, + }, + } + + _, err := getDeviceConfig("bad-port") + if err == nil { + t.Errorf("Expected error for port %d, got nil", tc.port) + } + }) + } +} + func TestLoadConfigFromFile(t *testing.T) { // Unset env var to force file loading oldEnv := os.Getenv("WOL_CONFIG") diff --git a/handlers_test.go b/handlers_test.go index 6c89caf..dc2549e 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -217,3 +217,37 @@ func TestWakeHandlerValidIPv4(t *testing.T) { } } } + +func TestWakeHandlerPortOutOfRange(t *testing.T) { + globalConfig = Config{ + Devices: map[string]DeviceConfig{}, + } + + testCases := []struct { + name string + port string + }{ + {"negative port", "-1"}, + {"zero port", "0"}, + {"port too high", "65536"}, + {"port way too high", "99999"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/wake?mac=00:11:22:33:44:55&ip=127.0.0.1&port="+tc.port, nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected 400 for port %s, got %d", tc.port, resp.StatusCode) + } + + if !strings.Contains(w.Body.String(), "out of range") && !strings.Contains(w.Body.String(), "Invalid port") { + t.Errorf("Expected error message about port range, got: %s", w.Body.String()) + } + }) + } +} diff --git a/main.go b/main.go index 4fdebcf..97d8a09 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,10 @@ func handler(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Invalid port value: %s", portStr), http.StatusBadRequest) return } + if p < 1 || p > 65535 { + http.Error(w, fmt.Sprintf("Port %d out of range: must be between 1 and 65535", p), http.StatusBadRequest) + return + } port = p } From 693490d6b06d814dcdacf194444ec57d03211142 Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sun, 21 Dec 2025 01:06:02 +0000 Subject: [PATCH 12/14] use OIDC (instead of token for CodeCov) --- .github/workflows/test.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d345998..cedde05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,10 @@ on: jobs: test: runs-on: ubuntu-latest - + permissions: + contents: read + id-token: write + steps: - name: Checkout code uses: actions/checkout@v4 @@ -32,7 +35,8 @@ jobs: - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 with: - token: ${{ secrets.CODECOV_TOKEN }} + use_oidc: true + fail_ci_if_error: true files: coverage.out - name: Check formatting From 2cb8b81c6fbf0b58fad3bb82d61d7ac8181aa035 Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sun, 21 Dec 2025 01:07:38 +0000 Subject: [PATCH 13/14] updated go.sum --- go.sum | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/go.sum b/go.sum index d61b19e..a62c313 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 891541df02cad0b5d09dfebb1be2caff68d56dc0 Mon Sep 17 00:00:00 2001 From: Dalton Lima Date: Sun, 21 Dec 2025 01:10:35 +0000 Subject: [PATCH 14/14] fix formatting issues --- config_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config_test.go b/config_test.go index f92a5e0..aa8df07 100644 --- a/config_test.go +++ b/config_test.go @@ -183,14 +183,14 @@ func TestGetDeviceConfig(t *testing.T) { globalConfig = Config{ Devices: map[string]DeviceConfig{ "desktop": { - MAC: "00:11:22:33:44:55", - IP: "192.168.1.255", - Port: 9, + MAC: "00:11:22:33:44:55", + IP: "192.168.1.255", + Port: 9, }, "laptop": { - MAC: "aa:bb:cc:dd:ee:ff", - IP: "192.168.1.100", - Port: 0, // Test default port + MAC: "aa:bb:cc:dd:ee:ff", + IP: "192.168.1.100", + Port: 0, // Test default port }, }, }