Operational Security Guide¶
Security recommendations for running and using MoaV safely.
For Server Operators¶
Server Security¶
-
Keep system updated:
-
Use SSH keys, disable password auth:
-
Change SSH port (optional but recommended):
Firewall & Docker¶
Important: Docker bypasses UFW/iptables. Docker publishes ports by inserting iptables rules before UFW's chains. This means
ufw deny 9443does NOT block access to port 9443 if Docker is publishing it. Ports listed indocker-compose.ymlunderports:are publicly accessible regardless of UFW rules.Ports listed under
expose:(withoutports:) are Docker-internal only and NOT affected.
Basic UFW setup (for non-Docker ports like SSH):
Warning: UFW rules only effectively control non-Docker services (SSH, system services). For Docker-published ports, use the methods below.
Protocol ports (published by Docker — UFW rules are informational only):
| Port | Service | Notes |
|---|---|---|
| 443/tcp | Reality (VLESS) | Required |
| 443/udp | Hysteria2 | Required if enabled |
| 8443/tcp | Trojan | Required if enabled |
| 4443/tcp+udp | TrustTunnel | Required if enabled |
| 2082/tcp | CDN WebSocket | Required if enabled |
| 51820/udp | WireGuard | Required if enabled |
| 51821/udp | AmneziaWG | Required if enabled |
| 8080/tcp | wstunnel | Required if enabled |
| 993/tcp | Telegram MTProxy | Required if enabled |
| 2096/tcp | XHTTP | Required if enabled |
| 53/udp | DNS tunnels — dnstt, Slipstream, MasterDNS, XDNS (all 4 via dns-router) | Required if DNS tunnels enabled |
| 8444/tcp | GooseRelay (SOCKS5-over-Google-Apps-Script exit) | Only if ENABLE_GOOSERELAY=true |
| 80/tcp | Let's Encrypt | Required during cert renewal |
| 9443/tcp | Admin dashboard | See access control below |
| 9444/tcp | Grafana | See access control below |
| 2083/tcp | Grafana CDN proxy | See access control below |
Protocol ports (Reality, Hysteria2, etc.) are designed to be public — they require authentication. The concern is admin/monitoring ports.
Option 1: Use ADMIN_IP_WHITELIST (Recommended)¶
MoaV's admin dashboard has built-in IP whitelisting. Set in .env:
# Allow only your IP (comma-separated for multiple)
ADMIN_IP_WHITELIST=YOUR_HOME_IP,YOUR_OFFICE_IP
# Then restart admin
moav restart admin
This blocks all other IPs at the application level, regardless of Docker/UFW.
Option 2: Bind to localhost + SSH tunnel¶
For maximum security, bind admin/monitoring to 127.0.0.1 so they're only accessible via SSH tunnel:
Then access via SSH tunnel:
# From your local machine
ssh -L 9443:127.0.0.1:9443 -L 9444:127.0.0.1:9444 root@YOUR_SERVER
# Then open https://localhost:9443 in your browser
Option 3: ufw-docker (Advanced)¶
ufw-docker patches UFW to work with Docker by adding rules to the DOCKER-USER iptables chain. This makes ufw commands effective for Docker ports.
# Install
sudo wget -O /usr/local/bin/ufw-docker https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install
sudo systemctl restart ufw
# Allow specific ports from any IP
sudo ufw-docker allow moav-sing-box 443/tcp
sudo ufw-docker allow moav-sing-box 443/udp
# Allow admin only from your IP
sudo ufw-docker allow moav-admin 8443/tcp from YOUR_IP
Trade-offs: Requires modifying UFW config files (
/etc/ufw/after.rules). Rules useufw routesyntax. Needs reload after container restarts. Works well but adds complexity. For most users,ADMIN_IP_WHITELISTis simpler.
Admin & Monitoring Access Control¶
Admin dashboard (https://server:9443):
- Username: any value — only the password is checked
- Password: set during install (in
.envasADMIN_PASSWORD) - Reset:
moav admin password - IP whitelist:
ADMIN_IP_WHITELISTin.env
Fail-closed authentication. The dashboard refuses to serve (HTTP 503) if
ADMIN_PASSWORDis empty, unset, or one of the known-insecure defaults (admin,change_me_to_something_secure), rather than silently allowing access. This closes an edge case where an empty password would otherwise accept an emptyAuthorizationheader. Password comparison is constant-time. If you see a 503 with a remediation message, set a realADMIN_PASSWORDin.envandmoav restart admin.
Grafana (https://server:9444):
- Username:
admin - Password: same as
ADMIN_PASSWORD - Accessible from any IP by default (password-protected)
Internal services (not publicly accessible):
- Prometheus (9091) —
expose:only, Docker-internal - All exporters —
expose:only, Docker-internal - cAdvisor —
expose:only, Docker-internal - Docker socket proxy —
expose:only, Docker-internal
Domain Security¶
- Use WHOIS privacy — hide personal information in domain registration
- Use a neutral registrar — avoid country-specific registrars
- Keep registration details accurate — use registrar-provided WHOIS privacy or a reputable privacy/proxy registration service rather than false information
- Match payment to your threat model — don't assume cryptocurrency is anonymous; many blockchains create permanent public transaction records
- Separate domain from identity — don't use a domain linked to your name
Reality fallback target (REALITY_TARGET)¶
How to pick one, with candidate targets and the trade-offs, is in Setup → Choosing a Reality Target. This section covers why the choice matters for your threat model.
Reality's REALITY_TARGET (and XHTTP_REALITY_TARGET) is the public TLS site the inbound proxies non-Reality TLS hellos to. Every probe and every misauthenticated client gets that site's real ServerHello back — so an outside observer sees a normal connection to a real CDN, not a closed port.
The hostname MUST resolve in public DNS and be reachable from your server's network. If it doesn't, the inbound RSTs every TLS hello — including ones from your own users — and your :443 looks visibly dead to passive scanners. This is exactly what issue #115 reported (update.samsung.com is not a real public hostname; Samsung's update infrastructure uses opaque internal names).
Bootstrap now validates this and moav doctor reality re-checks it post-deployment.
Vetted targets:
| Audience | Hostname | Why it works |
|---|---|---|
| Global | www.cloudflare.com:443 |
Real TLS 1.3, ECH-capable, anycast, won't go dark |
| Global | www.apple.com:443 |
Corporate, stable, real ECH support |
| Global | cdn.kernel.org:443 |
Linux kernel CDN, real, neutral |
| Iran users | www.aparat.com:443 |
Iranian video CDN — looks like normal domestic traffic to Iran DPI |
| Iran users | digikala.com:443 |
Iran e-commerce, high-volume traffic profile |
| Iran users | taghche.com:443 |
Iranian book platform, plausible browsing target |
Avoid:
dl.google.com:443— throttled by Iran DPI; works elsewhere but bad for Iran-bound clients- Hostnames that sound like an update server but aren't real public DNS names (
update.samsung.com,swl.samsung.com,update.windows.com,cdn.tesla.com) — verify withgetent hosts <host>first - Hostnames behind country-specific blocks from your VPS region (some VPS providers throttle major CDNs)
- Anything you wouldn't expect a real browser to talk to on a quiet Tuesday
If you change REALITY_TARGET after bootstrap, you must re-issue client bundles so the SNI in their config matches:
Credential Management¶
- Never share master credentials — each user gets unique credentials
-
Revoke compromised users immediately:
-
Keep dnstt private keys private —
state/keys/dnstt-server.key.hexis the persistent dnstt server private key. It should be readable only by the dnstt container user (100:101, mode0600). If MoaV cannot set that ownership during bootstrap, fix the host permissions and re-run bootstrap rather than making the key world-readable. - Rotate server keys periodically — re-bootstrap if concerned
-
Keep backups:
-
Use strong admin password — at least 16 characters, generated randomly
Monitoring¶
-
Use
moav doctorto check for configuration issues: -
Check logs regularly:
-
Inspect connections to see who's connecting and what they're accessing:
-
Grafana dashboards (if monitoring enabled):
- Per-user traffic and connections
- GeoIP country distribution
- Protocol breakdown
-
System health (CPU, RAM, disk)
-
Watch for unusual patterns:
- Sudden traffic spikes from unexpected countries
- Single IPs with very high error counts (scanning/probing)
- Connections to suspicious destinations
Docker Security Hardening¶
MoaV applies these hardening measures to all containers (since v1.7.2):
cap_drop: ALL— drops all Linux capabilities, adds back only what's neededread_only: true— read-only root filesystem with targetedtmpfsmountsno-new-privileges: true— prevents privilege escalationmem_limitandcpus— resource limits per container- Non-root users — containers run as unprivileged
moavuser where possible - Docker socket proxy — admin uses
tecnativa/docker-socket-proxyinstead of mounting the raw Docker socket
Network tuning¶
The installer offers a one-time kernel-level network tuning bundle aimed at the long-RTT / lossy paths that proxy traffic to censored regions actually traverses. Real-world Portugal→Vilnius testing (Time4VPS box, ~400 ms RTT with burst loss) showed BBR roughly 3× single-flow TCP throughput compared to the Linux default (CUBIC 5.45 Mbps → BBR 14.8 Mbps), and dramatically faster recovery from packet loss. Bigger UDP buffers help Hysteria2 and WireGuard even though they don't use BBR — quic-go alone needs ≥7.5 MiB to avoid drops at high throughput.
What it tunes (written to a single dedicated file, /etc/sysctl.d/99-moav-net.conf, for clean rollback):
| Knob | Value | Why |
|---|---|---|
net.ipv4.tcp_congestion_control |
bbr |
RTT/bandwidth-based, recovers from loss faster than CUBIC |
net.core.default_qdisc |
fq |
Required for BBR's pacing |
net.core.{r,w}mem_max |
32 MiB (16 MiB if RAM < 2 GB) | Headroom for high-BDP TCP + QUIC |
net.ipv4.tcp_{r,w}mem |
4096 / 131072 / max |
TCP buffer auto-tune range |
net.core.{r,w}mem_default |
1 MiB | Default UDP socket buffer (Hysteria2 / WireGuard) |
net.core.netdev_max_backlog |
16384 | Queue depth — UDP drops hurt circumvention more than TCP drops |
net.core.somaxconn |
8192 | Listen backlog for high-concurrency inbounds |
net.ipv4.tcp_max_syn_backlog |
8192 | SYN queue depth — prevents drops on coordinated reconnect bursts |
net.ipv4.tcp_slow_start_after_idle |
0 | Avoid restarting at congestion-window 1 on idle long-lived proxies |
net.ipv4.tcp_mtu_probing |
1 | Recover gracefully if a path silently has a smaller MTU |
net.ipv4.tcp_notsent_lowat |
131072 | Smaller send buffers to reduce HOL latency for interactive flows |
What it deliberately does NOT set: net.ipv4.tcp_fastopen. TFO server-side adds latency in heavily-censored networks because middleboxes (notably China Mobile) drop SYN+data on ~5% of paths and the client has to retry. The same reason MoaV v1.8.4 removed tcp_fast_open: true from the sing-box Reality and Trojan inbounds.
Commands:
moav net status # show current vs recommended values
moav net apply # write 99-moav-net.conf + reload sysctl + print verification
moav net revert # remove the file + reload sysctl (clean rollback)
moav doctor net # sysctl + packet drops + PMTU + CGNAT + per-interface MTU
moav doctor net extends the sysctl check with:
- Packet drops: reads
/proc/net/snmp+/proc/net/netstatfor TCPListenDrops/ListenOverflows(SYN queue overflows) and UDPRcvbufErrors/SndbufErrors(Hysteria2/WireGuard buffer overflows). Counters are since-boot; non-zero values name the matching sysctl knob to raise. - PMTU: confirms
tcp_mtu_probingis enabled so silent black-hole paths recover instead of stalling. - CGNAT / NAT: looks at the default route's source address. CGNAT (100.64/10) is reported as a hard failure for inbound proxy traffic; RFC1918 addresses are flagged as "behind NAT" with the public
SERVER_IPfrom.envfor context. - MTU: prints egress MTU + WireGuard
wg0MTU (recommends 1420). Informational.
When it skips silently: kernel <4.9 (no BBR), OpenVZ guests (shared kernel, no sysctl writes), or already-applied installs.
Compatibility notes:
net.ipv4.tcp_*sysctls are network-namespaced, so they apply per-container. MoaV's sing-box runs innetwork_mode: hostand inherits the host's BBR directly. Bridge-network containers (xray, telemt) use the host's settings as their default and pick up BBR on container restart aftermoav net apply.- BBR + fq are mainline since kernel 4.9 — Ubuntu 20.04+, Debian 11+, RHEL 9+, every supported modern distro.
- Existing custom sysctl tweaks in
/etc/sysctl.confor other/etc/sysctl.d/*.conffiles are not modified. If a later-numbered file (99-moav-net.confis at 99) overrides something, it wins by sysctl-load order.
If Server is Blocked¶
- Try different protocols first — switch from Reality to Hysteria2, XHTTP, or CDN mode
- CDN mode — routes through Cloudflare/CloudFront, works when server IP is blocked
- DNS tunnels — XDNS/dnstt/Slipstream work when most traffic is blocked
-
If IP is burned:
-
Donate bandwidth — even if your server is blocked for your users, it can still serve millions through Psiphon Conduit, Tor Snowflake, and MahsaNet
What MoaV Records¶
The most reliable protection for your users is data that was never written down. A server cannot be compelled to hand over, and an attacker cannot steal, a link that does not exist on it.
This is what MoaV keeps, what it refuses to keep, and how long any of it survives.
The rule¶
No metric, log, or label ever links a user to a destination. A user identifier means a client IP, username, public key, UUID, or session ID. A destination means a hostname, IP, or the port they reached.
This holds regardless of retention period, aggregation, or who is asking.
Recorded by default¶
Monitoring, when you enable it, keeps volume and liveness per user:
| recorded | example | why an operator needs it |
|---|---|---|
| username, public key | alice, k8U6+… |
matching a bundle to a person you issued it to |
| bytes up / down | 4.2 GB |
quotas, spotting an account being resold |
| last handshake, active state | 2m ago |
is this user connected right now |
| country of the user's connection | IR |
which networks your server is reaching |
None of this says where anyone went. It is the same information you would need to run any VPN responsibly.
Never recorded¶
- Which sites a user visited. No metric pairs a user with a destination.
- DNS queries per user.
- Message, packet, or payload contents. MoaV never sees inside a tunnel.
Until 2.2.0 one exception existed and was not intentional: a third-party monitoring exporter stored client IP × destination hostname (389,324 series, 83% of the database, on every monitored server), and sing-box logged the same pairing to disk. Both are fixed at the source in 2.2.0 — the exporter is removed, and destinations are stripped from the log before it is written. Audit and decisions: MoaV#297; implementation: MoaV#298. If you ran monitoring earlier, that data is on disk until it ages out — see Purging old monitoring data.
Optional: site analytics¶
ENABLE_SITE_ANALYTICS=true answers what is this proxy used for without recording who did it:
- domains only, folded to the registrable name (
edge-42.example-cdn.net→example-cdn.net) - a domain is named only after
SITE_ANALYTICS_MIN_CLIENTS(5) distinct clients have reached it in a bucket; below that it counts underother - ranked by distinct clients, not bytes
- no client identifier is ever produced — not filtered afterwards, never created
- counters advance once per bucket (
SITE_ANALYTICS_BUCKET_SECONDS, default 1h), so the timeline cannot be aligned against the per-user connection counts published every scrape - destination country comes from resolving each named domain through sing-box's own DNS cache, then a local GeoIP lookup; only threshold-clearing domains are resolved and no query goes anywhere the traffic had not already gone.
otheris never resolved, so it shows asunknown.
ENABLE_SITE_ANALYTICS_RESEARCH=true adds destination port and protocol. Separate switch: a rare port is more identifying than a popular domain.
Why k≥5 and hourly buckets: the median domain has exactly one client, so a lower threshold or a per-scrape timeline would name individuals — a site spike lined up against one user's connection count re-links them to that destination. Measured on a real server, k≥5 drops 94% of domains while still attributing ~89% of traffic. On a server with very few users even aggregates leak, so this is opt-in.
Tuning: if too much lands under other, widen SITE_ANALYTICS_BUCKET_SECONDS, don't lower SITE_ANALYTICS_MIN_CLIENTS. A longer bucket gathers more distinct clients per site, so more sites clear the same threshold with no loss of anonymity; the only cost is timeline resolution. The Threshold Cost panel shows how many sites were folded and how close the nearest miss came.
How long anything survives¶
| data | retention |
|---|---|
| Prometheus metrics | PROMETHEUS_RETENTION_TIME (90 days) or PROMETHEUS_RETENTION_SIZE (2 GB), whichever comes first |
| Container logs | LOG_MAX_SIZE x LOG_MAX_FILES per container (10 MB x 3) |
| systemd journal | capped at 200 MB (see below) |
User bundles in outputs/ |
until you delete them — they contain working credentials |
The journal is not capped by default on most distributions; it grows to 10% of the disk. On a small VPS:
sudo sed -i 's/^#\?SystemMaxUse=.*/SystemMaxUse=200M/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
sudo journalctl --vacuum-size=200M
Purging old monitoring data¶
If you ran monitoring before 2.2.0 and would rather not wait 15 days:
cd /opt/moav
docker compose stop prometheus
docker volume rm moav_moav_prometheus # deletes ALL metrics history
docker compose up -d prometheus
This drops every metric, not only the sensitive one — dashboards start from empty. Waiting for the retention window to expire achieves the same thing without losing your history.
For Users¶
Device Security¶
- Use a separate profile/user for circumvention apps on shared devices
- Don't screenshot QR codes — or delete immediately after import
- Delete bundle files after importing to your apps
- Use device encryption — enable full disk encryption
- Set strong device PIN/password
Connection Security¶
- Verify you're connected:
- Check your IP: https://whatismyip.com
-
Should show server IP, not your real IP
-
Use HTTPS everywhere even over tunnel:
- The tunnel encrypts transport, HTTPS encrypts content
-
Protects against compromised tunnel endpoints
-
Don't trust public WiFi even with VPN:
- Your device can still be attacked locally
- Tunnel doesn't protect against local network attacks
App Security¶
- Keep apps updated — updates often fix detection bypasses
- Download from official sources:
- iOS: App Store (Happ, Streisand, Hiddify)
- Android: GitHub releases (Happ, v2rayNG, Hiddify)
-
Avoid random APK sites
-
Backup your configs — export from apps, store securely
Behavior Security¶
- Don't share your credentials — each person should have their own
- Don't share screenshots showing server addresses or QR codes
- Don't mention specific servers in public forums
- Use secure messaging to receive configs (Signal, encrypted email)
If You Suspect Compromise¶
- Stop using that config immediately
- Contact admin for new credentials
- Check your device for malware
- Change passwords for any accounts accessed over that connection
Distribution Security¶
Sharing Bundles Safely¶
DO:
- Use end-to-end encrypted messaging (Signal, Telegram secret chat)
- Share in person when possible (scan QR code directly)
- Use encrypted file sharing (OnionShare)
- Delete messages after recipient confirms receipt
DON'T:
- Email unencrypted configs
- Post links in public channels
- Share via unencrypted cloud storage
- Send screenshots of QR codes to groups
Recommended Distribution Methods¶
- In Person — safest, scan QR code directly
- Signal — send configs as files, enable disappearing messages
- Telegram (Secret Chat only) — NOT regular chats, use self-destruct timer
- Admin Dashboard — share download links directly (HTTPS, password-protected)
Legal Considerations¶
Disclaimer: This is not legal advice.
- Laws vary by country — running or using circumvention tools may carry legal risks
- Assess your personal risk level
- The decoy website reduces obvious disclosure during casual or unauthenticated probing; it is not protection against targeted investigation
Data Retention¶
MoaV is configured for minimal logging:
- No URLs logged
- No request content
- Basic connection stats only (for admin dashboard)
- IP addresses are in memory only (not persisted to disk)
To minimize logging further:
Emergency Procedures¶
If You Think You're Monitored¶
- Stop using current credentials
- Contact admin through alternate channel
- Get fresh credentials
- Consider using a different device
- Assess whether to continue using service
If Server is Seized¶
User data exposure is limited:
- No content is logged
- IP addresses are in memory only
- User identifiers are usernames (not real names)
But assume:
- Server IP is known
- User identifiers are known
- Active connections at time of seizure are known
If User is Compromised¶
As admin:
- Revoke user immediately:
moav user revoke username - Monitor for unusual activity
- Consider rotating server if credentials were extracted
- Do NOT contact compromised user through normal channels
Checklist¶
Server Operator¶
- [ ] SSH keys only, no password auth
- [ ] SSH port changed from default 22
- [ ] System auto-updates enabled
- [ ] Admin IP whitelist configured (
ADMIN_IP_WHITELIST) - [ ] Strong admin password (16+ characters)
- [ ] Unique user credentials for everyone
- [ ]
moav doctorpasses all checks - [ ] Backup plan if blocked (new IP or migration ready)
- [ ] Secure distribution channel established
- [ ] Monitoring enabled (Grafana) or logs checked regularly
User¶
- [ ] Device encrypted
- [ ] App from official source
- [ ] Config imported securely
- [ ] Bundle files deleted after import
- [ ] Knows which protocol to try if one fails
- [ ] Knows how to contact admin securely