From f9b73b48da3decd9605a32518ea3351b5a84df6b Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Fri, 12 Jun 2026 22:41:34 +0200 Subject: [PATCH] sync --- .../remoteaccess/RemoteAccessService.java | 35 +++++- .../backend/public/assets/js/remote_access.js | 4 + .../backend/src/RemoteAccessRepository.php | 109 ++++++++++++++++-- .../backend/src/UrlShortenerDatabase.php | 80 ++++++++++++- 4 files changed, 215 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java index d601e5b..eaa43a6 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java @@ -21,14 +21,18 @@ import com.foxx.androidcast.DeviceInfo; import com.foxx.androidcast.R; import com.foxx.androidcast.vpn.VpnServiceClient; -import org.json.JSONArray; -import org.json.JSONObject; - +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.Collections; import java.util.Random; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import org.json.JSONArray; +import org.json.JSONObject; + /** Jittered heartbeat type:ra polling while remote access mode is active. */ public final class RemoteAccessService extends Service { private static final String TAG = "RemoteAccessSvc"; @@ -369,9 +373,34 @@ public final class RemoteAccessService extends Service { abis.put(abi); } device.put("abis", abis); + String lanIp = localLanIpV4(); + if (!lanIp.isEmpty()) { + device.put("lan_ip", lanIp); + } return device; } + private static String localLanIpV4() { + try { + for (NetworkInterface intf : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (intf == null || !intf.isUp() || intf.isLoopback()) { + continue; + } + for (InetAddress addr : Collections.list(intf.getInetAddresses())) { + if (addr == null || addr.isLoopbackAddress() || !(addr instanceof Inet4Address)) { + continue; + } + String host = addr.getHostAddress(); + if (host != null && !host.isEmpty()) { + return host; + } + } + } + } catch (Exception ignored) { + } + return ""; + } + @Override public IBinder onBind(Intent intent) { return null; diff --git a/examples/crash_reporter/backend/public/assets/js/remote_access.js b/examples/crash_reporter/backend/public/assets/js/remote_access.js index fd15f1b..b33b942 100644 --- a/examples/crash_reporter/backend/public/assets/js/remote_access.js +++ b/examples/crash_reporter/backend/public/assets/js/remote_access.js @@ -393,6 +393,10 @@ 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.vpn_ip) lines.push('VPN IP: ' + d.vpn_ip); + if (d.vpn_public_key) lines.push('VPN public key: ' + d.vpn_public_key); 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 || '—')); diff --git a/examples/crash_reporter/backend/src/RemoteAccessRepository.php b/examples/crash_reporter/backend/src/RemoteAccessRepository.php index 3e54856..9243308 100644 --- a/examples/crash_reporter/backend/src/RemoteAccessRepository.php +++ b/examples/crash_reporter/backend/src/RemoteAccessRepository.php @@ -25,6 +25,22 @@ final class RemoteAccessRepository { self::ensureSessionColumns($pdo); self::ensureRsshColumns($pdo); self::ensureDeviceMetaColumn($pdo); + self::ensureLastClientIpColumn($pdo); + } + + 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; + } + } } private static function ensureDeviceMetaColumn(PDO $pdo): void { @@ -275,20 +291,20 @@ final class RemoteAccessRepository { if ($metaJson !== null) { $pdo->prepare( 'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, - device_meta_json = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?' - )->execute([$tunnelMode, $random, $appVersion, $metaJson, $now, $now, $deviceId]); + device_meta_json = ?, last_client_ip = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?' + )->execute([$tunnelMode, $random, $appVersion, $metaJson, $clientIp, $now, $now, $deviceId]); } else { $pdo->prepare( 'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, - last_seen_at = ?, updated_at = ? WHERE device_id = ?' - )->execute([$tunnelMode, $random, $appVersion, $now, $now, $deviceId]); + last_client_ip = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?' + )->execute([$tunnelMode, $random, $appVersion, $clientIp, $now, $now, $deviceId]); } return; } $pdo->prepare( 'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, opt_in_mode, last_random, - app_version, device_meta_json, last_seen_at, created_at, updated_at) - VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?)' + app_version, device_meta_json, last_client_ip, last_seen_at, created_at, updated_at) + VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)' )->execute([ $deviceId, Rbac::defaultCompanyId(), @@ -296,6 +312,7 @@ final class RemoteAccessRepository { $random, $appVersion, $metaJson, + $clientIp !== '' ? $clientIp : null, $now, $now, $now, @@ -308,7 +325,7 @@ final class RemoteAccessRepository { if ($deviceMeta === []) { return null; } - $allowed = ['name', 'manufacturer', 'brand', 'model', 'device', 'product', 'sdk_int', 'release', 'abis']; + $allowed = ['name', 'manufacturer', 'brand', 'model', 'device', 'product', 'sdk_int', 'release', 'abis', 'lan_ip', 'wan_ip']; $clean = []; foreach ($allowed as $key) { if (!array_key_exists($key, $deviceMeta)) { @@ -828,6 +845,11 @@ 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); + $net = self::lookupDeviceSessionNetwork($deviceId); + $row['vpn_ip'] = $net['vpn_ip'] ?? ''; + $row['vpn_public_key'] = $net['vpn_public_key'] ?? ''; $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; @@ -889,6 +911,79 @@ final class RemoteAccessRepository { return (time() - $t) <= $maxAgeSec; } + /** @param array $storedMeta */ + private static function resolveDeviceWanIp(array $row, array $storedMeta): string { + $fromDevice = trim((string) ($storedMeta['wan_ip'] ?? '')); + if ($fromDevice !== '') { + return self::normalizeClientIp($fromDevice); + } + $fromRow = trim((string) ($row['last_client_ip'] ?? '')); + if ($fromRow !== '') { + return self::normalizeClientIp($fromRow); + } + $deviceId = (string) ($row['device_id'] ?? ''); + if ($deviceId === '') { + return ''; + } + $st = Database::pdo()->prepare( + "SELECT client_ip FROM remote_access_events + WHERE device_id = ? AND client_ip IS NOT NULL AND client_ip != '' + ORDER BY id DESC LIMIT 1" + ); + $st->execute([$deviceId]); + $ip = trim((string) ($st->fetchColumn() ?: '')); + + return self::normalizeClientIp($ip); + } + + /** @param array $storedMeta */ + private static function resolveDeviceLanIp(array $storedMeta): string { + return trim((string) ($storedMeta['lan_ip'] ?? '')); + } + + private static function normalizeClientIp(string $raw): string { + $raw = trim($raw); + if ($raw === '') { + return ''; + } + if (str_contains($raw, ',')) { + $raw = trim(explode(',', $raw)[0]); + } + + return $raw; + } + + /** @return array{vpn_ip: string, vpn_public_key: string} */ + private static function lookupDeviceSessionNetwork(string $deviceId): array { + $empty = ['vpn_ip' => '', 'vpn_public_key' => '']; + if ($deviceId === '') { + 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'] ?? ''); + if ($tunnel !== '' && $tunnel !== 'wireguard') { + return $empty; + } + $addr = trim((string) ($row['wg_client_address'] ?? '')); + $vpnIp = $addr !== '' ? preg_replace('/\/\d+$/', '', $addr) ?? $addr : ''; + $pub = trim((string) ($row['wg_client_public_key'] ?? '')); + + return [ + 'vpn_ip' => is_string($vpnIp) ? trim($vpnIp) : '', + 'vpn_public_key' => $pub, + ]; + } + /** @param array $storedMeta @return array */ private static function lookupDeviceContext(string $deviceId, int $companyId, array $storedMeta = []): array { if ($deviceId === '') { diff --git a/examples/crash_reporter/backend/src/UrlShortenerDatabase.php b/examples/crash_reporter/backend/src/UrlShortenerDatabase.php index b42f176..552f466 100644 --- a/examples/crash_reporter/backend/src/UrlShortenerDatabase.php +++ b/examples/crash_reporter/backend/src/UrlShortenerDatabase.php @@ -4,23 +4,97 @@ declare(strict_types=1); /** PDO to MariaDB `url_shortener` (separate from crashes DB). */ final class UrlShortenerDatabase { private static ?PDO $pdo = null; + /** @var array|null|false */ + private static $resolvedConfig = null; + + /** @return array|null */ + private static function resolveConfig(): ?array { + if (self::$resolvedConfig !== null) { + return self::$resolvedConfig === false ? null : self::$resolvedConfig; + } + $block = cfg('url_shortener', null); + if (is_array($block)) { + if (!empty($block['enabled']) && is_array($block['db']['mysql'] ?? null)) { + self::$resolvedConfig = $block; + return $block; + } + if (is_array($block['db']['mysql'] ?? null) && ($block['db']['mysql']['database'] ?? '') !== '') { + $block['enabled'] = true; + self::$resolvedConfig = $block; + return $block; + } + } + $crashDb = cfg('db.mysql', []); + if (!is_array($crashDb) || ($crashDb['password'] ?? '') === '') { + self::$resolvedConfig = false; + return null; + } + $m = $crashDb; + $m['database'] = 'url_shortener'; + $candidate = [ + 'enabled' => true, + 'public_base' => 'https://s.f0xx.org', + 'db' => ['mysql' => $m], + ]; + if (!self::pingMysql($m)) { + self::$resolvedConfig = false; + return null; + } + self::$resolvedConfig = $candidate; + return $candidate; + } + + /** @param array $m */ + private static function pingMysql(array $m): bool { + try { + $db = (string) ($m['database'] ?? ''); + if ($db === '') { + return false; + } + $charset = (string) ($m['charset'] ?? 'utf8mb4'); + $socket = trim((string) ($m['socket'] ?? '')); + if ($socket !== '') { + $dsn = sprintf('mysql:unix_socket=%s;dbname=%s;charset=%s', $socket, $db, $charset); + } else { + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $m['host'] ?? '127.0.0.1', + (int) ($m['port'] ?? 3306), + $db, + $charset + ); + } + $pdo = new PDO($dsn, (string) ($m['username'] ?? ''), (string) ($m['password'] ?? ''), [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + $pdo->query('SELECT 1'); + return true; + } catch (Throwable) { + return false; + } + } public static function enabled(): bool { - return (bool) cfg('url_shortener.enabled', false); + return self::resolveConfig() !== null; } public static function publicBase(): string { + $block = self::resolveConfig(); + if ($block !== null) { + return rtrim((string) ($block['public_base'] ?? 'https://s.f0xx.org'), '/'); + } return rtrim((string) cfg('url_shortener.public_base', 'https://s.f0xx.org'), '/'); } public static function pdo(): PDO { if (!self::enabled()) { - throw new RuntimeException('url_shortener.enabled is false'); + throw new RuntimeException('url_shortener not configured'); } if (self::$pdo !== null) { return self::$pdo; } - $m = cfg('url_shortener.db.mysql', []); + $block = self::resolveConfig(); + $m = is_array($block) ? ($block['db']['mysql'] ?? []) : []; if (!is_array($m) || ($m['database'] ?? '') === '') { throw new RuntimeException('url_shortener.db.mysql not configured'); }