1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:18:42 +03:00

sync / interactive remote shell / remote web interface to WG connected mobile

This commit is contained in:
Anton Afanasyeu
2026-06-14 21:17:35 +02:00
parent 622d75bdcf
commit 22cc1c6a6a
41 changed files with 2701 additions and 141 deletions

View File

@@ -26,18 +26,16 @@ if ($method === 'GET') {
json_out(['ok' => true, 'events' => RemoteAccessRepository::listEvents((int)($_GET['limit'] ?? 100))]);
}
if ($action === 'dashboard') {
$sessions = RemoteAccessRepository::listSessions('');
$active = array_values(array_filter($sessions, static fn($s) => in_array($s['status'] ?? '', ['pending', 'active'], true)));
$inactive = array_values(array_filter($sessions, static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true)));
$payload = RemoteAccessRepository::buildDashboardPayload(
rtrim(Auth::basePath(), '/'),
'/app/androidcast_project'
);
json_out([
'ok' => true,
'devices' => RemoteAccessRepository::listDevicesForDashboard(
rtrim(Auth::basePath(), '/'),
'/app/androidcast_project'
),
'active_sessions' => $active,
'inactive_sessions' => array_slice($inactive, 0, 50),
'recent_events' => RemoteAccessRepository::listEvents(30),
'devices' => $payload['devices'],
'active_sessions' => $payload['active_sessions'],
'inactive_sessions' => $payload['inactive_sessions'],
'recent_events' => $payload['recent_events'],
'permissions' => [
'can_operate' => Rbac::can('remote_access_operate'),
'can_admin' => Rbac::can('remote_access_admin'),

View File

@@ -411,6 +411,8 @@
lines.push(pollLabel + ': ' + d.poll_source_ip);
}
if (d.vpn_ip) lines.push('VPN IP: ' + d.vpn_ip);
if (d.vpn_route_scope) lines.push('VPN route scope: ' + d.vpn_route_scope);
if (d.vpn_app_scope) lines.push('VPN app scope: ' + d.vpn_app_scope);
if (d.vpn_public_key) lines.push('VPN public key: ' + d.vpn_public_key);
if (d.wg_rx_bytes != null || d.wg_tx_bytes != null) {
const rx = formatBytes(d.wg_rx_bytes || 0);

View File

@@ -5,12 +5,16 @@ 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.
# Alpine BE: default `php` may be 8.5 without session/PDO; php-fpm81 uses 8.1.
php_bin() {
if [[ -n "${PHP_BIN:-}" ]]; then
echo "$PHP_BIN"
return
fi
if command -v php81 >/dev/null 2>&1 && php81 -m 2>/dev/null | grep -qiE '^(session|pdo_mysql|pdo_sqlite)$'; then
echo php81
return
fi
if php -m 2>/dev/null | grep -qi '^session$'; then
echo php
return
@@ -34,6 +38,13 @@ echo "== Remote access production verify =="
echo "backend: $ROOT"
echo
# MariaDB rejects PostgreSQL-style NULLS LAST/FIRST (SQLite dev may hide this).
if grep -R --include='*.php' -E 'NULLS (LAST|FIRST)' "$ROOT/src" >/dev/null 2>&1; then
bad "MariaDB-incompatible SQL (NULLS LAST/FIRST) under src/"
else
ok "no NULLS LAST/FIRST in PHP SQL"
fi
# config.php
CFG="$ROOT/config/config.php"
if [[ ! -f "$CFG" ]]; then
@@ -97,6 +108,13 @@ foreach (["remote_access_devices","remote_access_sessions","remote_access_events
echo "OK MariaDB/SQLite remote_access tables present\n";
' || fail=1
$DB_RUN -r '
require "'"$ROOT"'/src/bootstrap.php";
RemoteAccessRepository::ensureSchema();
RemoteAccessRepository::listDevices();
echo "OK listDevices() SQL executes\n";
' || bad "listDevices() failed (check ORDER BY / MariaDB syntax)"
# 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)"

View File

@@ -16,46 +16,44 @@ final class RemoteAccessRepository {
public const STATUS_CLOSED = 'closed';
public const STATUS_CLOSED_TIMEOUT = 'closed_timeout';
private static bool $schemaReady = false;
public static function ensureSchema(): void {
if (self::$schemaReady) {
return;
}
$pdo = Database::pdo();
if (!Database::tableExists($pdo, 'remote_access_devices')) {
self::createTables($pdo);
self::$schemaReady = true;
return;
}
self::ensureSessionColumns($pdo);
self::ensureRsshColumns($pdo);
self::ensureDeviceMetaColumn($pdo);
self::ensureLastClientIpColumn($pdo);
self::$schemaReady = true;
}
private static function addColumnIfMissing(PDO $pdo, string $table, string $column, string $ddl): void {
if (!Database::tableExists($pdo, $table)) {
return;
}
$names = Database::columnNames($pdo, $table);
if (in_array($column, $names, true)) {
return;
}
$pdo->exec('ALTER TABLE ' . $table . ' ADD COLUMN ' . $column . ' ' . $ddl);
}
private static function ensureLastClientIpColumn(PDO $pdo): void {
if (!Database::tableExists($pdo, 'remote_access_devices')) {
return;
}
$col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL';
try {
$pdo->exec('ALTER TABLE remote_access_devices ADD COLUMN last_client_ip ' . $col);
} catch (PDOException $e) {
$msg = strtolower($e->getMessage());
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
throw $e;
}
}
self::addColumnIfMissing($pdo, 'remote_access_devices', 'last_client_ip', $col);
}
private static function ensureDeviceMetaColumn(PDO $pdo): void {
if (!Database::tableExists($pdo, 'remote_access_devices')) {
return;
}
$col = Database::isMysql() ? 'LONGTEXT NULL' : 'TEXT NULL';
try {
$pdo->exec('ALTER TABLE remote_access_devices ADD COLUMN device_meta_json ' . $col);
} catch (PDOException $e) {
$msg = strtolower($e->getMessage());
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
throw $e;
}
}
self::addColumnIfMissing($pdo, 'remote_access_devices', 'device_meta_json', $col);
}
/** @param list<string> $where @param list<mixed> $params */
@@ -113,30 +111,13 @@ final class RemoteAccessRepository {
'rssh_local_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL',
];
foreach ($cols as $name => $ddl) {
try {
$pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN ' . $name . ' ' . $ddl);
} catch (PDOException $e) {
$msg = strtolower($e->getMessage());
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
throw $e;
}
}
self::addColumnIfMissing($pdo, 'remote_access_sessions', $name, $ddl);
}
}
private static function ensureSessionColumns(PDO $pdo): void {
if (!Database::tableExists($pdo, 'remote_access_sessions')) {
return;
}
$col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL';
try {
$pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN wg_client_public_key ' . $col);
} catch (PDOException $e) {
$msg = strtolower($e->getMessage());
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
throw $e;
}
}
self::addColumnIfMissing($pdo, 'remote_access_sessions', 'wg_client_public_key', $col);
}
private static function createTables(PDO $pdo): void {
@@ -185,6 +166,14 @@ final class RemoteAccessRepository {
}
$deviceMeta = is_array($heartbeat['device'] ?? null) ? $heartbeat['device'] : [];
$routeScope = trim((string) ($heartbeat['vpn_route_scope'] ?? ''));
$appScope = trim((string) ($heartbeat['vpn_app_scope'] ?? ''));
if ($routeScope !== '') {
$deviceMeta['vpn_route_scope'] = $routeScope;
}
if ($appScope !== '') {
$deviceMeta['vpn_app_scope'] = $appScope;
}
self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp, $deviceMeta);
@@ -853,22 +842,89 @@ final class RemoteAccessRepository {
return Database::isMysql() ? date('Y-m-d H:i:s') : gmdate('Y-m-d H:i:s');
}
/** @return array{devices: list<array<string, mixed>>, active_sessions: list<array<string, mixed>>, inactive_sessions: list<array<string, mixed>>, recent_events: list<array<string, mixed>>} */
public static function buildDashboardPayload(string $issuesBase, string $projectBase): array {
self::ensureSchema();
$sessions = self::listSessions('');
$active = array_values(array_filter(
$sessions,
static fn($s) => in_array($s['status'] ?? '', ['pending', 'active'], true)
));
$inactive = array_values(array_filter(
$sessions,
static fn($s) => !in_array($s['status'] ?? '', ['pending', 'active'], true)
));
return [
'devices' => self::listDevicesForDashboard($issuesBase, $projectBase),
'active_sessions' => $active,
'inactive_sessions' => array_slice($inactive, 0, 50),
'recent_events' => self::listEvents(30),
];
}
/** @return list<array<string, mixed>> */
public static function listDevicesForDashboard(string $issuesBase, string $projectBase): array {
$devices = self::listDevices();
if ($devices === []) {
return [];
}
$deviceIds = array_values(array_filter(array_map(
static fn($row) => (string) ($row['device_id'] ?? ''),
$devices
)));
$sessionRows = self::batchActiveSessionRows($deviceIds);
$wgPeers = WireGuardTrafficStats::peerMap();
$out = [];
foreach ($devices as $row) {
$out[] = self::enrichDeviceRow($row, $issuesBase, $projectBase);
$deviceId = (string) ($row['device_id'] ?? '');
$out[] = self::enrichDeviceRow(
$row,
$issuesBase,
$projectBase,
$sessionRows[$deviceId] ?? null,
$wgPeers
);
}
return $out;
}
/** @param array<string, mixed> $row */
private static function enrichDeviceRow(array $row, string $issuesBase, string $projectBase): array {
/** @param list<string> $deviceIds @return array<string, array<string, mixed>> */
private static function batchActiveSessionRows(array $deviceIds): array {
$deviceIds = array_values(array_unique(array_filter(array_map('strval', $deviceIds))));
if ($deviceIds === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($deviceIds), '?'));
$st = Database::pdo()->prepare(
"SELECT device_id, tunnel, wg_client_address, wg_client_public_key
FROM remote_access_sessions
WHERE status IN ('pending', 'active') AND device_id IN ($placeholders)
ORDER BY id DESC"
);
$st->execute($deviceIds);
$map = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
$did = (string) ($row['device_id'] ?? '');
if ($did !== '' && !isset($map[$did])) {
$map[$did] = $row;
}
}
return $map;
}
/** @param array<string, mixed> $row @param array<string, mixed>|null $sessionRow @param array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}>|null $wgPeers */
private static function enrichDeviceRow(
array $row,
string $issuesBase,
string $projectBase,
?array $sessionRow = null,
?array $wgPeers = null
): array {
$deviceId = (string) ($row['device_id'] ?? '');
$companyId = (int) ($row['company_id'] ?? 1);
$storedMeta = self::decodeDeviceMeta($row['device_meta_json'] ?? null);
$ctx = self::lookupDeviceContext($deviceId, $companyId, $storedMeta);
$ctx = self::lookupDeviceContextFromMeta($deviceId, $companyId, $storedMeta);
$row['needs_whitelist'] = self::deviceNeedsWhitelist($row);
$row['status_label'] = self::deviceStatusLabel($row);
$row['device_name'] = $ctx['device_name'] ?? '';
@@ -885,7 +941,9 @@ final class RemoteAccessRepository {
$row['poll_source_ip'] = self::resolvePollSourceIp($row);
$row['poll_source_is_private'] = self::isPrivateIp($row['poll_source_ip']);
$row['device_wan_ip'] = trim((string) ($storedMeta['wan_ip'] ?? ''));
$net = self::lookupDeviceSessionNetwork($deviceId);
$row['vpn_route_scope'] = trim((string) ($storedMeta['vpn_route_scope'] ?? ''));
$row['vpn_app_scope'] = trim((string) ($storedMeta['vpn_app_scope'] ?? ''));
$net = self::sessionNetworkFromRow($sessionRow, $wgPeers);
$row['vpn_ip'] = $net['vpn_ip'] ?? '';
$row['vpn_public_key'] = $net['vpn_public_key'] ?? '';
$row['wg_rx_bytes'] = $net['wg_rx_bytes'] ?? 0;
@@ -1002,8 +1060,8 @@ final class RemoteAccessRepository {
return $raw;
}
/** @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
private static function lookupDeviceSessionNetwork(string $deviceId): array {
/** @param array<string, mixed>|null $sessionRow @param array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}>|null $wgPeers @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
private static function sessionNetworkFromRow(?array $sessionRow, ?array $wgPeers): array {
$empty = [
'vpn_ip' => '',
'vpn_public_key' => '',
@@ -1012,29 +1070,20 @@ final class RemoteAccessRepository {
'wg_latest_handshake' => 0,
'wg_endpoint' => '',
];
if ($deviceId === '') {
if (!is_array($sessionRow)) {
return $empty;
}
$st = Database::pdo()->prepare(
"SELECT tunnel, wg_client_address, wg_client_public_key
FROM remote_access_sessions
WHERE device_id = ? AND status IN ('pending', 'active')
ORDER BY id DESC LIMIT 1"
);
$st->execute([$deviceId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
return $empty;
}
$tunnel = (string) ($row['tunnel'] ?? '');
$tunnel = (string) ($sessionRow['tunnel'] ?? '');
if ($tunnel !== '' && $tunnel !== 'wireguard') {
return $empty;
}
$addr = trim((string) ($row['wg_client_address'] ?? ''));
$addr = trim((string) ($sessionRow['wg_client_address'] ?? ''));
$vpnIp = $addr !== '' ? preg_replace('/\/\d+$/', '', $addr) ?? $addr : '';
$pub = trim((string) ($row['wg_client_public_key'] ?? ''));
$stats = $pub !== '' ? WireGuardTrafficStats::forPublicKey($pub) : null;
$pub = trim((string) ($sessionRow['wg_client_public_key'] ?? ''));
$stats = ($wgPeers !== null && $pub !== '') ? ($wgPeers[$pub] ?? null) : null;
if ($stats === null && $pub !== '') {
$stats = WireGuardTrafficStats::forPublicKey($pub);
}
return [
'vpn_ip' => is_string($vpnIp) ? trim($vpnIp) : '',
@@ -1046,6 +1095,72 @@ final class RemoteAccessRepository {
];
}
/** Fast dashboard enrichment from heartbeat meta (no reports/graph table scans). */
private static function lookupDeviceContextFromMeta(
string $deviceId,
int $companyId,
array $storedMeta = []
): array {
if ($deviceId === '') {
return [];
}
unset($companyId);
$deviceName = trim((string) ($storedMeta['name'] ?? ''));
$manufacturer = trim((string) ($storedMeta['manufacturer'] ?? ''));
$brand = trim((string) ($storedMeta['brand'] ?? ''));
$model = trim((string) ($storedMeta['model'] ?? ''));
$hardwareDevice = trim((string) ($storedMeta['device'] ?? ''));
$product = trim((string) ($storedMeta['product'] ?? ''));
$osRelease = trim((string) ($storedMeta['release'] ?? ''));
$sdkInt = isset($storedMeta['sdk_int']) ? (int) $storedMeta['sdk_int'] : null;
$abis = is_array($storedMeta['abis'] ?? null) ? $storedMeta['abis'] : [];
$appVersion = trim((string) ($storedMeta['app_version'] ?? ''));
$deviceDisplay = $deviceName !== ''
? $deviceName
: trim($manufacturer . ' ' . $model);
return [
'issue_count' => 0,
'latest_issue_id' => null,
'graph_session_count' => 0,
'device_name' => $deviceName,
'device_display' => $deviceDisplay,
'manufacturer' => $manufacturer,
'brand' => $brand,
'model' => $model,
'hardware_device' => $hardwareDevice,
'product' => $product,
'os_release' => $osRelease,
'sdk_int' => $sdkInt,
'abis' => $abis,
'app_version' => $appVersion,
];
}
/** @return array{vpn_ip: string, vpn_public_key: string, wg_rx_bytes: int, wg_tx_bytes: int, wg_latest_handshake: int, wg_endpoint: string} */
private static function lookupDeviceSessionNetwork(string $deviceId): array {
if ($deviceId === '') {
return [
'vpn_ip' => '',
'vpn_public_key' => '',
'wg_rx_bytes' => 0,
'wg_tx_bytes' => 0,
'wg_latest_handshake' => 0,
'wg_endpoint' => '',
];
}
$st = Database::pdo()->prepare(
"SELECT tunnel, wg_client_address, wg_client_public_key
FROM remote_access_sessions
WHERE device_id = ? AND status IN ('pending', 'active')
ORDER BY id DESC LIMIT 1"
);
$st->execute([$deviceId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return self::sessionNetworkFromRow(is_array($row) ? $row : null, null);
}
/** @param array<string, mixed> $storedMeta @return array<string, mixed> */
private static function lookupDeviceContext(string $deviceId, int $companyId, array $storedMeta = []): array {
if ($deviceId === '') {
@@ -1087,21 +1202,27 @@ final class RemoteAccessRepository {
}
if (Database::tableExists($pdo, 'reports')) {
$like = '%' . $deviceId . '%';
$cntSt = $pdo->prepare(
'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.payload_json LIKE ?'
);
$cntSt->execute([$companyId, $like]);
$issueCount = (int) $cntSt->fetchColumn();
$reportsDeviceId = self::resolveDeviceDbId($deviceId, $companyId);
if ($reportsDeviceId !== null) {
$cntSt = $pdo->prepare(
'SELECT COUNT(*) FROM reports r WHERE r.company_id = ? AND r.device_id = ?'
);
$cntSt->execute([$companyId, $reportsDeviceId]);
$issueCount = (int) $cntSt->fetchColumn();
$latestSt = $pdo->prepare(
'SELECT r.id, r.device_model, r.app_version, r.payload_json
FROM reports r
WHERE r.company_id = ? AND r.payload_json LIKE ?
ORDER BY r.received_at_ms DESC LIMIT 1'
);
$latestSt->execute([$companyId, $like]);
$latest = $latestSt->fetch(PDO::FETCH_ASSOC);
$latestSt = $pdo->prepare(
'SELECT r.id, r.device_model, r.app_version, r.payload_json
FROM reports r
WHERE r.company_id = ? AND r.device_id = ?
ORDER BY r.received_at_ms DESC LIMIT 1'
);
$latestSt->execute([$companyId, $reportsDeviceId]);
$latest = $latestSt->fetch(PDO::FETCH_ASSOC);
} else {
// Avoid payload_json LIKE scans (full table) on unregistered devices.
$issueCount = 0;
$latest = null;
}
if (is_array($latest)) {
$latestIssueId = (int) ($latest['id'] ?? 0) ?: null;
if ($appVersion === '') {
@@ -1163,6 +1284,28 @@ final class RemoteAccessRepository {
];
}
private static function resolveDeviceDbId(string $deviceId, int $companyId): ?int {
static $cache = [];
$key = $companyId . ':' . $deviceId;
if (array_key_exists($key, $cache)) {
return $cache[$key];
}
$pdo = Database::pdo();
if (!Database::tableExists($pdo, 'devices')) {
$cache[$key] = null;
return null;
}
$st = $pdo->prepare(
'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1'
);
$st->execute([$companyId, $deviceId]);
$id = $st->fetchColumn();
$cache[$key] = ($id !== false && $id !== null && $id !== '') ? (int) $id : null;
return $cache[$key];
}
/** @param array<string, mixed> $ctx @return list<array{label: string, href: string}> */
private static function deviceLinks(string $deviceId, string $issuesBase, string $projectBase, array $ctx): array {
if ($deviceId === '') {