mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:58:51 +03:00
BE sync
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] User registration + email verification
|
||||
- [ ] User registration + email verification + TOTP 2FA — **alpha must-have** — [docs/20260607-2FA-email-mobile-auth-flow.md](../../docs/20260607-2FA-email-mobile-auth-flow.md)
|
||||
- [ ] Email infra: `info@` / `admin@` / `root@` @ `apps.f0xx.org` → Gmail forward (§9)
|
||||
- [ ] RBAC editor UI (delegate roles per module)
|
||||
- [ ] Symbolication (ndk-stack / retrace) for native/Java stacks
|
||||
- [ ] MQTT ingest endpoint (parallel to HTTP POST)
|
||||
|
||||
@@ -311,7 +311,13 @@ BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/ra_e2e_cli.
|
||||
|
||||
**CLI validation (Linux device simulator):** [docs/REMOTE_ACCESS_VALIDATION.md](../../../docs/REMOTE_ACCESS_VALIDATION.md).
|
||||
|
||||
Android: Developer settings → **Remote access** → WireGuard (RSSH reserved). Requires VPN permission; polls BE every 1–7 min while enabled.
|
||||
Android: Developer settings → **Remote access** → **RSSH** (alpha target; WireGuard lab-only). RSSH avoids VPN permission. Polls BE every 1–7 min while enabled. See [REMOTE_ACCESS_IMPL.md § VPN consent](../../../docs/REMOTE_ACCESS_IMPL.md#android-vpn-consent-wireguard) and [ROADMAP § RSSH](../../../docs/ROADMAP.md#remote-access-alpha--rssh).
|
||||
|
||||
## Third-party licenses (BE / landing)
|
||||
|
||||
- [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) — server stack, optional WireGuard tools, link to mobile app list.
|
||||
- Mobile APK list: in-app **Licenses** button → `app/src/main/assets/licenses/combined.html`.
|
||||
- Suggested public URL after sync: `…/crashes/THIRD_PARTY_LICENSES.md` (or HTML excerpt on hub legal page).
|
||||
|
||||
## Default accounts
|
||||
|
||||
|
||||
46
examples/crash_reporter/backend/THIRD_PARTY_LICENSES.md
Normal file
46
examples/crash_reporter/backend/THIRD_PARTY_LICENSES.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Third-party licenses — crash reporter backend & apps.f0xx.org stack
|
||||
|
||||
Prepared for landing / legal disclosure. **Verify against production deploy** before publishing.
|
||||
|
||||
## Android Cast application code (PHP/JS in this tree)
|
||||
|
||||
- **License:** same BSD-style terms as the Android Cast mobile app (see repo root / mobile `licenses/combined.html`).
|
||||
- **Scope:** `examples/crash_reporter/backend/` PHP, views, static JS/CSS under `public/assets/`.
|
||||
|
||||
## Runtime infrastructure (operator-installed)
|
||||
|
||||
| Component | Typical license | Notes |
|
||||
|-----------|-----------------|--------|
|
||||
| Alpine Linux | Various (GPL-2.0 kernel; mixed userspace) | BE VM base |
|
||||
| nginx | BSD-2-Clause | Reverse proxy / static |
|
||||
| PHP 8.x | PHP License v3.01 | FPM |
|
||||
| MariaDB / SQLite | GPL-2.0 (MariaDB); public domain (SQLite) | DB engine |
|
||||
| OpenSSH / OpenSSL | Various | Admin access, TLS |
|
||||
|
||||
These are **server-side**; not bundled in the mobile APK.
|
||||
|
||||
## Optional remote-access tooling
|
||||
|
||||
| Component | License | When used |
|
||||
|-----------|---------|-----------|
|
||||
| **wireguard-tools** (`wg`, `wg-quick`) | GPLv2 (userspace) | BE dynamic peer provisioning (`WireGuardPeerProvisioner.php`) |
|
||||
| Linux **WireGuard** kernel module | GPLv2 | UDP tunnel termination on BE |
|
||||
|
||||
Mobile WireGuard mode uses outbound tunnel from device; see mobile app licenses for optional `wireguard-android` (Apache-2.0).
|
||||
|
||||
## Planned: reverse SSH (RSSH)
|
||||
|
||||
**Alpha essential** (owner agreement). Device opt-in via existing `heartbeat.php` RA flow; outbound SSH reverse tunnel to bastion; no `VpnService`. Stack may include OpenSSH-compatible client on device and `sshd`/SFTP on bastion — licenses TBD (typically OpenSSH license + OpenSSL).
|
||||
|
||||
## Frontend assets (this backend)
|
||||
|
||||
- **Vanilla JavaScript/CSS** in `public/assets/` — project-authored unless noted.
|
||||
- No npm bundle in production tree at time of writing; if CDN libraries are added later, list them here.
|
||||
|
||||
## Publishing on landing
|
||||
|
||||
Suggested public URL after deploy sync:
|
||||
|
||||
`https://apps.f0xx.org/app/androidcast_project/crashes/THIRD_PARTY_LICENSES.md`
|
||||
|
||||
(or render an HTML excerpt on the hub legal page linking mobile + BE lists).
|
||||
@@ -58,5 +58,27 @@ return [
|
||||
'require_wg_tools' => false,
|
||||
'session_ttl_s' => 3600,
|
||||
'min_poll_interval_s' => 45,
|
||||
// Reverse SSH (alpha): outbound -R from device; no VpnService on phone
|
||||
'rssh' => [
|
||||
'bastion_host' => 'ra.apps.f0xx.org',
|
||||
'bastion_port' => 443,
|
||||
'username_prefix' => 'ra-',
|
||||
'local_forward_port' => 8022,
|
||||
'remote_port_min' => 18000,
|
||||
'remote_port_max' => 18999,
|
||||
],
|
||||
],
|
||||
// Outbound mail (registration verify, password reset) — see docs/20260607-2FA-email-mobile-auth-flow.md
|
||||
'mail' => [
|
||||
'transport' => 'smtp', // smtp | sendmail
|
||||
'from' => 'Android Cast Issues <noreply@apps.f0xx.org>',
|
||||
'reply_to' => 'info@apps.f0xx.org',
|
||||
'smtp' => [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 587,
|
||||
'encryption' => 'tls', // tls | ssl | ''
|
||||
'username' => '',
|
||||
'password' => '', // set in prod config.php only — not in git
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
26
examples/crash_reporter/backend/public/api/auth_register.php
Normal file
26
examples/crash_reporter/backend/public/api/auth_register.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw ?: '', true);
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
|
||||
}
|
||||
|
||||
$action = (string) ($data['action'] ?? 'register');
|
||||
if ($action === 'verify') {
|
||||
$result = AuthRegistration::verifyEmailToken((string) ($data['token'] ?? ''));
|
||||
json_out($result, $result['ok'] ? 200 : 400);
|
||||
}
|
||||
|
||||
$email = (string) ($data['email'] ?? '');
|
||||
$password = (string) ($data['password'] ?? '');
|
||||
$username = (string) ($data['username'] ?? '');
|
||||
$result = AuthRegistration::register($email, $password, $username);
|
||||
json_out($result, $result['ok'] ? 200 : 400);
|
||||
@@ -50,7 +50,7 @@
|
||||
(devices || []).forEach((d) => {
|
||||
const tr = document.createElement('tr');
|
||||
const wl = Number(d.whitelisted) === 1;
|
||||
const canOpen = canOperate() && wl && (d.opt_in_mode === 'wireguard');
|
||||
const canOpen = canOperate() && wl && (d.opt_in_mode === 'wireguard' || d.opt_in_mode === 'rssh');
|
||||
const openDisabled = canOpen ? '' : ' disabled title="Whitelist device and wait for WireGuard opt-in poll"';
|
||||
const wlDisabled = canAdmin() ? '' : ' disabled';
|
||||
tr.innerHTML =
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Ingest 2026-06-08 priority renumber as tickets (tag 20260608).
|
||||
*
|
||||
* Usage:
|
||||
* php scripts/ingest_priorities_20260608.php [--url=BASE] [--dry-run]
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
$opts = getopt('', ['url::', 'owner::', 'dry-run']);
|
||||
$base = rtrim($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/crashes', '/');
|
||||
$owner = $opts['owner'] ?? 'admin';
|
||||
$dryRun = isset($opts['dry-run']);
|
||||
$uploadUrl = $base . '/api/ticket_upload.php';
|
||||
|
||||
function priority_tags(string $ownerTag): array {
|
||||
return [
|
||||
['id' => 'ticket', 'label' => 'ticket', 'bg' => '#6366f1'],
|
||||
['id' => '20260608', 'label' => '20260608', 'bg' => '#0ea5e9'],
|
||||
['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'],
|
||||
['id' => $ownerTag, 'label' => $ownerTag, 'bg' => $ownerTag === 'dev' ? '#f59e0b' : '#3b82f6'],
|
||||
];
|
||||
}
|
||||
|
||||
$tasks = [
|
||||
['key' => '0.1', 'owner' => 'agent', 'text' => 'P0 FR: Stream dump while casting — zip + meta.json + raw A/V; see docs/20260608_STREAM_DUMP_DEBUG.md. Cyclic with 6.6.'],
|
||||
['key' => '1', 'owner' => 'agent', 'text' => 'P1 Graphs dashboard parent — fixes + FRs.'],
|
||||
['key' => '1.1', 'owner' => 'agent', 'text' => 'P1.1 Graphs: all pending fixes (drill-down, perf, permalinks).'],
|
||||
['key' => '1.2', 'owner' => 'agent', 'text' => 'P1.2 Graphs: all pending FRs (columns 1/2/4, full-width grid).'],
|
||||
['key' => '2.1', 'owner' => 'dev', 'text' => 'P2.1 DEV: DNS MX/SPF/DKIM/DMARC for apps.f0xx.org — tmp/20260608_help_request.md'],
|
||||
['key' => '2.2', 'owner' => 'dev', 'text' => 'P2.2 DEV: Email forward info@/admin@/root@ → Gmail.'],
|
||||
['key' => '2.3', 'owner' => 'dev', 'text' => 'P2.3 DEV: Verify inbound mail to info@apps.f0xx.org.'],
|
||||
['key' => '2.4', 'owner' => 'dev', 'text' => 'P2.4 DEV: Gmail filters / reply-as optional.'],
|
||||
['key' => '2.5', 'owner' => 'agent', 'text' => 'P2.5 AGENT: BE outbound SMTP for verification emails (blocked on 2.1).'],
|
||||
['key' => '3', 'owner' => 'agent', 'text' => 'P3 AGENT: Auth register + verify + TOTP + migration 008 — docs/20260607-2FA-email-mobile-auth-flow.md'],
|
||||
['key' => '4', 'owner' => 'agent', 'text' => 'P4 AGENT: Reverse SSH alpha — replace WG for prod; mobile + BE; hidden no VPN.'],
|
||||
['key' => '5.1', 'owner' => 'shared', 'text' => 'P5.1 OTA channel + artifacts — 50% dev / 50% agent.'],
|
||||
['key' => '5.2', 'owner' => 'shared', 'text' => 'P5.2 Device crash/OTA URLs — 50% dev / 50% agent.'],
|
||||
['key' => '5.3', 'owner' => 'agent', 'text' => 'P5.3 RSSH BE validation — depends on 4.x.'],
|
||||
['key' => '5.4', 'owner' => 'agent', 'text' => 'P5.4 auth-2fa validation ALPHA G2–G6.'],
|
||||
['key' => '5.5', 'owner' => 'agent', 'text' => 'P5.5 RBAC validation.'],
|
||||
['key' => '6.1', 'owner' => 'dev', 'text' => 'P6.1 Opus/Speex E2E — dev + agent JNI.'],
|
||||
['key' => '6.2', 'owner' => 'dev', 'text' => 'P6.2 LAN soak A/B/C.'],
|
||||
['key' => '6.3', 'owner' => 'dev', 'text' => 'P6.3 App lifecycle notifications/recents.'],
|
||||
['key' => '6.4', 'owner' => 'dev', 'text' => 'P6.4 Rotation + preview crash — cyclic with 6.3.'],
|
||||
['key' => '6.5', 'owner' => 'dev', 'text' => 'P6.5 Audio lag — use 0.1 dumps for analysis.'],
|
||||
['key' => '6.6', 'owner' => 'dev', 'text' => 'P6.6 Stream debug analysis — same as 0.1.'],
|
||||
['key' => '9', 'owner' => 'agent', 'text' => 'P9 Landing hub — LOW priority.'],
|
||||
['key' => '9.1', 'owner' => 'agent', 'text' => 'P9.1 Hub cards graphs/builder/RA.'],
|
||||
['key' => '9.2', 'owner' => 'agent', 'text' => 'P9.2 Globe animation branch cleanup.'],
|
||||
['key' => '9.3', 'owner' => 'agent', 'text' => 'P9.3 Mobile chrome validation LOW.'],
|
||||
];
|
||||
|
||||
$now = (int) round(microtime(true) * 1000);
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
$text = $task['text'];
|
||||
$ownerTag = $task['owner'];
|
||||
$payload = [
|
||||
'schema_version' => 1,
|
||||
'ingest_kind' => 'ticket',
|
||||
'ticket_id' => 'priority-20260608-' . preg_replace('/[^a-z0-9.-]+/i', '-', $task['key']),
|
||||
'title' => mb_substr($text, 0, 256),
|
||||
'brief' => mb_substr($text, 0, 512),
|
||||
'body' => $text . "\n\nRef: docs/20260608_ALPHA_PRIORITIES.md",
|
||||
'opened_at_epoch_ms' => $now,
|
||||
'rating' => 3,
|
||||
'owner' => $owner,
|
||||
'tags' => priority_tags($ownerTag),
|
||||
'device' => ['manufacturer' => 'roadmap', 'model' => 'priorities', 'sdk_int' => 34, 'release' => '14'],
|
||||
'app' => ['package' => 'com.foxx.androidcast', 'version_name' => 'roadmap', 'version_code' => 1],
|
||||
];
|
||||
if ($dryRun) {
|
||||
echo "DRY {$task['key']} [{$ownerTag}]\n";
|
||||
$ok++;
|
||||
continue;
|
||||
}
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\n",
|
||||
'content' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
$resp = @file_get_contents($uploadUrl, false, $ctx);
|
||||
$code = 0;
|
||||
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
|
||||
$code = (int) $m[1];
|
||||
}
|
||||
if ($code === 200 || $code === 409) {
|
||||
echo ($code === 409 ? 'DUP ' : 'OK ') . "{$task['key']}\n";
|
||||
$ok++;
|
||||
} else {
|
||||
echo "FAIL {$task['key']} HTTP {$code} — {$resp}\n";
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done: ok={$ok} fail={$fail}\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
@@ -35,7 +35,9 @@ $tasks = [
|
||||
['key' => '1', 'status' => 'open', 'text' => 'BE/builder deployment phases — dedicated services on BE; WIP verifying (Docker, out/builds, runner_script).'],
|
||||
['key' => '2', 'status' => 'open', 'text' => 'OTA checks — part of builder/deploy path; verify artifacts and device OTA flow.'],
|
||||
['key' => '3', 'status' => 'open', 'text' => 'Opus/Speex validation — E2E, encryption, FEC, NACK, negotiations; user and developer controls.'],
|
||||
['key' => '4', 'status' => 'open', 'text' => 'RSSH/WG (WireGuard v1) — finish deploy, prod validations, minimal tests; RA E2E CLI + Android poll.'],
|
||||
['key' => '4', 'status' => 'open', 'text' => 'Alpha: RSSH remote access (prod/hidden, no VPN) — BE bastion + Android reverse SSH client; WG v1 lab only. Opus/Speex JNI sinks TODO.'],
|
||||
['key' => 'auth-2fa', 'status' => 'open', 'text' => 'Alpha: registration + email verify + TOTP 2FA — mobile-friendly; depends on email DNS/SMTP. See docs/20260607-2FA-email-mobile-auth-flow.md.'],
|
||||
['key' => 'email-dns', 'status' => 'open', 'text' => 'Alpha: email infra — info@/admin@/root@ @ apps.f0xx.org forward to Gmail; SPF/DKIM/DMARC. Blocks auth mail.'],
|
||||
['key' => '5', 'status' => 'open', 'text' => 'Data visualization / graphs — WIP; issues found on drill-down and layout.'],
|
||||
['key' => '5.1', 'status' => 'open', 'text' => 'ISSUE 5.1: Graph brick click must open full-browser detailed chart (axis, legend, hover per point like Java visualizer).'],
|
||||
['key' => '5.2', 'status' => 'open', 'text' => 'REQUIREMENT 5.2: All graph preview bricks clickable; Columns combo (1/2/4) after Window default 2; grid uses full width.'],
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Email registration + 2FA tables (MariaDB). Run as DB root once; SQLite dev auto-migrates via AuthEmailSchema.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS email_normalized VARCHAR(254) NULL,
|
||||
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP NULL,
|
||||
ADD COLUMN IF NOT EXISTS status ENUM('pending','active','locked','disabled') NOT NULL DEFAULT 'active',
|
||||
ADD COLUMN IF NOT EXISTS recovery_email_normalized VARCHAR(254) NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email_normalized ON users (email_normalized);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNSIGNED NULL,
|
||||
purpose ENUM('verify_email','reset_password','login_magic') NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL,
|
||||
email_normalized VARCHAR(254) NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_auth_tokens_hash (token_hash),
|
||||
KEY idx_auth_tokens_email (email_normalized)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_factors (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
type ENUM('totp','webauthn','backup_code') NOT NULL,
|
||||
secret_encrypted TEXT NULL,
|
||||
label VARCHAR(64) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_auth_factors_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_attempts (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
ip_hash CHAR(64) NOT NULL,
|
||||
username_hash CHAR(64) NULL,
|
||||
outcome VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_auth_attempts_ip (ip_hash, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- RSSH session credentials (MariaDB). Idempotent ALTERs.
|
||||
ALTER TABLE remote_access_sessions
|
||||
ADD COLUMN IF NOT EXISTS rssh_bastion_host VARCHAR(255) NULL,
|
||||
ADD COLUMN IF NOT EXISTS rssh_bastion_port INT UNSIGNED NULL,
|
||||
ADD COLUMN IF NOT EXISTS rssh_username VARCHAR(64) NULL,
|
||||
ADD COLUMN IF NOT EXISTS rssh_secret VARCHAR(128) NULL,
|
||||
ADD COLUMN IF NOT EXISTS rssh_remote_port INT UNSIGNED NULL,
|
||||
ADD COLUMN IF NOT EXISTS rssh_local_port INT UNSIGNED NULL DEFAULT 8022;
|
||||
@@ -0,0 +1 @@
|
||||
-- RSSH columns for SQLite (applied via Database::ensureSchema).
|
||||
133
examples/crash_reporter/backend/src/AuthEmailSchema.php
Normal file
133
examples/crash_reporter/backend/src/AuthEmailSchema.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Migration 008 — email registration + 2FA tables (SQLite dev + MariaDB ALTER). */
|
||||
final class AuthEmailSchema {
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
public static function ensure(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'users')) {
|
||||
return;
|
||||
}
|
||||
self::ensureUserColumns($pdo);
|
||||
self::ensureAuthTokens($pdo);
|
||||
self::ensureAuthFactors($pdo);
|
||||
self::ensureAuthAttempts($pdo);
|
||||
}
|
||||
|
||||
private static function ensureUserColumns(PDO $pdo): void {
|
||||
$cols = [
|
||||
'email_normalized' => Database::isMysql() ? 'VARCHAR(254) NULL' : 'TEXT NULL',
|
||||
'email_verified_at' => Database::isMysql() ? 'TIMESTAMP NULL' : 'TEXT NULL',
|
||||
'status' => Database::isMysql()
|
||||
? "ENUM('pending','active','locked','disabled') NOT NULL DEFAULT 'active'"
|
||||
: "TEXT NOT NULL DEFAULT 'active'",
|
||||
'recovery_email_normalized' => Database::isMysql() ? 'VARCHAR(254) NULL' : 'TEXT NULL',
|
||||
];
|
||||
foreach ($cols as $name => $ddl) {
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE users ADD COLUMN ' . $name . ' ' . $ddl);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureAuthTokens(PDO $pdo): void {
|
||||
if (Database::tableExists($pdo, 'auth_tokens')) {
|
||||
return;
|
||||
}
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE auth_tokens (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNSIGNED NULL,
|
||||
purpose ENUM('verify_email','reset_password','login_magic') NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL,
|
||||
email_normalized VARCHAR(254) NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_auth_tokens_hash (token_hash),
|
||||
KEY idx_auth_tokens_email (email_normalized)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
return;
|
||||
}
|
||||
$pdo->exec(
|
||||
"CREATE TABLE auth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
email_normalized TEXT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens (token_hash)');
|
||||
}
|
||||
|
||||
private static function ensureAuthFactors(PDO $pdo): void {
|
||||
if (Database::tableExists($pdo, 'auth_factors')) {
|
||||
return;
|
||||
}
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE auth_factors (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
type ENUM('totp','webauthn','backup_code') NOT NULL,
|
||||
secret_encrypted TEXT NULL,
|
||||
label VARCHAR(64) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_auth_factors_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
return;
|
||||
}
|
||||
$pdo->exec(
|
||||
"CREATE TABLE auth_factors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
secret_encrypted TEXT NULL,
|
||||
label TEXT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
|
||||
private static function ensureAuthAttempts(PDO $pdo): void {
|
||||
if (Database::tableExists($pdo, 'auth_attempts')) {
|
||||
return;
|
||||
}
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE auth_attempts (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
ip_hash CHAR(64) NOT NULL,
|
||||
username_hash CHAR(64) NULL,
|
||||
outcome VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_auth_attempts_ip (ip_hash, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
return;
|
||||
}
|
||||
$pdo->exec(
|
||||
"CREATE TABLE auth_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_hash TEXT NOT NULL,
|
||||
username_hash TEXT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
}
|
||||
142
examples/crash_reporter/backend/src/AuthMailer.php
Normal file
142
examples/crash_reporter/backend/src/AuthMailer.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Outbound mail (registration verify, password reset). Task 2.5 — uses config mail.* */
|
||||
final class AuthMailer {
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
public static function send(string $to, string $subject, string $bodyText): bool {
|
||||
$from = (string) cfg('mail.from', 'Android Cast <noreply@apps.f0xx.org>');
|
||||
$replyTo = (string) cfg('mail.reply_to', '');
|
||||
$transport = strtolower((string) cfg('mail.transport', 'smtp'));
|
||||
if ($transport === 'sendmail') {
|
||||
return self::sendMail($to, $subject, $bodyText, $from, $replyTo);
|
||||
}
|
||||
return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo);
|
||||
}
|
||||
|
||||
public static function sendVerifyEmail(string $to, string $verifyUrl): bool {
|
||||
$subject = 'Verify your Android Cast account';
|
||||
$body = "Open this link to verify your email (valid 24h):\n\n" . $verifyUrl . "\n";
|
||||
return self::send($to, $subject, $body);
|
||||
}
|
||||
|
||||
private static function sendMail(string $to, string $subject, string $body, string $from, string $replyTo): bool {
|
||||
$headers = "From: {$from}\r\n";
|
||||
if ($replyTo !== '') {
|
||||
$headers .= "Reply-To: {$replyTo}\r\n";
|
||||
}
|
||||
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||
return @mail($to, $subject, $body, $headers);
|
||||
}
|
||||
|
||||
private static function sendSmtp(string $to, string $subject, string $body, string $from, string $replyTo): bool {
|
||||
$host = (string) cfg('mail.smtp.host', '127.0.0.1');
|
||||
$port = (int) cfg('mail.smtp.port', 587);
|
||||
$enc = strtolower((string) cfg('mail.smtp.encryption', 'tls'));
|
||||
$user = (string) cfg('mail.smtp.username', '');
|
||||
$pass = (string) cfg('mail.smtp.password', '');
|
||||
$remote = ($enc === 'ssl' ? 'ssl://' : '') . $host;
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 15);
|
||||
if ($fp === false) {
|
||||
error_log('AuthMailer SMTP connect failed: ' . $errstr);
|
||||
return self::sendMail($to, $subject, $body, $from, $replyTo);
|
||||
}
|
||||
stream_set_timeout($fp, 15);
|
||||
if (!self::smtpExpect($fp, [220])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
$ehloHost = 'localhost';
|
||||
fwrite($fp, "EHLO {$ehloHost}\r\n");
|
||||
if (!self::smtpExpect($fp, [250])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
if ($enc === 'tls') {
|
||||
fwrite($fp, "STARTTLS\r\n");
|
||||
if (!self::smtpExpect($fp, [220])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
fwrite($fp, "EHLO {$ehloHost}\r\n");
|
||||
if (!self::smtpExpect($fp, [250])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ($user !== '') {
|
||||
fwrite($fp, "AUTH LOGIN\r\n");
|
||||
if (!self::smtpExpect($fp, [334])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
fwrite($fp, base64_encode($user) . "\r\n");
|
||||
if (!self::smtpExpect($fp, [334])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
fwrite($fp, base64_encode($pass) . "\r\n");
|
||||
if (!self::smtpExpect($fp, [235])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$fromAddr = self::extractAddress($from);
|
||||
fwrite($fp, "MAIL FROM:<{$fromAddr}>\r\n");
|
||||
if (!self::smtpExpect($fp, [250])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
fwrite($fp, "RCPT TO:<{$to}>\r\n");
|
||||
if (!self::smtpExpect($fp, [250, 251])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
fwrite($fp, "DATA\r\n");
|
||||
if (!self::smtpExpect($fp, [354])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
$msg = "From: {$from}\r\n";
|
||||
if ($replyTo !== '') {
|
||||
$msg .= "Reply-To: {$replyTo}\r\n";
|
||||
}
|
||||
$msg .= "To: {$to}\r\nSubject: {$subject}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{$body}\r\n.\r\n";
|
||||
fwrite($fp, $msg);
|
||||
if (!self::smtpExpect($fp, [250])) {
|
||||
fclose($fp);
|
||||
return false;
|
||||
}
|
||||
fwrite($fp, "QUIT\r\n");
|
||||
fclose($fp);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param resource $fp @param list<int> $codes */
|
||||
private static function smtpExpect($fp, array $codes): bool {
|
||||
$line = '';
|
||||
while (($chunk = fgets($fp, 512)) !== false) {
|
||||
$line .= $chunk;
|
||||
if (strlen($chunk) >= 4 && $chunk[3] === ' ') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$code = (int) substr(trim($line), 0, 3);
|
||||
return in_array($code, $codes, true);
|
||||
}
|
||||
|
||||
private static function extractAddress(string $from): string {
|
||||
if (preg_match('/<([^>]+)>/', $from, $m)) {
|
||||
return trim($m[1]);
|
||||
}
|
||||
return trim($from);
|
||||
}
|
||||
}
|
||||
94
examples/crash_reporter/backend/src/AuthRegistration.php
Normal file
94
examples/crash_reporter/backend/src/AuthRegistration.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Email self-registration (verify link). TOTP enrollment follows login — task 3.x. */
|
||||
final class AuthRegistration {
|
||||
private const TOKEN_TTL_S = 86400;
|
||||
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
public static function ensureSchema(): void {
|
||||
AuthEmailSchema::ensure(Database::pdo());
|
||||
}
|
||||
|
||||
/** @return array{ok:bool,error?:string} */
|
||||
public static function register(string $email, string $password, string $username = ''): array {
|
||||
self::ensureSchema();
|
||||
$emailNorm = self::normalizeEmail($email);
|
||||
if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
|
||||
return ['ok' => false, 'error' => 'invalid_email'];
|
||||
}
|
||||
if (strlen($password) < 10) {
|
||||
return ['ok' => false, 'error' => 'weak_password'];
|
||||
}
|
||||
$username = trim($username);
|
||||
if ($username === '') {
|
||||
$username = strstr($emailNorm, '@', true) ?: $emailNorm;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1');
|
||||
$st->execute([$emailNorm, $username]);
|
||||
if ($st->fetchColumn()) {
|
||||
return ['ok' => false, 'error' => 'already_registered'];
|
||||
}
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO users (username, password_hash, role, email_normalized, status) VALUES (?, ?, ?, ?, ?)'
|
||||
)->execute([
|
||||
$username,
|
||||
$hash,
|
||||
'viewer',
|
||||
$emailNorm,
|
||||
'pending',
|
||||
]);
|
||||
$userId = (int) $pdo->lastInsertId();
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$expires = date('Y-m-d H:i:s', time() + self::TOKEN_TTL_S);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]);
|
||||
$base = rtrim((string) cfg('base_path', ''), '/');
|
||||
$verifyUrl = 'https://apps.f0xx.org' . $base . '/?view=verify_email&token=' . urlencode($token);
|
||||
if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) {
|
||||
error_log('AuthRegistration: verify mail failed for ' . $emailNorm);
|
||||
}
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/** @return array{ok:bool,error?:string} */
|
||||
public static function verifyEmailToken(string $token): array {
|
||||
self::ensureSchema();
|
||||
$token = trim($token);
|
||||
if ($token === '') {
|
||||
return ['ok' => false, 'error' => 'missing_token'];
|
||||
}
|
||||
$hash = hash('sha256', $token);
|
||||
$pdo = Database::pdo();
|
||||
$st = $pdo->prepare(
|
||||
"SELECT * FROM auth_tokens WHERE token_hash = ? AND purpose = 'verify_email' AND used_at IS NULL LIMIT 1"
|
||||
);
|
||||
$st->execute([$hash]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return ['ok' => false, 'error' => 'invalid_token'];
|
||||
}
|
||||
$exp = strtotime((string) ($row['expires_at'] ?? ''));
|
||||
if ($exp !== false && $exp < time()) {
|
||||
return ['ok' => false, 'error' => 'expired_token'];
|
||||
}
|
||||
$userId = (int) ($row['user_id'] ?? 0);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$pdo->prepare('UPDATE auth_tokens SET used_at = ? WHERE id = ?')->execute([$now, (int) $row['id']]);
|
||||
$pdo->prepare(
|
||||
"UPDATE users SET email_verified_at = ?, status = 'active' WHERE id = ?"
|
||||
)->execute([$now, $userId]);
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
private static function normalizeEmail(string $email): string {
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ final class Database {
|
||||
self::ensureReportColumns($pdo);
|
||||
self::ensureReportViewsTable($pdo);
|
||||
self::ensureRbacSchema($pdo);
|
||||
AuthEmailSchema::ensure($pdo);
|
||||
return;
|
||||
}
|
||||
$schemaFile = __DIR__ . '/../sql/schema.sqlite.sql';
|
||||
@@ -112,6 +113,7 @@ final class Database {
|
||||
self::ensureReportColumns($pdo);
|
||||
self::ensureReportViewsTable($pdo);
|
||||
self::ensureRbacSchema($pdo);
|
||||
AuthEmailSchema::ensure($pdo);
|
||||
}
|
||||
|
||||
private static function ensureMysqlSchema(PDO $pdo): void {
|
||||
@@ -156,6 +158,7 @@ final class Database {
|
||||
}
|
||||
self::ensureMysqlReportViewsTable($pdo);
|
||||
self::ensureRbacSchema($pdo);
|
||||
AuthEmailSchema::ensure($pdo);
|
||||
self::ensureTicketsTable($pdo);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* On-demand remote access control plane (heartbeat type: ra).
|
||||
* v1: WireGuard opt-in from device; reverse SSH (RSSH) reserved for future use.
|
||||
* v1: WireGuard + reverse SSH (RSSH) opt-in from device (lab / alpha).
|
||||
*/
|
||||
final class RemoteAccessRepository {
|
||||
public const OPT_NONE = 'none';
|
||||
@@ -23,6 +23,7 @@ final class RemoteAccessRepository {
|
||||
return;
|
||||
}
|
||||
self::ensureSessionColumns($pdo);
|
||||
self::ensureRsshColumns($pdo);
|
||||
}
|
||||
|
||||
/** @param list<string> $where @param list<mixed> $params */
|
||||
@@ -67,6 +68,30 @@ final class RemoteAccessRepository {
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
private static function ensureRsshColumns(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'remote_access_sessions')) {
|
||||
return;
|
||||
}
|
||||
$cols = [
|
||||
'rssh_bastion_host' => Database::isMysql() ? 'VARCHAR(255) NULL' : 'TEXT NULL',
|
||||
'rssh_bastion_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL',
|
||||
'rssh_username' => Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL',
|
||||
'rssh_secret' => Database::isMysql() ? 'VARCHAR(128) NULL' : 'TEXT NULL',
|
||||
'rssh_remote_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL',
|
||||
'rssh_local_port' => Database::isMysql() ? 'INT UNSIGNED NULL' : 'INTEGER NULL',
|
||||
];
|
||||
foreach ($cols as $name => $ddl) {
|
||||
try {
|
||||
$pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN ' . $name . ' ' . $ddl);
|
||||
} catch (PDOException $e) {
|
||||
$msg = strtolower($e->getMessage());
|
||||
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureSessionColumns(PDO $pdo): void {
|
||||
if (!Database::tableExists($pdo, 'remote_access_sessions')) {
|
||||
return;
|
||||
@@ -142,7 +167,13 @@ final class RemoteAccessRepository {
|
||||
}
|
||||
|
||||
if ($tunnelMode === self::OPT_RSSH) {
|
||||
return self::raEnvelope('deny', ['reason' => 'rssh_not_implemented']);
|
||||
$session = $pendingSession ?? self::findConnectableSession($deviceId, $random);
|
||||
if ($session === null) {
|
||||
self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp);
|
||||
return self::raEnvelope('wait', ['reason' => 'no_pending_session']);
|
||||
}
|
||||
self::touchSessionActivity((string) ($session['session_id'] ?? ''));
|
||||
return self::buildConnectResponse($session, $deviceId, $clientIp);
|
||||
}
|
||||
|
||||
$session = $pendingSession ?? self::findConnectableSession($deviceId, $random);
|
||||
@@ -279,6 +310,15 @@ final class RemoteAccessRepository {
|
||||
|
||||
/** @param array<string, mixed> $session */
|
||||
private static function buildConnectResponse(array $session, string $deviceId, string $clientIp): array {
|
||||
$tunnel = (string) ($session['tunnel'] ?? 'wireguard');
|
||||
if ($tunnel === 'ssh_reverse') {
|
||||
return self::buildRsshConnectResponse($session, $deviceId, $clientIp);
|
||||
}
|
||||
return self::buildWireGuardConnectResponse($session, $deviceId, $clientIp);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $session */
|
||||
private static function buildWireGuardConnectResponse(array $session, string $deviceId, string $clientIp): array {
|
||||
$sessionId = (string) ($session['session_id'] ?? '');
|
||||
$tunnel = (string) ($session['tunnel'] ?? 'wireguard');
|
||||
if ($tunnel !== 'wireguard') {
|
||||
@@ -318,6 +358,67 @@ final class RemoteAccessRepository {
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $session */
|
||||
private static function buildRsshConnectResponse(array $session, string $deviceId, string $clientIp): array {
|
||||
$sessionId = (string) ($session['session_id'] ?? '');
|
||||
$creds = self::ensureSessionRsshCredentials($sessionId, $session);
|
||||
$expiresAt = (int) ($session['expires_at'] ?? (time() + 3600));
|
||||
$endpoint = RsshSessionProvisioner::endpointFromSession($session);
|
||||
|
||||
self::markSessionActive($sessionId);
|
||||
self::logEvent($deviceId, $sessionId, null, 'tunnel_connect', 'ssh_reverse', null, $clientIp);
|
||||
|
||||
return self::raEnvelope('connect', [
|
||||
'session_id' => $sessionId,
|
||||
'endpoint' => $endpoint,
|
||||
'tunnel' => 'ssh_reverse',
|
||||
'credentials' => [
|
||||
'mode' => 'password',
|
||||
'bastion_host' => $creds['bastion_host'],
|
||||
'bastion_port' => $creds['bastion_port'],
|
||||
'username' => $creds['username'],
|
||||
'password' => $creds['password'],
|
||||
'remote_bind_port' => $creds['remote_bind_port'],
|
||||
'local_forward_host' => '127.0.0.1',
|
||||
'local_forward_port' => $creds['local_forward_port'],
|
||||
],
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $session @return array{bastion_host:string,bastion_port:int,username:string,password:string,remote_bind_port:int,local_forward_port:int} */
|
||||
private static function ensureSessionRsshCredentials(string $sessionId, array $session): array {
|
||||
$user = trim((string) ($session['rssh_username'] ?? ''));
|
||||
$secret = trim((string) ($session['rssh_secret'] ?? ''));
|
||||
$remote = (int) ($session['rssh_remote_port'] ?? 0);
|
||||
$local = (int) ($session['rssh_local_port'] ?? 0);
|
||||
$host = trim((string) ($session['rssh_bastion_host'] ?? ''));
|
||||
$port = (int) ($session['rssh_bastion_port'] ?? 0);
|
||||
if ($user !== '' && $secret !== '' && $remote > 0) {
|
||||
return [
|
||||
'bastion_host' => $host !== '' ? $host : (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org'),
|
||||
'bastion_port' => $port > 0 ? $port : (int) cfg('remote_access.rssh.bastion_port', 443),
|
||||
'username' => $user,
|
||||
'password' => $secret,
|
||||
'remote_bind_port' => $remote,
|
||||
'local_forward_port' => $local > 0 ? $local : (int) cfg('remote_access.rssh.local_forward_port', 8022),
|
||||
];
|
||||
}
|
||||
$alloc = RsshSessionProvisioner::allocate($sessionId);
|
||||
Database::pdo()->prepare(
|
||||
'UPDATE remote_access_sessions SET rssh_bastion_host = ?, rssh_bastion_port = ?, rssh_username = ?, rssh_secret = ?, rssh_remote_port = ?, rssh_local_port = ? WHERE session_id = ?'
|
||||
)->execute([
|
||||
$alloc['bastion_host'],
|
||||
$alloc['bastion_port'],
|
||||
$alloc['username'],
|
||||
$alloc['password'],
|
||||
$alloc['remote_bind_port'],
|
||||
$alloc['local_forward_port'],
|
||||
$sessionId,
|
||||
]);
|
||||
return $alloc;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string,client_public:string} */
|
||||
private static function ensureSessionWireGuardKeys(string $sessionId, array $session): array {
|
||||
$priv = trim((string) ($session['wg_client_private_key'] ?? ''));
|
||||
@@ -408,12 +509,15 @@ final class RemoteAccessRepository {
|
||||
|
||||
public static function closeSession(string $sessionId, string $status, string $reason): void {
|
||||
$st = Database::pdo()->prepare(
|
||||
'SELECT wg_client_public_key FROM remote_access_sessions WHERE session_id = ? LIMIT 1'
|
||||
'SELECT tunnel, 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);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (is_array($row) && ($row['tunnel'] ?? 'wireguard') === 'wireguard') {
|
||||
$pub = $row['wg_client_public_key'] ?? '';
|
||||
if (is_string($pub) && $pub !== '') {
|
||||
WireGuardPeerProvisioner::removeClientPeer($pub);
|
||||
}
|
||||
}
|
||||
$now = self::nowSql();
|
||||
Database::pdo()->prepare(
|
||||
@@ -432,13 +536,24 @@ final class RemoteAccessRepository {
|
||||
if (!Rbac::canAccessCompany($deviceCompanyId) || !Rbac::can('remote_access_operate', $deviceCompanyId)) {
|
||||
return ['ok' => false, 'error' => 'forbidden'];
|
||||
}
|
||||
if ((string) ($device['opt_in_mode'] ?? self::OPT_NONE) !== self::OPT_WIREGUARD) {
|
||||
return ['ok' => false, 'error' => 'device_not_opted_in_wg'];
|
||||
if ((string) ($device['opt_in_mode'] ?? self::OPT_NONE) === self::OPT_NONE) {
|
||||
return ['ok' => false, 'error' => 'device_not_opted_in'];
|
||||
}
|
||||
$optIn = (string) ($device['opt_in_mode'] ?? self::OPT_NONE);
|
||||
if (!in_array($optIn, [self::OPT_WIREGUARD, self::OPT_RSSH], true)) {
|
||||
return ['ok' => false, 'error' => 'device_not_opted_in'];
|
||||
}
|
||||
self::closeActiveSessionsForDevice($deviceId, 'superseded');
|
||||
$sessionId = self::uuid4();
|
||||
$companyId = $companyId ?? (int) ($device['company_id'] ?? Rbac::defaultCompanyId());
|
||||
$endpoint = (string) cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:51820');
|
||||
$tunnel = $optIn === self::OPT_RSSH ? 'ssh_reverse' : 'wireguard';
|
||||
if ($tunnel === 'ssh_reverse') {
|
||||
$host = (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org');
|
||||
$port = (int) cfg('remote_access.rssh.bastion_port', 443);
|
||||
$endpoint = $port === 443 ? $host : $host . ':' . $port;
|
||||
} else {
|
||||
$endpoint = (string) cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:51820');
|
||||
}
|
||||
$expiresAt = time() + (int) cfg('remote_access.session_ttl_s', 3600);
|
||||
$now = self::nowSql();
|
||||
Database::pdo()->prepare(
|
||||
@@ -450,7 +565,7 @@ final class RemoteAccessRepository {
|
||||
$companyId,
|
||||
$operatorUserId,
|
||||
self::STATUS_PENDING,
|
||||
'wireguard',
|
||||
$tunnel,
|
||||
$endpoint,
|
||||
$expiresAt,
|
||||
$now,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Ephemeral reverse-SSH session credentials (alpha). */
|
||||
final class RsshSessionProvisioner {
|
||||
private function __construct() {
|
||||
}
|
||||
|
||||
/** @return array{bastion_host:string,bastion_port:int,username:string,password:string,remote_bind_port:int,local_forward_port:int} */
|
||||
public static function allocate(string $sessionId): array {
|
||||
$host = (string) cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org');
|
||||
$port = max(1, min(65535, (int) cfg('remote_access.rssh.bastion_port', 443)));
|
||||
$prefix = (string) cfg('remote_access.rssh.username_prefix', 'ra-');
|
||||
$username = $prefix . preg_replace('/[^a-zA-Z0-9_-]/', '', $sessionId);
|
||||
if (strlen($username) > 32) {
|
||||
$username = substr($username, 0, 32);
|
||||
}
|
||||
$password = bin2hex(random_bytes(16));
|
||||
$remotePort = self::allocateRemotePort($sessionId);
|
||||
$localPort = max(1, min(65535, (int) cfg('remote_access.rssh.local_forward_port', 8022)));
|
||||
return [
|
||||
'bastion_host' => $host,
|
||||
'bastion_port' => $port,
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'remote_bind_port' => $remotePort,
|
||||
'local_forward_port' => $localPort,
|
||||
];
|
||||
}
|
||||
|
||||
public static function allocateRemotePort(string $sessionId): int {
|
||||
$min = (int) cfg('remote_access.rssh.remote_port_min', 18000);
|
||||
$max = (int) cfg('remote_access.rssh.remote_port_max', 18999);
|
||||
if ($max <= $min) {
|
||||
$max = $min + 999;
|
||||
}
|
||||
$span = $max - $min + 1;
|
||||
return $min + (abs(crc32($sessionId . '|' . time())) % $span);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $session */
|
||||
public static function endpointFromSession(array $session): string {
|
||||
$host = (string) ($session['rssh_bastion_host'] ?? cfg('remote_access.rssh.bastion_host', 'ra.apps.f0xx.org'));
|
||||
$port = (int) ($session['rssh_bastion_port'] ?? cfg('remote_access.rssh.bastion_port', 443));
|
||||
if ($port === 443) {
|
||||
return $host;
|
||||
}
|
||||
return $host . ':' . $port;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ require_once __DIR__ . '/Database.php';
|
||||
require_once __DIR__ . '/Rbac.php';
|
||||
require_once __DIR__ . '/DeviceRepository.php';
|
||||
require_once __DIR__ . '/Auth.php';
|
||||
require_once __DIR__ . '/AuthEmailSchema.php';
|
||||
require_once __DIR__ . '/AuthMailer.php';
|
||||
require_once __DIR__ . '/AuthRegistration.php';
|
||||
require_once __DIR__ . '/ReportRepository.php';
|
||||
require_once __DIR__ . '/TagCatalog.php';
|
||||
require_once __DIR__ . '/TicketTagCatalog.php';
|
||||
@@ -58,6 +61,7 @@ require_once __DIR__ . '/TicketRepository.php';
|
||||
require_once __DIR__ . '/GraphRepository.php';
|
||||
require_once __DIR__ . '/RemoteAccessRepository.php';
|
||||
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
|
||||
require_once __DIR__ . '/RsshSessionProvisioner.php';
|
||||
require_once __DIR__ . '/AnalyticsHead.php';
|
||||
|
||||
function cfg(string $key, $default = null) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"crash": {
|
||||
"enabled": "true",
|
||||
"upload_url": "https://f0xx.org/app/androidcast_project/crashes/api/upload.php",
|
||||
"upload_url": "https://apps.f0xx.org/app/androidcast_project/crashes/api/upload.php",
|
||||
"prune_after_upload": "true",
|
||||
"upload_interval": "5m",
|
||||
"max_pending": 32
|
||||
|
||||
Reference in New Issue
Block a user