⚠️ EXPERIMENTAL. Do not run this on a production server.
This is a release candidate published for testing. It has not been run on a long-lived server, and the upgrade path from 1.9.x is untested.
If people depend on your server working, stay on v1.9.1. That is what
moav.shand the install script still serve by default, and this release does not change that.Use this on a throwaway VPS, and tell us what breaks.
What this release is
rc.2 is the security and correctness pass on top of rc.1: roughly 60 commits, no new protocols, no new features. It is the result of going looking for what was quietly broken, and building the test infrastructure that found most of it.
The theme is silent failure. Several of these bugs produced no error at all:
- The DNS tunnel had not been genuinely tested in months. Its test looked for a bundle file that a refactor had deleted, and a skipped test is not a failed one, so the suite stayed green.
- The connectivity suite could report success while testing nothing. An empty user bundle made every protocol skip, and skips did not count against the result.
- A dead WireGuard tunnel and a healthy one were indistinguishable to the container checks, because nothing asserted that a container was still running.
- An AnyTLS test existed and had never once executed in CI.
Those gaps are the main reason this candidate exists. Finding them changed what a green run is allowed to mean: it now has to prove that specific protocols actually passed.
Security
Two findings are worth understanding rather than just noting.
docker-proxy was reachable from every container. It is an unauthenticated Docker API endpoint. The socket proxy filters by URL path and HTTP method, never by request body, so anything able to reach it could create a privileged container with the host filesystem mounted. It sat on one flat network shared by all 29 containers, including every service that terminates untrusted traffic from the internet: sing-box, xray, wireguard, wstunnel, telemt, snowflake, trusttunnel. A memory-safety bug in any protocol server was therefore a path to host root. The proxy now lives on an internal network with only the admin dashboard, and no data-plane traffic changed: the wstunnel to WireGuard and DNS-tunnel to sing-box chains are untouched.
The admin panel could serve plain HTTP. If Let's Encrypt had not issued yet, it printed a warning and started anyway, sending the dashboard password (the same secret Grafana uses) in cleartext on a port published to the internet. This was not an edge case. The self-signed fallback was only generated in domainless mode, so every fresh install with a domain spent its first minutes there, and any install with a DNS, port-80 or rate-limit problem stayed there indefinitely. The panel now always has a certificate and refuses to start without TLS, while still preferring Let's Encrypt when it arrives.
Also fixed:
- Server private key material was world-readable at
0644, includingreality.envwith the Reality private key, the Shadowsocks PSK, the MasterDNS and GooseRelay keys, and the wstunnel secret. Now0600, and repaired on existing installs. .envwas created0644. It holdsADMIN_PASSWORD,REALITY_PRIVATE_KEY,CLASH_API_SECRETand the Hysteria2 obfuscation password. Now0600.- All four monitoring exporters held unrestricted Docker API access purely to run
docker logsanddocker exec. They now read published state files instead. ADMIN_IP_WHITELISTset to a CIDR such as10.0.0.0/8silently denied everyone. It failed closed, so it was never a bypass, but operators who set one locked themselves out and most likely removed the whitelist entirely.- Grafana gained
no-new-privilegesandcap_drop: ALL.
Bugs you may have hit
moav regenerate-usersfailed for every user ifENABLE_REALITY=false. XHTTP is Reality-over-XHTTP and defaults to on, so it still needed those keys. One config flag broke the whole command, with the cause buried in container logs.- WireGuard and AmneziaWG peers could be created with an empty public key. When no key generator was available the peer was written anyway, and on bash 3.2 it did not even error. The peer simply never worked.
- A Mahsanet API key containing
=was silently truncated. Base64 keys routinely end in=, and the.envreader cut at the first one, so authentication failed in a way that looked exactly like a wrong key. moav user addcould exit silently, with no message and no error.- A half-written user state file wedged that user permanently. If a previous run died mid-write, every retry failed the same way and never self-healed.
moavdid not run on macOS at all, failing withdeclare: -A: invalid optionon bash 3.2.- The WireGuard peer count printed as
0 0. moav logs --tailandmoav update -bwith no value died with$2: unbound variableand no usage hint.
Under the hood
moav.sh went from 9,483 lines to 1,038, a reduction of 89%. It is now a dispatcher over fifteen focused modules rather than one monolith. Every extraction was verified by function-count conservation and byte-identical command output against the previous revision, so behaviour is unchanged by construction.
All 40 ad-hoc .env readers now share a single accessor, which is what surfaced the API-key truncation described above.
Container entrypoints were hardened with strict mode, which required fixing the landmines first. One of those is worth repeating: set -o pipefail is fatal in dash regardless of || true, because set is a POSIX special builtin and a failed special builtin exits a non-interactive shell outright.
Testing
moav testgenuinely exercises dnstt again, and AnyTLS has live coverage for the first time.- The e2e refuses to report success unless the core protocols actually passed. Skips no longer masquerade as a green run.
- Every long-lived container must be running after startup, so a crash-looping service cannot hide behind a passing test.
- The admin panel is probed on each run to confirm that it speaks TLS and refuses cleartext.
- Nine test suites, roughly 150 assertions, run on every change.
Known gaps, deliberately not fixed
- The 1.9.x to 2.0 upgrade path is untested. This is the single biggest unknown, and the main reason this is a candidate rather than a release.
cadvisorstill holds a raw Docker socket and runs privileged. It needs both for what it does, so there is no clean fix and it is documented rather than hidden. It only runs if you enable monitoring.- Client bundles and generated configs are still world-readable on the host.
- AnyTLS provisioning breaks bootstrap when enabled, so it stays off by default.
Testing this release
On a disposable VPS only:
git clone https://github.com/MotherofallVPNs/MoaV.git && cd MoaV
git checkout v2.0.0-rc.2
./moav.sh bootstrapAfterwards, moav doctor and moav test <user> are the two commands worth running. They will tell you more than the install output does. Bug reports, logs, and "this broke on my setup" issues are all genuinely useful.
Full itemised list below.
Added
moav doctor peers, duplicate peer-address detection, with--fix. The pre-v2 allocator assigned peer IPs from the count of[Peer]blocks, so revoking a user made the next one reuse a live address. WireGuard's crypto-routing maps an address to exactly one peer, so the later claimant takes it and the earlier peer silently stops receiving traffic, a tunnel that handshakes and then passes nothing. The new check reports every duplicated address, the users claiming it, and which of them currently owns it on the running interface (on the reference server: 19 WireGuard addresses shared by 45 peers, 22 AmneziaWG addresses shared by 50).moav doctor peers --fix [--yes]keeps the live owner and resets the others, dropping their[Peer]block and per-protocol state so the allocator reassigns them on the nextmoav regenerate-users, then lists exactly who needs a new bundle. Read-only by default; the repair prompts unless--yes, and refuses non-interactively without it. Block removal was verified against a copy of a real 147-peer config: exactly one peer removed,[Interface], server keys and PostUp rules intact.
Changed
- e2e now proves the admin panel is TLS-only, functionally rather than by inspection: HTTPS must answer and a cleartext request must not be served. It also reports which certificate was bound, which was previously invisible, a domain install must prefer Let's Encrypt over the self-signed fallback.
- The xray exporter no longer needs the Docker socket, the last of the four. It ran both
docker exec … statsqueryanddocker logs -f. xray now publishes stats snapshots to the sharedmoav_metricsvolume and writes its access log there (its error log still goes to stdout, somoav logs xrayis unchanged); the access log is size-capped from the same loop that publishes stats. No monitoring container holds a raw Docker socket now except cadvisor. - WireGuard and AmneziaWG exporters no longer need the Docker socket. Each ran
docker exec … wg show, which required unrestricted Docker API access for a read-only scrape.wg showneeds the tunnel container's network namespace, so those containers now publish their interface state to a sharedmoav_metricsvolume (atomically, from the monitor loop they already run) and the exporters read it. Raw socket mounts across the stack: 5 → 3. - The sing-box exporter no longer needs the Docker socket. It tailed
docker logsto attribute connections to users, which required unrestricted Docker API access. The Clash API already returnsmetadata.inboundUserper connection, and the exporter was already polling it for GeoIP, so counting moved there and the socket mount is gone. Note: polling cannot see a connection that opens and closes between samples, so very short bursts read low; the interval is now 15s and tunable viaSINGBOX_POLL_INTERVAL. - Pinned the state-vs-
.envprecedence defences. The three variables that are authoritative instate/keysbut also injected by compose (REALITY_SHORT_ID,REALITY_PRIVATE_KEY,CDN_WS_PATH) each have a guard preventing the injected value from shadowing the real one; those guards are now asserted by tests so they cannot be removed as "redundant". - Refactor (C1b): the provisioning tree's
.envreads now useget_env_valtoo. Workstream C's single-accessor goal is complete for both trees, every.envread in the repo resolves values identically (last-wins duplicates, inline comments stripped, whitespace trimmed,=preserved). - Refactor (C1a): the 24 ad-hoc
.envscrapers in thelib/tree now use the singleget_env_valaccessor. Beyond removing duplication this fixes inline-comment, surrounding-whitespace and duplicate-key handling at every one of those sites (last-wins instead of emitting every matching line). set -u+pipefailfor the remaining six entrypoints (admin, grafana, grafana-proxy, sing-box, snowflake, wstunnel), which previously had nosetline at all. Three SIGPIPE landmines guarded first.set -eis deliberately deferred for these, they have never run under it, so every currently-tolerated non-zero exit would become fatal.- Strict mode for the conduit/dnstt/trusttunnel/xray entrypoints (
set -eu+pipefail, up from bareset -e). Two landmines fixed first: conduit's key extraction would have died underpipefailbefore reaching its own empty-key check, and xray'sxray version | head -1raises SIGPIPE on a cosmetic line. - e2e now asserts every long-lived container is actually running.
docker compose pspreviously appeared only in the failure-diagnostics step, so a service that crash-looped at startup went unnoticed on a green run, the protocol tests can still pass around it. One-shot jobs (certbot, bootstrap) are exempt; a failure now dumps the offending container's logs. - e2e: WireGuard and AmneziaWG are now tested live.
moav testgrants the client containerNET_ADMIN+/dev/net/tun, the client image gainswireguard-tools, and both tests bring up a real tunnel and fetch the exit IP through it. Previously WireGuard "passed" on a DNS resolve of the endpoint; degraded (no-TUN) runs now cap at warn. - e2e: AnyTLS live test enabled,
ENABLE_ANYTLS=truein the domain pass; its test existed but had never run in CI.moav regenerate-usersis now exercised as a command (reconcile + zero-placeholder assertions). CLI smoke addsdoctor peers,user base64, and the standalone packager. - Refactor (B13): extracted the interactive menu and the small commands it fronts (
check,conduit,admin,user base64,test,client, usage text) frommoav.shintolib/menu.sh(570 lines).moav.shis now 1,038 lines, down from 9,483 at the start of the sprint (-89%), and is a dispatcher over fifteen modules. Relocation only; repo total is roughly flat.
Fixed
- The admin panel could serve plain HTTP. If Let's Encrypt had not issued yet it printed a warning and started anyway, sending the operator's HTTP-Basic credential (the same secret as the Grafana password) in cleartext on an internet-published port, the state of every fresh install for its first minutes, and of any install with a DNS or rate-limit problem. The self-signed fallback is now generated in every mode (it was domainless-only), the admin container mints a last-resort certificate if none arrived, and the panel refuses to start without TLS. Let's Encrypt is still preferred.
- dnstt was silently untested. The connectivity test globbed for
dnstt-instructions.txt, which was removed when the per-user instruction files were retired, so it found no config, reportedskip, and since skip is not a failure the suite stayed green while the DNS tunnel went unexercised. It now reads the guide (the current carrier) and looks for the server key at/dnstt, which is wheremoav testactually mounts it. docker-proxywas reachable from every container. It is an unauthenticated Docker API endpoint, the socket proxy filters path and method, never the request body, so anything on the shared network could create a privileged container with arbitrary bind mounts and take the host. That included every service terminating untrusted internet traffic. It now sits on an internalmoav_mgmtnetwork with only the admin dashboard. No data-plane change: all 27 other services stay onmoav_net, so the wstunnel→WireGuard and dnstt/Slipstream→sing-box chains are untouched.secure_state_keyssilently did nothing on BSD/macOS. Its loose-permission check usedfind -perm /077, which is GNU-only; on BSDfinderrors, the check yields nothing and no key is tightened. Production was unaffected (it runs in a Linux container) but the helper no-opped for anyone running it on a Mac, and its test failed only there. Now reads the mode viastatwith a GNU-then-BSD fallback.- Admin IP whitelist silently denied every CIDR entry.
ADMIN_IP_WHITELIST=10.0.0.0/8matched nothing, the old prefix-stripping comparison could never be true for a CIDR, so operators using one locked themselves out. Now matched with real network containment (IPv4 + IPv6). It failed closed, so this was never a bypass. .envis now created 0600. It holdsADMIN_PASSWORD,REALITY_PRIVATE_KEY,CLASH_API_SECRETand the Hysteria2 obfs password, and was created 0644.- Grafana gains
no-new-privilegesandcap_drop: ALL. It stays root with a writable filesystem, the entrypoint patches branding into/usr/share/grafana/public, which uid 472 cannot write, but neither of those two hardening flags conflicts with that. - A
MAHSANET_API_KEYcontaining=was silently truncated. The ad-hoc.envscrapers usecut -d= -f2, which cuts at the first=, so base64 API keys lost their padding and authentication failed in a way that looked like a wrong key. The credential reads now use theget_env_valaccessor (cut -d= -f2-). TheADMIN_PASSWORDread was migrated too; that one was benign (it only tested for empty/default) but shared the same flaw. set -o pipefailcannot be guarded with|| trueindash.setis a POSIX special builtin, so a failedset -o pipefailexits a non-interactive shell outright,|| truenever runs. On debian-based images (/bin/sh= dash, no pipefail) this killed the entrypoint at line 3 with exit 2 and no output. All#!/bin/shentrypoints now probe support in a subshell first.- WireGuard/AmneziaWG containers could report healthy with a dead tunnel. Both entrypoints ran with no
setline at all; an emptyPrivateKeysailed pastwg set/awg setinto the monitor loop. They now run underset -eu+pipefailand fail loudly when required config is missing. - WireGuard peer count printed as
0 0,grep -calready prints0and exits 1 on no match, so the|| echo 0fallback appended a second zero. - Server private key material was world-readable (0644). Files under
state/keys/written via heredoc (reality.envholdingREALITY_PRIVATE_KEY,clash-api.envwith the Clash API secret and Hysteria2 obfs password,shadowsocks-server.psk,masterdns-encrypt.key,gooserelay-tunnel.key,wstunnel-path.secret,cdn.env,amneziawg.env) inherited umask 022, while the*.keyfiles written underumask 077beside them were correctly 0600. Bootstrap now tightens all secret key material to 0600, this also repairs existing installs on the next bootstrap. Public counterparts (*.pub, certs) are left readable.clash-api.envis a deliberate exception, the admin container reads it as a non-root user, so it stays readable (tracked separately). - A truncated per-user WireGuard/AmneziaWG state file permanently wedged provisioning for that user. If a previous run died between creating
state/users/<id>/and writing the key material, the emptywireguard.envstill won the "load existing keys" branch, so every retry died onWG_PRIVATE_KEY: unbound variableand never self-healed. The state file is now validated after sourcing; an incomplete one is ignored with a warning and the user falls through to the existing recovery paths. moavfailed on bash 3.2 withdeclare: -A: invalid option.moav.shnow checks the bash version up front and prints an actionable message (stock macOS ships 3.2; supported servers ship 5.x). SetMOAV_SKIP_BASH_CHECK=1to bypass for read-only commands during development.moav logs --tailandmoav update -bwith no value died with$2: unbound variableand no usage hint; both now report which value is missing.moav regenerate-usersfailed for every user whenENABLE_REALITY=false. XHTTP is VLESS+Reality-over-xhttp andENABLE_XHTTPdefaults totrue, butgenerate-user.shonly sourcedreality.envwhen Reality itself was enabled, leavingREALITY_PUBLIC_KEYunset for a bundle that still read it. Reality/Hysteria2 key material is now sourced whenever it exists, and a missing required key reports which variable and where instead ofunbound variable.- WireGuard/AmneziaWG peers could be created with an empty public key. When no key generator was available (
wg_keypairfailing with no output), the pairedreadshort-circuited and leftclient_public_keyunset: a hard crash on bash >= 4, and silently an empty key written into the client config on bash 3.2. Both generators now fail with an actionable message. moav user addcould die silently. Underpipefail, agrep | cutover an empty Reality volume read exited non-zero with no output, soset -ekilled the command after[1/3]with no error.- Hardened
*_add_peeragainst a state/config mismatch that would hand a user unusable credentials. The function handled two cases, key material in state (reuse) and no state with no peer (create), but not the third: no state while the server already has a peer for that user. It minted a fresh keypair and address, then the "peer already in config, skipping" guard declined to install them, so the regenerated bundle would carry credentials the server has never seen. The private key only ever exists in the user's bundle, so it cannot be recovered. That state is now detected and nothing is touched: the existing peer and bundle are left exactly as they are and the operator is told the user can only be re-issued (moav user revoke+moav user add). Latent, not active: on the reference server every user's key material is present in themoav_moav_statevolume that the container path reads (147/147 for both protocols), so no user was at risk, the hoststate/users/directory holds only the subset created viamoav user add, which is a red herring when reasoning about this path. Pre-existing since at least 1.9.1; the v2 allocator work changed the address math, not this branch. Verified across all three states against a copy of that server's config: peer + no state returns 2 and mints nothing (server peer byte-identical afterwards), a brand-new user still allocates and appends, and an existing user with state is reused without duplicating the peer. moav regenerate-userssilently skipped bundles with no state entry. The A8 shared provisioning path iteratesstate/users/*/, whereas the older command iteratedoutputs/bundles/*/, so a bundle whose credentials had been lost was passed over without a word (one such user existed on the reference server). It is now reported per user and summarised, with the re-issue command.
Internal
moav.shdecomposition B12,lib/service.sh; the monolith is gone. The service layer: docker-compose profile selection and persistence, start / stop / restart, status and version reporting, log viewing, and themoav start|stop|restart|status|logs|profilescommands with their profile/service name resolution, two slices, 1,572 lines. This is the densest and most-called part of the old script, which is why the plan deferred it to the end.moav.sh3,178 → 1,607, down from 9,483 at the start of the sprint (−7,876, −83%), now a dispatcher over fourteen focused modules. Verified: function count conserved at 189, no duplicate definitions, andversion/help/status/profiles/doctor help/users/logsbyte-identical against a cleandevworktree once the git-branch string in the banner is normalised; the only remaining textual differences are the file path and line number quoted by two pre-existing errors (declare -Aneeds bash 4 and macOS ships 3.2;/dev/ttyin a non-interactive shell), which is exactly what moving code does to an error message.moav.shdecomposition B11,lib/doctor.sh. The whole diagnostic suite in one contiguous slice: everydoctor_check_*, theDOCTOR_CHECKSregistry that names them, andmoav doctoritself, 1,156 lines, the largest extraction of the sprint. Sourced afternettuneandpeers, whose checks it calls.moav.sh4,333 → 3,178; cumulative 9,483 → 3,178 (−6,305, −66.5%) across thirteen modules. The risk here was the dynamic dispatch, checks are invoked as"doctor_check_${check_name}"and so never appear as literal calls, meaning a botched move would fail only at runtime, so verification specifically exercised it:moav doctor helpstill lists all 16 checks, anddoctor net/doctor dnsstill resolve across module boundaries intolib/nettune.shandlib/dns.sh. Function count conserved at 189, no duplicate definitions, and all seven probed subcommands byte-identical against a cleandevworktree.moav.shdecomposition B10,lib/build.sh. Image building: the compose-build wrapper,moav build(including--local, which builds the monitoring images from source rather than pulling them), the local-build info banner, and the interactive build-services picker, two slices, 346 lines.moav.sh4,678 → 4,333; cumulative 9,483 → 4,333 (−5,150, −54.3%) across twelve modules. Verified: function count conserved at 189, no duplicate definitions, and the usual subcommand set byte-identical against a cleandevworktree.moav.shdecomposition B9,lib/users.sh. User lifecycle from the CLI: the interactive user menu, listing, add / revoke / package, themoav userandmoav userscommands, andmoav regenerate-users. Three non-contiguous slices (240 + 88 + 229 lines) removed in descending order so earlier line numbers stay valid. The provisioning itself stays inscripts/(user-add.shon the host,generate-user.shin the container), this is only the CLI surface over it.moav.sh5,234 → 4,678, now less than half its original size; cumulative 9,483 → 4,678 (−4,805, −50.7%) across eleven modules. Verified: function count conserved at 189, no duplicate definitions, andversion/help/users/doctor help/cert statusbyte-identical against a cleandevworktree.moav.shdecomposition B8,lib/bootstrap.sh. The host-side wrapper around first-run setup: detecting whether a deployment has been bootstrapped, driving the bootstrap container,moav bootstrap, andmoav domainless(switch to IP-only operation). Unlike the earlier modules this one was not contiguous, the state helpers sat ~3,000 lines above the commands, so it moves as two slices (148 + 159 lines), removing the later slice first so the earlier line numbers stay valid. The bootstrap container's own logic remains inscripts/bootstrap.sh; this is only the CLI side.moav.sh5,540 → 5,234; cumulative 9,483 → 5,234 (−4,249, −44.8%) across ten modules. Verified: function count conserved at 189, no duplicate definitions, andversion/help/doctor help/update --help/cert statusbyte-identical against a cleandevworktree.moav.shdecomposition B7,lib/install.sh. Makingmoavavailable as a command: the/usr/local/binsymlink, shell-completion install/removal, andmoav uninstall(stop and remove containers, optionally images and data), 428 lines, moved verbatim.moav.sh5,967 → 5,540; cumulative 9,483 → 5,540 (−3,943, −41.6%) across nine modules. The module header records why the dispatcher keeps its own$0/BASH_SOURCEresolution: this symlink is exactly what would break if that moved into a lib, since a lib'sBASH_SOURCEpoints at the lib. Verified accordingly, a symlinked invocation still resolvesSCRIPT_DIRand runs, plus function count conserved at 189, no duplicate definitions, andversion/help/update --help/doctor helpbyte-identical againstdev.moav.shdecomposition B6,lib/update.sh.moav updateand the seven functions that work out what an operator must do after pulling a new revision: component-version comparison, detecting config templates that changed under a live deployment, detecting sources needing a rebuild, the post-update apply steps, new-variable detection against.env.example, and the dnstt→DNS-tunnel state migration older installs still need, 620 lines, moved verbatim.moav.shdrops below 6,000 for the first time: 6,586 → 5,967. Cumulative with B0-B5 the dispatcher is down 3,516 lines (−37%) from 9,483, across eight focused modules. Verified: function count conserved at 189, no duplicate definitions, andversion/help/update --help/cert status/doctor helpbyte-identical againstdev.moav.shdecomposition B5,lib/dns.sh. DNS handling for the DNS-tunnel protocols moves verbatim: port-53 conflict detection and resolution, the declarative DNS-tunnel registry (metadata for the tunnels sharing port 53),moav switch-dns,moav setup-dnsand zone-file generation, 527 lines.moav.sh7,112 → 6,586; cumulative with B0-B4 the monolith is down 2,897 lines (−30.5%) from 9,483. Verified: function count conserved at 189, no duplicate definitions, andversion/help/cert status/doctor help/dnsbyte-identical againstdevwithmoav doctor dnsstill crossing correctly into the extracted module.moav.shdecomposition B4,lib/migrate.sh, plus the first genuinely dead function removed.moav export/moav import(state, configs and bundles as one archive) andmoav migrate-ip(rewrite the server address across configs and bundles after a VPS move) move verbatim intolib/migrate.sh, 644 lines;moav.sh7,755 → 7,112. Also deletedget_cdn_url: one occurrence repo-wide, its own definition, while its three sibling URL getters are used 4-6 times each. Worth recording how that was established, because the obvious approach lies, a naive "defined but never called" scan flagged 15 functions and 14 were false positives, since everydoctor_check_*is invoked as"doctor_check_${check_name}"from the registry and so never appears as a literal call. Verified: function count 190 → 189 (exactly the one deletion), no duplicate definitions, andversion/help/cert status/donate status/doctor helpbyte-identical againstdev, withmoav exportstill dispatching into the extracted module.moav.shdecomposition B3,lib/cert.sh. TLS certificate auto-renewal scheduling (the systemd timer, the cron.d fallback, install/uninstall/status) plus themoav certcommand and theauto_setup_cert_renewhook thatmoav startcalls, 5 functions and their four path globals, move verbatim intolib/cert.sh. 142 lines;moav.sh7,867 → 7,726. Verified: function count conserved exactly (182 = 126 + 17 + 12 + 22 + 5), no duplicate definitions, andmoav cert status(the extracted path) plusversion/help/donate status/net statusare all byte-identical in output and exit code againstdev.moav.shdecomposition B2,lib/donate.sh. The MahsaNet config-donation block plus the Conduit/Snowflake donation setup and status views (22 functions, the API endpoint and the donations-ledger path) move verbatim intolib/donate.sh, the largest single extraction so far at 1,006 lines.moav.sh8,872 → 7,867; with B0 and B1 the monolith is down 1,616 lines (−15%) from 9,483. Also corrected a B1 side effect: theDOCTOR_CHECKSregistry sat inside the range B1 extracted and was carried intolib/nettune.sh, where it does not belong; it is back besidecmd_doctorin the dispatcher, ready for the doctor module to claim later. Verified: top-level function count conserved exactly (182 = 131 + 17 + 12 + 22) with no duplicate definitions, andmoav version,help,donate status,doctor helpandnet statusall produce byte-identical output and exit codes againstdev.moav.shdecomposition B1,lib/nettune.sh. Second module out of the monolith (after B0'slib/common.sh): the self-contained network-tuning block, BBR/qdisc detection, socket-buffer sizing, the sysctl bundle render/apply/revert,nt_status, the PMTU/drops/CGNAT/MTU checks and themoav netcommand, moves verbatim into a top-levellib/nettune.sh, along with its only global (NT_CONF_PATH). Sourced right aftercommonand before the doctor checks, sincedoctor_check_netcallsnt_status/nt_check_*(modules are sourced into one shell, so this is documentation of intent rather than a hard requirement).moav.sh9,257 → 8,872 lines. Verified: the top-level function count is conserved exactly (182 = 153 + 17 + 12) with no duplicate definitions;moav net status,moav versionandmoav helpproduce byte-identical output and exit codes todev; andmoav doctor, which calls across the new module boundary, differs only in the line number quoted by a pre-existingset -uerror, exactly as expected after removing 380 lines.bash -n+shellcheck --severity=errorclean.
Note: the install command below fetches the latest stable release, not
this candidate. To try this build specifically, use thegit checkoutsteps in
the notes above.
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