v2.0.0-rc.1
Pre-releaseFirst 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.bootstrapandmoav regenerate-usersboth 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.shsourcedlib/sync.shunderset -euo pipefail, so a single user's failed field-parse aborted the whole reconcile silently;moav regenerate-usersran the same code in adocker … -cshell withoutset -eand therefore survived — which is exactly why "regenerate-users fixes it but bootstrap doesn't" was a real thing operators hit. Both now callprovision_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 usesset -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 underset -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-usersalso gets simpler and faster: it was doing onedocker compose runper user (re-passing ~50-evariables 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 addpath still generated the AmneziaWG keypair, allocated the client IP and appended the[Peer]block itself, even thoughlib/amneziawg.sh'samneziawg_add_peerdid exactly that — and had been written for this caller: its trailingextra_usedoctet arguments exist so the host can pass the octets scraped from a liveawg 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 inawg0.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 ismax(config, live)+1, theamneziawg.envfield 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 hostmoav user addpath 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 calltrusttunnel_write_client_bundle <out> <user> <password>. Container output is otherwise byte-identical (verified against the pre-refactor render; passwords containing&survive, which the removedsed-based paths were fragile about, and the JSON validates). Also removed the deadtrusttunnel_add_user/trusttunnel_remove_userpair fromlib/sing-box.sh— zero callers repo-wide (leftovers of the same class as the deadsingbox_*helpers A4 removed; the real server-sidecredentials.tomlmutation is inline insingbox-user-add.shon 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.ymlmounts onlybootstrap.sh/generate-user.sh/scripts/libinto the bootstrap image, andDockerfile.bootstrapdeliberatelyCOPYs no scripts — yet the file sourced/app/lib/*.shand 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.mdhad 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 inscripts/lib/<proto>.sh) — and its pre-A5sed -i.bak/qr_to_base64/replace_placeholderrecipes now point atlib/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.shgenerates credentials, mutates six server configs, reloads services and owns batch/--package/donate under three privilege models, whilegenerate-user.shonly renders a bundle from existing state, never reloads, and is force-idempotent; they share almost nothing directly, carry incompatibleset -econtracts, andadmin/main.pyscreen-scrapesuser-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, A7clib/trusttunnel.sh, A7d host AWG →amneziawg_add_peer) are recorded indocs/devdocs/V2-REFACTOR-PLAN.md. moav.shdecomposition B0 — new top-levellib/common.sh+ source scaffolding. First step of retiring the 9,483-linemoav.shmonolith (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-levellib/(distinct fromscripts/lib/, which holds the protocol generators), and the dispatcher sources it. Modules are sourced, not sub-shelled, so the dispatchcaseand 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_SOURCEsymlink resolution stay in the entrypoint, set before the source — a lib's ownBASH_SOURCEwould point at the lib, andmoav installsymlinks the script into/usr/local/bin, so that path is load-bearing.moav.sh9,483 → 9,257 lines. Verified withbash -n+shellcheck --severity=erroron both files, a symlinked-invocation run (the resolution hazard), and livemoav version/moav helpdispatch spot-checks rendering lib-provided output; the one non-zero exit found (moav usage, state-dependent) reproduces identically ondev, so it predates this change. Seedocs/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.shre-read the obfuscation params and server key itself and carried its own copies of the two.conftemplates (direct + IPv6). It now callsamneziawg_generate_client_config, matching the WireGuard side: the block already writesamneziawg.envwith exactly the fields the lib reads, and since the lib takes its obfuscation params from theawg0.confheader (see the config-path cleanup below) the output is byte-identical. Three supporting fixes:lib/amneziawg.shis now sourced byuser-add.sh;STATE_DIR+ the hostAWG_CONFIG_DIR/GOOSERELAY_CONFIG_DIRoverrides 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.confidentical) +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 addpath (wg-user-add.sh) carried its own copies of the three WireGuard.conftemplates (direct / IPv6 / wstunnel) — byte-identical tolib/wireguard.sh'swireguard_generate_client_configonce #169 aligned the hostMTU. It now calls that lib function (reading thewireguard.envit already writes + theserver.pubit already syncs, honoringSERVER_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.confidentical) +shellcheck --severity=error; gated by the e2e wireguard/wstunnel checks. Net −54 lines. (The AmneziaWG host block is inline inuser-add.shwith a hardcoded./stateand 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 remainingslipstream/masterdns/gooserelay/dnstt/trusttunnelinstruction files weren't redundant guides — they were consumed as data (bundle_readme.pyembedded three of them into the README,user-package.shparsed the dnstt pubkey out of one,admin/main.pydetected protocols by their presence). Each consumer is now repointed to a canonical artifact, and the.txtare gone: masterdns — the lib writesmasterdns-client_config.toml(the client config; the README embeds that andadmindetects it); gooserelay — the README embeds the already-generatedgooserelay-client_config.json; slipstream — the README section is keyed onslipstream-cert.pem(this also fixes a latent bug whereCONFIG_SLIPSTREAMheld the instructions text but was wired into the section'sstyle=""display toggle), and a minimalslipstream-client.confnow carries the tunnel domain thatclient-connect.sh+ the connectivity test read; dnstt — the README already rendered fromRB_DNSTT_PUBKEY/RB_DNSTT_DOMAINenv, souser-package.shnow reads the pubkey fromoutputs/dnstt/server.pub(fallbackstate/keys/dnstt-server.pub.hex),admindetects it server-level (outputs/dnstt/server.pub), and the now-unuseddnstt_generate_client_instructions+ its two call sites are removed; trusttunnel — the README password comes fromRB_USER_PASSWORD(fallback now namestrusttunnel.json), andclient-connect.sh/adminuse.toml/.json. Net −300 lines. Verified with a README render harness (positive: masterdns TOML + gooserelay JSON embedded, dnstt/trusttunnel from env, slipstream section shown, noinstructions.txtreferences, no leftover{{…}}; negative: absent protocols hide their sections without error) +shellcheck --severity=error. Gated by the e2etest_readme_bundle+ per-protocol checks. - Bundles: README.html is the single client guide — dropped 4 redundant instruction
.txtfiles. 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 inREADME.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.shno longer falls back toxhttp.txt(the link lives inxhttp-vless.txt), and the WG terminal summary drops the stale line. Also, per the same "consistent config paths" cleanup: WireGuard client configs now carryMTU = 1280on the hostmoav user addpath too (the container/lib path already did — host and container WG bundles now match), andamneziawg_generate_client_configreads the obfuscation params from theawg0.conf[Interface]header (present on both host and container) instead ofstate/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 data —bundle_readme.pyembeds them into the README,user-package.shparses the dnstt pubkey,admin/main.pydetects 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,xdnsJSON valid, AWG params match theawg0.confheader, WG/AWG MTU present) + goldentests/singbox-links-test.sh+shellcheck --severity=error. Gated by the e2e xhttp/xdns/wireguard/amneziawg +test_readme_bundlechecks. - Provisioning refactor A6 — XHTTP/XDNS client-bundle generation into
lib/xray.sh. The hostmoav user addpath (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, thexhttp.txt/xdns.txtguides, the twoxdns-*.jsonconfigs, and the embedded Python that builds the FinalMaskresolvers). The XHTTP copies were byte-identical; the XDNS copies had drifted — the host block lagged the container'sXDNS_METHOD(txt/aaaa) support anduuid.envfallback, and emitted differently-formatted JSON plus three redundant Telegram lines inxdns.txt. Both now callxray_xhttp_link/xray_write_xhttp_bundle/xray_write_xdns_bundle(+ thexray_xdns_finalmaskPython helper) inlib/xray.sh, mirroring thelib/sing-box.shshare-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 (gainingXDNS_METHOD+ the uuid fallback, losing the redundant lines) — the host↔container de-drift.generate-user.shnow sourceslib/xray.sh; the dead XHTTP copy in the orphangenerate-single-user.shis 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.txt— byte-identical, JSON valid) plus the goldentests/singbox-links-test.shandshellcheck --severity=error. Gated by the e2e xhttp/xdns +test_readme_bundlechecks. Stacks on A4 (shareslib/xray.sh). Seedocs/devdocs/V2-REFACTOR-PLAN.md. - Provisioning refactor A4 — one canonical sing-box/xray server-config mutation (
singbox_add_user+ newlib/xray.sh). Inserting a user's inbound entries intoconfigs/sing-box/config.json/configs/xray/config.jsonexisted in five divergent copies — three differentjqidioms, three dedup strategies, and three xray-field behaviours — of which two were already dead code (lib/sing-box.sh'ssingbox_add_user/singbox_remove_user/singbox_reload, which also missed AnyTLS + Shadowsocks and had no dedup; andgenerate-single-user.sh's inline copy). The only correct copy wassync_server_usersinlib/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")→clientselseusers, since Xray v26.5.9 renamed the field). That logic is now the canonicalsingbox_add_user <config> <user> <uuid> <pass> <ss_psk>inlib/sing-box.shandxray_add_user <config> <uuid> <user>in a newlib/xray.sh; bothsync_server_usersand the hostsingbox-user-add.shcall them (the host previously always wrote xraysettings.users, silently wrong on a legacyclients-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.shis sourced bybootstrap.sh, the host add path, and themoav regenerate-usersreconcile shell. Net −80 lines (−168/+88). Verified with the goldentests/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. Seedocs/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 inuser-add.sh) scanned the config and the running interface for the highest octet and used max+1, while the twolib/{wireguard,amneziawg}.shcopies 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 singlenet_next_free_octet <config> <prefix> [live_octet …]inlib/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_peerdrop theirpeer_numargument;generate-user.shno 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 theawg0.confheader, the lib readsstate/keys/amneziawg.env), so folding those bodies is a follow-up. Net +150/−44. Verified with a newtests/net-alloc-test.shunit (9 cases inci.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'swg0.conf/awg0.conf(fresh add = collision-free max+1; the gap case where the old scheme collided on an occupied.5now 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. Seedocs/devdocs/V2-REFACTOR-PLAN.md. - Provisioning refactor A2 — host DNS-family + Telegram instructions via the shared libs. The host
user-add.shcarried 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 fromoutputs/<proto>/*while the container path already used the canonical$STATE_DIR/keys/*. The host now sources and calls the samelib/<proto>.sh*_generate_client_instructionsthe 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 hostconfigs/gooserelay. Guards emit a file only when the protocol is enabled and its key/config is present, and each call isset -e-safe. Net −219 lines. Verified with a host-context render harness (dnstt/slipstream/masterdns/telemt: files present, keys + server IP rendered, no leftoverKEY_NOT_GENERATED/{{}}) plus the e2e dnstt/slipstream/masterdns/telemt +test_readme_bundlegates. Second step of the v2 provisioning unification — seedocs/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-guideREADME.htmlfrom the template and writessubscription.txtwas duplicated inuser-add.sh(host) andgenerate-user.sh(container) and had drifted — the two used differentsedflavors (-ivs-i.bak), different variable names, and diverging fallbacks. Both are now thin callers of onerender_bundle_readme, which does all substitution in a single Python pass: nosed(so no BSD/GNU split), and multiline configs + passwords with shell-special chars (|,&) are handled natively instead of silently corrupting the guide.subscription.txtis 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 e2etest_readme_bundlegate. Seedocs/devdocs/V2-REFACTOR-PLAN.md. - e2e: 3 named tiers + fix a stale smoke-test path. The e2e
workflow_dispatchnow takes a singletierchoice —default(domain only),full(domain + domainless),mega(full +build --local+ image removal) — replacing the oldfull/domainlessbooleans; adomainless_onlytoggle keeps the Let's-Encrypt-free path. Also fixedtests/cli-smoke-test.sh, which still ranbash site/install.shafter 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 twolib/{wireguard,amneziawg}.shcopies) 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-safewg_keypair()(prefers a localwg/awg, else a runningwireguard/amneziawgcontainer bounded bytimeout -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 — seedocs/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 labeledannounce(title + link — not every issue). Manualworkflow_dispatchsends a plain test message, or — with arelease_taginput — fires a real notification for any past release (fetched viagh api), so the formatting can be tested against a known release. A smallcurlto the Telegram Bot API (no third-party action in the release path); message built + HTML-escaped by.github/scripts/telegram_format.py. Needs repo secretsTELEGRAM_BOT_TOKEN+TELEGRAM_CHAT_ID; sends are skipped cleanly when unset. Release notes render in a monospace code box, and posts are product-labeled (PRODUCTenv →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
directoutbound tried to dial IPv6 addresses it can't route — a burst ofnetwork is unreachableerrors and a wasted dial per connection before the client fell back to IPv4. WhenSERVER_IPV6is empty, bootstrap now injects aresolveroute rule (strategy: prefer_ipv4) so sniffed domains re-resolve to IPv4 first. Only when there's no server IPv6 — dual-stack servers are untouched — andprefer_ipv4still falls back to IPv6, so it can never strand a destination. Validated withsing-box checkon a fully-rendered config. -
User docs + website moved to
moav-site— the end-user documentation (docs/*.md), the marketing site (site/),mkdocs.yml, and thedeploy-siteworkflow now live in themoav-siterepo (served athttps://moav.sh/docs/); they're removed from the server repo to end the multi-surface doc drift. README/CONTRIBUTING links and themoav/bootstrapdoc hints now point atmoav.sh/docs/…. The contributor/agent-facing dev docs stay in-repo underdocs/devdocs/. Runtime files that lived under the moved trees were relocated, not deleted: the client-bundle templatedocs/client-guide-template.html→templates/, the Grafana/admin branding assetssite/assets/{favicon,logo}→branding/(bundle generation and dashboard favicons are unchanged), and the installersite/install.sh→ repo rootinstall.sh(its natural source-of-truth home — it clones and versions this repo). The release workflow now attachesinstall.shas a release asset, and moav-site publishes it atmoav.sh/install.shby fetching the latest release at deploy time, so thecurl … | bashone-liner downloads from the vanity domain (never redirected to a GitHub host that may be blocked in censored networks). The oldcloud-init.shone-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.tomlsethas_ipv6from${SERVER_IPV6:+true}${SERVER_IPV6:-false}, which reads like a ternary but is not one: whenSERVER_IPV6is set,:+yieldstrueand:-yields the address, so the file containedhas_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 withsed/grepand 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 hardcodedhas_ipv6 = false, so a user provisioned bymoav user addnever 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 (viatomllib) plus a bare-boolean assertion overhas_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 --packageshipped users a broken client guide. The zip'sREADME.htmlwent 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.shstill carried a pre-A5sed -i.bak/awkrenderer 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 theREADME.htmlthatrender_bundle_readmehad 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 ifREADME.htmlis missing rather than shipping a guide-less zip). As a bonus the zip now also carries the QR images and the.pem/.toml/.gsfiles, which the old.txt/.conf/.yaml/.jsonfilter silently dropped. Net −273 lines, including the lastsed -i.bakrender path andqr_to_base64copy in the tree. The--packagepath had no test coverage at all — which is why this shipped — sotests/cli-smoke-test.shnow runsmoav user add --packageand 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.--packagesilently produced no archive on hosts withoutzip. Found by the new smoke assertion above, on the first run:zipis not an install prerequisite (check_prerequisitescovers docker/qrencode, not zip) andscripts/user-package.shnever checked for it — whilemoav user packageandmoav user base64both do. Becauseuser-add.shinvokes the packager inside anif, the non-zero exit was absorbed andmoav user add --packagereported 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 checkszipup 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 --packagedeliberately 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--packagecall sits inside anif, so its failure is otherwise invisible).- Bootstrap no longer aborts mid-reconcile on servers with SS-less users.
sync_server_usersis sourced intobootstrap.sh, which runs underset -euo pipefail. Its per-user parsess=$(sed … shadowsocks.env …)exited non-zero for any user without ashadowsocks.env(common on older/large deployments) —pipefailturned that into a fatalset -eabort, and the2>/dev/nullhid it, so bootstrap failed with no real error right after "sing-box configuration written" (reproduced on a 145-user server).moav regenerate-userswas unaffected because it runs the reconcile in a shell withoutset -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), butmoav user addwrites 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 missingcredentials.env) was never repaired and the reconcile skipped that user — leavingmoav update/moav bootstrapstill emittingunknown UUIDfor them even on 1.9.1. Bootstrap now force-mirrors host state → volume immediately before the reconcile (asmoav regenerate-usersalready did), so host state is authoritative and every user is re-inserted. (moav regenerate-usersremains the manual heal for an already-running server.) - Reconcile now repairs every protocol inbound independently, not just the UUID ones.
sync_server_usersgated all sing-box inserts ongrep -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 sawshadowsocks: 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 aftermoav user addand asserts every user is present in every enabled inbound (tests/assert-users-reconciled.sh).
Quick Install
curl -fsSL moav.sh/install.sh | bashThis will install MoaV to /opt/moav and guide you through setup.
Documentation
moav.sh/docs — Full documentation
- Setup Guide — Complete installation instructions
- Client Setup — How to connect from devices
- DNS Configuration — DNS records setup
- CLI Reference — All commands and options
- Monitoring — Grafana dashboards and metrics
- Troubleshooting — Common issues and solutions