From 2fd6d421a3a71f9bfb9f732adbb616bd02654eee Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sat, 13 Jun 2026 16:49:40 +0200 Subject: [PATCH] sync --- docs/REMOTE_ACCESS_IMPL.md | 9 +-- docs/REMOTE_ACCESS_VALIDATION.md | 13 ++-- docs/VPN_DEMO_GAPS_20260613.md | 12 +++- examples/crash_reporter/backend/README.md | 1 + .../backend/scripts/purge_remote_access.php | 10 ++- .../crash_reporter/backend/scripts/ra_lib.sh | 2 +- .../backend/scripts/ra_udp_debug.sh | 6 +- .../backend/scripts/sync_wg_peers.php | 46 ++++++++++++ .../scripts/verify_remote_access_prod.sh | 13 ++++ .../backend/src/RemoteAccessRepository.php | 33 +++++++++ .../backend/src/WireGuardPeerProvisioner.php | 71 +++++++++++++++++++ 11 files changed, 198 insertions(+), 18 deletions(-) create mode 100755 examples/crash_reporter/backend/scripts/sync_wg_peers.php diff --git a/docs/REMOTE_ACCESS_IMPL.md b/docs/REMOTE_ACCESS_IMPL.md index 1eff4ba..7535490 100644 --- a/docs/REMOTE_ACCESS_IMPL.md +++ b/docs/REMOTE_ACCESS_IMPL.md @@ -49,7 +49,7 @@ WireGuard on Android **must** use `VpnService` (see `DeveloperSettingsActivity` | One-time system dialog | Yes — “Allow VPN?” | No VPN permission | | Status-bar key icon | Yes while tunnel up | No | | Full-device routing | TUN interface (split routes in config, still a VPN) | No L3 tunnel | -| Outbound from phone | UDP to BE `:51820` | HTTPS poll + outbound SSH to bastion | +| Outbound from phone | UDP to BE `:45340` | HTTPS poll + outbound SSH to bastion | There is no supported “hidden VPN” on non-root Android: any TUN creator is classified as a VPN app. **RSSH** (foreground service + existing HTTPS poll + outbound SSH reverse tunnel) is the **alpha deliverable** agreed by project owners — device initiates, operator reaches a forwarded port on the bastion, without VPN consent or status-bar key. See [Rev. 3 Q&A](20260602_REVERSE_SSH_proposals_summary.md#rev-3--wg-vs-ssh-qa--bastion-deep-dive) and [ROADMAP.md § Remote access](ROADMAP.md#remote-access-alpha--rssh). @@ -105,7 +105,7 @@ Migration **007** (already applied on prod per deploy notes). Tables auto-create ```php 'remote_access' => [ - 'wg_endpoint' => 'ra.apps.f0xx.org:51820', // public UDP hostname:port (FE DNAT → BE) + 'wg_endpoint' => 'ra.apps.f0xx.org:45340', // public UDP hostname:port (FE DNAT → BE) 'wg_server_public_key' => '…', // wg show wg0 public-key on BE — REQUIRED 'wg_interface' => 'wg0', 'provision_peers' => true, // wg set on connect/close @@ -121,7 +121,7 @@ Migration **007** (already applied on prod per deploy notes). Tables auto-create ```sh apk add wireguard-tools wireguard-tools-wg -# wg0 configured separately (Address 10.66.66.1/24, ListenPort 51820, etc.) +# wg0 configured separately (Address 172.200.2.1/16, ListenPort 45340, etc.) # PHP-FPM user must run wg when provision_peers=true (same as CLI test): sudo -u www-data wg show wg0 @@ -131,6 +131,7 @@ sudo -u www-data wg show wg0 ```cron 0 * * * * cd /var/www/.../backend && php81 scripts/purge_remote_access.php --hours=24 +# Orphan wg peers are pruned each run; skip with --no-prune-wg. Manual: php81 scripts/sync_wg_peers.php --dry-run ``` Use `php81` when Alpine default `php` is 8.5+ without the session module; FPM is `php-fpm81`. @@ -156,7 +157,7 @@ Hard-refresh after deploying `public/assets/js/remote_access.js`. HTTP control plane stays on FE→BE `:80`. **WireGuard traffic is UDP** — configure at the **FE firewall/DNAT**, not in nginx: ```text -Internet UDP :51820 → FE DNAT → BE wg0 :51820 +Internet UDP :45340 → FE DNAT → BE wg0 :45340 ``` Hostname `ra.apps.f0xx.org` (or similar) should resolve to the FE public IP. Devices use `wg_endpoint` from config/connect payload. diff --git a/docs/REMOTE_ACCESS_VALIDATION.md b/docs/REMOTE_ACCESS_VALIDATION.md index dc99e59..d5cf492 100644 --- a/docs/REMOTE_ACCESS_VALIDATION.md +++ b/docs/REMOTE_ACCESS_VALIDATION.md @@ -55,8 +55,9 @@ All under `examples/crash_reporter/backend/scripts/`: | `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 | -| `ra_udp_debug.sh` | DNS + UDP port + HTTP smoke + connect + `wg-quick` + ping BE (`10.66.66.1`) | +| `verify_remote_access_prod.sh` | BE preflight: config, wg tools, DB tables, wg peer sync, optional smoke | +| `sync_wg_peers.php` | Reconcile wg0 peers with pending/active sessions (`--dry-run` / `--apply`) | +| `ra_udp_debug.sh` | DNS + UDP port + HTTP smoke + connect + `wg-quick` + ping BE (`172.200.2.1`) | ```bash cd examples/crash_reporter/backend @@ -91,7 +92,7 @@ export RA_DEVICE_ID=my-lab-phone ## Production / staging BE -**Verified 2026-06-04** on `https://apps.f0xx.org/app/androidcast_project/crashes`: `ra_e2e_cli.sh` passed (connect returned real `peer_public_key`, endpoint `ra.apps.f0xx.org:51820`). Poll step may take up to ~45s per `min_poll_interval_s`. UDP handshake still needs a device or `ra_device_sim.sh --apply-wg` from a reachable host. +**Verified 2026-06-04** on `https://apps.f0xx.org/app/androidcast_project/crashes`: `ra_e2e_cli.sh` passed (connect returned real `peer_public_key`, endpoint `ra.apps.f0xx.org:45340`). Poll step may take up to ~45s per `min_poll_interval_s`. UDP handshake still needs a device or `ra_device_sim.sh --apply-wg` from a reachable host. ```bash ssh alpine-be # or your BE host @@ -130,10 +131,10 @@ export CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes export ADMIN_USER=admin ADMIN_PASS='…' ./scripts/ra_udp_debug.sh # DNS, UDP nc, HTTP smoke, connect, wg conf -./scripts/ra_udp_debug.sh --apply # also wg-quick up + ping 10.66.66.1 + teardown +./scripts/ra_udp_debug.sh --apply # also wg-quick up + ping 172.200.2.1 + teardown ``` -Steps: CNAME check (`ra.apps.f0xx.org` → same A as `apps.f0xx.org`) → UDP `51820` → `test_remote_access_api.sh` → whitelist + open + poll connect → write `/tmp/ra-udp-.conf`. With `--apply`, brings tunnel up and pings the BE WG address. +Steps: CNAME check (`ra.apps.f0xx.org` → same A as `apps.f0xx.org`) → UDP `45340` → `test_remote_access_api.sh` → whitelist + open + poll connect → write `/tmp/ra-udp-.conf`. With `--apply`, brings tunnel up and pings the BE WG address. Skip HTTP re-check when control plane was just verified: `./scripts/ra_udp_debug.sh --skip-http --apply`. @@ -157,7 +158,7 @@ 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 +ping -c1 172.200.2.1 # BE wg0 address if routed sudo wg-quick down /tmp/ra-test.conf ``` diff --git a/docs/VPN_DEMO_GAPS_20260613.md b/docs/VPN_DEMO_GAPS_20260613.md index 3d9ed12..a29e0a7 100644 --- a/docs/VPN_DEMO_GAPS_20260613.md +++ b/docs/VPN_DEMO_GAPS_20260613.md @@ -9,20 +9,26 @@ See also [REMOTE_ACCESS_VALIDATION.md](../REMOTE_ACCESS_VALIDATION.md), [REMOTE_ - **VPN traffic counters** in admin expanded device row (`WireGuardTrafficStats.php`) - sudoers: `wg show wg0 dump` for nginx FPM user +## Fixed in repo (awaiting BE sync) + +- **Operational docs** — `REMOTE_ACCESS_IMPL.md` / `REMOTE_ACCESS_VALIDATION.md` now use prod **45340** and **172.200.2.0/16** (proposal doc still uses example 51820) +- **Orphan wg peer cleanup** — `WireGuardPeerProvisioner::pruneOrphanPeers()`, hourly via `purge_remote_access.php`; manual `scripts/sync_wg_peers.php --dry-run|--apply` +- **CLI defaults** — `ra_udp_debug.sh` / `ra_lib.sh` help and fallbacks match prod subnet + ## Verified OK - `wg0` on BE, endpoint `ra.apps.f0xx.org:45340` - UDP 45340 open on public IP - Heartbeat disable/enable API - Mobile boot restores RA mode (`RemoteAccessCoordinator.onBootIfNeeded`) +- App wait-state + diagnostics (idle vs fail-reason); 30s heartbeat timeout ## Open (PO / device) 1. Full `ra_e2e_cli.sh` — needs admin login on laptop 2. UDP handshake + ping — needs phone or `ra_udp_debug.sh --apply` 3. SSH passthrough — RSSH track, not WG v1 -4. Doc port references still say 51820 in places; prod uses **45340** -5. Stale wg peer with `allowed ips: (none)` — manual cleanup -6. Persistent traffic history — live stats only today +4. Persistent traffic history — live stats only today +5. Whitelist + device E2E — human validation in progress Journal: `/shared/journal/cluster0.journal.tsv` (tag `vpn-demo`). diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md index 88c884e..38ae5fb 100644 --- a/examples/crash_reporter/backend/README.md +++ b/examples/crash_reporter/backend/README.md @@ -295,6 +295,7 @@ mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql ```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 +# Prunes orphan wg0 peers each run; manual: php81 scripts/sync_wg_peers.php --dry-run ``` Smoke / verify: diff --git a/examples/crash_reporter/backend/scripts/purge_remote_access.php b/examples/crash_reporter/backend/scripts/purge_remote_access.php index c314a6e..7f84e08 100644 --- a/examples/crash_reporter/backend/scripts/purge_remote_access.php +++ b/examples/crash_reporter/backend/scripts/purge_remote_access.php @@ -4,19 +4,27 @@ declare(strict_types=1); /** * Purge stale remote-access sessions (cron: hourly). - * Usage: php scripts/purge_remote_access.php [--hours=24] + * Usage: php scripts/purge_remote_access.php [--hours=24] [--no-prune-wg] */ require_once __DIR__ . '/../src/bootstrap.php'; require_once __DIR__ . '/../src/RemoteAccessRepository.php'; require_once __DIR__ . '/../src/WireGuardPeerProvisioner.php'; $hours = 24; +$pruneWg = true; foreach ($argv as $arg) { if (str_starts_with($arg, '--hours=')) { $hours = max(1, (int) substr($arg, 8)); + } elseif ($arg === '--no-prune-wg') { + $pruneWg = false; } } RemoteAccessRepository::ensureSchema(); $n = RemoteAccessRepository::purgeStale($hours); fwrite(STDOUT, "purged_sessions={$n}\n"); + +if ($pruneWg) { + $pruned = RemoteAccessRepository::pruneOrphanWireGuardPeers(false); + fwrite(STDOUT, 'pruned_wg_peers=' . count($pruned) . "\n"); +} diff --git a/examples/crash_reporter/backend/scripts/ra_lib.sh b/examples/crash_reporter/backend/scripts/ra_lib.sh index 648cb16..7563d9a 100755 --- a/examples/crash_reporter/backend/scripts/ra_lib.sh +++ b/examples/crash_reporter/backend/scripts/ra_lib.sh @@ -87,7 +87,7 @@ ra_wg_quick_from_connect() { 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"') + addr=$(echo "$connect_json" | jq -r '.heartbeat.credentials.interface_address // "172.200.2.10/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"') diff --git a/examples/crash_reporter/backend/scripts/ra_udp_debug.sh b/examples/crash_reporter/backend/scripts/ra_udp_debug.sh index 5c2dd6d..c140077 100755 --- a/examples/crash_reporter/backend/scripts/ra_udp_debug.sh +++ b/examples/crash_reporter/backend/scripts/ra_udp_debug.sh @@ -22,8 +22,8 @@ Usage: $0 [--apply] [--device-id ID] [--skip-http] Env: CRASHES_BASE, ADMIN_USER, ADMIN_PASS — operator API - RA_DEVICE_ID, WG_HOST (default ra.apps.f0xx.org), WG_PORT (51820) - BE_WG_IP (10.66.66.1) — ping target on tunnel + RA_DEVICE_ID, WG_HOST (default ra.apps.f0xx.org), WG_PORT (45340) + BE_WG_IP (172.200.2.1) — ping target on tunnel RA_UDP_APPLY=1 — same as --apply (wg-quick up) Steps: DNS → UDP port → optional HTTP E2E → connect JSON → wg conf → optional tunnel → ping @@ -116,7 +116,7 @@ if [[ "$APPLY" -eq 1 ]]; then if ping -c 3 -W 2 "$BE_WG_IP"; then ok "ping ${BE_WG_IP}" else - warn "ping failed — routing or AllowedIPs (expected 10.66.66.1/32)" + warn "ping failed — routing or AllowedIPs (expected 172.200.2.1/32)" fi note "9. Teardown" sudo wg-quick down "$CONF" diff --git a/examples/crash_reporter/backend/scripts/sync_wg_peers.php b/examples/crash_reporter/backend/scripts/sync_wg_peers.php new file mode 100755 index 0000000..17e521e --- /dev/null +++ b/examples/crash_reporter/backend/scripts/sync_wg_peers.php @@ -0,0 +1,46 @@ +#!/usr/bin/env php + 12 ? substr($pub, 0, 8) . '…' : $pub; + fwrite( + STDOUT, + sprintf( + " %s reason=%s allowed_ips=%s\n", + $short, + (string) ($row['reason'] ?? ''), + (string) ($row['allowed_ips'] ?? '') + ) + ); +} diff --git a/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh b/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh index 769a45d..c93f3ec 100755 --- a/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh +++ b/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh @@ -97,6 +97,19 @@ foreach (["remote_access_devices","remote_access_sessions","remote_access_events echo "OK MariaDB/SQLite remote_access tables present\n"; ' || fail=1 +# Orphan wg peers (dry-run; run sync_wg_peers.php --apply on BE to fix) +if [[ -f "$ROOT/scripts/sync_wg_peers.php" ]]; then + WG_SYNC="$($DB_RUN "$ROOT/scripts/sync_wg_peers.php" --dry-run 2>/dev/null || true)" + if [[ -n "$WG_SYNC" ]]; then + pruned=$(echo "$WG_SYNC" | awk -F= '/^pruned_peers=/{print $2}') + if [[ -n "$pruned" && "$pruned" != "0" ]]; then + note "wg0 has ${pruned} orphan peer(s) — run: php81 scripts/sync_wg_peers.php --apply" + else + ok "wg0 peers in sync with active sessions" + fi + fi +fi + # API smoke (local php -S or deployed BASE) BASE="${BASE:-${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}}" if [[ -x "$ROOT/scripts/test_remote_access_api.sh" ]]; then diff --git a/examples/crash_reporter/backend/src/RemoteAccessRepository.php b/examples/crash_reporter/backend/src/RemoteAccessRepository.php index 95fea0c..964c46c 100644 --- a/examples/crash_reporter/backend/src/RemoteAccessRepository.php +++ b/examples/crash_reporter/backend/src/RemoteAccessRepository.php @@ -756,6 +756,39 @@ final class RemoteAccessRepository { return true; } + /** @return list */ + public static function activeWireGuardSessionPublicKeys(): array { + self::ensureSchema(); + $st = Database::pdo()->query( + "SELECT DISTINCT wg_client_public_key FROM remote_access_sessions + WHERE status IN ('pending','active') AND tunnel = 'wireguard' + AND wg_client_public_key IS NOT NULL AND wg_client_public_key != ''" + ); + if ($st === false) { + return []; + } + $keys = []; + foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $pub) { + $pub = trim((string) $pub); + if ($pub !== '') { + $keys[] = $pub; + } + } + return $keys; + } + + /** + * Drop wg0 peers that are not referenced by pending/active sessions. + * + * @return list + */ + public static function pruneOrphanWireGuardPeers(bool $dryRun = false): array { + return WireGuardPeerProvisioner::pruneOrphanPeers( + self::activeWireGuardSessionPublicKeys(), + $dryRun + ); + } + public static function purgeStale(int $maxAgeHours = 24): int { self::ensureSchema(); $cutoff = time() - max(1, $maxAgeHours) * 3600; diff --git a/examples/crash_reporter/backend/src/WireGuardPeerProvisioner.php b/examples/crash_reporter/backend/src/WireGuardPeerProvisioner.php index 4d2abc1..b420b63 100644 --- a/examples/crash_reporter/backend/src/WireGuardPeerProvisioner.php +++ b/examples/crash_reporter/backend/src/WireGuardPeerProvisioner.php @@ -59,6 +59,77 @@ final class WireGuardPeerProvisioner { ); } + /** @return list */ + public static function listPeerDumpRows(): array { + if (!self::isEnabled()) { + return []; + } + $iface = self::interfaceName(); + $prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t"); + $cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg show ' . escapeshellarg($iface) . ' dump 2>/dev/null'; + $raw = @shell_exec($cmd); + if (!is_string($raw) || trim($raw) === '') { + return []; + } + $rows = []; + foreach (preg_split('/\r\n|\n|\r/', trim($raw)) as $line) { + if ($line === '' || str_starts_with($line, 'interface:')) { + continue; + } + $cols = explode("\t", $line); + if (count($cols) < 4) { + continue; + } + $pub = trim($cols[0]); + if ($pub === '') { + continue; + } + $rows[] = [ + 'public_key' => $pub, + 'allowed_ips' => trim((string) ($cols[3] ?? '')), + ]; + } + return $rows; + } + + /** + * Remove live wg peers not tied to pending/active sessions (or with empty AllowedIPs). + * + * @param list $keepPublicKeys + * @return list + */ + public static function pruneOrphanPeers(array $keepPublicKeys, bool $dryRun = false): array { + if (!self::isEnabled()) { + return []; + } + $keep = []; + foreach ($keepPublicKeys as $key) { + $pub = trim((string) $key); + if ($pub !== '') { + $keep[$pub] = true; + } + } + $pruned = []; + foreach (self::listPeerDumpRows() as $row) { + $pub = $row['public_key']; + $allowed = trim($row['allowed_ips']); + $broken = ($allowed === '' || $allowed === '(none)'); + $orphan = !isset($keep[$pub]); + if (!$orphan && !$broken) { + continue; + } + if (!$dryRun) { + self::removeClientPeer($pub); + } + $pruned[] = [ + 'public_key' => $pub, + 'allowed_ips' => $allowed, + 'reason' => $broken ? 'broken_allowed_ips' : 'orphan', + ]; + } + return $pruned; + } + private static function runWg(string $args): int { $prefix = rtrim((string) cfg('remote_access.wg_set_prefix', ''), " \t"); $cmd = ($prefix !== '' ? $prefix . ' ' : '') . 'wg ' . $args;