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
+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)