This commit is contained in:
2025-08-05 19:15:23 +02:00
parent 37b1b55ebc
commit a596961be2
11 changed files with 5574 additions and 0 deletions

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module git.send.nrw/sendnrw/edge-wol
go 1.24.4
require golang.org/x/crypto v0.40.0

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=

1
machines.json Normal file
View File

@@ -0,0 +1 @@
{"Linux-PC":"10:bf:67:32:00:03"}

530
main.go Normal file
View File

@@ -0,0 +1,530 @@
// 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("DB", "./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>
`

6
static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

2
static/css/tom-select.default.min.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
static/img/footerlower.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
static/img/footerupper.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

7
static/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

5021
static/js/tom-select.complete.min.js vendored Normal file

File diff suppressed because it is too large Load Diff