eggrd 0.4.0

A drop-in Rust edge proxy that gives any app a secure front door: auth, rate limiting, and hardened response headers, with zero changes to the upstream app.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
# Changelog

All notable changes to EdgeGuard are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.4.0] — 2026-09-14

Three new hardening defaults, so this is a minor release per `docs/RELEASE.md`'s versioning
policy, not a patch. Two ship new capability that stays off until configured (self-signed TLS,
the `:80` redirect listener) — the third changes what a running deployment already does:
**`[log] query` now defaults to `redact`.** A deployment that scrapes access logs expecting the
full query string — a dashboard keyed on a `?campaign=` value, a SIEM rule matching on
`?session=` — will see `<redacted>` there after upgrading. Set `[log] query = "full"` to keep the
previous verbatim behaviour; nothing else about the request, routing or the upstream changes.

### Added
- **HTTPS works without first obtaining a certificate, and plaintext traffic is upgraded rather
  than dropped.** These were the two remaining places where "secure by default" required the
  operator to already know something. `tls.enabled = true` used to be a promise you could not
  keep until you had a PEM pair in hand, and even once TLS was up, nothing listened on `:80` — so
  a visitor typing a bare hostname got a connection error, and an app still bound there answered
  in the clear. The proxy was hardening a door people were walking past.

  - `[tls] self_signed = true` generates a certificate at `cert_path`/`key_path` when none is
    there yet, then serves it. The key is written `0600` — a private key that lands
    world-readable is a worse outcome than the missing certificate it fixes. Generation is
    skipped once the files exist, so a restart does not hand every client a new identity, and
    ACME still wins when both are configured: `self_signed` is a floor ("never fail to start for
    want of a certificate"), not a ceiling. `self_signed_hosts` sets the SANs (empty means
    `localhost`/`127.0.0.1`/`::1`), with IP literals becoming IP SANs rather than DNS names,
    because that is what clients actually match an address against.

    **What it buys is stated plainly, in the docs and by `doctor`.** It encrypts the connection —
    which is what makes HSTS, `Secure` cookies and the hardening headers mean anything at all —
    and it proves no identity, so browsers warn and strict clients refuse. Right for localhost, a
    private network, a sidecar hop or staging; wrong for a public domain, where `[tls.acme]` is
    the answer. Selling it as more than that would be the same overstatement the Status table in
    the README exists to prevent.

  - `edgeguard cert [--host h]... [--days n] [--cert-out p] [--key-out p] [--force]` writes the
    same pair as a standalone utility — no config file, no listener — for a Docker build stage or
    a compose init step. It refuses to overwrite without `--force`: replacing a certificate is
    not recoverable, since the previous key is gone and anything that trusted it breaks.

  - `[tls] redirect_port` (also `REDIRECT_PORT`) runs a small plaintext listener that redirects to
    HTTPS. The default status is **308, not 301**, so a `POST` that lands on the plaintext port is
    replayed over TLS instead of being silently downgraded to a `GET`. The TLS port is carried
    into the `Location`, so a proxy on `:8443` redirects there rather than to a `:443` nothing is
    listening on.

    **It does not ship the hole it exists to close.** The `Host` header is attacker-controlled, and
    a redirect listener that reflects it unchecked is an open redirect wearing the site's own
    name. A malformed host — spaces, CR/LF, userinfo, an over-long name — is answered `400`
    rather than reflected, and `redirect_hosts` pins redirects to an allow-list.
    `/.well-known/acme-challenge/` is answered `404` and never redirected: a CA must read the
    HTTP-01 token in plaintext, and bouncing it to the port whose certificate is being issued
    would deadlock the order. With ACME enabled, issuance runs first and the redirect listener
    binds afterwards, so the two never contend for `:80`.

  - `doctor` gained the checks that make the above discoverable rather than documented: TLS on
    with no certificate source at all is an **error** (it will not start), `self_signed` warns
    that it is not publicly trusted, a `redirect_port` colliding with `server.port` or
    `admin_port` is an error, a non-3xx `redirect_status` is an error, and `redirect_port = 0`
    is called out because TLS that nothing redirects to is the most common way it gets bypassed.
    It also now flags **`headers.hsts = true` with `tls.enabled = false`** — the shipped default —
    which reads as protected and is not, since browsers ignore HSTS on a plain-HTTP response.

  **Review round.** Eight findings from the PR review bot, seven acted on. The one that
  mattered: the code did the *opposite* of its own comment. `self_signed` generation ran before
  ACME, wrote to the same `cert_path`, and the ACME branch skips issuance when a certificate is
  already there — so with both enabled on a public domain, the untrusted self-signed certificate
  silently won over the publicly trusted one the operator asked for. ACME now runs first, which
  is also what makes `self_signed` the floor it was documented to be. Alongside it: `Host` is now
  parsed as a whole authority rather than split on the first colon (`attacker.example:443@victim.example`
  was reduced to `attacker.example` and redirected instead of refused — the strictness the docs
  already claimed); `redirect_status` is validated before the listener binds, since failing inside
  the spawned task left the port closed while HTTPS carried on, turning a typo into "connection
  refused"; an out-of-range `--days` returns an error instead of panicking on `OffsetDateTime`
  overflow; identical `cert_path`/`key_path` is refused rather than writing the key over the
  certificate and reporting success; and both files are now staged and renamed into place, which
  also fixes a real permissions hole — `OpenOptions::mode` only applies when it *creates* a file,
  so regenerating over an existing `0644` key had been leaving new key material world-readable.
  Not taken: a cross-process lock around generation — with the reasoning stated more carefully
  than the first draft managed, because whether a lock helps depends on the storage. On **shared**
  storage one would work: the first replica generates, the rest find a complete pair and reuse it,
  and they converge on one identity. On **per-replica** storage it cannot — there is nothing to
  coordinate through, so each replica necessarily holds a different self-signed identity. The
  second case has no fix at this layer and the first is better solved by not generating at boot,
  so the documented answer for either is to generate once with `edgeguard cert` and mount it
  read-only, and `ensure` now says exactly that rather than dismissing locks outright.

  A second round found four more, all taken. `is_redirection()` accepts the whole 3xx class, so
  `redirect_status = 304` was allowed and would answer a cache validator with a `Location`,
  leaving the client on plaintext — the exact failure the feature exists to prevent, reached
  through a value we accepted; only 301/302/303/307/308 now pass. The ACME skip-issuance guard
  tested `cert_path` alone, so a certificate whose key was missing skipped the order and handed
  the half-pair to `ensure`, which regenerates both — the precedence bug again, through a
  different door; both files must now be present to skip. And `Path::exists()` follows symlinks,
  so a dangling one read as "absent" and let a no-force `edgeguard cert` replace the operator's
  symlink with a regular file; presence is now tested with `symlink_metadata`.

  A third round closed the pair-publication gap properly. The previous attempt staged and
  renamed one file at a time while its own comment claimed it staged both first — the third
  comment/code mismatch this change produced, and the reviewer was right that an ordinary I/O
  error on the key (not just a crash) could therefore leave a new certificate live against the
  old key. Both files are now staged before either live path moves, and the live certificate is
  set aside first and put back if the key never lands, so **any** failure leaves the operator's
  existing pair exactly as it was. Writing the test for that caught the remaining hole in the
  fix: a `rename` fails on its own too (a directory in the way, a read-only mount), so staging
  alone was not enough — hence the rollback.

  **Build cost: none measurable.** `rcgen` is declared directly for the first time since 0.3.0, but
  `instant-acme` already depends on it, so it was compiled on every build regardless: the graph
  stays at **218 crates**, cold release build time is unchanged, and the stripped binary grows
  **331 KiB (+1.9%)**, from 16.63 MiB to 16.96 MiB. Both features are always-on code paths with
  no feature flag, because a security default behind an opt-in flag is not a default.

- **Access logs no longer carry credentials.** The request line is the most useful field in an
  access log and the easiest place to leak a secret: password-reset links, OAuth `?code=`,
  presigned URLs and `?api_key=` all travel in the query string, and access logs are the
  most-copied artifact a service produces — scraped, shipped to a SIEM, retained for months, read
  by people who were never meant to hold the credential. EdgeGuard was logging
  `path_and_query()` verbatim, which for a proxy that advertises DLP is the wrong component to be
  writing them to disk.

  Query values are now redacted on two independent signals: **by name** (a built-in list covering
  `key`, `token`, `secret`, `password`, `auth`, `code`, `state`, `email`, … matched
  case-insensitively as substrings, plus whatever `[log] redact_params` adds) and **by shape** (a
  JWT, or a long high-entropy token — which catches `?t=eyJhbGciOi…` where the parameter name is
  innocuous and a name list never would). Ordinary values — page numbers, slugs, dates, search
  terms — stay readable, so the log keeps its debugging value. The replacement is a fixed
  `<redacted>`, so neither the value nor its length leaks.

  `[log] query` selects `redact` (default), `drop` (path only), or `full` (verbatim, opt-in).

  **Only the log is affected.** The upstream receives the client's target byte-for-byte, and the
  WAF, per-route rate limits, route-scoped DLP and upstream selection all still see the raw
  request — redacting those would break routing and blind the security pipeline. Both properties
  are covered by integration tests, including a WAF test whose SQLi payload sits in a parameter
  named `code`, which is on the redaction list.

  Path segments are *not* redacted: `/reset/<token>` cannot be distinguished from `/users/<id>`
  without knowing the application's routes, and guessing would mangle ordinary paths. Use `drop`
  where secrets ride in path segments.

  **Encoded parameters do not slip past it.** Classification runs on the decoded form as well as the
  raw text, so `?%74%6f%6b%65%6e=secret` (that is `?token=secret`) and a JWT whose dots are `%2E`
  are both caught — otherwise redaction would have covered exactly the credentials nobody bothered
  to obfuscate. Only classification decodes; the logged text keeps the original encoding, because
  rewriting it would change what the request actually said.

  **Upstream failure logs are covered too.** `upstream timed out` and `upstream unreachable` printed
  the forwarded URI, query and all — redacting the access line and then leaking the same `?api_key=`
  in a `warn!` would give it up on the path an operator is most likely to be reading. The forwarded
  URI is still verbatim; only the logged one is sanitised.

  The setting is deliberately **not** pushable from a control plane. A managed plane able to flip
  an edge to `query = "full"` could turn that edge's own logs into an exfiltration channel for
  every credential its users put in a URL, without touching the edge's config file.
- **A documentation site at `eggrd.dev/docs`, so the reference is not the source.** The landing
  page's "Documentation" link pointed at the GitHub README, which meant every question about a
  configuration key ended in `config.rs`. There are now four pages: an overview, the CLI and
  environment variables, the full configuration reference, and operations (endpoints, every
  Prometheus series, and what fails closed).

  **The configuration page is generated** from `src/config.rs` by
  `scripts/build-config-reference.py` — 151 keys across 21 tables, with the doc comment as the
  description and the `Default` impl as the stated default. It cannot describe a binary that
  does not exist, and a field added without a doc comment fails the generator rather than
  producing a blank row: a reference with silent holes looks complete and is not.

  Writing it found **21 public config fields with no doc comment at all**, including
  `auth.realm`, all six `[headers]` policy values and every `enabled` flag. Those are now
  documented in `config.rs`, which improves the source as much as the page.

### Added
- **The LLM gateway's ordering guarantee is now enforced by tests, not asserted in comments.**
  The landing page claims the budget reserve, model allowlist and DLP gates run *before the
  provider is called* — the whole economic argument, since a request that reaches the provider has
  been paid for whatever the client eventually sees. Every denial test checked the client's status
  and the metric and said "never reaches the upstream" in a comment; none of them observed the
  upstream. A gate that forwarded first and denied afterwards would have passed all of them.

  Five tests now use a counting stub provider and assert it saw **exactly zero** requests: over
  budget, DLP block, off-allowlist model, and a budget-store outage (fail-closed, the default).
  A control test asserts an admitted request registers exactly one hit — without it, a counter
  that never incremented would make the rest pass vacuously.

  Confirmed to have teeth by mutation: making the denial path call the provider before returning
  makes `over_budget_request_never_reaches_the_provider` fail with `left: 1, right: 0`, while the
  client still receives the same `429` the old test accepted.
- **The same guarantee is now covered for the gates in front of an ordinary app**, not just the
  LLM path: unauthenticated (`401`), WAF block (`403`), IP-denied (`403`), oversized body (`413`),
  rate-limited (`429`), and a rate-limiter store outage (`503`, fail-closed). Each asserts the
  upstream saw zero requests, with a control asserting an allowed request reaches it exactly once.
  Mutating the auth path to forward before rejecting makes
  `unauthenticated_request_never_reaches_the_upstream` fail the same way.

### Changed
- **`aws-lc-sys` is gone from the dependency graph.** It was the single most expensive crate in
  the build — 54.8s of a 139s cold `cargo build --release --bin edgeguard` on 4 cores, 17.4% of
  total unit-time — and nothing used it. `src/tls.rs` has always pinned `ring`: `init_crypto()`
  installs the ring provider and `load_server_config()` builds the `ServerConfig` with an
  explicit one. The aws-lc stack was in the graph purely by feature unification, because
  `rustls = { features = ["ring"] }` adds `ring` *on top of* rustls's defaults rather than
  replacing them, leaving `aws_lc_rs` on — and `tokio-rustls` and `instant-acme` each turned it
  back on through their own defaults, so fixing only `rustls` was not enough.

  All three now take an explicit ring path (`default-features = false` plus the features the
  crate actually uses). `reqwest` and `redis` needed no change and are annotated as such in
  `Cargo.toml`, so the next person does not re-derive it: reqwest's `rustls-tls` already implies
  `__rustls-ring`, and redis declares rustls with `default-features = false`.

  Measured on the same 4-core box, cold target and warm registry: **139s → 111s wall**, total
  unit-time **314.7s → 255.7s**, 285 → 271 compilation units, and `cargo tree -i aws-lc-rs`
  reports nothing under default, `--all-features` and `--features ner`. The stripped release
  binary goes from **16.6 MiB to 7.5 MiB** — 54.7% smaller, since the whole of libcrypto was
  being linked in unused. It also removes the build's only cmake/bindgen C dependency, which was
  the piece most likely to break on a new toolchain or a musl/cross target — the thing the
  single-static-binary and distroless claims rest on.

  Nothing changes on the wire. rustls's `prefer-post-quantum` default is dropped along with the
  rest (it is an `aws_lc_rs` alias), but it was already inert: the listener builds with an
  explicit ring provider and ring has no ML-KEM. Before and after both negotiate
  `TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / RSASSA-PSS`.

- **The single-provider rule is now enforced, not just documented.**
  `scripts/check-crypto-provider.sh` fails the build if `aws-lc-rs` or `aws-lc-sys` reappears
  anywhere in the resolved graph, and names what pulled it in. It runs in CI and as `make deps`
  (part of `make test-all`).

  It has to be a graph check rather than a test, because neither consequence surfaces as a test
  failure: the C build is a build-time and binary-size cost, and the provider ambiguity only
  bites the `rediss://` path at runtime. A lockfile does not remove the need for it: a
  `cargo update`, a new dependency, or a point release that flips a default feature anywhere
  across rustls / tokio-rustls / instant-acme / hyper-rustls / rcgen can put aws-lc back, and in
  a ~26k-line workspace lock diff the handful of changed edges is not something a reviewer
  spots. The crate also ships without a lock of its own, so the OSS cut resolves fresh every
  time.

  The check reads `cargo tree`, not `Cargo.lock`, because the lock keeps entries for optional
  dependencies that no enabled feature activates — `aws-lc-rs` is still listed there under
  `rustls-webpki` — so grepping the lock would report a regression that is not one. Both package
  names are matched: `rcgen` can pull `aws-lc-rs` without touching rustls's features, so neither
  name implies the other.

### Fixed
- **`ratelimit.store = "redis"` over `rediss://` aborted the process when TLS termination was
  off.** Linking two rustls crypto providers makes `rustls::ClientConfig::builder()` panic —
  *"Could not automatically determine the process-level CryptoProvider"* — unless a default has
  been installed first. `tls::init_crypto()` installs one, but only runs under `[tls] enabled`,
  and the `redis` crate calls that builder when it opens a `rediss://` connection. So an operator
  terminating TLS at a load balancer (the documented deployment) and pointing the distributed
  limiter at a TLS Redis lost the process on the first rate-limited request, with a panic
  message about crate features rather than anything resembling their config.

  With `ring` now the only provider linked, the lookup resolves from crate features and the path
  behaves like every other store failure: the connection is attempted, and an unreachable store
  fails closed with `503` and a `rate-limit store error` warning.

- **`dead_addr()` in the integration tests was racy.** It bound an ephemeral port and dropped it,
  assuming nothing would claim it — but the OS is free to hand that port to the next listener, and
  with more concurrent tests it does. The "down" upstream then answered `200` and
  `bad_gateway_when_upstream_down` failed pointing at the proxy, when the fault was in the
  fixture. Now returns `127.0.0.1:1`, which is privileged, unallocated, and refuses immediately.

### Fixed (release tooling, no crate change)
- **The crate is published to crates.io again.** The mirror's release workflow had a `crates`
  job that published `edgeguard-ner` and then `eggrd`; rewriting that workflow upstream dropped
  it, and nothing noticed because the images kept publishing. crates.io stopped at **0.2.1**
  (2026-07-14) while 0.2.2, 0.3.0 and 0.3.1 shipped as containers — so `cargo install eggrd`,
  which the site and this README both recommend, was installing the version whose ACME issuance
  is broken.

  Restored, and deliberately independent of the image jobs so a Docker Hub outage cannot block
  the crate. It keeps the ordering the original had, which a dry run confirms is still required:
  `eggrd` declares `edgeguard-ner = "^0.1.1"`, only `0.1.0` is on the index, and packaging
  `eggrd` fails outright until the new `edgeguard-ner` is published *and* indexed. Both steps
  treat an already-published version as success, and the retry loop waits only on index
  propagation — any other failure exits immediately rather than burning four minutes to report
  the wrong cause.

  A `workflow_dispatch` with `push: false` now skips the crate publish as well as the image
  push. A crates.io release cannot be withdrawn, only yanked, so a "dry run" that publishes one
  is not a dry run.

## [0.3.1] — 2026-08-24

A patch release, but read the argument-parsing note before upgrading: it changes what happens to
a command line the proxy previously accepted.

Both fixes came from the same habit as 0.3.0 — running the thing rather than reading it. Both
turned out to be the project being wrong about itself: one flag that silently did nothing, and
one build failure blamed on the wrong component for weeks.

### Added
- **`edgeguard --version` / `-V`.** There was previously no way to ask a binary or a published
  image what version it was — the flag fell through the parser's catch-all and started a proxy
  instead. Works for the subcommands too (`edgeguard doctor --version`).

### Fixed
- **The WASM edge worker builds and runs — the toolchain was never the problem.** `worker-build`
  failed with `externref table required for catch wrappers`, which was recorded as a pinned-
  toolchain incompatibility and left the worker as the one unproven capability. It was actually
  `strip = true` in the worker's own `[profile.release]`: `worker-build` invokes wasm-bindgen with
  `--force-enable-abort-handler`, whose catch wrappers need the externref table, and stripping
  removes the symbols used to find it. The error names neither `strip` nor the manifest.

  Bisected by holding everything else constant — `lto = true` + no strip builds; `lto = false` +
  strip fails. LTO, the usual suspect, was never involved.

  With `strip` removed, `worker-build --release` emits the bundle (`index.js` +
  a 415 KB `index_bg.wasm`), and it was **run on workerd**, the runtime Cloudflare runs in
  production: `401` unauthenticated, `401` on a wrong password, `200` fetched from a live origin
  carrying all six hardening headers with `Server` and `X-Powered-By` stripped. Deployment to a
  Cloudflare account — routes, custom domains, secret bindings — remains untested.
- **Unknown arguments are rejected instead of silently discarded.** The serve path and
  `generate` ended in `_ => {}`, so any token the parser did not recognise was dropped without
  a word. A typo such as `--wrpa "npm start"` started an unwrapped, unconfigured proxy that
  looked healthy, and `generate --targt vercel` emitted the default `_headers` target and
  reported success. Both now fail with the offending argument named, matching what `doctor` and
  `init` already did. A front door that ignores its instructions is worse than one that refuses
  to open.

  This is a **behaviour change**: a deployment currently passing an argument that was being
  ignored will now fail at startup rather than run with settings it did not ask for.

### Fixed (release tooling, no crate change)
- **The 0.3.0 manifest assertion could never pass.** It grepped the raw manifest for
  `"architecture":"amd64"`, but `docker buildx imagetools inspect --raw` returns
  pretty-printed JSON — `"architecture": "amd64"`, with a space. The 0.3.0 release therefore
  reported `amd64 missing` and failed while all three tags were in fact correctly
  multi-arch. It now parses the index with `jq` and prints what it found, so a real failure
  is diagnosable and a false one cannot recur. An assertion that cannot pass is worse than
  no assertion: it teaches people to re-run through red.
- **Two release pipelines could run concurrently.** `gh release create` creates the tag,
  raising a push event alongside the release event, so 0.3.0 ran the whole pipeline twice
  against the same tags. Serialized with a concurrency group keyed on the ref, deliberately
  *without* `cancel-in-progress` — cancelling mid-push is what leaves half-written tags.
- **The Docker Hub overview stopped updating.** `dockerhub-readme.yml` existed only on the
  public mirror, hand-placed and untracked upstream, so the first sync that owned
  `.github/workflows/` deleted it. The overview kept advertising 0.2.1 after 0.3.0 shipped.
  Restored upstream and added to the sync manifest, which is the only place it survives.

## [0.3.0] — 2026-08-24

Three capabilities this crate shipped were compiled but had never been run against real
infrastructure. Running them found one of the three genuinely broken, and that fix is the
reason this is a minor bump rather than a patch.

### Fixed
- **ACME certificate issuance was broken in the field, and is now proven working.**
  The pinned `instant-acme` 0.7.2 (October 2024) could no longer parse Let's Encrypt's
  current authorization payload, so every issuance attempt failed at the first
  authorization with:

      Error: ACME certificate provisioning
      Caused by:
        0: fetching authorizations
        1: missing field `token`

  This affected anyone relying on `[tls] acme = true` for automatic certificates — the
  proxy started, then failed to obtain a certificate. Bumped to `instant-acme` 0.8 and
  reworked the order flow around its API: the HTTP-01 challenge responder is now started
  **before** the authorizations are walked (the previous order raced the CA's validation),
  challenges are marked ready explicitly, and both the order and the certificate are polled
  with a retry policy instead of a hand-rolled sleep loop.

  Verified end to end twice: against a local Pebble CA, and against Let's Encrypt's staging
  environment, which issued a real certificate in about five seconds
  (`CN=acme-test.eggrd.dev`, issuer `(STAGING) Artificial Amaranth YE1`).
  `docs/ACME_TESTING.md` carries both recipes and every trap that stopped them working.

### Removed
- **The `rcgen` dependency.** `instant-acme` 0.8 generates the key pair and CSR inside
  `finalize()` and returns the private key, so nothing in this crate constructed a
  certificate itself any more. `rcgen` had been left behind as an unused dependency — in a
  proxy that terminates TLS, an unused crypto dependency is supply-chain surface with no
  compensating benefit.

### Added
- **Native `linux/arm64` images.** Releases now build each architecture on its own native
  runner (`ubuntu-24.04` and `ubuntu-24.04-arm`), push by digest, and merge the two into one
  multi-arch manifest. The release fails if the merged manifest does not contain both
  architectures, so a half-published image cannot pass as a green release. Previous images
  were amd64 only. The release also now asserts that `Cargo.toml` matches the tag being
  released — the image tag came from the git tag while `--version` came from the manifest,
  and nothing compared them, so a forgotten bump could have published an image labelled
  `0.3.0` containing a binary reporting `0.2.2`.
- **`examples/tls-termination.toml`** — a TLS configuration that has actually been run,
  with the reproduce recipe in its header. Explicitly marked as a demonstration rather than
  a production template.
- **`docs/ACME_TESTING.md`** — how to test ACME locally against Pebble and against Let's
  Encrypt staging, plus each failure encountered on the way and the misleading symptom it
  presented.

### Changed
- **CI now runs on the public repository.** The crate's `ci.yml` travelled with the source
  but was never registered, so the public repository had no CI at all after 2026-07-28. It
  and the new `release.yml` are now shipped by the release pipeline and run on every push
  there.

### Proven, not changed
No code change, but previously undemonstrated and now exercised:
- **TLS termination with a supplied certificate** — TLS 1.3 negotiated, certificate
  verified, the upstream response proxied back through it, plaintext refused on the TLS
  port, and all six hardening headers present on the response.
- **The shared-store rate limiter** — two replicas against one Redis, thirty requests from
  a single client alternating between them: **5 allowed** against one shared key. The same
  run with the per-replica store allowed **10**, exactly twice, which is the reason the
  shared store exists.

The **WASM edge worker remains unproven.** The crate compiles to
`wasm32-unknown-unknown`, but `worker-build` — the step that produces the deployable
bundle — fails on the pinned toolchain, so there is no artifact to run.

## [0.2.2] — 2026-07-28

### Changed
- **Documentation comments describe failure modes rather than attributing them.** A number of
  doc comments across the LLM metering, DLP and alerting paths justified a design choice by
  pointing at a specific defect in another vendor's issue tracker. The engineering rationale is
  unchanged and still stated — an unmask that can restore another caller's value is still the
  thing reversible masking is built to avoid, and an unknown model is still never served at a
  silent `$0` — but the reasoning now stands on its own rather than on a third-party bug report
  that this project does not re-check and cannot keep current.

## [0.2.1] — 2026-07-15

### Fixed
- **Cookie hardening no longer forces `HttpOnly` on cookies that must stay JS-readable.**
  EdgeGuard's `[headers]` hardening added `HttpOnly` to every `Set-Cookie` unconditionally,
  which silently broke apps behind the proxy that use a **double-submit CSRF cookie** the
  frontend reads from `document.cookie` (the cookie became unreadable → the app saw no
  session). Two new `[headers]` keys make it configurable:
  - `httponly_cookies` (bool, default `true`) — global toggle for adding `HttpOnly`.
  - `httponly_cookie_exempt` (string list, default `[]`) — cookie **names** to never add
    `HttpOnly` to, e.g. `["doneyet_csrf"]`. The surgical fix.

  `Secure` and the `SameSite=Lax` default are unchanged. Existing configs keep the previous
  behaviour (HttpOnly on by default); opt out only for cookies you intend JS to read.

## [0.2.0] — 2026-06-28

### Hardened (pre-release review follow-ups)
- **CORS on error responses**: EdgeGuard-generated `401`/`403`/`429` are now CORS-decorated too
  (centralized in `proxy::handle`), so an allowed browser origin sees the real status instead of a
  generic CORS failure.
- **WebSocket upgrade path**: forwards under the same `upstream_timeout`, caps a rejected
  (non-`101`) body by `max_response_body`, and strips hop-by-hop headers before forwarding (so a
  client can't smuggle connection-scoped headers upstream).
- **`[[upstreams]]` validation**: a `path` without a leading `/` (e.g. `api/`) is now rejected at
  startup instead of silently never matching.
- **`edgeguard doctor`**: also validates the managed-mode control-plane path (`CpClient::from_cfg`),
  no longer prints "no issues found" when info-level findings were emitted, and no longer warns
  about secrets "in the config file" when they were actually sourced from the env / `*_FILE`.
- **CLI**: `doctor` / `init` now reject unknown flags (a typo like `--confg` no longer silently
  validates the default config).

### Added
- **Request IDs** (`X-Request-Id`): for every request EdgeGuard reuses a well-formed inbound id (a
  short, printable-ASCII token — validated so it can't inject control characters into the log) or
  mints a UUID v4, forwards it upstream, echoes it on every response (including errors), and adds it
  to the JSON access log — one id correlates the client, EdgeGuard, and the app. Always on. See
  `src/proxy.rs`.
- **Per-path upstreams** (`[[upstreams]]`, single upstream by default): a static path-prefix →
  upstream map (longest prefix wins; unmatched falls back to the default upstream), for the common
  "static frontend + `/api` backend" shape. Deliberately not a gateway — no service discovery, load
  balancing, or request rewriting. Edge-local (not pushed by the control plane). See `src/config.rs`.
- **Response compression** (`validation.compress_responses`, off by default): gzip for clients that
  send `Accept-Encoding: gzip`, via `tower-http`, skipping small/already-compressed responses and
  (always) `text/event-stream` so SSE streaming is never buffered by the compressor. Listener-level
  (restart to toggle). See `src/lib.rs`.
- **Prometheus alert rules** (`monitoring/prometheus/alerts.yml`): reusable alerts on upstream-5xx
  ratio, p95 latency, auth-failure / WAF / rate-limit spikes, and limiter-store errors, wired into
  the bundled monitoring stack and droppable into any Prometheus.
- **WebSocket / `Upgrade` passthrough** (`[validation] websocket_passthrough`, **off by default**):
  tunnel WebSocket connections through to the upstream. The normal path strips the hop-by-hop
  `Upgrade`/`Connection` headers (so a handshake would fail); when enabled, an authenticated,
  rate-limited upgrade request is forwarded intact and, on the upstream's `101 Switching
  Protocols`, EdgeGuard splices the client and upstream connections into a raw bidirectional tunnel
  (`tokio::io::copy_bidirectional` over `hyper::upgrade`). A non-`101` reply is passed back
  unchanged. Response hardening / WAF body inspection don't apply to a tunneled connection. See
  `src/proxy.rs`.
- **IP access control** (`[access]`, allow-all by default): CIDR `allow`/`deny` lists (IPv4 + IPv6)
  evaluated by client IP **before auth and rate limiting** — lock the app to an office/VPN range or
  drop an abusive subnet. `deny` wins over `allow`; a non-empty `allow` is a whitelist. Matching is
  implemented directly (no new dependency); a bad CIDR fails at startup/reload. Keys on the same
  resolved client IP rate limiting uses. See `src/access.rs`.
- **`*_FILE` secret loading**: every secret env var now also accepts a `*_FILE` variant
  (`EDGEGUARD_JWT_SECRET_FILE`, `EDGEGUARD_API_KEYS_FILE`, `EDGEGUARD_REDIS_URL_FILE`,
  `EDGEGUARD_CP_EDGE_TOKEN_FILE`) pointing at a file whose contents are the value — the Docker /
  Kubernetes / systemd-`LoadCredential` secret-mount convention, so secrets stay out of the config
  file and the process environment. The direct variable wins when both are set; an unreadable
  `*_FILE` is a hard startup error. See `src/config.rs`.
- **Deploy examples**: a hardened systemd unit (`examples/edgeguard.service` — sandboxed, binds
  80/443 via `CAP_NET_BIND_SERVICE`, secrets via `LoadCredential` + `*_FILE`) and a
  `docker compose` front-door layout (`examples/docker-compose.yml` — app reachable only through
  EdgeGuard, file-mounted secret).
- **CORS** (`[cors]`, **off by default**): a small, explicit Cross-Origin Resource Sharing policy
  so a separate-origin browser frontend (a static host, a preview URL, `localhost:5173` in dev) can
  call the app EdgeGuard fronts. EdgeGuard answers browser **preflight** `OPTIONS` requests itself —
  *before* auth, since preflights carry no credentials — and **decorates** actual responses with the
  matching `Access-Control-*` headers (echoing the request `Origin` + `Vary: Origin` for an explicit
  allow-list, or the cacheable `*` for a wildcard). A credentialed wildcard
  (`allow_credentials = true` with `allow_origins = ["*"]`) is rejected at startup/reload, since the
  Fetch spec forbids it. Configure `allow_origins`/`allow_methods`/`allow_headers`/`expose_headers`/
  `allow_credentials`/`max_age`. Compiled into a `crate::cors::CorsPolicy` on the hot-swappable
  runtime. See `src/cors.rs`.
- **`edgeguard init`**: scaffold a starter `edgeguard.toml` (the annotated, secure-by-default
  reference, embedded so it can't drift) plus a `Dockerfile.edgeguard` that wraps your app behind
  EdgeGuard — tailored to the runtime detected from the working directory (Node / Python / Go /
  Rust). Refuses to clobber existing files unless `--force`. Turns adoption from "read the README and
  hand-write a config + Dockerfile" into one command. See `src/scaffold.rs`.
- **`edgeguard doctor`**: load + validate a config (the same `Config::load` + `build_runtime` paths
  the proxy uses) and report the common deployment foot-guns — the shipped **placeholder credential**
  still in place (a malformed argon2 hash that can never authenticate), `auth.mode = "none"`, secrets
  committed to the file, an over-permissive or credentialed-wildcard CORS policy, a `redis` store with
  no URL, `enforce_quota` without managed mode, and more. Exits non-zero on a hard error, so it can
  gate a deploy in CI. See `src/doctor.rs`.

## [0.1.5] — 2026-06-21

### Added
- **Streaming / LLM-proxy passthrough** (`[validation] stream_passthrough`, **off by default**):
  forward `text/event-stream` (Server-Sent Events) responses **unbuffered, frame-by-frame** instead
  of buffering the whole body first. This makes EdgeGuard a viable front door for **streaming LLM
  backends** (OpenAI-compatible token streams) and any SSE app — time-to-first-byte is preserved
  rather than collapsing to time-to-completion. A small `CountingBody` wrapper tallies egress bytes
  as frames flow, so managed-mode usage stays correct without buffering. On a streamed response the
  `max_response_body` cap and the body-read deadline don't apply (the connect/first-byte
  `upstream_timeout` still does). Non-SSE responses are unchanged. See `src/proxy.rs`.

### Docs
- Grafana dashboard for the load-test harness (`loadtest/grafana/`): an auto-provisioned
  **"EdgeGuard — proxy overview"** dashboard (request rate by outcome, p50/p95/p99 latency from the
  histogram, rate-limit hits by scope, WAF hits by rule) wired to the harness Prometheus, plus a
  pinned datasource uid so it binds deterministically.

## [0.1.4] — 2026-06-20

### Added
- **Managed mode** (`[control_plane]`, **off by default**): an optional client that pulls this
  edge's policy from a remote control plane and hot-reloads it (conditional `GET` with an ETag →
  `304`, applied through the same `build_runtime` + arc-swap path as a local file edit), reports
  usage deltas (requests + ingress/egress bytes), and forwards received CSP reports. The pushed
  policy is the *policy subset* (auth/ratelimit/validation/headers/waf) — the edge keeps its own
  local `[server]`/`[tls]`. The edge token comes from `EDGEGUARD_CP_EDGE_TOKEN`. With no
  `[control_plane]` configured the proxy is byte-for-byte unchanged. See `src/cp.rs`.
- **Live-dependency proof tests** (`#[ignore]`d — no effect on the default suite): two against a
  live **Redis** exercising the real GCRA Lua script (global per-IP + per-key limits;
  `cargo test --lib redis_ -- --ignored`), and one **ACME HTTP-01** end-to-end against
  [Pebble]https://github.com/letsencrypt/pebble (`src/acme.rs`), plus a
  `loadtest/pebble.compose.yaml` starting point.

### Docs
- Expanded the **distributed rate-limiting** README section: *why* a shared store (a per-replica
  limit multiplies under autoscale — Redis keeps one global cap) and *how to run it* (a local
  one-Redis snippet and a 3-replica compose).

## [0.1.3] — 2026-06-19

### Added
- README **"Where it fits"** section: high-level architecture diagrams (front-door + wider-stack
  placement), a "what it does *not* replace" list, and migration examples for moving an existing
  app behind EdgeGuard (from no-proxy / plain nginx / a hosted gate / a static host).
- **Multi-arch release image** `mancube/eggrd` (Docker Hub) — `linux/amd64` + `linux/arm64`, a
  static musl binary on `distroless/static`; see `Dockerfile`.

### Changed
- Deploy templates and docs point the container image at `mancube/eggrd` (Docker Hub) and the
  build-from-source step at `cargo install eggrd`; repository links use `lucheeseng827/eggrd`.

## [0.1.2] — 2026-06-18

### Changed
- Crate `repository` metadata now points at the public mirror `lucheeseng827/eggrd` (was the
  development monorepo, whose link 404s for the public). Metadata-only; no code change.

## [0.1.1] — 2026-06-18

### Changed
- README rewritten to a neutral, data-plane-only, user-facing tone. No code change.

## [0.1.0] — 2026-06-18

First public release on crates.io, published as the **`eggrd`** package — the name `edgeguard`
was already taken, so the crate is `eggrd` while the binary and library keep the name
`edgeguard` (the CLI, the `EDGEGUARD_*` env vars, and the `/__edgeguard/*` namespace are
unchanged). Ships the v0–v2.5 feature set below.

> **Note:** 0.1.0 and 0.1.1 are **yanked** — they carried, respectively, a `repository` link that
> 404s for the public and an interim README. Use **0.1.2+**.

### Changed
- **License consolidated to Apache-2.0** (was MIT OR Apache-2.0), pre-release.

### Added
- **Phase 5 / v2.5 (static/edge surface):**
  - **Static-host / edge config generator** (`edgeguard generate --target <t>`): renders the
    `[headers]` policy into a `_headers` file (Netlify / Cloudflare Pages), a `vercel.json` headers
    block, a Vercel Edge Middleware (`middleware.ts`), or a Netlify Edge Function. `--out <path>`
    writes to a file (otherwise stdout). Every target renders from a new shared
    `proxy::security_headers` — the **same** source of truth the live proxy injects — so generated
    config can't drift from runtime; an integration test cross-checks the generated `_headers`
    against a real proxied response. (A static `_headers` file can only *add* headers, so cookie
    hardening / leaky-header stripping / auth are documented as worker-only.) See `src/generate.rs`.
  - **Rust→WASM Cloudflare Worker** (`worker/`): a detached-workspace crate that compiles to
    `wasm32-unknown-unknown` via `worker-build`. It authenticates at the edge (HTTP Basic / static
    API key, constant-time), forwards to the configured origin, and hardens the response (security
    headers + cookie hardening + leaky-header stripping) — mirroring `src/proxy.rs` / `src/auth.rs`.
    The pure logic (header set, auth decision, cookie hardening, env parsing, origin-URL joining)
    is unit-tested on the native target and the wasm entrypoint compiles clean under
    `cargo clippy --target wasm32-unknown-unknown -D warnings`; the `fetch` runtime is *proven only
    against a live Cloudflare deploy* (like ACME / Redis). Rate limiting and JWT are out of scope
    for the edge subset. See `worker/README.md`.
  - Refactor: extracted `proxy::security_headers` + `proxy::HSTS_VALUE` as the single source of
    truth for the injected security-header set, now shared by the live proxy and the generator.
- **Phase 4 / v2 (WAF-lite), in progress:**
  - **WAF-lite input inspection** (`[waf]`, **off by default**): built-in heuristic **SQLi**,
    **XSS**, and **path-traversal** rulesets screen the request path/query (matched both raw and
    percent-decoded) and, opt-in (`inspect_headers` / `inspect_body`), header values and the
    size-capped request body. `mode = "report"` logs + counts matches without blocking;
    `mode = "block"` returns `403 Forbidden`. Each built-in ruleset is individually toggleable.
    Runs after auth and the size/method checks; the internal `/__edgeguard/*` endpoints are
    never inspected. See `src/waf.rs`.
  - **Custom deny patterns / pluggable rule sets** (`[[waf.rules]]`): operator-defined RE2 regex
    rules with a per-rule `target` (`path` / `headers` / `body` / `all`), evaluated alongside the
    built-ins. RE2 matching is linear-time and rejects backreferences/lookaround, so an operator
    pattern can't cause catastrophic backtracking (ReDoS); a pattern that fails to compile (or an
    unknown `target`) is rejected at startup/reload like any other config error.
  - `edgeguard_waf_hits_total{rule="sqli|xss|path_traversal|custom"}` metric, counting both
    report-only and blocked matches; blocked requests are additionally counted under the existing
    `forbidden` request outcome. The startup log line now also reports the active `waf` mode.
  - **Distributed (shared-store) rate limiter** (`ratelimit.store`): in addition to the default
    in-process `governor` limiter (`"local"`), a **Redis**-backed shared store (`"redis"`) so
    multiple replicas enforce one global GCRA limit (`redis_url` / `redis_prefix`, or the
    `EDGEGUARD_REDIS_URL` env var; `rediss://` TLS supported). The GCRA check-and-update runs
    atomically as a Redis Lua script. `ratelimit.fail_open` controls behavior when the store is
    unreachable: fail-closed `503` (default) or fail-open allow — this is the failure path the
    removed `fail_mode` knob was meant for. A `"memory"` store exercises the same shared-store
    code path in-process. *The Redis transport is compiled but, like ACME, is not covered by the
    in-process test suite (the GCRA core and the in-memory store are); see `src/limiter.rs`.*
  - **Public/private service split** (`server.admin_port` / `server.admin_addr`, or the
    `ADMIN_PORT` env var): when set, the internal ops endpoints (`/__edgeguard/health`, `/ready`,
    `/metrics`) are served on a separate, plain-HTTP **private listener**, keeping them off the
    public port; the public port serves only the proxy plus the browser-facing CSP report sink.
    The `/__edgeguard/*` namespace is now **reserved** — unknown internal paths return `404`
    rather than being forwarded upstream (`not_found` outcome). New `build_public_router` /
    `build_admin_router`, and a `limiter_error` outcome for fail-closed store errors.
- **Phase 3 / v1 (self-hostable & production-usable):**
  - **JWT auth** (`auth.mode = "jwt"`): HS/RS/ES/PS/EdDSA verification with either a static
    secret/PEM key or a fetched, **cached JWKS** (keys selected by `kid`, refreshed on miss or
    TTL expiry). The configured algorithm is pinned, so a token can't substitute its own `alg`
    (`alg=none`/HS-vs-RS confusion). Optional `issuer`/`audience`/leeway checks.
  - **Static API-key / bearer-token gate** (`auth.mode = "apikey"`): constant-time match of
    `Authorization: Bearer <key>` or a configurable header (default `X-API-Key`); keys may come
    from `EDGEGUARD_API_KEYS`.
  - **Per-route and per-key rate limits**: per-route overrides matched by longest path prefix
    (`[[ratelimit.routes]]`) and an optional per-principal limit (`[ratelimit.per_key]`) keyed
    by API-key id / JWT subject.
  - **TLS termination** (`[tls]`) via `rustls` + `tokio-rustls`, loading a PEM cert/key, with
    **ACME / Let's Encrypt** automatic certificates over HTTP-01 (`[tls.acme]`, via
    `instant-acme` + `rcgen`; staging by default). *ACME is compiled/CI-checked but provable
    only against a live CA — it binds port 80 and needs a public domain.*
  - **Prometheus metrics** at `/__edgeguard/metrics`: requests by outcome, rate-limit hits by
    scope, a request-latency histogram, and CSP report count (hand-rolled text exposition, no
    new metrics dependency).
  - **Config hot-reload** via `notify`: the config file is watched and policy is rebuilt and
    swapped atomically (`arc-swap`) with no dropped connections; an invalid edit is logged and
    the previous policy retained. The connection pool and metric counters survive a reload.
  - **CSP report-only mode + violation sink**: `headers.csp_report_only` emits
    `Content-Security-Policy-Report-Only`; `headers.csp_report_uri` appends a `report-uri`
    directive, and `POST /__edgeguard/csp-report` logs + counts received reports.
  - **Max-header-size limit** (`validation.max_header_bytes`): requests whose total header
    bytes exceed the cap get `431` (completes the Phase 3 timeout/header-size item).
- OSS launch scaffolding: dual `LICENSE-MIT` / `LICENSE-APACHE`, `CONTRIBUTING.md`, this
  changelog, and the `docs/` set (`REQUIREMENTS.md`, `DEPLOYMENT.md`, `ROADMAP.md`).
- `examples/` directory holding the deploy templates (`Dockerfile.node`,
  `Dockerfile.python`, `render.yaml`, `fly.toml`).
- Test suite (Phase 0): unit tests for `parse_size`, `parse_rate`, `client_ip` (XFF
  parsing), `harden_cookie`, and `check_basic_auth` (plaintext + argon2 + bad-creds paths);
  and in-process integration tests that drive the real pipeline against a stub upstream —
  401 without auth, 200 with auth, 429 over the limit, 413 on oversized body, 405 on a
  disallowed method, security headers injected, leaky headers stripped, cookie hardened, 502
  when the upstream is down, plus the health/readiness endpoints.
- `edgeguard --hash`: reads a password on stdin and prints an Argon2id PHC hash for
  `auth.users`, so operators don't need a separate argon2 tool.
- Configurable upstream timeout (`validation.upstream_timeout`, default `30s`; `0` disables):
  the proxy bounds the upstream request + body read with a single deadline and returns
  `504 Gateway Timeout` if the upstream stalls, instead of pinning the handler task.
- Library target (`src/lib.rs`) exposing `build_state` / `build_router`, so the binary and
  the tests share one code path rather than a reimplementation.

### Changed
- Restructured the repository into `src/` + `docs/` + `examples/` and rewrote the README as
  a clean, user-facing document (the product/requirements prose moved to `docs/`).
- `/__edgeguard/ready` now probes the upstream — it returns `200` only when the upstream
  accepts a connection, `503` otherwise — instead of always returning `200`.
  `/__edgeguard/health` remains unconditional liveness.
- Made the co-process supervisor cross-platform: Unix keeps full process-group signaling;
  Windows uses a `cmd /C` launch with a best-effort child kill on shutdown.
- `libc` is now a Unix-only dependency (`[target.'cfg(unix)'.dependencies]`).
- `argon2` now enables its `std` feature (provides the getrandom-backed `OsRng` the `--hash`
  helper uses to generate a salt).

### Removed
- `server.fail_mode` config field. It was parsed but never honored, and v0 has no failure
  path for it to govern (the in-memory limiter cannot fail; an unreachable upstream stays a
  `502`). EdgeGuard remains fail-closed; a configurable fail-open returns with the
  distributed limiter (see `docs/ROADMAP.md`, Phase 4). Configs that still set `fail_mode`
  are ignored, not rejected.

### Security
- `X-Forwarded-For` is no longer trusted by default — client identity uses the real peer
  address unless `server.trust_forwarded_for` is enabled (behind a trusted proxy). Prevents
  spoofed per-IP rate limiting and forged access logs.
- Cookie hardening now parses cookie attributes by token instead of substring matching, so
  a value like `session=securetoken` can no longer skip the `Secure` flag.
- The default `auth.users` value is a non-working placeholder rather than a plaintext
  password, so the shipped config can't be copied straight to production.

### Fixed
- The crate now compiles on Windows (previously failed with 7 errors from Unix-only
  `setsid`/`pre_exec`/`libc::kill` usage in the supervisor).
- `parse_size` is now overflow-checked (returns an error instead of silently wrapping).
- The startup readiness wait is skipped when pointing at an external `UPSTREAM`, avoiding a
  needless cold-start stall.
- Added an optional `validation.max_response_body` cap so a huge upstream response can't
  OOM the proxy.

[Unreleased]: https://github.com/lucheeseng827/eggrd/compare/v0.3.1...HEAD
[0.3.1]: https://github.com/lucheeseng827/eggrd/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/lucheeseng827/eggrd/compare/v0.2.2...v0.3.0
[0.2.2]: https://github.com/lucheeseng827/eggrd/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/lucheeseng827/eggrd/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/lucheeseng827/eggrd/compare/v0.1.5...v0.2.0
[0.1.3]: https://github.com/lucheeseng827/eggrd/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/lucheeseng827/eggrd/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/lucheeseng827/eggrd/releases/tag/v0.1.1
[0.1.0]: https://crates.io/crates/eggrd/0.1.0