Latest Updates done, before integrating
This commit is contained in:
67
.gitea/workflows/ci.yml
Normal file
67
.gitea/workflows/ci.yml
Normal file
@@ -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"
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -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"]
|
||||||
@@ -112,8 +112,12 @@ forecast:
|
|||||||
# EMS operational settings
|
# EMS operational settings
|
||||||
ems:
|
ems:
|
||||||
poll_interval: "2m"
|
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"
|
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
|
state_file: "/run/ems/heartbeat" # written each cycle for state recovery
|
||||||
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
|
recovery_timeout: "1h" # ignore Shelly state if EMS was down longer
|
||||||
override_timeout: "1h" # default lockout when EMS detects external Shelly change
|
override_timeout: "1h" # default lockout when EMS detects external Shelly change
|
||||||
|
|||||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
@@ -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
|
||||||
12
infra/ca/.gitignore
vendored
Normal file
12
infra/ca/.gitignore
vendored
Normal file
@@ -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
|
||||||
94
infra/ca/enroll-iphone.md
Normal file
94
infra/ca/enroll-iphone.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
40
infra/ca/gen-ca.sh
Executable file
40
infra/ca/gen-ca.sh
Executable file
@@ -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 <name> # one per device"
|
||||||
47
infra/ca/gen-server-cert.sh
Executable file
47
infra/ca/gen-server-cert.sh
Executable file
@@ -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."
|
||||||
57
infra/ca/issue-client-cert.sh
Executable file
57
infra/ca/issue-client-cert.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# issue-client-cert.sh — Issue a client cert for one device.
|
||||||
|
#
|
||||||
|
# Usage: ./issue-client-cert.sh <device-name>
|
||||||
|
# Example: ./issue-client-cert.sh lutz-iphone
|
||||||
|
#
|
||||||
|
# Outputs <device-name>.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 <device-name> 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."
|
||||||
37
infra/traefik/dynamic.yml
Normal file
37
infra/traefik/dynamic.yml
Normal file
@@ -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
|
||||||
19
infra/traefik/traefik.yml
Normal file
19
infra/traefik/traefik.yml
Normal file
@@ -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
|
||||||
221
internal/auth/auth.go
Normal file
221
internal/auth/auth.go
Normal 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>`
|
||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -191,7 +192,8 @@ func (f *ForecastConfig) FetchIntervalParsed() time.Duration {
|
|||||||
// EMSConfig holds operational settings for the EMS daemon.
|
// EMSConfig holds operational settings for the EMS daemon.
|
||||||
type EMSConfig struct {
|
type EMSConfig struct {
|
||||||
PollInterval string `yaml:"poll_interval"`
|
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"`
|
LogLevel string `yaml:"log_level"`
|
||||||
StateFile string `yaml:"state_file"`
|
StateFile string `yaml:"state_file"`
|
||||||
RecoveryTimeout string `yaml:"recovery_timeout"`
|
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
|
WWBoostDisableFile string `yaml:"ww_boost_disable_file"` // flag file path: presence = WW boost disabled
|
||||||
TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal
|
TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal
|
||||||
SessionLogFile string `yaml:"session_log_file"` // JSONL log of completed charge sessions
|
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 {
|
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.
|
// Load reads and parses the YAML config file at the given path.
|
||||||
func Load(path string) (*Config, error) {
|
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)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("reading config file: %w", err)
|
return nil, fmt.Errorf("reading config file: %w", err)
|
||||||
@@ -256,5 +267,21 @@ func (c *Config) validate() error {
|
|||||||
if c.EMS.ListenAddr == "" {
|
if c.EMS.ListenAddr == "" {
|
||||||
return fmt.Errorf("ems.listen_addr is required")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -690,8 +690,12 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proactive wallboxes: only emergency-brake at SOCFloor
|
// Intentional wallbox charging (proactive or trip mode): only emergency-brake at SOCFloor.
|
||||||
if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) {
|
// 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)
|
floor := float64(e.cfg.CarCharging.SOCFloor)
|
||||||
if floor > 0 && soc >= floor {
|
if floor > 0 && soc >= floor {
|
||||||
continue // still above floor, keep charging
|
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.
|
// On match: expired overrides are cleared, resuming normal EMS control.
|
||||||
// Power readings (from PM-capable devices) update the low-power cycle counter for
|
// Power readings (from PM-capable devices) update the low-power cycle counter for
|
||||||
// car-not-charging detection.
|
// 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 {
|
for c, status := range states {
|
||||||
cs, ok := e.consumers[c]
|
cs, ok := e.consumers[c]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -975,21 +982,33 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cs.Active != status.On {
|
if cs.Active != status.On {
|
||||||
// External change detected
|
if monitorOnly {
|
||||||
e.logger.Info("manual override detected — external state change",
|
// In monitor-only mode the EMS cannot execute switch actions, so a
|
||||||
"consumer", c.String(),
|
// mismatch just means a pending action couldn't be carried out.
|
||||||
"engine_state", cs.Active,
|
// Silently realign engine state to hardware without locking.
|
||||||
"hardware_state", status.On,
|
cs.Active = status.On
|
||||||
"override_until", now.Add(overrideTimeout).Format("15:04"),
|
if !status.On {
|
||||||
)
|
cs.ActivatedAt = time.Time{}
|
||||||
cs.Active = status.On
|
} else {
|
||||||
cs.ManualOverride = true
|
cs.ActivatedAt = now
|
||||||
cs.OverrideUntil = now.Add(overrideTimeout)
|
}
|
||||||
cs.LowPowerCycles = 0
|
|
||||||
if !status.On {
|
|
||||||
cs.ActivatedAt = time.Time{}
|
|
||||||
} else {
|
} 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) {
|
} else if cs.ManualOverride && now.After(cs.OverrideUntil) {
|
||||||
// Override expired and state matches — resume EMS control
|
// Override expired and state matches — resume EMS control
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ func TestCarNotChargingReleasesWallbox(t *testing.T) {
|
|||||||
map[Consumer]DeviceStatus{ConsumerWallboxA: lowPower},
|
map[Consumer]DeviceStatus{ConsumerWallboxA: lowPower},
|
||||||
base.Add(time.Duration(i+1)*2*time.Minute),
|
base.Add(time.Duration(i+1)*2*time.Minute),
|
||||||
time.Hour,
|
time.Hour,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (m *Mode) Set(active bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if active {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,12 @@ type Store struct {
|
|||||||
wwBoostDisabled *monitor.Mode
|
wwBoostDisabled *monitor.Mode
|
||||||
tripMgr *trip.Manager
|
tripMgr *trip.Manager
|
||||||
carOptions []CarOption
|
carOptions []CarOption
|
||||||
|
csrfToken string
|
||||||
|
hasAuth bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore creates a new status store.
|
// 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))
|
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||||
for _, c := range consumerOrder {
|
for _, c := range consumerOrder {
|
||||||
consumers[c] = &consumerRecord{}
|
consumers[c] = &consumerRecord{}
|
||||||
@@ -82,6 +84,8 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig,
|
|||||||
wwBoostDisabled: wwBoostDisabled,
|
wwBoostDisabled: wwBoostDisabled,
|
||||||
tripMgr: tm,
|
tripMgr: tm,
|
||||||
carOptions: cars,
|
carOptions: cars,
|
||||||
|
csrfToken: csrfToken,
|
||||||
|
hasAuth: hasAuth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +199,8 @@ type pageData struct {
|
|||||||
Consumers []consumerView
|
Consumers []consumerView
|
||||||
Trip tripView
|
Trip tripView
|
||||||
CarOptions []CarOption
|
CarOptions []CarOption
|
||||||
|
CSRFToken string
|
||||||
|
HasAuth bool // true when login is required — shows logout button
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncConsumerStates updates active flags for all consumers directly from the
|
// 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,
|
IsExporting: s.state.GridPowerW < 0,
|
||||||
AbsGridW: abs(s.state.GridPowerW),
|
AbsGridW: abs(s.state.GridPowerW),
|
||||||
HasPhaseData: hasPhase,
|
HasPhaseData: hasPhase,
|
||||||
|
CSRFToken: s.csrfToken,
|
||||||
|
HasAuth: s.hasAuth,
|
||||||
}
|
}
|
||||||
if hasPhase {
|
if hasPhase {
|
||||||
for _, ph := range []struct {
|
for _, ph := range []struct {
|
||||||
@@ -782,9 +790,13 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if not .MonitorOnly}}
|
{{if not .MonitorOnly}}
|
||||||
<form method="post" action="/monitor">
|
<form method="post" action="/monitor">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="monitor-toggle-btn">⏸ Monitor</button>
|
<button type="submit" class="monitor-toggle-btn">⏸ Monitor</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -792,6 +804,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
<div class="banner monitor">
|
<div class="banner monitor">
|
||||||
<span>⏸ Monitor-Only — Keine Schaltvorgänge</span>
|
<span>⏸ Monitor-Only — Keine Schaltvorgänge</span>
|
||||||
<form method="post" action="/monitor">
|
<form method="post" action="/monitor">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="resume-btn">▶ Automatik</button>
|
<button type="submit" class="resume-btn">▶ Automatik</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -907,6 +920,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if .CanOverride}}
|
{{if .CanOverride}}
|
||||||
<form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0">
|
<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}}">
|
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
|
||||||
{{if .Active}}
|
{{if .Active}}
|
||||||
<input type="hidden" name="state" value="off">
|
<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">
|
<div style="display:flex;flex-direction:column;gap:0.3rem;flex-shrink:0">
|
||||||
{{if not .Unconfigured}}
|
{{if not .Unconfigured}}
|
||||||
<form method="post" action="/ww/reset">
|
<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>
|
<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>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="post" action="/ww/boost">
|
<form method="post" action="/ww/boost">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
{{if .WWBoostDisabled}}
|
{{if .WWBoostDisabled}}
|
||||||
<button type="submit" class="override-btn turn-on" style="width:100%" title="WW Boost durch PV-Überschuss wieder erlauben">✅ Boost ein</button>
|
<button type="submit" class="override-btn turn-on" style="width:100%" title="WW Boost durch PV-Überschuss wieder erlauben">✅ Boost ein</button>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -948,6 +964,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
<div class="trip-header">
|
<div class="trip-header">
|
||||||
<span class="trip-title">🚗 Fahrt geplant</span>
|
<span class="trip-title">🚗 Fahrt geplant</span>
|
||||||
<form method="post" action="/trip/cancel">
|
<form method="post" action="/trip/cancel">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="trip-cancel">Abbrechen</button>
|
<button type="submit" class="trip-cancel">Abbrechen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -966,6 +983,7 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
<div class="trip-form-card">
|
<div class="trip-form-card">
|
||||||
<div class="section-title">🚗 Fahrtziel planen</div>
|
<div class="section-title">🚗 Fahrtziel planen</div>
|
||||||
<form method="post" action="/trip" class="trip-form">
|
<form method="post" action="/trip" class="trip-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<div class="trip-row">
|
<div class="trip-row">
|
||||||
<select name="wallbox" class="trip-select">
|
<select name="wallbox" class="trip-select">
|
||||||
<option value="wallbox_a">Wallbox A</option>
|
<option value="wallbox_a">Wallbox A</option>
|
||||||
|
|||||||
83
main.go
83
main.go
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/tb/ems/internal/actuator"
|
"github.com/tb/ems/internal/actuator"
|
||||||
|
"github.com/tb/ems/internal/auth"
|
||||||
"github.com/tb/ems/internal/collector"
|
"github.com/tb/ems/internal/collector"
|
||||||
"github.com/tb/ems/internal/config"
|
"github.com/tb/ems/internal/config"
|
||||||
"github.com/tb/ems/internal/engine"
|
"github.com/tb/ems/internal/engine"
|
||||||
@@ -134,36 +135,74 @@ func main() {
|
|||||||
// Build sorted car option list for the status page form
|
// Build sorted car option list for the status page form
|
||||||
carOptions := buildCarOptions(cfg.Cars)
|
carOptions := buildCarOptions(cfg.Cars)
|
||||||
|
|
||||||
|
// Auth manager — loads or generates persistent session and CSRF tokens.
|
||||||
|
// Token files live alongside other persistent state in /var/lib/ems/.
|
||||||
|
tokenDir := filepath.Dir(cfg.EMS.SessionLogFile)
|
||||||
|
authMgr := auth.New(cfg.EMS.HTTPUsername, cfg.EMS.HTTPPassword, tokenDir, logger)
|
||||||
|
if authMgr.Enabled() {
|
||||||
|
logger.Info("web UI authentication enabled", "username", cfg.EMS.HTTPUsername)
|
||||||
|
} else {
|
||||||
|
logger.Warn("web UI authentication disabled — set ems.http_username and ems.http_password to enable")
|
||||||
|
}
|
||||||
|
|
||||||
// Status store (shared between HTTP handler and control loop)
|
// Status store (shared between HTTP handler and control loop)
|
||||||
wwConfigured := cfg.Viessmann.InstallationID != ""
|
wwConfigured := cfg.Viessmann.InstallationID != ""
|
||||||
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, cfg.CarCharging, monitorMode, wwBoostDisabled, tripMgr, carOptions)
|
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, cfg.CarCharging, monitorMode, wwBoostDisabled, tripMgr, carOptions, authMgr.CSRFToken(), authMgr.Enabled())
|
||||||
|
|
||||||
// Metrics HTTP server
|
// Web UI server — authenticated, serves status page and control endpoints.
|
||||||
mux := http.NewServeMux()
|
uiMux := http.NewServeMux()
|
||||||
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
|
uiMux.HandleFunc("/login", authMgr.LoginHandler())
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
uiMux.HandleFunc("/logout", authMgr.LogoutHandler())
|
||||||
|
uiMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok")) //nolint:errcheck
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/override", overrideHandler(act, eng, logger))
|
uiMux.HandleFunc("/override", overrideHandler(act, eng, logger))
|
||||||
mux.HandleFunc("/monitor", monitorHandler(monitorMode, logger))
|
uiMux.HandleFunc("/monitor", monitorHandler(monitorMode, logger))
|
||||||
mux.HandleFunc("/trip", tripSetHandler(tripMgr, cfg, logger))
|
uiMux.HandleFunc("/trip", tripSetHandler(tripMgr, cfg, logger))
|
||||||
mux.HandleFunc("/trip/cancel", tripCancelHandler(tripMgr, logger))
|
uiMux.HandleFunc("/trip/cancel", tripCancelHandler(tripMgr, logger))
|
||||||
mux.HandleFunc("/ww/reset", wwResetHandler(act, eng, cfg, logger))
|
uiMux.HandleFunc("/ww/reset", wwResetHandler(act, eng, cfg, logger))
|
||||||
mux.HandleFunc("/ww/boost", wwBoostToggleHandler(wwBoostDisabled, logger))
|
uiMux.HandleFunc("/ww/boost", wwBoostToggleHandler(wwBoostDisabled, logger))
|
||||||
mux.HandleFunc("/", statusStore.Handler())
|
uiMux.HandleFunc("/", statusStore.Handler())
|
||||||
|
|
||||||
srv := &http.Server{
|
var uiHandler http.Handler = authMgr.VerifyCSRF(uiMux)
|
||||||
|
uiHandler = authMgr.RequireAuth(uiHandler)
|
||||||
|
|
||||||
|
uiSrv := &http.Server{
|
||||||
Addr: cfg.EMS.ListenAddr,
|
Addr: cfg.EMS.ListenAddr,
|
||||||
Handler: mux,
|
Handler: uiHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics server — no auth, for Prometheus scraping.
|
||||||
|
// Bind to MetricsAddr; if unset, skip the separate server (metrics stay on UI port).
|
||||||
|
var metricsSrv *http.Server
|
||||||
|
if cfg.EMS.MetricsAddr != "" && cfg.EMS.MetricsAddr != cfg.EMS.ListenAddr {
|
||||||
|
metMux := http.NewServeMux()
|
||||||
|
metMux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
|
||||||
|
metMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok")) //nolint:errcheck
|
||||||
|
})
|
||||||
|
metricsSrv = &http.Server{Addr: cfg.EMS.MetricsAddr, Handler: metMux}
|
||||||
|
} else {
|
||||||
|
// Fallback: add /metrics to the UI mux (no separate port configured)
|
||||||
|
uiMux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("metrics server listening", "addr", cfg.EMS.ListenAddr)
|
logger.Info("web UI listening", "addr", cfg.EMS.ListenAddr)
|
||||||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
if err := uiSrv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
logger.Error("metrics server error", "error", err)
|
logger.Error("web UI server error", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
if metricsSrv != nil {
|
||||||
|
go func() {
|
||||||
|
logger.Info("metrics server listening", "addr", cfg.EMS.MetricsAddr)
|
||||||
|
if err := metricsSrv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
|
logger.Error("metrics server error", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -192,7 +231,10 @@ func main() {
|
|||||||
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
srv.Shutdown(shutdownCtx)
|
uiSrv.Shutdown(shutdownCtx) //nolint:errcheck
|
||||||
|
if metricsSrv != nil {
|
||||||
|
metricsSrv.Shutdown(shutdownCtx) //nolint:errcheck
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,7 +277,7 @@ func runCycle(
|
|||||||
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
|
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
|
||||||
} else {
|
} else {
|
||||||
shellyStates = states
|
shellyStates = states
|
||||||
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed())
|
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed(), monitorMode.IsActive())
|
||||||
store.SyncDeviceStates(shellyStates)
|
store.SyncDeviceStates(shellyStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,3 +793,4 @@ func writeHeartbeat(stateFile string, logger *slog.Logger) {
|
|||||||
logger.Warn("could not write heartbeat file", "error", err)
|
logger.Warn("could not write heartbeat file", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user