Latest Updates done, before integrating

This commit is contained in:
2026-04-12 10:13:53 +02:00
parent db46fcf0c6
commit 5354e34055
19 changed files with 820 additions and 41 deletions

221
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,221 @@
// Package auth provides session-based authentication and CSRF protection
// for the EMS web UI. Both the session token and CSRF token are persisted
// to disk so they survive EMS restarts without logging out open browser tabs.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
)
const (
cookieName = "ems_session"
csrfField = "csrf_token"
sessionFile = "session.token"
csrfFile = "csrf.token"
)
// Manager handles login, session cookies, and CSRF tokens.
type Manager struct {
username string
password string
tokenDir string
mu sync.RWMutex
session string
csrf string
logger *slog.Logger
}
// New creates a Manager and loads or generates persistent tokens from tokenDir.
// If password is empty, authentication is disabled (open access).
func New(username, password, tokenDir string, logger *slog.Logger) *Manager {
m := &Manager{
username: username,
password: password,
tokenDir: tokenDir,
logger: logger,
}
m.session = m.loadOrGenerate(sessionFile)
m.csrf = m.loadOrGenerate(csrfFile)
return m
}
// Enabled reports whether authentication is active.
func (m *Manager) Enabled() bool { return m.password != "" }
// CSRFToken returns the persistent CSRF token to embed in HTML forms.
func (m *Manager) CSRFToken() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.csrf
}
// RequireAuth is middleware that redirects unauthenticated requests to /login.
// Exempt paths: /login, /health (health checks don't need auth).
func (m *Manager) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !m.Enabled() || r.URL.Path == "/login" || r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(cookieName)
if err != nil || !m.validSession(cookie.Value) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
// VerifyCSRF is middleware that checks the csrf_token field on POST requests.
// On failure it redirects to / so the user gets a fresh form — avoids a
// confusing error page after an EMS restart regenerates the token.
func (m *Manager) VerifyCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if m.Enabled() && r.Method == http.MethodPost && r.URL.Path != "/login" && r.URL.Path != "/health" {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
got := r.FormValue(csrfField)
m.mu.RLock()
ok := subtle.ConstantTimeCompare([]byte(got), []byte(m.csrf)) == 1
m.mu.RUnlock()
if !ok {
// Redirect to status page so the user gets a fresh form.
m.logger.Warn("CSRF token mismatch — redirecting to status page", "path", r.URL.Path)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
next.ServeHTTP(w, r)
})
}
// LoginHandler serves GET /login (form) and POST /login (credential check).
func (m *Manager) LoginHandler() http.HandlerFunc {
tmpl := template.Must(template.New("login").Parse(loginTemplate))
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
tmpl.Execute(w, nil) //nolint:errcheck
return
}
if r.Method != http.MethodPost {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
user := r.FormValue("username")
pass := r.FormValue("password")
uOK := subtle.ConstantTimeCompare([]byte(user), []byte(m.username)) == 1
pOK := subtle.ConstantTimeCompare([]byte(pass), []byte(m.password)) == 1
if !uOK || !pOK {
m.logger.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
tmpl.Execute(w, "Ungültige Zugangsdaten") //nolint:errcheck
return
}
m.mu.RLock()
session := m.session
m.mu.RUnlock()
// Set Secure flag when arriving via HTTPS reverse proxy.
secure := r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: session,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
m.logger.Info("web UI login", "remote_addr", r.RemoteAddr)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// LogoutHandler clears the session cookie and redirects to /login.
func (m *Manager) LogoutHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
m.logger.Info("web UI logout", "remote_addr", r.RemoteAddr)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
}
func (m *Manager) validSession(token string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
return subtle.ConstantTimeCompare([]byte(token), []byte(m.session)) == 1
}
func (m *Manager) loadOrGenerate(filename string) string {
path := filepath.Join(m.tokenDir, filename)
if data, err := os.ReadFile(path); err == nil {
if t := strings.TrimSpace(string(data)); len(t) == 64 {
return t
}
}
token := newToken()
if err := os.MkdirAll(m.tokenDir, 0o700); err == nil {
_ = os.WriteFile(path, []byte(token), 0o600)
}
return token
}
func newToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("auth: cannot generate random token: " + err.Error())
}
return hex.EncodeToString(b)
}
const loginTemplate = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EMS · Anmelden</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;background:#f1f5f9;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:1rem;box-shadow:0 2px 12px rgba(0,0,0,.1);padding:2rem;width:100%;max-width:360px}
h1{font-size:1.4rem;margin-bottom:1.5rem;color:#0f172a;text-align:center}
label{display:block;font-size:.85rem;color:#64748b;margin-bottom:.3rem;margin-top:1rem}
input{width:100%;padding:.65rem .8rem;border:1px solid #cbd5e1;border-radius:.5rem;font-size:1rem;outline:none}
input:focus{border-color:#3b82f6;box-shadow:0 0 0 2px #bfdbfe}
button{width:100%;margin-top:1.5rem;padding:.75rem;background:#2563eb;color:#fff;border:none;border-radius:.5rem;font-size:1rem;cursor:pointer}
button:hover{background:#1d4ed8}
.error{background:#fee2e2;color:#b91c1c;border-radius:.5rem;padding:.6rem .9rem;margin-top:1rem;font-size:.9rem;text-align:center}
</style>
</head>
<body>
<div class="card">
<h1>☀️ Solar EMS</h1>
<form method="post" action="/login">
<label for="u">Benutzer</label>
<input type="text" id="u" name="username" autocomplete="username" required autofocus>
<label for="p">Passwort</label>
<input type="password" id="p" name="password" autocomplete="current-password" required>
{{if .}}<div class="error">{{.}}</div>{{end}}
<button type="submit">Anmelden</button>
</form>
</div>
</body>
</html>`

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"net"
"os"
"time"
@@ -191,7 +192,8 @@ func (f *ForecastConfig) FetchIntervalParsed() time.Duration {
// EMSConfig holds operational settings for the EMS daemon.
type EMSConfig struct {
PollInterval string `yaml:"poll_interval"`
ListenAddr string `yaml:"listen_addr"`
ListenAddr string `yaml:"listen_addr"` // web UI address (authenticated)
MetricsAddr string `yaml:"metrics_addr"` // Prometheus /metrics address (no auth, internal only)
LogLevel string `yaml:"log_level"`
StateFile string `yaml:"state_file"`
RecoveryTimeout string `yaml:"recovery_timeout"`
@@ -201,6 +203,8 @@ type EMSConfig struct {
WWBoostDisableFile string `yaml:"ww_boost_disable_file"` // flag file path: presence = WW boost disabled
TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal
SessionLogFile string `yaml:"session_log_file"` // JSONL log of completed charge sessions
HTTPUsername string `yaml:"http_username"` // login username (empty = no auth)
HTTPPassword string `yaml:"http_password"` // login password
}
func (e *EMSConfig) PollIntervalParsed() time.Duration {
@@ -226,6 +230,13 @@ func (e *EMSConfig) OverrideTimeoutParsed() time.Duration {
// Load reads and parses the YAML config file at the given path.
func Load(path string) (*Config, error) {
// Warn if config file is readable by group or others (contains credentials).
if info, err := os.Stat(path); err == nil {
if info.Mode().Perm()&0o077 != 0 {
fmt.Fprintf(os.Stderr, "WARNING: config file %s has permissions %o — should be 0600 (contains credentials)\n", path, info.Mode().Perm())
}
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
@@ -256,5 +267,21 @@ func (c *Config) validate() error {
if c.EMS.ListenAddr == "" {
return fmt.Errorf("ems.listen_addr is required")
}
// Validate Shelly IPs to prevent SSRF via config manipulation.
for name, dev := range map[string]ShellyDevice{
"sg_ready": c.Shelly.SGReady,
"wallbox_a": c.Shelly.WallboxA,
"wallbox_b": c.Shelly.WallboxB,
} {
if dev.IP != "" && net.ParseIP(dev.IP) == nil {
return fmt.Errorf("shelly.%s.ip %q is not a valid IP address", name, dev.IP)
}
}
if c.EMS.HTTPUsername != "" && c.EMS.HTTPPassword == "" {
return fmt.Errorf("ems.http_username is set but ems.http_password is empty")
}
if c.EMS.HTTPPassword != "" && c.EMS.HTTPUsername == "" {
return fmt.Errorf("ems.http_password is set but ems.http_username is empty")
}
return nil
}

View File

@@ -690,8 +690,12 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
continue
}
// Proactive wallboxes: only emergency-brake at SOCFloor
if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) {
// Intentional wallbox charging (proactive or trip mode): only emergency-brake at SOCFloor.
// Trip mode uses ManualOverride=true, proactive charging uses ProactiveCharging=true —
// both deserve soc_floor protection instead of the standard block_all gate.
isIntentionalCharging := (cs.ProactiveCharging || cs.ManualOverride) &&
(c == ConsumerWallboxA || c == ConsumerWallboxB)
if isIntentionalCharging {
floor := float64(e.cfg.CarCharging.SOCFloor)
if floor > 0 && soc >= floor {
continue // still above floor, keep charging
@@ -967,7 +971,10 @@ func (e *Engine) RecoverState(states map[Consumer]DeviceStatus) {
// On match: expired overrides are cleared, resuming normal EMS control.
// Power readings (from PM-capable devices) update the low-power cycle counter for
// car-not-charging detection.
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration) {
// When monitorOnly is true, hardware mismatches silently update engine state without
// logging or applying override lockouts — the EMS cannot act anyway, so treating
// every missed switch as a "manual override" would produce spurious log spam.
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration, monitorOnly bool) {
for c, status := range states {
cs, ok := e.consumers[c]
if !ok {
@@ -975,21 +982,33 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
}
if cs.Active != status.On {
// External change detected
e.logger.Info("manual override detected — external state change",
"consumer", c.String(),
"engine_state", cs.Active,
"hardware_state", status.On,
"override_until", now.Add(overrideTimeout).Format("15:04"),
)
cs.Active = status.On
cs.ManualOverride = true
cs.OverrideUntil = now.Add(overrideTimeout)
cs.LowPowerCycles = 0
if !status.On {
cs.ActivatedAt = time.Time{}
if monitorOnly {
// In monitor-only mode the EMS cannot execute switch actions, so a
// mismatch just means a pending action couldn't be carried out.
// Silently realign engine state to hardware without locking.
cs.Active = status.On
if !status.On {
cs.ActivatedAt = time.Time{}
} else {
cs.ActivatedAt = now
}
} else {
cs.ActivatedAt = now
// External change detected — log and apply override lockout
e.logger.Info("manual override detected — external state change",
"consumer", c.String(),
"engine_state", cs.Active,
"hardware_state", status.On,
"override_until", now.Add(overrideTimeout).Format("15:04"),
)
cs.Active = status.On
cs.ManualOverride = true
cs.OverrideUntil = now.Add(overrideTimeout)
cs.LowPowerCycles = 0
if !status.On {
cs.ActivatedAt = time.Time{}
} else {
cs.ActivatedAt = now
}
}
} else if cs.ManualOverride && now.After(cs.OverrideUntil) {
// Override expired and state matches — resume EMS control

View File

@@ -344,6 +344,7 @@ func TestCarNotChargingReleasesWallbox(t *testing.T) {
map[Consumer]DeviceStatus{ConsumerWallboxA: lowPower},
base.Add(time.Duration(i+1)*2*time.Minute),
time.Hour,
false,
)
}

View File

@@ -46,7 +46,7 @@ func (m *Mode) Set(active bool) error {
}
if active {
f, err := os.Create(m.filePath)
f, err := os.OpenFile(m.filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}

View File

@@ -64,10 +64,12 @@ type Store struct {
wwBoostDisabled *monitor.Mode
tripMgr *trip.Manager
carOptions []CarOption
csrfToken string
hasAuth bool
}
// NewStore creates a new status store.
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, carCharging config.CarChargingConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store {
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, carCharging config.CarChargingConfig, mode *monitor.Mode, wwBoostDisabled *monitor.Mode, tm *trip.Manager, cars []CarOption, csrfToken string, hasAuth bool) *Store {
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
for _, c := range consumerOrder {
consumers[c] = &consumerRecord{}
@@ -82,6 +84,8 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig,
wwBoostDisabled: wwBoostDisabled,
tripMgr: tm,
carOptions: cars,
csrfToken: csrfToken,
hasAuth: hasAuth,
}
}
@@ -195,6 +199,8 @@ type pageData struct {
Consumers []consumerView
Trip tripView
CarOptions []CarOption
CSRFToken string
HasAuth bool // true when login is required — shows logout button
}
// SyncConsumerStates updates active flags for all consumers directly from the
@@ -289,6 +295,8 @@ func (s *Store) Handler() http.HandlerFunc {
IsExporting: s.state.GridPowerW < 0,
AbsGridW: abs(s.state.GridPowerW),
HasPhaseData: hasPhase,
CSRFToken: s.csrfToken,
HasAuth: s.hasAuth,
}
if hasPhase {
for _, ph := range []struct {
@@ -782,9 +790,13 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
{{end}}
{{if not .MonitorOnly}}
<form method="post" action="/monitor">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="monitor-toggle-btn">⏸ Monitor</button>
</form>
{{end}}
{{if .HasAuth}}
<a href="/logout" style="font-size:.8rem;color:#94a3b8;text-decoration:none;padding:.3rem .6rem;border:1px solid #e2e8f0;border-radius:.4rem">Abmelden</a>
{{end}}
</div>
</header>
@@ -792,6 +804,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<div class="banner monitor">
<span>⏸ Monitor-Only — Keine Schaltvorgänge</span>
<form method="post" action="/monitor">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="resume-btn">▶ Automatik</button>
</form>
</div>
@@ -907,6 +920,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
{{end}}
{{if .CanOverride}}
<form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
{{if .Active}}
<input type="hidden" name="state" value="off">
@@ -928,10 +942,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<div style="display:flex;flex-direction:column;gap:0.3rem;flex-shrink:0">
{{if not .Unconfigured}}
<form method="post" action="/ww/reset">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="override-btn turn-off" style="width:100%" title="WW-Solltemperatur auf Basiswert zurücksetzen, Boost für heute sperren">🌡️ Zurücksetzen</button>
</form>
{{end}}
<form method="post" action="/ww/boost">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
{{if .WWBoostDisabled}}
<button type="submit" class="override-btn turn-on" style="width:100%" title="WW Boost durch PV-Überschuss wieder erlauben">✅ Boost ein</button>
{{else}}
@@ -948,6 +964,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<div class="trip-header">
<span class="trip-title">🚗 Fahrt geplant</span>
<form method="post" action="/trip/cancel">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="trip-cancel">Abbrechen</button>
</form>
</div>
@@ -966,6 +983,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<div class="trip-form-card">
<div class="section-title">🚗 Fahrtziel planen</div>
<form method="post" action="/trip" class="trip-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="trip-row">
<select name="wallbox" class="trip-select">
<option value="wallbox_a">Wallbox A</option>