From bd339ee90c3e10754197163e7063f3cb87fa8f0b Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Wed, 3 Jun 2026 06:23:08 +0200 Subject: [PATCH] TOC inside the docs --- AGENTS.md | 1 + .../20260602_REVERSE_SSH_proposals_summary.md | 874 ++++++++++++++++++ ...20260602_REVERSE_SSH_proposals_summary.pdf | 829 +++++++++++++++++ docs/ALPHA.md | 23 + docs/ALPHA_SOAK.md | 17 + docs/AV_QUALITY_QA_SESSION.md | 33 + docs/BUILD_DEPLOY.md | 17 + docs/COMMERCIAL.md | 14 + docs/CRASH_REPORTER.md | 14 + docs/GIT_FLOW.md | 34 + docs/GRAFANA_vs_others_graphvis_pivot.md | 22 + docs/INFRA.md | 26 + docs/OPEN_API.md | 20 + docs/OTA.md | 20 + docs/PRO_AAR.md | 18 + docs/README.md | 100 ++ docs/ROADMAP.md | 31 +- docs/TICKETS_ROADMAP.md | 17 + docs/USB_HDMI_CAST.md | 15 + .../build_reverse_ssh_proposals_pdf.py | 24 + docs/_pdf_build/md_common.py | 48 + docs/_pdf_build/md_to_pdf.py | 318 +++++++ scripts/build-reverse-ssh-proposals-pdf.sh | 7 + scripts/generate-docs-toc.py | 247 +++++ 24 files changed, 2768 insertions(+), 1 deletion(-) create mode 100644 docs/20260602_REVERSE_SSH_proposals_summary.md create mode 100644 docs/20260602_REVERSE_SSH_proposals_summary.pdf create mode 100644 docs/README.md create mode 100644 docs/_pdf_build/build_reverse_ssh_proposals_pdf.py create mode 100644 docs/_pdf_build/md_common.py create mode 100644 docs/_pdf_build/md_to_pdf.py create mode 100755 scripts/build-reverse-ssh-proposals-pdf.sh create mode 100644 scripts/generate-docs-toc.py diff --git a/AGENTS.md b/AGENTS.md index 4024a9e..bac141d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ After a **reset** or new session on deploy/infra work: +0. **[docs/README.md](docs/README.md)** — documentation index (linked TOCs for all guides in `docs/`). 1. Read **[docs/INFRA.md](docs/INFRA.md)** end-to-end (topology, **no :8089** for androidcast, BE `listen 80`, sshfs `tmp/` paths). 2. For hub deploy only: **[examples/app_hub/DEPLOY.md](examples/app_hub/DEPLOY.md)**. 3. For PHP/DB/tickets: **[examples/crash_reporter/backend/README.md](examples/crash_reporter/backend/README.md)**. diff --git a/docs/20260602_REVERSE_SSH_proposals_summary.md b/docs/20260602_REVERSE_SSH_proposals_summary.md new file mode 100644 index 0000000..6960c7e --- /dev/null +++ b/docs/20260602_REVERSE_SSH_proposals_summary.md @@ -0,0 +1,874 @@ +# Android Cast — On-Demand Remote Access (Reverse Tunnel) Proposals + +_Date: 2026-06-02 — **merged document**: initial proposal (rev. 1) + infra review addendum (rev. 2)_ + +--- + +## Table of contents + + +- [Document history](#document-history) +- [Context and constraints](#context-and-constraints) +- [Executive summary](#executive-summary) + - [Initial proposal (rev. 1)](#initial-proposal-rev-1) + - [Refined after infra review (rev. 2)](#refined-after-infra-review-rev-2) +- [Stakeholder questions (rev. 2 — added to requirements)](#stakeholder-questions-rev-2-added-to-requirements) +- [Problem statement](#problem-statement) +- [Proposed control plane (heartbeat type: ra)](#proposed-control-plane-heartbeat-type-ra) + - [Device → BE (poll, random interval 1–7 min when enabled)](#device-be-poll-random-interval-17-min-when-enabled) + - [BE → device (only if whitelisted + operator requested session)](#be-device-only-if-whitelisted-operator-requested-session) + - [Disable (user unchecks box or admin revokes)](#disable-user-unchecks-box-or-admin-revokes) + - [Field reference](#field-reference) + - [State machine (simplified)](#state-machine-simplified) +- [Network topology — Gentoo FE → Alpine BE (rev. 2)](#network-topology-gentoo-fe-alpine-be-rev-2) + - [FE ↔ BE tuning (by track)](#fe-be-tuning-by-track) +- [Security model](#security-model) + - [Key handling options (rev. 1)](#key-handling-options-rev-1) +- [Embedding in the Android app (rev. 2)](#embedding-in-the-android-app-rev-2) +- [Backend integration ease — Alpine BE (rev. 2)](#backend-integration-ease-alpine-be-rev-2) +- [Licensing and open source (rev. 2)](#licensing-and-open-source-rev-2) +- [What “Custom file API” means (rev. 2 — clarified)](#what-custom-file-api-means-rev-2-clarified) +- [Tooling alternatives — full survey (rev. 1)](#tooling-alternatives-full-survey-rev-1) +- [Tooling summary — infra-focused (rev. 2)](#tooling-summary-infra-focused-rev-2) +- [Comparison matrix — initial scoring (rev. 1)](#comparison-matrix-initial-scoring-rev-1) +- [Comparison matrix — FE proxy focus (rev. 2)](#comparison-matrix-fe-proxy-focus-rev-2) +- [WireGuard vs SSH — decision lens (rev. 2)](#wireguard-vs-ssh-decision-lens-rev-2) +- [Recommendations](#recommendations) + - [Initial (rev. 1)](#initial-rev-1) + - [Refined (rev. 2 — Gentoo FE → Alpine BE)](#refined-rev-2-gentoo-fe-alpine-be) +- [RBAC roles (BE)](#rbac-roles-be) +- [BE admin UI (crashes look-and-feel)](#be-admin-ui-crashes-look-and-feel) +- [MariaDB schema (sketch — rev. 1)](#mariadb-schema-sketch-rev-1) +- [Android app changes (sketch — rev. 1)](#android-app-changes-sketch-rev-1) +- [Estimates (engineering)](#estimates-engineering) +- [Open questions (merged)](#open-questions-merged) +- [Rev. 3 — WG vs SSH Q&A + bastion deep-dive](#rev-3-wg-vs-ssh-qa-bastion-deep-dive) + - [Q1 — Exact FE/BE rules: WireGuard (DNAT) vs SSH (no DNAT required)](#q1-exact-febe-rules-wireguard-dnat-vs-ssh-no-dnat-required) + - [Q2 — Can we use ports other than :80? (heartbeats and everything else)](#q2-can-we-use-ports-other-than-80-heartbeats-and-everything-else) + - [Q3 — Why is “Proxyable (FE→BE)” low for WG (2) and high for SSH (5)?](#q3-why-is-proxyable-febe-low-for-wg-2-and-high-for-ssh-5) +- [Rev. 3 — Bastion Q&A](#rev-3-bastion-qa) + - [Q4 — How easy to implement? Command checklist (FE + BE)](#q4-how-easy-to-implement-command-checklist-fe-be) + - [Q5 — Bastion: file transfers only, or other operations too?](#q5-bastion-file-transfers-only-or-other-operations-too) +- [Source linkage](#source-linkage) + + +**Documentation index:** [README.md](README.md) + +--- + +## Document history + +| Rev | Focus | Summary | +|---|---|---| +| **1** | Initial research memo | On-demand reverse tunnel via heartbeat `type: ra`; compare WG, SSH, tinc, Tailscale, etc.; hybrid **WireGuard outbound + SSH/SFTP bastion** as first pick. | +| **2** | Stakeholder Q&A | Gentoo FE → Alpine BE path, embed WG/SSH in app, licensing, Custom file API clarified; refined pick **reverse SSH/SFTP** (**Proxyable**) over WG (**Lightweight**) for this nginx topology. | +| **3** | WG vs SSH + bastion deep-dive | Exact FE/BE commands (DNAT vs `stream`), port policy, **Proxyable** scoring explained, bastion scope (files vs full debug). | + +Both revisions are kept below. Where they differ, **§14 Recommendations** lists both and explains why rev. 2 refined the tunnel choice. **§Rev. 3** adds operator-ready command checklists for the final pick. + +--- + +## Context and constraints + +- **Need:** occasional remote debug / manual settings on **one field device** (e.g. edit `codecs.json` in app home, inspect logs) without a **persistent** device↔BE tunnel. +- **Stack:** minimalistic LAMP (nginx + php-fpm + MariaDB) on BE; Android app already posts to `/api/heartbeat.php`. +- **Traffic path (rev. 2):** **Mobile app → Gentoo FE** (TLS, firewall, nginx reverse proxy) **→ Alpine BE** (XEN HVM, project services). Heartbeat already follows this; any data tunnel must be tunable on **both** FE and BE. +- **Security:** no standing inbound connection from BE to device; user opt-in; admin whitelist; full audit trail; minimal secret exposure on wire. +- **Ops:** sessions expire on inactivity; 24h DB cleanup; user can disable instantly from app settings. +- **Convenience (rev. 2):** SCP-/SFTP-**like** file exchange is **not mandatory** but highly desirable for `codecs.json` and logs. + +--- + +## Executive summary + +### Initial proposal (rev. 1) + +- **Recommended:** on-demand reverse tunnel orchestrated by BE heartbeat (`type: ra`) + **WireGuard or SSH over a single ephemeral port** on BE, with **per-session keys** and **PHP admin UI** matching crashes console look-and-feel. +- **Not recommended for v1:** always-on VPN mesh (tinc/Tailscale on every device), public inbound SSH to phones, or BE-initiated connections through carrier NAT. +- **Pattern:** device polls BE at **random 1–7 min** when user enables “Remote access”; BE responds only if device is **whitelisted**; device then **dials out** and attaches tunnel; operator connects to BE **bastion/port**, not to the phone directly. +- **Primary pick (rev. 1):** WireGuard outbound + SSH/SFTP on bastion for operator (combines mobile simplicity with familiar file edit workflow for `codecs.json`). +- **Fallback (rev. 1):** if UDP blocked on target networks, use **chisel/frp over HTTPS** on same `ra` vhost with identical control plane. + +### Refined after infra review (rev. 2) + +- **Control plane:** unchanged (heartbeat `type: ra`, checkbox, whitelist, jittered poll, device dials out). +- **Tunnel choice (refined):** prefer **reverse SSH (+ SFTP)** over WireGuard for v1 **on this stack**. +- **One word — SSH:** **Proxyable** — TCP (nginx **`stream`**) matches Gentoo FE → Alpine BE like existing HTTP proxy; SFTP gives SCP-like files. +- **One word — WireGuard:** **Lightweight** — excellent embeddable crypto, but **UDP does not ride nginx HTTP reverse-proxy**; FE needs UDP DNAT/firewall rules. +- **Custom file API:** optional third track — HTTPS upload/download via PHP (§10); good Phase-1 or narrow path without sshd. + +--- + +## Stakeholder questions (rev. 2 — added to requirements) + +| # | Question | Short answer | +|---|---|---| +| Q1 | Is WireGuard “best”? | Best **crypto/simplicity**, not best **FE→BE proxy fit** for your stack. | +| Q2 | Embed WG in app or separate app? | **Embed** via `wireguard-android` tunnel library + `VpnService`; no standalone WireGuard app. | +| Q3 | Embed SSH or separate app? | **Embed** Dropbear/libssh2/MINA SSHD; no Termux. | +| Q4 | BE integration: WG vs SSH? | **SSH easier** on Alpine (`openssh`, `authorized_keys`, `-R` port). WG needs `wg set` + **UDP** FE→BE. | +| Q5 | Traffic Mobile → Gentoo FE → Alpine BE? | Heartbeat: `proxy_pass :80`. SSH: nginx **`stream`**. WG: **UDP DNAT** (not nginx `location /`). | +| Q6 | Licensing / open source? | See §9; Tailscale server is main license caveat. | +| Q7 | What is “Custom file API”? | HTTPS file transfer via PHP — not SCP, not a VPN; see §10. | +| Q8 | SCP-like file exchange? | Native **SFTP (SSH)**; similar UX via Custom file API; WG needs SSH/SFTP on top for SCP-like UX. | + +--- + +## Problem statement + +| Aspect | Detail | +|---|---| +| Trigger | Single device in the field needs interactive access (debug, replace config, pull logs). | +| Anti-goals | No permanent VPN; no open listening port on mobile; no broad fleet exposure. | +| User consent | Checkbox in app settings (“allow remote access at my own risk”). | +| Admin gate | Device ID must be on BE **remote_access_devices** whitelist before any session is offered. | +| Observability | Every enable/disable/connect/disconnect/timeout logged with reason and actor. | +| Path (rev. 2) | Signalling via FE→BE :80; tunnel endpoint on FE forwarded to BE. | + +--- + +## Proposed control plane (heartbeat `type: ra`) + +Reuse existing heartbeat endpoint; add branch for remote access. + +### Device → BE (poll, random interval 1–7 min when enabled) + +```json +{ + "heartbeat": { + "type": "ra", + "status": "enable", + "device_id": "", + "random": "", + "app_version": "0.1.0", + "capabilities": ["ssh", "sftp", "files_app_home"] + } +} +``` + +_Also accepted (rev. 2): `"capabilities": ["ssh_reverse", "sftp", "files_app_home"]`._ + +### BE → device (only if whitelisted + operator requested session) + +```json +{ + "heartbeat": { + "type": "ra", + "action": "connect", + "session_id": "uuid", + "endpoint": "ra.apps.f0xx.org:443", + "tunnel": "wireguard|ssh|ssh_reverse", + "credentials": { + "mode": "ephemeral_pubkey|otp", + "username": "ra-", + "host_key_fingerprint": "…", + "remote_forward": "127.0.0.1::127.0.0.1:8022" + }, + "expires_at": 1717340000 + } +} +``` + +_WireGuard branch (rev. 1): `"tunnel": "wireguard"` with peer keys in `credentials`. SSH branch (rev. 2): `"tunnel": "ssh_reverse"` as above._ + +### Disable (user unchecks box or admin revokes) + +```json +{ "heartbeat": { "type": "ra", "status": "disable", "device_id": "...", "random": "..." } } +``` + +### Field reference + +| Field | Purpose | +|---|---| +| `random` | Client nonce for matching pending BE session; rotation on reconnect. | +| `session_id` | DB primary key for audit + port/key binding. | +| `status` | `enable` / `disable` / `poll` (optional alias for enable). | +| `action` | BE command: `connect`, `wait`, `deny`, `rotate`. | + +### State machine (simplified) + +```text +[user enables RA] → device polls → BE: wait (not whitelisted / no operator) + → admin opens session for device_id → next poll → BE: connect + creds + → device opens outbound tunnel → BE marks active → operator uses bastion +[user disables] → disable heartbeat → tunnel tear-down → BE inactive + audit row +[24h idle] → BE cron purges stale rows + closes ports +``` + +--- + +## Network topology — Gentoo FE → Alpine BE (rev. 2) + +```text +[Android app] + │ HTTPS POST /api/heartbeat.php (type: ra) + ▼ +[Gentoo FE — apps.f0xx.org] + │ nginx: proxy_pass http://artc0.intra.raptor.org:80 (existing) + ▼ +[Alpine BE — artc0 / XEN HVM] + │ php-fpm + MariaDB (control plane) + │ sshd / ra-bastion (data plane — SSH track) + │ wg0 + UDP listener (data plane — WG track, if used) + +[Operator laptop] + │ SSH/SFTP to FE or BE bastion port (never to phone IP) + ▼ + session forwarded to device's reverse tunnel +``` + +### FE ↔ BE tuning (by track) + +| Track | Public entry (FE) | FE config | BE service | Notes | +|---|---|---|---|---| +| **Heartbeat (all)** | `https://apps.f0xx.org/.../heartbeat.php` | `proxy_pass http://artc0:80` | php-fpm | Already works. | +| **Reverse SSH** | `ra.apps.f0xx.org:443` TCP or `:2222` | `stream { proxy_pass artc0:22; }` | `sshd` + `Match User ra-*` | Rev. 2 pick (**Proxyable**). | +| **WireGuard** | `ra.apps.f0xx.org:51820/udp` | **iptables/nft DNAT** → BE | `wg-quick` / dynamic peers | Rev. 1 pick candidate (**Lightweight**). | +| **Custom file API** | `https://apps.f0xx.org/.../api/ra/*` | existing HTTP proxy | php-fpm only | No stream/UDP rules. | + +--- + +## Security model + +| Layer | Mechanism | +|---|---| +| Authorization | RBAC roles (§12) + per-device whitelist + user opt-in on device. | +| Authentication | Per-session ephemeral key pair or one-time port + password; no long-lived device secrets in APK. | +| Transport | TLS to BE for heartbeat; tunnel encrypted (WireGuard / SSH). | +| Exposure | BE listens on dedicated **ra** vhost/port; no inbound to mobile; optional IP allowlist for operators. | +| Audit | `remote_access_events` table: who, device, action, reason, ip, timestamps. | +| Cleanup | Inactivity timeout (e.g. 30–60 min); daily purge of records > 24h; credential invalidation on disable. | +| Reconnect | Same `session_id` + rotated `random` within grace window; new keys if expired. | + +### Key handling options (rev. 1) + +| Mode | Description | Risk | +|---|---|---| +| Ephemeral WG keypair | BE generates keys per session; device installs peer config once | Low; keys short-lived | +| SSH host cert + user cert | Step-CA or internal CA signs 1h user cert | Medium ops; mature tooling | +| Pre-allocated device key | One WG pubkey per device in DB | Higher leak impact; simpler reconnect | +| OTP + reverse SSH | `autossh -R random_port` with password from BE | Simple; weaker than pubkey | + +**Key ceremony (rev. 1):** ephemeral WireGuard or SSH **user certificate** per session; optional device **identity key** (Ed25519) registered at whitelist time for authenticating poll requests only (not tunnel). + +**Additional (rev. 2):** + +- Terminate operator SSH on FE stream or require VPN to Gentoo before bastion. +- Device **outbound-only** to `ra.apps.f0xx.org:443` (TCP). +- Per-session **`ForceCommand internal-sftp`** if shell not required. + +--- + +## Embedding in the Android app (rev. 2) + +| Solution | Separate app required? | How to embed | Android notes | +|---|---|---|---| +| **WireGuard** | **No** | [`wireguard-android`](https://git.zx2c4.com/wireguard-android) tunnel AAR / `GoBackend` | `VpnService`; VPN icon while active. | +| **SSH reverse** | **No** | Dropbear + JNI, **libssh2**, **Apache MINA SSHD** | Foreground service; no VPN icon. | +| **frp/chisel client** | **No** | Static binary or Go mobile bind | Extra artifact. | +| **Custom file API** | **No** | OkHttp + JSON | Pure Java/Kotlin. | + +You do **not** need Termux or the standalone WireGuard app if libraries are embedded in **Android Cast**. + +--- + +## Backend integration ease — Alpine BE (rev. 2) + +| Aspect | Reverse SSH | WireGuard | +|---|---|---| +| Package on Alpine | `openssh` | `wireguard-tools` + kernel mod | +| Session open (PHP) | `authorized_keys` + `-R` port; optional `ForceCommand internal-sftp` | `wg set wg0 peer …` | +| Session close | Remove key; kill session; free port | `wg set … remove` | +| FE cooperation | nginx **`stream`** (TCP) | **UDP DNAT** | +| Operator access | `sftp -P 443 ra-...@ra.apps.f0xx.org` | `wg-quick up` or SSH over WG | +| Jump host `f0xx.org:222` | Natural extension | Separate UDP path | + +--- + +## Licensing and open source (rev. 2) + +| Component | License | Ship in app / BE | Notes | +|---|---|---|---| +| **WireGuard** (kernel module) | **GPL-2.0** | BE kernel | Stock Alpine package. | +| **wireguard-go** / Android tunnel | **MIT** / **Apache-2.0** | App embed | Userspace embed. | +| **OpenSSH** | **BSD-style** | BE + client | Permissive. | +| **Dropbear** | **Permissive (MIT-like)** | App embed | Small client. | +| **libssh2** | **BSD-3-Clause** | App embed | | +| **Apache MINA SSHD** | **Apache-2.0** | App (Java) | SSH/SFTP in Java. | +| **chisel** | **MIT** | App/BE | | +| **frp** | **Apache-2.0** | App/BE | | +| **tinc** | **GPL-2.0+** | — | Copyleft; mesh. | +| **Tailscale client** | **BSD-3-Clause** | — | Client OSS. | +| **Headscale / Tailscale server** | **BSD** / **BSL** | — | Self-host Headscale if needed. | +| **Custom file API** | **Project code** | BE PHP | No third-party tunnel license. | + +_GPL-2.0 Android app: use permissively licensed clients (MIT/BSD/Apache); review before linking GPL client libs into APK._ + +--- + +## What “Custom file API” means (rev. 2 — clarified) + +**Not** a tunnel and **not** the SCP protocol. Application-layer remote file exchange on existing PHP: + +- Endpoints: `POST /api/ra/upload`, `GET /api/ra/download`, `GET /api/ra/list`. +- Auth: short-lived token from heartbeat `connect` (same session as control plane). +- Scope: **`getFilesDir()`** / `codecs.json` unless Storage permission granted (broader scope needs second consent). +- Operator: crashes-themed **web UI** or `curl` — **SCP-like** push/pull without `scp` binary. +- **Pros:** no FE stream/UDP; minimal attack surface (no shell); reuses LAMP. +- **Cons:** no shell; implement listing/chunking for large logs. + +**Use when:** Phase-1 before sshd bastion, or permanent narrow path alongside SSH for power users. + +_Original rev. 1 one-liner: “Custom HTTPS file API — PHP endpoints; no shell; minimal attack surface; more app code for file ops.”_ + +--- + +## Tooling alternatives — full survey (rev. 1) + +| Option | Maturity | Mobile fit | BE fit | Pros | Cons / risks | +|---|---|---|---|---|---| +| **WireGuard (outbound from device)** | High | Excellent (userspace WG) | Single UDP listener / per-session peers | Fast, modern crypto, small code | UDP blocked on some carriers; peer lifecycle automation | +| **Reverse SSH (`autossh -R`)** | High | Good (embedded dropbear client) | sshd on bastion | Familiar, SFTP, shell | Key mgmt; battery if misconfigured | +| **SSH via WebSocket (chisel, frp)** | Medium | Good | One HTTPS port | Traverses strict firewalls | Extra binary; audit frp history | +| **OpenVPN / IKEv2 (on-demand)** | High | Heavy | openvpn-access | Full tunnel | Overkill; battery; not file-focused | +| **tinc** | Medium | Poor (daemon, mesh) | mesh coordinator | P2P mesh | Persistent mesh ≠ on-demand; key distribution painful | +| **Tailscale / Headscale** | High | App + VPN | control server | Easy UX | Persistent identity; fleet-wide trust | +| **MeshCentral / RustDesk** | High | Agent app | relay server | Remote desktop | Different UX; screen control not primary need | +| **ADB over TLS tunnel** | Medium | Dev-only | reverse port | Exact Android debug | Dev options; security sensitive | +| **Custom HTTPS file API** | Low (build) | Native | PHP endpoints | No shell; minimal attack surface | No arbitrary shell; more app code | + +--- + +## Tooling summary — infra-focused (rev. 2) + +| Option | Embed? | FE→BE fit | SCP-like | License | Verdict | +|---|---|---|---|---|---| +| **Reverse SSH + SFTP** | Yes | **Excellent** (`stream`) | **Native** | BSD | **v1 pick (rev. 2)** | +| **WireGuard outbound** | Yes | Fair (UDP DNAT) | Via SFTP over WG | MIT/GPL | **v1 pick (rev. 1)** / v2 if UDP path ready | +| **Custom file API** | Yes | **Excellent** (HTTP) | Similar UX | Yours | Phase-1 / narrow | +| **frp/chisel** | Yes | Good | Possible | MIT/Apache | Fallback (both revs) | +| **tinc / Tailscale** | VPN app | Poor on-demand | Varies | GPL/BSL | Not recommended | + +--- + +## Comparison matrix — initial scoring (rev. 1) + +Scoring: 1 = low, 5 = high. Lower total was interpreted as stronger general fit in rev. 1 table orientation. + +| Criterion | WG outbound | Reverse SSH | frp/chisel | tinc | Tailscale | Custom file API | +|---|:---:|:---:|:---:|:---:|:---:|:---:| +| Security | 5 | 4 | 3 | 3 | 4 | 5 | +| Simplicity (ops) | 4 | 4 | 3 | 2 | 3 | 4 | +| On-demand fit | 5 | 5 | 5 | 1 | 2 | 5 | +| File access (app dir) | 4 | 5 | 4 | 3 | 4 | 5 | +| Shell/debug | 4 | 5 | 4 | 3 | 4 | 1 | +| Fits LAMP BE | 4 | 5 | 4 | 3 | 3 | 5 | +| Battery / data | 4 | 3 | 3 | 2 | 2 | 5 | +| **Weighted total** | **31** | **30** | **26** | **17** | **22** | **30** | + +_Rev. 1 tie note: WireGuard, reverse SSH, and Custom file API scored highest. Rev. 1 suggested **WireGuard + SFTP/SSH bastion** if shell needed; **Custom file API** if only `codecs.json` + logs._ + +--- + +## Comparison matrix — FE proxy focus (rev. 2) + +| Criterion | Rev SSH | WG outbound | Custom file API | frp/chisel | +|---|:---:|:---:|:---:|:---:| +| **Proxyable (FE→BE)** | **5** | 2 | **5** | 4 | +| Security | 4 | 5 | 5 | 3 | +| On-demand fit | 5 | 5 | 5 | 5 | +| SCP-like files | **5** | 3* | 4 | 3 | +| BE integration ease | **5** | 3 | **5** | 3 | +| Embed without 3rd-party app | 5 | 5 | **5** | 4 | +| **Total** | **29** | 23 | **29** | 22 | + +\*WG files row assumes SSH/SFTP layered on tunnel. + +--- + +## WireGuard vs SSH — decision lens (rev. 2) + +| Criterion | WireGuard | Reverse SSH + SFTP | +|---|---|---| +| **One-word headline** | **Lightweight** | **Proxyable** | +| Embed in app | Yes (`VpnService`) | Yes (libssh2/MINA/dropbear) | +| FE nginx HTTP proxy | No (UDP) | Yes (**`stream` TCP**) | +| Gentoo → Alpine path | Firewall UDP DNAT | Same as HTTP ops model | +| SCP-like files | Needs SSH/SFTP on top | **Built-in (SFTP)** | +| Carrier networks | UDP sometimes blocked | TCP 443 usually works | + +--- + +## Recommendations + +### Initial (rev. 1) + +1. **Phase 0** — Design + threat model (1–2 days): consent copy, port allocation, key ceremony. +2. **Phase 1** — Control plane (1–2 weeks): heartbeat `ra`, whitelist, admin UI, audit, cron; dry-run `wait`/`connect`. +3. **Phase 2** — Tunnel (1–2 weeks): **WireGuard outbound** from app OR **reverse SSH to bastion**; operator connects via existing VPN/SSH to BE network. +4. **Phase 3** — Hardening: rate limits, mTLS on heartbeat, device identity signing, reconnect, pen-test. + +**Primary pick (rev. 1):** WireGuard outbound + SSH/SFTP on bastion for operator. + +**Fallback (rev. 1):** chisel/frp over HTTPS on same `ra` vhost. + +### Refined (rev. 2 — Gentoo FE → Alpine BE) + +**Primary v1:** **reverse SSH (+ SFTP)** — path is Mobile → Gentoo FE (nginx) → Alpine BE; **Proxyable** TCP; SFTP for `codecs.json`. + +**Secondary:** WireGuard when dedicated **UDP forwarding** on FE is acceptable — **Lightweight** tunnel. + +**Optional parallel:** **Custom file API** for whitelist-only file push/pull without internet-facing sshd. + +**Phases (merged):** + +1. Phase 0 — FE/BE port map + threat model (1–2 d). +2. Phase 1 — heartbeat `ra` + admin UI + optional Custom file API (1–2 w). +3. Phase 2 — reverse SSH/SFTP + Gentoo `stream` → Alpine sshd **or** WG track if chosen (1–2 w). +4. Phase 3 — MFA, reconnect, pen-test. + +--- + +## RBAC roles (BE) + +| Role | Permissions | +|---|---| +| **remote_access_user** | Open/close own sessions for whitelisted devices; view active/inactive list (scoped); SFTP / file API. | +| **admin** | Same as user + add/remove whitelist entries + assign `remote_access_user` role. | +| **root** | Full role management + force-disconnect any session + view all audit logs. | + +Integrate with existing `users` / `Rbac`; new permissions: `remote_access_view`, `remote_access_operate`, `remote_access_admin`. + +--- + +## BE admin UI (crashes look-and-feel) + +- New nav: **Remote access** under `/app/androidcast_project/crashes/?view=remote_access` (or `/remote/`). +- Reuse `layout.php`, themes, locale picker, session auth. +- Screens (rev. 1): + 1. **Devices** — whitelist, last seen, RA opt-in status (from last heartbeat). + 2. **Sessions** — active / inactive; device, operator, started, last activity, tunnel type, port, status. + 3. **Audit log** — filter by device, user, action, date. + 4. **Open session** — pick device → pending session → waits for next device poll. +- Rev. 2 add-on: **file browser** tab when Custom file API enabled (upload/download `codecs.json`). +- No new frontend framework; same CSS/JS as tickets/graphs. + +--- + +## MariaDB schema (sketch — rev. 1) + +```sql +-- remote_access_devices (whitelist) +-- remote_access_sessions (session_id, device_id, operator_user_id, status, +-- tunnel, port, keys_ref, expires_at, last_activity) +-- remote_access_events (audit) +-- remote_access_credentials (optional encrypted blob, TTL) +``` + +Cron: `scripts/purge_remote_access.php` — inactive > 24h → `closed_timeout`; revoke keys; free ports. + +--- + +## Android app changes (sketch — rev. 1) + +- Settings → Developer → **Allow remote access** (off by default, strong warning). +- `RemoteAccessService` (foreground while tunnel active): jittered alarm 1–7 min → POST heartbeat. +- On `action: connect`: start WireGuard/SSH client with supplied creds; expose app files dir only unless Storage permission granted (broader scope needs second consent). +- On disable: stop service, wipe session keys, POST `status: disable`. +- Persist nothing long-term except optional device identity pubkey for poll authentication. + +--- + +## Estimates (engineering) + +| Track | Initial delivery | Hardening | Total | +|---|---:|---:|---:| +| Control plane only (heartbeat + DB + admin list) | 5–8 d | 3–5 d | 8–13 d | +| Control plane + Custom file API | 6–9 d | 3–5 d | 9–14 d | +| + WireGuard outbound tunnel (rev. 1 pick) | 8–12 d | 5–8 d | 13–20 d (+ FE UDP) | +| + Reverse SSH/SFTP bastion (rev. 2 pick) | 7–11 d | 4–7 d | 11–18 d | +| + FE nginx `stream` + firewall docs | +1–2 d | +1 d | (included in SSH track) | + +_Assumptions: one BE bastion; excludes Play Store policy review._ + +--- + +## Open questions (merged) + +**Rev. 1:** + +- Maximum concurrent RA sessions per BE? +- Store tunnel configs only in RAM vs encrypted MariaDB blob? +- Require operator MFA (TOTP) before `Open session`? +- Play Store disclosure for “remote access” foreground service? + +**Rev. 2:** + +- Public TCP port: dedicated `2222` vs share `443` with `ssl_preread`? +- SFTP-only vs full shell for `remote_access_user`? +- Store device identity Ed25519 pubkey at whitelist time? +- Rate limit heartbeat `ra` polls? + +**Rev. 3:** + +- Confirm public RA port(s) with FE firewall owner before Phase 2? +- SFTP-only (`ForceCommand internal-sftp`) vs full shell for v1? + +--- + +## Rev. 3 — WG vs SSH Q&A + bastion deep-dive + +_Added 2026-06-02 — concrete FE/BE commands and decision support._ + +Constants used below (from [INFRA.md](INFRA.md)): + +| Symbol | Value | +|---|---| +| **FE** | Gentoo HVM, public `apps.f0xx.org` (TLS **443** terminates here) | +| **BE** | Alpine `artc0.intra.raptor.org` / `10.7.16.128`, app vhost **`listen 80`** | +| **FE→BE (existing HTTP)** | `proxy_pass http://artc0.intra.raptor.org:80` | +| **Operator jump (existing)** | `f0xx.org:222` → Alpine `:22` | + +Replace `10.7.16.128` with the live BE IP if it changes. + +--- + +### Q1 — Exact FE/BE rules: WireGuard (DNAT) vs SSH (no DNAT required) + +**Short answer:** WireGuard **must** use **UDP DNAT** (or equivalent firewall forward) on Gentoo FE because nginx **`proxy_pass` is HTTP/TCP-only** and cannot carry WG packets. SSH **does not need DNAT** on FE if you use nginx **`stream`** TCP forwarding (same nginx daemon, different block). You *can* use TCP DNAT for SSH as an alternative, but it is redundant when `stream` works. + +#### WireGuard track — FE (Gentoo): UDP DNAT + forward allow + +Public UDP **51820** on FE → BE **51820**. Example with **nftables** (adjust interface `eth0`, table/chain names to match your FE): + +```bash +# FE — one-time / boot-persistent (example) +BE_IP=10.7.16.128 +WG_PORT=51820 + +nft add rule inet nat prerouting iifname "eth0" udp dport $WG_PORT \ + dnat ip to ${BE_IP}:${WG_PORT} +nft add rule inet filter forward ip daddr ${BE_IP} udp dport ${WG_PORT} accept +``` + +**iptables equivalent:** + +```bash +BE_IP=10.7.16.128 +WG_PORT=51820 + +iptables -t nat -A PREROUTING -i eth0 -p udp --dport $WG_PORT \ + -j DNAT --to-destination ${BE_IP}:${WG_PORT} +iptables -A FORWARD -p udp -d ${BE_IP} --dport $WG_PORT -j ACCEPT +``` + +**Persist:** save rules (`/etc/nftables.conf` or `iptables-save` → Gentoo `iptables` init). **Open UDP 51820** on FE edge firewall (in addition to 443/tcp). + +**Verify from outside:** + +```bash +# should show BE wg listening (once BE step done) +nc -u -v apps.f0xx.org 51820 +``` + +#### WireGuard track — BE (Alpine): interface + listen + +```bash +# BE +apk add wireguard-tools +modprobe wireguard 2>/dev/null || echo "wireguard module required" + +cat >/etc/wireguard/wg0.conf <<'EOF' +[Interface] +Address = 10.66.66.1/24 +ListenPort = 51820 +PrivateKey = +# PostUp/PostDown: optional iptables for operator subnet +EOF + +wg-quick up wg0 +rc-update add wg-quick.wg0 default # if using OpenRC +``` + +**Per RA session** (PHP or admin script adds ephemeral peer — example): + +```bash +wg set wg0 peer allowed-ips 10.66.66.2/32 +# on session end: +wg set wg0 peer remove +``` + +Device connects **outbound UDP** to `apps.f0xx.org:51820` (DNAT lands on BE). Operator joins same WG net (second peer) or uses SSH **over** the tunnel (see Q5). + +--- + +#### SSH track — FE (Gentoo): nginx **`stream`** (TCP proxy, not DNAT) + +Preferred: public TCP **443** on `ra.apps.f0xx.org` (or dedicated **2222**) → BE **22**. No kernel DNAT if nginx terminates the TCP hop. + +Add to FE nginx (often `/etc/nginx/nginx.conf`, **outside** `http {}`): + +```nginx +stream { + upstream be_sshd { + server 10.7.16.128:22; + } + server { + listen 443; # or 2222 if 443 shared with TLS SNI split + proxy_pass be_sshd; + proxy_connect_timeout 30s; + } +} +``` + +If **443** is already used for HTTPS on the same IP, either: + +- use **`listen 2222`** for RA SSH, or +- use **`ssl_preread`** on 443 to split HTTPS vs SSH by ClientHello (more complex). + +**Reload FE nginx:** + +```bash +nginx -t && nginx -s reload +``` + +**Verify:** + +```bash +ssh -p 443 -o StrictHostKeyChecking=accept-new ra-test@ra.apps.f0xx.org +# or: nc -v ra.apps.f0xx.org 2222 +``` + +#### SSH track — optional FE DNAT (parallel to WG, for comparison only) + +If you refused nginx `stream`, SSH could mirror WG with **TCP DNAT**: + +```bash +# FE — iptables example (TCP 2222 → BE:22) +iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 2222 \ + -j DNAT --to-destination 10.7.16.128:22 +iptables -A FORWARD -p tcp -d 10.7.16.128 --dport 22 -j ACCEPT +``` + +This works but **duplicates** what `stream` already does and bypasses nginx logging/timeouts. **Recommendation:** use **`stream`**, not DNAT, for SSH. + +#### SSH track — BE (Alpine): sshd + reverse-forward slot + +```bash +# BE +apk add openssh +rc-update add sshd default +rc-service sshd start + +# Dedicated match block — /etc/ssh/sshd_config.d/ra.conf +cat >/etc/ssh/sshd_config.d/ra.conf <<'EOF' +Match User ra-* + AllowTcpForwarding yes + GatewayPorts no + X11Forwarding no + PermitTTY yes + # v1 file-only: ForceCommand internal-sftp +EOF +rc-service sshd restart +``` + +**Per session — device outbound reverse forward** (embedded client runs equivalent): + +```bash +# On device (conceptual — app embeds this): +ssh -N -R 127.0.0.1:18022:127.0.0.1:8022 ra-@ra.apps.f0xx.org -p 443 +``` + +**BE — operator reaches device file area via forwarded port:** + +```bash +# On BE (after device connected): +sftp -P 18022 ra-@127.0.0.1 +# or scp -P 18022 ... +``` + +--- + +#### Side-by-side: what runs where + +| Step | WireGuard | Reverse SSH | +|---|---|---| +| **FE edge open** | UDP **51820** | TCP **443** or **2222** | +| **FE mechanism** | **nft/iptables DNAT** | nginx **`stream`** (or optional TCP DNAT) | +| **BE daemon** | `wg0` / `wg set` | `sshd` + ephemeral `Match User ra-*` | +| **Device direction** | Outbound UDP to FE public IP | Outbound TCP to FE → proxied to BE sshd | +| **Operator path** | WG peer IP or SSH over WG | SFTP/SSH to BE forwarded port (see Q5) | + +--- + +### Q2 — Can we use ports other than `:80`? (heartbeats and everything else) + +**Two different “ports” — do not conflate them.** + +| Leg | Port today | Can change? | Notes | +|---|---|---|---| +| **Phone → FE** | **443** (HTTPS) | Yes, but **443 is correct** for TLS + carrier-friendly egress | App already targets `https://apps.f0xx.org/...`; users never hit `:80` directly. | +| **FE → BE (heartbeat, crashes, graphs)** | **80** (HTTP) | **Yes** | Requires new FE `proxy_pass` upstream **and** BE `listen` block. Not forbidden — just **unused today**. | +| **RA data plane (WG)** | **51820/udp** (proposed) | Must be **non-80** | UDP tunnel; unrelated to HTTP vhost. | +| **RA data plane (SSH)** | **443/tcp** or **2222/tcp** | Must be **non-80** | Raw TCP/`stream`; not HTTP `location /`. | + +**Why heartbeats stay on the existing `:80` FE→BE path (recommended for v1):** + +1. **Already deployed** — [nginx.fe-apps.f0xx.org.snippet](../examples/crash_reporter/backend/nginx.fe-apps.f0xx.org.snippet) and BE `listen 80` fragment are the repo source of truth; zero new FE/BE holes for control plane. +2. **Same security zone** as crash upload and graphs — one ops playbook. +3. **No benefit** to moving heartbeat to BE `:443` internally — the phone still speaks HTTPS to FE; internal hop is your choice. +4. **Port 8089** on BE is **Janus/other** — do **not** reuse for androidcast ([INFRA.md §2](INFRA.md)). + +**If you intentionally want heartbeat on BE `:443` internally:** + +```bash +# FE — change upstream in server block (example) +# proxy_pass https://artc0.intra.raptor.org:443; +# BE — ensure listen 443 ssl server includes /api/heartbeat.php location +``` + +That is a **separate migration**; not required for RA. + +**Summary:** Public **443** for all HTTPS (including heartbeat). Internal FE→BE **80** for LAMP today — **can** change, **should not** for v1 unless you have another reason. RA tunnels **must** use **dedicated non-80** ports (UDP 51820 or TCP 443/2222 stream). + +--- + +### Q3 — Why is “Proxyable (FE→BE)” low for WG (2) and high for SSH (5)? + +The rev. 2 score measures one thing: **“Can we forward this service from Gentoo FE to Alpine BE the same way we already forward crashes/hub HTTP?”** + +| | **Reverse SSH** | **WireGuard** | +|---|---|---| +| **Protocol** | TCP | UDP | +| **Existing FE tool** | nginx **`stream`** → `10.7.16.128:22` | **Not** nginx `location` / `proxy_pass` | +| **FE change type** | Add `stream {}` block; reload nginx | **Firewall DNAT** + forward rules; separate from nginx HTTP config in repo | +| **Ops familiarity** | Same team that edits `nginx.conf` | Often different (iptables/nft, UDP, kernel module) | +| **Debugging** | `nginx -t`, error log, `curl`/ `nc` TCP | `nft list ruleset`, UDP blocked on some carriers, `wg show` on BE | +| **Matches INFRA docs** | Extends nginx pattern | New parallel path not in current snippets | + +**Score meaning:** + +- **5 (SSH):** “Drop in a `stream` upstream to BE — same deployment rhythm as today’s reverse proxy.” +- **2 (WG):** “Works, but **not** proxyable via HTTP nginx; needs **DNAT** (or a UDP proxy like `udp2raw`, which is extra complexity). Partial credit — not zero — because FE→BE forwarding is still one hop.” + +**Important nuance:** Low **Proxyable** does **not** mean WG is insecure or bad on the phone — it means **your FE→BE operational model** favours TCP/nginx. That is why rev. 2 refined the v1 pick to SSH despite WG being **Lightweight** on-device. + +**Custom file API also scores 5** because it reuses existing **`proxy_pass http://artc0:80`** with new PHP locations — no `stream`, no DNAT. + +--- + +## Rev. 3 — Bastion Q&A + +### Q4 — How easy to implement? Command checklist (FE + BE) + +Rough effort (after control plane PHP/DB exists): + +| Track | FE ops | BE ops | App embed | Typical first-time | +|---|---|---|---|---| +| **Reverse SSH + SFTP** | 1 nginx `stream` block + reload | `openssh`, `sshd_config.d/ra.conf`, session keys | libssh2/MINA client, `-R` forward | **~0.5–1 day** infra | +| **WireGuard outbound** | UDP DNAT + firewall + persist | `wireguard-tools`, `wg0`, dynamic peers | `wireguard-android` + `VpnService` | **~1–2 days** infra | +| **Custom file API only** | none (HTTP already proxied) | PHP endpoints only | OkHttp upload/download | **~0 FE days** | + +#### SSH bastion — minimal ordered checklist + +**FE (Gentoo):** + +```bash +# 1. Edit nginx — add stream {} → 10.7.16.128:22 (see Q1) +nginx -t && nginx -s reload + +# 2. Firewall — allow inbound TCP on chosen RA port (443 or 2222) +# (example — adjust for your firewall frontend) +iptables -A INPUT -p tcp --dport 2222 -j ACCEPT +``` + +**BE (Alpine):** + +```bash +apk add openssh +mkdir -p /etc/ssh/sshd_config.d +# ra.conf Match User ra-* (see Q1) +rc-service sshd restart + +# Per session (automate in PHP): +# echo "" >> /home/ra-/.ssh/authorized_keys +# adduser -D -s /bin/sh ra- +``` + +**App (Android):** foreground service → heartbeat poll → on `connect`, run embedded SSH client with `-R 127.0.0.1::127.0.0.1:8022` → local SFTP server or `internal-sftp` on device side (design choice). + +**Operator:** + +```bash +# Via existing jump, then BE localhost forward: +ssh -J f0xx.org:222 sftp -P 18022 ra-@127.0.0.1 +``` + +#### WireGuard — minimal ordered checklist + +**FE (Gentoo):** + +```bash +BE_IP=10.7.16.128 +WG_PORT=51820 +# nft DNAT rules (Q1) + persist + open UDP WG_PORT on edge +``` + +**BE (Alpine):** + +```bash +apk add wireguard-tools +# wg0.conf + wg-quick up wg0 +# per session: wg set wg0 peer ... allowed-ips ... +``` + +**App (Android):** `VpnService` + userspace WG → outbound to `apps.f0xx.org:51820`. + +**Operator (files still need a channel):** + +```bash +# Option A — add operator as second WG peer (PHP generates config) +# Option B — SSH/SFTP over WG inner IP (still need sshd on device or BE hop) +wg-quick up operator-ra- +sftp user@10.66.66.2 +``` + +WG **alone** does not give SFTP; you almost always add **SSH on top** or fall back to **Custom file API** — extra moving parts vs SSH-only track. + +--- + +### Q5 — Bastion: file transfers only, or other operations too? + +| Capability | Custom file API | SSH/SFTP bastion | WireGuard alone | WG + SSH on tunnel | +|---|---|---|---|---| +| Edit `codecs.json` | Yes (scoped paths) | Yes (SFTP) | No | Yes | +| Pull log files | Yes | Yes | No | Yes | +| Interactive shell (`logcat`, `run-as`, debug cmds) | **No** | **Yes** (if not `ForceCommand internal-sftp`) | No | Yes | +| Port forwarding (ADB-like / local tools) | No | **Yes** (`-L`/`-R`) | Yes (L3) | Yes | +| Packet capture / low-level network | No | Limited | Yes (L3 VPN) | Yes | + +**What “bastion” means in this project:** + +1. **Narrow bastion (file-only v1):** `ForceCommand internal-sftp`, chroot or path jail to app home — enough for **codecs.json + logs** with smallest attack surface. Matches your stated primary need. +2. **Full debug bastion:** interactive shell + reverse forwards — for **logcat**, running one-off commands, inspecting `/data/data/...` (where policy allows), or forwarding a port to a laptop tool. Same SSH track, one config flag difference. +3. **WireGuard “bastion”:** really a **VPN endpoint**, not a file bastion — gives IP reachability; you still mount **SFTP/SSH or file API** for codecs.json unless you build a custom agent. + +**Practical recommendation for your decision:** + +| Your priority | Best fit | +|---|---| +| **Only** replace `codecs.json` + download logs | **Custom file API** or **SFTP-only SSH** — bastion = file transfer only | +| Occasional **shell debug** on one field device | **Reverse SSH bastion** with shell allowed (not SFTP-only) | +| Lowest FE friction + familiar ops | **Reverse SSH** (`stream` to BE) — scores **Proxyable 5** | +| Minimum bytes on wire / best battery on tunnel | **WireGuard** — but budget **+SSH or file API** for files | + +**Rev. 3 decision hint:** If bastion = **files only**, Custom file API + existing `:80` heartbeat is the shortest path (no FE `stream`, no UDP DNAT). If bastion = **files + shell debug**, **reverse SSH** wins on infra fit. Pure **WG** only wins if you explicitly want L3 VPN and accept FE UDP DNAT + a second protocol for files. + +--- + +## Source linkage + +- Editable source: `docs/20260602_REVERSE_SSH_proposals_summary.md` +- PDF: `docs/20260602_REVERSE_SSH_proposals_summary.pdf` +- Regenerate: `bash scripts/build-reverse-ssh-proposals-pdf.sh` diff --git a/docs/20260602_REVERSE_SSH_proposals_summary.pdf b/docs/20260602_REVERSE_SSH_proposals_summary.pdf new file mode 100644 index 0000000..a68b602 --- /dev/null +++ b/docs/20260602_REVERSE_SSH_proposals_summary.pdf @@ -0,0 +1,829 @@ +%PDF-1.4 +% ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 12 0 R /F4 48 0 R /F5 50 0 R /F6 51 0 R + /F7 54 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 52 0 R /Fit ] /Rect [ 45.68504 694.2874 107.925 705.2874 ] /Subtype /Link /Type /Annot +>> +endobj +5 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 52 0 R /Fit ] /Rect [ 45.68504 683.2874 129.725 694.2874 ] /Subtype /Link /Type /Annot +>> +endobj +6 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 52 0 R /Fit ] /Rect [ 45.68504 672.2874 115.477 683.2874 ] /Subtype /Link /Type /Annot +>> +endobj +7 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 52 0 R /Fit ] /Rect [ 59.68504 661.8499 133.875 671.8499 ] /Subtype /Link /Type /Annot +>> +endobj +8 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 52 0 R /Fit ] /Rect [ 59.68504 651.8499 168.885 661.8499 ] /Subtype /Link /Type /Annot +>> +endobj +9 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 52 0 R /Fit ] /Rect [ 45.68504 641.2874 244.885 652.2874 ] /Subtype /Link /Type /Annot +>> +endobj +10 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 53 0 R /Fit ] /Rect [ 45.68504 630.2874 112.821 641.2874 ] /Subtype /Link /Type /Annot +>> +endobj +11 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 53 0 R /Fit ] /Rect [ 45.68504 619.2874 198.205 630.2874 ] /Subtype /Link /Type /Annot +>> +endobj +12 0 obj +<< +/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +13 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 53 0 R /Fit ] /Rect [ 59.68504 608.8499 256.3275 618.8499 ] /Subtype /Link /Type /Annot +>> +endobj +14 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 53 0 R /Fit ] /Rect [ 59.68504 598.8499 266.13 608.8499 ] /Subtype /Link /Type /Annot +>> +endobj +15 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 53 0 R /Fit ] /Rect [ 59.68504 588.8499 213.9 598.8499 ] /Subtype /Link /Type /Annot +>> +endobj +16 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 53 0 R /Fit ] /Rect [ 59.68504 578.8499 109.7025 588.8499 ] /Subtype /Link /Type /Annot +>> +endobj +17 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 55 0 R /Fit ] /Rect [ 59.68504 568.8499 145.9575 578.8499 ] /Subtype /Link /Type /Annot +>> +endobj +18 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 55 0 R /Fit ] /Rect [ 45.68504 558.2874 233.653 569.2874 ] /Subtype /Link /Type /Annot +>> +endobj +19 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 55 0 R /Fit ] /Rect [ 59.68504 547.8499 147.1125 557.8499 ] /Subtype /Link /Type /Annot +>> +endobj +20 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 55 0 R /Fit ] /Rect [ 45.68504 537.2874 98.58904 548.2874 ] /Subtype /Link /Type /Annot +>> +endobj +21 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 55 0 R /Fit ] /Rect [ 59.68504 526.8499 155.145 536.8499 ] /Subtype /Link /Type /Annot +>> +endobj +22 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 55 0 R /Fit ] /Rect [ 45.68504 516.2874 180.861 527.2874 ] /Subtype /Link /Type /Annot +>> +endobj +23 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 56 0 R /Fit ] /Rect [ 45.68504 505.2874 211.093 516.2874 ] /Subtype /Link /Type /Annot +>> +endobj +24 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 56 0 R /Fit ] /Rect [ 45.68504 494.2874 168.853 505.2874 ] /Subtype /Link /Type /Annot +>> +endobj +25 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 56 0 R /Fit ] /Rect [ 45.68504 483.2874 220.837 494.2874 ] /Subtype /Link /Type /Annot +>> +endobj +26 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 56 0 R /Fit ] /Rect [ 45.68504 472.2874 191.061 483.2874 ] /Subtype /Link /Type /Annot +>> +endobj +27 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 57 0 R /Fit ] /Rect [ 45.68504 461.2874 193.277 472.2874 ] /Subtype /Link /Type /Annot +>> +endobj +28 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 57 0 R /Fit ] /Rect [ 45.68504 450.2874 199.045 461.2874 ] /Subtype /Link /Type /Annot +>> +endobj +29 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 57 0 R /Fit ] /Rect [ 45.68504 439.2874 206.165 450.2874 ] /Subtype /Link /Type /Annot +>> +endobj +30 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 57 0 R /Fit ] /Rect [ 45.68504 428.2874 199.501 439.2874 ] /Subtype /Link /Type /Annot +>> +endobj +31 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 58 0 R /Fit ] /Rect [ 45.68504 417.2874 112.373 428.2874 ] /Subtype /Link /Type /Annot +>> +endobj +32 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 58 0 R /Fit ] /Rect [ 59.68504 406.8499 103.0275 416.8499 ] /Subtype /Link /Type /Annot +>> +endobj +33 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 58 0 R /Fit ] /Rect [ 59.68504 396.8499 203.8125 406.8499 ] /Subtype /Link /Type /Annot +>> +endobj +34 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 58 0 R /Fit ] /Rect [ 45.68504 386.2874 105.693 397.2874 ] /Subtype /Link /Type /Annot +>> +endobj +35 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 58 0 R /Fit ] /Rect [ 45.68504 375.2874 174.613 386.2874 ] /Subtype /Link /Type /Annot +>> +endobj +36 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 58 0 R /Fit ] /Rect [ 45.68504 364.2874 170.157 375.2874 ] /Subtype /Link /Type /Annot +>> +endobj +37 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 58 0 R /Fit ] /Rect [ 45.68504 353.2874 184.413 364.2874 ] /Subtype /Link /Type /Annot +>> +endobj +38 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 58 0 R /Fit ] /Rect [ 45.68504 342.2874 130.157 353.2874 ] /Subtype /Link /Type /Annot +>> +endobj +39 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 59 0 R /Fit ] /Rect [ 45.68504 331.2874 136.389 342.2874 ] /Subtype /Link /Type /Annot +>> +endobj +40 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 59 0 R /Fit ] /Rect [ 45.68504 319.6874 215.309 329.2874 ] /Subtype /Link /Type /Annot +>> +endobj +41 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 59 0 R /Fit ] /Rect [ 59.68504 309.8499 304.74 319.8499 ] /Subtype /Link /Type /Annot +>> +endobj +42 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 60 0 R /Fit ] /Rect [ 59.68504 299.8499 301.89 309.8499 ] /Subtype /Link /Type /Annot +>> +endobj +43 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 61 0 R /Fit ] /Rect [ 59.68504 289.8499 303.3975 299.8499 ] /Subtype /Link /Type /Annot +>> +endobj +44 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 61 0 R /Fit ] /Rect [ 45.68504 278.6874 127.053 288.2874 ] /Subtype /Link /Type /Annot +>> +endobj +45 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 61 0 R /Fit ] /Rect [ 59.68504 268.8499 266.205 278.8499 ] /Subtype /Link /Type /Annot +>> +endobj +46 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 62 0 R /Fit ] /Rect [ 59.68504 258.8499 251.025 268.8499 ] /Subtype /Link /Type /Annot +>> +endobj +47 0 obj +<< +/Border [ 0 0 0 ] /Contents () /Dest [ 62 0 R /Fit ] /Rect [ 45.68504 248.2874 98.59704 259.2874 ] /Subtype /Link /Type /Annot +>> +endobj +48 0 obj +<< +/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font +>> +endobj +49 0 obj +<< +/Annots [ 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 13 0 R 14 0 R + 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R + 25 0 R 26 0 R 27 0 R 28 0 R 29 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R + 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R 42 0 R 43 0 R 44 0 R + 45 0 R 46 0 R 47 0 R ] /Contents 110 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 + /Trans << + +>> /Type /Page +>> +endobj +50 0 obj +<< +/BaseFont /Helvetica-BoldOblique /Encoding /WinAnsiEncoding /Name /F5 /Subtype /Type1 /Type /Font +>> +endobj +51 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F6 /Subtype /Type1 /Type /Font +>> +endobj +52 0 obj +<< +/Contents 111 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +53 0 obj +<< +/Contents 112 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +54 0 obj +<< +/BaseFont /ZapfDingbats /Name /F7 /Subtype /Type1 /Type /Font +>> +endobj +55 0 obj +<< +/Contents 113 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +56 0 obj +<< +/Contents 114 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +57 0 obj +<< +/Contents 115 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +58 0 obj +<< +/Contents 116 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +59 0 obj +<< +/Contents 117 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +60 0 obj +<< +/Contents 118 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +61 0 obj +<< +/Contents 119 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +62 0 obj +<< +/Contents 120 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 109 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +63 0 obj +<< +/Outlines 65 0 R /PageMode /UseNone /Pages 109 0 R /Type /Catalog +>> +endobj +64 0 obj +<< +/Author (Android Cast project) /CreationDate (D:20260603062149+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260603062149+02'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Android Cast \204 On-Demand Remote Access \(Reverse Tunnel\) Proposals) /Trapped /False +>> +endobj +65 0 obj +<< +/Count 50 /First 66 0 R /Last 108 0 R /Type /Outlines +>> +endobj +66 0 obj +<< +/Dest [ 52 0 R /Fit ] /Next 67 0 R /Parent 65 0 R /Title (Document history) +>> +endobj +67 0 obj +<< +/Dest [ 52 0 R /Fit ] /Next 68 0 R /Parent 65 0 R /Prev 66 0 R /Title (Context and constraints) +>> +endobj +68 0 obj +<< +/Count 2 /Dest [ 52 0 R /Fit ] /First 69 0 R /Last 70 0 R /Next 71 0 R /Parent 65 0 R + /Prev 67 0 R /Title (Executive summary) +>> +endobj +69 0 obj +<< +/Dest [ 52 0 R /Fit ] /Next 70 0 R /Parent 68 0 R /Title (Initial proposal \(rev. 1\)) +>> +endobj +70 0 obj +<< +/Dest [ 52 0 R /Fit ] /Parent 68 0 R /Prev 69 0 R /Title (Refined after infra review \(rev. 2\)) +>> +endobj +71 0 obj +<< +/Dest [ 52 0 R /Fit ] /Next 72 0 R /Parent 65 0 R /Prev 68 0 R /Title (Stakeholder questions \(rev. 2 \204 added to requirements\)) +>> +endobj +72 0 obj +<< +/Dest [ 53 0 R /Fit ] /Next 73 0 R /Parent 65 0 R /Prev 71 0 R /Title (Problem statement) +>> +endobj +73 0 obj +<< +/Count 5 /Dest [ 53 0 R /Fit ] /First 74 0 R /Last 78 0 R /Next 79 0 R /Parent 65 0 R + /Prev 72 0 R /Title (Proposed control plane \(heartbeat type: ra\)) +>> +endobj +74 0 obj +<< +/Dest [ 53 0 R /Fit ] /Next 75 0 R /Parent 73 0 R /Title (\376\377\000D\000e\000v\000i\000c\000e\000 !\222\000 \000B\000E\000 \000\(\000p\000o\000l\000l\000,\000 \000r\000a\000n\000d\000o\000m\000 \000i\000n\000t\000e\000r\000v\000a\000l\000 \0001 \023\0007\000 \000m\000i\000n\000 \000w\000h\000e\000n\000 \000e\000n\000a\000b\000l\000e\000d\000\)) +>> +endobj +75 0 obj +<< +/Dest [ 53 0 R /Fit ] /Next 76 0 R /Parent 73 0 R /Prev 74 0 R /Title (\376\377\000B\000E\000 !\222\000 \000d\000e\000v\000i\000c\000e\000 \000\(\000o\000n\000l\000y\000 \000i\000f\000 \000w\000h\000i\000t\000e\000l\000i\000s\000t\000e\000d\000 \000+\000 \000o\000p\000e\000r\000a\000t\000o\000r\000 \000r\000e\000q\000u\000e\000s\000t\000e\000d\000 \000s\000e\000s\000s\000i\000o\000n\000\)) +>> +endobj +76 0 obj +<< +/Dest [ 53 0 R /Fit ] /Next 77 0 R /Parent 73 0 R /Prev 75 0 R /Title (Disable \(user unchecks box or admin revokes\)) +>> +endobj +77 0 obj +<< +/Dest [ 53 0 R /Fit ] /Next 78 0 R /Parent 73 0 R /Prev 76 0 R /Title (Field reference) +>> +endobj +78 0 obj +<< +/Dest [ 55 0 R /Fit ] /Parent 73 0 R /Prev 77 0 R /Title (State machine \(simplified\)) +>> +endobj +79 0 obj +<< +/Count 1 /Dest [ 55 0 R /Fit ] /First 80 0 R /Last 80 0 R /Next 81 0 R /Parent 65 0 R + /Prev 73 0 R /Title (\376\377\000N\000e\000t\000w\000o\000r\000k\000 \000t\000o\000p\000o\000l\000o\000g\000y\000 \024\000 \000G\000e\000n\000t\000o\000o\000 \000F\000E\000 !\222\000 \000A\000l\000p\000i\000n\000e\000 \000B\000E\000 \000\(\000r\000e\000v\000.\000 \0002\000\)) +>> +endobj +80 0 obj +<< +/Dest [ 55 0 R /Fit ] /Parent 79 0 R /Title (\376\377\000F\000E\000 !\224\000 \000B\000E\000 \000t\000u\000n\000i\000n\000g\000 \000\(\000b\000y\000 \000t\000r\000a\000c\000k\000\)) +>> +endobj +81 0 obj +<< +/Count 1 /Dest [ 55 0 R /Fit ] /First 82 0 R /Last 82 0 R /Next 83 0 R /Parent 65 0 R + /Prev 79 0 R /Title (Security model) +>> +endobj +82 0 obj +<< +/Dest [ 55 0 R /Fit ] /Parent 81 0 R /Title (Key handling options \(rev. 1\)) +>> +endobj +83 0 obj +<< +/Dest [ 55 0 R /Fit ] /Next 84 0 R /Parent 65 0 R /Prev 81 0 R /Title (Embedding in the Android app \(rev. 2\)) +>> +endobj +84 0 obj +<< +/Dest [ 56 0 R /Fit ] /Next 85 0 R /Parent 65 0 R /Prev 83 0 R /Title (Backend integration ease \204 Alpine BE \(rev. 2\)) +>> +endobj +85 0 obj +<< +/Dest [ 56 0 R /Fit ] /Next 86 0 R /Parent 65 0 R /Prev 84 0 R /Title (Licensing and open source \(rev. 2\)) +>> +endobj +86 0 obj +<< +/Dest [ 56 0 R /Fit ] /Next 87 0 R /Parent 65 0 R /Prev 85 0 R /Title (What \215Custom file API\216 means \(rev. 2 \204 clarified\)) +>> +endobj +87 0 obj +<< +/Dest [ 56 0 R /Fit ] /Next 88 0 R /Parent 65 0 R /Prev 86 0 R /Title (Tooling alternatives \204 full survey \(rev. 1\)) +>> +endobj +88 0 obj +<< +/Dest [ 57 0 R /Fit ] /Next 89 0 R /Parent 65 0 R /Prev 87 0 R /Title (Tooling summary \204 infra-focused \(rev. 2\)) +>> +endobj +89 0 obj +<< +/Dest [ 57 0 R /Fit ] /Next 90 0 R /Parent 65 0 R /Prev 88 0 R /Title (Comparison matrix \204 initial scoring \(rev. 1\)) +>> +endobj +90 0 obj +<< +/Dest [ 57 0 R /Fit ] /Next 91 0 R /Parent 65 0 R /Prev 89 0 R /Title (Comparison matrix \204 FE proxy focus \(rev. 2\)) +>> +endobj +91 0 obj +<< +/Dest [ 57 0 R /Fit ] /Next 92 0 R /Parent 65 0 R /Prev 90 0 R /Title (WireGuard vs SSH \204 decision lens \(rev. 2\)) +>> +endobj +92 0 obj +<< +/Count 2 /Dest [ 58 0 R /Fit ] /First 93 0 R /Last 94 0 R /Next 95 0 R /Parent 65 0 R + /Prev 91 0 R /Title (Recommendations) +>> +endobj +93 0 obj +<< +/Dest [ 58 0 R /Fit ] /Next 94 0 R /Parent 92 0 R /Title (Initial \(rev. 1\)) +>> +endobj +94 0 obj +<< +/Dest [ 58 0 R /Fit ] /Parent 92 0 R /Prev 93 0 R /Title (\376\377\000R\000e\000f\000i\000n\000e\000d\000 \000\(\000r\000e\000v\000.\000 \0002\000 \024\000 \000G\000e\000n\000t\000o\000o\000 \000F\000E\000 !\222\000 \000A\000l\000p\000i\000n\000e\000 \000B\000E\000\)) +>> +endobj +95 0 obj +<< +/Dest [ 58 0 R /Fit ] /Next 96 0 R /Parent 65 0 R /Prev 92 0 R /Title (RBAC roles \(BE\)) +>> +endobj +96 0 obj +<< +/Dest [ 58 0 R /Fit ] /Next 97 0 R /Parent 65 0 R /Prev 95 0 R /Title (BE admin UI \(crashes look-and-feel\)) +>> +endobj +97 0 obj +<< +/Dest [ 58 0 R /Fit ] /Next 98 0 R /Parent 65 0 R /Prev 96 0 R /Title (MariaDB schema \(sketch \204 rev. 1\)) +>> +endobj +98 0 obj +<< +/Dest [ 58 0 R /Fit ] /Next 99 0 R /Parent 65 0 R /Prev 97 0 R /Title (Android app changes \(sketch \204 rev. 1\)) +>> +endobj +99 0 obj +<< +/Dest [ 58 0 R /Fit ] /Next 100 0 R /Parent 65 0 R /Prev 98 0 R /Title (Estimates \(engineering\)) +>> +endobj +100 0 obj +<< +/Dest [ 59 0 R /Fit ] /Next 101 0 R /Parent 65 0 R /Prev 99 0 R /Title (Open questions \(merged\)) +>> +endobj +101 0 obj +<< +/Count 3 /Dest [ 59 0 R /Fit ] /First 102 0 R /Last 104 0 R /Next 105 0 R /Parent 65 0 R + /Prev 100 0 R /Title (Rev. 3 \204 WG vs SSH Q&A + bastion deep-dive) +>> +endobj +102 0 obj +<< +/Dest [ 59 0 R /Fit ] /Next 103 0 R /Parent 101 0 R /Title (Q1 \204 Exact FE/BE rules: WireGuard \(DNAT\) vs SSH \(no DNAT required\)) +>> +endobj +103 0 obj +<< +/Dest [ 60 0 R /Fit ] /Next 104 0 R /Parent 101 0 R /Prev 102 0 R /Title (Q2 \204 Can we use ports other than :80? \(heartbeats and everything else\)) +>> +endobj +104 0 obj +<< +/Dest [ 61 0 R /Fit ] /Parent 101 0 R /Prev 103 0 R /Title (\376\377\000Q\0003\000 \024\000 \000W\000h\000y\000 \000i\000s\000 \034\000P\000r\000o\000x\000y\000a\000b\000l\000e\000 \000\(\000F\000E!\222\000B\000E\000\) \035\000 \000l\000o\000w\000 \000f\000o\000r\000 \000W\000G\000 \000\(\0002\000\)\000 \000a\000n\000d\000 \000h\000i\000g\000h\000 \000f\000o\000r\000 \000S\000S\000H\000 \000\(\0005\000\)\000?) +>> +endobj +105 0 obj +<< +/Count 2 /Dest [ 61 0 R /Fit ] /First 106 0 R /Last 107 0 R /Next 108 0 R /Parent 65 0 R + /Prev 101 0 R /Title (Rev. 3 \204 Bastion Q&A) +>> +endobj +106 0 obj +<< +/Dest [ 61 0 R /Fit ] /Next 107 0 R /Parent 105 0 R /Title (Q4 \204 How easy to implement? Command checklist \(FE + BE\)) +>> +endobj +107 0 obj +<< +/Dest [ 62 0 R /Fit ] /Parent 105 0 R /Prev 106 0 R /Title (Q5 \204 Bastion: file transfers only, or other operations too?) +>> +endobj +108 0 obj +<< +/Dest [ 62 0 R /Fit ] /Parent 65 0 R /Prev 105 0 R /Title (Source linkage) +>> +endobj +109 0 obj +<< +/Count 11 /Kids [ 49 0 R 52 0 R 53 0 R 55 0 R 56 0 R 57 0 R 58 0 R 59 0 R 60 0 R 61 0 R + 62 0 R ] /Type /Pages +>> +endobj +110 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2046 +>> +stream +Gatm=>BALX'RnB33);VHgUO\>!V4[\Ks[E<[^iWs(M5F[/[-$qU6;%"J%nK/,U?)dOk#O"N,:Q3\UWcjJe2:OrLZ"Ka(+m`(-t>r4G>0i&f?+PdrpihMuBe[WVeM!LeYn2fDC45u07&\0\N4Tj4l1fVa.EQ:-m-+aWUlgMCqAGmZ@2+^?EK+S((UPdekVZ@\-/a/NC#_rp$\2u>N+.PhBS_"h0S!nnq68#c[>c[l/Qjq.VjMu&*%]iAL/,`H.D1b`[XHXK@kob?bq%A-c1qqRL+qq@c>11R@$&?:i1gcj^NdWGNhs+A0\+,[#8]-i*l)m.:Q#9/COo<3[ZtAMUm2Tg1@Tu2X!D:t"%m'W1&r;i7t$HH0mK'[,V90h;b]8>1R3nkID_uf]j.d/OOVB)iU4dqL".W,:sQi`rbrW;]/q0M[^u/0Mf:dF,o35Xh]m5L@+iR=_;m318VFh(qTr1]mRkKMEDoL<(]DdU?i:Pt)g!SkX%[hkf1W'>W(*UdNMRI4q$t?[F?:X_<&8ku@Gf*"G>`G=fnG]sX_Q:"'rGH`o9/3V"ZPE^&0icc+##._S2mEPg@pNoXC?='!2`FFShgTOR9oqq-Lm4_jCMmY:K>Z6Y,7Io7&HBU>4@O94!Ech%l&"@Ct[?0,YqSL3898=bqD5:e'/hN<%j1s2A+b,5aq&BcW9dXbHQ4ap!>:op^FFb/--VX5'K?._#aRSs$D;\/%pU*'WcHh9m.>dc#A0:-'VLlCL[OQGg\dH-q9)hN%_Wigp!M,5d4;Q&grglYX0.ah4cEYI:51VOC6-p9j=#o]g_euUGrt@XEe%>-iOtZ=6pK')3Yr,N%KU5'!8NX@g>9u=Sn/Bg:8t&BlRZs?s6u;3'.(kQP*sKrIpd'23lHu0_%[#a$SFGV`"#K:(>:k71_4o#d%PW#=J)>3)dn?*[]l30b2d^cBWk8a*VmXf'r[i[,`C`NUD$T@TnVLljcQJ*7DQWSPdI$WoY/)33EktO^V6B9>Z+QGOBRJ9!HtGp.GJ)SDF'M4CQj6W;4si-=Ir.9:rC$19]tO"C=!8alE*j";O\X4fZ^qlNNa%F$SS>,XIL%7j"h*MB'Qo?$KA2dI5a9#F.:W43HJ[9EO=EaIMLC)GS'X29N)J5"gN'eO&,)9I:4l&Id>jNE"KdX(+3.'[Q#[s7l[.MGAkRW9%QM]_&V:[>[so4J12mN9`IJ)loL?el*mGXAH/A%oXFmBBtY=(1,'SlL;'[o-^;Lh"6;AE&5k"i2uuPi"7Z0G+T/a[XAYAORa[+i/e-WE[:2nh#G/c8_kUI'Aa*]PDMZ;I'll4d]k<^ID96fMmZ=VC'7,i`&d#+T=j9Qc[<#_:g&L9/ooi[=\"#Ea`Vm-%E5:LZ[;qCOJ3KaIE&LphbndlUU^L=P@g2`o,-#jWee!WR+,M7gXBFqk'2Fc[[LfP&!iP[BpG2h-43s9VV(M4i$^)[)7]"Aqe@s2+\%ntLGC5N@eU9hLA\IDf=QNFc-6aC`]H3WLW_tZKd.9endstream +endobj +111 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 4117 +>> +stream +Gatm>CN%re(B*)s6"#743jiN^r2`+6Ou@_>g.[r[VY_OPJsL(SN@\g83JmL^n%!aIQE436"IWt5MnGk2I@Y^VMja@FP(2:F@i$rPqd_G)B!VZIqF7+%^9Pl7S%mf^fuX0.(lei5T8gj#A)L&WR"r_LBGIfmA&)\lAsr;g)^JlL#rDj`2&TT*heL9Y54VggB=;Lb]-dN`fpuu@fNU92L&$@Crn1JRV=9j"h9B,VEV;5QIeKB/Tca.o@$-V7eG/(l9Sj",=a+f;4$6dscL?N@*Z=]7&UKph5/b0^Ru!R47dp3V56p(k@#W>/Lu,[NJ8L68VU[e:?krhoW*'meB%nbFG4Kp@4>g&jZF=Er?cQ&])n.mK)l@*Sf[.'"CDl9eFp8F4SB9+tNL;L>fTSLMs!FOUFs">d(q*ud=k@\E'3kA=RIl](Z)lB@^6ss8&qPI3rLM1O*LsJafI7?ILWY^s.d^3YQ/k-ac8A9XM-Q%O[?1G;`p$a/8">DomO%b\o`i&(.\(7:m]PHD-3RGYob-e:EPaQnQ.K#O35'ENRM3Eoq^GV4620AZL^bT]WkN=_$_cN6cepM@uW\W/YNoB59H]=n0k@Bgoc*B)r,TNrUp:+kj+`3(h+X6j0f?67*ACsRS,8poK7jY=<&kAl4R]dkErD"%)Hm$c20NdVdGp;"1M$X`.c7D=+e1rI=f-`bucY3oP.>um.!DI\,K<\j/t@R$"ZcMj723^9cgkB*8E7Ym:lICdX"GKR+6B+KaoLo0(9o-!Tk_(I?C`8ajQM=hjVk3(nKoMOhl%iLG&%L[YcK\?s'*VNoo@(_4d!l<0c^G7b+b$Xq\Rhi>p=?s6[.'..j\R"?$YA3t&X>8aH.Xi*(Df%C:8B#&6()AP$n;5-*TRg?cT?EDLHb.+q5%m7o3BhL&jp;WB4o[ef[k[cAM4)\Xf]ASlkaNQTd]=3RfqJZ<3l]!X-8,WJ@=loD&k;V=?Ns/`imrC=7%+oFfa=KP0pV&OY"%u!"";0B:5JkJ=d:i!jB_T$l5g%fP;9eiGEZ)JNVoJVcuaQ&."pN&sIf'ad%At)Pc8iR@"M>rF3R!]_d/hcZE7SMaG(X_@LquuT^cnh,PTf!:!gHY<1fmXfhjk'`Be\NAEAac,dM&cAId%J4a_%APP-*eKFRVRd!mqXk6mKl;qXFogL[cDLau9FGd'/%+>uq70?JASDNM+[l/A#*3[aGqIO*b54f,?O3RZj`J0)Khkr>HfV)>>/0<`+&QXmt"eO9kjU!eObN+!>!DhH/$RCp63[_o3al`$KHim6*qMK>Y;qO1-ts#URqS2F\+q&(;-tYr;ef`,$1rPaf=F^.7N7Oe^'4lTlFC$^T6e"eDa=sBpoatS5IacN&=dq24/?eZII,c"3Q44NRlRjntG'Do3!$""q=mF^sk4Ndi+;ED"`*'ACir(KaMT4V6Y)o1>kd_]2^@0\O_E&;)f^;@iR'T]"njRDVgY!]6eg$%]h#XDt)DLXJ9BQ6;*i8^*nY2),gk>F6gZC`CV\k3G(L[IPctVqoMTR!_4@0^$bl#UB?/j#*;2d_/.%!a%0Y9@1^U`:U?k)/tV9A40HMK&/g_BbpJ!RpFnmM"R4?72;\'Z=+7oP^P"Q^5)R)7r0D[Ng3!^%X?W\]@rJ-@JJR;rL9l1`"r\8qkpHl*2_HMk#Ck[DRi-.VS#Wj&k!8Bga2c>.DD!hW93ZMZR[Oem$CM1QL'`k;!-/6M.YA;7dEfVhM8YWBjp)N%R:phjY4-RHr":53e9VH$M9hlR`TT\I!H@mY0;pa#%3P>f/,>C()o:fXg4+]=0eOsfsi!#27+6rh!]S\';ZOa\Ac1>6@Jgi$?$dnU^0i__%U"\*J*EH#tK[PY08RQHN=jlHO1sm4GU)MBM0ic^t'UY'tJN!9bbu+0Ln1Um+Qkg/P>a=u4g.qhPh2OscmC]6LF4E1+ALAqr.e5!DHP4Cr-t?IAaHAC8Lp05i0l[:tK#20nQh\:5>uYCiQ/m1dGeT`ik.Pj\mo,U]ik'M.Zb4S]iB+3?5tPmqs%@=ie!G06+KHL,ssJWN;/3<\k@MGO(pH$Go[CgUD1HnYt]gs(K?`FOB/[WBW/O2[\GJa#RH#m>?p@H)Mg=W[ps^o),&Kal([dmN4%KjI@jqX"L5h.d?(\N[hSh*QN%[Cin"#"7uslYF]j6iOZKK08e[F.[=<2"#Gl/Ia_#P4hYD9q5!1?&K]?*H5e%O1L%Ce.;sM"$H/mf26M?2[^]mi]%"pi\k7GHL)82\imcVTcRmX+(`^-m@Fsp5d%XP,-tcMh&2ht(A6RJSU1::iS?t7/oFgq?74%?5Wh;SOn2$RHN4IPDb;'\RYcc!YI"/bZHio8&r;R)l^O(Cd4@iN6G&OCMTT0cp;Z\*cZ8H9#()kbu()c"0K.lG,Fj/6X>Z&f(]SZi@[Y;S@-NI+*&tW&!DKjX&o!jVdi\58rurKGs<^07eG7*oPMjmO/g>CUg.(4\tMpYrD[f=d]>,~>endstream +endobj +112 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3124 +>> +stream +Gau0FHZ.Xu')t1g@LuoJGHXjhl=D9\*g.japTWCn2stg>o>.):g=B:;QTi4X'[H/(cj3=sTG`UfdpM$VHLg_sb:'KtU?\0R[S$%IA)OY7O*q6mEJ;kr?mprSrao=K4MBT_ZeI=]T*rVItg%Hqh:@6:a>T\:;\1e5R>3aTT%NH-)Yb3fGX>@4V=9KK$0Q8O4)ET8(V0X"leB/r'M):G75Hb>bsDb:QU;L>Utc=6\\p1JcY/Oo(elXR@p+t*'Lr#bGokBnL5Wa`89NIR^bJ1o>\)h/*4!u^bc98O`fH8L3*=4?*YSrK+4=Q%p8lehaL2hj*h7%Ff$Xg8]J1Bdmk821BoXEfZkd:WU%MrMDcH\k^c1\`o'cS!&$JTK-bq2UF=Wk$3i(C2990rg;e=sSEo>i9nWp26M.b!38m'&Kniu/I`5b3k:US,S:LF2&&]jonSo(fJ+c:EaJ+HjH?/SGdeKu\Rs':LTse*]LXc#JuIlfDH13[Qn&:!llN",^q.a!^qL7dQlm8^iV7THo<"/ZLP6e[E/Pn29U.I!,#akLK3"7AQlBFJ(ZhlN=pn2%Ep2+^6#%:SJNA;QUb?^0qk-S*LA)Xp]GQc4lQj.e1:6C#ot_)-X.1qRCC=*TGo.-U<>CEdcHW8baXSmCpDa-`et6g\h#)bOp>b=a2=J2J]B]h2p`IY6T&iu,js??;_L>;%(d/mZmt)CK`E-Xd9;$k[k4#mr8e>TbBYH2J7mh<.@<%#ChZBAegO6uMokJ>hm/2[1's=">ARZb[K,>i^o-E!Sth8CRV7#(4>kY[HI6YA]oG6:sD-;U;2S`6E>`EU+rFfF"oMXFNnMSdUaK7j6WGh6\C1lTl9PH6!PhGV%,Tt"h#upG+qb.eH$;?G84T_t')<0'*U:a67BSt;nPpDGQMY>LO#1%6_@hk5puao=5T^^L-XMkl7gVOU_KHo&AL,VqFUDI`0M6(fPS=dSNLO3kDq<3LQFe:#W.ImQgqEu"[2cmnilJ_H3u'FE\I[[r;qD`,EqFuD'C0.>XNU+`8micS%bV"R["qVCjf$)\bCbh3U?b;N0j7IerSnU8W+#aDpc#X>7:h6?:,kOb^tbA3JZ`%@'5=]+V?KmM?Q=7`np'<,EkL,*`q!*_3*2n##$RaHiKY:3>Z#W;d[1XjIl7YEk97"Ek_+Xu$@Nto#9'Hs$geVVd,PnhJ;f#Z5.]<^ZOl8S8L1Tn:QpI6-8_G%FDpKUo!)G`LQlHLD\5/6:(,==?9iW7VhB'Jk\3F\?e8pYWO%N%SmZZQ8A-FfiGRh]IVLKsW_07WSh"bYT&p(qi1b;*r[]X/=Sose4V!9(k*T5g)SnmC1du90/86O(R`iY.s*Yg+gXFb&c8l9N;j?eA>]AD;T:PtZZMJq42ek>)WIe4^>JC/M_p4AS,#0]?0l>+WXQ;D-Z#Q*dsN@VqB[No[,sG#T!k=[kQE:Y3XB6p0mbf=*DQ]Ze$#,9D*Ui3#]eKj+;A^u#_udQ$f?a3qfJ`;96N;=-]A^Ab#Bm4WNG;+X>Hcb*`56`"Ue$OWMA;WNC0DP5p)a3%S6H;]!p\M+H*8MAJGTRLu-[b2hDpkJM6X_'p0Im\14^?f=&[3lqhqg=Zda20G"nTMQj\6F>hcel5d_^78'M^Y6IVNA,CV1d)q+I(%/A<*bRCWnC^$DU;A),\Y,l3rnia@X`eSl\nhBGYS&CbCUQT.\9kYtE)ONQLplEh>Zt')I^BJ5`eqiI&iuG2+XZT,(+IaaP=D9D_6Us:F.b.Q1.CBD0OAua]=I:],nFTfq`up7KN'T;&7IcJ,b2PVT7mhZm+/X8[3>?l[0rH(*Wl[E)c,=!AUFV3G`lb)/#2hQ_[:IP#0WNXo2aKPQEpC1P>e$ck?".sNQs.I$@p&d!eNUmPcY#-4I'V[EG3mMDq'ZWA8*NF<\Gs;UGUT^891J3WR'MD%jM'GEd6;EGR[PL\m+pP[W271F-sH/(q`al(?Dc#l\0:M1-J9VdO4#._GWnT\qG?c)r`iCGWjS!@ZX6A,TPO"Pendstream +endobj +113 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3845 +>> +stream +Gau0GgN)%.&q0LUoZPh>1D.Tq8BJ$g.`\fXbiGM'23Y.GV,FN'8XN=""%&PmmDi!$c\'Ld9pY1TEKFT[VUmW/BJ!$/h5jSNBgn9P;V)ZpK^AkOgoc%amV\=T8h3&._1p.08E,M`QJChUC;Z12nfN#sm@Xg'd9f`mD?r:KKuKSfWG125?kYMf%J&4'_l(2F[^R%MB7'DZIdW&Hq&K@ukNqanBt%DS?:>@ZdkDc9Dt+l)NOF*V>E-qBqOoYu0XD5M\3HDEkRK`3E"'\TX6Jmk$J,8j23U@"b(ks.IYZinLI2:jb?jK%(@73*f+YP$\$M?WsSJ#kY!bj]O+f98'_>l9Kfi5[?YJ8205)LTia+[n5"p&!8p_aj;HL_#TY1`BAP7>"\526>`Lb8HbC934qr]LIk&F=uA2it3j+RijM-aCJFX6HL#oN7XAn`qpY9g9#$9";C/7.`F#`C?MSJJ[a+US>\Y/4)Q'fm8Hm*q"o"Re@YekL6WTXt"a"E("QMjXZ:"/-sN=?,R@AIOJ\FKj;Q]FOHOM7[iW]Y"U/o*DC`$G:hC8`*cZe>JIIO+M6L1YnWl%93&+mF+03r&mgc)-6CRSGLo^a)h,SIAUK!Q5p,JXVCVhgoH-agV>lp`hC8W[&m3se-344]_oj.dC\eVe#NXB=t:%M93nT>!`>NaXM-F?U](6l*AR!l9`58VFW>qDo_aEa&)IF$C%50:c,M$d#cO:Fmpo]`&'&rqQ8_M;\LEQI,mqg.0!tH1Mr0NTj[:d"f[i!Bk,_XleYg/P@05K.K$WH&i5og.nPeGP;S$I#IGHF1#6:3/c\O9HgCF!N+`:^:q6S?+gL0[oTS)42]80U$KpNV^TE`jRl_O"`]PhYV,?/7*:sU'QoFMee7l$,e($+SIM6/_R/S[cnuf:"MDB3'n=ITA4Fgeh'tTn?6hbohV9SEQrIudo,kHYI*Whp%M4DchT$[rB8>t%.9R`8OC#L)4V;/r'?&hJ(ZGd*`>8j,bh6adInEBl]fTl9,&SX5"GMTP_=Y,8M/Wd:&>S=Z5FV\:6LW-TR3()YC-r!+.5P%)?d0AsV$0KC=$P"Se"E/K=H2Y2tt/Cs;PVB7ZJ,[WcYK.BI-o7bN1A(8P?S#?sUf]"u)#$%hTED0qkUirl"G^d(3L6g@+L0kdqDn!f/\j:#`]pJU6#Y2m:`RWIn>H^+!=\[tU**BNg=;HbS`*aE5"haYQ3NK?p@SN\mQDA%;"X%1;7UtTuT7M!k)Z86nMuf/t2?`It+<)li&]O[pNB=K2e?Q:kRXo+I^fuo9A'"4@V*052`=Hdm(-="\pAuQLU*ge][]p)sY[fqObN\m++Sf*'8ZApS)7K#0*17%+YQC\,cmFW6c%4^/lL-&9V.&;kgK+[GDuW`ABd+Wk\]D7if:%6Z$i^+r6lVVu4Yi]0oChPX$Bq%/$_)a!b=L(/#CdP>&QCB]XVVmW8/raR@3]3dC3DFo_`1UOBSLUs7iHA=@f";a8bmLqtDR8K-fR9a926=K;TIHF]+AW0#HCj"F;fd'D.*E^s@SHn>f1H#$1"9+&e\9Id0Ct2;M)(I/$I/+b=:KZO5e[)a/#E\L71:$*bNZ5-,'2*[6=nM":4cRXV`Q4q)#nP&:EVeu"6H=RP98@a/;>qX&d#SYfgTM/Q0'.Pj+Z8)LaU-gqe;`XI6ZL5Rn&2j^O1F>YcmF2Nu\4X@jF4nKS"XtMZn!aDtF))AR#a>U3t6@9n]^U?19>SSIL3QR.Lg&`uOVIE08EPNdr<=c+&>g#;q*4.30)bqeoD!Qt.Sq-i<'eWfA;K=oX.]KU#*tbJ;)PcIQ+pX;R-#Xt7j8Z-,[/ac9&!)f]m#HY'pe=*d?>G7Rti)QZ@$=&DC/2aJcT%cS&+,l)m31_X8?]00"m`E^:*+U1##/&a""7>e^q6SC3HF]t'+HRpY$p'IN'VGLT*/l!,IN7@piK2sQcEj"sg]pW.9A064YH!r:u=?^uqq,J`Z7%?&WJgTWg$rF>=_VXS+e0rc1T''OaCTJqLfmjCZ;"q/S=Cm"bc%eUFZ"HrB7l)X#7ISrqIMd-:4_V!QdF.pe0JcS;uQ=c^uPmSjO>XLf0Z,eI]$n[8%+A#nlL;(l$DpJYQ'/BAEsdYi%7Oaq<2*^^$=i*1A&T$QEHH`q;Pn0)]@3o4=gDT_bj1QE78+$U/4%d%Ys`&pABUZHZ84_Yp<:q*R*AQ?1O7`DdiVeYZZ1$WOhF/S*gu@V(+Gt2UT:-HZAJ.=4gU",$Z^RXU$b]$:qUdoIXk@&0.hI5;e.u4X&8IL2dG._Pb[0%s,B:W:b>`^p$9kFG#KVe=@NNs*<)=SOs_R/bF\J!/Q\CU=*Hoo`fj;^)M_IGe>S!V)=O%aK]K>3&(J!e#pM@cB*re-$P!MGZ`E=V9CGWEpYFel.\C2$L3aO("hb7%)bg0eQ42cLM!>(S9;Gu1OXuWaX]M9TXq)TfcpZ_<[GUNh*I;&O>]9$Z@N5%YepQ7o:jT7SrT.cRq&cds3KF~>endstream +endobj +114 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 4585 +>> +stream +Gb!S0flGh,ni^@-O.(nonYR!bloL/f>dbtW-FHjVg4CbE@S@@B%j1]AgE=I6&dcX+eWr"28:V/BmgGnAMEb;<\%;Zc5DRX_#+?%n-"s/b_YadHbhr>EUMXYt@mr73$QRF2InWQ6$qD%TX;dH+MD;A9B+0Z-kn@!JB_<^fcc-0VejRnCh1hV85X]&V4+cNXgF8.dS"f6'.7sfiK_Fm%8*5'R7h)sfZ=iDC\6AWU1G%eKA4Y-m:h5*Ok@d`!2u+7nM/)rt;ZW9TmftZ2VhrHGhdHEsk.btfJ)F*+<7;guN0lg62@W5G&dflmnL2.M0;=B>**c&oFN%;J@nq$gSfhH,2tqE6&ADR6MQ%f_aMU4M((p-R&?pi-XTdud3"I+.N\ur)pBP0o.OEL$6^?_p#CaSe8R\SYe-7U$j3TP(UJ!ZR=Y'r5-f"I>AH=oq4eJ#Z,];U);W[Cc^i6*g@s3i0nOTFBp6/Q!]EifUE?FVU2XWas/A.l+K"(76k2T$!\F%]Hhn+s0[GJddcEsp>\EaL8?U*D;!TN-,5IlIZU"'`pacIYjQ3I_QF/kk,+IA>\+tD:c8ZXS?E9+18(`D-%4W5UEUm5:TKX='B)gIet,k5-LpAtu@!/ocm_1pd@)";_NOsn*+`H)Y(+Aq,M;8Tmm+g&&rdl:bT=RtjO9gA?86$`\X!L(LDmDF(0KNu$![l=76Q\I_!dAQb-*m*V%971Y2pOC'[I_J\!\mnn^["!S;HD=U3<;9Oab%&78+(j2V>(<_6?E;Vhg5PuT2EX7NV:o),k>W-nk*t)COlJuTY4=`/=i!7^#i`0YFSs!9]IgEC^^G?@ioH+];=gR?M*TpNgQq=JNHWR*Y@c4![TRT4'd'gbRZj!pA56-)k^235em3`i,4H\`3RX&1Hi"!VQ.>GaOiKsBQ22P@)UKSJ87i)p\S;(ElQP!mTCN-\&/h_(fEE[j9WiaErV-r/r,_"3`.^IV-+l;+obn3k&>-N$\;bRN]u=:"-%R'Z!eY*%A9#RFe<=eF-XV'L,rjk4mc&g-0d$[3dMr`Z=]6n&%_Q5"+AjD^fE!Dr77GPZ7%`PNH,b)r*kl?([*TcoUKbVA?.=-S2Q=m8_?fm+iH6_iG"!pGlgbl'gbM;<6)#h5oJ1N7oIA"=]_P%O@4PKDfil<.5KJt.6MjhO6=Vc4G@?7Bnl&)`!J$-%riRrW'pFs\fBq=FPLs=/jjO%9k?QGp'-_,="G5j]\p?FY&P(OZ>$Z3h-b]g,u>m+L!J@JZJRl?iPWpnr3^NPduViEL2I`G?"A\=lj58k+?u2_(f%n(d>!gXU*tMNLk=JeX=VobB8&dTIWQ=J9'FmWn5nnc&!2N_,2mNe;?IdkJfe(md@<1oHqH=@XBD(#\YV:M4'gGA=B(TF2^Cs>iWFU(csV!Q6,eBFl=l_7jbt%>A=lj9&';>N=V#UR<@fQ!-LQr;l@@5UXiG3P'sF$bGg.cEciL6Xi);@\:1e>CT8jEB/0h'4(<@4$eZKA3cKUOHC;iOpIP=g/CgGHTM/b3jZ#D(F@G6JakY=-(11L(=\X_u;'lZO13>O>Bd5K1d.#)L8hoN\U1]1]O_W\j(qg'`1,KVqO6nGYa"D^rHAc)V1KRb;?HosOm=7\U@4XGWoRT6A19-,E:>N;(lpb>;8>!J1WoDV3k@(Yb$JJ5YsbB*gq4Bq&B8o@UX\jC%U?7m")W,2O&MP@k"e3\E(t#uF-@"(+:'ku`;^Rook`lX,lU?dK*[mfIs?_+77]0-!?NA_i4dbUa$9bkc72:,6#0a2MK&%pURC.FMJU$k-d.E75H6>IUdp[Z[D)r_ZfbUs5r7$9_C*7aAnSbSNVburm?T)0cJZ1(*LO%6b;NXH74X4$h09QrgQQ+Y&hZ"o0[B-HJ#34g.!\mneOVa-_6I)O0@Fuc18a,@.L`N6Li.n*dkn&N"%jMGq\U0@n>)r2_NcdUPR>E(B_/UiLeF>tpYod<=`Q/P7WkXV>fh?j,a;p^N1,[TF&j%(6Zeh;L,O!4>#2D:!^j$t0Y3=F4V=XL&NU%,_#ksIKaRM4#.?/efAmMksZjQqePhH^QYno2o2N54gsp9/=Y\u'##j*UtErctOZ(%JK'6W(dODm]%`p>t2Z+sf^I,?h:L^DpkMm0]9<1@*db1UO!?uOl1O]+cl$uD+Ba%.7#*$[?A^@85L1%WY"addHZ`/r45aCQ*B_;sb.SBl'N\.0ofWqXU!'^>dCBf-T^FH>Qn,B?,pU>Veis0?,L4pu/[O2;Q#)9*+eonn`A0Y*DfZ73thef5Ko%(%mV7B>!D0s2.-_\`eOYD.fBY5H="',Ati:P">ElNCk3^R[Fb!!#AhbJI3Z:CPFSh=/6nWVm^=!qsMX2Q8"[$$D9nI1sCjM6_D$<-u0/\BRXjqm&ipAp[umG02=Lc:m8U,39aQ":BF6,kXW;7*!\*J7ECQBicjbkmdtZ\LCjj+;+*lfkVKMX*VEt+Ekk-:`I^5VZJYH60o_%$U[=tg3#9ffAGm"t22dWkO&EH;KK[Z^Q4\0#R\JWKVdu^Dm4r2[GD?4e"u4fXU=BB;N#"?C,3D8VP5qFZj@m#Y>0>,q(=F5'Y%Vj4>ShnZU:\6BRVI3,\aRA*"ZKJ2`nLiID/L77S/>iEMg/H0TsO70X,$lb,4TPi+!D"7_oI-\RG/E#@LsUhNg(@qP8H>3osH%tqEEbe)j,A>iuncpL@.V)'>91i+5:F>d10X6aPW[Rf2XDIW!]ge@s`*\`p6KhqE@#eadT%fg_IG0qj[KfJ-`lJa_TS-FUW@5j**SEap]mU+R][VIK)"?^sNP&%7+!^,fG+]3/s4LX,>q:7(VjM[L-kV$t%;o:.%-gYGeJWS&p".r_HE.^,2kC_5^nRB;qAS_]R>/BH]+sS2EOOPSkq=D?:,;bM`S!Qiml?#D<.g\T7+ch]nY[1U7_W'-+)eQ>gMT^=m7'i&r+C50tocuO"6@Xl4M4b:h0?I@jX._'[6i@b%9!"%1Dp"GHQ-D<-b$#ILhcYEDLh'%B=8e.UU&GSWm?7o)`c1Wi]fD0s59b4lF0FIHE_d"3Yr\iMJrQM`0J16_ELHqnBP8mYb;a1S(7OmHtuRTVF12;#Y[bSJu,5QEH)FLcb##&^\k&dNt:KhFo(sf>CME%ZLN='6modn[jS_Ee^)E/@.H+b2k/>E3Q:#(kQAMK[k,p`+XI>M95g(b?$1SB46qjE\i\OA+$BZikl$H5;Gq#Y]Wcd8$9Am[91-9li@A2Yl)((/jWS]bm,pBS\@2LpHK/JdB#H+G=CN>69S:8GVY$mHJV,6ed3hnl!hR4?bN>/IoAKHZ8@n:'W0PX5a9r<4%XVnQ`KIpYa'6a^s6*hn+$X*pl8@.&_/M^9"YUJ-e"ho7@LDW2\]qN5.Qq,AnfMQOg\2rdX#eoBY@JF-B6I&UoHQ1N=(_Pendstream +endobj +115 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 4330 +>> +stream +Gb!SpCNJ5g(B*Z.JWF[e;g_M[AnT0QFN8CO857eUJ"];57RN:f8_NXKhocg,3@'-uYhb]fO)7/_%m](B!r#rM#1;DM.0$HZKUf0cm)gH*8QHA?YVhoODTLr)kcZ5+0'UY=h(fu0H3ScdD7H[k/Ul-m;-XejkJVOkbqr+\S658D,nFpS1uHis\P6E1YHRN1c:+U5?ZZ;73?rK[>D`gGp(Yot4,+r%Vg)dFR,@Ol71@)7\tOJ-_7LI8f`Hb.+nu5VER*S:%qP>\=hQ92IYFC0dIT69Ebn``UC7!NL2=UFKJjfOdllM/"2.6gPh[:7!So*m>I(*Yj4t5u249c0\[jlf-44FlL?_)@#C2IB3jZO$)cQ1:'4HoQS&6nT*VeO/VSAohW#:V0k?!5u=GG]$&hfl8XVApuR8P$3G/)@+&sHV:ieMq=gjCl$Z*[a>/BZO07*1D#Va4F6QLe(TU4`GjcSiZ]cUj#74l,MHO6"dLYJr9^h;([U+3VF;U*pA6q7X]%i%52a]W'.5fnV/.*HX_E\B%0`Q6/?C9^]f75CFA%c4\)MbqX`H`dXRaPR`2E`5:,E8FAABSGd./XrI%e;_aIj*K/I0eC*#&s9qWB3Z>)9cj[9AKq]q_eI&!i?9+`J/f+rs+#Q4OWAhB0-ne0d!dq1H%]#O%n@1LsOD6PdX6O4\WlY88WKR@^.`6=CqA5.OM7*kJ@o_kInp-tS2bh'H@$/bKD8)jY'nqNmT'57]c"`Whr$KhPs8%Te2K3-nkVdFsh=\G'[hpOOBYGe6\#^qVY)l$.X)sV`juK;WF*Dlcou%ODn?&W[l/A*5&)Ta$;SSI&P'f=+,@>?WC%uWB4Ffr9]'OW>*Ol%IT_-n`b^RI1#Y?"s3YrX^P5&0H]5H5LD;uldn;J]j<67\`bp=a*JW4>eUHHGBpT"./6^(`S;PWGj90P.BCM/Q"YVViOhM[GPQ@j;^T!]LDn5gR'lc0\,I_t;]9IZ'iU6dn3&7($FS.q\ei\Y(NRs_)uO6.*J8Kp_H[)D]8hUFbYic#&(6ObqtrY2a2:XjB:)RKG_Y0cY6%`1QG6]VaMmQ28qf"p8j9MQI.Ab"oCKA[48:j#r4JJ(05_IdG%5Kf1%Z7Lmq'VL/.,:K@`WsJHqu'c<8b-'`sE3fGRQ?R@K<[m+AJ&4!JLrUf[r]fLNGIZ5I*sOmSQ'Dh/b+ZQ,;%s]Q?)t_pi\.rbtXh>SeMrRYY"l"WHHB9l_f@;.9?E,YrAnObj\I=IFS)chuFgBiO3:'Y/eQ%t3JKclDAE$4XddV;U,#&<^44$d9<^-U4\W[&n-3IN5:'RN(io2"-/oE37=9j/5-'Vk`g\R0PT%u'$`gmdL9K*TfK;mbh6hP*g'<4Cqk=-&1M4Le+sLdR'gV[=%7L2K+TB)k(>0U+8LJRc#+VgAdSV'Y_i_oog4kb8s\dbmR3d")(IsI^JZ:RJ.,_M,58-A&n"MLH,MF-i:cJALRV1f4%n-:hl1Z7q"<3`b(+"HXUE50lGe2[pucG'he?3$B'P[D#/;CU%-.6!2V/sr%)0Sfe:^?5er)Bu*aZ@7aKc4K0B5?3>ksfoSoDq3oML5jG4N4(P&48]HASkR'd=X[.N[>A?mm)H8FeP`(#Pc1+1;g(FL_'oTs(,Ot;?N#?J/n/T=(9@V$S]FZB#_oNN^k@GLPCb^b_Lj>B$P-uEaYdWs3ObmP;T2bZ!ST2dO!b@E1p"RS.p/oF5oWj!$-HD)#;r>7]=5R?6LS3i.OO%\]r"p:-EEn=*/?(F0OOVMLP#?=K.7FLb*I(BiBcRHj-1#77%:JT/KS4`b`'NLR$i1$=MlRM\cSf!TF.3XZDF'iGYgDuVJI[D'HU4]AOhCaY#q9XS'qlUEcrlX'cp\4<-V4C;GGhB:+AB)S5b\]2PceI9?0Fmc@S0VkmNoA0ZX@,Iu(6mJQ@$BiJqcg_[/V5k7A(P?bT&(?`@thn2L+R1VXO*WOK!E1ER]_7I_L`\tgfU6hWkbWK\l:g2AK8^7N)#I)(lrD>%db2&7#PB*Mg2m*0BMkEQJU1K(ag72E&e.BKH5.1!#O"]I%#p^2kShd21QP*F&BrdRRAMF#.^OSRGT:DI+'r'[[$ru-ILs[k.DsG)<%S[GB/,4U/796,b"YI#aj^WRdRcd@"?G+9jZb#JBKPNfu/q`=*@#+c-bMU)+.[bkmD%6=(k)5l=7dr$n@[P6!ah2M(o`qRgM-6Eop:i3Qe`Gm%>^3bjiC!/H51(I`MP;UV8bs'$SYPpT3rli*YpdYrCRqt&,edd?:FL99N=J_j@]YH@taK%t>99ZP4UtccHfbJE"HD`@#n=:i7,RD=[U/q<8L@]k!HTid&k=DA"h]7MOP-g@&38`MtIkj#!`!hi-$[Z?J*"GYir20g[QoMpUt0RT/@N#AZq*5$4"@a3A!dX%22odP'p]7HJ>>]Y0Gg(B27*I'iCF[bf)\$]cbk=3Y]DBW>IM/G)#-c7[:'Cn%feO%*e*34[#K9W8rkR,OMe:sk^TS$J`8aJNdQ9M"O[/XGQ5aYV*^@9#h!1ctf:V/n+nZltm!U%hmnj/h=+R!]=-5d=gTDlIF7JR6R'KOSLS7VHM\Q]D`)Ff=f":"dm:N6j0D>KA!F^gVXdX9%mD!ug+T-1hZ7A@E*+6X[C,+g;I52IJO#U!GZ4-5N`GM=u[_\Q+fm<:]>#D6E+`'g5/4Y=Z+sQKqNq>tT66U<+LAlAW9Oq6"*Y]&@q'm>g!#Ir!'K=p!Oc_c-#Nlbr,H.Fj[Ef?3$s-[HcUIfucpm$L+!j`Jbkc(o:?f?J,lg8[7jJqN!R)s)B&.mf1h?n2Ye,/D$WMGJ&o"P:SqhKEmhbWUE/FcL[ftGJD)gj>hd(jcKaU`8]apDL:dm?-,eQP'ekaPO]4%_fJ[ioS0cfag5/MZ<6KR?%`//Cjg]?`bs&N+#Nlg]`XmMnWKbFXF>SZd^ob<;r6_bXP+5jq^d(NB-2T:4e7:@ZFd#cBLgpH?!16q,i5d#C\\UD.X3*)<.$R48Tjl9V7V%LY2b<(i)LA$'>7P>0+]?f@P>k]NUnD5GfqJ$?k4k5VpQ!GnF@j<]^s21P"Ot[&4NC?rIK>)7N=Z([^8"s2WcR@'V"PNI!nY%fNCKT.jra!&Q'pQ%or?eMt.N8@X#j]:#CAhq;endstream +endobj +116 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3571 +>> +stream +GatU6=``:h(4Q"]3%n3!DEql&J6as0NajHIQ%^(CbR7FR7VY2nOEA)R'E7qnh-,oJJAC8=5kK6no:=>c5fZ)VrU4V(50T]"@";\&C^GiufeE:1pTpp8YDdS#4j]_H5dt/]Fu3.,LUcd;BC,iBc6rO4fh%@F$J^&MK?kG9*IJZYU:8DRscTqOu0+,0V5b^RCp%nXjgL9k^0l)^"*OG=^Km'*bmTm5:XckekP@,>>a70@'!Y/PshUCHN"1t)PLq>"%4F7f;o6N_I_Udbf9->UIIkio;8Je$"!EsNj:pK&NN#I.m*#hVNdQ,FS#lBpLl4eKL$"r@G36\1m"^'er!"HXa^>U@6>4OZI'PgN2>m^3i.),?A\:\/3Wn7U8AG8Gsb701=-$0d#E*Mq)>Yr*h$/0sECEX.>63?tC!]!,E9Jd+CT>p/"D&)j]Fphf?mpk-ban-2"`n$-_3hJ5W/;;/-ZRXtSNWEYtE6C,oAd-(bd#uOk8U,6XS3SPa76&c:GHi2c6o[B*[&#-1.7,-'=Dms9)#"T`OO2pg#F:u`XK^&+bL^4cL9+*7eE!&s2)%dI5<,N[^Hiiq!.`W'$nA!*>I^2&1''quasA"Z]>f]Cn5oqF_SkKI."tB=!Fm#U^R;10D?83bkPqCYZkKl.ki#Q[VK=N"%SJW)%(J;$7F()3n721>/Z`2[<)qTs'702Mc?Gpi,+;:H<%a38S=701JD(7q0?JKB@f`PEK#@VO3/sp`.":$U:9h!N8!r0gu*[V4iM5k+-JQLVN$@0%hS;7n>bW8n2E\m5-O/&AHLOVrepSRj:'Mhd`^inM(&%5Btj4otem`tHFBI8hcbE$g=?nh0-(-u@BNEEsIj.@ESjV-Uh3X0pSWq1!YtTe]Oc60O2nr19-jt?Q?,UEMo&ko9T?N/h2f$2!'=Ph0l2F`$Hb&Lop3O]l&G;Bf7Mbn\l%]duJk5B'TG-moKU]+!]M'Wbk>@>-J$JbHfnI=#I:QL`$T)Yckee!G_gshV%4S1dQOUu+j]F;Jdt&Q@'o^$#&):(^V,JA30EhbWVJ'^+)p4f;,298)oP.b2p"0u/;D8al29\ZW_*WU3l91&RC4ichq.#YdjuW%#QDlmUF32-0au2HH2(aUC*mt1\\JYh$4]U(oJj.<3b5'MP;g+TggGsFL@Z*7;lBn0Gl%@C)6Kd%D6qP;1)kaN(ZpVW&'J=MkOWiLR2*U[BnJ.Y#b8c:c)2Z'*MQ?B@W<6RNX-dusTeSu@QLF"W#M[.*b.RGEdY:hbdm,im#N5!M+:W?fq9>$4F6Ct/XC49JZ`I_ST0oWFJ.>gM$Odp2ISHfIC,e9<(6aEF*fk,O`D8i>7cf]#@"VU5Z9>]Z+@W8/Y^Z7h4&?U0tgOJ4t?WYt&*81Jsb+F`eXt\Z"Z;A"j6l^R]@&aG'ipZ`ETCp+7_Mn>.0MWAfn0ob"fZf`p`k))h6!XjJhpE$c\mjI?=uH,jKLY;)aS;=rWgU"Hr'5pqS:k[Otl.32j]'7JPADP_Qj!6p#k#D)Br<-!)$-'*qIi$S&OL"#LgY&.Q8@GUr*a*kDOl6u41FWZA4=jHX"4j]d&EG?f^Gi+euI'I7-Zs\rHTTUOb>3XQH[:3O&&\B7beTK_a]h#N.53'S:r!8=R6:qa)lQX%i5pN.QOR(e#t1&:nh765XDb"Y;/;."^C<"sUU.kk3jk\4VZIUb7_k#GS6Io3rBD8\RCa??UIg^1Q>%eP5=q,SkQ]RmJ#LZZ,_6p\Y>#lQW9eI7=h@V.o$?$a[mPU+]X!8=EMgEbm)b<->lE!]`F4(t-cBqcFWYM]c2F@5e!d;GF9&L77-2R_c]m*bn/*iTrOL`sSE02[NdPa(XB#QEBB"/(SIN9O_^]`W%$]W7RA(^N)T$XU";dp`#)5W_u3WAOk'GWUpC#Pu=I=2U5upD%2\X1L`9S%V?C"FN3&5r>ZMD[+d]eM\W/ci1!I[Ro/D^(Il9J08"6jE9ct/4RJfrdD5137YTURL!$"d;&D4nZRg?&a+,*Vc]Xg3Ic%I5n'?=`0ouW0V[]I(A7'O.d8?!g%pd,XSrRLEK7%$\UV;0qB=MDB#ht:8:od^0[P0ntYb69EO@ZU&L=DlNG0J/f3&r*,a]X^6!([n800*UB`PTU'kI!QEmn9bH*3c,hq,DR^;Ha9B"`W9KH`Dl6dEHp>+GX>rgWEP7Jc>dV&AFf"AHhjY3*U/3c\fOJfo=4ug;AN,gA?Z1FmsHB46]!RIFVBa8DJE]G]?YR[^G@Sc.&A$M&Y=YD'?[h$AqlFM95GQ?6h\lg2lJ?Sc$Qr//&0UID\qte15TQgIT6QR*(o'FCI>e7fjdNr)Pa[7+"!KqV=-EV'eD[^Co6dr!(rXd0eG"_MMIe5E'`):G2[K4f&LE_[][5(PBUi&4;dX$$Fr-GhsB`#%tWICAc"E0OR%qJ?_D.OG@N\@os&pr=<^%_2t`A3H%kZ<5.thRkcFF?!tX_pptWFRAS~>endstream +endobj +117 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3635 +>> +stream +Gau`VHZRn3'*$j]U+;SBA`Fo/d1]lm=2["RDj-PrP18(YasEk".+'PkJ>qB'maj7@+G9`'Dq3O$M);!qSpp[MAI1d&QM>CA"*cVQhG=91^gmY4+t,-j&!rKD+"'t#;_nPRQ;l4"*WOSin1hZ"^8(Q=+#ij!-u**3'%*@F#XfA=02@27-L->s`A!hJqu:SPXYco$4YJa]c"[POgnPYA'E^aWO$q&(O:@.F@[VdS5Mj+H[9amIEnD3WumI]qjp,[E+N1gilO`$#TX1o:X!`>!3-hXtc@P7^L53,o#7DBmT`7RT91W&hDMeAatu'3?#j]oA#3V_(JFo"?(EH"#AQ`bCYO$a,R/gV7t1#N0->6S7'@M3Eaa?@F\[Y(PDEL68o$>o2kCMHa3HfQ3YTF1)FM8'l-bRq'RsFr]C[;k)KkAJ`$!P/uG;_#ME(hbF<,k_^iUrH%o16r7=6;2,>tE,=UP%GiKF#J4>LPfr(KIBgGE4.>p0)\;/u-P/19(@\>+5\O,"^oLoF958#=YHe<%It+.=KuOB8)]HUD+FnRbJr-S1:Y*Ge95Vd>Wc>S7F<*Q4&!!!&?j4907M5;,(_7adEQh2udKn+jY8394H2hjp8s7jkLEn:nbdicj(loPE4SSo7(oV@AX"_@@o"g8)oAU3Qh-er+4@sF4J[JI20Vrl,0AtiLl&QPM3WuAo'1ITc_Z;g:jEcD3!2akXUotL=mQS06b;-njGM:+hOsd9:&lZ4P>Ab"MX:[hYZ%0n-on%jKBiui-hGDi3]oP-0N+oY3qZ6gV*a(seOsp]CWYPSriTdft%tm[5U!]4e`:T610\b:EVf&_,LRCS^#?N"&j+6,3i7f8Q`Um1he;=fhBFn=36'1p/'EfGO:+>A$\oMaHX%?#i%ZG!7j5=Sd=,s354O5VDnp)k@1XF%Cj`&72WZC-Eb*rV'i=>U,b$Rh$XTq2cl2?f[R`9T\EC/E0E(9/Otc31[N2Zl!\r%5")`S(q.9_OSt6tV5dUId2RqI>m@FFiOo@at+c"B7*,k!>LOa&kRG:^NkG6[\N^E/u[FL@W?+KrE_&.1>gCS`p5KiqW9;DDKm(Yq-XH8h-sc-]EY4eQlk.kTi_4T(RES\j"lLNAa/#NT4:\Xk:,4$S*TAF3$^Y5csHQ73J.$]7\mO%bP<%&dHV+H'VBWlmN8M8p++0*1D.aUU+uJoB<8B#-1U4!O%@aX>lIK]IF]dWj7.>#=`.HZ`[PdS\m#3+G(bLoDI_[3Y>o5)#-$UJitf.9LIF=[Z&b$&usbp11r2UJkrt\7c;Jj]*2tfqY)ga!G>qr@-[.^iW%D^dhqmpMukgk+rdHU,\?3*o"h\r*-t&Q9B3A$bG!\9`Q,e?K=q[mhKJesfIa3#Hih`2qB\d4PJZFm`^sIr2fjY"L]cZ`NR$h3>t\=4]55GZo+YH_N+fB.mIS;Q+/Q^E[Y%/)SShlIC][YG/dV0;a:>V/GVlV6f\0#uAMWh9D["_6Yf7]RR-3Daj?.3N2T=VCFVk+e9]$B-?d'p%0m"M+ep!S$!]NW/VB'&QB"NJKfX9s[-oahX0";6dfI:q0\cfI$7ms!?=CCe!+5Y^AAeFTXPT(Z775p]=7_K(EQl\0%scMB0ZHGTTVG2dZlGb=e5"#n%JI'`(ttgBc!-a2]sh9&*thI;]j-U+#B3EP$ON^Yr85$i)lC/>e0'e7KbRblSTN`ngo2$H.R+@rYU1dVjej#?+VdhdVO[5(\bN&Y%UN/6oS(^KS6-QWQjq6!n"k`X?(`0b,_*n7RS<`OrMtLSk"Z6TOJRB+:8q-M':!Qb\IN0Y]Y4punD+,QW.%@a1=i7sNC/rS(NI$3p`a+%:!FJ^5^n*gr/$_atQAn.MgX=fO?"e9,smHM/Po%-MciU=nXe@qhr4MtLCZ[d80>9lN!"k`YF&h??s/OG3j'nCJc:M=o?knb6uZPDuo)Q'sqZ"][U`QV2`C`B4_K%.9&RmM<")tOZoLs2F(/7W5b[Jk&N-CUUh)u`0G_&N3OEFe9B>olcbRkqDg7k77e6TmnmR^tkAB)%qKN?npYc\08r'=d/]\obU#7Mn7alO?+.l.L1+o@YDfO$A.uo)PAaQgkA_K8L`1fg_"/0JEFB_mXrc\%EN&m)#f)5@%S+Ik#aDkGlnSJ'30@/7CAqiAFQBVQh5B8!J&m4]gX!-+MQrT$S?D=A0A(.p_,^/'N`!!KO@t$mqendstream +endobj +118 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 4059 +>> +stream +GauHNH#oHG(B<9ai`2ge`gJQ;k*D4%=iYM%n)R;UA\>!?Pm]rP,"=hjAQ^mUn%Pup6O86rS>l01kiVP"If6F(J:cZHr,q0&Y=?lc6\)*hRK1`H*CfMU@W_80Lu^@76T/dKdD>)]=jd(,5'QYJ+JFuIR6-@)Dr;ndOWpYMdj(YZb!h,&^hAa$QsfKG'R!iV@O3I>-(PSA.jZ*87i;=iB;>2G^Pn=j8=AgZ2>Y3$eq3rsh%i3NEfAjFZCm9ji2M.8<`*$3`(fF^ioVN/n3^#7r10d+f-TFV?uO>e1V_4JQtd3!$r3BoH'B!rD-)j+*/^T5FqbbdFF9>^'1q^I@\9.13k@.p=Xuo(Bt/=6CC/(PB15mk9E.mqrB&?i>9?j`bZq+BhZ=o&GsM`AICOg=+Q'i<*a>F<3Tl^#h*c=[@_KRnr@:'5HYSg8=O6h0Q9hKC;]WUG/Jr9-LcODWTnl_="$dkeZH6K=Yc"<6R8:'"=Aj:?@?'paAYSo[G"lWbg_SonTjc"AXaYLdH?/0n(`r*%)4`P^JJg0SS#E;n0Ak&Cia(:+:5H$!iR`F!rUOCN+_#[SU4_h$9BbYrSSp(+a&OjPi\!JpoV^a5%-%J.7"e=T"_qehka$gIPdrq3,Ps"OQC?;k4)*@FWUJ6G0l!LsN5b;\pFQJhnDL1TQFs7'\$QneT'Zs]]C`uX.U`qP\*rNtRa/S"0`OaZJmO)qfYXa5'@f9HeJB1R^2I9<%r>/Or046'&cVnN^7K?%`NdQ?K9IFK3Oj?Bl*D7"E"1D@Xrr]+T5@F@48UpZd^0SH7E_4VOl%pD:=4N`.;>8&h_ttnBHC1)'k4Ut5a)biT2.E`\Q=t)Y/3shi1^7c(&.PG+@>#RBJd6]:5!OA&Lspf-U:Oe2!G'&Kp5JP0TF32I]g`0Z8aB-?'*gT_%d9J0gS-=It@sYc_/ZeR^MuZ%&R`X)^gm9ZG@W(32.?(kfX_=+CZnWXl)>20ds`3R/*D)R7@S0(O2DBfI;n#[SftIo<),P9s)uonrO';1&1S-gqQH1Ao4'7I@J9]'U`VrZ8TVabGJTKhUg3"O[=Nc3Uf_"nMVspL^<>A&.Jo=3c=bY?a,8qbNpG,\8KhA+i^ampftSJYq_$.XI9A@#d]PUTQL0(:?Hq#QHrK#>`=,BU/4:%],,gQ`@oQ58@$&E7EH?CL2DGHMPP?TpQRE8C?1KL5[7aYoiZVCN!qA/\^9;lIGTm94UIF+WgeSRD*OBGlB50&cB.7*F)M'NV_Q9SqX>@)^(1^iJkhH?/3T4#V'FLWl(^r'P+$Ygh5$W^#"(a9B@un/T8j$nYZkmc>,JA^B;Za5P:rn9)9t=+.$Q-=>j#r;:"Xg5BH0Q'K(c\)LN!8NGbV\ns:Ad(#$tHcuaLbh()(/^TXO>WJq`qW5q0h.hfkmWoHp#g5)a"?Z_$M[YD-1r]LD?Ckl,Eh/%1ent"=%6\-PdlRH)(tB&WU7`+1YV[Cf]L229d1%%kNrK,enVS,sB0km>]Lr8LWVd5".&MYHQ&rhYPB`/n&_kVOm[oZQc5hkP4ljQUU2(I^1dnK=4`0nW"M]48WB,_\[Cd(o``)"Fjf"Pt=G#F!&V1VjYK&HDM*I9=*r:*[).(&Tjl5oSduk7W/,NdhhIaX`CW];_F3oV6_)u[R8,))e8moV"/&5E52i92B%"s,5DWg#Yb/H9&+og!n)&P3J*L5gCLQ@_]U(u',HpqTgZ"t9'D#(gAHJ5U8QM*p@atfBX41)GZ+Djn%E94os0TQft#4u#rsiO95?T'UE13&i/0W-P!t`>rO22;S,f`S&*0W!@#:Uf"Ork4^>=CIY@Cm*I*DbC*<'0.JiU0C/Fe-[KUdUXIsjim!D1/E-t00A'si>dRH(uEXpO&q0QJqQE/9;BV48jk>@SVD4?(ANf0&GDA7@]=8=hs1?,IM"n%8:Q8jbeI1#kWhuXr34'bsDh;<\1*I"M-O@68'./cYF`MYsrhE4b:F6_&@cqij%Q$PFgE5Hp4WDQ]#412%H2b9FKj^_O>UUQW'_5<3Ip$FptieeVC?RPJ?tb^K$:MCQ,X]_$sSfk]1[[^K:]#[KrR&[ccTs9_p\S$hlfPUX]i;%T`0FcH%ZLWZ:L,?l\knK!k9!J9(fp(EEcSlX3>6(KDAMY!3_7\bb6KgGZ>/[k#<56X'u(4.KfHE$t'uQ)8N:hJ7.=_XNYr?01t(X`QSb.LWbU'petBlYGu9]^=qF'<`N)L%(e[A,2-NK5(aftjqQ[%hTemnuIaI[Y3>/G?hRj,=IfoqF\$qTZqS6&i9<-OA52a,l]a6WsO6P#jM@2L,K)"S-qqn>pV%s(M/O\g?Z7?[iF]%hW%?Y7@]eLiRHS0pkBQh%sicT"s"6_/d]4dQjpI=:KDgpfJM/mdM-k/c/"98"K"hT"iYEA@%D)(*redg5Q`:@+BP^LI1psrjT1%kW2;pdmTY_hp;a35c4o_lW.i;LY[32Y3b0D/<7n#4`cc8A`YtBDeGlD^"u4gX=)239pYHF!pK0<'LM5i@HMJK(9F'TWJ2u$$s)c0;Zh(g)DgDiQXpD1;0:sDQu`=HihSB;CFRpnET1-4"$n5DXi?]IFs2$5.iDa,i76O%h\oJcA]leb5Y>sJ<.>^[L:loCn09!cXQ82kmFSP8C^QZjVKgXg[Y6F-f2_EWMfSOf7"*ot&@qN]WjkFW>OKf24IS4@^Fms:4hD!qEG>6u3WQ61mm=$tYVh/J=R%XHikRk[3Q;cI$c]Q+0d\q9]nI8@6ObU\7jrKr\F!RH$Pr3AQLAKmd:!_]p5[^#!CX?EhVY8*7&_VAg3`pZ0VO&BmgmSU^8FTP50TacmBS#u\5uV<-!!NAN(Na9CRRRpO1AJlCi4H^1\]ras)5dr*+t&PP*bjT/q'`9,!2tH]](Rg2`o^]FkGC'CK6t/fNbR[$1W,P3^hO6.q[b*&\KRtoSim"4eQ]B\8"n$&u'$K'SU7@Z0$YiQi]$j]Y%@-CV$*?=B)HB75TXB;`rq(6$JD)mkj+o,!M+Uj&%f4++9FUkl~>endstream +endobj +119 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 4599 +>> +stream +Gau0G>BAOW(4OT5^s()IPh)RR&P:+/bt4GDSMX2RFn=U\$q*DpCPlLcb-JUbrU"qAGS(+B,3;LnfJ3Wg!''`E!A]H@015a42MA`5PL1>%(abY(M)!T$AH&0*c_(13bfG-kD9\L$qO"e\7+8:&(reit$oCR[L.15D@d'8?_i`j[l07abiY.!DR1W1q3krU]-k[/.VKIq0@$FE^9%ddj`XF3RYNF;9.hL#q6snmnL,,7,]Q@"VA10TdscpN'B:'6Z^VhF,0*H!cE)$RQ>RO2R(!er\gN!tLoD>i$1S-memo0$V7u$>9$hgRA8HrCKZ=C:@)(Xt^<[j:[nZYRHa%73pqY"'7;Nj;lIo032Z+_S.f[cNW&M$rn9)7I\+eBTeOj_9I`oT<>P\&#@4JH2"r^5:i_ASsUulDO/@l.?Q"fGUCKP"cGP-6H4h)>qkTUE-^W!SP,^m;\CoGE$;piP:FqFqM\qL>]Qc]XNSXK')+Gd[LlQ-]T(H#'lq)7k:K8&;A%gL]V&,EBdIFRtnBGb?n'QtTUZFprg,K#usFf/$kT'2p9__"k#K!E"hMp]h9F(:38e1S!]/)dD]QiOF'YY3=Oa2iC8$B%+s6%uU(MQ-&+YHB6VXIp4_F+89jh10B#p/b5SQXf>Ql9%1uL]B;Fl9L?IWK),<[."Q`0EHWA(f(.;EgNW.lIJa[5;]/K9%Q$U$36>Z7VeR;b)$Z7!!3d3]>=s7lUk:0t+!KBp]AJ!i'/pbAVqW^5>+ch:+rY"NNoYcUU^S:;ZG+dKS.6(L>d4)K\C!ZCaAuQ?[/X1<2>[:l1%CP2KQ[8OkX%_hN^*l;QVK/rf!9BP%i1V`p@MnqfL`!BpZX(P`Kb7@sKKcIck,=K8(>d7up1CVg;mX(ePa!=>qiI&&]<]AM?s`Y6V$R(MeI4r(bfQ\qFu-mS,Gr&7&!\!'0=Z"?+nq"t0dXB?N79[p^?_gc;h^hUphuB[f?3!(T?\&0>AZK?D[rC\J;qs3)8`pP-O<(3ZYeNFR')Z1MD`*AUqtBM4ooR4B_q>W&Q&sE'c`uk?Rh1KJIsI2aJ9W/GHa[<(t\H5S?j`;'7==&7@Z$J1U1JX:^m/2eHcY8Nl#d1LmXocPStBE"H+'2lbnf%Klugk'BX=t`XYAICG&B[t0jG8:"=/pL?X9UXpU28F]%9&3jJa'Q/"f/9eR:B/%4kG0kZVR8XM,8t#G_eri;eRX_De;aJMKcP!:k`q*d9+9L!NNMKIlak$27]Xr^p*"1/];n%H;H4h3/n5Hu@8V#!o7?97HDN<++\oA076Gf;ckpI`3C_`0a'[cfO([1bFN:gUeFuaWT0]`e.oYfZ+h,D1OPIu*^"T,$IB6L2XL_A'];"HPtbjPc@>GUaUSqlVck;pbR_G,552M?`B[''h4VUs%P/I"aTkOJ.XjFC+"7hJOeQV:mVQMq)LK8gOE$nQb/8g@I0Z'in]Bln5UeslfR0/W7DT(ebE,/R2%B^Yurk%>NA-Znqf)d,Hk^ZOC"V!KK$0Q"BKo!gVSfn>hjrZ>\qk1n8)>2((UGj1(NPT?BBst^BX3JjYXrH"2rBYJ!SU"NScNl$Vl0Yh;)fSC>1dsS[Qq%a'H6O&i-Y6D1+B0NSI"XK14RO_/B@8?j*e<@s"sGXQU8'<"4B@[2HbQP3q1[VR^-60eWAVa;%2lu.fPV8B"!<7OgOdT+H8sZ@g^R07^ID9GBCoWCl:c`i4D#I?%.]*oMhT2%P_*5i#FH[NS7@dpM55ZrGO'YX9aS6:,3I';58YXU]R.##`5m=l_uV.8YgEVAkpdWs+0=&XNoBI'TubSn&,cuQ`jT)kXCiSJ]\4\]hiMCL2L%*k+Z)l;#.Gns<``;bH&uVC.qgkejkO%gO`d,4Q_P-0PG_d=n^^ur&KYE\m+mU72.M*((\(i"oE%+KDs50AYr8l,2,red_)0=FN@CZ;(9GI;.PF#[7c^N79:Fd.aDZ)jK5\')X6p-pdn//-(Y4q%4K^pJfL=?UBZZ`^W^dL.Dj7W9GOmt_h'p7eK\>*)>\HF-U^hU6t+r+*0`]qc<\j_$7oVD*AD/iUMcq@cF&d4G!"FdtCF3[,5tr-W%O7A&:,3VLkf_%_K:V5K6V=]a'T/6"Cjh,TWbu7R`cGUeX:d[f$q[1`5cMRsB@#>`/H4B5<,sN+RUD)8)2&1AS(HQSQL[;dm8(>hc-SbV]i65n-t9WbR5!W/9'!hAjQil"A.=HRZ2:=C";"TCb+;u&<@Kf/f!3EFL=cP8q.r1:#M/$_/d0\nZ*FphiXnS`l56U\BWXmb$u1Wbr9nT2GD2367'<8EMQ!W=6/M]m.WLX`[(4d1*$,rmLXY^C%qqDHM-QL2F#naiuT2bSPIFQq2QK?bWD?>;8bO$fr^`m*/0!WR-^k69&k7bI@jJ[7sX0=mfrp)2I`XEJsK7kD*6VP&`0b%8q\3M(eI=gg7"!/b%ji:_j$F5W\K8cl3S0?PgqQT*_":O_644j[S^)1q-`IN$F23C_hsm=AJC/5\f1'@1GT^S^M%JWGY>c]VZNjVP@P>rOSe&ACdh3n#]G8k>2)*[YSpoQ$P[-J;jj?q+]%$RrdjE@Gg.!di"-L@ZNk6AOI]7G4$Cs7,8MFBqp[5W3+5eth-e+QBqWLSr+j6Qu'/;9H-!Ej>XhPVU6J3E]\i:0Rkro(pPSm"``sUXC2J^\oqbphB_[]LLX)/8LtSViK.W/9@PFo+(eFcnW=E(R'9LL)2SBW@0mJ&-K9UEgLs(kb,V7LU2"#Y+VJ5/u@F*FSlV%=c4Q&Sil4]8mKF\!!K?fBoftJFa;?/_u)[1ima6@r&V"ebeaFd31e%J8ODMBZ\X=K<,,k^QLV@@[p'=Agu:oS2U7Ah\/6]EZ8=r59i4GG)gDFFs331X"4&uMFJiF?*&,C,_qEr239WqR'Loup071gY6p,0h"V4:]eedm]1c4"W,_`]Je:>uS'Pa!ji&N!b#?a?YL]T/AG"eeB<1nbro5-EN.aGfN'iY%=^dE]Vaq.3]Os9JRkGnXQ#8@PkE$EX-&+4#6JH~>endstream +endobj +120 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2689 +>> +stream +Gatm>>ArQ1&q801Qq+&Wb8+YpS:dQgFHc)-l.2?boUp_c"X?l!'3S9`HF*GAB1Q;[g[_0K,>Md41W-bQj4H'*QYkTs-,A%5N,EI9#s?.6:TOjbgqra%ffs+m\T?b6?Jphki!6\JG,>p8LWaqhY%\'*$P,n7"+>K;6XZoYLs>c5j:_s9\1f;KLF\fD0%*CKH,;gg3J]M;HNulh:^`$H)3\$:`-[!o;bq+b.(iY334$&98WDa^j/"27eJ5DD>`Mi#=21%3%B*WNNPZ\p]FDDG=N=cOYK$)1CQP=)U1&1S%XaKXg(WlDn0HA`4s(B:j@QYQI\5k6W-rmsc;4h`T-@Z#IgU/;TAT9mlC4*rk8`I9]Ve!DPu)G-Z)bZ%YF9.4d[""lNZ?Jj?HdPU.>&D,NFEGPaX5D[C9uF'?M)H+8^rl\I&Fr'iVOYrE"X`5:;Uc?iMo4%@2ppV(hJ^43I/k(>e7$kYcpeKUWXn+D]>fkCEL+^]T9nao&(rQ]AFe@Wh3Z94H0Cm?9-LaNjk]1LW:4J"p4`0L"E/CiHfa-/[)BKoO"#:A;nTX=+#W;Ml0XIc-Fh\bD^L*iE$3^JW]%B1gA/-uaAcsf;+7dWs-ORQ%F)FMRU*5TXHkE&:*bX-H_difZ%S7tWZp%710iBbRd]4J4!Y*+%,IQdoah/Sf,f=U#8=AAqN#V.0,)cm>nHBF>7m:;aLjmu$h]eY:?6q3!)WBe,iK@=ElW-"@iK$iZeK>[S%d'.XC"U.V1&ad5u@6-<;?*FJn038"@D_ZDb8)AIU-Bn["mo8Ps+![K+;RBrnta:%r`;-^"dXiu>*BrYAG7+4L8`t0"UbhjSQ4@<3"M]HSo,-#>HAm&/9b5't<_.8PM#FS,-pkF;>lOe:iAgl3pD(&r+gX.VR$.^_kR@_EMH]$[QJ[iQY^V&#.U3(+n&NqH4#pLG2aP/:3T>RRpVA_)0@F9S3$_mrJFGH\pV5h^2A+#HsBZTl::J4Bg7r>GQKWS,\*YTgg\Dg)5@YMcDM*A1D*MRd0rr/l'F(W'j\%.Z9a$boT+f=dJ'=fM@cg+]tRqTCOD@*U]U?V%eBY()M1nFRI:#_^/(\dUMh)h`\NujM`XgO2r;ol\(-#nb&NP/7->j8=1F*7'GD6e*V[Xh0P`,aH5-^&$I(6!Z/\^lTFLr=(t#T>S^WE2%!$bC2BgQJ5#r;AY]'\+0:_$9ne\YVnT5HfR!@(o.pK.4XA,L/NkOQqY@T^E(gPVc;?2f=kKoH/./3@J@oGdr+o>O-F8C`@]6.uUCPQL-,E9+p&dPf;6R1i+,n".cZ3(&+C.Y`BhXg`d8fKUZb5,nGup/g.#a9`A"@YB,5H_Yn2?"!_,J'Q/`H4o1&X6PR`rS+8$0d$liS-6Ge8_-gYD!pjji2'p-;iU'W^GSWNJE;Fqn`+0%)iE`eVH(D[f]m#D,RO+&fVh\8-'0D'5[]"t?)I;N`n.8K(-VKrg8V2g!=3Pi'O49d8g[M&7r_H>D&l^]GIeNGe5agrcso2U,@48[n)9Y`nK0rn2LeDsPV#K5m7@Jl/Kg)'Vk0QYN4V"8:J1((C2ja\+"Jrh+-i;u#g:)1;5WraC7niI1!oER21@.sQT"\TVIXitE@!UL/@66oh%_mRPk-j`2rk?fknD*NIm(-%>_L+bk"=&"^^>"_r't'BbS0n'`I'qJ&gKO$:o/._U@(URl(O>`5k$Nt57I^D!gA\iem2\QIV>k7>;l^aNfRnj?B.=u@>W+-#Ashb>@4h'^A[V(t7pJ%N5Jq2*endstream +endobj +xref +0 121 +0000000000 65535 f +0000000061 00000 n +0000000160 00000 n +0000000267 00000 n +0000000379 00000 n +0000000526 00000 n +0000000673 00000 n +0000000820 00000 n +0000000967 00000 n +0000001114 00000 n +0000001261 00000 n +0000001409 00000 n +0000001557 00000 n +0000001635 00000 n +0000001784 00000 n +0000001931 00000 n +0000002077 00000 n +0000002226 00000 n +0000002375 00000 n +0000002523 00000 n +0000002672 00000 n +0000002821 00000 n +0000002969 00000 n +0000003117 00000 n +0000003265 00000 n +0000003413 00000 n +0000003561 00000 n +0000003709 00000 n +0000003857 00000 n +0000004005 00000 n +0000004153 00000 n +0000004301 00000 n +0000004449 00000 n +0000004598 00000 n +0000004747 00000 n +0000004895 00000 n +0000005043 00000 n +0000005191 00000 n +0000005339 00000 n +0000005487 00000 n +0000005635 00000 n +0000005783 00000 n +0000005930 00000 n +0000006077 00000 n +0000006226 00000 n +0000006374 00000 n +0000006522 00000 n +0000006670 00000 n +0000006819 00000 n +0000006935 00000 n +0000007462 00000 n +0000007582 00000 n +0000007688 00000 n +0000007896 00000 n +0000008104 00000 n +0000008188 00000 n +0000008396 00000 n +0000008604 00000 n +0000008812 00000 n +0000009020 00000 n +0000009228 00000 n +0000009436 00000 n +0000009644 00000 n +0000009852 00000 n +0000009940 00000 n +0000010285 00000 n +0000010361 00000 n +0000010459 00000 n +0000010577 00000 n +0000010728 00000 n +0000010837 00000 n +0000010956 00000 n +0000011110 00000 n +0000011222 00000 n +0000011401 00000 n +0000011773 00000 n +0000012188 00000 n +0000012329 00000 n +0000012439 00000 n +0000012549 00000 n +0000012938 00000 n +0000013142 00000 n +0000013290 00000 n +0000013390 00000 n +0000013524 00000 n +0000013669 00000 n +0000013800 00000 n +0000013955 00000 n +0000014098 00000 n +0000014238 00000 n +0000014382 00000 n +0000014525 00000 n +0000014666 00000 n +0000014815 00000 n +0000014915 00000 n +0000015207 00000 n +0000015319 00000 n +0000015451 00000 n +0000015583 00000 n +0000015720 00000 n +0000015841 00000 n +0000015963 00000 n +0000016147 00000 n +0000016305 00000 n +0000016479 00000 n +0000016918 00000 n +0000017080 00000 n +0000017225 00000 n +0000017372 00000 n +0000017470 00000 n +0000017606 00000 n +0000019745 00000 n +0000023955 00000 n +0000027172 00000 n +0000031110 00000 n +0000035788 00000 n +0000040211 00000 n +0000043875 00000 n +0000047603 00000 n +0000051755 00000 n +0000056447 00000 n +trailer +<< +/ID +[<24e9fc955939835014159e55a7e21eb8><24e9fc955939835014159e55a7e21eb8>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 64 0 R +/Root 63 0 R +/Size 121 +>> +startxref +59229 +%%EOF diff --git a/docs/ALPHA.md b/docs/ALPHA.md index 9cd2d11..c5802fa 100644 --- a/docs/ALPHA.md +++ b/docs/ALPHA.md @@ -6,6 +6,29 @@ Last updated: 2026-05-23. Use this document for **feature freeze**, **alpha buil --- +--- + +## Table of contents + + +- [Alpha scope (what we ship)](#alpha-scope-what-we-ship) +- [Recommended defaults (both devices)](#recommended-defaults-both-devices) +- [Feature freeze checklist (engineering)](#feature-freeze-checklist-engineering) +- [Alpha app sign-off (manual QA)](#alpha-app-sign-off-manual-qa) + - [A. Smoke (required — all must pass)](#a-smoke-required-all-must-pass) + - [B. Resilience (required)](#b-resilience-required) + - [C. Settings matrix (pick one row for official alpha demo)](#c-settings-matrix-pick-one-row-for-official-alpha-demo) + - [D. Regression guards (quick)](#d-regression-guards-quick) + - [E. Known limitations (not alpha blockers)](#e-known-limitations-not-alpha-blockers) +- [Log capture (when something fails)](#log-capture-when-something-fails) +- [Alpha backend (optional for LAN-only alpha)](#alpha-backend-optional-for-lan-only-alpha) +- [Ending feature freeze](#ending-feature-freeze) + + +**Documentation index:** [README.md](README.md) + +--- + ## Alpha scope (what we ship) | In scope | Out of scope (frozen) | diff --git a/docs/ALPHA_SOAK.md b/docs/ALPHA_SOAK.md index 091f277..4f074ee 100644 --- a/docs/ALPHA_SOAK.md +++ b/docs/ALPHA_SOAK.md @@ -2,6 +2,23 @@ Manual QA on **two devices** on the same Wi‑Fi. Goal: stable show path without experimental transports or stream protection. +--- + +## Table of contents + + +- [Before you start](#before-you-start) +- [Happy path (~15 min)](#happy-path-15-min) +- [Stress (optional, ~10 min)](#stress-optional-10-min) +- [Pass criteria](#pass-criteria) +- [If something fails](#if-something-fails) +- [Not in alpha soak scope](#not-in-alpha-soak-scope) + + +**Documentation index:** [README.md](README.md) + +--- + ## Before you start | Setting | Sender | Receiver | diff --git a/docs/AV_QUALITY_QA_SESSION.md b/docs/AV_QUALITY_QA_SESSION.md index 11e1035..e836cf0 100644 --- a/docs/AV_QUALITY_QA_SESSION.md +++ b/docs/AV_QUALITY_QA_SESSION.md @@ -7,6 +7,39 @@ Research and recommendations for **movie cast** vs **video cast** quality enhanc --- +--- + +## Table of contents + + +- [0. Receiver A/V presets (product, May 2026)](#0-receiver-av-presets-product-may-2026) +- [1. User requirements (preliminary conditions)](#1-user-requirements-preliminary-conditions) +- [2. Mode overview](#2-mode-overview) +- [3. Video — method comparison](#3-video-method-comparison) +- [4. Audio — method comparison](#4-audio-method-comparison) +- [5. Single vs 2-frame vs stream — decision matrix](#5-single-vs-2-frame-vs-stream-decision-matrix) +- [6. PTS/DTS reassembly (2.3)](#6-ptsdts-reassembly-23) +- [7. Pipeline placement: sender (2.1) vs receiver (2.2)](#7-pipeline-placement-sender-21-vs-receiver-22) + - [User question](#user-question) + - [Assessment](#assessment) + - [Preference (consensus from session)](#preference-consensus-from-session) +- [8. Named algorithm pipelines (best practices)](#8-named-algorithm-pipelines-best-practices) + - [A. Receiver — live video cast (default)](#a-receiver-live-video-cast-default) + - [B. Receiver — movie cast (+1 frame delay)](#b-receiver-movie-cast-1-frame-delay) + - [C. Sender — optional compression prep only](#c-sender-optional-compression-prep-only) + - [D. Avoid for realtime in this project](#d-avoid-for-realtime-in-this-project) +- [9. Ranked preferences (quality benefit vs implementation in android cast)](#9-ranked-preferences-quality-benefit-vs-implementation-in-android-cast) +- [10. Multithreading pipeline (target architecture)](#10-multithreading-pipeline-target-architecture) +- [11. Short answers to numbered conditions](#11-short-answers-to-numbered-conditions) +- [12. Bottom line](#12-bottom-line) +- [13. Other work in same project session (context only)](#13-other-work-in-same-project-session-context-only) +- [14. Open decisions when implementation starts](#14-open-decisions-when-implementation-starts) + + +**Documentation index:** [README.md](README.md) + +--- + ## 0. Receiver A/V presets (product, May 2026) Developer-only combo in **Developer settings** (not the main cast drawer): diff --git a/docs/BUILD_DEPLOY.md b/docs/BUILD_DEPLOY.md index c717b08..9e30e32 100644 --- a/docs/BUILD_DEPLOY.md +++ b/docs/BUILD_DEPLOY.md @@ -1,5 +1,22 @@ # Android Cast build ecosystem +--- + +## Table of contents + + +- [Shared authentication](#shared-authentication) +- [Local stack](#local-stack) +- [Trigger a build](#trigger-a-build) +- [Docker CI](#docker-ci) +- [CircleCI](#circleci) +- [Production notes](#production-notes) + + +**Documentation index:** [README.md](README.md) + +--- + ## Shared authentication All web consoles (Crashes, Tickets, Builder) share: diff --git a/docs/COMMERCIAL.md b/docs/COMMERCIAL.md index 7cb6098..733ed3e 100644 --- a/docs/COMMERCIAL.md +++ b/docs/COMMERCIAL.md @@ -2,6 +2,20 @@ Ads, in-app purchases, and Play Store helpers are **disabled for normal users**. Turn them on only in **Developer settings** while testing monetization. +--- + +## Table of contents + + +- [Developer toggles](#developer-toggles) +- [Before production](#before-production) +- [Code map](#code-map) + + +**Documentation index:** [README.md](README.md) + +--- + ## Developer toggles | Setting | Effect | diff --git a/docs/CRASH_REPORTER.md b/docs/CRASH_REPORTER.md index f6ff7d7..7a0cfee 100644 --- a/docs/CRASH_REPORTER.md +++ b/docs/CRASH_REPORTER.md @@ -1,5 +1,19 @@ # Anonymous crash reporter +--- + +## Table of contents + + +- [Android](#android) +- [Backend](#backend) +- [Report files](#report-files) + + +**Documentation index:** [README.md](README.md) + +--- + ## Android - **Main process:** `CrashReporter.install()` — any uncaught Java exception (cast or idle UI) → `files/crash_reports/pending/crash_yyyyMMdd_HHmmss.json` (same stamp as `session_*.json`). Active cast session stats are flushed with `globals.reason: "crash"` when applicable. diff --git a/docs/GIT_FLOW.md b/docs/GIT_FLOW.md index e787c9b..f2688ca 100644 --- a/docs/GIT_FLOW.md +++ b/docs/GIT_FLOW.md @@ -13,6 +13,39 @@ This document defines how we develop, integrate, release, and hotfix. **Cursor a --- +--- + +## Table of contents + + +- [1. Goals](#1-goals) +- [2. Branches](#2-branches) +- [3. Branch diagram (steady state)](#3-branch-diagram-steady-state) +- [4. Daily development](#4-daily-development) +- [5. Release: next → master](#5-release-next-master) +- [6. Hotfixes — chosen strategy](#6-hotfixes-chosen-strategy) + - [Default: hotfix from master (option 1) ✓](#default-hotfix-from-master-option-1) + - [Exception: fix only on next (option 2)](#exception-fix-only-on-next-option-2) +- [7. Protection rules (CI + policy)](#7-protection-rules-ci-policy) + - [7.1 How to protect branches (not local hooks alone)](#71-how-to-protect-branches-not-local-hooks-alone) +- [8. Sync cheat sheet](#8-sync-cheat-sheet) +- [9. Agent / Cursor checklist](#9-agent-cursor-checklist) +- [10. Comparison to classic Git Flow](#10-comparison-to-classic-git-flow) +- [11. First-time setup (maintainer)](#11-first-time-setup-maintainer) +- [12. Private primary + GitHub publish (dual remote)](#12-private-primary-github-publish-dual-remote) + - [12.1 Add GitHub without replacing origin](#121-add-github-without-replacing-origin) + - [12.2 What to push where](#122-what-to-push-where) + - [12.3 Daily habit](#123-daily-habit) + - [12.4 Where branch protection lives](#124-where-branch-protection-lives) + - [12.5 One-way mirror (optional automation)](#125-one-way-mirror-optional-automation) + - [12.6 Suggested order of operations (your situation)](#126-suggested-order-of-operations-your-situation) +- [Related](#related) + + +**Documentation index:** [README.md](README.md) + +--- + ## 1. Goals | Goal | How | @@ -388,6 +421,7 @@ Avoid `git push github --mirror` unless GitHub is a full duplicate of **private* ## Related +- [README.md](README.md) — documentation index (all guides) - [ROADMAP.md](ROADMAP.md) — product tracks - [.cursor/rules/git-flow.mdc](../.cursor/rules/git-flow.mdc) — agent enforcement - `docs/_pdf_build/build_git_flow_pdf.py` — PDF generator diff --git a/docs/GRAFANA_vs_others_graphvis_pivot.md b/docs/GRAFANA_vs_others_graphvis_pivot.md index c0e6c84..3394baf 100644 --- a/docs/GRAFANA_vs_others_graphvis_pivot.md +++ b/docs/GRAFANA_vs_others_graphvis_pivot.md @@ -2,6 +2,28 @@ _Date: 2026-06-02_ +--- + +## Table of contents + + +- [Context and constraints](#context-and-constraints) +- [Executive summary](#executive-summary) +- [Option comparison](#option-comparison) +- [Role-based graph packs (suggested)](#role-based-graph-packs-suggested) + - [User graphs](#user-graphs) + - [Slug admin graphs](#slug-admin-graphs) + - [Platform admin graphs](#platform-admin-graphs) +- [Estimates (engineering)](#estimates-engineering) +- [Recommendation under your constraints](#recommendation-under-your-constraints) +- [Implementation slice (proposed)](#implementation-slice-proposed) +- [Source linkage](#source-linkage) + + +**Documentation index:** [README.md](README.md) + +--- + ## Context and constraints - Stack: minimalistic LAMP with nginx + php-fpm + MariaDB. diff --git a/docs/INFRA.md b/docs/INFRA.md index faf251a..763b833 100644 --- a/docs/INFRA.md +++ b/docs/INFRA.md @@ -4,6 +4,7 @@ | Doc | When | |-----|------| +| [docs/README.md](README.md) | **Documentation index** — TOC for all guides | | **This file** | Topology, hosts, ports, sshfs, agent rules | | [examples/app_hub/DEPLOY.md](../examples/app_hub/DEPLOY.md) | Hub landing only (BE `listen 80`) | | [examples/crash_reporter/backend/README.md](../examples/crash_reporter/backend/README.md) | PHP, MariaDB, tickets, ingest | @@ -12,6 +13,31 @@ --- +--- + +## Table of contents + + +- [1. Topology](#1-topology) +- [2. Port 8089 — not the androidcast vhost](#2-port-8089-not-the-androidcast-vhost) +- [3. Public URLs](#3-public-urls) +- [4. Disk layout (BE)](#4-disk-layout-be) +- [5. Nginx — source of truth in repo](#5-nginx-source-of-truth-in-repo) + - [BE apps.conf today (typical)](#be-appsconf-today-typical) + - [Hub location (correct)](#hub-location-correct) + - [Reload (BE only for hub deploy)](#reload-be-only-for-hub-deploy) +- [6. Local sshfs workspace (not in git)](#6-local-sshfs-workspace-not-in-git) +- [7. FE nginx — two server names](#7-fe-nginx-two-server-names) +- [8. Verify](#8-verify) +- [9. Backend app (crashes / tickets)](#9-backend-app-crashes-tickets) +- [10. Rules for agents](#10-rules-for-agents) +- [11. Changelog (infra doc)](#11-changelog-infra-doc) + + +**Documentation index:** [README.md](README.md) + +--- + ## 1. Topology ```text diff --git a/docs/OPEN_API.md b/docs/OPEN_API.md index 3e28e60..5977138 100644 --- a/docs/OPEN_API.md +++ b/docs/OPEN_API.md @@ -2,6 +2,26 @@ Triage **real device crashes** vs **synthetic / bulk test traffic** when exposing upload or query APIs beyond the Android app. Builds on RBAC ([`examples/crash_reporter/backend/README.md`](../examples/crash_reporter/backend/README.md)). +--- + +## Table of contents + + +- [Problems](#problems) +- [Payload extension (ingest)](#payload-extension-ingest) +- [Upload API (POST …/api/upload.php)](#upload-api-post-apiuploadphp) +- [Console / query API (read)](#console-query-api-read) +- [Classifying synthetic traffic (heuristics)](#classifying-synthetic-traffic-heuristics) +- [Security](#security) +- [Android](#android) +- [Triage tags (implemented v1)](#triage-tags-implemented-v1) +- [Implementation order](#implementation-order) + + +**Documentation index:** [README.md](README.md) + +--- + ## Problems - Load tests and CI may POST many similar JSON blobs → drown signal in the reports list. diff --git a/docs/OTA.md b/docs/OTA.md index 9a1768d..76aa94b 100644 --- a/docs/OTA.md +++ b/docs/OTA.md @@ -6,6 +6,26 @@ The client runs checks and downloads in **`OtaUpdateService`** (a standalone `Se MQTT is fully supported for OTA sources using retained topic payloads (`mqtt://...` / `mqtts://...`). The URI path is treated as the exact topic to subscribe. +--- + +## Table of contents + + +- [URL layout](#url-layout) +- [Versioning](#versioning) +- [JSON files](#json-files) +- [Optional HTTP bundle (.otabundle.zip)](#optional-http-bundle-otabundlezip) +- [MQTT transport](#mqtt-transport) +- [Publish from a release APK](#publish-from-a-release-apk) + - [Docker build + staging channel](#docker-build-staging-channel) +- [App configuration](#app-configuration) +- [Checklist](#checklist) + + +**Documentation index:** [README.md](README.md) + +--- + ## URL layout ```text diff --git a/docs/PRO_AAR.md b/docs/PRO_AAR.md index 2629043..eeaa5c9 100644 --- a/docs/PRO_AAR.md +++ b/docs/PRO_AAR.md @@ -2,6 +2,24 @@ Design notes for a commercial build without forking the cast protocol. **Not implemented yet** — complements [COMMERCIAL.md](COMMERCIAL.md) (dev-gated Play Billing placeholders). +--- + +## Table of contents + + +- [Goals](#goals) +- [Entitlements API (in core)](#entitlements-api-in-core) +- [Module layout (Gradle)](#module-layout-gradle) +- [Play Billing flow](#play-billing-flow) +- [Build & release](#build-release) +- [Migration steps (suggested order)](#migration-steps-suggested-order) +- [Relation to sender resolution test](#relation-to-sender-resolution-test) + + +**Documentation index:** [README.md](README.md) + +--- + ## Goals - **OSS (`cast-core`)** — protocol, UDP/TCP transport, receiver, sender engine, crash reporter client, settings model. Publishable repo for trust and forks. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..895a58a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,100 @@ +# Android Cast — documentation index + +Click a link to open a guide. Section links jump within the document (GitHub, Cursor, and most Markdown previews). + +--- + +## Table of contents + + +- [All documents](#all-documents) +- [By topic](#by-topic) + - [Infrastructure and deploy](#infrastructure-and-deploy) + - [Development process](#development-process) + - [Alpha and QA](#alpha-and-qa) + - [Backend apps](#backend-apps) + - [Product and codecs](#product-and-codecs) +- [Agent bootstrap](#agent-bootstrap) +- [Maintaining TOCs](#maintaining-tocs) + + +--- + +## All documents + +| Document | Summary | +|----------|---------| +| [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md) | On-demand remote access (WG/SSH/file API) proposals. | +| [ALPHA.md](ALPHA.md) | Alpha feature freeze, QA sign-off, defaults. | +| [ALPHA_SOAK.md](ALPHA_SOAK.md) | Short LAN soak checklist for demo validation. | +| [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md) | AV enhancement Q&A: sender vs receiver pipelines. | +| [BUILD_DEPLOY.md](BUILD_DEPLOY.md) | Docker CI, staging OTA, builder console deploy. | +| [COMMERCIAL.md](COMMERCIAL.md) | Developer-gated ads / IAP toggles. | +| [CRASH_REPORTER.md](CRASH_REPORTER.md) | Anonymous crash upload (app + backend). | +| [GIT_FLOW.md](GIT_FLOW.md) | Branches (`master` / `next`), release, hotfix, dual remote. | +| [GRAFANA_vs_others_graphvis_pivot.md](GRAFANA_vs_others_graphvis_pivot.md) | Custom graphs vs Grafana decision memo. | +| [INFRA.md](INFRA.md) | Production topology: Gentoo FE → Alpine BE, ports, nginx, sshfs. | +| [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. | +| [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. | + +--- + +## By topic + +### Infrastructure and deploy + +- [INFRA.md](INFRA.md) — FE/BE topology, nginx, ports ([§1 Topology](INFRA.md#1-topology)) +- [BUILD_DEPLOY.md](BUILD_DEPLOY.md) — Docker CI, builder ([§Docker CI](BUILD_DEPLOY.md#docker-ci)) +- [OTA.md](OTA.md) — OTA v0 layout ([§URL layout](OTA.md#url-layout)) + +### Development process + +- [GIT_FLOW.md](GIT_FLOW.md) — `master` / `next` ([§2 Branches](GIT_FLOW.md#2-branches)) +- [ROADMAP.md](ROADMAP.md) — milestones ([§Alpha definition](ROADMAP.md#alpha-definition)) + +### Alpha and QA + +- [ALPHA.md](ALPHA.md) — freeze and sign-off ([§Alpha scope](ALPHA.md#alpha-scope-what-we-ship)) +- [ALPHA_SOAK.md](ALPHA_SOAK.md) — LAN soak ([§Happy path](ALPHA_SOAK.md#happy-path-15-min)) + +### Backend apps + +- [CRASH_REPORTER.md](CRASH_REPORTER.md) — crash upload +- [OPEN_API.md](OPEN_API.md) — ingest API ([§Upload API](OPEN_API.md#upload-api-post-apiv1uploadphp)) +- [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) — tickets + Gitea +- [GRAFANA_vs_others_graphvis_pivot.md](GRAFANA_vs_others_graphvis_pivot.md) — graphs pivot +- [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md) — remote access ([§Rev. 3 Q&A](20260602_REVERSE_SSH_proposals_summary.md#rev-3--wg-vs-ssh-qa--bastion-deep-dive)) + +### Product and codecs + +- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP +- [PRO_AAR.md](PRO_AAR.md) — Pro AAR split +- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — HDMI / USB-tether +- [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md) — AV enhancement Q&A + +--- + +## Agent bootstrap + +After a context reset, read in order: + +1. [INFRA.md](INFRA.md) — topology (no `:8089` for androidcast) +2. [GIT_FLOW.md](GIT_FLOW.md) — branch rules +3. Task-specific: [BUILD_DEPLOY.md](BUILD_DEPLOY.md), [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md), [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md), etc. + +Repo root [AGENTS.md](../AGENTS.md) points here. + +--- + +## Maintaining TOCs + +Section links use GitHub/Cursor-style heading anchors. After adding or renaming `##` / `###` headings, run: + +```bash +python3 scripts/generate-docs-toc.py +``` + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 944de71..d818d22 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,6 +4,30 @@ Last updated: 2026-05-23. Living document — refine as alpha and commercial tra --- +--- + +## Table of contents + + +- [Alpha definition](#alpha-definition) + - [Alpha app checklist](#alpha-app-checklist) + - [Alpha backend checklist](#alpha-backend-checklist) +- [Product positioning](#product-positioning) +- [USB-C / HDMI (roadmap D) and USB-tether cast (roadmap E)](#usb-c-hdmi-roadmap-d-and-usb-tether-cast-roadmap-e) +- [Commercial (Play Store prep)](#commercial-play-store-prep) +- [Crash console open API](#crash-console-open-api) +- [Stream protection (FEC / NACK)](#stream-protection-fec-nack) +- [libvpx / Opus / Speex](#libvpx-opus-speex) +- [Transports & protocols](#transports-protocols) +- [Receiver / sender UX](#receiver-sender-ux) +- [Deferred / post-alpha](#deferred-post-alpha) +- [Related docs](#related-docs) + + +**Documentation index:** [README.md](README.md) + +--- + ## Alpha definition | Track | Goal | Status | @@ -162,8 +186,10 @@ See [ndk/README.md](../ndk/README.md). ## Related docs +- [README.md](README.md) — **documentation index** (all `docs/*.md` with section links) - [GIT_FLOW.md](GIT_FLOW.md) — green `master` / `next` development cycle ([PDF](GIT_FLOW.pdf)) -- [README.md](../README.md) — quick start +- [INFRA.md](INFRA.md) — production FE/BE topology +- [../README.md](../README.md) — repo quick start - [ALPHA.md](ALPHA.md) — alpha freeze & QA sign-off - [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — D & E - [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP / Play @@ -172,3 +198,6 @@ See [ndk/README.md](../ndk/README.md). - [OTA.md](OTA.md) — backend layout v0 - [BUILD_DEPLOY.md](BUILD_DEPLOY.md) — Docker build, staging OTA, deploy flow - [CRASH_REPORTER.md](CRASH_REPORTER.md) — crash upload +- [GRAFANA_vs_others_graphvis_pivot.md](GRAFANA_vs_others_graphvis_pivot.md) — graphs dashboard pivot +- [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md) — on-demand remote access +- [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md) — AV quality enhancement Q&A diff --git a/docs/TICKETS_ROADMAP.md b/docs/TICKETS_ROADMAP.md index 4b67634..ceb7bb3 100644 --- a/docs/TICKETS_ROADMAP.md +++ b/docs/TICKETS_ROADMAP.md @@ -1,5 +1,22 @@ # Tickets, Gitea, and alpha smoke delegation +--- + +## Table of contents + + +- [Gitea (/app/androidcast_project/git/)](#gitea-appandroidcastprojectgit) +- [Alpha smoke tickets (typical DB id → block)](#alpha-smoke-tickets-typical-db-id-block) +- [Console features (implementation)](#console-features-implementation) + - [Attachments (deploy)](#attachments-deploy) + - [Google Workspace — phase 2 (not implemented)](#google-workspace-phase-2-not-implemented) +- [Agent / engineering queue (from your list)](#agent-engineering-queue-from-your-list) + + +**Documentation index:** [README.md](README.md) + +--- + ## Gitea (`/app/androidcast_project/git/`) Public URL should return Gitea HTML (assets under `/app/androidcast_project/git/assets/…`). diff --git a/docs/USB_HDMI_CAST.md b/docs/USB_HDMI_CAST.md index 97fdd48..8a4251d 100644 --- a/docs/USB_HDMI_CAST.md +++ b/docs/USB_HDMI_CAST.md @@ -2,6 +2,21 @@ Android Cast streams over **Wi‑Fi** (UDP/TCP/QUIC). USB-C→HDMI adapters use Android’s **wired external display** stack, which is separate from the cast protocol. +--- + +## Table of contents + + +- [Layer model](#layer-model) +- [Phase D — external display awareness & future capture](#phase-d-external-display-awareness-future-capture) +- [Phase E — USB-tether transport](#phase-e-usb-tether-transport) +- [Tablet + adapter checklist (e.g. Doogee Tab G6 Max → HY300)](#tablet-adapter-checklist-eg-doogee-tab-g6-max-hy300) + + +**Documentation index:** [README.md](README.md) + +--- + ## Layer model | Layer | Mechanism | This app today | diff --git a/docs/_pdf_build/build_reverse_ssh_proposals_pdf.py b/docs/_pdf_build/build_reverse_ssh_proposals_pdf.py new file mode 100644 index 0000000..8e885b7 --- /dev/null +++ b/docs/_pdf_build/build_reverse_ssh_proposals_pdf.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Build docs/20260602_REVERSE_SSH_proposals_summary.pdf from markdown source + heading TOC.""" + +from __future__ import annotations + +from pathlib import Path + +from md_to_pdf import build_pdf_from_markdown + +ROOT = Path(__file__).resolve().parents[2] +SOURCE_MD = ROOT / "docs" / "20260602_REVERSE_SSH_proposals_summary.md" +OUT_PDF = ROOT / "docs" / "20260602_REVERSE_SSH_proposals_summary.pdf" + + +def build_pdf() -> None: + build_pdf_from_markdown( + SOURCE_MD, + OUT_PDF, + doc_subtitle="Generated from markdown — rev. 1 + rev. 2 + rev. 3 (2026-06-02)", + ) + + +if __name__ == "__main__": + build_pdf() diff --git a/docs/_pdf_build/md_common.py b/docs/_pdf_build/md_common.py new file mode 100644 index 0000000..66250d2 --- /dev/null +++ b/docs/_pdf_build/md_common.py @@ -0,0 +1,48 @@ +"""Shared markdown heading / slug helpers for docs PDF and TOC scripts.""" + +from __future__ import annotations + +import re + +TOC_START = "" +TOC_END = "" + + +def github_slug(text: str) -> str: + """GitHub/Cursor-compatible heading anchor.""" + text = text.strip().lower() + text = re.sub(r"`([^`]+)`", r"\1", text) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"[*_]+", "", text) + text = re.sub(r"[^\w\s-]", "", text) + text = re.sub(r"\s+", "-", text) + text = re.sub(r"-+", "-", text) + return text.strip("-") + + +def collect_headings(lines: list[str]) -> list[tuple[int, str, str]]: + """Return (level, title, slug) for ## and ### headings.""" + out: list[tuple[int, str, str]] = [] + used: dict[str, int] = {} + for line in lines: + m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line) + if not m: + continue + level = len(m.group(1)) + title = m.group(2).strip() + if title == "Table of contents": + continue + base = github_slug(title) + if not base: + continue + n = used.get(base, 0) + slug = base if n == 0 else f"{base}-{n}" + used[base] = n + 1 + out.append((level, title, slug)) + return out + + +def strip_md_link_text(title: str) -> str: + t = re.sub(r"\*\*([^*]+)\*\*", r"\1", title) + t = re.sub(r"`([^`]+)`", r"\1", t) + return t diff --git a/docs/_pdf_build/md_to_pdf.py b/docs/_pdf_build/md_to_pdf.py new file mode 100644 index 0000000..56571c4 --- /dev/null +++ b/docs/_pdf_build/md_to_pdf.py @@ -0,0 +1,318 @@ +"""Render a markdown file to PDF with TOC from ## / ### headings (internal links + outline).""" + +from __future__ import annotations + +import re +from pathlib import Path + +from reportlab.lib import colors +from reportlab.lib.enums import TA_LEFT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import cm +from reportlab.platypus import ( + Flowable, + PageBreak, + Paragraph, + Preformatted, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, +) + +from md_common import TOC_END, TOC_START, collect_headings, github_slug, strip_md_link_text + + +def _xml(text: str) -> str: + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + +def _inline_md(text: str) -> str: + text = _xml(text) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"`([^`]+)`", r'\1', text) + text = re.sub( + r"\[([^\]]+)\]\(#([^)]+)\)", + r'\1', + text, + ) + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) + return text + + +class AnchorParagraph(Paragraph): + """Paragraph with PDF bookmark anchor.""" + + def __init__(self, text, style, *, anchor: str, outline_level: int): + super().__init__(f'{text}', style) + self._anchor = anchor + self._outline_level = outline_level + + +class OutlineDocTemplate(SimpleDocTemplate): + def afterFlowable(self, flowable: Flowable) -> None: + if isinstance(flowable, AnchorParagraph): + self.canv.bookmarkPage(flowable._anchor) + self.canv.addOutlineEntry( + flowable.getPlainText(), + flowable._anchor, + level=max(0, flowable._outline_level - 2), + ) + + +def _cell(text: str, style: ParagraphStyle) -> Paragraph: + return Paragraph(_inline_md(text), style) + + +def _parse_table_row(line: str) -> list[str]: + line = line.strip() + if line.startswith("|"): + line = line[1:] + if line.endswith("|"): + line = line[:-1] + return [c.strip() for c in line.split("|")] + + +def _is_table_sep(line: str) -> bool: + return bool(re.match(r"^\|?[\s\-:|]+\|?$", line.strip())) + + +def build_slug_by_line(lines: list[str]) -> dict[int, str]: + """Map source line index → anchor slug (same rules as collect_headings).""" + out: dict[int, str] = {} + used: dict[str, int] = {} + for i, line in enumerate(lines): + m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line) + if not m: + continue + title = m.group(2).strip() + if title == "Table of contents": + continue + base = github_slug(title) + if not base: + continue + n = used.get(base, 0) + slug = base if n == 0 else f"{base}-{n}" + used[base] = n + 1 + out[i] = slug + return out + + +def build_pdf_from_markdown( + source_md: Path, + out_pdf: Path, + *, + doc_title: str | None = None, + doc_subtitle: str = "", +) -> None: + lines = source_md.read_text(encoding="utf-8").splitlines() + headings = collect_headings(lines) + slug_by_line = build_slug_by_line(lines) + + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + "DocTitle", + parent=styles["Title"], + fontSize=15, + spaceAfter=5, + textColor=colors.HexColor("#1565C0"), + ) + toc_title = ParagraphStyle( + "TocTitle", + parent=styles["Heading1"], + fontSize=12, + spaceBefore=4, + spaceAfter=6, + textColor=colors.HexColor("#1565C0"), + ) + toc_item = ParagraphStyle( + "TocItem", + parent=styles["Normal"], + fontSize=8, + leading=11, + leftIndent=0, + ) + toc_sub = ParagraphStyle( + "TocSub", + parent=toc_item, + leftIndent=14, + fontSize=7.5, + leading=10, + ) + h1 = ParagraphStyle("H1", parent=styles["Heading1"], fontSize=11, spaceBefore=10, spaceAfter=4) + h2 = ParagraphStyle("H2", parent=styles["Heading2"], fontSize=9.5, spaceBefore=6, spaceAfter=3) + body = ParagraphStyle("Body", parent=styles["Normal"], fontSize=8, leading=10.5, alignment=TA_LEFT) + bullet = ParagraphStyle("Bullet", parent=body, leftIndent=12, bulletIndent=4, fontSize=8, leading=10.5) + small = ParagraphStyle("Small", parent=body, fontSize=7, textColor=colors.grey) + code_style = ParagraphStyle("Code", parent=body, fontName="Courier", fontSize=6.5, leading=8) + tbl_head = ParagraphStyle("TblHead", parent=body, fontName="Helvetica-Bold", fontSize=7, leading=9) + tbl_body = ParagraphStyle("TblBody", parent=body, fontSize=7, leading=9) + + story: list = [] + + # Title from first # line if not overridden + if doc_title is None: + for line in lines: + if line.startswith("# "): + doc_title = line[2:].strip() + break + doc_title = doc_title or source_md.stem + + story.append(Paragraph(_inline_md(doc_title), title_style)) + if doc_subtitle: + story.append(Paragraph(_inline_md(doc_subtitle), small)) + story.append(Spacer(1, 0.15 * cm)) + + # Table of contents (clickable internal links) + story.append(Paragraph("Table of contents", toc_title)) + for level, title, slug in headings: + text = strip_md_link_text(title) + link = f'{_xml(text)}' + style = toc_sub if level == 3 else toc_item + story.append(Paragraph(link, style)) + story.append(Spacer(1, 0.2 * cm)) + story.append( + Paragraph( + f"Source: {source_md.name} — section links work in PDF viewers that support internal anchors.", + small, + ) + ) + story.append(PageBreak()) + + # Body — skip title, TOC block, index link + skip_toc = False + in_code = False + code_lines: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i] + + if line.strip() == TOC_START: + skip_toc = True + i += 1 + continue + if line.strip() == TOC_END: + skip_toc = False + i += 1 + continue + if skip_toc: + i += 1 + continue + if line.startswith("# ") and not line.startswith("## "): + i += 1 + continue + if line.strip() == "## Table of contents": + i += 1 + continue + if line.strip().startswith("**Documentation index:**"): + i += 1 + continue + + if line.strip().startswith("```"): + if in_code: + story.append(Preformatted("\n".join(code_lines), code_style)) + story.append(Spacer(1, 0.08 * cm)) + code_lines = [] + in_code = False + else: + in_code = True + i += 1 + continue + if in_code: + code_lines.append(line) + i += 1 + continue + + if line.startswith("### "): + title = line[4:].strip() + slug = slug_by_line.get(i, github_slug(title)) + story.append( + AnchorParagraph(_inline_md(title), h2, anchor=slug, outline_level=3) + ) + i += 1 + continue + + if line.startswith("## "): + title = line[3:].strip() + slug = slug_by_line.get(i, github_slug(title)) + story.append( + AnchorParagraph(_inline_md(title), h1, anchor=slug, outline_level=2) + ) + i += 1 + continue + + if line.strip() == "---": + story.append(Spacer(1, 0.1 * cm)) + i += 1 + continue + + if line.strip().startswith("|") and i + 1 < len(lines) and _is_table_sep(lines[i + 1]): + headers = _parse_table_row(line) + i += 2 + rows: list[list[str]] = [] + while i < len(lines) and lines[i].strip().startswith("|"): + rows.append(_parse_table_row(lines[i])) + i += 1 + ncol = len(headers) + if ncol: + width = (16.2 * cm) / ncol + data = [[_cell(h, tbl_head) for h in headers]] + for row in rows: + padded = row + [""] * (ncol - len(row)) + data.append([_cell(c, tbl_body) for c in padded[:ncol]]) + t = Table(data, colWidths=[width] * ncol) + t.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565C0")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 7), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]), + ("LEFTPADDING", (0, 0), (-1, -1), 3), + ("RIGHTPADDING", (0, 0), (-1, -1), 3), + ("TOPPADDING", (0, 0), (-1, -1), 2), + ("BOTTOMPADDING", (0, 0), (-1, -1), 2), + ] + ) + ) + story.append(t) + story.append(Spacer(1, 0.08 * cm)) + continue + + if line.strip().startswith("- "): + story.append(Paragraph(f"• {_inline_md(line.strip()[2:])}", bullet)) + i += 1 + continue + + if line.strip().startswith("_") and line.strip().endswith("_") and len(line.strip()) > 2: + story.append(Paragraph(f"{_inline_md(line.strip()[1:-1])}", small)) + i += 1 + continue + + if not line.strip(): + i += 1 + continue + + story.append(Paragraph(_inline_md(line.strip()), body)) + i += 1 + + doc = OutlineDocTemplate( + str(out_pdf), + pagesize=A4, + leftMargin=1.4 * cm, + rightMargin=1.4 * cm, + topMargin=1.3 * cm, + bottomMargin=1.3 * cm, + title=doc_title, + author="Android Cast project", + ) + doc.build(story) + print(f"Wrote {out_pdf} ({len(headings)} TOC entries from markdown headings)") diff --git a/scripts/build-reverse-ssh-proposals-pdf.sh b/scripts/build-reverse-ssh-proposals-pdf.sh new file mode 100755 index 0000000..9263005 --- /dev/null +++ b/scripts/build-reverse-ssh-proposals-pdf.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +python3 "$ROOT_DIR/docs/_pdf_build/build_reverse_ssh_proposals_pdf.py" +echo "Generated: $ROOT_DIR/docs/20260602_REVERSE_SSH_proposals_summary.pdf" diff --git a/scripts/generate-docs-toc.py b/scripts/generate-docs-toc.py new file mode 100644 index 0000000..df83fc3 --- /dev/null +++ b/scripts/generate-docs-toc.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Insert/update ## Table of contents in all docs/*.md (GitHub-style anchor links).""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DOCS = ROOT / "docs" + +TOC_START = "" +TOC_END = "" +TOC_HEADING = "## Table of contents" +INDEX_NAME = "README.md" + +# One-line blurbs for docs/README.md +DOC_BLURBS: dict[str, str] = { + "README.md": "Master index of all documentation (this file).", + "INFRA.md": "Production topology: Gentoo FE → Alpine BE, ports, nginx, sshfs.", + "GIT_FLOW.md": "Branches (`master` / `next`), release, hotfix, dual remote.", + "ROADMAP.md": "Implementation roadmap, alpha definition, deferred work.", + "ALPHA.md": "Alpha feature freeze, QA sign-off, defaults.", + "ALPHA_SOAK.md": "Short LAN soak checklist for demo validation.", + "BUILD_DEPLOY.md": "Docker CI, staging OTA, builder console deploy.", + "OTA.md": "OTA channel layout v0, manifests, MQTT, publish flow.", + "CRASH_REPORTER.md": "Anonymous crash upload (app + backend).", + "OPEN_API.md": "Crash ingest API design, triage tags, heuristics.", + "TICKETS_ROADMAP.md": "Tickets console, Gitea, attachments, agent queue.", + "COMMERCIAL.md": "Developer-gated ads / IAP toggles.", + "PRO_AAR.md": "cast-core / cast-pro AAR split and Play Billing.", + "USB_HDMI_CAST.md": "Roadmap D/E: HDMI awareness, USB-tether transport.", + "GRAFANA_vs_others_graphvis_pivot.md": "Custom graphs vs Grafana decision memo.", + "20260602_REVERSE_SSH_proposals_summary.md": "On-demand remote access (WG/SSH/file API) proposals.", + "AV_QUALITY_QA_SESSION.md": "AV enhancement Q&A: sender vs receiver pipelines.", +} + + +def github_slug(text: str) -> str: + """Approximate GitHub/Cursor heading anchor slugs.""" + text = text.strip().lower() + text = re.sub(r"`([^`]+)`", r"\1", text) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"[*_]+", "", text) + text = re.sub(r"[^\w\s-]", "", text) + text = re.sub(r"\s+", "-", text) + text = re.sub(r"-+", "-", text) + return text.strip("-") + + +def collect_headings(lines: list[str]) -> list[tuple[int, str, str]]: + """Return (level, title, slug) for ## and ### only.""" + out: list[tuple[int, str, str]] = [] + used: dict[str, int] = {} + for line in lines: + m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line) + if not m: + continue + level = len(m.group(1)) + title = m.group(2).strip() + if title == "Table of contents": + continue + base = github_slug(title) + if not base: + continue + n = used.get(base, 0) + slug = base if n == 0 else f"{base}-{n}" + used[base] = n + 1 + out.append((level, title, slug)) + return out + + +def build_toc_block(headings: list[tuple[int, str, str]], *, doc_name: str) -> list[str]: + lines = [TOC_HEADING, "", TOC_START] + for level, title, slug in headings: + indent = " " if level == 3 else "" + # Strip markdown emphasis for link text + link_text = re.sub(r"\*\*([^*]+)\*\*", r"\1", title) + link_text = re.sub(r"`([^`]+)`", r"\1", link_text) + lines.append(f"{indent}- [{link_text}](#{slug})") + lines.append(TOC_END) + if doc_name != INDEX_NAME: + lines.append("") + lines.append(f"**Documentation index:** [{INDEX_NAME}]({INDEX_NAME})") + lines.append("") + return lines + + +def strip_existing_toc(lines: list[str]) -> list[str]: + out: list[str] = [] + i = 0 + while i < len(lines): + if lines[i].strip() == TOC_HEADING: + i += 1 + while i < len(lines) and TOC_END not in lines[i]: + i += 1 + if i < len(lines): + i += 1 # skip + while i < len(lines) and lines[i].strip() == "": + i += 1 + if i < len(lines) and lines[i].strip().startswith("**Documentation index:**"): + i += 1 + while i < len(lines) and lines[i].strip() == "": + i += 1 + continue + out.append(lines[i]) + i += 1 + return out + + +def find_insert_index(lines: list[str]) -> int: + """Index to insert TOC block (before first ## section).""" + for i, line in enumerate(lines): + if re.match(r"^## (?!Table of contents)", line): + return i + return len(lines) + + +def process_file(path: Path) -> bool: + text = path.read_text(encoding="utf-8") + lines = text.splitlines(keepends=True) + raw = [ln.rstrip("\n") for ln in lines] + raw = strip_existing_toc(raw) + headings = collect_headings(raw) + if not headings and path.name != INDEX_NAME: + return False + toc = build_toc_block(headings, doc_name=path.name) + idx = find_insert_index(raw) + # Avoid duplicate --- before TOC + if idx > 0 and raw[idx - 1].strip() == "---": + new_lines = raw[: idx - 1] + toc + ["---", ""] + raw[idx:] + else: + sep = ["---", ""] if idx < len(raw) and raw[idx].startswith("##") else [] + new_lines = raw[:idx] + toc + sep + raw[idx:] + new_text = "\n".join(new_lines) + "\n" + if new_text != text.replace("\r\n", "\n"): + path.write_text(new_text, encoding="utf-8") + return True + return False + + +def build_index_md() -> str: + md_files = sorted(p for p in DOCS.glob("*.md") if p.name != INDEX_NAME) + lines = [ + "# Android Cast — documentation index", + "", + "Click a link to open a guide. Section links jump within the document (GitHub, Cursor, and most Markdown previews).", + "", + TOC_HEADING, + "", + TOC_START, + "- [All documents](#all-documents)", + "- [By topic](#by-topic)", + "- [Agent bootstrap](#agent-bootstrap)", + TOC_END, + "", + "---", + "", + "## All documents", + "", + "| Document | Summary |", + "|----------|---------|", + ] + for p in md_files: + name = p.name + blurb = DOC_BLURBS.get(name, "") + lines.append(f"| [{name}]({name}) | {blurb} |") + + lines += [ + "", + "---", + "", + "## By topic", + "", + "### Infrastructure and deploy", + "", + f"- [INFRA.md](INFRA.md) — FE/BE topology, nginx, ports ([§1 Topology](INFRA.md#1-topology))", + f"- [BUILD_DEPLOY.md](BUILD_DEPLOY.md) — Docker CI, builder ([§Docker CI](BUILD_DEPLOY.md#docker-ci))", + f"- [OTA.md](OTA.md) — OTA v0 layout ([§URL layout](OTA.md#url-layout))", + "", + "### Development process", + "", + f"- [GIT_FLOW.md](GIT_FLOW.md) — `master` / `next` ([§2 Branches](GIT_FLOW.md#2-branches))", + f"- [ROADMAP.md](ROADMAP.md) — milestones ([§Alpha definition](ROADMAP.md#alpha-definition))", + "", + "### Alpha and QA", + "", + f"- [ALPHA.md](ALPHA.md) — freeze and sign-off ([§Alpha scope](ALPHA.md#alpha-scope-what-we-ship))", + f"- [ALPHA_SOAK.md](ALPHA_SOAK.md) — LAN soak ([§Happy path](ALPHA_SOAK.md#happy-path-15-min))", + "", + "### Backend apps", + "", + f"- [CRASH_REPORTER.md](CRASH_REPORTER.md) — crash upload", + f"- [OPEN_API.md](OPEN_API.md) — ingest API ([§Upload API](OPEN_API.md#upload-api-post-apiv1uploadphp))", + f"- [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) — tickets + Gitea", + f"- [GRAFANA_vs_others_graphvis_pivot.md](GRAFANA_vs_others_graphvis_pivot.md) — graphs pivot", + f"- [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md) — remote access ([§Rev. 3 Q&A](20260602_REVERSE_SSH_proposals_summary.md#rev-3--wg-vs-ssh-qa--bastion-deep-dive))", + "", + "### Product and codecs", + "", + f"- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP", + f"- [PRO_AAR.md](PRO_AAR.md) — Pro AAR split", + f"- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — HDMI / USB-tether", + f"- [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md) — AV enhancement Q&A", + "", + "---", + "", + "## Agent bootstrap", + "", + "After a context reset, read in order:", + "", + "1. [INFRA.md](INFRA.md) — topology (no `:8089` for androidcast)", + "2. [GIT_FLOW.md](GIT_FLOW.md) — branch rules", + "3. Task-specific: [BUILD_DEPLOY.md](BUILD_DEPLOY.md), [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md), [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md), etc.", + "", + "Repo root [AGENTS.md](../AGENTS.md) points here.", + "", + "---", + "", + "## Maintaining TOCs", + "", + "Section links use GitHub/Cursor-style heading anchors. After adding or renaming `##` / `###` headings, run:", + "", + "```bash", + "python3 scripts/generate-docs-toc.py", + "```", + "", + ] + return "\n".join(lines) + "\n" + + +def main() -> None: + index_path = DOCS / INDEX_NAME + index_path.write_text(build_index_md(), encoding="utf-8") + print(f"Wrote {index_path}") + + changed = [] + for path in sorted(DOCS.glob("*.md")): + if process_file(path): + changed.append(path.name) + print(f"Updated TOC: {path.name}") + + print(f"Done. {len(changed)} file(s) updated.") + + +if __name__ == "__main__": + main()