Technical reference for the proxy-core internals: the sing-box/xray dialer bridge, balancer + failover, the tunnel-aware prober, sidecar config generation, and docker control. Useful for integration, debugging, or extending the client.
moav-client splits real protocol cryptography across two upstream dialers so each one only has to do what it's good at:
- sing-box speaks VLESS / Reality / Trojan / AnyTLS / SS-2022 / Hy2 / WireGuard.
Generator:
proxy-core/singbox/generator.go. Inbound port range:10800+. - xray speaks Xray-only transports — currently
xhttp,splithttp,raw. Generator:proxy-core/xray/generator.go. Inbound port range:11800+. The image is the official XTLS release binary (sidecars/xray/Dockerfile, pinned viaXRAY_VERSION), not a third-party image.
The two generators are mutually exclusive: xray.HandlesEndpoint(ep) only
matches endpoints sing-box would have rejected, so each parsed endpoint is
claimed by exactly one of them. main.go runs sing-box's Generate first
(handles the common case), then xray's Generate over the leftovers; the
balancer just dials whichever socks5_addr the generator pinned and doesn't
need to know which dialer is on the other side.
Both sidecars wait for their config file before launching
(/etc/sing-box/singbox.json and /etc/xray/xray.json), so there's no
circular dependency with proxy-core. If a config file is never written
(e.g. xray.enabled=false, or no xhttp endpoints in the bundle), the sidecar
just idles in the wait loop.
File: proxy-core/singbox/generator.go
Generate(eps, Config) converts each parsed subscription.Endpoint into a
sing-box outbound block (vless w/ Reality + utls, trojan, anytls,
shadowsocks, hysteria2 w/ obfs, vmess, tuic) and pairs it with a SOCKS5 inbound on
Config.ListenHost:Config.BasePort+i plus a 1:1 route rule. The returned
endpoint slice has Config["socks5_addr"] populated with DialHost:port —
which is what balancer.dialThrough and prober.ProbeOne use to reach the
endpoint via sing-box.
Endpoints whose transport is Xray-only (xhttp, splithttp, raw,
unknown) are returned unchanged — no socks5_addr — so the balancer's
legacy path tries to SOCKS5 the upstream directly (which usually fails and
trips the failover loop). They still get probed via raw TCP to ep.Address.
main.go writes the generated JSON atomically (os.WriteFile to .tmp +
os.Rename) to cfg.Singbox.OutputPath (default data/singbox.json). The
sing-box sidecar container waits for that file before launching
(docker-compose.yml entrypoint loop).
There is no circular dependency: sing-box's entrypoint spins on
[ -s /etc/sing-box/singbox.json ] so proxy-core can start first, drop the
file, and sing-box will pick it up within one second.
File: proxy-core/balancer/balancer.go
DialContext tries up to maxDialAttempts (4) different live endpoints before
falling back to direct dial. pickExcluding(map[id]struct{}) mirrors Pick()
but skips IDs that already failed this call — preventing the same broken peer
(e.g. Reality with a server-side handshake bug) from being re-picked just
because its latency is lowest.
On each dial failure: markError(ep.ID) flips its status to "error"
immediately (write lock), so concurrent SOCKS5 connections seen during the
retry window also skip it. The next background probe (30 s) restores it if
the underlying issue resolves.
File: proxy-core/prober/prober.go
When Config["socks5_addr"] is set, the probe is a SOCKS5 CONNECT through
that address to 1.1.1.1:443 (overridable via Prober.Target). The measured
latency is wall-clock for the whole chain: client → sing-box inbound → moav
server → 1.1.1.1. This is what surfaces Reality handshake breakage as
status=error. The fallback (no socks5_addr) is the previous raw TCP
connect against ep.Address.
File: proxy-core/sidecars/configgen.go
SidecarManager.GenerateConfigs(baseDir) writes per-sidecar config files
under baseDir (default data/sidecar-configs/) on every serve startup.
The free-form config: map declared per-sidecar in config.yaml carries
the secrets / paths needed:
- masterdns: emits
masterdns/client_config.toml(withLISTEN_PORT=5300) and a smallmasterdns/client_resolvers.txtof public 53/udp resolvers. - amneziawg: copies the file at
config.source_path(a wg-quick / Amnezia.conf) toamneziawg/awg0.conf. The sidecar'sentrypoint.shreads it, strips wg-quick-only directives (Address/DNS/MTU), feeds the rest toawg setconf awg0, brings up the interface, adds a/32host route for the wg peer through the original eth0 gateway, swings the default route to awg0, and startsmicrosockson:5500. NET_ADMIN +/dev/net/tunare required (set in docker-compose). - trusttunnel: copies the file at
config.source_pathtotrusttunnel/client.toml. The sidecar builds the upstreamTrustTunnel/TrustTunnelClient(pinned viaTT_VERSION), rewrites the bundle's[listener.tun]to a loopback[listener.socks], and republishes it on:5600— no TUN device orNET_ADMINneeded. - psiphon: writes either the verbatim
config_jsonblob or a minimal default topsiphon/psiphon.config. The sidecar builds thePsiphon-Labs/psiphon-tunnel-coreConsoleClient from source.
Each sidecar's docker-compose entry mounts the matching subdirectory at the
container's expected path (e.g. ./data/sidecar-configs/masterdns →
/etc/masterdns). Sidecars are gated by docker-compose profiles
(--profile masterdns, etc.) so docker compose up without flags starts
only the core stack.
File: proxy-core/api/api.go
The API keeps a hub of subscriber channels (clients map[chan []byte]struct{}
under hubMu). On connect to /api/ws, the handler registers a buffered
channel and immediately sends the current endpoint list, then forwards each
broadcast frame to the socket; on disconnect the channel is removed.
broadcast(eps) marshals the pool to JSON and fans out with a non-blocking
select { case ch <- data: default: }, so a slow client drops a frame rather
than stalling the broadcast. It fires after an API-triggered probe
(POST /api/probe). The periodic background probe updates the balancer pool
(SetEndpoints); connected clients see those changes on the next probe
broadcast or a dashboard refresh.
File: proxy-core/sidecars/manager.go
EnabledEndpoints() turns each enabled sidecar into a synthetic balancer
endpoint with Config["socks5_addr"] = "<service>:<port>" (the docker-compose
service name on moav-net) and Config["sidecar_kind"] = <name>, so
balancer.dialThrough and prober.ProbeOne reach it like any other endpoint.
| Sidecar | Address (moav-net) | Default priority |
|---|---|---|
| masterdns | masterdns:5300 |
1 (highest) |
| psiphon | psiphon:5400 |
5 |
| amneziawg | amneziawg:5500 |
5 |
| trusttunnel | trusttunnel:5600 |
5 |
| tor | tor:9150 |
5 |
Priority feeds the priority strategy (ascending — masterdns first) and the
weighted strategy (weight = priority).
File: proxy-core/state/state.go
State struct:
type State struct {
LastProbeAt time.Time `json:"last_probe_at"`
Endpoints []subscription.Endpoint `json:"endpoints"`
}What is saved: The full Endpoint slice including LatencyMs, Status, and all Config map entries. RawURI is the restore key in main.go.
Atomic write: Save(path) writes to path + ".tmp" first, then os.Rename(tmp, path). On POSIX systems, rename is atomic — a crash between write and rename leaves the old file intact. On Windows, rename may fail if the destination exists; this is not handled.
Restore logic (main.go): After subscription parsing, a stateByURI map[string]Endpoint is built from saved state. For each newly parsed endpoint, if stateByURI[ep.RawURI] exists, its LatencyMs and Status are copied in. This gives the balancer an initial view of endpoint health without waiting for a probe.
State path: Hardcoded as "data/state.json" in main.go. Docker Compose mounts ./data as a volume so state survives container restarts.
File: docker-compose.yml
All services join the moav-net bridge and address each other by service name
(e.g. proxy-core:8088, singbox:10800, xray:11800, masterdns:5300).
Host-exposed ports — the bind address is .env-driven (127.0.0.1 by default,
0.0.0.0 for LAN/public via the Network exposure setting):
| Service | Host | Container | Purpose |
|---|---|---|---|
| proxy-core | 1080 | 1080 | SOCKS5 |
| proxy-core | 8081 | 8080 | HTTP CONNECT |
| proxy-core | 8088 | 8088 | REST / WebSocket API |
| web-ui | 3001 | 3000 | dashboard (nginx) |
The dashboard talks to the API same-origin — nginx reverse-proxies /api to
proxy-core, so the browser only needs :3001. For local dev
(npm run dev), the API target defaults to http://localhost:8088 (override
with VITE_API_URL).
proxy-core, web-ui, singbox, and xray have no profile and always start;
optional services are gated so a bare docker compose up runs only the core
stack.
| Profile | Service | Image / build |
|---|---|---|
masterdns |
masterdns | ./sidecars/dns-tunnels |
amneziawg |
amneziawg | ./sidecars/amneziawg |
psiphon |
psiphon | ./sidecars/psiphon |
trusttunnel |
trusttunnel | ./sidecars/trusttunnel |
tor |
tor | peterdavehello/tor-socks-proxy |
sni-spoof |
sni-spoof | ./sidecars/sni-spoof |
https |
caddy | caddy:2 (HTTPS termination) |
all-sidecars |
all of the above | — |
docker compose --profile tor up -d # core + Tor
moavc sidecar add tor # the wrapper does this for youNamed volumes (psiphon-data, caddy-data, caddy-config) persist across
restarts; ./data is bind-mounted for config, state, and generated sidecar
configs.