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