Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions scripts/apl-feed/claim.sh
Original file line number Diff line number Diff line change
Expand Up @@ -530,13 +530,173 @@ claim_set() {
}


# claim_status [--json]
#
# Read-only probe of the feeder's account-claim status against
# /api/feeders/status. Distinguishes "registered with the backend" from
# "claimed by a user account" (owner_present). Writes nothing — no
# version-mirror update — so it runs unprivileged: the on-device webconfig
# invokes it as the airplanes-webconfig user (the airplanes-feed group
# grants read access to the claim secret). Emits a single result every
# time; --json produces a stable schema-v1 object, otherwise a human line.
claim_status() {
local opt_rc json=0
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage_claim_status; exit 0 ;;
--json) json=1; shift; continue ;;
esac
if parse_common_option "$@"; then opt_rc=0; else opt_rc=$?; fi
case "$opt_rc" in
1) shift ;;
2) shift 2 ;;
0) die "unknown flag for claim status: $1" ;;
esac
done
require_jq

local result uuid final secret

# Local state first — these answers need no network call.
if ! uuid="$(read_uuid 2>/dev/null)"; then
_claim_status_emit no_identity "$json"
return 0
fi
final="$(secret_final_path)"
if [[ ! -f "$final" ]]; then
_claim_status_emit unregistered "$json"
return 0
fi
if [[ ! -r "$final" ]]; then
# Present but unreadable (permissions) — distinct from malformed.
# Re-registering won't fix a perms problem, so don't suggest it.
_claim_status_emit error "$json"
return 2
fi
if ! secret="$(read_secret_file "$final" 2>/dev/null)"; then
# Present but empty / non-canonical — a local corruption, distinct
# from "no secret". UI action: re-register.
_claim_status_emit secret_invalid "$json"
return 0
fi

claim_status_probe "$uuid" "$secret"
case "$CLAIM_PROBE_OUTCOME" in
authenticated)
case "$CLAIM_PROBE_OWNER_PRESENT" in
true) result=claimed ;;
false) result=unclaimed ;;
# An authenticated reply always carries a boolean
# owner_present; anything else is a contract fault, not
# an "unclaimed" feeder.
*) result=error ;;
esac
;;
minimal) result=secret_mismatch ;;
registered_false) result=server_unregistered ;;
blocked) result=blocked ;;
rate_limited) result=rate_limited ;;
unreachable) result=unreachable ;;
*) result=error ;;
esac
_claim_status_emit "$result" "$json"
case "$result" in
unreachable|error) return 2 ;;
*) return 0 ;;
esac
}

# _claim_status_emit <result> <json-flag>
# Renders <result> as schema-v1 JSON (json=1) or a human line. Reads the
# CLAIM_PROBE_* globals for server-derived fields; for the local-only
# results (no_identity/unregistered/secret_invalid) those are empty, which
# the JSON nullifies.
_claim_status_emit() {
local result="$1" json="$2"
if (( json )); then
jq -nc \
--arg result "$result" \
--arg registered "${CLAIM_PROBE_REGISTERED:-}" \
--arg owner_present "${CLAIM_PROBE_OWNER_PRESENT:-}" \
--arg version "${CLAIM_PROBE_VERSION:-}" \
--arg reset_until "${CLAIM_PROBE_RESET_UNTIL:-}" \
--arg last_seen_at "${CLAIM_PROBE_LAST_SEEN_AT:-}" \
--arg last_seen_age "${CLAIM_PROBE_LAST_SEEN_AGE:-}" \
--arg retry_after "${CLAIM_PROBE_RETRY_AFTER:-}" \
--arg detail "${CLAIM_PROBE_DETAIL:-}" \
'
def nullempty: if . == "" then null else . end;
def boolish: if . == "true" then true elif . == "false" then false else null end;
def numberish: if . == "" then null else (tonumber? // null) end;
{
schema_version: 1,
result: $result,
registered: ($registered | boolish),
owner_present: ($owner_present | boolish),
version: ($version | numberish),
reset_until: ($reset_until | nullempty),
last_seen_at: ($last_seen_at | nullempty),
last_seen_age_seconds: ($last_seen_age | numberish),
retry_after_seconds: ($retry_after | numberish),
detail: ($detail | nullempty)
}'
return
fi
_claim_status_human "$result"
}

# _claim_status_human <result> — one or two readable lines per result.
_claim_status_human() {
local result="$1"
case "$result" in
claimed)
echo "Claimed: yes — this feeder is linked to an airplanes.live account." ;;
unclaimed)
echo "Claimed: no — registered, but not yet linked to an account."
echo "Claim it at: $(claim_page_url)" ;;
secret_mismatch)
echo "The claim secret on this feeder did not authenticate with airplanes.live."
echo "Re-register: sudo apl-feed claim register" ;;
server_unregistered)
echo "airplanes.live has no record of this feeder's claim secret."
echo "Register: sudo apl-feed claim register" ;;
unregistered)
echo "No claim secret on this feeder yet."
echo "Register: sudo apl-feed claim register" ;;
secret_invalid)
echo "The local claim secret is missing or malformed."
echo "Re-register: sudo apl-feed claim register" ;;
no_identity)
echo "This device has no Feeder ID yet." ;;
blocked)
echo "This feeder is blocked by an administrator." ;;
rate_limited)
echo "airplanes.live is rate-limiting status checks; try again shortly." ;;
unreachable)
echo "Could not reach airplanes.live to check claim status." ;;
*)
echo "Claim status unavailable." ;;
esac
}

usage_claim_status() {
cat <<'USAGE'
Usage: apl-feed claim status [--json]

Reports whether this feeder is registered with airplanes.live and whether a
user account has claimed it. Contacts the website read-only and writes
nothing. --json emits a machine-readable object instead of the summary.
USAGE
}

usage_claim() {
cat <<'USAGE'
Usage: apl-feed claim <subcommand> [options]

Subcommands:
register Generate and register the claim secret with airplanes.live
show Print the local claim secret and the claim page URL
status Show registration + account-claim status (--json available)
rotate Rotate the claim secret (--abort cancels a pending rotation)
set Save a claim secret minted by the website (reads stdin)

Expand Down Expand Up @@ -598,6 +758,7 @@ dispatch_claim() {
case "$sub" in
register) claim_register "$@" ;;
show) claim_show "$@" ;;
status) claim_status "$@" ;;
rotate) claim_rotate "$@" ;;
set) claim_set "$@" ;;
*) usage_error usage_claim "unknown claim subcommand: $sub" ;;
Expand Down
127 changes: 127 additions & 0 deletions scripts/apl-feed/http.sh
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,130 @@ status_probe_version() {
[[ -n "$version" && "$version" != "null" ]] || return 1
printf '%s' "$version"
}

# claim_status_probe <uuid> <secret>
#
# Single source of truth for probing POST /api/feeders/status and parsing
# its response. Both `apl-feed status` (claim_registration_status_line)
# and `apl-feed claim status` consume the CLAIM_PROBE_* output globals, so
# the two never drift on the wire shape. Builds a Bearer token from the
# uuid + secret, POSTs, and classifies the reply. Always returns 0 — the
# outcome travels in CLAIM_PROBE_OUTCOME so callers under `set -e` are not
# aborted by a non-2xx reply. Does NOT touch the on-disk version mirror;
# a caller that wants that side effect calls write_version_file itself.
#
# Outputs (reset on every call; empty when not applicable):
# CLAIM_PROBE_OUTCOME unreachable|registered_false|authenticated|minimal|blocked|rate_limited|http_error
# CLAIM_PROBE_HTTP numeric HTTP status ("" on transport failure)
# CLAIM_PROBE_REGISTERED raw .registered ("true"/"false"/"")
# CLAIM_PROBE_OWNER_PRESENT raw .owner_present ("true"/"false"/"")
# CLAIM_PROBE_VERSION raw .version
# CLAIM_PROBE_RESET_UNTIL raw .reset_until
# CLAIM_PROBE_LAST_SEEN_PRESENT "true" when the last_seen_at key exists
# (distinct from a present-but-null value)
# CLAIM_PROBE_LAST_SEEN_AT raw .last_seen_at ("" when null or key absent)
# CLAIM_PROBE_LAST_SEEN_AGE raw .last_seen_age_seconds
# CLAIM_PROBE_RETRY_AFTER raw .retry_after (429 only)
# CLAIM_PROBE_ERROR raw .error (non-200 diagnostics)
# CLAIM_PROBE_DETAIL short body preview (diagnostics)
#
# CLAIM_PROBE_* are consumed by sibling apl-feed modules (claim.sh,
# status.sh), not within this file (SC2034 is disabled repo-wide).
claim_status_probe() {
local uuid="$1" secret="$2"
CLAIM_PROBE_OUTCOME=''
CLAIM_PROBE_HTTP=''
CLAIM_PROBE_REGISTERED=''
CLAIM_PROBE_OWNER_PRESENT=''
CLAIM_PROBE_VERSION=''
CLAIM_PROBE_RESET_UNTIL=''
CLAIM_PROBE_LAST_SEEN_PRESENT=''
CLAIM_PROBE_LAST_SEEN_AT=''
CLAIM_PROBE_LAST_SEEN_AGE=''
CLAIM_PROBE_RETRY_AFTER=''
CLAIM_PROBE_ERROR=''
CLAIM_PROBE_DETAIL=''

local response_file status curl_rc body token
response_file="$(mktemp)"
# Body carries only the UUID; auth is in the Bearer header.
body="$(printf '{"uuid":"%s"}' "$uuid")"
# Honor the always-returns-0 contract: bad uuid/secret inputs would
# otherwise abort under set -e via the failing command substitution.
if ! token="$(apl_auth_token "$uuid" "$secret")"; then
CLAIM_PROBE_OUTCOME='error'
CLAIM_PROBE_DETAIL='invalid auth token inputs'
rm -f "$response_file"
return 0
fi
set +e
status="$(post_json_bearer "$token" '/api/feeders/status' "$body" "$response_file")"
curl_rc=$?
set -e
CLAIM_PROBE_HTTP="$status"
if [[ "$curl_rc" -ne 0 ]]; then
CLAIM_PROBE_OUTCOME='unreachable'
CLAIM_PROBE_DETAIL="curl rc=$curl_rc"
rm -f "$response_file"
return 0
fi
CLAIM_PROBE_ERROR="$(parse_field_from "$response_file" '.error')"
CLAIM_PROBE_DETAIL="$(body_preview "$response_file")"
case "$status" in
200)
# A 200 must carry a JSON object; an HTML error page or a
# truncated body is a contract fault, not "unregistered".
if ! jq -e . "$response_file" >/dev/null 2>&1; then
CLAIM_PROBE_OUTCOME='http_error'
rm -f "$response_file"
return 0
fi
CLAIM_PROBE_REGISTERED="$(parse_field_from "$response_file" '.registered')"
case "$CLAIM_PROBE_REGISTERED" in
true) ;;
false)
CLAIM_PROBE_OUTCOME='registered_false'
rm -f "$response_file"
return 0
;;
*)
# `registered` missing or non-boolean on a valid-JSON
# 200: a contract fault, surfaced as an error rather
# than a (false) claim state.
CLAIM_PROBE_OUTCOME='http_error'
rm -f "$response_file"
return 0
;;
esac
CLAIM_PROBE_VERSION="$(parse_field_from "$response_file" '.version')"
CLAIM_PROBE_OWNER_PRESENT="$(parse_field_from "$response_file" '.owner_present')"
CLAIM_PROBE_RESET_UNTIL="$(parse_field_from "$response_file" '.reset_until')"
# The authenticated reply carries a version; a minimal reply
# (wrong/superseded local secret) is registered:true with no
# version — surfaced distinctly so the UI says "re-register"
# rather than "claimed".
if [[ -n "$CLAIM_PROBE_VERSION" ]]; then
CLAIM_PROBE_OUTCOME='authenticated'
CLAIM_PROBE_LAST_SEEN_PRESENT="$(json_has_key "$response_file" 'last_seen_at')"
if [[ "$CLAIM_PROBE_LAST_SEEN_PRESENT" == "true" ]]; then
CLAIM_PROBE_LAST_SEEN_AT="$(parse_field_from "$response_file" '.last_seen_at')"
CLAIM_PROBE_LAST_SEEN_AGE="$(parse_field_from "$response_file" '.last_seen_age_seconds')"
fi
else
CLAIM_PROBE_OUTCOME='minimal'
fi
;;
423)
CLAIM_PROBE_OUTCOME='blocked'
;;
429)
CLAIM_PROBE_OUTCOME='rate_limited'
CLAIM_PROBE_RETRY_AFTER="$(parse_field_from "$response_file" '.retry_after')"
;;
*)
CLAIM_PROBE_OUTCOME='http_error'
;;
esac
rm -f "$response_file"
return 0
}
Loading