Files
edge-wol/main.go
jbergner 9e4729fbc2
All checks were successful
release-tag / release-image (push) Successful in 1m45s
Update für Docker und automatischer Build
2025-08-05 19:26:50 +02:00

531 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// WakeonLAN (WoL) HTTP server with persistent storage and simple web UI.
//
// Usage:
//
// go run wol_server.go
//
// Environment variables:
//
// LISTEN address to bind (default ":8080")
// DB JSON file for machine list (default "machines.json")
//
// Endpoints/API:
//
// GET /list JSON map of machines (name ➜ MAC)
// GET /wake/<name> send WoL magic packet to <name>
// GET /manage simple HTML UI with list/add/remove forms
// POST /add (form/json) add a machine ("name", "mac")
// POST /remove (form/json) remove a machine ("name")
//
// Security: for production, add authentication and restrict binding.
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"log"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
username = GetENV("EW_USERNAME", "root")
password = GetENV("EW_PASSWORD", "root")
productive = Enabled("EW_PRODUCTIVE", false)
hashedPassword = ""
)
var (
machines map[string]string // name ➜ MAC
mu sync.RWMutex // guards machines
dbPath string // path to JSON file
)
/*func init() {
// Ensure correct MIME types for CSS and JS so browsers don't block them.
_ = mime.AddExtensionType(".css", "text/css; charset=utf-8")
_ = mime.AddExtensionType(".js", "application/javascript")
}*/
// HTML template for the /manage UI.
var manageTmpl = template.Must(template.New("manage").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>WoL Manager</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
</head>
<body class="bg-light">
<div class="container py-4">
<h1 class="mb-4">Wake-on-LAN Manager</h1>
<!-- Machines list -->
<div class="card mb-4 shadow-sm">
<div class="card-header fw-semibold">Machines</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>MAC</th>
<th class="text-end">
<form method="GET" action="/wakeall" class="d-inline">
<button type="submit" class="btn btn-sm btn-success me-1">Wake-All</button>
</form>Action</th>
</tr>
</thead>
<tbody>
{{range $name,$mac := .Machines}}
<tr>
<td class="align-middle">{{$name}}</td>
<td class="align-middle">{{$mac}}</td>
<td class="text-end">
<form method="POST" action="/wake/{{$name}}" class="d-inline">
<button type="submit" class="btn btn-sm btn-success me-1">Wake</button>
</form>
<form method="POST" action="/remove" class="d-inline" onsubmit="return confirm('Remove {{$name}}?');">
<input type="hidden" name="name" value="{{$name}}">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Add machine form -->
<div class="card shadow-sm">
<div class="card-header fw-semibold">Add Machine</div>
<div class="card-body">
<form method="POST" action="/add" class="row g-3">
<div class="col-md-4">
<label class="form-label">Name</label>
<input name="name" class="form-control" required>
</div>
<div class="col-md-4">
<label class="form-label">MAC</label>
<input name="mac" class="form-control" required pattern="([0-9A-Fa-f]{2}:?){6}">
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Add</button>
</div>
</form>
</div>
</div>
</div>
<script src="/static/js/bootstrap.bundle.min.js"></script>
</body>
</html>`))
func GetENV(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func Enabled(k string, def bool) bool {
b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k)))
if err != nil {
return def
}
return b
}
var sessionStore = make(map[string]string) // token → username
var loginAttempts = make(map[string]int)
var loginLastAttempt = make(map[string]time.Time)
var loginBlockedUntil = make(map[string]time.Time)
var loginMutex sync.Mutex
func isAuthenticated(r *http.Request) bool {
cookie, err := r.Cookie("session")
if err != nil {
return false
}
// Prüfen, ob der Token im sessionStore existiert
_, ok := sessionStore[cookie.Value]
return ok
}
func hashPassword(pw string) string {
hash, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
return string(hash)
}
func checkPasswordHash(pw, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw))
return err == nil
}
func generateSessionToken() string {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "" // handle error besser im echten Code
}
return hex.EncodeToString(b)
}
func main() {
// Determine DB path and load machines.
hashedPassword = hashPassword(password)
dbPath = GetENV("EW_DB", "/data/machines.json")
loadMachines()
// Save on SIGINT/SIGTERM.
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() {
<-sig
if err := saveMachines(); err != nil {
log.Printf("error saving machines: %v", err)
}
os.Exit(0)
}()
// Register handlers.
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
ip := strings.Split(r.RemoteAddr, ":")[0]
loginMutex.Lock()
blockUntil, blocked := loginBlockedUntil[ip]
if blocked && time.Now().Before(blockUntil) {
loginMutex.Unlock()
http.Error(w, "Zu viele Fehlversuche. Bitte versuch es später erneut.", http.StatusTooManyRequests)
return
}
loginMutex.Unlock()
if r.Method == http.MethodPost {
r.ParseForm()
user := r.FormValue("username")
pass := r.FormValue("password")
if user == username && checkPasswordHash(pass, hashedPassword) {
token := generateSessionToken()
// Speichere Session
sessionStore[token] = user
// Cookie setzen
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
// Erfolgreich -> Versuche zurücksetzen
loginMutex.Lock()
delete(loginAttempts, ip)
delete(loginLastAttempt, ip)
delete(loginBlockedUntil, ip)
loginMutex.Unlock()
http.Redirect(w, r, "/manage", http.StatusSeeOther)
return
}
// Fehlversuch behandeln
loginMutex.Lock()
loginAttempts[ip]++
loginLastAttempt[ip] = time.Now()
if loginAttempts[ip] >= 5 {
loginBlockedUntil[ip] = time.Now().Add(10 * time.Minute)
}
loginMutex.Unlock()
http.Error(w, "Login fehlgeschlagen", http.StatusUnauthorized)
return
}
// GET: Login-Formular
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(loginForm))
})
http.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil {
token := cookie.Value
// Token aus dem serverseitigen Store löschen
delete(sessionStore, token)
// Cookie ungültig machen
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
http.Redirect(w, r, "/", http.StatusSeeOther)
})
if productive {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("/data/static"))))
} else {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
}
http.HandleFunc("/list", listHandler)
http.HandleFunc("/wake/", wakeHandler)
http.HandleFunc("/wakeall", wakeAllHandler)
http.HandleFunc("/manage", manageHandler)
http.HandleFunc("/", manageHandler)
http.HandleFunc("/add", addHandler)
http.HandleFunc("/remove", removeHandler)
addr := os.Getenv("LISTEN")
if addr == "" {
addr = ":8080"
}
log.Printf("WoL server listening on %s (DB: %s)", addr, dbPath)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatalf("http server error: %v", err)
}
}
// ========= persistence =========
func wakeAllHandler(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
type StatusStruct struct {
PCName string
MAC string
Status string
}
var ResultStruct []StatusStruct
for a, b := range machines {
err := sendMagicPacket(b)
var c string
if err == nil {
c = "OK"
} else {
c = err.Error()
}
a1 := StatusStruct{PCName: a, MAC: b, Status: c}
fmt.Println(a1)
ResultStruct = append(ResultStruct, a1)
}
_ = json.NewEncoder(w).Encode(ResultStruct)
}
func loadMachines() {
file, err := os.Open(dbPath)
if err != nil {
if os.IsNotExist(err) {
machines = make(map[string]string)
return
}
log.Fatalf("cannot open %s: %v", dbPath, err)
}
defer file.Close()
if err := json.NewDecoder(file).Decode(&machines); err != nil {
log.Fatalf("decode %s: %v", dbPath, err)
}
}
func saveMachines() error {
mu.RLock()
defer mu.RUnlock()
tmp := dbPath + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
if err := json.NewEncoder(f).Encode(machines); err != nil {
f.Close()
return err
}
f.Close()
return os.Rename(tmp, dbPath)
}
// ========= handlers =========
// listHandler returns the current machines map as JSON.
func listHandler(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(machines)
}
// wakeHandler sends a WoL magic packet to the requested machine name.
func wakeHandler(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/wake/")
mu.RLock()
mac, ok := machines[name]
mu.RUnlock()
if !ok {
http.Error(w, "unknown machine", http.StatusNotFound)
return
}
if err := sendMagicPacket(mac); err != nil {
log.Printf("failed to wake %s: %v", name, err)
http.Error(w, "failed to wake device", http.StatusInternalServerError)
return
}
if r.Header.Get("Accept") == "text/html" {
http.Redirect(w, r, "/manage", http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "host": name})
}
// manageHandler serves a minimal HTML UI to list/add/remove machines.
func manageHandler(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
mu.RLock()
data := struct{ Machines map[string]string }{machines}
mu.RUnlock()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = manageTmpl.Execute(w, data)
}
// addHandler adds a new machine via form or JSON.
func addHandler(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
mac := strings.TrimSpace(r.FormValue("mac"))
if name == "" || mac == "" {
http.Error(w, "name and mac required", http.StatusBadRequest)
return
}
if _, err := net.ParseMAC(mac); err != nil {
http.Error(w, "invalid mac", http.StatusBadRequest)
return
}
mu.Lock()
machines[name] = mac
mu.Unlock()
http.Redirect(w, r, "/manage", http.StatusSeeOther)
}
// removeHandler deletes a machine specified by name.
func removeHandler(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
mu.Lock()
delete(machines, name)
mu.Unlock()
http.Redirect(w, r, "/manage", http.StatusSeeOther)
}
// ========= WoL core =========
// sendMagicPacket crafts and broadcasts the 102byte magic packet defined by the WoL spec.
func sendMagicPacket(macAddr string) error {
hwAddr, err := net.ParseMAC(macAddr)
if err != nil {
return fmt.Errorf("invalid MAC address %q: %w", macAddr, err)
}
// Build magic packet: 6×0xFF followed by MAC repeated 16 times.
packet := make([]byte, 102)
for i := 0; i < 6; i++ {
packet[i] = 0xFF
}
for i := 0; i < 16; i++ {
copy(packet[6+i*6:], hwAddr)
}
udpAddr := &net.UDPAddr{IP: net.IPv4bcast, Port: 9}
conn, err := net.DialUDP("udp", nil, udpAddr)
if err != nil {
return fmt.Errorf("dial udp: %w", err)
}
defer conn.Close()
if _, err := conn.Write(packet); err != nil {
return fmt.Errorf("write packet: %w", err)
}
return nil
}
const loginForm = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Login</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
</head>
<body class="bg-light">
<div class="container mt-5">
<h2>Login</h2>
<form method="POST" class="card p-4 shadow-sm">
<div class="mb-3">
<label class="form-label">Benutzername</label>
<input type="text" name="username" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Passwort</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
</body>
</html>
`