1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 03:38:52 +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

@@ -62,6 +62,9 @@ public final class AppPreferences {
private static final String KEY_DEV_DIAG_PING_RESPONSE_MODE = "dev_diag_ping_response_mode";
private static final String KEY_DEV_BACKEND_NTP_SYNC = "dev_backend_ntp_sync";
private static final String KEY_DEV_REMOTE_ACCESS_MODE = "dev_remote_access_mode";
private static final String KEY_DEV_REMOTE_ACCESS_RANDOM = "dev_remote_access_random";
private static final String KEY_DEV_REMOTE_ACCESS_SESSION_ID = "dev_remote_access_session_id";
private static final String KEY_DEV_REMOTE_ACCESS_EXPIRES_AT = "dev_remote_access_expires_at";
/** Minimum interval between automatic OTA checks on app launch. */
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
@@ -441,6 +444,32 @@ public final class AppPreferences {
prefs(context).edit().putString(KEY_DEV_REMOTE_ACCESS_MODE, mode.name()).apply();
}
public static String getDevRemoteAccessRandom(Context context) {
return prefs(context).getString(KEY_DEV_REMOTE_ACCESS_RANDOM, "");
}
public static void setDevRemoteAccessRandom(Context context, String random) {
prefs(context).edit().putString(KEY_DEV_REMOTE_ACCESS_RANDOM, random != null ? random : "").apply();
}
public static void setDevRemoteAccessSession(Context context, String sessionId, long expiresAtEpochSec) {
prefs(context).edit()
.putString(KEY_DEV_REMOTE_ACCESS_SESSION_ID, sessionId != null ? sessionId : "")
.putLong(KEY_DEV_REMOTE_ACCESS_EXPIRES_AT, expiresAtEpochSec)
.apply();
}
public static void clearDevRemoteAccessSession(Context context) {
prefs(context).edit()
.remove(KEY_DEV_REMOTE_ACCESS_SESSION_ID)
.remove(KEY_DEV_REMOTE_ACCESS_EXPIRES_AT)
.apply();
}
public static long getDevRemoteAccessExpiresAt(Context context) {
return prefs(context).getLong(KEY_DEV_REMOTE_ACCESS_EXPIRES_AT, 0L);
}
/**
* Reset user-facing settings shown in Android Cast settings.
* Developer-only settings are intentionally preserved.

View File

@@ -15,9 +15,10 @@ public final class RemoteAccessCoordinator {
Context app = context.getApplicationContext();
if (mode == null || mode == RemoteAccessMode.DISABLED) {
Log.i(TAG, "disabling remote access");
RemoteAccessService.sendDisableOnce(app);
RemoteAccessService.stop(app);
RemoteAccessVpnService.stop(app);
RemoteAccessService.sendDisableOnce(app);
AppPreferences.clearDevRemoteAccessSession(app);
return;
}
if (mode == RemoteAccessMode.WIREGUARD) {

View File

@@ -45,7 +45,27 @@ public final class RemoteAccessService extends Service {
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Random random = new Random();
private String sessionRandom = UUID.randomUUID().toString();
private String sessionRandom = "";
private String loadOrCreateSessionRandom() {
String stored = AppPreferences.getDevRemoteAccessRandom(this);
if (stored != null && !stored.isEmpty()) {
sessionRandom = stored;
return sessionRandom;
}
sessionRandom = UUID.randomUUID().toString();
AppPreferences.setDevRemoteAccessRandom(this, sessionRandom);
return sessionRandom;
}
private void checkSessionExpiry() {
long exp = AppPreferences.getDevRemoteAccessExpiresAt(this);
if (exp > 0 && exp < System.currentTimeMillis() / 1000L) {
Log.i(TAG, "session expired — tearing down tunnel");
WireGuardTunnelBridge.tearDown(this);
AppPreferences.clearDevRemoteAccessSession(this);
}
}
public static void start(Context context, RemoteAccessMode mode) {
Intent i = new Intent(context, RemoteAccessService.class);
@@ -94,6 +114,8 @@ public final class RemoteAccessService extends Service {
}
ensureChannel();
startForeground(NOTIF_ID, buildNotification(mode));
loadOrCreateSessionRandom();
checkSessionExpiry();
final RemoteAccessMode pollMode = mode;
if (ACTION_START.equals(action) || ACTION_POLL.equals(action)) {
executor.execute(() -> runPoll(pollMode));
@@ -103,12 +125,13 @@ public final class RemoteAccessService extends Service {
}
private void runPoll(RemoteAccessMode mode) {
checkSessionExpiry();
try {
JSONObject hb = new JSONObject();
hb.put("type", "ra");
hb.put("status", "enable");
hb.put("device_id", DeviceInfo.getDeviceUuid(this));
hb.put("random", sessionRandom);
hb.put("random", loadOrCreateSessionRandom());
hb.put("app_version", com.foxx.androidcast.BuildConfig.VERSION_NAME);
hb.put("tunnel_mode", mode.toApiValue());
JSONArray caps = new JSONArray();
@@ -134,11 +157,12 @@ public final class RemoteAccessService extends Service {
private void postDisableHeartbeat() {
try {
WireGuardTunnelBridge.tearDown(this);
AppPreferences.clearDevRemoteAccessSession(this);
JSONObject hb = new JSONObject();
hb.put("type", "ra");
hb.put("status", "disable");
hb.put("device_id", DeviceInfo.getDeviceUuid(this));
hb.put("random", sessionRandom);
hb.put("random", loadOrCreateSessionRandom());
RemoteAccessHeartbeatClient.postRa(this, hb);
} catch (Exception e) {
Log.w(TAG, "disable notify failed: " + e.getMessage());
@@ -150,12 +174,35 @@ public final class RemoteAccessService extends Service {
Log.w(TAG, "unsupported tunnel in connect");
return;
}
String sessionId = resp.optString("session_id", "");
long expiresAt = resp.optLong("expires_at", 0L);
if (!sessionId.isEmpty()) {
AppPreferences.setDevRemoteAccessSession(this, sessionId, expiresAt);
}
JSONObject creds = resp.optJSONObject("credentials");
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
if (wgConfig.isEmpty()) {
return;
}
WireGuardTunnelBridge.applyConfig(this, wgConfig);
if (WireGuardTunnelBridge.applyConfig(this, wgConfig)) {
updateNotificationConnected(sessionId);
}
}
private void updateNotificationConnected(String sessionId) {
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm == null) {
return;
}
String text = sessionId.isEmpty()
? getString(R.string.dev_remote_access_notification_connected)
: getString(R.string.dev_remote_access_notification_session, sessionId);
nm.notify(NOTIF_ID, new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(getString(R.string.dev_remote_access_notification_title))
.setContentText(text)
.setOngoing(true)
.build());
}
private void scheduleNextPoll() {

View File

@@ -272,6 +272,8 @@
<string name="dev_remote_access_notification_channel">Remote access</string>
<string name="dev_remote_access_notification_title">Remote debug active</string>
<string name="dev_remote_access_notification_poll">Polling BE (%1$s)</string>
<string name="dev_remote_access_notification_connected">WireGuard tunnel connected</string>
<string name="dev_remote_access_notification_session">Connected · session %1$s</string>
<string name="dev_tab_network_self_test">Network self-test</string>
<string name="dev_network_selftest_unlocked">Network self-test tab unlocked</string>
<string name="dev_network_selftest_intro">Hidden diagnostics tab. Long-press the tabs row to unlock. Runs quick network checks: connectivity, captive portal, CIDR/DNS/MTU, peer reachability, backend RTT/throughput, NAT and NTP correction.</string>

View File

@@ -6,8 +6,10 @@ See the full proposal: [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVE
<!-- toc -->
- [App (developer settings)](#app-developer-settings)
- [BE](#be)
- [WireGuard third-party](#wireguard-third-party)
- [BE control plane](#be-control-plane)
- [Production deploy (no new nginx HTTP paths)](#production-deploy-no-new-nginx-http-paths)
- [WireGuard data plane (UDP, not nginx)](#wireguard-data-plane-udp-not-nginx)
- [WireGuard third-party (Android)](#wireguard-third-party-android)
- [Tests](#tests)
<!-- /toc -->
@@ -19,29 +21,130 @@ See the full proposal: [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVE
- Dropdown: **Disabled** | **WireGuard** | **RSSH (greyed — not selectable)**
- Pref key: `dev_remote_access_mode` (`AppPreferences`)
- **Disabled:** stops `RemoteAccessService`, tears down VPN, POST `type:ra` + `status:disable`
- **WireGuard:** foreground poll 17 min`RemoteAccessService` → BE `heartbeat.php`
- **Disabled:** notifies BE (`status:disable`), tears down VPN, stops `RemoteAccessService`
- **WireGuard:** foreground service; jittered poll **17 min**`heartbeat.php` with `type: ra`
- Heartbeat URL derived from crash upload URL (`…/api/heartbeat.php`)
- Session credentials persisted until `expires_at`; expiry tears down tunnel locally
- Optional full userspace WG via `third-party/wireguard-android` (`:tunnel`); otherwise `RemoteAccessVpnService` TUN fallback
## BE
## BE control plane
- Migration: `sql/migrations/007_remote_access.sql`
- Repository: `src/RemoteAccessRepository.php`
- Device API: `public/api/heartbeat.php` (`type: ra`)
- Admin API: `public/api/remote_access.php`
- UI: `?view=remote_access` (nav + hub card)
- RBAC: `remote_access_view`, `remote_access_operate`, `remote_access_admin`
Uses the **existing crashes vhost** — no new nginx `location` blocks for HTTP.
## WireGuard third-party
| Piece | Path |
|-------|------|
| Migration | `sql/migrations/007_remote_access.sql` |
| Repository | `src/RemoteAccessRepository.php` |
| WG peers | `src/WireGuardPeerProvisioner.php` (`wg set wg0 peer …`) |
| Device API | `public/api/heartbeat.php` (`type: ra`) |
| Admin API | `public/api/remote_access.php` |
| UI | `?view=remote_access` (nav + hub card) |
| RBAC | `remote_access_view`, `remote_access_operate`, `remote_access_admin` |
| RBAC admin UI | `?view=rbac` — global roles (root), company roles, privilege sets |
| Cron | `scripts/purge_remote_access.php` (hourly recommended) |
**RBAC rules (enforced in API)**
| Permission | Scope |
|------------|--------|
| `remote_access_view` | List devices/sessions/events in allowed companies |
| `remote_access_operate` | Open session; close **own** sessions |
| `remote_access_admin` | Whitelist devices; close **any** session in company |
| Global admin / root | All companies; root edits global roles via `?view=rbac` |
Privilege sets (`remote_access_user`, `remote_access_admin`) on `company_memberships.permissions_json` grant extra actions to `member` rows — see crash console README § RBAC.
**Flow**
1. Device polls with `{type:ra, status:enable, capabilities:[wireguard], tunnel_mode:wireguard}`.
2. BE upserts `remote_access_devices`; returns `wait` until device is **whitelisted** and operator **opens session**.
3. Next eligible poll returns `action:connect` + ephemeral WG credentials; BE runs `wg set` when `provision_peers=true`.
4. Disable from app → `status:disable` → closes sessions, removes peers.
**URLs (production)**
- Dashboard: `https://apps.f0xx.org/app/androidcast_project/crashes/?view=remote_access`
- Heartbeat: `…/crashes/api/heartbeat.php`
- Admin API: `…/crashes/api/remote_access.php`
## Production deploy (no new nginx HTTP paths)
After syncing PHP/JS to the BE tree under
`…/androidcast_project/android_cast/examples/crash_reporter/backend/`:
### 1. Database
Migration **007** (already applied on prod per deploy notes). Tables auto-create on SQLite dev; MariaDB should use the migration file once as root.
### 2. `config.php` — required keys
```php
'remote_access' => [
'wg_endpoint' => 'ra.apps.f0xx.org:51820', // public UDP hostname:port (FE DNAT → BE)
'wg_server_public_key' => '…', // wg show wg0 public-key on BE — REQUIRED
'wg_interface' => 'wg0',
'provision_peers' => true, // wg set on connect/close
'require_wg_tools' => true, // reject connect if wg genkey missing
'session_ttl_s' => 3600,
'min_poll_interval_s' => 45, // poll rate limit per device
],
```
**Critical:** `wg_server_public_key` must match the live `wg0` interface. Empty value makes connect credentials unusable on real devices.
### 3. BE packages and permissions
```sh
apk add wireguard-tools wireguard-tools-wg
# wg0 configured separately (Address 10.66.66.1/24, ListenPort 51820, etc.)
# PHP-FPM user must run wg when provision_peers=true (same as CLI test):
sudo -u www-data wg show wg0
```
### 4. Cron (session cleanup)
```cron
0 * * * * cd /var/www/.../backend && php scripts/purge_remote_access.php --hours=24
```
### 5. Verify
```bash
cd examples/crash_reporter/backend
chmod +x scripts/verify_remote_access_prod.sh scripts/test_remote_access_api.sh
./scripts/verify_remote_access_prod.sh
BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_remote_access_api.sh
```
### 6. Browser
Hard-refresh after deploying `public/assets/js/remote_access.js`.
## WireGuard data plane (UDP, not nginx)
HTTP control plane stays on FE→BE `:80`. **WireGuard traffic is UDP** — configure at the **FE firewall/DNAT**, not in nginx:
```text
Internet UDP :51820 → FE DNAT → BE wg0 :51820
```
Hostname `ra.apps.f0xx.org` (or similar) should resolve to the FE public IP. Devices use `wg_endpoint` from config/connect payload.
## WireGuard third-party (Android)
```bash
bash scripts/init-wireguard-submodule.sh
./gradlew :app:assembleDebug # includes :tunnel when submodule present
```
Optional `:tunnel` module from `third-party/wireguard-android`. Without it, `WireGuardTunnelBridge` uses `RemoteAccessVpnService` (TUN fallback).
Without the submodule, `WireGuardTunnelBridge` uses `RemoteAccessVpnService` (TUN only — sufficient for lab; prefer `:tunnel` for production tunnels).
## Tests
```bash
./gradlew :app:testDebugUnitTest
./gradlew :app:testDebugUnitTest --tests 'com.foxx.androidcast.remoteaccess.*'
bash examples/crash_reporter/backend/scripts/test_rbac_api.sh
bash examples/crash_reporter/backend/scripts/test_remote_access_api.sh
BASE=https://apps.f0xx.org/app/androidcast_project/crashes bash examples/crash_reporter/backend/scripts/test_remote_access_api.sh
```

View File

@@ -345,6 +345,41 @@
position: relative;
z-index: 1;
}
.hub-page .platform-footer {
flex-direction: column;
align-items: stretch;
justify-content: center;
height: auto;
min-height: 40px;
padding: 6px 16px;
gap: 4px;
}
.hub-page .platform-footer__top,
.hub-page .platform-footer__bottom {
width: 100%;
text-align: center;
line-height: 1.35;
}
.hub-page .platform-footer__main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
min-height: 24px;
}
.hub-page .platform-footer--single-row .platform-footer__main {
justify-content: flex-start;
}
.hub-page .platform-footer__left {
flex: 1 1 auto;
min-width: 0;
}
.hub-page .platform-footer__right {
flex: 0 0 auto;
text-align: right;
max-width: 50%;
}
.hub-clock-widget {
--cw-lcd-x: 24px;

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 = [];
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>

View File

@@ -16,4 +16,4 @@ i need orchestration for the whole project and project parts. if you need to wri
2) update the existing infra from previous run
anyway, finally it should run, ideally on any devices
the side branch for it is 'feature/20260527_orchestration' forked from the current stable 'next'. do preparations in parallel, ill request SVG landing changes over 'next'
orchestration lives on next in ./orchestration/ (feature/20260527_orchestration merged and removed 2026-06-04). SVG landing changes continue on next / feature branches.

View File

@@ -2,17 +2,46 @@
## Footer (`footer.php`)
Single copyright footer for hub, crashes, tickets, graphs, and builder consoles.
Flexible site-wide footer for hub, crashes, tickets, graphs, and builder consoles.
**Configure:** edit `footer.config.php` (copy from `footer.config.example.php`).
### Layout
```
┌─────────────────────────────────────────────┐
│ top (optional, full width) │
├──────────────────────────┬──────────────────┤
│ left (optional / auto) │ right (optional) │
├──────────────────────────┴──────────────────┤
│ bottom (optional, full width) │
└─────────────────────────────────────────────┘
```
When `left` is null, the left column is built from `copyright_symbol`, `holder`, and year settings.
| Key | Purpose |
|-----|---------|
| `holder` | Copyright name |
| `copyright_symbol` | `(c)` or `©` |
| `show_year` | Append year |
| `year` | Fixed year, or `null` for current |
| `use_i18n` | `data-i18n` for consoles with `i18n.js` |
| `top` | Full-width line above main row (`null` = hidden) |
| `left` | Main left text; `null` = auto copyright line |
| `right` | Main right text (e.g. build id, links) |
| `bottom` | Full-width line below main row |
| `holder` | Copyright name (default left) |
| `copyright_symbol` | `©` (U+00A9) or empty |
| `show_year` | Append year / range on default left |
| `year` | Fixed end year, or `null` for current |
| `year_start` | If set (e.g. `2026`), shows `2026{current}` |
| `use_i18n` | `data-i18n` on auto-built left column |
| `footer_class` | Extra CSS class (always adds `platform-footer`) |
**Per-page override** when rendering:
```php
platform_render_footer([
'right' => 'OTA: staging',
'bottom' => 'Internal only',
]);
```
**In PHP views:** `<?php platform_render_footer(); ?>` (loaded via each app `bootstrap.php`).

View File

@@ -4,19 +4,34 @@ declare(strict_types=1);
/**
* Site-wide footer (hub, crashes, tickets, graphs, builder).
* Copy to footer.config.php and edit; footer.config.php is optional (defaults apply).
*
* Layout (empty/null row = hidden):
* top — full-width line above main row
* main — left | right (flex); invisible gap between columns
* bottom — full-width line below main row
*
* Left column: set `left` to a custom string, or leave null to build from holder + year below.
*/
return [
// Visible copyright holder (also used in i18n {holder} if you customize strings).
// --- Optional rows (static or PHP-dynamic strings) ---
'top' => null,
'left' => null,
'right' => null,
'bottom' => null,
// --- Default left column when `left` is null ---
'holder' => 'Anton Afanaasyeu',
// Prefix before the name: "(c)", "©", or "".
'copyright_symbol' => '(c)',
// Append current calendar year (or fixed year below).
'copyright_symbol' => "\u{00A9}",
'show_year' => true,
// null = (int) date('Y') at render time.
// null = current calendar year at render time (end of range).
'year' => null,
// CSS class on <footer>.
// Set e.g. 2026 for "2026{current}" (en dash). Omit for single year only.
'year_start' => "2026 - current",
# 'year_start' => null,
// CSS class on <footer> (platform-footer layout classes are always added).
'footer_class' => 'bottom-bar',
// When true, consoles with i18n.js replace text via data-i18n / data-year.
// When true, consoles with i18n.js replace default left text via data-i18n / data-year.
'use_i18n' => true,
'i18n_key' => 'footer.copyright',
];

View File

@@ -6,6 +6,11 @@ declare(strict_types=1);
*
* Configure: examples/platform/footer.config.php (see footer.config.example.php).
* Usage: <?php platform_render_footer(); ?> or platform_render_footer(['use_i18n' => false]);
*
* Layout (each row omitted when empty):
* [ top — full width ]
* [ left | right ] — main row (copyright defaults on left when left unset)
* [ bottom — full width ]
*/
function platform_footer_h(mixed $value): string
@@ -28,7 +33,8 @@ function platform_footer_config(): array
return is_array($loaded) ? $loaded : [];
}
function platform_footer_year(array $cfg): int
/** @param array<string, mixed> $cfg */
function platform_footer_year_end(array $cfg): int
{
if (array_key_exists('year', $cfg) && $cfg['year'] !== null) {
return (int) $cfg['year'];
@@ -37,9 +43,34 @@ function platform_footer_year(array $cfg): int
return (int) date('Y');
}
function platform_footer_plain_text(array $cfg, int $year): string
/**
* Year label: single year or range (e.g. 20262026) when year_start is set.
*
* @param array<string, mixed> $cfg
*/
function platform_footer_year_label(array $cfg): string
{
$symbol = trim((string) ($cfg['copyright_symbol'] ?? '(c)'));
$end = platform_footer_year_end($cfg);
$start = isset($cfg['year_start']) ? (int) $cfg['year_start'] : 0;
if ($start > 0) {
if ($start === $end) {
return (string) $start;
}
return $start . "\u{2013}" . $end;
}
return (string) $end;
}
/**
* Default left line: © holder[, year|year-range].
*
* @param array<string, mixed> $cfg
*/
function platform_footer_default_left(array $cfg): string
{
$symbol = trim((string) ($cfg['copyright_symbol'] ?? "\u{00A9}"));
$holder = trim((string) ($cfg['holder'] ?? ''));
$showYear = (bool) ($cfg['show_year'] ?? true);
$parts = [];
@@ -50,36 +81,117 @@ function platform_footer_plain_text(array $cfg, int $year): string
$parts[] = $holder;
}
$line = implode(' ', $parts);
if ($showYear && $line !== '') {
return $line . ', ' . $year;
if (!$showYear) {
return $line;
}
if ($showYear && $line === '') {
return (string) $year;
$yearLabel = platform_footer_year_label($cfg);
if ($line === '') {
return $yearLabel;
}
return $line;
return $line . ', ' . $yearLabel;
}
/**
* @param array{use_i18n?: bool, extra_class?: string} $opts
* Resolve optional row text (null/empty = omit row). Supports per-render overrides in $opts.
*
* @param array<string, mixed> $cfg
* @param array<string, mixed> $opts
*/
function platform_footer_field(array $cfg, array $opts, string $key): ?string
{
if (array_key_exists($key, $opts)) {
$v = $opts[$key];
if ($v === null || $v === false) {
return null;
}
$t = trim((string) $v);
return $t === '' ? null : $t;
}
if (!array_key_exists($key, $cfg)) {
return null;
}
$v = $cfg[$key];
if ($v === null || $v === false) {
return null;
}
$t = trim((string) $v);
return $t === '' ? null : $t;
}
/**
* @param array<string, mixed> $cfg
* @param array<string, mixed> $opts
*/
function platform_footer_resolve_left(array $cfg, array $opts): ?string
{
$custom = platform_footer_field($cfg, $opts, 'left');
if ($custom !== null) {
return $custom;
}
$built = platform_footer_default_left($cfg);
return $built === '' ? null : $built;
}
/** @deprecated Use platform_footer_default_left(); kept for scripts/tests. */
function platform_footer_plain_text(array $cfg, int $year): string
{
$cfg = array_merge($cfg, ['year' => $year]);
return platform_footer_default_left($cfg);
}
/**
* @param array{use_i18n?: bool, extra_class?: string, top?: ?string, left?: ?string, right?: ?string, bottom?: ?string} $opts
*/
function platform_render_footer(array $opts = []): void
{
$cfg = platform_footer_config();
$year = platform_footer_year($cfg);
$text = platform_footer_plain_text($cfg, $year);
$class = (string) ($opts['extra_class'] ?? ($cfg['footer_class'] ?? 'bottom-bar'));
$useI18n = $opts['use_i18n'] ?? (bool) ($cfg['use_i18n'] ?? true);
$i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright');
$top = platform_footer_field($cfg, $opts, 'top');
$left = platform_footer_resolve_left($cfg, $opts);
$right = platform_footer_field($cfg, $opts, 'right');
$bottom = platform_footer_field($cfg, $opts, 'bottom');
if ($useI18n && $i18nKey !== '') {
echo '<footer class="' . platform_footer_h($class) . '"'
. ' data-i18n="' . platform_footer_h($i18nKey) . '"'
. ' data-year="' . $year . '">'
. platform_footer_h($text)
. '</footer>';
return;
$baseClass = (string) ($opts['extra_class'] ?? ($cfg['footer_class'] ?? 'bottom-bar'));
$classes = trim($baseClass . ' platform-footer');
if ($top === null && $bottom === null && $right === null) {
$classes .= ' platform-footer--single-row';
}
echo '<footer class="' . platform_footer_h($class) . '">' . platform_footer_h($text) . '</footer>';
$useI18n = $opts['use_i18n'] ?? (bool) ($cfg['use_i18n'] ?? true);
$i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright');
$leftUsesDefault = platform_footer_field($cfg, $opts, 'left') === null;
$yearEnd = platform_footer_year_end($cfg);
echo '<footer class="' . platform_footer_h($classes) . '">';
if ($top !== null) {
echo '<div class="platform-footer__top">' . platform_footer_h($top) . '</div>';
}
if ($left !== null || $right !== null) {
echo '<div class="platform-footer__main">';
if ($left !== null) {
echo '<div class="platform-footer__left"';
if ($useI18n && $i18nKey !== '' && $leftUsesDefault) {
echo ' data-i18n="' . platform_footer_h($i18nKey) . '"'
. ' data-year="' . platform_footer_h(platform_footer_year_label($cfg)) . '"'
. ' data-year-end="' . $yearEnd . '"';
}
echo '>' . platform_footer_h($left) . '</div>';
}
if ($right !== null) {
echo '<div class="platform-footer__right">' . platform_footer_h($right) . '</div>';
}
echo '</div>';
}
if ($bottom !== null) {
echo '<div class="platform-footer__bottom">' . platform_footer_h($bottom) . '</div>';
}
echo '</footer>';
}