All checks were successful
release-tag / release-image (push) Successful in 1m45s
531 lines
14 KiB
Go
531 lines
14 KiB
Go
// Wake‑on‑LAN (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 102‑byte 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>
|
||
`
|