This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
rathole-manager is a multi-location reverse-tunnel system built on rathole + Nginx. A single Iran server (behind one domain, one cert, one port 443) fronts many foreign "nodes" that connect back via reverse tunnel. User traffic is routed to nodes by URL path (map $uri $backend_port in nginx). The primary goal is censorship-resistant tunneling for Iran; almost all code comments and log strings are Persian transliterated into Latin ("Finglish", e.g. tvlid khodkar = auto-generate). Match that style when editing.
The repo root holds packaging/bootstrap scripts; the actual product lives in rathole-manager/.
The system has three distinct runtime roles. Read the file for the role you're touching:
- Iran panel →
rathole-manager/ratholectl(bash). Runs the rathole server + nginx. Owns node inventory. Generates/etc/rathole/server.tomland/etc/nginx/conf.d/rathole.conf. - Foreign node →
rathole-manager/ratholenode(bash). Runs the rathole client. Generates/etc/rathole/client.toml. - Hub →
rathole-manager/ratholehub/hub.py(Python 3, stdlib only, no pip). Central web panel that drives many Iran servers + nodes over SSH. Listens on127.0.0.1(fronted by nginx under/hub/).
rathole-manager/common.sh is sourced by both bash tools (colors/logging, kcp_profile, install_kcptun, backhaul_mux_profile, backhaul_client_transport, install_backhaul, apply_sysctl_tuning, fakeweb_service).
Every mutation follows the same pattern — never hand-edit the generated configs; change state and regenerate:
- ratholectl: state is
/etc/rathole-manager/state.json(jq-manipulated viastate_set/s_get). Commands mutate state, then callregenerate()→gen_server_toml()+gen_nginx_conf()→nginx -t→ reload. Configs are written in place (preserving inode) so rathole'sconfig_watcherhot-reloads without dropping active tunnels.regeneratekeeps a.rathole-good.bakand auto-reverts nginx ifnginx -tfails. - ratholenode: state is
/etc/rathole/node.env(key=value, viaenv_set/load_env) +/etc/rathole/services.conf(name|token|inboundlines).gen_client()buildsclient.toml;reload_svcprefers hot-reload over restart (restart_svconly for transport changes like kcp on/off).
The same tunnel can carry traffic five ways; switching modes never changes user-facing services/tokens/paths — only the transport. Key invariant: TLS is terminated only by nginx — rathole server transport is always tls = false; the default client uses tls = true over websocket to nginx/443.
-
websocket + TLS (default): client connects
wss://domain:443, nginx splits root/between the fake site and the rathole control channel using$http_upgrade(rathole always uses/for control; path isn't configurable in rathole). -
kcp (
cmd_kcpboth sides): parallel UDP+FEC path via kcptun for lossy links (TCP-over-TCP mitigation). Additive — doesn't touch server/nginx/443. Profiles (balanced/lossy/aggressive) must match on both ends; defined incommon.sh:kcp_profile. Multi-Iran nodes run independent kcp per upstream (rathole-kcp-up-<id>, local ports from 29901). -
plain (
cmd_plainboth sides): no-TLS websocket to a separate HTTP listener port (default 8880). Lighter, unencrypted tunnel path. -
backhaul (
cmd_backhaulboth sides,gen_backhaul_server_toml): a separate Go core (Musixal/Backhaul) beside rathole that multiplexes many user connections onto one stream via SMUX — for busy/lossy links where rathole has no mux.backhaul-serverlistens on127.0.0.1:<backhaul_port>(default 3080) and nginx proxies the hardcoded/channel(control) and/tunnel(data) paths to it on the same 443, so single-port/single-domain holds. Three invariants to preserve:- Server transport is always the non-TLS variant (
ws/wsmux) and the client always the TLS one (wss/wssmux) — same rule as rathole;backhaul_client_transportmaps between them and both sides reject the wrong variant.tcpmuxcannot traverse the L7 nginx at all. backhaulis a value of a node's.transport(likenoise), never a separate flag.gen_server_tomlskipsnoise|backhaulnodes — otherwise rathole-server and backhaul-server both bind127.0.0.1:<node.port>and the second one dies. On the node,ratholenode backhaul onstops and disablesrathole-clientfor the same reason.- Port mapping is
"127.0.0.1:<iran_port>=<node_inbound_port>", keeping the port the nginxmap $uripoints at unchanged — so user routing never moves. The mux profile must match on both ends (mux_conis server-only,connection_poolclient-only).
- Server transport is always the non-TLS variant (
-
game / SNI (
ratholectl game,gen_stream_conf): when any node has ansni, port 443 switches to nginx stream/SNI mode (L4 passthrough) and the L7 path/WS vhost moves to an internal port (internal_port, default 8443). TLS for game traffic terminates on the node (real cert, VLESS+TLS+Vision). This is whygen_nginx_confbranches onsni_count.
A node's name is simultaneously its URL path, its nginx map entry, and the Xray inbound path on the node. These three must stay identical. Each node has a data service; adding --api-port also creates a <name>_api service (bound to 127.0.0.1) for panel↔node management over the tunnel.
- Single-file stdlib HTTP server; all UI (HTML/JS/CSS) and i18n (fa/en dicts) are inline.
Handleris the router;main()servesThreadingHTTPServer. - Security-critical: it never runs raw strings on servers.
build_iran_cmd/build_node_cmdmap anaction+ validated args (via theRE_*regexes) to an argv list, executed over SSH with each arg passed separately (run_on_server→_ssh_base). When adding a server action, add it to the rightbuild_*_cmdand the allow-list of actions, and validate every arg with a regex — do not interpolate user input into a shell string. deploy_to_server(the hub "update" button) fetches the latest Releaseinstall.shon the server itself (over SSH, via the ghproxy mirror loop) and runs it with--update— so servers pull from GitHub and no longer depend on the hub's localbundle_dirbeing fresh. The repo slug comes from configgh_repo(defaultloopy-iri/RatholeEngine, validated byRE_SLUG); the remote command is a fixedbash -cscript with only that validated slug interpolated.provision_serverbootstraps SSH key auth.
There is no build step (bash + stdlib Python). Common tasks:
# Test ratholectl end-to-end WITHOUT root/systemd/nginx (sandboxed; stubs need_root/nginx/systemctl):
bash rathole-manager/test-harness.sh
# NOTE: it hardcodes BASE=/mnt/d/... (WSL path) and needs a `jq-linux` binary beside the scripts.
# Run the hub locally with mocked SSH (no real servers touched):
RATHOLEHUB_MOCK=1 RATHOLEHUB_CONF=/tmp/hub-conf.json RATHOLEHUB_INV=/tmp/hub-inv.json \
python3 rathole-manager/ratholehub/hub.py # then open http://127.0.0.1:8088
# Env overrides: RATHOLEHUB_HOST, RATHOLEHUB_PORT, RATHOLEHUB_CONF, RATHOLEHUB_INV, RATHOLEHUB_MOCK.
# Build the distributable zip (LF endings, forward-slash paths — do NOT use Windows Compress-Archive):
bash package.sh # → rathole-manager.zip (bundles rathole-manager/ + docs/; falls back to tar.gz)
# One-command install from GitHub (fetches the latest Release bundle, then runs bootstrap):
curl -fsSL https://raw.githubusercontent.com/loopy-iri/RatholeEngine/main/install.sh | sudo bash -s -- --panel --domain ... --fullchain ... --key ...
# install.sh: defaults to loopy-iri/RatholeEngine; RATHOLE_GH="owner/repo" overrides the repo slug; RATHOLE_RELEASE="vX" pins a version.
# Local install on a fresh server (no download; bundle already present):
sudo bash bootstrap.sh # interactive; asks panel/node/update/rollback
sudo bash bootstrap.sh --local ./rathole-manager.zip --panel --domain ... --fullchain ... --key ...update.sh is the safe upgrade path (auto-detects panel/node/hub). Before touching anything it takes a full snapshot (CLI + configs + systemd units, per role) into /var/backups/rathole-manager/pre-update-<ts>/ (manifest.txt + backup.tar.gz), applies the update, runs a per-role health check (rathole-server/rathole-client/ratholehub active + nginx -t), and auto-rolls-back to the snapshot on failure. Retention = last 7 (env RATHOLE_BACKUP_RETENTION).
-
From the server itself:
ratholectl update/ratholenode update(new subcommands) download the latest Releaseinstall.shfrom GitHub — through the ghproxy mirror loop so it works from inside Iran — and run it with--update(which reaches this sameupdate.shviabootstrap.sh --local). The hub "update" button (deploy_to_server) does the identical thing remotely over SSH. Slug fromRATHOLE_GH(defaultloopy-iri/RatholeEngine), version fromRATHOLE_RELEASE(defaultlatest). -
Manual:
update.sh --list-backups,update.sh --rollback [<ts>],update.sh --no-rollback.bootstrap.shforwards--rollback/--list-backups(and menu entries 5/6) toupdate.sh, using a localupdate.shwithout re-downloading when possible. -
This complements the narrower
.rathole-good.bak(nginx rollback insideregenerate) andratholectl backup/ratholenode backup(state-only tarballs). The rathole binary is not changed byupdate.sh, so it is intentionally excluded from snapshots.
Installers/lifecycle (run on the target server, all need root): rathole-manager/install-panel.sh, install-node.sh, ratholehub/install-hub.sh, update.sh (snapshot + upgrade + health-check + rollback; also finishes partial installs), uninstall-panel.sh, uninstall-node.sh. One-command entry from GitHub: root install.sh (curl-piped) → downloads the Release bundle → bootstrap.sh.
CI/release: .github/workflows/ci.yml (shellcheck + bash -n + py_compile on push/PR) and .github/workflows/release.yml (on tag v*: runs package.sh, uploads rathole-manager.zip + bootstrap.sh + install.sh as Release assets — the exact assets install.sh fetches from releases/latest/download).
- Bash: these scripts intentionally use
set -uo pipefail(not-e) — thejq | while readpattern returns nonzero and would abort under-e; errors are handled explicitly viadie. Keep that. Preserve in-place config writes (inode preservation) so hot-reload keeps working. Temp files go throughrth_mktemp/rth_mktempd(auto-cleaned via trap). - Windows/line-endings: this repo lives on a Windows drive. Scripts must ship with LF endings and be executable;
package.sh/update.sh/bootstrap.shall runsed -i 's/\r$//'defensively. Don't introduce CRLF. - Secrets: certs/keys,
state.json,inventory.json,node.env,services.conf, andconfig.jsonare gitignored and must never be committed. (Note:fullchain.pemcurrently sits at repo root and is gitignored — leave it out of any bundle.)
Documentation lives in docs/ (Persian, except the root README). Diagrams (SVG/PNG) are in docs/assets/.
README.md(repo root) — GitHub landing page, bilingual (English + Persian summary). Embedsdocs/assets/architecture.svgandtransport-modes.svg.docs/README.fa.md— full CLI reference and install flows (Persian; wasrathole-manager/README.md).docs/install-manual.md/docs/install-manual.fa.md— full manual install walkthrough (English + Persian): Iran panel + Pasargad Xray/user config + foreign nodes + hub, step by step, mirroring exactly whatinstall-panel.sh/install-node.sh/install-hub.sh/ratholectl initdo.docs/architecture.md— three roles + the state→regenerate→reload principle (embedsarchitecture.svg,state-regenerate-reload.svg).docs/transport-modes.md— the four transport carriers + game/SNI (embedstransport-modes.svg).docs/traffic-flow.md— packet path layer-by-layer (Mermaid +assets/*.svg; wasTRAFFIC-FLOW.md).docs/hub.md— hub web panel / REST API + security model (embedshub-architecture.svg; mirrorsrathole-manager/ratholehub/README.md).docs/performance.md— tuning beyond the tunnel (BBR, kcp, non-tunnel bottlenecks; wasPERFORMANCE-GUIDE.md).docs/amneziawg-reverse.md— a separate AmneziaWG reverse-tunnel design (not part of the rathole flow; wasAMNEZIAWG-REVERSE.md).rathole-multilocation-pasargad.md(repo root) — original detailed design/troubleshooting doc.
Three hand-authored diagrams are new: docs/assets/transport-modes.svg, hub-architecture.svg, state-regenerate-reload.svg. All diagrams share one visual style (white bg, dark title banner, per-zone pastel boxes, #334155 data-path arrows / #9333ea dashed reverse-tunnel arrows, Segoe UI font). Match it when adding diagrams. package.sh bundles docs/ alongside rathole-manager/.