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-12 22:41:34 +02:00
parent 8e0cbba416
commit f9b73b48da
4 changed files with 215 additions and 13 deletions

View File

@@ -21,14 +21,18 @@ import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.R; import com.foxx.androidcast.R;
import com.foxx.androidcast.vpn.VpnServiceClient; import com.foxx.androidcast.vpn.VpnServiceClient;
import org.json.JSONArray; import java.net.Inet4Address;
import org.json.JSONObject; import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.Collections;
import java.util.Random; import java.util.Random;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import org.json.JSONArray;
import org.json.JSONObject;
/** Jittered heartbeat type:ra polling while remote access mode is active. */ /** Jittered heartbeat type:ra polling while remote access mode is active. */
public final class RemoteAccessService extends Service { public final class RemoteAccessService extends Service {
private static final String TAG = "RemoteAccessSvc"; private static final String TAG = "RemoteAccessSvc";
@@ -369,9 +373,34 @@ public final class RemoteAccessService extends Service {
abis.put(abi); abis.put(abi);
} }
device.put("abis", abis); device.put("abis", abis);
String lanIp = localLanIpV4();
if (!lanIp.isEmpty()) {
device.put("lan_ip", lanIp);
}
return device; 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 @Override
public IBinder onBind(Intent intent) { public IBinder onBind(Intent intent) {
return null; return null;

View File

@@ -393,6 +393,10 @@
if (d.sdk_int != null) lines.push('Android API: ' + d.sdk_int); 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.os_release) lines.push('Android version: ' + d.os_release);
if (d.abis && d.abis.length) lines.push('ABIs: ' + formatAbis(d.abis)); 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('App version: ' + (d.app_version || '—'));
lines.push('Opt-in mode: ' + (d.opt_in_mode || 'none')); lines.push('Opt-in mode: ' + (d.opt_in_mode || 'none'));
lines.push('Last seen: ' + (d.last_seen_at || '—')); lines.push('Last seen: ' + (d.last_seen_at || '—'));

View File

@@ -25,6 +25,22 @@ final class RemoteAccessRepository {
self::ensureSessionColumns($pdo); self::ensureSessionColumns($pdo);
self::ensureRsshColumns($pdo); self::ensureRsshColumns($pdo);
self::ensureDeviceMetaColumn($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 { private static function ensureDeviceMetaColumn(PDO $pdo): void {
@@ -275,20 +291,20 @@ final class RemoteAccessRepository {
if ($metaJson !== null) { if ($metaJson !== null) {
$pdo->prepare( $pdo->prepare(
'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, 'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?,
device_meta_json = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?' device_meta_json = ?, last_client_ip = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?'
)->execute([$tunnelMode, $random, $appVersion, $metaJson, $now, $now, $deviceId]); )->execute([$tunnelMode, $random, $appVersion, $metaJson, $clientIp, $now, $now, $deviceId]);
} else { } else {
$pdo->prepare( $pdo->prepare(
'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?, 'UPDATE remote_access_devices SET opt_in_mode = ?, last_random = ?, app_version = ?,
last_seen_at = ?, updated_at = ? WHERE device_id = ?' last_client_ip = ?, last_seen_at = ?, updated_at = ? WHERE device_id = ?'
)->execute([$tunnelMode, $random, $appVersion, $now, $now, $deviceId]); )->execute([$tunnelMode, $random, $appVersion, $clientIp, $now, $now, $deviceId]);
} }
return; return;
} }
$pdo->prepare( $pdo->prepare(
'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, opt_in_mode, last_random, '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) app_version, device_meta_json, last_client_ip, last_seen_at, created_at, updated_at)
VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?)' VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)'
)->execute([ )->execute([
$deviceId, $deviceId,
Rbac::defaultCompanyId(), Rbac::defaultCompanyId(),
@@ -296,6 +312,7 @@ final class RemoteAccessRepository {
$random, $random,
$appVersion, $appVersion,
$metaJson, $metaJson,
$clientIp !== '' ? $clientIp : null,
$now, $now,
$now, $now,
$now, $now,
@@ -308,7 +325,7 @@ final class RemoteAccessRepository {
if ($deviceMeta === []) { if ($deviceMeta === []) {
return null; 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 = []; $clean = [];
foreach ($allowed as $key) { foreach ($allowed as $key) {
if (!array_key_exists($key, $deviceMeta)) { if (!array_key_exists($key, $deviceMeta)) {
@@ -828,6 +845,11 @@ final class RemoteAccessRepository {
$row['os_release'] = $ctx['os_release'] ?? ''; $row['os_release'] = $ctx['os_release'] ?? '';
$row['sdk_int'] = $ctx['sdk_int'] ?? null; $row['sdk_int'] = $ctx['sdk_int'] ?? null;
$row['abis'] = $ctx['abis'] ?? []; $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['issue_count'] = $ctx['issue_count'] ?? 0;
$row['graph_session_count'] = $ctx['graph_session_count'] ?? 0; $row['graph_session_count'] = $ctx['graph_session_count'] ?? 0;
$row['latest_issue_id'] = $ctx['latest_issue_id'] ?? null; $row['latest_issue_id'] = $ctx['latest_issue_id'] ?? null;
@@ -889,6 +911,79 @@ final class RemoteAccessRepository {
return (time() - $t) <= $maxAgeSec; 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);
}
$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<string, mixed> $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<string, mixed> $storedMeta @return array<string, mixed> */ /** @param array<string, mixed> $storedMeta @return array<string, mixed> */
private static function lookupDeviceContext(string $deviceId, int $companyId, array $storedMeta = []): array { private static function lookupDeviceContext(string $deviceId, int $companyId, array $storedMeta = []): array {
if ($deviceId === '') { if ($deviceId === '') {

View File

@@ -4,23 +4,97 @@ declare(strict_types=1);
/** PDO to MariaDB `url_shortener` (separate from crashes DB). */ /** PDO to MariaDB `url_shortener` (separate from crashes DB). */
final class UrlShortenerDatabase { final class UrlShortenerDatabase {
private static ?PDO $pdo = null; private static ?PDO $pdo = null;
/** @var array<string,mixed>|null|false */
private static $resolvedConfig = null;
/** @return array<string,mixed>|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<string,mixed> $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 { public static function enabled(): bool {
return (bool) cfg('url_shortener.enabled', false); return self::resolveConfig() !== null;
} }
public static function publicBase(): string { 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'), '/'); return rtrim((string) cfg('url_shortener.public_base', 'https://s.f0xx.org'), '/');
} }
public static function pdo(): PDO { public static function pdo(): PDO {
if (!self::enabled()) { if (!self::enabled()) {
throw new RuntimeException('url_shortener.enabled is false'); throw new RuntimeException('url_shortener not configured');
} }
if (self::$pdo !== null) { if (self::$pdo !== null) {
return self::$pdo; 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'] ?? '') === '') { if (!is_array($m) || ($m['database'] ?? '') === '') {
throw new RuntimeException('url_shortener.db.mysql not configured'); throw new RuntimeException('url_shortener.db.mysql not configured');
} }