1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 02:18:40 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-04 17:27:19 +02:00
parent a29d84038e
commit 7b1311c78e
20 changed files with 665 additions and 35 deletions

View File

@@ -79,3 +79,19 @@ Direct debug ports: crashes `:8082`, builder `:8083`.
- Mount builder PHP with Docker socket (build host only). - Mount builder PHP with Docker socket (build host only).
- FE nginx: read-only OTA under `/v0/ota/`; BE RWX on artifact mount. - 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. - 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).

View File

@@ -180,7 +180,7 @@ Mounted copy: `tmp/FE_gentoo/etc/nginx/nginx.conf`.
| `server_name` | `location /` | androidcast crashes | Hub `/app/androidcast_project/` | | `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 | | **`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`. **Agent default:** fix **BE `listen 80`** first. Change FE only when user asks or to delete wrong **8089** hub overrides on `artc0.f0xx.org`.

View File

@@ -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. | | [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. | | [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. | | [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. | | [ROADMAP.md](ROADMAP.md) | Implementation roadmap, alpha definition, deferred work. |
| [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) | Tickets console, Gitea, attachments, agent queue. | | [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. | | [USB_HDMI_CAST.md](USB_HDMI_CAST.md) | Roadmap D/E: HDMI awareness, USB-tether transport. |

View File

@@ -105,18 +105,23 @@ sudo -u www-data wg show wg0
### 4. Cron (session cleanup) ### 4. Cron (session cleanup)
```cron ```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 ### 5. Verify
```bash ```bash
cd examples/crash_reporter/backend 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 ./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/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 ### 6. Browser
Hard-refresh after deploying `public/assets/js/remote_access.js`. 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_rbac_api.sh
bash examples/crash_reporter/backend/scripts/test_remote_access_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/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).

View File

@@ -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
<!-- toc -->
- [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)
<!-- /toc -->
---
## 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-<id>.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-<device_id>.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 14 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 DNATd, 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).

View File

@@ -1,5 +1,10 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
require_once dirname(__DIR__) . '/platform/footer.php'; $__platformDir = __DIR__ . '/android_cast/examples/platform';
if (!is_file($__platformDir . '/footer.php')) {
$__platformDir = dirname(__DIR__) . '/platform';
}
require_once $__platformDir . '/footer.php';
unset($__platformDir);
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark" data-clock-font="mixed"> <html lang="en" data-theme="dark" data-clock-font="mixed">

View File

@@ -31,14 +31,30 @@
fetch(base + '/api/build_trigger.php', { fetch(base + '/api/build_trigger.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload) 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) { if (j.ok && j.build && j.build.id) {
window.location.href = base + '/?view=build&id=' + j.build.id; window.location.href = base + '/?view=build&id=' + j.build.id;
} else { } else {
status.textContent = j.error || 'Failed'; status.textContent = j.error || 'Failed';
} }
}).catch(function () { status.textContent = 'Network error'; }); }).catch(function (err) {
status.textContent = err && err.message ? err.message : 'Network error';
});
}); });
} }

View File

@@ -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. 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) ## Quick start (SQLite, development)
```bash ```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`. **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: Smoke / verify:
```bash ```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_rbac_api.sh
./scripts/test_remote_access_api.sh ./scripts/test_remote_access_api.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/test_remote_access_api.sh
./scripts/verify_remote_access_prod.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 17 min while enabled. Android: Developer settings → **Remote access** → WireGuard (RSSH reserved). Requires VPN permission; polls BE every 17 min while enabled.
## Default accounts ## Default accounts

View File

@@ -1,5 +1,9 @@
# FE apps.f0xx.org — replace hub/crashes upstream blocks (use BE port 80, preserve URI). # 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; }) # 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/ { location /app/androidcast_project/ {
proxy_pass http://artc0.intra.raptor.org:80; proxy_pass http://artc0.intra.raptor.org:80;

View File

@@ -7,12 +7,6 @@ header('Content-Type: application/json; charset=utf-8');
Auth::check(); 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')) { if (!Rbac::can('remote_access_view')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403); json_out(['ok' => false, 'error' => 'forbidden'], 403);
} }

View File

@@ -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-<hostname>)
--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

View File

@@ -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 =="

View File

@@ -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 <<EOF
[Interface]
PrivateKey = ${priv}
Address = ${addr}
[Peer]
PublicKey = ${peer_pub}
EOF
if [[ -n "$endpoint" ]]; then
echo "Endpoint = ${endpoint}"
fi
echo "AllowedIPs = ${allowed}"
echo "PersistentKeepalive = 25"
}

View File

@@ -5,6 +5,24 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
# Alpine BE: default `php` may be 8.5 without session; php-fpm81 uses 8.1.
php_bin() {
if [[ -n "${PHP_BIN:-}" ]]; then
echo "$PHP_BIN"
return
fi
if php -m 2>/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 fail=0
warn=0 warn=0
@@ -24,7 +42,7 @@ else
ok "config.php present" ok "config.php present"
fi fi
php -r ' "$PHP" -r '
$config = require "'"$CFG"'"; $config = require "'"$CFG"'";
$ra = $config["remote_access"] ?? []; $ra = $config["remote_access"] ?? [];
$pub = trim((string)($ra["wg_server_public_key"] ?? "")); $pub = trim((string)($ra["wg_server_public_key"] ?? ""));
@@ -56,14 +74,14 @@ else
fi fi
# PHP can shell_exec wg (same user as FPM) # 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)" ok "php can run wg genkey (FPM user must match for connect)"
else else
note "php CLI cannot run wg genkey — ensure www-data can run wg if provision_peers=true" note "php CLI cannot run wg genkey — ensure www-data can run wg if provision_peers=true"
fi fi
# DB tables (via bootstrap) # DB tables (via bootstrap)
php -r ' "$PHP" -r '
require "'"$ROOT"'/src/bootstrap.php"; require "'"$ROOT"'/src/bootstrap.php";
RemoteAccessRepository::ensureSchema(); RemoteAccessRepository::ensureSchema();
$pdo = Database::pdo(); $pdo = Database::pdo();
@@ -74,12 +92,24 @@ echo "OK MariaDB/SQLite remote_access tables present\n";
' || fail=1 ' || fail=1
# API smoke (local php -S or deployed BASE) # 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 [[ -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)" ok "API smoke test ($BASE)"
else 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
fi fi

View File

@@ -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 -- MariaDB / production: mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql
CREATE TABLE IF NOT EXISTS remote_access_devices ( CREATE TABLE IF NOT EXISTS remote_access_devices (

View File

@@ -15,22 +15,32 @@ declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/platform/shared_session.php'; require_once dirname(__DIR__, 3) . '/platform/shared_session.php';
require_once dirname(__DIR__, 3) . '/platform/footer.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'; $configPath = __DIR__ . '/../config/config.php';
if (!is_file($configPath)) { if (!is_file($configPath)) {
$configPath = __DIR__ . '/../config/config.example.php'; $configPath = __DIR__ . '/../config/config.example.php';
} }
$config = require $configPath; $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__ . '/Database.php';
require_once __DIR__ . '/Rbac.php'; require_once __DIR__ . '/Rbac.php';

View File

@@ -30,7 +30,8 @@ When `left` is null, the left column is built from `copyright_symbol`, `holder`,
| `copyright_symbol` | `©` (U+00A9) or empty | | `copyright_symbol` | `©` (U+00A9) or empty |
| `show_year` | Append year / range on default left | | `show_year` | Append year / range on default left |
| `year` | Fixed end year, or `null` for current | | `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 | | `use_i18n` | `data-i18n` on auto-built left column |
| `footer_class` | Extra CSS class (always adds `platform-footer`) | | `footer_class` | Extra CSS class (always adds `platform-footer`) |

View File

@@ -25,9 +25,10 @@ return [
'show_year' => true, 'show_year' => true,
// null = current calendar year at render time (end of range). // null = current calendar year at render time (end of range).
'year' => null, 'year' => null,
// Set e.g. 2026 for "2026{current}" (en dash). Omit for single year only. // Integer start year. With year_through_current: "2026 - current" (literal suffix).
'year_start' => "2026 - current", // Without it: "20262027" range (en dash) or lone "2026" when start equals end year.
# 'year_start' => null, 'year_start' => 2026,
'year_through_current' => true,
// CSS class on <footer> (platform-footer layout classes are always added). // CSS class on <footer> (platform-footer layout classes are always added).
'footer_class' => 'bottom-bar', 'footer_class' => 'bottom-bar',

View File

@@ -53,6 +53,9 @@ function platform_footer_year_label(array $cfg): string
$end = platform_footer_year_end($cfg); $end = platform_footer_year_end($cfg);
$start = isset($cfg['year_start']) ? (int) $cfg['year_start'] : 0; $start = isset($cfg['year_start']) ? (int) $cfg['year_start'] : 0;
if ($start > 0) { if ($start > 0) {
if (!empty($cfg['year_through_current'])) {
return $start . ' - current';
}
if ($start === $end) { if ($start === $end) {
return (string) $start; return (string) $start;
} }

View File

@@ -12,6 +12,12 @@
## and ## and
## ssh -o 'ProxyJump=foxx@f0xx.org:222' 'foxx@10.7.16.128' ## ssh -o 'ProxyJump=foxx@f0xx.org:222' 'foxx@10.7.16.128'
if [[ -z "$(mount | grep FE | grep var)" && -d "tmp/FE_gentoo/var/log/nginx" ]]; then
set -x
sshfs f0xx-monstro:/var/log/nginx tmp/FE_gentoo/var/log/nginx
set +x
fi
sleep 3 sleep 3
if [[ -z "$(mount | grep BE | grep etc | grep f0xx)" && -d "tmp/BE_alpine/etc/nginx" ]]; then if [[ -z "$(mount | grep BE | grep etc | grep f0xx)" && -d "tmp/BE_alpine/etc/nginx" ]]; then