Files
internet_use_control/deploy/mosquitto/provision-clients.sh
Sam Rolfe 14b304e0b0 Phase 3: MQTT transport — retained config, status/telemetry, LWT presence, TLS/ACL design
- internal/mqtt: publish each device's desired state retained on
  restrict/<device>/config; subscribe to restrict/+/status and
  restrict/+/telemetry and write reports to SQLite; publish on every state
  change, republish on connect and every 15 minutes from SQLite, so the broker
  is repaired from the source of truth.
- store: presence (devices.online and last_seen, audited only on transitions),
  telemetry upsert per (device, day, app, domain) via migration 002, allow and
  block lists read into the config payload, device lookup by topic.
- config: MQTT settings. TLS plus per-device credentials are required; the
  pre-TLS shared broker needs an explicit CONTROL_MQTT_INSECURE escape hatch.
- web: republish config after lock, unlock, expiry, create and edit; broker card
  on the status partial.
- deploy/mosquitto: TLS listener, topic ACL, credential provisioning and a
  verifier that proves the policy on a throwaway broker. Not applied to the
  shared Mosquitto on .13 — that change needs Sam's approval.
- cmd/control: mqtt-clients prints the MQTT identity of every device.
- paho.mqtt.golang v1.5.1, which pulls golang.org/x/net and with it the
  golang.org/x/crypto bump to v0.42.0.

Verified on .13: retained config delivered to a fresh subscriber; a client
killed without DISCONNECT drove the broker's retained will and the service
flipped the device offline; telemetry landed in SQLite; make mqtt-verify passed
13/13; the service connected over TLS with credentials and a CA.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 14:37:43 +11:00

101 lines
3.8 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Create one broker account per registered device, plus the Control Service's
# own account (AGENTS.md §11 Phase 3, §10 rule 6).
#
# The MQTT username of a device is its topic token — the middle level of its
# config topic. The device list comes from the Control Service itself:
#
# docker exec webrestrict-control /app/control mqtt-clients
#
# Because conf.d/webrestrict.acl is written with `%u`, no per-device ACL edit is
# needed: adding the account is the whole onboarding step.
#
# Output (both gitignored, mode 0600 for the secrets):
#
# conf.d/webrestrict.passwd hashed accounts, read by the broker
# clients.env the plaintext values, for the devices and .env
#
# Nothing secret is printed to the terminal. Read clients.env to collect them.
set -euo pipefail
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
passwd_file="$here/conf.d/webrestrict.passwd"
secrets_file="$here/clients.env"
container="${CONTROL_CONTAINER:-webrestrict-control}"
broker_image="${MQTT_IMAGE:-eclipse-mosquitto:latest}"
service_user="${CONTROL_MQTT_USERNAME:-control-service}"
service_pass="${CONTROL_MQTT_PASSWORD:-}"
die() {
echo "provision-clients: $*" >&2
exit 1
}
command -v docker >/dev/null 2>&1 || die "docker is not available"
# head reads a fixed number of bytes from the device and cut consumes its whole
# input, so no stage of this pipeline is signalled with SIGPIPE.
rand_pass() {
head -c 32 /dev/urandom | base64 | tr -d '/+=' | cut -c1-32
}
[[ -n "$service_pass" ]] || service_pass="$(rand_pass)"
# The device list comes from the service, so the broker accounts can never
# drift from the devices the Control Service knows about.
clients="$(timeout 30 docker exec "$container" /app/control mqtt-clients)" ||
die "could not read the device list from container '$container' (is it running?)"
tmp_dir="$(mktemp -d)"
chmod 700 "$tmp_dir"
trap 'rm -rf "$tmp_dir"' EXIT
add_user() { # user password (creates the file on the first call)
local user="$1" pass="$2" args=(-b)
[[ -s "$tmp_dir/webrestrict.passwd" ]] || args+=(-c)
timeout 60 docker run --rm --user "$(id -u):$(id -g)" \
-v "$tmp_dir":/cfg --entrypoint mosquitto_passwd "$broker_image" \
"${args[@]}" /cfg/webrestrict.passwd "$user" "$pass" >/dev/null ||
die "mosquitto_passwd failed for $user"
}
add_user "$service_user" "$service_pass"
: >"$tmp_dir/clients.env"
{
echo "# GENERATED by deploy/mosquitto/provision-clients.sh — secrets, do not commit."
echo "#"
echo "# Control Service account. Put these two lines in"
echo "# environment.d/control-service.env, then set CONTROL_MQTT_INSECURE=false"
echo "# and CONTROL_MQTT_BROKER=tls://host.docker.internal:8883."
echo "CONTROL_MQTT_USERNAME=$service_user"
echo "CONTROL_MQTT_PASSWORD=$service_pass"
echo
echo "# One account per device. The name is the device's topic token; the"
echo "# password goes into the device's own MQTT settings."
echo "# token<TAB>password<TAB>config topic"
} >>"$tmp_dir/clients.env"
count=0
while IFS=$'\t' read -r token topic platform child name; do
# mqtt-clients prints real tabs; names contain spaces, so the split matters.
token="$(printf '%s' "${token:-}" | tr -d '[:space:]')"
[[ -n "$token" ]] || continue
pass="$(rand_pass)"
add_user "$token" "$pass"
printf '%s\t%s\t%s\n' "$token" "$pass" "$topic" >>"$tmp_dir/clients.env"
count=$((count + 1))
echo "provision-clients: account '$token' ($name · $platform) -> $topic"
done <<<"$clients"
[[ $count -gt 0 ]] || echo "provision-clients: no devices registered yet; only the Control Service account was created."
install -m 600 "$tmp_dir/webrestrict.passwd" "$passwd_file"
install -m 600 "$tmp_dir/clients.env" "$secrets_file"
echo "provision-clients: wrote $passwd_file ($((count + 1)) accounts)"
echo "provision-clients: wrote $secrets_file — read it, do not commit it."