tppr 0.13.0 — a global pool, LAN-reachable proxy ports, an optional dispatcher, pinned accounts, local pool aggregation, and custom probe checks.
Vibe-coded side project, built to demo a fun idea: a self-hosted proxy router.
tppr-mihomo-relayexposes HTTP/SOCKS inside Docker and publishes the proxy ports on all host interfaces (HOST_MIXED_PORT, 17890 in the checked-in example). The controller stays on127.0.0.1only. ThePOOLselector is the production route; the separateTEST_POOLselector is used only by the speed tester, so benchmarks never reroute live Telegram or YouTube connections.- Health loop: only the current production node is checked every 10s. On failure, candidates are latency-probed in batches of 100 with 100 workers; failover stops after 1 good candidate; a scheduled sweep collects up to 5 good candidates and benchmarks at most 5 total nodes including production.
- Slow loop (
tppr-speed-tester, default every 300s): downloads a test file through up to 5 total nodes (the current production node plus up to 4 discovered candidates), measures throughput, then updates onlyPOOLto the fastest healthy node. Results persist in SQLite (tester-datavolume): nodes failing 3 sweeps in a row are skipped but retried once failures age pastHISTORY_COOLDOWN_H; old rows are pruned. Mihomo's persisted production selection is kept on startup; stale or dead selections go through speed-tested failover. No background health checks run across the full provider. - Routing: Telegram and YouTube go through
POOL, everything else isDIRECT. - Custom upstream proxies live in
mihomo/providers/mine.yamland are inlined into the generated config; the generator is the only writer of the active config, soscripts/reload.ps1is the only supported way to apply changes.
- Each
[[services]]block in the gitignoredservices.tomlgets a group and, unless the service is reachable through the dispatcher, a fixed port reachable from the LAN. Ports are declared in that file, not in.env, and are generated intodocker-compose.override.yml. The controller API is the only thing still bound to127.0.0.1. - A service selects upstream nodes in one of three ways:
subscriptions = ["name", ...]— every node of those providers;primary = "proxy-name"— one exact proxy, optionally followed byfallback = ["other-proxy"]and the providers insubscriptions;select = true— a plain list that never moves on its own; an external prober steers it (see Custom probe checks).
- Service groups are Mihomo
fallbackgroups, so a dead node is skipped for the next one.empty-fallback: REJECTmeans an empty provider rejects the connection instead of leaking traffic direct.
- Mihomo's own health check is a GET against one URL, which is not enough when
an endpoint only answers meaningfully to a POST with headers and a body. Two
pieces cover that:
[subscriptions.health]overrides the provider health check for one subscription:url,interval,timeout,max_failed_times,expected_status, each falling back to the global[health]values. A service may not mix providers with different checks — that is a generation error, not a silent choice.post-prober/steers aselect = trueservice with a real POST. Everyinterval_sit sends the configured request through the group's current node via the dispatcher and expects one exact status. Anything else means reroll: it switches to the next candidate immediately instead of waiting, so a node that answers403 RegionError(wrong country) is abandoned in about a second. If nothing answers as expected, the group stays put and the next round retries from the top.
- The prober is a separate container because its job is a steering loop, not
node admission. It reads the dispatcher port and the account password from
services.toml(one source for both) and verifies at startup thattarget.groupandtarget.providerreally belong to the account it probes — a typo there aborts instead of quietly steering nothing. - Request shape lives in the gitignored
post-prober.tomlandpost-prober-body.json; copy the.examplefiles to start. It carries the URL, method, headers, body file, expected status and interval. - Each completed round touches a heartbeat file, and the container reports
unhealthy when no round lands within
POST_PROBER_HEARTBEAT_MAX_AGE_S(default 180 s, keep it aboveinterval_s). "Running" and "still working" stop being the same claim.
- Add one
[[subscriptions]]block per remote feed; Mihomo pulls it natively as aproxy-provider, so Clash/Mihomo YAML, URI lists, and base64 subscriptions all work without a converter:[[subscriptions]] name = "my-vless" url = "https://example.com/subscription" interval = 21600
- Subscription URLs can contain tokens; keep
services.tomllocal and do not commit it.
- Copy
pool-sources.example.tomltopool-sources.tomland assign the ten sources to named pools. The file is gitignored because source URLs may contain tokens. - The
pool-aggregationCompose profile starts SubConv and an internal pool worker. No worker or SubConv port is published to Windows. - The worker calls SubConv's
/providerendpoint, accepts Clash/URI/base64 output, removes cross-source duplicates by canonical node fingerprint, and writes deterministic pool files such asmihomo/providers/pool-free-pool.yaml. - Sources are fetched in parallel (
worker.max_workers). A dead source is reported asdegradedin/healthzand skipped instead of taking the other pools down; the last good pool snapshot is kept when every source fails. - The worker also drops nodes Mihomo cannot parse. Mihomo empties an entire
proxy-providerwhen a single node fails to initialize, so one free node with an unsupported shadowsocks cipher or a malformed key would otherwise take the whole pool down. The count is reported asdropped_nodesin/healthz. - The worker then probes every remaining HTTP/SOCKS5 node with a real HTTP(S) exchange through the proxy tunnel (
common/probe.py): method, headers, body and cookies are configurable in the[check]section, and[check.expect]decides what counts as live — status codes or2xx-style classes, header/body/cookie matches, and a latency ceiling. Without[check.expect]any completed exchange is live. Only passing nodes reach a pool file (checked_nodes/dead_nodesin/healthz). Other protocols pass through unchecked for now. max_nodescaps a pool at 1000 nodes; raiseMAX_NODESinpool-worker/worker.pyto go higher.- Reference pool names from
services.tomlwithsubscriptions = ["free-pool"]. A source may appear in several pools; a node is assigned to one pool by stable hash unless the pool is markedshared = true. - Pools use Mihomo fallback failover. Per-pool throughput rotation is not enabled; the existing speed tester manages only the global
POOL. reload.ps1enables the profile automatically whenpool-sources.tomlexists.
- Add one optional authenticated entry point; fixed per-service ports keep working unchanged:
[dispatcher] port = 20000
- Add
usernameandpasswordto any service that should be reachable through it. A service with credentials no longer needs its ownport, so hundreds of accounts can share the single dispatcher port:[[services]] name = "acc01" primary = "my-proxy-01" subscriptions = ["my-vless"] username = "acc01" password = "change-me-at-least-8"
- Mihomo authenticates the user and routes the connection with
IN-USERtoSVC_acc01. Usehttp://acc01:change-me-at-least-8@127.0.0.1:20000orsocks5://acc01:change-me-at-least-8@127.0.0.1:20000in clients that support proxy authentication. - Services without credentials remain available only on their fixed port. Passwords stay in the gitignored
services.toml. MAX_SERVICESis 1000. Exceeding it names the constant and the file to edit.scripts/new_accounts.pywrites account blocks in bulk; seescripts/README.md.
primarynames one proxy frommihomo/providers/mine.yaml. Mihomo does not resolve provider proxies by name, so the generator inlines those definitions into the generated config; a name that is not in the file fails generation instead of starting a dead group.- With
primaryplussubscriptions, the group is afallbackchain: the pinned proxy first, then the free pool. When the pinned proxy dies the account silently continues on a free node. lock_proxy = trueremoves every fallback source for that account. When its proxy is down the connection is rejected instead of leaving through a free node. The generator refuses to combinelock_proxywithfallback/subscriptions.fallback = ["name"]inserts extra named proxies between the primary and the free pool.balance = "round-robin"turns the group into a load-balance pool: every new connection exits through the next node instead of sticking to one. It needssubscriptionsand forbidsprimary,fallbackandlock_proxy. The effective variety equals the currently healthy nodes, so a free pool rotates between its survivors.- One node per service, proxies may repeat. A node is a named entry in
mine.yaml; a paid proxy is the upstream account behind it. Several nodes may share one paid proxy (same creds under different names) — that is normal. Each service still gets its OWN node. A service without its own node gets no primary; pool-only is an outage waiting to happen, not a fallback.
- Port
17894serves the all-pools mix without touching Mihomo (override withHASH_GATE_ALL_PORT). The free-only and custom-only gateways keep listening inside the compose network; publish17895/17896to enable them. - Every connection exits through a uniformly random pool node. Authentication
only gates lifetime: the username is any name (
randomworks), the password is a TTL in seconds, from 5 up to 30 days. The countdown starts at the first request and is stored in SQLite, so restarts do not extend it. An expired credential is rejected like a wrong password. - A dead pick never fails the client outright: the gateway retries against up to 5 other random nodes of the same pool before giving up.
- Only HTTP and SOCKS5 upstreams are forwardable, so roughly two thirds of a pool are reachable through these ports. The rest needs the Mihomo groups.
reload.ps1starts the gate with the pool profile and waits for it to report healthy before applying anything else.
17890(fromHOST_MIXED_PORT) is thePOOLgroup: subscriptions, local pools, and custom proxies.- Two optional extra endpoints split that mix:
[global_pools] free_port = 17891 # FREE group: subscriptions and local pools only custom_port = 17892 # CUSTOM group: mihomo/providers/mine.yaml only
- Both share the LAN-reachable binding with every other proxy port in the stack; only the controller stays on
127.0.0.1. - A port that has no matching source is a generation error:
custom_portwithout custom proxies, orfree_portwithout any subscription or pool.
Copy-Item .env.example .env
Copy-Item services.example.toml services.toml
# ports default to 7890/9090 — change them in .env, README examples follow .env
# private upstreams are optional — free pool works alone:
# Copy-Item mihomo\providers\mine.yaml.example mihomo\providers\mine.yaml
# local pool aggregation is optional — copy pool-sources.example.toml to
# pool-sources.toml to switch the pool-aggregation profile on.
powershell -ExecutionPolicy Bypass -File .\scripts\reload.ps1
curl.exe --proxy http://127.0.0.1:7890 https://www.gstatic.com/generate_204 -o NUL -w "%{http_code}`n"Add another [[services]] block with a fixed localhost port in services.toml, then run scripts/reload.ps1. Service ports do not need variables in .env.
# list production members (not a speed ranking)
Invoke-RestMethod http://127.0.0.1:9090/proxies/POOL | Select-Object -ExpandProperty all
# pin a node
Invoke-RestMethod -Method Put http://127.0.0.1:9090/proxies/POOL -Body '{"name":"node-17"}' -ContentType 'application/json'
# pool worker health, including per-source node counts and dropped nodes
docker exec tppr-pool-worker python -c "import urllib.request;print(urllib.request.urlopen('http://127.0.0.1:8080/healthz').read().decode())"Controller and mixed ports in those examples are container ports; on the host use the values of HOST_CONTROLLER_PORT and HOST_MIXED_PORT from .env.
git remote add origin https://github.com/<you>/tppr.git
git push -u origin master.env, services.toml, pool-sources.toml, mihomo/providers/mine.yaml, and mihomo/generated/ are gitignored — secrets never leave the machine.
docker-compose.yml/.env.example— topology and tunables (single source).mihomo/config.base.yaml— template: isolated production/test groups, internal speed-test listener and TG/YT rules.services.example.toml/services.toml— dispatcher, subscriptions, pinned proxies, and fixed-port service-to-proxy mapping; one block can be copied for each service or account, and the same proxy may appear in multiple blocks.pool-sources.example.toml/pool-sources.toml— remote source catalog, pool membership, caps, and shared-pool policy.scripts/generate.py— validates the mapping and generates active config/Compose override.scripts/new_accounts.py— writes dispatcher account blocks with generated passwords.pool-worker/worker.py— SubConv client, node validation, canonical dedupe, deterministic pool builder, and internal pool HTTP endpoint.mihomo/providers/mine.yaml.example— template for private upstreams.mihomo/generated/config.yaml— generated active config; never edit it by hand.speed-tester/tester.py— throughput sweeps (stdlib only, structured logs).speed-tester/history.py— SQLite sweep history and cooldown tracking.post-prober/prober.py— POST probe that steers oneselect = trueservice.post-prober.example.toml/post-prober-body.example.json— request-shape templates; the live copies are gitignored.scripts/README.md— what each script does and every error it can raise.api/— management REST API on127.0.0.1:18080(Bearer token): accounts CRUD, status, hash-lease revocation.scripts/smoke.ps1— config + syntax sanity checks.scripts/reload.ps1— regenerate, wait for the pool worker, validate with Mihomo, then recreate the relay and tester.AGENTS.md— contributor onboarding: architecture, workflows, verification commands, and verified Mihomo behaviors.