Sign inSign up

hteppl/remnawave-subpage-proxy

By hteppl

Updated 6 days ago

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

Image
Content management system
0

966

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 X-Forwarded-* chain 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 renderingmax_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 readouttraffic.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_URLSubscription page to proxy. Required.
REMNAWAVE_PANEL_URLPanel base URL, unless PANEL_ENABLED=false.
REMNAWAVE_API_TOKENPanel 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_PREFIXPath 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_IPfalseSend 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_TOKENX-Api-Key for a panel behind Caddy security.
CLOUDFLARE_ZERO_TRUST_CLIENT_IDCF-Access-Client-Id for Cloudflare Zero Trust.
CLOUDFLARE_ZERO_TRUST_CLIENT_SECRETCF-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:000fedbb9

Size

7.4 MB

Last updated

6 days ago

docker pull hteppl/remnawave-subpage-proxy