1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:57:40 +03:00

sync on be, vpn

This commit is contained in:
Anton Afanasyeu
2026-06-13 15:31:18 +02:00
parent f9b73b48da
commit 86055bf509
42 changed files with 2472 additions and 917 deletions

View File

@@ -81,6 +81,14 @@
return d.innerHTML;
}
function formatBytes(n) {
const b = Number(n) || 0;
if (b < 1024) return b + ' B';
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KiB';
if (b < 1024 * 1024 * 1024) return (b / (1024 * 1024)).toFixed(2) + ' MiB';
return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GiB';
}
function getColWidths() {
try {
const w = JSON.parse(lsGet(STORAGE.colWidths, '{}'));
@@ -393,10 +401,26 @@
if (d.sdk_int != null) lines.push('Android API: ' + d.sdk_int);
if (d.os_release) lines.push('Android version: ' + d.os_release);
if (d.abis && d.abis.length) lines.push('ABIs: ' + formatAbis(d.abis));
if (d.wan_ip) lines.push('WAN IP (last poll): ' + d.wan_ip);
if (d.lan_ip) lines.push('LAN IP: ' + d.lan_ip);
if (d.lan_ip) lines.push('Device LAN IP: ' + d.lan_ip);
if (d.device_wan_ip) lines.push('Device WAN IP: ' + d.device_wan_ip);
if (d.poll_source_ip) {
let pollLabel = 'Poll source IP (server view)';
if (d.poll_source_is_private) {
pollLabel += ' — intra/private hop, not device WAN';
}
lines.push(pollLabel + ': ' + d.poll_source_ip);
}
if (d.vpn_ip) lines.push('VPN IP: ' + d.vpn_ip);
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);
const tx = formatBytes(d.wg_tx_bytes || 0);
lines.push('VPN traffic (BE wg): ↓' + rx + ' ↑' + tx);
}
if (d.wg_latest_handshake > 0) {
lines.push('VPN last handshake: ' + new Date(d.wg_latest_handshake * 1000).toISOString());
}
if (d.wg_endpoint) lines.push('VPN endpoint: ' + d.wg_endpoint);
lines.push('App version: ' + (d.app_version || '—'));
lines.push('Opt-in mode: ' + (d.opt_in_mode || 'none'));
lines.push('Last seen: ' + (d.last_seen_at || '—'));

View File

@@ -80,8 +80,14 @@ 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 '
# DB tables (via bootstrap) — use FPM user + php81 so PDO/mysql extensions match production
DB_RUN="$PHP"
DB_USER=""
if [[ "$(id -u)" -eq 0 ]] && id nginx >/dev/null 2>&1; then
DB_RUN="sudo -u nginx $PHP"
DB_USER="nginx"
fi
$DB_RUN -r '
require "'"$ROOT"'/src/bootstrap.php";
RemoteAccessRepository::ensureSchema();
$pdo = Database::pdo();

View File

@@ -266,11 +266,12 @@ final class RemoteAccessRepository {
$pdo = Database::pdo();
$now = self::nowSql();
$pdo->prepare(
'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?'
'UPDATE remote_access_devices SET whitelisted = 0, opt_in_mode = ?, last_random = ?, app_version = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?'
)->execute([self::OPT_NONE, $random, $appVersion, $now, $now, $deviceId]);
self::closeActiveSessionsForDevice($deviceId, 'user_disabled');
self::logEvent($deviceId, null, null, 'user_disable', 'device_opt_out', null, $clientIp);
self::logEvent($deviceId, null, null, 'whitelist_revoked', 'device_opt_out', null, $clientIp);
return self::raEnvelope('disabled', ['reason' => 'user_opt_out']);
}
@@ -667,7 +668,9 @@ final class RemoteAccessRepository {
$sql .= ' WHERE company_id = ?';
$params[] = $companyId;
}
$sql .= ' ORDER BY last_seen_at DESC, device_id ASC';
$sql .= Database::isMysql()
? ' ORDER BY last_seen_at DESC NULLS LAST, device_id ASC'
: ' ORDER BY datetime(COALESCE(last_seen_at, \'1970-01-01\')) DESC, device_id ASC';
$st = Database::pdo()->prepare($sql);
$st->execute($params);
return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
@@ -845,11 +848,17 @@ final class RemoteAccessRepository {
$row['os_release'] = $ctx['os_release'] ?? '';
$row['sdk_int'] = $ctx['sdk_int'] ?? null;
$row['abis'] = $ctx['abis'] ?? [];
$row['wan_ip'] = self::resolveDeviceWanIp($row, $storedMeta);
$row['lan_ip'] = self::resolveDeviceLanIp($storedMeta);
$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_ip'] = $net['vpn_ip'] ?? '';
$row['vpn_public_key'] = $net['vpn_public_key'] ?? '';
$row['wg_rx_bytes'] = $net['wg_rx_bytes'] ?? 0;
$row['wg_tx_bytes'] = $net['wg_tx_bytes'] ?? 0;
$row['wg_latest_handshake'] = $net['wg_latest_handshake'] ?? 0;
$row['wg_endpoint'] = $net['wg_endpoint'] ?? '';
$row['issue_count'] = $ctx['issue_count'] ?? 0;
$row['graph_session_count'] = $ctx['graph_session_count'] ?? 0;
$row['latest_issue_id'] = $ctx['latest_issue_id'] ?? null;
@@ -911,12 +920,8 @@ final class RemoteAccessRepository {
return (time() - $t) <= $maxAgeSec;
}
/** @param array<string, mixed> $storedMeta */
private static function resolveDeviceWanIp(array $row, array $storedMeta): string {
$fromDevice = trim((string) ($storedMeta['wan_ip'] ?? ''));
if ($fromDevice !== '') {
return self::normalizeClientIp($fromDevice);
}
/** @param array<string, mixed> $row */
private static function resolvePollSourceIp(array $row): string {
$fromRow = trim((string) ($row['last_client_ip'] ?? ''));
if ($fromRow !== '') {
return self::normalizeClientIp($fromRow);
@@ -936,6 +941,17 @@ final class RemoteAccessRepository {
return self::normalizeClientIp($ip);
}
private static function isPrivateIp(string $ip): bool {
if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return true;
}
return false;
}
/** @param array<string, mixed> $storedMeta */
private static function resolveDeviceLanIp(array $storedMeta): string {
return trim((string) ($storedMeta['lan_ip'] ?? ''));
@@ -953,9 +969,16 @@ final class RemoteAccessRepository {
return $raw;
}
/** @return array{vpn_ip: string, vpn_public_key: string} */
/** @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 {
$empty = ['vpn_ip' => '', 'vpn_public_key' => ''];
$empty = [
'vpn_ip' => '',
'vpn_public_key' => '',
'wg_rx_bytes' => 0,
'wg_tx_bytes' => 0,
'wg_latest_handshake' => 0,
'wg_endpoint' => '',
];
if ($deviceId === '') {
return $empty;
}
@@ -978,9 +1001,15 @@ final class RemoteAccessRepository {
$vpnIp = $addr !== '' ? preg_replace('/\/\d+$/', '', $addr) ?? $addr : '';
$pub = trim((string) ($row['wg_client_public_key'] ?? ''));
$stats = $pub !== '' ? WireGuardTrafficStats::forPublicKey($pub) : null;
return [
'vpn_ip' => is_string($vpnIp) ? trim($vpnIp) : '',
'vpn_public_key' => $pub,
'wg_rx_bytes' => (int) ($stats['rx_bytes'] ?? 0),
'wg_tx_bytes' => (int) ($stats['tx_bytes'] ?? 0),
'wg_latest_handshake' => (int) ($stats['latest_handshake'] ?? 0),
'wg_endpoint' => (string) ($stats['endpoint'] ?? ''),
];
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/** Live WireGuard transfer counters from `wg show dump` (BE data plane). */
final class WireGuardTrafficStats {
private function __construct() {}
/** @return array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}> */
public static function peerMap(): array {
static $cache = null;
static $cacheAt = 0;
if ($cache !== null && (time() - $cacheAt) < 5) {
return $cache;
}
$cache = self::loadPeerMap();
$cacheAt = time();
return $cache;
}
/** @return array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}|null */
public static function forPublicKey(string $publicKey): ?array {
$pub = trim($publicKey);
if ($pub === '') {
return null;
}
return self::peerMap()[$pub] ?? null;
}
/** @return array<string, array{rx_bytes: int, tx_bytes: int, latest_handshake: int, endpoint: string}> */
private static function loadPeerMap(): array {
$iface = (string) cfg('remote_access.wg_interface', 'wg0');
$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 [];
}
$out = [];
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) < 7) {
continue;
}
$pub = trim($cols[0]);
if ($pub === '') {
continue;
}
$out[$pub] = [
'rx_bytes' => (int) ($cols[5] ?? 0),
'tx_bytes' => (int) ($cols[6] ?? 0),
'latest_handshake' => (int) ($cols[4] ?? 0),
'endpoint' => trim((string) ($cols[2] ?? '')),
];
}
return $out;
}
public static function formatBytes(int $bytes): string {
if ($bytes < 1024) {
return $bytes . ' B';
}
if ($bytes < 1024 * 1024) {
return round($bytes / 1024, 1) . ' KiB';
}
if ($bytes < 1024 * 1024 * 1024) {
return round($bytes / (1024 * 1024), 2) . ' MiB';
}
return round($bytes / (1024 * 1024 * 1024), 2) . ' GiB';
}
}

View File

@@ -68,6 +68,7 @@ require_once __DIR__ . '/UrlShortenerDatabase.php';
require_once __DIR__ . '/ShortLinksRepository.php';
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
require_once __DIR__ . '/WireGuardAddressPool.php';
require_once __DIR__ . '/WireGuardTrafficStats.php';
require_once __DIR__ . '/RsshSessionProvisioner.php';
require_once __DIR__ . '/AnalyticsHead.php';

View File

@@ -443,6 +443,7 @@
Devices appear after they poll remote access (dev settings → WireGuard or RSSH). Sort is by <strong>last seen</strong> — top rows are actively polling.
Whitelist rows marked <span class="tag-pill tag-pill--warn">Needs whitelist</span> (opted in, not yet allowed).
Stale rows have not polled in 7+ days.
<strong>Poll source IP</strong> is the address BE sees on the HTTP request (often a router or proxy on <code>10.7.x.x</code>) — not the device WAN or LAN.
</p>
<div class="reports-table-wrap">
<table class="data-table reports-tree reports-table--cols" id="ra-devices-table">