# Quick health check
docker compose ps
dnstm tunnel list
dnstm router status# Via reality-ezpz
cd /opt/reality-ezpz
bash reality-ezpz.sh
# Choose: Add user
# Or via Telegram bot (if configured during setup)cd /opt/reality-ezpz
bash reality-ezpz.sh
# Choose: Remove user# Check sing-box connections (VLESS Reality + Hysteria2)
docker compose logs sing-box --tail=50
# Check dnstm tunnel logs
dnstm tunnel logs slip-socks
dnstm tunnel logs dnstt-socks
dnstm tunnel logs slip-ssh# Restart all VPN protocols (VLESS Reality + Hysteria2)
cd /opt/reality-ezpz
docker compose restart
# Restart DNS tunnels
dnstm router stop && dnstm router start
# Restart individual tunnel
dnstm tunnel stop slip-socks && dnstm tunnel start slip-sockscd /opt/reality-ezpz
git pull
docker compose pull
docker compose up -d
# WARNING: running 'bash reality-ezpz.sh' will overwrite engine.conf
# and remove manually-injected Hysteria2. Back up first!
cp engine.conf engine.conf.bak
cp docker-compose.yml docker-compose.yml.bak# Download latest binary
curl -Lo /usr/local/bin/dnstm \
https://github.com/net2share/dnstm/releases/latest/download/dnstm-linux-amd64
chmod +x /usr/local/bin/dnstm
# Restart router after update
dnstm router stop && dnstm router start# Reset tunnel user password
sshtun-user reset-password <SSH_TUNNEL_USER>
# Remove tunnel user
sshtun-user remove <SSH_TUNNEL_USER>
# Create a new tunnel user
sshtun-user create <SSH_TUNNEL_USER>
sshtun-user configure# Check status
systemctl status dns-tun-lb
# Activate load balancer on port 53 (replaces dnstm router)
/opt/dns-tun-lb/activate-lb.sh activate
# Deactivate (return to dnstm router on port 53)
/opt/dns-tun-lb/activate-lb.sh deactivate
# Check current mode
/opt/dns-tun-lb/activate-lb.sh statusAutomated daily backup is already configured at /opt/vpn-backups/backup-vpn-v2.sh (cron: daily at 2 AM UTC, keeps last 7 backups).
#!/bin/bash
# /opt/vpn-backups/backup-vpn-v2.sh
BACKUP_DIR="/opt/vpn-backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/vpn-backup-$TIMESTAMP.tar.gz"
mkdir -p "$BACKUP_DIR"
echo "[$(date)] Starting VPN backup..."
tar -czf "$BACKUP_FILE" \
/opt/reality-ezpz/config \
/opt/reality-ezpz/engine.conf \
/opt/reality-ezpz/docker-compose.yml \
/opt/reality-ezpz/certs/ \
/etc/dnstm/ \
/opt/dns-tun-lb/lb.yaml \
/root/.ssh/authorized_keys \
2>/dev/null
echo "[$(date)] Backup complete: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
# Keep last 7 backups
ls -t "$BACKUP_DIR"/vpn-backup-*.tar.gz 2>/dev/null | tail -n +8 | xargs rm -f 2>/dev/null
echo "[$(date)] Total backups: $(ls "$BACKUP_DIR"/vpn-backup-*.tar.gz 2>/dev/null | wc -l)"# Add to cron
echo "0 2 * * * /opt/vpn-backups/backup-vpn-v2.sh" | crontab -# From your local machine
scp root@<SERVER_IP>:/opt/vpn-backups/vpn-backup-*.tar.gz ~/vpn-backups/# On a fresh server, after base setup:
scp ~/vpn-backups/vpn-backup-LATEST.tar.gz root@<SERVER_IP>:/tmp/
# SSH into new server
ssh root@<SERVER_IP>
tar -xzf /tmp/vpn-backup-*.tar.gz -C /
# Restart VPN services
cd /opt/reality-ezpz
docker compose up -d
# Disable systemd-resolved and restart DNS tunnels
systemctl stop systemd-resolved && systemctl disable systemd-resolved
dnstm router start
# Restart dns-tun-lb
systemctl start dns-tun-lb
# Recreate SSH tunnel user
sshtun-user create <SSH_TUNNEL_USER># Verify xray-core XHTTP inbound is listening (all servers)
for HOST in root@<HEL_IP> ubuntu@<ORC_IP> ubuntu@<GCP_IP> root@<SCW_IP>; do
echo "=== $HOST ==="
ssh $HOST 'sudo docker logs reality-ezpz-xray-1 --tail 3 2>&1 | grep -i xhttp || echo "xray running"'
done
# Test XHTTP through CDN (from outside Iran)
for CDN in cdn cdn2 cdn3 cdn4; do
echo "$CDN: $(curl -s -o /dev/null -w '%{http_code}' https://${CDN}.example.com/xhttp/)"
done# SSH into server, then:
cd /opt/reality-ezpz
docker compose restart xray haproxy
# Verify all 3 backends alive:
docker compose logs haproxy --tail 10
# Should show: bk_xray_xhttp, bk_singbox_ws, bk_xray all UP# From local machine (runs deploy-xhttp.sh):
cd tools/deploy
bash deploy-xhttp.sh # live deployment
bash deploy-xhttp.sh --dry-run # preview only# Deploy to CF Pages (Workers blocked by abuse detection)
cd tools/smart-sub
cp worker.js _worker.js
npx wrangler pages deploy . --project-name your-pages-project
# Verify: curl -s https://sub.example.com/health | python3 -m json.toolNote:
sub.example.comis served by CF Pages projectyour-pages-project, not Workers. Workers.dev returns error 1101 (account-level abuse block). See checklist.md Phase 30 for details.
Current routing on all 4 servers:
| Match | Backend | Container | Port | Transport |
|---|---|---|---|---|
/xhttp/* |
bk_xray_xhttp | xray | 10081 | XHTTP/splithttp (CDN-fronted) |
/ws |
bk_singbox_ws | engine | 8080 | VLESS WebSocket (CDN-WS) |
| HTTP verbs (GET/POST/HEAD/PUT) | bk_xray | xray | 10080 | TCP+HTTP obfuscation (XrayHTTP) |
| Non-HTTP (default) | bk_ss2022 | engine | 10082 | Shadowsocks 2022 (random bytes) |
Track which protocols work on which ISP. Update after each testing session from Iran.
How to test: Connect to each server using each protocol. Record: works / slow / blocked / timeout.
| Protocol | Helsinki | Oracle | GCP | Scaleway | Irancell | MCI | Rightel | Fixed |
|---|---|---|---|---|---|---|---|---|
| XHTTP-CDN | - | - | - | - | ? | ? | ? | ? |
| XrayHTTP | telewebion | myket | aparat | divar | ? | ? | ? | ? |
| Reality | - | - | - | - | ? | ? | ? | ? |
| Hysteria2 | - | - | - | - | ? | ? | ? | ? |
| CDN-WS | - | - | - | - | ? | ? | ? | ? |
| SS2022 | - | - | - | - | ? | ? | ? | ? |
| naiveproxy | N/A | N/A | N/A | Scaleway | ? | ? | ? | ? |
| Cloak | N/A | N/A | N/A | ? | ? | ? | ? | ? |
Legend: OK = works, SLOW = connects but degraded, BLOCK = blocked/timeout, ? = untested, N/A = not deployed
| Host Header | Irancell | MCI | Rightel | Fixed-line | Notes |
|---|---|---|---|---|---|
telewebion.com (Helsinki) |
? | ? | ? | ? | State TV — safest |
myket.ir (Oracle) |
? | ? | ? | ? | App store — high value |
aparat.com (GCP) |
? | ? | ? | ? | Video platform |
divar.ir (Scaleway) |
? | ? | ? | ? | Classifieds |
shad.ir (fallback) |
? | ? | ? | ? | Ministry of Education |
bale.ai (fallback) |
? | ? | ? | ? | Gov messenger |
igap.net (fallback) |
? | ? | ? | ? | Gov messenger |
zula.ir (fallback) |
? | ? | ? | ? | Gaming — most used in wild |
# From inside Iran, for each server:
# 1. XHTTP-CDN (via Cloudflare)
# In Hiddify: try XHTTP-CDN-Finland, XHTTP-CDN-Madrid, etc.
# 2. XrayHTTP (direct, fake HTTP headers)
# In Hiddify: try XrayHTTP-Finland, XrayHTTP-Madrid, etc.
# 3. Reality (TLS with fragment)
# In Hiddify: enable TLS Fragment, try Reality-Finland, etc.
# 4. SS2022 (random bytes)
# In Hiddify: try SS2022-Finland, SS2022-Madrid, etc.
# 5. naiveproxy (Chrome TLS)
# In NekoBox: try Naive-London
# 6. Cloak (TLS camouflage)
# In NekoBox/SS+Cloak: try Cloak-London
# Record results per ISP:
# - Check which ISP you're on: https://whatismyipaddress.com
# - Test each config, note: OK / SLOW / BLOCKED
# - If blocked, note error type: timeout, reset, TLS errorWhen a host gets flagged:
- SSH into affected server
- Edit
/opt/reality-ezpz/xray-config.json - Change
headers.Hostto a fallback host fromconfig/cdn.yaml→whitelisted_hosts docker compose restart xray- Update worker.js
http_obfs.hostfor that server cd tools/smart-sub && wrangler deploy- Users refresh subscription in Hiddify (auto within 4h)
- Check CDN DNS:
dig cdn.example.com— must return Cloudflare IPs (104.x.x.x), NOT server IP - Check CF SSL rule: must have
starts_with(http.host, "cdn")→ Flexible SSL - Check HAProxy:
docker compose logs haproxy --tail 20— look for xhttp backend errors - Check xray-core:
docker compose logs xray --tail 20— look for XHTTP inbound errors - Test from outside:
curl -v https://cdn.example.com/xhttp/— should get response (even error is OK) - If timeout: CF may be throttling — try CFScanner to find clean CF edge IPs
- If 521/522 error: origin server is down or HAProxy routing misconfigured
- Check xray-core running:
docker compose ps— xray container must be Up - Check port 80:
curl -s -o /dev/null -w '%{http_code}' http://<SERVER_IP>/— should return something - Check host header: wrong Host value (blocked by DPI?) — try different whitelisted host
- If works from outside Iran but not inside: the whitelisted host may have been flagged
- Rotate host: update xray-config.json
headers.Hostto a different whitelisted domain
- Check service:
docker compose ps - Check logs:
docker compose logs sing-box --tail=100 - Verify port open:
ss -tlnp | grep 443 - Test from outside:
curl -I https://<SERVER_IP>:443 - Try different SNI target in client config
- Check logs:
docker compose logs sing-box --tail=100(Hysteria2 runs inside sing-box) - Verify UDP port:
ss -ulnp | grep 8443 - Some networks block UDP entirely - switch to TCP-based protocol
- Check tunnels:
dnstm tunnel list - Check DNS router:
dnstm router status - Check DNS:
dig NS t.<DOMAIN>(must resolve correctly) - Check logs:
dnstm tunnel logs slip-socks - Verify port 53:
ss -tulnp | grep 53(must be dnstm, not systemd-resolved) - If systemd-resolved is running:
systemctl stop systemd-resolved && systemctl disable systemd-resolved
- Visit
<CF_PAGES_URL>in browser — should load - Check UUID matches between Pages secret and client config
- Verify Pages is not rate-limited (check Cloudflare dashboard)
- Try redeploying:
wrangler pages deploy pages-dist
If a VPS IP gets blocked:
- Immediate: Hiddify auto-falls back to the other server (Helsinki ↔ France)
- Immediate: Users switch to CF Pages (EDtunnel) - no VPS IP needed
- Immediate: Users switch to Oblivion (WARP) - no server at all
- dnstt should still work (uses DNS, not direct IP)
- Request a new IP from provider (delete server, create new one)
- Or provision a new server and restore from backup
- Users enable TLS Fragment in client settings for better connectivity
- Try different protocol (Hysteria2 often has lower latency than VLESS)
- Check server load:
htop,docker stats - Check if too many users connected
- Consider upgrading VPS plan
| What | Frequency | How |
|---|---|---|
| System updates | Auto (unattended-upgrades) | Automatic |
| User audit | Monthly | Review active users, remove inactive |
| Backup test | Monthly | Download backup, test restore on throwaway server |
| UUID rotation | As needed | If credentials leaked, regenerate |
| SSH key rotation | Annually | Generate new key pair, update server |
- Immediately: Delete the server via Hetzner Cloud dashboard
- Provision new server
- Restore from last clean backup
- Rotate ALL credentials:
- Reality keypair
- User UUIDs
- DNSTT keypair + Slipstream keys
- CF Pages UUID
- Distribute new configs to users
- Investigate how compromise happened
See multi-server.md for full architecture details.
# Helsinki
ssh root@<HEL_IP> 'docker compose -f /opt/reality-ezpz/docker-compose.yml ps && ss -tulnp | grep -E "443|8443|53"'
# Oracle Madrid
ssh ubuntu@<ORC_IP> 'sudo docker compose -f /opt/reality-ezpz/docker-compose.yml ps && sudo ss -tulnp | grep -E "443|8443"'
# GCP Dammam
ssh ubuntu@<GCP_IP> 'sudo docker compose -f /opt/reality-ezpz/docker-compose.yml ps && sudo ss -tulnp | grep -E "443|8443"'
# Scaleway London
ssh root@<SCW_IP> 'docker compose -f /opt/reality-ezpz/docker-compose.yml ps && ss -tulnp | grep -E "443|8443|53"'scp root@<HEL_IP>:/opt/vpn-backups/*.tar.gz ~/vpn-backups/helsinki/
scp ubuntu@<ORC_IP>:/opt/vpn-backups/*.tar.gz ~/vpn-backups/oracle-madrid/
scp ubuntu@<GCP_IP>:/opt/vpn-backups/*.tar.gz ~/vpn-backups/gcp-dammam/
scp root@<SCW_IP>:/opt/vpn-backups/*.tar.gz ~/vpn-backups/scaleway-london/UUID is the only shared credential. When rotating:
- Generate new UUID:
uuidgen - Update on every server (reality-ezpz config + Hy2 password)
- Update CF Pages secret
- Redistribute client configs
| Item | Monthly Cost |
|---|---|
| Helsinki — Hetzner CX23 | |
| Scaleway London — PLAY2-PICO | |
| GCP Dammam — e2-medium | ~$25/mo (covered by $300 credit, ~90 days) |
| Oracle Madrid — ARM Free Tier | $0 |
| Cloudflare (Workers + Pages + DNS) | Free |
| Domain (example.com, Porkbun) | ~$0.83/mo ($10/year) |
| Total | ~$9.23/mo (excluding GCP credit) |
Note: GCP e2-medium is NOT free tier. Delete or downgrade to e2-micro before credits expire (~June 2026).
Before making the repository public, you must scrub all secrets from git history. Even though worker.js no longer contains hardcoded secrets (as of v4.1.0), previous commits still have them embedded.
Critical: After scrubbing, you MUST rotate every credential on all servers. Anyone who cloned/forked the repo before the scrub retains the old history with real values.
# 1. Install BFG
brew install bfg
# 2. Create a replacements file listing every secret value (one per line)
# Format: literal_value==>***REMOVED***
cat > /tmp/secrets-to-scrub.txt << 'EOF'
<ADMIN_UUID_VALUE>==>***REMOVED***
<FAMILY_UUID_VALUE>==>***REMOVED***
<TEST_UUID_VALUE>==>***REMOVED***
<SHADOWTLS_PASSWORD>==>***REMOVED***
<SALAMANDER_PASSWORD>==>***REMOVED***
<SS_USER_KEY>==>***REMOVED***
<FINALMASK_SEED>==>***REMOVED***
<EDTUNNEL_PAGES_URL>==>***REMOVED***
<HEL_IP>==>***REMOVED***
<HEL_IPV6>==>***REMOVED***
<HEL_SS_KEY>==>***REMOVED***
<HEL_NAIVE_PASS>==>***REMOVED***
<ORC_IP>==>***REMOVED***
<ORC_SS_KEY>==>***REMOVED***
<ORC_NAIVE_PASS>==>***REMOVED***
<GCP_IP>==>***REMOVED***
<GCP_SS_KEY>==>***REMOVED***
<SCW_IP>==>***REMOVED***
<SCW_IPV6>==>***REMOVED***
<SCW_SS_KEY>==>***REMOVED***
<SCW_NAIVE_PASS>==>***REMOVED***
<SCW_CLOAK_UID>==>***REMOVED***
<SCW_CLOAK_PUBKEY>==>***REMOVED***
EOF
# ⚠ Replace <PLACEHOLDER> with actual secret values from .dev.vars
# 3. Clone a fresh mirror
git clone --mirror git@github.com:<YOUR-ORG>/polypath.git polypath-mirror
# 4. Run BFG
bfg --replace-text /tmp/secrets-to-scrub.txt polypath-mirror
# 5. Clean up and force push
cd polypath-mirror
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force
# 6. Delete the replacements file
rm /tmp/secrets-to-scrub.txt# 1. Install
brew install git-filter-repo
# 2. Create expressions file (same format as BFG but using regex)
cat > /tmp/expressions.txt << 'EOF'
regex:<ADMIN_UUID_VALUE>==>***REMOVED***
regex:<HEL_IP_VALUE>==>***REMOVED***
# ... one line per secret
EOF
# 3. Run from a fresh clone
git clone git@github.com:<YOUR-ORG>/polypath.git polypath-clean
cd polypath-clean
git filter-repo --replace-text /tmp/expressions.txt --force
# 4. Re-add remote and force push
git remote add origin git@github.com:<YOUR-ORG>/polypath.git
git push --force --all
git push --force --tagsAfter scrubbing history and force-pushing, rotate all of these on every server:
| # | Secret | Where to Rotate |
|---|---|---|
| 1 | ADMIN_UUID | All 4 servers (Xray config) + Wrangler secret |
| 2 | FAMILY_UUID | All 4 servers (Xray config) + Wrangler secret |
| 3 | TEST_UUID | Wrangler secret only |
| 4 | SHADOWTLS_PASSWORD | HEL, SCW (shadow-tls service) + Wrangler secret |
| 5 | SALAMANDER_PASSWORD | HEL, ORC, SCW (Hysteria2 config) + Wrangler secret |
| 6 | SS_USER_KEY | All 4 servers (ss-rust config) + Wrangler secret |
| 7 | FINALMASK_SEED | All 4 servers (finalmask config) + Wrangler secret |
| 8 | HEL_SS_KEY | HEL (ss-rust server key) + Wrangler secret |
| 9 | HEL_NAIVE_PASS | HEL (naiveproxy config) + Wrangler secret |
| 10 | ORC_SS_KEY | ORC (ss-rust server key) + Wrangler secret |
| 11 | ORC_NAIVE_PASS | ORC (naiveproxy config) + Wrangler secret |
| 12 | GCP_SS_KEY | GCP (ss-rust server key) + Wrangler secret |
| 13 | SCW_SS_KEY | SCW (ss-rust server key) + Wrangler secret |
| 14 | SCW_NAIVE_PASS | SCW (naiveproxy config) + Wrangler secret |
| 15 | SCW_CLOAK_UID | SCW (cloak config) + Wrangler secret |
| 16 | SCW_CLOAK_PUBKEY | SCW (cloak config) + Wrangler secret |
| 17 | EDTUNNEL_PAGES | Wrangler secret (CF Pages URL) |
| 18 | Server IPs | Cannot rotate — accept the exposure risk |
# All collaborators must re-clone after force push
# Old clones will have divergent history
# Recreate GitHub releases (tags get rewritten by BFG/filter-repo)
gh release delete v1.0.0 -y && gh release create v1.0.0 --title "v1.0.0" --notes "Initial spec"
gh release delete v2.0.0 -y && gh release create v2.0.0 --title "v2.0.0" --notes "Multi-server"
gh release delete v3.0.0 -y && gh release create v3.0.0 --title "v3.0.0" --notes "Smart-sub worker"
gh release delete v4.0.0 -y && gh release create v4.0.0 --title "v4.0.0" --notes "Protocol expansion"
gh release create v4.1.0 --title "v4.1.0" --notes "Security: externalize all secrets to env vars"