Sign inSign up

hteppl/remnawave-subpage-proxy

By hteppl

•Updated 1 day ago

Live templates, conditions and host shuffling for Remnawave (https://docs.rw) subscriptions.

Image
Content management system
0

1.3K

hteppl/remnawave-subpage-proxy repository overview

remnawave-subpage-proxy

⁠remnawave-subpage-proxy

Release Docker Image Build Go License: GPL v3

English | Русский⁠

An announce is defined in the panel or in the proxy configuration:

Used {TRAFFIC_USED} of {TRAFFIC_LIMIT} · {DAYS_LEFT} days left

Every client receives it with the values resolved:

Used 10.50 GB of 100.00 GB · 12 days left

⁠Features

  • Fallback Cache - Optionally replays the last good subscription while Remnawave is unreachable
  • Force Unlimited - Optionally reports every plan as unlimited, whatever quota the panel holds
  • Scanner Blocking - Probes for /.env, /.git and the like are refused before they reach the panel
  • Broken User-Agent Notice - A link or JSON pasted as the User-Agent gets a message instead of the servers
  • Rich Placeholders - Formatted dates, ∞ for unlimited plans, percentages, a progress bar and modifiers on top of the panel's own {{VAR}} templates
  • Conditional Rules - Templated text per client type, user agent, user status or quota
  • Host Shuffling - Optionally shuffles the servers matching a name pattern, each group within its own positions
  • Zero-Cost Placeholders - Traffic and expiry come from the response headers, so the common case makes no API call
  • Transparent Proxy - Header casing, the real client IP and drop-on-error all preserved
  • Docker Ready - Multi-arch image, non-root, read-only, self-probing healthcheck

⁠Compared with Remnawave

Since Remnawave 3.0 the announce, profile title, support link and routing are plain Custom Response Headers, and the panel resolves its own {{VAR}} templates in them. A static announce such as Used {{TRAFFIC_USED}} of {{TOTAL_TRAFFIC}} therefore needs no proxy. The proxy covers what the panel does not:

FeatureRemnawaveProxy
Templated custom response headers{{VAR}}, 22 variables{VAR}, more variables and modifiers
Formatted expiry date— (only {{EXPIRE_UNIX}}){EXPIRES_AT}, {EXPIRES_AT_DATE}, {EXPIRES_AT_TIME}
Unlimited plan in text{{TRAFFIC_LEFT}} renders 0∞, configurable
Percentages, progress bar, modifiers—{TRAFFIC_USED_PERCENT}, {PROGRESS_BAR}, |truncate
Base64 header valuesrwEncodeBase64: prefixencode: base64 / base64-prefixed
Length limit after rendering—max_length
Headers by user agent / clientResponse Rules, static values onlywhen: user_agent, client_types, templated
Headers by user status or quota— ({{STATUS:EXPIRED=…}} labels only)when: user_statuses, has_traffic_limit
Remarks for expired / limited usersCustom Remarks— (use the panel)
Host shufflingrandomizeHosts; per-host flag moves hosts to the topBy client-visible name, positions kept
Fallback cache during a panel outage— (the connection is dropped)SUBSCRIPTION_CACHE_ENABLED
Force unlimited in the app's readout—traffic.force_unlimited
Scanner probe blocking— (probes are looked up in the panel)block:
Broken User-Agent handlingResponse Rules can block, without templated textuser_agents:, templated message hosts
Header name casingLowercased by the subscription pagePreserved

Both template layers can be combined in one header: the panel resolves {{VAR}} before the response reaches the proxy, which then resolves {VAR}. The syntaxes never collide.

⁠Prerequisites

Docker and Docker Compose, a Remnawave panel with a subscription page, and an API token from Remnawave Settings → API Tokens. The subscription page is bundled in the compose file if it is not already running.

⁠How it works

The proxy sits in front of the official subscription page⁠ and rewrites response headers. The page is left unmodified: the web interface, browser detection, client-type templates, Marzban legacy links and subpage configurations keep working, and upstream updates still apply.

client → caddy/nginx :443 → subpage-proxy :3020 → subscription-page :3010 → panel
                                  │
                                  └── GET /api/sub/{shortUuid}/info   (only when needed)

Traffic and expiry placeholders come from the subscription-userinfo header on every subscription response, so the common case requires no API request. The panel is queried only for data the headers cannot supply, such as {USERNAME} or {USER_STATUS}; those lookups are cached and de-duplicated.

⁠Quick start

git clone https://github.com/hteppl/remnawave-subpage-proxy.git
cd remnawave-subpage-proxy

cp .env.example .env
cp .env.subscription-page.example .env.subscription-page
cp config.example.yaml config.yaml

# Fill in REMNAWAVE_API_TOKEN in both .env files.
# Create the token in Remnawave Dashboard → Remnawave Settings → API Tokens.

docker compose pull
docker compose up -d

config.yaml is optional: without it, the proxy resolves the placeholders defined in the panel's Custom Response Headers.

To build from source instead of the published image, run make dev: it applies docker-compose.dev.yml, which builds the :dev tag locally, sets logging to debug/text and publishes the health port on 3021.

Finally, point the reverse proxy at the proxy rather than the subscription page. No port is published on the host — the containers live only on remnawave-network — so a reverse proxy on that network addresses them by name.

sub.example.com {
    reverse_proxy remnawave-subpage-proxy:3020
}

If the reverse proxy runs on the host instead, publish the port by adding ports: ['127.0.0.1:3020:3020'] to the service in the compose file.

⁠Existing subscription page deployment

Use docker-compose.proxy-only.yml and drop the ports: mapping from the subscription page's compose file, so it is reachable only through the proxy.

cp .env.example .env
cp config.example.yaml config.yaml
docker compose -f docker-compose.proxy-only.yml up -d

⁠Production notes

A specific version should be pinned rather than tracking latest. Both compose files run the container read-only, with all capabilities dropped, no-new-privileges set, and the JSON log file capped at 3 × 10 MB.

LOG_FORMAT=json is recommended where logs are forwarded to an external system. HTTP_SHUTDOWN_TIMEOUT must stay below the compose stop_grace_period (15s and 20s by default), so in-flight requests finish before the container is stopped.

The panel is verified once at startup; a failure is logged at error level but does not prevent operation.

⁠Configuring the announce

The template may be defined in either place, and both are applied.

From the panel — Remnawave Settings → Subscription Settings → Custom Response Headers:

HeaderValue
announceUsed {TRAFFIC_USED} of {TRAFFIC_LIMIT}

The proxy resolves the placeholders in the outgoing response; nothing else is required. The panel's {{VAR}} templates can sit in the same value, so {{USERNAME}}: {TRAFFIC_USED} of {TRAFFIC_LIMIT} works. A value stored with the panel's rwEncodeBase64: prefix arrives as base64:… and is handled the same way. scan_all_headers is on by default, so this applies to any header the panel sets, and base64 values are decoded, resolved and re-encoded in place.

From config.yaml — for templates kept in version control, or needing conditions:

vars:
  BRAND: "MyProject"
  SUPPORT: "@my_support_bot"

headers:
  - name: announce
    template: "{BRAND} · {TRAFFIC_USED} of {TRAFFIC_LIMIT} used · {DAYS_LEFT} days left · {SUPPORT}"
    encode: base64-prefixed   # required by Happ
    max_length: 200           # Happ displays at most 200 characters

  # Alternative message once the plan is exhausted.
  - name: profile-title
    template: "{BRAND} — {TRAFFIC_AVAILABLE}"
    encode: base64
    max_length: 25

Every option is documented in config.example.yaml⁠, and examples/⁠ holds ready-made configurations for common setups.

⁠Placeholders

Syntax is {NAME}, with optional chained modifiers: {NAME|upper}, {NAME|lower}, {NAME|trim}, {NAME|truncate:40}, {NAME|default:n/a}. Names use UPPER_SNAKE_CASE, so JSON and Clash payloads passing through the proxy are never interpreted as templates.

⁠Resolved without a panel request

The Remnawave column names the panel's own variable, where one exists.

PlaceholderExampleRemnawave
{TRAFFIC_USED}10.50 GB{{TRAFFIC_USED}}
{TRAFFIC_LIMIT}100.00 GB{{TOTAL_TRAFFIC}}
{TRAFFIC_AVAILABLE}89.50 GB (limit minus used){{TRAFFIC_LEFT}}
{TRAFFIC_USED_BYTES}10500000000{{TRAFFIC_USED_BYTES}}
{TRAFFIC_LIMIT_BYTES}100000000000{{TOTAL_TRAFFIC_BYTES}}
{TRAFFIC_USED_IN_LIMIT}3.0 (used, in the limit's unit, no suffix)—
{TRAFFIC_LIMIT_VALUE}20.0 (limit, no suffix)—
{TRAFFIC_UNIT}GB (the unit both share)—
{TRAFFIC_AVAILABLE_BYTES}89500000000{{TRAFFIC_LEFT_BYTES}}
{TRAFFIC_UPLOAD}0.50 GB—
{TRAFFIC_DOWNLOAD}10.00 GB—
{TRAFFIC_USED_PERCENT}10—
{TRAFFIC_LEFT_PERCENT}90—
{PROGRESS_BAR}▰▱▱▱▱▱▱▱▱▱—
{DAYS_LEFT}12{{DAYS_LEFT}}
{EXPIRES_AT}31.12.2026 23:59—
{EXPIRES_AT_DATE}31.12.2026—
{EXPIRES_AT_TIME}23:59—
{EXPIRES_AT_UNIX}1798761599{{EXPIRE_UNIX}}
{SHORT_UUID}aBcDeF123{{SHORT_UUID}}
{CLIENT_TYPE}clash—
{USER_AGENT}Happ/1.0—
{CLIENT_IP}203.0.113.9—
{ORIGINAL_VALUE}the header's own text, before rewriting—
{NOW} {DATE} {TIME}01.09.2026 14:30—
{SUBSCRIPTION_URL}https://example.com/sub/aBcDeF123{{SUBSCRIPTION_URL}}

Where the panel has an equivalent, the native variable is usually the simpler choice. The proxy's version differs where noted: an unlimited plan renders as ∞ rather than 0, and values follow the proxy's own formatting.

{TRAFFIC_USED_IN_LIMIT}, {TRAFFIC_LIMIT_VALUE} and {TRAFFIC_UNIT} render both sides of a quota in one shared unit: {TRAFFIC_USED_IN_LIMIT} of {TRAFFIC_LIMIT} gives 0.0 of 20.0 GB where {TRAFFIC_USED} would give 0 B of 20.0 GB. The unit follows the limit.

{ORIGINAL_VALUE} holds the text the panel sent for the header the rule targets, decoded if it was base64, so a template can wrap the panel's announce rather than discard it; placeholders the panel used are resolved inside it.

{SUBSCRIPTION_URL} is rebuilt from the incoming request, so it needs no panel request; the client-type segment is dropped, and /{shortUuid}/clash yields the plain /{shortUuid} link.

An unlimited plan renders {TRAFFIC_LIMIT} and {TRAFFIC_AVAILABLE} as ∞ (configurable), as does {EXPIRES_AT} without an expiry date.

⁠Requiring a panel request (cached)
PlaceholderExampleRemnawave
{USERNAME}alice{{USERNAME}}
{USER_STATUS}ACTIVE DISABLED LIMITED EXPIRED{{STATUS}}
{IS_ACTIVE}true{{STATUS:ACTIVE=true|…}}
{TRAFFIC_LIMIT_STRATEGY}NO_RESET DAY WEEK MONTH MONTH_ROLLING{{RESET_STRATEGY}}
{LIFETIME_TRAFFIC_USED}1.20 TB{{LIFETIME_USED_BYTES}} (bytes)

In addition, any variable defined under vars: in config.yaml.

Every placeholder in this table has a native counterpart that costs no API request, since the panel fills it while building the response. In a header the panel sets, prefer {{USERNAME}} to {USERNAME}. The proxy's versions exist for config.yaml rules and for conditions such as user_statuses.

⁠Conditions

Rules may be scoped so different clients or users receive different text:

headers:
  # Happ only.
  - name: announce
    template: "{PROGRESS_BAR} {TRAFFIC_USED_PERCENT}% used"
    encode: base64-prefixed
    when:
      user_agent: "(?i)happ"

  # Client-type paths /json and /clash only; `user_statuses` works the same way.
  - name: X-Plan-Summary
    template: "{TRAFFIC_USED} / {TRAFFIC_LIMIT}"
    when:
      client_types: [ json, clash ]

  # Plans with a finite quota; `false` matches unlimited plans.
  - name: announce
    template: "{TRAFFIC_USED} of {TRAFFIC_LIMIT} used"
    encode: base64-prefixed
    when:
      has_traffic_limit: true

  # Default value, applied only if the panel did not send the header.
  - name: support-url
    template: "https://t.me/my_support_bot"
    when:
      exists: false

has_traffic_limit distinguishes a finite quota from an unlimited plan, which Remnawave encodes as a zero total. It comes from the subscription-userinfo header, so it normally costs no panel request, and a rule is skipped when the quota cannot be determined at all.

traffic.force_unlimited does not affect it: that option changes what the client is shown, while has_traffic_limit tests the quota configured in the panel, so a plan presented as unlimited still matches has_traffic_limit: true. user_statuses always triggers a panel request, as the status is absent from the response headers.

Remnawave's Response Rules can also match a user agent, but the headers they add are sent verbatim, without templates, and they cannot test the user's status or quota. Selecting a config template or excluding hosts per client is done better in Response Rules; the text of the header is done here.

⁠Configuration reference

Infrastructure is configured through environment variables, templating through config.yaml. Defaults below apply when a variable is unset; .env.example⁠ overrides several.

VariableDefaultMeaning
UPSTREAM_URL—Subscription page to proxy. Required.
REMNAWAVE_PANEL_URL—Panel base URL, unless PANEL_ENABLED=false.
REMNAWAVE_API_TOKEN—Panel API token, unless PANEL_ENABLED=false.
APP_HOST0.0.0.0Public bind address.
APP_PORT3020Public port.
HEALTH_HOST0.0.0.0Bind address for the health endpoints.
HEALTH_PORT3021Health port. 0 disables both endpoints.
CONFIG_PATHconfig.yamlHeader rules file. Missing is an error only when set explicitly.
CUSTOM_SUB_PREFIX—Path prefix. Must match the subscription page's setting.
TRUST_PROXY1true/false, a hop count, or presets, IPs and CIDRs.
UPSTREAM_FORCE_HTTPSfalseAlways send X-Forwarded-Proto: https upstream.
PANEL_ENABLEDtruefalse runs without panel credentials.
PANEL_ALWAYS_FETCHfalseLook up every subscription, even when nothing needs it.
PANEL_FORWARD_REAL_IPtrueSend the end user's IP on info lookups.
PANEL_TIMEOUT10sTimeout for one panel API call.
CACHE_TTL30sHow long a successful panel lookup is reused.
CACHE_NEGATIVE_TTL10sHow long a "not found" is remembered.
CACHE_MAX_ENTRIES10000Cap on cached lookups.
CADDY_AUTH_API_TOKEN—X-Api-Key for a panel behind Caddy security.
CLOUDFLARE_ZERO_TRUST_CLIENT_ID—CF-Access-Client-Id for Cloudflare Zero Trust.
CLOUDFLARE_ZERO_TRUST_CLIENT_SECRET—CF-Access-Client-Secret for the same.
SUBSCRIPTION_CACHE_ENABLEDfalseReplay the last good response while Remnawave is down.
SUBSCRIPTION_CACHE_TTL1hHow long a stored response stays usable.
SUBSCRIPTION_CACHE_MAX_BYTES64MiBTotal memory budget for the fallback cache.
SUBSCRIPTION_CACHE_MAX_BODY1MiBLargest single response worth storing.
UPSTREAM_TIMEOUT60sWait for upstream response headers.
HTTP_READ_TIMEOUT30sReading the client request.
HTTP_WRITE_TIMEOUT90sWriting the response.
HTTP_IDLE_TIMEOUT120sKeep-alive idle time.
HTTP_SHUTDOWN_TIMEOUT15sDrain on SIGTERM. Keep below stop_grace_period.
LOG_LEVELinfodebug logs every header rewrite.
LOG_FORMATtexttext or json.

A duration without a unit means seconds, so CACHE_TTL=45 and CACHE_TTL=45s are equivalent. A byte size accepts 1MiB, 512KB or a plain number.

⁠Forcing an unlimited plan

traffic.force_unlimited hides the quota from the client's built-in traffic display, whatever the panel has configured:

traffic:
  force_unlimited: true

The subscription-userinfo header is sent with total=0, the standard encoding for an unlimited plan and the value client apps read for their quota display. Only that header is rewritten: placeholders and conditions keep reporting the real quota, so {TRAFFIC_LIMIT} still yields 100.00 GB and has_traffic_limit: true still matches. It controls the one thing a template cannot — the app's own traffic readout.

⁠Subscription fallback cache

Disabled by default; enabled with SUBSCRIPTION_CACHE_ENABLED=true. The proxy then keeps the last successful subscription response per client and replays it while Remnawave is unreachable, so existing users keep a working configuration through a panel outage or a page restart.

This is a fallback, not a read-through cache: every request goes to the upstream first, and the stored copy is used only if that fails — a dropped connection, a timeout, or a 5xx response. Entries are keyed by short UUID, client type, User-Agent and Accept-Encoding, since Remnawave varies the payload by client; only subscription payloads are stored, never the web page.

Two consequences: traffic counters in a replay are as old as the cache entry, and a user revoked during an outage keeps access until SUBSCRIPTION_CACHE_TTL expires.

⁠Shuffling hosts

Clients tend to connect to the first host in a subscription, so a fixed order sends every user to the same server. hosts.shuffle groups hosts by a Go regexp matched against the name the client shows — the link fragment or vmess ps, Xray remarks, the sing-box tag, the Clash proxy name. On each request a group's hosts are shuffled among the positions they already hold, while a host matching no pattern keeps its place:

hosts:
  shuffle:

The full README is on GitHub⁠.

Tag summary

Content type

Image

Digest

sha256:88cc3cdfc…

Size

7.4 MB

Last updated

1 day ago

docker pull hteppl/remnawave-subpage-proxy