A secure, lightweight Node.js service that aggregates proxy subscriptions from multiple MHSanaei/3x-ui panel servers into a single subscription endpoint.
  • JavaScript 98.5%
  • Dockerfile 1.5%
Find a file
Paul Git 0850c02d73
All checks were successful
Docker Publish / Lint, Test & Audit (push) Successful in 22s
Docker Publish / Build, Scan, and Push Docker Image (push) Successful in 2m31s
Docker Publish / Notify (push) Successful in 5s
chore: bump version to 2.7.7
Co-Authored-By: Cline with glm-5.2
2026-07-13 22:03:47 +08:00
.forgejo/workflows Add debug logging for config/subscriptions; improve CI notify format 2026-06-18 22:18:11 +07:00
config Add configurable in-memory response cache 2026-04-15 18:40:06 +08:00
src fix(security): hardcode skip-cert-verify false for hysteria2 and tuic parsers 2026-07-13 22:02:10 +08:00
tests fix(security): hardcode skip-cert-verify false for hysteria2 and tuic parsers 2026-07-13 22:02:10 +08:00
.dockerignore Add initial project scaffold with Docker 2026-04-12 09:37:46 +08:00
.env.example Move CI from local scripts to Forgejo Actions workflow 2026-06-07 09:50:40 +07:00
.eslintrc.js Support TRUST_PROXY and add ESLint no-unused-vars 2026-04-12 14:21:11 +08:00
.gitignore Add CI workflow and Grype image scanning 2026-04-12 15:36:37 +08:00
AGENTS.md fix: mask subscription UUIDs in error handler logs 2026-07-13 00:24:55 +08:00
docker-compose.yml Move CI from local scripts to Forgejo Actions workflow 2026-06-07 09:50:40 +07:00
Dockerfile Harden CI workflow: single build, Trivy cache, full-pipeline notify 2026-06-07 12:55:01 +07:00
jest.config.js Add initial project scaffold with Docker 2026-04-12 09:37:46 +08:00
package-lock.json chore: bump version to 2.7.7 2026-07-13 22:03:47 +08:00
package.json chore: bump version to 2.7.7 2026-07-13 22:03:47 +08:00
README.md Harden CI workflow: single build, Trivy cache, full-pipeline notify 2026-06-07 12:55:01 +07:00

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, …)

  1. A subscription client requests GET /sub/<uuid>.
  2. The aggregator fans out requests to every enabled server in config/servers.json.
  3. Servers that don't recognise the UUID (404 / network error) are silently ignored.
  4. All proxy entries from successful responses are decoded, deduplicated, and re-encoded as a single base64 payload returned to the client.
  5. subscription-userinfo traffic counters are summed across servers (upload/download/total) and the earliest expiry is used.

/clash/ — Clash / Mihomo YAML configuration

  1. A Clash client requests GET /clash/<uuid>.
  2. The same fan-out, deduplication, and optional proxyDomain rewriting steps run as for /sub/.
  3. Each collected proxy URI is parsed and converted into a native Clash proxy object (see Supported proxy schemes).
  4. The converted proxies are injected into a built-in Clash configuration template containing DNS settings, three proxy groups, and a standard rule set.
  5. 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-Control response header is set to public, max-age=<cacheTTL> instead of no-store.
  • Set "cacheTTL": 0 in config/servers.json to 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.json is 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 OKtext/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 OKtext/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
  • helmet security 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.