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

footer changes, remote access changes

This commit is contained in:
Anton Afanasyeu
2026-06-04 15:14:08 +02:00
parent f818ec4f0c
commit a29d84038e
30 changed files with 1612 additions and 92 deletions

View File

@@ -58,15 +58,18 @@ Loads `public/assets/js/analytics.js` from `layout.php` and `login.php`. The pro
Events include `page_view`, `console_view` (home, tickets, reports, …), and hub `hub_nav_click` / `hub_open_docs`.
## RBAC (phase 1)
## RBAC (phase 1 + admin UI)
Multi-tenant foundation (no admin UI yet):
Multi-tenant foundation:
- **Global roles** on `users.role`: `root`, `platform_admin` (legacy `admin` works), `viewer`
- **Companies** + **company_memberships** (`owner` / `admin` / `member`)
- **Companies** + **company_memberships** (`owner` / `admin` / `member`)**slug admin** = `owner` or `admin` on a company (graphs company scope)
- **Privilege sets** on `company_memberships.permissions_json`: `remote_access_user`, `remote_access_admin` (extra grants for `member` rows)
- **Devices** auto-registered on upload; reports get `company_id` + `device_id`
- Default company slug `default` — existing rows migrate on first request (`Database::ensureRbacSchema()`)
**Admin UI:** `?view=rbac` (nav **Access**) — company owner/admin and global admins. **Root only** can change global roles. API: `GET/POST …/api/rbac.php` (`panel`, `set_global_role`, `set_company_role`, `apply_privilege_set`). Smoke: `./scripts/test_rbac_api.sh`.
Config (`config.php`):
```php
@@ -269,6 +272,36 @@ Payload (`schema_version=1`): `session_id`, `company_id`, `user_id`, `device_id`
NTP heartbeat (`POST …/api/heartbeat.php`, `type=ntp`) returns `time_source` and `ntp_correction_s`
for clients that sync clocks before upload.
## Remote access (WireGuard v1)
On-demand debug tunnels — **same crashes vhost**, no new nginx HTTP locations. Full notes: [docs/REMOTE_ACCESS_IMPL.md](../../../docs/REMOTE_ACCESS_IMPL.md).
- Dashboard: `https://apps.f0xx.org/app/androidcast_project/crashes/?view=remote_access`
- Hub card: **Remote access**
- Device poll: `POST …/api/heartbeat.php` with `heartbeat.type = ra`
- Admin API: `…/api/remote_access.php` (session cookie + RBAC)
**MariaDB (existing server):** run once as root if not already applied:
```sh
mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql
```
**Production `config.php`** (not in git) — set `remote_access.wg_server_public_key` from `wg show wg0 public-key`, `require_wg_tools => true`, and `wg_endpoint` (public UDP DNAT hostname). See `config.example.php`.
**Cron:** `php scripts/purge_remote_access.php --hours=24` (hourly).
Smoke / verify:
```bash
./scripts/test_rbac_api.sh
./scripts/test_remote_access_api.sh
BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_remote_access_api.sh
./scripts/verify_remote_access_prod.sh
```
Android: Developer settings → **Remote access** → WireGuard (RSSH reserved). Requires VPN permission; polls BE every 17 min while enabled.
## Default accounts
| User | Password | Role |

View File

@@ -45,4 +45,18 @@ return [
'measurement_id' => '', // e.g. G-XXXXXXXXXX
'debug' => false,
],
// On-demand remote access (WireGuard v1) — see docs/REMOTE_ACCESS_IMPL.md
'remote_access' => [
// Public UDP endpoint shown to devices (FE DNAT → BE wg0)
'wg_endpoint' => 'ra.apps.f0xx.org:51820',
// Server WG public key (wg show wg0 public-key on BE)
'wg_server_public_key' => '',
'wg_interface' => 'wg0',
// Run `wg set wg0 peer …` when device receives connect (requires wireguard-tools on BE)
'provision_peers' => true,
// Reject connect if wg genkey/pubkey unavailable (set true on production BE)
'require_wg_tools' => false,
'session_ttl_s' => 3600,
'min_poll_interval_s' => 45,
],
];

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
require_once __DIR__ . '/../../src/RbacAdminRepository.php';
header('Content-Type: application/json; charset=utf-8');
Auth::check();
if (!Rbac::canManageRbac()) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = trim((string) ($_GET['action'] ?? ''));
if ($method === 'GET' && $action === 'panel') {
$panel = RbacAdminRepository::buildPanel();
json_out($panel, empty($panel['ok']) ? 403 : 200);
}
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);
}
if ($action === 'set_global_role') {
$result = RbacAdminRepository::setGlobalRole(
(int) ($body['user_id'] ?? 0),
(string) ($body['role'] ?? '')
);
json_out($result, empty($result['ok']) ? 400 : 200);
}
if ($action === 'set_company_role') {
$result = RbacAdminRepository::setCompanyRole(
(int) ($body['user_id'] ?? 0),
(int) ($body['company_id'] ?? 0),
(string) ($body['role'] ?? '')
);
json_out($result, empty($result['ok']) ? 400 : 200);
}
if ($action === 'apply_privilege_set') {
$result = RbacAdminRepository::applyPrivilegeSet(
(int) ($body['user_id'] ?? 0),
(int) ($body['company_id'] ?? 0),
(string) ($body['privilege_set'] ?? '')
);
json_out($result, empty($result['ok']) ? 400 : 200);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);

View File

@@ -41,6 +41,13 @@ if ($method === 'GET') {
'active_sessions' => $active,
'inactive_sessions' => array_slice($inactive, 0, 50),
'recent_events' => RemoteAccessRepository::listEvents(30),
'permissions' => [
'can_operate' => Rbac::can('remote_access_operate'),
'can_admin' => Rbac::can('remote_access_admin'),
],
'config' => [
'wg_endpoint' => (string) cfg('remote_access.wg_endpoint', ''),
],
]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
@@ -67,7 +74,12 @@ if ($action === 'whitelist') {
if ($deviceId === '') {
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
}
RemoteAccessRepository::setDeviceWhitelist($deviceId, !empty($body['whitelisted']), $body['notes'] ?? null);
if (!RemoteAccessRepository::canAccessDevice($deviceId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
if (!RemoteAccessRepository::setDeviceWhitelist($deviceId, !empty($body['whitelisted']), $body['notes'] ?? null)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
RemoteAccessRepository::logEvent($deviceId, null, $userId, 'whitelist_update', null, [
'whitelisted' => !empty($body['whitelisted']),
], '');
@@ -82,9 +94,13 @@ if ($action === 'open_session') {
if ($deviceId === '') {
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
}
if (!RemoteAccessRepository::canAccessDevice($deviceId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$result = RemoteAccessRepository::openSession($deviceId, $userId);
if (empty($result['ok'])) {
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400);
$err = $result['error'] ?? 'failed';
json_out(['ok' => false, 'error' => $err], $err === 'forbidden' ? 403 : 400);
}
json_out(['ok' => true, 'session_id' => $result['session_id'] ?? '']);
}
@@ -97,6 +113,9 @@ if ($action === 'close_session') {
if ($sessionId === '') {
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
}
if (!RemoteAccessRepository::canOperatorCloseSession($sessionId, $userId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
RemoteAccessRepository::closeSession($sessionId, RemoteAccessRepository::STATUS_CLOSED, 'operator_closed');
RemoteAccessRepository::logEvent((string)($body['device_id'] ?? ''), $sessionId, $userId, 'session_close', 'operator', null, '');
json_out(['ok' => true]);

View File

@@ -438,6 +438,41 @@ a:hover { text-decoration: underline; }
background: var(--surface);
z-index: 40;
}
.platform-footer {
flex-direction: column;
align-items: stretch;
justify-content: center;
height: auto;
min-height: 40px;
padding: 6px 16px;
gap: 4px;
}
.platform-footer__top,
.platform-footer__bottom {
width: 100%;
text-align: center;
line-height: 1.35;
}
.platform-footer__main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
min-height: 24px;
}
.platform-footer--single-row .platform-footer__main {
justify-content: flex-start;
}
.platform-footer__left {
flex: 1 1 auto;
min-width: 0;
}
.platform-footer__right {
flex: 0 0 auto;
text-align: right;
max-width: 50%;
}
.login-page {
display: flex;
align-items: center;

View File

@@ -119,7 +119,7 @@
"login.register": "Register",
"login.register_soon": "(coming soon)",
"login.error": "Invalid credentials",
"footer.copyright": "(c) Anton Afanaasyeu, {year}",
"footer.copyright": "© Anton Afanaasyeu, {year}",
"tickets.title": "Tickets",
"tickets.empty": "No tickets yet.",
"tickets.col_issue": "Issue",

View File

@@ -120,7 +120,7 @@
"login.register": "Регистрация",
"login.register_soon": "(скоро)",
"login.error": "Неверные учётные данные",
"footer.copyright": "(c) Anton Afanaasyeu, {year}",
"footer.copyright": "© Anton Afanaasyeu, {year}",
"nav.tickets": "Тикеты",
"ticket.lifecycle": "Жизненный цикл",
"ticket.assignees": "Исполнители",

View File

@@ -93,11 +93,10 @@
if (k) opt.textContent = t(k);
});
});
const footer = document.querySelector('[data-i18n="footer.copyright"]');
if (footer) {
scope.querySelectorAll('.platform-footer__left[data-i18n], footer[data-i18n="footer.copyright"]').forEach((footer) => {
const year = footer.getAttribute('data-year') || String(new Date().getFullYear());
footer.textContent = t('footer.copyright', { year });
}
footer.textContent = t(footer.getAttribute('data-i18n') || 'footer.copyright', { year });
});
scope.querySelectorAll('[data-i18n="detail.report_meta"]').forEach((el) => {
el.textContent = t('detail.report_meta', {
id: el.getAttribute('data-i18n-id') || '',

View File

@@ -0,0 +1,161 @@
(function () {
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function canEditGlobal() {
return document.body.getAttribute('data-can-rbac-root') === '1';
}
function setStatus(msg, isError) {
const el = document.getElementById('rbac-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 renderPanel(data) {
const tbody = document.getElementById('rbac-users-tbody');
if (!tbody) return;
tbody.innerHTML = '';
const companies = data.companies || [];
const companyRoles = data.company_roles || [];
const globalRoles = data.global_roles || [];
const sets = [{ key: '', label: '(role defaults only)' }].concat(data.privilege_sets || []);
(data.users || []).forEach((u) => {
(u.memberships || []).forEach((m) => {
const tr = document.createElement('tr');
const globalSelect = canEditGlobal()
? '<select class="rbac-global-role" data-user-id="' + u.id + '">' +
globalRoles.map((r) => '<option value="' + esc(r) + '"' + (u.global_role === r ? ' selected' : '') + '>' + esc(r) + '</option>').join('') +
'</select>'
: '<code>' + esc(u.global_role) + '</code>';
const roleSelect =
'<select class="rbac-company-role" data-user-id="' + u.id + '" data-company-id="' + m.company_id + '">' +
companyRoles
.map((r) => '<option value="' + esc(r) + '"' + (m.role === r ? ' selected' : '') + '>' + esc(r) + '</option>')
.join('') +
'</select>';
const setSelect =
'<select class="rbac-privilege-set" data-user-id="' + u.id + '" data-company-id="' + m.company_id + '">' +
sets
.map((s) => {
const key = s.key != null ? s.key : '';
const label = s.label || key || '(none)';
const sel = (m.privilege_set || '') === key ? ' selected' : '';
return '<option value="' + esc(key) + '"' + sel + '>' + esc(label) + '</option>';
})
.join('') +
(m.privilege_set === 'custom' ? '<option value="" disabled>custom grants (edit via API)</option>' : '') +
'</select>';
tr.innerHTML =
'<td>' + esc(u.username) + '</td>' +
'<td>' + globalSelect + '</td>' +
'<td><code>' + esc(m.slug) + '</code> ' + esc(m.name) + '</td>' +
'<td>' + roleSelect + '</td>' +
'<td>' + setSelect + '</td>';
tbody.appendChild(tr);
});
if (!(u.memberships || []).length && canEditGlobal()) {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + esc(u.username) + '</td>' +
'<td><code>' + esc(u.global_role) + '</code></td>' +
'<td colspan="3" class="muted">No company membership</td>';
tbody.appendChild(tr);
}
});
if (!tbody.children.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No users in scope.</td></tr>';
}
const hint = document.getElementById('rbac-scope-hint');
if (hint) {
hint.textContent =
'Slug admin = company owner/admin (graphs company scope). Root may change global roles. Privilege sets add remote-access grants to members.';
if (companies.length) {
hint.textContent += ' Companies: ' + companies.map((c) => c.slug).join(', ');
}
}
}
async function postAction(action, body) {
await fetchJson(basePath() + '/api/rbac.php?action=' + encodeURIComponent(action), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function bindChanges() {
document.addEventListener('change', async (ev) => {
const t = ev.target;
if (!t || !t.classList) return;
try {
if (t.classList.contains('rbac-global-role') && canEditGlobal()) {
await postAction('set_global_role', {
user_id: Number(t.getAttribute('data-user-id')),
role: t.value,
});
setStatus('Global role updated.');
return;
}
if (t.classList.contains('rbac-company-role')) {
await postAction('set_company_role', {
user_id: Number(t.getAttribute('data-user-id')),
company_id: Number(t.getAttribute('data-company-id')),
role: t.value,
});
setStatus('Company role updated.');
return;
}
if (t.classList.contains('rbac-privilege-set')) {
await postAction('apply_privilege_set', {
user_id: Number(t.getAttribute('data-user-id')),
company_id: Number(t.getAttribute('data-company-id')),
privilege_set: t.value,
});
setStatus('Privilege set applied.');
}
} catch (e) {
setStatus(String(e.message || e), true);
}
});
}
async function load() {
setStatus('Loading…');
try {
const data = await fetchJson(basePath() + '/api/rbac.php?action=panel');
renderPanel(data);
setStatus('Ready — changes save on selection.');
} catch (e) {
setStatus(String(e.message || e), true);
}
}
document.addEventListener('DOMContentLoaded', function () {
bindChanges();
load();
});
})();

View File

@@ -3,6 +3,14 @@
return document.body.getAttribute('data-base-path') || '';
}
function canOperate() {
return document.body.getAttribute('data-can-ra-operate') === '1';
}
function canAdmin() {
return document.body.getAttribute('data-can-ra-admin') === '1';
}
function apiUrl(action, params) {
const q = new URLSearchParams(params || {});
q.set('action', action);
@@ -35,19 +43,27 @@
const tbody = document.getElementById('ra-devices-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!devices || !devices.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">No devices have polled yet.</td></tr>';
return;
}
(devices || []).forEach((d) => {
const tr = document.createElement('tr');
const wl = Number(d.whitelisted) === 1;
const canOpen = canOperate() && wl && (d.opt_in_mode === 'wireguard');
const openDisabled = canOpen ? '' : ' disabled title="Whitelist device and wait for WireGuard opt-in poll"';
const wlDisabled = canAdmin() ? '' : ' disabled';
tr.innerHTML =
'<td><code>' + esc(d.device_id) + '</code></td>' +
'<td><code class="ra-device-id">' + esc(d.device_id) + '</code></td>' +
'<td>' + esc(d.opt_in_mode || 'none') + '</td>' +
'<td>' + (wl ? 'yes' : 'no') + '</td>' +
'<td>' + (wl ? '<span class="tag-pill">yes</span>' : '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>' +
'<button type="button" class="btn btn-sm btn-primary"' + openDisabled + ' data-open-session="' + esc(d.device_id) + '">Open session</button> ' +
'<button type="button" class="btn btn-sm"' + wlDisabled + ' data-toggle-wl="' + esc(d.device_id) + '" data-wl="' + (wl ? '0' : '1') + '">' +
(wl ? 'Revoke WL' : 'Whitelist') + '</button> ' +
'<button type="button" class="btn btn-sm" data-copy-id="' + esc(d.device_id) + '">Copy ID</button>' +
'</td>';
tbody.appendChild(tr);
});
@@ -58,15 +74,21 @@
const iBody = document.getElementById('ra-inactive-tbody');
if (aBody) {
aBody.innerHTML = '';
if (!active || !active.length) {
aBody.innerHTML = '<tr><td colspan="6" class="muted">No pending or active sessions.</td></tr>';
}
(active || []).forEach((s) => {
const tr = document.createElement('tr');
const closeBtn = canOperate()
? '<button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button>'
: '';
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>';
'<td>' + closeBtn + '</td>';
aBody.appendChild(tr);
});
}
@@ -82,6 +104,9 @@
'<td>' + esc(s.close_reason || '—') + '</td>';
iBody.appendChild(tr);
});
if (!inactive || !inactive.length) {
iBody.innerHTML = '<tr><td colspan="5" class="muted">No closed sessions yet.</td></tr>';
}
}
}
@@ -89,6 +114,10 @@
const tbody = document.getElementById('ra-events-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!events || !events.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">No audit events.</td></tr>';
return;
}
(events || []).forEach((e) => {
const tr = document.createElement('tr');
tr.innerHTML =
@@ -100,14 +129,18 @@
});
}
let lastConfig = {};
async function refresh() {
setStatus('Loading…');
try {
const data = await fetchJson(apiUrl('dashboard'));
lastConfig = data.config || {};
renderDevices(data.devices);
renderSessions(data.active_sessions, data.inactive_sessions);
renderEvents(data.recent_events);
setStatus('Updated ' + new Date().toLocaleTimeString());
const ep = lastConfig.wg_endpoint ? ' · WG ' + lastConfig.wg_endpoint : '';
setStatus('Updated ' + new Date().toLocaleTimeString() + ep);
} catch (e) {
setStatus('Failed: ' + e.message, true);
}
@@ -116,14 +149,27 @@
document.addEventListener('click', async (ev) => {
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
const copyId = t.getAttribute('data-copy-id');
if (copyId) {
try {
await navigator.clipboard.writeText(copyId);
setStatus('Copied device ID');
} catch (e) {
setStatus('Copy failed', true);
}
return;
}
const openId = t.getAttribute('data-open-session');
if (openId) {
if (!canOperate()) return;
try {
setStatus('Opening session for ' + openId + '…');
await fetchJson(apiUrl('open_session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: openId }),
});
setStatus('Session opened — device will connect on next poll (≤7 min)');
await refresh();
} catch (e) {
setStatus('Open session: ' + e.message, true);
@@ -132,6 +178,7 @@
}
const closeId = t.getAttribute('data-close-session');
if (closeId) {
if (!canOperate()) return;
try {
await fetchJson(apiUrl('close_session'), {
method: 'POST',
@@ -149,6 +196,7 @@
}
const wlDevice = t.getAttribute('data-toggle-wl');
if (wlDevice) {
if (!canAdmin()) return;
try {
await fetchJson(apiUrl('whitelist'), {
method: 'POST',
@@ -167,8 +215,12 @@
const form = document.getElementById('ra-whitelist-form');
if (form) {
if (!canAdmin()) {
form.hidden = true;
}
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
if (!canAdmin()) return;
const fd = new FormData(form);
try {
await fetchJson(apiUrl('whitelist'), {
@@ -189,5 +241,5 @@
}
refresh();
setInterval(refresh, 60000);
setInterval(refresh, 30000);
})();

View File

@@ -100,6 +100,11 @@ if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_ac
exit;
}
if ($route === '/api/rbac.php' || str_ends_with($route, '/api/rbac.php')) {
require __DIR__ . '/api/rbac.php';
exit;
}
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
require __DIR__ . '/api/ticket_attachment.php';
exit;
@@ -203,6 +208,7 @@ $pageTitle = match ($view) {
'tickets' => 'Tickets',
'graphs' => 'Graphs',
'remote_access' => 'Remote access',
'rbac' => 'Access control',
'reports', 'report' => 'Crash reports',
default => 'Console',
};

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Purge stale remote-access sessions (cron: hourly).
* Usage: php scripts/purge_remote_access.php [--hours=24]
*/
require_once __DIR__ . '/../src/bootstrap.php';
require_once __DIR__ . '/../src/RemoteAccessRepository.php';
require_once __DIR__ . '/../src/WireGuardPeerProvisioner.php';
$hours = 24;
foreach ($argv as $arg) {
if (str_starts_with($arg, '--hours=')) {
$hours = max(1, (int) substr($arg, 8));
}
}
RemoteAccessRepository::ensureSchema();
$n = RemoteAccessRepository::purgeStale($hours);
fwrite(STDOUT, "purged_sessions={$n}\n");

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Smoke test RBAC admin API (local orchestration or deployed crashes console).
set -euo pipefail
BASE="${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
COOKIE_JAR="$(mktemp)"
trap 'rm -f "$COOKIE_JAR"' EXIT
curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
-d "username=${ADMIN_USER:-admin}&password=${ADMIN_PASS:-admin}" \
"$BASE/login" >/dev/null
echo "== rbac panel =="
curl -sf -b "$COOKIE_JAR" "$BASE/api/rbac.php?action=panel" | grep -q '"ok":true' && echo OK
curl -sf -b "$COOKIE_JAR" "$BASE/api/rbac.php?action=panel" | grep -q 'privilege_sets' && echo OK sets
echo "All RBAC API checks passed."

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Pre-flight checks for remote-access v1 on the BE (run on Alpine VM as root or deploy user).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
fail=0
warn=0
ok() { echo "OK $*"; }
bad() { echo "FAIL $*"; fail=1; }
note() { echo "WARN $*"; warn=1; }
echo "== Remote access production verify =="
echo "backend: $ROOT"
echo
# config.php
CFG="$ROOT/config/config.php"
if [[ ! -f "$CFG" ]]; then
bad "config/config.php missing (copy from config.example.php)"
else
ok "config.php present"
fi
php -r '
$config = require "'"$CFG"'";
$ra = $config["remote_access"] ?? [];
$pub = trim((string)($ra["wg_server_public_key"] ?? ""));
$ep = trim((string)($ra["wg_endpoint"] ?? ""));
if ($pub === "") { fwrite(STDERR, "FAIL wg_server_public_key is empty — run: wg show wg0 public-key\n"); exit(2); }
if ($ep === "") { fwrite(STDERR, "FAIL wg_endpoint is empty\n"); exit(2); }
if (empty($ra["require_wg_tools"])) { fwrite(STDERR, "WARN require_wg_tools should be true on production\n"); exit(3); }
echo "OK remote_access config: endpoint=$ep, require_wg_tools=" . (($ra["require_wg_tools"] ?? false) ? "true" : "false") . "\n";
' || {
code=$?
if [[ $code -eq 2 ]]; then fail=1; elif [[ $code -eq 3 ]]; then warn=1; fi
}
# wireguard-tools
if command -v wg >/dev/null 2>&1; then
ok "wg in PATH ($(command -v wg))"
else
bad "wireguard-tools missing (apk add wireguard-tools)"
fi
IFACE="${WG_INTERFACE:-wg0}"
if ip link show "$IFACE" >/dev/null 2>&1; then
ok "interface $IFACE up"
if command -v wg >/dev/null 2>&1; then
wg show "$IFACE" 2>/dev/null | head -3 || note "wg show $IFACE returned nothing"
fi
else
note "interface $IFACE not found (data plane not ready; HTTP control plane may still work)"
fi
# PHP can shell_exec wg (same user as FPM)
if php -r 'echo trim((string)@shell_exec("wg genkey 2>/dev/null"));' | grep -qE '.{40,}'; then
ok "php can run wg genkey (FPM user must match for connect)"
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 '
require "'"$ROOT"'/src/bootstrap.php";
RemoteAccessRepository::ensureSchema();
$pdo = Database::pdo();
foreach (["remote_access_devices","remote_access_sessions","remote_access_events"] as $t) {
if (!Database::tableExists($pdo, $t)) { fwrite(STDERR, "FAIL table missing: $t\n"); exit(2); }
}
echo "OK MariaDB/SQLite remote_access tables present\n";
' || fail=1
# API smoke (local php -S or deployed BASE)
BASE="${BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
if [[ -x "$ROOT/scripts/test_remote_access_api.sh" ]]; then
if BASE="$BASE" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then
ok "API smoke test ($BASE)"
else
note "API smoke test failed against $BASE (set BASE=…/crashes if needed)"
fi
fi
echo
if [[ $fail -ne 0 ]]; then
echo "Result: NOT READY ($fail hard failure(s), $warn warning(s))"
exit 1
fi
if [[ $warn -ne 0 ]]; then
echo "Result: READY WITH WARNINGS ($warn warning(s))"
exit 0
fi
echo "Result: READY"
exit 0

View File

@@ -21,6 +21,27 @@ final class Rbac {
private const GLOBAL_ADMIN_ROLES = [self::GLOBAL_ROOT, self::GLOBAL_PLATFORM_ADMIN, self::GLOBAL_ADMIN_LEGACY];
/** Company role => granted actions (unless global admin). */
/**
* Named privilege sets stored in company_memberships.permissions_json as {"grants":[...]}.
* Company role defaults still apply; grants extend (not replace) role actions.
*/
public const PRIVILEGE_SET_NONE = '';
public const PRIVILEGE_SET_REMOTE_ACCESS_USER = 'remote_access_user';
public const PRIVILEGE_SET_REMOTE_ACCESS_ADMIN = 'remote_access_admin';
/** @var array<string, list<string>> */
public const PRIVILEGE_SETS = [
self::PRIVILEGE_SET_REMOTE_ACCESS_USER => [
'remote_access_view',
'remote_access_operate',
],
self::PRIVILEGE_SET_REMOTE_ACCESS_ADMIN => [
'remote_access_view',
'remote_access_operate',
'remote_access_admin',
],
];
private const COMPANY_ROLE_ACTIONS = [
self::COMPANY_OWNER => [
'view_reports',
@@ -274,6 +295,76 @@ final class Rbac {
return null;
}
/** @return list<string> */
public static function privilegeSetKeys(): array {
return array_keys(self::PRIVILEGE_SETS);
}
/** @return list<string> */
public static function grantsForPrivilegeSet(string $setKey): array {
$setKey = trim($setKey);
if ($setKey === '' || $setKey === self::PRIVILEGE_SET_NONE) {
return [];
}
return self::PRIVILEGE_SETS[$setKey] ?? [];
}
public static function encodePermissionsJson(?string $privilegeSetKey): ?string {
$grants = self::grantsForPrivilegeSet((string) $privilegeSetKey);
if ($grants === []) {
return null;
}
return json_encode(['grants' => $grants], JSON_UNESCAPED_UNICODE);
}
public static function privilegeSetFromPermissionsJson(?string $raw): string {
if (!is_string($raw) || trim($raw) === '') {
return self::PRIVILEGE_SET_NONE;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return self::PRIVILEGE_SET_NONE;
}
$grants = $decoded['grants'] ?? $decoded;
if (!is_array($grants)) {
return self::PRIVILEGE_SET_NONE;
}
sort($grants);
foreach (self::PRIVILEGE_SETS as $key => $want) {
$sorted = $want;
sort($sorted);
if ($grants === $sorted) {
return $key;
}
}
return 'custom';
}
public static function isRoot(?array $user = null): bool {
$user ??= Auth::user();
if (!$user) {
return false;
}
return self::normalizeGlobalRole((string) ($user['role'] ?? '')) === self::GLOBAL_ROOT;
}
public static function canManageRbac(?array $user = null): bool {
$user ??= Auth::user();
if (!$user) {
return false;
}
if (self::isGlobalAdmin($user)) {
return true;
}
foreach ($user['companies'] ?? [] as $c) {
$role = (string) ($c['role'] ?? '');
if ($role === self::COMPANY_OWNER || $role === self::COMPANY_ADMIN) {
return true;
}
}
return false;
}
private static function hasPermissionOverride(array $user, int $companyId, string $action): bool {
$pdo = Database::pdo();
$stmt = $pdo->prepare(

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
/** RBAC admin: global roles, company memberships, privilege sets. */
final class RbacAdminRepository {
private function __construct() {}
public static function buildPanel(?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !Rbac::canManageRbac($actor)) {
return ['ok' => false, 'error' => 'forbidden'];
}
$actorId = (int) ($actor['id'] ?? 0);
$globalAdmin = Rbac::isGlobalAdmin($actor);
$companies = self::listCompaniesForActor($actor);
$companyIds = array_map(static fn($c) => (int) $c['id'], $companies);
return [
'ok' => true,
'actor' => [
'id' => $actorId,
'username' => (string) ($actor['username'] ?? ''),
'role' => (string) ($actor['role'] ?? ''),
'is_root' => Rbac::isRoot($actor),
'is_global_admin' => $globalAdmin,
],
'companies' => $companies,
'users' => self::listUsersWithMemberships($companyIds, $globalAdmin),
'global_roles' => [
Rbac::GLOBAL_ROOT,
Rbac::GLOBAL_PLATFORM_ADMIN,
Rbac::GLOBAL_VIEWER,
],
'company_roles' => [
Rbac::COMPANY_OWNER,
Rbac::COMPANY_ADMIN,
Rbac::COMPANY_MEMBER,
],
'privilege_sets' => array_map(
static fn($key) => [
'key' => $key,
'label' => str_replace('_', ' ', $key),
'grants' => Rbac::grantsForPrivilegeSet($key),
],
Rbac::privilegeSetKeys()
),
'permissions' => [
'can_edit_global_roles' => Rbac::isRoot($actor),
'can_manage_platform' => Rbac::can('manage_platform', null, $actor),
'can_manage_company_users' => $globalAdmin,
],
];
}
public static function setGlobalRole(int $targetUserId, string $role, ?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !Rbac::isRoot($actor)) {
return ['ok' => false, 'error' => 'forbidden'];
}
$role = Rbac::normalizeGlobalRole($role);
$allowed = [Rbac::GLOBAL_ROOT, Rbac::GLOBAL_PLATFORM_ADMIN, Rbac::GLOBAL_VIEWER];
if (!in_array($role, $allowed, true)) {
return ['ok' => false, 'error' => 'invalid_role'];
}
if ($targetUserId <= 0) {
return ['ok' => false, 'error' => 'invalid_user'];
}
$pdo = Database::pdo();
$stmt = $pdo->prepare('UPDATE users SET role = ? WHERE id = ?');
$stmt->execute([$role, $targetUserId]);
if ($stmt->rowCount() < 1) {
return ['ok' => false, 'error' => 'user_not_found'];
}
if (in_array($role, [Rbac::GLOBAL_ROOT, Rbac::GLOBAL_PLATFORM_ADMIN], true)) {
Rbac::seedMembershipsForUser($targetUserId, $role);
}
return ['ok' => true];
}
public static function setCompanyRole(int $targetUserId, int $companyId, string $role, ?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !self::canEditMembership($actor, $companyId)) {
return ['ok' => false, 'error' => 'forbidden'];
}
$allowed = [Rbac::COMPANY_OWNER, Rbac::COMPANY_ADMIN, Rbac::COMPANY_MEMBER];
if (!in_array($role, $allowed, true)) {
return ['ok' => false, 'error' => 'invalid_role'];
}
if ($targetUserId <= 0 || $companyId <= 0) {
return ['ok' => false, 'error' => 'invalid_input'];
}
$pdo = Database::pdo();
if (Database::isMysql()) {
$sql = 'INSERT INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE role = VALUES(role)';
} else {
$sql = 'INSERT INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)
ON CONFLICT(user_id, company_id) DO UPDATE SET role = excluded.role';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([$targetUserId, $companyId, $role]);
return ['ok' => true];
}
public static function applyPrivilegeSet(int $targetUserId, int $companyId, string $setKey, ?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !self::canEditMembership($actor, $companyId)) {
return ['ok' => false, 'error' => 'forbidden'];
}
if ($targetUserId <= 0 || $companyId <= 0) {
return ['ok' => false, 'error' => 'invalid_input'];
}
$setKey = trim($setKey);
if ($setKey !== '' && $setKey !== Rbac::PRIVILEGE_SET_NONE && !isset(Rbac::PRIVILEGE_SETS[$setKey])) {
return ['ok' => false, 'error' => 'invalid_privilege_set'];
}
$json = Rbac::encodePermissionsJson($setKey === Rbac::PRIVILEGE_SET_NONE ? '' : $setKey);
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'UPDATE company_memberships SET permissions_json = ? WHERE user_id = ? AND company_id = ?'
);
$stmt->execute([$json, $targetUserId, $companyId]);
if ($stmt->rowCount() < 1) {
return ['ok' => false, 'error' => 'membership_not_found'];
}
return ['ok' => true];
}
/** @return list<array{id:int,slug:string,name:string}> */
private static function listCompaniesForActor(array $actor): array {
$pdo = Database::pdo();
$allowed = Rbac::allowedCompanyIds($actor);
if ($allowed === null) {
$stmt = $pdo->query('SELECT id, slug, name FROM companies ORDER BY name ASC');
return self::mapCompanies($stmt->fetchAll(PDO::FETCH_ASSOC));
}
if ($allowed === []) {
return [];
}
$ph = implode(',', array_fill(0, count($allowed), '?'));
$stmt = $pdo->prepare("SELECT id, slug, name FROM companies WHERE id IN ($ph) ORDER BY name ASC");
$stmt->execute($allowed);
return self::mapCompanies($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @param list<array<string,mixed>> $rows @return list<array{id:int,slug:string,name:string}> */
private static function mapCompanies(array $rows): array {
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int) $row['id'],
'slug' => (string) $row['slug'],
'name' => (string) $row['name'],
];
}
return $out;
}
/**
* @param list<int> $companyIds
* @return list<array<string, mixed>>
*/
private static function listUsersWithMemberships(array $companyIds, bool $globalAdmin): array {
$pdo = Database::pdo();
if ($globalAdmin) {
$users = $pdo->query('SELECT id, username, role FROM users ORDER BY username ASC')->fetchAll(PDO::FETCH_ASSOC);
} else {
if ($companyIds === []) {
return [];
}
$ph = implode(',', array_fill(0, count($companyIds), '?'));
$stmt = $pdo->prepare(
"SELECT DISTINCT u.id, u.username, u.role
FROM users u
INNER JOIN company_memberships m ON m.user_id = u.id
WHERE m.company_id IN ($ph)
ORDER BY u.username ASC"
);
$stmt->execute($companyIds);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
$out = [];
foreach ($users as $u) {
$uid = (int) $u['id'];
$memberships = self::membershipsForUser($uid, $companyIds, $globalAdmin);
$out[] = [
'id' => $uid,
'username' => (string) $u['username'],
'global_role' => Rbac::normalizeGlobalRole((string) ($u['role'] ?? '')),
'memberships' => $memberships,
];
}
return $out;
}
/**
* @param list<int> $companyIds
* @return list<array<string, mixed>>
*/
private static function membershipsForUser(int $userId, array $companyIds, bool $globalAdmin): array {
$pdo = Database::pdo();
if ($globalAdmin) {
$stmt = $pdo->prepare(
'SELECT m.company_id, c.slug, c.name, m.role, m.permissions_json
FROM company_memberships m
INNER JOIN companies c ON c.id = m.company_id
WHERE m.user_id = ?
ORDER BY c.name ASC'
);
$stmt->execute([$userId]);
} else {
if ($companyIds === []) {
return [];
}
$ph = implode(',', array_fill(0, count($companyIds), '?'));
$params = array_merge([$userId], $companyIds);
$stmt = $pdo->prepare(
"SELECT m.company_id, c.slug, c.name, m.role, m.permissions_json
FROM company_memberships m
INNER JOIN companies c ON c.id = m.company_id
WHERE m.user_id = ? AND m.company_id IN ($ph)
ORDER BY c.name ASC"
);
$stmt->execute($params);
}
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$raw = isset($row['permissions_json']) ? (string) $row['permissions_json'] : '';
$out[] = [
'company_id' => (int) $row['company_id'],
'slug' => (string) $row['slug'],
'name' => (string) $row['name'],
'role' => (string) $row['role'],
'privilege_set' => Rbac::privilegeSetFromPermissionsJson($raw),
];
}
return $out;
}
private static function canEditMembership(array $actor, int $companyId): bool {
if (Rbac::isGlobalAdmin($actor)) {
return true;
}
if (!Rbac::canAccessCompany($companyId, $actor)) {
return false;
}
$uid = (int) ($actor['id'] ?? 0);
$role = $uid > 0 ? Rbac::companyRole($uid, $companyId) : null;
return $role === Rbac::COMPANY_OWNER || $role === Rbac::COMPANY_ADMIN;
}
}

View File

@@ -18,10 +18,68 @@ final class RemoteAccessRepository {
public static function ensureSchema(): void {
$pdo = Database::pdo();
if (Database::tableExists($pdo, 'remote_access_devices')) {
if (!Database::tableExists($pdo, 'remote_access_devices')) {
self::createTables($pdo);
return;
}
self::createTables($pdo);
self::ensureSessionColumns($pdo);
}
/** @param list<string> $where @param list<mixed> $params */
private static function appendCompanyScopeSql(string $column, array &$where, array &$params): void {
Rbac::appendCompanyScope($column, $where, $params);
}
public static function canAccessDevice(string $deviceId): bool {
$device = self::findDevice($deviceId);
if ($device === null) {
return Rbac::isGlobalAdmin();
}
return Rbac::canAccessCompany((int) ($device['company_id'] ?? 0));
}
public static function canOperatorCloseSession(string $sessionId, int $operatorUserId): bool {
$session = self::findSession($sessionId);
if ($session === null) {
return false;
}
$companyId = (int) ($session['company_id'] ?? 0);
if (!Rbac::canAccessCompany($companyId)) {
return false;
}
if (Rbac::isGlobalAdmin() || Rbac::can('remote_access_admin', $companyId)) {
return true;
}
return $operatorUserId > 0
&& (int) ($session['operator_user_id'] ?? 0) === $operatorUserId
&& Rbac::can('remote_access_operate', $companyId);
}
/** @return array<string, mixed>|null */
public static function findSession(string $sessionId): ?array {
self::ensureSchema();
$st = Database::pdo()->prepare('SELECT * FROM remote_access_sessions WHERE session_id = ? LIMIT 1');
$st->execute([$sessionId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
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;
}
}
}
private static function createTables(PDO $pdo): void {
@@ -60,6 +118,15 @@ final class RemoteAccessRepository {
return self::handleDisable($deviceId, $random, $clientIp, $appVersion);
}
$existing = self::findDevice($deviceId);
$pendingSession = null;
if ($existing !== null && self::isRateLimited($existing)) {
$pendingSession = self::findConnectableSession($deviceId, $random);
if ($pendingSession === null) {
return self::raEnvelope('wait', ['reason' => 'rate_limited']);
}
}
self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp);
$device = self::findDevice($deviceId);
@@ -78,15 +145,40 @@ final class RemoteAccessRepository {
return self::raEnvelope('deny', ['reason' => 'rssh_not_implemented']);
}
$session = self::findConnectableSession($deviceId, $random);
$session = $pendingSession ?? 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']);
}
self::touchSessionActivity((string) ($session['session_id'] ?? ''));
return self::buildConnectResponse($session, $deviceId, $clientIp);
}
/** @param array<string, mixed> $device */
private static function isRateLimited(array $device): bool {
$min = max(15, (int) cfg('remote_access.min_poll_interval_s', 45));
$last = (string) ($device['last_seen_at'] ?? '');
if ($last === '') {
return false;
}
$ts = strtotime($last);
if ($ts === false) {
return false;
}
return (time() - $ts) < $min;
}
private static function touchSessionActivity(string $sessionId): void {
if ($sessionId === '') {
return;
}
Database::pdo()->prepare(
'UPDATE remote_access_sessions SET last_activity_at = ? WHERE session_id = ?'
)->execute([self::nowSql(), $sessionId]);
}
/** @param list<string> $capabilities */
private static function inferTunnelMode(array $capabilities, array $heartbeat): string {
foreach ($capabilities as $cap) {
@@ -193,7 +285,17 @@ final class RemoteAccessRepository {
return self::raEnvelope('deny', ['reason' => 'tunnel_not_supported', 'tunnel' => $tunnel]);
}
$keys = self::ensureSessionWireGuardKeys($sessionId, $session);
$keys = [];
try {
$keys = self::ensureSessionWireGuardKeys($sessionId, $session);
} catch (Throwable $e) {
error_log('RemoteAccess connect keys: ' . $e->getMessage());
return self::raEnvelope('deny', ['reason' => 'key_generation_failed']);
}
if (str_starts_with($keys['client_public'] ?? '', 'dev-placeholder-pub-')
&& (bool) cfg('remote_access.require_wg_tools', false)) {
return self::raEnvelope('deny', ['reason' => 'wg_tools_required']);
}
$endpoint = (string) ($session['endpoint'] ?? cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:51820'));
$expiresAt = (int) ($session['expires_at'] ?? (time() + 3600));
@@ -216,34 +318,49 @@ final class RemoteAccessRepository {
]);
}
/** @param array<string, mixed> $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string} */
/** @param array<string, mixed> $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string,client_public:string} */
private static function ensureSessionWireGuardKeys(string $sessionId, array $session): array {
$priv = trim((string) ($session['wg_client_private_key'] ?? ''));
$pub = trim((string) ($session['wg_client_public_key'] ?? ''));
$addr = trim((string) ($session['wg_client_address'] ?? ''));
$pub = trim((string) ($session['wg_server_public_key'] ?? ''));
$serverPub = trim((string) ($session['wg_server_public_key'] ?? ''));
$allowed = trim((string) ($session['wg_peer_allowed_ips'] ?? ''));
if ($priv !== '' && $addr !== '' && $pub !== '') {
if ($priv !== '' && $addr !== '' && $serverPub !== '') {
if ($pub === '') {
$pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv);
}
return [
'client_private' => $priv,
'client_public' => $pub,
'client_address' => $addr,
'server_public' => $pub,
'server_public' => $serverPub,
'allowed_ips' => $allowed !== '' ? $allowed : '10.66.66.1/32',
];
}
$pair = self::generateWireGuardKeyPair();
$priv = $pair['private'];
$pub = $pair['public'];
if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
$pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv);
}
$clientIp = self::allocateClientAddress($sessionId);
$serverPub = cfg('remote_access.wg_server_public_key', '');
$serverPub = (string) 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]);
'UPDATE remote_access_sessions SET wg_client_private_key = ?, wg_client_public_key = ?, wg_client_address = ?, wg_server_public_key = ?, wg_peer_allowed_ips = ? WHERE session_id = ?'
)->execute([$priv, $pub, $clientIp, $serverPub, $allowed, $sessionId]);
if (!WireGuardPeerProvisioner::addClientPeer($pub, $clientIp)) {
error_log('RemoteAccess: wg peer add failed for session ' . $sessionId);
}
return [
'client_private' => $pair['private'],
'client_private' => $priv,
'client_public' => $pub,
'client_address' => $clientIp,
'server_public' => $serverPub,
'allowed_ips' => $allowed,
@@ -264,6 +381,9 @@ final class RemoteAccessRepository {
return ['private' => $priv, 'public' => $pub];
}
}
if ((bool) cfg('remote_access.require_wg_tools', false)) {
throw new RuntimeException('wireguard-tools unavailable (wg genkey)');
}
$raw = random_bytes(32);
$priv = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
return ['private' => $priv, 'public' => 'dev-placeholder-pub-' . substr(hash('sha256', $priv), 0, 43)];
@@ -287,6 +407,14 @@ final class RemoteAccessRepository {
}
public static function closeSession(string $sessionId, string $status, string $reason): void {
$st = Database::pdo()->prepare(
'SELECT wg_client_public_key FROM remote_access_sessions WHERE session_id = ? LIMIT 1'
);
$st->execute([$sessionId]);
$pub = $st->fetchColumn();
if (is_string($pub) && $pub !== '') {
WireGuardPeerProvisioner::removeClientPeer($pub);
}
$now = self::nowSql();
Database::pdo()->prepare(
'UPDATE remote_access_sessions SET status = ?, close_reason = ?, closed_at = ?, last_activity_at = ? WHERE session_id = ?'
@@ -300,6 +428,10 @@ final class RemoteAccessRepository {
if (!$device || !(int) ($device['whitelisted'] ?? 0)) {
return ['ok' => false, 'error' => 'device_not_whitelisted'];
}
$deviceCompanyId = (int) ($device['company_id'] ?? 0);
if (!Rbac::canAccessCompany($deviceCompanyId) || !Rbac::can('remote_access_operate', $deviceCompanyId)) {
return ['ok' => false, 'error' => 'forbidden'];
}
if ((string) ($device['opt_in_mode'] ?? self::OPT_NONE) !== self::OPT_WIREGUARD) {
return ['ok' => false, 'error' => 'device_not_opted_in_wg'];
}
@@ -333,7 +465,17 @@ final class RemoteAccessRepository {
self::ensureSchema();
$sql = 'SELECT * FROM remote_access_devices';
$params = [];
if ($companyId !== null && !Rbac::isGlobalAdmin()) {
if (!Rbac::isGlobalAdmin()) {
$allowed = Rbac::allowedCompanyIds();
if ($allowed === []) {
return [];
}
if ($allowed !== null) {
$placeholders = implode(',', array_fill(0, count($allowed), '?'));
$sql .= ' WHERE company_id IN (' . $placeholders . ')';
$params = $allowed;
}
} elseif ($companyId !== null) {
$sql .= ' WHERE company_id = ?';
$params[] = $companyId;
}
@@ -348,11 +490,16 @@ final class RemoteAccessRepository {
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';
$where = [];
$params = [];
if ($statusFilter !== '') {
$sql .= ' WHERE s.status = ?';
$where[] = 's.status = ?';
$params[] = $statusFilter;
}
self::appendCompanyScopeSql('s.company_id', $where, $params);
if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY s.id DESC LIMIT 200';
$st = Database::pdo()->prepare($sql);
$st->execute($params);
@@ -362,8 +509,28 @@ final class RemoteAccessRepository {
/** @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);
$sql = 'SELECT e.* FROM remote_access_events e';
$where = [];
$params = [];
if (!Rbac::isGlobalAdmin()) {
$allowed = Rbac::allowedCompanyIds();
if ($allowed === []) {
return [];
}
if ($allowed !== null) {
$sql .= ' INNER JOIN remote_access_devices d ON d.device_id = e.device_id';
self::appendCompanyScopeSql('d.company_id', $where, $params);
}
}
if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY e.id DESC LIMIT ?';
$params[] = max(1, min(500, $limit));
$st = Database::pdo()->prepare($sql);
foreach ($params as $i => $v) {
$st->bindValue($i + 1, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$st->execute();
return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
@@ -372,11 +539,15 @@ final class RemoteAccessRepository {
self::ensureSchema();
$device = self::findDevice($deviceId);
if (!$device) {
$companyId = Rbac::activeCompanyId() ?? Rbac::defaultCompanyId();
if (!Rbac::can('remote_access_admin', $companyId)) {
return false;
}
Database::pdo()->prepare(
'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
)->execute([
$deviceId,
Rbac::defaultCompanyId(),
$companyId,
$whitelisted ? 1 : 0,
$notes,
self::nowSql(),
@@ -384,6 +555,10 @@ final class RemoteAccessRepository {
]);
return true;
}
$companyId = (int) ($device['company_id'] ?? 0);
if (!Rbac::canAccessCompany($companyId) || !Rbac::can('remote_access_admin', $companyId)) {
return false;
}
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]);

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/** Alpine BE WireGuard peer lifecycle (wg set) for remote-access sessions. */
final class WireGuardPeerProvisioner {
private function __construct() {}
public static function isEnabled(): bool {
return (bool) cfg('remote_access.provision_peers', true);
}
public static function interfaceName(): string {
return (string) cfg('remote_access.wg_interface', 'wg0');
}
public static function publicKeyFromPrivate(string $privateKey): string {
$priv = trim($privateKey);
if ($priv === '') {
return '';
}
$pub = trim((string) @shell_exec('echo ' . escapeshellarg($priv) . ' | wg pubkey 2>/dev/null') ?: '');
return $pub;
}
public static function addClientPeer(string $clientPublicKey, string $clientAddressCidr): bool {
if (!self::isEnabled()) {
return true;
}
$pub = trim($clientPublicKey);
if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
return false;
}
$iface = self::interfaceName();
$allowed = trim($clientAddressCidr);
if ($allowed === '') {
$allowed = '10.66.66.2/32';
}
$cmd = 'wg set ' . escapeshellarg($iface)
. ' peer ' . escapeshellarg($pub)
. ' allowed-ips ' . escapeshellarg($allowed);
exec($cmd, $out, $code);
if ($code !== 0) {
error_log('WireGuardPeerProvisioner add failed: ' . implode(' ', $out));
}
return $code === 0;
}
public static function removeClientPeer(?string $clientPublicKey): void {
if (!self::isEnabled()) {
return;
}
$pub = trim((string) $clientPublicKey);
if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
return;
}
$iface = self::interfaceName();
$cmd = 'wg set ' . escapeshellarg($iface)
. ' peer ' . escapeshellarg($pub)
. ' remove';
exec($cmd);
}
}

View File

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

View File

@@ -37,13 +37,19 @@
<?php if (($view ?? '') === 'remote_access'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'rbac'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/rbac_admin.js" defer></script>
<?php endif; ?>
<?php AnalyticsHead::render('crashes'); ?>
</head>
<body data-base-path="<?= h(Auth::basePath()) ?>"
data-view="<?= h($view ?? 'home') ?>"
data-can-tag-edit="<?= Auth::canEditTags() ? '1' : '0' ?>"
<?= (($view ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>
<?= (($view ?? '') === 'ticket' && !empty($ticket['id'])) ? ' data-ticket-id="' . (int) $ticket['id'] . '"' : '' ?>>
<?= (($view ?? '') === 'ticket' && !empty($ticket['id'])) ? ' data-ticket-id="' . (int) $ticket['id'] . '"' : '' ?>
<?= Rbac::can('remote_access_operate') ? ' data-can-ra-operate="1"' : '' ?>
<?= Rbac::can('remote_access_admin') ? ' data-can-ra-admin="1"' : '' ?>
<?= Rbac::isRoot() ? ' data-can-rbac-root="1"' : '' ?>>
<header class="top-menu" hidden aria-hidden="true"></header>
<div class="shell">
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
@@ -101,6 +107,17 @@
</a>
</li>
<?php endif; ?>
<?php if (Rbac::canManageRbac()): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=rbac"
class="nav-link <?= ($view ?? '') === 'rbac' ? 'active' : '' ?>"
aria-label="Access control"
title="Access control">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<span class="nav-label">Access</span>
</a>
</li>
<?php endif; ?>
<li>
<a href="/app/androidcast_project/build/"
class="nav-link"
@@ -144,6 +161,12 @@
<li><span data-i18n="home.upload_api">Upload API:</span> <code><?= h(Auth::basePath()) ?>/api/upload.php</code></li>
<li><span data-i18n="home.schema">Schema:</span> <code>schema_version: 1</code></li>
<li><span data-i18n="home.ticket_upload_api">Ticket upload API:</span> <code><?= h(Auth::basePath()) ?>/api/ticket_upload.php</code></li>
<?php if (Rbac::can('remote_access_view')): ?>
<li>Remote access dashboard: <a href="<?= h(Auth::basePath()) ?>/?view=remote_access">Remote access</a> · API <code><?= h(Auth::basePath()) ?>/api/remote_access.php</code></li>
<?php endif; ?>
<?php if (Rbac::canManageRbac()): ?>
<li>Access control: <a href="<?= h(Auth::basePath()) ?>/?view=rbac">Users &amp; roles</a> · API <code><?= h(Auth::basePath()) ?>/api/rbac.php</code></li>
<?php endif; ?>
</ul>
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
<?php require __DIR__ . '/report_detail.php'; ?>
@@ -357,6 +380,10 @@
<a href="/app/androidcast_project/">Hub</a>
</nav>
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p class="muted graphs-footnote">
Device poll: <code><?= h(Auth::basePath()) ?>/api/heartbeat.php</code> (<code>type: ra</code>).
No nginx changes required — same crashes vhost. Configure <code>remote_access.*</code> in BE <code>config.php</code>.
</p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Whitelisted devices</h2>
@@ -436,6 +463,35 @@
</div>
</section>
</div>
<?php elseif (($view ?? '') === 'rbac'): ?>
<div id="rbac-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Access control</h1>
</div>
<p id="rbac-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p id="rbac-scope-hint" class="muted graphs-footnote"></p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Users &amp; company roles</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="rbac-users-table">
<thead>
<tr>
<th scope="col">User</th>
<th scope="col">Global role</th>
<th scope="col">Company</th>
<th scope="col">Company role</th>
<th scope="col">Privilege set</th>
</tr>
</thead>
<tbody id="rbac-users-tbody"></tbody>
</table>
</div>
</section>
<p class="muted graphs-footnote">
API: <code><?= h(Auth::basePath()) ?>/api/rbac.php</code> ·
Root edits global roles; company owner/admin edits memberships and remote-access privilege sets.
</p>
</div>
<?php endif; ?>
</main>
</div>