Skip to content

v2.0.0-rc.1

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 26 Jul 22:13
9236afe

First release candidate for v2.0.0. Please test on a non-critical server before
you rely on it, and report anything odd.

Fixes you can feel. Three bugs that quietly shipped broken configs to users
are gone. moav user add --package was handing out a zip whose guide still had
26 unfilled {{PLACEHOLDER}} markers, wiping out the correctly rendered one, and
it silently produced no archive at all on hosts without zip. TrustTunnel client
configs were invalid TOML on every IPv6 capable server, so the client refused to
load them. WireGuard and AmneziaWG peers could be handed an IP address that
another user already had, which is why two users sometimes fought over one
tunnel: on one real server this had already produced 26 duplicate WireGuard
addresses and 28 duplicate AmneziaWG ones.

Sturdier user provisioning. moav bootstrap and moav regenerate-users now
share one implementation for turning your user state into configs and bundles,
with one error contract. Previously they diverged, which is why
regenerate-users could heal a server that bootstrap silently aborted on.

Internals. The provisioning code that was duplicated between the host and
container paths (keys, WireGuard and AmneziaWG, the client guide, the sing-box
and xray server mutations, XHTTP and XDNS, TrustTunnel) is now single sourced in
scripts/lib/, and the 9,500 line moav.sh has started splitting into modules.
Bundles ship one guide, README.html, instead of a pile of per protocol
instruction text files.

Testing. The end to end suite now installs its own tooling and refuses to
skip a check when something is missing, which is how several of the bugs above
were found. New gates cover the packaged bundle, the peer IP allocator, and
TrustTunnel config validity.

Internal

  • Provisioning refactor A8 — one shared "materialize every user" path (scripts/lib/provision.sh); completes Workstream A. bootstrap and moav regenerate-users both have to make the server match the user state, and both implemented it separately — with different shell-safety contracts, which is the divergence behind two incidents this cycle. bootstrap.sh sourced lib/sync.sh under set -euo pipefail, so a single user's failed field-parse aborted the whole reconcile silently; moav regenerate-users ran the same code in a docker … -c shell without set -e and therefore survived — which is exactly why "regenerate-users fixes it but bootstrap doesn't" was a real thing operators hit. Both now call provision_all_users [force]: mirror host state (authoritative — the import loops skip existing dirs, so a stale/partial one is otherwise never repaired) → materialize a bundle for every user that has credentials → reconcile into the sing-box/xray configs. Every step is individually guarded, so the function behaves identically whether or not the caller uses set -e: one user's failure never aborts the run, and the reconcile always gets a chance to run (a partial bundle is recoverable; an unreconciled server config means users cannot connect at all). Verified with a harness that runs the real function twice — once under set -euo pipefail, once without — against a seeded state containing a user whose bundle generation fails and a user with no credentials: identical output in both modes, the failing user is reported and skipped, the credential-less user is ignored without error, and the reconcile runs in both cases. moav regenerate-users also gets simpler and faster: it was doing one docker compose run per user (re-passing ~50 -e variables each time) plus a second, separate run for the reconcile; it is now a single run that provisions and reconciles, then reloads the proxies. Bootstrap additionally now heals a user that exists in state but never got a bundle. Net +112/−32 lines (a new 82-line lib against inline duplication).
  • Provisioning refactor A7d — host AmneziaWG peer-add via the shared lib; completes A7 and Workstream A's WG/AWG unification. The host moav user add path still generated the AmneziaWG keypair, allocated the client IP and appended the [Peer] block itself, even though lib/amneziawg.sh's amneziawg_add_peer did exactly that — and had been written for this caller: its trailing extra_used octet arguments exist so the host can pass the octets scraped from a live awg show awg0 allowed-ips, and that parameter had never been wired up. A3b moved the host to the lib's client-config renderer; this moves the peer-add half, so the host now only scrapes the live octets, calls the lib, reads back the allocated values and keeps its own hot-add (awg set) and bundle copy. Two behaviour improvements come with it: the insert is now idempotent (the lib skips a user already present in awg0.conf; the inline block would have appended a duplicate [Peer]), and a keypair already in state is reused instead of silently regenerated. Verified with a harness on a seeded config containing both a revoked-user gap and live-only octets: the allocated address is max(config, live)+1, the amneziawg.env field set/order and the appended [Peer] block are unchanged, and a repeat call adds no second peer. Net −24 lines. Workstream A's WG/AWG duplication is now fully retired — both protocols, both halves (allocation + rendering), on both paths.
  • Provisioning refactor A7c — scripts/lib/trusttunnel.sh (client bundle single-sourced). The host moav user add path and the container bundle generator each carried their own copy of the TrustTunnel client artifacts (trusttunnel.toml + trusttunnel.json, ~47 lines) — the largest remaining un-libified duplicate, and it had drifted in a user-visible way (has_ipv6, see Fixed above). Both now call trusttunnel_write_client_bundle <out> <user> <password>. Container output is otherwise byte-identical (verified against the pre-refactor render; passwords containing & survive, which the removed sed-based paths were fragile about, and the JSON validates). Also removed the dead trusttunnel_add_user/trusttunnel_remove_user pair from lib/sing-box.sh — zero callers repo-wide (leftovers of the same class as the dead singbox_* helpers A4 removed; the real server-side credentials.toml mutation is inline in singbox-user-add.sh on the host and comes from the bootstrap template in the container). Net −43 lines. Third of the four rescoped A7 items.
  • Provisioning refactor A7 rescoped; deleted the dead third user-generator (A7b). scripts/generate-single-user.sh (311 lines) is removed. It was not merely uncalled but unrunnable: nothing invoked it anywhere in the tree, docker-compose.yml mounts only bootstrap.sh / generate-user.sh / scripts/lib into the bootstrap image, and Dockerfile.bootstrap deliberately COPYs no scripts — yet the file sourced /app/lib/*.sh and exec'd /app/generate-user.sh, paths that exist only inside a container where the file itself was absent. It also held the last orphan copies of the server-config mutation and XHTTP client blocks that A4/A6 deferred to A7. The docs that described it as a live path are corrected: docs/devdocs/PROTOCOL-INTEGRATION-CHECKLIST.md had a whole "3c" section prescribing edits to it plus a callout claiming it was the container path that "both paths must handle" — replaced with the real topology (container = generate-user.sh, host = user-add.sh, shared work belongs in scripts/lib/<proto>.sh) — and its pre-A5 sed -i.bak / qr_to_base64 / replace_placeholder recipes now point at lib/bundle_readme.py's placeholder map, since those mechanisms no longer exist anywhere in the tree. A7 itself is rescoped: its premise ("the two entry points differ only in paths + reload strategy") is false — user-add.sh generates credentials, mutates six server configs, reloads services and owns batch/--package/donate under three privilege models, while generate-user.sh only renders a bundle from existing state, never reloads, and is force-idempotent; they share almost nothing directly, carry incompatible set -e contracts, and admin/main.py screen-scrapes user-add.sh's stdout. The split is a real responsibility boundary and is kept deliberately; the rationale and the replacement items (A7a done, A7b done, A7c lib/trusttunnel.sh, A7d host AWG → amneziawg_add_peer) are recorded in docs/devdocs/V2-REFACTOR-PLAN.md.
  • moav.sh decomposition B0 — new top-level lib/common.sh + source scaffolding. First step of retiring the 9,483-line moav.sh monolith (which sourced nothing): the file's own "Helper Functions" block — 17 foundation helpers (check_for_updates, get_latest_version, version_gt, print_header, print_section, info/success/warn/error, prompt, confirm, press_enter, the four service-URL getters, run_command) — moves verbatim into a new top-level lib/ (distinct from scripts/lib/, which holds the protocol generators), and the dispatcher sources it. Modules are sourced, not sub-shelled, so the dispatch case and every command function are untouched; the top-level function count is conserved exactly (182 = 165 + 17, no duplicate or lost definitions). Per the plan's hard constraints, the colors / SCRIPT_DIR / VERSION / state globals and the $0+BASH_SOURCE symlink resolution stay in the entrypoint, set before the source — a lib's own BASH_SOURCE would point at the lib, and moav install symlinks the script into /usr/local/bin, so that path is load-bearing. moav.sh 9,483 → 9,257 lines. Verified with bash -n + shellcheck --severity=error on both files, a symlinked-invocation run (the resolution hazard), and live moav version / moav help dispatch spot-checks rendering lib-provided output; the one non-zero exit found (moav usage, state-dependent) reproduces identically on dev, so it predates this change. See docs/devdocs/V2-REFACTOR-PLAN.md (Workstream B).
  • Provisioning refactor A3b (AmneziaWG) — host renders AWG client configs via the shared lib; completes A3b. The host AmneziaWG block inline in user-add.sh re-read the obfuscation params and server key itself and carried its own copies of the two .conf templates (direct + IPv6). It now calls amneziawg_generate_client_config, matching the WireGuard side: the block already writes amneziawg.env with exactly the fields the lib reads, and since the lib takes its obfuscation params from the awg0.conf header (see the config-path cleanup below) the output is byte-identical. Three supporting fixes: lib/amneziawg.sh is now sourced by user-add.sh; STATE_DIR + the host AWG_CONFIG_DIR/GOOSERELAY_CONFIG_DIR overrides are hoisted above the per-user loop (they were previously set only after the AmneziaWG block, so the lib would have defaulted to the container paths); and the credentials copy now writes to $STATE_DIR/users/<user>/ instead of a hardcoded ./state/..., so the generator reads back the same file. Byte-equivalence verified (pre-refactor inline output vs the lib: both .conf identical) + shellcheck --severity=error; gated by the e2e amneziawg checks. Net −49 lines. Host and container now share one renderer for both WireGuard and AmneziaWG.
  • Provisioning refactor A3b (WireGuard) — host renders WG client configs via the shared lib. The host moav user add path (wg-user-add.sh) carried its own copies of the three WireGuard .conf templates (direct / IPv6 / wstunnel) — byte-identical to lib/wireguard.sh's wireguard_generate_client_config once #169 aligned the host MTU. It now calls that lib function (reading the wireguard.env it already writes + the server.pub it already syncs, honoring SERVER_IPV6/PORT_WIREGUARD), so host and container WireGuard bundles have a single source. Byte-equivalence verified (pre-refactor inline output vs the lib: all three .conf identical) + shellcheck --severity=error; gated by the e2e wireguard/wstunnel checks. Net −54 lines. (The AmneziaWG host block is inline in user-add.sh with a hardcoded ./state and no lib sourcing — folded in a follow-up.)
  • Bundles: removed the last 5 instruction .txt; consumers now read canonical config sources. The follow-up to the 4-file cleanup: the remaining slipstream/masterdns/gooserelay/dnstt/trusttunnel instruction files weren't redundant guides — they were consumed as data (bundle_readme.py embedded three of them into the README, user-package.sh parsed the dnstt pubkey out of one, admin/main.py detected protocols by their presence). Each consumer is now repointed to a canonical artifact, and the .txt are gone: masterdns — the lib writes masterdns-client_config.toml (the client config; the README embeds that and admin detects it); gooserelay — the README embeds the already-generated gooserelay-client_config.json; slipstream — the README section is keyed on slipstream-cert.pem (this also fixes a latent bug where CONFIG_SLIPSTREAM held the instructions text but was wired into the section's style="" display toggle), and a minimal slipstream-client.conf now carries the tunnel domain that client-connect.sh + the connectivity test read; dnstt — the README already rendered from RB_DNSTT_PUBKEY/RB_DNSTT_DOMAIN env, so user-package.sh now reads the pubkey from outputs/dnstt/server.pub (fallback state/keys/dnstt-server.pub.hex), admin detects it server-level (outputs/dnstt/server.pub), and the now-unused dnstt_generate_client_instructions + its two call sites are removed; trusttunnel — the README password comes from RB_USER_PASSWORD (fallback now names trusttunnel.json), and client-connect.sh/admin use .toml/.json. Net −300 lines. Verified with a README render harness (positive: masterdns TOML + gooserelay JSON embedded, dnstt/trusttunnel from env, slipstream section shown, no instructions.txt references, no leftover {{…}}; negative: absent protocols hide their sections without error) + shellcheck --severity=error. Gated by the e2e test_readme_bundle + per-protocol checks.
  • Bundles: README.html is the single client guide — dropped 4 redundant instruction .txt files. Each bundle shipped human-readable setup guides (xhttp.txt, xdns.txt, wireguard-instructions.txt, telegram-proxy-instructions.txt) that duplicated the per-protocol sections already in README.html. They're now removed; the importable data stays in its own files (xhttp-vless.txt, xdns-config.json/xdns-direct-config.json, the WireGuard .confs, telegram-proxy-link.txt). Before deletion, the template was diffed against each guide — the only content not already present was the XDNS MTU-tuning tips, now backfilled into the XDNS section (EN + FA). Two consumers updated: client-connect.sh no longer falls back to xhttp.txt (the link lives in xhttp-vless.txt), and the WG terminal summary drops the stale line. Also, per the same "consistent config paths" cleanup: WireGuard client configs now carry MTU = 1280 on the host moav user add path too (the container/lib path already did — host and container WG bundles now match), and amneziawg_generate_client_config reads the obfuscation params from the awg0.conf [Interface] header (present on both host and container) instead of state/keys/amneziawg.env (container-only) — one config-read path, byte-identical output (the two sources hold identical values, verified against a live server). Scope: only the 4 pure-guide files. Five other instruction .txt (slipstream/masterdns/gooserelay/dnstt/trusttunnel) are consumed as databundle_readme.py embeds them into the README, user-package.sh parses the dnstt pubkey, admin/main.py detects protocols by their presence — so removing those needs a follow-up that first repoints each consumer to the canonical config source. Verified on a live server (removed files absent, kept links/configs present, xdns JSON valid, AWG params match the awg0.conf header, WG/AWG MTU present) + golden tests/singbox-links-test.sh + shellcheck --severity=error. Gated by the e2e xhttp/xdns/wireguard/amneziawg + test_readme_bundle checks.
  • Provisioning refactor A6 — XHTTP/XDNS client-bundle generation into lib/xray.sh. The host moav user add path (singbox-user-add.sh) and the container bundle generator (generate-user.sh) each carried a near-identical ~195-line copy of the XHTTP + XDNS client-config generation (share link, QR, the xhttp.txt/xdns.txt guides, the two xdns-*.json configs, and the embedded Python that builds the FinalMask resolvers). The XHTTP copies were byte-identical; the XDNS copies had drifted — the host block lagged the container's XDNS_METHOD (txt/aaaa) support and uuid.env fallback, and emitted differently-formatted JSON plus three redundant Telegram lines in xdns.txt. Both now call xray_xhttp_link / xray_write_xhttp_bundle / xray_write_xdns_bundle (+ the xray_xdns_finalmask Python helper) in lib/xray.sh, mirroring the lib/sing-box.sh share-link builder pattern. The container form is canonical, so its output is byte-for-byte unchanged and the host bundle is reconciled up to it (gaining XDNS_METHOD + the uuid fallback, losing the redundant lines) — the host↔container de-drift. generate-user.sh now sources lib/xray.sh; the dead XHTTP copy in the orphan generate-single-user.sh is left for A7. Net −194 lines. Verified with a byte-equivalence harness (the pre-refactor container code vs the new lib functions, identical env: all five emitted files — xhttp-vless.txt, xhttp.txt, xdns-config.json, xdns-direct-config.json, xdns.txtbyte-identical, JSON valid) plus the golden tests/singbox-links-test.sh and shellcheck --severity=error. Gated by the e2e xhttp/xdns + test_readme_bundle checks. Stacks on A4 (shares lib/xray.sh). See docs/devdocs/V2-REFACTOR-PLAN.md.
  • Provisioning refactor A4 — one canonical sing-box/xray server-config mutation (singbox_add_user + new lib/xray.sh). Inserting a user's inbound entries into configs/sing-box/config.json / configs/xray/config.json existed in five divergent copies — three different jq idioms, three dedup strategies, and three xray-field behaviours — of which two were already dead code (lib/sing-box.sh's singbox_add_user/singbox_remove_user/singbox_reload, which also missed AnyTLS + Shadowsocks and had no dedup; and generate-single-user.sh's inline copy). The only correct copy was sync_server_users in lib/sync.sh: per-inbound-independent idempotency (UUID inbounds dedup by uuid, password inbounds by name — the fix for the SS "invalid request" orphan bug) and a field-adaptive xray insert (has("clients")clients else users, since Xray v26.5.9 renamed the field). That logic is now the canonical singbox_add_user <config> <user> <uuid> <pass> <ss_psk> in lib/sing-box.sh and xray_add_user <config> <uuid> <user> in a new lib/xray.sh; both sync_server_users and the host singbox-user-add.sh call them (the host previously always wrote xray settings.users, silently wrong on a legacy clients-field config — now adaptive). The dead lib helpers are removed; generate-single-user.sh's orphan copy is left for A7, which owns collapsing that file. xray.sh is sourced by bootstrap.sh, the host add path, and the moav regenerate-users reconcile shell. Net −80 lines (−168/+88). Verified with the golden tests/singbox-links-test.sh (link-gen half untouched) and an isolation harness running the real canonical reconcile against copies of a live 146-user server's sing-box + xray configs: the canonical produced zero changes against the config the old code built (byte-identical output across every user × inbound), a drop-and-re-add restored the exact inbound membership including the Shadowsocks path, and a second reconcile pass was a no-op (convergent). Gated by the e2e per-protocol reachability checks + tests/assert-users-reconciled.sh. See docs/devdocs/V2-REFACTOR-PLAN.md.
  • Provisioning refactor A3 — one collision-safe WireGuard/AmneziaWG peer-IP allocator (net_next_free_octet). Peer-IP assignment was implemented four times: the two host paths (wg-user-add.sh; the AmneziaWG block inline in user-add.sh) scanned the config and the running interface for the highest octet and used max+1, while the two lib/{wireguard,amneziawg}.sh copies used the peer's [Peer]-block count+1. The count scheme reused revoked users' addresses: any gap in the peer list handed a still-live IP to a new user. On a real 147-peer server this had already produced 26 duplicate WireGuard octets and 28 duplicate AmneziaWG octets — up to three users sharing one tunnel IP. All four sites now call a single net_next_free_octet <config> <prefix> [live_octet …] in lib/common.sh, which merges the config scan with any live-interface octets and returns max+1 (or fails when the /24 is exhausted). wireguard_add_peer/amneziawg_add_peer drop their peer_num argument; generate-user.sh no longer computes a peer count; the host loops collapse to the same call with byte-identical output. Scope: allocation only — the per-path client-config rendering still differs in bundle contents (MTU line, instructions.txt/QR, and the AmneziaWG obfuscation-param source: host reads the awg0.conf header, the lib reads state/keys/amneziawg.env), so folding those bodies is a follow-up. Net +150/−44. Verified with a new tests/net-alloc-test.sh unit (9 cases in ci.yml: empty/contiguous/gap/dual-stack/live-merge/AWG-prefix/garbage/exhaustion) plus an isolation harness that ran the real lib functions against copies of a live server's wg0.conf/awg0.conf (fresh add = collision-free max+1; the gap case where the old scheme collided on an occupied .5 now yields .6; idempotent re-add reuses the stored IP), and confirmed the host old-loop and new function pick the identical octet on live data. Gated by the e2e wireguard/wstunnel/amneziawg checks. See docs/devdocs/V2-REFACTOR-PLAN.md.
  • Provisioning refactor A2 — host DNS-family + Telegram instructions via the shared libs. The host user-add.sh carried its own ~250-line copies of the dnstt / Slipstream / MasterDNS / GooseRelay / telemt client-instruction text (each literally commented "keep in sync with lib/*"), reading keys from outputs/<proto>/* while the container path already used the canonical $STATE_DIR/keys/*. The host now sources and calls the same lib/<proto>.sh *_generate_client_instructions the container uses, so host and container bundles can no longer drift; keys come from $STATE_DIR/keys/* and GooseRelay's config dir is pointed at the host configs/gooserelay. Guards emit a file only when the protocol is enabled and its key/config is present, and each call is set -e-safe. Net −219 lines. Verified with a host-context render harness (dnstt/slipstream/masterdns/telemt: files present, keys + server IP rendered, no leftover KEY_NOT_GENERATED/{{}}) plus the e2e dnstt/slipstream/masterdns/telemt + test_readme_bundle gates. Second step of the v2 provisioning unification — see docs/devdocs/V2-REFACTOR-PLAN.md.
  • Provisioning refactor A5 — single-source bundle README + subscription (scripts/lib/bundle-readme.sh + bundle_readme.py). The ~290-line block that renders each user's client-guide README.html from the template and writes subscription.txt was duplicated in user-add.sh (host) and generate-user.sh (container) and had drifted — the two used different sed flavors (-i vs -i.bak), different variable names, and diverging fallbacks. Both are now thin callers of one render_bundle_readme, which does all substitution in a single Python pass: no sed (so no BSD/GNU split), and multiline configs + passwords with shell-special chars (|, &) are handled natively instead of silently corrupting the guide. subscription.txt is now written unconditionally (empty when the bundle has no V2Ray-compatible links), fixing the coupling where a bundle could lack the file. Net −576 lines across the two scripts. Verified with a render harness (full/minimal/no-proxy bundles, host + container contexts: no leftover {{}}, correct fallbacks, special-char passwords intact, subscription always present) and the e2e test_readme_bundle gate. See docs/devdocs/V2-REFACTOR-PLAN.md.
  • e2e: 3 named tiers + fix a stale smoke-test path. The e2e workflow_dispatch now takes a single tier choice — default (domain only), full (domain + domainless), mega (full + build --local + image removal) — replacing the old full/domainless booleans; a domainless_only toggle keeps the Let's-Encrypt-free path. Also fixed tests/cli-smoke-test.sh, which still ran bash site/install.sh after the installer moved to the repo root — the only failure in the post-cutover e2e (unrelated to bundle generation). (A future tier will drive a real moav-client connection test.)
  • Provisioning refactor A1 — single-source key generation (scripts/lib/keys.sh). The five WireGuard/AmneziaWG client-keypair generators (user-add.sh, wg-user-add.sh, generate-single-user.sh, and the two lib/{wireguard,amneziawg}.sh copies) each carried their own tool-selection + CRLF handling — four were patched for the CRLF-key bug, one lib copy was not. They now all call one CRLF-safe wg_keypair() (prefers a local wg/awg, else a running wireguard/amneziawg container bounded by timeout -k, else a throwaway image). No behaviour change — WireGuard/AmneziaWG keys are interchangeable standard Curve25519 keys; verified by the e2e wireguard/amneziawg checks + the share-link golden test. First step of the v2 provisioning unification — see docs/devdocs/V2-REFACTOR-PLAN.md.
  • Telegram release notifier (.github/workflows/telegram-notify.yml) — auto-posts to the MoaV Telegram channel when a GitHub Release is published (title + notes excerpt + release/site links), and when an issue is labeled announce (title + link — not every issue). Manual workflow_dispatch sends a plain test message, or — with a release_tag input — fires a real notification for any past release (fetched via gh api), so the formatting can be tested against a known release. A small curl to the Telegram Bot API (no third-party action in the release path); message built + HTML-escaped by .github/scripts/telegram_format.py. Needs repo secrets TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID; sends are skipped cleanly when unset. Release notes render in a monospace code box, and posts are product-labeled (PRODUCT env → MoaV / moav-client) so server and client releases are distinct in the shared channel.

Changed

  • No-IPv6 servers: proxied traffic now prefers IPv4, silencing "network is unreachable" spam. On a host without IPv6, clients still hand sing-box AAAA targets (happy-eyeballs), so the direct outbound tried to dial IPv6 addresses it can't route — a burst of network is unreachable errors and a wasted dial per connection before the client fell back to IPv4. When SERVER_IPV6 is empty, bootstrap now injects a resolve route rule (strategy: prefer_ipv4) so sniffed domains re-resolve to IPv4 first. Only when there's no server IPv6 — dual-stack servers are untouched — and prefer_ipv4 still falls back to IPv6, so it can never strand a destination. Validated with sing-box check on a fully-rendered config.

  • User docs + website moved to moav-site — the end-user documentation (docs/*.md), the marketing site (site/), mkdocs.yml, and the deploy-site workflow now live in the moav-site repo (served at https://moav.sh/docs/); they're removed from the server repo to end the multi-surface doc drift. README/CONTRIBUTING links and the moav/bootstrap doc hints now point at moav.sh/docs/…. The contributor/agent-facing dev docs stay in-repo under docs/devdocs/. Runtime files that lived under the moved trees were relocated, not deleted: the client-bundle template docs/client-guide-template.htmltemplates/, the Grafana/admin branding assets site/assets/{favicon,logo}branding/ (bundle generation and dashboard favicons are unchanged), and the installer site/install.sh → repo root install.sh (its natural source-of-truth home — it clones and versions this repo). The release workflow now attaches install.sh as a release asset, and moav-site publishes it at moav.sh/install.sh by fetching the latest release at deploy time, so the curl … | bash one-liner downloads from the vanity domain (never redirected to a GitHub host that may be blocked in censored networks). The old cloud-init.sh one-click deploy path was dropped — it duplicated install.sh's prereq/Docker/clone logic (and had already drifted: no low-RAM swapfile guard) while never doing the real config, which always required an interactive SSH session anyway; the deploy-provider flow is now just "create the VPS, SSH in, run the install one-liner." The protocol-roster gate (gen-protocol-docs.py) now drift-checks the server README only; the generated overview table lives on the site.

Fixed

  • TrustTunnel client configs were unparseable on IPv6-capable servers. trusttunnel.toml set has_ipv6 from ${SERVER_IPV6:+true}${SERVER_IPV6:-false}, which reads like a ternary but is not one: when SERVER_IPV6 is set, :+ yields true and :- yields the address, so the file contained has_ipv6 = true2001:db8::1 — invalid TOML that the client rejects outright (TOMLDecodeError), meaning TrustTunnel simply could not be configured from a MoaV bundle on any dual-stack server. It rendered correctly (false) only on IPv4-only hosts, which is why it went unnoticed — including by the e2e, whose TrustTunnel check greps individual fields with sed/grep and therefore "succeeds" on a syntactically broken file, on a runner that has no IPv6. Now computed as a real boolean. The host path had the mirror-image bug: it hardcoded has_ipv6 = false, so a user provisioned by moav user add never got IPv6 even on a dual-stack server. Both paths now share one renderer (see A7c below), and the e2e TrustTunnel check gained a real TOML parse (via tomllib) plus a bare-boolean assertion over has_ipv6/killswitch_enabled/post_quantum_group_enabled/skip_verification/anti_dpi, so this class fails the suite instead of shipping (verified: the guard rejects the old malformed value and accepts the fixed one).
  • moav user add --package shipped users a broken client guide. The zip's README.html went out with ~26 raw {{PLACEHOLDER}} markers — including the entire Shadowsocks, XHTTP, AmneziaWG, Telegram-MTProxy, XDNS, CDN, MasterDNS, GooseRelay and Slipstream config blocks — so anyone handed a packaged bundle saw literal {{CONFIG_SHADOWSOCKS}} where their connection details belonged. Cause: scripts/user-package.sh still carried a pre-A5 sed -i.bak/awk renderer of its own that substituted only 19 of the template's 45 placeholders, and it overwrote the correct guide — its file-copy loop (*.txt *.conf *.yaml *.json) never copied the README.html that render_bundle_readme had already rendered correctly into the bundle, then it wrote its own broken render to the same path. This is the exact bug class the A5 single-source README refactor retired; it survived because A5 unified the two provisioning entry points and this third renderer sat behind --package. The packager now copies the bundle's finished artifacts and renders nothing (and fails loudly if README.html is missing rather than shipping a guide-less zip). As a bonus the zip now also carries the QR images and the .pem/.toml/.gs files, which the old .txt/.conf/.yaml/.json filter silently dropped. Net −273 lines, including the last sed -i.bak render path and qr_to_base64 copy in the tree. The --package path had no test coverage at all — which is why this shipped — so tests/cli-smoke-test.sh now runs moav user add --package and asserts the zip's guide contains zero unsubstituted placeholders (verified to pass on a good zip and fail on a placeholder-bearing one). Before/after measured on a rendered bundle: 26 placeholders + guide overwritten → 0 placeholders + guide byte-identical to the bundle's.
  • --package silently produced no archive on hosts without zip. Found by the new smoke assertion above, on the first run: zip is not an install prerequisite (check_prerequisites covers docker/qrencode, not zip) and scripts/user-package.sh never checked for it — while moav user package and moav user base64 both do. Because user-add.sh invokes the packager inside an if, the non-zero exit was absorbed and moav user add --package reported success for a user who got no zip; the success line even named a file (<user>.zip) that this script never creates (it writes <user>-configs.zip). The packager now checks zip up front and fails with install instructions per platform, the success message names the real path, and the failure message notes the user itself was still created. (moav user add --package deliberately still exits 0 when only the archive fails — the account and its bundle are complete; the missing extra is reported loudly rather than fatally.) The e2e preflight now installs the tooling the suite itself needs (zip, unzip, qrencode) and hard-fails if it cannot, so a missing tool can never become a silently skipped test; the packaged-guide assertion therefore never skips, and when the archive is absent it re-runs the packager inline to surface the reason (the --package call sits inside an if, so its failure is otherwise invisible).
  • Bootstrap no longer aborts mid-reconcile on servers with SS-less users. sync_server_users is sourced into bootstrap.sh, which runs under set -euo pipefail. Its per-user parse ss=$(sed … shadowsocks.env …) exited non-zero for any user without a shadowsocks.env (common on older/large deployments) — pipefail turned that into a fatal set -e abort, and the 2>/dev/null hid it, so bootstrap failed with no real error right after "sing-box configuration written" (reproduced on a 145-user server). moav regenerate-users was unaffected because it runs the reconcile in a shell without set -e — which is why it kept working. Every per-user field parse is now guarded (|| true / file-exists check), so a missing file or empty field is a skip, never fatal.
  • Bootstrap reconcile now heals a stale user-state volume — completes the 1.9.1 fix. The 1.9.1 reconcile read the Docker state volume (/state), but moav user add writes to the host state dir (/host-state); bootstrap's import loop skips volume dirs that already exist, so a stale/partial dir (e.g. an aborted run's user missing credentials.env) was never repaired and the reconcile skipped that user — leaving moav update/moav bootstrap still emitting unknown UUID for them even on 1.9.1. Bootstrap now force-mirrors host state → volume immediately before the reconcile (as moav regenerate-users already did), so host state is authoritative and every user is re-inserted. (moav regenerate-users remains the manual heal for an already-running server.)
  • Reconcile now repairs every protocol inbound independently, not just the UUID ones. sync_server_users gated all sing-box inserts on grep -q "$uuid" — but Shadowsocks/Trojan/AnyTLS/Hysteria2 entries carry only a password, not the UUID. So once Reality re-added a user's UUID, that guard treated the user as fully present and skipped repairing the password inbounds — leaving them in Reality but missing from Shadowsocks (client saw shadowsocks: invalid request), and likewise Trojan/AnyTLS/Hysteria2. Each insert is now independently idempotent (UUID inbounds dedup by uuid, password inbounds by name), and the xray insert is per-inbound idempotent too. The e2e suite now force-re-bootstraps after moav user add and asserts every user is present in every enabled inbound (tests/assert-users-reconciled.sh).

Quick Install

curl -fsSL moav.sh/install.sh | bash

This will install MoaV to /opt/moav and guide you through setup.

Documentation

moav.sh/docs — Full documentation