diff --git a/AGENTS.md b/AGENTS.md index 14a9284..a52a225 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ bb-vpn enroll # Submit an enrollment URI (the menuba bb-vpn --version # Print version (ldflags-stamped at build time) ``` -### .pkg installer (Phase 4 + 5) +### .pkg installer (Phase 4 + 5 + 6) ```bash make build-bb-vpn-host # Host-arch bb-vpn binary -> build/bb-vpn (dev/test) make build-bb-vpn-pkg # Darwin universal bb-vpn -> build/pkg/bb-vpn (for .pkg) @@ -100,6 +100,19 @@ make test-bb-vpn # Run client/bb-vpn Go tests make build-pkg # Assemble BB-VPN-.pkg (incl. BBVPN.app) in client/pkg-build/dist/ ``` +Phase 6 adds ad-hoc codesigning to `build.sh` (`codesign -s - --force` +on the standalone bb-vpn/sing-box/xray Mach-Os, `codesign -s - --force +--deep` on `BBVPN.app`; no Apple Developer license, no notarization — +Gatekeeper still shows "unidentified developer" on first install + first +launch) and a +user-facing install page template at +`client/pkg-build/install-page-template.html`. The operator-facing +host/distribute runbook (build, sign, host on a long-random nginx +location, per-user install page via envsubst, token rotation, +verification) lives in [`docs/release.md`](docs/release.md). Future +operators/agents touching the .pkg flow should read it before +modifying `build.sh` or the install page. + `vpn-start` no longer parses its own flags. Any args after the program name are forwarded verbatim to `render-config`; xray-need is auto-detected from the rendered sing-box config (presence of any `xhttp-*` SOCKS outbound). diff --git a/README.md b/README.md index 94cfa92..dd19d9f 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,18 @@ openssl s_client -connect :443 -servername -alpn h2 -tls1_ # NOT xray's synthetic cert. ``` +## .pkg distribution (Phase 4–6) + +The macOS `.pkg` installer flow under `client/pkg-build/` (with the +`bb-vpn` control-plane binary in `client/bb-vpn/` and the `BBVPN.app` +menu-bar app in `client/menubar/`) is the supported way to ship to +end-users. Build with `make build-pkg`, host the resulting +`BB-VPN-.pkg` + per-user install page on a long-random URL, and +share that URL out-of-band. + +Full operator runbook (build, ad-hoc codesign, host, per-user install +page, token rotation, verification): [docs/release.md](docs/release.md). + ## Files ``` @@ -163,6 +175,13 @@ config/ sing-box.template.json - Legacy single-server sing-box template com.xray-xhttp.plist - launchd plist for xray-core com.sing-box-vpn.plist - launchd plist for sing-box +client/ + bb-vpn/ - Go control-plane CLI (shipped in the .pkg) + menubar/ - SwiftUI BBVPN.app sources + pkg-build/ - .pkg installer assembly (build.sh, postinstall.sh, install-page-template.html) +docs/ + release.md - Phase 6 operator runbook for the .pkg flow + control-plane-bootstrap.md - Phase 1 control-plane setup scripts/ deploy.sh - First-time server deployment xray-users - User management CLI diff --git a/client/pkg-build/build.sh b/client/pkg-build/build.sh index 4c714c1..13d4e9c 100755 --- a/client/pkg-build/build.sh +++ b/client/pkg-build/build.sh @@ -51,6 +51,7 @@ extract_version() { command -v jq >/dev/null 2>&1 || die "jq is required" command -v pkgbuild >/dev/null 2>&1 || die "pkgbuild is required (Xcode CLT)" command -v productbuild >/dev/null 2>&1 || die "productbuild is required (Xcode CLT)" +command -v codesign >/dev/null 2>&1 || die "codesign is required (Xcode CLT)" EXPECT_BB=$(jq -r '.bb_vpn' "$MANIFEST") EXPECT_SB=$(jq -r '.sing_box' "$MANIFEST") @@ -148,9 +149,45 @@ jq --arg tok "$TOKEN_TRIMMED" \ > "$STAGING_DIR/Library/Application Support/bb-dpi/control-plane.json" chmod 0600 "$STAGING_DIR/Library/Application Support/bb-dpi/control-plane.json" +# Phase 6: ad-hoc codesign every executable + the .app bundle in the +# staging tree. `-s -` is ad-hoc (no Apple identity); the signature +# only gives each binary a stable code-signing identifier so the +# kernel's library-validation and TCC paths don't trip on completely +# unsigned binaries. Gatekeeper still treats the .pkg + .app as +# "unidentified developer" — the user-facing right-click → Open dance +# is documented in docs/release.md. +# +# `--force` overwrites any existing signature (upstream sing-box and +# xray release builds ship with their own ad-hoc sigs we don't want to +# rely on). `--deep` recurses into BBVPN.app's bundle and signs the +# embedded Mach-O at Contents/MacOS/BBVPN in the same invocation, so +# no separate inner pre-sign is needed. +blue "ad-hoc codesigning payload..." +SIGN_BINS=( + "$STAGING_DIR/Library/Application Support/bb-dpi/bin/bb-vpn" + "$STAGING_DIR/Library/Application Support/bb-dpi/bin/sing-box" + "$STAGING_DIR/Library/Application Support/bb-dpi/bin/xray" +) +for bin in "${SIGN_BINS[@]}"; do + codesign --sign - --force --timestamp=none "$bin" +done +codesign --sign - --force --deep --timestamp=none \ + "$STAGING_DIR/Applications/BBVPN.app" + +# Verify everything we just signed actually validates. +for bin in "${SIGN_BINS[@]}"; do + codesign --verify --strict "$bin" || die "codesign verify failed: $bin" +done +codesign --verify --deep --strict \ + "$STAGING_DIR/Applications/BBVPN.app" || die "codesign verify failed: BBVPN.app" +green "ad-hoc signatures verified." + # Strip xattrs so pkgbuild doesn't emit AppleDouble ._* sidecars for # every payload file (com.apple.provenance and friends on brew-installed # binaries). Keeps the .pkg smaller and the payload listing clean. +# Runs AFTER codesign — the signature is stored inside the Mach-O +# LC_CODE_SIGNATURE load command (and inside the .app's +# Contents/_CodeSignature/), not as an xattr, so xattr -cr is safe. xattr -cr "$STAGING_DIR" # postinstall lives in pkgbuild's --scripts dir, NOT the payload tree. diff --git a/client/pkg-build/install-page-template.html b/client/pkg-build/install-page-template.html new file mode 100644 index 0000000..c1cf863 --- /dev/null +++ b/client/pkg-build/install-page-template.html @@ -0,0 +1,144 @@ + + + + + + + +BB-VPN install — ${USER_NAME} + + + +
+ +

BB-VPN install

+

Personal link for ${USER_NAME}. Don't share this page — both the installer and your enrollment link are on it.

+ +
+

1Download the installer

+

Download ${PKG_NAME}

+

~20 MB. Save it somewhere you can find in Finder (Downloads is fine).

+
+ +
+

2Right-click → Open the installer

+

Find ${PKG_NAME} in Finder. Don't double-click it.

+
    +
  1. Right-click (or Control-click) the file.
  2. +
  3. Choose Open from the menu.
  4. +
  5. macOS will show a warning: “${PKG_NAME} cannot be opened because Apple cannot check it for malicious software.” Click Open.
  6. +
  7. The standard installer opens. Click through. Enter your Mac password when asked.
  8. +
+
+ Why the warning? The installer isn't signed with an Apple Developer ID (we don't have a paid Apple Developer account). Right-click → Open is the official one-time approval flow. macOS remembers your approval; you won't see this dialog again for this file. (Text-only instructions are intentional — no screenshots are shipped with the page.) +
+
+ +
+

3BBVPN starts automatically

+

After the installer finishes, BBVPN launches on its own — a small icon (grey or yellow circle) appears in your menu bar within a few seconds. If the icon doesn't show up, open /Applications/BBVPN.app once via right-click → Open → click Open on the warning dialog.

+
+ +
+

4Enroll

+

Click the button below. Your browser will ask if you want to open the link in BBVPN — click Allow or Open BBVPN.

+

Enroll this Mac

+
+ The button doesn't open BBVPN — what now? +

Open Terminal (Spotlight → “Terminal”) and run:

+

sudo "/Library/Application Support/bb-dpi/bin/bb-vpn" enroll '${ENROLL_URI}'

+
+
+ +
+ +

How do I know it's working?

+

The menu bar icon turns green within a minute of enrollment. Click the icon to see your exit country and which services are running. Visit ifconfig.co — the IP shown should NOT be your real one.

+ +

It's stuck on yellow / grey

+
    +
  1. Grey: not enrolled yet — repeat step 4.
  2. +
  3. Yellow with “syncing”: wait 15-30 seconds, the first sync is in flight.
  4. +
  5. Yellow with an error: click the icon, copy the error line, and DM the operator.
  6. +
+ +

Stop / start

+

In Terminal:

+

sudo "/Library/Application Support/bb-dpi/bin/bb-vpn" stop — turn the VPN off (survives reboots).
+ sudo "/Library/Application Support/bb-dpi/bin/bb-vpn" start — turn it back on.

+

The absolute path is needed because sudo resets $PATH and the ~/.local/bin/bb-vpn shortcut isn't on its search path.

+ +

Uninstall

+

If you ever need to remove BB-VPN, in Terminal:

+

sudo /Library/Application\ Support/bb-dpi/bin/bb-vpn-uninstall

+ +
+

Trouble? DM the operator. Don't forward this page.

+ +
+ + diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..d1f316c --- /dev/null +++ b/docs/release.md @@ -0,0 +1,350 @@ +# Release runbook + +Operator runbook for cutting a `BB-VPN-.pkg`, signing it ad-hoc, +hosting it, and rolling it out. Phase 6 of the pkg-and-pull-control-plane +plan. + +The operator does **not** have an Apple Developer license. Everything is +unsigned (or ad-hoc signed). No notarization. Users see Gatekeeper +warnings on first install and first launch; the user-facing install page +walks them through the right-click → Open dance. + +--- + +## 1. Prerequisites + +One-time on the dev machine: + +- Xcode Command Line Tools (`xcrun`, `pkgbuild`, `productbuild`, `lipo`, + `codesign`, `swiftc`) — `xcode-select --install`. +- Go ≥ 1.22 — `brew install go`. `go.mod` enforces the floor. +- `jq` — `brew install jq`. +- `envsubst` (ships in `gettext`) — `brew install gettext` (needed by + the per-user install-page recipe in [§3d](#3d-per-user-install-page)). +- Control plane bootstrap completed: `config/control-plane/endpoints.json` + + `config/control-plane/token` exist (see + [control-plane-bootstrap.md](control-plane-bootstrap.md)). +- `sing-box` and `xray` binaries dropped into + `client/pkg-build/payload-binaries/` along with `geoip.dat` + + `geosite.dat` (see `client/pkg-build/README.md`). +- `config/control-plane/package-manifest.json` versions match the + payload binaries (`make build-pkg` aborts otherwise). + +--- + +## 2. Build + +From the project root: + +``` +make build-pkg +``` + +This runs in sequence: + +1. `make build-bb-vpn-pkg` — Darwin universal `bb-vpn` (arm64+amd64 via + `lipo`) at `build/pkg/bb-vpn`. Version from + `package-manifest.json.bb_vpn`, baked in via `-ldflags`. +2. `client/menubar/build.sh` — Darwin universal `BBVPN.app` at + `build/menubar/BBVPN.app`. +3. `client/pkg-build/build.sh`: + - Version-couples bb-vpn, sing-box, xray against the manifest. Abort + on mismatch. + - Stages payload under `build/pkg-staging/`. + - **Ad-hoc codesigns** `bb-vpn`, `sing-box`, `xray`, and `BBVPN.app` + in place inside the staging tree (`codesign -s - --force` on the + standalone Mach-O binaries; `codesign -s - --force --deep` on + `BBVPN.app` so the bundle's `Contents/MacOS/BBVPN` is signed in + the same invocation). No Apple identity, no notarization. The + signatures only give each + binary a stable code-signing identifier so the kernel's + library-validation and TCC paths don't trip on "completely + unsigned" binaries. Gatekeeper still treats the .pkg and .app as + "unidentified developer" — the right-click → Open dance is still + required on first install + first launch. + - Runs `pkgbuild` + `productbuild`. Output: + `client/pkg-build/dist/BB-VPN-.pkg`. + +The .pkg itself is unsigned. `productbuild --sign` requires a Developer +ID Installer cert that the operator doesn't have; skipping it is the +intentional cost of zero-license distribution. + +Smoke-check the bundled signatures. `build.sh` already runs +`codesign --verify --deep --strict` on the staging tree (output: +`ad-hoc signatures verified.`), so the in-pkg payload is signed. +To inspect the in-pkg signatures directly: + +``` +rm -rf /tmp/bb-pkg-expand +pkgutil --expand client/pkg-build/dist/BB-VPN-.pkg /tmp/bb-pkg-expand +# productbuild distribution .pkgs expand to a component subdir; +# the cpio Payload lives inside it. +cd /tmp/bb-pkg-expand/BB-VPN-component.pkg && cat Payload | gunzip | cpio -i -d +codesign -dv ./Library/Application\ Support/bb-dpi/bin/bb-vpn +codesign -dv ./Applications/BBVPN.app +``` + +Expected: `Signature=adhoc` for each. + +--- + +## 3. Host the .pkg + install page + +The .pkg and the user-facing install page get served from a separate +nginx `location` on the cover-site backend (the same nginx that already +fronts xray's REALITY fallback on `127.0.0.1:8081`). + +**Why a separate location, not under `/control/bundle.json`'s +`auth_request` umbrella**: browsers can't add `Authorization` headers on +plain navigation, so a token-gated download URL would just hand every +user a 401. The protection here is path-obscurity: + +``` +https:///d/<32-hex>/BB-VPN-.pkg +https:///d/<32-hex>/install-.html +``` + +The 32-hex segment is per-cohort random. Anyone with the URL has the +.pkg (which itself contains the bearer token in +`control-plane.json`); rotation cost is documented in +[§5](#5-token-rotation). + +### 3a. Mint a random path + +``` +PATH_HEX=$(openssl rand -hex 16) +echo "$PATH_HEX" +``` + +Record this somewhere out-of-band (1Password, operator notes). Treat it +as a secret of the same blast-radius as the token: leaked path = leaked +.pkg = leaked token. + +### 3b. nginx location + +On each cover-site host that should serve downloads, drop this snippet +into the same server block where `/control/bundle.json` lives (NOT +gated by `auth_request`). Replace `` with the hex string +from §3a before reload — same pattern as the control-plane bootstrap +`@@TOKEN@@` substitution: + +``` +PATH_HEX=<32-hex from §3a> +awk -v ph="$PATH_HEX" '{gsub("", ph); print}' \ + snippet-template.conf > /etc/nginx/snippets/bb-dpi-downloads.conf +``` + +Template (`snippet-template.conf`): + +``` +# /etc/nginx/snippets/bb-dpi-downloads.conf +location ^~ /d// { + alias /var/www/bb-dpi-downloads/; + autoindex off; + add_header Cache-Control "no-store"; + # Same cover fallthrough as /control/bundle.json — any miss/error + # leaks to the cover site's natural 404 so the directory itself + # isn't fingerprintable. Mirror the wide status list from + # nginx-bundle.conf.template so malformed-Authorization edge cases + # (400/413/414) and internal 5xx also funnel through @cover_404. + error_page 400 401 403 404 405 408 413 414 444 500 502 503 504 = @cover_404; +} +``` + +`^~` prevents the location from also matching adjacent regex +`location ~` blocks (e.g., a `.html$` PHP handler on the cover site). +`@cover_404` is already wired during control-plane bootstrap. + +Then in the parent server block: + +``` +include /etc/nginx/snippets/bb-dpi-downloads.conf; +``` + +Reload: + +``` +nginx -t +systemctl reload nginx +``` + +### 3c. Drop the files + +`` is whatever the cover-site nginx worker runs as +(typically `www-data` on Debian/Ubuntu, `nginx` on RHEL/Alpine, +`http` on Arch). Discover the local value with: + +``` +ps -o group= -p "$(pgrep -f 'nginx: worker' | head -1)" +``` + +Then drop the .pkg into the download dir: + +``` +sudo mkdir -p /var/www/bb-dpi-downloads +sudo chown -R root: /var/www/bb-dpi-downloads +sudo chmod 0750 /var/www/bb-dpi-downloads +sudo cp BB-VPN-.pkg /var/www/bb-dpi-downloads/ +sudo chmod 0640 /var/www/bb-dpi-downloads/BB-VPN-.pkg +``` + +(Repeat the install-page step below per user.) + +### 3d. Per-user install page + +`client/pkg-build/install-page-template.html` is the template. Fill the +placeholders with `envsubst`: + +``` +export PKG_URL="https:///d//BB-VPN-.pkg" +export PKG_NAME="BB-VPN-.pkg" +export ENROLL_URI=$(./scripts/xray-users enroll-url "") +export USER_NAME="" +envsubst < client/pkg-build/install-page-template.html \ + > /tmp/install-.html +scp /tmp/install-.html \ + cover-host:/var/www/bb-dpi-downloads/install-.html +ssh cover-host 'sudo chmod 0640 /var/www/bb-dpi-downloads/install-*.html && \ + sudo chown root: /var/www/bb-dpi-downloads/install-*.html' +``` + +(Use a non-guessable `` — first name + 4 random hex is +plenty.) The DM the user receives is just the URL of this HTML page. + +--- + +## 4. Upgrade flow + +`.pkg` is fully self-contained. The user downloads `BB-VPN-1.0.1.pkg`, +right-clicks → Open, runs through the install dialog. No uninstall +required. + +What postinstall does on top of an existing install: + +- Overwrites every payload file (bb-vpn binary, sing-box, xray, + BBVPN.app, plists, geoip.dat/geosite.dat). +- Re-runs `lsregister` for BBVPN.app so `bb-vpn://` URL handler resolves + to the new binary. +- `launchctl bootout` + `launchctl bootstrap` for the sync daemon and + the menubar LaunchAgent (the bb-vpn binary they exec resolves to the + new copy at next spawn). +- Calls `bb-vpn start` (guarded by `manually_stopped.flag` absence + + `identity.json` presence). For an already-installed user with an + active identity, this kicks the daemons fresh. +- **Does NOT clobber** `manually_stopped.flag` on reinstall. A user who + stopped the VPN stays stopped through an upgrade. +- **Does NOT clobber** `identity.json` (the user's UUID + enrollment). + Enrollment survives upgrades. The same .pkg can be installed onto a + pristine Mac (where postinstall skips `bb-vpn start` because + `identity.json` is absent — user clicks the enroll link in the + install page to enroll). + +For an already-enrolled user (the upgrade case), postinstall calls +`bb-vpn start` immediately after bootstrapping the sync daemon, so +sing-box and xray are re-bootstrapped within seconds — not on the +next 15-min sync tick. The first sync tick still runs at install time +(`RunAtLoad=true` on the sync daemon) and the daemons go through +pre-restart validation (sing-box `check`, xray `-test`). On a pristine +install (no `identity.json`), postinstall skips `bb-vpn start`; the +user enrolls via the install-page link and the first sync triggers +the daemons. + +To fully remove an existing install before reinstalling (rarely +needed — the .pkg's postinstall is reinstall-safe), the uninstaller +ships in the same payload as `bb-vpn` and lives at +`/Library/Application Support/bb-dpi/bin/bb-vpn-uninstall` (see +[§6 verification](#6-verification) for the one-liner). + +`control-plane.json` (endpoint URLs + bearer token) is **baked into the +.pkg payload** at build time. On reinstall, the new copy replaces the +old — which means token rotation is a "reinstall everyone" event. + +--- + +## 5. Token rotation + +Token rotation is high-cost and not routine. Trigger only on suspected +compromise of the .pkg (URL leak with enrollment data, lost laptop with +active install, etc.). Roll out plan: + +| step | action | est. time | +|------|------------------------------------------------------------------|-----------| +| 1 | Mint a new token: `openssl rand -base64 48 \| tr -d '+/=\n' \| cut -c1-64 > config/control-plane/token && chmod 600 config/control-plane/token` (the `chmod` re-asserts the 0600 mode from control-plane bootstrap §1b, in case the file was deleted between rotations and a fresh umask write left it world-readable) | <1 min | +| 2 | Redeploy nginx snippet on every cover-site host: re-substitute `@@TOKEN@@`, reload nginx (see [control-plane-bootstrap.md](control-plane-bootstrap.md) §2) | ~5 min × n hosts | +| 3 | `make publish-bundle` — push new bundle.json (still uses the old token at this point; the swap is nginx-side) | <1 min | +| 4 | `make publish-status` — confirm every endpoint serves the new bundle | <1 min | +| 5 | (Optional) bump `package-manifest.json.bb_vpn` patch version, then `make build-pkg`. The .pkg's job in a rotation is to carry the new control-plane.json token; the version bump is independent of token rotation and only matters if you also want the rotation to force a `bundle.min_versions` floor advance | ~3 min | +| 6 | Mint a fresh download path (`openssl rand -hex 16`), update nginx `/d/` snippet on every cover-site host, reload nginx, drop the new .pkg in the new path | ~5 min × n hosts | +| 7 | Regenerate per-user install pages with the new `PKG_URL`, host them | ~1 min × n users | +| 8 | Slack DM every user: new install URL, deadline (24-48h), "your current install will stop working after this date" | ~15 min × n users (interactive) | +| 9 | After deadline: any user who hasn't pulled the new .pkg → their installed bb-vpn returns 401 from `/control/bundle.json` → bb-vpn's `runtime_blackhole` circuit breaker eventually kicks → daemons stop. Old token + old path are dead. Sweep stragglers manually. | open-ended | + +Total dev-machine time for a 7-user fleet on 1 cover-site host: +~30-45 min active, plus the user-driven adoption tail (1-3 days). + +--- + +## 6. Verification + +After a fresh build + host: + +1. From a **clean Mac** (or wipe an existing install on a test machine + by running the shipped uninstaller): + ``` + sudo "/Library/Application Support/bb-dpi/bin/bb-vpn-uninstall" + ``` + The uninstaller boots out every daemon + LaunchAgent, removes both + `/Library/Application Support/bb-dpi/` and `/Applications/BBVPN.app`, + and clears the LaunchDaemon plists. On a pristine Mac there's + nothing to wipe — skip to step 2. +2. In a browser, open the per-user install page URL. Click the download + button. +3. In Finder, right-click `BB-VPN-.pkg` → Open. Gatekeeper warning + dialog → "Open" → password prompt → install completes. +4. Watch for the menubar icon. Postinstall bootstraps the BBVPN + LaunchAgent so the app auto-launches; the icon (grey on a pristine + install — not yet enrolled) should appear within a few seconds. + If it doesn't, open `/Applications/BBVPN.app` once via right-click + → Open to clear Gatekeeper's first-launch prompt. +5. In the install page, click the `bb-vpn://enroll?uuid=...` link. + BBVPN.app receives the URL via `LSGetApplicationForURL`, shells out + to `bb-vpn enroll`. Menubar icon turns yellow (first sync in flight) + then green (synced, daemons up). +6. Verify exit: + ``` + curl -fsS https://ifconfig.co/json + ``` + The exit country in the menubar should match the country of the VPN + server. + +If you need to inspect launchd state directly during verification, +remember system-domain bootout requires root: + +``` +sudo launchctl bootout system/com.bb-dpi.bb-vpn-sync +sudo launchctl bootout system/com.sing-box-vpn +sudo launchctl bootout system/com.xray-xhttp +``` + +Subsequent launches don't need right-click → Open. Gatekeeper remembers +the user's first-time approval. + +--- + +## Don'ts + +- **Don't** post the install page URL in any public channel. The URL is + the only barrier between a stranger and the bearer token baked into + the .pkg. +- **Don't** `productbuild --sign` with an arbitrary identity. Without a + Developer ID Installer cert, the result is worse than unsigned (it + positively asserts an unknown signer, which Gatekeeper rejects more + aggressively than no signature at all). +- **Don't** put the .pkg behind Cloudflare or another TLS-terminating + CDN — the cover-site SNI camouflage relies on the cover host serving + its own LE cert; a CDN would serve its own cert and break the + REALITY camouflage at the same time. +- **Don't** reuse a download path across rotations. After token rotation + (§5), retire the old `/d//` location entirely — leaving it + alive lets an attacker who scraped the old URL keep downloading old + .pkgs (which contain the old, still-cached bundle).