# Changelog All notable changes to quicgate are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and the project uses [Semantic Versioning](https://semver.org/). ## [1.18.2] - 2026-10-27 ### Security - **Built with Go 1.27.3.** Two Go standard library vulnerabilities that quicgate's own code reaches are fixed by it: GO-2026-6617 in HTTP/2 (every HTTPS listener serves HTTP/2, and the WireGuard site transports use it towards upstreams; also fixed in `golang.org/x/net` 0.60.0) and GO-2026-6809 in `crypto/x509` certificate verification (hosts that require client certificates). Images up to 1.18.1 were built with Go 1.27.1 and carry both. ### Changed - Dependencies: quic-go 0.64.1, golang.org/x/net 0.60.0, golang.org/x/crypto 0.58.0, certmagic 0.25.6, modernc.org/sqlite 1.60.1, go-ldap 3.4.15, klauspost/compress 1.20.3. - CI pins staticcheck v0.8.2; v0.8.1 cannot read packages compiled by Go 1.27.3. ## [1.18.1] - 2026-09-27 ### Fixed - **The browser's Back button goes to the previous page of the admin UI.** Opening a page or a settings section replaced the address instead of adding a history entry, so Back left quicgate for whatever site was open before it. Every page and section you open is now a step in the browser's history: Back and Forward move between them, and opening the page you are on adds nothing. ### Changed - **Release images build in a minute or two instead of eleven.** The arm64 image was compiled under QEMU emulation; the Dockerfile now cross-compiles on the builder's own platform (the final image is `FROM scratch` and runs nothing at build time), so both architectures are native compiles. The image contents are the same. - CI pins staticcheck (v0.8.1) instead of installing whatever is latest. ## [1.18.0] - 2026-09-27 Fixes from the September 2026 security, technology and functional scan (the record is in SECURITY.md). Every change below carries a regression test that fails without it. ### Security - **The real client address is read from every `X-Forwarded-For` header line, not the first only.** Behind a trusted proxy that appends the client as a separate header line (HAProxy's `option forwardfor`, any Go proxy), a client could put an address of its own choosing in a first line it wrote itself and walk past IP allowlists, rate limits, auto-ban and the never-ban list. - **A basic-auth login with an unknown username takes as long to refuse as a wrong password for a known one.** It was refused in microseconds, so the response time told which usernames exist. - **Paths an upstream may read differently are refused on hosts with path rules or custom locations.** A path with an empty segment (`//`), a backslash, a `;` inside a segment or a `%` left after decoding is answered 400 before any rule or gate, like a dot segment: nginx, IIS and Tomcat normalise those, so `//admin/secret` reached a gated `/admin/`. Hosts without path rules or locations are unaffected. Prefix rules and locations match whole segments: `/api` covers `/api` and `/api/...`, no longer `/api-internal`; a rule ending in `/` is a plain prefix as before. - **HTTP/3 refuses 0-RTT early data**, which a network attacker could replay. - **Admin API request bodies are bounded and the listener has timeouts.** Any client that could reach port 81 could send an unauthenticated login with a body of hundreds of megabytes and drive the process towards an out-of-memory kill of the whole proxy; bodies are now limited to 16 KiB on credential endpoints and 4 MiB on configuration endpoints (413 above that), and the admin server has a 30-second read timeout (the restore upload gets fifteen minutes) and a 120-second idle timeout. - **Failed admin logins are counted per network, and wrong codes per account.** Ten failures in fifteen minutes lock the client out; an IPv6 client is counted per /64, so rotating through one network's addresses no longer buys unlimited attempts. Ten wrong second-factor codes lock the account's code step from any address (only someone with the password gets that far), and ten failed password confirmations by a signed-in session lock those confirmations. Wrong passwords are not counted per account on purpose: that lockout would refuse the right password too, so anyone who can reach the login could keep the administrator out. A wrong code costs the same delay as a wrong password, a locked entry is never evicted when the table is full, and an unknown account costs the same comparison as a known one (the response time no longer says which addresses are accounts). - **A TOTP code is accepted once.** The time step of the last accepted code is remembered per account and that step and earlier ones are refused, at login, when enabling 2FA and when creating a break-glass device. - **The admin listener honours trusted proxies.** Behind a proxy listed under trusted proxies the login throttle keys on the address the proxy reports, so one careless colleague behind the proxy no longer locks everyone out and a client behind it cannot spoof its address. The settings are validated on save (addresses or CIDRs, `/0` refused, a valid header name). - **The 2FA setup secret is bound to the session.** `/api/2fa/enable` confirms the secret that `/api/2fa/setup` produced for this session (fifteen minutes), not one the client sends. - **Cookie-authenticated admin writes need an `Origin` or `Referer` header**, which browsers always send; a request with neither is refused. Scripts use API tokens, which are exempt. - **SSO hosts are HTTPS only.** A host with single sign-on on the host or on a path rule redirects plain HTTP to HTTPS (308) and refuses other methods, like the VPN portal, unless quicgate runs with `QG_TLS=off`; the login and the session cookie were served over plain HTTP when force-SSL was off. `X-Forwarded-Proto` is believed only from a trusted proxy, for the cookie's `Secure` flag and the redirect URI alike. - **The SSO session cookie is not forwarded to the upstream** or to a forward-auth server; every other cookie is passed unchanged. A backend no longer holds a replayable credential for its host. - **Identity headers are stripped on forward-auth hosts too.** `Remote-User`, `Remote-Email` and `Remote-Groups` in any spelling are removed from inbound requests whenever any identity gate exists, not only with the built-in SSO. - **A group name cannot inject groups.** Commas, percent signs and control characters in a group name are percent-encoded in `Remote-Groups` (`Sales, EMEA` arrives as `Sales%2C EMEA`). - **`preferred_username` satisfies no e-mail rule.** When the ID token carries no `email` claim the username is used as the identity, but it passes allowed-emails and allowed-domains rules only when `email_verified` is true; group rules still apply. - **The SSO signing key is never regenerated over one that cannot be opened.** After a restore under another sealing key, single sign-on answers 503 and the log says why, instead of silently making a new key that signed everyone out for good once the right key came back. - The admin OIDC sign-in uses distinct random values for `state` and `nonce` and compares the state in constant time; the SSO gate does the same. - Provider discovery runs under its own context (a visitor who disconnected mid-discovery no longer poisons the cache), is refreshed hourly and retried ten seconds after a failure. - The VPN portal limits login starts and device enrolments to ten per minute per client address. - **The declarative import refuses to drop a host's VPN-only restriction**, to downgrade its client-certificate mode from *require*, to turn its certificate mode into *none*, to drop force-SSL or to drop a minimum TLS version of 1.3: a document that omits the field left a private host public. Include the field, or change the host in the UI or API. - **Manual hosts win over Docker labels in every case.** A container could take an exact name under a manual wildcard host (`api.example.com` under `*.example.com`) or any name of a manual host that was switched off, and have a certificate issued for it. Both are refused now. - **A Docker label key quicgate does not know leaves the container unrouted.** A typo in the key (`quicgate.access_list`) used to publish the container without the access list it asked for; the Docker page now names the key and the one it probably meant. - **Remote Docker endpoints can use TLS with a client certificate** (`caFile`, `certFile`, `keyFile`, Docker's `--tlsverify` model). A `tcp://` endpoint without them is plaintext and trusts whoever answers on that port; the guide says so now. - **VPN refresh tokens are part of the seal inventory**: key rotation re-seals them and `-unseal` writes them back; before they stayed under the old key. - The database file and its `-wal`/`-shm` companions are made owner-only (0600) at start, whatever the data directory's permissions. ### Changed - **Auto-ban no longer counts what a visitor's browser was made to send.** Any web page could make a visitor's browser send five requests to an address-listed host (image tags will do) and get the visitor, and everyone behind the same NAT, banned from every host for an hour. A request the browser marks as sent for another site's page (`Sec-Fetch-Site: cross-site` or `same-site`), a refused CORS preflight, a missing credential and a token of another scheme are refused as before but not counted. Wrong basic-auth passwords and refusals by address of everything else (scanners, direct visits) still count, so auto-ban keeps banning the scanners that walk every host. An IPv6 client refused by a list that opens to every IPv4 address (`0.0.0.0/0`) and to no IPv6 one is not banned either: add `::/0` next to `0.0.0.0/0` to let IPv6 visitors in. - **The response cache is bounded to 64 MiB per host** (entries up to 2 MiB as before) and evicts the least recently used response; before, 512 entries of up to 2 MiB each could be filled with cache-busting query strings. - **TCP streams have an idle timeout and a per-client cap.** A connection that carries nothing in either direction for ten minutes is closed with its backend connection (within ten more minutes; traffic one way keeps it open, so a download with a silent client runs as long as it likes), and one client address may hold at most 256 connections per listener (the listener's total stays 4096). Behind a trusted PROXY-protocol peer the cap counts the real client. The copy itself is unchanged: plain TCP streams keep the kernel's zero-copy path, and the idle timeout costs one deadline per ten minutes, not one per read. - **SNI routes match DNS names.** Case and a trailing dot no longer matter, a ClientHello split over several TLS records is read whole, a connection that does not start with a ClientHello is closed, and a connection sent to the default target for want of a matching name is logged. - **Health checks verify certificates.** The probe of an HTTPS upstream accepted any certificate; it now verifies the way the host's own traffic does (with the host's upstream SNI) and skips verification only for hosts that skip it themselves. - **Static hosts serve files only.** A directory without `index.html` answers 404 instead of a listing, and dot-prefixed names (`.env`, `.git`) are not served, except under `/.well-known/`. - Access-log records cut the host to 253 bytes, the path to 2 KiB and the user agent to 512 bytes. - A stream cannot take the admin port; it is reserved like 80 and 443, as the guide already said, and the admin port is bound before the engine starts, so a stream configured on it fails as a stream instead of crashing the admin UI. - **Admin OIDC sign-in needs only a selected identity provider.** `oidc_enabled` is no longer required, and when the redirect URL is empty the callback URL is derived from the request (the scheme from `X-Forwarded-Proto` only when it comes from a trusted proxy). Starting a sign-in is limited to 30 per client address per fifteen minutes, a full table of pending sign-ins refuses new ones instead of evicting one in progress, and provider error details are logged, not shown. - The identity-provider session lifetime (`sessionHours`) is capped at 720 hours (30 days); longer stored values are treated as 720. - The admin UI no longer lists directories, `/api/version` no longer includes the Go version, and every `/api/*` and `/metrics` response carries `Cache-Control: no-store`. - One admin account holds at most 32 live sessions; the oldest ends when another starts. A password longer than 72 bytes is refused with 400 and a clear message instead of a 500. - **Validation is complete on every host and stream field.** Domain labels are limited to 63 and names to 253 characters; upstream, forward and redirect hosts must be an IP or hostname; a static root must be an absolute path; a forward-auth URL must be http(s); header rules cannot touch `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Upgrade`, `Keep-Alive`, `TE` or `Trailer`; timeouts are capped at an hour, `cacheSec` at 30 days, `maxBodyMb` at 10240, `hsts.maxAge` at two years, self-signed certificates at 3650 days; a single-port stream needs a forward port. Existing rows are not rewritten, but a row outside these bounds is refused on its next save. - **`docker_endpoints` is validated strictly.** An unknown field, a `connect` that is not a socket path, `tcp://host:port` or `https://host:port`, or an `address` that is not an IP or hostname refuses the whole list; the refusal is logged and no Docker host is watched until it is fixed (before, an invalid list fell back to the local socket silently). - Certificate import from a file path requires a regular file of at most 512 KiB (symlinks are followed, so certbot's `live/` paths work); a FIFO or device is refused without being opened. - The build context leaves out `.git`, brand assets and root Markdown (`.dockerignore`); CI runs gofmt and staticcheck, only the build job may publish and sign, and the image carries an SBOM. - Dependencies: quic-go 0.63.0, golang.org/x/net 0.59.0, acmez 3.1.7, klauspost/compress 1.20.1, and the gVisor netstack moved from a May 2025 commit to the current `go` branch (it is untagged, so Dependabot never proposed it; sixteen months of upstream fixes to the packet parser every WireGuard peer talks to). ### Fixed - **TLS-terminating streams no longer restart on every reload.** The change detection compared the address of the certificate id rather than its value, so every configuration save, Docker reconcile and five-minute periodic reload stopped and restarted every TLS-terminating listener and cut its connections. Only a real change restarts a listener now. - **Custom locations follow the host's rules.** Request and response header rules, `X-Robots-Tag`, the Host override, sticky sessions and the buffering setting applied to the default upstream only; a path routed to a location got none of them. - **A list with users no longer strips `Authorization` from a request admitted by its address** (satisfy any) or from a non-Basic scheme; bearer-token APIs behind a "LAN or password" list work again. The header is removed only when the list itself checked a Basic credential naming one of its users and Pass Auth is off. - **Behind a trusted proxy that ends TLS, upstreams see `X-Forwarded-Proto: https`**, and so do the forward-auth server and the `{scheme}` placeholder. The header is believed only from a trusted proxy, and a request quicgate received over TLS is never reported as plain. - **A pool member that cannot be reached is taken out at once.** The request that failed to connect marks it down, so the following requests go to the other members instead of every other one answering 502 for up to fifteen seconds; the health check brings it back when it answers. - **The force-SSL redirect keeps a non-standard HTTPS port.** - **"Remove device" on the VPN portal works.** The page sent the request without a content type and the portal refused it as not coming from the portal, so a device could never be removed from the page; the page and the check agree now, and the test drives the request as a browser sends it. - **Declining the login at the identity provider shows a refusal page (403)** instead of a 502 from an empty token exchange. - `GET /api/me` with an API token answers `{"email": "", "token": true, ...}` instead of a 500. - The portal's `/api/me` reports `connected: false` for a device that has never connected. - **LAN access (experimental): the flow log and site-endpoint lookups no longer run under the WireGuard endpoint's global lock**, the forwarder's view of this machine's addresses and listeners is cached, and UDP admission runs on worker goroutines instead of the packet-decrypt path, so a slow disk or resolver no longer stalls the tunnel, tunnel DNS, reloads, the status page or the public proxy's connections through sites. "No record, no flow" still holds. - **A denied UDP flow is remembered briefly** so its repeat datagrams are dropped without a new decision or record; deny records are rate-limited per device; an allowed flow refused because the flow-log queue is full is counted in the dropped-records figure the Overview shows. - **Relay buffers are pooled and sized to their direction**, cutting the memory a device's UDP flows can hold to about a quarter, and one device can no longer fill the forwarder's half-open connection table (64 per device; the excess is reset). - **The flow log never records a LAN flow as allowed when it was refused** because the log was too slow; the record is written as a refusal. - The tunnel network cannot be changed while devices exist (as was already the case for sites), and a device whose address falls outside the tunnel network is left out rather than misconfigured. - A WireGuard site's endpoint is validated beyond `host:port`: loopback, unspecified, multicast, link-local, broadcast and reserved addresses, and quicgate's own listeners, are refused. - The test of "removing a site closes its connections" no longer fails under the race detector on small machines (crossed handshake initiations). - **`quicgate.streams` entries with `/udp` work.** Only TCP publications were read, so the documented `53/udp` example warned "not published"; `/both` now needs the same host port for both protocols and is refused with a warning naming both otherwise. A label stream whose listen port a manual stream, quicgate itself or another container already uses is shown as skipped in the container's status instead of only in the log. - **Docker routes follow manual hosts without a container event.** Creating or deleting a manual host left the Docker status stale, and a label route displaced by a manual host did not return after the manual host was deleted; routes are re-aggregated every 30 seconds as well. - **A damaged stored secret no longer stops the sealing migration.** One value that does not open is left as it is and named in the secret status (`warnings`), and everything else is sealed; before, the whole migration was skipped while the status said "sealed". - The sticky-session cookie is `Secure` when the client's connection is encrypted; a forward-auth refusal is relayed without the auth server's hop-by-hop headers; the default-site setting is compiled at reload, so a request for an unknown host no longer reads the database; a UPnP mapping key that does not parse is dropped instead of unmapping port 0. ### Added - An access list with users under "satisfy any" that also has deny rules shows a warning on its hosts: valid credentials admit a client the deny rules refuse. ## [1.17.5] - 2026-09-27 ### Added - **The sign-in email can be changed.** Until now the first account kept the address `admin@example.com` for good: only its password could be changed (issue #20). Account & security has a Sign-in email panel now, and the API has `POST /api/email`. The change needs the current password, like a change to the second factor, because the address is also what admin sign-in through an identity provider matches on. It is stored in lowercase, an address another account uses is refused, and every other session of the account is signed out. Two-factor authentication stays as it was. ### Changed - Dependency updates: `modernc.org/sqlite` 1.59.0 and a newer `golang:1.27.1-alpine` build image. ### Fixed - A test of 1.17.2's "leaving the public side ends open connections" failed now and then under load: it checked the connection once, right after the reload, while the connection is closed a moment later from another goroutine. It now waits up to three seconds for the connection to end. ## [1.17.4] - 2026-09-19 ### Fixed - **The WireGuard endpoint is in the list of open ports.** It holds a UDP port and the router maps it through UPnP like the others, but the Overview left it out, so the list disagreed with the router's. It is shown now with its state, the router mapping, the sites and devices configured, how many peers are connected, and the tunnel's traffic. ## [1.17.3] - 2026-09-19 ### Fixed - **A name belongs to one host.** Nothing stopped two hosts from carrying the same domain, so saving a new host several times made several hosts with one name. The routing table served one of them and the others sat in the list answering nothing. A domain that another host already serves is now refused, without regard to case, and so is a name listed twice on one host. Saves that arrive at the same moment are decided one after the other, so eight simultaneous saves of one name make one host. - **Save buttons cannot be pressed twice.** While a change is on its way to the server every Save button is disabled and reads "Saving...", and a second submit is ignored. This applies to every form in the admin interface. ## [1.17.2] - 2026-09-19 A security release. An external review of the 1.17.1 source found twelve defects in the WireGuard work, five rated high. None is an unauthenticated remote attack, and an instance that never switched WireGuard on is not affected by any of them, but every one was a gap between what the design promises and what the code did. All twelve are fixed here, each with a regression test that fails without its fix. **If you use sites, devices or the portal, upgrade.** LAN access stays marked experimental: the fixes have not been reviewed from outside yet. ### Security - **One key, one peer, whatever its spelling (QG-01, high).** Go's base64 decoder skips line breaks and accepts loose padding, so one WireGuard key had many accepted spellings, and the uniqueness check compared text. Somebody allowed to add a device could register *another peer's* public key in a different spelling; inside WireGuard the two became one peer, and the new entry took over the victim's addresses and preshared key, cutting it off. No traffic could be read that way. Keys are now stored and compared in one canonical form, other spellings are refused, keys stored by older versions are canonicalised at start, a site's key can no longer be changed by an update, and the endpoint leaves a duplicate out instead of letting the last one win. - **An open connection ends when its authorization ends (QG-02, high).** A lapsed login, an expired break-glass device or the 30-day limit only kept *new* connections out; one that was already open went on until something else closed it. The endpoint now has a timer on the next deadline and closes what the expired device holds. A renewal that arrived in time keeps its connections. - **Making a host VPN only disconnects whoever is connected from outside (QG-03, high).** New public requests got the 404 at once, but an open WebSocket or stream from the public side stayed open. Public requests are now tracked per host and ended when the host becomes VPN only, is disabled or is deleted. On HTTP/2 and HTTP/3 only that host's streams are reset. - **A server key that cannot be read is never replaced (QG-04, high).** After restoring a backup onto an instance with a different sealing key, quicgate read "cannot open the stored server key" as "first use" and generated a new key over it. Putting the right sealing key back later could then no longer recover the old one, and every site and device had to be set up again. Now the endpoint stays down and says why, the stored key is kept byte for byte, and the right sealing key brings the original identity back. If that key is lost, the VPN page offers an explicit, password-confirmed reset. - **No portal over plain HTTP (QG-05, high).** A portal host without a certificate served its login, its session cookie (without `Secure`) and a new device's configuration over HTTP, where anybody on the path could read them or change the page that makes the private key. The portal now redirects plain HTTP to HTTPS and refuses everything else, its cookies are always `Secure`, and `X-Forwarded-Proto` is only believed from a trusted proxy. - **The tunnel's listeners share the per-device budget (QG-06).** The limits of 512 open flows per peer and 4096 in total only applied to forwarded LAN flows, not to connections to quicgate's own listeners in the tunnel. They now cover both, and dials through a site too. - **"No record, no flow" means written (QG-07).** The record of an allowed LAN flow only had to fit in a memory queue. With a full disk or a log directory that cannot be created, every record was lost silently while every flow went through. The record is now written before the flow is admitted; while the log cannot be written LAN flows are refused, the Overview says so, and admission comes back by itself with the log. - **Adding a device takes a login that is fresh now (QG-08).** The freshness of the login was checked when logging in, not when enrolling, so a portal page kept open (or a copied session cookie) could add devices for as long as it stayed alive. - **An identity provider's issuer cannot change under the VPN (QG-09).** The VPN names people by provider and subject id. With another issuer under the same provider entry, somebody else with the same subject id would have owned the first person's devices, policies and blocks. - **The outage grace reaches the running endpoint (QG-10).** It was written to the database but not applied, so devices were cut off at the normal deadline during an identity-provider outage although a grace was configured. This one failed closed. - **UDP datagrams through LAN access stay whole (QG-11).** Datagrams above 32 KiB were delivered cut off at 32 KiB. - **A pending dial through a site ends when the site is removed (QG-12),** instead of waiting for its caller's timeout. - Also: a relayed DNS reply must repeat the question it answers, not only carry the right id. ### Still open from the review No load or flood test of the VPN exists yet, so nothing is claimed about memory or CPU under hostile load; the question whether a listener must be bound to one instance of the network stack across a controlled reset has not been settled with a test on Linux; the portal is not qualified against a live Keycloak; the official phone apps are untested. See ROADMAP.md. ## [1.17.1] - 2026-09-18 ### Changed - **The VPN page shows everything it can do, from the start.** In 1.17.0 the parts about people (logins, LAN access, policies) stayed hidden until a portal host existed, the "VPN only" option was hidden in the host editor until WireGuard was on, and the portal was only findable as a host type. With WireGuard off that left a page with two empty tables and no hint of the rest. Now the page opens with a map of the four parts in the order they build on each other (the endpoint, sites, devices and VPN-only hosts, people with the portal and LAN access), each with its state, what it still needs, and links to the right place, including "Add a VPN portal host". Every section is always there and says what it is for while it is empty. - "VPN only" is always shown in the host editor. While WireGuard is off it is disabled and says so, because a VPN-only host would then be reachable by nobody. ## [1.17.0] - 2026-09-18 The rest of the WireGuard work in SPEC-wireguard.md: Release B (devices and a private entrance) and Release C (single sign-on for the VPN, LAN access). Everything here is off until you use it, and an install that does not switch WireGuard on opens no socket for it and admits nothing (a few idle background workers exist whether it is on or not). The new **VPN guide** in the built-in help describes all of it. ### Added - **Hosts that exist on the VPN only.** Tick "VPN only" on a host and it is served inside the WireGuard tunnel and nowhere else. On the public listeners the name is answered like a name quicgate has never heard of, in HTTP and in the TLS handshake. Devices find such hosts without DNS work: quicgate is the DNS server inside the tunnel and answers its own names itself. - **Devices.** Add a phone or a laptop on the VPN page. The browser makes the keypair, the configuration is shown once and can be downloaded as a `.conf` file. Revoking is final: the key can never be registered again and open connections are closed at once. - **VPN rules in access lists:** a fourth kind of rule that says who is at the other end of the tunnel (anyone, a site, a device, a group, a person). A request from outside the tunnel never matches one, and a request from inside never matches an IP or country rule, so switching the VPN on cannot change who passes a list you already have. - **The VPN portal**, a new host type. People log in with single sign-on and add their own devices. The login must be fresh (`max_age` and a checked `auth_time`) and must come with a refresh token; quicgate then asks the identity provider again every few minutes. A refusal takes the person's devices off at once and closes their connections; an outage extends nothing; after 30 days the person logs in again. Admins can end a login or block a person. - **LAN access (experimental).** People who logged in reach the destinations their policies name, on the networks the quicgate machine is on. quicgate is not a router: it terminates each connection in its userspace stack and opens a new one, so nothing on the LAN can reach a device and devices cannot reach each other. Loopback, link-local, multicast, the tunnel itself and every listener of quicgate are refused whatever a policy says, and this machine's own addresses are not covered by a route for the network they are in. Every flow is logged before it is allowed. In Docker on a bridge network, LAN access does not switch on until you list the host's addresses or confirm that no quicgate port is published in reach. - **Break-glass devices** for the day the identity provider itself is what broke: LAN routes without a login. They take your password and a two-factor code, there are at most two, and they expire unless you state otherwise. The Overview page reminds you that they exist. - The Overview's Attention list reports a WireGuard endpoint that does not run, break-glass devices, and dropped flow-log records. ### Fixed - **A `GET` rule in an access list now covers `HEAD`.** Monitors and link previews send HEAD; they were refused by a list that allowed GET, and those refusals counted toward a ban. - **A login prompt is not a ban strike.** The 401 that asks a browser for credentials, sent when none were given, no longer counts toward an automatic ban. Wrong credentials still do. Together with the previous item this removes the two ways an ordinary visitor's address could end up banned. - A WireGuard site that calls in and had never been heard from made wireguard-go log an error every five seconds, for as long as the site was away. Keepalives now start when the site has been heard from. - A dial-in site returns within seconds after quicgate restarts, instead of up to 40: quicgate remembers where each peer was last seen and calls it back. - A tunnel address that a deleted site gave up was handed to the next site at once, which forces a rebuild of the network stack and drops every open tunnel connection. Freed addresses now stay out of use until the network has nothing else left. - An identity provider can no longer be deleted while the VPN still names it. - The key options in the "Add site" dialog were drawn with the browser's default white border, and the hints under several fields were unstyled. ### Notes - LAN access is marked experimental in FEATURES.md until the code has had an outside review, and the portal is tested against a synthetic identity provider, not yet against a live Keycloak. The official WireGuard phone apps have not been tested by the project; the key format the browser produces is verified against the WireGuard implementation quicgate embeds. - Still not built from SPEC-wireguard.md: the passphrase-wrapped key in backups and a key-rotation action (S36), and cancelling request contexts of requests in flight (S42; their connections are closed, which ends them). ## [1.16.0] - 2026-09-18 ### Added - **WireGuard sites: reach upstreams on networks quicgate is not on.** A site is an ordinary WireGuard peer at another location (a router, a small Linux box, a VPS) that fronts one or more networks. A proxy host, its pool and locations, and a stream can then be reached *through* that site. It also works the other way round: quicgate on a VPS, with the network at home calling in from behind carrier NAT. The endpoint runs inside quicgate (wireguard-go on a userspace network stack): no network device, no extra privileges, no change to the host's routing, still one static binary. Off by default; the new **VPN** page switches it on. - A site's private key never reaches quicgate: the browser makes the keypair (or you paste a public key), and the site's configuration, with the preshared key quicgate adds, is shown once. The server's key and the preshared keys are sealed at rest like every other secret. - The page shows each site's last handshake, traffic and open connections. With UPnP on, the UDP port is mapped on the router like 80 and 443. ### Security properties of sites - **Never by address, never a fallback.** Traffic enters the tunnel only for an upstream that names a site. When the site or the tunnel is down, such an upstream answers 502: its address is never tried on the local network, where the same address can belong to another machine. An address outside the site's declared networks is refused before a packet is made. - One connection pool per site. quicgate used one pool per host, and Go pools connections by address, so the same address behind a site and on the local network would have shared connections. A host may also not reach one address two ways. - A network never changes hands inside the running VPN stack: giving a site a network another site had, or a new key, rebuilds the endpoint, which interrupts VPN traffic for a moment. Disabling or deleting a site closes the connections through it at once. A site that hosts or streams still use cannot be deleted. - One site's problem stays that site's: an endpoint name that does not resolve leaves the endpoint and the other sites running, and names are looked up again every five minutes, so a dynamic-DNS endpoint is followed. ### Good to know - A site without an endpoint has to call quicgate. After quicgate restarts such a site needs up to 40 seconds to come back, and hosts behind it answer 502 until then. A site with an endpoint is back at once. Give a site an endpoint wherever it has a reachable address. - The site's own router must forward between the tunnel and its network (IP forwarding, and a return route or NAT). See the Streams & port forwards guide. - The image grows from about 28 MB to about 34 MB. ## [1.15.0] - 2026-09-18 ### Security - **Secrets in the database are encrypted at rest.** Until now the database held them in the clear: the admin's two-factor (TOTP) secret, OIDC client secrets, DNS provider credentials, the private keys of uploaded and self-signed certificates, and the key that signs SSO cookies. A leaked `quicgate.db` or backup archive handed all of them over. They are now sealed with XChaCha20-Poly1305, each value bound to its own row and column so a ciphertext copied elsewhere does not open. The first start of this version seals everything that is stored, then rewrites the database file, because replacing a value in SQLite leaves the old bytes in free pages and in the write-ahead log. Passwords, basic-auth passwords and API tokens were hashes already. - The key comes from `QG_SECRET_KEY_FILE` or `QG_SECRET_KEY`; without either, quicgate creates `secret.key` in the data directory. A key in the data directory protects against a leaked database or backup, not against someone who can read the whole directory: keep the key elsewhere if that matters to you. **Backup archives never contain the key.** Keep a copy of it with your backups, stored separately; a restore on another machine needs it. - **A missing or wrong key locks the secrets, it never replaces them.** quicgate still starts and proxies. Sealed values are kept untouched, no new key is invented, and what needs a secret fails closed: OIDC sign-in, DNS-01, hosts on a custom certificate, and any login for an account with two-factor (an unreadable 2FA secret is never read as "2FA is off"). The Overview says so under Attention. Put the key back and restart. - Restoring a backup from an older version seals what it brings in. Restoring one that another instance sealed keeps those values as they are and warns that its key must be added to the key file (one key per line; the first line seals, all lines open). ### Upgrade note - **Rolling back:** a version before this one reads a sealed value as if it were the secret, so certificates fail to load and OIDC logins fail (closed, not open). Before starting an older version, run `quicgate -unseal` once: it writes the secrets back in plaintext and exits. ## [1.14.1] - 2026-09-18 ### Fixed - "Never ban my own addresses" no longer counts link-local addresses, and Settings shows how many addresses of this machine it covers (the full list is in the tooltip) instead of printing them all. On a Docker host that was one address per container interface. ## [1.14.0] - 2026-09-18 ### Added - **A never-ban list for auto-ban.** Settings, Auto-ban has a list of addresses and CIDR ranges that are never banned, and a switch "Never ban my own addresses" that covers this machine's addresses and the router's public address (learned over UPnP, and followed when it changes). Devices at home that open a public hostname arrive from the router's public address, so one monitor sending requests an access list refuses could ban the whole household from every host. Access lists still apply to these addresses; they are only never banned. Putting a banned address on the list lifts its ban straight away. A list with a typo is refused instead of half applied. `GET /api/own-addresses` shows which addresses the switch covers. ## [1.13.0] - 2026-09-15 ### Changed - Bans survive a restart or an upgrade. Auto-ban saves its bans to `bans.json` in the data directory whenever a ban starts or is lifted, and at shutdown, and restores them at startup, leaving out the ones that expired in the meantime. Before, every restart lifted all bans. ## [1.12.1] - 2026-09-15 ### Fixed - Browser password managers no longer fill the saved admin login into the Logs and Proxy hosts search boxes, or into other settings fields. The search boxes sit in forms of their own, the two-factor password fields in forms that name the account, and a basic-auth user's password in an access list counts as a new password for someone else, so the admin login is not offered there either. ## [1.12.0] - 2026-09-15 ### Added - **See who is banned and why.** Access control lists every address auto-ban is turning away: its country, the reason (address not allowed, no credentials or wrong credentials for which access list), the host it asked for, how many refusals led to the ban, when it started and when it lifts, with a button to lift a ban by hand. The Overview's Blocked tile and panel link there. - `GET /api/bans` and `DELETE /api/bans/{ip}`. ## [1.11.1] - 2026-09-14 ### Fixed - Plain HTTP connections half-close again before closing a connection whose request body was not read (for example a refused upload), so the client receives the response instead of a reset. v1.11.0's byte counting had hidden that from the HTTP server. - Traffic rates divide by the seconds of traffic a point actually holds, so the point spanning a restart, and the partial interval saved at shutdown, no longer read as a drop. Shutdown now records the traffic since the last sample. - The login prompt a client without credentials gets from a basic-auth access list is no longer counted as blocked; wrong credentials still are. - A refused UDP sender counts once a minute instead of once per packet. - Paths closed because their single sign-on or forward auth is not configured are counted under that reason instead of access lists. - HTTP/3 connections count only once their handshake completes, so spoofed handshakes do not inflate the connection numbers. - Moving the start of a stream port range restarts the ports it shifts. Before, those ports kept forwarding to their old target port until a restart. - A saved traffic history larger than the engine keeps is ignored instead of loaded. ## [1.11.0] - 2026-09-14 ### Added - **Traffic on the Overview.** quicgate now records its own traffic and the Overview charts it for the last hour, 6 hours, day or week: - Throughput in and out, measured on the client side of every listener (HTTP, HTTPS, HTTP/3 and streams), requests per minute by response status, and time to first byte at the 50th and 95th percentile. - A row of headline numbers with sparklines: outbound and inbound volume, requests, the server error rate, blocked requests, response time and open connections. - Open ports: every listener with its service, state, whether UPnP mapped it on the router, bytes in and out, connections and a traffic sparkline. - The busiest hosts, where requests come from (with a GeoIP database), the share of HTTP/1.1, HTTP/2 and HTTP/3, and what quicgate refused, by reason: access lists, auto-ban, rate limits, the exploit filter, bad bots, client certificates, single sign-on, forward auth and stream source filters. - The page follows along while it is open, every chart has a table view, and the history is kept in `traffic.json` in the data directory, so a restart or an upgrade does not wipe it. - `GET /api/traffic?range=1h|6h|24h|7d` serves that history, and `GET /api/overview` reports when the engine started. - Prometheus metrics per listener (`quicgate_listener_received_bytes_total`, `quicgate_listener_sent_bytes_total`, `quicgate_listener_connections_total`, `quicgate_listener_open_connections`), requests by HTTP version (`quicgate_requests_by_protocol_total`) and refusals by reason (`quicgate_blocked_total`). ### Changed - The Overview's configuration numbers moved to a Configuration panel below the traffic, and the Listeners panel became the Open ports table. ### Fixed - An informational response such as 103 Early Hints was logged as the request's status. The access log now records the final status. ## [1.10.0] - 2026-09-13 ### Changed - **A more deliberate admin interface.** The layout now reads like an operations console instead of a demo: - Account, appearance and sign-out live in a menu under the signed-in user at the top right. The profile page became an Account page (password and 2FA, sessions, API tokens) reached from that menu. - Every page has a header with its title, a short summary and its main action, instead of action buttons in the top bar. - Settings is split into sections (General, Certificates, Client IP & GeoIP, Auto-ban, Notifications, Admin sign-in, Backup & restore), each a panel of labelled rows with an explanation per field. Saving reports success or the server's error in the panel, instead of a browser alert. - The System page is now Logs: one viewer for traffic that matched no host, all traffic or a single host, with search, status filters and paging, instead of hundreds of rows on one long page. A host's Logs button opens it for that host. The page and section are kept in the URL, so a reload stays where you were. - The effective-configuration table is gone. What it added over the host list (routes that fail closed) is now shown on the affected host rows and on the Overview. - The Overview shows the key numbers, a list of what needs attention (unreachable upstreams, failed certificates, routes that fail closed, streams that are not running, disconnected Docker hosts) and plain lists of listeners and features, instead of charts and on/off tiles. - Tables use quieter badges, and destructive buttons only turn red when you point at them. [1.10.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.10.0 ## [1.9.1] - 2026-09-13 ### Changed - Built with Go 1.27.1: the digest-pinned build image and the toolchain pin in `go.mod` move together, and building from source now needs Go 1.26 or newer. - Dependency updates: quic-go 0.62.0 (HTTP/3), go-oidc 3.21.0, klauspost/compress 1.20.0, golang.org/x/crypto 0.57.0, golang.org/x/oauth2 0.37.0, golang.org/x/time 0.16.0 and modernc.org/sqlite 1.58.0. - CI refuses a build image whose Go version differs from the toolchain pinned in `go.mod`, so a release is always built with the Go that was tested and scanned, and the provenance attestation now also records where the image is stored. [1.9.1]: https://github.com/Quicgate/quicgate/releases/tag/v1.9.1 ## [1.9.0] - 2026-09-13 Security remediation from an independent review of v1.8.1 (findings Q01 to Q16), plus the defects that two further independent checks of that remediation found. Every fix ships with a regression test that fails when the fix is removed. ### Security - **Client certificates are bound to the requested host (Q01).** The TLS handshake picks the client-certificate policy from the SNI name, but the request was routed by its Host header, so a connection opened for a public name could ask for a certificate-protected host and be served. A request is now refused with `421 Misdirected Request` whenever its SNI selects a different host and either side takes client certificates (this also covers HTTP/2 and HTTP/3 connection reuse), and the certificate is verified again against the requested host's current CA on every request, so replacing a CA applies to connections that are already open. A client-certificate host is never served over plain HTTP, whatever its force-SSL flag says. A CA bundle that does not parse is now rejected when saved, and one already in the configuration closes the host instead of silently dropping the requirement. Client certificates on a `certMode: none` host are refused. - **Per-path SSO policies on the same identity provider are kept apart (Q02).** Gates were shared by provider id, so a stricter `/admin/` rule on the same IdP as the host inherited the host's broader allowed groups, emails, domains and passIdentity. Each distinct policy now gets its own gate (they still share discovery and token verification), every request is authorised against the policy of the rule that matched it, and the login callback is finished by the gate that started the login. - **The response cache never crosses identities (Q03).** The cache key held only method, host and URI, so a personalised response could be replayed to another user. Requests that carry a cookie, arrived with an Authorization header (even one an access list stripped), or were admitted by an identity gate (basic-auth user, SSO session, forward auth) now bypass the cache entirely. The key also varies on the normalised Accept-Encoding, so a compressed upstream body is never replayed to a client that did not ask for it; any other `Vary` is not cached; request `Cache-Control: no-cache` and `no-store` are honoured; `max-age=0` and `s-maxage=0` responses are not stored. - **A DNS failure can no longer open an access list (Q04).** A hostname rule that failed to resolve was dropped, and an access list left with no rules was treated as unrestricted. Hostname rules now keep their last resolved addresses for up to 24 hours while DNS is failing; after that, or with no earlier answer, an unresolved allow rule matches nobody and an unresolved deny rule denies everyone who reaches it. Country rules behave the same way when the GeoIP database is not loaded. The effective-config view shows these conditions as warnings on the affected routes. - **Missing security references fail closed (Q05, engine side).** A host naming a deleted access list was served without one, and a path rule naming a deleted access list, forward auth the host does not configure, SSO without a provider, or an unknown mode fell back to the host's own gate, which may be public. All of these now refuse every request on the affected host or path. - **References are checked in one place, and in-use objects cannot be deleted (Q05).** Deleting an access list only checked host-level use, so a list still used by a path rule or a stream could be removed. The store now refuses to delete an access list, certificate or identity provider while any host, path rule, stream or the admin login uses it, and refuses to store a host or stream that names one that does not exist. The checks run in the store itself, so import, Docker adoption and every other writer get them, not only the admin API. A Docker container whose `quicgate.access-list` label names a list that does not exist is no longer routed without it; it is not routed at all. - **Streams fail closed (Q07).** A TLS-terminating stream whose certificate was missing became a plaintext forwarder, and an access list reused as a stream filter kept only its allow CIDRs, dropping deny rules and leaving the stream open when nothing usable remained. Streams now evaluate the whole ordered list (an allow limited to HTTP methods never opens a connection, a deny limited to methods still closes it, a list that needs basic-auth credentials admits no connection), a configured filter that yields nothing keeps the stream closed, and a stream that cannot run safely is not started. Replacing a stream's certificate restarts its listener with the new one. - **PROXY protocol is only believed from trusted peers (Q08).** Any client could claim an allowed source address by sending a PROXY header. Accepting PROXY protocol now requires a list of trusted proxies: a trusted peer must send a valid v1 or v2 header within 5 seconds, and any other peer connects as itself, with its own address checked and nothing it sends parsed as a header. **Action needed:** a stream with *Accept inbound PROXY header* enabled and no trusted proxies does not start until you add them. - **Credential changes end live sessions (Q11).** Changing the admin password left other sessions signed in, and replacing the SSO signing key did nothing until a restart. A password change now signs out every other session of the account (the caller continues on a fresh session id), a new *Sessions* card can sign out every other admin session and sign every user out of all SSO-protected hosts, a changed or cleared SSO signing key applies on the next reload, and turning 2FA on or off asks for the current password. - **Admin OIDC login binds the sign-in (Q12).** The admin login now sends a PKCE (S256) challenge and a nonce, keeps each sign-in as a one-use server-side record valid for five minutes, checks the nonce in the ID token, marks the state cookie `Secure` on HTTPS and scopes it to `/api/oidc/`, and bounds every request to the IdP with a timeout (application SSO requests are bounded too). - **Request data is never rendered as HTML in the admin UI (Q13).** The log viewer inserted the request path, Host and method a remote client chose straight into the page. Those, and every other value that comes from the API (certificate errors, custom certificate names, host targets, effective configuration), are now escaped, and the escaping helper covers single quotes as well. - **Bounded state for public traffic (Q14).** Per-host metrics are labelled by configured route, with one `_unmatched` label for everything else, so invented Host headers no longer grow the metrics without limit. Rate limiting, bad-bot and exploit filtering now run before the authentication gates, and the rate-limit, auto-ban and login-throttle tables have hard size caps. - **Stream resource limits (Q14, streams).** Each UDP listener keeps at most 1024 client sessions, each TCP listener at most 4096 concurrent connections, and TLS-terminating streams allow 10 seconds for the handshake. - **Reproducible, scanned and attested releases (Q15).** The build used a mutable `golang:1.26-alpine` image and whatever Go CI picked, and six reachable standard-library advisories affected Go 1.26.5 builds. `go.mod` now pins `toolchain go1.26.8` and CI fails if the runner's Go differs, the build image is pinned by digest (Dependabot keeps it current), a `govulncheck` job gates the image build on zero reachable vulnerabilities, and every published image gets signed build provenance (`gh attestation verify oci://ghcr.io/quicgate/quicgate: --owner Quicgate`). - **Spoofed identity headers are stripped on every path (Q06).** Inbound `Remote-User`, `Remote-Email` and `Remote-Groups` were only removed on hosts with host-level SSO. They are now removed on any host with an SSO gate on any path, and forward-auth response headers are removed host-wide, so public carve-outs cannot pass forged values to an upstream that trusts them. - **An SSO login-state cookie is no longer accepted as a session.** The short-lived state cookie that every anonymous visitor receives when a login starts was signed with the same key as the session cookie, and its JSON shares the session's host, expiry and provider fields. Renamed to the session cookie, it opened any host whose SSO policy admits every authenticated user, without a login. Both cookies are now signed for their own purpose. Existing SSO sessions and logins in progress become invalid once on upgrade, so users of SSO-protected hosts sign in again. - **The response cache also bypasses client certificates and reads every Cache-Control field.** Two clients presenting different client certificates could share a cached response, and a `private` or `no-store` directive in a second `Cache-Control` header field was ignored. - **Restore refuses files that are not quicgate backups.** Any valid SQLite file was accepted, and because tables missing from a backup are emptied (and tables that share no columns with the schema restore no rows), it wiped the configuration and the admin account. A restore that would leave no admin account able to sign in is refused too: it would lock the operator out, and the next start would recreate the default credentials. - **CORS preflights no longer pass address rules.** A preflight skipped the whole access list, so any client could send `OPTIONS` requests to a backend behind an IP allowlist, even one that fails closed. Preflights still skip the basic-auth check (they cannot carry credentials), but address, hostname and country rules now apply to them, evaluated for the method the preflight announces. - **The admin sign-in provider fails closed.** `admin_oidc_provider_id` accepted an id that does not exist, and when the provider it named was gone, admin OIDC sign-in silently used the inline issuer settings instead. The setting is now validated, and a missing provider stops OIDC sign-in with an error. - **A sign-in cannot outlive a password change it raced.** A login that verified the old password while the password was being changed could still create its session after the change had signed everyone out. Sessions are now only created while the account's password and second factor are the ones the login checked. - **Changing a stream closes the connections it admitted.** A TCP connection accepted before a source restriction was added kept its access until it closed by itself. When a stream's settings change, or it is disabled or removed, its open connections are now closed with the old listener. - **An auto-ban notification can no longer freeze the proxy.** The webhook was called while holding the lock every request takes, so with auto-ban and a notification URL set, each ban stalled all traffic on all hosts for as long as the webhook took (up to 10 seconds), repeatable by anyone who can fail a login. Notifications are now sent in the background, and the auto-ban settings are applied at reload instead of being read from the database on every request. - **Identity headers are stripped under every spelling.** `Remote_User` (or any case of it) passed the strip and reached the upstream, and many application servers read it as the same variable as `Remote-User`. The same applies to forward-auth response headers. - **UDP streams cap sessions per source address** (64), so one sender cannot fill a listener's 1024 sessions and lock every other client out. - **Idle keep-alive connections on the public listeners close after 2 minutes** instead of being held open indefinitely. - **Restore checks the certificate entries and every row it restores.** A regular file named `certs/` in an archive replaced the certificate directory with a file while reporting success, and a backup whose access-list rules do not decode was committed and broke every reload. Both are refused. - **Import never silently removes protection, and binds the document's own access lists.** An entry matching an existing host, stream or access list replaced it whole, so a document that left out a host's access list, SSO or path gate made it public. Such an import is now refused with the reason. An access list `id` in the document now refers to the list that document defines; before, a host was bound to whatever list had that id in the database. ### Changed - A Host header or SNI with a trailing dot (`example.com.`) now routes to the same host as `example.com` instead of being an unknown name. - The access-log viewer skips an entry longer than 1 MiB instead of stopping at it; a single request with a huge path used to hide every later entry. - Requests to an identity provider reuse pooled connections; with certificate verification skipped, every login leaked a transport. - **A replaced or restored custom certificate is served right away.** The certificate cache kept every certificate it had loaded, so after replacing a custom certificate's PEM (or restoring a backup) the TLS listener could go on serving the old one until a restart. Reload now unloads the certificates that are no longer current. - **Stream listeners report whether they run (Q09).** A saved stream could fail to start (a port another process holds) with only a log line to show for it. `GET /api/streams` and the stream create and update responses now include a `listeners` array with `state` (`running` or `failed`) and the error, the stream list shows *not running*, and the stream dialog stays open with the reason when a saved stream cannot run. - The stream guide no longer claims managed ACME certificates work for TLS termination on streams; only custom certificates do. - **Import is atomic and repeatable (Q09).** `POST /api/import` wrote entries one by one and stopped at the first error, leaving the earlier ones stored but not applied, and importing the same access list twice failed on its unique name. The whole document now applies in one transaction or not at all, and entries matching existing configuration (access lists by name, hosts by domain set, streams by listen port and protocol) are updated in place. The response keeps the created counts and adds an `updated` object. Unknown fields in the document are now rejected like everywhere else in the API. - **Restore reproduces the backup, or changes nothing (Q10).** Restore copied a fixed list of tables that missed API tokens and port forwards (so tokens created after the backup survived and the backup's own were lost), ignored certificate copy errors, left stale certificate files, and still reported success. It now replaces every table in the schema (emptying tables an older backup lacks, matching columns by name), swaps the certificate tree in as a unit with rollback, refuses a snapshot that fails SQLite's integrity check, reports any failure without changing anything, returns warnings for dangling references, and signs out every admin session afterwards. Backup export builds the archive completely before sending it, so a read failure is an error instead of a truncated download. - **Graceful shutdown on SIGTERM (Q16).** Only Ctrl-C triggered the shutdown path, so `docker stop` killed the process without closing listeners, releasing UPnP mappings or flushing the access log. SIGTERM now shuts down the same way, and the access log is flushed on exit. - **Claims match what is built (Q16).** A new [FEATURES.md](FEATURES.md) tracks, per feature, whether it is tested locally, qualified against real infrastructure, deferred or a non-goal. The roadmap no longer says every item is fully proven, the spec no longer promises encrypted key storage, roles or an audit log that do not exist, the API reference no longer calls `/metrics` unauthenticated, from-file certificates are documented as read once, the per-host HTTP/3 switch is documented as advertisement-only, and the benchmark wording no longer claims the proxy is never the bottleneck. - **Behaviour changes to note:** requests from clients over a host's rate limit are now refused before authentication (they no longer reach the login prompt), `POST /api/2fa/enable` and `/api/2fa/disable` require a `password` field, CORS preflights from addresses an access list does not admit get `403`, users of SSO-protected hosts sign in once more after the upgrade, and editing a stream reconnects its clients. [1.9.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.9.0 ## [1.8.1] - 2026-08-23 ### Changed - **The theme picker is a real menu.** It was a native `