From 5354e34055a02c738858c75561bd2042d683fb6f Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Sun, 12 Apr 2026 10:13:53 +0200 Subject: [PATCH] Latest Updates done, before integrating --- .gitea/workflows/ci.yml | 67 ++++++++++ Dockerfile | 19 +++ configs/ems-config.yaml | 6 +- docker-compose.yml | 53 ++++++++ infra/ca/.gitignore | 12 ++ infra/ca/enroll-iphone.md | 94 ++++++++++++++ infra/ca/gen-ca.sh | 40 ++++++ infra/ca/gen-server-cert.sh | 47 +++++++ infra/ca/issue-client-cert.sh | 57 +++++++++ infra/traefik/dynamic.yml | 37 ++++++ infra/traefik/traefik.yml | 19 +++ internal/auth/auth.go | 221 +++++++++++++++++++++++++++++++++ internal/config/config.go | 29 ++++- internal/engine/engine.go | 53 +++++--- internal/engine/engine_test.go | 1 + internal/monitor/mode.go | 2 +- internal/status/status.go | 20 ++- main.go | 83 ++++++++++--- resume | 1 + 19 files changed, 820 insertions(+), 41 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 infra/ca/.gitignore create mode 100644 infra/ca/enroll-iphone.md create mode 100755 infra/ca/gen-ca.sh create mode 100755 infra/ca/gen-server-cert.sh create mode 100755 infra/ca/issue-client-cert.sh create mode 100644 infra/traefik/dynamic.yml create mode 100644 infra/traefik/traefik.yml create mode 100644 internal/auth/auth.go create mode 100644 resume diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..6f27273 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI/CD + +on: + push: + branches: [main] + pull_request: + +jobs: + + # ── Test ────────────────────────────────────────────────────────────────── + # Runs on every push and every PR. Must pass before deploy proceeds. + test: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Run unit tests + run: go test ./... + + - name: Build binary (compile check) + run: CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /dev/null . + + # ── Deploy ──────────────────────────────────────────────────────────────── + # Runs only on push to main. SSH into the Synology, pull, rebuild, restart. + # + # Required secrets (set in Gitea → Repository → Settings → Secrets): + # DEPLOY_HOST — Synology LAN IP or hostname (e.g. 192.168.0.10) + # DEPLOY_USER — SSH user with docker access (e.g. ems-deploy) + # DEPLOY_KEY — SSH private key (PEM, no passphrase) + # DEPLOY_PATH — Absolute path to this repo on the Synology (e.g. /opt/ems) + # + # One-time setup on Synology: + # 1. Create a dedicated deploy user (or reuse existing) + # 2. Add the deploy public key to ~/.ssh/authorized_keys + # 3. Add the user to the 'docker' group: sudo synogroup --member docker deploy-user + deploy: + needs: test + runs-on: self-hosted + if: github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - name: Install SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + # Suppress host key prompt — runner talks to a known LAN host + echo "Host ${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/config + echo " StrictHostKeyChecking no" >> ~/.ssh/config + echo " IdentityFile ~/.ssh/deploy_key" >> ~/.ssh/config + + - name: Deploy to Synology + run: | + ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + "cd ${{ secrets.DEPLOY_PATH }} \ + && git pull --ff-only \ + && docker compose up -d --build \ + && docker image prune -f" + + - name: Verify health + run: | + # Wait for container to come up, then check /health via LAN + sleep 10 + ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + "wget -qO- http://localhost:9099/health" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2048b99 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.23-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o ems . + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app +COPY --from=builder /app/ems . + +# Web UI port (put behind Synology HTTPS reverse proxy) +EXPOSE 9099 +# Prometheus metrics port (keep internal — do not expose via reverse proxy) +EXPOSE 9100 + +ENTRYPOINT ["/app/ems"] +CMD ["-config", "/etc/ems/ems-config.yaml"] diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index e91f9a3..23a8df8 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -112,8 +112,12 @@ forecast: # EMS operational settings ems: poll_interval: "2m" - listen_addr: ":9099" # Prometheus metrics endpoint + listen_addr: ":9099" # web UI (login-protected; put behind Synology HTTPS proxy for internet access) + metrics_addr: ":9101" # Prometheus /metrics (no auth — keep internal, do not expose to internet) + # Note: :9100 is typically taken by prometheus-node-exporter log_level: "info" + http_username: "ems" # login username (empty = no auth required) + http_password: "changeme" # login password — change this; config must be chmod 600 state_file: "/run/ems/heartbeat" # written each cycle for state recovery recovery_timeout: "1h" # ignore Shelly state if EMS was down longer override_timeout: "1h" # default lockout when EMS detects external Shelly change diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6572db5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +networks: + ems-internal: + driver: bridge + +services: + + # ── Traefik ───────────────────────────────────────────────────────────────── + # TLS termination + mTLS on port 9443. + # Perimeter firewall forwards external TCP 9443 → Synology:9443. + # Only connections presenting a client cert signed by /certs/ca.crt are forwarded. + traefik: + image: traefik:v3.1 + restart: unless-stopped + ports: + - "9443:9443" # HTTPS + mTLS — open on perimeter firewall + volumes: + - ./infra/traefik/traefik.yml:/etc/traefik/traefik.yml:ro + - ./infra/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro + # Certs: ca.crt, server.crt, server.key (generated by infra/ca/ scripts) + - /etc/ems/certs:/certs:ro + networks: + - ems-internal + depends_on: + - ems + + # ── EMS ───────────────────────────────────────────────────────────────────── + # Web UI is NOT exposed externally — Traefik proxies to :9099 internally. + # Prometheus metrics on :9101 stay LAN-accessible for the existing scrape job. + ems: + build: . + restart: unless-stopped + ports: + # Metrics — Prometheus scrape from LAN (192.168.0.23:9090) + # Do NOT route through Traefik (no auth on metrics endpoint by design). + - "9101:9101" + volumes: + # Config (read-only) — must be chmod 600 + - /etc/ems:/etc/ems:ro + # Persistent state: trip goals, sessions, auth tokens + - /var/lib/ems:/var/lib/ems + # Heartbeat file + - /run/ems:/run/ems + networks: + - ems-internal + environment: + - TZ=Europe/Berlin + + # Health check using the unauthenticated /health endpoint + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9099/health"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/infra/ca/.gitignore b/infra/ca/.gitignore new file mode 100644 index 0000000..d5261ef --- /dev/null +++ b/infra/ca/.gitignore @@ -0,0 +1,12 @@ +# Private keys and device certs — NEVER commit +*.key +*.p12 +*.csr +*.srl + +# ca.crt and server.crt are public — safe to commit if desired, +# but kept out by default so the repo doesn't become a cert store. +# Uncomment the next two lines to allow committing public certs: +# !ca.crt +# !server.crt +*.crt diff --git a/infra/ca/enroll-iphone.md b/infra/ca/enroll-iphone.md new file mode 100644 index 0000000..b525a96 --- /dev/null +++ b/infra/ca/enroll-iphone.md @@ -0,0 +1,94 @@ +# iPhone Enrollment Guide + +Two steps: first install the CA as a trusted root, then install your personal client cert. +Both must be done before Safari can reach the EMS. + +--- + +## Step 1 — Install the CA cert (trust anchor) + +Do this once. It makes your iPhone trust the EMS server cert and accept the client cert. + +### On your Mac / Linux machine (in the `infra/ca/` directory): + +```bash +# Serve ca.crt temporarily on the LAN +python3 -m http.server 8080 +``` + +### On the iPhone (Safari — not Chrome): + +1. Open **Safari** and navigate to `http://192.168.x.x:8080/ca.crt` + (replace with your Mac's LAN IP — check with `ifconfig | grep 192.168`) +2. Safari shows: *"This website is trying to download a configuration profile. Do you want to allow this?"* → **Allow** +3. Open **Settings** → you will see a banner: **Profile Downloaded** → tap it → **Install** +4. Enter your iPhone passcode if prompted → **Install** (top right) → **Install** again to confirm +5. Go to **Settings → General → About → Certificate Trust Settings** +6. Under *"Enable Full Trust For Root Certificates"*, toggle **EMS Private CA** → **Continue** + +The CA is now trusted. You can stop the Python server. + +--- + +## Step 2 — Install the client cert + +Do this once per device. Generate the cert first if you haven't: + +```bash +cd infra/ca +./issue-client-cert.sh lutz-iphone +``` + +Then transfer `lutz-iphone.p12` to the iPhone. The easiest ways: + +**AirDrop (recommended):** +1. On Mac: right-click `lutz-iphone.p12` → Share → AirDrop → select your iPhone +2. On iPhone: tap Accept +3. Tap the received file → **Settings** opens automatically +4. **Settings → Profile Downloaded** → **Install** → enter PKCS12 password → **Install** + +**Alternatively via Files / Mail / Notes:** +- Share the `.p12` file to yourself via any app, then tap it to trigger profile installation. + +--- + +## Step 3 — Test + +1. Open **Safari** on the iPhone +2. Navigate to `https://ems.famfi.dyndns.org:9443` +3. Safari will prompt: *"ems.famfi.dyndns.org" wants to use "lutz-iphone EMS"* → **Continue** +4. The EMS login page should appear — no certificate warning + +--- + +## Revoking a device + +There is no CRL/OCSP for this private CA (not needed for a home setup). +To revoke a device: +1. On the iPhone: **Settings → General → VPN & Device Management** → select the EMS profile → **Remove** +2. Generate a new CA (`gen-ca.sh`) and re-enroll all remaining devices, OR + regenerate only the server cert and client certs — a revoked client cert is still technically valid + until the Traefik config is updated to exclude it by CN. + +For a home setup with 1-2 devices, deleting the profile from the device is sufficient protection. + +--- + +## Cert renewal (annually) + +Server and client certs are valid for 825 days (~2.25 years). When they approach expiry: + +```bash +cd infra/ca +./gen-server-cert.sh # new server cert +scp server.crt server.key user@synology:/etc/ems/certs/ +ssh user@synology "cd /opt/ems && docker compose restart traefik" + +./issue-client-cert.sh lutz-iphone # new client cert — repeat enrollment Step 2 +``` + +The CA itself is valid for 10 years. Its expiry date: + +```bash +openssl x509 -noout -dates -in infra/ca/ca.crt +``` diff --git a/infra/ca/gen-ca.sh b/infra/ca/gen-ca.sh new file mode 100755 index 0000000..0ffabea --- /dev/null +++ b/infra/ca/gen-ca.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# gen-ca.sh — Generate the EMS root CA (one-time operation). +# +# Outputs: ca.key (KEEP SECRET) and ca.crt (distribute to all devices). +# ca.key must be backed up encrypted and kept off the Synology. +# Losing ca.key means all devices must re-enroll after generating a new CA. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +if [[ -f ca.key ]]; then + echo "ERROR: ca.key already exists." + echo "Delete it explicitly if you intend to replace the CA." + echo "WARNING: a new CA invalidates ALL existing server and client certs — every device must re-enroll." + exit 1 +fi + +echo "Generating EMS root CA (RSA-4096, 10 years)..." + +openssl req -x509 -newkey rsa:4096 -sha256 \ + -days 3650 \ + -keyout ca.key \ + -out ca.crt \ + -nodes \ + -subj "/CN=EMS Private CA/O=FamFi/C=DE" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -addext "subjectKeyIdentifier=hash" + +chmod 600 ca.key + +echo "" +echo "Done." +echo " ca.crt — distribute to devices (install as trusted root)" +echo " ca.key — KEEP SECRET: store in encrypted backup, remove from Synology after cert issuance" +echo "" +echo "Next steps:" +echo " ./gen-server-cert.sh # server cert for Traefik" +echo " ./issue-client-cert.sh # one per device" diff --git a/infra/ca/gen-server-cert.sh b/infra/ca/gen-server-cert.sh new file mode 100755 index 0000000..bdebcf8 --- /dev/null +++ b/infra/ca/gen-server-cert.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# gen-server-cert.sh — Generate the Traefik server cert for ems.famfi.dyndns.org. +# +# Signed by the private CA. Once ca.crt is trusted on a device, +# this cert is accepted without warnings. +# Renew annually (before 825-day expiry) by re-running this script. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +DOMAIN="${1:-ems.famfi.dyndns.org}" + +if [[ ! -f ca.key ]]; then + echo "ERROR: ca.key not found. Run gen-ca.sh first." + exit 1 +fi + +echo "Generating server cert for ${DOMAIN}..." + +openssl req -newkey rsa:2048 -nodes \ + -keyout server.key \ + -out server.csr \ + -subj "/CN=${DOMAIN}/O=FamFi/C=DE" + +openssl x509 -req \ + -in server.csr \ + -CA ca.crt \ + -CAkey ca.key \ + -CAcreateserial \ + -out server.crt \ + -days 825 \ + -sha256 \ + -extfile <(printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:%s\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid" "$DOMAIN") + +chmod 600 server.key +rm -f server.csr + +echo "" +echo "Done: server.crt + server.key for ${DOMAIN}" +echo "" +echo "Deploy certs to Synology (run from infra/ca/):" +echo " ssh user@synology 'mkdir -p /etc/ems/certs'" +echo " scp ca.crt server.crt server.key user@synology:/etc/ems/certs/" +echo " ssh user@synology 'chmod 600 /etc/ems/certs/server.key'" +echo "" +echo "Traefik will pick up the new cert on next container restart." diff --git a/infra/ca/issue-client-cert.sh b/infra/ca/issue-client-cert.sh new file mode 100755 index 0000000..94ffafd --- /dev/null +++ b/infra/ca/issue-client-cert.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# issue-client-cert.sh — Issue a client cert for one device. +# +# Usage: ./issue-client-cert.sh +# Example: ./issue-client-cert.sh lutz-iphone +# +# Outputs .p12 — install on the device after enrolling ca.crt. +# See enroll-iphone.md for the full iPhone enrollment flow. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +NAME="${1:?Usage: $0 e.g.: $0 lutz-iphone}" + +if [[ ! -f ca.key ]]; then + echo "ERROR: ca.key not found. Run gen-ca.sh first." + exit 1 +fi + +if [[ -f "${NAME}.p12" ]]; then + echo "WARNING: ${NAME}.p12 already exists. Overwriting." +fi + +echo "Issuing client cert for: ${NAME}" +echo "You will be prompted for a PKCS12 export password." +echo "Use a strong password — you will need it during iPhone installation." +echo "" + +openssl req -newkey rsa:2048 -nodes \ + -keyout "${NAME}.key" \ + -out "${NAME}.csr" \ + -subj "/CN=${NAME}/O=FamFi/C=DE" + +openssl x509 -req \ + -in "${NAME}.csr" \ + -CA ca.crt \ + -CAkey ca.key \ + -CAcreateserial \ + -out "${NAME}.crt" \ + -days 825 \ + -sha256 \ + -extfile <(printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid") + +openssl pkcs12 -export \ + -out "${NAME}.p12" \ + -inkey "${NAME}.key" \ + -in "${NAME}.crt" \ + -certfile ca.crt \ + -name "${NAME} EMS" + +chmod 600 "${NAME}.key" "${NAME}.p12" +rm -f "${NAME}.csr" "${NAME}.crt" + +echo "" +echo "Done: ${NAME}.p12" +echo "AirDrop to iPhone, then follow enroll-iphone.md." diff --git a/infra/traefik/dynamic.yml b/infra/traefik/dynamic.yml new file mode 100644 index 0000000..fa5b7ef --- /dev/null +++ b/infra/traefik/dynamic.yml @@ -0,0 +1,37 @@ +# Traefik dynamic configuration — hot-reloaded by Traefik on change. +# +# TLS: private CA server cert + mandatory client cert (mTLS). +# Any connection without a valid client cert signed by ca.crt is rejected +# at the TLS handshake — before any HTTP reaches EMS. + +tls: + certificates: + - certFile: /certs/server.crt + keyFile: /certs/server.key + + options: + mtls: + clientAuth: + caFiles: + - /certs/ca.crt + clientAuthType: RequireAndVerifyClientCert + # Minimum TLS 1.2; prefer 1.3 + minVersion: VersionTLS12 + sniStrict: true + +http: + routers: + ems: + rule: "Host(`ems.famfi.dyndns.org`)" + entryPoints: + - websecure + tls: + options: mtls + service: ems + + services: + ems: + loadBalancer: + servers: + - url: "http://ems:9099" + passHostHeader: true diff --git a/infra/traefik/traefik.yml b/infra/traefik/traefik.yml new file mode 100644 index 0000000..8151001 --- /dev/null +++ b/infra/traefik/traefik.yml @@ -0,0 +1,19 @@ +# Traefik static configuration +# Handles TLS termination on port 9443 with private CA mTLS. +# Dynamic routing config is in dynamic.yml (hot-reloaded on change). + +entryPoints: + websecure: + address: ":9443" + +providers: + file: + filename: /etc/traefik/dynamic.yml + watch: true # reload dynamic.yml without container restart + +log: + level: INFO + +accessLog: + filePath: "/dev/stdout" + format: common diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..c9646bb --- /dev/null +++ b/internal/auth/auth.go @@ -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 = ` + + + + + EMS · Anmelden + + + +
+

☀️ Solar EMS

+
+ + + + + {{if .}}
{{.}}
{{end}} + +
+
+ +` diff --git a/internal/config/config.go b/internal/config/config.go index 5847f75..1d599c9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index fec3633..1b6ebc4 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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 diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 16dadb3..01adaaf 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -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, ) } diff --git a/internal/monitor/mode.go b/internal/monitor/mode.go index 87bf470..f86c4e3 100644 --- a/internal/monitor/mode.go +++ b/internal/monitor/mode.go @@ -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 } diff --git a/internal/status/status.go b/internal/status/status.go index 24c02bf..9979ba3 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -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}}
+
{{end}} + {{if .HasAuth}} + Abmelden + {{end}} @@ -792,6 +804,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } @@ -907,6 +920,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } {{end}} {{if .CanOverride}}
+ {{if .Active}} @@ -928,10 +942,12 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
{{if not .Unconfigured}} + {{end}}
+ {{if .WWBoostDisabled}} {{else}} @@ -948,6 +964,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
🚗 Fahrt geplant +
@@ -966,6 +983,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
🚗 Fahrtziel planen
+