init
This commit is contained in:
5
go.mod
Normal file
5
go.mod
Normal 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
2
go.sum
Normal 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
1
machines.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"Linux-PC":"10:bf:67:32:00:03"}
|
530
main.go
Normal file
530
main.go
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
// 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("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 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>
|
||||||
|
`
|
6
static/css/bootstrap.min.css
vendored
Normal file
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
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
BIN
static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
static/img/footerlower.webp
Normal file
BIN
static/img/footerlower.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
BIN
static/img/footerupper.webp
Normal file
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
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
5021
static/js/tom-select.complete.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user