1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:58:51 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-08 12:38:57 +02:00
parent bfb41f06d9
commit c1591fa849
86 changed files with 5081 additions and 637 deletions

View File

@@ -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)

View File

@@ -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 17 min while enabled.
Android: Developer settings → **Remote access** → **RSSH** (alpha target; WireGuard lab-only). RSSH avoids VPN permission. Polls BE every 17 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

View 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).

View File

@@ -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
],
],
];

View 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);

View File

@@ -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 =

View File

@@ -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 G2G6.'],
['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);

View File

@@ -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.'],

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
-- RSSH columns for SQLite (applied via Database::ensureSchema).

View 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'))
)"
);
}
}

View 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);
}
}

View 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));
}
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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