feat(sol): UDP + HTTP listener

This commit is contained in:
Björn Benouarets
2025-11-11 17:19:49 +01:00
commit 8ed56f7ba0
12 changed files with 565 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
build.sh
dist/
.DS_Store
.idea/
.vscode/
.env
build.log
build/
Makefile

35
auth/auth.go Normal file
View File

@@ -0,0 +1,35 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
// Authenticator handles authentication
type Authenticator struct {
user string
password string
hash string
}
// New creates a new authenticator with the given credentials
func New(username, password string) *Authenticator {
authString := fmt.Sprintf("%s:%s", username, password)
hash := sha256.Sum256([]byte(authString))
hashStr := hex.EncodeToString(hash[:])
return &Authenticator{
user: username,
password: password,
hash: hashStr,
}
}
// Verify checks if the provided username and password are valid
func (a *Authenticator) Verify(username, password string) bool {
authString := fmt.Sprintf("%s:%s", username, password)
hash := sha256.Sum256([]byte(authString))
hashStr := hex.EncodeToString(hash[:])
return hashStr == a.hash
}

82
config/config.go Normal file
View File

@@ -0,0 +1,82 @@
package config
import (
"os"
"strconv"
"git.secnex.io/secnex/masterlog"
)
const (
// Default ports
DefaultSOLPort = 9999
DefaultAPIPort = 8080
)
// Config holds the application configuration
type Config struct {
SOLPort int
APIPort int
APIEnabled bool
AuthUser string
AuthPass string
}
// Load loads configuration from environment variables
func Load() *Config {
cfg := &Config{
SOLPort: getPortFromEnv("SOL_PORT", DefaultSOLPort),
APIPort: getPortFromEnv("API_PORT", DefaultAPIPort),
APIEnabled: getBoolFromEnv("API_ENABLED", false),
AuthUser: os.Getenv("SOL_AUTH_USER"),
AuthPass: os.Getenv("SOL_AUTH_PASSWORD"),
}
if cfg.AuthUser == "" || cfg.AuthPass == "" {
masterlog.Error("Environment variables not set", map[string]interface{}{
"variables": "SOL_AUTH_USER, SOL_AUTH_PASSWORD",
})
return nil
}
return cfg
}
func getPortFromEnv(envVar string, defaultPort int) int {
if portStr := os.Getenv(envVar); portStr != "" {
if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port < 65536 {
return port
} else {
masterlog.Error("Invalid environment variable value", map[string]interface{}{
"error": err,
"variable": envVar,
"value": portStr,
"default": defaultPort,
"type": "port",
})
}
}
return defaultPort
}
func getBoolFromEnv(envVar string, defaultValue bool) bool {
if val := os.Getenv(envVar); val != "" {
if val == "true" || val == "TRUE" || val == "True" || val == "1" {
return true
} else {
masterlog.Error("Invalid environment variable value", map[string]interface{}{
"variable": envVar,
"value": val,
"type": "boolean",
})
}
return false
} else {
masterlog.Error("Invalid environment variable value", map[string]interface{}{
"variable": envVar,
"value": val,
"type": "boolean",
})
return false
}
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module wol-sol-agent
go 1.25.3
require git.secnex.io/secnex/masterlog v0.1.0

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
git.secnex.io/secnex/masterlog v0.1.0 h1:74j9CATpfeK0lxpWIQC9ag9083akwG8khi5BwLedD8E=
git.secnex.io/secnex/masterlog v0.1.0/go.mod h1:OnDlwEzdkKMnqY+G5O9kHdhoJ6fH1llbVdXpgSc5SdM=

View File

@@ -0,0 +1,89 @@
package magicpacket
import (
"bytes"
"strings"
)
const (
// Magic packet format: 6 bytes of 0xFF followed by 16 repetitions of the MAC address
MagicPacketHeader = 6
MacAddressRepetitions = 16
MacAddressLength = 6
// Authentication format for UDP: "AUTH:username:password:" followed by magic packet
AuthPrefix = "AUTH:"
)
// IsMagicPacket checks if the received data is a valid Wake-on-LAN Magic Packet
// Format: 6 bytes of 0xFF followed by 16 repetitions of the MAC address (6 bytes each)
func IsMagicPacket(data []byte) bool {
// Minimum size: 6 (header) + 16*6 (MAC repetitions) = 102 bytes
minSize := MagicPacketHeader + (MacAddressRepetitions * MacAddressLength)
if len(data) < minSize {
return false
}
// Check for 6 bytes of 0xFF at the beginning
header := data[:MagicPacketHeader]
for _, b := range header {
if b != 0xFF {
return false
}
}
// Extract the MAC address (first 6 bytes after header)
macAddr := data[MagicPacketHeader : MagicPacketHeader+MacAddressLength]
// Check if the MAC address is repeated 16 times
for i := 1; i < MacAddressRepetitions; i++ {
offset := MagicPacketHeader + (i * MacAddressLength)
if offset+MacAddressLength > len(data) {
return false
}
repeatedMac := data[offset : offset+MacAddressLength]
if !bytes.Equal(macAddr, repeatedMac) {
return false
}
}
return true
}
// ExtractAuthFromPacket extracts username and password from an authenticated packet
// Format: "AUTH:username:password:" followed by standard magic packet
// Returns username, password, magicPacketData, and success status
func ExtractAuthFromPacket(data []byte) (string, string, []byte, bool) {
dataStr := string(data)
// Check for authentication prefix
if !strings.HasPrefix(dataStr, AuthPrefix) {
return "", "", nil, false
}
// Extract authentication part
authEnd := strings.Index(dataStr[len(AuthPrefix):], ":")
if authEnd == -1 {
return "", "", nil, false
}
authEnd += len(AuthPrefix)
// Extract username and password
authPart := dataStr[len(AuthPrefix):authEnd]
parts := strings.Split(authPart, ":")
if len(parts) != 2 {
return "", "", nil, false
}
username := parts[0]
password := parts[1]
// Extract magic packet part (after "AUTH:username:password:")
magicPacketStart := authEnd + 1
if magicPacketStart >= len(data) {
return "", "", nil, false
}
magicPacketData := data[magicPacketStart:]
return username, password, magicPacketData, true
}

59
main.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"os"
"os/signal"
"time"
"wol-sol-agent/auth"
"wol-sol-agent/config"
"wol-sol-agent/server"
"wol-sol-agent/utils"
"git.secnex.io/secnex/masterlog"
)
func main() {
cfg := config.Load()
pseudonymizer := masterlog.NewPseudonymizerFromString(utils.GetStringFromEnv("MASTERLOG_PSEUDONYMIZER", "high-secure-secret"))
masterlog.SetPseudonymizer(pseudonymizer)
masterlog.SetLevel(masterlog.Level(utils.GetIntFromEnv("MASTERLOG_LEVEL", int(masterlog.LevelInfo))))
masterlog.AddSensitiveFields("AUTH_USER", "AUTH_PASS")
masterlog.Info("Authentication enabled")
authenticator := auth.New(cfg.AuthUser, cfg.AuthPass)
sigChan := make(chan os.Signal, 1)
// Use os.Interrupt which works on all platforms (Windows, Linux, macOS)
signal.Notify(sigChan, os.Interrupt)
udpServer := server.NewUDPServer(cfg.SOLPort, authenticator)
udpDone := make(chan bool)
go udpServer.Start(udpDone)
var apiDone chan bool
if cfg.APIEnabled {
apiServer := server.NewAPIServer(cfg.APIPort, authenticator)
apiDone = make(chan bool)
go apiServer.Start(apiDone)
}
masterlog.Info("Starting servers", map[string]interface{}{
"SOL_PORT": cfg.SOLPort,
"API_PORT": cfg.APIPort,
"API_ENABLED": cfg.APIEnabled,
})
<-sigChan
masterlog.Info("Shutting down", map[string]interface{}{
"SOL_PORT": cfg.SOLPort,
"API_PORT": cfg.APIPort,
"API_ENABLED": cfg.APIEnabled,
})
close(udpDone)
if apiDone != nil {
close(apiDone)
}
time.Sleep(100 * time.Millisecond)
}

91
server/api.go Normal file
View File

@@ -0,0 +1,91 @@
package server
import (
"fmt"
"net/http"
"time"
"wol-sol-agent/auth"
"wol-sol-agent/system"
"git.secnex.io/secnex/masterlog"
)
// APIServer handles HTTP API requests
type APIServer struct {
port int
authenticator *auth.Authenticator
}
// NewAPIServer creates a new API server
func NewAPIServer(port int, authenticator *auth.Authenticator) *APIServer {
return &APIServer{
port: port,
authenticator: authenticator,
}
}
// Start starts the API server
func (s *APIServer) Start(done chan bool) {
mux := http.NewServeMux()
mux.HandleFunc("/shutdown", s.handleShutdown)
server := &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: mux,
}
masterlog.Info("API server listening on port", map[string]interface{}{
"port": s.port,
})
// Start server in goroutine
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
masterlog.Error("API server error", map[string]interface{}{
"error": err,
})
}
}()
// Wait for shutdown signal
<-done
server.Close()
}
func (s *APIServer) handleShutdown(w http.ResponseWriter, r *http.Request) {
// Check authentication
username, password, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="SOL Agent"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
masterlog.Error("Unauthorized API request", map[string]interface{}{
"address": r.RemoteAddr,
"error": "no auth",
})
return
}
// Verify credentials
if !s.authenticator.Verify(username, password) {
w.Header().Set("WWW-Authenticate", `Basic realm="SOL Agent"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
masterlog.Error("Unauthorized API request", map[string]interface{}{
"address": r.RemoteAddr,
"error": "invalid credentials",
})
return
}
masterlog.Info("Authenticated shutdown request", map[string]interface{}{
"address": r.RemoteAddr,
})
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Shutting down system...\n")
// Shutdown in a goroutine to allow response to be sent
go func() {
time.Sleep(500 * time.Millisecond)
system.Shutdown()
}()
}

107
server/udp.go Normal file
View File

@@ -0,0 +1,107 @@
package server
import (
"fmt"
"net"
"time"
"wol-sol-agent/auth"
"wol-sol-agent/magicpacket"
"wol-sol-agent/system"
"git.secnex.io/secnex/masterlog"
)
// UDPServer handles UDP connections for Magic Packets
type UDPServer struct {
port int
authenticator *auth.Authenticator
}
// NewUDPServer creates a new UDP server
func NewUDPServer(port int, authenticator *auth.Authenticator) *UDPServer {
return &UDPServer{
port: port,
authenticator: authenticator,
}
}
// Start starts the UDP server
func (s *UDPServer) Start(done chan bool) {
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf(":%d", s.port))
if err != nil {
masterlog.Error("Failed to resolve UDP address", map[string]interface{}{
"error": err,
})
return
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
masterlog.Error("Failed to listen on UDP port", map[string]interface{}{
"error": err,
"port": s.port,
})
return
}
defer conn.Close()
masterlog.Info("UDP server listening on port", map[string]interface{}{
"port": s.port,
})
buffer := make([]byte, 2048)
for {
select {
case <-done:
return
default:
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
n, clientAddr, err := conn.ReadFromUDP(buffer)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
masterlog.Error("Error reading UDP packet", map[string]interface{}{
"error": err,
})
continue
}
masterlog.Info("Received UDP packet", map[string]interface{}{
"bytes": n,
"address": clientAddr,
})
// Check authentication and magic packet
if s.isValidAuthenticatedMagicPacket(buffer[:n]) {
masterlog.Info("Authenticated Magic Packet detected! Shutting down system...", map[string]interface{}{
"address": clientAddr,
})
system.Shutdown()
return
}
masterlog.Error("Invalid or unauthenticated packet", map[string]interface{}{
"address": clientAddr,
})
}
}
}
// isValidAuthenticatedMagicPacket checks if the packet contains valid authentication
// followed by a valid Magic Packet
func (s *UDPServer) isValidAuthenticatedMagicPacket(data []byte) bool {
username, password, magicPacketData, ok := magicpacket.ExtractAuthFromPacket(data)
if !ok {
return false
}
// Verify authentication
if !s.authenticator.Verify(username, password) {
return false
}
// Check if it's a valid magic packet
return magicpacket.IsMagicPacket(magicPacketData)
}

18
system/shutdown.go Normal file
View File

@@ -0,0 +1,18 @@
//go:build !windows
// +build !windows
package system
import (
"log"
"os/exec"
)
// Shutdown shuts down the system (Unix/Linux/macOS)
func Shutdown() {
cmd := exec.Command("shutdown", "-h", "now")
if err := cmd.Run(); err != nil {
log.Fatalf("Failed to shutdown system: %v", err)
}
}

View File

@@ -0,0 +1,18 @@
//go:build windows
// +build windows
package system
import (
"log"
"os/exec"
)
// Shutdown shuts down the system (Windows)
func Shutdown() {
cmd := exec.Command("shutdown", "/s", "/t", "0")
if err := cmd.Run(); err != nil {
log.Fatalf("Failed to shutdown system: %v", err)
}
}

50
utils/env.go Normal file
View File

@@ -0,0 +1,50 @@
package utils
import (
"os"
"strconv"
"git.secnex.io/secnex/masterlog"
)
func GetEnv(key string, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
func GetStringFromEnv(key string, defaultValue string) string {
return GetEnv(key, defaultValue)
}
func GetIntFromEnv(key string, defaultValue int) int {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
intValue, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return intValue
}
func GetBoolFromEnv(key string, defaultValue bool) bool {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
if value == "true" || value == "TRUE" || value == "True" || value == "1" {
return true
} else {
masterlog.Error("Invalid environment variable value", map[string]interface{}{
"variable": key,
"value": value,
"type": "boolean",
})
return false
}
}