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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9
This commit is contained in:
2026-09-23 21:45:16 -04:00
co-authored by Claude Sonnet 5
parent 594213138e
commit e0792fade2
5 changed files with 179 additions and 6 deletions
+3
View File
@@ -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 |
+13
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{"username": "", "password": ""}
+88 -6
View File
@@ -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)
+74
View File
@@ -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"