mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-16 20:12:35 +00:00
433 lines
13 KiB
Bash
Executable File
433 lines
13 KiB
Bash
Executable File
#!/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='meshcore-analytics-mosquitto-1'
|
|
readonly BACKEND_CONTAINER='meshcore-analytics-backend-1'
|
|
readonly TIMESCALEDB_CONTAINER='meshcore-analytics-timescaledb-1'
|
|
readonly OWNER_DATABASE='meshcore_owner_auth'
|
|
readonly POSTGRES_USER='meshcore'
|
|
readonly BROKER_URL='wss://mqtt.ukmesh.com:443'
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: newuser <username> [key1,key2,...]
|
|
|
|
Creates one MQTT/owner-dashboard account. Missing values are prompted for.
|
|
Each key must be a 64-character hexadecimal MeshCore public key.
|
|
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"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if (( $# > 2 )); then
|
|
usage >&2
|
|
exit 2
|
|
fi
|
|
if [[ ${1:-} == '-h' || ${1:-} == '--help' ]]; then
|
|
usage
|
|
exit 0
|
|
fi
|
|
|
|
username=${1:-}
|
|
raw_key_list=${2:-}
|
|
if [[ -z $username ]]; then
|
|
read -r -p 'MQTT username: ' username || die 'unable to read username'
|
|
fi
|
|
if [[ -z $raw_key_list ]]; then
|
|
read -r -p 'MeshCore public key(s), comma-separated: ' raw_key_list \
|
|
|| die 'unable to read public key(s)'
|
|
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=()
|
|
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
|
|
(( ${#keys[@]} > 0 )) || die 'at least one MeshCore public key is required'
|
|
|
|
for command_name in docker openssl awk flock mktemp chmod mv; 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
|
|
|
|
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
|
|
die "username already exists in Mosquitto passwd: ${username}"
|
|
else
|
|
passwd_check_status=$?
|
|
(( passwd_check_status == 1 )) \
|
|
|| die 'could not inspect Mosquitto passwd'
|
|
fi
|
|
|
|
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
|
|
|
|
owner_db_exists="$(
|
|
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 EXISTS (
|
|
SELECT 1 FROM owner_accounts WHERE mqtt_username = :'mqtt_username'
|
|
);
|
|
SQL
|
|
)"
|
|
[[ $owner_db_exists == f ]] || die "username already exists in owner database: ${username}"
|
|
|
|
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
|
|
|
|
credential_created=0
|
|
env_changed=0
|
|
db_changed=0
|
|
rollback() {
|
|
local status=${1:-1}
|
|
trap - ERR INT TERM
|
|
set +e
|
|
printf 'newuser: provisioning failed; rolling back %s\n' "$username" >&2
|
|
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
|
|
delete_owner_account "$username" \
|
|
|| printf 'newuser: WARNING: failed to remove owner database rows\n' >&2
|
|
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
|
|
fi
|
|
if (( env_changed )); then
|
|
apply_backend_config \
|
|
|| printf 'newuser: WARNING: failed to re-apply backend config after rollback\n' >&2
|
|
fi
|
|
exit "$status"
|
|
}
|
|
trap 'rollback $?' ERR
|
|
trap 'rollback 130' INT
|
|
trap 'rollback 143' TERM
|
|
|
|
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
|
|
|
|
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
|
|
|
|
# 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'
|
|
|
|
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
|
|
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"
|
|
printf 'Password: %s\n' "$password"
|
|
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
|