Skip to content
Draft
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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,18 @@ APP_STORE_CACHE_RETENTION_DAYS=90
PORT=3000
CLOUDFLARE_API_TOKEN=change-me
CLOUDFLARE_ZONE_ID=change-me
NTFY_TOPIC=
NTFY_TOKEN=
```

`BODY_LIMIT` applies to authenticated analyser JSON and text uploads.
`PUBLIC_FORM_BODY_LIMIT` is the smaller limit for the public analysis request form.
`APP_STORE_CACHE_RETENTION_DAYS` controls how long cached App Store metadata is kept.
`NTFY_TOPIC` names the [ntfy](https://ntfy.sh) topic the website posts to when an
app is queued, which wakes an idle analyser straight away; use the same value in
`analyser/.env` (see [Queue And Operations](#queue-and-operations)).
`NTFY_TOKEN` is optional: an ntfy account access token, so publishing is not
subject to ntfy.sh's per-IP limits for anonymous use.

Set `SITE_URL` in production to the public origin, for example
`SITE_URL=https://ios.trackercontrol.org`. Canonical links, Open Graph URLs,
Expand Down Expand Up @@ -262,14 +269,15 @@ For maintenance, use `sudo bash /opt/tracker-control-ios/scripts/manage-raspi-ip

The website exposes analyser endpoints:

- `GET /queue` returns the next app to process in the response body and its one-use assignment token in the `X-Analysis-Claim-Token` response header.
- `GET /ping` marks the analyser online.
- `GET /queue` returns the next app to process in the response body and its one-use assignment token in the `X-Analysis-Claim-Token` response header. With the queue empty the body is empty, and `X-Analysis-Next-Due-Seconds` says when the next app falls due on its own (a stale analysis, a retryable failure, an abandoned claim); the header is absent if none ever will. Each call also records that the analyser is online.
- `POST /uploadAnalysis` stores successful analysis results when sent with the assignment's `X-Analysis-Claim-Token` header.
- `POST /reportAnalysisFailure` stores failed analysis results when sent with the assignment's `X-Analysis-Claim-Token` header.

Analyzer requests authenticate with `Authorization: Bearer $UPLOAD_PASSWORD`.
Authentication and assignment tokens serve different purposes: the bearer token authorises the analyser, while the assignment token prevents an expired worker from overwriting a newer result.

The analyser does not poll. With the queue empty it waits until the next app falls due, or until the website posts to the `NTFY_TOPIC` ntfy topic after queueing an app, whichever comes first, and asks again at least once a day (`MAX_IDLE_WAIT`). The website and its database therefore only see traffic when there is work or a visitor, so a host that stops idle services, such as Railway, can let them sleep. A failed or missing wake-up only delays an analysis until the analyser's next check.

### Claim-token rollout

The claim-token migration deliberately requeues every in-flight assignment that predates tokens. Use a coordinated deployment:
Expand All @@ -284,7 +292,7 @@ The `/queue` response body remains a plain bundle ID, but older analyser scripts
Health checks:

- `GET /healthz` returns `200` when the website can reach PostgreSQL.
- `GET /healthz/analyser` returns `200` when the analyser has pinged in the last hour.
- `GET /healthz/analyser` returns `200` when the analyser has asked for work in the last 26 hours.

Useful maintenance commands:

Expand Down
13 changes: 12 additions & 1 deletion analyser/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Web server that exposes /queue, /ping, /uploadAnalysis, and /reportAnalysisFailure.
# Web server that exposes /queue, /uploadAnalysis, and /reportAnalysisFailure.
SERVER=https://your-railway-app.example

# Must match UPLOAD_PASSWORD on the web server.
Expand Down Expand Up @@ -27,6 +27,17 @@ MAX_APP_SIZE_BYTES=3000000000
MAX_ATTEMPT_DOWNLOAD_BYTES=3000000000
CONSECUTIVE_FAILURE_LIMIT=5
CIRCUIT_BREAKER_SLEEP=3600

# Waiting for work. The website posts to this ntfy topic (https://ntfy.sh) when
# an app is queued; use the same NTFY_TOPIC (and NTFY_TOKEN, if any) as the web
# server. Pick a long random name, e.g. `openssl rand -hex 16`. Without a topic
# the analyser only checks when the next app falls due, or after MAX_IDLE_WAIT.
NTFY_TOPIC=
NTFY_TOKEN=
NTFY_SERVER=https://ntfy.sh
MIN_IDLE_WAIT=60
MAX_IDLE_WAIT=86400
QUEUE_RETRY_WAIT=300
DOWNLOAD_WATCHDOG_INTERVAL=5
NETWORK_INTERFACE=

Expand Down
84 changes: 72 additions & 12 deletions analyser/processQueue.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ fi
: "${IOS_STAGING_CLEANUP_TIMEOUT:=30}"
: "${CONSECUTIVE_FAILURE_LIMIT:=5}"
: "${CIRCUIT_BREAKER_SLEEP:=3600}"
: "${NTFY_TOPIC:=}"
: "${NTFY_SERVER:=https://ntfy.sh}"
: "${NTFY_TOKEN:=}"
: "${MIN_IDLE_WAIT:=60}"
: "${MAX_IDLE_WAIT:=86400}"
: "${QUEUE_RETRY_WAIT:=300}"
: "${DOWNLOAD_WATCHDOG_INTERVAL:=5}"
: "${NETWORK_INTERFACE:=}"
: "${DAILY_BYTES_FILE:=./daily-download-bytes.txt}"
Expand Down Expand Up @@ -113,6 +119,10 @@ fi
curl_auth_config=$(mktemp)
chmod 600 "$curl_auth_config"
printf 'header = "Authorization: Bearer %s"\n' "$UPLOAD_PASSWORD" > "$curl_auth_config"
# The server may be asleep (Railway stops idle services) and answer its first
# request with a 502 or refuse the connection while it starts, so every call
# retries transient failures for up to a minute.
printf 'retry = 5\nretry-delay = 5\nretry-max-time = 60\nretry-connrefused\n' >> "$curl_auth_config"
queue_headers=$(mktemp)
chmod 600 "$queue_headers"
trap 'rm -f "$curl_auth_config" "$queue_headers"' EXIT
Expand Down Expand Up @@ -434,6 +444,7 @@ fetch_queue_app()
: > "$queue_headers"
appId=$(curl -sS -K "$curl_auth_config" -D "$queue_headers" "$queueUrl" --fail) || return 1
claimToken=$(awk 'tolower($1) == "x-analysis-claim-token:" { gsub(/\r/, "", $2); print $2; exit }' "$queue_headers")
nextDueSeconds=$(awk 'tolower($1) == "x-analysis-next-due-seconds:" { gsub(/\r/, "", $2); print $2; exit }' "$queue_headers")

if [ -n "$appId" ] && [ -z "$claimToken" ]; then
echo "Queue response for $appId did not include an analysis claim token." >&2
Expand Down Expand Up @@ -784,12 +795,58 @@ process_app()
[ "$success" -eq 1 ]
}

# The website is never polled: with the queue empty, wait until the next app
# falls due on its own (the server says when) or until the website announces
# a newly queued app on the ntfy topic, whichever comes first. Every /queue
# call also counts as a check-in, and MAX_IDLE_WAIT bounds the wait so the
# website keeps seeing the analyser at least daily.
wait_for_work()
{
local wait="$MAX_IDLE_WAIT" started elapsed line wake_pid woken=0

if [[ "$nextDueSeconds" =~ ^[0-9]+$ ]] && [ "$nextDueSeconds" -lt "$wait" ]; then
wait="$nextDueSeconds"
fi
if [ "$wait" -lt "$MIN_IDLE_WAIT" ]; then
wait="$MIN_IDLE_WAIT"
fi
started=$(date +%s)

if [ -n "$NTFY_TOPIC" ]; then
echo "Queue empty. Waiting up to ${wait}s for a wake-up."
# since= replays a wake-up sent between the /queue check and this
# subscription. ntfy sends keepalives as empty lines.
exec {wake_fd}< <(exec curl -sN --max-time "$wait" \
${NTFY_TOKEN:+-H "Authorization: Bearer $NTFY_TOKEN"} \
"${NTFY_SERVER%/}/$NTFY_TOPIC/raw?since=$queue_checked_at")
wake_pid=$!
while IFS= read -r -u "$wake_fd" line; do
if [ -n "$line" ]; then
woken=1
break
fi
done
kill "$wake_pid" 2>/dev/null
exec {wake_fd}<&-
else
echo "Queue empty. Checking again in ${wait}s (set NTFY_TOPIC to be woken for new requests)."
fi

if [ "$woken" -eq 1 ]; then
echo "Woken up: an app was queued."
return
fi

# Also covers ntfy being unreachable, so the website is not hammered.
elapsed=$(( $(date +%s) - started ))
if [ "$elapsed" -lt "$wait" ]; then
sleep $(( wait - elapsed ))
fi
}

if [ -n "$ONLY_APP_ID" ]; then
reset_daily_counter

echo "Reporting online status apps to install"
curl -s -K "$curl_auth_config" "$SERVER/ping"

if ! check_daily_limit; then
exit 1
fi
Expand All @@ -814,9 +871,6 @@ fi
while true; do
reset_daily_counter

echo "Reporting online status apps to install"
curl -s -K "$curl_auth_config" "$SERVER/ping"

if ! check_daily_limit; then
sleep "$CIRCUIT_BREAKER_SLEEP"
continue
Expand All @@ -830,18 +884,24 @@ while true; do
fi

echo "Fetching apps to install"
queue_checked_at=$(date +%s)
nextDueSeconds=""
if ! fetch_queue_app "$SERVER/queue"; then
echo "Failed to fetch a queue assignment."
appId=""
claimToken=""
echo "Failed to fetch a queue assignment. Retrying in ${QUEUE_RETRY_WAIT}s."
[ "$RUN_ONCE" = "1" ] && exit 1
sleep "$QUEUE_RETRY_WAIT"
continue
fi

if [ "$appId" == "" ] ; then
echo "No app to process.."
else
process_app "$appId" "$claimToken"
echo "No app to process.."
[ "$RUN_ONCE" = "1" ] && exit 0
wait_for_work
continue
fi

process_app "$appId" "$claimToken"

if [ "$RUN_ONCE" = "1" ]; then
exit 0
fi
Expand Down
61 changes: 61 additions & 0 deletions lib/analyserWake.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Tells a waiting analyser that an app was queued, through an ntfy topic
// (https://ntfy.sh) that analyser/processQueue.sh subscribes to while idle.
// The analyser never polls the website, so without this a newly requested app
// waits until the analyser's next scheduled check. The message carries no
// data; it only ends the analyser's wait. Inert unless NTFY_TOPIC is set.
// ntfy.sh rate-limits anonymous publishing per IP address, which a shared
// hosting egress address can exhaust; NTFY_TOKEN (an ntfy account access
// token) publishes under the account's own limits instead.

const DEFAULT_SERVER = 'https://ntfy.sh';
const TIMEOUT_MS = 3000;
// ntfy topic names: letters, digits, dashes and underscores, at most 64.
const TOPIC_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;

function wakeUrl({
topic = process.env.NTFY_TOPIC,
server = process.env.NTFY_SERVER
} = {}) {
topic = (topic || '').trim();
if (!topic) return null;
if (!TOPIC_PATTERN.test(topic)) {
console.error('NTFY_TOPIC must be 1-64 letters, digits, dashes or underscores; analyser wake-ups are disabled.');
return null;
}
const base = ((server || '').trim() || DEFAULT_SERVER).replace(/\/+$/, '');
return `${base}/${topic}`;
}

/**
* Best effort: resolves to true if the notification was accepted, false
* otherwise. Never throws, so callers need not await it. A missed wake-up only
* delays the analysis until the analyser's next scheduled check.
*/
async function wakeAnalyser({
fetchImpl = fetch,
token = process.env.NTFY_TOKEN,
...options
} = {}) {
const url = wakeUrl(options);
if (!url) return false;

token = (token || '').trim();
try {
const response = await fetchImpl(url, {
method: 'POST',
body: 'wake',
headers: token ? { authorization: `Bearer ${token}` } : {},
signal: AbortSignal.timeout(TIMEOUT_MS)
});
if (!response.ok) {
console.error('Analyser wake-up rejected:', response.status);
return false;
}
return true;
} catch (err) {
console.error('Analyser wake-up failed:', err.message);
return false;
}
}

module.exports = { wakeAnalyser, wakeUrl };
7 changes: 7 additions & 0 deletions migrations/015_analyser_heartbeat.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- When the analyser last checked in. Kept in the database so a restarted or
-- woken web process still knows the analyser is online.

CREATE TABLE IF NOT EXISTS analyser_heartbeat (
id boolean PRIMARY KEY DEFAULT true CHECK (id),
last_seen timestamptz NOT NULL
);
46 changes: 46 additions & 0 deletions models/Apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,49 @@ const getSiteDataSignature = async () => {
};
}

// When the analyser next has work if nothing new is queued: the earliest moment
// an app becomes claimable by nextApp through the passage of time alone (a
// stale analysis, a retryable failure, an abandoned claim). Mirrors the
// candidate conditions in nextApp. Returns a Date, or null if no app will ever
// become due without being queued.
const nextDueAt = async () => {
const result = await pool.query(`
SELECT MIN(due) AS due
FROM (
SELECT CASE
WHEN status = 'processing'
THEN processing_started + ($3::int * INTERVAL '1 minute')
WHEN (status = 'analysed'
OR (status = 'failed' AND failure_retryable))
AND analysisversion IS NOT DISTINCT FROM $1
THEN analysed + ($2::int * INTERVAL '1 day')
END AS due
FROM apps
WHERE ${appIdSqlPredicate(4, 5)}
) candidates
`, [
currentAnalysisVersion,
staleAnalysisDays,
processingTimeoutMinutes,
APP_ID_PATTERN_SOURCE,
MAX_APP_ID_LENGTH
]);
return result.rows[0].due;
}

const recordAnalyserHeartbeat = async () => {
await pool.query(`
INSERT INTO analyser_heartbeat (id, last_seen) VALUES (true, NOW())
ON CONFLICT (id) DO UPDATE SET last_seen = EXCLUDED.last_seen
`);
}

// Milliseconds since the epoch, or 0 if the analyser has never checked in.
const lastAnalyserHeartbeat = async () => {
const result = await pool.query('SELECT last_seen FROM analyser_heartbeat');
return result.rows.length > 0 ? result.rows[0].last_seen.getTime() : 0;
}

const countAnalysed = async () => {
const result = await pool.query("SELECT COUNT(*) FROM apps WHERE status = 'analysed'");
return parseInt(result.rows[0].count, 10);
Expand All @@ -532,6 +575,9 @@ module.exports = {
countAnalysed,
addApp,
nextApp,
nextDueAt,
recordAnalyserHeartbeat,
lastAnalyserHeartbeat,
updateAnalysis,
getAllApps,
getSiteDataSignature,
Expand Down
2 changes: 2 additions & 0 deletions raspberry-pi-analyser.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ CIRCUIT_BREAKER_SLEEP=3600
DOWNLOAD_WATCHDOG_INTERVAL=5
```

The analyser does not poll the website. When the queue is empty it waits until the next app falls due (the website says when), until the website announces a newly queued app on an [ntfy](https://ntfy.sh) topic, or for `MAX_IDLE_WAIT` seconds (a day by default), whichever comes first. Set the same long random topic name as `NTFY_TOPIC` here and on the web server, for example from `openssl rand -hex 16`; without it, a newly requested app waits for the next of the other two. Requests to the website retry for up to a minute, so the analyser rides out the website waking up from sleep.

Set `NETWORK_INTERFACE` only if auto-detection picks the wrong interface. On a Pi this is often `eth0` or `wlan0`.

## Use RAM-Backed IPA Storage
Expand Down
Loading
Loading