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

initial web rssh stage 1

This commit is contained in:
Anton Afanasyeu
2026-06-03 06:50:57 +02:00
parent bd339ee90c
commit f818ec4f0c
52 changed files with 2173 additions and 10 deletions

View File

@@ -110,6 +110,12 @@ require_once dirname(__DIR__) . '/platform/footer.php';
</span>
<span class="hub-card-label">AndroidCast graphs</span>
</a>
<a class="hub-card card card--lift" href="crashes/?view=remote_access">
<span class="hub-card-icon" aria-hidden="true">
<span class="nav-icon nav-icon--reports"></span>
</span>
<span class="hub-card-label">Remote access</span>
</a>
<a class="hub-card card card--lift" href="build/">
<span class="hub-card-icon" aria-hidden="true">
<span class="nav-icon nav-icon--builder"></span>

View File

@@ -26,6 +26,7 @@ if (!is_array($heartbeat)) {
$type = (string)($heartbeat['type'] ?? 'generic');
$srvEpoch = time();
$srvTz = date('O');
$clientIp = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));
$resp = [
'heartbeat' => [
@@ -52,7 +53,13 @@ if ($type === 'ntp') {
}
if ($type === 'ip') {
$resp['heartbeat']['external_ip'] = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '');
$resp['heartbeat']['external_ip'] = $clientIp;
}
if ($type === 'ra') {
require_once __DIR__ . '/../../src/RemoteAccessRepository.php';
$ra = RemoteAccessRepository::handleDeviceHeartbeat($heartbeat, $clientIp);
$resp['heartbeat'] = array_merge($resp['heartbeat'], $ra);
}
echo json_encode($resp, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
require_once __DIR__ . '/../../src/RemoteAccessRepository.php';
header('Content-Type: application/json; charset=utf-8');
Auth::check();
function json_out(array $payload, int $code = 200): void {
http_response_code($code);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (!Rbac::can('remote_access_view')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = trim((string)($_GET['action'] ?? ''));
if ($method === 'GET') {
if ($action === 'devices') {
json_out(['ok' => true, 'devices' => RemoteAccessRepository::listDevices()]);
}
if ($action === 'sessions') {
$filter = trim((string)($_GET['status'] ?? ''));
json_out(['ok' => true, 'sessions' => RemoteAccessRepository::listSessions($filter)]);
}
if ($action === 'events') {
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)));
json_out([
'ok' => true,
'devices' => RemoteAccessRepository::listDevices(),
'active_sessions' => $active,
'inactive_sessions' => array_slice($inactive, 0, 50),
'recent_events' => RemoteAccessRepository::listEvents(30),
]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
}
if ($method !== 'POST') {
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
}
$raw = file_get_contents('php://input') ?: '';
$body = json_decode($raw, true);
if (!is_array($body)) {
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
}
$user = Auth::user();
$userId = (int)($user['id'] ?? 0);
if ($action === 'whitelist') {
if (!Rbac::can('remote_access_admin')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$deviceId = trim((string)($body['device_id'] ?? ''));
if ($deviceId === '') {
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
}
RemoteAccessRepository::setDeviceWhitelist($deviceId, !empty($body['whitelisted']), $body['notes'] ?? null);
RemoteAccessRepository::logEvent($deviceId, null, $userId, 'whitelist_update', null, [
'whitelisted' => !empty($body['whitelisted']),
], '');
json_out(['ok' => true]);
}
if ($action === 'open_session') {
if (!Rbac::can('remote_access_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$deviceId = trim((string)($body['device_id'] ?? ''));
if ($deviceId === '') {
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
}
$result = RemoteAccessRepository::openSession($deviceId, $userId);
if (empty($result['ok'])) {
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400);
}
json_out(['ok' => true, 'session_id' => $result['session_id'] ?? '']);
}
if ($action === 'close_session') {
if (!Rbac::can('remote_access_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$sessionId = trim((string)($body['session_id'] ?? ''));
if ($sessionId === '') {
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
}
RemoteAccessRepository::closeSession($sessionId, RemoteAccessRepository::STATUS_CLOSED, 'operator_closed');
RemoteAccessRepository::logEvent((string)($body['device_id'] ?? ''), $sessionId, $userId, 'session_close', 'operator', null, '');
json_out(['ok' => true]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);

View File

@@ -0,0 +1,193 @@
(function () {
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function apiUrl(action, params) {
const q = new URLSearchParams(params || {});
q.set('action', action);
return basePath() + '/api/remote_access.php?' + q.toString();
}
function setStatus(msg, isError) {
const el = document.getElementById('ra-status');
if (!el) return;
el.textContent = msg;
el.classList.toggle('error', !!isError);
}
async function fetchJson(url, opts) {
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || ('HTTP ' + res.status));
}
return data;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function renderDevices(devices) {
const tbody = document.getElementById('ra-devices-tbody');
if (!tbody) return;
tbody.innerHTML = '';
(devices || []).forEach((d) => {
const tr = document.createElement('tr');
const wl = Number(d.whitelisted) === 1;
tr.innerHTML =
'<td><code>' + esc(d.device_id) + '</code></td>' +
'<td>' + esc(d.opt_in_mode || 'none') + '</td>' +
'<td>' + (wl ? 'yes' : 'no') + '</td>' +
'<td>' + esc(d.last_seen_at || '—') + '</td>' +
'<td>' + esc(d.app_version || '—') + '</td>' +
'<td>' +
'<button type="button" class="btn btn-sm" data-open-session="' + esc(d.device_id) + '">Open session</button> ' +
'<button type="button" class="btn btn-sm" data-toggle-wl="' + esc(d.device_id) + '" data-wl="' + (wl ? '0' : '1') + '">' +
(wl ? 'Revoke WL' : 'Whitelist') + '</button>' +
'</td>';
tbody.appendChild(tr);
});
}
function renderSessions(active, inactive) {
const aBody = document.getElementById('ra-active-tbody');
const iBody = document.getElementById('ra-inactive-tbody');
if (aBody) {
aBody.innerHTML = '';
(active || []).forEach((s) => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td><code>' + esc(s.session_id) + '</code></td>' +
'<td><code>' + esc(s.device_id) + '</code></td>' +
'<td>' + esc(s.status) + '</td>' +
'<td>' + esc(s.tunnel) + '</td>' +
'<td>' + esc(s.endpoint || '—') + '</td>' +
'<td><button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button></td>';
aBody.appendChild(tr);
});
}
if (iBody) {
iBody.innerHTML = '';
(inactive || []).forEach((s) => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td><code>' + esc(s.session_id) + '</code></td>' +
'<td><code>' + esc(s.device_id) + '</code></td>' +
'<td>' + esc(s.status) + '</td>' +
'<td>' + esc(s.closed_at || '—') + '</td>' +
'<td>' + esc(s.close_reason || '—') + '</td>';
iBody.appendChild(tr);
});
}
}
function renderEvents(events) {
const tbody = document.getElementById('ra-events-tbody');
if (!tbody) return;
tbody.innerHTML = '';
(events || []).forEach((e) => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + esc(e.created_at) + '</td>' +
'<td><code>' + esc(e.device_id) + '</code></td>' +
'<td>' + esc(e.action) + '</td>' +
'<td>' + esc(e.reason || '—') + '</td>';
tbody.appendChild(tr);
});
}
async function refresh() {
setStatus('Loading…');
try {
const data = await fetchJson(apiUrl('dashboard'));
renderDevices(data.devices);
renderSessions(data.active_sessions, data.inactive_sessions);
renderEvents(data.recent_events);
setStatus('Updated ' + new Date().toLocaleTimeString());
} catch (e) {
setStatus('Failed: ' + e.message, true);
}
}
document.addEventListener('click', async (ev) => {
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
const openId = t.getAttribute('data-open-session');
if (openId) {
try {
await fetchJson(apiUrl('open_session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: openId }),
});
await refresh();
} catch (e) {
setStatus('Open session: ' + e.message, true);
}
return;
}
const closeId = t.getAttribute('data-close-session');
if (closeId) {
try {
await fetchJson(apiUrl('close_session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: closeId,
device_id: t.getAttribute('data-device') || '',
}),
});
await refresh();
} catch (e) {
setStatus('Close session: ' + e.message, true);
}
return;
}
const wlDevice = t.getAttribute('data-toggle-wl');
if (wlDevice) {
try {
await fetchJson(apiUrl('whitelist'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: wlDevice,
whitelisted: t.getAttribute('data-wl') === '1',
}),
});
await refresh();
} catch (e) {
setStatus('Whitelist: ' + e.message, true);
}
}
});
const form = document.getElementById('ra-whitelist-form');
if (form) {
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const fd = new FormData(form);
try {
await fetchJson(apiUrl('whitelist'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: fd.get('device_id'),
notes: fd.get('notes'),
whitelisted: true,
}),
});
form.reset();
await refresh();
} catch (e) {
setStatus('Whitelist add: ' + e.message, true);
}
});
}
refresh();
setInterval(refresh, 60000);
})();

View File

@@ -95,6 +95,11 @@ if ($route === '/api/graph_upload.php' || str_ends_with($route, '/api/graph_uplo
exit;
}
if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_access.php')) {
require __DIR__ . '/api/remote_access.php';
exit;
}
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
require __DIR__ . '/api/ticket_attachment.php';
exit;
@@ -197,6 +202,7 @@ $pageTitle = match ($view) {
'home' => 'Home',
'tickets' => 'Tickets',
'graphs' => 'Graphs',
'remote_access' => 'Remote access',
'reports', 'report' => 'Crash reports',
default => 'Console',
};

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Smoke test remote access API (SQLite dev or deployed BE).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BASE="${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
DEVICE_ID="test-ra-device-$(date +%s)"
COOKIE_JAR="$(mktemp)"
trap 'rm -f "$COOKIE_JAR"' EXIT
login() {
curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
-d "username=${ADMIN_USER:-admin}&password=${ADMIN_PASS:-admin}" \
"$BASE/login" >/dev/null
}
echo "== heartbeat ra disable =="
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"disable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r1\"}}" \
"$BASE/api/heartbeat.php" | grep -q '"action":"disabled"' && echo OK
echo "== heartbeat ra enable (not whitelisted → wait) =="
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r2\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \
"$BASE/api/heartbeat.php" | grep -q '"action":"wait"' && echo OK
login
echo "== whitelist device =="
curl -sf -b "$COOKIE_JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" \
"$BASE/api/remote_access.php?action=whitelist" | grep -q '"ok":true' && echo OK
echo "== poll enable (whitelisted, no session → wait) =="
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r3\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \
"$BASE/api/heartbeat.php" | grep -q '"action":"wait"' && echo OK
echo "== open session =="
curl -sf -b "$COOKIE_JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"device_id\":\"$DEVICE_ID\"}" \
"$BASE/api/remote_access.php?action=open_session" | grep -q '"ok":true' && echo OK
echo "== poll → connect wireguard =="
RESP="$(curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r4\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \
"$BASE/api/heartbeat.php")"
echo "$RESP" | grep -q '"action":"connect"' && echo OK
echo "$RESP" | grep -q 'interface_private_key' && echo OK credentials
echo "== dashboard API =="
curl -sf -b "$COOKIE_JAR" "$BASE/api/remote_access.php?action=dashboard" | grep -q '"ok":true' && echo OK
echo "All remote access API checks passed."

View File

@@ -0,0 +1,56 @@
-- Remote access control plane (WireGuard v1; reverse SSH reserved).
-- MariaDB / production: mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql
CREATE TABLE IF NOT EXISTS remote_access_devices (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
device_id VARCHAR(128) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
whitelisted TINYINT(1) NOT NULL DEFAULT 0,
opt_in_mode ENUM('none','wireguard','rssh') NOT NULL DEFAULT 'none',
last_random VARCHAR(96) NULL,
app_version VARCHAR(64) NULL,
last_seen_at TIMESTAMP NULL,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_ra_device (device_id),
KEY idx_ra_device_company (company_id),
KEY idx_ra_device_whitelist (whitelisted, opt_in_mode)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS remote_access_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(36) NOT NULL,
device_id VARCHAR(128) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
operator_user_id INT UNSIGNED NULL,
status ENUM('pending','active','inactive','closed','closed_timeout') NOT NULL DEFAULT 'pending',
tunnel ENUM('wireguard','ssh_reverse') NOT NULL DEFAULT 'wireguard',
endpoint VARCHAR(255) NULL,
wg_client_private_key VARCHAR(64) NULL,
wg_client_address VARCHAR(64) NULL,
wg_server_public_key VARCHAR(64) NULL,
wg_peer_allowed_ips VARCHAR(255) NULL,
expires_at INT UNSIGNED NULL,
last_activity_at TIMESTAMP NULL,
close_reason VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP NULL,
UNIQUE KEY uq_ra_session (session_id),
KEY idx_ra_sess_device (device_id, status),
KEY idx_ra_sess_status (status, last_activity_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS remote_access_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(36) NULL,
device_id VARCHAR(128) NOT NULL,
actor_user_id INT UNSIGNED NULL,
action VARCHAR(64) NOT NULL,
reason VARCHAR(255) NULL,
meta_json LONGTEXT NULL,
client_ip VARCHAR(64) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_ra_evt_device (device_id, created_at),
KEY idx_ra_evt_session (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,50 @@
-- SQLite dev mirror for remote access tables.
CREATE TABLE IF NOT EXISTS remote_access_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL UNIQUE,
company_id INTEGER NOT NULL DEFAULT 1,
whitelisted INTEGER NOT NULL DEFAULT 0,
opt_in_mode TEXT NOT NULL DEFAULT 'none',
last_random TEXT NULL,
app_version TEXT NULL,
last_seen_at TEXT NULL,
notes TEXT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS remote_access_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
device_id TEXT NOT NULL,
company_id INTEGER NOT NULL DEFAULT 1,
operator_user_id INTEGER NULL,
status TEXT NOT NULL DEFAULT 'pending',
tunnel TEXT NOT NULL DEFAULT 'wireguard',
endpoint TEXT NULL,
wg_client_private_key TEXT NULL,
wg_client_address TEXT NULL,
wg_server_public_key TEXT NULL,
wg_peer_allowed_ips TEXT NULL,
expires_at INTEGER NULL,
last_activity_at TEXT NULL,
close_reason TEXT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
closed_at TEXT NULL
);
CREATE TABLE IF NOT EXISTS remote_access_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NULL,
device_id TEXT NOT NULL,
actor_user_id INTEGER NULL,
action TEXT NOT NULL,
reason TEXT NULL,
meta_json TEXT NULL,
client_ip TEXT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ra_evt_device ON remote_access_events(device_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ra_sess_device ON remote_access_sessions(device_id, status);

View File

@@ -30,6 +30,9 @@ final class Rbac {
'manage_company_users',
'manage_company_settings',
'manage_self',
'remote_access_view',
'remote_access_operate',
'remote_access_admin',
],
self::COMPANY_ADMIN => [
'view_reports',
@@ -38,10 +41,14 @@ final class Rbac {
'manage_devices',
'manage_company_users',
'manage_self',
'remote_access_view',
'remote_access_operate',
'remote_access_admin',
],
self::COMPANY_MEMBER => [
'view_reports',
'manage_self',
'remote_access_view',
],
];

View File

@@ -0,0 +1,465 @@
<?php
declare(strict_types=1);
/**
* On-demand remote access control plane (heartbeat type: ra).
* v1: WireGuard opt-in from device; reverse SSH (RSSH) reserved for future use.
*/
final class RemoteAccessRepository {
public const OPT_NONE = 'none';
public const OPT_WIREGUARD = 'wireguard';
public const OPT_RSSH = 'rssh';
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
public const STATUS_CLOSED = 'closed';
public const STATUS_CLOSED_TIMEOUT = 'closed_timeout';
public static function ensureSchema(): void {
$pdo = Database::pdo();
if (Database::tableExists($pdo, 'remote_access_devices')) {
return;
}
self::createTables($pdo);
}
private static function createTables(PDO $pdo): void {
$path = Database::isMysql()
? __DIR__ . '/../sql/migrations/007_remote_access.sql'
: __DIR__ . '/../sql/migrations/007_remote_access.sqlite.sql';
if (!is_readable($path)) {
throw new RuntimeException('remote access migration missing: ' . $path);
}
$sql = (string) file_get_contents($path);
foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) {
if ($stmt !== '') {
$pdo->exec($stmt);
}
}
}
/** @return array<string, mixed> heartbeat.ra response payload */
public static function handleDeviceHeartbeat(array $heartbeat, string $clientIp): array {
self::ensureSchema();
$deviceId = trim((string) ($heartbeat['device_id'] ?? ''));
if ($deviceId === '') {
return self::raEnvelope('deny', ['reason' => 'missing_device_id']);
}
$status = strtolower(trim((string) ($heartbeat['status'] ?? 'enable')));
$random = trim((string) ($heartbeat['random'] ?? ''));
$appVersion = trim((string) ($heartbeat['app_version'] ?? ''));
$capabilities = $heartbeat['capabilities'] ?? [];
if (!is_array($capabilities)) {
$capabilities = [];
}
$tunnelMode = self::inferTunnelMode($capabilities, $heartbeat);
if ($status === 'disable') {
return self::handleDisable($deviceId, $random, $clientIp, $appVersion);
}
self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp);
$device = self::findDevice($deviceId);
if (!$device || !(int) ($device['whitelisted'] ?? 0)) {
self::logEvent($deviceId, null, null, 'poll_wait', 'not_whitelisted', [
'opt_in' => $tunnelMode,
], $clientIp);
return self::raEnvelope('wait', ['reason' => 'not_whitelisted']);
}
if ($tunnelMode === self::OPT_NONE) {
return self::raEnvelope('wait', ['reason' => 'opt_in_none']);
}
if ($tunnelMode === self::OPT_RSSH) {
return self::raEnvelope('deny', ['reason' => 'rssh_not_implemented']);
}
$session = self::findConnectableSession($deviceId, $random);
if ($session === null) {
self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp);
return self::raEnvelope('wait', ['reason' => 'no_pending_session']);
}
return self::buildConnectResponse($session, $deviceId, $clientIp);
}
/** @param list<string> $capabilities */
private static function inferTunnelMode(array $capabilities, array $heartbeat): string {
foreach ($capabilities as $cap) {
$c = strtolower(trim((string) $cap));
if ($c === 'wireguard' || $c === 'wg') {
return self::OPT_WIREGUARD;
}
if ($c === 'ssh_reverse' || $c === 'rssh') {
return self::OPT_RSSH;
}
}
$mode = strtolower(trim((string) ($heartbeat['tunnel_mode'] ?? '')));
if (in_array($mode, [self::OPT_WIREGUARD, self::OPT_RSSH, self::OPT_NONE], true)) {
return $mode;
}
return self::OPT_WIREGUARD;
}
private static function handleDisable(string $deviceId, string $random, string $clientIp, string $appVersion): array {
$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 = ?'
)->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);
return self::raEnvelope('disabled', ['reason' => 'user_opt_out']);
}
private static function upsertDevicePoll(
string $deviceId,
string $random,
string $appVersion,
string $tunnelMode,
string $clientIp
): void {
$pdo = Database::pdo();
$now = self::nowSql();
$existing = self::findDevice($deviceId);
if ($existing) {
$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]);
return;
}
$pdo->prepare(
'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, opt_in_mode, last_random, app_version, last_seen_at, created_at, updated_at)
VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?)'
)->execute([
$deviceId,
Rbac::defaultCompanyId(),
$tunnelMode,
$random,
$appVersion,
$now,
$now,
$now,
]);
self::logEvent($deviceId, null, null, 'device_seen', 'first_poll', ['opt_in' => $tunnelMode], $clientIp);
}
/** @return array<string, mixed>|null */
private static function findDevice(string $deviceId): ?array {
$st = Database::pdo()->prepare('SELECT * FROM remote_access_devices WHERE device_id = ? LIMIT 1');
$st->execute([$deviceId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
/** @return array<string, mixed>|null */
private static function findConnectableSession(string $deviceId, string $random): ?array {
$pdo = Database::pdo();
$st = $pdo->prepare(
"SELECT * FROM remote_access_sessions
WHERE device_id = ? AND status IN ('pending','active')
ORDER BY id DESC LIMIT 5"
);
$st->execute([$deviceId]);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
if (!is_array($rows)) {
return null;
}
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$exp = (int) ($row['expires_at'] ?? 0);
if ($exp > 0 && $exp < time()) {
self::closeSession((string) $row['session_id'], self::STATUS_CLOSED_TIMEOUT, 'expired');
continue;
}
return $row;
}
return null;
}
/** @param array<string, mixed> $session */
private static function buildConnectResponse(array $session, string $deviceId, string $clientIp): array {
$sessionId = (string) ($session['session_id'] ?? '');
$tunnel = (string) ($session['tunnel'] ?? 'wireguard');
if ($tunnel !== 'wireguard') {
return self::raEnvelope('deny', ['reason' => 'tunnel_not_supported', 'tunnel' => $tunnel]);
}
$keys = self::ensureSessionWireGuardKeys($sessionId, $session);
$endpoint = (string) ($session['endpoint'] ?? cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:51820'));
$expiresAt = (int) ($session['expires_at'] ?? (time() + 3600));
self::markSessionActive($sessionId);
self::logEvent($deviceId, $sessionId, null, 'tunnel_connect', 'wireguard', null, $clientIp);
return self::raEnvelope('connect', [
'session_id' => $sessionId,
'endpoint' => $endpoint,
'tunnel' => 'wireguard',
'credentials' => [
'mode' => 'ephemeral_pubkey',
'interface_private_key' => $keys['client_private'],
'interface_address' => $keys['client_address'],
'peer_public_key' => $keys['server_public'],
'peer_endpoint' => $endpoint,
'peer_allowed_ips' => $keys['allowed_ips'],
],
'expires_at' => $expiresAt,
]);
}
/** @param array<string, mixed> $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string} */
private static function ensureSessionWireGuardKeys(string $sessionId, array $session): array {
$priv = trim((string) ($session['wg_client_private_key'] ?? ''));
$addr = trim((string) ($session['wg_client_address'] ?? ''));
$pub = trim((string) ($session['wg_server_public_key'] ?? ''));
$allowed = trim((string) ($session['wg_peer_allowed_ips'] ?? ''));
if ($priv !== '' && $addr !== '' && $pub !== '') {
return [
'client_private' => $priv,
'client_address' => $addr,
'server_public' => $pub,
'allowed_ips' => $allowed !== '' ? $allowed : '10.66.66.1/32',
];
}
$pair = self::generateWireGuardKeyPair();
$clientIp = self::allocateClientAddress($sessionId);
$serverPub = cfg('remote_access.wg_server_public_key', '');
if ($serverPub === '') {
$serverPair = self::generateWireGuardKeyPair();
$serverPub = $serverPair['public'];
}
$allowed = '10.66.66.1/32';
Database::pdo()->prepare(
'UPDATE remote_access_sessions SET wg_client_private_key = ?, wg_client_address = ?, wg_server_public_key = ?, wg_peer_allowed_ips = ? WHERE session_id = ?'
)->execute([$pair['private'], $clientIp, $serverPub, $allowed, $sessionId]);
return [
'client_private' => $pair['private'],
'client_address' => $clientIp,
'server_public' => $serverPub,
'allowed_ips' => $allowed,
];
}
private static function allocateClientAddress(string $sessionId): string {
$n = abs(crc32($sessionId)) % 200 + 2;
return '10.66.66.' . $n . '/32';
}
/** @return array{private:string,public:string} */
public static function generateWireGuardKeyPair(): array {
$priv = trim((string) @shell_exec('wg genkey 2>/dev/null') ?: '');
if ($priv !== '') {
$pub = trim((string) @shell_exec('echo ' . escapeshellarg($priv) . ' | wg pubkey 2>/dev/null') ?: '');
if ($pub !== '') {
return ['private' => $priv, 'public' => $pub];
}
}
$raw = random_bytes(32);
$priv = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
return ['private' => $priv, 'public' => 'dev-placeholder-pub-' . substr(hash('sha256', $priv), 0, 43)];
}
private static function markSessionActive(string $sessionId): void {
$now = self::nowSql();
Database::pdo()->prepare(
"UPDATE remote_access_sessions SET status = 'active', last_activity_at = ? WHERE session_id = ?"
)->execute([$now, $sessionId]);
}
public static function closeActiveSessionsForDevice(string $deviceId, string $reason): void {
$st = Database::pdo()->prepare(
"SELECT session_id FROM remote_access_sessions WHERE device_id = ? AND status IN ('pending','active')"
);
$st->execute([$deviceId]);
foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $sid) {
self::closeSession((string) $sid, self::STATUS_CLOSED, $reason);
}
}
public static function closeSession(string $sessionId, string $status, string $reason): void {
$now = self::nowSql();
Database::pdo()->prepare(
'UPDATE remote_access_sessions SET status = ?, close_reason = ?, closed_at = ?, last_activity_at = ? WHERE session_id = ?'
)->execute([$status, $reason, $now, $now, $sessionId]);
}
/** @return array{ok:bool,session_id?:string} */
public static function openSession(string $deviceId, int $operatorUserId, ?int $companyId = null): array {
self::ensureSchema();
$device = self::findDevice($deviceId);
if (!$device || !(int) ($device['whitelisted'] ?? 0)) {
return ['ok' => false, 'error' => 'device_not_whitelisted'];
}
if ((string) ($device['opt_in_mode'] ?? self::OPT_NONE) !== self::OPT_WIREGUARD) {
return ['ok' => false, 'error' => 'device_not_opted_in_wg'];
}
self::closeActiveSessionsForDevice($deviceId, 'superseded');
$sessionId = self::uuid4();
$companyId = $companyId ?? (int) ($device['company_id'] ?? Rbac::defaultCompanyId());
$endpoint = (string) cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:51820');
$expiresAt = time() + (int) cfg('remote_access.session_ttl_s', 3600);
$now = self::nowSql();
Database::pdo()->prepare(
'INSERT INTO remote_access_sessions (session_id, device_id, company_id, operator_user_id, status, tunnel, endpoint, expires_at, last_activity_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
)->execute([
$sessionId,
$deviceId,
$companyId,
$operatorUserId,
self::STATUS_PENDING,
'wireguard',
$endpoint,
$expiresAt,
$now,
$now,
]);
self::logEvent($deviceId, $sessionId, $operatorUserId, 'session_open', 'operator_requested', null, '');
return ['ok' => true, 'session_id' => $sessionId];
}
/** @return list<array<string, mixed>> */
public static function listDevices(?int $companyId = null): array {
self::ensureSchema();
$sql = 'SELECT * FROM remote_access_devices';
$params = [];
if ($companyId !== null && !Rbac::isGlobalAdmin()) {
$sql .= ' WHERE company_id = ?';
$params[] = $companyId;
}
$sql .= ' ORDER BY last_seen_at DESC, device_id ASC';
$st = Database::pdo()->prepare($sql);
$st->execute($params);
return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
/** @return list<array<string, mixed>> */
public static function listSessions(string $statusFilter = ''): array {
self::ensureSchema();
$sql = 'SELECT s.*, d.opt_in_mode, d.app_version AS device_app_version FROM remote_access_sessions s
LEFT JOIN remote_access_devices d ON d.device_id = s.device_id';
$params = [];
if ($statusFilter !== '') {
$sql .= ' WHERE s.status = ?';
$params[] = $statusFilter;
}
$sql .= ' ORDER BY s.id DESC LIMIT 200';
$st = Database::pdo()->prepare($sql);
$st->execute($params);
return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
/** @return list<array<string, mixed>> */
public static function listEvents(int $limit = 100): array {
self::ensureSchema();
$st = Database::pdo()->prepare('SELECT * FROM remote_access_events ORDER BY id DESC LIMIT ?');
$st->bindValue(1, max(1, min(500, $limit)), PDO::PARAM_INT);
$st->execute();
return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
public static function setDeviceWhitelist(string $deviceId, bool $whitelisted, ?string $notes = null): bool {
self::ensureSchema();
$device = self::findDevice($deviceId);
if (!$device) {
Database::pdo()->prepare(
'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
)->execute([
$deviceId,
Rbac::defaultCompanyId(),
$whitelisted ? 1 : 0,
$notes,
self::nowSql(),
self::nowSql(),
]);
return true;
}
Database::pdo()->prepare(
'UPDATE remote_access_devices SET whitelisted = ?, notes = COALESCE(?, notes), updated_at = ? WHERE device_id = ?'
)->execute([$whitelisted ? 1 : 0, $notes, self::nowSql(), $deviceId]);
return true;
}
public static function purgeStale(int $maxAgeHours = 24): int {
self::ensureSchema();
$cutoff = time() - max(1, $maxAgeHours) * 3600;
$pdo = Database::pdo();
if (Database::isMysql()) {
$st = $pdo->prepare(
"SELECT session_id FROM remote_access_sessions WHERE status IN ('inactive','closed','closed_timeout','pending','active')
AND ((expires_at IS NOT NULL AND expires_at < ?) OR (last_activity_at IS NOT NULL AND UNIX_TIMESTAMP(last_activity_at) < ?))"
);
$st->execute([time(), $cutoff]);
} else {
$cutoffIso = gmdate('Y-m-d H:i:s', $cutoff);
$st = $pdo->prepare(
"SELECT session_id FROM remote_access_sessions WHERE status IN ('inactive','closed','closed_timeout','pending','active')
AND ((expires_at IS NOT NULL AND expires_at < ?) OR (last_activity_at IS NOT NULL AND last_activity_at < ?))"
);
$st->execute([time(), $cutoffIso]);
}
$n = 0;
foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $sid) {
self::closeSession((string) $sid, self::STATUS_CLOSED_TIMEOUT, 'purge_stale');
$n++;
}
return $n;
}
public static function logEvent(
string $deviceId,
?string $sessionId,
?int $actorUserId,
string $action,
?string $reason,
?array $meta,
string $clientIp
): void {
self::ensureSchema();
Database::pdo()->prepare(
'INSERT INTO remote_access_events (session_id, device_id, actor_user_id, action, reason, meta_json, client_ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
)->execute([
$sessionId,
$deviceId,
$actorUserId,
$action,
$reason,
$meta !== null ? json_encode($meta, JSON_UNESCAPED_UNICODE) : null,
$clientIp !== '' ? $clientIp : null,
self::nowSql(),
]);
}
/** @param array<string, mixed> $extra */
private static function raEnvelope(string $action, array $extra = []): array {
return array_merge([
'type' => 'ra',
'action' => $action,
'server_date' => time(),
], $extra);
}
private static function nowSql(): string {
return Database::isMysql() ? date('Y-m-d H:i:s') : gmdate('Y-m-d H:i:s');
}
private static function uuid4(): string {
$b = random_bytes(16);
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
$b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
$h = bin2hex($b);
return substr($h, 0, 8) . '-' . substr($h, 8, 4) . '-' . substr($h, 12, 4)
. '-' . substr($h, 16, 4) . '-' . substr($h, 20, 12);
}
}

View File

@@ -45,6 +45,7 @@ require_once __DIR__ . '/TicketCommentRepository.php';
require_once __DIR__ . '/UserRepository.php';
require_once __DIR__ . '/TicketRepository.php';
require_once __DIR__ . '/GraphRepository.php';
require_once __DIR__ . '/RemoteAccessRepository.php';
require_once __DIR__ . '/AnalyticsHead.php';
function cfg(string $key, $default = null) {

View File

@@ -34,6 +34,9 @@
<?php if (($view ?? '') === 'graphs'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'remote_access'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
<?php endif; ?>
<?php AnalyticsHead::render('crashes'); ?>
</head>
<body data-base-path="<?= h(Auth::basePath()) ?>"
@@ -87,6 +90,17 @@
<span class="nav-label">Graphs</span>
</a>
</li>
<?php if (Rbac::can('remote_access_view')): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=remote_access"
class="nav-link <?= ($view ?? '') === 'remote_access' ? 'active' : '' ?>"
aria-label="Remote access"
title="Remote access">
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<span class="nav-label">Remote access</span>
</a>
</li>
<?php endif; ?>
<li>
<a href="/app/androidcast_project/build/"
class="nav-link"
@@ -320,6 +334,108 @@
<code><?= h(Auth::basePath()) ?>/api/graphs.php</code>
</p>
</div>
<?php elseif (($view ?? '') === 'remote_access'): ?>
<div id="remote-access-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Remote access</h1>
<div class="toolbar-actions">
<label class="toolbar-select">
<span>Theme</span>
<select id="theme-select" aria-label="UI theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
</div>
</div>
<nav class="graphs-quick-links" aria-label="Related services">
<a href="<?= h(Auth::basePath()) ?>/?view=home">Console home</a>
<a href="<?= h(Auth::basePath()) ?>/?view=reports">Crash reports</a>
<a href="<?= h(Auth::basePath()) ?>/?view=tickets">Tickets</a>
<a href="/app/androidcast_project/graphs/">Graphs</a>
<a href="/app/androidcast_project/build/">Builder</a>
<a href="/app/androidcast_project/">Hub</a>
</nav>
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Whitelisted devices</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="ra-devices-table">
<thead>
<tr>
<th scope="col">Device ID</th>
<th scope="col">Opt-in</th>
<th scope="col">Whitelisted</th>
<th scope="col">Last seen</th>
<th scope="col">App</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="ra-devices-tbody"></tbody>
</table>
</div>
<form id="ra-whitelist-form" class="tag-editor-add" style="margin-top:1rem">
<label class="tag-field"><span>Device ID</span><input type="text" name="device_id" required maxlength="128"></label>
<label class="tag-field"><span>Notes</span><input type="text" name="notes" maxlength="255"></label>
<button type="submit" class="btn">Add / whitelist</button>
</form>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Active / pending sessions</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="ra-active-table">
<thead>
<tr>
<th scope="col">Session</th>
<th scope="col">Device</th>
<th scope="col">Status</th>
<th scope="col">Tunnel</th>
<th scope="col">Endpoint</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="ra-active-tbody"></tbody>
</table>
</div>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Inactive / closed sessions</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="ra-inactive-table">
<thead>
<tr>
<th scope="col">Session</th>
<th scope="col">Device</th>
<th scope="col">Status</th>
<th scope="col">Closed</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody id="ra-inactive-tbody"></tbody>
</table>
</div>
</section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Audit log</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="ra-events-table">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Device</th>
<th scope="col">Action</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody id="ra-events-tbody"></tbody>
</table>
</div>
</section>
</div>
<?php endif; ?>
</main>
</div>