Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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:<tag> --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 `<select>`, whose
  dropdown is drawn by the operating system and ignores the app's palette —
  a bright blue row in the middle of a dark UI. It is now a themed popover
  where each entry previews the theme it selects (surface colour plus accent),
  ticks the active one, and closes on Escape or an outside click.
- **Settings is grouped and only shows what applies.** The eight cards sat in
  one flat grid with every field for every feature visible, configured or not,
  which left tall/short cards next to each other and a lot of empty inputs to
  read past. They are now grouped under *Certificates*, *Who can administer
  quicgate*, *Traffic handling* and *Data*, and the OIDC, LDAP and auto-ban
  cards keep their fields hidden until the feature is switched on. Choosing a
  shared identity provider also hides the inline issuer/client/secret fields
  it replaces. A default install now shows half the inputs it used to.

[1.8.1]: https://github.com/Quicgate/quicgate/releases/tag/v1.8.1

## [1.8.0] - 2026-08-23

### Added
- **Theme chooser with a second theme.** The top bar gains a theme picker
  next to the light/dark toggle. **Console** stays the default and is
  unchanged; **Brass & Iron** is a warm-metallic alternative — brass, copper
  and aged iron surfaces, Playfair Display for headings, Lora for body text,
  JetBrains Mono for data, and a thin brass edge along the top of each card.
  Each theme has its own light variant, so there are four combinations, and
  both choices persist and are applied before first paint.
  The theme is token overrides scoped to `html[data-skin="brass"]`, so
  components pick it up without their own rules changing. All three
  typefaces are vendored as variable fonts (107 KB total): the admin origin
  serves a strict CSP and has to keep working offline, so nothing is fetched
  from a font CDN — and for the same reason the pre-paint theme script is a
  file rather than an inline `<script>`.
- **Per-path identity providers.** A `mode: oidc` path rule can now name its
  own provider and policy instead of inheriting the host's, so one host can
  gate `/staff` with the company IdP and `/partner` with another, or use a
  separate app registration per URL on the same IdP. Every provider redirects
  back to the one callback path; the signed state cookie records which login
  is in flight, so the gate that started it finishes it.
- **The admin login can reuse a provider entry.** Settings gains an identity
  provider picker (`admin_oidc_provider_id`) listing the same providers the
  hosts use, instead of repeating issuer, client id and secret in its own
  fields. Those fields remain the fallback, so existing setups are untouched,
  and the admin allow-list stays separate either way.

### Security
- **SSO sessions are now bound to the issuing provider.** They were bound to
  the host only, so once a host could reference two providers, a session
  obtained from the less trusted one satisfied a path gated by the more
  trusted one: get an account wherever you can, walk in everywhere. Sessions
  carry the provider id and a gate accepts only its own. Existing sessions do
  not carry it and are re-authenticated once.
- Deleting a provider is refused while a path rule or the admin login still
  references it, matching the existing check for hosts.

### Fixed
- The host modal's OIDC provider pickers were empty unless the Access Lists
  page had been opened first, so saving a host from that state silently
  dropped the provider it was using. The hosts page loads providers itself.

[1.8.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.8.0

## [1.7.2] - 2026-08-23

### Security
- **Admin OIDC login admitted anyone the provider would authenticate.** With
  `oidc_allowed_emails` empty, `emailAllowed()` returned true for every
  address, so enabling admin SSO against a tenant you do not exclusively
  control handed full proxy administration to every account in it. An external
  identity now needs either a local account with the same address or an
  explicit allow-list entry; an empty list matches nobody. Password login is
  unaffected, so this cannot lock anyone out.
- **Admin OIDC ignored `email_verified`.** The claim was parsed and never
  read. A provider that lets users choose their own address could therefore be
  used to claim an administrator's address. A login is now refused when the
  provider explicitly marks the address unverified.
- **Any directory user could administer the proxy over LDAP.** A successful
  bind was treated as authorisation, so every account in a corporate directory
  held root over the ingress. Binding now only proves the password;
  administering also requires a local account or an entry in the new
  `ldap_allowed_users` list.
- **LDAP used filter escaping on a distinguished name.** `EscapeFilter`
  escapes search-filter metacharacters and leaves `,`, `=`, `+` and friends
  alone, so a crafted username could restructure the DN it was spliced into.
  Now `EscapeDN`, as the value's context requires.
- **LDAP binds refuse plaintext transport.** `ldap://` sent the admin password
  in clear text; `ldaps://` is now required, with a 5s dial and 10s operation
  timeout so a hung directory cannot pin a login request.
- **Restore could be used as a decompression bomb.** The upload limit bounded
  the compressed body only, so a few megabytes of zeroes could expand without
  bound and fill the data volume. Expansion is now capped at 2 GiB, and
  archives carrying duplicate or non-regular (symlink, device) members are
  rejected.
- Admin sessions minted by OIDC now get the same cookie treatment as password
  logins: `SameSite=Strict` and `Secure` on HTTPS, instead of `Lax` and never
  `Secure`. Entropy failures when minting the session id are no longer ignored.

### Fixed
- `/api/me` returned 500 for an approved OIDC/LDAP identity with no local
  database row, breaking the UI right after a successful external login. It
  now reports the session identity and no local 2FA.
- An LDAP user who also has a local account keeps that local identity on the
  session, so the forced password change, 2FA and the profile page resolve to
  the real account instead of an `ldap:`-prefixed stand-in.

[1.7.2]: https://github.com/Quicgate/quicgate/releases/tag/v1.7.2

## [1.7.1] - 2026-08-23

### Security
- **Path traversal could walk out of a public path rule.** A request for
  `/public/../admin` matched a `public` auth rule on its raw path, while an
  upstream that resolves dot segments (nginx, Apache, IIS, most frameworks)
  served `/admin` — bypassing the host's access list, forward auth or SSO.
  Any path containing a `.` or `..` segment is now refused with 400 before it
  reaches a gate, a path rule, a custom location or an upstream. Whole
  segments only, so `/.well-known/acme-challenge/...` and `/file.tar.gz` are
  unaffected. Encoded forms (`%2e%2e`) are covered, since matching happens on
  the decoded path.
- **Forward-auth hosts passed client-supplied identity headers.** The headers
  listed under *Copy response headers upstream* were only overwritten when the
  auth server returned a non-empty value, so a 2xx response that omitted
  `Remote-User` let the client's own `Remote-User` reach the upstream. Those
  headers are now stripped from the inbound request before the auth subrequest
  and before the upstream sees it. (OIDC hosts already stripped them.)
- **SSO session cookies were not marked Secure behind a TLS-terminating
  proxy.** `Secure` was set from `r.TLS` alone, so with TLS terminating on a
  load balancer in front of quicgate the session cookie could ride a plain
  HTTP hop. It now honours `X-Forwarded-Proto`, as the OIDC redirect URI
  scheme does — which also fixes redirect-URI mismatches in that deployment.
- **Unthrottled admin login.** A wrong password cost a flat 400ms and nothing
  else, and the six-digit TOTP code had no limit at all, so an attacker
  holding the password could walk the code space. Failed logins are now
  counted per client IP, with a 15-minute lockout after 10 failures in 15
  minutes, covering password, LDAP and TOTP failures alike.
- **Credentials echoed by the settings API.** `GET /api/settings` returned the
  admin OIDC client secret and the DNS provider config (which holds a private
  key) in cleartext to any session or API token, putting them in the browser
  and in any exported HAR. Both are masked on read; sending the mask back
  keeps the stored value.

### Fixed
- The OIDC login callback is now always routed to the SSO gate. A host that
  gated only some paths with SSO sent the browser to the IdP and then handed
  the redirect back to whichever path rule matched `/.qg/oidc/callback`, so
  the session was never minted and the user looped through login.
- A login is refused when the IdP explicitly marks the address unverified
  (`email_verified: false`), so a self-set address cannot satisfy an
  allowed-domains policy.
- The post-login redirect target rejects backslashes and CR/LF in addition to
  protocol-relative paths.

[1.7.1]: https://github.com/Quicgate/quicgate/releases/tag/v1.7.1

## [1.7.0] - 2026-08-23

### Added
- **Built-in OIDC SSO.** A proxy host can now require an OpenID Connect login
  served by quicgate itself, replacing the Pomerium/Authelia/oauth2-proxy
  sidecar for the common case. Identity providers (Keycloak, Entra ID,
  Authentik, any spec-compliant IdP) are defined once under **Access Lists ->
  Identity providers** and referenced per host; quicgate runs the auth-code
  flow with PKCE and a nonce, keeps a stateless HMAC-signed session cookie
  that is bound to the exact host it was minted for, and enforces a per-host
  policy of allowed emails, domains and/or groups (from a configurable groups
  claim). The identity can be passed upstream as `Remote-User` /
  `Remote-Email` / `Remote-Groups`; inbound copies of those headers are
  always stripped, on public paths too, so they can never be spoofed through
  quicgate. Gated hosts reserve `/.qg/oidc/callback` (the redirect URI to
  register at the IdP) and `/.qg/oidc/logout`. IdP discovery is lazy and
  cached, so config reloads never block on the IdP, and a host whose
  provider was deleted fails closed with 403. Providers are managed via
  `/api/oidc-providers` (client secret masked in responses; an empty secret
  on update keeps the stored one) and included in backups.
- **Path authentication.** A proxy host's access list and forward auth used to
  gate the whole host; they can now be overridden per URL. Each rule is a path
  (prefix or exact), a mode (`public`, a named access list, the host's
  forward auth, or the host's OIDC SSO) and optional HTTP verbs, and the longest matching path wins.
  Anything matching no rule keeps the host's own gate. This covers the case a
  reverse proxy in front of SSO always runs into: a licensing callback, webhook
  receiver or health probe that has to answer without credentials while the
  rest of the host stays behind authentication. Rate limits, bad-bot and
  exploit filters remain host-wide, so a public path is still protected from
  abuse. Editable under **Security -> Path authentication** in the host modal;
  stored as `options.authRules`. A rule naming an access list that no longer
  exists falls back to the host's gate instead of opening the path, and the API
  rejects such a reference on write.
- **Built-in guides.** The Help page now opens with five markdown guides
  (getting started, configuration reference, access control & SSO, Docker
  labels, streams & port forwards) rendered by a small vanilla markdown
  renderer, embedded in the binary and fully offline. The same files live in
  `web/docs/` on GitHub, and the README slimmed down to a pitch that links to
  them.
- The Proxy Hosts table links each domain to the site itself (new tab), with
  the scheme taken from the host's certificate mode. Wildcard domains stay
  plain text since they have no single address to visit.

[1.7.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.7.0

## [1.6.1] - 2026-08-23

### Changed
- Dependency updates: `klauspost/compress` 1.19.1 -> 1.19.2 (gzip/zstd
  compression), `golang.org/x/crypto` 0.54.0 -> 0.55.0 and
  `modernc.org/sqlite` 1.55.0 -> 1.56.0 (the config store), plus the
  indirect bumps they pull in. No functional changes.

[1.6.1]: https://github.com/Quicgate/quicgate/releases/tag/v1.6.1

## [1.6.0] - 2026-08-04

### Added
- The Proxy Hosts table now shows backend health inline: a host whose
  upstream fails its health probe gets a red-tinted row and a `down` badge
  next to the upstream. Pool hosts keep their `N/N up` badge and pick up
  the same row tint when part of the pool is down. Uses the same health
  data as the Overview donut, refreshed every time the page is opened.

### Fixed
- HTTP/3 requests were proxied upstream as `Transfer-Encoding: chunked`:
  quic-go reports "unknown length" for bodyless requests, so every browser
  GET arriving over h3 was forwarded chunked, which strict upstreams (for
  example lighttpd) reject with 400 Bad Request before routing. Bodyless
  GET/HEAD requests are now normalized to an explicit empty body on both
  the host proxy and per-location proxies; POST/PUT bodies stream as
  before.

[1.6.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.6.0

## [1.5.3] - 2026-07-30

### Fixed
- **IPv6 literal upstreams and stream targets.** Addresses were built as
  `host:port`, which produces `2001:db8::1:8080` for an IPv6 literal — an
  address `net.Dial` rejects ("too many colons in address"). Health checks
  therefore marked every IPv6-literal backend permanently **down**, and IPv6
  stream forwards (and SNI-route targets) failed outright. All upstream, pool,
  location, stream and SNI addresses are now built with `net.JoinHostPort`, so
  they are correctly bracketed (`[2001:db8::1]:8080`). Hostnames that resolve to
  AAAA records were unaffected; only literal IPv6 addresses were.

[1.5.3]: https://github.com/Quicgate/quicgate/releases/tag/v1.5.3

## [1.5.2] - 2026-07-30

### Changed
- Dependency updates: `quic-go` 0.60.0 -> 0.61.0 (the HTTP/3 engine) and
  `modernc.org/sqlite` 1.54.0 -> 1.55.0 (the config store). No functional
  changes.

[1.5.2]: https://github.com/Quicgate/quicgate/releases/tag/v1.5.2

## [1.5.1] - 2026-07-24

### Changed
- Project moved to its own GitHub organization: **`Quicgate/quicgate`**. The
  container image is now **`ghcr.io/quicgate/quicgate`** (the old
  `ghcr.io/maferick/quicgate` path stops receiving updates; repoint your
  `image:` to the new one). No functional changes.

[1.5.1]: https://github.com/Quicgate/quicgate/releases/tag/v1.5.1

## [1.5.0] - 2026-07-24

### Added
- **Overview dashboard**: a new landing page with an at-a-glance summary —
  listeners, config counts (hosts by type, certificates, streams, access
  lists), health donuts (upstreams up/down, certificates issued/pending/failed,
  hosts by type), feature flags (HTTP/3, UPnP, auto-ban, GeoIP, forward-auth,
  OIDC, LDAP, Docker), and providers. One `GET /api/overview` call; vanilla
  inline-SVG donuts, no chart library.
- **Real client IP behind a trusted proxy** (Traefik #3097): when quicgate sits
  behind Cloudflare or another load balancer, set trusted-proxy CIDRs and a
  header (Settings) so access lists, GeoIP, rate limits and logs use the real
  client IP. A rightmost-untrusted `X-Forwarded-For` walk defeats client
  spoofing of the header.
- **Sticky sessions** (Traefik #1207/#1035): a per-host cookie affinity across a
  load-balanced upstream pool, so a client keeps hitting the same backend
  (the cookie carries an opaque id, never the upstream address).
- **Maintenance mode** (Traefik #3520): a per-host toggle that serves a 503
  "under maintenance" page (with `Retry-After` and an optional custom body)
  instead of proxying.
- **Response caching** (Traefik #878): a per-host TTL that caches cacheable
  GET/HEAD responses in memory (honouring `Cache-Control`, skipping `Set-Cookie`
  and authenticated requests), with an `X-Cache: HIT/MISS` header.

These four came from mining Traefik's most-reacted enhancement requests for
proxy-layer features that fit a homelab.

[1.5.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.5.0

## [1.4.0] - 2026-07-24

### Added
- **GeoIP status on the Settings page**: shows whether the GeoLite2-Country
  database is loaded (with its type and build date, or the exact expected path
  and error when missing), a **Recheck** button that re-opens the file with no
  restart, and a **test-an-IP** lookup so you can confirm country resolution
  actually works.
- Country access rules are now chosen from a **country picker** (the full list
  of ISO 3166-1 countries by name) instead of a free-text code, and the
  access-list editor warns when a country rule is used while GeoIP is not loaded.
  Country codes are also validated server-side.

### API
- `GET /api/geoip/status`, `POST /api/geoip/reload`, `GET /api/geoip/lookup?ip=`.

[1.4.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.4.0

## [1.3.0] - 2026-07-24

### Added
- **Multiple Docker hosts**: the label provider can watch several daemons at
  once. Configure a JSON list of endpoints (`QG_DOCKER_ENDPOINTS`, or the
  **Docker hosts** box on the Docker page), each with a name, a connection (a
  local socket path or `tcp://host:port`, e.g. a read-only socket proxy), and
  the address where that host's published ports are reachable. The Docker page
  shows each host's connection state and labels every container with its host.
- The Docker client now speaks `tcp://` endpoints in addition to unix sockets.

### Changed
- **Simpler connect model**: quicgate now always reaches a container at the
  Docker host's address on its published port (a `network_mode: host` container
  at that port directly). The `auto` / `network` / `published` connect-mode and
  the shared-network container-IP path are gone; `quicgate.port` still names the
  container's internal port, so publish the port you want routed. Removes the
  `docker_connect_mode` and `docker_host_address` settings (endpoints carry the
  address now).

[1.3.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.3.0

## [1.2.0] - 2026-07-24

### Added
- **Docker label provider** (opt-in via `QG_DOCKER=1`): derive proxy hosts and
  TCP/UDP streams from container labels — Traefik's provider idea with a flat
  label set, no router/service/middleware graph.
  - Labels: `quicgate.enable`, `quicgate.host`, `quicgate.port`,
    `quicgate.exclude-ports`, `quicgate.scheme`, `quicgate.tls-skip-verify`,
    `quicgate.tls`, `quicgate.access-list`, and `quicgate.streams` (raw L4
    forwards). Stream ports are excluded from web-port auto-detection, so a
    container with a web port and a game/DB port needs no manual excludes.
  - Optional `QG_DOCKER_DOMAIN` derives the hostname from the container name.
    Access lists are reused by name. A container can be HTTP-only, streams-only,
    or both.
  - `auto` / `network` / `published` connect-modes resolve the upstream address
    per container, so it works whether quicgate runs on a bridge network or
    `network_mode: host`.
  - Manual hosts always win a naming conflict; derived routes are never
    persisted (re-derived from live containers). A **Docker** page shows every
    container with the exact reason it is or isn't routed, plus one-click
    **Convert to host** to graduate a container to editable configuration.
  - Read-only Docker client over the socket (list / inspect / events), no
    third-party SDK, zero new dependencies.

[1.2.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.2.0

## [1.1.1] - 2026-07-23

### Added
- In-app **Help & FAQ** page (the `?` icon in the top bar): common recipes and
  concepts (access-list evaluation, the GET-from-everywhere pattern, hosts vs
  streams, certs/HTTP-3, admin-port safety, API tokens). Embedded, works offline.

[1.1.1]: https://github.com/Quicgate/quicgate/releases/tag/v1.1.1

## [1.1.0] - 2026-07-23

### Added
- **Streams can reuse an access list as their source filter** instead of
  retyping CIDRs — pick an access list on the stream, and its allow CIDR/host
  rules become the source allowlist (only IP rules apply at L4).

### Changed
- Method-scoped access rules now use clickable **HTTP-verb chips** in the UI
  instead of a free-text box (keyboard-accessible; none selected = all verbs).

[1.1.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.1.0

## [1.0.0] - 2026-07-23

First public release. quicgate is a single-binary reverse-proxy manager: the
Nginx Proxy Manager workflow on a native Go engine (HTTP/1.1/2/3), automatic
Let's Encrypt certificates, and every advanced option as a typed, validated
setting instead of a free-text config blob. It has been running a ~50-host
homelab in production.

### Hosts & TLS
- Proxy, redirection (301/302/307/308), 404 and static-file hosts; wildcard
  domains; load-balanced upstream pools with active health checks; custom
  locations (path prefix → upstream) and path rewrites.
- Automatic Let's Encrypt (HTTP-01), DNS-01 wildcards, custom cert upload,
  self-signed generation, custom ACME CAs, mTLS client certs, per-host minimum
  TLS version, HSTS, hardened AEAD-only cipher defaults, HTTP/3 with a per-host
  opt-out that clears the browser's cached Alt-Svc hint.

### Security & access
- Access lists: ordered CIDR / dynamic-DNS / GeoIP-country rules **plus
  per-rule HTTP-method scoping**, basic auth, satisfy any/all.
- CORS preflight requests bypass the auth gate (the real request stays gated).
- Forward-auth (Authelia/Authentik/Keycloak), per-IP rate limiting,
  block-common-exploits, bad-bot blocking, fail2ban-style auto-ban.
- Admin hardening: strict CSP, same-origin CSRF guard (bearer-exempt),
  server-side forced first-password change, `SameSite=Strict`/`Secure`/
  `HttpOnly` cookies, TOTP 2FA, API tokens, optional OIDC and LDAP login.

### Streams & router
- TCP/UDP L4 forwards with source whitelists, PROXY protocol v1/v2, TLS
  termination, SNI passthrough routing, port ranges.
- Router port-forward management over UPnP IGD (self-healing after reboots).

### Ops
- JSON access logs with a built-in per-host and system-wide viewer, Prometheus
  `/metrics` (behind auth, per-host), one-click backup/restore, declarative
  JSON import, effective-config viewer, certificate renewal alerts.
- Runs fully offline: fonts and Swagger UI are vendored, no runtime CDN calls.
- Version is stamped into the binary and shown in the UI (`/api/version`).

[1.0.0]: https://github.com/Quicgate/quicgate/releases/tag/v1.0.0