1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:38:53 +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

@@ -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 17 min while enabled.
## Default accounts

View File

@@ -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;

View File

@@ -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);
}

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)"
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
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

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
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/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';