diff --git a/docs/BUILD_DEPLOY.md b/docs/BUILD_DEPLOY.md index 65cc903..e236407 100644 --- a/docs/BUILD_DEPLOY.md +++ b/docs/BUILD_DEPLOY.md @@ -79,3 +79,19 @@ Direct debug ports: crashes `:8082`, builder `:8083`. - Mount builder PHP with Docker socket (build host only). - FE nginx: read-only OTA under `/v0/ota/`; BE RWX on artifact mount. - Keep `session_cookie_path` identical across all PHP apps behind the hub. + +### Alpine BE (`config.php` paths) + +| Key | Example on artc0 | +|-----|------------------| +| `build.repo_root` | `/var/www/.../androidcast_project/android_cast` (git tree) | +| `build.artifacts_root` | `/var/www/.../androidcast_project/out/builds` — **must exist**, writable by PHP-FPM (`nginx`) | +| `build.runner_script` | full path to `scripts/docker-build-runner.sh` (no `...` placeholders) | + +```sh +mkdir -p .../out/builds .../ota-artifacts +chown -R nginx:nginx .../out +chmod -R 775 .../out +``` + +If **Start build** shows *Network error*, check BE `php-fpm81` log: often `mkdir(): Permission denied` on `out/builds` (HTTP 500, not a browser network fault). diff --git a/docs/INFRA.md b/docs/INFRA.md index eed3992..080dad6 100644 --- a/docs/INFRA.md +++ b/docs/INFRA.md @@ -180,7 +180,7 @@ Mounted copy: `tmp/FE_gentoo/etc/nginx/nginx.conf`. | `server_name` | `location /` | androidcast crashes | Hub `/app/androidcast_project/` | |---------------|--------------|---------------------|--------------------------------| -| **`apps.f0xx.org`** | → `http://artc0:80` | May → `https://artc0:443/.../crashes/` | Via **`location /`** → BE :80 (no extra block needed) | +| **`apps.f0xx.org`** | → `http://artc0:80` | Must **not** → `https://artc0:443/...` (static download) | Hub/crashes blocks must use **`http://artc0:80`** — see `nginx.fe-apps.f0xx.org.snippet` | | **`artc0.f0xx.org`** | → `http://artc0:80` | Legacy → **:8089** | Legacy → **:8089** — **remove** hub block; prefer apps.f0xx.org | **Agent default:** fix **BE `listen 80`** first. Change FE only when user asks or to delete wrong **8089** hub overrides on `artc0.f0xx.org`. diff --git a/docs/README.md b/docs/README.md index a304a86..b7552dd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,7 +37,8 @@ Click a link to open a guide. Section links jump within the document (GitHub, Cu | [OPEN_API.md](OPEN_API.md) | Crash ingest API design, triage tags, heuristics. | | [OTA.md](OTA.md) | OTA channel layout v0, manifests, MQTT, publish flow. | | [PRO_AAR.md](PRO_AAR.md) | cast-core / cast-pro AAR split and Play Billing. | -| [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md) | | +| [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md) | WireGuard v1 impl + deploy | +| [REMOTE_ACCESS_VALIDATION.md](REMOTE_ACCESS_VALIDATION.md) | Linux CLI suite + mobile parity | | [ROADMAP.md](ROADMAP.md) | Implementation roadmap, alpha definition, deferred work. | | [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) | Tickets console, Gitea, attachments, agent queue. | | [USB_HDMI_CAST.md](USB_HDMI_CAST.md) | Roadmap D/E: HDMI awareness, USB-tether transport. | diff --git a/docs/REMOTE_ACCESS_IMPL.md b/docs/REMOTE_ACCESS_IMPL.md index 59b328f..41ae988 100644 --- a/docs/REMOTE_ACCESS_IMPL.md +++ b/docs/REMOTE_ACCESS_IMPL.md @@ -105,18 +105,23 @@ sudo -u www-data wg show wg0 ### 4. Cron (session cleanup) ```cron -0 * * * * cd /var/www/.../backend && php scripts/purge_remote_access.php --hours=24 +0 * * * * cd /var/www/.../backend && php81 scripts/purge_remote_access.php --hours=24 ``` +Use `php81` when Alpine default `php` is 8.5+ without the session module; FPM is `php-fpm81`. + ### 5. Verify ```bash cd examples/crash_reporter/backend -chmod +x scripts/verify_remote_access_prod.sh scripts/test_remote_access_api.sh +chmod +x scripts/ra_*.sh scripts/verify_remote_access_prod.sh scripts/test_remote_access_api.sh ./scripts/verify_remote_access_prod.sh BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_remote_access_api.sh +BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/ra_e2e_cli.sh ``` +**Linux CLI device simulator** (same heartbeat payloads as the app): [REMOTE_ACCESS_VALIDATION.md](REMOTE_ACCESS_VALIDATION.md). + ### 6. Browser Hard-refresh after deploying `public/assets/js/remote_access.js`. @@ -147,4 +152,7 @@ Without the submodule, `WireGuardTunnelBridge` uses `RemoteAccessVpnService` (TU bash examples/crash_reporter/backend/scripts/test_rbac_api.sh bash examples/crash_reporter/backend/scripts/test_remote_access_api.sh BASE=https://apps.f0xx.org/app/androidcast_project/crashes bash examples/crash_reporter/backend/scripts/test_remote_access_api.sh +BASE=https://apps.f0xx.org/app/androidcast_project/crashes bash examples/crash_reporter/backend/scripts/ra_e2e_cli.sh ``` + +CLI validation guide: [REMOTE_ACCESS_VALIDATION.md](REMOTE_ACCESS_VALIDATION.md). diff --git a/docs/REMOTE_ACCESS_VALIDATION.md b/docs/REMOTE_ACCESS_VALIDATION.md new file mode 100644 index 0000000..f00d10f --- /dev/null +++ b/docs/REMOTE_ACCESS_VALIDATION.md @@ -0,0 +1,160 @@ +# Remote access — validation (Linux CLI + mobile parity) + +Companion to [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md). Use this when verifying WireGuard v1 **before** or **after** deploying the BE, and when debugging without an Android device. + +**Documentation index:** [README.md](README.md) + +## Table of contents + + +- [What the CLI suite simulates](#what-the-cli-suite-simulates) +- [Scripts](#scripts) +- [Quick start (local orchestration)](#quick-start-local-orchestration) +- [Production / staging BE](#production--staging-be) +- [Optional: bring up WireGuard on Linux](#optional-bring-up-wireguard-on-linux) +- [Mobile vs CLI checklist](#mobile-vs-cli-checklist) +- [Troubleshooting](#troubleshooting) + + +--- + +## What the CLI suite simulates + +The Android app (`RemoteAccessService`) does the following over HTTP only (no RSSH in v1): + +| App behaviour | CLI equivalent | +|---------------|----------------| +| Persist stable `device_id` | `RA_DEVICE_ID` or `--device-id` | +| Persist stable `random` in prefs | file `RA_RANDOM_FILE` (default `/tmp/androidcast-ra-.random`) | +| Mode **Disabled** → `status:disable` | `ra_device_sim.sh disable` | +| Mode **WireGuard** → jittered `status:enable` poll | `ra_device_sim.sh poll --until connect` (fixed interval via `RA_POLL_INTERVAL_S`, default 3s for tests) | +| Capabilities + `tunnel_mode: wireguard` | same JSON in `ra_lib.sh` / `ra_ra_post` | +| Apply WG config on `connect` | `ra_wg_quick_from_connect` + optional `--apply-wg` (`wg-quick up`) | +| Operator whitelist + open session | `ra_e2e_cli.sh` or manual `remote_access.php` curls | + +The **control plane** is validated by HTTP alone. **Data plane** (UDP to `wg_endpoint`) requires DNAT, `wg0` on the BE, and optionally `wg-quick` on the machine running the device sim. + +--- + +## Scripts + +All under `examples/crash_reporter/backend/scripts/`: + +| Script | Role | +|--------|------| +| `ra_lib.sh` | Shared heartbeat + admin helpers (sourced, not run alone) | +| `ra_device_sim.sh` | Device-only: disable / enable / poll / optional WG | +| `ra_e2e_cli.sh` | Full flow: disable → wait → whitelist → open → connect → close → disable | +| `test_remote_access_api.sh` | Minimal API smoke (grep, no jq) | +| `verify_remote_access_prod.sh` | BE preflight: config, wg tools, DB tables, optional smoke | + +```bash +cd examples/crash_reporter/backend +chmod +x scripts/ra_*.sh scripts/test_remote_access_api.sh scripts/verify_remote_access_prod.sh +``` + +**Dependencies:** `curl`, `jq` (device sim and E2E), `openssl` (random). Optional: `wireguard-tools` for `--apply-wg`. + +--- + +## Quick start (local orchestration) + +With the crashes stack listening on the default orchestration URL: + +```bash +export CRASHES_BASE=http://127.0.0.1:8080/app/androidcast_project/crashes +export ADMIN_USER=admin ADMIN_PASS=admin + +./scripts/verify_remote_access_prod.sh +./scripts/test_remote_access_api.sh +./scripts/ra_e2e_cli.sh +``` + +Single device poll after you have opened a session in the UI: + +```bash +export RA_DEVICE_ID=my-lab-phone +./scripts/ra_device_sim.sh poll --until connect +``` + +--- + +## Production / staging BE + +```bash +ssh alpine-be # or your BE host +cd /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend + +export CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes +export ADMIN_USER=admin ADMIN_PASS='…' + +./scripts/verify_remote_access_prod.sh +BASE="$CRASHES_BASE" ./scripts/test_remote_access_api.sh +BASE="$CRASHES_BASE" ./scripts/ra_e2e_cli.sh +``` + +Or from your workstation (same cookies/session as browser login): + +```bash +export CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes +./scripts/ra_e2e_cli.sh --device-id ra-cli-$(hostname -s) +``` + +**Full E2E from verify script:** + +```bash +RA_E2E=1 BASE="$CRASHES_BASE" ./scripts/verify_remote_access_prod.sh +``` + +--- + +## Optional: bring up WireGuard on Linux + +After `poll --until connect` returns credentials: + +```bash +./scripts/ra_device_sim.sh poll --until connect --apply-wg +# writes /tmp/ra-.conf and runs: sudo wg-quick up … +``` + +Manual: + +```bash +RESP="$(curl -sf …)" # connect response +source scripts/ra_lib.sh +ra_wg_quick_from_connect "$RESP" | sudo tee /tmp/ra-test.conf +sudo wg-quick up /tmp/ra-test.conf +ping -c1 10.66.66.1 # BE wg0 address if routed +sudo wg-quick down /tmp/ra-test.conf +``` + +This step fails if UDP `wg_endpoint` is not reachable (FE DNAT, firewall). HTTP E2E can still pass. + +--- + +## Mobile vs CLI checklist + +| Step | Android | Pass criteria | +|------|---------|---------------| +| 1 | Developer settings → Disabled | `action: disabled` | +| 2 | WireGuard mode on, not whitelisted | `action: wait` | +| 3 | Dashboard whitelist device | API `ok: true` | +| 4 | Operator open session | `action: wait` then `connect` on next poll | +| 5 | VPN comes up | `wg show` / ping hub via tunnel | +| 6 | Close session / disable app | tunnel down; peer removed if `provision_peers` | + +CLI E2E covers steps 1–4 and 6 over HTTP; step 5 needs `--apply-wg` or a real device. + +--- + +## Troubleshooting + +| Symptom | Likely cause | +|---------|----------------| +| `action: deny` + `wg_tools_required` | `require_wg_tools` true but `wg genkey` fails for PHP-FPM user — fix permissions / install `wireguard-tools` | +| `connect` but no handshake | `wg_server_public_key` mismatch, UDP not DNAT’d, or wrong `wg_endpoint` | +| `wait` after open session | Poll rate limit (`min_poll_interval_s`); wait and retry | +| `jq required` | Install `jq` on the host running device sim | +| API smoke fails on verify | Set `BASE` / `CRASHES_BASE` to live crashes URL; ensure login user has `remote_access_admin` | + +See also [REMOTE_ACCESS_IMPL.md § Production deploy](REMOTE_ACCESS_IMPL.md#production-deploy-no-new-nginx-http-paths). diff --git a/examples/app_hub/index.php b/examples/app_hub/index.php index 0409a53..dd639e9 100644 --- a/examples/app_hub/index.php +++ b/examples/app_hub/index.php @@ -1,5 +1,10 @@ diff --git a/examples/build_console/backend/public/assets/js/builder.js b/examples/build_console/backend/public/assets/js/builder.js index 81102f9..3f13c62 100644 --- a/examples/build_console/backend/public/assets/js/builder.js +++ b/examples/build_console/backend/public/assets/js/builder.js @@ -31,14 +31,30 @@ fetch(base + '/api/build_trigger.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify(payload) - }).then(function (r) { return r.json(); }).then(function (j) { + }).then(function (r) { + return r.text().then(function (text) { + var j = null; + try { + j = JSON.parse(text); + } catch (e) { + throw new Error(r.status + ': ' + (text.slice(0, 120) || r.statusText)); + } + if (!r.ok) { + throw new Error((j && j.error) || ('HTTP ' + r.status)); + } + return j; + }); + }).then(function (j) { if (j.ok && j.build && j.build.id) { window.location.href = base + '/?view=build&id=' + j.build.id; } else { status.textContent = j.error || 'Failed'; } - }).catch(function () { status.textContent = 'Network error'; }); + }).catch(function (err) { + status.textContent = err && err.message ? err.message : 'Network error'; + }); }); } diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md index 4ec921d..bb01676 100644 --- a/examples/crash_reporter/backend/README.md +++ b/examples/crash_reporter/backend/README.md @@ -26,6 +26,8 @@ php -m | grep -E 'session|pdo_sqlite|pdo_mysql|PDO' If `session_name()` is undefined, `php81-session` is missing or FPM was not restarted after install. +**CLI vs FPM:** `php` on the shell may be a different major version than `php-fpm81` (e.g. `php82` CLI + `php81-fpm`). Install `phpNN-session` for **both** SAPIs you use, or run cron with the matching binary: `php81 scripts/purge_remote_access.php`. Maintenance scripts skip sessions on CLI when the extension is absent (web/FPM still require it). + ## Quick start (SQLite, development) ```bash @@ -289,17 +291,26 @@ mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql **Production `config.php`** (not in git) — set `remote_access.wg_server_public_key` from `wg show wg0 public-key`, `require_wg_tools => true`, and `wg_endpoint` (public UDP DNAT hostname). See `config.example.php`. -**Cron:** `php scripts/purge_remote_access.php --hours=24` (hourly). +**Cron:** hourly purge — on Alpine use **php81** if default `php` is 8.x without session (see § Requirements): + +```cron +0 * * * * cd /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend && php81 scripts/purge_remote_access.php --hours=24 +``` Smoke / verify: ```bash +chmod +x scripts/ra_*.sh scripts/test_remote_access_api.sh scripts/verify_remote_access_prod.sh ./scripts/test_rbac_api.sh ./scripts/test_remote_access_api.sh BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_remote_access_api.sh ./scripts/verify_remote_access_prod.sh +BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/ra_e2e_cli.sh # device + operator E2E (jq) +./scripts/ra_device_sim.sh poll --until connect # after open session in UI ``` +**CLI validation (Linux device simulator):** [docs/REMOTE_ACCESS_VALIDATION.md](../../../docs/REMOTE_ACCESS_VALIDATION.md). + Android: Developer settings → **Remote access** → WireGuard (RSSH reserved). Requires VPN permission; polls BE every 1–7 min while enabled. ## Default accounts diff --git a/examples/crash_reporter/backend/nginx.fe-apps.f0xx.org.snippet b/examples/crash_reporter/backend/nginx.fe-apps.f0xx.org.snippet index 4103fa7..5c5551b 100644 --- a/examples/crash_reporter/backend/nginx.fe-apps.f0xx.org.snippet +++ b/examples/crash_reporter/backend/nginx.fe-apps.f0xx.org.snippet @@ -1,5 +1,9 @@ # FE apps.f0xx.org — replace hub/crashes upstream blocks (use BE port 80, preserve URI). # File: tmp/FE_gentoo/etc/nginx/nginx.conf (inside server { listen 443; server_name apps.f0xx.org; }) +# +# WRONG (causes browser to download index.php as application/octet-stream): +# proxy_pass https://artc0.intra.raptor.org/app/androidcast_project/; +# BE :443 serves static files; hub must hit BE :80 (PHP-FPM) like location / already does. location /app/androidcast_project/ { proxy_pass http://artc0.intra.raptor.org:80; diff --git a/examples/crash_reporter/backend/public/api/remote_access.php b/examples/crash_reporter/backend/public/api/remote_access.php index 9d8bb4a..3248029 100644 --- a/examples/crash_reporter/backend/public/api/remote_access.php +++ b/examples/crash_reporter/backend/public/api/remote_access.php @@ -7,12 +7,6 @@ header('Content-Type: application/json; charset=utf-8'); Auth::check(); -function json_out(array $payload, int $code = 200): void { - http_response_code($code); - echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - exit; -} - if (!Rbac::can('remote_access_view')) { json_out(['ok' => false, 'error' => 'forbidden'], 403); } diff --git a/examples/crash_reporter/backend/scripts/ra_device_sim.sh b/examples/crash_reporter/backend/scripts/ra_device_sim.sh new file mode 100755 index 0000000..d279d43 --- /dev/null +++ b/examples/crash_reporter/backend/scripts/ra_device_sim.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Simulate Android device remote-access heartbeats (type: ra) from Linux CLI. +# +# Usage: +# ./scripts/ra_device_sim.sh disable +# ./scripts/ra_device_sim.sh enable --once +# ./scripts/ra_device_sim.sh poll --until connect +# CRASHES_BASE=https://apps.f0xx.org/.../crashes ./scripts/ra_device_sim.sh poll --until connect --apply-wg +# +# Env: CRASHES_BASE, RA_DEVICE_ID, RA_RANDOM_FILE, RA_APP_VERSION +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=ra_lib.sh +source "$ROOT/scripts/ra_lib.sh" + +DEVICE_ID="${RA_DEVICE_ID:-ra-cli-$(hostname -s | tr ' ' '-')}" +RANDOM_FILE="${RA_RANDOM_FILE:-/tmp/androidcast-ra-${DEVICE_ID}.random}" +APP_VERSION="${RA_APP_VERSION:-cli-sim/1.0}" +APPLY_WG=0 +POLL_UNTIL="" +ONCE=0 +CMD="${1:-help}" +shift || true + +while [[ $# -gt 0 ]]; do + case "$1" in + --device-id) DEVICE_ID="$2"; shift 2 ;; + --random-file) RANDOM_FILE="$2"; shift 2 ;; + --app-version) APP_VERSION="$2"; shift 2 ;; + --apply-wg) APPLY_WG=1; shift ;; + --once) ONCE=1; shift ;; + --until) POLL_UNTIL="$2"; shift 2 ;; + --base) export CRASHES_BASE="$2"; shift 2 ;; + -h|--help) CMD=help; shift ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac +done + +load_random() { + if [[ -f "$RANDOM_FILE" ]]; then + cat "$RANDOM_FILE" + else + openssl rand -hex 16 2>/dev/null || date +%s + fi +} + +save_random() { + local r="$1" + echo "$r" >"$RANDOM_FILE" +} + +usage() { + cat <<'EOF' +AndroidCast remote-access device simulator (heartbeat type: ra) + +Commands: + disable POST status:disable (like app "Disabled" mode) + enable POST status:enable once + poll Poll enable; use --until connect|wait|disabled|deny + show-random Print persisted device random (like AppPreferences) + +Options: + --base URL CRASHES_BASE (default local orchestration crashes URL) + --device-id ID Stable device_id (default ra-cli-) + --random-file F Persist random across polls (like app prefs) + --app-version V Sent as app_version on heartbeat + --apply-wg On connect: write wg-quick conf and run wg-quick up (needs root) + --once Single poll (default for enable/poll) + --until ACTION Stop polling when heartbeat.action matches (poll mode) + +Examples: + ./scripts/ra_device_sim.sh disable + ./scripts/ra_device_sim.sh enable --once + ./scripts/ra_device_sim.sh poll --until connect + CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes \ + ./scripts/ra_device_sim.sh poll --until connect --apply-wg +EOF +} + +cmd_disable() { + local rnd + rnd="$(load_random)" + save_random "$rnd" + echo "== ra disable device_id=$DEVICE_ID random=$rnd" + local resp action + resp="$(ra_ra_post disable "$DEVICE_ID" "$rnd" "$APP_VERSION")" + action="$(echo "$resp" | ra_json -r '.heartbeat.action // empty')" + echo "action=$action" + echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp" +} + +cmd_enable_once() { + local rnd + rnd="$(load_random)" + save_random "$rnd" + echo "== ra enable device_id=$DEVICE_ID random=$rnd" + local resp action + resp="$(ra_ra_post enable "$DEVICE_ID" "$rnd" "$APP_VERSION")" + action="$(echo "$resp" | ra_json -r '.heartbeat.action // empty')" + echo "action=$action" + echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp" +} + +cmd_poll() { + local until="${POLL_UNTIL:-connect}" + local rnd + rnd="$(load_random)" + save_random "$rnd" + echo "== ra poll until action=$until (device_id=$DEVICE_ID)" + while true; do + local resp action + resp="$(ra_ra_post enable "$DEVICE_ID" "$rnd" "$APP_VERSION")" + action="$(echo "$resp" | ra_json -r '.heartbeat.action // empty')" + echo "$(date -Is) action=$action" + if [[ "$action" == "$until" ]]; then + echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp" + if [[ "$action" == "connect" && "$APPLY_WG" -eq 1 ]]; then + apply_wg "$resp" + fi + return 0 + fi + if [[ "$ONCE" -eq 1 ]]; then + echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp" + return 0 + fi + sleep "${RA_POLL_INTERVAL_S:-3}" + done +} + +apply_wg() { + local resp="$1" + if ! command -v wg-quick >/dev/null 2>&1; then + echo "WARN: wg-quick not installed; skipping tunnel bring-up" >&2 + ra_wg_quick_from_connect "$resp" >"/tmp/ra-${DEVICE_ID}.conf" + echo "Wrote /tmp/ra-${DEVICE_ID}.conf" + return 0 + fi + local conf="/tmp/ra-${DEVICE_ID}.conf" + ra_wg_quick_from_connect "$resp" >"$conf" + echo "== wg-quick up $conf (sudo)" + sudo wg-quick up "$conf" || { + echo "FAIL: wg-quick up (run as root or check endpoint UDP)" >&2 + return 1 + } + wg show 2>/dev/null | head -20 || true +} + +case "$CMD" in + disable) cmd_disable ;; + enable) ONCE=1; cmd_enable_once ;; + poll) cmd_poll ;; + show-random) load_random; echo ;; + help|-h|--help) usage ;; + *) + echo "Unknown command: $CMD" >&2 + usage + exit 2 + ;; +esac diff --git a/examples/crash_reporter/backend/scripts/ra_e2e_cli.sh b/examples/crash_reporter/backend/scripts/ra_e2e_cli.sh new file mode 100755 index 0000000..57cf61b --- /dev/null +++ b/examples/crash_reporter/backend/scripts/ra_e2e_cli.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# End-to-end remote-access validation: operator + device (CLI simulates Android). +# +# Flow (matches app + dashboard): +# 1. device disable (clean slate) +# 2. device enable → wait (not whitelisted) +# 3. operator whitelist + open session +# 4. device poll → connect +# 5. operator close session +# 6. device disable +# +# Usage: +# ./scripts/ra_e2e_cli.sh +# CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/ra_e2e_cli.sh +# ./scripts/ra_e2e_cli.sh --apply-wg +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=ra_lib.sh +source "$ROOT/scripts/ra_lib.sh" + +APPLY_WG=0 +DEVICE_ID="ra-e2e-$(date +%s)" +while [[ $# -gt 0 ]]; do + case "$1" in + --apply-wg) APPLY_WG=1; shift ;; + --base) export CRASHES_BASE="$2"; shift 2 ;; + --device-id) DEVICE_ID="$2"; shift 2 ;; + -h|--help) + echo "Usage: $0 [--base URL] [--device-id ID] [--apply-wg]" + exit 0 + ;; + *) echo "Unknown: $1" >&2; exit 2 ;; + esac +done + +export RA_DEVICE_ID="$DEVICE_ID" +export RA_RANDOM_FILE="/tmp/androidcast-ra-${DEVICE_ID}.random" +COOKIE_JAR="$(mktemp)" +trap 'rm -f "$COOKIE_JAR"' EXIT + +echo "== Remote access E2E CLI ==" +echo "BASE=$(ra_base)" +echo "DEVICE_ID=$DEVICE_ID" +echo + +echo "== 1. device disable ==" +bash "$ROOT/scripts/ra_device_sim.sh" disable --device-id "$DEVICE_ID" --base "$(ra_base)" +echo + +echo "== 2. device enable (expect wait) ==" +bash "$ROOT/scripts/ra_device_sim.sh" enable --once --device-id "$DEVICE_ID" --base "$(ra_base)" | tee /tmp/ra-e2e-enable.out +grep -q 'action=wait' /tmp/ra-e2e-enable.out || { + echo "FAIL: expected wait before whitelist" >&2 + exit 1 +} +echo OK +echo + +echo "== 3. operator login + whitelist ==" +ra_admin_login "$COOKIE_JAR" +ra_admin_post "$COOKIE_JAR" whitelist "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" | ra_json '.' +echo OK whitelist +echo + +echo "== 4. operator open session ==" +ra_admin_post "$COOKIE_JAR" open_session "{\"device_id\":\"$DEVICE_ID\"}" | ra_json '.' +echo OK open_session +echo + +echo "== 5. device poll until connect ==" +WG_ARGS=() +[[ "$APPLY_WG" -eq 1 ]] && WG_ARGS+=(--apply-wg) +bash "$ROOT/scripts/ra_device_sim.sh" poll --until connect --device-id "$DEVICE_ID" --base "$(ra_base)" "${WG_ARGS[@]}" +echo OK connect +echo + +echo "== 6. operator dashboard ==" +ra_admin_post "$COOKIE_JAR" dashboard '{}' 2>/dev/null || \ + curl -sf -b "$COOKIE_JAR" "$(ra_admin_url dashboard)" | ra_json '{ok, permissions}' +echo + +echo "== 7. operator close session ==" +SESSION_ID="$(curl -sf -b "$COOKIE_JAR" "$(ra_admin_url sessions)" | ra_json -r '.sessions[] | select(.device_id=="'"$DEVICE_ID"'") | .session_id' | head -1)" +if [[ -z "$SESSION_ID" ]]; then + SESSION_ID="$(curl -sf -b "$COOKIE_JAR" "$(ra_admin_url dashboard)" | ra_json -r '.active_sessions[0].session_id // empty')" +fi +if [[ -n "$SESSION_ID" ]]; then + ra_admin_post "$COOKIE_JAR" close_session "{\"session_id\":\"$SESSION_ID\",\"device_id\":\"$DEVICE_ID\"}" | ra_json '.' + echo OK close_session "$SESSION_ID" +else + echo "WARN: no session_id to close" +fi +echo + +echo "== 8. device disable ==" +bash "$ROOT/scripts/ra_device_sim.sh" disable --device-id "$DEVICE_ID" --base "$(ra_base)" +echo + +echo "== E2E CLI passed ==" diff --git a/examples/crash_reporter/backend/scripts/ra_lib.sh b/examples/crash_reporter/backend/scripts/ra_lib.sh new file mode 100755 index 0000000..4d7dafc --- /dev/null +++ b/examples/crash_reporter/backend/scripts/ra_lib.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Shared helpers for remote-access CLI validation (device + operator). +# Mirrors Android RemoteAccessService heartbeat payloads. +set -euo pipefail + +RA_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +ra_json() { + if command -v jq >/dev/null 2>&1; then + jq -r "$@" + else + echo "jq required for: $*" >&2 + return 1 + fi +} + +ra_base() { + echo "${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}" +} + +ra_heartbeat_url() { + echo "$(ra_base)/api/heartbeat.php" +} + +ra_admin_url() { + local action="${1:-}" + echo "$(ra_base)/api/remote_access.php?action=${action}" +} + +# POST heartbeat type:ra — prints full JSON response to stdout. +# Usage: ra_ra_post enable|disable DEVICE_ID RANDOM [app_version] +ra_ra_post() { + local status="$1" + local device_id="$2" + local random="${3:-}" + local app_version="${4:-cli-sim}" + local caps='["wireguard","files_app_home"]' + local body + body=$(printf '{"heartbeat":{"type":"ra","status":"%s","device_id":"%s","random":"%s","app_version":"%s","tunnel_mode":"wireguard","capabilities":%s}}' \ + "$status" "$device_id" "$random" "$app_version" "$caps") + local url body_tmp code + url="$(ra_heartbeat_url)" + body_tmp="$(mktemp)" + code=$(curl -sS -o "$body_tmp" -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$body" "$url") + if [[ "$code" != "200" ]]; then + echo "heartbeat HTTP $code from $url" >&2 + cat "$body_tmp" >&2 + rm -f "$body_tmp" + return 1 + fi + cat "$body_tmp" + rm -f "$body_tmp" +} + +ra_ra_action() { + ra_ra_post "$@" | ra_json -r '.heartbeat.action // empty' +} + +ra_admin_login() { + local jar="${1:?cookie jar}" + local user="${ADMIN_USER:-admin}" + local pass="${ADMIN_PASS:-admin}" + curl -sf -c "$jar" -b "$jar" -X POST \ + -d "username=${user}&password=${pass}" \ + "$(ra_base)/login" >/dev/null +} + +ra_admin_post() { + local jar="$1" + local action="$2" + local json_body="$3" + curl -sf -b "$jar" -c "$jar" -X POST -H 'Content-Type: application/json' \ + -d "$json_body" "$(ra_admin_url "$action")" +} + +# Build wireguard-quick config from connect JSON (same fields as WireGuardConfigBuilder.java). +ra_wg_quick_from_connect() { + local connect_json="$1" + if ! command -v jq >/dev/null 2>&1; then + return 1 + fi + local priv addr peer_pub endpoint allowed + priv=$(echo "$connect_json" | jq -r '.heartbeat.credentials.interface_private_key // empty') + addr=$(echo "$connect_json" | jq -r '.heartbeat.credentials.interface_address // "10.66.66.2/32"') + peer_pub=$(echo "$connect_json" | jq -r '.heartbeat.credentials.peer_public_key // empty') + endpoint=$(echo "$connect_json" | jq -r '.heartbeat.endpoint // .heartbeat.credentials.peer_endpoint // empty') + allowed=$(echo "$connect_json" | jq -r '.heartbeat.credentials.peer_allowed_ips // "0.0.0.0/0"') + if [[ -z "$priv" || -z "$peer_pub" ]]; then + return 1 + fi + cat </dev/null | grep -qi '^session$'; then + echo php + return + fi + if command -v php81 >/dev/null 2>&1 && php81 -m 2>/dev/null | grep -qi '^session$'; then + echo php81 + return + fi + echo php +} +PHP="$(php_bin)" + fail=0 warn=0 @@ -24,7 +42,7 @@ else ok "config.php present" fi -php -r ' +"$PHP" -r ' $config = require "'"$CFG"'"; $ra = $config["remote_access"] ?? []; $pub = trim((string)($ra["wg_server_public_key"] ?? "")); @@ -56,14 +74,14 @@ else fi # PHP can shell_exec wg (same user as FPM) -if php -r 'echo trim((string)@shell_exec("wg genkey 2>/dev/null"));' | grep -qE '.{40,}'; then +if "$PHP" -r 'echo trim((string)@shell_exec("wg genkey 2>/dev/null"));' | grep -qE '.{40,}'; then ok "php can run wg genkey (FPM user must match for connect)" else note "php CLI cannot run wg genkey — ensure www-data can run wg if provision_peers=true" fi # DB tables (via bootstrap) -php -r ' +"$PHP" -r ' require "'"$ROOT"'/src/bootstrap.php"; RemoteAccessRepository::ensureSchema(); $pdo = Database::pdo(); @@ -74,12 +92,24 @@ echo "OK MariaDB/SQLite remote_access tables present\n"; ' || fail=1 # API smoke (local php -S or deployed BASE) -BASE="${BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}" +BASE="${BASE:-${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}}" if [[ -x "$ROOT/scripts/test_remote_access_api.sh" ]]; then - if BASE="$BASE" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then + if CRASHES_BASE="$BASE" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then ok "API smoke test ($BASE)" else - note "API smoke test failed against $BASE (set BASE=…/crashes if needed)" + note "API smoke test failed against $BASE (set BASE or CRASHES_BASE=…/crashes)" + fi +fi + +if [[ "${RA_E2E:-}" == "1" ]] && [[ -x "$ROOT/scripts/ra_e2e_cli.sh" ]]; then + if command -v jq >/dev/null 2>&1; then + if CRASHES_BASE="$BASE" bash "$ROOT/scripts/ra_e2e_cli.sh" >/dev/null 2>&1; then + ok "RA E2E CLI ($BASE)" + else + note "RA E2E CLI failed against $BASE (needs jq, admin login, remote_access RBAC)" + fi + else + note "RA E2E skipped (install jq)" fi fi diff --git a/examples/crash_reporter/backend/sql/migrations/007_remote_access.sql b/examples/crash_reporter/backend/sql/migrations/007_remote_access.sql index 782722e..0e9113b 100644 --- a/examples/crash_reporter/backend/sql/migrations/007_remote_access.sql +++ b/examples/crash_reporter/backend/sql/migrations/007_remote_access.sql @@ -1,4 +1,4 @@ --- Remote access control plane (WireGuard v1; reverse SSH reserved). +-- Remote access control plane (WireGuard v1, reverse SSH reserved). -- MariaDB / production: mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql CREATE TABLE IF NOT EXISTS remote_access_devices ( diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php index daa69e2..c3369fd 100644 --- a/examples/crash_reporter/backend/src/bootstrap.php +++ b/examples/crash_reporter/backend/src/bootstrap.php @@ -15,22 +15,32 @@ declare(strict_types=1); require_once dirname(__DIR__, 3) . '/platform/shared_session.php'; require_once dirname(__DIR__, 3) . '/platform/footer.php'; -if (!function_exists('session_start')) { - http_response_code(500); - header('Content-Type: text/plain; charset=utf-8'); - echo "PHP session extension is not loaded.\n" - . "Alpine: apk add php81-session (or php82-session) && rc-service php-fpm81 restart\n" - . "Debian: apt install php-session\n"; - exit(1); -} - $configPath = __DIR__ . '/../config/config.php'; if (!is_file($configPath)) { $configPath = __DIR__ . '/../config/config.example.php'; } $config = require $configPath; -platform_start_session($config['session_name'] ?? 'ac_crash_sess', $config['session_cookie_path'] ?? '/app/androidcast_project'); +$sessionName = $config['session_name'] ?? 'ac_crash_sess'; +$sessionPath = $config['session_cookie_path'] ?? '/app/androidcast_project'; + +if (!function_exists('session_start')) { + if (PHP_SAPI !== 'cli') { + http_response_code(500); + header('Content-Type: text/plain; charset=utf-8'); + $phpV = PHP_VERSION; + echo "PHP session extension is not loaded (SAPI=" . PHP_SAPI . ", PHP {$phpV}).\n" + . "CLI check: php -m | grep -i session\n" + . " php -v # match package major (php81-* vs php82-*)\n" + . "Alpine: apk add php81-session php81-pdo # or php82-session for PHP 8.2\n" + . " rc-service php-fpm81 restart\n" + . "Debian: apt install php-session\n"; + exit(1); + } + // CLI cron (purge_remote_access.php, verify): PDO only; no web login cookie. +} elseif (PHP_SAPI !== 'cli') { + platform_start_session($sessionName, $sessionPath); +} require_once __DIR__ . '/Database.php'; require_once __DIR__ . '/Rbac.php'; diff --git a/examples/platform/README.md b/examples/platform/README.md index da0cd26..8ee897d 100644 --- a/examples/platform/README.md +++ b/examples/platform/README.md @@ -30,7 +30,8 @@ When `left` is null, the left column is built from `copyright_symbol`, `holder`, | `copyright_symbol` | `©` (U+00A9) or empty | | `show_year` | Append year / range on default left | | `year` | Fixed end year, or `null` for current | -| `year_start` | If set (e.g. `2026`), shows `2026–{current}` | +| `year_start` | Start year (integer). With `year_through_current` → `2026 - current`; else range `2026–{end}` | +| `year_through_current` | If true with `year_start`, year label is `{start} - current` (not a rolling end year) | | `use_i18n` | `data-i18n` on auto-built left column | | `footer_class` | Extra CSS class (always adds `platform-footer`) | diff --git a/examples/platform/footer.config.example.php b/examples/platform/footer.config.example.php index 166c333..edae337 100644 --- a/examples/platform/footer.config.example.php +++ b/examples/platform/footer.config.example.php @@ -25,9 +25,10 @@ return [ 'show_year' => true, // null = current calendar year at render time (end of range). 'year' => null, - // Set e.g. 2026 for "2026–{current}" (en dash). Omit for single year only. - 'year_start' => "2026 - current", - # 'year_start' => null, + // Integer start year. With year_through_current: "2026 - current" (literal suffix). + // Without it: "2026–2027" range (en dash) or lone "2026" when start equals end year. + 'year_start' => 2026, + 'year_through_current' => true, // CSS class on