feat(sol): UDP + HTTP listener
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
build.sh
|
||||
dist/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
.env
|
||||
build.log
|
||||
build/
|
||||
Makefile
|
||||
35
auth/auth.go
Normal file
35
auth/auth.go
Normal 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
82
config/config.go
Normal 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
5
go.mod
Normal 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
2
go.sum
Normal 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=
|
||||
89
magicpacket/magicpacket.go
Normal file
89
magicpacket/magicpacket.go
Normal 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
59
main.go
Normal 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
91
server/api.go
Normal 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
107
server/udp.go
Normal 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
18
system/shutdown.go
Normal 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)
|
||||
}
|
||||
}
|
||||
18
system/shutdown_windows.go
Normal file
18
system/shutdown_windows.go
Normal 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
50
utils/env.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user