github MotherofallVPNs/MoaV v2.0.0-rc.2
v2.0.0-rc.2 — EXPERIMENTAL, not for production use

pre-release5 hours ago

⚠️ 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.sh and 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, including reality.env with the Reality private key, the Shadowsocks PSK, the MasterDNS and GooseRelay keys, and the wstunnel secret. Now 0600, and repaired on existing installs.
  • .env was created 0644. It holds ADMIN_PASSWORD, REALITY_PRIVATE_KEY, CLASH_API_SECRET and the Hysteria2 obfuscation password. Now 0600.
  • All four monitoring exporters held unrestricted Docker API access purely to run docker logs and docker exec. They now read published state files instead.
  • ADMIN_IP_WHITELIST set to a CIDR such as 10.0.0.0/8 silently 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-privileges and cap_drop: ALL.

Bugs you may have hit

  • moav regenerate-users failed for every user if ENABLE_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 .env reader cut at the first one, so authentication failed in a way that looked exactly like a wrong key.
  • moav user add could 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.
  • moav did not run on macOS at all, failing with declare: -A: invalid option on bash 3.2.
  • The WireGuard peer count printed as 0 0.
  • moav logs --tail and moav update -b with no value died with $2: unbound variable and 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 test genuinely 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.
  • cadvisor still 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 bootstrap

Afterwards, 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 next moav 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 … statsquery and docker logs -f. xray now publishes stats snapshots to the shared moav_metrics volume and writes its access log there (its error log still goes to stdout, so moav logs xray is 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 show needs the tunnel container's network namespace, so those containers now publish their interface state to a shared moav_metrics volume (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 logs to attribute connections to users, which required unrestricted Docker API access. The Clash API already returns metadata.inboundUser per 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 via SINGBOX_POLL_INTERVAL.
  • Pinned the state-vs-.env precedence defences. The three variables that are authoritative in state/keys but 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 .env reads now use get_env_val too. Workstream C's single-accessor goal is complete for both trees, every .env read in the repo resolves values identically (last-wins duplicates, inline comments stripped, whitespace trimmed, = preserved).
  • Refactor (C1a): the 24 ad-hoc .env scrapers in the lib/ tree now use the single get_env_val accessor. 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 + pipefail for the remaining six entrypoints (admin, grafana, grafana-proxy, sing-box, snowflake, wstunnel), which previously had no set line at all. Three SIGPIPE landmines guarded first. set -e is 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 bare set -e). Two landmines fixed first: conduit's key extraction would have died under pipefail before reaching its own empty-key check, and xray's xray version | head -1 raises SIGPIPE on a cosmetic line.
  • e2e now asserts every long-lived container is actually running. docker compose ps previously 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 test grants the client container NET_ADMIN + /dev/net/tun, the client image gains wireguard-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=true in the domain pass; its test existed but had never run in CI. moav regenerate-users is now exercised as a command (reconcile + zero-placeholder assertions). CLI smoke adds doctor 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) from moav.sh into lib/menu.sh (570 lines). moav.sh is 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, reported skip, 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 where moav test actually mounts it.
  • docker-proxy was 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 internal moav_mgmt network with only the admin dashboard. No data-plane change: all 27 other services stay on moav_net, so the wstunnel→WireGuard and dnstt/Slipstream→sing-box chains are untouched.
  • secure_state_keys silently did nothing on BSD/macOS. Its loose-permission check used find -perm /077, which is GNU-only; on BSD find errors, 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 via stat with a GNU-then-BSD fallback.
  • Admin IP whitelist silently denied every CIDR entry. ADMIN_IP_WHITELIST=10.0.0.0/8 matched 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.
  • .env is now created 0600. It holds ADMIN_PASSWORD, REALITY_PRIVATE_KEY, CLASH_API_SECRET and the Hysteria2 obfs password, and was created 0644.
  • Grafana gains no-new-privileges and cap_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_KEY containing = was silently truncated. The ad-hoc .env scrapers use cut -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 the get_env_val accessor (cut -d= -f2-). The ADMIN_PASSWORD read was migrated too; that one was benign (it only tested for empty/default) but shared the same flaw.
  • set -o pipefail cannot be guarded with || true in dash. set is a POSIX special builtin, so a failed set -o pipefail exits a non-interactive shell outright, || true never 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/sh entrypoints now probe support in a subshell first.
  • WireGuard/AmneziaWG containers could report healthy with a dead tunnel. Both entrypoints ran with no set line at all; an empty PrivateKey sailed past wg set/awg set into the monitor loop. They now run under set -eu + pipefail and fail loudly when required config is missing.
  • WireGuard peer count printed as 0 0, grep -c already prints 0 and exits 1 on no match, so the || echo 0 fallback appended a second zero.
  • Server private key material was world-readable (0644). Files under state/keys/ written via heredoc (reality.env holding REALITY_PRIVATE_KEY, clash-api.env with 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 *.key files written under umask 077 beside 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.env is 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 empty wireguard.env still won the "load existing keys" branch, so every retry died on WG_PRIVATE_KEY: unbound variable and 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.
  • moav failed on bash 3.2 with declare: -A: invalid option. moav.sh now checks the bash version up front and prints an actionable message (stock macOS ships 3.2; supported servers ship 5.x). Set MOAV_SKIP_BASH_CHECK=1 to bypass for read-only commands during development.
  • moav logs --tail and moav update -b with no value died with $2: unbound variable and no usage hint; both now report which value is missing.
  • moav regenerate-users failed for every user when ENABLE_REALITY=false. XHTTP is VLESS+Reality-over-xhttp and ENABLE_XHTTP defaults to true, but generate-user.sh only sourced reality.env when Reality itself was enabled, leaving REALITY_PUBLIC_KEY unset 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 of unbound variable.
  • WireGuard/AmneziaWG peers could be created with an empty public key. When no key generator was available (wg_keypair failing with no output), the paired read short-circuited and left client_public_key unset: 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 add could die silently. Under pipefail, a grep | cut over an empty Reality volume read exited non-zero with no output, so set -e killed the command after [1/3] with no error.
  • Hardened *_add_peer against 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 the moav_moav_state volume that the container path reads (147/147 for both protocols), so no user was at risk, the host state/users/ directory holds only the subset created via moav 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-users silently skipped bundles with no state entry. The A8 shared provisioning path iterates state/users/*/, whereas the older command iterated outputs/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.sh decomposition 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 the moav start|stop|restart|status|logs|profiles commands 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.sh 3,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, and version/help/status/profiles/doctor help/users/logs byte-identical against a clean dev worktree 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 -A needs bash 4 and macOS ships 3.2; /dev/tty in a non-interactive shell), which is exactly what moving code does to an error message.
  • moav.sh decomposition B11, lib/doctor.sh. The whole diagnostic suite in one contiguous slice: every doctor_check_*, the DOCTOR_CHECKS registry that names them, and moav doctor itself, 1,156 lines, the largest extraction of the sprint. Sourced after nettune and peers, whose checks it calls. moav.sh 4,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 help still lists all 16 checks, and doctor net / doctor dns still resolve across module boundaries into lib/nettune.sh and lib/dns.sh. Function count conserved at 189, no duplicate definitions, and all seven probed subcommands byte-identical against a clean dev worktree.
  • moav.sh decomposition 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.sh 4,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 clean dev worktree.
  • moav.sh decomposition B9, lib/users.sh. User lifecycle from the CLI: the interactive user menu, listing, add / revoke / package, the moav user and moav users commands, and moav regenerate-users. Three non-contiguous slices (240 + 88 + 229 lines) removed in descending order so earlier line numbers stay valid. The provisioning itself stays in scripts/ (user-add.sh on the host, generate-user.sh in the container), this is only the CLI surface over it. moav.sh 5,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, and version/help/users/doctor help/cert status byte-identical against a clean dev worktree.
  • moav.sh decomposition 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, and moav 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 in scripts/bootstrap.sh; this is only the CLI side. moav.sh 5,540 → 5,234; cumulative 9,483 → 5,234 (−4,249, −44.8%) across ten modules. Verified: function count conserved at 189, no duplicate definitions, and version/help/doctor help/update --help/cert status byte-identical against a clean dev worktree.
  • moav.sh decomposition B7, lib/install.sh. Making moav available as a command: the /usr/local/bin symlink, shell-completion install/removal, and moav uninstall (stop and remove containers, optionally images and data), 428 lines, moved verbatim. moav.sh 5,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_SOURCE resolution: this symlink is exactly what would break if that moved into a lib, since a lib's BASH_SOURCE points at the lib. Verified accordingly, a symlinked invocation still resolves SCRIPT_DIR and runs, plus function count conserved at 189, no duplicate definitions, and version/help/update --help/doctor help byte-identical against dev.
  • moav.sh decomposition B6, lib/update.sh. moav update and 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.sh drops 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, and version/help/update --help/cert status/doctor help byte-identical against dev.
  • moav.sh decomposition 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-dns and zone-file generation, 527 lines. moav.sh 7,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, and version/help/cert status/doctor help/dns byte-identical against dev with moav doctor dns still crossing correctly into the extracted module.
  • moav.sh decomposition B4, lib/migrate.sh, plus the first genuinely dead function removed. moav export / moav import (state, configs and bundles as one archive) and moav migrate-ip (rewrite the server address across configs and bundles after a VPS move) move verbatim into lib/migrate.sh, 644 lines; moav.sh 7,755 → 7,112. Also deleted get_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 every doctor_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, and version/help/cert status/donate status/doctor help byte-identical against dev, with moav export still dispatching into the extracted module.
  • moav.sh decomposition B3, lib/cert.sh. TLS certificate auto-renewal scheduling (the systemd timer, the cron.d fallback, install/uninstall/status) plus the moav cert command and the auto_setup_cert_renew hook that moav start calls, 5 functions and their four path globals, move verbatim into lib/cert.sh. 142 lines; moav.sh 7,867 → 7,726. Verified: function count conserved exactly (182 = 126 + 17 + 12 + 22 + 5), no duplicate definitions, and moav cert status (the extracted path) plus version/help/donate status/net status are all byte-identical in output and exit code against dev.
  • moav.sh decomposition 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 into lib/donate.sh, the largest single extraction so far at 1,006 lines. moav.sh 8,872 → 7,867; with B0 and B1 the monolith is down 1,616 lines (−15%) from 9,483. Also corrected a B1 side effect: the DOCTOR_CHECKS registry sat inside the range B1 extracted and was carried into lib/nettune.sh, where it does not belong; it is back beside cmd_doctor in 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, and moav version, help, donate status, doctor help and net status all produce byte-identical output and exit codes against dev.
  • moav.sh decomposition B1, lib/nettune.sh. Second module out of the monolith (after B0's lib/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 the moav net command, moves verbatim into a top-level lib/nettune.sh, along with its only global (NT_CONF_PATH). Sourced right after common and before the doctor checks, since doctor_check_net calls nt_status/nt_check_* (modules are sourced into one shell, so this is documentation of intent rather than a hard requirement). moav.sh 9,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 version and moav help produce byte-identical output and exit codes to dev; and moav doctor, which calls across the new module boundary, differs only in the line number quoted by a pre-existing set -u error, exactly as expected after removing 380 lines. bash -n + shellcheck --severity=error clean.

Note: the install command below fetches the latest stable release, not
this candidate. To try this build specifically, use the git checkout steps in
the notes above.


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

Don't miss a new MoaV release

NewReleases is sending notifications on new releases.