#!/usr/bin/env bash set -Eeuo pipefail IFS=$'\n\t' readonly REPO_DIR='/home/ben/ukmesh/meshcore-analytics' readonly ENV_FILE="${REPO_DIR}/.env" readonly MOSQUITTO_CONTAINER="$(docker ps --format '{{.Names}}' | grep -E '^meshcore-infra-mosquitto-1$|mosquitto-1$' | head -1)" readonly BACKEND_CONTAINER='meshcore-analytics-backend-1' readonly TIMESCALEDB_CONTAINER="$(docker ps --format '{{.Names}}' | grep -E '^meshcore-infra-timescaledb-1$|timescaledb-1$' | head -1)" readonly OWNER_DATABASE='meshcore_owner_auth' readonly POSTGRES_USER='meshcore' readonly BROKER_URL='wss://mqtt.ukmesh.com:443' readonly DEFAULT_DISCOVERY_TIMEOUT_SECONDS=900 readonly DISCOVERY_POLL_SECONDS="${NEWUSER_DISCOVERY_POLL_SECONDS:-5}" usage() { cat <<'EOF' Usage: newuser [--timeout ] [key1,key2,...] newuser --watch [--timeout ] Creates one MQTT/owner-dashboard account. A missing username is prompted for. Each key must be a 64-character hexadecimal MeshCore public key. When keys are omitted, the credential is printed immediately and newuser watches fresh Mosquitto logs for the user's first device contact. The default discovery timeout is 15 minutes; durations accept seconds or an s, m, or h suffix. --watch resumes discovery for an existing credential-only user. EOF } die() { printf 'newuser: ERROR: %s\n' "$*" >&2 exit 1 } log() { printf 'newuser: %s\n' "$*" >&2 } trim() { local value=$1 value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s' "$value" } duration_seconds() { local duration=${1,,} local amount local multiplier if [[ $duration =~ ^([1-9][0-9]*)([smh]?)$ ]]; then amount=${BASH_REMATCH[1]} case ${BASH_REMATCH[2]} in ''|s) multiplier=1 ;; m) multiplier=60 ;; h) multiplier=3600 ;; esac printf '%s' "$(( amount * multiplier ))" return 0 fi return 1 } # Read Mosquitto log lines from stdin and print the first public key attributable # to mqtt_username. A denied topic is trusted only after that exact client ID was # observed connecting with the requested username in the same fresh log window. extract_node_key_from_logs() { local mqtt_username=$1 awk -v username="$mqtt_username" ' function is_public_key(value) { return length(value) == 64 && value !~ /[^0-9A-Fa-f]/ } function connected_client(line, marker, remainder, end_at) { marker = " as " if ((start_at = index(line, marker)) == 0) return "" remainder = substr(line, start_at + length(marker)) if ((end_at = index(remainder, " (")) == 0) return "" return substr(remainder, 1, end_at - 1) } function key_from_client(client, remainder, candidate, suffix) { if (index(client, "meshcore_") == 1) { remainder = substr(client, 10) candidate = substr(remainder, 1, 64) suffix = substr(remainder, 65) if (is_public_key(candidate) && suffix ~ /^_[0-9]+$/) { return toupper(candidate) } } if (index(client, "auto-") == 1) { candidate = substr(client, 6) if (is_public_key(candidate)) return toupper(candidate) } return "" } BEGIN { quote = sprintf("%c", 39) user_marker = "u" quote username quote } found_key != "" { next } index($0, "New client connected") { client = connected_client($0) if (client == "") next options_at = index($0, " (") if (options_at == 0 || !index(substr($0, options_at), user_marker)) { delete user_clients[client] next } user_clients[client] = 1 key = key_from_client(client) if (key != "") { found_key = key } next } index($0, "Denied PUBLISH from ") { denied_marker = "Denied PUBLISH from " remainder = substr($0, index($0, denied_marker) + length(denied_marker)) end_at = index(remainder, " (") if (end_at == 0) next client = substr(remainder, 1, end_at - 1) if (!(client in user_clients)) next quoted_count = split($0, quoted, quote) for (i = 2; i <= quoted_count; i += 2) { part_count = split(quoted[i], topic, "/") if (part_count < 4) continue if (topic[1] != "meshcore" && topic[1] != "meshcore-test" && topic[1] != "ukmesh") continue if (is_public_key(topic[3])) { found_key = toupper(topic[3]) next } } } END { if (found_key != "") print found_key } ' } read_discovery_logs() { local since_timestamp=$1 local log_file=${2:-} local line if [[ -n $log_file ]]; then [[ -r $log_file ]] || return 1 while IFS= read -r line || [[ -n $line ]]; do printf '%s\n' "$line" done <"$log_file" return 0 fi docker logs "$MOSQUITTO_CONTAINER" --since "$since_timestamp" 2>&1 } discover_node_key() { local mqtt_username=$1 local since_timestamp=$2 local timeout_seconds=$3 local log_file=${4:-} local deadline=$(( $(date +%s) + timeout_seconds )) local logs local key local warned=0 while :; do if logs="$(read_discovery_logs "$since_timestamp" "$log_file")"; then warned=0 key="$(extract_node_key_from_logs "$mqtt_username" <<<"$logs")" if [[ -n $key ]]; then printf '%s' "$key" return 0 fi elif (( ! warned )); then log 'could not read Mosquitto logs; discovery will retry' warned=1 fi (( $(date +%s) < deadline )) || return 1 sleep "$DISCOVERY_POLL_SECONDS" done } print_mctomqtt_settings() { local mqtt_username=$1 local mqtt_password=$2 cat <"$temporary"; then rm -f -- "$temporary" return 1 fi chmod --reference="$ENV_FILE" "$temporary" mv -f -- "$temporary" "$ENV_FILE" } delete_owner_account() { local mqtt_username=$1 docker exec -i "$TIMESCALEDB_CONTAINER" \ psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$OWNER_DATABASE" \ -v "mqtt_username=${mqtt_username}" >/dev/null <<'SQL' BEGIN; DELETE FROM owner_grant_audit WHERE mqtt_username = :'mqtt_username'; DELETE FROM mqtt_node_logins WHERE mqtt_username = :'mqtt_username'; DELETE FROM owner_accounts WHERE mqtt_username = :'mqtt_username'; COMMIT; SQL } restore_keyless_owner_account() { local mqtt_username=$1 local node_ids=$2 local restore_active=$3 docker exec -i "$TIMESCALEDB_CONTAINER" \ psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$OWNER_DATABASE" \ -v "mqtt_username=${mqtt_username}" \ -v "node_ids=${node_ids}" \ -v "restore_active=${restore_active}" >/dev/null <<'SQL' BEGIN; DELETE FROM mqtt_node_logins WHERE mqtt_username = :'mqtt_username' AND node_id = ANY(string_to_array(:'node_ids', ',')); DELETE FROM owner_account_nodes WHERE mqtt_username = :'mqtt_username' AND node_id = ANY(string_to_array(:'node_ids', ',')); UPDATE owner_accounts SET is_active = :'restore_active'::boolean, updated_at = NOW() WHERE mqtt_username = :'mqtt_username'; COMMIT; SQL } apply_backend_config() { ( cd "$REPO_DIR" docker compose \ -f docker-compose.yml \ -f docker-compose.live.yml \ up -d --no-deps backend ) >&2 } wait_for_backend() { local state='' local attempt # Startup refreshes the canonical node-identity view before the healthcheck # turns green. On the production dataset that can legitimately take well over # one minute, so allow five minutes without weakening the readback checks. for attempt in {1..150}; do state="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ "$BACKEND_CONTAINER" 2>/dev/null || true)" case "$state" in healthy|running) return 0 ;; exited|dead) return 1 ;; esac sleep 2 done printf 'newuser: backend did not become healthy (last state: %s)\n' "${state:-missing}" >&2 return 1 } acl_has_grant() { local mqtt_username=$1 local node_id=$2 docker exec "$MOSQUITTO_CONTAINER" awk -v username="$mqtt_username" -v node_id="$node_id" ' $1 == "user" { active = ($2 == username) next } active && $1 == "topic" && $2 == "write" { seen[$3] = 1 } END { suffixes[1] = "packets" suffixes[2] = "status" suffixes[3] = "neighbors" suffixes[4] = "neighbours" for (i = 1; i <= 4; i++) { required = "meshcore/+" "/" node_id "/" suffixes[i] if (!seen[required]) exit 1 } } ' /mosquitto/config/acl } verify_broker_login() { local mqtt_username=$1 local mqtt_password=$2 docker exec \ -e "NEWUSER_MQTT_USERNAME=${mqtt_username}" \ -e "NEWUSER_MQTT_PASSWORD=${mqtt_password}" \ "$BACKEND_CONTAINER" \ node --input-type=module -e ' import mqtt from "mqtt"; const finish = (code) => { clearTimeout(timer); client.removeAllListeners(); client.end(true, {}, () => process.exit(code)); }; const client = mqtt.connect("ws://mosquitto:9001", { username: process.env.NEWUSER_MQTT_USERNAME, password: process.env.NEWUSER_MQTT_PASSWORD, reconnectPeriod: 0, connectTimeout: 5000, clean: true, clientId: `newuser-check-${process.pid}`, }); const timer = setTimeout(() => finish(1), 6000); client.once("connect", () => finish(0)); client.once("error", () => finish(1)); client.once("close", () => finish(1)); ' >/dev/null } main() { watch_mode=0 timeout_value=$DEFAULT_DISCOVERY_TIMEOUT_SECONDS while (( $# > 0 )); do case $1 in --watch) watch_mode=1 shift ;; --timeout) (( $# >= 2 )) || die '--timeout requires a duration' timeout_value=$2 shift 2 ;; --timeout=*) timeout_value=${1#*=} shift ;; -h|--help) usage return 0 ;; --) shift break ;; -*) usage >&2 die "unknown option: $1" ;; *) break ;; esac done if (( watch_mode )); then (( $# == 1 )) || { usage >&2; return 2; } else (( $# <= 2 )) || { usage >&2; return 2; } fi timeout_seconds="$(duration_seconds "$timeout_value")" \ || die "invalid discovery timeout: ${timeout_value}" [[ $DISCOVERY_POLL_SECONDS =~ ^[0-9]+([.][0-9]+)?$ ]] \ || die 'NEWUSER_DISCOVERY_POLL_SECONDS must be a non-negative number' username=${1:-} raw_key_list=${2:-} if [[ -z $username ]]; then read -r -p 'MQTT username: ' username || die 'unable to read username' fi username="$(trim "$username")" [[ $username =~ ^[A-Za-z0-9_.@-]{1,128}$ ]] \ || die 'username must match [A-Za-z0-9_.@-] and be 1-128 characters long' declare -a keys=() declare -A seen_keys=() if [[ -n $raw_key_list ]]; then IFS=',' read -r -a raw_keys <<<"$raw_key_list" for raw_key in "${raw_keys[@]}"; do key="$(trim "$raw_key")" key=${key^^} [[ $key =~ ^[0-9A-F]{64}$ ]] \ || die "invalid MeshCore public key: ${raw_key}" if [[ -z ${seen_keys[$key]+set} ]]; then seen_keys[$key]=1 keys+=("$key") fi done fi discovery_mode=0 if (( watch_mode )) || (( ${#keys[@]} == 0 )); then discovery_mode=1 fi for command_name in docker openssl awk flock mktemp chmod mv date sleep; do command -v "$command_name" >/dev/null || die "required command not found: ${command_name}" done [[ -d $REPO_DIR && -f $ENV_FILE ]] || die "canonical stack not found at ${REPO_DIR}" [[ -O $ENV_FILE && -w $ENV_FILE ]] || die "${ENV_FILE} must be owned and writable by the current user" for container in "$MOSQUITTO_CONTAINER" "$BACKEND_CONTAINER" "$TIMESCALEDB_CONTAINER"; do [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true) == true ]] \ || die "required container is not running: ${container}" done # Serialize operators using this script. The lock lives outside the repository, # so OWNER_MQTT_USERNAME_MAP remains the only repository configuration touched. exec 9>"${TMPDIR:-/tmp}/meshcore-analytics-newuser.lock" flock -x 9 mapfile -t owner_map_lines < <(awk '/^OWNER_MQTT_USERNAME_MAP=/{ print }' "$ENV_FILE") (( ${#owner_map_lines[@]} == 1 )) \ || die "expected exactly one OWNER_MQTT_USERNAME_MAP line in ${ENV_FILE}" old_owner_map=${owner_map_lines[0]#OWNER_MQTT_USERNAME_MAP=} IFS=',' read -r -a existing_entries <<<"$old_owner_map" for existing_entry in "${existing_entries[@]}"; do existing_entry="$(trim "$existing_entry")" [[ -z $existing_entry ]] && continue [[ $existing_entry == *=* ]] || die 'existing OWNER_MQTT_USERNAME_MAP is malformed' existing_username="$(trim "${existing_entry%%=*}")" [[ $existing_username != "$username" ]] \ || die "username already exists in OWNER_MQTT_USERNAME_MAP: ${username}" done passwd_exists=0 if docker exec "$MOSQUITTO_CONTAINER" sh -c ' wanted=$1 while IFS=: read -r name ignored; do [ "$name" = "$wanted" ] && exit 0 done < /mosquitto/config/passwd exit 1 ' sh "$username" >/dev/null; then passwd_exists=1 else passwd_check_status=$? (( passwd_check_status == 1 )) \ || die 'could not inspect Mosquitto passwd' fi if (( watch_mode )); then (( passwd_exists )) || die "username does not exist in Mosquitto passwd: ${username}" else (( ! passwd_exists )) || die "username already exists in Mosquitto passwd: ${username}" fi if (( ! watch_mode )); then if docker exec "$MOSQUITTO_CONTAINER" awk -v username="$username" ' $1 == "user" && $2 == username { found = 1 } END { exit found ? 0 : 1 } ' /mosquitto/config/acl >/dev/null; then die "username already exists in Mosquitto ACL: ${username}" else acl_check_status=$? (( acl_check_status == 1 )) \ || die 'could not inspect Mosquitto ACL' fi fi owner_db_state="$( docker exec -i "$TIMESCALEDB_CONTAINER" \ psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$OWNER_DATABASE" \ -v "mqtt_username=${username}" -At <<'SQL' SELECT COALESCE( (SELECT CASE WHEN is_active THEN 'active' ELSE 'inactive' END FROM owner_accounts WHERE mqtt_username = :'mqtt_username'), 'missing' ); SQL )" owner_db_preexisting=0 owner_db_restore_active=false if (( watch_mode )); then if [[ $owner_db_state != missing ]]; then owner_db_preexisting=1 [[ $owner_db_state == active ]] && owner_db_restore_active=true existing_owner_node_count="$( docker exec -i "$TIMESCALEDB_CONTAINER" \ psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$OWNER_DATABASE" \ -v "mqtt_username=${username}" -At <<'SQL' SELECT COUNT(*) FROM owner_account_nodes WHERE mqtt_username = :'mqtt_username'; SQL )" [[ $existing_owner_node_count == 0 ]] \ || die "owner database already has node grants for username: ${username}" fi else [[ $owner_db_state == missing ]] \ || die "username already exists in owner database: ${username}" fi credential_created=0 env_changed=0 db_changed=0 preserve_credential=0 rollback_done=0 rollback() { local status=${1:-1} (( rollback_done )) && exit "$status" rollback_done=1 trap - ERR INT TERM EXIT set +e if (( preserve_credential || env_changed || db_changed || credential_created )); then if (( preserve_credential )); then printf 'newuser: provisioning failed; rolling back owner grant for %s (broker credential remains)\n' "$username" >&2 else printf 'newuser: provisioning failed; rolling back %s\n' "$username" >&2 fi if (( env_changed )); then replace_owner_map "$old_owner_map" \ || printf 'newuser: WARNING: failed to restore OWNER_MQTT_USERNAME_MAP\n' >&2 fi if (( db_changed )); then if (( owner_db_preexisting )); then restore_keyless_owner_account "$username" "$key_csv" "$owner_db_restore_active" \ || printf 'newuser: WARNING: failed to restore key-less owner database row\n' >&2 else delete_owner_account "$username" \ || printf 'newuser: WARNING: failed to remove owner database rows\n' >&2 fi fi if (( credential_created )); then docker exec "$MOSQUITTO_CONTAINER" \ mosquitto_passwd -D /mosquitto/config/passwd "$username" >/dev/null 2>&1 \ || printf 'newuser: WARNING: failed to remove Mosquitto credential\n' >&2 docker exec "$MOSQUITTO_CONTAINER" sh -c 'kill -HUP 1' >/dev/null 2>&1 \ || true fi if (( env_changed )); then apply_backend_config \ || printf 'newuser: WARNING: failed to re-apply backend config after rollback\n' >&2 fi fi exit "$status" } # EXIT trap guarantees rollback even when die() exits explicitly (ERR does not # fire on `exit 1`). rollback is idempotent and no-ops before any mutation flag. trap 'rollback $?' ERR trap 'rollback 130' INT trap 'rollback 143' TERM trap 'rollback 1' EXIT password='' discovery_since='' if (( watch_mode )); then preserve_credential=1 discovery_since="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" log "resuming discovery for ${username}; reconnect the device using its existing credential" else if (( discovery_mode )); then discovery_since="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" fi password="$(openssl rand -hex 24)" log 'creating persistent Mosquitto credential' docker exec "$MOSQUITTO_CONTAINER" \ mosquitto_passwd -b /mosquitto/config/passwd "$username" "$password" credential_created=1 # mosquitto only re-reads password_file on SIGHUP; the reconciler reloads # every ~60s but a device may connect before then, so reload immediately. docker exec "$MOSQUITTO_CONTAINER" sh -c 'kill -HUP 1' >/dev/null 2>&1 \ || log 'WARNING: could not reload mosquitto; credential activates on the next reload cycle' if (( discovery_mode )); then # Once shown to the operator, this credential must survive timeout or an # interrupted/failed finalization so --watch can resume without a reset. preserve_credential=1 credential_created=0 print_mctomqtt_settings "$username" "$password" fi fi if (( discovery_mode )); then log "watching fresh Mosquitto logs for up to ${timeout_seconds}s" # The watch is passive (log polling only); holding the lock through it would # block every other newuser run for the whole window. Release, then # re-acquire before the mutation phase. flock -u 9 2>/dev/null || true if discovered_key="$(discover_node_key "$username" "$discovery_since" "$timeout_seconds")"; then keys=("$discovered_key") log "discovered node public key ${discovered_key}" else discovery_status=$? trap - ERR INT TERM EXIT if (( watch_mode )); then log "discovery timed out; reconnect the device and retry: newuser --watch --timeout ${timeout_value} ${username}" else log "discovery timed out; the broker credential remains valid" log "resume after reconnecting the device: newuser --watch --timeout ${timeout_value} ${username}" fi return "$discovery_status" fi fi key_pipe="$(IFS='|'; printf '%s' "${keys[*]}")" key_csv="$(IFS=','; printf '%s' "${keys[*]}")" new_entry="${username}=${key_pipe}" flock -x 9 # re-acquire for the mutation phase # BUG-011 fix: the map was last read before the long unlocked discovery window. # Another provisioning run may have added grants while we were unlocked — re-read # the CURRENT map under the lock, re-validate, and merge into it so we never # overwrite a concurrent grant with a stale snapshot. mapfile -t owner_map_lines < <(awk '/^OWNER_MQTT_USERNAME_MAP=/{ print }' "$ENV_FILE") (( ${#owner_map_lines[@]} == 1 )) \ || die "expected exactly one OWNER_MQTT_USERNAME_MAP line in ${ENV_FILE}" old_owner_map=${owner_map_lines[0]#OWNER_MQTT_USERNAME_MAP=} IFS=',' read -r -a existing_entries <<<"$old_owner_map" for existing_entry in "${existing_entries[@]}"; do existing_entry="$(trim "$existing_entry")" [[ -z $existing_entry ]] && continue [[ $existing_entry == *=* ]] || die 'existing OWNER_MQTT_USERNAME_MAP is malformed' existing_username="$(trim "${existing_entry%%=*}" )" [[ $existing_username != "$username" ]] \ || die "username already exists in OWNER_MQTT_USERNAME_MAP: ${username}" done if [[ -n $old_owner_map ]]; then new_owner_map="${old_owner_map},${new_entry}" else new_owner_map=$new_entry fi log 'adding deduplicated owner grant to OWNER_MQTT_USERNAME_MAP' replace_owner_map "$new_owner_map" env_changed=1 log 'inserting verified owner account and node grant rows' docker exec -i "$TIMESCALEDB_CONTAINER" \ psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$OWNER_DATABASE" \ -v "mqtt_username=${username}" -v "node_ids=${key_csv}" >/dev/null <<'SQL' BEGIN; INSERT INTO owner_accounts (mqtt_username, is_active, updated_at) VALUES (:'mqtt_username', TRUE, NOW()) ON CONFLICT (mqtt_username) DO UPDATE SET is_active = TRUE, updated_at = NOW(); INSERT INTO owner_account_nodes ( mqtt_username, node_id, verification_method, verified_at, grant_id, revoked_at, revocation_reason, grant_generation, updated_at ) SELECT :'mqtt_username', node_id, 'operator-config', NOW(), md5(random()::text || clock_timestamp()::text || node_id), NULL, NULL, NULL, NOW() FROM unnest(string_to_array(:'node_ids', ',')) AS configured(node_id) ON CONFLICT (mqtt_username, node_id) DO UPDATE SET verification_method = 'operator-config', verified_at = NOW(), grant_id = EXCLUDED.grant_id, revoked_at = NULL, revocation_reason = NULL, updated_at = NOW(); COMMIT; SQL db_changed=1 log 'recreating only the backend to reconcile owner grants and reload the ACL' apply_backend_config wait_for_backend verified_grant_count="$( docker exec -i "$TIMESCALEDB_CONTAINER" \ psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$OWNER_DATABASE" \ -v "mqtt_username=${username}" -v "node_ids=${key_csv}" -At <<'SQL' SELECT COUNT(*) FROM owner_account_nodes oan JOIN owner_accounts oa ON oa.mqtt_username = oan.mqtt_username WHERE oa.mqtt_username = :'mqtt_username' AND oa.is_active = TRUE AND oan.node_id = ANY(string_to_array(:'node_ids', ',')) AND oan.node_id ~ '^[0-9A-F]{64}$' AND oan.verified_at IS NOT NULL AND oan.verification_method IN ('operator-config', 'operator-database') AND oan.revoked_at IS NULL; SQL )" [[ $verified_grant_count == "${#keys[@]}" ]] \ || die "owner database verification returned ${verified_grant_count}/${#keys[@]} grants" for key in "${keys[@]}"; do acl_has_grant "$username" "$key" \ || die "ACL readback is incomplete for ${username}=${key}" done if (( watch_mode )); then log 'existing broker password is unchanged; ACL and database readbacks verified' else # ownerAccess.verifyMqttCredentials() performs a clean MQTT CONNECT over this # same internal WebSocket endpoint. Reproduce that check without publishing. verify_broker_login "$username" "$password" \ || die 'broker rejected the new credentials' fi dashboard_node_count="$( docker exec -i "$TIMESCALEDB_CONTAINER" \ psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d meshcore \ -v "node_ids=${key_csv}" -At <<'SQL' SELECT COUNT(DISTINCT n.node_id) FROM node_identity_nodes n WHERE n.node_id IN ( SELECT meshcore_canonical_node_id(source_node_id) FROM unnest(string_to_array(:'node_ids', ',')) AS source(source_node_id) ); SQL )" if [[ $dashboard_node_count == 0 ]]; then log 'grant is active; the dashboard will show the node after its first valid status/packet publish' else log "dashboard source contains ${dashboard_node_count} matching node(s)" fi trap - ERR INT TERM EXIT credential_created=0 env_changed=0 db_changed=0 printf '\nProvisioned MQTT owner account\n' printf 'Broker: %s\n' "$BROKER_URL" printf 'Username: %s\n' "$username" if (( watch_mode )); then printf 'Password: unchanged (existing credential)\n' else printf 'Password: %s\n' "$password" fi printf 'Topic root(s):\n' for key in "${keys[@]}"; do printf ' meshcore//%s/\n' "$key" done cat <<'EOF' Current MeshCore MQTT firmware template (credentials are the values above): set mqtt.wifi.ssid set mqtt.wifi.pass set mqtt.1.uri wss://mqtt.ukmesh.com:443/ set mqtt.1.username set mqtt.1.password set mqtt.1.iata set mqtt.1.retain.status 1 set mqtt.1.enabled 1 EOF for key in "${keys[@]}"; do printf ' set mqtt.1.topic.root meshcore//%s/packets\n' "$key" done } if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@" fi