From 77654b62239c2a05f70c181d99af853447736ec6 Mon Sep 17 00:00:00 2001 From: NarrativAI Agent Date: Mon, 28 Sep 2026 16:39:05 +0200 Subject: [PATCH 1/3] ops: add scheduled uptime and origin cert monitor The hosted API returned Cloudflare 525 for about four months after the Fly certificate failed to renew behind the proxy, and nothing alerted. Check the API, CORS preflight and origin cert expiry every 15 minutes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/monitor.yml | 86 +++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/monitor.yml diff --git a/.github/workflows/monitor.yml b/.github/workflows/monitor.yml new file mode 100644 index 0000000..0b4a214 --- /dev/null +++ b/.github/workflows/monitor.yml @@ -0,0 +1,86 @@ +name: Monitor + +# Uptime check for the hosted API behind openattribution.org/policycheck. +# A failed scheduled run emails whoever last changed the cron line. + +on: + schedule: + - cron: '*/15 * * * *' + workflow_dispatch: + pull_request: + paths: [ .github/workflows/monitor.yml ] + +permissions: + actions: write + contents: read + +env: + API_URL: https://policycheck.openattribution.org + FLY_ORIGIN: policycheck-d7wv0g.fly.dev + CERT_MIN_DAYS: 21 + +jobs: + api: + name: API + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Goes through Cloudflare, so an origin TLS failure shows up as 525. + # Retries cover Fly cold starts (min_machines_running = 0). + - name: POST /analyze + run: | + body=$(curl -sS --fail-with-body --retry 3 --retry-all-errors --retry-delay 10 --max-time 60 \ + -X POST "$API_URL/analyze" \ + -H 'Origin: https://openattribution.org' \ + -H 'Content-Type: application/json' \ + -d '{"urls":["https://openattribution.org"],"user_agent":"*"}') + status=$(jq -r '.results[0].status' <<<"$body") + if [ "$status" != success ]; then + echo "::error::Unexpected result status: $status" + echo "$body" + exit 1 + fi + + - name: CORS preflight + run: | + headers=$(curl -sS --fail --max-time 30 -D - -o /dev/null -X OPTIONS "$API_URL/analyze" \ + -H 'Origin: https://openattribution.org' \ + -H 'Access-Control-Request-Method: POST' \ + -H 'Access-Control-Request-Headers: content-type') + grep -qi '^access-control-allow-origin:' <<<"$headers" || { + echo "::error::No Access-Control-Allow-Origin on preflight" + echo "$headers" + exit 1 + } + + cert: + name: Origin certificate + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Cloudflare's edge cert hides the Fly one, so ask the origin directly. + # Fly renews at 30 days left; under 21 means renewal is failing. + - name: Check expiry + run: | + end=$(echo | openssl s_client -connect "$FLY_ORIGIN:443" -servername policycheck.openattribution.org 2>/dev/null \ + | openssl x509 -noout -enddate | cut -d= -f2) + [ -n "$end" ] || { echo "::error::Could not read origin certificate"; exit 1; } + days=$(( ($(date -d "$end" +%s) - $(date +%s)) / 86400 )) + echo "Origin certificate expires $end ($days days)" + if [ "$days" -lt "$CERT_MIN_DAYS" ]; then + echo "::error::Origin certificate expires in $days days; check fly certs show policycheck.openattribution.org" + exit 1 + fi + + keepalive: + name: Keepalive + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + # GitHub disables schedules in public repos after 60 days without + # activity; re-enabling via the API resets that clock. + - name: Re-enable workflow + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow enable monitor.yml --repo "$GITHUB_REPOSITORY" From e2d02caf93c546337b3a7f5a83be5014a9182c9e Mon Sep 17 00:00:00 2001 From: NarrativAI Agent Date: Mon, 28 Sep 2026 16:40:24 +0200 Subject: [PATCH 2/3] ops: log response on monitor failure Co-Authored-By: Claude Opus 5.5 --- .github/workflows/monitor.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/monitor.yml b/.github/workflows/monitor.yml index 0b4a214..a93b6db 100644 --- a/.github/workflows/monitor.yml +++ b/.github/workflows/monitor.yml @@ -29,15 +29,17 @@ jobs: # Retries cover Fly cold starts (min_machines_running = 0). - name: POST /analyze run: | - body=$(curl -sS --fail-with-body --retry 3 --retry-all-errors --retry-delay 10 --max-time 60 \ + code=$(curl -sS --retry 3 --retry-all-errors --retry-delay 10 --max-time 60 \ + -D headers.txt -o body.json -w '%{http_code}' \ -X POST "$API_URL/analyze" \ -H 'Origin: https://openattribution.org' \ -H 'Content-Type: application/json' \ -d '{"urls":["https://openattribution.org"],"user_agent":"*"}') - status=$(jq -r '.results[0].status' <<<"$body") - if [ "$status" != success ]; then - echo "::error::Unexpected result status: $status" - echo "$body" + status=$(jq -r '.results[0].status' body.json 2>/dev/null || true) + if [ "$code" != 200 ] || [ "$status" != success ]; then + echo "::error::HTTP $code, result status: ${status:-none}" + cat headers.txt + head -c 2000 body.json exit 1 fi From 8448bb6ec0898be0e5cefc46308ccd15fd02b5dd Mon Sep 17 00:00:00 2001 From: NarrativAI Agent Date: Mon, 28 Sep 2026 16:41:36 +0200 Subject: [PATCH 3/3] ops: monitor the Fly origin directly Cloudflare serves a managed challenge to GitHub runners, so the proxied hostname can't be checked from Actions. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/monitor.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/monitor.yml b/.github/workflows/monitor.yml index a93b6db..44d648f 100644 --- a/.github/workflows/monitor.yml +++ b/.github/workflows/monitor.yml @@ -14,9 +14,12 @@ permissions: actions: write contents: read +# Checks hit the Fly origin directly: Cloudflare serves a managed challenge +# to datacenter IPs, so runners can't get through the proxied hostname. +# The 525 failure mode (expired origin cert) is caught by the cert job. env: - API_URL: https://policycheck.openattribution.org FLY_ORIGIN: policycheck-d7wv0g.fly.dev + PUBLIC_HOST: policycheck.openattribution.org CERT_MIN_DAYS: 21 jobs: @@ -25,13 +28,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - # Goes through Cloudflare, so an origin TLS failure shows up as 525. # Retries cover Fly cold starts (min_machines_running = 0). - name: POST /analyze run: | code=$(curl -sS --retry 3 --retry-all-errors --retry-delay 10 --max-time 60 \ -D headers.txt -o body.json -w '%{http_code}' \ - -X POST "$API_URL/analyze" \ + -X POST "https://$FLY_ORIGIN/analyze" \ -H 'Origin: https://openattribution.org' \ -H 'Content-Type: application/json' \ -d '{"urls":["https://openattribution.org"],"user_agent":"*"}') @@ -45,7 +47,7 @@ jobs: - name: CORS preflight run: | - headers=$(curl -sS --fail --max-time 30 -D - -o /dev/null -X OPTIONS "$API_URL/analyze" \ + headers=$(curl -sS --fail --max-time 30 -D - -o /dev/null -X OPTIONS "https://$FLY_ORIGIN/analyze" \ -H 'Origin: https://openattribution.org' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: content-type') @@ -64,13 +66,13 @@ jobs: # Fly renews at 30 days left; under 21 means renewal is failing. - name: Check expiry run: | - end=$(echo | openssl s_client -connect "$FLY_ORIGIN:443" -servername policycheck.openattribution.org 2>/dev/null \ + end=$(echo | openssl s_client -connect "$FLY_ORIGIN:443" -servername "$PUBLIC_HOST" 2>/dev/null \ | openssl x509 -noout -enddate | cut -d= -f2) [ -n "$end" ] || { echo "::error::Could not read origin certificate"; exit 1; } days=$(( ($(date -d "$end" +%s) - $(date +%s)) / 86400 )) echo "Origin certificate expires $end ($days days)" if [ "$days" -lt "$CERT_MIN_DAYS" ]; then - echo "::error::Origin certificate expires in $days days; check fly certs show policycheck.openattribution.org" + echo "::error::Origin certificate expires in $days days; check fly certs show $PUBLIC_HOST" exit 1 fi