- JavaScript 98.5%
- Dockerfile 1.5%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| config | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .eslintrc.js | ||
| .gitignore | ||
| AGENTS.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| jest.config.js | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
3x-ui Subscription Aggregator
A secure, lightweight Node.js service that aggregates proxy subscriptions from multiple MHSanaei/3x-ui panel servers into a single endpoint — available as either a raw base64 subscription payload or a ready-to-use Clash / Mihomo YAML configuration.
How It Works
/sub/ — Base64 subscription (v2rayN, Shadowrocket, …)
- A subscription client requests
GET /sub/<uuid>. - The aggregator fans out requests to every enabled server in
config/servers.json. - Servers that don't recognise the UUID (404 / network error) are silently ignored.
- All proxy entries from successful responses are decoded, deduplicated, and re-encoded as a single base64 payload returned to the client.
subscription-userinfotraffic counters are summed across servers (upload/download/total) and the earliest expiry is used.
/clash/ — Clash / Mihomo YAML configuration
- A Clash client requests
GET /clash/<uuid>. - The same fan-out, deduplication, and optional
proxyDomainrewriting steps run as for/sub/. - Each collected proxy URI is parsed and converted into a native Clash proxy object (see Supported proxy schemes).
- The converted proxies are injected into a built-in Clash configuration template containing DNS settings, three proxy groups, and a standard rule set.
- The complete YAML document is returned directly — no further client-side configuration required.
proxyDomain — Per-server hostname rewriting (both endpoints)
When proxyDomain is set on a server entry, the aggregator rewrites the
destination hostname inside every proxy URI returned by that server before
combining it with entries from other servers.
This is useful when:
- Your panel advertises the server's internal / direct IP but clients should connect through a CDN edge node or reverse-proxy instead.
- You want all traffic for a given panel to funnel through a single domain regardless of the underlying IP.
Rewriting is applied per-server and per-scheme, and affects both the
/sub/ and /clash/ endpoints:
| Scheme | Rewrite strategy |
|---|---|
vmess |
Decodes the base64 JSON payload, replaces the add field |
vless |
Replaces the hostname in the userinfo@host:port authority |
trojan |
Replaces the hostname in the password@host:port authority |
ss |
Replaces the hostname in the userinfo@host:port authority |
hysteria2 |
Replaces the hostname in the password@host:port authority |
tuic |
Replaces the hostname in the uuid:pass@host:port authority |
| others | Same authority-based rewrite; original returned on parse failure |
The port number is always preserved. All other URI components (path, query, fragment, userinfo) are left unchanged.
cacheTTL — In-memory response caching
By default, aggregated responses are cached in memory for 1 day (86 400 s) so that repeated requests for the same subscription ID do not trigger fresh fan-out queries to every upstream server.
- The cache is keyed by subscription ID and endpoint type (
/sub/and/clash/are cached independently). - On a cache hit the upstream servers are not contacted at all.
- When caching is active the
Cache-Controlresponse header is set topublic, max-age=<cacheTTL>instead ofno-store. - Set
"cacheTTL": 0inconfig/servers.jsonto disable caching entirely and restore the previous always-fetch behaviour. - Maximum allowed value is 604 800 (7 days).
- The cache lives in process memory and is cleared on restart.
Quick Start
1. Install dependencies
npm ci
2. Create your server config
cp config/servers.example.json config/servers.json
# Edit config/servers.json with your actual panel URLs
3. Set environment variables
cp .env.example .env
# Edit .env as needed
4. Run in development
npm run dev
5. Fetch a subscription
# Raw base64 payload (v2rayN, Shadowrocket, …)
GET http://localhost:3000/sub/<your-subscription-uuid>
# Full Clash / Mihomo YAML configuration
GET http://localhost:3000/clash/<your-subscription-uuid>
Docker
# Build and start
docker compose up --build -d
# View logs
docker compose logs -f
Note:
config/servers.jsonis bind-mounted into the container at runtime and is never baked into the image.
Endpoints
GET /sub/:subscriptionId
Returns a combined, base64-encoded proxy subscription payload.
| Condition | Response |
|---|---|
subscriptionId is not a UUID |
400 Bad Request |
| No server recognises the UUID | 404 Not Found |
| One or more servers respond | 200 OK — text/plain base64 payload |
Response headers:
| Header | Value |
|---|---|
Content-Type |
text/plain; charset=utf-8 |
subscription-userinfo |
Summed traffic counters (when present) |
Cache-Control |
public, max-age=<cacheTTL> when caching is enabled; no-store, no-cache, must-revalidate when disabled |
GET /clash/:subscriptionId
Returns a complete Clash / Mihomo YAML configuration with all aggregated proxies pre-configured.
| Condition | Response |
|---|---|
subscriptionId is not a UUID |
400 Bad Request |
| No server recognises the UUID | 404 Not Found |
| One or more servers respond | 200 OK — text/yaml Clash config |
Response headers:
| Header | Value |
|---|---|
Content-Type |
text/yaml; charset=utf-8 |
Content-Disposition |
inline; filename="clash.yaml" |
Cache-Control |
public, max-age=<cacheTTL> when caching is enabled; no-store, no-cache, must-revalidate when disabled |
The returned YAML is ready to be imported directly into Clash Meta / Mihomo or any compatible client (Clash Verge, OpenClash, etc.).
POST /cache/clear
Clears the entire in-memory cache. The next request to /sub/:subscriptionId
or /clash/:subscriptionId will fan out to the upstream servers and repopulate
the cache from scratch.
Requires the CACHE_CLEAR_TOKEN environment variable to be set. Callers must
supply the matching value as a bearer token.
Request header:
Authorization: Bearer <CACHE_CLEAR_TOKEN>
All responses return a JSON body. On success success is true; on failure success is false and an error string describes the problem.
| Condition | Status | Body |
|---|---|---|
CACHE_CLEAR_TOKEN is not configured |
503 Service Unavailable |
{"success":false,"error":"Cache clear endpoint is not configured."} |
| Token missing or incorrect | 401 Unauthorized |
{"success":false,"error":"Unauthorized."} |
| Token correct | 200 OK |
{"success":true} |
Example:
curl -X POST http://localhost:3000/cache/clear \
-H "Authorization: Bearer your-secret-token"
Clash Configuration Template
Every /clash/ response is assembled at request time from the aggregated
proxies, proxy groups, rule providers, and rules. No static base configuration
is applied — clients receive only the dynamic sections populated from the
upstream subscription data.
Proxy groups
Two groups are generated automatically from the proxy list:
| Group | Type | Members |
|---|---|---|
🚀 Proxy |
select |
⚡ Auto Select, every proxy, DIRECT |
⚡ Auto Select |
url-test |
Every proxy (latency-sorted, 300 s interval) |
If no usable proxies are found, 🚀 Proxy contains only DIRECT.
Rules
Rules are applied using external rule providers from
code.paulg.it/paulgit/clash-rules:
| Rule | Action |
|---|---|
RULE-SET,applications |
DIRECT |
RULE-SET,private |
DIRECT |
RULE-SET,reject |
REJECT |
RULE-SET,icloud |
DIRECT |
RULE-SET,apple |
DIRECT |
RULE-SET,google |
🚀 Proxy |
RULE-SET,proxy |
🚀 Proxy |
RULE-SET,direct |
DIRECT |
RULE-SET,lancidr |
DIRECT |
RULE-SET,cncidr |
DIRECT |
RULE-SET,telegramcidr |
🚀 Proxy |
GEOIP,LAN |
DIRECT |
GEOIP,CN |
DIRECT |
MATCH (catch-all) |
🚀 Proxy |
Supported Proxy Schemes for Clash
The following URI schemes are parsed and converted to native Clash proxy objects. Entries that cannot be parsed are silently dropped.
| Scheme | Clash type | Notable fields extracted |
|---|---|---|
vmess:// |
vmess |
UUID, alterId, cipher, TLS, SNI, network (WS/gRPC/H2), fingerprint |
vless:// |
vless |
UUID, TLS/REALITY, SNI, network, flow (XTLS), fingerprint |
trojan:// |
trojan |
Password, SNI, ALPN, network |
ss:// |
ss |
Cipher, password — SIP002 base64, plaintext, and legacy formats |
hysteria2:// |
hysteria2 |
Password, SNI, obfs/obfs-password, pinSHA256 |
hy2:// |
hysteria2 |
Alias for hysteria2:// |
tuic:// |
tuic |
UUID, password, SNI, congestion controller, UDP relay mode, ALPN |
Supported transport networks: ws (WebSocket), grpc, h2 (HTTP/2), tcp
Configuration (config/servers.json)
| Field | Type | Default | Description |
|---|---|---|---|
servers[].name |
string | — | Human-readable label for this panel |
servers[].baseUrl |
string | — | Full base URL of the panel, e.g. https://… |
servers[].subPath |
string | /sub |
Subscription path prefix on the panel |
servers[].enabled |
boolean | true |
Set to false to skip this server |
servers[].proxyDomain |
string | — | If set, replaces the hostname in every proxy entry from this server |
requestTimeout |
number | 10000 |
Per-server request timeout in ms |
maxConcurrentRequests |
number | 10 |
Max simultaneous upstream requests |
cacheTTL |
number | 86400 |
Response cache TTL in seconds (0 to disable, max 604800 = 7 days) |
Example config/servers.json:
{
"servers": [
{
"name": "Panel 1 — behind CDN",
"baseUrl": "https://panel1.example.com:2053",
"subPath": "/sub",
"proxyDomain": "cdn.example.com",
"enabled": true
},
{
"name": "Panel 2 — direct IP",
"baseUrl": "https://panel2.example.com:2053",
"subPath": "/sub",
"enabled": true
}
],
"requestTimeout": 10000,
"maxConcurrentRequests": 10,
"cacheTTL": 86400
}
In this example every proxy entry from Panel 1 will have its hostname
replaced with cdn.example.com, while entries from Panel 2 are returned
as-is. The rewriting applies to both the /sub/ and /clash/ endpoints.
Environment Variables
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Port the HTTP server listens on |
HOST |
0.0.0.0 |
Address the HTTP server binds to |
NODE_ENV |
— | Set to production in deployed environments |
LOG_LEVEL |
info |
Pino log level (fatal, error, warn, info, debug, trace) |
TRUST_PROXY |
— | Express trust proxy value (e.g. 1, loopback, a CIDR range). Leave unset to disable |
RATE_LIMIT_WINDOW_MS |
60000 |
Rate-limit sliding window in milliseconds |
RATE_LIMIT_MAX |
30 |
Maximum requests per window per IP |
SERVERS_CONFIG_PATH |
config/servers.json |
Path to the server configuration file |
CORS_ALLOWED_ORIGINS |
— | Comma-separated allowlist of origins permitted to make cross-origin requests (e.g. https://example.com,https://app.example.com). When unset or empty, all cross-origin requests are blocked |
CACHE_CLEAR_TOKEN |
— | Bearer token required to call POST /cache/clear. When unset the endpoint returns 503 and is effectively disabled |
See .env.example for a ready-to-use template.
Security
- Non-root Docker user
helmetsecurity headers- Per-IP rate limiting
- Read-only container filesystem
- No secrets in source code or image
- CORS with explicit origin allowlist
- Container image scanned for CVEs (Trivy) in CI before publishing
Testing
npm test # Unit tests (233 tests)
npm run test:int # Integration tests (21 tests)
npm run test:cov # Full suite with coverage report (254 tests total)
npm audit # Dependency audit
Coverage summary
| Area | Statements | Branches | Functions | Lines |
|---|---|---|---|---|
| All files | 96.6% | 85.9% | 100% | 98.3% |
src/utils/clashConverter |
94.3% | 81.2% | 100% | 98.4% |
src/config/clashTemplate |
100% | 100% | 100% | 100% |
src/services/clashAggregator |
100% | 100% | 100% | 100% |
src/routes/clash |
96% | 100% | 100% | 96% |
Vulnerability Scanning
Container images are scanned with Trivy as part of the CI pipeline (see CI/CD Pipeline). The policy is zero fixable Critical or High vulnerabilities — any such finding fails the build before the image is pushed.
Scanning locally
There is no project-specific scan wrapper; run Trivy directly against a locally built image:
# Build the image locally
docker build -t 3x-ui-sub-aggregator:dev .
# Scan it, mirroring the CI policy (fail on fixable High/Critical)
trivy image \
--exit-code 1 \
--ignore-unfixed \
--severity HIGH,CRITICAL \
3x-ui-sub-aggregator:dev
CI/CD Pipeline
CI runs on Forgejo Actions via .forgejo/workflows/docker-publish.yml. It is
triggered by pushing a v*.*.* tag or by a manual workflow_dispatch — not on
every push or pull request. The workflow has three jobs:
Job 1 — validate (Lint, Test & Audit)
Runs in the code.paulg.it/paulgit/node-ci:24 container.
| Step | Command | Gate |
|---|---|---|
| Install | npm ci |
— |
| Lint | npm run lint |
Zero warnings/errors |
| Unit tests | npm test |
All pass |
| Integration tests | npm run test:int |
All pass |
| Dependency audit | npm run audit:deps |
Zero High/Critical |
Job 2 — docker-build (needs validate)
The image is built once, loaded into the local daemon, scanned, and only then pushed — the published image is byte-for-byte the one that passed the scan.
| Step | Description | Gate |
|---|---|---|
| Version guard | Asserts the pushed tag equals v<package.json version> |
Tag matches version |
| Build | Builds the production image once via docker/build-push-action (loaded) |
Build succeeds |
| Trivy scan | Scans that exact image for OS + npm vulnerabilities (DB cached) | Zero fixable High/Critical |
| Push | On a tag push, pushes the scanned :<version> and :latest images |
Push succeeds |
Job 3 — notify (needs validate + docker-build, always runs)
Sends a Pushover notification reporting the combined result of the validate and
docker-build jobs, so a failure in either is reported.
A workflow_dispatch run builds a :<version>-dev-<sha> image for verification
without pushing it. Releases are cut by bumping version in package.json,
committing, and pushing a matching v<version> tag.
Required repository secrets: REGISTRY_USER, REGISTRY_TOKEN,
PUSHOVER_APP_TOKEN, PUSHOVER_USER_KEY.