Skip to content

Latest commit

 

History

History
464 lines (373 loc) · 18 KB

File metadata and controls

464 lines (373 loc) · 18 KB

Reverse-proxy recipes

SUB/WAVE expects the player, API, tune-in files, and live mounts to share one public origin, such as https://radio.example.com. This page gives complete recipes for the split-service docker-compose.byo.yml deployment and the simpler Cloudflare Tunnel path through the bundled edge.

Set these in the root .env before starting the stack:

SITE_URL=https://radio.example.com

# Use this when nginx runs directly on the same host. It keeps the three BYO
# ports off the LAN while leaving them reachable on loopback.
BIND_ADDRESS=127.0.0.1

A proxy in another container cannot reach the host's loopback address. Attach it to SUB/WAVE's Docker network and use service names, or bind the ports to an address that container can reach and protect them with the host firewall.

The route contract

Every split-service proxy must apply these rules in this order:

Public path Upstream Path sent upstream Required handling
/api/listener-auth none none Return 404 or otherwise deny it before the general API rule. Icecast calls this password callback over the private Compose network; it must not be public.
/stream* Icecast on :7702 unchanged Disable response buffering, caching, and compression. Match the prefix so optional Opus, FLAC, and AAC mounts work without another proxy edit.
/listen.pls, /listen.m3u Controller on :7701 unchanged These tune-in files are controller routes, not Icecast routes.
/api/* Controller on :7701 strip /api /api/health must reach the controller as /health. Keep streaming responses such as diagnosis events unbuffered.
everything else Web on :7700 unchanged Player, admin, onboarding, manual, and static assets.

The examples use the default ports and a proxy on the same host. Replace 127.0.0.1 with an address or Docker service name reachable from your proxy when it runs elsewhere.

nginx

Put these directives inside the existing server block for radio.example.com; keep your current TLS certificate and redirect settings. The trailing slash on the controller proxy_pass is what removes /api/.

# Forward the public request identity to every upstream.
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Overwrite, rather than append, so a client cannot seed a forged address.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;

# Icecast's private URL-auth callback. This must win over location /api/.
location = /api/listener-auth {
    return 404;
}

# All present and future stream mounts keep their original path.
location ^~ /stream {
    proxy_pass http://127.0.0.1:7702;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 1h;
    gzip off;
}

# Tune-in files are generated by the controller and keep their root path.
location = /listen.pls {
    proxy_pass http://127.0.0.1:7701;
}

location = /listen.m3u {
    proxy_pass http://127.0.0.1:7701;
}

# The controller mounts routes at /, so the trailing slash strips /api/.
location /api/ {
    proxy_pass http://127.0.0.1:7701/;
    proxy_buffering off;
    proxy_read_timeout 1h;
}

location / {
    proxy_pass http://127.0.0.1:7700;
}

If this nginx server is itself behind another trusted edge, configure nginx's real-IP module with only that edge's addresses first. $remote_addr will then hold the resolved client address and the rule above will still replace any client-supplied forwarding chain.

Reload only after nginx accepts the configuration:

sudo nginx -t && sudo systemctl reload nginx

Nginx Proxy Manager

Create one Proxy Host for radio.example.com:

  • Scheme: http
  • Forward Hostname / IP: the SUB/WAVE host
  • Forward Port: 7700
  • Websockets Support: on
  • SSL: request or select the certificate you normally use

Then open its Advanced tab and paste the block below. Replace SUBWAVE_HOST with an address reachable from the NPM container; it is usually the Docker host's LAN address, not 127.0.0.1.

proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Overwrite, rather than append, so a client cannot seed a forged address.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;

location = /api/listener-auth {
    return 404;
}

location ^~ /stream {
    proxy_pass http://SUBWAVE_HOST:7702;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 1h;
    gzip off;
}

location = /listen.pls {
    proxy_pass http://SUBWAVE_HOST:7701;
}

location = /listen.m3u {
    proxy_pass http://SUBWAVE_HOST:7701;
}

location /api/ {
    proxy_pass http://SUBWAVE_HOST:7701/;
    proxy_buffering off;
    proxy_read_timeout 1h;
}

NPM's generated / location continues to send the web UI to the Proxy Host's port 7700. The Advanced locations override only the API, tune-in files, and stream mounts.

Traefik with Docker labels

This overlay attaches the three public services to the external Docker network used by Traefik and declares one router per rule. Change proxy, websecure, and letsencrypt if your Traefik installation uses different names. Keep BIND_ADDRESS=127.0.0.1 in SUB/WAVE's root .env: Traefik reaches the services over Docker's proxy network, while the inherited BYO port mappings remain loopback-only.

Save it as docker-compose.traefik.yml beside the SUB/WAVE compose file:

services:
  broadcast:
    networks:
      - default
      - proxy
    labels:
      traefik.enable: "true"
      traefik.docker.network: proxy
      traefik.http.routers.subwave-stream.rule: "Host(`radio.example.com`) && PathPrefix(`/stream`)"
      traefik.http.routers.subwave-stream.entrypoints: websecure
      traefik.http.routers.subwave-stream.tls: "true"
      traefik.http.routers.subwave-stream.tls.certresolver: letsencrypt
      traefik.http.routers.subwave-stream.priority: "20"
      traefik.http.routers.subwave-stream.service: subwave-stream
      traefik.http.services.subwave-stream.loadbalancer.server.port: "7702"

  controller:
    networks:
      - default
      - proxy
    labels:
      traefik.enable: "true"
      traefik.docker.network: proxy
      traefik.http.middlewares.subwave-strip-api.stripprefix.prefixes: /api
      traefik.http.routers.subwave-api.rule: "Host(`radio.example.com`) && PathPrefix(`/api/`)"
      traefik.http.routers.subwave-api.entrypoints: websecure
      traefik.http.routers.subwave-api.tls: "true"
      traefik.http.routers.subwave-api.tls.certresolver: letsencrypt
      traefik.http.routers.subwave-api.priority: "20"
      traefik.http.routers.subwave-api.middlewares: subwave-strip-api
      traefik.http.routers.subwave-api.service: subwave-controller
      traefik.http.routers.subwave-listen.rule: "Host(`radio.example.com`) && (Path(`/listen.pls`) || Path(`/listen.m3u`))"
      traefik.http.routers.subwave-listen.entrypoints: websecure
      traefik.http.routers.subwave-listen.tls: "true"
      traefik.http.routers.subwave-listen.tls.certresolver: letsencrypt
      traefik.http.routers.subwave-listen.priority: "20"
      traefik.http.routers.subwave-listen.service: subwave-controller
      traefik.http.services.subwave-controller.loadbalancer.server.port: "7701"

  web:
    networks:
      - default
      - proxy
    labels:
      traefik.enable: "true"
      traefik.docker.network: proxy
      # Route the private callback to Next.js, where it is a guaranteed 404.
      # Priority 100 makes this rule win over the controller's /api prefix.
      traefik.http.routers.subwave-auth-block.rule: "Host(`radio.example.com`) && Path(`/api/listener-auth`)"
      traefik.http.routers.subwave-auth-block.entrypoints: websecure
      traefik.http.routers.subwave-auth-block.tls: "true"
      traefik.http.routers.subwave-auth-block.tls.certresolver: letsencrypt
      traefik.http.routers.subwave-auth-block.priority: "100"
      traefik.http.routers.subwave-auth-block.service: subwave-web
      traefik.http.routers.subwave-web.rule: "Host(`radio.example.com`)"
      traefik.http.routers.subwave-web.entrypoints: websecure
      traefik.http.routers.subwave-web.tls: "true"
      traefik.http.routers.subwave-web.tls.certresolver: letsencrypt
      traefik.http.routers.subwave-web.priority: "1"
      traefik.http.routers.subwave-web.service: subwave-web
      traefik.http.services.subwave-web.loadbalancer.server.port: "7700"

networks:
  proxy:
    external: true

Traefik forwards response bodies as they arrive unless a buffering middleware is added, so the stream router needs no buffering option. Do not attach Traefik's Buffering or Compress middleware to subwave-stream; if either is configured on the websecure entrypoint, it applies to every router on that entrypoint and cannot be exempted per router. Remove the global middleware or give the stream router a dedicated entrypoint without it. Start SUB/WAVE with both files:

docker compose \
  -f docker-compose.byo.yml \
  -f docker-compose.traefik.yml \
  up -d

Cloudflare Tunnel

Keep the bundled edge for this topology. The Caddy container already owns the route contract, so the tunnel needs one public hostname and one upstream—not four path rules. Before trusting private proxy addresses, close Caddy's direct public route so every request must pass through the tunnel:

  • If cloudflared runs on the host, change Caddy's port mapping in docker-compose.yml to 127.0.0.1:${CADDY_PORT:-7700}:80 (or firewall port 7700 to the tunnel host) and use http://127.0.0.1:7700 below.
  • If cloudflared runs in Docker, remove Caddy's host ports mapping, attach cloudflared to SUB/WAVE's private Docker network, and use http://caddy:80. The tunnel must then be the only public ingress.

For a locally managed tunnel running on the host, use:

tunnel: YOUR-TUNNEL-UUID
credentials-file: /etc/cloudflared/YOUR-TUNNEL-UUID.json

ingress:
  - hostname: radio.example.com
    service: http://127.0.0.1:7700
  - service: http_status:404

In the Cloudflare dashboard, the equivalent is a single published application whose service URL is the bundled edge.

Validate a locally managed configuration before running the tunnel:

cloudflared tunnel --config /etc/cloudflared/config.yml ingress validate

Create a Cache Rule that bypasses cache for paths beginning with /stream. Cloudflare Access should not guard the stream mount unless every listening client can complete its login flow; use SUB/WAVE's own stream password when ordinary players and hardware radios need access.

Cloudflare Tunnel reaches Caddy from a private address rather than a Cloudflare edge address. Once the direct public route is closed, preserve real listener IPs with the trusted-proxy setup in deployment.md.

Public stream, LAN-only admin

A common homelab shape: the player and the stream reachable from the internet, the operator console reachable only from home. SUB/WAVE has no setting for this — the split is made at your proxy, and this section is the recipe.

What can and cannot be split

There are two operator surfaces, and only one of them separates cleanly by path.

Surface Paths Can you restrict it by path?
Operator UI /admin*, /onboarding Yes. Distinct prefixes, served by the web container.
Operator API admin routes under /api/* No. The controller mounts public and admin routes on one flat namespace — /api/now-playing and /api/settings are siblings. There is no /api/admin/ prefix to deny.

So the practical split is: deny the console's pages from the internet, and let the admin API stay guarded by HTTP Basic auth, which is what guards it today on every default install (ADMIN_USER / ADMIN_PASS, mandatory in production). That gets you the requested outcome — nobody outside your LAN sees a login box, let alone a console — without an allowlist that breaks the next time a listener-facing endpoint is added.

If you want the admin API off the public interface as well, see Two front doors below.

Leave SITE_URL pointing at the public origin either way. It's the canonical address the tune-in files, share cards and the Connect panel hand out; the LAN address is how you reach the box, not how listeners reach the station.

One hostname, restricted console

Add this to the recipe you already applied, above the catch-all web rule.

nginx — order matters, location prefixes are matched before the / fallback:

# Operator console: LAN only. Everything else on this server block stays public.
location ~ ^/(admin|onboarding) {
    allow 192.168.1.0/24;   # your LAN
    allow 100.64.0.0/10;    # Tailscale, if you use it
    deny  all;

    proxy_pass http://127.0.0.1:7700;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Caddy — including the bundled docker/Caddyfile, where this goes above the final handle:

@console path /admin /admin/* /onboarding /onboarding/*
handle @console {
	@notlan not remote_ip 192.168.1.0/24 100.64.0.0/10
	respond @notlan 404
	reverse_proxy web:7700
}

404 rather than 403 — a 403 confirms there is a console there to find.

Traefik — attach an ipAllowList middleware to a higher-priority router matching the same paths:

labels:
  - "traefik.http.middlewares.subwave-lan.ipallowlist.sourcerange=192.168.1.0/24,100.64.0.0/10"
  - "traefik.http.routers.subwave-console.rule=Host(`radio.example.com`) && (PathPrefix(`/admin`) || PathPrefix(`/onboarding`))"
  - "traefik.http.routers.subwave-console.priority=100"
  - "traefik.http.routers.subwave-console.middlewares=subwave-lan@docker"
  - "traefik.http.routers.subwave-console.service=subwave-web"

Behind Cloudflare or a tunnel, the connecting peer is the edge, not the listener — your allowlist must read the forwarded address, which means the trusted-proxy setup in deployment.md has to be correct first. Test it before you rely on it: a misconfigured trusted-proxy list turns an IP allowlist into either a lockout or a no-op.

Do not add /api to the allowlist. The player calls /api/now-playing, /api/state, /api/session, /api/request, /api/like and /api/cover/:id from every listener's browser; denying /api from the internet takes the public player down with it.

Two front doors

For a genuinely separate admin surface — the console and its API off the public interface — run two front ends over the same containers. The web bundle calls /api and /stream.mp3 relative to its own origin, so both work with the stock image and no rebuild.

Use docker-compose.byo.yml with BIND_ADDRESS=127.0.0.1, then:

  • Public front end (radio.example.com, port 443 on the WAN) — the route contract above, minus the console: /admin* and /onboarding return 404.
  • LAN front end (subwave.lan, or port 8443 bound to your LAN interface) — the full route contract, nothing denied.

Then decide what the public front end does with /api/*. Two honest options:

  • Pass it through (simple, recommended). The admin API stays reachable from the internet and stays protected by Basic auth. This is the same posture as a default install; the win is that the console pages are gone.
  • Allowlist the listener endpoints (strict, brittle). Route only /api/now-playing, /api/state, /api/session, /api/health, /api/schedule, /api/themes, /api/beacon, /api/cover/*, /api/request, /api/request/*, /api/like and /api/station-auth, and 404 the rest. It works, and it will silently break a listener feature the first time a release adds an endpoint to that list. Pin SUBWAVE_VERSION and re-read this list at each upgrade if you take this path.

/api/onboarding/status is deliberately absent from that list. The player calls it to redirect a fresh install to the wizard and treats any non-200 as "nothing to do", so denying it publicly costs nothing and stops the public origin advertising an unconfigured station.

/api/listener-auth stays denied on both front ends. Icecast calls the controller over the private Compose network; it never needs a route.

On the all-in-one image

The AIO image can't do this internally — it bundles Caddy, the controller, the web UI and Icecast behind one host port by design. Put your own proxy in front of that single port and apply the console rule there, exactly as above with the AIO's port as the upstream. That works on Unraid, where the outer proxy is usually SWAG or Nginx Proxy Manager already fronting everything else.

Verify the public route table

Run these after applying any recipe:

BASE=https://radio.example.com

# Controller reached with /api stripped.
curl -fsS "$BASE/api/health"

# Tune-in files come from the controller at the public root.
curl -fsS "$BASE/listen.pls"
curl -fsS "$BASE/listen.m3u"

# The private Icecast callback must not reach the controller: expect 404/403.
AUTH_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
  -X POST "$BASE/api/listener-auth")
case "$AUTH_STATUS" in
  403|404) ;;
  *) echo "listener-auth is public (HTTP $AUTH_STATUS)" >&2; exit 1 ;;
esac

# Check the stream headers, then confirm a bounded GET receives audio bytes.
curl -fsSI "$BASE/stream.mp3" | grep -i '^content-type:'
STREAM_SAMPLE=$(mktemp)
trap 'rm -f "$STREAM_SAMPLE"' EXIT
curl -fsS --max-time 10 "$BASE/stream.mp3" -o "$STREAM_SAMPLE" || \
  test -s "$STREAM_SAMPLE"
test -s "$STREAM_SAMPLE"

Also open /admin/connect?tab=integrations; every enabled mount should show a copyable URL on the same public hostname.

When SUB/WAVE's stream-password setting is on, /listen.pls and /listen.m3u always return 403; they do not accept credentials. Verify the stream itself with curl -u listener:PASSWORD "$BASE/stream.mp3" (any username works) or append ?auth=PASSWORD. See Private station mode.