mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:17:39 +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_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_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_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. */
|
/** Minimum interval between automatic OTA checks on app launch. */
|
||||||
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
|
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();
|
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.
|
* Reset user-facing settings shown in Android Cast settings.
|
||||||
* Developer-only settings are intentionally preserved.
|
* Developer-only settings are intentionally preserved.
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ public final class RemoteAccessCoordinator {
|
|||||||
Context app = context.getApplicationContext();
|
Context app = context.getApplicationContext();
|
||||||
if (mode == null || mode == RemoteAccessMode.DISABLED) {
|
if (mode == null || mode == RemoteAccessMode.DISABLED) {
|
||||||
Log.i(TAG, "disabling remote access");
|
Log.i(TAG, "disabling remote access");
|
||||||
|
RemoteAccessService.sendDisableOnce(app);
|
||||||
RemoteAccessService.stop(app);
|
RemoteAccessService.stop(app);
|
||||||
RemoteAccessVpnService.stop(app);
|
RemoteAccessVpnService.stop(app);
|
||||||
RemoteAccessService.sendDisableOnce(app);
|
AppPreferences.clearDevRemoteAccessSession(app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode == RemoteAccessMode.WIREGUARD) {
|
if (mode == RemoteAccessMode.WIREGUARD) {
|
||||||
|
|||||||
@@ -45,7 +45,27 @@ public final class RemoteAccessService extends Service {
|
|||||||
|
|
||||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
private final Random random = new Random();
|
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) {
|
public static void start(Context context, RemoteAccessMode mode) {
|
||||||
Intent i = new Intent(context, RemoteAccessService.class);
|
Intent i = new Intent(context, RemoteAccessService.class);
|
||||||
@@ -94,6 +114,8 @@ public final class RemoteAccessService extends Service {
|
|||||||
}
|
}
|
||||||
ensureChannel();
|
ensureChannel();
|
||||||
startForeground(NOTIF_ID, buildNotification(mode));
|
startForeground(NOTIF_ID, buildNotification(mode));
|
||||||
|
loadOrCreateSessionRandom();
|
||||||
|
checkSessionExpiry();
|
||||||
final RemoteAccessMode pollMode = mode;
|
final RemoteAccessMode pollMode = mode;
|
||||||
if (ACTION_START.equals(action) || ACTION_POLL.equals(action)) {
|
if (ACTION_START.equals(action) || ACTION_POLL.equals(action)) {
|
||||||
executor.execute(() -> runPoll(pollMode));
|
executor.execute(() -> runPoll(pollMode));
|
||||||
@@ -103,12 +125,13 @@ public final class RemoteAccessService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void runPoll(RemoteAccessMode mode) {
|
private void runPoll(RemoteAccessMode mode) {
|
||||||
|
checkSessionExpiry();
|
||||||
try {
|
try {
|
||||||
JSONObject hb = new JSONObject();
|
JSONObject hb = new JSONObject();
|
||||||
hb.put("type", "ra");
|
hb.put("type", "ra");
|
||||||
hb.put("status", "enable");
|
hb.put("status", "enable");
|
||||||
hb.put("device_id", DeviceInfo.getDeviceUuid(this));
|
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("app_version", com.foxx.androidcast.BuildConfig.VERSION_NAME);
|
||||||
hb.put("tunnel_mode", mode.toApiValue());
|
hb.put("tunnel_mode", mode.toApiValue());
|
||||||
JSONArray caps = new JSONArray();
|
JSONArray caps = new JSONArray();
|
||||||
@@ -134,11 +157,12 @@ public final class RemoteAccessService extends Service {
|
|||||||
private void postDisableHeartbeat() {
|
private void postDisableHeartbeat() {
|
||||||
try {
|
try {
|
||||||
WireGuardTunnelBridge.tearDown(this);
|
WireGuardTunnelBridge.tearDown(this);
|
||||||
|
AppPreferences.clearDevRemoteAccessSession(this);
|
||||||
JSONObject hb = new JSONObject();
|
JSONObject hb = new JSONObject();
|
||||||
hb.put("type", "ra");
|
hb.put("type", "ra");
|
||||||
hb.put("status", "disable");
|
hb.put("status", "disable");
|
||||||
hb.put("device_id", DeviceInfo.getDeviceUuid(this));
|
hb.put("device_id", DeviceInfo.getDeviceUuid(this));
|
||||||
hb.put("random", sessionRandom);
|
hb.put("random", loadOrCreateSessionRandom());
|
||||||
RemoteAccessHeartbeatClient.postRa(this, hb);
|
RemoteAccessHeartbeatClient.postRa(this, hb);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "disable notify failed: " + e.getMessage());
|
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");
|
Log.w(TAG, "unsupported tunnel in connect");
|
||||||
return;
|
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");
|
JSONObject creds = resp.optJSONObject("credentials");
|
||||||
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
|
String wgConfig = WireGuardConfigBuilder.fromConnectCredentials(creds);
|
||||||
if (wgConfig.isEmpty()) {
|
if (wgConfig.isEmpty()) {
|
||||||
return;
|
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() {
|
private void scheduleNextPoll() {
|
||||||
|
|||||||
@@ -272,6 +272,8 @@
|
|||||||
<string name="dev_remote_access_notification_channel">Remote access</string>
|
<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_title">Remote debug active</string>
|
||||||
<string name="dev_remote_access_notification_poll">Polling BE (%1$s)</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_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_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>
|
<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 -->
|
<!-- toc -->
|
||||||
- [App (developer settings)](#app-developer-settings)
|
- [App (developer settings)](#app-developer-settings)
|
||||||
- [BE](#be)
|
- [BE control plane](#be-control-plane)
|
||||||
- [WireGuard third-party](#wireguard-third-party)
|
- [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)
|
- [Tests](#tests)
|
||||||
<!-- /toc -->
|
<!-- /toc -->
|
||||||
|
|
||||||
@@ -19,29 +21,130 @@ See the full proposal: [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVE
|
|||||||
|
|
||||||
- Dropdown: **Disabled** | **WireGuard** | **RSSH (greyed — not selectable)**
|
- Dropdown: **Disabled** | **WireGuard** | **RSSH (greyed — not selectable)**
|
||||||
- Pref key: `dev_remote_access_mode` (`AppPreferences`)
|
- Pref key: `dev_remote_access_mode` (`AppPreferences`)
|
||||||
- **Disabled:** stops `RemoteAccessService`, tears down VPN, POST `type:ra` + `status:disable`
|
- **Disabled:** notifies BE (`status:disable`), tears down VPN, stops `RemoteAccessService`
|
||||||
- **WireGuard:** foreground poll 1–7 min → `RemoteAccessService` → BE `heartbeat.php`
|
- **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`
|
Uses the **existing crashes vhost** — no new nginx `location` blocks for HTTP.
|
||||||
- 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`
|
|
||||||
|
|
||||||
## 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
|
||||||
bash scripts/init-wireguard-submodule.sh
|
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
|
## Tests
|
||||||
|
|
||||||
```bash
|
```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
|
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;
|
position: relative;
|
||||||
z-index: 1;
|
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 {
|
.hub-clock-widget {
|
||||||
--cw-lcd-x: 24px;
|
--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`.
|
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`
|
- **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`
|
- **Devices** auto-registered on upload; reports get `company_id` + `device_id`
|
||||||
- Default company slug `default` — existing rows migrate on first request (`Database::ensureRbacSchema()`)
|
- 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`):
|
Config (`config.php`):
|
||||||
|
|
||||||
```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`
|
NTP heartbeat (`POST …/api/heartbeat.php`, `type=ntp`) returns `time_source` and `ntp_correction_s`
|
||||||
for clients that sync clocks before upload.
|
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
|
## Default accounts
|
||||||
|
|
||||||
| User | Password | Role |
|
| User | Password | Role |
|
||||||
|
|||||||
@@ -45,4 +45,18 @@ return [
|
|||||||
'measurement_id' => '', // e.g. G-XXXXXXXXXX
|
'measurement_id' => '', // e.g. G-XXXXXXXXXX
|
||||||
'debug' => false,
|
'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,
|
'active_sessions' => $active,
|
||||||
'inactive_sessions' => array_slice($inactive, 0, 50),
|
'inactive_sessions' => array_slice($inactive, 0, 50),
|
||||||
'recent_events' => RemoteAccessRepository::listEvents(30),
|
'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);
|
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||||
@@ -67,7 +74,12 @@ if ($action === 'whitelist') {
|
|||||||
if ($deviceId === '') {
|
if ($deviceId === '') {
|
||||||
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
|
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, [
|
RemoteAccessRepository::logEvent($deviceId, null, $userId, 'whitelist_update', null, [
|
||||||
'whitelisted' => !empty($body['whitelisted']),
|
'whitelisted' => !empty($body['whitelisted']),
|
||||||
], '');
|
], '');
|
||||||
@@ -82,9 +94,13 @@ if ($action === 'open_session') {
|
|||||||
if ($deviceId === '') {
|
if ($deviceId === '') {
|
||||||
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
|
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);
|
$result = RemoteAccessRepository::openSession($deviceId, $userId);
|
||||||
if (empty($result['ok'])) {
|
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'] ?? '']);
|
json_out(['ok' => true, 'session_id' => $result['session_id'] ?? '']);
|
||||||
}
|
}
|
||||||
@@ -97,6 +113,9 @@ if ($action === 'close_session') {
|
|||||||
if ($sessionId === '') {
|
if ($sessionId === '') {
|
||||||
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
|
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::closeSession($sessionId, RemoteAccessRepository::STATUS_CLOSED, 'operator_closed');
|
||||||
RemoteAccessRepository::logEvent((string)($body['device_id'] ?? ''), $sessionId, $userId, 'session_close', 'operator', null, '');
|
RemoteAccessRepository::logEvent((string)($body['device_id'] ?? ''), $sessionId, $userId, 'session_close', 'operator', null, '');
|
||||||
json_out(['ok' => true]);
|
json_out(['ok' => true]);
|
||||||
|
|||||||
@@ -438,6 +438,41 @@ a:hover { text-decoration: underline; }
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
z-index: 40;
|
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 {
|
.login-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
"login.register": "Register",
|
"login.register": "Register",
|
||||||
"login.register_soon": "(coming soon)",
|
"login.register_soon": "(coming soon)",
|
||||||
"login.error": "Invalid credentials",
|
"login.error": "Invalid credentials",
|
||||||
"footer.copyright": "(c) Anton Afanaasyeu, {year}",
|
"footer.copyright": "© Anton Afanaasyeu, {year}",
|
||||||
"tickets.title": "Tickets",
|
"tickets.title": "Tickets",
|
||||||
"tickets.empty": "No tickets yet.",
|
"tickets.empty": "No tickets yet.",
|
||||||
"tickets.col_issue": "Issue",
|
"tickets.col_issue": "Issue",
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
"login.register": "Регистрация",
|
"login.register": "Регистрация",
|
||||||
"login.register_soon": "(скоро)",
|
"login.register_soon": "(скоро)",
|
||||||
"login.error": "Неверные учётные данные",
|
"login.error": "Неверные учётные данные",
|
||||||
"footer.copyright": "(c) Anton Afanaasyeu, {year}",
|
"footer.copyright": "© Anton Afanaasyeu, {year}",
|
||||||
"nav.tickets": "Тикеты",
|
"nav.tickets": "Тикеты",
|
||||||
"ticket.lifecycle": "Жизненный цикл",
|
"ticket.lifecycle": "Жизненный цикл",
|
||||||
"ticket.assignees": "Исполнители",
|
"ticket.assignees": "Исполнители",
|
||||||
|
|||||||
@@ -93,11 +93,10 @@
|
|||||||
if (k) opt.textContent = t(k);
|
if (k) opt.textContent = t(k);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const footer = document.querySelector('[data-i18n="footer.copyright"]');
|
scope.querySelectorAll('.platform-footer__left[data-i18n], footer[data-i18n="footer.copyright"]').forEach((footer) => {
|
||||||
if (footer) {
|
|
||||||
const year = footer.getAttribute('data-year') || String(new Date().getFullYear());
|
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) => {
|
scope.querySelectorAll('[data-i18n="detail.report_meta"]').forEach((el) => {
|
||||||
el.textContent = t('detail.report_meta', {
|
el.textContent = t('detail.report_meta', {
|
||||||
id: el.getAttribute('data-i18n-id') || '',
|
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') || '';
|
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) {
|
function apiUrl(action, params) {
|
||||||
const q = new URLSearchParams(params || {});
|
const q = new URLSearchParams(params || {});
|
||||||
q.set('action', action);
|
q.set('action', action);
|
||||||
@@ -35,19 +43,27 @@
|
|||||||
const tbody = document.getElementById('ra-devices-tbody');
|
const tbody = document.getElementById('ra-devices-tbody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.innerHTML = '';
|
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) => {
|
(devices || []).forEach((d) => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
const wl = Number(d.whitelisted) === 1;
|
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 =
|
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>' + 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.last_seen_at || '—') + '</td>' +
|
||||||
'<td>' + esc(d.app_version || '—') + '</td>' +
|
'<td>' + esc(d.app_version || '—') + '</td>' +
|
||||||
'<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 btn-primary"' + openDisabled + ' 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') + '">' +
|
'<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>' +
|
(wl ? 'Revoke WL' : 'Whitelist') + '</button> ' +
|
||||||
|
'<button type="button" class="btn btn-sm" data-copy-id="' + esc(d.device_id) + '">Copy ID</button>' +
|
||||||
'</td>';
|
'</td>';
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
@@ -58,15 +74,21 @@
|
|||||||
const iBody = document.getElementById('ra-inactive-tbody');
|
const iBody = document.getElementById('ra-inactive-tbody');
|
||||||
if (aBody) {
|
if (aBody) {
|
||||||
aBody.innerHTML = '';
|
aBody.innerHTML = '';
|
||||||
|
if (!active || !active.length) {
|
||||||
|
aBody.innerHTML = '<tr><td colspan="6" class="muted">No pending or active sessions.</td></tr>';
|
||||||
|
}
|
||||||
(active || []).forEach((s) => {
|
(active || []).forEach((s) => {
|
||||||
const tr = document.createElement('tr');
|
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 =
|
tr.innerHTML =
|
||||||
'<td><code>' + esc(s.session_id) + '</code></td>' +
|
'<td><code>' + esc(s.session_id) + '</code></td>' +
|
||||||
'<td><code>' + esc(s.device_id) + '</code></td>' +
|
'<td><code>' + esc(s.device_id) + '</code></td>' +
|
||||||
'<td>' + esc(s.status) + '</td>' +
|
'<td>' + esc(s.status) + '</td>' +
|
||||||
'<td>' + esc(s.tunnel) + '</td>' +
|
'<td>' + esc(s.tunnel) + '</td>' +
|
||||||
'<td>' + esc(s.endpoint || '—') + '</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);
|
aBody.appendChild(tr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -82,6 +104,9 @@
|
|||||||
'<td>' + esc(s.close_reason || '—') + '</td>';
|
'<td>' + esc(s.close_reason || '—') + '</td>';
|
||||||
iBody.appendChild(tr);
|
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');
|
const tbody = document.getElementById('ra-events-tbody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
if (!events || !events.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="muted">No audit events.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
(events || []).forEach((e) => {
|
(events || []).forEach((e) => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
@@ -100,14 +129,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastConfig = {};
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setStatus('Loading…');
|
setStatus('Loading…');
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson(apiUrl('dashboard'));
|
const data = await fetchJson(apiUrl('dashboard'));
|
||||||
|
lastConfig = data.config || {};
|
||||||
renderDevices(data.devices);
|
renderDevices(data.devices);
|
||||||
renderSessions(data.active_sessions, data.inactive_sessions);
|
renderSessions(data.active_sessions, data.inactive_sessions);
|
||||||
renderEvents(data.recent_events);
|
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) {
|
} catch (e) {
|
||||||
setStatus('Failed: ' + e.message, true);
|
setStatus('Failed: ' + e.message, true);
|
||||||
}
|
}
|
||||||
@@ -116,14 +149,27 @@
|
|||||||
document.addEventListener('click', async (ev) => {
|
document.addEventListener('click', async (ev) => {
|
||||||
const t = ev.target;
|
const t = ev.target;
|
||||||
if (!(t instanceof HTMLElement)) return;
|
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');
|
const openId = t.getAttribute('data-open-session');
|
||||||
if (openId) {
|
if (openId) {
|
||||||
|
if (!canOperate()) return;
|
||||||
try {
|
try {
|
||||||
|
setStatus('Opening session for ' + openId + '…');
|
||||||
await fetchJson(apiUrl('open_session'), {
|
await fetchJson(apiUrl('open_session'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ device_id: openId }),
|
body: JSON.stringify({ device_id: openId }),
|
||||||
});
|
});
|
||||||
|
setStatus('Session opened — device will connect on next poll (≤7 min)');
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus('Open session: ' + e.message, true);
|
setStatus('Open session: ' + e.message, true);
|
||||||
@@ -132,6 +178,7 @@
|
|||||||
}
|
}
|
||||||
const closeId = t.getAttribute('data-close-session');
|
const closeId = t.getAttribute('data-close-session');
|
||||||
if (closeId) {
|
if (closeId) {
|
||||||
|
if (!canOperate()) return;
|
||||||
try {
|
try {
|
||||||
await fetchJson(apiUrl('close_session'), {
|
await fetchJson(apiUrl('close_session'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -149,6 +196,7 @@
|
|||||||
}
|
}
|
||||||
const wlDevice = t.getAttribute('data-toggle-wl');
|
const wlDevice = t.getAttribute('data-toggle-wl');
|
||||||
if (wlDevice) {
|
if (wlDevice) {
|
||||||
|
if (!canAdmin()) return;
|
||||||
try {
|
try {
|
||||||
await fetchJson(apiUrl('whitelist'), {
|
await fetchJson(apiUrl('whitelist'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -167,8 +215,12 @@
|
|||||||
|
|
||||||
const form = document.getElementById('ra-whitelist-form');
|
const form = document.getElementById('ra-whitelist-form');
|
||||||
if (form) {
|
if (form) {
|
||||||
|
if (!canAdmin()) {
|
||||||
|
form.hidden = true;
|
||||||
|
}
|
||||||
form.addEventListener('submit', async (ev) => {
|
form.addEventListener('submit', async (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
if (!canAdmin()) return;
|
||||||
const fd = new FormData(form);
|
const fd = new FormData(form);
|
||||||
try {
|
try {
|
||||||
await fetchJson(apiUrl('whitelist'), {
|
await fetchJson(apiUrl('whitelist'), {
|
||||||
@@ -189,5 +241,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
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;
|
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')) {
|
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
|
||||||
require __DIR__ . '/api/ticket_attachment.php';
|
require __DIR__ . '/api/ticket_attachment.php';
|
||||||
exit;
|
exit;
|
||||||
@@ -203,6 +208,7 @@ $pageTitle = match ($view) {
|
|||||||
'tickets' => 'Tickets',
|
'tickets' => 'Tickets',
|
||||||
'graphs' => 'Graphs',
|
'graphs' => 'Graphs',
|
||||||
'remote_access' => 'Remote access',
|
'remote_access' => 'Remote access',
|
||||||
|
'rbac' => 'Access control',
|
||||||
'reports', 'report' => 'Crash reports',
|
'reports', 'report' => 'Crash reports',
|
||||||
default => 'Console',
|
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];
|
private const GLOBAL_ADMIN_ROLES = [self::GLOBAL_ROOT, self::GLOBAL_PLATFORM_ADMIN, self::GLOBAL_ADMIN_LEGACY];
|
||||||
|
|
||||||
/** Company role => granted actions (unless global admin). */
|
/** 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 = [
|
private const COMPANY_ROLE_ACTIONS = [
|
||||||
self::COMPANY_OWNER => [
|
self::COMPANY_OWNER => [
|
||||||
'view_reports',
|
'view_reports',
|
||||||
@@ -274,6 +295,76 @@ final class Rbac {
|
|||||||
return null;
|
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 {
|
private static function hasPermissionOverride(array $user, int $companyId, string $action): bool {
|
||||||
$pdo = Database::pdo();
|
$pdo = Database::pdo();
|
||||||
$stmt = $pdo->prepare(
|
$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 {
|
public static function ensureSchema(): void {
|
||||||
$pdo = Database::pdo();
|
$pdo = Database::pdo();
|
||||||
if (Database::tableExists($pdo, 'remote_access_devices')) {
|
if (!Database::tableExists($pdo, 'remote_access_devices')) {
|
||||||
|
self::createTables($pdo);
|
||||||
return;
|
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 {
|
private static function createTables(PDO $pdo): void {
|
||||||
@@ -60,6 +118,15 @@ final class RemoteAccessRepository {
|
|||||||
return self::handleDisable($deviceId, $random, $clientIp, $appVersion);
|
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);
|
self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp);
|
||||||
|
|
||||||
$device = self::findDevice($deviceId);
|
$device = self::findDevice($deviceId);
|
||||||
@@ -78,15 +145,40 @@ final class RemoteAccessRepository {
|
|||||||
return self::raEnvelope('deny', ['reason' => 'rssh_not_implemented']);
|
return self::raEnvelope('deny', ['reason' => 'rssh_not_implemented']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$session = self::findConnectableSession($deviceId, $random);
|
$session = $pendingSession ?? self::findConnectableSession($deviceId, $random);
|
||||||
if ($session === null) {
|
if ($session === null) {
|
||||||
self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp);
|
self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp);
|
||||||
return self::raEnvelope('wait', ['reason' => 'no_pending_session']);
|
return self::raEnvelope('wait', ['reason' => 'no_pending_session']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::touchSessionActivity((string) ($session['session_id'] ?? ''));
|
||||||
|
|
||||||
return self::buildConnectResponse($session, $deviceId, $clientIp);
|
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 */
|
/** @param list<string> $capabilities */
|
||||||
private static function inferTunnelMode(array $capabilities, array $heartbeat): string {
|
private static function inferTunnelMode(array $capabilities, array $heartbeat): string {
|
||||||
foreach ($capabilities as $cap) {
|
foreach ($capabilities as $cap) {
|
||||||
@@ -193,7 +285,17 @@ final class RemoteAccessRepository {
|
|||||||
return self::raEnvelope('deny', ['reason' => 'tunnel_not_supported', 'tunnel' => $tunnel]);
|
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'));
|
$endpoint = (string) ($session['endpoint'] ?? cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:51820'));
|
||||||
$expiresAt = (int) ($session['expires_at'] ?? (time() + 3600));
|
$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 {
|
private static function ensureSessionWireGuardKeys(string $sessionId, array $session): array {
|
||||||
$priv = trim((string) ($session['wg_client_private_key'] ?? ''));
|
$priv = trim((string) ($session['wg_client_private_key'] ?? ''));
|
||||||
|
$pub = trim((string) ($session['wg_client_public_key'] ?? ''));
|
||||||
$addr = trim((string) ($session['wg_client_address'] ?? ''));
|
$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'] ?? ''));
|
$allowed = trim((string) ($session['wg_peer_allowed_ips'] ?? ''));
|
||||||
if ($priv !== '' && $addr !== '' && $pub !== '') {
|
if ($priv !== '' && $addr !== '' && $serverPub !== '') {
|
||||||
|
if ($pub === '') {
|
||||||
|
$pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv);
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'client_private' => $priv,
|
'client_private' => $priv,
|
||||||
|
'client_public' => $pub,
|
||||||
'client_address' => $addr,
|
'client_address' => $addr,
|
||||||
'server_public' => $pub,
|
'server_public' => $serverPub,
|
||||||
'allowed_ips' => $allowed !== '' ? $allowed : '10.66.66.1/32',
|
'allowed_ips' => $allowed !== '' ? $allowed : '10.66.66.1/32',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
$pair = self::generateWireGuardKeyPair();
|
$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);
|
$clientIp = self::allocateClientAddress($sessionId);
|
||||||
$serverPub = cfg('remote_access.wg_server_public_key', '');
|
$serverPub = (string) cfg('remote_access.wg_server_public_key', '');
|
||||||
if ($serverPub === '') {
|
if ($serverPub === '') {
|
||||||
$serverPair = self::generateWireGuardKeyPair();
|
$serverPair = self::generateWireGuardKeyPair();
|
||||||
$serverPub = $serverPair['public'];
|
$serverPub = $serverPair['public'];
|
||||||
}
|
}
|
||||||
$allowed = '10.66.66.1/32';
|
$allowed = '10.66.66.1/32';
|
||||||
Database::pdo()->prepare(
|
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 = ?'
|
'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([$pair['private'], $clientIp, $serverPub, $allowed, $sessionId]);
|
)->execute([$priv, $pub, $clientIp, $serverPub, $allowed, $sessionId]);
|
||||||
|
|
||||||
|
if (!WireGuardPeerProvisioner::addClientPeer($pub, $clientIp)) {
|
||||||
|
error_log('RemoteAccess: wg peer add failed for session ' . $sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'client_private' => $pair['private'],
|
'client_private' => $priv,
|
||||||
|
'client_public' => $pub,
|
||||||
'client_address' => $clientIp,
|
'client_address' => $clientIp,
|
||||||
'server_public' => $serverPub,
|
'server_public' => $serverPub,
|
||||||
'allowed_ips' => $allowed,
|
'allowed_ips' => $allowed,
|
||||||
@@ -264,6 +381,9 @@ final class RemoteAccessRepository {
|
|||||||
return ['private' => $priv, 'public' => $pub];
|
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);
|
$raw = random_bytes(32);
|
||||||
$priv = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
$priv = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
||||||
return ['private' => $priv, 'public' => 'dev-placeholder-pub-' . substr(hash('sha256', $priv), 0, 43)];
|
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 {
|
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();
|
$now = self::nowSql();
|
||||||
Database::pdo()->prepare(
|
Database::pdo()->prepare(
|
||||||
'UPDATE remote_access_sessions SET status = ?, close_reason = ?, closed_at = ?, last_activity_at = ? WHERE session_id = ?'
|
'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)) {
|
if (!$device || !(int) ($device['whitelisted'] ?? 0)) {
|
||||||
return ['ok' => false, 'error' => 'device_not_whitelisted'];
|
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) {
|
if ((string) ($device['opt_in_mode'] ?? self::OPT_NONE) !== self::OPT_WIREGUARD) {
|
||||||
return ['ok' => false, 'error' => 'device_not_opted_in_wg'];
|
return ['ok' => false, 'error' => 'device_not_opted_in_wg'];
|
||||||
}
|
}
|
||||||
@@ -333,7 +465,17 @@ final class RemoteAccessRepository {
|
|||||||
self::ensureSchema();
|
self::ensureSchema();
|
||||||
$sql = 'SELECT * FROM remote_access_devices';
|
$sql = 'SELECT * FROM remote_access_devices';
|
||||||
$params = [];
|
$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 = ?';
|
$sql .= ' WHERE company_id = ?';
|
||||||
$params[] = $companyId;
|
$params[] = $companyId;
|
||||||
}
|
}
|
||||||
@@ -348,11 +490,16 @@ final class RemoteAccessRepository {
|
|||||||
self::ensureSchema();
|
self::ensureSchema();
|
||||||
$sql = 'SELECT s.*, d.opt_in_mode, d.app_version AS device_app_version FROM remote_access_sessions s
|
$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';
|
LEFT JOIN remote_access_devices d ON d.device_id = s.device_id';
|
||||||
|
$where = [];
|
||||||
$params = [];
|
$params = [];
|
||||||
if ($statusFilter !== '') {
|
if ($statusFilter !== '') {
|
||||||
$sql .= ' WHERE s.status = ?';
|
$where[] = 's.status = ?';
|
||||||
$params[] = $statusFilter;
|
$params[] = $statusFilter;
|
||||||
}
|
}
|
||||||
|
self::appendCompanyScopeSql('s.company_id', $where, $params);
|
||||||
|
if ($where !== []) {
|
||||||
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||||
|
}
|
||||||
$sql .= ' ORDER BY s.id DESC LIMIT 200';
|
$sql .= ' ORDER BY s.id DESC LIMIT 200';
|
||||||
$st = Database::pdo()->prepare($sql);
|
$st = Database::pdo()->prepare($sql);
|
||||||
$st->execute($params);
|
$st->execute($params);
|
||||||
@@ -362,8 +509,28 @@ final class RemoteAccessRepository {
|
|||||||
/** @return list<array<string, mixed>> */
|
/** @return list<array<string, mixed>> */
|
||||||
public static function listEvents(int $limit = 100): array {
|
public static function listEvents(int $limit = 100): array {
|
||||||
self::ensureSchema();
|
self::ensureSchema();
|
||||||
$st = Database::pdo()->prepare('SELECT * FROM remote_access_events ORDER BY id DESC LIMIT ?');
|
$sql = 'SELECT e.* FROM remote_access_events e';
|
||||||
$st->bindValue(1, max(1, min(500, $limit)), PDO::PARAM_INT);
|
$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();
|
$st->execute();
|
||||||
return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||||
}
|
}
|
||||||
@@ -372,11 +539,15 @@ final class RemoteAccessRepository {
|
|||||||
self::ensureSchema();
|
self::ensureSchema();
|
||||||
$device = self::findDevice($deviceId);
|
$device = self::findDevice($deviceId);
|
||||||
if (!$device) {
|
if (!$device) {
|
||||||
|
$companyId = Rbac::activeCompanyId() ?? Rbac::defaultCompanyId();
|
||||||
|
if (!Rbac::can('remote_access_admin', $companyId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Database::pdo()->prepare(
|
Database::pdo()->prepare(
|
||||||
'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
)->execute([
|
)->execute([
|
||||||
$deviceId,
|
$deviceId,
|
||||||
Rbac::defaultCompanyId(),
|
$companyId,
|
||||||
$whitelisted ? 1 : 0,
|
$whitelisted ? 1 : 0,
|
||||||
$notes,
|
$notes,
|
||||||
self::nowSql(),
|
self::nowSql(),
|
||||||
@@ -384,6 +555,10 @@ final class RemoteAccessRepository {
|
|||||||
]);
|
]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
$companyId = (int) ($device['company_id'] ?? 0);
|
||||||
|
if (!Rbac::canAccessCompany($companyId) || !Rbac::can('remote_access_admin', $companyId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Database::pdo()->prepare(
|
Database::pdo()->prepare(
|
||||||
'UPDATE remote_access_devices SET whitelisted = ?, notes = COALESCE(?, notes), updated_at = ? WHERE device_id = ?'
|
'UPDATE remote_access_devices SET whitelisted = ?, notes = COALESCE(?, notes), updated_at = ? WHERE device_id = ?'
|
||||||
)->execute([$whitelisted ? 1 : 0, $notes, self::nowSql(), $deviceId]);
|
)->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__ . '/TicketRepository.php';
|
||||||
require_once __DIR__ . '/GraphRepository.php';
|
require_once __DIR__ . '/GraphRepository.php';
|
||||||
require_once __DIR__ . '/RemoteAccessRepository.php';
|
require_once __DIR__ . '/RemoteAccessRepository.php';
|
||||||
|
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
|
||||||
require_once __DIR__ . '/AnalyticsHead.php';
|
require_once __DIR__ . '/AnalyticsHead.php';
|
||||||
|
|
||||||
function cfg(string $key, $default = null) {
|
function cfg(string $key, $default = null) {
|
||||||
|
|||||||
@@ -37,13 +37,19 @@
|
|||||||
<?php if (($view ?? '') === 'remote_access'): ?>
|
<?php if (($view ?? '') === 'remote_access'): ?>
|
||||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (($view ?? '') === 'rbac'): ?>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/rbac_admin.js" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
<?php AnalyticsHead::render('crashes'); ?>
|
<?php AnalyticsHead::render('crashes'); ?>
|
||||||
</head>
|
</head>
|
||||||
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
||||||
data-view="<?= h($view ?? 'home') ?>"
|
data-view="<?= h($view ?? 'home') ?>"
|
||||||
data-can-tag-edit="<?= Auth::canEditTags() ? '1' : '0' ?>"
|
data-can-tag-edit="<?= Auth::canEditTags() ? '1' : '0' ?>"
|
||||||
<?= (($view ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>
|
<?= (($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>
|
<header class="top-menu" hidden aria-hidden="true"></header>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
|
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
|
||||||
@@ -101,6 +107,17 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<?php endif; ?>
|
<?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>
|
<li>
|
||||||
<a href="/app/androidcast_project/build/"
|
<a href="/app/androidcast_project/build/"
|
||||||
class="nav-link"
|
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.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.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>
|
<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>
|
</ul>
|
||||||
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
|
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
|
||||||
<?php require __DIR__ . '/report_detail.php'; ?>
|
<?php require __DIR__ . '/report_detail.php'; ?>
|
||||||
@@ -357,6 +380,10 @@
|
|||||||
<a href="/app/androidcast_project/">Hub</a>
|
<a href="/app/androidcast_project/">Hub</a>
|
||||||
</nav>
|
</nav>
|
||||||
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
<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">
|
<section class="graphs-scope">
|
||||||
<h2 class="graphs-scope-title">Whitelisted devices</h2>
|
<h2 class="graphs-scope-title">Whitelisted devices</h2>
|
||||||
@@ -436,6 +463,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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; ?>
|
<?php endif; ?>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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
|
2) update the existing infra from previous run
|
||||||
anyway, finally it should run, ideally on any devices
|
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`)
|
## 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`).
|
**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 |
|
| Key | Purpose |
|
||||||
|-----|---------|
|
|-----|---------|
|
||||||
| `holder` | Copyright name |
|
| `top` | Full-width line above main row (`null` = hidden) |
|
||||||
| `copyright_symbol` | `(c)` or `©` |
|
| `left` | Main left text; `null` = auto copyright line |
|
||||||
| `show_year` | Append year |
|
| `right` | Main right text (e.g. build id, links) |
|
||||||
| `year` | Fixed year, or `null` for current |
|
| `bottom` | Full-width line below main row |
|
||||||
| `use_i18n` | `data-i18n` for consoles with `i18n.js` |
|
| `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`).
|
**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).
|
* Site-wide footer (hub, crashes, tickets, graphs, builder).
|
||||||
* Copy to footer.config.php and edit; footer.config.php is optional (defaults apply).
|
* 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 [
|
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',
|
'holder' => 'Anton Afanaasyeu',
|
||||||
// Prefix before the name: "(c)", "©", or "".
|
'copyright_symbol' => "\u{00A9}",
|
||||||
'copyright_symbol' => '(c)',
|
|
||||||
// Append current calendar year (or fixed year below).
|
|
||||||
'show_year' => true,
|
'show_year' => true,
|
||||||
// null = (int) date('Y') at render time.
|
// null = current calendar year at render time (end of range).
|
||||||
'year' => null,
|
'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',
|
'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,
|
'use_i18n' => true,
|
||||||
'i18n_key' => 'footer.copyright',
|
'i18n_key' => 'footer.copyright',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* Configure: examples/platform/footer.config.php (see footer.config.example.php).
|
* Configure: examples/platform/footer.config.php (see footer.config.example.php).
|
||||||
* Usage: <?php platform_render_footer(); ?> or platform_render_footer(['use_i18n' => false]);
|
* 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
|
function platform_footer_h(mixed $value): string
|
||||||
@@ -28,7 +33,8 @@ function platform_footer_config(): array
|
|||||||
return is_array($loaded) ? $loaded : [];
|
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) {
|
if (array_key_exists('year', $cfg) && $cfg['year'] !== null) {
|
||||||
return (int) $cfg['year'];
|
return (int) $cfg['year'];
|
||||||
@@ -37,9 +43,34 @@ function platform_footer_year(array $cfg): int
|
|||||||
return (int) date('Y');
|
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'] ?? ''));
|
$holder = trim((string) ($cfg['holder'] ?? ''));
|
||||||
$showYear = (bool) ($cfg['show_year'] ?? true);
|
$showYear = (bool) ($cfg['show_year'] ?? true);
|
||||||
$parts = [];
|
$parts = [];
|
||||||
@@ -50,36 +81,117 @@ function platform_footer_plain_text(array $cfg, int $year): string
|
|||||||
$parts[] = $holder;
|
$parts[] = $holder;
|
||||||
}
|
}
|
||||||
$line = implode(' ', $parts);
|
$line = implode(' ', $parts);
|
||||||
if ($showYear && $line !== '') {
|
if (!$showYear) {
|
||||||
return $line . ', ' . $year;
|
return $line;
|
||||||
}
|
}
|
||||||
if ($showYear && $line === '') {
|
$yearLabel = platform_footer_year_label($cfg);
|
||||||
return (string) $year;
|
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
|
function platform_render_footer(array $opts = []): void
|
||||||
{
|
{
|
||||||
$cfg = platform_footer_config();
|
$cfg = platform_footer_config();
|
||||||
$year = platform_footer_year($cfg);
|
$top = platform_footer_field($cfg, $opts, 'top');
|
||||||
$text = platform_footer_plain_text($cfg, $year);
|
$left = platform_footer_resolve_left($cfg, $opts);
|
||||||
$class = (string) ($opts['extra_class'] ?? ($cfg['footer_class'] ?? 'bottom-bar'));
|
$right = platform_footer_field($cfg, $opts, 'right');
|
||||||
$useI18n = $opts['use_i18n'] ?? (bool) ($cfg['use_i18n'] ?? true);
|
$bottom = platform_footer_field($cfg, $opts, 'bottom');
|
||||||
$i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright');
|
|
||||||
|
|
||||||
if ($useI18n && $i18nKey !== '') {
|
$baseClass = (string) ($opts['extra_class'] ?? ($cfg['footer_class'] ?? 'bottom-bar'));
|
||||||
echo '<footer class="' . platform_footer_h($class) . '"'
|
$classes = trim($baseClass . ' platform-footer');
|
||||||
. ' data-i18n="' . platform_footer_h($i18nKey) . '"'
|
if ($top === null && $bottom === null && $right === null) {
|
||||||
. ' data-year="' . $year . '">'
|
$classes .= ' platform-footer--single-row';
|
||||||
. platform_footer_h($text)
|
|
||||||
. '</footer>';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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