From a29d84038e1f2bfdd0d849085a8598d0926f0fde Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Thu, 4 Jun 2026 15:14:08 +0200 Subject: [PATCH] footer changes, remote access changes --- .../com/foxx/androidcast/AppPreferences.java | 29 ++ .../remoteaccess/RemoteAccessCoordinator.java | 3 +- .../remoteaccess/RemoteAccessService.java | 55 +++- app/src/main/res/values/strings.xml | 2 + docs/REMOTE_ACCESS_IMPL.md | 131 ++++++++- examples/app_hub/hub.css | 35 +++ examples/crash_reporter/backend/README.md | 39 ++- .../backend/config/config.example.php | 14 + .../backend/public/api/rbac.php | 58 ++++ .../backend/public/api/remote_access.php | 23 +- .../backend/public/assets/css/app.css | 35 +++ .../backend/public/assets/i18n/en.json | 2 +- .../backend/public/assets/i18n/ru.json | 2 +- .../backend/public/assets/js/i18n.js | 7 +- .../backend/public/assets/js/rbac_admin.js | 161 +++++++++++ .../backend/public/assets/js/remote_access.js | 68 ++++- .../crash_reporter/backend/public/index.php | 6 + .../backend/scripts/purge_remote_access.php | 22 ++ .../backend/scripts/test_rbac_api.sh | 16 ++ .../scripts/verify_remote_access_prod.sh | 96 +++++++ examples/crash_reporter/backend/src/Rbac.php | 91 +++++++ .../backend/src/RbacAdminRepository.php | 251 ++++++++++++++++++ .../backend/src/RemoteAccessRepository.php | 209 +++++++++++++-- .../backend/src/WireGuardPeerProvisioner.php | 62 +++++ .../crash_reporter/backend/src/bootstrap.php | 1 + .../crash_reporter/backend/views/layout.php | 58 +++- examples/definitions.txt | 2 +- examples/platform/README.md | 41 ++- examples/platform/footer.config.example.php | 29 +- examples/platform/footer.php | 156 +++++++++-- 30 files changed, 1612 insertions(+), 92 deletions(-) create mode 100644 examples/crash_reporter/backend/public/api/rbac.php create mode 100644 examples/crash_reporter/backend/public/assets/js/rbac_admin.js create mode 100644 examples/crash_reporter/backend/scripts/purge_remote_access.php create mode 100755 examples/crash_reporter/backend/scripts/test_rbac_api.sh create mode 100755 examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh create mode 100644 examples/crash_reporter/backend/src/RbacAdminRepository.php create mode 100644 examples/crash_reporter/backend/src/WireGuardPeerProvisioner.php diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java index 7c0911c..9fedc41 100644 --- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java +++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java @@ -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. diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java index 63ac4b7..fd77faa 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessCoordinator.java @@ -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) { diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java index 131df99..5276862 100644 --- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java +++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessService.java @@ -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() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31e18ef..b69d689 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -272,6 +272,8 @@ Remote access Remote debug active Polling BE (%1$s) + WireGuard tunnel connected + Connected · session %1$s Network self-test Network self-test tab unlocked 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. diff --git a/docs/REMOTE_ACCESS_IMPL.md b/docs/REMOTE_ACCESS_IMPL.md index 1924b45..59b328f 100644 --- a/docs/REMOTE_ACCESS_IMPL.md +++ b/docs/REMOTE_ACCESS_IMPL.md @@ -6,8 +6,10 @@ See the full proposal: [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVE - [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) @@ -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 1–7 min → `RemoteAccessService` → BE `heartbeat.php` +- **Disabled:** notifies BE (`status:disable`), tears down VPN, stops `RemoteAccessService` +- **WireGuard:** foreground service; jittered poll **1–7 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 ``` diff --git a/examples/app_hub/hub.css b/examples/app_hub/hub.css index f03e938..56322a1 100644 --- a/examples/app_hub/hub.css +++ b/examples/app_hub/hub.css @@ -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; diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md index f6bf97e..4ec921d 100644 --- a/examples/crash_reporter/backend/README.md +++ b/examples/crash_reporter/backend/README.md @@ -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 1–7 min while enabled. + ## Default accounts | User | Password | Role | diff --git a/examples/crash_reporter/backend/config/config.example.php b/examples/crash_reporter/backend/config/config.example.php index 34ba5d8..f51a251 100644 --- a/examples/crash_reporter/backend/config/config.example.php +++ b/examples/crash_reporter/backend/config/config.example.php @@ -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, + ], ]; diff --git a/examples/crash_reporter/backend/public/api/rbac.php b/examples/crash_reporter/backend/public/api/rbac.php new file mode 100644 index 0000000..b1283cc --- /dev/null +++ b/examples/crash_reporter/backend/public/api/rbac.php @@ -0,0 +1,58 @@ + 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); diff --git a/examples/crash_reporter/backend/public/api/remote_access.php b/examples/crash_reporter/backend/public/api/remote_access.php index 42f664f..9d8bb4a 100644 --- a/examples/crash_reporter/backend/public/api/remote_access.php +++ b/examples/crash_reporter/backend/public/api/remote_access.php @@ -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]); diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index 68c2c32..dcaefbc 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -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; diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json index 7196d5d..d8bf387 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/en.json +++ b/examples/crash_reporter/backend/public/assets/i18n/en.json @@ -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", diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json index 0dd3dc2..bb382d0 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/ru.json +++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json @@ -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": "Исполнители", diff --git a/examples/crash_reporter/backend/public/assets/js/i18n.js b/examples/crash_reporter/backend/public/assets/js/i18n.js index 5b35eaa..1314f73 100644 --- a/examples/crash_reporter/backend/public/assets/js/i18n.js +++ b/examples/crash_reporter/backend/public/assets/js/i18n.js @@ -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') || '', diff --git a/examples/crash_reporter/backend/public/assets/js/rbac_admin.js b/examples/crash_reporter/backend/public/assets/js/rbac_admin.js new file mode 100644 index 0000000..d31ec2c --- /dev/null +++ b/examples/crash_reporter/backend/public/assets/js/rbac_admin.js @@ -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() + ? '' + : '' + esc(u.global_role) + ''; + + const roleSelect = + ''; + + const setSelect = + ''; + + tr.innerHTML = + '' + esc(u.username) + '' + + '' + globalSelect + '' + + '' + esc(m.slug) + ' ' + esc(m.name) + '' + + '' + roleSelect + '' + + '' + setSelect + ''; + tbody.appendChild(tr); + }); + if (!(u.memberships || []).length && canEditGlobal()) { + const tr = document.createElement('tr'); + tr.innerHTML = + '' + esc(u.username) + '' + + '' + esc(u.global_role) + '' + + 'No company membership'; + tbody.appendChild(tr); + } + }); + + if (!tbody.children.length) { + tbody.innerHTML = 'No users in scope.'; + } + + 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(); + }); +})(); diff --git a/examples/crash_reporter/backend/public/assets/js/remote_access.js b/examples/crash_reporter/backend/public/assets/js/remote_access.js index 2734a81..b1608f2 100644 --- a/examples/crash_reporter/backend/public/assets/js/remote_access.js +++ b/examples/crash_reporter/backend/public/assets/js/remote_access.js @@ -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 = 'No devices have polled yet.'; + 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 = - '' + esc(d.device_id) + '' + + '' + esc(d.device_id) + '' + '' + esc(d.opt_in_mode || 'none') + '' + - '' + (wl ? 'yes' : 'no') + '' + + '' + (wl ? 'yes' : 'no') + '' + '' + esc(d.last_seen_at || '—') + '' + '' + esc(d.app_version || '—') + '' + '' + - ' ' + - '' + + ' ' + + ' ' + + '' + ''; tbody.appendChild(tr); }); @@ -58,15 +74,21 @@ const iBody = document.getElementById('ra-inactive-tbody'); if (aBody) { aBody.innerHTML = ''; + if (!active || !active.length) { + aBody.innerHTML = 'No pending or active sessions.'; + } (active || []).forEach((s) => { const tr = document.createElement('tr'); + const closeBtn = canOperate() + ? '' + : ''; tr.innerHTML = '' + esc(s.session_id) + '' + '' + esc(s.device_id) + '' + '' + esc(s.status) + '' + '' + esc(s.tunnel) + '' + '' + esc(s.endpoint || '—') + '' + - ''; + '' + closeBtn + ''; aBody.appendChild(tr); }); } @@ -82,6 +104,9 @@ '' + esc(s.close_reason || '—') + ''; iBody.appendChild(tr); }); + if (!inactive || !inactive.length) { + iBody.innerHTML = 'No closed sessions yet.'; + } } } @@ -89,6 +114,10 @@ const tbody = document.getElementById('ra-events-tbody'); if (!tbody) return; tbody.innerHTML = ''; + if (!events || !events.length) { + tbody.innerHTML = 'No audit events.'; + 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); })(); diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php index e2c0d45..33e66c6 100644 --- a/examples/crash_reporter/backend/public/index.php +++ b/examples/crash_reporter/backend/public/index.php @@ -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', }; diff --git a/examples/crash_reporter/backend/scripts/purge_remote_access.php b/examples/crash_reporter/backend/scripts/purge_remote_access.php new file mode 100644 index 0000000..c314a6e --- /dev/null +++ b/examples/crash_reporter/backend/scripts/purge_remote_access.php @@ -0,0 +1,22 @@ +#!/usr/bin/env php +/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." diff --git a/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh b/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh new file mode 100755 index 0000000..bdf8140 --- /dev/null +++ b/examples/crash_reporter/backend/scripts/verify_remote_access_prod.sh @@ -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 diff --git a/examples/crash_reporter/backend/src/Rbac.php b/examples/crash_reporter/backend/src/Rbac.php index cd7303a..4f30773 100644 --- a/examples/crash_reporter/backend/src/Rbac.php +++ b/examples/crash_reporter/backend/src/Rbac.php @@ -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> */ + 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 */ + public static function privilegeSetKeys(): array { + return array_keys(self::PRIVILEGE_SETS); + } + + /** @return list */ + 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( diff --git a/examples/crash_reporter/backend/src/RbacAdminRepository.php b/examples/crash_reporter/backend/src/RbacAdminRepository.php new file mode 100644 index 0000000..45532f3 --- /dev/null +++ b/examples/crash_reporter/backend/src/RbacAdminRepository.php @@ -0,0 +1,251 @@ + 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 */ + 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> $rows @return list */ + 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 $companyIds + * @return list> + */ + 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 $companyIds + * @return list> + */ + 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; + } +} diff --git a/examples/crash_reporter/backend/src/RemoteAccessRepository.php b/examples/crash_reporter/backend/src/RemoteAccessRepository.php index ffae8f2..2519c09 100644 --- a/examples/crash_reporter/backend/src/RemoteAccessRepository.php +++ b/examples/crash_reporter/backend/src/RemoteAccessRepository.php @@ -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 $where @param list $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|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 $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 $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 $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string} */ + /** @param array $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> */ 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]); diff --git a/examples/crash_reporter/backend/src/WireGuardPeerProvisioner.php b/examples/crash_reporter/backend/src/WireGuardPeerProvisioner.php new file mode 100644 index 0000000..bda99ee --- /dev/null +++ b/examples/crash_reporter/backend/src/WireGuardPeerProvisioner.php @@ -0,0 +1,62 @@ +/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); + } +} diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php index 767d72f..daa69e2 100644 --- a/examples/crash_reporter/backend/src/bootstrap.php +++ b/examples/crash_reporter/backend/src/bootstrap.php @@ -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) { diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index 84a105e..266950e 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -37,13 +37,19 @@ + + + - > + + + + >

Loading…

+

+ Device poll: /api/heartbeat.php (type: ra). + No nginx changes required — same crashes vhost. Configure remote_access.* in BE config.php. +

Whitelisted devices

@@ -436,6 +463,35 @@
+ +
+
+

Access control

+
+

Loading…

+

+
+

Users & company roles

+
+ + + + + + + + + + + +
UserGlobal roleCompanyCompany rolePrivilege set
+
+
+

+ API: /api/rbac.php · + Root edits global roles; company owner/admin edits memberships and remote-access privilege sets. +

+
diff --git a/examples/definitions.txt b/examples/definitions.txt index b155408..56b8b53 100644 --- a/examples/definitions.txt +++ b/examples/definitions.txt @@ -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. diff --git a/examples/platform/README.md b/examples/platform/README.md index afa1508..da0cd26 100644 --- a/examples/platform/README.md +++ b/examples/platform/README.md @@ -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:** `` (loaded via each app `bootstrap.php`). diff --git a/examples/platform/footer.config.example.php b/examples/platform/footer.config.example.php index 872baed..166c333 100644 --- a/examples/platform/footer.config.example.php +++ b/examples/platform/footer.config.example.php @@ -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