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/.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 diff --git a/.gitignore b/.gitignore index 11f9d06..980318e 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 73e5b8a..40ef0d0 100644 --- a/README.md +++ b/README.md @@ -3,96 +3,164 @@ # 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. -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. +## Quick Start -📦 How It Works +### Docker (Recommended) -- The server exposes an HTTP endpoint: +```yml +services: + wol-server: + image: ghcr.io/daltonbr/wol-server:latest + network_mode: "host" # Required for WOL broadcast on Linux + environment: + WOL_CONFIG: | + devices: + desktop: + 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" + 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 + # target: /root/.config/wol-server/config.yaml + # read_only: true +``` + +### Native Binary ```bash -GET /wake -``` +# Install config +mkdir -p ~/.config/wol-server +cp config.example.yaml ~/.config/wol-server/config.yaml +# Edit config.yaml with your device details +```` -- 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 +```bash +# Run +go run . +``` -🌱 Environment Variables +## Usage -Set these before running the server: +**Wake a configured device:** -| 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) | +```bash +curl http://localhost:5000/wake?device=desktop +``` -▶️ Running +**Wake using direct parameters (no config needed):** -## Docker +```bash +curl "http://localhost:5000/wake?mac=00:11:22:33:44:55&ip=192.168.1.255" +``` -`docker-compose.yml` +**Check server status:** -```yml -services: - wol-server: - 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) - restart: unless-stopped +```bash +curl http://localhost:5000/status ``` -> [!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`. +## Configuration -## How to use +### Option 1: Environment Variable (Docker) -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). +Set `WOL_CONFIG` with YAML content (see docker-compose example above). -Turn on PC - send the magical packet +### Option 2: Config File (Bare Metal) -```http -http://localhost:5000/wake +Create `~/.config/wol-server/config.yaml`: + +```yaml +devices: + desktop: + mac: "00:11:22:33:44:55" + ip: "192.168.1.255" + port: 9 # optional, default: 9 + + laptop: + mac: "11:22:33:44:55:66" + ip: "192.168.1.255" ``` -Check status +See [`config.example.yaml`](config.example.yaml) for more examples. -```http -http://localhost:5000/status -``` +### Configuration Fields -📤 Publishing (GHCR) +| 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 | -To build and publish a multi-arch Docker image (specifically `linux/amd64`) to GitHub Container Registry (GHCR): +## API Reference -```bash -# Build for linux/amd64 -docker build --platform linux/amd64 -t wol-server . +### Endpoints + +#### `GET /wake` + +Wake a device using either a configured device name or direct parameters. + +**Query 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) +- `port` - UDP port (optional, default: 9) + +**Examples:** -# 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: +```bash +# Using device name +GET /wake?device=desktop -# Push to GHCR -docker push ghcr.io/daltonbr/wol-server:latest -docker push ghcr.io/daltonbr/wol-server: +# Using direct parameters +GET /wake?mac=00:11:22:33:44:55&ip=192.168.1.255&port=9 ``` -> 💡 You must be logged in to GHCR with a Personal Access Token (PAT) that has `write:packages` permission: +#### `GET /status` + +Check if the server is running. + +**Response:** `WOL service is running` + +## Prerequisites + +Your target machine must have: + +- Wake-on-LAN enabled in BIOS/UEFI +- Wake-on-LAN enabled in network adapter settings +- Connected to power (or battery with WOL support) + +[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) + +## Alternative: Volume Mount Config + +Instead of using `WOL_CONFIG` env var, you can mount a config file (see commented example in docker-compose above). + +## Development + +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/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..0ef5eea --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,18 @@ +# 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 + + gaming-pc: + mac: "aa:bb:cc:dd:ee:ff" + ip: "192.168.1.100" + port: 9 + + laptop: + mac: "11:22:33:44:55:66" + ip: "192.168.1.255" + # port defaults to 9 if not specified diff --git a/config.go b/config.go index 3fd0525..62df125 100644 --- a/config.go +++ b/config.go @@ -1,64 +1,111 @@ package main import ( - "errors" + "fmt" + "net" "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"` +} + +// 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"). 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 +} + +// 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 +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 + } + + // 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") + } - // Broadcast indicates whether to use UDP broadcast (true) or direct IP (false). - Broadcast bool + configPath := filepath.Join(configDir, "wol-server", "config.yaml") - // 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 + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config file %s: %w", configPath, err) + } - // 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 + if err := yaml.Unmarshal(data, &globalConfig); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } - // PingTimeout defines the timeout in seconds when SendPing is enabled. - // PingTimeout int + return nil } -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" +// 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) + } + + // Apply defaults + port := device.Port + if port == 0 { + port = 9 + } - if mac == "" { - return WOLConfig{}, errors.New("WOL_MAC environment variable is required") + // 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) } - if ip == "" { - return WOLConfig{}, errors.New("WOL_IP environment variable is required") + // 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) } - port := 9 // default - if p, err := strconv.Atoi(portStr); err == nil { - port = p + // 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: mac, - IPAddress: ip, + MACAddress: device.MAC, + IPAddress: device.IP, Port: port, - Broadcast: broadcast, }, nil } diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..aa8df07 --- /dev/null +++ b/config_test.go @@ -0,0 +1,227 @@ +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 +` + + 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) + } +} + +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 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") + 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 +` + + 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, + }, + "laptop": { + MAC: "aa:bb:cc:dd:ee:ff", + IP: "192.168.1.100", + Port: 0, // Test default port + }, + }, + } + + // 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/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/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. 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/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= diff --git a/handlers_test.go b/handlers_test.go new file mode 100644 index 0000000..dc2549e --- /dev/null +++ b/handlers_test.go @@ -0,0 +1,253 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "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 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()) + } +} + +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()) + } + } +} + +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 227fa25..97d8a09 100644 --- a/main.go +++ b/main.go @@ -3,19 +3,16 @@ 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 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) @@ -25,10 +22,60 @@ 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 != "" { + p, err := strconv.Atoi(portStr) + if err != nil { + 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 + } + + // Validate MAC address format + if _, err := net.ParseMAC(mac); err != nil { + http.Error(w, fmt.Sprintf("Invalid MAC address: %s", mac), http.StatusBadRequest) + 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, + Port: port, + } + } else { + http.Error(w, "Missing required parameter: either 'device' or 'mac' and 'ip' must be provided", http.StatusBadRequest) return } diff --git a/wol.go b/wol.go index ce2012d..5c4e0d9 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), @@ -36,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 new file mode 100644 index 0000000..9dee15d --- /dev/null +++ b/wol_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "net" + "testing" +) + +func TestBuildMagicPacket(t *testing.T) { + macAddress := "00:11:22:33:44:55" + + packet, err := buildMagicPacket(macAddress) + if err != nil { + t.Fatalf("buildMagicPacket failed: %v", err) + } + + // 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 + hardwareAddr, _ := net.ParseMAC(macAddress) + 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 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 + 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 + }, + }, + } + + 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) + } +}