Help us shape the future of Go! Take the 2026 Go Developer Survey

concert

command module
v0.0.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 25, 2026 License: Apache-2.0 Imports: 44 Imported by: 0

README

Concert

Go Reference Latest Release Go Version Apache 2.0 License Built on room GitHub Stars

A FIFO waiting room reverse proxy. Put Concert in front of any HTTP origin, such as a PHP site, a WordPress install, or a legacy app that falls over under load. When traffic exceeds what the origin can handle, visitors wait in an orderly queue with a live position instead of getting 502s and timeouts. Admitted visitors load their page's assets through a separate tier that queued and denied visitors can't reach, and clients that misbehave are blocked for an escalating cooldown.

Concert is a single Go binary built on room, a FIFO waiting room middleware for Gin, and sema, a resizable semaphore.

Why

A PHP-FPM pool has a fixed number of workers (pm.max_children). When every worker is busy, new requests pile up in the web server's backlog until they time out. Users see 502 or 504 errors, hit refresh, and make the spike worse.

Concert caps the number of concurrent page requests that reach your origin. Everyone past that cap is shown a waiting room page with their queue position, and they are admitted automatically, in arrival order, as slots free up. Your origin only ever sees the load you chose.

How it works

browser ──▶ TLS terminator ──▶ concert :8080 ──▶ origin :3000
                                  │
          every request ──────────┼─▶ client banned? ──▶ 429 until the cooldown ends
                                  │   ban path? ──────▶ ban now, 429
                                  │
          page requests ──────────┼─▶ room: slot free?
                                  │     yes ──▶ proxied, concert_admit pass issued
                                  │     no  ──▶ waiting room page, polls /queue/status
                                  │
          asset requests ─────────┼─▶ valid concert_admit pass?
                                  │     no  ──▶ 403
                                  │     yes ──▶ per-user semaphore (HTTP/1.1 or HTTP/2 rule)
                                  │               ──▶ global asset semaphore ──▶ proxied
                                  │
          bypass paths ───────────┴─▶ proxied, no guard

Every request is first matched to a client address. Banned clients are rejected before anything else runs.

Pages go through room. A request whose ticket falls inside the serving window takes a slot, is proxied, and releases the slot when its response has been fully delivered. Other requests get a room_ticket cookie and the waiting room page, which polls /queue/status and reloads when the visitor's turn arrives. -cap is the maximum number of page requests in flight to your origin.

Admitted page responses also carry concert_admit, a signed, HttpOnly pass that slides forward as the visitor keeps browsing.

Assets don't queue. A stylesheet or image can't run the waiting room's JavaScript, so making one wait is the same as failing it. Instead, asset paths require a valid pass, and each pass has its own small concurrency pool. One visitor can't monopolise the asset tier, and visitors who were never admitted can't touch it at all. A global semaphore across all passes protects the host.

Install

go install github.com/andreimerlescu/concert@latest

Or build from source:

git clone https://github.com/andreimerlescu/concert.git
cd concert
make build

make build writes static binaries for Linux, macOS and Windows on amd64 and arm64 to bin/. Requires Go 1.22 or newer.

Quick start

Start something to protect. PHP's built-in server is enough for a demo:

php -S 127.0.0.1:3000 -t /var/www/html

Put Concert in front of it with a deliberately small capacity:

concert -upstream http://127.0.0.1:3000 -cap 5 -assets "/css/*,/js/*,/images/*"

Open http://127.0.0.1:8080/ in a browser, then generate load from another terminal:

ab -c 100 -n 2000 http://127.0.0.1:8080/

Refresh the browser while the load test runs and you'll land in the waiting room, then be admitted automatically as slots open.

Configuration

Every option can be set with a flag or an environment variable. A flag overrides its environment variable, and the environment variable overrides the built-in default.

Flag Environment Default Description
-listen CONCERT_LISTEN :8080 Address to listen on
-upstream CONCERT_UPSTREAM http://127.0.0.1:3000 Origin to proxy to (http or https)
-cap CONCERT_CAPACITY 500 Max concurrent page requests allowed through to the origin
-max-queue CONCERT_MAX_QUEUE 10000 Reject new arrivals with 503 beyond this queue depth (0 = unlimited)
-reaper CONCERT_REAPER 30s How often abandoned tickets are cleaned up
-token-ttl CONCERT_TOKEN_TTL 0 (room default, 5m) Sliding lifetime of a queued ticket, 30s–24h
-secure-cookie CONCERT_SECURE_COOKIE false Mark cookies Secure (only if browsers reach you over HTTPS)
-cookie-path CONCERT_COOKIE_PATH / Path attribute of cookies
-cookie-domain CONCERT_COOKIE_DOMAIN (empty) Domain attribute of cookies
-preserve-host CONCERT_PRESERVE_HOST true Forward the client's Host header to the origin
-trusted-proxies CONCERT_TRUSTED_PROXIES 127.0.0.1/32,::1/128 CIDRs whose X-Forwarded-For and X-Forwarded-Proto are trusted
-bypass CONCERT_BYPASS /favicon.ico Paths that skip every guard; suffix /* for a prefix
-assets CONCERT_ASSETS (empty) Asset paths that require an admission pass; suffix /* for a prefix
-asset-public CONCERT_ASSET_PUBLIC (empty) Asset paths served without a pass, still under the global asset cap
-asset-cap CONCERT_ASSET_CAP 0 (derived) Global concurrent asset requests; 0 means cap × asset-user-cap-h2
-asset-wait CONCERT_ASSET_WAIT 2s Max wait for a global asset slot before 503
-asset-user-cap-h1 CONCERT_ASSET_USER_CAP_H1 8 Concurrent asset requests per pass over HTTP/1.x
-asset-user-cap-h2 CONCERT_ASSET_USER_CAP_H2 128 Concurrent asset requests per pass over HTTP/2 and HTTP/3
-asset-user-wait CONCERT_ASSET_USER_WAIT 2s Max wait for a per-pass asset slot before 429
-admit-ttl CONCERT_ADMIT_TTL 10m Sliding lifetime of the admission pass (minimum 30s)
-client-proto-header CONCERT_CLIENT_PROTO_HEADER (empty) Header from a trusted TLS terminator carrying the client's HTTP protocol
-abuse CONCERT_ABUSE true Enable the abuse registry
-abuse-strikes CONCERT_ABUSE_STRIKES 20 Strike total within the window that triggers a ban
-abuse-window CONCERT_ABUSE_WINDOW 1m Window over which strikes accumulate
-abuse-cooldown CONCERT_ABUSE_COOLDOWN 5m First ban length; doubles with each repeat ban
-abuse-max-cooldown CONCERT_ABUSE_MAX_COOLDOWN 24h Longest ban; also how long ban history is remembered
-abuse-max-entries CONCERT_ABUSE_MAX_ENTRIES 100000 Max clients tracked at once
-abuse-allow CONCERT_ABUSE_ALLOW (empty) CIDRs that are never struck or banned
-ban-paths CONCERT_BAN_PATHS (empty) Paths that ban the client on first hit; suffix /* for a prefix
-access-log CONCERT_ACCESS_LOG true Write an access log line per non-asset request
-html CONCERT_HTML_FILE (empty) Custom waiting room HTML file
-skip-url CONCERT_SKIP_URL (empty) Payment page URL for the skip-the-line card (see limitations)
-rate CONCERT_RATE 0 Base price per queue position (0 disables skip-the-line)
-surge CONCERT_SURGE 0 Extra price per position for each client in the queue
-pass CONCERT_PASS_DURATION 0 VIP pass lifetime after paying to skip (0 disables passes)
-upstream-timeout CONCERT_UPSTREAM_TIMEOUT 30s How long to wait for the origin's response headers
-api-json CONCERT_API_JSON true Answer queued non-browser clients with JSON 429 instead of HTML
-retry-after CONCERT_RETRY_AFTER 5 Retry-After seconds sent to queued API clients
-version Print the version and exit
CONCERT_ADMIN_TOKEN (empty) Bearer token for the admin endpoints; they are disabled when unset
CONCERT_ADMIT_SECRET (random) Key that signs admission passes; at least 32 bytes

Both secrets are environment-only on purpose: command-line arguments are visible in ps output and shell history.

When CONCERT_ADMIT_SECRET is unset, Concert generates a random key at startup. Passes then stop working on restart, and a second instance won't accept the first instance's passes. Set it explicitly in production:

openssl rand -base64 48

Choosing a page capacity

-cap counts concurrent page requests, not users. A slot is held from the moment a request is admitted until the last byte of the origin's response has been sent to the client.

For a PHP-FPM origin, start with -cap at or slightly below the pool's pm.max_children. Concert then admits only as many requests as there are workers to run them, and the backlog lives in Concert's queue, where users can see their position, instead of in a socket buffer where they can't.

Throughput follows from Little's law: requests admitted per second ≈ cap ÷ average time a slot is held. With -cap 50 and a 200 ms average response, Concert admits about 250 requests per second. If the origin slows down under load, admissions slow down with it, which is exactly the protection you want.

Page capacity can be changed at runtime without a restart; see POST /_room/cap below.

Client addresses and trusted proxies

Behind nginx or a load balancer, every connection to Concert comes from the proxy. -trusted-proxies lists the addresses Concert should look past.

When a connection comes from a trusted proxy, Concert reads X-Forwarded-For from right to left and takes the first address that isn't itself a trusted proxy. Addresses further left were written by the client and are ignored. When a connection comes from anywhere else, its own address is the client, and any forwarded headers it sent are discarded.

The same rule decides what the origin sees. For trusted hops, Concert appends to the incoming X-Forwarded-For chain and keeps X-Forwarded-Proto, so your application gets real client IPs and knows the visitor used HTTPS. From untrusted peers, both are replaced.

The default trusts loopback only. That is safe even when Concert faces the internet directly, because internet connections never arrive from loopback. Add your load balancer's range if it runs on another host:

-trusted-proxies "127.0.0.1/32,::1/128,10.0.0.0/8"

Persistent settings and bans

Concert keeps two files in -data-dir (default /var/lib/concert/data), created with mode 0600:

File Holds
settings.json Settings changed in the admin portal or with POST /_room/cap
bans.json Every ban, permanent bans, and offense history

Each setting takes the first value it finds, in this order:

settings.json  >  command-line flag  >  CONCERT_* environment variable  >  built-in default

settings.json only holds values an operator changed, so everything else keeps following your flags and concert.env. Reset in the portal removes a value from the file. Every change is validated by building a throwaway copy of Concert before it is written, so a saved file always starts. If you edit the file by hand and Concert refuses to start, the error names the file: fix it or delete it.

Page slots, queue depth, ticket TTL, pricing, the skip URL and VIP pass lifetime apply immediately. Every other setting is saved at once and takes effect on the next systemctl restart concert; the portal marks those as pending restart. Secrets (CONCERT_ADMIN_TOKEN, CONCERT_ADMIT_SECRET, CONCERT_PORTAL_PASS) and -data-dir itself are never stored in the file.

Bans are restored at startup. Administrator bans are written before the API answers; automatic bans within a second. Addresses added to -abuse-allow since a ban was made are not restored as banned. A corrupt bans.json stops startup rather than silently lifting permanent bans. Set -data-dir "" to keep everything in memory as before.

Permanent bans

The portal's ban dialog has a Permanent switch, for single addresses and ranges alike. A permanently banned client gets 403 Forbidden with {"error":"blocked","permanent":true}, or a short HTML notice in a browser, until someone unbans it.

Bans drop waiting visitors

Any new ban, whether from the portal, a ban path or strikes, removes that address's or range's visitors from the line at once. Their next /queue/status poll returns ready:true, so the waiting room page reloads straight into the block notice, and their room_ticket is invalidated so an unban means rejoining at the back. room's internal queue_depth keeps counting the old ticket until the reaper removes it; live_queue_depth and the portal are accurate immediately.

Assets

List the paths that hold your stylesheets, scripts, fonts and images in -assets:

concert -upstream http://127.0.0.1:3000 \
        -assets "/wp-content/themes/*,/wp-content/plugins/*,/wp-includes/*" \
        -asset-public "/wp-content/uploads/*"
The admission pass

Every page response that room admits carries concert_admit: an HttpOnly cookie holding a random ID, an expiry, a key identifier and an HMAC signature. Checking it costs one HMAC and no lookup, so any instance sharing CONCERT_ADMIT_SECRET accepts it.

The pass slides. It is re-signed once it is past half of -admit-ttl, on either a page load or an asset request. Visitors who keep browsing stay admitted, while most responses carry no Set-Cookie header and stay cacheable.

An asset request without a valid pass gets 403 Forbidden immediately. It never waits and never reaches your origin.

A pass signed with a different key is treated as stale, not forged. After a restart or a secret rotation, returning visitors get 403 on assets until their next page load issues a fresh pass, but they never earn abuse strikes for it.

Per-user concurrency: HTTP/1.1 and HTTP/2

Each pass gets its own semaphores, so one visitor can have at most a fixed number of asset requests in flight. Browsers behave very differently on each protocol, so there are two rules.

HTTP/1.1 browsers open about 6 connections per host, so their natural concurrency is about 6. The default -asset-user-cap-h1 8 leaves a little slack.

HTTP/2 and HTTP/3 browsers send every request at once over one connection, up to the server's stream limit (nginx's http2_max_concurrent_streams defaults to 128). The default -asset-user-cap-h2 128 matches that.

A request that finds its pool full waits up to -asset-user-wait, then gets 429 Too Many Requests with Retry-After: 1. Each pass keeps a separate pool per protocol family, created on first use.

Behind a TLS terminator, Concert can't see the client's protocol. Browsers only speak HTTP/2 over TLS, and the terminator talks to Concert over HTTP/1.1 no matter what the browser used. Have the terminator pass the protocol in a header, and name that header with -client-proto-header:

proxy_set_header X-Client-Proto $server_protocol;

concert -client-proto-header X-Client-Proto ...

Values starting with HTTP/1 use the HTTP/1.1 rule, and HTTP/2 or HTTP/3 use the HTTP/2 rule. Anything else falls back to the protocol of Concert's own connection. Only enable this when a terminator you control sets the header, since it overwrites whatever the client sent.

Global asset concurrency

After its per-user check, every asset request takes a slot from one global semaphore. By default its size is cap × asset-user-cap-h2: with -cap 1200 and -asset-user-cap-h2 200, the asset cap is 240,000. Set -asset-cap to override it with a number that reflects what your asset tier can actually serve. A request that can't get a global slot within -asset-wait gets 503 Service Unavailable with Retry-After: 1.

The per-user check runs first, so a client over its own limit is rejected before it can take a global slot.

The asset cap is fixed at startup. POST /_room/cap changes only the page cap.

Public assets

Some assets are fetched by things that never loaded a page first:

  • link-preview crawlers from Slack, iMessage, Facebook and LinkedIn fetching your og:image
  • email clients rendering newsletter images
  • RSS readers
  • other sites embedding your images

List those paths in -asset-public. They skip the pass check but still count against the global asset semaphore.

Logging

The asset path writes no log lines; activity is counted in /_room/stats. Asset paths, /queue/status and /_room/healthz are also left out of the access log, and -access-log=false turns the access log off entirely.

Abuse registry

Concert keeps a registry of clients that misbehave. Each abusive act adds weighted strikes to the client's address. When a client's strikes within -abuse-window reach -abuse-strikes, it is banned for a cooldown. While banned, every request from it gets:

HTTP/1.1 429 Too Many Requests
Retry-After: 300
Content-Type: application/json; charset=utf-8
Cache-Control: no-store

{"error":"temporarily blocked","retry_after_seconds":300}

Enforcement happens before anything else: before the queue, the asset tier, the operations endpoints and the access log. It is a single map lookup, and banned requests are not logged.

What earns strikes
Behaviour Strikes Why it signals abuse
Admission pass with a bad signature 5 Browsers never alter the pass; someone is tampering with it
Wrong admin token 5 Someone is guessing CONCERT_ADMIN_TOKEN
Per-user asset pool exhausted 1 One pass is sending far more concurrent requests than a browser would
Queued without a room_ticket cookie 1 A script is discarding cookies to take fresh places in line

With the defaults, 20 strikes within one minute trigger a ban. That is four forged passes, four wrong admin tokens, or twenty cookie-less queue arrivals.

These never earn strikes: missing passes (link-preview crawlers), expired or stale passes (idle visitors, restarts), and the global asset limit (not any one client's fault). Queued visitors who keep their room_ticket cookie never earn churn strikes, however long they wait.

Ban paths

-ban-paths lists paths no legitimate visitor to your site ever requests. A client that requests one is banned immediately, without accumulating strikes. On a PHP site these are typically vulnerability scanners probing for secrets and admin tools:

-ban-paths "/.env,/.git/*,/.aws/*,/.DS_Store,/phpmyadmin/*,/pma/*,/wp-config.php.bak,/server-status"

Only list paths your site genuinely never serves. Don't list /wp-login.php or /xmlrpc.php on WordPress: real users and plugins such as Jetpack use them. A path may not appear in both -ban-paths and any other path flag.

Escalating cooldowns

The first ban lasts -abuse-cooldown. Each later ban of the same client doubles it, up to -abuse-max-cooldown: with the defaults, 5 minutes, then 10, 20, 40, and so on to 24 hours. A client whose last ban ended more than -abuse-max-cooldown ago, with no strikes since, is forgotten and starts from the beginning.

Who is never banned

Addresses in -abuse-allow and in -trusted-proxies are never struck or banned. List your monitoring systems, your office, and your own address there, so an operator mistake can't lock you out of the admin endpoints. Trusted proxies are exempt because they only appear as the client when a request carries no forwarding headers, such as nginx's own health checks.

Think carefully about shared addresses. Behind carrier-grade NAT or a large office network, thousands of real people can share one IPv4 address, and their strikes add up together. If you expect heavy traffic from such networks, add their ranges to -abuse-allow or raise -abuse-strikes.

IPv6 clients are tracked by their /64 prefix, because a single IPv6 host can rotate through billions of addresses in its own /64.

Memory

At most -abuse-max-entries clients are tracked. When the table is full, a new client replaces an unbanned entry if one is available; otherwise its strikes are dropped and counted in abuse_dropped_total. Existing bans are never evicted to make room.

Bypass paths

-bypass sends matching paths straight to the origin with no pass, no queue and no semaphore. Banned clients are still rejected. Use it only for traffic that has no admitted visitor behind it and can't tolerate a queue:

-bypass "/favicon.ico,/robots.txt,/.well-known/acme-challenge/*,/webhooks/*"

That covers payment-provider webhooks, certificate renewal challenges and uptime checks. Bypassed paths are otherwise unprotected, so never bypass anything that runs expensive application code.

A path may appear in only one of -bypass, -assets, -asset-public and -ban-paths. Overlapping or conflicting entries are rejected at startup.

API and non-browser clients

Browsers get the HTML waiting room. Any request whose Accept header does not include text/html gets a machine-readable response instead:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
Retry-After: 5
Set-Cookie: room_ticket=…; Path=/; HttpOnly; SameSite=Lax

{"hint":"keep the room_ticket cookie and retry to hold your position","queued":true,"retry_after_seconds":5,"status_url":"/queue/status"}

A client that keeps cookies holds its place in line across retries:

curl -c jar -b jar https://example.com/api/orders      # 429, ticket issued
curl -c jar -b jar https://example.com/queue/status    # {"ready":false,"position":12,...}
curl -c jar -b jar https://example.com/api/orders      # 200 once ready

A client that discards cookies takes a new ticket on every retry and earns a churn strike each time. With the defaults, twenty retries within a minute ban it.

When -max-queue is reached, new arrivals receive 503 Service Unavailable with a JSON body and a Retry-After header.

Set -api-json=false to serve the HTML page to every client regardless of Accept.

Operations endpoints

These are never queued, so they keep answering even when the room is full. Banned clients are rejected here too.

Endpoint Purpose
GET /_room/healthz Liveness check; returns ok
GET /_room/stats Page, asset and abuse counters
POST /_room/cap Change page capacity at runtime (admin)
GET /_room/abuse List banned clients (admin)
DELETE /_room/abuse?client=… Unban a client and forget its history (admin)

Admin endpoints require Authorization: Bearer $CONCERT_ADMIN_TOKEN and return 404 when the token is unset. Wrong tokens earn strikes.

Example stats output:

{
  "abuse_bans_total": 7,
  "abuse_dropped_total": 0,
  "abuse_enabled": true,
  "abuse_rejected_total": 1893,
  "abuse_strikes_total": 164,
  "abuse_tracked": 41,
  "asset_cap": 64000,
  "asset_denied_total": 312,
  "asset_global_throttled_total": 0,
  "asset_in_flight": 214,
  "asset_served_total": 48211,
  "asset_user_cap_h1": 8,
  "asset_user_cap_h2": 128,
  "asset_user_throttled_total": 9,
  "asset_users": 1180,
  "cap": 500,
  "evicted_total": 3,
  "live_queue_depth": 118,
  "max_queue_depth": 10000,
  "occupancy": 500,
  "promoted_total": 0,
  "queue_depth": 121,
  "queued_total": 412,
  "timeouts_total": 0,
  "token_ttl": "5m0s",
  "upstream": "http://127.0.0.1:3000",
  "utilization": 0.98
}

live_queue_depth counts only clients still holding a ticket, so it is the better number for dashboards than queue_depth, which briefly includes abandoned tickets. asset_users is the number of passes with an active per-user pool.

Listing and lifting bans:

curl https://example.com/_room/abuse \
     -H "Authorization: Bearer $CONCERT_ADMIN_TOKEN"

{"bans":[{"client":"203.0.113.9","until":"2026-09-21T18:04:11Z","remaining_seconds":243,"offenses":1}],"tracked":41}

curl -X DELETE "https://example.com/_room/abuse?client=203.0.113.9" \
     -H "Authorization: Bearer $CONCERT_ADMIN_TOKEN"

IPv6 clients appear and are unbanned as their /64, for example client=2001:db8:1:2::/64.

Raising page capacity during an event:

curl -X POST https://example.com/_room/cap \
     -H "Authorization: Bearer $CONCERT_ADMIN_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"cap": 80}'

Expose /_room/* only on a trusted network, or block it at your TLS terminator.

Reserved paths

Concert answers these paths itself, and they never reach your origin:

/queue/status
/_room/healthz
/_room/stats
/_room/cap
/_room/abuse

Priority: customers, members and staff

Your application ranks its own visitors by adding a header to any page response:

Concert-Priority: checkout; ttl=30m
Rank Value Send it when While the room is full Default TTL
0 guest Anonymous, or to clear a grant First come, first served —
1 member Signed in, nothing bought yet Waits in arrival order 8h
2 prospect On a buying page, items in cart Waits in arrival order 30m
3 customer Has bought before Priority lane 8h
4 subscriber Has an active subscription Priority lane 8h
5 checkout Payment in progress Priority lane 30m
6 staff Operators and support Priority lane, served first 8h

While the room is busy, ranks at or above -priority-lane-rank skip the line through the priority lane: a separate pool of -priority-cap slots, so the origin sees at most -cap + -priority-cap page requests at once. A lane request waits up to -priority-wait for a slot, highest rank first, then joins the line. Ranks below it wait in arrival order for now; set the lane rank to 2 to let prospects skip the line too. Bans apply at every rank.

  • The latest header wins, so the application can raise, lower or clear (guest) a rank, and every header renews it.
  • ttl is seconds or a duration, clamped to 1m–24h.
  • Unknown values are ignored and logged.
  • Only page responses grant ranks: asset, bypass and stream responses can't.

While the room is busy, ranks below -priority-lane-rank still wait, but the line stays ordered by rank. Ranks at or above it skip the line through the priority lane: a separate pool of -priority-cap slots, so the origin sees at most -cap + -priority-cap page requests at once. A lane request waits up to -priority-wait for a slot, highest rank first, then joins the line at the front of its rank. Bans apply at every rank.

With -priority-forms, a form submission from a visitor already admitted uses the lane too. If the lane stays full, the visitor gets 503 and a page saying the form was not sent. Queuing the submission would lose the form.

Cookies

Concert removes all of these from requests before forwarding to the origin, so your application never sees them.

Cookie Purpose
room_ticket HttpOnly. Identifies a queued visitor's place in line
room_pass HttpOnly. VIP pass after paying to skip the line
room_probe Readable by JavaScript. Lets the waiting room page detect whether cookies work; carries no secret
concert_admit HttpOnly. Signed admission pass for the asset tier

A browser that refuses cookies can't hold a place in line. Instead of reloading forever, the waiting room page detects this and tells the visitor that cookies are required.

The same message appears when cookies are misconfigured. The most common cause is setting -secure-cookie while browsers reach Concert over plain HTTP. A wrong -cookie-domain produces it too.

Custom waiting room page

Replace the built-in page with -html /path/to/waiting_room.html. The file may use these placeholders:

Placeholder Replaced with
{{.Position}} The visitor's queue position
{{.SkipURL}} The skip-the-line URL, or an empty string

Your page's JavaScript is responsible for the whole client side of the queue:

  • Poll GET /queue/status every few seconds.
  • Reload the page when the response contains "ready": true.
  • Treat "cookies_required": true as final: stop polling and show an error.
  • Check document.cookie for room_probe before the first poll. If it is missing, show the cookie error immediately rather than polling.
  • Read position, and optionally skip_cost, rate_per_pos and has_pass, to update the display.

The waiting room page loads before the visitor has an admission pass. Any stylesheet, script or image it uses must come from a -bypass or -asset-public path, or be inlined.

Production deployment

A typical layout keeps TLS at the edge and Concert on localhost:

internet ──▶ nginx :443 (TLS) ──▶ concert 127.0.0.1:8080 ──▶ php-fpm site 127.0.0.1:3000

nginx:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass         http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_set_header   X-Client-Proto $server_protocol;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection $connection_upgrade;
        proxy_buffering    off;
        proxy_read_timeout 3600s;
    }
}

Put the map block in the http section. X-Forwarded-For is what lets Concert identify and ban individual clients; without it every request appears to come from nginx, which is exempt. Turning proxy_buffering off lets streamed responses reach clients immediately.

systemd unit (/etc/systemd/system/concert.service):

[Unit]
Description=Concert waiting room proxy
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/concert
Environment=CONCERT_LISTEN=127.0.0.1:8080
Environment=CONCERT_UPSTREAM=http://127.0.0.1:3000
Environment=CONCERT_CAPACITY=50
Environment=CONCERT_SECURE_COOKIE=true
Environment=CONCERT_CLIENT_PROTO_HEADER=X-Client-Proto
Environment=CONCERT_ASSETS=/wp-content/themes/*,/wp-content/plugins/*,/wp-includes/*
Environment=CONCERT_ASSET_PUBLIC=/wp-content/uploads/*
Environment=CONCERT_BYPASS=/favicon.ico,/robots.txt,/.well-known/acme-challenge/*
Environment=CONCERT_BAN_PATHS=/.env,/.git/*,/.aws/*,/phpmyadmin/*,/wp-config.php.bak
Environment=CONCERT_ABUSE_ALLOW=198.51.100.10
Environment=CONCERT_ACCESS_LOG=false
EnvironmentFile=/etc/concert/secrets.env
Restart=on-failure
DynamicUser=yes
NoNewPrivileges=yes

[Install]
WantedBy=multi-user.target

/etc/concert/secrets.env, mode 0600:

CONCERT_ADMIT_SECRET=…
CONCERT_ADMIN_TOKEN=…

On SIGTERM Concert stops accepting new connections and gives in-flight requests up to 30 seconds to finish.

Limitations

One instance, one queue, one registry. Queue state, asset semaphores and the abuse registry live in the process's memory. Two Concert instances are two independent waiting rooms: the origin sees up to twice -cap, a visitor's place in line exists only on the instance that issued their ticket, and a client banned on one instance is not banned on the other. If you must run more than one, divide -cap by the number of instances and enable sticky sessions at the load balancer. Use a cookie the load balancer inserts itself, because room_ticket is only issued once a visitor is queued. Admission passes work across instances as long as they share CONCERT_ADMIT_SECRET.

A pass limits concurrency, not request rate. A pass holder can have at most its per-user cap of asset requests in flight, but fast assets finish quickly, so a single pass can still generate many requests per second. The global semaphore bounds the total load on the host regardless. Volumetric attacks from many addresses belong at a CDN or WAF in front of Concert; the abuse registry handles individual misbehaving clients.

Long-lived connections hold slots. WebSockets and server-sent event streams keep their page slot until they close. With -cap 50 and 50 open sockets, nobody else gets in. Run a separate Concert instance for long-lived paths.

Skip the line is not wired end to end. -rate, -surge, -skip-url, and -pass enable the pricing card on the waiting room page. But promoting a paid visitor requires calling room's in-process API after payment, and Concert does not yet expose an endpoint your payment flow can call. Leave -rate at 0 in production for now.

Development

make all                    # vet, clean, test, race tests, benchmarks, cross-platform build
make build                  # binaries for linux, darwin, windows × amd64, arm64 in bin/
go test -race -count=1 ./...

The test suite runs the real waiting room, asset tier and abuse registry against a fake origin. It covers:

  • configuration precedence and validation
  • client address resolution through trusted and untrusted proxies
  • admission pass signing, tampering, staleness, expiry and refresh
  • per-user HTTP/1.1 and HTTP/2 limits, and the global asset limit
  • every strike source, ban paths, escalating cooldowns, allowlists, IPv6 /64 grouping, and the admin ban endpoints
  • queueing for browsers and API clients, the queue-depth breaker, and cookie-jar resume
  • bypass routing, streaming, and forwarded-header handling
  • graceful shutdown

License

Copyright 2026 Andrei Merlescu

Licensed under the Apache License, Version 2.0. You may not use this project except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Documentation

The Go Gopher

There is no documentation for this package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL