mirror of
git://f0xx.org/android_cast
synced 2026-07-29 03:18:32 +03:00
wg start
This commit is contained in:
@@ -79,3 +79,19 @@ Direct debug ports: crashes `:8082`, builder `:8083`.
|
||||
- Mount builder PHP with Docker socket (build host only).
|
||||
- FE nginx: read-only OTA under `/v0/ota/`; BE RWX on artifact mount.
|
||||
- Keep `session_cookie_path` identical across all PHP apps behind the hub.
|
||||
|
||||
### Alpine BE (`config.php` paths)
|
||||
|
||||
| Key | Example on artc0 |
|
||||
|-----|------------------|
|
||||
| `build.repo_root` | `/var/www/.../androidcast_project/android_cast` (git tree) |
|
||||
| `build.artifacts_root` | `/var/www/.../androidcast_project/out/builds` — **must exist**, writable by PHP-FPM (`nginx`) |
|
||||
| `build.runner_script` | full path to `scripts/docker-build-runner.sh` (no `...` placeholders) |
|
||||
|
||||
```sh
|
||||
mkdir -p .../out/builds .../ota-artifacts
|
||||
chown -R nginx:nginx .../out
|
||||
chmod -R 775 .../out
|
||||
```
|
||||
|
||||
If **Start build** shows *Network error*, check BE `php-fpm81` log: often `mkdir(): Permission denied` on `out/builds` (HTTP 500, not a browser network fault).
|
||||
|
||||
@@ -180,7 +180,7 @@ Mounted copy: `tmp/FE_gentoo/etc/nginx/nginx.conf`.
|
||||
|
||||
| `server_name` | `location /` | androidcast crashes | Hub `/app/androidcast_project/` |
|
||||
|---------------|--------------|---------------------|--------------------------------|
|
||||
| **`apps.f0xx.org`** | → `http://artc0:80` | May → `https://artc0:443/.../crashes/` | Via **`location /`** → BE :80 (no extra block needed) |
|
||||
| **`apps.f0xx.org`** | → `http://artc0:80` | Must **not** → `https://artc0:443/...` (static download) | Hub/crashes blocks must use **`http://artc0:80`** — see `nginx.fe-apps.f0xx.org.snippet` |
|
||||
| **`artc0.f0xx.org`** | → `http://artc0:80` | Legacy → **:8089** | Legacy → **:8089** — **remove** hub block; prefer apps.f0xx.org |
|
||||
|
||||
**Agent default:** fix **BE `listen 80`** first. Change FE only when user asks or to delete wrong **8089** hub overrides on `artc0.f0xx.org`.
|
||||
|
||||
@@ -37,7 +37,8 @@ Click a link to open a guide. Section links jump within the document (GitHub, Cu
|
||||
| [OPEN_API.md](OPEN_API.md) | Crash ingest API design, triage tags, heuristics. |
|
||||
| [OTA.md](OTA.md) | OTA channel layout v0, manifests, MQTT, publish flow. |
|
||||
| [PRO_AAR.md](PRO_AAR.md) | cast-core / cast-pro AAR split and Play Billing. |
|
||||
| [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md) | |
|
||||
| [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md) | WireGuard v1 impl + deploy |
|
||||
| [REMOTE_ACCESS_VALIDATION.md](REMOTE_ACCESS_VALIDATION.md) | Linux CLI suite + mobile parity |
|
||||
| [ROADMAP.md](ROADMAP.md) | Implementation roadmap, alpha definition, deferred work. |
|
||||
| [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) | Tickets console, Gitea, attachments, agent queue. |
|
||||
| [USB_HDMI_CAST.md](USB_HDMI_CAST.md) | Roadmap D/E: HDMI awareness, USB-tether transport. |
|
||||
|
||||
@@ -105,18 +105,23 @@ sudo -u www-data wg show wg0
|
||||
### 4. Cron (session cleanup)
|
||||
|
||||
```cron
|
||||
0 * * * * cd /var/www/.../backend && php scripts/purge_remote_access.php --hours=24
|
||||
0 * * * * cd /var/www/.../backend && php81 scripts/purge_remote_access.php --hours=24
|
||||
```
|
||||
|
||||
Use `php81` when Alpine default `php` is 8.5+ without the session module; FPM is `php-fpm81`.
|
||||
|
||||
### 5. Verify
|
||||
|
||||
```bash
|
||||
cd examples/crash_reporter/backend
|
||||
chmod +x scripts/verify_remote_access_prod.sh scripts/test_remote_access_api.sh
|
||||
chmod +x scripts/ra_*.sh scripts/verify_remote_access_prod.sh scripts/test_remote_access_api.sh
|
||||
./scripts/verify_remote_access_prod.sh
|
||||
BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_remote_access_api.sh
|
||||
BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/ra_e2e_cli.sh
|
||||
```
|
||||
|
||||
**Linux CLI device simulator** (same heartbeat payloads as the app): [REMOTE_ACCESS_VALIDATION.md](REMOTE_ACCESS_VALIDATION.md).
|
||||
|
||||
### 6. Browser
|
||||
|
||||
Hard-refresh after deploying `public/assets/js/remote_access.js`.
|
||||
@@ -147,4 +152,7 @@ Without the submodule, `WireGuardTunnelBridge` uses `RemoteAccessVpnService` (TU
|
||||
bash examples/crash_reporter/backend/scripts/test_rbac_api.sh
|
||||
bash examples/crash_reporter/backend/scripts/test_remote_access_api.sh
|
||||
BASE=https://apps.f0xx.org/app/androidcast_project/crashes bash examples/crash_reporter/backend/scripts/test_remote_access_api.sh
|
||||
BASE=https://apps.f0xx.org/app/androidcast_project/crashes bash examples/crash_reporter/backend/scripts/ra_e2e_cli.sh
|
||||
```
|
||||
|
||||
CLI validation guide: [REMOTE_ACCESS_VALIDATION.md](REMOTE_ACCESS_VALIDATION.md).
|
||||
|
||||
160
docs/REMOTE_ACCESS_VALIDATION.md
Normal file
160
docs/REMOTE_ACCESS_VALIDATION.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Remote access — validation (Linux CLI + mobile parity)
|
||||
|
||||
Companion to [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md). Use this when verifying WireGuard v1 **before** or **after** deploying the BE, and when debugging without an Android device.
|
||||
|
||||
**Documentation index:** [README.md](README.md)
|
||||
|
||||
## Table of contents
|
||||
|
||||
<!-- toc -->
|
||||
- [What the CLI suite simulates](#what-the-cli-suite-simulates)
|
||||
- [Scripts](#scripts)
|
||||
- [Quick start (local orchestration)](#quick-start-local-orchestration)
|
||||
- [Production / staging BE](#production--staging-be)
|
||||
- [Optional: bring up WireGuard on Linux](#optional-bring-up-wireguard-on-linux)
|
||||
- [Mobile vs CLI checklist](#mobile-vs-cli-checklist)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
<!-- /toc -->
|
||||
|
||||
---
|
||||
|
||||
## What the CLI suite simulates
|
||||
|
||||
The Android app (`RemoteAccessService`) does the following over HTTP only (no RSSH in v1):
|
||||
|
||||
| App behaviour | CLI equivalent |
|
||||
|---------------|----------------|
|
||||
| Persist stable `device_id` | `RA_DEVICE_ID` or `--device-id` |
|
||||
| Persist stable `random` in prefs | file `RA_RANDOM_FILE` (default `/tmp/androidcast-ra-<id>.random`) |
|
||||
| Mode **Disabled** → `status:disable` | `ra_device_sim.sh disable` |
|
||||
| Mode **WireGuard** → jittered `status:enable` poll | `ra_device_sim.sh poll --until connect` (fixed interval via `RA_POLL_INTERVAL_S`, default 3s for tests) |
|
||||
| Capabilities + `tunnel_mode: wireguard` | same JSON in `ra_lib.sh` / `ra_ra_post` |
|
||||
| Apply WG config on `connect` | `ra_wg_quick_from_connect` + optional `--apply-wg` (`wg-quick up`) |
|
||||
| Operator whitelist + open session | `ra_e2e_cli.sh` or manual `remote_access.php` curls |
|
||||
|
||||
The **control plane** is validated by HTTP alone. **Data plane** (UDP to `wg_endpoint`) requires DNAT, `wg0` on the BE, and optionally `wg-quick` on the machine running the device sim.
|
||||
|
||||
---
|
||||
|
||||
## Scripts
|
||||
|
||||
All under `examples/crash_reporter/backend/scripts/`:
|
||||
|
||||
| Script | Role |
|
||||
|--------|------|
|
||||
| `ra_lib.sh` | Shared heartbeat + admin helpers (sourced, not run alone) |
|
||||
| `ra_device_sim.sh` | Device-only: disable / enable / poll / optional WG |
|
||||
| `ra_e2e_cli.sh` | Full flow: disable → wait → whitelist → open → connect → close → disable |
|
||||
| `test_remote_access_api.sh` | Minimal API smoke (grep, no jq) |
|
||||
| `verify_remote_access_prod.sh` | BE preflight: config, wg tools, DB tables, optional smoke |
|
||||
|
||||
```bash
|
||||
cd examples/crash_reporter/backend
|
||||
chmod +x scripts/ra_*.sh scripts/test_remote_access_api.sh scripts/verify_remote_access_prod.sh
|
||||
```
|
||||
|
||||
**Dependencies:** `curl`, `jq` (device sim and E2E), `openssl` (random). Optional: `wireguard-tools` for `--apply-wg`.
|
||||
|
||||
---
|
||||
|
||||
## Quick start (local orchestration)
|
||||
|
||||
With the crashes stack listening on the default orchestration URL:
|
||||
|
||||
```bash
|
||||
export CRASHES_BASE=http://127.0.0.1:8080/app/androidcast_project/crashes
|
||||
export ADMIN_USER=admin ADMIN_PASS=admin
|
||||
|
||||
./scripts/verify_remote_access_prod.sh
|
||||
./scripts/test_remote_access_api.sh
|
||||
./scripts/ra_e2e_cli.sh
|
||||
```
|
||||
|
||||
Single device poll after you have opened a session in the UI:
|
||||
|
||||
```bash
|
||||
export RA_DEVICE_ID=my-lab-phone
|
||||
./scripts/ra_device_sim.sh poll --until connect
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production / staging BE
|
||||
|
||||
```bash
|
||||
ssh alpine-be # or your BE host
|
||||
cd /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend
|
||||
|
||||
export CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes
|
||||
export ADMIN_USER=admin ADMIN_PASS='…'
|
||||
|
||||
./scripts/verify_remote_access_prod.sh
|
||||
BASE="$CRASHES_BASE" ./scripts/test_remote_access_api.sh
|
||||
BASE="$CRASHES_BASE" ./scripts/ra_e2e_cli.sh
|
||||
```
|
||||
|
||||
Or from your workstation (same cookies/session as browser login):
|
||||
|
||||
```bash
|
||||
export CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes
|
||||
./scripts/ra_e2e_cli.sh --device-id ra-cli-$(hostname -s)
|
||||
```
|
||||
|
||||
**Full E2E from verify script:**
|
||||
|
||||
```bash
|
||||
RA_E2E=1 BASE="$CRASHES_BASE" ./scripts/verify_remote_access_prod.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional: bring up WireGuard on Linux
|
||||
|
||||
After `poll --until connect` returns credentials:
|
||||
|
||||
```bash
|
||||
./scripts/ra_device_sim.sh poll --until connect --apply-wg
|
||||
# writes /tmp/ra-<device_id>.conf and runs: sudo wg-quick up …
|
||||
```
|
||||
|
||||
Manual:
|
||||
|
||||
```bash
|
||||
RESP="$(curl -sf …)" # connect response
|
||||
source scripts/ra_lib.sh
|
||||
ra_wg_quick_from_connect "$RESP" | sudo tee /tmp/ra-test.conf
|
||||
sudo wg-quick up /tmp/ra-test.conf
|
||||
ping -c1 10.66.66.1 # BE wg0 address if routed
|
||||
sudo wg-quick down /tmp/ra-test.conf
|
||||
```
|
||||
|
||||
This step fails if UDP `wg_endpoint` is not reachable (FE DNAT, firewall). HTTP E2E can still pass.
|
||||
|
||||
---
|
||||
|
||||
## Mobile vs CLI checklist
|
||||
|
||||
| Step | Android | Pass criteria |
|
||||
|------|---------|---------------|
|
||||
| 1 | Developer settings → Disabled | `action: disabled` |
|
||||
| 2 | WireGuard mode on, not whitelisted | `action: wait` |
|
||||
| 3 | Dashboard whitelist device | API `ok: true` |
|
||||
| 4 | Operator open session | `action: wait` then `connect` on next poll |
|
||||
| 5 | VPN comes up | `wg show` / ping hub via tunnel |
|
||||
| 6 | Close session / disable app | tunnel down; peer removed if `provision_peers` |
|
||||
|
||||
CLI E2E covers steps 1–4 and 6 over HTTP; step 5 needs `--apply-wg` or a real device.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---------|----------------|
|
||||
| `action: deny` + `wg_tools_required` | `require_wg_tools` true but `wg genkey` fails for PHP-FPM user — fix permissions / install `wireguard-tools` |
|
||||
| `connect` but no handshake | `wg_server_public_key` mismatch, UDP not DNAT’d, or wrong `wg_endpoint` |
|
||||
| `wait` after open session | Poll rate limit (`min_poll_interval_s`); wait and retry |
|
||||
| `jq required` | Install `jq` on the host running device sim |
|
||||
| API smoke fails on verify | Set `BASE` / `CRASHES_BASE` to live crashes URL; ensure login user has `remote_access_admin` |
|
||||
|
||||
See also [REMOTE_ACCESS_IMPL.md § Production deploy](REMOTE_ACCESS_IMPL.md#production-deploy-no-new-nginx-http-paths).
|
||||
@@ -1,5 +1,10 @@
|
||||
<?php declare(strict_types=1);
|
||||
require_once dirname(__DIR__) . '/platform/footer.php';
|
||||
$__platformDir = __DIR__ . '/android_cast/examples/platform';
|
||||
if (!is_file($__platformDir . '/footer.php')) {
|
||||
$__platformDir = dirname(__DIR__) . '/platform';
|
||||
}
|
||||
require_once $__platformDir . '/footer.php';
|
||||
unset($__platformDir);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark" data-clock-font="mixed">
|
||||
|
||||
@@ -31,14 +31,30 @@
|
||||
fetch(base + '/api/build_trigger.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function (r) { return r.json(); }).then(function (j) {
|
||||
}).then(function (r) {
|
||||
return r.text().then(function (text) {
|
||||
var j = null;
|
||||
try {
|
||||
j = JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error(r.status + ': ' + (text.slice(0, 120) || r.statusText));
|
||||
}
|
||||
if (!r.ok) {
|
||||
throw new Error((j && j.error) || ('HTTP ' + r.status));
|
||||
}
|
||||
return j;
|
||||
});
|
||||
}).then(function (j) {
|
||||
if (j.ok && j.build && j.build.id) {
|
||||
window.location.href = base + '/?view=build&id=' + j.build.id;
|
||||
} else {
|
||||
status.textContent = j.error || 'Failed';
|
||||
}
|
||||
}).catch(function () { status.textContent = 'Network error'; });
|
||||
}).catch(function (err) {
|
||||
status.textContent = err && err.message ? err.message : 'Network error';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ php -m | grep -E 'session|pdo_sqlite|pdo_mysql|PDO'
|
||||
|
||||
If `session_name()` is undefined, `php81-session` is missing or FPM was not restarted after install.
|
||||
|
||||
**CLI vs FPM:** `php` on the shell may be a different major version than `php-fpm81` (e.g. `php82` CLI + `php81-fpm`). Install `phpNN-session` for **both** SAPIs you use, or run cron with the matching binary: `php81 scripts/purge_remote_access.php`. Maintenance scripts skip sessions on CLI when the extension is absent (web/FPM still require it).
|
||||
|
||||
## Quick start (SQLite, development)
|
||||
|
||||
```bash
|
||||
@@ -289,17 +291,26 @@ mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql
|
||||
|
||||
**Production `config.php`** (not in git) — set `remote_access.wg_server_public_key` from `wg show wg0 public-key`, `require_wg_tools => true`, and `wg_endpoint` (public UDP DNAT hostname). See `config.example.php`.
|
||||
|
||||
**Cron:** `php scripts/purge_remote_access.php --hours=24` (hourly).
|
||||
**Cron:** hourly purge — on Alpine use **php81** if default `php` is 8.x without session (see § Requirements):
|
||||
|
||||
```cron
|
||||
0 * * * * cd /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend && php81 scripts/purge_remote_access.php --hours=24
|
||||
```
|
||||
|
||||
Smoke / verify:
|
||||
|
||||
```bash
|
||||
chmod +x scripts/ra_*.sh scripts/test_remote_access_api.sh scripts/verify_remote_access_prod.sh
|
||||
./scripts/test_rbac_api.sh
|
||||
./scripts/test_remote_access_api.sh
|
||||
BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_remote_access_api.sh
|
||||
./scripts/verify_remote_access_prod.sh
|
||||
BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/ra_e2e_cli.sh # device + operator E2E (jq)
|
||||
./scripts/ra_device_sim.sh poll --until connect # after open session in UI
|
||||
```
|
||||
|
||||
**CLI validation (Linux device simulator):** [docs/REMOTE_ACCESS_VALIDATION.md](../../../docs/REMOTE_ACCESS_VALIDATION.md).
|
||||
|
||||
Android: Developer settings → **Remote access** → WireGuard (RSSH reserved). Requires VPN permission; polls BE every 1–7 min while enabled.
|
||||
|
||||
## Default accounts
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# FE apps.f0xx.org — replace hub/crashes upstream blocks (use BE port 80, preserve URI).
|
||||
# File: tmp/FE_gentoo/etc/nginx/nginx.conf (inside server { listen 443; server_name apps.f0xx.org; })
|
||||
#
|
||||
# WRONG (causes browser to download index.php as application/octet-stream):
|
||||
# proxy_pass https://artc0.intra.raptor.org/app/androidcast_project/;
|
||||
# BE :443 serves static files; hub must hit BE :80 (PHP-FPM) like location / already does.
|
||||
|
||||
location /app/androidcast_project/ {
|
||||
proxy_pass http://artc0.intra.raptor.org:80;
|
||||
|
||||
@@ -7,12 +7,6 @@ header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
Auth::check();
|
||||
|
||||
function json_out(array $payload, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!Rbac::can('remote_access_view')) {
|
||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
160
examples/crash_reporter/backend/scripts/ra_device_sim.sh
Executable file
160
examples/crash_reporter/backend/scripts/ra_device_sim.sh
Executable 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
|
||||
100
examples/crash_reporter/backend/scripts/ra_e2e_cli.sh
Executable file
100
examples/crash_reporter/backend/scripts/ra_e2e_cli.sh
Executable 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 =="
|
||||
104
examples/crash_reporter/backend/scripts/ra_lib.sh
Executable file
104
examples/crash_reporter/backend/scripts/ra_lib.sh
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/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 caps='["wireguard","files_app_home"]'
|
||||
local body
|
||||
body=$(printf '{"heartbeat":{"type":"ra","status":"%s","device_id":"%s","random":"%s","app_version":"%s","tunnel_mode":"wireguard","capabilities":%s}}' \
|
||||
"$status" "$device_id" "$random" "$app_version" "$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 // "10.66.66.2/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"
|
||||
}
|
||||
@@ -5,6 +5,24 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Alpine BE: default `php` may be 8.5 without session; php-fpm81 uses 8.1.
|
||||
php_bin() {
|
||||
if [[ -n "${PHP_BIN:-}" ]]; then
|
||||
echo "$PHP_BIN"
|
||||
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
|
||||
|
||||
@@ -24,7 +42,7 @@ else
|
||||
ok "config.php present"
|
||||
fi
|
||||
|
||||
php -r '
|
||||
"$PHP" -r '
|
||||
$config = require "'"$CFG"'";
|
||||
$ra = $config["remote_access"] ?? [];
|
||||
$pub = trim((string)($ra["wg_server_public_key"] ?? ""));
|
||||
@@ -56,14 +74,14 @@ else
|
||||
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
|
||||
if "$PHP" -r 'echo trim((string)@shell_exec("wg genkey 2>/dev/null"));' | grep -qE '.{40,}'; then
|
||||
ok "php can run wg genkey (FPM user must match for connect)"
|
||||
else
|
||||
note "php CLI cannot run wg genkey — ensure www-data can run wg if provision_peers=true"
|
||||
fi
|
||||
|
||||
# DB tables (via bootstrap)
|
||||
php -r '
|
||||
"$PHP" -r '
|
||||
require "'"$ROOT"'/src/bootstrap.php";
|
||||
RemoteAccessRepository::ensureSchema();
|
||||
$pdo = Database::pdo();
|
||||
@@ -74,12 +92,24 @@ echo "OK MariaDB/SQLite remote_access tables present\n";
|
||||
' || fail=1
|
||||
|
||||
# API smoke (local php -S or deployed BASE)
|
||||
BASE="${BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
|
||||
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 BASE="$BASE" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; 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=…/crashes if needed)"
|
||||
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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Remote access control plane (WireGuard v1; reverse SSH reserved).
|
||||
-- Remote access control plane (WireGuard v1, reverse SSH reserved).
|
||||
-- MariaDB / production: mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_access_devices (
|
||||
|
||||
@@ -15,22 +15,32 @@ declare(strict_types=1);
|
||||
require_once dirname(__DIR__, 3) . '/platform/shared_session.php';
|
||||
require_once dirname(__DIR__, 3) . '/platform/footer.php';
|
||||
|
||||
if (!function_exists('session_start')) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "PHP session extension is not loaded.\n"
|
||||
. "Alpine: apk add php81-session (or php82-session) && rc-service php-fpm81 restart\n"
|
||||
. "Debian: apt install php-session\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$configPath = __DIR__ . '/../config/config.php';
|
||||
if (!is_file($configPath)) {
|
||||
$configPath = __DIR__ . '/../config/config.example.php';
|
||||
}
|
||||
$config = require $configPath;
|
||||
|
||||
platform_start_session($config['session_name'] ?? 'ac_crash_sess', $config['session_cookie_path'] ?? '/app/androidcast_project');
|
||||
$sessionName = $config['session_name'] ?? 'ac_crash_sess';
|
||||
$sessionPath = $config['session_cookie_path'] ?? '/app/androidcast_project';
|
||||
|
||||
if (!function_exists('session_start')) {
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
$phpV = PHP_VERSION;
|
||||
echo "PHP session extension is not loaded (SAPI=" . PHP_SAPI . ", PHP {$phpV}).\n"
|
||||
. "CLI check: php -m | grep -i session\n"
|
||||
. " php -v # match package major (php81-* vs php82-*)\n"
|
||||
. "Alpine: apk add php81-session php81-pdo # or php82-session for PHP 8.2\n"
|
||||
. " rc-service php-fpm81 restart\n"
|
||||
. "Debian: apt install php-session\n";
|
||||
exit(1);
|
||||
}
|
||||
// CLI cron (purge_remote_access.php, verify): PDO only; no web login cookie.
|
||||
} elseif (PHP_SAPI !== 'cli') {
|
||||
platform_start_session($sessionName, $sessionPath);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/Database.php';
|
||||
require_once __DIR__ . '/Rbac.php';
|
||||
|
||||
@@ -30,7 +30,8 @@ When `left` is null, the left column is built from `copyright_symbol`, `holder`,
|
||||
| `copyright_symbol` | `©` (U+00A9) or empty |
|
||||
| `show_year` | Append year / range on default left |
|
||||
| `year` | Fixed end year, or `null` for current |
|
||||
| `year_start` | If set (e.g. `2026`), shows `2026–{current}` |
|
||||
| `year_start` | Start year (integer). With `year_through_current` → `2026 - current`; else range `2026–{end}` |
|
||||
| `year_through_current` | If true with `year_start`, year label is `{start} - current` (not a rolling end year) |
|
||||
| `use_i18n` | `data-i18n` on auto-built left column |
|
||||
| `footer_class` | Extra CSS class (always adds `platform-footer`) |
|
||||
|
||||
|
||||
@@ -25,9 +25,10 @@ return [
|
||||
'show_year' => true,
|
||||
// null = current calendar year at render time (end of range).
|
||||
'year' => null,
|
||||
// Set e.g. 2026 for "2026–{current}" (en dash). Omit for single year only.
|
||||
'year_start' => "2026 - current",
|
||||
# 'year_start' => null,
|
||||
// Integer start year. With year_through_current: "2026 - current" (literal suffix).
|
||||
// Without it: "2026–2027" range (en dash) or lone "2026" when start equals end year.
|
||||
'year_start' => 2026,
|
||||
'year_through_current' => true,
|
||||
|
||||
// CSS class on <footer> (platform-footer layout classes are always added).
|
||||
'footer_class' => 'bottom-bar',
|
||||
|
||||
@@ -53,6 +53,9 @@ function platform_footer_year_label(array $cfg): string
|
||||
$end = platform_footer_year_end($cfg);
|
||||
$start = isset($cfg['year_start']) ? (int) $cfg['year_start'] : 0;
|
||||
if ($start > 0) {
|
||||
if (!empty($cfg['year_through_current'])) {
|
||||
return $start . ' - current';
|
||||
}
|
||||
if ($start === $end) {
|
||||
return (string) $start;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
## and
|
||||
## ssh -o 'ProxyJump=foxx@f0xx.org:222' 'foxx@10.7.16.128'
|
||||
|
||||
if [[ -z "$(mount | grep FE | grep var)" && -d "tmp/FE_gentoo/var/log/nginx" ]]; then
|
||||
set -x
|
||||
sshfs f0xx-monstro:/var/log/nginx tmp/FE_gentoo/var/log/nginx
|
||||
set +x
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
|
||||
if [[ -z "$(mount | grep BE | grep etc | grep f0xx)" && -d "tmp/BE_alpine/etc/nginx" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user