From e0792fade2783dbbb701b10336ccb4baf9f4ab63 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Wed, 23 Sep 2026 21:45:16 -0400 Subject: [PATCH] van-router-mqtt: broker credentials in a separate 0600 file; log refused connects config.json is re-rendered every deploy, so credentials go in /etc/van-router-mqtt/credentials.json (seeded empty if absent, never overwritten). The service logs MQTT connect/refuse/disconnect state changes, and --post-check warns when the broker refuses the service. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9 --- README.md | 3 + deploy.sh | 13 ++++ router-status/credentials.json.example | 1 + router-status/van-router-mqtt | 94 ++++++++++++++++++++++++-- tests/check-templates.sh | 74 ++++++++++++++++++++ 5 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 router-status/credentials.json.example diff --git a/README.md b/README.md index dc80410..b595438 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,9 @@ hog); left in place until the Home Assistant dashboard fully covers its actions. | `van-router-mqtt` | `/usr/local/sbin/van-router-mqtt` | status+control daemon (Python, paho-mqtt): AP/clients/Starlink/WAN/modem, HA MQTT discovery; republishes van-failover/van-thermal/van-battery's own state.json verbatim rather than recomputing them | | `config.json` | `/etc/van-router-mqtt/config.json` | broker + interface identifiers (`@TOKEN@` templated like `failover/config.json` — no secrets, rendered fresh every deploy) | | `van-router-mqtt.service` | `/etc/systemd/system/van-router-mqtt.service` | `Restart=always` | +| `credentials.json.example` | → `/etc/van-router-mqtt/credentials.json` (seeded if absent) | MQTT `username`/`password`, needed only when the broker requires authentication (empty username = anonymous); 0600, never overwritten by deploys since `config.json` is re-rendered each time | + +The MQTT credentials live in `/etc/van-router-mqtt/credentials.json` (0600, not overwritten by deploys) and are needed only when the broker requires authentication. The service logs a refused connect once, and `./deploy.sh --post-check` flags it. ### `power/` — never sleep + thermal monitor | file | → installs to | purpose | diff --git a/deploy.sh b/deploy.sh index cfaf7f8..b46ffff 100755 --- a/deploy.sh +++ b/deploy.sh @@ -330,6 +330,13 @@ post_check() { # read-only live checks; every probe is guarded, warns instead o if [ -z "$out" ]; then warn "post-deploy: no default route" elif ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; then echo " ok uplink: default route, 1.1.1.1 reachable" else warn "post-deploy: default route present but 1.1.1.1 does not answer ping"; fi + # g2. van-router-mqtt refused by the MQTT broker (logs state changes only, so the latest state line decides) + out=$(timeout 5 journalctl -u van-router-mqtt -b --no-pager -n 60 -q 2>/dev/null) || out= + out=$(awk '/MQTT connect refused by broker/ { s = "refused" } /MQTT connected/ { s = "connected" } END { print s }' <<<"$out") + case "$out" in + refused) warn "post-deploy: van-router-mqtt is refused by the MQTT broker — add credentials to /etc/van-router-mqtt/credentials.json (see the service journal)" ;; + connected) echo " ok van-router-mqtt: MQTT connection ok" ;; + esac if [ "$ENABLE_NM_WAN" = 1 ]; then local sysnet=${VANLINK_SYSNET:-/sys/class/net} nmst fst as_f as_l # h. NetworkManager must leave the AP/LAN radios and ports alone @@ -1039,6 +1046,12 @@ echo "== router status (MQTT -> Home Assistant, replacing the Cockpit plugin abo install -D -m0755 router-status/van-router-mqtt /usr/local/sbin/van-router-mqtt install_rendered router-status/config.json /etc/van-router-mqtt/config.json install -D -m0644 router-status/van-router-mqtt.service /etc/systemd/system/van-router-mqtt.service +# MQTT credentials live only on the system (0600, seeded empty if absent, never +# overwritten): config.json above is re-rendered every deploy. Empty username = +# anonymous, the correct state on brokers that allow it, so no warn here. +if [ ! -f /etc/van-router-mqtt/credentials.json ]; then + install -D -m0600 router-status/credentials.json.example /etc/van-router-mqtt/credentials.json +fi python3 -c 'import paho.mqtt' 2>/dev/null \ || warn "python3-paho-mqtt missing (apt install python3-paho-mqtt) — van-router-mqtt won't start" # MQTT broker must listen on all interfaces (not just localhost) for clients on br0 diff --git a/router-status/credentials.json.example b/router-status/credentials.json.example new file mode 100644 index 0000000..d0b7434 --- /dev/null +++ b/router-status/credentials.json.example @@ -0,0 +1 @@ +{"username": "", "password": ""} diff --git a/router-status/van-router-mqtt b/router-status/van-router-mqtt index 7998d52..99a9609 100644 --- a/router-status/van-router-mqtt +++ b/router-status/van-router-mqtt @@ -29,8 +29,47 @@ import time import paho.mqtt.client as mqtt CONFIG = "/etc/van-router-mqtt/config.json" -with open(sys.argv[1] if len(sys.argv) > 1 else CONFIG) as f: - cfg = json.load(f) +CONFIG_PATH = sys.argv[1] if len(sys.argv) > 1 else CONFIG + + +def log(msg): + print(msg, flush=True) + + +def credentials_path(config_path): + return os.path.join(os.path.dirname(config_path), "credentials.json") + + +def load_credentials(config_path, cfg): + """Merge username/password from credentials.json (next to the config file) + into cfg. The config is re-rendered on every deploy, so secrets live in + this separate 0600 file. Never raises; never logs the values.""" + path = credentials_path(config_path) + if not os.path.exists(path): + return cfg + try: + with open(path) as f: + cred = json.load(f) + if not isinstance(cred, dict): + raise ValueError("not a JSON object") + except (OSError, ValueError) as e: + log(f"credentials file {path} ignored ({type(e).__name__}); connecting anonymously") + return cfg + if not cred.get("username"): + log(f"credentials file {path} has no username; connecting anonymously") + return cfg + cfg["username"] = cred["username"] + cfg["password"] = cred.get("password", "") + return cfg + + +if __name__ == "__main__": + with open(CONFIG_PATH) as f: + cfg = json.load(f) + cfg = load_credentials(CONFIG_PATH, cfg) +else: # imported (tests): no config file, no side effects + import collections + cfg = collections.defaultdict(str) APS = [ {"id": "5g", "iface": cfg["wifi_5g_iface"], "unit": "hostapd", "band": "5GHz"}, @@ -60,10 +99,6 @@ DEVICE_INFO = {"identifiers": [device_id], "name": "Van Router", "manufacturer": WAN_NAMES = ("wifi", "starlink", "cellular") -def log(msg): - print(msg, flush=True) - - def sh(args, timeout=10): try: return subprocess.run(args, capture_output=True, text=True, timeout=timeout) @@ -523,11 +558,58 @@ def on_message(mqc, userdata, msg): publish_state(mqc) # reflect the result immediately, don't wait for next poll +REFUSAL_REASONS = { + 1: "unacceptable protocol version", 2: "client identifier rejected", + 3: "server unavailable", 4: "bad username or password", 5: "not authorised", + 134: "bad username or password", 135: "not authorised", +} +_mqtt_state = None # None | "connected" | "refused" | "down": log changes only + + +def refusal_reason(rc): + n = getattr(rc, "value", rc) + if n in REFUSAL_REASONS: + return REFUSAL_REASONS[n] + return f"unknown reason, rc={n}" + + +def note_connect(rc): + """rc: paho reason code (int-like) of a CONNACK. Logs on state change only.""" + global _mqtt_state + if getattr(rc, "value", rc) != 0: + if _mqtt_state != "refused": + log(f"MQTT connect refused by broker ({refusal_reason(rc)}) — if the broker " + f"requires authentication, set username/password in {credentials_path(CONFIG_PATH)}") + _mqtt_state = "refused" + else: + if _mqtt_state != "connected": + log(f"MQTT connected to {broker}:{mqtt_port}") + _mqtt_state = "connected" + + +def note_disconnect(rc): + global _mqtt_state + if _mqtt_state == "connected": + log(f"MQTT disconnected (rc={getattr(rc, 'value', rc)})") + if _mqtt_state != "refused": + _mqtt_state = "down" + + +def on_connect(mqc, userdata, flags, reason_code, properties=None): + note_connect(reason_code) + + +def on_disconnect(mqc, userdata, disconnect_flags, reason_code, properties=None): + note_disconnect(reason_code) + + def main(): mqc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=cfg.get("client_id", "van-router-mqtt")) if cfg.get("username"): mqc.username_pw_set(cfg["username"], cfg.get("password", "")) mqc.on_message = on_message + mqc.on_connect = on_connect + mqc.on_disconnect = on_disconnect mqc.connect(broker, mqtt_port, keepalive=60) for topic in COMMANDS: mqc.subscribe(topic) diff --git a/tests/check-templates.sh b/tests/check-templates.sh index fb5f0b1..257e84b 100755 --- a/tests/check-templates.sh +++ b/tests/check-templates.sh @@ -711,4 +711,78 @@ for k in 'VANLINK_CONF=' 'VANLINK_HOSTNAME=' '--apply-preflight'; do done rm -rf "$t20" +# 21. van-router-mqtt: broker credentials (separate 0600 file) and refusal logging +t21=$(mktemp -d) +if python3 -c 'import ast,sys; ast.parse(open(sys.argv[1]).read())' router-status/van-router-mqtt; then ok "21a: van-router-mqtt parses"; else bad "21a: van-router-mqtt does not parse"; fi +cat > "$t21/t.py" <<'PY' +import importlib.machinery, importlib.util, io, json, os, sys, tempfile, contextlib +ldr = importlib.machinery.SourceFileLoader("vrm", "router-status/van-router-mqtt") +spec = importlib.util.spec_from_loader("vrm", ldr) +m = importlib.util.module_from_spec(spec) +buf = io.StringIO() +res = [] +def check(name, cond): res.append((name, bool(cond))) +with contextlib.redirect_stdout(buf): + ldr.exec_module(m) # must not start the service or connect + d = tempfile.mkdtemp(); cfgp = os.path.join(d, "config.json"); cp = os.path.join(d, "credentials.json") + base = {"broker": "b", "username": "old", "password": "oldpw"} + r = m.load_credentials(cfgp, dict(base)); check("missing file -> unchanged", r == base) + open(cp, "w").write('{"username": "homeassistant", "password": "s3cretPW"}') + r = m.load_credentials(cfgp, {"broker": "b"}); check("valid merged", r == {"broker": "b", "username": "homeassistant", "password": "s3cretPW"}) + r = m.load_credentials(cfgp, dict(base)); check("credentials override config", r["username"] == "homeassistant") + open(cp, "w").write("{not json"); r = m.load_credentials(cfgp, {"broker": "b"}); check("invalid JSON -> unchanged", r == {"broker": "b"}) + open(cp, "w").write("[1]"); r = m.load_credentials(cfgp, {"broker": "b"}); check("non-object -> unchanged", r == {"broker": "b"}) + open(cp, "w").write('{"username": "", "password": "x"}'); r = m.load_credentials(cfgp, {"broker": "b"}); check("empty username -> anonymous", "username" not in r and "password" not in r) + os.chmod(cp, 0); r = m.load_credentials(cfgp, {"broker": "b"}); check("unreadable -> unchanged", r == {"broker": "b"} or os.geteuid() == 0) + os.chmod(cp, 0o600); open(cp, "w").write('{"username": "u", "password": "topsecretpw"}') + m.load_credentials(cfgp, {}) + check("credentials path beside config", m.credentials_path("/etc/van-router-mqtt/config.json") == "/etc/van-router-mqtt/credentials.json") + check("reason rc4", "bad username or password" in m.refusal_reason(4)) + check("reason rc5", "not authorised" in m.refusal_reason(5)) + check("reason unknown", "unknown" in m.refusal_reason(99) and "99" in m.refusal_reason(99)) + mark = buf.tell() + m.note_connect(5); m.note_connect(5); m.note_disconnect(5); m.note_connect(5) + refused = buf.getvalue()[mark:] + check("refusal logged once, with hint", refused.count("MQTT connect refused by broker (not authorised)") == 1 and "credentials.json" in refused) + mark = buf.tell() + m.note_connect(0); m.note_connect(0); m.note_disconnect(7); m.note_disconnect(7); m.note_connect(0) + conn = buf.getvalue()[mark:] + check("connected once per connect", conn.count("MQTT connected to") == 2) + check("disconnect logged once per outage", conn.count("MQTT disconnected (rc=7)") == 1) +out = buf.getvalue() +check("password/username never printed", "s3cretPW" not in out and "topsecretpw" not in out and "homeassistant" not in out) +check("import printed nothing service-like", "started" not in out) +for n, c in res: print(("ok " if c else "FAIL ") + n) +PY +if o=$(python3 "$t21/t.py" 2>&1); then + echo "$o" | grep -q '^FAIL' && { bad "21b: behavioural checks"; echo "$o" | sed 's/^/ /'; } || ok "21b: credentials merge and refusal logging ($(echo "$o" | grep -c '^ok') checks)" +else bad "21b: test harness crashed"; echo "$o" | sed 's/^/ /'; fi +# 21c. post_check probe against a stub journalctl +mkdir -p "$t21/stub" "$t21/nojd" +for f in /usr/bin/*; do [ "${f##*/}" = journalctl ] || ln -s "$f" "$t21/nojd/${f##*/}" 2>/dev/null || true; done +mkstub() { printf '#!/bin/sh\ncat <<'"'"'EOF'"'"'\n%s\nEOF\n' "$1" > "$t21/stub/journalctl"; chmod +x "$t21/stub/journalctl"; } +pc() { PATH="$1:/usr/bin:/bin" POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf 2>&1 || true; } +R='MQTT connect refused by broker (not authorised) — if the broker requires authentication, set username/password in /x' +C='MQTT connected to localhost:1883' +mkstub "$R"; o=$(pc "$t21/stub") +echo "$o" | grep -qF 'van-router-mqtt is refused by the MQTT broker' && ok "21c: refused last -> warn" || bad "21c: refused last: no warn" +mkstub "$R +$C"; o=$(pc "$t21/stub") +{ echo "$o" | grep -qF 'van-router-mqtt: MQTT connection ok' && ! echo "$o" | grep -qF 'refused by the MQTT broker'; } && ok "21c: refused then connected -> ok" || bad "21c: refused then connected" +mkstub "$C +$R"; o=$(pc "$t21/stub") +echo "$o" | grep -qF 'refused by the MQTT broker' && ok "21c: connected then refused -> warn" || bad "21c: connected then refused" +mkstub ""; o=$(pc "$t21/stub") +{ ! echo "$o" | grep -qF 'MQTT'; } && ok "21c: no journal lines -> nothing" || bad "21c: empty journal produced MQTT output" +o=$(PATH="$t21/nojd" POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf 2>&1 || true) +{ echo "$o" | grep -qF '== post-deploy check ==' && ! echo "$o" | grep -qF 'MQTT'; } && ok "21c: journalctl missing -> nothing" || bad "21c: journalctl missing" +# 21d. example file, seeding +python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d.get("username")=="" and d.get("password")=="" else 1)' router-status/credentials.json.example && ok "21d: credentials.json.example is valid JSON with an empty username" || bad "21d: credentials.json.example" +! grep -q CHANGE_ME router-status/credentials.json.example && ! grep -q CHANGE_ME deploy.sh && ok "21d: no CHANGE_ME in the example or deploy.sh" || bad "21d: CHANGE_ME present" +grep -qE '^ +install -D -m0600 router-status/credentials\.json\.example /etc/van-router-mqtt/credentials\.json' deploy.sh && ! grep -E 'credentials\.json' deploy.sh | grep -q -- '-m0644' && ok "21d: deploy.sh seeds credentials.json 0600, never 0644" || bad "21d: deploy.sh credentials seeding" +grep -B1 'install -D -m0600 router-status/credentials.json.example' deploy.sh | grep -qF '[ ! -f /etc/van-router-mqtt/credentials.json ]' && ok "21d: seed only if absent" || bad "21d: seed not guarded" +d=$(./deploy.sh --diff hosts/van.conf 2>&1 || true) +! echo "$d" | grep -q 'credentials.json' && ok "21d: --diff never lists credentials.json" || bad "21d: --diff mentions credentials.json" +rm -rf "$t21" + exit "$fail"