1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-07-29 04:38:48 +03:00

cluster0: lab seeds, Gitea mirrors, compose mail, verify tooling

Freeze lab-seeds backend for COMPOSE_SKIP_MONOLITH; fix Gitea mirror scripts
for 1.25 API; add secrets.lab.env template, verify-mail-lab, LAB_PUBLIC_ORIGIN,
and credentials pointers for mirror token recovery.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-06-23 17:07:46 +02:00
parent 98f30786b7
commit c554dc761d
209 changed files with 31249 additions and 6 deletions

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Replace BE apps.conf listen-80 block from repo fragment (run on machine with BE mount write access).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
EXAMPLES="$REPO_ROOT/examples"
FRAG="$EXAMPLES/crash_reporter/backend/nginx.apps-port80.fragment"
APPS_CONF="${1:-$REPO_ROOT/tmp/BE_alpine/etc/nginx/conf.d/apps.conf}"
if [[ ! -f "$FRAG" || ! -f "$APPS_CONF" ]]; then
echo "Usage: $0 [path/to/apps.conf]" >&2
exit 1
fi
python3 - "$FRAG" "$APPS_CONF" << 'PY'
import sys
from pathlib import Path
frag, apps_path = map(Path, sys.argv[1:3])
text = apps_path.read_text()
start = text.index("server {\n\tlisten 80;")
end = text.index("\nserver {\n\tlisten 443")
apps_path.write_text(text[:start] + frag.read_text().strip() + text[end:])
print(f"Updated listen 80 in {apps_path}")
PY

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Fix builder nginx on sshfs mounts + optional reload on hosts.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
BE_NGINX="${BE_NGINX:-$REPO_ROOT/tmp/BE_alpine/etc/nginx/conf.d}"
FE_NGINX="${FE_NGINX:-$REPO_ROOT/tmp/FE_gentoo/etc/nginx}"
EXAMPLES="$REPO_ROOT/examples"
install -D -m 0644 "$EXAMPLES/crash_reporter/backend/nginx.apps-builder.frag" \
"$BE_NGINX/apps_builder.frag"
install -D -m 0644 "$EXAMPLES/crash_reporter/backend/nginx.fe-apps.conf.fragment" \
"$FE_NGINX/apps.conf"
if [[ "${1:-}" == "--port80" ]]; then
"$EXAMPLES/crash_reporter/backend/scripts/apply-be-port80-nginx.sh" \
"$BE_NGINX/apps.conf"
fi
echo "Installed:"
echo " $BE_NGINX/apps_builder.frag"
echo " $FE_NGINX/apps.conf"
echo
echo "Reload on hosts:"
echo " ssh alpine-be 'sudo nginx -t && sudo rc-service nginx reload'"
echo " # Gentoo FE: sudo nginx -t && sudo rc-service nginx reload"
echo
echo "Verify:"
echo " curl -sSI 'https://apps.f0xx.org/app/androidcast_project/build/' | grep -E 'HTTP|location|x-powered-by'"

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Import graph_session NDJSON directly into graph_sessions (no HTTP).
* Usage: php scripts/bulk_import_graph_sessions.php --file=/tmp/fake_graph_100dev.ndjson
*/
$opts = getopt('', ['file::', 'limit::']);
$file = $opts['file'] ?? '/tmp/fake_graph_sessions.ndjson';
$limit = isset($opts['limit']) ? max(0, (int) $opts['limit']) : 0;
if (!is_readable($file)) {
fwrite(STDERR, "File not found: $file\n");
exit(1);
}
require_once __DIR__ . '/../src/bootstrap.php';
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$ok = 0;
$fail = 0;
foreach ($lines as $i => $line) {
if ($limit > 0 && $i >= $limit) {
break;
}
$payload = json_decode($line, true);
if (!is_array($payload)) {
$fail++;
continue;
}
try {
GraphRepository::insertSession($payload);
$ok++;
} catch (Throwable $e) {
$fail++;
fwrite(STDERR, 'FAIL line ' . ($i + 1) . ': ' . $e->getMessage() . "\n");
}
}
echo "Imported ok=$ok fail=$fail total=" . count($lines) . "\n";
exit($fail > 0 ? 1 : 0);

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Sync backend code to BE sshfs mount (skip data/ — permissions).
# Usage: ./scripts/deploy-to-be-mount.sh [DEST]
set -euo pipefail
SRC="$(cd "$(dirname "$0")/.." && pwd)"
REPO_EXAMPLES="$(cd "$SRC/../.." && pwd)"
BE_ROOT="${BE_ROOT:-$(cd "$REPO_EXAMPLES/../../tmp/BE_alpine/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast" 2>/dev/null && pwd || true)}"
DEST="${1:-$(cd "$SRC/../../../tmp/BE_alpine/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend" 2>/dev/null && pwd || true)}"
if [[ -z "$DEST" || ! -d "$DEST" ]]; then
echo "DEST not found. Pass BE mount path as first argument." >&2
exit 1
fi
rsync -a --no-group --no-owner \
--exclude 'data/' --exclude 'config/config.php' \
"$SRC/" "$DEST/"
if [[ -n "$BE_ROOT" && -d "$BE_ROOT" ]]; then
rsync -a --no-group --no-owner \
"$REPO_EXAMPLES/platform/" "$BE_ROOT/examples/platform/"
echo "Synced platform/ to $BE_ROOT/examples/platform/"
fi
echo "Synced to $DEST (config.php and data/ untouched)."

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Run on Gentoo FE and Alpine BE to diagnose build proxy (paste output when asking for help).
set -u
echo "=== $(hostname) build proxy diagnostic ==="
echo
if [[ -f /etc/nginx/nginx.conf ]]; then
echo "--- FE: androidcast locations in nginx.conf ---"
grep -n 'androidcast_project' /etc/nginx/nginx.conf | head -20
echo
fi
if [[ -f /etc/nginx/apps.conf ]]; then
echo "--- FE: apps.conf ---"
cat /etc/nginx/apps.conf
echo
fi
if [[ -f /etc/nginx/conf.d/apps_builder.frag ]]; then
echo "--- BE: apps_builder.frag ---"
cat /etc/nginx/conf.d/apps_builder.frag
echo
fi
if [[ -f /etc/nginx/conf.d/apps.conf ]]; then
echo "--- BE: apps.conf listen 80 (first 25 lines) ---"
sed -n '1,25p' /etc/nginx/conf.d/apps.conf
echo
fi
echo "--- DNS artc0 ---"
getent hosts artc0.intra.raptor.org 2>/dev/null || true
echo "--- Local curls ---"
for url in \
"http://127.0.0.1/app/androidcast_project/build/" \
"http://artc0.intra.raptor.org/app/androidcast_project/build/" \
"https://artc0.intra.raptor.org/app/androidcast_project/build/"; do
code=$(curl -sS -o /dev/null -w "%{http_code}" -H "Host: apps.f0xx.org" -k "$url" 2>/dev/null || echo "fail")
echo "$code $url"
done
echo "--- PHP index exists? ---"
ls -la /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/index.php 2>&1 || true

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env php81
<?php
/**
* Restore url-shortener prod config after rsync/submodule deploy (config.php is gitignored).
*
* On BE (as user that can read crashes config.php):
* php81 examples/crash_reporter/backend/scripts/ensure_url_shortener_prod_config.php
*
* - Writes backend/url-shortener/config/config.php (via apps/s symlink when present)
* - Ensures crashes config.php has url_shortener.enabled + db block
*/
declare(strict_types=1);
$backendRoot = realpath(dirname(__DIR__)) ?: dirname(__DIR__);
$crashConfigPath = $backendRoot . '/config/config.php';
$repoRoot = realpath(dirname($backendRoot, 3)) ?: dirname($backendRoot, 3);
$urlConfigDir = $repoRoot . '/backend/url-shortener/config';
$urlConfigPath = $urlConfigDir . '/config.php';
$sLink = '/var/www/localhost/htdocs/apps/s';
if (is_link($sLink)) {
$resolved = realpath($sLink);
if ($resolved !== false) {
$urlConfigDir = $resolved . '/config';
$urlConfigPath = $urlConfigDir . '/config.php';
}
}
function fail(string $msg): void {
fwrite(STDERR, "ensure_url_shortener_prod_config: $msg\n");
exit(1);
}
if (!is_readable($crashConfigPath)) {
fail("missing crashes config: $crashConfigPath");
}
/** @var array<string,mixed> $crash */
$crash = require $crashConfigPath;
$m = $crash['db']['mysql'] ?? null;
if (!is_array($m) || ($m['password'] ?? '') === '') {
fail('crashes db.mysql.password not set in config.php');
}
$user = (string) ($m['username'] ?? 'androidcast');
$pass = (string) $m['password'];
$socket = (string) ($m['socket'] ?? '/run/mysqld/mysqld.sock');
try {
$pdo = new PDO(
"mysql:unix_socket={$socket};dbname=url_shortener;charset=utf8mb4",
$user,
$pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$pdo->query('SELECT 1');
} catch (Throwable $e) {
fail('url_shortener DB ping failed: ' . $e->getMessage());
}
$urlBlock = [
'db' => [
'driver' => 'mysql',
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'socket' => $socket,
'database' => 'url_shortener',
'username' => $user,
'password' => $pass,
'charset' => 'utf8mb4',
],
],
'public_base' => 'https://s.f0xx.org',
];
if (!is_dir($urlConfigDir)) {
fail("url-shortener config dir missing: $urlConfigDir");
}
file_put_contents($urlConfigPath, "<?php\nreturn " . var_export($urlBlock, true) . ";\n");
chmod($urlConfigPath, 0644);
$crashUrl = [
'enabled' => true,
'public_base' => 'https://s.f0xx.org',
'db' => [
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'socket' => $socket,
'database' => 'url_shortener',
'username' => $user,
'password' => $pass,
'charset' => 'utf8mb4',
],
],
];
$crash['url_shortener'] = $crashUrl;
file_put_contents($crashConfigPath, "<?php\nreturn " . var_export($crash, true) . ";\n");
echo "ok: $urlConfigPath\n";
echo "ok: crashes url_shortener.enabled=true\n";

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Build fe-nginx-apps-consolidation.patch from repo template vs tmp/FE_gentoo mirror.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
FE_MIRROR="${FE_MIRROR:-$REPO_ROOT/tmp/FE_gentoo/etc/nginx}"
TEMPLATE="$REPO_ROOT/examples/crash_reporter/backend/nginx/fe/apps.conf"
PATCH_OUT="$REPO_ROOT/examples/crash_reporter/backend/patches/fe-nginx-apps-consolidation.patch"
if [[ ! -f "$FE_MIRROR/nginx.conf" || ! -f "$FE_MIRROR/apps.conf" ]]; then
echo "Missing FE mirror at $FE_MIRROR (sshfs tmp/FE_gentoo)" >&2
exit 1
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
cp "$FE_MIRROR/nginx.conf" "$WORK/nginx.conf.orig"
cp "$FE_MIRROR/apps.conf" "$WORK/apps.conf.orig"
cp "$TEMPLATE" "$WORK/apps.conf.new"
python3 - "$WORK/nginx.conf.orig" "$WORK/nginx.conf.new" << 'PY'
import sys
from pathlib import Path
src, dst = map(Path, sys.argv[1:3])
lines = src.read_text().splitlines(keepends=True)
start = None
for i, line in enumerate(lines):
if line.strip() == "## -------------------------------------------------------------------------------":
# Next non-empty should be server { listen 80 apps.f0xx.org
for j in range(i + 1, min(i + 5, len(lines))):
if "server_name apps.f0xx.org" in lines[j] or (
j + 1 < len(lines) and "server_name apps.f0xx.org" in lines[j + 1]
):
start = i
break
if start is not None:
break
if start is None:
raise SystemExit("Could not find apps.f0xx.org section start in nginx.conf")
# End at closing brace of 443 server: line that is only `\t}` or ` }` before blank line and http `}`
end = None
for i in range(len(lines) - 1, start, -1):
if lines[i].strip() == "}":
# http block ends with `}\n` then EOF; 443 server closes with `}\n` then `\n` then `}`
if i + 1 < len(lines) and lines[i + 1].strip() == "":
if i + 2 < len(lines) and lines[i + 2].strip() == "}":
end = i
break
if end is None:
raise SystemExit("Could not find apps.f0xx.org section end in nginx.conf")
replacement = (
"## -------------------------------------------------------------------------------\n"
"## apps.f0xx.org — Android Cast (hub, crashes, graphs, build, OTA, git)\n"
"\tinclude /etc/nginx/apps.conf;\n"
"\n"
)
new_lines = lines[:start] + [replacement] + lines[end + 1 :]
dst.write_text("".join(new_lines))
print(f"nginx.conf: replaced lines {start + 1}{end + 1} with include")
PY
mkdir -p "$(dirname "$PATCH_OUT")"
(
cd "$WORK"
diff -u apps.conf.orig apps.conf.new || true
diff -u nginx.conf.orig nginx.conf.new || true
) > "$PATCH_OUT" || true
# Fix patch headers to use etc/nginx/ paths for clarity
sed -i 's|--- .*apps.conf.orig|--- a/etc/nginx/apps.conf|' "$PATCH_OUT"
sed -i 's|+++ .*apps.conf.new|+++ b/etc/nginx/apps.conf|' "$PATCH_OUT"
sed -i 's|--- .*nginx.conf.orig|--- a/etc/nginx/nginx.conf|' "$PATCH_OUT"
sed -i 's|+++ .*nginx.conf.new|+++ b/etc/nginx/nginx.conf|' "$PATCH_OUT"
cat > "$REPO_ROOT/examples/crash_reporter/backend/patches/fe-nginx-APPLY.md" << EOF
# FE nginx consolidation — review & apply
## What changes
- **\`/etc/nginx/apps.conf\`** becomes the full \`apps.f0xx.org\` vhost (HTTP redirect + HTTPS server + all Android Cast locations).
- **\`/etc/nginx/nginx.conf\`** drops inline \`apps.f0xx.org\` server blocks; adds one \`include /etc/nginx/apps.conf;\` at \`http\` level.
## New / consolidated paths in apps.conf
| Path | Upstream |
|------|----------|
| \`/v0/ota/\` | BE :80 (OTA) |
| \`/app/androidcast_project/build/\` | BE :80 |
| \`/app/androidcast_project/crashes/\` | BE :80 |
| \`/app/androidcast_project/graphs/\` | BE :80 (explicit) |
| \`/app/androidcast_project/\` | BE :80 (hub) |
| \`/app/androidcast_project/git/\` | BE :3000 (Gitea) |
| \`/\` (default) | BE :80 |
Canonical template: \`examples/crash_reporter/backend/nginx/fe/apps.conf\`
## Apply (on f0xx-monstro)
\`\`\`bash
cd /etc/nginx
cp -a apps.conf "apps.conf.bak.\$(date +%Y%m%d)"
cp -a nginx.conf "nginx.conf.bak.\$(date +%Y%m%d)"
# copy template from repo sync, or patch:
# patch -p1 < .../fe-nginx-apps-consolidation.patch
cp /path/to/android_cast/examples/crash_reporter/backend/nginx/fe/apps.conf apps.conf
# edit nginx.conf: replace apps.f0xx.org server {…} blocks with:
# include /etc/nginx/apps.conf;
nginx -t && rc-service nginx reload
\`\`\`
## Verify
\`\`\`bash
curl -sS -o /dev/null -w 'hub %{http_code}\\n' https://apps.f0xx.org/app/androidcast_project/
curl -sS -o /dev/null -w 'ota %{http_code}\\n' https://apps.f0xx.org/v0/ota/channel/stable.json
curl -sS -o /dev/null -w 'build %{http_code}\\n' https://apps.f0xx.org/app/androidcast_project/build/
\`\`\`
EOF
echo "Wrote $PATCH_OUT"
echo "Wrote $REPO_ROOT/examples/crash_reporter/backend/patches/fe-nginx-APPLY.md"

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::', 'devices::', 'per-device::']);
$devices = max(1, (int) ($opts['devices'] ?? 0));
$perDevice = max(1, (int) ($opts['per-device'] ?? 5));
$count = isset($opts['count']) ? max(1, (int) $opts['count']) : 0;
if ($count <= 0) {
$count = $devices > 0 ? $devices * $perDevice : 180;
}
$days = max(1, (int) ($opts['days'] ?? 30));
$out = $opts['out'] ?? '/tmp/fake_graph_sessions.ndjson';
$companyId = (int) ($opts['company'] ?? 1);
$userId = (int) ($opts['user'] ?? 1);
if ($devices <= 0) {
$devices = max(1, min(40, (int) ceil($count / max(1, $perDevice))));
}
$nowMs = (int) round(microtime(true) * 1000);
$startFloorMs = $nowMs - ($days * 86400 * 1000);
$transports = ['wifi', 'cellular', 'ethernet'];
$installs = ['direct', 'play_store', 'mdm', 'partner'];
$ntp = ['device', 'backend', 'pool.ntp.org'];
$fh = fopen($out, 'wb');
if (!$fh) {
fwrite(STDERR, "Cannot write $out\n");
exit(1);
}
for ($i = 0; $i < $count; $i++) {
$start = random_int($startFloorMs, $nowMs - 120000);
$duration = random_int(120, 9000);
$end = $start + ($duration * 1000);
$payload = [
'schema_version' => 1,
'ingest_kind' => 'graph_session',
'session_id' => 'fake-' . gmdate('Ymd', (int) ($start / 1000)) . '-' . substr(sha1((string) ($start . ':' . $i)), 0, 12),
'company_id' => $companyId,
'user_id' => $userId,
'device_id' => 'grab-stats-dev-' . str_pad((string) ($i % $devices), 3, '0', STR_PAD_LEFT),
'app_version' => '0.1.' . ($i % 5),
'sdk_int' => 29 + ($i % 6),
'started_at_epoch_ms' => $start,
'ended_at_epoch_ms' => $end,
'duration_s' => $duration,
'completed' => ($i % 17 !== 0),
'direction' => ($i % 3 === 0) ? 'receiver' : 'sender',
'transport' => $transports[array_rand($transports)],
'avg_kbps' => random_int(640, 12000),
'recv_kbps' => random_int(200, 4000),
'packet_loss_pct' => round(mt_rand(0, 600) / 100, 2),
'ntp_source' => $ntp[array_rand($ntp)],
'ntp_correction_s' => random_int(0, 8),
'install_source' => $installs[array_rand($installs)],
'meta' => [
'fake' => true,
'seed' => $i,
'grab_session_stats' => true,
'simulated' => 'bulk-100-devices',
],
];
fwrite($fh, json_encode($payload, JSON_UNESCAPED_SLASHES) . "\n");
}
fclose($fh);
echo "Generated $count rows ($devices devices, ~" . (int) ceil($count / $devices) . " sessions/device, {$days}d window) into $out\n";

View File

@@ -0,0 +1,526 @@
# Gitea backup and remote attach (Alpine BE)
Scripts for the BE Gitea instance at `https://apps.f0xx.org/app/androidcast_project/git/`.
**Safety:** The main working tree under
`/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast`
is **never** modified. Only Gitea-owned paths from `/etc/gitea/app.ini` are backed up or reset.
## Temp files (/tmp)
Gitea scripts use `mktemp` work dirs for `git clone --mirror` fallbacks. Each `*.mirror.git` tree is **removed after every repo** (success or failure) and the work dir is removed on script exit.
Stale dirs (e.g. after `kill -9`) are pruned automatically on the next run (default: older than **60 minutes**). Manual cleanup:
```bash
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_cleanup_tmp.sh
# immediate wipe of all gitea-* temps:
GITEA_TMP_MAX_AGE_MIN=0 sudo ./gitea_cleanup_tmp.sh
```
## Prerequisites (Alpine BE)
- Gitea installed (`apk add gitea`), config at `/etc/gitea/app.ini`
- Run backup as **root** (service control + mysqldump)
- For attach: `git`, `curl`; **`GITEA_ADMIN_TOKEN`** (admin PAT from Gitea UI)
## 1. Full backup + optional reset
```bash
cd /path/to/android_cast/examples/crash_reporter/backend/scripts/gitea
chmod +x gitea_collect_backup.sh gitea_attach_remote.sh gitea_ini.sh
# default output: /var/backups/gitea/gitea-collect-YYYYMMDD-HHMMSS/
./gitea_collect_backup.sh
```
Collects:
| Item | Source (from `app.ini`) |
|------|-------------------------|
| Config | `/etc/gitea/app.ini` |
| App data | `[server] APP_DATA_PATH` (caches, indexes, attachments) |
| Bare repos | `[repository] ROOT` |
| LFS | `[lfs] PATH` (if set) |
| Logs | `[log] ROOT_PATH` |
| DB | `mysqldump` / `pg_dump` / sqlite file copy |
| Built-in dump | `gitea dump` when available |
At the end:
```text
Would you like to reset Gitea repositories state to the default (empty)? [y/N]
```
- **`N`** (default): backup only
- **`y`**: stops Gitea, `gitea admin repo-delete` per repo, clears **only** `[repository] ROOT` (must be under `/var/lib/gitea/`), restarts Gitea
## 2. Attach canonical remote as pull mirror
After backup (and optional reset), point Gitea at the real remote:
```bash
./gitea_attach_remote.sh git://10.7.0.10/android_cast
# or: GITEA_MIRROR_URL=git://f0xx.org/android_cast ./gitea_attach_remote.sh
```
### Do I need `GITEA_ADMIN_TOKEN`?
**Usually no** when run **as root on the BE**. The script tries `gitea admin user create-repo` and `gitea admin user generate-access-token` before push.
**Optional** if CLI fails — create in Gitea UI: **Avatar → Settings → Applications → Generate New Token** (scopes: All), then:
```bash
export GITEA_ADMIN_TOKEN='gitea_…'
./gitea_attach_remote.sh git://10.7.0.10/android_cast
```
**Why auth?** Gitea rejects anonymous `git push`. The token (or CLI-generated token) proves write access to `foxx/android_cast`.
**Prerequisite:** a **Gitea user** must exist (you have `admin`). Default owner is **`admin`** (override: `GITEA_OWNER=...`).
**Run the script as root** (so it can `su` to `RUN_USER` from `app.ini`, usually `gitea`):
```bash
sudo ./gitea_attach_remote.sh git://10.7.0.10/android_cast
```
Or run directly as the Gitea service user:
```bash
sudo -u gitea ./gitea_attach_remote.sh git://10.7.0.10/android_cast
```
Running `gitea admin ...` as `foxx` fails with `Expect user 'gitea' but current user is: foxx` — that is normal.
List users (on BE):
```bash
sudo -u gitea gitea -c /etc/gitea/app.ini admin user list
```
**`remote: Not found`** = repo did not exist yet and nothing created it. Re-sync the updated script and re-run.
Local push URL is `http://127.0.0.1:3000/foxx/android_cast.git` (no `/app/.../git/` — nginx strips subpath on :3000).
Override:
```bash
GITEA_MIRROR_URL='git://10.7.0.10/android_cast' \
GITEA_OWNER='foxx' GITEA_REPO='android_cast' \
./gitea_attach_remote.sh
```
**Method (no NFS/SSHFS):**
1. **Preferred:** Gitea API `POST /api/v1/repos/migrate` with `"mirror": true` — UI updates on Giteas mirror interval (`10m` default).
2. **Fallback:** `git clone --mirror` from remote + `git push --mirror` to local Gitea HTTP.
Ongoing sync: Gitea pull mirror or cron `gitea admin repo-sync-mirrors`.
## Allow local git mirror (`git://10.7.0.10/...`)
Gitea blocks private/local hosts by default (SSRF protection). For a LAN git daemon:
Edit the **live** config (often `/var/lib/gitea/custom/conf/app.ini`, not only `/etc/gitea/app.ini`):
```ini
[migrations]
ALLOW_LOCALNETWORKS = true
ALLOWED_DOMAINS = *,github.com,*.github.com,chromium.googlesource.com,git.zx2c4.com
BLOCKED_DOMAINS =
```
Notes:
- Section name is **`[migrations]`** (plural) — `[migration]` is ignored.
- `10.7.0.10` is a private IP → **`ALLOW_LOCALNETWORKS = true`** is required.
- On some Gitea versions an empty `ALLOWED_DOMAINS` still blocks UI mirrors; use **`*`** if needed.
- Tighter allowlist (optional): `ALLOWED_DOMAINS = 10.7.0.10` plus `ALLOW_LOCALNETWORKS = true`.
Restart Gitea, then re-sync the mirror:
```bash
sudo rc-service gitea restart
```
In UI: repo → **Settings → Mirror settings** → set interval (e.g. `10m`) → **Sync now**.
CLI (as `gitea` user):
```bash
sudo -u gitea gitea -c /var/lib/gitea/custom/conf/app.ini admin repo-sync-mirrors
```
Ensure BE can reach the git daemon:
```bash
git ls-remote git://10.7.0.10/android_cast HEAD
```
Optional faster default interval for all mirrors:
```ini
[repository]
DEFAULT_MIRROR_INTERVAL = 10m
MIRROR_UPDATE_INTERVAL = 10m
```
## AndroidCast organization (team repos)
Project mirrors live under the **`AndroidCast`** org (not personal `admin/`):
`https://apps.f0xx.org/app/androidcast_project/git/AndroidCast`
| Role | Who | Access |
|------|-----|--------|
| **Site admin** | Gitea user `admin` | Instance users/settings + org **Owners** |
| **Org Owners** | `admin`, `foxx` | Full control of all `AndroidCast/*` repos |
| **Future members** | Add to org teams in UI | Read/write per team (create e.g. Developers later) |
**One-time org setup** (idempotent; org already created in UI is fine):
```bash
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_org_setup.sh
```
This will:
- Ensure org **AndroidCast** exists (skip create if you made it in UI)
- Create Gitea user **`foxx`** if missing (`--must-change-password` — set password or SSH key in UI later)
- Add **`admin`** and **`foxx`** to org **Owners** team (org-admin on all repos)
- **Transfer** existing `admin/android_cast`, `admin/libvpx`, … → `AndroidCast/*` when present
Then migrate mirrors under the org:
```bash
cd /var/www/.../androidcast_project/android_cast
GITEA_OWNER=AndroidCast sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_project.sh .
```
Defaults: `GITEA_OWNER=AndroidCast`, `GITEA_ORG_OWNERS="admin foxx"`, `GITEA_TRANSFER_FROM=admin`.
Clone URLs become:
`https://apps.f0xx.org/app/androidcast_project/git/AndroidCast/android_cast.git`
## Full project migration (main + external submodules)
**`gitea_migrate_project.sh`** — one script for BE:
1. Pull-mirror **`android_cast`** from FE git (`git://10.7.0.10/android_cast`) with a **short interval** (default `1m`).
2. Read **external** submodules from a checkout (`.gitmodules` + **pinned SHA** at `HEAD` / `--ref`).
3. Create read-only pull mirrors for each upstream (libvpx, opus, speex, …).
4. Verify the **same commit digest** the superproject pins is reachable on each Gitea mirror.
Canonical `.gitmodules` stays on upstream URLs — no remote edit.
```bash
cd /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast
chmod +x examples/crash_reporter/backend/scripts/gitea/*.sh
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_project.sh .
# Re-run submodules only (main mirror already exists):
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_project.sh . --skip-main
# Pin check at a tag/branch:
GITEA_REF=next sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_project.sh .
```
### Troubleshooting: only `android_cast` visible (Repositories 1)
`gitea_attach_remote.sh` mirrors **only the main repo**. Submodule repos need a separate step.
```bash
# 1) See what BE would mirror / what exists in Gitea:
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_diagnose_mirrors.sh .
# 2) Create submodule mirrors (main already exists):
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_migrate_project.sh . --skip-main
# or:
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_mirror_submodules.sh .gitmodules
```
Expect **4 repos** under **`AndroidCast`**: `android_cast`, `libvpx`, `opus`, `speex`. If migrate summary shows `submodules ok=0` and a WARN about empty discovery, `.gitmodules` is missing on the BE checkout — the updated migrate script falls back to `submodules.defaults.conf`.
In Gitea UI, use **Explore** or **Site Administration → Repositories** if the profile counter hides mirror repos.
### Fast sync for local `android_cast` mirror only
Gitea **default minimum** pull interval is **10m**. To use **1m** for the FE git mirror:
```ini
[mirror]
MIN_INTERVAL = 1m
[cron.update_mirrors]
SCHEDULE = @every 1m
RUN_AT_START = true
```
Restart Gitea. The migrate script sets `admin/android_cast` mirror interval to `GITEA_MAIN_MIRROR_INTERVAL` (default `1m`). External submodule mirrors default to `24h` (`GITEA_SUBMODULE_MIRROR_INTERVAL`).
**Near-immediate sync on each FE push** (better than polling):
| Method | Command |
|--------|---------|
| BE cron every minute | `* * * * * root /path/to/gitea_sync_mirror.sh android_cast` |
| FE `post-receive` hook | Copy `fe-android_cast-post-receive.example` → FE bare repo `hooks/post-receive`; set `GITEA_SYNC_CMD` (ssh to BE or curl `mirror-sync` API) |
| Manual | `sudo ./gitea_sync_mirror.sh android_cast` |
```bash
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_sync_mirror.sh android_cast
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_sync_mirror.sh --all
```
There is no built-in “push mirror from FE git daemon → Gitea” in stock git; **pull mirror + trigger** (`mirror-sync` API or hook) is the usual integration when git lives on FE and Gitea on BE.
## Submodule mirrors (libvpx, opus, speex, …)
Gitea does not “nest” submodules inside one repo — each submodule is its **own pull mirror** (`admin/libvpx`, `admin/opus`, …). That works **without** changing committed `.gitmodules`.
Prefer **`gitea_migrate_project.sh`** for the full flow; or submodule-only:
**1. Allow upstream hosts** in `app.ini` (see `[migrations]` block above — includes GitHub, googlesource, zx2c4).
**2. Run on BE (as root):**
```bash
cd /path/to/android_cast
chmod +x examples/crash_reporter/backend/scripts/gitea/*.sh
# From bundled list (libvpx, opus, speex, wireguard-android):
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_mirror_submodules.sh
# Or parse live .gitmodules from a checkout:
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_mirror_submodules.sh \
/var/www/.../android_cast/.gitmodules
```
**3. Keep canonical `.gitmodules` unchanged** (upstream GitHub / googlesource URLs). After clone, rewrite submodule fetch URLs **locally**:
```bash
# Per checkout (.git/config only — does not touch .gitmodules):
./examples/crash_reporter/backend/scripts/gitea/gitea_submodule_local_rewrite.sh --apply
# Or machine-wide (url.insteadOf in ~/.gitconfig):
./examples/crash_reporter/backend/scripts/gitea/gitea_submodule_local_rewrite.sh --apply --global
git submodule update --init --recursive
```
On the BE working tree, run `--apply` once per checkout (e.g. after `git pull` in `androidcast_project/android_cast`).
**Optional:** commit Gitea URLs in `.gitmodules` on `git://10.7.0.10/android_cast` so every clone defaults to Gitea without the rewrite script. Skip this if you want upstream URLs to stay canonical.
**4. Add local submodules** — edit `submodules.defaults.conf` or pass a custom list:
```text
# repo|upstream_url|submodule_path
mylib|git://10.7.0.10/mylib|third-party/mylib
```
```bash
GITEA_SUBMODULES_CONF=/root/my-submodules.list sudo ./gitea_mirror_submodules.sh
```
**5. Sync all mirrors periodically:**
```bash
sudo -u gitea gitea -c /var/lib/gitea/custom/conf/app.ini admin repo-sync-mirrors
```
## Branch protection (`master`, `next` only)
Server-side rules per [docs/GIT_FLOW.md](../../../../docs/GIT_FLOW.md) §7. **Two places:**
| Where | Tool |
|-------|------|
| **Gitea** `AndroidCast/android_cast` | `gitea_protect_branches.sh` |
| **Canonical FE** `git://10.7.0.10/android_cast` | `examples/git/hooks/pre-receive.android_cast.example` |
**Gitea (BE):**
```bash
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_protect_branches.sh
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_protect_branches.sh --list
```
- `master` and `next` only (no `*` wildcard — `feature/*` stays free)
- Force-push **disabled for everyone**
- Push/merge allowlist: **admin**, **foxx**, org team **Owners**
- Admins cannot bypass merge rules
**FE bare repo** (primary enforcement for `git push origin`):
```bash
# on FE, in bare repo hooks/
cp examples/git/hooks/pre-receive.android_cast.example \
/path/to/android_cast.git/hooks/pre-receive
chmod +x /path/to/android_cast.git/hooks/pre-receive
```
Rejects on `master` / `next`: branch delete, non-fast-forward (force-push). Other branches unchanged.
`gitea_polish_project.sh` runs branch protection automatically unless `GITEA_SKIP_BRANCH_PROTECT=1`.
Apply the same Gitea rules to other AndroidCast repos:
```bash
GITEA_REPO=url-shortener GITEA_PROTECT_BRANCHES="next master" \
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_protect_branches.sh
```
(Branches that do not exist yet are skipped when using `gitea_attach_url_shortener.sh`.)
## url-shortener (first-party submodule mirror)
The url-shortener service lives in its **own** canonical repo
`git://f0xx.org/androidcast_project/url-shortener` (submodule `backend/url-shortener`).
It is **not** mirrored by `gitea_mirror_submodules.sh` (local `f0xx.org` URLs are skipped).
**One command on BE** (org + pull mirror + default `next` + protect `master`/`next` when present):
```bash
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_attach_url_shortener.sh
```
This reuses `gitea_attach_remote.sh`, `gitea_set_default_branch.sh`, and `gitea_protect_branches.sh` — no duplicate mirror logic.
| Step | What |
|------|------|
| Org | Ensures **AndroidCast** + **Owners** (`admin`, `foxx`) |
| Transfer | `admin/url-shortener` or `androidcast_project/url-shortener``AndroidCast/url-shortener` if present |
| Mirror | Pull from `git://f0xx.org/androidcast_project/url-shortener` (`GITEA_PRIVATE=false` — team-visible under public org) |
| Default branch | `next` |
| Protection | `next` and `master` only (skips branches not on canonical remote yet) |
Clone URL:
`https://apps.f0xx.org/app/androidcast_project/git/AndroidCast/url-shortener.git`
**FE canonical hook** (primary enforcement for `git push` to `git://f0xx.org/...`):
```bash
cp examples/git/hooks/pre-receive.android_cast.example \
/path/to/url-shortener.git/hooks/pre-receive
chmod +x /path/to/url-shortener.git/hooks/pre-receive
```
Ensure `app.ini` `[migrations]` allows **`f0xx.org`** (in addition to `10.7.0.10` for the main repo).
`gitea_polish_project.sh` calls `gitea_attach_url_shortener.sh` automatically when the mirror is missing.
## Polish (finish line — safe BE run)
```bash
cd /var/www/.../androidcast_project/android_cast
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_polish_project.sh .
```
Idempotent: **foxx** → AndroidCast **Owners**, transfer `admin/*` repos, **sync mirrors now**, create missing **libvpx/opus/speex**, default branch **`next`**.
If `gitea_diagnose_mirror_sync.sh` shows `mirror=false` for `android_cast`:
```bash
GITEA_RECREATE_MAIN_MIRROR=yes sudo ./gitea_polish_project.sh .
```
**foxx UI:** browse **`…/git/AndroidCast`** (org), not only profile “Your repositories”.
**Email/2FA (deferred):** `REGISTER_EMAIL_CONFIRM = false` until SMTP; passwords via `gitea_user_admin.sh`.
## Verify
```bash
# Remote reachable from BE
git ls-remote git://f0xx.org/android_cast HEAD
# Gitea UI
curl -sS -o /dev/null -w '%{http_code}\n' \
'https://apps.f0xx.org/app/androidcast_project/git/'
# After attach
git ls-remote "https://apps.f0xx.org/app/androidcast_project/git/foxx/android_cast.git" HEAD
```
## Troubleshooting
### 1) Mirror synced once, then nothing (1h+ stale)
Common cause: repo was created via **`git push --mirror` fallback**, not API **pull mirror** — Gitea shows the repo but **`mirror: false`** and cron never pulls again.
```bash
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_diagnose_mirror_sync.sh
```
If `api mirror=false`:
1. Delete `AndroidCast/android_cast` in Gitea (mirror copy only).
2. Re-attach with API migrate (must succeed):
```bash
GITEA_OWNER=AndroidCast sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_attach_remote.sh git://10.7.0.10/android_cast
```
Ensure `app.ini`:
```ini
[cron.update_mirrors]
ENABLED = true
SCHEDULE = @every 10m
RUN_AT_START = true
[migrations]
ALLOW_LOCALNETWORKS = true
```
Manual sync / stuck queue:
```bash
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_sync_mirror.sh android_cast
# Admin UI -> Monitor -> Queues -> Remove All (if log says "already in queue")
sudo rc-service gitea restart
```
### 2) Default branch `next` in UI
Per-repo (Gitea UI): **Settings → Repository → Default Branch → `next`**.
Or script (all repos under org):
```bash
GITEA_OWNER=AndroidCast GITEA_DEFAULT_BRANCH=next \
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_set_default_branch.sh
```
Run after mirror has fetched `next` from upstream. Each developer can still pick another branch locally; this only changes the **web UI default**.
### 3) Register says “user exists”, login fails (`foxx`)
`gitea_migrate_org_setup.sh` **pre-creates** `foxx` — do **not** use **Register**. The account has an admin-set random password (`must-change-password`).
**Site admin** sets password:
```bash
sudo ./examples/crash_reporter/backend/scripts/gitea/gitea_user_admin.sh reset-password foxx 'choose-a-password'
```
Or Gitea UI: **Site Administration → Users → foxx → Edit**.
Then **Sign In** (not Register). Add SSH key under **Settings → SSH / GPG Keys**.
If self-registration is intended instead: delete `foxx` in admin UI and use **Register** once, or set in `app.ini`:
```ini
[service]
DISABLE_REGISTRATION = false
REGISTER_EMAIL_CONFIRM = false
```
## Related docs
- [docs/INFRA.md](../../../../docs/INFRA.md) — `git://f0xx.org/android_cast`, disk layout
- [docs/TICKETS_ROADMAP.md](../../../../docs/TICKETS_ROADMAP.md) — Gitea URL / `ROOT_URL`
- [docs/GIT_FLOW.md](../../../../docs/GIT_FLOW.md) — branch flow after remote is wired

View File

@@ -0,0 +1,32 @@
#!/bin/sh
# FE bare repo hook: android_cast.git/hooks/post-receive
# After push to git://10.7.0.10/android_cast, trigger immediate Gitea pull sync on BE.
#
# Install on Gentoo FE (adjust paths):
# cp fe-android_cast-post-receive.example /var/git/android_cast.git/hooks/post-receive
# chmod +x /var/git/android_cast.git/hooks/post-receive
#
# Pick ONE trigger method below (uncomment).
# --- A) SSH to BE (recommended if FE has alpine-be key) ---
# GITEA_SYNC_CMD='ssh alpine-be sudo /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/scripts/gitea/gitea_sync_mirror.sh android_cast'
# --- B) curl Gitea API on BE loopback via FE→BE nginx/internal URL ---
# Create a long-lived token in Gitea UI (admin, scope: write repository).
# GITEA_SYNC_CMD='curl -fsS -X POST -H "Authorization: token YOUR_TOKEN" http://artc0.intra.raptor.org:3000/api/v1/repos/AndroidCast/android_cast/mirror-sync'
# --- C) curl public apps URL (only if you add a protected internal endpoint) ---
# GITEA_SYNC_CMD='curl -fsS -X POST -H "Authorization: Bearer ..." https://apps.f0xx.org/.../gitea_mirror_sync'
: "${GITEA_SYNC_CMD:=}"
while read -r _old _new _ref; do
[ "$_new" = "0000000000000000000000000000000000000000" ] && continue
case "$_ref" in
refs/heads/next|refs/heads/master) ;;
*) continue ;;
esac
if [ -n "$GITEA_SYNC_CMD" ]; then
sh -c "$GITEA_SYNC_CMD" </dev/null >/dev/null 2>&1 &
fi
done
exit 0

View File

@@ -0,0 +1,316 @@
#!/bin/ash
#
# Attach canonical git:// remote to Gitea as a pull mirror (no NFS/SSHFS shared folder).
# Run on Alpine BE after gitea_collect_backup.sh (optional reset) and Gitea is running.
#
# Usage:
# ./gitea_attach_remote.sh
# ./gitea_attach_remote.sh git://10.7.0.10/android_cast
# GITEA_MIRROR_URL=git://f0xx.org/android_cast ./gitea_attach_remote.sh
#
# GITEA_ADMIN_TOKEN is optional on BE when run as root: the script can create a repo
# and a short-lived push token via `gitea admin` CLI. Set a token only if CLI fails.
#
# Methods (auto):
# 1) Gitea API /repos/migrate with mirror=true (ongoing pull sync in UI)
# 2) gitea admin create-repo + git clone --mirror + git push --mirror
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
# shellcheck source=gitea_org_lib.sh
. "$SCRIPT_DIR/gitea_org_lib.sh"
GITEA_MIRROR_URL="${GITEA_MIRROR_URL:-git://f0xx.org/android_cast}"
GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-ac}}"
GITEA_REPO="${GITEA_REPO:-android_cast}"
GITEA_MIRROR_INTERVAL="${GITEA_MIRROR_INTERVAL:-10m}"
GITEA_PRIVATE="${GITEA_PRIVATE:-false}"
WORK_DIR="${WORK_DIR:-}"
# First positional argument = mirror URL (convenience).
if [ $# -ge 1 ] && [ -n "$1" ]; then
case "$1" in
-h|--help)
sed -n '2,20p' "$0"
exit 0
;;
*)
GITEA_MIRROR_URL="$1"
;;
esac
fi
log() { printf '[gitea-attach] %s\n' "$*"; }
warn() { printf '[gitea-attach] WARN: %s\n' "$*" >&2; }
die() { printf '[gitea-attach] ERROR: %s\n' "$*" >&2; exit 1; }
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
}
# Public clone URL (via ROOT_URL + subpath). Local :3000 git/API omit subpath (nginx strips it).
gitea_public_repo_url() {
_root="$GITEA_ROOT_URL"
case "$_root" in
*/) _root="${_root%/}" ;;
esac
printf '%s/%s/%s.git' "$_root" "$GITEA_OWNER" "$GITEA_REPO"
}
gitea_local_git_url() {
printf 'http://127.0.0.1:%s/%s/%s.git' "${GITEA_HTTP_PORT:-3000}" "$GITEA_OWNER" "$GITEA_REPO"
}
gitea_api_base() {
printf '%s' "${GITEA_LOCAL_API:-http://127.0.0.1:3000/api/v1}"
}
test_remote_reachable() {
_url="$1"
log "probing remote: $_url"
if command -v git >/dev/null 2>&1; then
if git ls-remote "$_url" HEAD >/dev/null 2>&1; then
log "remote reachable (git ls-remote HEAD)"
return 0
fi
if git ls-remote "$_url" refs/heads/next >/dev/null 2>&1; then
log "remote reachable (git ls-remote refs/heads/next)"
return 0
fi
if git ls-remote "$_url" 2>/dev/null | grep -q refs/heads/; then
log "remote reachable (git ls-remote has branches)"
return 0
fi
warn "git ls-remote failed for $_url"
fi
return 1
}
resolve_gitea_owner() {
if [ "${GITEA_SKIP_ORG_SETUP:-0}" -eq 0 ]; then
gitea_org_ensure_androidcast "$GITEA_ORG" 2>/dev/null || true
GITEA_OWNER="${GITEA_OWNER:-$GITEA_ORG}"
fi
if gitea_owner_exists "$GITEA_OWNER"; then
log "Gitea repo owner: $GITEA_OWNER"
return 0
fi
warn "Gitea has no owner '$GITEA_OWNER' (user or org — not Linux/FE login)"
log "Gitea users on this instance:"
gitea_list_usernames | while read -r _u; do
[ -n "$_u" ] && log " - $_u"
done
_fallback="$(gitea_pick_owner_username)"
if [ -z "$_fallback" ]; then
die "no Gitea users in DB — complete first-run at $GITEA_ROOT_URL or: sudo -u $GITEA_RUN_USER gitea -c $GITEA_APP_INI admin user create --username admin --password '...' --email admin@local --admin"
fi
warn "using Gitea user '$_fallback' as repo owner (override: GITEA_OWNER=...)"
GITEA_OWNER="$_fallback"
}
resolve_push_token() {
gitea_resolve_push_token
}
create_repo_via_cli() {
log "creating empty repo $GITEA_OWNER/$GITEA_REPO via Gitea API..."
if gitea_create_repo_cli "$GITEA_OWNER" "$GITEA_REPO" "$GITEA_PRIVATE"; then
log "repo created (API)"
return 0
fi
warn "API create repo failed for $GITEA_OWNER/$GITEA_REPO"
return 1
}
create_repo_via_api() {
create_repo_via_cli
}
gitea_owner_uid() {
gitea_entity_uid_for "$GITEA_OWNER"
}
migrate_via_api() {
need_cmd curl
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(resolve_push_token)" || return 1
_uid="$(gitea_owner_uid)" || {
warn "API migrate skipped: no uid for owner $GITEA_OWNER"
return 1
}
_api="$(gitea_api_base)"
_payload=$(cat <<EOF
{
"clone_addr": "$GITEA_MIRROR_URL",
"repo_name": "$GITEA_REPO",
"uid": $_uid,
"mirror": true,
"mirror_interval": "$GITEA_MIRROR_INTERVAL",
"private": $GITEA_PRIVATE,
"wiki": true,
"issues": false,
"milestones": false,
"labels": false,
"releases": true,
"description": "Pull mirror of $GITEA_MIRROR_URL"
}
EOF
)
log "API migrate (pull mirror) uid=$_uid via $_api ..."
if gitea_repo_exists_api "$GITEA_OWNER" "$GITEA_REPO" 2>/dev/null; then
log "repo already exists — sync mirror instead of migrate"
gitea_sync_mirror_api "$GITEA_OWNER" "$GITEA_REPO" && return 0
return 1
fi
_resp="$(gitea_mktemp_file)"
_http="$(curl -sS -m "${GITEA_MIGRATE_TIMEOUT:-600}" -o "$_resp" -w '%{http_code}' \
-X POST "$_api/repos/migrate" \
-H "Authorization: token $_token" \
-H "Content-Type: application/json" \
-d "$_payload")"
_rc=1
if [ "$_http" = "201" ] || [ "$_http" = "200" ]; then
log "mirror created via API (HTTP $_http)"
cat "$_resp"
printf '\n'
_rc=0
else
if [ "$_http" = "409" ] || [ "$_http" = "422" ]; then
warn "migrate rejected HTTP $_http — repo may already exist; use mirror settings or delete repo first"
fi
warn "API migrate failed HTTP $_http:"
cat "$_resp" >&2
fi
gitea_rm_path "$_resp"
return "$_rc"
}
ensure_gitea_repo_exists() {
create_repo_via_cli || create_repo_via_api || true
}
mirror_via_git() {
need_cmd git
gitea_prune_stale_tmp
if [ -n "$WORK_DIR" ]; then
mkdir -p "$WORK_DIR"
else
WORK_DIR="$(gitea_make_work_dir gitea-attach)"
fi
trap 'gitea_rm_path "$WORK_DIR"' EXIT INT HUP TERM
_mirror="$WORK_DIR/${GITEA_REPO}.mirror.git"
_rc=1
log "git clone --mirror $GITEA_MIRROR_URL"
gitea_rm_path "$_mirror"
if ! git clone --mirror "$GITEA_MIRROR_URL" "$_mirror"; then
gitea_rm_path "$WORK_DIR"
return 1
fi
ensure_gitea_repo_exists
_push_base="$(gitea_local_git_url)"
if _token="$(resolve_push_token)"; then
_auth_url="${_push_base#http://}"
_push_url="http://${_token}@${_auth_url}"
log "git push --mirror -> $_push_base (localhost, no subpath)"
if git -C "$_mirror" push --mirror "$_push_url"; then
_rc=0
log "one-time mirror push complete"
fi
else
die "need credentials: export GITEA_ADMIN_TOKEN=... or run as root with gitea CLI"
fi
warn "for automatic updates, re-run with API migrate or enable pull mirror in Gitea UI"
gitea_rm_path "$WORK_DIR"
trap - EXIT INT HUP TERM
return "$_rc"
}
enable_mirror_sync_cron_hint() {
cat <<EOF
Ongoing sync (no shared mount):
• Pull mirror (recommended): Gitea → Repo → Settings → Mirror → Pull mirror
Remote: $GITEA_MIRROR_URL
Interval: $GITEA_MIRROR_INTERVAL
• Or cron on BE (as git user):
*/15 * * * * gitea -c $GITEA_APP_INI admin repo-sync-mirrors
Clone URL (public):
$(gitea_public_repo_url)
EOF
}
print_token_help() {
cat <<'EOF'
About GITEA_ADMIN_TOKEN (optional on BE):
• What: a Personal Access Token for your Gitea admin/owner user.
• Where: log in to Gitea UI → top-right avatar → Settings → Applications
→ Generate New Token → name it e.g. "attach-script" → scopes: all (or repo write).
• Why: HTTP git push and REST API require authentication. Without a token the script
tries `gitea admin user generate-access-token` on the server (works when run as root).
• You do NOT need a token if `gitea admin create-repo` + generate-access-token succeed.
EOF
}
# --- main ---
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
resolve_gitea_owner
log "config ini=$GITEA_APP_INI RUN_USER=$GITEA_RUN_USER"
log "Gitea ROOT_URL=$GITEA_ROOT_URL (public)"
log "local API=$(gitea_api_base) local git=$(gitea_local_git_url)"
log "target repo: $GITEA_OWNER/$GITEA_REPO"
log "mirror from: $GITEA_MIRROR_URL"
if ! test_remote_reachable "$GITEA_MIRROR_URL"; then
warn "remote not reachable from this host — check git:// daemon / firewall"
fi
if command -v rc-service >/dev/null 2>&1; then
if ! rc-service gitea status 2>/dev/null | grep -qi started; then
warn "gitea service may not be running — starting..."
rc-service gitea start 2>/dev/null || true
fi
fi
if migrate_via_api; then
enable_mirror_sync_cron_hint
log "done (API pull mirror)"
exit 0
fi
# Skip git push if repo already exists as mirror (stale curl migrate may have completed server-side).
if gitea_repo_exists_api "$GITEA_OWNER" "$GITEA_REPO" 2>/dev/null; then
log "repo $GITEA_OWNER/$GITEA_REPO already exists — syncing mirror"
gitea_sync_mirror_api "$GITEA_OWNER" "$GITEA_REPO" 2>/dev/null || true
enable_mirror_sync_cron_hint
log "done (existing mirror)"
exit 0
fi
log "falling back to git clone --mirror + push --mirror"
if ! mirror_via_git; then
print_token_help
exit 1
fi
enable_mirror_sync_cron_hint
log "done (git mirror push)"

View File

@@ -0,0 +1,128 @@
#!/bin/ash
#
# Import url-shortener into the AndroidCast Gitea org (pull mirror, team-visible, protected branches).
# Thin orchestrator — reuses gitea_attach_remote.sh, gitea_set_default_branch.sh,
# gitea_protect_branches.sh (same rules as android_cast).
#
# Canonical remote: git://f0xx.org/androidcast_project/url-shortener
# Gitea UI: …/git/AndroidCast/url-shortener
#
# Usage (Alpine BE as root):
# sudo ./gitea_attach_url_shortener.sh
#
# Override:
# GITEA_MIRROR_URL=git://f0xx.org/androidcast_project/url-shortener sudo ./gitea_attach_url_shortener.sh
#
# Prerequisites: app.ini [migrations] must allow f0xx.org (see scripts/gitea/README.md).
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
# shellcheck source=gitea_org_lib.sh
. "$SCRIPT_DIR/gitea_org_lib.sh"
GITEA_ORG="${GITEA_ORG:-AndroidCast}"
GITEA_OWNER="${GITEA_OWNER:-$GITEA_ORG}"
GITEA_REPO="${GITEA_REPO:-url-shortener}"
GITEA_MIRROR_URL="${GITEA_MIRROR_URL:-git://f0xx.org/androidcast_project/url-shortener}"
GITEA_DEFAULT_BRANCH="${GITEA_DEFAULT_BRANCH:-next}"
GITEA_MIRROR_INTERVAL="${GITEA_MIRROR_INTERVAL:-10m}"
GITEA_PRIVATE="${GITEA_PRIVATE:-false}"
# Personal or legacy owners to re-home under AndroidCast (space-separated).
GITEA_TRANSFER_SOURCES="${GITEA_TRANSFER_SOURCES:-admin androidcast_project}"
log() { printf '[gitea-url-shortener] %s\n' "$*"; }
warn() { printf '[gitea-url-shortener] WARN: %s\n' "$*" >&2; }
case "${1:-}" in
-h|--help|help)
sed -n '2,18p' "$0"
exit 0
;;
esac
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
log "=== $GITEA_REPO$GITEA_OWNER/$GITEA_REPO ==="
log "mirror: $GITEA_MIRROR_URL"
log "public: ${GITEA_ROOT_URL%/}/$GITEA_OWNER/$GITEA_REPO"
# 1) Org + Owners team (admin, foxx) — repos under the org are team-visible.
gitea_org_ensure_androidcast "$GITEA_ORG" || warn "org setup incomplete"
# 2) Transfer if mirror was created under a personal/legacy account.
for _from in $GITEA_TRANSFER_SOURCES; do
[ "$_from" = "$GITEA_OWNER" ] && continue
if gitea_repo_exists_api "$_from" "$GITEA_REPO"; then
log "transfer $_from/$GITEA_REPO$GITEA_OWNER/$GITEA_REPO"
gitea_transfer_repo_api "$_from" "$GITEA_REPO" "$GITEA_OWNER" \
&& log " transferred" \
|| warn " transfer failed"
fi
done
# 3) Pull mirror (attach when missing). Resolve PAT once for all API calls this run.
_gitea_token="$(gitea_resolve_push_token)" || true
[ -n "$_gitea_token" ] && GITEA_ADMIN_TOKEN="$_gitea_token" && export GITEA_ADMIN_TOKEN
if gitea_repo_exists_api "$GITEA_OWNER" "$GITEA_REPO"; then
log "repo exists — trigger mirror sync"
gitea_sync_mirror_api "$GITEA_OWNER" "$GITEA_REPO" || warn "mirror sync API failed — try gitea_sync_mirror.sh"
else
log "attach pull mirror (API migrate) ..."
if ! gitea_mirror_pull_repo "$GITEA_MIRROR_URL" "$GITEA_REPO" "$GITEA_OWNER" \
"$GITEA_MIRROR_INTERVAL" ""; then
warn "API migrate failed — trying gitea_attach_remote.sh fallback ..."
GITEA_OWNER="$GITEA_OWNER" GITEA_REPO="$GITEA_REPO" \
GITEA_MIRROR_URL="$GITEA_MIRROR_URL" \
GITEA_MIRROR_INTERVAL="$GITEA_MIRROR_INTERVAL" \
GITEA_PRIVATE="$GITEA_PRIVATE" \
sh "$SCRIPT_DIR/gitea_attach_remote.sh" "$GITEA_MIRROR_URL" || exit 1
fi
gitea_sync_mirror_api "$GITEA_OWNER" "$GITEA_REPO" || warn "initial sync pending — check [migrations] / git daemon"
fi
# 4) Default branch next (integration; master when release branch exists).
GITEA_OWNER="$GITEA_OWNER" GITEA_REPO_NAMES="$GITEA_REPO" \
GITEA_DEFAULT_BRANCH="$GITEA_DEFAULT_BRANCH" \
sh "$SCRIPT_DIR/gitea_set_default_branch.sh" || warn "default branch set failed (sync mirror first)"
# 5) Branch protection — master + next when present on canonical remote (GIT_FLOW §7).
_branches=""
for _b in next master; do
if gitea_git_remote_has_branch "$GITEA_MIRROR_URL" "$_b"; then
_branches="$_branches $_b"
else
log "skip Gitea protection for $_b (not on $GITEA_MIRROR_URL yet)"
fi
done
_branches="$(echo "$_branches" | sed 's/^ //')"
if [ -n "$_branches" ]; then
GITEA_OWNER="$GITEA_OWNER" GITEA_REPO="$GITEA_REPO" \
GITEA_PROTECT_BRANCHES="$_branches" \
sh "$SCRIPT_DIR/gitea_protect_branches.sh" || warn "branch protection failed — run manually"
else
warn "no branches to protect — mirror may be empty"
fi
cat <<EOF
url-shortener on Gitea:
• ${GITEA_ROOT_URL%/}/$GITEA_OWNER/$GITEA_REPO
• Org repo (private=$GITEA_PRIVATE) — visible to AndroidCast org members; Owners: $GITEA_ORG_OWNERS
• Default branch: $GITEA_DEFAULT_BRANCH
• Gitea protected branches:${_branches:+ }${_branches:- (none yet)}
Canonical FE git (optional pre-receive hook — same as android_cast):
cp examples/git/hooks/pre-receive.android_cast.example \\
/path/to/url-shortener.git/hooks/pre-receive
chmod +x /path/to/url-shortener.git/hooks/pre-receive
Re-sync: sudo ./gitea_sync_mirror.sh url-shortener
EOF

View File

@@ -0,0 +1,22 @@
#!/bin/ash
#
# Remove stale gitea script temp dirs under /tmp (mirror clones, work dirs, json).
# Safe to run anytime; does not touch running jobs younger than GITEA_TMP_MAX_AGE_MIN.
#
# Usage:
# sudo ./gitea_cleanup_tmp.sh
# GITEA_TMP_MAX_AGE_MIN=0 sudo ./gitea_cleanup_tmp.sh # aggressive (all ages)
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
_before="$(find /tmp -maxdepth 1 \( -name 'gitea-migrate-*' -o -name 'gitea-submodules-*' \
-o -name 'gitea-attach-*' -o -name 'gitea-mirror-*' -o -name 'gitea-*.json' \) 2>/dev/null | wc -l | tr -d ' ')"
gitea_prune_stale_tmp
_after="$(find /tmp -maxdepth 1 \( -name 'gitea-migrate-*' -o -name 'gitea-submodules-*' \
-o -name 'gitea-attach-*' -o -name 'gitea-mirror-*' -o -name 'gitea-*.json' \) 2>/dev/null | wc -l | tr -d ' ')"
printf '[gitea-cleanup] max_age_min=%s before=%s after=%s\n' \
"${GITEA_TMP_MAX_AGE_MIN:-60}" "$_before" "$_after"

View File

@@ -0,0 +1,251 @@
#!/bin/ash
#
# Collect full Gitea backup on Alpine BE (parse /etc/gitea/app.ini).
# Run as root on the BE where Gitea is installed.
#
# Usage:
# ./gitea_collect_backup.sh
# OUT_BASE=/root/gitea-backups ./gitea_collect_backup.sh
# GITEA_INI=/etc/gitea/app.ini ./gitea_collect_backup.sh
#
# Does NOT touch the main working tree:
# /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
OUT_BASE="${OUT_BASE:-/var/backups/gitea}"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT_DIR="$OUT_BASE/gitea-collect-$STAMP"
MANIFEST="$OUT_DIR/MANIFEST.txt"
# Paths that must never be deleted or overwritten by the optional reset step.
PROTECTED_MAIN_REPO="${PROTECTED_MAIN_REPO:-/var/www/localhost/htdocs/apps/app/androidcast_project/android_cast}"
log() { printf '[gitea-collect] %s\n' "$*"; }
warn() { printf '[gitea-collect] WARN: %s\n' "$*" >&2; }
die() { printf '[gitea-collect] ERROR: %s\n' "$*" >&2; exit 1; }
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
}
copy_tree() {
_src="$1"
_dst="$2"
if [ ! -e "$_src" ]; then
warn "skip missing: $_src"
return 0
fi
mkdir -p "$(dirname "$_dst")"
if cp -a "$_src" "$_dst" 2>/dev/null; then
log "copied: $_src -> $_dst"
return 0
fi
# BusyBox cp may lack -a; try tar pipe.
mkdir -p "$_dst"
(cd "$_src" && tar cf - .) | (cd "$_dst" && tar xf -)
log "archived (tar): $_src -> $_dst"
}
dump_mysql() {
_out="$1"
need_cmd mysqldump
_args=""
if [ -n "$GITEA_DB_HOST" ]; then
case "$GITEA_DB_HOST" in
/*) _args="--socket=$GITEA_DB_HOST" ;;
*) _args="-h $GITEA_DB_HOST" ;;
esac
fi
# shellcheck disable=SC2086
mysqldump $_args -u"$GITEA_DB_USER" ${GITEA_DB_PASS:+-p"$GITEA_DB_PASS"} \
--single-transaction --routines --triggers "$GITEA_DB_NAME" > "$_out"
log "mysqldump -> $_out"
}
dump_postgres() {
_out="$1"
need_cmd pg_dump
PGPASSWORD="$GITEA_DB_PASS" pg_dump -h "${GITEA_DB_HOST:-127.0.0.1}" -U "$GITEA_DB_USER" "$GITEA_DB_NAME" > "$_out"
log "pg_dump -> $_out"
}
write_manifest() {
{
echo "Gitea collect backup"
echo "created_at=$STAMP"
echo "hostname=$(hostname 2>/dev/null || echo unknown)"
echo "gitea_ini=$GITEA_APP_INI"
echo "repo_root=$GITEA_REPO_ROOT"
echo "app_data=$GITEA_APP_DATA"
echo "root_url=$GITEA_ROOT_URL"
echo "db_type=$GITEA_DB_TYPE"
echo "db_name=$GITEA_DB_NAME"
echo "lfs_path=$GITEA_LFS_PATH"
echo "log_root=$GITEA_LOG_ROOT"
echo "protected_main_repo=$PROTECTED_MAIN_REPO"
echo ""
echo "--- gitea version ---"
gitea_cli "-v" 2>/dev/null || echo "gitea -v unavailable"
echo ""
echo "--- repo directories ---"
if [ -d "$GITEA_REPO_ROOT" ]; then
find "$GITEA_REPO_ROOT" -maxdepth 3 -type d -name '*.git' 2>/dev/null || true
else
echo "(repo root missing)"
fi
} > "$MANIFEST"
}
list_gitea_repos() {
if [ ! -d "$GITEA_REPO_ROOT" ]; then
return 0
fi
find "$GITEA_REPO_ROOT" -mindepth 2 -maxdepth 2 -type d -name '*.git' 2>/dev/null \
| while read -r gitdir; do
owner="$(basename "$(dirname "$gitdir")")"
repo="$(basename "$gitdir" .git)"
printf '%s/%s\n' "$owner" "$repo"
done
}
delete_gitea_repo_records() {
_owner="$1"
_repo="$2"
if gitea_cli "admin" "repo" "delete" "$_owner/$_repo" 2>/dev/null; then
log "gitea admin repo delete $_owner/$_repo"
return 0
fi
if gitea_cli "admin" "repo-delete" "$_owner/$_repo" 2>/dev/null; then
log "gitea admin repo-delete $_owner/$_repo"
return 0
fi
warn "could not delete $_owner/$_repo via gitea CLI (will still remove bare files)"
return 1
}
reset_gitea_repos_only() {
if ! gitea_path_is_safe_to_wipe "$GITEA_REPO_ROOT"; then
die "refusing reset: repo root not under safe prefix: $GITEA_REPO_ROOT"
fi
case "$PROTECTED_MAIN_REPO" in
"$GITEA_REPO_ROOT"|"$GITEA_REPO_ROOT"/*)
die "refusing reset: Gitea repo root overlaps protected main repo path"
;;
esac
log "stopping gitea (if running)..."
if command -v rc-service >/dev/null 2>&1; then
rc-service gitea stop 2>/dev/null || true
fi
log "removing Gitea repo registrations via CLI..."
_repos="$(list_gitea_repos)"
if [ -n "$_repos" ]; then
echo "$_repos" | while read -r slug; do
[ -n "$slug" ] || continue
_o="${slug%%/*}"
_r="${slug#*/}"
delete_gitea_repo_records "$_o" "$_r" || true
done
fi
if [ -d "$GITEA_REPO_ROOT" ]; then
log "clearing bare repos under $GITEA_REPO_ROOT (not touching $PROTECTED_MAIN_REPO)"
find "$GITEA_REPO_ROOT" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} +
fi
mkdir -p "$GITEA_REPO_ROOT"
log "starting gitea..."
if command -v rc-service >/dev/null 2>&1; then
rc-service gitea start 2>/dev/null || warn "rc-service gitea start failed — start manually"
fi
log "Gitea repository store reset to empty (Gitea DB may still need doctor if UI shows ghosts)"
}
# --- main ---
[ "$(id -u)" -eq 0 ] || die "run as root (needed for service stop, file reads, mysqldump)"
gitea_load_config || exit 1
need_cmd awk
need_cmd mkdir
need_cmd date
mkdir -p "$OUT_DIR"
log "backup directory: $OUT_DIR"
write_manifest
log "copy app.ini"
mkdir -p "$OUT_DIR/config"
copy_tree "$GITEA_APP_INI" "$OUT_DIR/config/app.ini"
log "copy APP_DATA_PATH (includes caches, indexes, attachments, custom/)"
copy_tree "$GITEA_APP_DATA" "$OUT_DIR/app_data"
log "copy repository ROOT (bare repos)"
copy_tree "$GITEA_REPO_ROOT" "$OUT_DIR/gitea-repositories"
if [ -n "$GITEA_LFS_PATH" ] && [ "$GITEA_LFS_PATH" != "$GITEA_APP_DATA/lfs" ]; then
log "copy LFS path"
copy_tree "$GITEA_LFS_PATH" "$OUT_DIR/lfs"
fi
if [ -n "$GITEA_LOG_ROOT" ] && [ -d "$GITEA_LOG_ROOT" ]; then
log "copy log directory"
copy_tree "$GITEA_LOG_ROOT" "$OUT_DIR/logs"
fi
log "database dump"
mkdir -p "$OUT_DIR/database"
case "$(echo "$GITEA_DB_TYPE" | tr '[:upper:]' '[:lower:]')" in
mysql)
dump_mysql "$OUT_DIR/database/gitea-mysql.sql"
;;
postgres|postgresql)
dump_postgres "$OUT_DIR/database/gitea-postgres.sql"
;;
sqlite3|sqlite)
if [ -n "$GITEA_DB_PATH" ] && [ -f "$GITEA_DB_PATH" ]; then
copy_tree "$GITEA_DB_PATH" "$OUT_DIR/database/gitea.sqlite3"
elif [ -f "$GITEA_APP_DATA/gitea.db" ]; then
copy_tree "$GITEA_APP_DATA/gitea.db" "$OUT_DIR/database/gitea.sqlite3"
else
warn "sqlite DB file not found (database PATH / gitea.db)"
fi
;;
*)
warn "unknown DB_TYPE=$GITEA_DB_TYPE — copy app_data only"
;;
esac
if command -v gitea >/dev/null 2>&1; then
if gitea_cli "dump" "--file" "$OUT_DIR/gitea-built-in-dump.zip" 2>/dev/null; then
log "gitea built-in dump -> gitea-built-in-dump.zip"
else
warn "gitea dump subcommand unavailable or failed (backup copies above are still valid)"
fi
fi
_bytes="$(du -sh "$OUT_DIR" 2>/dev/null | awk '{print $1}')"
log "done. size≈${_bytes:-?} manifest=$MANIFEST"
log "protected working tree (never touched): $PROTECTED_MAIN_REPO"
printf '\nWould you like to reset Gitea repositories state to the default (empty)? [y/N] '
read -r _reset_answer || _reset_answer=""
case "$(echo "$_reset_answer" | tr '[:upper:]' '[:lower:]')" in
y|yes)
log "reset confirmed"
reset_gitea_repos_only
;;
*)
log "reset skipped"
;;
esac
log "backup complete: $OUT_DIR"

View File

@@ -0,0 +1,120 @@
#!/bin/ash
#
# Why Gitea pull mirrors stopped syncing (one-time migrate vs cron vs queue).
#
# Usage:
# sudo ./gitea_diagnose_mirror_sync.sh
# GITEA_OWNER=AndroidCast GITEA_REPO=android_cast sudo ./gitea_diagnose_mirror_sync.sh
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-AndroidCast}}"
GITEA_REPO="${GITEA_REPO:-android_cast}"
GITEA_MIRROR_URL="${GITEA_MIRROR_URL:-git://10.7.0.10/android_cast}"
log() { printf '[gitea-mirror-diag] %s\n' "$*"; }
warn() { printf '[gitea-mirror-diag] WARN: %s\n' "$*" >&2; }
json_bool() {
_json="$1"
_key="$2"
printf '%s' "$_json" | tr -d '\n' | grep -o "\"$_key\":[^,}]*" | head -1 | sed 's/.*://;s/ //g'
}
json_str() {
_json="$1"
_key="$2"
printf '%s' "$_json" | tr -d '\n' | grep -o "\"$_key\":\"[^\"]*\"" | head -1 | sed 's/.*:"//;s/"$//'
}
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
log "ini=$GITEA_APP_INI"
log "repo=$GITEA_OWNER/$GITEA_REPO upstream=$GITEA_MIRROR_URL"
# --- upstream reachability ---
if git ls-remote "$GITEA_MIRROR_URL" HEAD >/dev/null 2>&1; then
log "upstream reachable from this host (git ls-remote OK)"
else
warn "upstream NOT reachable — scheduled pull mirrors will fail (check git daemon, ALLOW_LOCALNETWORKS)"
fi
# --- repo mirror flags (API) ---
_meta="$(gitea_repo_get_api "$GITEA_OWNER" "$GITEA_REPO" 2>/dev/null)" || _meta=""
if [ -z "$_meta" ]; then
warn "could not read repo via API"
else
_mirror="$(json_bool "$_meta" "mirror")"
_interval="$(json_str "$_meta" "mirror_interval")"
_default="$(json_str "$_meta" "default_branch")"
_updated="$(json_str "$_meta" "updated_at")"
log "api mirror=$_mirror mirror_interval=$_interval default_branch=$_default updated_at=$_updated"
if [ "$_mirror" != "true" ]; then
warn "repo is NOT a pull mirror — only the initial push happened; auto-sync will never run"
warn "fix: delete $GITEA_OWNER/$GITEA_REPO in Gitea UI, re-run:"
warn " sudo ./gitea_attach_remote.sh $GITEA_MIRROR_URL"
warn " (API migrate must succeed — not git-push fallback)"
fi
fi
# --- app.ini mirror cron hints ---
for _ini in "$GITEA_APP_INI" /etc/gitea/app.ini; do
[ -r "$_ini" ] || continue
log "config $_ini:"
awk '
/^\[/ { sec = $0; gsub(/^\[|\]$/, "", sec) }
/^(ENABLED|SCHEDULE|MIN_INTERVAL|DEFAULT_INTERVAL|PULL_LIMIT|ALLOW_LOCALNETWORKS)/ {
print " " sec " " $0
}
' "$_ini" | grep -E 'cron\.update_mirrors|^\s*\[mirror\]|migrations' || true
done
# --- sqlite mirror row (next_update) if available ---
if command -v sqlite3 >/dev/null 2>&1; then
_db="${GITEA_DB_PATH:-}"
[ -n "$_db" ] || _db="$GITEA_APP_DATA/gitea.db"
[ -r "$_db" ] || _db="/var/lib/gitea/db/gitea.db"
if [ -r "$_db" ]; then
log "mirror table (repo_id, interval, next_update_unix, updated_unix):"
sqlite3 -header -column "$_db" "
SELECT m.repo_id, m.interval, m.next_update_unix, m.updated_unix, r.name
FROM mirror m
JOIN repository r ON r.id = m.repo_id
WHERE r.lower_name = lower('$GITEA_REPO')
LIMIT 5;
" 2>/dev/null || warn "mirror SQL query failed"
fi
fi
# --- gitea.log mirror errors ---
for _logf in "$GITEA_LOG_ROOT/gitea.log" /var/log/gitea/gitea.log; do
[ -r "$_logf" ] || continue
log "recent mirror errors in $_logf:"
grep -E 'mirror|already in queue|update_mirrors' "$_logf" 2>/dev/null | tail -n 8 || true
break
done
cat <<EOF
Quick fixes:
1) Manual sync now:
sudo ./gitea_sync_mirror.sh $GITEA_REPO
2) Sync all mirrors:
sudo -u gitea gitea -c $GITEA_APP_INI admin repo-sync-mirrors
3) Stuck queue ("already in queue"): Gitea Admin -> Monitor -> Queues -> Remove All (mirror)
4) Ensure app.ini:
[cron.update_mirrors]
ENABLED = true
SCHEDULE = @every 10m
RUN_AT_START = true
then: sudo rc-service gitea restart
5) FE push trigger: fe-android_cast-post-receive.example
EOF

View File

@@ -0,0 +1,105 @@
#!/bin/ash
#
# Debug why submodule mirrors are missing in Gitea UI.
#
# Usage:
# sudo ./gitea_diagnose_mirrors.sh
# sudo ./gitea_diagnose_mirrors.sh /var/www/.../android_cast
# sudo ./gitea_diagnose_mirrors.sh /var/www/.../android_cast/.gitmodules
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
# shellcheck source=gitea_submodule_lib.sh
. "$SCRIPT_DIR/gitea_submodule_lib.sh"
GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-AndroidCast}}"
GITEA_REF="${GITEA_REF:-HEAD}"
GITEA_SUBMODULES_CONF="${GITEA_SUBMODULES_CONF:-$SCRIPT_DIR/submodules.defaults.conf}"
log() { printf '[gitea-diagnose] %s\n' "$*"; }
warn() { printf '[gitea-diagnose] WARN: %s\n' "$*" >&2; }
_arg="${1:-.}"
_repo_dir=""
_gitmodules=""
case "$_arg" in
*.gitmodules)
_gitmodules="$(cd "$(dirname "$_arg")" && pwd)/$(basename "$_arg")"
_parent="$(dirname "$_gitmodules")"
[ -d "$_parent/.git" ] || [ -f "$_parent/.git" ] && _repo_dir="$(cd "$_parent" && pwd)" || _repo_dir="$_parent"
;;
*)
[ -d "$_arg" ] || { warn "not a directory: $_arg"; exit 1; }
_repo_dir="$(cd "$_arg" && pwd)"
_gitmodules="$_repo_dir/.gitmodules"
;;
esac
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
log "ini=$GITEA_APP_INI owner=$GITEA_OWNER"
log "checkout=$_repo_dir"
log "gitmodules=$_gitmodules"
if [ -r "$_gitmodules" ]; then
log ".gitmodules present ($(wc -l < "$_gitmodules" | tr -d ' ') lines)"
else
warn ".gitmodules MISSING on BE — submodule migrate cannot discover entries"
fi
if command -v git >/dev/null 2>&1 && [ -d "$_repo_dir/.git" ] || [ -f "$_repo_dir/.git" ]; then
log "git branch: $(git -C "$_repo_dir" branch --show-current 2>/dev/null || echo '?')"
log "git HEAD: $(git -C "$_repo_dir" rev-parse HEAD 2>/dev/null || echo '?')"
for _p in third-party/libvpx third-party/opus third-party/speex; do
_sha="$(git -C "$_repo_dir" rev-parse "$GITEA_REF:$_p" 2>/dev/null)" || _sha="(no gitlink)"
log " $GITEA_REF:$_p -> $_sha"
done
else
warn "not a git checkout — only .gitmodules / defaults.conf can be used"
fi
log "collect_entries (what migrate would mirror):"
gitea_submodule_collect_entries "$_repo_dir" "$GITEA_REF" "$_gitmodules" "$GITEA_SUBMODULES_CONF" \
| while IFS= read -r _e; do
[ -n "$_e" ] && log " $_e"
done
log "Gitea repos via API:"
if _names="$(gitea_list_repos_api "$GITEA_OWNER" 2>/dev/null)"; then
echo "$_names" | while read -r _r; do
[ -n "$_r" ] && log " $GITEA_OWNER/$_r"
done
log "count=$(echo "$_names" | grep -c . 2>/dev/null || echo 0)"
else
warn "API list failed"
fi
log "on-disk bare repos:"
if [ -d "${GITEA_REPO_ROOT:-/var/lib/gitea/data/gitea-repositories}/$GITEA_OWNER" ]; then
ls -1 "${GITEA_REPO_ROOT}/$GITEA_OWNER" 2>/dev/null | while read -r _d; do
log " $GITEA_OWNER/$_d"
done
else
warn "no directory ${GITEA_REPO_ROOT}/$GITEA_OWNER"
fi
cat <<EOF
If count=1 (only android_cast under AndroidCast or admin):
• gitea_attach_remote.sh only mirrors the main repo — run submodule step:
sudo ./gitea_migrate_project.sh . --skip-main
or:
sudo ./gitea_mirror_submodules.sh .gitmodules
• If collect_entries is empty: sync .gitmodules to BE or use submodules.defaults.conf
Gitea UI: profile "Repositories N" may exclude mirrors on some versions —
try Explore → search libvpx, or Site Administration → Repositories.
EOF

View File

@@ -0,0 +1,264 @@
#!/bin/ash
# Shared /etc/gitea/app.ini helpers for ash/bash (Alpine BE).
# shellcheck shell=sh
gitea_ini_file="${GITEA_INI:-/etc/gitea/app.ini}"
# ini_get FILE SECTION KEY -> prints value (last PATH wins without section; use section)
ini_get() {
_ini_file="$1"
_ini_section="$2"
_ini_key="$3"
awk -F= -v sec="[$_ini_section]" -v key="$_ini_key" '
function trim(s) {
sub(/^[ \t\r\n]+/, "", s)
sub(/[ \t\r\n]+$/, "", s)
return s
}
function unquote(s) {
s = trim(s)
if (s ~ /^".*"$/) { sub(/^"/, "", s); sub(/"$/, "", s) }
if (s ~ /^'\''.*'\''$/) { sub(/^'\''/, "", s); sub(/'\''$/, "", s) }
return s
}
/^[ \t]*#/ { next }
/^[ \t]*;/ { next }
/^\[/ {
cur = $0
gsub(/^\[|\]$/, "", cur)
cur = trim(cur)
cur = tolower(cur)
next
}
{
k = trim($1)
if (k != key) next
sec_l = tolower(sec)
gsub(/^\[|\]$/, "", sec_l)
if (sec != "" && cur != sec_l) next
v = $0
sub(/^[^=]*=/, "", v)
print unquote(v)
exit
}
' "$_ini_file"
}
gitea_load_config() {
gitea_ini_file="${GITEA_INI:-/etc/gitea/app.ini}"
if [ ! -r "$gitea_ini_file" ]; then
echo "ERROR: cannot read $gitea_ini_file (set GITEA_INI=...)" >&2
return 1
fi
GITEA_APP_INI="$gitea_ini_file"
_load_ini_keys() {
_f="$1"
GITEA_REPO_ROOT="$(ini_get "$_f" repository ROOT)"
GITEA_APP_DATA="$(ini_get "$_f" server APP_DATA_PATH)"
GITEA_ROOT_URL="$(ini_get "$_f" server ROOT_URL)"
GITEA_HTTP_PORT="$(ini_get "$_f" server HTTP_PORT)"
GITEA_DOMAIN="$(ini_get "$_f" server DOMAIN)"
GITEA_DB_TYPE="$(ini_get "$_f" database DB_TYPE)"
GITEA_DB_HOST="$(ini_get "$_f" database HOST)"
GITEA_DB_NAME="$(ini_get "$_f" database NAME)"
GITEA_DB_USER="$(ini_get "$_f" database USER)"
GITEA_DB_PASS="$(ini_get "$_f" database PASSWD)"
GITEA_DB_PATH="$(ini_get "$_f" database PATH)"
GITEA_LFS_PATH="$(ini_get "$_f" lfs PATH)"
GITEA_LOG_ROOT="$(ini_get "$_f" log ROOT_PATH)"
}
_load_ini_keys "$gitea_ini_file"
[ -n "$GITEA_APP_DATA" ] || GITEA_APP_DATA="/var/lib/gitea"
# Canonical config on Alpine is often under APP_DATA, not /etc stub.
if [ -r "$GITEA_APP_DATA/custom/conf/app.ini" ]; then
_load_ini_keys "$GITEA_APP_DATA/custom/conf/app.ini"
GITEA_APP_INI="$GITEA_APP_DATA/custom/conf/app.ini"
fi
[ -n "$GITEA_REPO_ROOT" ] || GITEA_REPO_ROOT="$GITEA_APP_DATA/data/gitea-repositories"
[ -n "$GITEA_HTTP_PORT" ] || GITEA_HTTP_PORT="3000"
[ -n "$GITEA_DB_TYPE" ] || GITEA_DB_TYPE="sqlite3"
[ -n "$GITEA_ROOT_URL" ] || GITEA_ROOT_URL="${GITEA_ROOT_URL_DEFAULT:-https://apps.f0xx.org/app/androidcast_project/git/}"
GITEA_RUN_USER="$(ini_get "$GITEA_APP_INI" "" RUN_USER)"
[ -n "$GITEA_RUN_USER" ] || GITEA_RUN_USER="gitea"
export GITEA_APP_INI GITEA_REPO_ROOT GITEA_APP_DATA GITEA_ROOT_URL GITEA_HTTP_PORT
export GITEA_DOMAIN GITEA_DB_TYPE GITEA_DB_HOST GITEA_DB_NAME GITEA_DB_USER GITEA_DB_PASS
export GITEA_DB_PATH GITEA_LFS_PATH GITEA_LOG_ROOT GITEA_RUN_USER
# External URL (ROOT_URL) includes nginx subpath; direct :3000 API/git do not.
GITEA_HTTP_SUBPATH="${GITEA_HTTP_SUBPATH:-}"
if [ -z "$GITEA_HTTP_SUBPATH" ] && [ -n "$GITEA_ROOT_URL" ]; then
_p="${GITEA_ROOT_URL#*://}"
_p="${_p#*/}"
case "$_p" in
*/*) GITEA_HTTP_SUBPATH="/${_p%/}" ;;
esac
fi
GITEA_LOCAL_API="${GITEA_LOCAL_API:-http://127.0.0.1:${GITEA_HTTP_PORT}/api/v1}"
export GITEA_HTTP_SUBPATH GITEA_LOCAL_API
}
# Alpine/BusyBox mktemp: no suffix after XXXXXX — use -p /tmp prefix.XXXXXX
gitea_mktemp_file() {
mktemp -p "${TMPDIR:-/tmp}" gitea.XXXXXX
}
gitea_mktemp_dir() {
mktemp -d -p "${TMPDIR:-/tmp}" gitea.XXXXXX
}
# Must be absolute, no .., and under allowed prefixes (Gitea-owned paths only).
gitea_path_is_safe_to_wipe() {
_target="$1"
case "$_target" in
""|/*..*|*../*) return 1 ;;
esac
case "$_target" in
/var/lib/gitea/*|/var/lib/gitea) return 0 ;;
/data/gitea/*|/data/gitea) return 0 ;;
esac
return 1
}
# Gitea admin CLI must run as RUN_USER from app.ini (Alpine package: user "gitea", not "git").
gitea_run_as_service_user() {
_run="${GITEA_RUN_USER:-gitea}"
_cur="$(gitea_current_user)"
if [ "$_cur" = "$_run" ]; then
sh -c "$*"
return $?
fi
if id "$_run" >/dev/null 2>&1; then
su -s /bin/sh "$_run" -c "$*"
return $?
fi
if id git >/dev/null 2>&1; then
su -s /bin/sh git -c "$*"
return $?
fi
echo "ERROR: cannot run gitea as '$_run' (set RUN_USER in app.ini)" >&2
return 1
}
gitea_cli() {
if command -v gitea >/dev/null 2>&1; then
gitea_run_as_service_user "gitea -c '$GITEA_APP_INI' $*"
else
echo "WARN: gitea binary not in PATH" >&2
return 1
fi
}
gitea_current_user() {
whoami 2>/dev/null || id -un 2>/dev/null || echo unknown
}
# Caller must be root (to su) or already GITEA_RUN_USER.
gitea_ensure_cli_access() {
_run="${GITEA_RUN_USER:-gitea}"
_cur="$(gitea_current_user)"
if [ "$_cur" = "$_run" ]; then
return 0
fi
if [ "$(id -u 2>/dev/null || echo 1)" -eq 0 ]; then
return 0
fi
if id "$_run" >/dev/null 2>&1 && su -s /bin/sh "$_run" -c "true" 2>/dev/null; then
return 0
fi
echo "ERROR: Gitea admin CLI must run as OS user '$_run' (RUN_USER in app.ini)." >&2
echo " You are '$_cur'. Re-run with:" >&2
echo " sudo ./gitea_attach_remote.sh git://..." >&2
echo " Or:" >&2
echo " sudo -u $_run ./gitea_attach_remote.sh git://..." >&2
return 1
}
gitea_db_list_usernames() {
case "$(echo "$GITEA_DB_TYPE" | tr '[:upper:]' '[:lower:]')" in
mysql)
command -v mysql >/dev/null 2>&1 || return 1
_sock="${GITEA_DB_HOST:-/run/mysqld/mysqld.sock}"
case "$_sock" in
/*) mysql -u"$GITEA_DB_USER" ${GITEA_DB_PASS:+-p"$GITEA_DB_PASS"} -S "$_sock" "$GITEA_DB_NAME" \
-N -e "SELECT name FROM user ORDER BY id" 2>/dev/null ;;
*) mysql -u"$GITEA_DB_USER" ${GITEA_DB_PASS:+-p"$GITEA_DB_PASS"} -h "$_sock" "$GITEA_DB_NAME" \
-N -e "SELECT name FROM user ORDER BY id" 2>/dev/null ;;
esac
;;
sqlite3|sqlite)
command -v sqlite3 >/dev/null 2>&1 || return 1
_db="${GITEA_DB_PATH:-}"
[ -n "$_db" ] || _db="$GITEA_APP_DATA/gitea.db"
[ -r "$_db" ] || _db="/var/lib/gitea/db/gitea.db"
[ -r "$_db" ] || return 1
sqlite3 "$_db" "SELECT name FROM user ORDER BY id" 2>/dev/null
;;
*)
return 1
;;
esac
}
gitea_db_pick_admin_username() {
case "$(echo "$GITEA_DB_TYPE" | tr '[:upper:]' '[:lower:]')" in
mysql)
command -v mysql >/dev/null 2>&1 || return 1
_sock="${GITEA_DB_HOST:-/run/mysqld/mysqld.sock}"
case "$_sock" in
/*) mysql -u"$GITEA_DB_USER" ${GITEA_DB_PASS:+-p"$GITEA_DB_PASS"} -S "$_sock" "$GITEA_DB_NAME" \
-N -e "SELECT name FROM user WHERE is_admin=1 ORDER BY id LIMIT 1" 2>/dev/null ;;
*) mysql -u"$GITEA_DB_USER" ${GITEA_DB_PASS:+-p"$GITEA_DB_PASS"} -h "$_sock" "$GITEA_DB_NAME" \
-N -e "SELECT name FROM user WHERE is_admin=1 ORDER BY id LIMIT 1" 2>/dev/null ;;
esac
;;
sqlite3|sqlite)
command -v sqlite3 >/dev/null 2>&1 || return 1
_db="${GITEA_DB_PATH:-$GITEA_APP_DATA/gitea.db}"
[ -r "$_db" ] || _db="/var/lib/gitea/db/gitea.db"
[ -r "$_db" ] || return 1
sqlite3 "$_db" "SELECT name FROM user WHERE is_admin=1 ORDER BY id LIMIT 1" 2>/dev/null
;;
*)
return 1
;;
esac
}
# One username per line (CLI as RUN_USER, else DB).
gitea_list_usernames() {
_cli=""
_cli="$(gitea_cli "admin" "user" "list" 2>/dev/null)" || true
if [ -n "$_cli" ]; then
echo "$_cli" | awk 'NR > 1 && NF >= 2 { print $2 }'
return 0
fi
gitea_db_list_usernames
}
gitea_user_exists() {
_u="$1"
gitea_list_usernames | grep -Fx "$_u" >/dev/null 2>&1
}
# First site-admin account, else first user.
gitea_pick_owner_username() {
_pick=""
_pick="$(gitea_cli "admin" "user" "list" 2>/dev/null \
| awk 'NR > 1 && NF >= 2 && ($5 == "true" || $5 == "1") { print $2; exit }')"
if [ -n "$_pick" ]; then
printf '%s' "$_pick"
return 0
fi
_pick="$(gitea_db_pick_admin_username)"
if [ -n "$_pick" ]; then
printf '%s' "$_pick"
return 0
fi
gitea_list_usernames | head -n 1
}

View File

@@ -0,0 +1,41 @@
#!/bin/ash
#
# Prepare AndroidCast Gitea org: team owners (admin + foxx), transfer legacy admin/* repos.
# Safe to re-run (idempotent). Run before or with gitea_migrate_project.sh.
#
# Usage (Alpine BE as root):
# sudo ./gitea_migrate_org_setup.sh
# GITEA_ORG=AndroidCast GITEA_ORG_OWNERS="admin foxx" sudo ./gitea_migrate_org_setup.sh
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
# shellcheck source=gitea_org_lib.sh
. "$SCRIPT_DIR/gitea_org_lib.sh"
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
gitea_org_log "org=$GITEA_ORG owners=$GITEA_ORG_OWNERS transfer_from=$GITEA_TRANSFER_FROM"
gitea_org_log "public URL: ${GITEA_ROOT_URL%/}/$GITEA_ORG"
gitea_org_ensure_androidcast "$GITEA_ORG" || exit 1
gitea_org_log "repos under $GITEA_ORG:"
gitea_list_repos_api "$GITEA_ORG" 2>/dev/null | while read -r _r; do
[ -n "$_r" ] && gitea_org_log " $GITEA_ORG/$_r"
done
cat <<EOF
Org setup done.
• Owners team (full org/repo control): $GITEA_ORG_OWNERS
• Set password or SSH key for foxx: Gitea UI → foxx → Settings
• Mirror all project repos under org:
GITEA_OWNER=$GITEA_ORG sudo ./gitea_migrate_project.sh /path/to/android_cast
EOF

View File

@@ -0,0 +1,321 @@
#!/bin/ash
#
# One-shot Gitea migration for android_cast:
# 1) Pull-mirror main repo from FE git (git://10.7.0.10/android_cast) — fast interval
# 2) Discover external git submodules (.gitmodules + pinned SHA at REF)
# 3) Pull-mirror each upstream as read-only repo; verify project commit exists
#
# Canonical .gitmodules stays unchanged (upstream URLs). Use gitea_submodule_local_rewrite.sh
# locally if clones should fetch submodules via Gitea.
#
# Usage (Alpine BE, as root):
# sudo ./gitea_migrate_project.sh
# sudo ./gitea_migrate_project.sh /var/www/.../androidcast_project/android_cast
# GITEA_MIRROR_URL=git://10.7.0.10/android_cast GITEA_MAIN_MIRROR_INTERVAL=1m sudo ./gitea_migrate_project.sh
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
# shellcheck source=gitea_submodule_lib.sh
. "$SCRIPT_DIR/gitea_submodule_lib.sh"
# shellcheck source=gitea_org_lib.sh
. "$SCRIPT_DIR/gitea_org_lib.sh"
GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-AndroidCast}}"
GITEA_REPO="${GITEA_REPO:-android_cast}"
GITEA_MIRROR_URL="${GITEA_MIRROR_URL:-git://10.7.0.10/android_cast}"
GITEA_MAIN_MIRROR_INTERVAL="${GITEA_MAIN_MIRROR_INTERVAL:-1m}"
GITEA_SUBMODULE_MIRROR_INTERVAL="${GITEA_SUBMODULE_MIRROR_INTERVAL:-24h}"
GITEA_PRIVATE="${GITEA_PRIVATE:-false}"
GITEA_REF="${GITEA_REF:-HEAD}"
GITEA_SKIP_MAIN="${GITEA_SKIP_MAIN:-0}"
GITEA_SKIP_SUBMODULES="${GITEA_SKIP_SUBMODULES:-0}"
GITEA_SKIP_ORG_SETUP="${GITEA_SKIP_ORG_SETUP:-0}"
GITEA_SUBMODULES_CONF="${GITEA_SUBMODULES_CONF:-$SCRIPT_DIR/submodules.defaults.conf}"
WORK_DIR="${WORK_DIR:-}"
log() { printf '[gitea-migrate] %s\n' "$*"; }
warn() { printf '[gitea-migrate] WARN: %s\n' "$*" >&2; }
die() { printf '[gitea-migrate] ERROR: %s\n' "$*" >&2; exit 1; }
# Sets GITEA_REPO_DIR and GITEA_GITMODULES_FILE.
resolve_repo_inputs() {
_arg="${1:-}"
GITEA_REPO_DIR=""
GITEA_GITMODULES_FILE=""
if [ -n "$_arg" ]; then
case "$_arg" in
*.gitmodules)
[ -r "$_arg" ] || die "cannot read $_arg"
GITEA_GITMODULES_FILE="$(cd "$(dirname "$_arg")" && pwd)/$(basename "$_arg")"
_parent="$(dirname "$GITEA_GITMODULES_FILE")"
if [ -d "$_parent/.git" ] || [ -f "$_parent/.git" ]; then
GITEA_REPO_DIR="$(cd "$_parent" && pwd)"
else
GITEA_REPO_DIR="$_parent"
fi
return 0
;;
*)
[ -d "$_arg" ] || die "not a directory: $_arg"
GITEA_REPO_DIR="$(cd "$_arg" && pwd)"
GITEA_GITMODULES_FILE="$GITEA_REPO_DIR/.gitmodules"
return 0
;;
esac
fi
if [ -d "./.git" ] || [ -f "./.git" ]; then
GITEA_REPO_DIR="$(pwd)"
GITEA_GITMODULES_FILE="$GITEA_REPO_DIR/.gitmodules"
return 0
fi
die "pass android_cast checkout path or .gitmodules (needs .gitmodules)"
}
migrate_main_repo() {
log "main mirror: $GITEA_OWNER/$GITEA_REPO <- $GITEA_MIRROR_URL (interval $GITEA_MAIN_MIRROR_INTERVAL)"
if git ls-remote "$GITEA_MIRROR_URL" HEAD >/dev/null 2>&1; then
log " upstream reachable"
else
warn " upstream not reachable from BE — check FE git daemon / [migrations] ALLOW_LOCALNETWORKS"
fi
if gitea_mirror_pull_repo "$GITEA_MIRROR_URL" "$GITEA_REPO" "$GITEA_OWNER" \
"$GITEA_MAIN_MIRROR_INTERVAL" "$WORK_DIR"; then
gitea_mirror_set_interval_api "$GITEA_OWNER" "$GITEA_REPO" "$GITEA_MAIN_MIRROR_INTERVAL" \
2>/dev/null || true
gitea_sync_mirror_api "$GITEA_OWNER" "$GITEA_REPO" || true
log " OK -> $(gitea_public_repo_url_for "$GITEA_OWNER" "$GITEA_REPO")"
return 0
fi
warn " main mirror FAILED"
return 1
}
migrate_submodule() {
_line="$1"
_repo="${_line%%|*}"
_rest="${_line#*|}"
_upstream="${_rest%%|*}"
_rest2="${_rest#*|}"
_path="${_rest2%%|*}"
_rest3="${_rest2#*|}"
_sha="${_rest3%%|*}"
_branch="${_rest3#*|}"
case "$_branch" in
"$_sha") _branch="" ;;
esac
log "submodule $_path ($GITEA_OWNER/$_repo)"
log " upstream: $_upstream"
if [ "$_sha" = "-" ]; then
log " pinned @ $GITEA_REF: (unknown — no gitlink in checkout; mirroring upstream anyway)"
else
log " pinned @ $GITEA_REF: $_sha"
if git ls-remote "$_upstream" "$_sha" >/dev/null 2>&1; then
log " commit on upstream"
else
warn " commit $_sha not on upstream (mirror may still fetch via branch tip)"
fi
fi
if ! gitea_mirror_pull_repo "$_upstream" "$_repo" "$GITEA_OWNER" \
"$GITEA_SUBMODULE_MIRROR_INTERVAL" "$WORK_DIR"; then
warn " mirror create/sync FAILED for $_repo"
return 1
fi
gitea_mirror_set_interval_api "$GITEA_OWNER" "$_repo" "$GITEA_SUBMODULE_MIRROR_INTERVAL" \
2>/dev/null || true
gitea_sync_mirror_api "$GITEA_OWNER" "$_repo" || true
if [ "$_sha" = "-" ]; then
log " OK mirror -> $(gitea_public_repo_url_for "$GITEA_OWNER" "$_repo")"
return 0
fi
if gitea_mirror_has_commit "$GITEA_OWNER" "$_repo" "$_sha"; then
log " OK pinned commit present -> $(gitea_public_repo_url_for "$GITEA_OWNER" "$_repo")"
return 0
fi
warn " mirror ok but pinned $_sha not visible yet — retry: $SCRIPT_DIR/gitea_sync_mirror.sh $_repo"
return 2
}
print_gitea_repo_inventory() {
log "Gitea repos for $GITEA_OWNER (API):"
_names="$(gitea_list_repos_api "$GITEA_OWNER" 2>/dev/null)" || _names=""
if [ -z "$_names" ]; then
warn " could not list repos via API (token/CLI)"
return 1
fi
_n=0
echo "$_names" | while read -r _r; do
[ -n "$_r" ] || continue
log " - $GITEA_OWNER/$_r"
_n=$((_n + 1))
done
_count="$(echo "$_names" | grep -c . 2>/dev/null || echo 0)"
log "total listed: $_count (expect android_cast + libvpx + opus + speex under $GITEA_ORG)"
}
print_fast_sync_guide() {
_sync_sh="$SCRIPT_DIR/gitea_sync_mirror.sh"
_hook="$SCRIPT_DIR/fe-android_cast-post-receive.example"
cat <<EOF
Fast sync for local main mirror ($GITEA_REPO only)
-------------------------------------------------
Gitea default minimum mirror interval is 10m. For 1m polling set in live app.ini:
[mirror]
MIN_INTERVAL = 1m
[cron.update_mirrors]
SCHEDULE = @every 1m
RUN_AT_START = true
sudo rc-service gitea restart
Per-repo interval for $GITEA_REPO is already set to $GITEA_MAIN_MIRROR_INTERVAL by this script.
Immediate sync (push-triggered or cron every minute on BE):
sudo $_sync_sh $GITEA_REPO
Sync all mirrors:
sudo $_sync_sh --all
FE git post-receive hook (git on 10.7.0.10 → trigger BE sync):
See $_hook
Copy to FE bare repo hooks/post-receive and set GITEA_MIRROR_SYNC_CMD (ssh or curl).
Submodule mirrors use interval $GITEA_SUBMODULE_MIRROR_INTERVAL; pinned SHAs live in
android_cast gitlinks — mirrors hold full upstream history so those commits are fetchable.
Local clone rewrite (optional, .gitmodules unchanged):
$SCRIPT_DIR/gitea_submodule_local_rewrite.sh --apply
EOF
}
# --- main ---
_repo_arg=""
while [ $# -gt 0 ]; do
case "$1" in
-h|--help)
sed -n '2,18p' "$0"
exit 0
;;
--skip-main) GITEA_SKIP_MAIN=1; shift ;;
--skip-submodules) GITEA_SKIP_SUBMODULES=1; shift ;;
--skip-org) GITEA_SKIP_ORG_SETUP=1; shift ;;
--org-setup-only)
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
gitea_org_ensure_androidcast "$GITEA_ORG" || exit 1
exit 0
;;
--ref)
shift
GITEA_REF="${1:-HEAD}"
shift
;;
-*)
die "unknown option: $1"
;;
*)
_repo_arg="$1"
shift
;;
esac
done
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
if [ "$GITEA_SKIP_ORG_SETUP" -eq 0 ]; then
log "org setup: $GITEA_ORG (owners: $GITEA_ORG_OWNERS)"
gitea_org_ensure_androidcast "$GITEA_ORG" || warn "org setup incomplete — continue with owner=$GITEA_OWNER"
GITEA_OWNER="${GITEA_OWNER:-$GITEA_ORG}"
fi
if ! gitea_owner_exists "$GITEA_OWNER"; then
_fb="$(gitea_pick_owner_username)"
[ -n "$_fb" ] || die "no Gitea owner; set GITEA_OWNER=$GITEA_ORG or create org in UI"
warn "owner $GITEA_OWNER not found; using $_fb"
GITEA_OWNER="$_fb"
fi
if command -v rc-service >/dev/null 2>&1; then
rc-service gitea status 2>/dev/null | grep -qi started || rc-service gitea start 2>/dev/null || true
fi
gitea_prune_stale_tmp
if [ -n "$WORK_DIR" ]; then
mkdir -p "$WORK_DIR"
else
WORK_DIR="$(gitea_make_work_dir gitea-migrate)"
fi
trap 'gitea_rm_path "$WORK_DIR"' EXIT INT HUP TERM
log "ini=$GITEA_APP_INI owner=$GITEA_OWNER ref=$GITEA_REF"
_main_ok=0
_main_fail=0
_sub_ok=0
_sub_warn=0
_sub_fail=0
if [ "$GITEA_SKIP_MAIN" -eq 0 ]; then
if migrate_main_repo; then
_main_ok=1
else
_main_fail=1
fi
else
log "skipping main mirror (--skip-main)"
fi
if [ "$GITEA_SKIP_SUBMODULES" -eq 0 ]; then
resolve_repo_inputs "$_repo_arg"
log "submodule inputs: repo=$GITEA_REPO_DIR gitmodules=$GITEA_GITMODULES_FILE ref=$GITEA_REF"
_sub_list="$WORK_DIR/submodules.list"
gitea_submodule_collect_entries "$GITEA_REPO_DIR" "$GITEA_REF" \
"$GITEA_GITMODULES_FILE" "$GITEA_SUBMODULES_CONF" > "$_sub_list" || true
if [ ! -s "$_sub_list" ]; then
die "no external submodules found — check .gitmodules on BE or $GITEA_SUBMODULES_CONF"
fi
log "submodules to mirror:"
while IFS= read -r _preview; do
[ -n "$_preview" ] || continue
log " $_preview"
done < "$_sub_list"
while IFS= read -r _line; do
[ -n "$_line" ] || continue
gitea_submodule_entry_valid "$_line" || continue
_rc=0
migrate_submodule "$_line" || _rc=$?
case "$_rc" in
0) _sub_ok=$((_sub_ok + 1)) ;;
2) _sub_warn=$((_sub_warn + 1)) ;;
*) _sub_fail=$((_sub_fail + 1)) ;;
esac
done < "$_sub_list"
else
log "skipping submodule mirrors (--skip-submodules)"
fi
log "summary: main ok=$_main_ok fail=$_main_fail | submodules ok=$_sub_ok warn=$_sub_warn fail=$_sub_fail"
print_gitea_repo_inventory
print_fast_sync_guide
_sub_migrated=$((_sub_ok + _sub_warn))
[ "$_main_fail" -eq 0 ] && [ "$_sub_fail" -eq 0 ] \
&& { [ "$GITEA_SKIP_SUBMODULES" -eq 1 ] || [ "$_sub_migrated" -gt 0 ]; }

View File

@@ -0,0 +1,377 @@
#!/bin/ash
# Shared pull-mirror helpers for Gitea BE scripts.
# shellcheck shell=sh
# Requires: . "$SCRIPT_DIR/gitea_ini.sh" && gitea_load_config
gitea_public_repo_url_for() {
_owner="$1"
_repo="$2"
_root="$GITEA_ROOT_URL"
case "$_root" in
*/) _root="${_root%/}" ;;
esac
printf '%s/%s/%s.git' "$_root" "$_owner" "$_repo"
}
gitea_local_git_url_for() {
printf 'http://127.0.0.1:%s/%s/%s.git' "${GITEA_HTTP_PORT:-3000}" "$1" "$2"
}
gitea_api_base() {
printf '%s' "${GITEA_LOCAL_API:-http://127.0.0.1:3000/api/v1}"
}
# Remove abandoned gitea-* temp dirs/files (e.g. after kill -9). Default: older than 60 min.
gitea_prune_stale_tmp() {
_min="${GITEA_TMP_MAX_AGE_MIN:-60}"
command -v find >/dev/null 2>&1 || return 0
find /tmp -maxdepth 1 -type d \
\( -name 'gitea-migrate-*' -o -name 'gitea-submodules-*' \
-o -name 'gitea-attach-*' -o -name 'gitea-mirror-*' \) \
-mmin "+$_min" -exec rm -rf {} + 2>/dev/null || true
find /tmp -maxdepth 1 -type f -name 'gitea-*.json' \
-mmin "+$_min" -delete 2>/dev/null || true
}
gitea_make_work_dir() {
_prefix="${1:-gitea-work}"
mktemp -d -p "${TMPDIR:-/tmp}" "${_prefix}.XXXXXX"
}
gitea_rm_path() {
rm -rf "$1" 2>/dev/null || true
}
# First numeric "id" from Gitea JSON (handles "id":2 and "id":"2").
gitea_json_extract_id() {
printf '%s' "$1" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*'
}
gitea_resolve_push_token() {
if [ -n "${GITEA_ADMIN_TOKEN:-}" ]; then
printf '%s' "$GITEA_ADMIN_TOKEN"
return 0
fi
# One PAT per script run (re-generating with the same --token-name fails).
if [ -n "${_GITEA_RESOLVED_TOKEN:-}" ]; then
printf '%s' "$_GITEA_RESOLVED_TOKEN"
return 0
fi
_admin="$(gitea_pick_owner_username)"
[ -n "$_admin" ] || return 1
_token=""
_token="$(gitea_cli "admin" "user" "generate-access-token" \
"--username" "$_admin" \
"--token-name" "mirror-$$-$(date +%s)-${RANDOM:-0}" \
"--scopes" "all" \
"--raw" 2>/dev/null)" && [ -n "$_token" ] || return 1
_GITEA_RESOLVED_TOKEN="$_token"
export _GITEA_RESOLVED_TOKEN GITEA_ADMIN_TOKEN="$_token"
printf '%s' "$_token"
}
gitea_owner_uid_for() {
gitea_entity_uid_for "$1"
}
# UID for migrate/API owner — user or organization (not the same as /users/{org}).
gitea_entity_uid_for() {
_owner="$1"
command -v curl >/dev/null 2>&1 || return 1
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1
_api="$(gitea_api_base)"
_json=""
if curl -sS -o /dev/null -w '%{http_code}' "$_api/orgs/$_owner" \
-H "Authorization: token $_token" | grep -q '^200'; then
_json="$(curl -sS "$_api/orgs/$_owner" -H "Authorization: token $_token")"
else
_json="$(curl -sS "$_api/users/$_owner" -H "Authorization: token $_token")"
fi
_uid="$(gitea_json_extract_id "$_json")"
[ -n "$_uid" ] || return 1
printf '%s' "$_uid"
}
gitea_owner_is_org() {
gitea_org_exists "$1" 2>/dev/null
}
gitea_create_repo_cli() {
_owner="$1"
_repo="$2"
_private="${3:-false}"
command -v curl >/dev/null 2>&1 || return 1
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1
_api="$(gitea_api_base)"
_resp="$(gitea_mktemp_file)"
if gitea_owner_is_org "$_owner"; then
_http="$(curl -sS -o "$_resp" -w '%{http_code}' \
-X POST "$_api/orgs/$_owner/repos" \
-H "Authorization: token $_token" \
-H "Content-Type: application/json" \
-d "{\"name\":\"$_repo\",\"private\":$_private}")"
else
_http="$(curl -sS -o "$_resp" -w '%{http_code}' \
-X POST "$_api/admin/users/$_owner/repos" \
-H "Authorization: token $_token" \
-H "Content-Type: application/json" \
-d "{\"name\":\"$_repo\",\"private\":$_private}")"
fi
_rc=1
case "$_http" in
200|201) _rc=0 ;;
409) _rc=0 ;;
esac
gitea_rm_path "$_resp"
return "$_rc"
}
gitea_migrate_mirror_api() {
_owner="$1"
_repo="$2"
_clone_url="$3"
_interval="${4:-10m}"
_private="${5:-false}"
_desc="${6:-Pull mirror of $_clone_url}"
command -v curl >/dev/null 2>&1 || return 1
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1
_uid="$(gitea_entity_uid_for "$_owner")" || return 1
_api="$(gitea_api_base)"
_payload=$(cat <<EOF
{
"clone_addr": "$_clone_url",
"repo_name": "$_repo",
"uid": $_uid,
"mirror": true,
"mirror_interval": "$_interval",
"private": $_private,
"wiki": false,
"issues": false,
"milestones": false,
"labels": false,
"releases": true,
"description": "$_desc"
}
EOF
)
_resp="$(gitea_mktemp_file)"
_http="$(curl -sS -o "$_resp" -w '%{http_code}' \
-X POST "$_api/repos/migrate" \
-H "Authorization: token $_token" \
-H "Content-Type: application/json" \
-d "$_payload")"
if [ "$_http" = "201" ] || [ "$_http" = "200" ]; then
gitea_rm_path "$_resp"
return 0
fi
if [ "$_http" = "409" ] || [ "$_http" = "422" ]; then
gitea_rm_path "$_resp"
return 2
fi
if [ -r "$_resp" ]; then
printf '[gitea-mirror] migrate HTTP %s: ' "$_http" >&2
head -c 400 "$_resp" >&2 || true
printf '\n' >&2
fi
gitea_rm_path "$_resp"
return 1
}
gitea_mirror_push_git() {
_owner="$1"
_repo="$2"
_clone_url="$3"
_work_dir="$4"
command -v git >/dev/null 2>&1 || return 1
_mirror="$_work_dir/${_repo}.mirror.git"
_rc=1
gitea_rm_path "$_mirror"
if ! git clone --mirror "$_clone_url" "$_mirror"; then
gitea_rm_path "$_mirror"
return 1
fi
gitea_create_repo_cli "$_owner" "$_repo" "${GITEA_PRIVATE:-false}" || true
_push_base="$(gitea_local_git_url_for "$_owner" "$_repo")"
if _token="$(gitea_resolve_push_token)"; then
_auth_url="${_push_base#http://}"
if git -C "$_mirror" push --mirror "http://${_token}@${_auth_url}"; then
_rc=0
fi
fi
gitea_rm_path "$_mirror"
return "$_rc"
}
gitea_repo_exists_api() {
_owner="$1"
_repo="$2"
command -v curl >/dev/null 2>&1 || return 1
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1
_api="$(gitea_api_base)"
_http="$(curl -sS -o /dev/null -w '%{http_code}' \
"$_api/repos/$_owner/$_repo" \
-H "Authorization: token $_token")"
[ "$_http" = "200" ]
}
gitea_list_repos_api() {
_owner="$1"
command -v curl >/dev/null 2>&1 || return 1
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1
_api="$(gitea_api_base)"
_list_url="$_api/users/$_owner/repos?limit=50"
_http="$(curl -sS -o /dev/null -w '%{http_code}' "$_api/orgs/$_owner" \
-H "Authorization: token $_token")"
if [ "$_http" = "200" ]; then
_list_url="$_api/orgs/$_owner/repos?limit=50"
fi
curl -sS "$_list_url" \
-H "Authorization: token $_token" \
| awk -F'"' '
/"name":/ && !/"owner":/ && !/"full_name":/ {
for (i = 1; i <= NF; i++) {
if ($i == "\"name\":" && $(i+1) ~ /^"/) {
gsub(/^"/, "", $(i+1)); gsub(/",?$/, "", $(i+1))
if ($(i+1) != "" && $(i+1) != "null") print $(i+1)
break
}
}
}
'
}
gitea_repo_get_api() {
_owner="$1"
_repo="$2"
command -v curl >/dev/null 2>&1 || return 1
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1
_api="$(gitea_api_base)"
curl -sS "$_api/repos/$_owner/$_repo" \
-H "Authorization: token $_token"
}
gitea_repo_set_default_branch_api() {
_owner="$1"
_repo="$2"
_branch="$3"
command -v curl >/dev/null 2>&1 || return 1
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1
_api="$(gitea_api_base)"
_payload="{\"default_branch\":\"$_branch\"}"
_http="$(curl -sS -o /dev/null -w '%{http_code}' \
-X PATCH "$_api/repos/$_owner/$_repo" \
-H "Authorization: token $_token" \
-H "Content-Type: application/json" \
-d "$_payload")"
case "$_http" in
200|204) return 0 ;;
*) return 1 ;;
esac
}
gitea_sync_mirror_api() {
_owner="$1"
_repo="$2"
command -v curl >/dev/null 2>&1 || return 1
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1
_api="$(gitea_api_base)"
curl -sS -o /dev/null -w '%{http_code}' \
-X POST "$_api/repos/$_owner/$_repo/mirror-sync" \
-H "Authorization: token $_token" | grep -q '^20'
}
gitea_mirror_set_interval_api() {
_owner="$1"
_repo="$2"
_interval="$3"
command -v curl >/dev/null 2>&1 || return 1
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1
_api="$(gitea_api_base)"
_payload="{\"mirror_interval\":\"$_interval\"}"
_resp="$(gitea_mktemp_file)"
_http="$(curl -sS -o "$_resp" -w '%{http_code}' \
-X PATCH "$_api/repos/$_owner/$_repo" \
-H "Authorization: token $_token" \
-H "Content-Type: application/json" \
-d "$_payload")"
gitea_rm_path "$_resp"
case "$_http" in
200|204) return 0 ;;
*) return 1 ;;
esac
}
# True if branch exists on any git remote URL (canonical FE git, upstream, etc.).
gitea_git_remote_has_branch() {
_url="$1"
_branch="$2"
command -v git >/dev/null 2>&1 || return 1
git ls-remote "$_url" "refs/heads/$_branch" 2>/dev/null | grep -q .
}
# True if commit SHA is reachable on the mirror (after sync).
gitea_mirror_has_commit() {
_owner="$1"
_repo="$2"
_sha="$3"
command -v git >/dev/null 2>&1 || return 1
_url="$(gitea_local_git_url_for "$_owner" "$_repo")"
git ls-remote "$_url" "$_sha" 2>/dev/null | grep -q "$_sha"
}
# Mirror one upstream into Gitea (API migrate, else git push). Returns 0 on success.
gitea_mirror_pull_repo() {
_clone_url="$1"
_repo="$2"
_owner="${3:-$GITEA_OWNER}"
_interval="${4:-${GITEA_MIRROR_INTERVAL:-10m}}"
_local_work=""
if [ $# -ge 5 ] && [ -n "$5" ]; then
_work_dir="$5"
else
_local_work="$(gitea_make_work_dir gitea-mirror)"
_work_dir="$_local_work"
fi
_rc=0
gitea_migrate_mirror_api "$_owner" "$_repo" "$_clone_url" "$_interval" "${GITEA_PRIVATE:-false}" \
&& return 0
_api_rc=$?
if [ "$_api_rc" -eq 2 ]; then
if gitea_repo_exists_api "$_owner" "$_repo"; then
gitea_mirror_set_interval_api "$_owner" "$_repo" "$_interval" 2>/dev/null || true
gitea_sync_mirror_api "$_owner" "$_repo" && return 0
return 0
fi
printf '[gitea-mirror] migrate rejected for %s/%s but repo missing — trying git push\n' \
"$_owner" "$_repo" >&2
fi
if gitea_owner_is_org "$_owner"; then
printf '[gitea-mirror] API migrate failed for org %s/%s — git push fallback disabled (orgs cannot push-to-create)\n' \
"$_owner" "$_repo" >&2
printf '[gitea-mirror] Set GITEA_ADMIN_TOKEN or run as root; check [migrations] in app.ini\n' >&2
[ -n "$_local_work" ] && gitea_rm_path "$_local_work"
return 1
fi
mkdir -p "$_work_dir"
gitea_mirror_push_git "$_owner" "$_repo" "$_clone_url" "$_work_dir" || _rc=1
gitea_rm_path "$_work_dir/${_repo}.mirror.git"
[ -n "$_local_work" ] && gitea_rm_path "$_local_work"
return "$_rc"
}

View File

@@ -0,0 +1,246 @@
#!/bin/ash
#
# Mirror android_cast git submodules into Gitea as pull mirrors, then print a
# .gitmodules snippet pointing at Gitea URLs (commit that on canonical git).
#
# Usage (on Alpine BE as root or GITEA RUN_USER):
# sudo ./gitea_mirror_submodules.sh
# sudo ./gitea_mirror_submodules.sh /path/to/android_cast/.gitmodules
# GITEA_SUBMODULES_CONF=./extra.list sudo ./gitea_mirror_submodules.sh
#
# Requires [migrations] allowlist in app.ini for upstream hosts, e.g.:
# ALLOW_LOCALNETWORKS = true
# ALLOWED_DOMAINS = *,github.com,*.github.com,chromium.googlesource.com,git.zx2c4.com
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
# shellcheck source=gitea_org_lib.sh
. "$SCRIPT_DIR/gitea_org_lib.sh"
GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-AndroidCast}}"
GITEA_MIRROR_INTERVAL="${GITEA_MIRROR_INTERVAL:-24h}"
GITEA_PRIVATE="${GITEA_PRIVATE:-false}"
GITEA_SUBMODULES_CONF="${GITEA_SUBMODULES_CONF:-$SCRIPT_DIR/submodules.defaults.conf}"
WORK_DIR="${WORK_DIR:-}"
SNIPPET_OUT="${SNIPPET_OUT:-$WORK_DIR/gitmodules.gitea-snippet.ini}"
log() { printf '[gitea-submodules] %s\n' "$*"; }
warn() { printf '[gitea-submodules] WARN: %s\n' "$*" >&2; }
die() { printf '[gitea-submodules] ERROR: %s\n' "$*" >&2; exit 1; }
# Parse .gitmodules -> repo|url|path|branch lines (repo = basename of path).
parse_gitmodules_file() {
_file="$1"
[ -r "$_file" ] || return 1
awk '
function trim(s) {
sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s)
return s
}
/^[ \t]*path[ \t]*=/ {
p = $0; sub(/^[^=]*=/, "", p); path = trim(p)
n = path; sub(".*/", "", n)
next
}
/^[ \t]*url[ \t]*=/ {
u = $0; sub(/^[^=]*=/, "", u); url = trim(u)
next
}
/^[ \t]*branch[ \t]*=/ {
b = $0; sub(/^[^=]*=/, "", b); branch = trim(b)
next
}
/^\[/ {
if (path != "" && url != "" && n != "") {
if (branch != "") print n "|" url "|" path "|" branch
else print n "|" url "|" path
}
path = ""; url = ""; branch = ""; n = ""
}
END {
if (path != "" && url != "" && n != "") {
if (branch != "") print n "|" url "|" path "|" branch
else print n "|" url "|" path
}
}
' "$_file"
}
# repo|upstream|path[|branch] — stdout only; status lines go to stderr.
is_valid_submodule_entry() {
case "$1" in
*'|'*) ;;
*) return 1 ;;
esac
_repo="${1%%|*}"
_rest="${1#*|}"
_url="${_rest%%|*}"
_rest2="${_rest#*|}"
_path="${_rest2%%|*}"
[ -n "$_repo" ] && [ -n "$_url" ] && [ -n "$_path" ] || return 1
case "$_repo" in
*' '*|*'['*|*']'*) return 1 ;;
esac
return 0
}
load_submodule_entries() {
_gitmodules="${1:-}"
if [ -n "$_gitmodules" ] && [ -r "$_gitmodules" ]; then
log "reading submodules from $_gitmodules" >&2
parse_gitmodules_file "$_gitmodules"
return 0
fi
if [ -r "$GITEA_SUBMODULES_CONF" ]; then
log "reading submodules from $GITEA_SUBMODULES_CONF" >&2
grep -v '^[[:space:]]*#' "$GITEA_SUBMODULES_CONF" | grep -v '^[[:space:]]*$'
return 0
fi
return 1
}
write_gitmodules_snippet() {
_out="$1"
mkdir -p "$(dirname "$_out")"
: > "$_out"
while read -r line; do
[ -n "$line" ] || continue
_repo="${line%%|*}"
_rest="${line#*|}"
_url="${_rest%%|*}"
_rest2="${_rest#*|}"
_path="${_rest2%%|*}"
_branch="${_rest2#*|}"
case "$_branch" in
"$_path") _branch="" ;;
esac
_gitea_url="$(gitea_public_repo_url_for "$GITEA_OWNER" "$_repo")"
{
printf '[submodule "%s"]\n' "$_path"
printf '\tpath = %s\n' "$_path"
printf '\turl = %s\n' "$_gitea_url"
[ -n "$_branch" ] && printf '\tbranch = %s\n' "$_branch"
printf '\n'
} >> "$_out"
done <<EOF
$(cat "$WORK_DIR/entries.list")
EOF
}
# --- main ---
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
_gitmodules_arg=""
if [ $# -ge 1 ] && [ -n "$1" ]; then
case "$1" in
-h|--help)
sed -n '2,18p' "$0"
exit 0
;;
*)
_gitmodules_arg="$1"
;;
esac
fi
gitea_org_ensure_androidcast "$GITEA_ORG" 2>/dev/null || true
GITEA_OWNER="${GITEA_OWNER:-$GITEA_ORG}"
if ! gitea_owner_exists "$GITEA_OWNER"; then
_fb="$(gitea_pick_owner_username)"
[ -n "$_fb" ] || die "no Gitea owner; set GITEA_OWNER=$GITEA_ORG"
warn "GITEA_OWNER=$GITEA_OWNER not found; using $_fb"
GITEA_OWNER="$_fb"
fi
gitea_prune_stale_tmp
if [ -n "$WORK_DIR" ]; then
mkdir -p "$WORK_DIR"
else
WORK_DIR="$(gitea_make_work_dir gitea-submodules)"
fi
trap 'gitea_rm_path "$WORK_DIR"' EXIT INT HUP TERM
: > "$WORK_DIR/entries.list"
if ! load_submodule_entries "$_gitmodules_arg" | while IFS= read -r _line; do
[ -n "$_line" ] || continue
is_valid_submodule_entry "$_line" || {
warn "skipping invalid entry: $_line"
continue
}
printf '%s\n' "$_line"
done > "$WORK_DIR/entries.list"; then
die "no submodule list (pass .gitmodules path or use $GITEA_SUBMODULES_CONF)"
fi
[ -s "$WORK_DIR/entries.list" ] || die "no valid submodule entries parsed"
if command -v rc-service >/dev/null 2>&1; then
rc-service gitea status 2>/dev/null | grep -qi started || rc-service gitea start 2>/dev/null || true
fi
log "owner=$GITEA_OWNER interval=$GITEA_MIRROR_INTERVAL ini=$GITEA_APP_INI"
_ok=0
_fail=0
while IFS= read -r line; do
[ -n "$line" ] || continue
_repo="${line%%|*}"
_rest="${line#*|}"
_upstream="${_rest%%|*}"
_path="${_rest#*|}"
_path="${_path%%|*}"
log "mirror $_repo <- $_upstream"
if git ls-remote "$_upstream" HEAD >/dev/null 2>&1; then
log " upstream reachable"
else
warn " upstream not reachable from BE (mirror may still fail)"
fi
if gitea_mirror_pull_repo "$_upstream" "$_repo" "$GITEA_OWNER" "$GITEA_MIRROR_INTERVAL" "$WORK_DIR"; then
log " OK -> $(gitea_public_repo_url_for "$GITEA_OWNER" "$_repo")"
_ok=$((_ok + 1))
else
warn " FAILED $_repo"
_fail=$((_fail + 1))
fi
done < "$WORK_DIR/entries.list"
write_gitmodules_snippet "$SNIPPET_OUT"
log "mirrors ok=$_ok failed=$_fail"
log "Wrote .gitmodules snippet: $SNIPPET_OUT"
printf '\n'
cat "$SNIPPET_OUT"
printf '\n'
_main_repo_url="$(gitea_public_repo_url_for "$GITEA_OWNER" "android_cast")"
_rewrite_sh="$SCRIPT_DIR/gitea_submodule_local_rewrite.sh"
cat <<EOF
Keep upstream .gitmodules on canonical git (recommended):
Gitea mirrors are independent; no remote .gitmodules edit required.
After clone, point submodules at Gitea locally (stored in .git/config only):
$_rewrite_sh --apply
Or machine-wide url.insteadOf (any repo using the same upstream URLs):
$_rewrite_sh --apply --global
Then: git submodule update --init --recursive
Optional — rewrite committed .gitmodules on git://10.7.0.10/android_cast:
Use the snippet above if every clone should default to Gitea URLs.
Clone main repo from Gitea:
git clone $_main_repo_url
cd android_cast && $_rewrite_sh --apply && git submodule update --init --recursive
Periodic sync all mirrors:
sudo -u $GITEA_RUN_USER gitea -c $GITEA_APP_INI admin repo-sync-mirrors
EOF
[ "$_fail" -eq 0 ] || exit 1

View File

@@ -0,0 +1,229 @@
#!/bin/ash
# Gitea organization, team, and user helpers (AndroidCast team layout).
# shellcheck shell=sh
# Requires: . gitea_ini.sh && gitea_load_config && . gitea_mirror_lib.sh
GITEA_ORG="${GITEA_ORG:-ac}"
GITEA_ORG_FULL_NAME="${GITEA_ORG_FULL_NAME:-Android Cast (ac)}"
GITEA_ORG_VISIBILITY="${GITEA_ORG_VISIBILITY:-public}"
# Space-separated Gitea logins with org Owner (admin) team membership.
GITEA_ORG_OWNERS="${GITEA_ORG_OWNERS:-admin foxx}"
# Transfer mirrors from a personal account when re-homing under the org.
GITEA_TRANSFER_FROM="${GITEA_TRANSFER_FROM:-admin}"
gitea_org_log() { printf '[gitea-org] %s\n' "$*"; }
gitea_org_warn() { printf '[gitea-org] WARN: %s\n' "$*" >&2; }
gitea_api_token() {
_token="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_token" ] || _token="$(gitea_resolve_push_token)" || return 1
printf '%s' "$_token"
}
gitea_org_exists() {
_org="$1"
command -v curl >/dev/null 2>&1 || return 1
_token="$(gitea_api_token)" || return 1
_api="$(gitea_api_base)"
_http="$(curl -sS -o /dev/null -w '%{http_code}' \
"$_api/orgs/$_org" \
-H "Authorization: token $_token")"
[ "$_http" = "200" ]
}
gitea_owner_exists() {
_name="$1"
gitea_org_exists "$_name" && return 0
gitea_user_exists "$_name"
}
gitea_org_create_api() {
_org="$1"
_full="${2:-$GITEA_ORG_FULL_NAME}"
command -v curl >/dev/null 2>&1 || return 1
_token="$(gitea_api_token)" || return 1
_api="$(gitea_api_base)"
_payload=$(cat <<EOF
{
"username": "$_org",
"full_name": "$_full",
"description": "Android Cast project mirrors and sources",
"visibility": "$GITEA_ORG_VISIBILITY",
"repo_admin_change_team_access": true
}
EOF
)
_resp="$(gitea_mktemp_file)"
_http="$(curl -sS -o "$_resp" -w '%{http_code}' \
-X POST "$_api/orgs" \
-H "Authorization: token $_token" \
-H "Content-Type: application/json" \
-d "$_payload")"
_rc=1
case "$_http" in
201|200) _rc=0 ;;
409|422)
gitea_org_exists "$_org" && _rc=0
;;
esac
gitea_rm_path "$_resp"
return "$_rc"
}
gitea_user_create_if_missing() {
_user="$1"
_email="${2:-${_user}@local}"
if gitea_user_exists "$_user"; then
gitea_org_log "user exists: $_user"
return 0
fi
gitea_org_log "creating user $_user (password/SSH key: set later in Gitea UI)"
_pw="setme-$(date +%s)-$$"
if gitea_cli "admin" "user" "create" \
"--username" "$_user" \
"--email" "$_email" \
"--password" "$_pw" \
"--must-change-password" 2>/dev/null; then
return 0
fi
if gitea_cli "admin" "user" "create" \
"--username" "$_user" \
"--email" "$_email" \
"--random-password" \
"--must-change-password" 2>/dev/null; then
return 0
fi
gitea_org_warn "could not create user $_user via CLI — create in Gitea UI"
return 1
}
gitea_org_owners_team_id() {
_org="$1"
command -v curl >/dev/null 2>&1 || return 1
_token="$(gitea_api_token)" || return 1
_api="$(gitea_api_base)"
_json="$(curl -sS "$_api/orgs/$_org/teams" -H "Authorization: token $_token")"
_id="$(printf '%s' "$_json" | awk '
/"name":"Owners"/ || /"name": "Owners"/ {
for (i = 1; i <= NF; i++) if ($i ~ /^"id":/) { gsub(/[^0-9]/, "", $(i+1)); print $(i+1); exit }
}
')"
if [ -z "$_id" ]; then
_id="$(printf '%s' "$_json" | tr ',' '\n' | awk '
/"name":"Owners"/ { found=1 }
found && /"id":/ { gsub(/[^0-9]/, "", $0); print; exit }
')"
fi
[ -n "$_id" ] || return 1
printf '%s' "$_id"
}
gitea_org_team_has_member() {
_org="$1"
_user="$2"
_team_id="$(gitea_org_owners_team_id "$_org")" || return 1
command -v curl >/dev/null 2>&1 || return 1
_token="$(gitea_api_token)" || return 1
_api="$(gitea_api_base)"
curl -sS "$_api/teams/$_team_id/members" \
-H "Authorization: token $_token" \
| grep -q "\"login\":\"$_user\""
}
gitea_team_add_member_api() {
_team_id="$1"
_user="$2"
command -v curl >/dev/null 2>&1 || return 1
_token="$(gitea_api_token)" || return 1
_api="$(gitea_api_base)"
_http="$(curl -sS -o /dev/null -w '%{http_code}' \
-X PUT "$_api/teams/$_team_id/members/$_user" \
-H "Authorization: token $_token")"
case "$_http" in
204|200|201) return 0 ;;
422) return 0 ;;
*) return 1 ;;
esac
}
gitea_org_add_owners() {
_org="$1"
_team_id="$(gitea_org_owners_team_id "$_org")" || {
gitea_org_warn "Owners team not found for org $_org"
return 1
}
for _u in $GITEA_ORG_OWNERS; do
[ -n "$_u" ] || continue
gitea_user_create_if_missing "$_u" || true
if gitea_user_exists "$_u"; then
if gitea_org_team_has_member "$_org" "$_u"; then
gitea_org_log "Owners team: $_u (already member)"
elif gitea_team_add_member_api "$_team_id" "$_u"; then
gitea_org_log "Owners team: $_u (added)"
else
gitea_org_warn "could not add $_u to Owners — add in UI: Org $_org -> Teams -> Owners"
fi
fi
done
return 0
}
gitea_transfer_repo_api() {
_from="$1"
_repo="$2"
_to="$3"
command -v curl >/dev/null 2>&1 || return 1
gitea_repo_exists_api "$_from" "$_repo" || return 1
_token="$(gitea_api_token)" || return 1
_api="$(gitea_api_base)"
_payload="{\"new_owner\":\"$_to\",\"team_ids\":[]}"
_resp="$(gitea_mktemp_file)"
_http="$(curl -sS -o "$_resp" -w '%{http_code}' \
-X POST "$_api/repos/$_from/$_repo/transfer" \
-H "Authorization: token $_token" \
-H "Content-Type: application/json" \
-d "$_payload")"
_rc=1
case "$_http" in
200|201|202) _rc=0 ;;
409|422)
gitea_repo_exists_api "$_to" "$_repo" && _rc=0
;;
esac
gitea_rm_path "$_resp"
return "$_rc"
}
# Move known project mirrors from GITEA_TRANSFER_FROM → org (no-op if absent).
gitea_org_transfer_legacy_repos() {
_org="$1"
_from="${2:-$GITEA_TRANSFER_FROM}"
[ "$_from" = "$_org" ] && return 0
for _repo in android_cast libvpx opus speex wireguard-android; do
if gitea_repo_exists_api "$_from" "$_repo"; then
gitea_org_log "transfer $_from/$_repo -> $_org/$_repo"
gitea_transfer_repo_api "$_from" "$_repo" "$_org" \
&& gitea_org_log " transferred" \
|| gitea_org_warn " transfer failed"
fi
done
}
# Org + Owners team + foxx/admin. Idempotent.
gitea_org_ensure_androidcast() {
_org="${1:-$GITEA_ORG}"
if gitea_org_exists "$_org"; then
gitea_org_log "org exists: $_org"
else
gitea_org_log "creating org $_org"
gitea_org_create_api "$_org" || {
gitea_org_warn "org create failed — create $_org in Gitea UI first"
return 1
}
fi
gitea_org_add_owners "$_org"
gitea_org_transfer_legacy_repos "$_org" "$GITEA_TRANSFER_FROM"
return 0
}

View File

@@ -0,0 +1,235 @@
#!/bin/ash
#
# Safe idempotent Gitea polish: org access (foxx), repo inventory, mirror sync,
# submodule mirrors, default branch next. Does NOT delete repos unless
# GITEA_RECREATE_MAIN_MIRROR=yes (android_cast pull-mirror repair).
#
# Usage (BE as root):
# sudo ./gitea_polish_project.sh
# sudo ./gitea_polish_project.sh /var/www/.../androidcast_project/android_cast
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
# shellcheck source=gitea_org_lib.sh
. "$SCRIPT_DIR/gitea_org_lib.sh"
# shellcheck source=gitea_submodule_lib.sh
. "$SCRIPT_DIR/gitea_submodule_lib.sh"
GITEA_ORG="${GITEA_ORG:-AndroidCast}"
GITEA_OWNER="${GITEA_OWNER:-$GITEA_ORG}"
GITEA_REPO="${GITEA_REPO:-android_cast}"
GITEA_MIRROR_URL="${GITEA_MIRROR_URL:-git://10.7.0.10/android_cast}"
GITEA_DEFAULT_BRANCH="${GITEA_DEFAULT_BRANCH:-next}"
GITEA_SUBMODULES_CONF="${GITEA_SUBMODULES_CONF:-$SCRIPT_DIR/submodules.defaults.conf}"
GITEA_RECREATE_MAIN_MIRROR="${GITEA_RECREATE_MAIN_MIRROR:-}"
log() { printf '[gitea-polish] %s\n' "$*"; }
warn() { printf '[gitea-polish] WARN: %s\n' "$*" >&2; }
json_bool() {
printf '%s' "$1" | tr -d '\n' | grep -o "\"$2\":[^,}]*" | head -1 | sed 's/.*://;s/ //g'
}
repo_mirror_status() {
_owner="$1"
_repo="$2"
gitea_repo_exists_api "$_owner" "$_repo" || return 1
_meta="$(gitea_repo_get_api "$_owner" "$_repo")"
_mirror="$(json_bool "$_meta" "mirror")"
_updated="$(printf '%s' "$_meta" | tr -d '\n' | grep -o '"updated_at":"[^"]*"' | head -1 | sed 's/.*:"//;s/"$//')"
log " $_owner/$_repo mirror=$_mirror updated=$_updated"
[ "$_mirror" = "true" ]
}
list_owner_repos() {
_owner="$1"
log "repos under $_owner:"
_names="$(gitea_list_repos_api "$_owner" 2>/dev/null)" || _names=""
if [ -z "$(echo "$_names" | tr -d ' ')" ]; then
warn " (none)"
return 1
fi
echo "$_names" | while read -r _r; do
[ -n "$_r" ] && log " - $_owner/$_r"
done
return 0
}
gitea_org_team_members() {
_org="$1"
_team_id="$(gitea_org_owners_team_id "$_org")" || return 1
command -v curl >/dev/null 2>&1 || return 1
_token="$(gitea_api_token)" || return 1
_api="$(gitea_api_base)"
curl -sS "$_api/teams/$_team_id/members" \
-H "Authorization: token $_token" \
| awk -F'"' '/"login":/ { print $4 }'
}
verify_org_access() {
log "org $GITEA_ORG Owners team members:"
gitea_org_team_members "$GITEA_ORG" | while read -r _u; do
[ -n "$_u" ] && log " - $_u"
done
for _u in $GITEA_ORG_OWNERS; do
if gitea_org_team_members "$GITEA_ORG" | grep -Fx "$_u" >/dev/null 2>&1; then
log "ok: $_u is org Owner"
else
warn "missing: $_u NOT in Owners — re-run gitea_migrate_org_setup.sh"
fi
done
}
sync_all_mirrors() {
log "trigger mirror sync (all repos)..."
if gitea_cli "admin" "repo-sync-mirrors"; then
log "repo-sync-mirrors OK"
return 0
fi
warn "repo-sync-mirrors CLI failed — trying per-repo API"
for _owner in "$GITEA_ORG" admin; do
gitea_list_repos_api "$_owner" 2>/dev/null | while read -r _r; do
[ -n "$_r" ] || continue
gitea_sync_mirror_api "$_owner" "$_r" && log " synced $_owner/$_r" \
|| warn " sync failed $_owner/$_r"
done
done
}
ensure_url_shortener_mirror() {
gitea_repo_exists_api "$GITEA_ORG" url-shortener && {
log "url-shortener mirror present under $GITEA_ORG"
return 0
}
log "url-shortener missing — running gitea_attach_url_shortener.sh ..."
sh "$SCRIPT_DIR/gitea_attach_url_shortener.sh" \
|| warn "url-shortener attach failed — run: sudo ./gitea_attach_url_shortener.sh"
}
mirror_submodules_if_missing() {
_checkout="${1:-}"
_repo_dir=""
_gitmodules=""
if [ -n "$_checkout" ] && [ -d "$_checkout" ]; then
_repo_dir="$(cd "$_checkout" && pwd)"
_gitmodules="$_repo_dir/.gitmodules"
fi
_need=0
for _sub in libvpx opus speex wireguard-android; do
gitea_repo_exists_api "$GITEA_ORG" "$_sub" && continue
_need=1
break
done
[ "$_need" -eq 1 ] || {
log "submodule mirrors present under $GITEA_ORG"
return 0
}
log "creating missing submodule mirrors under $GITEA_ORG..."
_list="$(gitea_mktemp_file)"
gitea_submodule_collect_entries "$_repo_dir" HEAD "$_gitmodules" "$GITEA_SUBMODULES_CONF" > "$_list"
while IFS= read -r _line; do
[ -n "$_line" ] || continue
_name="${_line%%|*}"
_rest="${_line#*|}"
_url="${_rest%%|*}"
log " mirror $_name <- $_url"
gitea_mirror_pull_repo "$_url" "$_name" "$GITEA_ORG" "24h" "/tmp/gitea-polish-$$" \
&& gitea_sync_mirror_api "$GITEA_ORG" "$_name" || warn " failed $_name"
gitea_rm_path "/tmp/gitea-polish-$$/${_name}.mirror.git"
done < "$_list"
gitea_rm_path "$_list"
}
recreate_main_pull_mirror_if_asked() {
if [ "$GITEA_RECREATE_MAIN_MIRROR" != "yes" ]; then
return 0
fi
warn "GITEA_RECREATE_MAIN_MIRROR=yes — delete $GITEA_ORG/$GITEA_REPO and re-attach"
command -v curl >/dev/null 2>&1 || return 1
_token="$(gitea_api_token)" || return 1
_api="$(gitea_api_base)"
curl -sS -o /dev/null -X DELETE "$_api/repos/$GITEA_ORG/$GITEA_REPO" \
-H "Authorization: token $_token" || true
GITEA_OWNER="$GITEA_ORG" "$SCRIPT_DIR/gitea_attach_remote.sh" "$GITEA_MIRROR_URL"
}
# --- main ---
_checkout="${1:-}"
if [ -d "./.git" ] || [ -f "./.git" ]; then
_checkout="${_checkout:-$(pwd)}"
fi
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
log "=== Gitea polish (safe) ==="
log "ini=$GITEA_APP_INI org=$GITEA_ORG"
# 1) Org + foxx Owners
gitea_org_ensure_androidcast "$GITEA_ORG" || warn "org setup incomplete"
verify_org_access
# 2) Inventory
list_owner_repos "$GITEA_ORG" || true
list_owner_repos admin || true
# 3) Main mirror health
log "main repo mirror status:"
_main_ok=0
if repo_mirror_status "$GITEA_ORG" "$GITEA_REPO"; then
_main_ok=1
elif repo_mirror_status "admin" "$GITEA_REPO"; then
warn "android_cast still under admin — transfer to org:"
warn " sudo ./gitea_migrate_org_setup.sh"
_main_ok=1
else
warn "no pull mirror for $GITEA_REPO — auto-sync disabled"
warn "fix (destructive): GITEA_RECREATE_MAIN_MIRROR=yes sudo ./gitea_polish_project.sh"
warn " or delete $GITEA_ORG/$GITEA_REPO in UI then: sudo ./gitea_attach_remote.sh $GITEA_MIRROR_URL"
fi
recreate_main_pull_mirror_if_asked
# 4) Manual sync now
sync_all_mirrors
# 5) Submodules
mirror_submodules_if_missing "$_checkout"
# 5b) First-party submodule url-shortener (local git:// — not gitea_mirror_submodules)
ensure_url_shortener_mirror
# 6) Default branch
if command -v "$SCRIPT_DIR/gitea_set_default_branch.sh" >/dev/null 2>&1; then
GITEA_OWNER="$GITEA_ORG" GITEA_DEFAULT_BRANCH="$GITEA_DEFAULT_BRANCH" \
sh "$SCRIPT_DIR/gitea_set_default_branch.sh" || warn "default branch set failed (sync first)"
fi
# 7) Branch protection (master, next)
if [ "${GITEA_SKIP_BRANCH_PROTECT:-0}" -eq 0 ]; then
sh "$SCRIPT_DIR/gitea_protect_branches.sh" || warn "branch protection failed — run manually"
fi
# 8) Summary
log "=== done ==="
cat <<EOF
UI checklist:
• foxx/admin: open ${GITEA_ROOT_URL%/}/$GITEA_ORG (org page — not only "Your repositories")
• Expect: android_cast, url-shortener, libvpx, opus, speex under $GITEA_ORG
• If android_cast still stale: sudo ./gitea_diagnose_mirror_sync.sh
• Branch protection: master/next on Gitea; install FE hook:
examples/git/hooks/pre-receive.android_cast.example
• Email/2FA later: app.ini REGISTER_EMAIL_CONFIRM=false; add SMTP when ready
EOF
[ "$_main_ok" -eq 1 ] || exit 2

View File

@@ -0,0 +1,172 @@
#!/bin/ash
#
# Server-side protection for master and next on Gitea (android_cast).
# Blocks force-push for everyone; push/merge limited to org Owners (admin, foxx).
# Side branches (feature/*, fix/*) are unaffected — no wildcard rule.
#
# Usage (BE as root):
# sudo ./gitea_protect_branches.sh
# GITEA_OWNER=AndroidCast GITEA_REPO=android_cast sudo ./gitea_protect_branches.sh
# sudo ./gitea_protect_branches.sh --list
#
# Note: canonical git on FE (git://10.7.0.10/android_cast) needs its own hook —
# see examples/git/hooks/pre-receive.android_cast.example
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-AndroidCast}}"
GITEA_REPO="${GITEA_REPO:-android_cast}"
# Space-separated branches to protect (exact names only).
GITEA_PROTECT_BRANCHES="${GITEA_PROTECT_BRANCHES:-master next}"
# Users allowed to push (fast-forward / merge) to protected branches.
GITEA_PROTECT_PUSH_USERS="${GITEA_PROTECT_PUSH_USERS:-admin foxx}"
# Org team with push access (AndroidCast Owners).
GITEA_PROTECT_PUSH_TEAM="${GITEA_PROTECT_PUSH_TEAM:-Owners}"
log() { printf '[gitea-protect] %s\n' "$*"; }
warn() { printf '[gitea-protect] WARN: %s\n' "$*" >&2; }
die() { printf '[gitea-protect] ERROR: %s\n' "$*" >&2; exit 1; }
gitea_api_token() {
_t="${GITEA_ADMIN_TOKEN:-}"
[ -n "$_t" ] || _t="$(gitea_resolve_push_token)" || return 1
printf '%s' "$_t"
}
gitea_list_branch_protections() {
command -v curl >/dev/null 2>&1 || return 1
_token="$(gitea_api_token)" || return 1
_api="$(gitea_api_base)"
curl -sS "$_api/repos/$GITEA_OWNER/$GITEA_REPO/branch_protections" \
-H "Authorization: token $_token"
}
gitea_protection_exists() {
_branch="$1"
gitea_list_branch_protections 2>/dev/null | grep -q "\"branch_name\":\"$_branch\""
}
gitea_protect_payload() {
_branch="$1"
_prio="$2"
_users_json=""
for _u in $GITEA_PROTECT_PUSH_USERS; do
[ -n "$_u" ] || continue
_users_json="$_users_json\"$_u\","
done
_users_json="${_users_json%,}"
cat <<EOF
{
"rule_name": "$_branch",
"branch_name": "$_branch",
"priority": $_prio,
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": [$_users_json],
"push_whitelist_teams": ["$GITEA_PROTECT_PUSH_TEAM"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"block_admin_merge_override": true
}
EOF
}
gitea_apply_protection() {
_branch="$1"
_prio="$2"
command -v curl >/dev/null 2>&1 || return 1
_token="$(gitea_api_token)" || return 1
_api="$(gitea_api_base)"
_payload="$(gitea_protect_payload "$_branch" "$_prio")"
_resp="$(gitea_mktemp_file)"
if gitea_protection_exists "$_branch"; then
log "update protection: $GITEA_OWNER/$GITEA_REPO::$_branch"
_http="$(curl -sS -o "$_resp" -w '%{http_code}' \
-X PATCH "$_api/repos/$GITEA_OWNER/$GITEA_REPO/branch_protections/$_branch" \
-H "Authorization: token $_token" \
-H "Content-Type: application/json" \
-d "$_payload")"
else
log "create protection: $GITEA_OWNER/$GITEA_REPO::$_branch"
_http="$(curl -sS -o "$_resp" -w '%{http_code}' \
-X POST "$_api/repos/$GITEA_OWNER/$GITEA_REPO/branch_protections" \
-H "Authorization: token $_token" \
-H "Content-Type: application/json" \
-d "$_payload")"
fi
case "$_http" in
200|201|204) gitea_rm_path "$_resp"; return 0 ;;
*)
warn "HTTP $_http for $_branch:"
head -c 500 "$_resp" >&2 2>/dev/null || true
printf '\n' >&2
gitea_rm_path "$_resp"
return 1
;;
esac
}
list_protections() {
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
log "branch protections: $GITEA_OWNER/$GITEA_REPO"
gitea_list_branch_protections | tr ',' '\n' | grep -E 'branch_name|enable_force_push|enable_push' || true
}
# --- main ---
case "${1:-}" in
-h|--help|help)
sed -n '2,16p' "$0"
exit 0
;;
--list|list)
list_protections
exit 0
;;
esac
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
if ! gitea_repo_exists_api "$GITEA_OWNER" "$GITEA_REPO"; then
die "repo $GITEA_OWNER/$GITEA_REPO not found — run gitea_polish_project.sh first"
fi
log "repo=$GITEA_OWNER/$GITEA_REPO branches=$GITEA_PROTECT_BRANCHES"
log "push allowlist: users=$GITEA_PROTECT_PUSH_USERS team=$GITEA_PROTECT_PUSH_TEAM"
log "force-push: disabled for all | side branches: unprotected"
_prio=1
_fail=0
for _b in $GITEA_PROTECT_BRANCHES; do
gitea_apply_protection "$_b" "$_prio" || _fail=1
_prio=$((_prio + 1))
done
cat <<EOF
Gitea rules applied. Verify in UI:
$GITEA_OWNER/$GITEA_REPO → Settings → Branches
Protected: master, next only
• No force-push (anyone)
• Push/merge: $GITEA_PROTECT_PUSH_USERS + team $GITEA_PROTECT_PUSH_TEAM
• Admins cannot bypass (block_admin_merge_override)
IMPORTANT — canonical origin on FE is separate:
Install examples/git/hooks/pre-receive.android_cast.example on the bare repo
at git://10.7.0.10/android_cast (hooks/pre-receive).
EOF
[ "$_fail" -eq 0 ]

View File

@@ -0,0 +1,58 @@
#!/bin/ash
#
# Set default branch (next) on Gitea repos for UI clone/browse defaults.
#
# Usage:
# sudo ./gitea_set_default_branch.sh
# GITEA_DEFAULT_BRANCH=next GITEA_OWNER=AndroidCast sudo ./gitea_set_default_branch.sh
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-ac}}"
GITEA_DEFAULT_BRANCH="${GITEA_DEFAULT_BRANCH:-next}"
# Space-separated repo names; empty = all listed under owner.
GITEA_REPO_NAMES="${GITEA_REPO_NAMES:-}"
log() { printf '[gitea-default-branch] %s\n' "$*"; }
warn() { printf '[gitea-default-branch] WARN: %s\n' "$*" >&2; }
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
_repos=""
if [ -n "$GITEA_REPO_NAMES" ]; then
_repos="$GITEA_REPO_NAMES"
else
_repos="$(gitea_list_repos_api "$GITEA_OWNER" 2>/dev/null)" || _repos=""
if [ -z "$(echo "$_repos" | tr -d ' ')" ] && command -v sqlite3 >/dev/null 2>&1; then
_repos="$(sqlite3 "${GITEA_APP_DATA:-/var/lib/gitea}/gitea.db" \
"SELECT r.lower_name FROM repository r JOIN user u ON r.owner_id=u.id WHERE u.lower_name='$(printf '%s' "$GITEA_OWNER" | tr '[:upper:]' '[:lower:]')' ORDER BY r.lower_name;" 2>/dev/null)"
fi
fi
if [ -z "$(echo "$_repos" | tr -d ' ')" ]; then
warn "no repos under $GITEA_OWNER"
exit 1
fi
_ok=0
_fail=0
for _repo in $_repos; do
[ -n "$_repo" ] || continue
if gitea_repo_set_default_branch_api "$GITEA_OWNER" "$_repo" "$GITEA_DEFAULT_BRANCH"; then
log "$GITEA_OWNER/$_repo -> default branch $GITEA_DEFAULT_BRANCH"
_ok=$((_ok + 1))
else
warn "failed $GITEA_OWNER/$_repo (branch $GITEA_DEFAULT_BRANCH may not exist yet — sync mirror first)"
_fail=$((_fail + 1))
fi
done
log "done ok=$_ok fail=$_fail"
[ "$_fail" -eq 0 ]

View File

@@ -0,0 +1,197 @@
#!/bin/ash
# Discover git submodules (upstream URL + pinned SHA) from a checkout.
# shellcheck shell=sh
# Space-separated host/prefix patterns treated as "ours" (skipped for external mirrors).
GITEA_LOCAL_URL_PATTERNS="${GITEA_LOCAL_URL_PATTERNS:-10.7.0.10 10.7.16. apps.f0xx.org f0xx.org 127.0.0.1 localhost}"
gitea_submodule_is_local_url() {
_url="$1"
case "$_url" in
/*|./*|../*) return 0 ;;
esac
for _pat in $GITEA_LOCAL_URL_PATTERNS; do
case "$_url" in
*"$_pat"*) return 0 ;;
esac
done
return 1
}
gitea_submodule_is_external_url() {
gitea_submodule_is_local_url "$1" && return 1
return 0
}
# .gitmodules -> repo|url|path|branch (repo = basename of path). stdout only.
gitea_submodule_parse_gitmodules() {
_file="$1"
[ -r "$_file" ] || return 1
awk '
function trim(s) {
sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s)
return s
}
/^[ \t]*path[ \t]*=/ {
p = $0; sub(/^[^=]*=/, "", p); path = trim(p)
n = path; sub(".*/", "", n)
next
}
/^[ \t]*url[ \t]*=/ {
u = $0; sub(/^[^=]*=/, "", u); url = trim(u)
next
}
/^[ \t]*branch[ \t]*=/ {
b = $0; sub(/^[^=]*=/, "", b); branch = trim(b)
next
}
/^\[/ {
if (path != "" && url != "" && n != "") {
if (branch != "") print n "|" url "|" path "|" branch
else print n "|" url "|" path
}
path = ""; url = ""; branch = ""; n = ""
}
END {
if (path != "" && url != "" && n != "") {
if (branch != "") print n "|" url "|" path "|" branch
else print n "|" url "|" path
}
}
' "$_file"
}
# repo|url|path|sha[|branch] — external entries from .gitmodules (sha may be "-" if unknown).
gitea_submodule_discover_from_gitmodules() {
_gitmodules="$1"
_repo_dir="${2:-}"
_ref="${3:-HEAD}"
[ -r "$_gitmodules" ] || return 1
gitea_submodule_parse_gitmodules "$_gitmodules" | while IFS= read -r _line; do
[ -n "$_line" ] || continue
_repo="${_line%%|*}"
_rest="${_line#*|}"
_url="${_rest%%|*}"
_rest2="${_rest#*|}"
_path="${_rest2%%|*}"
_branch="${_rest2#*|}"
case "$_branch" in
"$_path") _branch="" ;;
esac
gitea_submodule_is_external_url "$_url" || continue
_sha="-"
if [ -n "$_repo_dir" ] && command -v git >/dev/null 2>&1 \
&& git -C "$_repo_dir" rev-parse --verify "$_ref" >/dev/null 2>&1; then
_got="$(git -C "$_repo_dir" rev-parse "$_ref:$_path" 2>/dev/null)" || _got=""
case "$_got" in
??000000000000000000000000000000000000) _sha="-" ;;
????????????????????????????????????????*) _sha="$_got" ;;
esac
fi
if [ -n "$_branch" ]; then
printf '%s|%s|%s|%s|%s\n' "$_repo" "$_url" "$_path" "$_sha" "$_branch"
else
printf '%s|%s|%s|%s\n' "$_repo" "$_url" "$_path" "$_sha"
fi
done
}
# repo|url|path|sha[|branch] for each external submodule with a gitlink at REF.
gitea_submodule_discover_external() {
_repo_dir="$1"
_ref="${2:-HEAD}"
_gitmodules="$_repo_dir/.gitmodules"
command -v git >/dev/null 2>&1 || return 1
git -C "$_repo_dir" rev-parse --verify "$_ref" >/dev/null 2>&1 || return 1
[ -r "$_gitmodules" ] || return 1
gitea_submodule_discover_from_gitmodules "$_gitmodules" "$_repo_dir" "$_ref" \
| while IFS= read -r _line; do
_sha="${_line##*|}"
case "$_sha" in
-) continue ;;
esac
printf '%s\n' "$_line"
done
}
# repo|url|path|sha[|branch] from submodules.defaults.conf (sha resolved when possible).
gitea_submodule_load_defaults() {
_conf="$1"
_repo_dir="${2:-}"
_ref="${3:-HEAD}"
[ -r "$_conf" ] || return 1
grep -v '^[[:space:]]*#' "$_conf" | grep -v '^[[:space:]]*$' | while IFS= read -r _line; do
[ -n "$_line" ] || continue
_repo="${_line%%|*}"
_rest="${_line#*|}"
_url="${_rest%%|*}"
_rest2="${_rest#*|}"
_path="${_rest2%%|*}"
_branch="${_rest2#*|}"
case "$_branch" in
"$_path") _branch="" ;;
esac
gitea_submodule_is_external_url "$_url" || continue
_sha="-"
if [ -n "$_repo_dir" ] && command -v git >/dev/null 2>&1 \
&& git -C "$_repo_dir" rev-parse --verify "$_ref" >/dev/null 2>&1; then
_got="$(git -C "$_repo_dir" rev-parse "$_ref:$_path" 2>/dev/null)" || _got=""
case "$_got" in
????????????????????????????????????????*) _sha="$_got" ;;
esac
fi
if [ -n "$_branch" ]; then
printf '%s|%s|%s|%s|%s\n' "$_repo" "$_url" "$_path" "$_sha" "$_branch"
else
printf '%s|%s|%s|%s\n' "$_repo" "$_url" "$_path" "$_sha"
fi
done
}
# Merge discovery sources; first win per repo name. stdout: repo|url|path|sha[|branch]
gitea_submodule_collect_entries() {
_repo_dir="${1:-}"
_ref="${2:-HEAD}"
_gitmodules="${3:-}"
_defaults="${4:-}"
_work="$(gitea_mktemp_file)"
if [ -n "$_gitmodules" ] && [ -r "$_gitmodules" ]; then
gitea_submodule_discover_from_gitmodules "$_gitmodules" "$_repo_dir" "$_ref" >> "$_work" 2>/dev/null || true
elif [ -n "$_repo_dir" ] && [ -r "$_repo_dir/.gitmodules" ]; then
gitea_submodule_discover_from_gitmodules "$_repo_dir/.gitmodules" "$_repo_dir" "$_ref" >> "$_work" 2>/dev/null || true
fi
# Use defaults only when .gitmodules produced nothing (never merge extras on top).
if [ ! -s "$_work" ] && [ -n "$_defaults" ] && [ -r "$_defaults" ]; then
gitea_submodule_load_defaults "$_defaults" "$_repo_dir" "$_ref" >> "$_work" 2>/dev/null || true
fi
awk -F'|' '!seen[$1]++' "$_work"
rm -f "$_work"
}
gitea_submodule_entry_valid() {
case "$1" in
*'|'*) ;;
*) return 1 ;;
esac
_repo="${1%%|*}"
_rest="${1#*|}"
_url="${_rest%%|*}"
_rest2="${_rest#*|}"
_path="${_rest2%%|*}"
[ -n "$_repo" ] && [ -n "$_url" ] && [ -n "$_path" ] || return 1
case "$_repo" in
*' '*|*'['*|*']'*) return 1 ;;
esac
return 0
}

View File

@@ -0,0 +1,156 @@
#!/bin/ash
#
# Point submodule fetches at Gitea mirrors without changing committed .gitmodules.
# Rewrites are stored in .git/config (per clone) or optional --global insteadOf rules.
#
# Usage (inside android_cast checkout, or pass repo path):
# ./gitea_submodule_local_rewrite.sh # print commands
# ./gitea_submodule_local_rewrite.sh --apply # write .git/config
# ./gitea_submodule_local_rewrite.sh --apply --global # url.insteadOf in ~/.gitconfig
# ./gitea_submodule_local_rewrite.sh .gitmodules # read paths/URLs from file
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GITEA_OWNER="${GITEA_OWNER:-AndroidCast}"
GITEA_ROOT_URL="${GITEA_ROOT_URL:-https://apps.f0xx.org/app/androidcast_project/git}"
GITEA_SUBMODULES_CONF="${GITEA_SUBMODULES_CONF:-$SCRIPT_DIR/submodules.defaults.conf}"
log() { printf '[gitea-submodule-rewrite] %s\n' "$*"; }
die() { printf '[gitea-submodule-rewrite] ERROR: %s\n' "$*" >&2; exit 1; }
gitea_public_repo_url_for() {
_owner="$1"
_repo="$2"
_root="$GITEA_ROOT_URL"
case "$_root" in
*/) _root="${_root%/}" ;;
esac
printf '%s/%s/%s.git' "$_root" "$_owner" "$_repo"
}
parse_gitmodules_file() {
_file="$1"
[ -r "$_file" ] || return 1
awk '
function trim(s) {
sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s)
return s
}
/^[ \t]*path[ \t]*=/ {
p = $0; sub(/^[^=]*=/, "", p); path = trim(p)
n = path; sub(".*/", "", n)
next
}
/^[ \t]*url[ \t]*=/ {
u = $0; sub(/^[^=]*=/, "", u); url = trim(u)
next
}
/^\[/ {
if (path != "" && url != "" && n != "") print n "|" url "|" path
path = ""; url = ""; n = ""
}
END {
if (path != "" && url != "" && n != "") print n "|" url "|" path
}
' "$_file"
}
load_entries() {
_gitmodules="${1:-}"
if [ -n "$_gitmodules" ] && [ -r "$_gitmodules" ]; then
log "reading $_gitmodules" >&2
parse_gitmodules_file "$_gitmodules"
return 0
fi
if [ -r "$GITEA_SUBMODULES_CONF" ]; then
log "reading $GITEA_SUBMODULES_CONF" >&2
grep -v '^[[:space:]]*#' "$GITEA_SUBMODULES_CONF" | grep -v '^[[:space:]]*$' \
| awk -F'|' 'NF >= 3 { print $1 "|" $2 "|" $3 }'
return 0
fi
return 1
}
_apply=0
_global=0
_gitmodules_arg=""
_repo_dir=""
while [ $# -gt 0 ]; do
case "$1" in
-h|--help)
sed -n '2,14p' "$0"
exit 0
;;
--apply) _apply=1; shift ;;
--global) _global=1; shift ;;
-*)
die "unknown option: $1"
;;
*)
if [ -r "$1" ] || [ -f "$1" ]; then
_gitmodules_arg="$1"
else
_repo_dir="$1"
fi
shift
;;
esac
done
if [ -z "$_repo_dir" ]; then
if git rev-parse --show-toplevel >/dev/null 2>&1; then
_repo_dir="$(git rev-parse --show-toplevel)"
else
_repo_dir="."
fi
fi
if [ -z "$_gitmodules_arg" ] && [ -r "$_repo_dir/.gitmodules" ]; then
_gitmodules_arg="$_repo_dir/.gitmodules"
fi
_entries="$(gitea_mktemp_file)"
trap 'rm -f "$_entries"' EXIT INT HUP TERM
load_entries "$_gitmodules_arg" > "$_entries" || die "no submodule list"
if [ ! -s "$_entries" ]; then
die "no submodule entries parsed"
fi
log "Gitea base: $GITEA_ROOT_URL owner=$GITEA_OWNER repo=$_repo_dir"
printf '\n'
while IFS= read -r line; do
[ -n "$line" ] || continue
_repo="${line%%|*}"
_rest="${line#*|}"
_upstream="${_rest%%|*}"
_path="${_rest#*|}"
_gitea_url="$(gitea_public_repo_url_for "$GITEA_OWNER" "$_repo")"
if [ "$_global" -eq 1 ]; then
_cmd="git config --global url.$_gitea_url.insteadOf $_upstream"
printf '%s\n' "$_cmd"
if [ "$_apply" -eq 1 ]; then
git config --global "url.$_gitea_url.insteadOf" "$_upstream"
fi
else
_cmd="git -C \"$_repo_dir\" config submodule.$_path.url $_gitea_url"
printf '%s\n' "$_cmd"
if [ "$_apply" -eq 1 ]; then
git -C "$_repo_dir" config "submodule.$_path.url" "$_gitea_url"
fi
fi
done < "$_entries"
if [ "$_apply" -eq 1 ] && [ "$_global" -eq 0 ]; then
git -C "$_repo_dir" submodule sync --recursive
log "applied; run: git -C $_repo_dir submodule update --init --recursive"
else
printf '\n'
log "To apply in this clone: $0 --apply"
log "Or machine-wide URL rewrite: $0 --apply --global"
log "Then: git submodule update --init --recursive"
fi

View File

@@ -0,0 +1,63 @@
#!/bin/ash
#
# Trigger Gitea pull-mirror sync (one repo or all). For BE cron / FE post-receive hook.
#
# Usage:
# sudo ./gitea_sync_mirror.sh android_cast
# sudo ./gitea_sync_mirror.sh android_cast libvpx opus
# sudo ./gitea_sync_mirror.sh --all
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
# shellcheck source=gitea_mirror_lib.sh
. "$SCRIPT_DIR/gitea_mirror_lib.sh"
GITEA_OWNER="${GITEA_OWNER:-${GITEA_ORG:-AndroidCast}}"
log() { printf '[gitea-sync] %s\n' "$*"; }
warn() { printf '[gitea-sync] WARN: %s\n' "$*" >&2; }
die() { printf '[gitea-sync] ERROR: %s\n' "$*" >&2; exit 1; }
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
_sync_all=0
_repos=""
while [ $# -gt 0 ]; do
case "$1" in
-h|--help)
sed -n '2,10p' "$0"
exit 0
;;
--all) _sync_all=1; shift ;;
-*) die "unknown option: $1" ;;
*) _repos="$_repos $1"; shift ;;
esac
done
if [ "$_sync_all" -eq 1 ]; then
log "sync all mirrors via gitea admin repo-sync-mirrors"
gitea_cli "admin" "repo-sync-mirrors"
exit $?
fi
if [ -z "$(echo "$_repos" | tr -d ' ')" ]; then
_repos="android_cast"
fi
_fail=0
for _repo in $_repos; do
log "mirror-sync $GITEA_OWNER/$_repo"
if gitea_sync_mirror_api "$GITEA_OWNER" "$_repo"; then
log " OK"
else
warn " FAILED"
_fail=1
fi
done
exit "$_fail"

View File

@@ -0,0 +1,49 @@
#!/bin/ash
#
# Gitea user admin helpers (foxx pre-created by migrate — not self-registration).
#
# Usage:
# sudo ./gitea_user_admin.sh list
# sudo ./gitea_user_admin.sh reset-password foxx 'NewSecurePassword'
# sudo ./gitea_user_admin.sh must-change-password foxx
#
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=gitea_ini.sh
. "$SCRIPT_DIR/gitea_ini.sh"
log() { printf '[gitea-user] %s\n' "$*"; }
die() { printf '[gitea-user] ERROR: %s\n' "$*" >&2; exit 1; }
gitea_load_config || exit 1
gitea_ensure_cli_access || exit 1
_cmd="${1:-}"
_user="${2:-}"
_pass="${3:-}"
case "$_cmd" in
list)
gitea_cli "admin" "user" "list"
;;
reset-password)
[ -n "$_user" ] && [ -n "$_pass" ] || die "usage: $0 reset-password USER PASSWORD"
gitea_cli "admin" "user" "change-password" \
"--username" "$_user" \
"--password" "$_pass"
log "password set for $_user (use Sign In, not Register)"
;;
must-change-password)
[ -n "$_user" ] || die "usage: $0 must-change-password USER"
gitea_cli "admin" "user" "must-change-password" \
"--username" "$_user"
log "$_user must change password on next login"
;;
-h|--help|help)
sed -n '2,12p' "$0"
;;
*)
die "unknown command: $_cmd (try --help)"
;;
esac

View File

@@ -0,0 +1,12 @@
# Gitea pull mirrors for android_cast git submodules.
# Format: repo_name|upstream_clone_url|submodule_path[|branch]
# Lines starting with # are ignored. Add local git:// entries at the bottom.
# Fallback only when .gitmodules is missing on BE. Canonical list: .gitmodules in repo.
libvpx|https://chromium.googlesource.com/webm/libvpx|third-party/libvpx|main
opus|https://github.com/xiph/opus|third-party/opus
speex|https://github.com/xiph/speex/|third-party/speex
wireguard-android|https://git.zx2c4.com/wireguard-android|third-party/wireguard-android
# Example local submodule (uncomment and set your git daemon URL):
# mylib|git://10.7.0.10/mylib|third-party/mylib

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env php
<?php
/**
* Parse tmp/alpha_smoke_round1.txt and POST tickets to ticket_upload.php.
*
* Usage:
* php scripts/ingest_alpha_smoke_tickets.php [--file=path] [--url=BASE] [--owner=admin] [--dry-run]
*
* Default input: <repo>/tmp/alpha_smoke_round1.txt (project root, not examples/).
*/
declare(strict_types=1);
// scripts → backend → crash_reporter → examples → repo root
$repoRoot = dirname(__DIR__, 4);
$defaultFile = $repoRoot . '/tmp/alpha_smoke_round1.txt';
$opts = getopt('', ['file::', 'url::', 'owner::', 'dry-run', 'device::', 'app-version::']);
$file = $opts['file'] ?? $defaultFile;
$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';
if (!is_readable($file)) {
fwrite(STDERR, "File not found: $file\n");
exit(1);
}
$lines = file($file, FILE_IGNORE_NEW_LINES);
$rows = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, 'block |')) {
continue;
}
$parts = array_map('trim', explode('|', $line, 3));
if (count($parts) < 3) {
continue;
}
[$block, $expected, $actual] = $parts;
$rows[] = compact('block', 'expected', 'actual');
}
$device = [
'manufacturer' => 'DOOGEE',
'brand' => 'DOOGEE',
'model' => $opts['device'] ?? 'BL6000 Pro',
'sdk_int' => 29,
'release' => '10',
'abis' => ['arm64-v8a', 'armeabi-v7a'],
];
$app = [
'package' => 'com.foxx.androidcast',
'version_name' => $opts['app-version'] ?? '0.1.0-alpha',
'version_code' => 1,
];
function classify_row(array $row): array {
$actual = $row['actual'];
$pass = (bool) preg_match('/^\s*pass\b/i', $actual) && !preg_match('/\bNOK\b/i', $actual);
$needsTicket = !$pass
|| preg_match('/needs review|remark|poor|low-res|frame drop|lifecycle|VU-meter|laggy|wrong/i', $actual);
if (!$needsTicket) {
return ['skip' => true];
}
$status = 'open';
$rating = 3;
if (preg_match('/\bNOK\b/i', $actual)) {
$status = 'urgent';
$rating = 4;
} elseif ($pass) {
$status = 'confirmed';
$rating = 2;
}
if (preg_match('/needs review/i', $actual)) {
$status = 'in-progress';
}
return ['skip' => false, 'status' => $status, 'rating' => $rating];
}
function ticket_tags(string $status): array {
$map = [
'open' => ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'],
'in-progress' => ['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3b82f6'],
'confirmed' => ['id' => 'confirmed', 'label' => 'confirmed', 'bg' => '#047857'],
'urgent' => ['id' => 'urgent', 'label' => 'urgent', 'bg' => '#dc2626'],
'closed' => ['id' => 'closed', 'label' => 'closed', 'bg' => '#6b7280'],
];
$tags = [
['id' => 'ticket', 'label' => 'ticket', 'bg' => '#6366f1'],
['id' => 'alpha-smoke', 'label' => 'alpha-smoke', 'bg' => '#8b5cf6'],
$map[$status] ?? $map['open'],
];
return $tags;
}
$now = (int) round(microtime(true) * 1000);
$ok = 0;
$skip = 0;
$fail = 0;
foreach ($rows as $row) {
$class = classify_row($row);
if (!empty($class['skip'])) {
$skip++;
continue;
}
$block = $row['block'];
$title = 'Alpha smoke ' . $block . ': ' . mb_substr($row['expected'], 0, 80);
$brief = mb_substr($row['actual'], 0, 240);
$body = "Block: {$block}\n\nExpected:\n{$row['expected']}\n\nActual:\n{$row['actual']}\n\nSource: alpha_smoke_round1.txt";
$payload = [
'schema_version' => 1,
'ingest_kind' => 'ticket',
'ticket_id' => 'alpha-smoke-' . strtolower($block) . '-' . substr(sha1($body), 0, 12),
'title' => $title,
'brief' => $brief,
'body' => $body,
'opened_at_epoch_ms' => $now,
'rating' => $class['rating'],
'owner' => $owner,
'verified_by' => '',
'tags' => ticket_tags($class['status']),
'device' => $device,
'app' => $app,
'alpha_block' => $block,
'source_file' => basename($file),
'source_excerpt' => $body,
];
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($dryRun) {
echo "DRY $block{$payload['ticket_id']} [{$class['status']}]\n";
$ok++;
continue;
}
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => $json,
'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) {
echo "OK $block\n";
$ok++;
} elseif ($code === 409) {
echo "DUP $block\n";
$ok++;
} else {
echo "FAIL $block HTTP $code$resp\n";
$fail++;
}
}
echo "Done: uploaded=$ok skipped=$skip failed=$fail\n";
exit($fail > 0 ? 1 : 0);

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

@@ -0,0 +1,113 @@
#!/usr/bin/env php
<?php
/**
* Ingest roadmap tasks as tickets tagged 20260604 (filter in Tickets UI).
*
* Usage:
* php scripts/ingest_roadmap_tickets_20260604.php [--url=BASE] [--owner=admin] [--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';
/** @return list<array{id:string,label:string,bg:string}> */
function roadmap_tags(string $status): array {
$statusMap = [
'open' => ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'],
'in-progress' => ['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3b82f6'],
'closed' => ['id' => 'closed', 'label' => 'closed', 'bg' => '#6b7280'],
];
return [
['id' => 'ticket', 'label' => 'ticket', 'bg' => '#6366f1'],
['id' => '20260604', 'label' => '20260604', 'bg' => '#0ea5e9'],
$statusMap[$status] ?? $statusMap['open'],
];
}
/** title + body are the same description text */
$tasks = [
['key' => 'bug-ticket-detail-500', 'status' => 'closed', 'text' => 'ISSUE: Ticket detail page HTTP 500 (?view=ticket&id=N). Fix recursion in comment/attachment loaders when rendering detail.'],
['key' => '8', 'status' => 'closed', 'text' => 'COMPLETED: Orchestration branch feature/20260527_orchestration — merged to next; work lives under orchestration/; close/delete branch.'],
['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' => '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.'],
['key' => '5.3', 'status' => 'closed', 'text' => 'ISSUE 5.3: Remove Direct DB metrics / graph_upload / graphs.php footnote from graphs page.'],
['key' => '6', 'status' => 'open', 'text' => 'Landing page checks, fixes, improvements — hub, styling, integration with dashboards.'],
['key' => '6.1', 'status' => 'open', 'text' => 'Landing 6.1: Hub dashboards for graphs, builder, remote access — partially done.'],
['key' => '6.1.1', 'status' => 'closed', 'text' => 'ISSUE 6.1.1: Remote access / graphs quick links — shell nav icons and 8px spacing.'],
['key' => '6.1.2', 'status' => 'closed', 'text' => 'ISSUE 6.1.2: Remove device poll / heartbeat dev footnote from Remote access page.'],
['key' => '6.2', 'status' => 'open', 'text' => 'Landing 6.2: Globe/earth animations — choose base on next; branch cleanup (3d_earth vs 20260531_globe_animation); clock/footer styling.'],
['key' => '6.3', 'status' => 'open', 'text' => 'Landing 6.3: Mobile device validations (mobile chrome) — LOW priority.'],
['key' => '7', 'status' => 'open', 'text' => 'BE roles RBAC — admin/root, slug admins, privilege sets; finish after related pages stable.'],
['key' => 'net-slowness', 'status' => 'open', 'text' => 'ISSUE: Sporadic FE/BE slowness (SSH, sshfs, web) — WAN/modem queueing suspected; investigate bufferbloat and paths.'],
];
$device = [
'manufacturer' => 'roadmap',
'model' => 'planning',
'sdk_int' => 34,
'release' => '14',
];
$app = ['package' => 'com.foxx.androidcast', 'version_name' => 'roadmap', 'version_code' => 1];
$now = (int) round(microtime(true) * 1000);
$ok = 0;
$fail = 0;
foreach ($tasks as $task) {
$text = trim($task['text']);
$key = $task['key'];
$status = $task['status'];
$payload = [
'schema_version' => 1,
'ingest_kind' => 'ticket',
'ticket_id' => 'roadmap-20260604-' . preg_replace('/[^a-z0-9.-]+/i', '-', $key),
'title' => mb_substr($text, 0, 256),
'brief' => mb_substr($text, 0, 512),
'body' => $text,
'opened_at_epoch_ms' => $now,
'rating' => $status === 'closed' ? 1 : 3,
'owner' => $owner,
'tags' => roadmap_tags($status),
'device' => $device,
'app' => $app,
];
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($dryRun) {
echo "DRY {$key} [{$status}]\n";
$ok++;
continue;
}
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => $json,
'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 ') . "{$key}\n";
$ok++;
} else {
echo "FAIL {$key} HTTP {$code}{$resp}\n";
$fail++;
}
}
echo "Done: ok={$ok} fail={$fail}\n";
exit($fail > 0 ? 1 : 0);

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env php
<?php
/**
* File infra validation findings as tickets (tag 20260608-validation).
*
* Usage:
* php scripts/ingest_validation_findings_20260608.php [--url=BASE] [--dry-run]
*/
declare(strict_types=1);
$opts = getopt('', ['url::', 'dry-run']);
$base = rtrim($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/crashes', '/');
$dryRun = isset($opts['dry-run']);
$uploadUrl = $base . '/api/ticket_upload.php';
function val_tags(string $kind): array {
$base = [
['id' => 'ticket', 'label' => 'ticket', 'bg' => '#6366f1'],
['id' => '20260608-validation', 'label' => '20260608-validation', 'bg' => '#f59e0b'],
['id' => 'infra', 'label' => 'infra', 'bg' => '#0ea5e9'],
];
if ($kind === 'issue') {
$base[] = ['id' => 'issue', 'label' => 'issue', 'bg' => '#94a3b8'];
}
return $base;
}
$tasks = [
[
'key' => 'SVC-OTA',
'kind' => 'ticket',
'text' => 'ISSUE SVC-OTA: OTA channel HTTP 400 on apps.f0xx.org — wire /v0/ota/ on apps vhost or align device ota.base_url. See docs/20260608_BE_SERVICES_and_infra.md',
],
[
'key' => 'SVC-URL',
'kind' => 'ticket',
'text' => 'ISSUE SVC-URL: Legacy f0xx.org crash upload URL 404 — production base should be apps.f0xx.org. Update settings.json / dev defaults.',
],
[
'key' => 'SVC-DIAG',
'kind' => 'issue',
'text' => 'ISSUE SVC-DIAG: diag.php returns 403 at public edge — acceptable; devices use upload.php + heartbeat.php. Documented in BE services map.',
],
[
'key' => 'SFU-SPEC',
'kind' => 'issue',
'text' => 'PLANNING SFU-SPEC: Janus/mediasoup relay post-alpha — branch feature/sfu-relay (hidden). Await owner spec. Gray section in docs/20260608_BE_SERVICES_and_infra.md',
],
[
'key' => 'graphs-5.1',
'kind' => 'ticket',
'status' => 'closed',
'text' => 'CLOSED 5.1: Graph brick drill-down — full overlay, axes, legend, hover implemented in graphs.js (2026-06-08).',
],
[
'key' => 'graphs-5.2',
'kind' => 'ticket',
'status' => 'closed',
'text' => 'CLOSED 5.2: Graph columns 1/2/4, clickable bricks, full-width shell--graphs-full CSS (2026-06-08).',
],
[
'key' => 'browser-js',
'kind' => 'ticket',
'status' => 'closed',
'text' => 'CLOSED: Browser SyntaxError on console JS — deployed assets pass node --check; validate_be_services.sh added.',
],
];
$device = ['manufacturer' => 'validation', 'model' => 'infra', 'sdk_int' => 34, 'release' => '14'];
$app = ['package' => 'com.foxx.androidcast', 'version_name' => 'validation', 'version_code' => 1];
$now = (int) round(microtime(true) * 1000);
foreach ($tasks as $task) {
$text = trim($task['text']);
$key = $task['key'];
$status = $task['status'] ?? 'open';
$tags = val_tags($task['kind']);
if ($status === 'closed') {
$tags[] = ['id' => 'closed', 'label' => 'closed', 'bg' => '#6b7280'];
} else {
$tags[] = ['id' => 'open', 'label' => 'open', 'bg' => '#22c55e'];
}
$payload = [
'schema_version' => 1,
'ingest_kind' => 'ticket',
'ticket_id' => 'validation-20260608-' . preg_replace('/[^a-z0-9.-]+/i', '-', $key),
'title' => mb_substr($text, 0, 256),
'brief' => mb_substr($text, 0, 512),
'body' => $text,
'opened_at_epoch_ms' => $now,
'rating' => $status === 'closed' ? 1 : 3,
'owner' => 'admin',
'tags' => $tags,
'device' => $device,
'app' => $app,
];
if ($dryRun) {
echo "DRY {$key}\n";
continue;
}
$ch = curl_init($uploadUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
CURLOPT_TIMEOUT => 30,
]);
$resp = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300) {
echo "OK {$key} HTTP {$code}\n";
} else {
echo "FAIL {$key} HTTP {$code} {$resp}\n";
}
}

View File

@@ -0,0 +1,47 @@
#!/bin/sh
# Fresh MariaDB setup for crash reporter (run on BE as root or with mysql admin).
# Usage:
# ./scripts/init-mariadb.sh
# CRASH_DB_PASS='secret' ./scripts/init-mariadb.sh
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SCHEMA="$ROOT/sql/schema.mariadb.sql"
MIG002="$ROOT/sql/migrations/002_reports_company_columns.sql"
MIG003="$ROOT/sql/migrations/003_tickets.sql"
DB_NAME="${CRASH_DB_NAME:-androidcast_crashes}"
DB_USER="${CRASH_DB_USER:-androidcast}"
DB_PASS="${CRASH_DB_PASS:-}"
if [ ! -f "$SCHEMA" ]; then
echo "Missing $SCHEMA" >&2
exit 1
fi
echo "Applying schema from $SCHEMA ..."
mysql -u root -p < "$SCHEMA"
if [ -f "$MIG002" ]; then
echo "Applying RBAC report columns (002) ..."
mysql -u root -p "$DB_NAME" < "$MIG002"
fi
if [ -f "$MIG003" ]; then
echo "Applying tickets table (003) ..."
mysql -u root -p "$DB_NAME" < "$MIG003"
fi
if [ -n "$DB_PASS" ]; then
echo "Creating app user ${DB_USER}@localhost and @127.0.0.1 ..."
mysql -u root -p <<EOF
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
CREATE USER IF NOT EXISTS '${DB_USER}'@'127.0.0.1' IDENTIFIED BY '${DB_PASS}';
GRANT SELECT, INSERT, UPDATE, DELETE ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON ${DB_NAME}.* TO '${DB_USER}'@'127.0.0.1';
FLUSH PRIVILEGES;
EOF
else
echo "Skip app user: set CRASH_DB_PASS to create '${DB_USER}'@localhost and @127.0.0.1"
fi
echo "Done. Set config.php: db.driver = mysql, db.mysql.* then restart php-fpm."

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Run on Alpine BE (artc0) as root — installs builder nginx + verifies.
set -euo pipefail
WEB="/var/www/localhost/htdocs/apps/app/androidcast_project"
BUILD_PUBLIC="$WEB/android_cast/examples/build_console/backend/public"
APPS_CONF="/etc/nginx/conf.d/apps.conf"
BUILDER_FRAG="/etc/nginx/conf.d/apps_builder.frag"
if [[ ! -f "$BUILD_PUBLIC/index.php" ]]; then
echo "Missing $BUILD_PUBLIC/index.php" >&2
exit 1
fi
cat > "$BUILDER_FRAG" << EOF
location = /app/androidcast_project/build {
return 301 /app/androidcast_project/build/;
}
location ^~ /app/androidcast_project/build/assets/ {
alias $BUILD_PUBLIC/assets/;
}
location ^~ /app/androidcast_project/build/ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME $BUILD_PUBLIC/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/build/index.php;
fastcgi_param REQUEST_URI \$request_uri;
client_max_body_size 512m;
fastcgi_read_timeout 1800s;
}
EOF
echo "Wrote $BUILDER_FRAG"
grep SCRIPT_FILENAME "$BUILDER_FRAG"
if ! grep -q 'include /etc/nginx/conf.d/apps_builder.frag' "$APPS_CONF"; then
echo "WARN: $APPS_CONF does not include apps_builder.frag inside listen 443 server block." >&2
echo "Add: include /etc/nginx/conf.d/apps_builder.frag; before the closing } of the 443 server." >&2
fi
nginx -t
rc-service nginx reload
echo
echo "=== effective build locations ==="
nginx -T 2>/dev/null | grep -A6 'androidcast_project/build/' | head -40
echo
echo "=== curl BE :443 ==="
curl -sSI -H 'Host: apps.f0xx.org' 'https://127.0.0.1/app/androidcast_project/build/' -k \
| grep -E 'HTTP|location|x-powered-by' || true
echo
echo "If still 404, check error log:"
echo " tail -20 /var/log/nginx/apps.intra.raptor.org.error_log"

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Run on Gentoo FE as root — wire build proxy like hub/crashes (BE :443).
set -euo pipefail
NGINX_CONF="/etc/nginx/nginx.conf"
APPS_CONF="/etc/nginx/apps.conf"
SNIPPET=' location /app/androidcast_project/build/ {
proxy_pass https://artc0.intra.raptor.org/app/androidcast_project/build/;
}
'
if grep -q 'androidcast_project/build/' "$NGINX_CONF"; then
echo "nginx.conf already has androidcast_project/build/ location"
else
if ! grep -q 'androidcast_project/crashes/' "$NGINX_CONF"; then
echo "Cannot find crashes block in $NGINX_CONF — add build block manually after crashes." >&2
exit 1
fi
python3 - "$NGINX_CONF" "$SNIPPET" << 'PY'
import sys
from pathlib import Path
path, snippet = Path(sys.argv[1]), sys.argv[2]
text = path.read_text()
needle = "\t\tlocation /app/androidcast_project/crashes/ {\n"
idx = text.find(needle)
if idx < 0:
raise SystemExit("crashes location not found")
end = text.find("\n\t\t}", idx)
if end < 0:
raise SystemExit("crashes block end not found")
end = text.find("\n", end + 1)
path.write_text(text[:end] + "\n" + snippet + text[end:])
print(f"Patched {path}")
PY
fi
if [[ -f "$APPS_CONF" ]]; then
if grep -q 'proxy_pass http://artc0.intra.raptor.org:80' "$APPS_CONF" 2>/dev/null; then
cp -a "$APPS_CONF" "${APPS_CONF}.bak.$(date +%Y%m%d%H%M%S)"
sed -i 's|proxy_pass http://artc0.intra.raptor.org:80;|# DISABLED — use nginx.conf :443 upstream\n\t\t # proxy_pass http://artc0.intra.raptor.org:80;|' "$APPS_CONF" || true
echo "Commented stale :80 build proxy in $APPS_CONF (backup created)"
fi
fi
nginx -t
rc-service nginx reload
echo
echo "=== FE localhost curl ==="
curl -sSI -H 'Host: apps.f0xx.org' 'http://127.0.0.1/app/androidcast_project/build/' -k 2>/dev/null \
| grep -E 'HTTP|location|x-powered-by' || \
curl -sSI -H 'Host: apps.f0xx.org' 'https://127.0.0.1/app/androidcast_project/build/' -k \
| grep -E 'HTTP|location|x-powered-by'
echo
echo "Public check:"
echo " curl -sSI 'https://apps.f0xx.org/app/androidcast_project/build/' | grep -E 'HTTP|location|x-powered-by'"

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Run on Gentoo FE as root — proxy /v0/ota/ to BE listen 80 (static OTA tree).
# Superseded by consolidated apps.conf (nginx/fe/apps.conf + fe-nginx-apps-consolidation.patch).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
SNIPPET_FILE="$REPO_ROOT/examples/crash_reporter/backend/nginx.fe-ota.snippet"
NGINX_CONF="${1:-/etc/nginx/nginx.conf}"
if [[ ! -f "$NGINX_CONF" ]]; then
echo "Usage: $0 [path/to/nginx.conf]" >&2
exit 1
fi
if grep -q 'location /v0/ota/' "$NGINX_CONF"; then
echo "$NGINX_CONF already has location /v0/ota/"
nginx -t
rc-service nginx reload
exit 0
fi
python3 - "$NGINX_CONF" "$SNIPPET_FILE" << 'PY'
import sys
from pathlib import Path
path = Path(sys.argv[1])
snippet_path = Path(sys.argv[2])
snippet = snippet_path.read_text()
# Strip comment header lines; keep tab-indented location block only.
lines = []
for line in snippet.splitlines():
if line.startswith("#") or not line.strip():
continue
lines.append(line)
block = "\n".join(lines) + "\n"
text = path.read_text()
needles = [
"\t\tlocation /app/androidcast_project/ {\n",
"\t\tlocation /app/androidcast_project/crashes/ {\n",
]
idx = -1
for needle in needles:
idx = text.find(needle)
if idx >= 0:
break
if idx < 0:
raise SystemExit("Could not find androidcast location block in nginx.conf — add nginx.fe-ota.snippet manually")
path.write_text(text[:idx] + block + text[idx:])
print(f"Patched {path} with /v0/ota/ proxy → BE :80")
PY
nginx -t
rc-service nginx reload
echo
echo "=== Verify ==="
curl -sS -o /dev/null -w 'public OTA HTTP %{http_code}\n' \
'https://apps.f0xx.org/v0/ota/channel/stable.json' || true

View File

@@ -0,0 +1,27 @@
#!/bin/sh
# Apply tickets table migration (003) on existing MariaDB installs.
# Requires MySQL root (app user androidcast cannot CREATE TABLE).
#
# Usage (from backend/):
# ./scripts/migrate-003-tickets.sh
# CRASH_DB_NAME=my_db ./scripts/migrate-003-tickets.sh
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SQL="$ROOT/sql/migrations/003_tickets.sql"
DB_NAME="${CRASH_DB_NAME:-androidcast_crashes}"
if [ ! -f "$SQL" ]; then
echo "Missing $SQL" >&2
exit 1
fi
echo "Applying tickets migration to database: $DB_NAME"
echo "File: $SQL"
echo "(you will be prompted for MySQL root password)"
mysql -u root -p "$DB_NAME" < "$SQL"
echo "Verifying tickets table..."
mysql -u root -p "$DB_NAME" -e "SHOW TABLES LIKE 'tickets'; DESCRIBE tickets;" | head -20
echo "Done. Restart php-fpm if needed, then reload Tickets in the console."

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/** Convert graph_session NDJSON to MySQL bulk INSERT SQL. */
$opts = getopt('', ['file::', 'out::']);
$file = $opts['file'] ?? '/tmp/fake_graph_sessions.ndjson';
$out = $opts['out'] ?? '/tmp/fake_graph_sessions.sql';
if (!is_readable($file)) {
fwrite(STDERR, "File not found: $file\n");
exit(1);
}
function sqlQuote(?string $s): string {
if ($s === null) {
return 'NULL';
}
return "'" . str_replace(["\\", "'"], ["\\\\", "''"], $s) . "'";
}
$fh = fopen($out, 'wb');
if (!$fh) {
fwrite(STDERR, "Cannot write $out\n");
exit(1);
}
fwrite($fh, "SET NAMES utf8mb4;\nSTART TRANSACTION;\n");
$batch = [];
$batchSize = 50;
$total = 0;
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$p = json_decode($line, true);
if (!is_array($p)) {
continue;
}
$dir = strtolower((string) ($p['direction'] ?? 'sender'));
if ($dir !== 'sender' && $dir !== 'receiver') {
$dir = 'sender';
}
$completed = !empty($p['completed']) ? 1 : 0;
$meta = isset($p['meta']) && is_array($p['meta'])
? json_encode($p['meta'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
: null;
$batch[] = '(' . implode(', ', [
sqlQuote((string) ($p['session_id'] ?? '')),
(int) ($p['company_id'] ?? 1),
(int) ($p['user_id'] ?? 1),
sqlQuote((string) ($p['device_id'] ?? '')),
sqlQuote((string) ($p['app_version'] ?? '')),
(int) ($p['sdk_int'] ?? 0),
(int) ($p['started_at_epoch_ms'] ?? 0),
(int) ($p['ended_at_epoch_ms'] ?? 0),
(int) ($p['duration_s'] ?? 0),
$completed,
sqlQuote($dir),
sqlQuote((string) ($p['transport'] ?? 'wifi')),
(int) ($p['avg_kbps'] ?? 0),
(int) ($p['recv_kbps'] ?? 0),
number_format((float) ($p['packet_loss_pct'] ?? 0), 3, '.', ''),
sqlQuote((string) ($p['ntp_source'] ?? 'device')),
isset($p['ntp_correction_s']) ? (int) $p['ntp_correction_s'] : 'NULL',
sqlQuote((string) ($p['install_source'] ?? 'direct')),
$meta !== null ? sqlQuote($meta) : 'NULL',
]) . ')';
$total++;
if (count($batch) >= $batchSize) {
fwrite($fh, "INSERT INTO graph_sessions\n");
fwrite($fh, " (session_id, company_id, user_id, device_id, app_version, sdk_int,\n");
fwrite($fh, " started_at_ms, ended_at_ms, duration_s, completed, direction, transport,\n");
fwrite($fh, " avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)\n");
fwrite($fh, "VALUES\n " . implode(",\n ", $batch) . "\n");
fwrite($fh, "ON DUPLICATE KEY UPDATE\n");
fwrite($fh, " ended_at_ms=VALUES(ended_at_ms), duration_s=VALUES(duration_s), completed=VALUES(completed),\n");
fwrite($fh, " avg_kbps=VALUES(avg_kbps), recv_kbps=VALUES(recv_kbps), packet_loss_pct=VALUES(packet_loss_pct),\n");
fwrite($fh, " meta_json=VALUES(meta_json);\n");
$batch = [];
}
}
if ($batch) {
fwrite($fh, "INSERT INTO graph_sessions\n");
fwrite($fh, " (session_id, company_id, user_id, device_id, app_version, sdk_int,\n");
fwrite($fh, " started_at_ms, ended_at_ms, duration_s, completed, direction, transport,\n");
fwrite($fh, " avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)\n");
fwrite($fh, "VALUES\n " . implode(",\n ", $batch) . "\n");
fwrite($fh, "ON DUPLICATE KEY UPDATE\n");
fwrite($fh, " ended_at_ms=VALUES(ended_at_ms), duration_s=VALUES(duration_s), completed=VALUES(completed),\n");
fwrite($fh, " avg_kbps=VALUES(avg_kbps), recv_kbps=VALUES(recv_kbps), packet_loss_pct=VALUES(packet_loss_pct),\n");
fwrite($fh, " meta_json=VALUES(meta_json);\n");
}
fwrite($fh, "COMMIT;\n");
fclose($fh);
echo "Wrote $total rows to $out\n";

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Create BE OTA artifact directory for nginx location /v0/ota/ (run on alpine-be).
#
# Usage (on BE as root):
# sudo ./prepare-be-ota-mount.sh
# sudo ./prepare-be-ota-mount.sh /custom/path/to/ota-artifacts
#
# After mkdir, publish artifacts:
# rsync -av ./out/ota/v0/ /var/www/.../ota-artifacts/v0/
# Or enable builder config build.ota_mount + auto_deploy.
set -euo pipefail
OTA_ROOT="${1:-/var/www/localhost/htdocs/apps/app/androidcast_project/ota-artifacts}"
V0="${OTA_ROOT}/v0"
mkdir -p "${V0}/ota/channel"
chown -R nginx:nginx "${OTA_ROOT}"
chmod -R 775 "${OTA_ROOT}"
echo "OTA mount ready: ${OTA_ROOT}"
echo "nginx alias target: ${V0}/ota/"
echo "Channel URL: https://apps.f0xx.org/v0/ota/channel/stable.json"
echo
echo "Publish example:"
echo " rsync -av out/ota/v0/ ${V0}/"

View File

@@ -0,0 +1,30 @@
#!/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] [--no-prune-wg]
*/
require_once __DIR__ . '/../src/bootstrap.php';
require_once __DIR__ . '/../src/RemoteAccessRepository.php';
require_once __DIR__ . '/../src/WireGuardPeerProvisioner.php';
$hours = 24;
$pruneWg = true;
foreach ($argv as $arg) {
if (str_starts_with($arg, '--hours=')) {
$hours = max(1, (int) substr($arg, 8));
} elseif ($arg === '--no-prune-wg') {
$pruneWg = false;
}
}
RemoteAccessRepository::ensureSchema();
$n = RemoteAccessRepository::purgeStale($hours);
fwrite(STDOUT, "purged_sessions={$n}\n");
if ($pruneWg) {
$pruned = RemoteAccessRepository::pruneOrphanWireGuardPeers(false);
fwrite(STDOUT, 'pruned_wg_peers=' . count($pruned) . "\n");
}

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# Simulate Android device remote-access heartbeats (type: ra) from Linux CLI.
#
# Usage:
# ./scripts/ra_device_sim.sh disable
# ./scripts/ra_device_sim.sh enable --once
# ./scripts/ra_device_sim.sh poll --until connect
# CRASHES_BASE=https://apps.f0xx.org/.../crashes ./scripts/ra_device_sim.sh poll --until connect --apply-wg
#
# Env: CRASHES_BASE, RA_DEVICE_ID, RA_RANDOM_FILE, RA_APP_VERSION
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=ra_lib.sh
source "$ROOT/scripts/ra_lib.sh"
DEVICE_ID="${RA_DEVICE_ID:-ra-cli-$(hostname -s | tr ' ' '-')}"
RANDOM_FILE="${RA_RANDOM_FILE:-/tmp/androidcast-ra-${DEVICE_ID}.random}"
APP_VERSION="${RA_APP_VERSION:-cli-sim/1.0}"
APPLY_WG=0
POLL_UNTIL=""
ONCE=0
CMD="${1:-help}"
shift || true
while [[ $# -gt 0 ]]; do
case "$1" in
--device-id) DEVICE_ID="$2"; shift 2 ;;
--random-file) RANDOM_FILE="$2"; shift 2 ;;
--app-version) APP_VERSION="$2"; shift 2 ;;
--apply-wg) APPLY_WG=1; shift ;;
--once) ONCE=1; shift ;;
--until) POLL_UNTIL="$2"; shift 2 ;;
--base) export CRASHES_BASE="$2"; shift 2 ;;
-h|--help) CMD=help; shift ;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
done
load_random() {
if [[ -f "$RANDOM_FILE" ]]; then
cat "$RANDOM_FILE"
else
openssl rand -hex 16 2>/dev/null || date +%s
fi
}
save_random() {
local r="$1"
echo "$r" >"$RANDOM_FILE"
}
usage() {
cat <<'EOF'
AndroidCast remote-access device simulator (heartbeat type: ra)
Commands:
disable POST status:disable (like app "Disabled" mode)
enable POST status:enable once
poll Poll enable; use --until connect|wait|disabled|deny
show-random Print persisted device random (like AppPreferences)
Options:
--base URL CRASHES_BASE (default local orchestration crashes URL)
--device-id ID Stable device_id (default ra-cli-<hostname>)
--random-file F Persist random across polls (like app prefs)
--app-version V Sent as app_version on heartbeat
--apply-wg On connect: write wg-quick conf and run wg-quick up (needs root)
--once Single poll (default for enable/poll)
--until ACTION Stop polling when heartbeat.action matches (poll mode)
Examples:
./scripts/ra_device_sim.sh disable
./scripts/ra_device_sim.sh enable --once
./scripts/ra_device_sim.sh poll --until connect
CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes \
./scripts/ra_device_sim.sh poll --until connect --apply-wg
EOF
}
cmd_disable() {
local rnd
rnd="$(load_random)"
save_random "$rnd"
echo "== ra disable device_id=$DEVICE_ID random=$rnd"
local resp action
resp="$(ra_ra_post disable "$DEVICE_ID" "$rnd" "$APP_VERSION")"
action="$(echo "$resp" | ra_json -r '.heartbeat.action // empty')"
echo "action=$action"
echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp"
}
cmd_enable_once() {
local rnd
rnd="$(load_random)"
save_random "$rnd"
echo "== ra enable device_id=$DEVICE_ID random=$rnd"
local resp action
resp="$(ra_ra_post enable "$DEVICE_ID" "$rnd" "$APP_VERSION")"
action="$(echo "$resp" | ra_json -r '.heartbeat.action // empty')"
echo "action=$action"
echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp"
}
cmd_poll() {
local until="${POLL_UNTIL:-connect}"
local rnd
rnd="$(load_random)"
save_random "$rnd"
echo "== ra poll until action=$until (device_id=$DEVICE_ID)"
while true; do
local resp action
resp="$(ra_ra_post enable "$DEVICE_ID" "$rnd" "$APP_VERSION")"
action="$(echo "$resp" | ra_json -r '.heartbeat.action // empty')"
echo "$(date -Is) action=$action"
if [[ "$action" == "$until" ]]; then
echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp"
if [[ "$action" == "connect" && "$APPLY_WG" -eq 1 ]]; then
apply_wg "$resp"
fi
return 0
fi
if [[ "$ONCE" -eq 1 ]]; then
echo "$resp" | ra_json '.' 2>/dev/null || echo "$resp"
return 0
fi
sleep "${RA_POLL_INTERVAL_S:-3}"
done
}
apply_wg() {
local resp="$1"
if ! command -v wg-quick >/dev/null 2>&1; then
echo "WARN: wg-quick not installed; skipping tunnel bring-up" >&2
ra_wg_quick_from_connect "$resp" >"/tmp/ra-${DEVICE_ID}.conf"
echo "Wrote /tmp/ra-${DEVICE_ID}.conf"
return 0
fi
local conf="/tmp/ra-${DEVICE_ID}.conf"
ra_wg_quick_from_connect "$resp" >"$conf"
echo "== wg-quick up $conf (sudo)"
sudo wg-quick up "$conf" || {
echo "FAIL: wg-quick up (run as root or check endpoint UDP)" >&2
return 1
}
wg show 2>/dev/null | head -20 || true
}
case "$CMD" in
disable) cmd_disable ;;
enable) ONCE=1; cmd_enable_once ;;
poll) cmd_poll ;;
show-random) load_random; echo ;;
help|-h|--help) usage ;;
*)
echo "Unknown command: $CMD" >&2
usage
exit 2
;;
esac

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# End-to-end remote-access validation: operator + device (CLI simulates Android).
#
# Flow (matches app + dashboard):
# 1. device disable (clean slate)
# 2. device enable → wait (not whitelisted)
# 3. operator whitelist + open session
# 4. device poll → connect
# 5. operator close session
# 6. device disable
#
# Usage:
# ./scripts/ra_e2e_cli.sh
# CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/ra_e2e_cli.sh
# ./scripts/ra_e2e_cli.sh --apply-wg
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=ra_lib.sh
source "$ROOT/scripts/ra_lib.sh"
APPLY_WG=0
DEVICE_ID="ra-e2e-$(date +%s)"
while [[ $# -gt 0 ]]; do
case "$1" in
--apply-wg) APPLY_WG=1; shift ;;
--base) export CRASHES_BASE="$2"; shift 2 ;;
--device-id) DEVICE_ID="$2"; shift 2 ;;
-h|--help)
echo "Usage: $0 [--base URL] [--device-id ID] [--apply-wg]"
exit 0
;;
*) echo "Unknown: $1" >&2; exit 2 ;;
esac
done
export RA_DEVICE_ID="$DEVICE_ID"
export RA_RANDOM_FILE="/tmp/androidcast-ra-${DEVICE_ID}.random"
COOKIE_JAR="$(mktemp)"
trap 'rm -f "$COOKIE_JAR"' EXIT
echo "== Remote access E2E CLI =="
echo "BASE=$(ra_base)"
echo "DEVICE_ID=$DEVICE_ID"
echo
echo "== 1. device disable =="
bash "$ROOT/scripts/ra_device_sim.sh" disable --device-id "$DEVICE_ID" --base "$(ra_base)"
echo
echo "== 2. device enable (expect wait) =="
bash "$ROOT/scripts/ra_device_sim.sh" enable --once --device-id "$DEVICE_ID" --base "$(ra_base)" | tee /tmp/ra-e2e-enable.out
grep -q 'action=wait' /tmp/ra-e2e-enable.out || {
echo "FAIL: expected wait before whitelist" >&2
exit 1
}
echo OK
echo
echo "== 3. operator login + whitelist =="
ra_admin_login "$COOKIE_JAR"
ra_admin_post "$COOKIE_JAR" whitelist "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" | ra_json '.'
echo OK whitelist
echo
echo "== 4. operator open session =="
ra_admin_post "$COOKIE_JAR" open_session "{\"device_id\":\"$DEVICE_ID\"}" | ra_json '.'
echo OK open_session
echo
echo "== 5. device poll until connect =="
WG_ARGS=()
[[ "$APPLY_WG" -eq 1 ]] && WG_ARGS+=(--apply-wg)
bash "$ROOT/scripts/ra_device_sim.sh" poll --until connect --device-id "$DEVICE_ID" --base "$(ra_base)" "${WG_ARGS[@]}"
echo OK connect
echo
echo "== 6. operator dashboard =="
ra_admin_post "$COOKIE_JAR" dashboard '{}' 2>/dev/null || \
curl -sf -b "$COOKIE_JAR" "$(ra_admin_url dashboard)" | ra_json '{ok, permissions}'
echo
echo "== 7. operator close session =="
SESSION_ID="$(curl -sf -b "$COOKIE_JAR" "$(ra_admin_url sessions)" | ra_json -r '.sessions[] | select(.device_id=="'"$DEVICE_ID"'") | .session_id' | head -1)"
if [[ -z "$SESSION_ID" ]]; then
SESSION_ID="$(curl -sf -b "$COOKIE_JAR" "$(ra_admin_url dashboard)" | ra_json -r '.active_sessions[0].session_id // empty')"
fi
if [[ -n "$SESSION_ID" ]]; then
ra_admin_post "$COOKIE_JAR" close_session "{\"session_id\":\"$SESSION_ID\",\"device_id\":\"$DEVICE_ID\"}" | ra_json '.'
echo OK close_session "$SESSION_ID"
else
echo "WARN: no session_id to close"
fi
echo
echo "== 8. device disable =="
bash "$ROOT/scripts/ra_device_sim.sh" disable --device-id "$DEVICE_ID" --base "$(ra_base)"
echo
echo "== E2E CLI passed =="

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Shared helpers for remote-access CLI validation (device + operator).
# Mirrors Android RemoteAccessService heartbeat payloads.
set -euo pipefail
RA_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ra_json() {
if command -v jq >/dev/null 2>&1; then
jq -r "$@"
else
echo "jq required for: $*" >&2
return 1
fi
}
ra_base() {
echo "${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
}
ra_heartbeat_url() {
echo "$(ra_base)/api/heartbeat.php"
}
ra_admin_url() {
local action="${1:-}"
echo "$(ra_base)/api/remote_access.php?action=${action}"
}
# POST heartbeat type:ra — prints full JSON response to stdout.
# Usage: ra_ra_post enable|disable DEVICE_ID RANDOM [app_version]
ra_ra_post() {
local status="$1"
local device_id="$2"
local random="${3:-}"
local app_version="${4:-cli-sim}"
local mode="${RA_TUNNEL_MODE:-wireguard}"
local caps
if [[ "$mode" == "rssh" ]]; then
caps='["rssh","shell"]'
else
caps='["wireguard","files_app_home"]'
fi
local body
body=$(printf '{"heartbeat":{"type":"ra","status":"%s","device_id":"%s","random":"%s","app_version":"%s","tunnel_mode":"%s","capabilities":%s}}' \
"$status" "$device_id" "$random" "$app_version" "$mode" "$caps")
local url body_tmp code
url="$(ra_heartbeat_url)"
body_tmp="$(mktemp)"
code=$(curl -sS -o "$body_tmp" -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$body" "$url")
if [[ "$code" != "200" ]]; then
echo "heartbeat HTTP $code from $url" >&2
cat "$body_tmp" >&2
rm -f "$body_tmp"
return 1
fi
cat "$body_tmp"
rm -f "$body_tmp"
}
ra_ra_action() {
ra_ra_post "$@" | ra_json -r '.heartbeat.action // empty'
}
ra_admin_login() {
local jar="${1:?cookie jar}"
local user="${ADMIN_USER:-admin}"
local pass="${ADMIN_PASS:-admin}"
curl -sf -c "$jar" -b "$jar" -X POST \
-d "username=${user}&password=${pass}" \
"$(ra_base)/login" >/dev/null
}
ra_admin_post() {
local jar="$1"
local action="$2"
local json_body="$3"
curl -sf -b "$jar" -c "$jar" -X POST -H 'Content-Type: application/json' \
-d "$json_body" "$(ra_admin_url "$action")"
}
# Build wireguard-quick config from connect JSON (same fields as WireGuardConfigBuilder.java).
ra_wg_quick_from_connect() {
local connect_json="$1"
if ! command -v jq >/dev/null 2>&1; then
return 1
fi
local priv addr peer_pub endpoint allowed
priv=$(echo "$connect_json" | jq -r '.heartbeat.credentials.interface_private_key // empty')
addr=$(echo "$connect_json" | jq -r '.heartbeat.credentials.interface_address // "172.200.2.10/32"')
peer_pub=$(echo "$connect_json" | jq -r '.heartbeat.credentials.peer_public_key // empty')
endpoint=$(echo "$connect_json" | jq -r '.heartbeat.endpoint // .heartbeat.credentials.peer_endpoint // empty')
allowed=$(echo "$connect_json" | jq -r '.heartbeat.credentials.peer_allowed_ips // "0.0.0.0/0"')
if [[ -z "$priv" || -z "$peer_pub" ]]; then
return 1
fi
cat <<EOF
[Interface]
PrivateKey = ${priv}
Address = ${addr}
[Peer]
PublicKey = ${peer_pub}
EOF
if [[ -n "$endpoint" ]]; then
echo "Endpoint = ${endpoint}"
fi
echo "AllowedIPs = ${allowed}"
echo "PersistentKeepalive = 25"
}

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# UDP / WireGuard data-plane debug from a Linux laptop (task 4).
# HTTP control plane must work first (ra_e2e_cli.sh). See docs/REMOTE_ACCESS_VALIDATION.md
# and proposals Appendix 1.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=ra_lib.sh
source "$ROOT/scripts/ra_lib.sh"
WG_HOST="${WG_HOST:-ra.apps.f0xx.org}"
WG_PORT="${WG_PORT:-45340}"
BE_WG_IP="${BE_WG_IP:-172.200.2.1}"
DEVICE_ID="${RA_DEVICE_ID:-ra-udp-$(hostname -s | tr ' ' '-')}"
CONF="/tmp/ra-udp-${DEVICE_ID}.conf"
APPLY="${RA_UDP_APPLY:-0}"
SKIP_HTTP="${RA_UDP_SKIP_HTTP:-0}"
usage() {
cat <<EOF
Usage: $0 [--apply] [--device-id ID] [--skip-http]
Env:
CRASHES_BASE, ADMIN_USER, ADMIN_PASS — operator API
RA_DEVICE_ID, WG_HOST (default ra.apps.f0xx.org), WG_PORT (45340)
BE_WG_IP (172.200.2.1) — ping target on tunnel
RA_UDP_APPLY=1 — same as --apply (wg-quick up)
Steps: DNS → UDP port → optional HTTP E2E → connect JSON → wg conf → optional tunnel → ping
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--apply) APPLY=1; shift ;;
--device-id) DEVICE_ID="$2"; shift 2 ;;
--skip-http) SKIP_HTTP=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown: $1" >&2; usage; exit 2 ;;
esac
done
export RA_DEVICE_ID="$DEVICE_ID"
export RA_RANDOM_FILE="/tmp/androidcast-ra-${DEVICE_ID}.random"
note() { echo "== $*"; }
ok() { echo "OK $*"; }
warn() { echo "WARN $*"; }
fail() { echo "FAIL $*"; exit 1; }
note "RA UDP debug — device_id=$DEVICE_ID"
note "BASE=$(ra_base)"
note "WG endpoint ${WG_HOST}:${WG_PORT}"
note "1. DNS (CNAME chain)"
APPS_IP="$(dig +short apps.f0xx.org A 2>/dev/null | head -1)"
RA_IP="$(dig +short "$WG_HOST" A 2>/dev/null | head -1)"
echo "apps.f0xx.org A: ${APPS_IP:-?}"
echo "${WG_HOST} A: ${RA_IP:-?}"
if [[ -n "$APPS_IP" && -n "$RA_IP" && "$APPS_IP" == "$RA_IP" ]]; then
ok "ra host resolves same as apps (CNAME OK)"
else
warn "A records differ or missing — check CNAME/DNS"
fi
note "2. UDP reachability"
if command -v nc >/dev/null 2>&1; then
if nc -zvu -w 3 "$WG_HOST" "$WG_PORT" 2>&1 | grep -qiE 'succeeded|open'; then
ok "UDP ${WG_PORT} appears open (nc)"
else
warn "UDP ${WG_PORT} not confirmed open — FE DNAT / firewall?"
fi
else
warn "nc not found — skip UDP probe"
fi
if [[ "$SKIP_HTTP" -eq 0 ]]; then
note "3. HTTP control plane (quick)"
if CRASHES_BASE="$(ra_base)" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then
ok "test_remote_access_api.sh"
else
warn "API smoke failed — fix HTTP before UDP"
fi
else
note "3. HTTP skipped"
fi
note "4. Operator: whitelist + open session (if not already)"
COOKIE_JAR="$(mktemp)"
trap 'rm -f "$COOKIE_JAR"; sudo wg-quick down "$CONF" 2>/dev/null || true' EXIT
ra_admin_login "$COOKIE_JAR"
ra_admin_post "$COOKIE_JAR" whitelist "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" >/dev/null
ra_admin_post "$COOKIE_JAR" open_session "{\"device_id\":\"$DEVICE_ID\"}" >/dev/null
ok "whitelist + open_session"
note "5. Device poll → connect (may wait min_poll_interval_s)"
export RA_DEVICE_ID="$DEVICE_ID"
bash "$ROOT/scripts/ra_device_sim.sh" poll --until connect --device-id "$DEVICE_ID" --base "$(ra_base)" > /tmp/ra-udp-connect.json
grep -q '"action":"connect"' /tmp/ra-udp-connect.json || fail "no connect in response"
ok "heartbeat connect"
note "6. Build wg-quick config"
RESP="$(cat /tmp/ra-udp-connect.json)"
ra_wg_quick_from_connect "$RESP" >"$CONF"
echo "--- $CONF ---"
sed 's/PrivateKey = .*/PrivateKey = <redacted>/' "$CONF"
ok "wrote $CONF"
if [[ "$APPLY" -eq 1 ]]; then
command -v wg-quick >/dev/null 2>&1 || fail "wg-quick not installed"
note "7. Bring up tunnel"
sudo wg-quick up "$CONF"
sudo wg show
note "8. Ping BE wg (${BE_WG_IP})"
if ping -c 3 -W 2 "$BE_WG_IP"; then
ok "ping ${BE_WG_IP}"
else
warn "ping failed — routing or AllowedIPs (expected 172.200.2.1/32)"
fi
note "9. Teardown"
sudo wg-quick down "$CONF"
ok "wg-quick down"
else
note "79. Skipped tunnel (pass --apply or RA_UDP_APPLY=1 to test handshake)"
echo " sudo wg-quick up $CONF && ping -c3 $BE_WG_IP && sudo wg-quick down $CONF"
fi
note "UDP debug done"

View File

@@ -0,0 +1,59 @@
#!/bin/sh
# Ephemeral RSSH bastion users (Match User ra-* in sshd_config.d/ra.conf).
# Usage: rssh_bastion_user.sh add|remove USERNAME [PASSWORD]
set -eu
ACTION="${1:-}"
USER="${2:-}"
PASS="${3:-}"
die() {
echo "rssh_bastion_user: $*" >&2
exit 1
}
validate_user() {
case "$USER" in
ra-*)
return 0
;;
*)
die "invalid username (expected ra-* prefix)"
;;
esac
}
cmd_add() {
validate_user
[ -n "$PASS" ] || die "password required for add"
if id "$USER" >/dev/null 2>&1; then
echo "$USER:$PASS" | chpasswd
exit 0
fi
if command -v adduser >/dev/null 2>&1; then
adduser -D -h /dev/null -s /bin/sh "$USER"
elif command -v useradd >/dev/null 2>&1; then
useradd -M -s /bin/sh "$USER"
else
die "no adduser/useradd"
fi
echo "$USER:$PASS" | chpasswd
}
cmd_remove() {
validate_user
if ! id "$USER" >/dev/null 2>&1; then
exit 0
fi
if command -v deluser >/dev/null 2>&1; then
deluser "$USER" 2>/dev/null || true
elif command -v userdel >/dev/null 2>&1; then
userdel "$USER" 2>/dev/null || true
fi
}
case "$ACTION" in
add) cmd_add ;;
remove) cmd_remove ;;
*) die "usage: $0 add|remove USERNAME [PASSWORD]" ;;
esac

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Reconcile wg0 peers with pending/active remote-access sessions.
*
* Usage:
* php scripts/sync_wg_peers.php [--dry-run]
* php scripts/sync_wg_peers.php --apply
*/
require_once __DIR__ . '/../src/bootstrap.php';
require_once __DIR__ . '/../src/RemoteAccessRepository.php';
$dryRun = true;
foreach ($argv as $arg) {
if ($arg === '--apply') {
$dryRun = false;
} elseif ($arg === '--dry-run' || $arg === '-n') {
$dryRun = true;
} elseif ($arg === '-h' || $arg === '--help') {
fwrite(STDOUT, "Usage: php scripts/sync_wg_peers.php [--dry-run|--apply]\n");
exit(0);
}
}
RemoteAccessRepository::ensureSchema();
$livePeers = WireGuardPeerProvisioner::listPeerDumpRows();
$keep = RemoteAccessRepository::activeWireGuardSessionPublicKeys();
$reconciled = RemoteAccessRepository::reconcileActiveWireGuardPeers($dryRun);
$pruned = RemoteAccessRepository::pruneOrphanWireGuardPeers($dryRun);
fwrite(STDOUT, 'mode=' . ($dryRun ? 'dry-run' : 'apply') . "\n");
fwrite(STDOUT, 'provision_peers=' . (WireGuardPeerProvisioner::isEnabled() ? 'true' : 'false') . "\n");
fwrite(STDOUT, 'live_wg_peers=' . count($livePeers) . "\n");
fwrite(STDOUT, 'active_session_peers=' . count($keep) . "\n");
fwrite(STDOUT, 'reconciled_sessions=' . count($reconciled) . "\n");
fwrite(STDOUT, 'pruned_peers=' . count($pruned) . "\n");
foreach ($reconciled as $row) {
$pub = (string) ($row['public_key'] ?? '');
$short = strlen($pub) > 12 ? substr($pub, 0, 8) . '…' : $pub;
fwrite(
STDOUT,
sprintf(
" reconcile %s allowed_ips=%s ok=%s\n",
$short,
(string) ($row['client_address'] ?? ''),
($row['ok'] ?? false) ? 'true' : 'false'
)
);
}
foreach ($livePeers as $row) {
$pub = (string) ($row['public_key'] ?? '');
$allowed = trim((string) ($row['allowed_ips'] ?? ''));
$short = strlen($pub) > 12 ? substr($pub, 0, 8) . '…' : $pub;
$broken = ($allowed === '' || $allowed === '(none)');
$orphan = !in_array($pub, $keep, true);
if (!$broken && !$orphan) {
continue;
}
fwrite(
STDOUT,
sprintf(
" live %s allowed_ips=%s broken=%s orphan=%s\n",
$short,
$allowed === '' ? '(empty)' : $allowed,
$broken ? 'true' : 'false',
$orphan ? 'true' : 'false'
)
);
}
foreach ($pruned as $row) {
$pub = $row['public_key'] ?? '';
$short = strlen($pub) > 12 ? substr($pub, 0, 8) . '…' : $pub;
fwrite(
STDOUT,
sprintf(
" pruned %s reason=%s allowed_ips=%s\n",
$short,
(string) ($row['reason'] ?? ''),
(string) ($row['allowed_ips'] ?? '')
)
);
}

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Smoke-test graph session ingest + dashboard API.
#
# ./scripts/test_graphs_api.sh
# GRAPHS_BASE=http://localhost:8080/app/androidcast_project/graphs CRASH_USER=admin CRASH_PASS=admin ./scripts/test_graphs_api.sh
# COOKIE='ac_crash_sess=…' GRAPHS_BASE=https://apps.f0xx.org/app/androidcast_project/graphs ./scripts/test_graphs_api.sh
set -euo pipefail
CRASH_BASE="${CRASH_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
GRAPHS_BASE="${GRAPHS_BASE:-${CRASH_BASE/\/crashes\//\/graphs\/}}"
UPLOAD_URL="${GRAPHS_BASE}/api/graph_upload.php"
API_URL="${GRAPHS_BASE}/api/graphs.php"
COOKIE_JAR="${TMPDIR:-/tmp}/graph_api_cookies_$$.txt"
trap 'rm -f "$COOKIE_JAR"' EXIT
CURL_AUTH=()
if [[ -n "${COOKIE:-}" ]]; then
CURL_AUTH=(-H "Cookie: ${COOKIE}")
else
user="${CRASH_USER:-admin}"
pass="${CRASH_PASS:-admin}"
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
-d "username=${user}&password=${pass}" \
"${CRASH_BASE}/login" -o /dev/null
CURL_AUTH=(-b "$COOKIE_JAR")
fi
now_ms="$(date +%s)000"
start_ms=$((now_ms - 600000))
sid="smoke-graph-$(date +%s)"
payload="$(cat <<EOF
{
"schema_version": 1,
"ingest_kind": "graph_session",
"session_id": "${sid}",
"company_id": 1,
"user_id": 1,
"device_id": "smoke-device-01",
"app_version": "0.1.0-smoke",
"sdk_int": 34,
"started_at_epoch_ms": ${start_ms},
"ended_at_epoch_ms": ${now_ms},
"duration_s": 600,
"completed": true,
"direction": "sender",
"transport": "wifi",
"avg_kbps": 4200,
"recv_kbps": 800,
"packet_loss_pct": 0.4,
"ntp_source": "backend",
"ntp_correction_s": 2,
"install_source": "ota",
"meta": {"smoke": true}
}
EOF
)"
echo "POST ${UPLOAD_URL}"
upload_code="$(curl -sS -o /tmp/graph_upload_resp.json -w '%{http_code}' \
"${CURL_AUTH[@]}" \
-H 'Content-Type: application/json' \
-d "${payload}" \
"${UPLOAD_URL}")"
echo "upload HTTP ${upload_code}"
cat /tmp/graph_upload_resp.json
echo
echo "GET ${API_URL}?days=7"
graphs_code="$(curl -sS -o /tmp/graphs_resp.json -w '%{http_code}' \
"${CURL_AUTH[@]}" \
"${API_URL}?days=7")"
echo "graphs HTTP ${graphs_code}"
head -c 400 /tmp/graphs_resp.json
echo
echo "…"
if [[ "${upload_code}" != "200" ]]; then
echo "FAIL: upload expected 200" >&2
exit 1
fi
if [[ "${graphs_code}" != "200" ]]; then
echo "FAIL: graphs API expected 200 (got ${graphs_code})" >&2
exit 1
fi
if ! grep -q '"ok":true' /tmp/graphs_resp.json; then
echo "FAIL: graphs API body missing ok:true" >&2
exit 1
fi
echo "OK"

View File

@@ -0,0 +1,33 @@
#!/bin/sh
# Smoke-test gzip crash upload (plain JSON still accepted).
# Usage: BASE=http://127.0.0.1:8080/app/androidcast_project/crashes ./scripts/test_gzip_upload.sh
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SAMPLE="${ROOT}/../sample_crash_java.json"
BASE="${BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
URL="${BASE}/api/upload.php"
RID="gzip-test-$(date +%s)"
if [ ! -f "$SAMPLE" ]; then
echo "Missing $SAMPLE" >&2
exit 1
fi
BODY="$(mktemp)"
jq --arg rid "$RID" '.report_id = $rid' "$SAMPLE" > "$BODY"
GZIP="$(mktemp)"
gzip -c -f "$BODY" > "$GZIP"
echo "POST plain JSON..."
curl -sS -o /dev/null -w "plain:%{http_code}\n" -X POST "$URL" \
-H 'Content-Type: application/json' \
--data-binary @"$BODY"
echo "POST gzip (Content-Encoding: gzip)..."
curl -sS -w "\n%{http_code}\n" -X POST "$URL" \
-H 'Content-Type: application/json' \
-H 'Content-Encoding: gzip' \
--data-binary @"$GZIP"
rm -f "$BODY" "$GZIP"

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Smoke-test live cast API (public join + authenticated list).
#
# ./scripts/test_live_cast_api.sh
# COOKIE='ac_crash_sess=…' CRASH_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_live_cast_api.sh
set -euo pipefail
CRASH_BASE="${CRASH_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
API_URL="${CRASH_BASE}/api/live_cast.php"
COOKIE_JAR="${TMPDIR:-/tmp}/live_cast_cookies_$$.txt"
trap 'rm -f "$COOKIE_JAR"' EXIT
CURL_AUTH=()
if [[ -n "${COOKIE:-}" ]]; then
CURL_AUTH=(-H "Cookie: ${COOKIE}")
else
user="${CRASH_USER:-admin}"
pass="${CRASH_PASS:-admin}"
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
-d "username=${user}&password=${pass}" \
"${CRASH_BASE}/login" -o /dev/null
CURL_AUTH=(-b "$COOKIE_JAR")
fi
sid="smoke-live-$(date +%s)"
echo "POST create_intent (${sid})"
create_code="$(curl -sS -o /tmp/live_create.json -w '%{http_code}' \
"${CURL_AUTH[@]}" \
-H 'Content-Type: application/json' \
-d "{\"action\":\"create_intent\",\"platform\":\"android\",\"device_id\":\"smoke-device\",\"max_duration_s\":120}" \
"${API_URL}")"
echo "create HTTP ${create_code}"
cat /tmp/live_create.json
echo
session_id="$(python3 - <<'PY'
import json
try:
d=json.load(open("/tmp/live_create.json"))
print(d.get("session",{}).get("session_id",""))
except Exception:
print("")
PY
)"
if [[ -z "${session_id}" ]]; then
echo "FAIL: no session_id in create response" >&2
exit 1
fi
echo "GET public session"
pub_code="$(curl -sS -o /tmp/live_session.json -w '%{http_code}' \
"${API_URL}?action=session&session_id=${session_id}")"
echo "session HTTP ${pub_code}"
head -c 300 /tmp/live_session.json
echo
echo "POST public join_event"
join_code="$(curl -sS -o /tmp/live_join.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
-d "{\"action\":\"join_event\",\"session_id\":\"${session_id}\",\"event_type\":\"open\",\"platform\":\"chrome\"}" \
"${API_URL}")"
echo "join HTTP ${join_code}"
cat /tmp/live_join.json
echo
echo "GET list (auth)"
list_code="$(curl -sS -o /tmp/live_list.json -w '%{http_code}' \
"${CURL_AUTH[@]}" \
"${API_URL}?action=list&days=7")"
echo "list HTTP ${list_code}"
head -c 300 /tmp/live_list.json
echo
for code in "${create_code}" "${pub_code}" "${join_code}" "${list_code}"; do
if [[ "${code}" != "200" ]]; then
echo "FAIL: expected HTTP 200" >&2
exit 1
fi
done
echo "OK"

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

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Smoke test remote access API (SQLite dev or deployed BE).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BASE="${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
DEVICE_ID="test-ra-device-$(date +%s)"
COOKIE_JAR="$(mktemp)"
trap 'rm -f "$COOKIE_JAR"' EXIT
login() {
curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
-d "username=${ADMIN_USER:-admin}&password=${ADMIN_PASS:-admin}" \
"$BASE/login" >/dev/null
}
echo "== heartbeat ra disable =="
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"disable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r1\"}}" \
"$BASE/api/heartbeat.php" | grep -q '"action":"disabled"' && echo OK
echo "== heartbeat ra enable (not whitelisted → wait) =="
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r2\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \
"$BASE/api/heartbeat.php" | grep -q '"action":"wait"' && echo OK
login
echo "== whitelist device =="
curl -sf -b "$COOKIE_JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" \
"$BASE/api/remote_access.php?action=whitelist" | grep -q '"ok":true' && echo OK
echo "== poll enable (whitelisted, no session → wait) =="
curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r3\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \
"$BASE/api/heartbeat.php" | grep -q '"action":"wait"' && echo OK
echo "== open session =="
curl -sf -b "$COOKIE_JAR" -X POST -H 'Content-Type: application/json' \
-d "{\"device_id\":\"$DEVICE_ID\"}" \
"$BASE/api/remote_access.php?action=open_session" | grep -q '"ok":true' && echo OK
echo "== poll → connect wireguard =="
RESP="$(curl -sf -X POST -H 'Content-Type: application/json' \
-d "{\"heartbeat\":{\"type\":\"ra\",\"status\":\"enable\",\"device_id\":\"$DEVICE_ID\",\"random\":\"r4\",\"capabilities\":[\"wireguard\"],\"tunnel_mode\":\"wireguard\"}}" \
"$BASE/api/heartbeat.php")"
echo "$RESP" | grep -q '"action":"connect"' && echo OK
echo "$RESP" | grep -q 'interface_private_key' && echo OK credentials
echo "== dashboard API =="
curl -sf -b "$COOKIE_JAR" "$BASE/api/remote_access.php?action=dashboard" | grep -q '"ok":true' && echo OK
echo "All remote access API checks passed."

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# RSSH control-plane smoke: whitelist → open session → connect payload with bastion fields.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=ra_lib.sh
source "$ROOT/scripts/ra_lib.sh"
BASE="$(ra_base)"
DEVICE_ID="${RA_DEVICE_ID:-rssh-smoke-$(date +%s)}"
COOKIE_JAR="$(mktemp)"
trap 'rm -f "$COOKIE_JAR"' EXIT
ra_admin_login "$COOKIE_JAR"
body=$(printf '{"heartbeat":{"type":"ra","status":"enable","device_id":"%s","random":"r0","tunnel_mode":"rssh","capabilities":["rssh","shell"]}}' "$DEVICE_ID")
code=$(curl -sS -o /tmp/rssh-hb.json -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$body" "$(ra_heartbeat_url)")
[[ "$code" == "200" ]] || { echo "FAIL heartbeat enable HTTP $code"; cat /tmp/rssh-hb.json; exit 1; }
grep -q '"action":"wait"' /tmp/rssh-hb.json && echo OK wait
ra_admin_post "$COOKIE_JAR" whitelist "$(printf '{"device_id":"%s","whitelisted":true}' "$DEVICE_ID")" >/dev/null
ra_admin_post "$COOKIE_JAR" open_session "$(printf '{"device_id":"%s"}' "$DEVICE_ID")" >/dev/null
body=$(printf '{"heartbeat":{"type":"ra","status":"enable","device_id":"%s","random":"r1","tunnel_mode":"rssh","capabilities":["rssh","shell"]}}' "$DEVICE_ID")
curl -sf -X POST -H 'Content-Type: application/json' -d "$body" "$(ra_heartbeat_url)" > /tmp/rssh-connect.json
grep -q '"action":"connect"' /tmp/rssh-connect.json && echo OK connect
grep -q 'bastion_host' /tmp/rssh-connect.json && echo OK bastion fields
grep -q 'remote_bind_port' /tmp/rssh-connect.json && echo OK remote_bind_port
echo "All RSSH API checks passed."

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Unit checks for RsshSessionProvisioner + RsshBastionProvisioner (no DB).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export CRASHES_CONFIG="${CRASHES_CONFIG:-$ROOT/config/config.example.php}"
php -r '
require "'"$ROOT"'/src/bootstrap.php";
$alloc = RsshSessionProvisioner::allocate("test-session-abc");
assert($alloc["username"] !== "");
assert($alloc["remote_bind_port"] >= 18000);
assert($alloc["local_forward_port"] === 8022);
$cmds = RsshBastionProvisioner::operatorCommands([
"rssh_username" => $alloc["username"],
"rssh_remote_port" => $alloc["remote_bind_port"],
]);
assert(isset($cmds["shell"]));
assert(str_contains($cmds["shell"], (string) $alloc["remote_bind_port"]));
echo "OK RsshSessionProvisioner + operatorCommands\n";
'
echo "All RSSH PHP unit checks passed."

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Short links admin API smoke — session cookie + RBAC.
# Usage:
# COOKIE='ac_crash_sess=…' CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_short_links_api.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BASE="${CRASHES_BASE:-http://127.0.0.1:8080}"
COOKIE="${COOKIE:-}"
JAR="${COOKIE_JAR:-/tmp/short_links_api_cookies.txt}"
if [[ -z "$COOKIE" && ! -f "$JAR" ]]; then
echo "Set COOKIE=ac_crash_sess=… or login first (COOKIE_JAR=$JAR)" >&2
exit 1
fi
curl_api() {
local url="$1"
shift
if [[ -n "$COOKIE" ]]; then
curl -fsS -b "$COOKIE" "$@" "$url"
else
curl -fsS -b "$JAR" -c "$JAR" "$@" "$url"
fi
}
echo "==> dashboard"
curl_api "$BASE/api/short_links.php?action=dashboard" | grep -q '"ok":true' && echo OK
if [[ "${FULL:-}" == "1" ]]; then
echo "==> mint bearer"
resp="$(curl_api "$BASE/api/short_links.php?action=mint_bearer" \
-X POST -H 'Content-Type: application/json' \
-d '{"label":"api-smoke-'"$(date +%s)"'","can_permanent":true,"mint_signer":true}')"
echo "$resp" | grep -q '"token"' && echo OK bearer
signer="$(echo "$resp" | sed -n 's/.*"signer_key":"\([^"]*\)".*/\1/p')"
bearer="$(echo "$resp" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')"
bid="$(echo "$resp" | sed -n 's/.*"bearer_id":\([0-9]*\).*/\1/p')"
if [[ -n "$signer" && -n "$bid" ]]; then
echo "==> permanent link"
curl_api "$BASE/api/short_links.php?action=create_link" \
-X POST -H 'Content-Type: application/json' \
-d '{"bearer_id":'"$bid"',"url":"https://example.com/perm-'"$(date +%s)"'","ttl":0,"signer":"'"$signer"'"}' \
| grep -q '"short_url"' && echo OK permanent
fi
fi
echo "Done"

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Smoke-test reports list + tag edit API (session cookie after login).
#
# Usage:
# export CRASH_BASE="https://f0xx.org/app/androidcast_project/crashes"
# export CRASH_USER="admin"
# export CRASH_PASS="secret"
# ./scripts/test_tags_api.sh
#
# Or pass a browser session cookie:
# export CRASH_COOKIE="PHPSESSID=..."
# ./scripts/test_tags_api.sh
set -euo pipefail
BASE="${CRASH_BASE:-http://127.0.0.1:8080}"
COOKIE_JAR="${TMPDIR:-/tmp}/crash_api_cookies_$$.txt"
trap 'rm -f "$COOKIE_JAR"' EXIT
if [[ -z "${CRASH_COOKIE:-}" ]]; then
if [[ -z "${CRASH_USER:-}" || -z "${CRASH_PASS:-}" ]]; then
echo "Set CRASH_USER + CRASH_PASS or CRASH_COOKIE" >&2
exit 1
fi
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
-d "username=${CRASH_USER}&password=${CRASH_PASS}" \
"${BASE}/login" -o /dev/null
CURL_AUTH=(-b "$COOKIE_JAR")
else
CURL_AUTH=(-H "Cookie: ${CRASH_COOKIE}")
fi
echo "== tag catalog =="
curl -sS "${CURL_AUTH[@]}" "${BASE}/api/tag_catalog.php" | head -c 400
echo ""
echo "== reports (first page) =="
LIST=$(curl -sS "${CURL_AUTH[@]}" "${BASE}/api/reports.php?page=1&per_page=5")
echo "$LIST" | head -c 500
echo ""
ID=$(echo "$LIST" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' | head -1)
if [[ -z "$ID" ]]; then
echo "No report id in list; skip tag write test"
exit 0
fi
echo "== set tags on report #$ID (duplicate + fixed) =="
BODY='{"id":'"$ID"',"tags":[{"id":"duplicate","label":"duplicate","bg":"#6d28d9"},{"id":"fixed","label":"fixed","bg":"#047857"}]}'
curl -sS "${CURL_AUTH[@]}" -X POST -H "Content-Type: application/json" \
-d "$BODY" "${BASE}/api/report_tags.php"
echo ""
echo "== filter AND duplicate+fixed =="
curl -sS "${CURL_AUTH[@]}" \
"${BASE}/api/reports.php?tag=duplicate&tag=fixed&tag_mode=and&per_page=3" | head -c 400
echo ""
echo "OK"

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$opts = getopt('', ['file::', 'url::', 'dry-run']);
$file = $opts['file'] ?? '/tmp/fake_graph_sessions.ndjson';
$base = (string) ($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/graphs');
if ($base === '' || $base === '1') {
$base = 'https://apps.f0xx.org/app/androidcast_project/graphs';
}
$base = rtrim($base, '/');
$dryRun = isset($opts['dry-run']);
$api = $base . '/api/graph_upload.php';
if (!is_readable($file)) {
fwrite(STDERR, "File not found: $file\n");
exit(1);
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$ok = 0;
$fail = 0;
foreach ($lines as $i => $line) {
$payload = json_decode($line, true);
if (!is_array($payload)) {
$fail++;
continue;
}
if ($dryRun) {
$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,
'timeout' => 20,
],
]);
$resp = @file_get_contents($api, 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 < 300) {
$ok++;
} else {
$fail++;
fwrite(STDERR, "FAIL line " . ($i + 1) . " http=$code resp=$resp\n");
}
}
echo "Done: ok=$ok fail=$fail total=" . count($lines) . "\n";
exit($fail > 0 ? 1 : 0);

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Reachability smoke for mobile-app + console services (public or CRASHES_BASE).
# Exit 0 when all required checks pass; prints FAIL lines for ticket triage.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PUBLIC="${PUBLIC_BASE:-https://apps.f0xx.org}"
CRASHES="${CRASHES_BASE:-${PUBLIC}/app/androidcast_project/crashes}"
GRAPH="${GRAPH_BASE:-${PUBLIC}/app/androidcast_project/graphs}"
HUB="${HUB_BASE:-${PUBLIC}/app/androidcast_project/}"
BUILD="${BUILD_BASE:-${PUBLIC}/app/androidcast_project/build/}"
OTA="${OTA_CHANNEL:-${PUBLIC}/v0/ota/channel/stable.json}"
SHORTENER="${URL_SHORTENER_BASE:-https://s.f0xx.org}"
fail=0
ok() { echo "OK $*"; }
bad() { echo "FAIL $*"; fail=1; }
note() { echo "WARN $*"; }
http_code() {
curl -sS -o /dev/null -w '%{http_code}' --max-time "${CURL_TIMEOUT:-15}" "$1" 2>/dev/null || echo "000"
}
js_syntax() {
local url="$1"
local tmp
tmp="$(mktemp --suffix=.js)"
if ! curl -sSLf --max-time "${CURL_TIMEOUT:-15}" "$url" -o "$tmp" 2>/dev/null; then
rm -f "$tmp"
echo "fetch"
return 1
fi
if node --check "$tmp" 2>/dev/null; then
rm -f "$tmp"
echo "ok"
return 0
fi
rm -f "$tmp"
echo "syntax"
return 1
}
echo "== Android Cast service reachability =="
echo "PUBLIC=$PUBLIC"
echo
# Hub
code="$(http_code "$HUB")"
[[ "$code" =~ ^(200|301|302)$ ]] && ok "hub $HUB HTTP $code" || bad "hub $HUB HTTP $code"
# Crashes console (login redirect OK)
code="$(http_code "$CRASHES/")"
[[ "$code" =~ ^(200|302)$ ]] && ok "crashes $CRASHES/ HTTP $code" || bad "crashes $CRASHES/ HTTP $code"
# Graphs
code="$(http_code "$GRAPH/")"
[[ "$code" =~ ^(200|302)$ ]] && ok "graphs $GRAPH/ HTTP $code" || bad "graphs $GRAPH/ HTTP $code"
# Builder
code="$(http_code "$BUILD")"
[[ "$code" =~ ^(200|302)$ ]] && ok "build $BUILD HTTP $code" || bad "build $BUILD HTTP $code"
# OTA channel (may 404 until wired — warn only)
code="$(http_code "$OTA")"
if [[ "$code" == "200" ]]; then
ok "ota $OTA HTTP $code"
elif [[ "$code" == "404" ]]; then
note "ota $OTA HTTP 404 — BE mount empty or channel missing"
elif [[ "$code" == "400" ]]; then
note "ota $OTA HTTP 400 — FE missing location /v0/ota/ → BE :80 (run install-fe-ota-nginx.sh on FE)"
else
bad "ota $OTA HTTP $code"
fi
# URL shortener (opt-in vhost — warn until PHP + MariaDB live)
code="$(http_code "$SHORTENER/api/v1/health")"
if [[ "$code" == "200" ]]; then
ok "url-shortener $SHORTENER/api/v1/health HTTP $code"
elif [[ "$code" =~ ^(000|502|503|504)$ ]]; then
note "url-shortener $SHORTENER/api/v1/health HTTP $code — nginx up? sync backend/url-shortener + php-fpm"
else
note "url-shortener $SHORTENER/api/v1/health HTTP $code (stub expects 200 when docroot synced)"
fi
# Device APIs (no session) — 403 on public edge is OK if upload/heartbeat work
code="$(http_code "$CRASHES/api/diag.php")"
if [[ "$code" == "200" ]]; then
ok "diag API HTTP $code"
elif [[ "$code" == "403" ]]; then
note "diag API HTTP 403 (edge block; use upload/heartbeat for device path)"
else
bad "diag API HTTP $code"
fi
code="$(http_code "$CRASHES/api/heartbeat.php")"
[[ "$code" =~ ^(200|400|405)$ ]] && ok "heartbeat API reachable HTTP $code" || bad "heartbeat API HTTP $code"
# JS assets
for pair in \
"crashes-app:${CRASHES}/assets/js/app.js" \
"graphs-js:${GRAPH}/assets/js/graphs.js" \
"tickets-js:${CRASHES}/assets/js/tickets.js" \
"i18n-js:${CRASHES}/assets/js/i18n.js"; do
name="${pair%%:*}"
url="${pair#*:}"
st="$(js_syntax "$url" || true)"
[[ "$st" == "ok" ]] && ok "js $name syntax" || bad "js $name ($st) $url"
done
# Authenticated API smoke (optional)
if [[ "${RUN_API_SMOKE:-1}" == "1" ]] && command -v bash >/dev/null 2>&1; then
if CRASHES_BASE="$CRASHES" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then
ok "remote_access API smoke"
else
bad "remote_access API smoke"
fi
if CRASHES_BASE="$CRASHES" bash "$ROOT/scripts/test_rbac_api.sh" >/dev/null 2>&1; then
ok "rbac API smoke"
else
bad "rbac API smoke"
fi
fi
echo
if [[ "$fail" -eq 0 ]]; then
echo "All required service checks passed."
else
echo "Some checks failed — file tickets via ingest or Issues dashboard."
fi
exit "$fail"

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/** Smoke: TOTP generate + verify round-trip (CLI). */
require_once __DIR__ . '/../src/bootstrap.php';
$secret = AuthTotp::generateSecret();
$slice = (int) floor(time() / 30);
$code = AuthTotp::codeAt($secret, $slice);
$ok = AuthTotp::verify($secret, $code);
echo $ok ? "OK totp verify\n" : "FAIL totp verify\n";
$enc = AuthCrypto::encrypt($secret);
$dec = AuthCrypto::decrypt($enc);
echo ($dec === $secret) ? "OK crypto round-trip\n" : "FAIL crypto\n";
exit($ok && $dec === $secret ? 0 : 1);

View File

@@ -0,0 +1,163 @@
#!/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"
# Alpine BE: default `php` may be 8.5 without session/PDO; php-fpm81 uses 8.1.
php_bin() {
if [[ -n "${PHP_BIN:-}" ]]; then
echo "$PHP_BIN"
return
fi
if command -v php81 >/dev/null 2>&1 && php81 -m 2>/dev/null | grep -qiE '^(session|pdo_mysql|pdo_sqlite)$'; then
echo php81
return
fi
if php -m 2>/dev/null | grep -qi '^session$'; then
echo php
return
fi
if command -v php81 >/dev/null 2>&1 && php81 -m 2>/dev/null | grep -qi '^session$'; then
echo php81
return
fi
echo php
}
PHP="$(php_bin)"
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
# MariaDB rejects PostgreSQL-style NULLS LAST/FIRST (SQLite dev may hide this).
if grep -R --include='*.php' -E 'NULLS (LAST|FIRST)' "$ROOT/src" >/dev/null 2>&1; then
bad "MariaDB-incompatible SQL (NULLS LAST/FIRST) under src/"
else
ok "no NULLS LAST/FIRST in PHP SQL"
fi
# 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) — use FPM user + php81 so PDO/mysql extensions match production
DB_RUN="$PHP"
DB_USER=""
if [[ "$(id -u)" -eq 0 ]] && id nginx >/dev/null 2>&1; then
DB_RUN="sudo -u nginx $PHP"
DB_USER="nginx"
fi
$DB_RUN -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
$DB_RUN -r '
require "'"$ROOT"'/src/bootstrap.php";
RemoteAccessRepository::ensureSchema();
RemoteAccessRepository::listDevices();
echo "OK listDevices() SQL executes\n";
' || bad "listDevices() failed (check ORDER BY / MariaDB syntax)"
# Orphan wg peers (dry-run; run sync_wg_peers.php --apply on BE to fix)
if [[ -f "$ROOT/scripts/sync_wg_peers.php" ]]; then
WG_SYNC="$($DB_RUN "$ROOT/scripts/sync_wg_peers.php" --dry-run 2>/dev/null || true)"
if [[ -n "$WG_SYNC" ]]; then
pruned=$(echo "$WG_SYNC" | awk -F= '/^pruned_peers=/{print $2}')
if [[ -n "$pruned" && "$pruned" != "0" ]]; then
note "wg0 has ${pruned} orphan peer(s) — run: php81 scripts/sync_wg_peers.php --apply"
else
ok "wg0 peers in sync with active sessions"
fi
fi
fi
# API smoke (local php -S or deployed BASE)
BASE="${BASE:-${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}}"
if [[ -x "$ROOT/scripts/test_remote_access_api.sh" ]]; then
if CRASHES_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 or CRASHES_BASE=…/crashes)"
fi
fi
if [[ "${RA_E2E:-}" == "1" ]] && [[ -x "$ROOT/scripts/ra_e2e_cli.sh" ]]; then
if command -v jq >/dev/null 2>&1; then
if CRASHES_BASE="$BASE" bash "$ROOT/scripts/ra_e2e_cli.sh" >/dev/null 2>&1; then
ok "RA E2E CLI ($BASE)"
else
note "RA E2E CLI failed against $BASE (needs jq, admin login, remote_access RBAC)"
fi
else
note "RA E2E skipped (install jq)"
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