mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-01 17:08:17 +00:00
Co-authored-by: gadgethd <111318106+gadgethd@users.noreply.github.com>
846 lines
27 KiB
Bash
Executable File
846 lines
27 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -Eeuo pipefail
|
|
IFS=$'\n\t'
|
|
|
|
readonly REPO_DIR="${NEWUSER_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 <duration>] <username> [key1,key2,...]
|
|
newuser --watch [--timeout <duration>] <username>
|
|
newuser --link [--timeout <duration>] <username> [key1,key2,...]
|
|
|
|
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.
|
|
--link adds one or more nodes to an existing owner without changing its password.
|
|
When link keys are omitted, the next node observed for that user is added.
|
|
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 <<EOF
|
|
|
|
MQTT credential created — configure mctomqtt now
|
|
Server hostname/IP: mqtt.ukmesh.com
|
|
Port: 443
|
|
Use WebSockets transport?: y
|
|
Use TLS/SSL encryption?: y
|
|
Verify TLS certificates?: y
|
|
Authentication method: username/password
|
|
Username: ${mqtt_username}
|
|
Password: ${mqtt_password}
|
|
EOF
|
|
}
|
|
|
|
replace_owner_map() {
|
|
local value=$1
|
|
local temporary
|
|
temporary="$(mktemp "${ENV_FILE}.newuser.XXXXXX")"
|
|
if ! awk -v replacement="OWNER_MQTT_USERNAME_MAP=${value}" '
|
|
BEGIN { matches = 0 }
|
|
/^OWNER_MQTT_USERNAME_MAP=/ {
|
|
print replacement
|
|
matches++
|
|
next
|
|
}
|
|
{ print }
|
|
END { if (matches != 1) exit 42 }
|
|
' "$ENV_FILE" >"$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
|
|
link_mode=0
|
|
timeout_value=$DEFAULT_DISCOVERY_TIMEOUT_SECONDS
|
|
while (( $# > 0 )); do
|
|
case $1 in
|
|
--watch)
|
|
(( ! link_mode )) || die '--watch and --link are mutually exclusive'
|
|
watch_mode=1
|
|
shift
|
|
;;
|
|
--link)
|
|
(( ! watch_mode )) || die '--watch and --link are mutually exclusive'
|
|
link_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; }
|
|
elif (( link_mode )); then
|
|
(( $# >= 1 && $# <= 2 )) || { 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
|
|
existing_credential_mode=0
|
|
if (( watch_mode || link_mode )); then
|
|
existing_credential_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"
|
|
owner_map_has_username=0
|
|
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%%=*}")"
|
|
if [[ $existing_username == "$username" ]]; then
|
|
owner_map_has_username=1
|
|
(( link_mode )) \
|
|
|| die "username already exists in OWNER_MQTT_USERNAME_MAP: ${username}"
|
|
fi
|
|
done
|
|
if (( link_mode && ! owner_map_has_username )); then
|
|
die "username does not exist in OWNER_MQTT_USERNAME_MAP: ${username}"
|
|
fi
|
|
|
|
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 (( existing_credential_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 (( ! existing_credential_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
|
|
elif (( link_mode )); then
|
|
[[ $owner_db_state != missing ]] \
|
|
|| die "username does not exist in owner database: ${username}"
|
|
owner_db_preexisting=1
|
|
[[ $owner_db_state == active ]] && owner_db_restore_active=true
|
|
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 (( existing_credential_mode )); then
|
|
preserve_credential=1
|
|
if (( discovery_mode )); then
|
|
discovery_since="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
|
if (( link_mode )); then
|
|
log "discovering an additional node for ${username}; reconnect it using the existing credential"
|
|
else
|
|
log "resuming discovery for ${username}; reconnect the device using its existing credential"
|
|
fi
|
|
fi
|
|
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}"
|
|
elif (( link_mode )); then
|
|
log "discovery timed out; reconnect the additional device and retry: newuser --link --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
|
|
|
|
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"
|
|
declare -A configured_keys=()
|
|
owner_map_has_username=0
|
|
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%%=*}" )"
|
|
if [[ $existing_username == "$username" ]]; then
|
|
owner_map_has_username=1
|
|
(( link_mode )) \
|
|
|| die "username already exists in OWNER_MQTT_USERNAME_MAP: ${username}"
|
|
existing_key_list="$(trim "${existing_entry#*=}")"
|
|
IFS='|' read -r -a existing_keys <<<"$existing_key_list"
|
|
for existing_key in "${existing_keys[@]}"; do
|
|
existing_key="$(trim "$existing_key")"
|
|
existing_key=${existing_key^^}
|
|
[[ $existing_key =~ ^[0-9A-F]{64}$ ]] \
|
|
|| die 'existing OWNER_MQTT_USERNAME_MAP is malformed'
|
|
configured_keys[$existing_key]=1
|
|
done
|
|
fi
|
|
done
|
|
if (( link_mode )); then
|
|
(( owner_map_has_username )) \
|
|
|| die "username does not exist in OWNER_MQTT_USERNAME_MAP: ${username}"
|
|
declare -a unconfigured_keys=()
|
|
for key in "${keys[@]}"; do
|
|
if [[ -z ${configured_keys[$key]+set} ]]; then
|
|
unconfigured_keys+=("$key")
|
|
fi
|
|
done
|
|
(( ${#unconfigured_keys[@]} > 0 )) \
|
|
|| die "all requested nodes are already linked to username: ${username}"
|
|
keys=("${unconfigured_keys[@]}")
|
|
fi
|
|
|
|
key_pipe="$(IFS='|'; printf '%s' "${keys[*]}")"
|
|
key_csv="$(IFS=','; printf '%s' "${keys[*]}")"
|
|
new_entry="${username}=${key_pipe}"
|
|
|
|
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 (( existing_credential_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 (( existing_credential_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/<IATA>/%s/\n' "$key"
|
|
done
|
|
|
|
cat <<'EOF'
|
|
|
|
Current MeshCore MQTT firmware template (credentials are the values above):
|
|
set mqtt.wifi.ssid <wifi-ssid>
|
|
set mqtt.wifi.pass <wifi-password>
|
|
set mqtt.1.uri wss://mqtt.ukmesh.com:443/
|
|
set mqtt.1.username <username-above>
|
|
set mqtt.1.password <password-above>
|
|
set mqtt.1.iata <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/<IATA>/%s/packets\n' "$key"
|
|
done
|
|
}
|
|
|
|
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
|
main "$@"
|
|
fi
|