diff --git a/.gitattributes b/.gitattributes index 046938b..a805c5d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ -# Normalise everything to LF in the repo. The release workflow's bash heredoc -# (SHA256SUMS + manifest.json generation) breaks if the terminator carries a -# CR, so LF is mandatory for the YAML and shell content specifically. +# Normalise everything to LF in the repo, so the YAML/shell content in +# .github/workflows behaves identically regardless of contributor platform. * text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61afe6a..4dfe45a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,12 @@ name: ci -# PR + push-to-main gate for the middleware modules. Until this existed the -# only automation was release.yml (tags / dispatch), so modules could land with -# no build/lint/test. This closes that hole: fmt, clippy, test, and a release -# build all run on every pull request and every push to main. +# PR + push-to-main gate for the example middleware modules — so these teaching +# examples never rot. fmt, clippy, test, and a release build all run on every +# pull request and every push to main. # -# Runners are GitHub-HOSTED on purpose (same reasoning as release.yml): these -# modules are pure Rust with no PHP SDK, so they don't need — and must not -# contend with — the self-hosted ephemerd fleet that builds ePHPm itself and -# the php-sdk tarballs. +# Runners are GitHub-HOSTED on purpose: these modules are pure Rust with no PHP +# SDK, so they don't need — and must not contend with — the self-hosted +# ephemerd fleet that builds ePHPm itself and the php-sdk tarballs. on: pull_request: @@ -24,7 +22,7 @@ concurrency: env: CARGO_TERM_COLOR: always # The ephpm-middleware ABI crate is a git dependency; fetch via the git CLI so - # host git rewrite rules apply — same as release.yml. + # host git rewrite rules apply. CARGO_NET_GIT_FETCH_WITH_CLI: "true" permissions: @@ -53,7 +51,7 @@ jobs: rustup default stable - run: cargo clippy --workspace --all-targets -- -D warnings - # ── Tests: ~80 unit tests + the fail-open / deny integration binaries ──────── + # ── Tests: the per-example unit tests + the ratelimit fail-open integration ── test: runs-on: ubuntu-latest steps: @@ -64,8 +62,7 @@ jobs: rustup default stable - run: cargo test --workspace - # ── Build: release-compile every cdylib to prove each module links. This is - # what catches a broken module before a release tag would. ─────────────── + # ── Build: release-compile every cdylib to prove each example still links ──── build: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2e81d6d..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,270 +0,0 @@ -name: release - -# Builds the loadable middleware cdylibs for the platform matrix ePHPm ships -# and publishes them as release assets under the platform-suffixed names the -# host loader (`resolve_library` in ephpm-server/src/middleware.rs) expects. -# -# Runners are all GitHub-HOSTED on purpose: these modules are pure Rust (no PHP -# SDK), so they don't need — and must not contend with — the self-hosted -# ephemerd fleet that builds ePHPm itself and the php-sdk tarballs. -# -# Two triggers: -# * push of a `v*` tag → full matrix, all modules, a real release. -# * workflow_dispatch → scriptable proof/partial cut. `modules` and -# `only_host_platform` subset the work so a single module on one platform -# can validate the whole pipeline without grinding every leg. - -on: - push: - tags: - - "v*" - workflow_dispatch: - inputs: - tag: - description: "Release tag to create (e.g. v0.1.0-rc.1)" - required: true - type: string - modules: - description: 'Comma list of module short names, or "all"' - required: false - default: "all" - type: string - only_host_platform: - description: "Build only linux-x86_64-gnu (fast proof)" - required: false - default: false - type: boolean - prerelease: - description: "Mark the release as a prerelease" - required: false - default: true - type: boolean - -# One source of truth for the ABI major this repo targets and the module list. -# ABI_MAJOR must equal `ephpm_middleware::abi::ABI_V1 >> 24` at the pinned rev. -env: - ABI_MAJOR: "1" - CARGO_TERM_COLOR: always - # The ephpm-middleware ABI crate is a git dependency; fetch via the git CLI so - # any host git rewrite rules apply and private-transport edge cases are the - # CLI's problem, not libgit2's. - CARGO_NET_GIT_FETCH_WITH_CLI: "true" - -permissions: - contents: write - -jobs: - # ── Compute the (possibly subset) platform matrix, module list and tag ────── - setup: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.plan.outputs.matrix }} - modules: ${{ steps.plan.outputs.modules }} - tag: ${{ steps.plan.outputs.tag }} - steps: - - id: plan - env: - EVENT: ${{ github.event_name }} - IN_TAG: ${{ inputs.tag }} - IN_MODULES: ${{ inputs.modules }} - ONLY_HOST: ${{ inputs.only_host_platform }} - REF_NAME: ${{ github.ref_name }} - run: | - set -euo pipefail - - # Master platform matrix — mirrors what ePHPm ships. `suffix` is the - # asset-name libc marker; `prefix` is the cdylib file prefix cargo - # emits on that OS. - full='[ - {"runner":"ubuntu-latest", "target":"x86_64-unknown-linux-gnu", "platform":"linux-x86_64", "libc":"gnu", "suffix":"", "prefix":"lib", "ext":"so"}, - {"runner":"ubuntu-latest", "target":"x86_64-unknown-linux-musl", "platform":"linux-x86_64", "libc":"musl", "suffix":"-musl", "prefix":"lib", "ext":"so"}, - {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-gnu", "platform":"linux-aarch64", "libc":"gnu", "suffix":"", "prefix":"lib", "ext":"so"}, - {"runner":"ubuntu-24.04-arm","target":"aarch64-unknown-linux-musl", "platform":"linux-aarch64", "libc":"musl", "suffix":"-musl", "prefix":"lib", "ext":"so"}, - {"runner":"macos-14", "target":"aarch64-apple-darwin", "platform":"darwin-aarch64","libc":"", "suffix":"", "prefix":"lib", "ext":"dylib"}, - {"runner":"windows-latest", "target":"x86_64-pc-windows-msvc", "platform":"windows-x86_64","libc":"", "suffix":"", "prefix":"", "ext":"dll"} - ]' - - # Master module list — short name ↔ crate. Single source of truth for - # asset naming; the CLI mirrors these short names. - all_modules='[ - {"short":"api-key", "crate":"ephpm-middleware-api-key"}, - {"short":"jwt", "crate":"ephpm-middleware-jwt"}, - {"short":"cors", "crate":"ephpm-middleware-cors"}, - {"short":"ratelimit", "crate":"ephpm-middleware-ratelimit"}, - {"short":"redirect", "crate":"ephpm-middleware-redirect"}, - {"short":"security-headers", "crate":"ephpm-middleware-security-headers"}, - {"short":"maintenance-mode", "crate":"ephpm-middleware-maintenance-mode"}, - {"short":"ip-allowlist", "crate":"ephpm-middleware-ip-allowlist"}, - {"short":"request-id", "crate":"ephpm-middleware-request-id"}, - {"short":"header-transform", "crate":"ephpm-middleware-header-transform"} - ]' - - if [ "$ONLY_HOST" = "true" ]; then - matrix="$(printf '%s' "$full" | jq -c '[.[] | select(.target=="x86_64-unknown-linux-gnu")]')" - else - matrix="$(printf '%s' "$full" | jq -c '.')" - fi - - if [ "$EVENT" = "workflow_dispatch" ] && [ "${IN_MODULES:-all}" != "all" ]; then - wanted="$(printf '%s' "$IN_MODULES" | jq -Rc 'split(",") | map(gsub("^\\s+|\\s+$";""))')" - modules="$(printf '%s' "$all_modules" | jq -c --argjson w "$wanted" '[.[] | select(.short as $s | $w | index($s))]')" - else - modules="$(printf '%s' "$all_modules" | jq -c '.')" - fi - - if [ "$EVENT" = "workflow_dispatch" ]; then - tag="$IN_TAG" - else - tag="$REF_NAME" - fi - - { - echo "matrix=$matrix" - echo "modules=$modules" - echo "tag=$tag" - } >> "$GITHUB_OUTPUT" - - echo "Planned tag=$tag" - echo "Modules: $modules" - echo "Matrix: $matrix" - - # ── Build every requested module for one platform, upload the assets ──────── - build: - needs: setup - strategy: - fail-fast: false - matrix: - plat: ${{ fromJson(needs.setup.outputs.matrix) }} - runs-on: ${{ matrix.plat.runner }} - steps: - - uses: actions/checkout@v4 - - - name: Install Rust - run: | - rustup toolchain install stable --profile minimal - rustup target add ${{ matrix.plat.target }} - rustup default stable - - - name: Install musl toolchain - if: matrix.plat.libc == 'musl' - run: | - sudo apt-get update - sudo apt-get install -y musl-tools - - - name: Build modules - shell: bash - env: - MODULES: ${{ needs.setup.outputs.modules }} - TARGET: ${{ matrix.plat.target }} - PLATFORM: ${{ matrix.plat.platform }} - SUFFIX: ${{ matrix.plat.suffix }} - PREFIX: ${{ matrix.plat.prefix }} - EXT: ${{ matrix.plat.ext }} - run: | - set -euo pipefail - mkdir -p dist - echo "$MODULES" | jq -c '.[]' | while read -r m; do - short="$(echo "$m" | jq -r '.short')" - crate="$(echo "$m" | jq -r '.crate')" - libname="$(echo "$crate" | tr '-' '_')" # cargo cdylib lib name - cargo build --release -p "$crate" --target "$TARGET" - src="target/${TARGET}/release/${PREFIX}${libname}.${EXT}" - asset="${short}.${PLATFORM}${SUFFIX}.${EXT}" - cp "$src" "dist/${asset}" - echo "built ${asset}" - done - ls -l dist - - - name: Upload platform artifacts - uses: actions/upload-artifact@v4 - with: - name: modules-${{ matrix.plat.platform }}-${{ matrix.plat.libc }} - path: dist/* - if-no-files-found: error - - # ── Gather everything, write SHA256SUMS + manifest.json, publish ──────────── - release: - needs: [setup, build] - runs-on: ubuntu-latest - steps: - - name: Download all module artifacts - uses: actions/download-artifact@v4 - with: - pattern: modules-* - path: staging - merge-multiple: true - - - name: Checksums + manifest - shell: bash - env: - TAG: ${{ needs.setup.outputs.tag }} - MODULES: ${{ needs.setup.outputs.modules }} - run: | - set -euo pipefail - cd staging - # Deterministic ordering so the SHA256SUMS diff is reviewable. - sha256sum $(ls | sort) > SHA256SUMS - cat SHA256SUMS - - # manifest.json: the CLI reads abi_major (download-time compat gate) - # and the module/asset list (list + platform→asset mapping). - python3 - "$TAG" <<'PY' - import json, os, sys, hashlib, re - tag = sys.argv[1] - modules = json.loads(os.environ["MODULES"]) - shorts = {m["short"]: m["crate"] for m in modules} - entries = {s: {"name": s, "crate": c, "describe": c, "assets": []} - for s, c in shorts.items()} - pat = re.compile(r"^(?P.+?)\.(?P(linux|darwin|windows)-[a-z0-9_]+?)(?P-musl)?\.(?Pso|dylib|dll)$") - for fn in sorted(os.listdir(".")): - if fn in ("SHA256SUMS", "manifest.json"): - continue - m = pat.match(fn) - if not m: - raise SystemExit(f"unexpected asset name: {fn}") - short = m["short"] - if short not in entries: - raise SystemExit(f"asset {fn} has no module entry for {short}") - with open(fn, "rb") as fh: - digest = hashlib.sha256(fh.read()).hexdigest() - entries[short]["assets"].append({ - "platform": m["platform"], - "libc": "musl" if m["musl"] else ("gnu" if m["platform"].startswith("linux") else ""), - "file": fn, - "ext": m["ext"], - "sha256": digest, - }) - manifest = { - "schema": 1, - "abi_major": int(os.environ.get("ABI_MAJOR", "1")), - "release": tag, - "modules": [entries[s] for s in sorted(entries) if entries[s]["assets"]], - } - with open("manifest.json", "w") as fh: - json.dump(manifest, fh, indent=2, sort_keys=True) - fh.write("\n") - print(open("manifest.json").read()) - PY - - - name: Create / update release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.setup.outputs.tag }} - PRERELEASE: ${{ inputs.prerelease }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - cd staging - flags=() - if [ "${PRERELEASE:-false}" = "true" ]; then flags+=(--prerelease); fi - # Recreate the release idempotently so a re-dispatch replaces assets. - if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then - gh release delete "$TAG" --repo "$REPO" --yes --cleanup-tag || true - fi - gh release create "$TAG" \ - --repo "$REPO" \ - --title "$TAG" \ - --notes "ePHPm native middleware modules (ABI major ${ABI_MAJOR}). Assets are platform-suffixed cdylibs; verify against SHA256SUMS. See manifest.json for the module/ABI listing." \ - "${flags[@]}" \ - * diff --git a/Cargo.lock b/Cargo.lock index 672059b..1aeddf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,12 +38,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bitflags" version = "2.13.1" @@ -216,72 +210,17 @@ dependencies = [ name = "ephpm-middleware-api-key" version = "0.1.0" dependencies = [ - "ephpm-middleware", - "ephpm-middleware-modules", -] - -[[package]] -name = "ephpm-middleware-cors" -version = "0.1.0" -dependencies = [ - "ephpm-middleware", - "ephpm-middleware-modules", -] - -[[package]] -name = "ephpm-middleware-header-transform" -version = "0.1.0" -dependencies = [ - "ephpm-middleware", - "ephpm-middleware-modules", -] - -[[package]] -name = "ephpm-middleware-ip-allowlist" -version = "0.1.0" -dependencies = [ - "ephpm-middleware", - "ephpm-middleware-modules", - "serde_json", -] - -[[package]] -name = "ephpm-middleware-jwt" -version = "0.1.0" -dependencies = [ - "ephpm-middleware", - "ephpm-middleware-modules", -] - -[[package]] -name = "ephpm-middleware-maintenance-mode" -version = "0.1.0" -dependencies = [ - "ephpm-middleware", - "ephpm-middleware-modules", - "serde_json", -] - -[[package]] -name = "ephpm-middleware-modules" -version = "0.1.0" -dependencies = [ - "base64ct", "ephpm-kv", "ephpm-middleware", - "hmac", - "ipnetwork", "serde_json", - "sha2", "subtle", ] [[package]] -name = "ephpm-middleware-ratelimit" +name = "ephpm-middleware-header-transform" version = "0.1.0" dependencies = [ "ephpm-middleware", - "ephpm-middleware-modules", "serde_json", ] @@ -290,23 +229,7 @@ name = "ephpm-middleware-redirect" version = "0.1.0" dependencies = [ "ephpm-middleware", - "ephpm-middleware-modules", -] - -[[package]] -name = "ephpm-middleware-request-id" -version = "0.1.0" -dependencies = [ - "ephpm-middleware", - "ephpm-middleware-modules", -] - -[[package]] -name = "ephpm-middleware-security-headers" -version = "0.1.0" -dependencies = [ - "ephpm-middleware", - "ephpm-middleware-modules", + "serde_json", ] [[package]] @@ -461,12 +384,6 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" -[[package]] -name = "ipnetwork" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" - [[package]] name = "itoa" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index 66e9cf1..3ec3486 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,43 +5,35 @@ resolver = "3" [workspace.package] version = "0.1.0" edition = "2024" -# 1.88 matches the ePHPm workspace and the `ephpm-middleware` ABI crate this -# repo depends on by git rev; cargo refuses to compile a dependency whose +# 1.88 matches the ePHPm workspace and the `ephpm-middleware` ABI crate these +# examples depend on by git rev; cargo refuses to compile a dependency whose # declared rust-version exceeds the active toolchain. rust-version = "1.88" license = "MIT" -repository = "https://github.com/ephpm/middleware" +repository = "https://github.com/ephpm/middleware-examples" [workspace.dependencies] # The ePHPm native-middleware ABI + authoring kit. It lives in the ePHPm repo # (`crates/ephpm-middleware`) and is the shared contract between the host and -# every module — the host side needs it too, so it is NOT vendored here. Pinned -# by `rev` exactly like ePHPm pins litewire: a drift in `EphpmHostV1` / `ABI_V1` -# would be silent UB at the FFI boundary, so the module is provably built -# against one specific host-ABI commit. Bump = replace `rev` + `cargo update`. +# every module. It is pinned by `rev` exactly the way ePHPm pins litewire: a +# drift in `EphpmHostV1` / `ABI_V1` would be silent UB at the FFI boundary, so +# each example is provably built against one specific host-ABI commit. To +# rebuild these examples against a newer host, replace `rev` and run +# `cargo update`. # # Pinned at ePHPm main `e63284838d07d348e2155e76916daaf9782c012b` — the merge of # #408, which added the response-phase ABI hook (`ResponseMiddleware` / -# `declare!(Type, response)` / the `ResponseView` accessors) the request-id and -# header-transform modules build on. Do NOT advance this to #409's -# scheme/host/body accessors: neither module needs them. -# The rlib of shared module implementations — re-exported by the cdylib shells. -ephpm-middleware-modules = { path = "crates/ephpm-middleware-modules" } +# `declare!(Type, response)` / the `ResponseView` accessors) the +# header-transform example builds on. ephpm-middleware = { git = "https://github.com/ephpm/ephpm.git", rev = "e63284838d07d348e2155e76916daaf9782c012b" } # Test-only: the same embedded KV store the host wires into the middleware host -# table, so the ratelimit unit tests exercise the real `kv_incr_ttl` path. Same -# rev as the ABI crate so both resolve to one crate instance and the `Store` -# type matches `ephpm_middleware::host::set_kv_store`. +# table, so the api-key and ratelimit examples exercise the real KV path in +# their unit tests. Same rev as the ABI crate so both resolve to one crate +# instance and the `Store` type matches `ephpm_middleware::host::set_kv_store`. ephpm-kv = { git = "https://github.com/ephpm/ephpm.git", rev = "e63284838d07d348e2155e76916daaf9782c012b" } +# Every example parses its config out of a `serde_json::Value`. serde_json = "1" -# ip-allowlist: CIDR parsing + membership for IPv4/IPv6. Default features only -# (`serde` is an opt-in feature we do not enable), so no extra transitive deps. -ipnetwork = "0.21" -# jwt: HS256 via hmac/sha2 — no heavyweight JWT dependency. -hmac = "0.12" -sha2 = "0.10" -base64ct = { version = "1", features = ["alloc"] } # api-key: constant-time key comparison to close the timing oracle a naive `==` # would open. Tiny, no_std, no transitive deps. subtle = "2" @@ -63,7 +55,7 @@ unsafe_code = "warn" all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } # Mirror the ePHPm workspace's allow-list so a module that passes clippy here -# passes it there too (and vice-versa when it is vendored back in). +# passes it there too. cast_possible_truncation = "allow" cast_precision_loss = "allow" doc_markdown = "allow" diff --git a/README.md b/README.md index ea5855a..ce532b8 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,182 @@ -# ePHPm middleware - -Prebuilt, versioned **native middleware modules** for -[ePHPm](https://github.com/ephpm/ephpm) — the official modules, shipped as -loadable shared libraries (`.so` / `.dylib` / `.dll`) you fetch and mount, -rather than compile into the server. - -> **This repo must be public to serve unauthenticated release downloads.** The -> `ephpm middleware` CLI downloads release assets over anonymous HTTPS; while -> the repo is private those downloads require a token. The owner flips it -> public when ready. - -ePHPm runs middleware in two phases. The **request phase** runs **in front of -PHP, before PHP dispatch** — reject, rewrite, or annotate a request at native -speed, with direct access to the embedded (cluster-replicated) KV store; it -fails **closed**. The optional **response phase** runs **after** the response -is generated (PHP, static file, or error page), in reverse chain order, to -*transform* it — header injection, correlation ids; it fails -**safe** and is not a security gate. A module opts into the response phase with -`declare!(Type, response)`. See the +# ePHPm middleware examples + +Reference implementations of **native middleware** for +[ePHPm](https://github.com/ephpm/ephpm) — small, well-commented Rust modules +you can read, copy, and adapt to write your own. + +> **The official modules are compiled into ePHPm itself.** `jwt`, `cors`, +> `ratelimit`, `security-headers`, `api-key`, `ip-allowlist`, `maintenance-mode`, +> `redirect`, `request-id`, and `header-transform` ship inside every ePHPm +> binary and are mounted by name (`library = "jwt"`) with nothing to download. +> This repo is **not** a distribution channel for them. It is teaching material: +> the four crates here are stand-alone templates that show the whole shape of a +> module — the ABI, the `declare!` macro, the request and response phases, and +> KV access — so you can build a *custom* one. + +## What native middleware is + +A native middleware module is a tiny shared library (`.so` / `.dylib` / `.dll`) +that ePHPm loads at startup and runs **in front of / around PHP**, at native +speed, with direct access to the embedded (cluster-replicated) KV store. It runs +in two phases: + +- **Request phase** — runs **before** the request is served (on the PHP path and + the static-file path), and can let the request `CONTINUE`, `REWRITE` it + (inject/override request headers, rewrite the path), or `RESPOND` immediately + (short-circuit with a status + body — an auth `401`, a redirect). It fails + **closed**: a broken module aborts startup, and a panicking + `invoke` returns `500` rather than letting the request through. +- **Response phase** — optional; runs **after** the response is generated + (PHP, static file, or error page), in **reverse** chain order, to *transform* + it: set/remove response headers, adjust status. It fails **safe** (a broken + transform leaves the response unchanged) and is **not** a security gate. A + module opts in with `declare!(Type, response)` (added to the ABI in ePHPm + [#408](https://github.com/ephpm/ephpm/pull/408)); the response phase only runs + on **buffered** bodies (streamed responses bypass it). + +See the operator-facing [Native Middleware guide](https://github.com/ephpm/ephpm/blob/main/site/content/guides/native-middleware.md) -for the operator view and chain semantics. - -## The modules - -| Module (short name) | Crate | What it does | -|---------------------|-------|--------------| -| `api-key` | `ephpm-middleware-api-key` | Validate an API key (header, optionally query param) against a static map or KV lookup; forward the resolved consumer id to PHP (constant-time compare; `401` otherwise). | -| `jwt` | `ephpm-middleware-jwt` | Validate HS256 bearer tokens before PHP runs (constant-time HMAC; `alg` pinned; `exp` required). | -| `cors` | `ephpm-middleware-cors` | Answer CORS preflights directly (`204`), append `Access-Control-*` to cross-origin responses. | -| `ratelimit` | `ephpm-middleware-ratelimit` | Fixed-window per-client rate limiting over the embedded KV store (`429` + `Retry-After`). | -| `redirect` | `ephpm-middleware-redirect` | Enforce canonical URLs with a single `301`/`308` — `http`→`https`, apex↔`www` (or an explicit host map), trailing-slash add/strip. | -| `security-headers` | `ephpm-middleware-security-headers` | Append standard security response headers (HSTS, CSP, `X-Frame-Options`, …). | -| `maintenance-mode` | `ephpm-middleware-maintenance-mode` | Flip a tenant into a `503` holding page via a per-site KV flag — no redeploy (`Retry-After`; IP/path bypass; fails **open**). | -| `ip-allowlist` | `ephpm-middleware-ip-allowlist` | Allow/deny requests by client IP against CIDR lists, fail-closed (`403`); deny beats allow. | -| `request-id` | `ephpm-middleware-request-id` | **Request + response phase.** Give every request a correlation id: generate or honor an inbound `X-Request-Id`, inject it for PHP, and echo it on the response. | -| `header-transform` | `ephpm-middleware-header-transform` | **Request + response phase.** Set request headers seen by PHP; set/remove response headers out. | - -> **No `compression` module.** Response-body compression is deliberately *not* -> shipped as a middleware: ePHPm's core already compresses buffered responses -> by default (`[server.response] compression`, **on**, brotli-then-gzip), -> negotiating `Accept-Encoding` and running **before** the response phase — so -> a middleware compressor would be redundant and inert on a stock server. Use -> the built-in knob, not a module. - -Per-module configuration keys are documented in each crate's module docs -(`crates/ephpm-middleware-/src/lib.rs` re-exports the implementation from -`crates/ephpm-middleware-modules/src/.rs`). - -## ABI version - -Every module is built against the ePHPm native-middleware **C ABI**, which is -versioned; the **major byte** gates compatibility. A module built against ABI -major *N* refuses to initialise in a host whose major is different — the check -is baked into the module by `ephpm_middleware::declare!`. - -- **Current ABI major: `1`** (`ephpm_middleware::abi::ABI_V1 = 0x0100_0000`). -- The ABI/trait crate `ephpm-middleware` is **not** vendored here — it is the - shared contract owned by the ePHPm host. This repo depends on it by git `rev` - (see the root `Cargo.toml`), exactly the way ePHPm pins litewire, so every - module is provably built against one specific host-ABI commit. Bumping the - ABI means bumping that `rev` and cutting a new release. -- Each release records its ABI major in `manifest.json` (below), so the CLI can - refuse an incompatible module **at download time**, before it is ever loaded. - -## Releases: what the CLI consumes - -Each release (tag `vX.Y.Z`) carries, per platform ePHPm ships: - -| Asset name | Meaning | -|------------|---------| -| `..` | The module cdylib for that platform. `` is `-` with `macos`→`darwin` — e.g. `jwt.linux-x86_64.so`, `cors.darwin-aarch64.dylib`, `jwt.windows-x86_64.dll`. This is **exactly** the file name the host loader looks for when a mount says `library = ""`. | -| `.linux--musl.` | The musl build, for the rare fully-*dynamic* musl host. The loader has no libc distinction in its file names, so the CLI does **not** install this automatically — place it yourself with `ephpm middleware get --dest ` and mount it by explicit path. (A fully *static* musl binary cannot `dlopen` at all.) | -| `SHA256SUMS` | `sha256sum`-format digest of every asset. **The integrity floor** — the CLI verifies a downloaded module against this before writing it to disk, and fails closed on a mismatch or a missing `SHA256SUMS`. | -| `manifest.json` | `{ schema, abi_major, release, modules: [{ name, crate, describe, assets: [{ platform, libc, file, ext, sha256 }] }] }`. The CLI reads `abi_major` for the download-time compatibility gate and the module/asset list for `list` and platform→asset mapping. | - -### Host loader search path (where the CLI drops files) - -A bare `library = ""` mount is resolved by the host, in each of the -current working directory, `$EPHPM_MIDDLEWARE_DIR` (when set), and -`/usr/local/lib/ephpm/middleware`, by trying: - -1. `..` ← the release asset name; the CLI writes here -2. `lib.` -3. `.` +for chain semantics, `match`/`order`, and mounting. -So `ephpm middleware get jwt` writes `jwt..` into a search -directory and `library = "jwt"` then resolves. Run `ephpm middleware -search-path` to print the exact directories. +## The examples -## Building locally +Three modules, chosen to cover the range rather than every use case: -```bash -# Fetch the ABI crate via the git CLI (handles host git rewrite rules). -CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p ephpm-middleware-jwt -# → target/release/libephpm_middleware_jwt.so (lib.dll on Windows) +| Example | Crate | Teaches | +|---------|-------|---------| +| `api-key` | `ephpm-middleware-api-key` | A **request-phase auth gate** that also **uses the KV store**: read a key from a header (or query param), validate it against a static map **or** a `kv_get` lookup with a constant-time compare, and forward the resolved consumer id to PHP — or short-circuit `401`. | +| `redirect` | `ephpm-middleware-redirect` | The **simplest early-return**: compute a canonical URL (scheme / host / trailing slash) and emit a single `301`/`308`, or `CONTINUE`. No KV, no extra deps. | +| `header-transform` | `ephpm-middleware-header-transform` | The **response phase**: `declare!(Type, response)`, setting request headers PHP sees *and* setting/removing response headers on the way out. | + +Each crate's `src/lib.rs` is self-contained — implementation, module docs, unit +tests, and the one `declare!` line that turns it into a loadable module — so you +can read one file end to end. + +## Anatomy of a module + +```rust +use ephpm_middleware::{Middleware, Request, Response}; + +pub struct MyGate { /* config parsed once at init */ } + +impl Middleware for MyGate { + // Parse `[[middleware]] config = { ... }` (as serde_json) once at startup. + // Return Err(msg) to fail the mount fast. + fn init(config: &serde_json::Value) -> Result { /* ... */ } + + // Run per request. Return one of the request-phase verdicts. + fn invoke(&self, req: &Request<'_>) -> Response { + if req.header("X-Token").is_none() { + return Response::respond(401, "missing token"); // short-circuit + } + Response::cont() // let it through + // or Response::rewrite().header("X-Consumer", id) // annotate for PHP + } +} + +// The ONE line that exports the C ABI entry points and bakes in the ABI-major +// compatibility check. Without it you have a plain Rust type, not a module. +ephpm_middleware::declare!(MyGate); ``` -`cargo test --workspace` runs the module unit tests plus the ratelimit -fail-open integration test (these pull the `host` feature of `ephpm-middleware` -and the embedded KV store as dev-dependencies; the shipped cdylibs need -neither). +To also transform the response, implement `ResponseMiddleware` and opt in with +`declare!(MyGate, response)`: -## Layout +```rust +use ephpm_middleware::{ResponseMiddleware, ResponseView}; +impl ResponseMiddleware for MyGate { + fn invoke_response(&self, _req: &Request<'_>, resp: &mut ResponseView<'_>) { + resp.remove_header("X-Powered-By"); + resp.set_header("X-Served-By", "ephpm"); + } +} ``` -crates/ - ephpm-middleware-modules rlib: the module impls as plain types, NO - C ABI exports (so they can all be linked - into one binary — the cdylib shells, or - ePHPm's `vendor-middleware` feature) - ephpm-middleware-api-key cdylib shell: pub use + declare!(ApiKey) - ephpm-middleware-jwt cdylib shell: pub use + declare!(Jwt) - ephpm-middleware-cors cdylib shell - ephpm-middleware-ratelimit cdylib shell - ephpm-middleware-redirect cdylib shell - ephpm-middleware-security-headers cdylib shell - ephpm-middleware-maintenance-mode cdylib shell - ephpm-middleware-ip-allowlist cdylib shell - ephpm-middleware-request-id cdylib shell: declare!(RequestId, response) - ephpm-middleware-header-transform cdylib shell: declare!(HeaderTransform, response) + +**KV access.** The request carries a handle to ePHPm's embedded KV store — +`req.host().kv_get(key)`, `kv_set`, `kv_incr_ttl(key, by, ttl)` — the same +gossip-replicated store PHP uses. See `api-key` for a real `kv_get` lookup. + +### The ABI is versioned + +Every module is built against ePHPm's native-middleware **C ABI**, whose **major +byte** gates compatibility: `declare!` embeds the major, and a module built +against a different host major refuses to initialise rather than corrupt memory +at the FFI boundary (current major: `1`). The ABI/trait crate `ephpm-middleware` +is **not** vendored here — it is the shared contract owned by the ePHPm host, so +these examples depend on it by git `rev` (see the root `Cargo.toml`), pinned to +one specific host commit exactly the way ePHPm pins litewire. To build against a +newer host, bump that `rev` and `cargo update`. + +## Building a module + +```bash +# The ephpm-middleware ABI crate is a git dependency; fetch via the git CLI so +# host git rewrite rules apply. +CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p ephpm-middleware-redirect +# → target/release/libephpm_middleware_redirect.so (.dylib on macOS; +# ephpm_middleware_redirect.dll — no `lib` prefix — on Windows) +``` + +`cargo test --workspace` runs every example's unit tests. The `host` feature of +`ephpm-middleware` and the embedded KV store are pulled in only as +**dev-dependencies** (to fabricate a request and a real KV store in tests); the +shipped cdylib needs neither. + +## Mounting a custom module + +Add a `[[middleware]]` block to your ePHPm config. `library` is resolved by +ePHPm's loader ([`resolve_library`](https://github.com/ephpm/ephpm/blob/main/crates/ephpm-server/src/middleware.rs)) +against the **builtin registry first**, then the shared-library lane: + +```toml +[[middleware]] +# A value with a path separator OR a file extension is used as an explicit +# path — the most predictable way to mount a module you just built: +library = "/usr/local/lib/ephpm/middleware/my-gate.so" +match = "/api/*" # optional glob; omit to run on every request +order = 20 # required; lower runs first +config = { header = "X-Token" } ``` -The last two opt into the **response phase** with `declare!(Type, response)` — -the host runs their `invoke_response` after the response is generated to -transform it, in addition to their request phase. +Or drop the file into a search directory and mount it by **bare name**. A bare +name (no separator, no extension) is resolved through the middleware search path +— the current directory, `$EPHPM_MIDDLEWARE_DIR` (when set), and +`/usr/local/lib/ephpm/middleware` — trying, in order: -The impl/shell split is deliberate: multiple crates each exporting the same -`ephpm_middleware_*` symbols cannot be linked into one binary, so the -implementations live symbol-free in `ephpm-middleware-modules` and each cdylib -adds only the `declare!` exports. That same rlib is what ePHPm's off-by-default -`vendor-middleware` feature compiles in when someone needs middleware in a -fully-static (non-`dlopen`) build. +1. `.-.` (e.g. `my-gate.linux-x86_64.so`) +2. `lib.` +3. `.` -## Releasing +```toml +[[middleware]] +library = "my-gate" # resolves my-gate.linux-x86_64.so / libmy-gate.so / my-gate.so +order = 20 +``` -`.github/workflows/release.yml`: +> **Avoid the official names.** Because the builtin registry is consulted +> first, naming your module `jwt`, `redirect`, `ratelimit`, etc. mounts the +> **built-in** module, not yours. Give a custom module its own name (or mount it +> by explicit path). + +The Linux release binaries are glibc-dynamic and can `dlopen` these modules; +a custom fully-static build cannot, and would need the module compiled in +instead. + +## Layout + +``` +crates/ + ephpm-middleware-api-key request-phase auth gate + KV (declare!(ApiKey)) + ephpm-middleware-redirect canonical-URL redirect (declare!(Redirect)) + ephpm-middleware-header-transform response phase (declare!(HeaderTransform, response)) +``` -- **push a `v*` tag** → builds all modules for the full platform matrix - (linux x86_64/aarch64 × gnu+musl, macOS aarch64, windows x86_64) and - publishes the assets + `SHA256SUMS` + `manifest.json`. -- **`workflow_dispatch`** → scriptable partial cut; `modules` and - `only_host_platform` subset the work (used to validate the pipeline with a - single module on one platform). +## CI -All runners are **GitHub-hosted** — these modules are pure Rust and must not -contend with ePHPm's self-hosted (ephemerd) fleet. +`.github/workflows/ci.yml` runs fmt, clippy (pedantic, warnings-as-errors), +tests, and a release build on every PR and push to main — so the examples don't +rot. Runners are GitHub-hosted (pure Rust, no PHP SDK). ## License diff --git a/crates/ephpm-middleware-api-key/Cargo.toml b/crates/ephpm-middleware-api-key/Cargo.toml index 1e2603c..16beb1f 100644 --- a/crates/ephpm-middleware-api-key/Cargo.toml +++ b/crates/ephpm-middleware-api-key/Cargo.toml @@ -5,17 +5,25 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "ePHPm native middleware: API-key authentication forwarding the resolved consumer identity to PHP (loadable cdylib; implementation in ephpm-middleware-modules)" +description = "Example ePHPm native middleware: API-key auth gate that forwards the resolved consumer identity to PHP" [lib] -# cdylib = the loadable module for the dlopen lane; rlib for tests + the -# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision -# rationale behind the impl/shell split. +# cdylib = the loadable module ePHPm dlopen()s; rlib so the unit tests can link +# the crate as a library. crate-type = ["cdylib", "rlib"] [dependencies] ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true +serde_json.workspace = true +# Constant-time key comparison — closes the timing oracle a naive `==` opens. +subtle.workspace = true + +[dev-dependencies] +# `host` gives the tests RequestCtx / host_table to fabricate a request; the +# real embedded KV store backs the kv-lookup tests. Dev-only: resolver 3 keeps +# both out of the shipped cdylib, which needs neither. +ephpm-middleware = { workspace = true, features = ["host"] } +ephpm-kv.workspace = true [lints] workspace = true diff --git a/crates/ephpm-middleware-api-key/src/lib.rs b/crates/ephpm-middleware-api-key/src/lib.rs index 7976022..99f1084 100644 --- a/crates/ephpm-middleware-api-key/src/lib.rs +++ b/crates/ephpm-middleware-api-key/src/lib.rs @@ -1,12 +1,437 @@ -//! `api-key` — loadable cdylib shell around the shared implementation in -//! [`ephpm_middleware_modules::api_key`]. +//! # Example: api-key (request-phase auth gate) middleware //! -//! The middleware itself (API-key extraction, static/KV validation with a -//! constant-time key comparison, and consumer-id forwarding to PHP — docs and -//! tests included) lives in `ephpm-middleware-modules`. This crate only adds -//! the C ABI exports (`declare!`) so the module can be `dlopen`ed by -//! dynamically linked ePHPm builds. +//! A self-contained, loadable ePHPm native-middleware module, kept as a +//! reference you can copy to write your own. The official build of this +//! module is compiled into ePHPm itself; nothing here needs to be fetched or +//! installed separately. See the repository README for the ABI, the +//! `declare!` macro, and the request- vs response-phase model. +//! + +//! `api-key` — ePHPm native middleware validating an API key on the request +//! before PHP runs, then forwarding the resolved **consumer identity** to PHP. +//! +//! Analogous to Kong's `key-auth`, AWS API Gateway API keys / usage plans, and +//! Tyk: a request carrying a recognised key is admitted and tagged with the +//! consumer it belongs to; a request with a missing or unrecognised key is +//! short-circuited with `401` and PHP never runs. +//! +//! The key is read from a configurable request header (default `X-Api-Key`) +//! and, only when explicitly enabled, from a query parameter (default off — +//! see the security note). It is validated against either a static +//! `key → consumer-id` map baked into the config, a KV lookup (`kv_get` on a +//! `kv_key_template` like `apikey:` whose value is the consumer id), or +//! both (the static map is consulted first). On success the module `REWRITE`s +//! the request, injecting the consumer id in a header (default +//! `X-Consumer-Id`) that PHP reads — the exact mechanism `jwt` uses to forward +//! claims. The injected header **overwrites** any same-named header the client +//! sent (the host's `override_header` replaces, not appends), so a client +//! cannot spoof its consumer identity. +//! +//! ## Security +//! +//! * **Constant-time comparison.** Static keys are compared with a +//! constant-time equality check (`subtle::ConstantTimeEq`) so the match does +//! not leak how many leading bytes were correct — closing the timing oracle +//! that a naive `==` would open. All configured keys are compared on every +//! request (no early return on the first match). Only the *lengths* of keys +//! can differ in timing, which is not a practical attack surface. The KV +//! path is an exact-key store lookup and does not compare secrets in Rust. +//! * **The key value is never logged.** This module emits no logs containing +//! the presented key. +//! * **Query parameter is off by default.** Query strings routinely end up in +//! access logs, proxy logs, browser history and `Referer` headers, so a key +//! in the URL leaks far more readily than one in a header. Enable +//! `query_param` only when a client genuinely cannot set a header. +//! * **Composes with `ratelimit`.** Point the `ratelimit` module's +//! `key_headers` at the same header (e.g. `["X-Api-Key"]`) to get per-key +//! rate limiting in front of, or alongside, this auth gate. +//! +//! Configuration (`[[middleware]] config = { ... }`): +//! +//! | key | default | meaning | +//! |-----|---------|---------| +//! | `header` (string) | `"X-Api-Key"` | request header carrying the key | +//! | `query_param` (string) | unset (disabled) | also accept the key from this query parameter — see the security note | +//! | `keys` (object) | unset | static `key → consumer-id` map | +//! | `kv_key_template` (string) | unset | KV lookup key with a `` placeholder, e.g. `apikey:`; the value is the consumer id | +//! | `consumer_header` (string) | `"X-Consumer-Id"` | header injected for PHP with the resolved consumer id | +//! +//! At least one of `keys` / `kv_key_template` must be configured. + +use ephpm_middleware::{Middleware, Request, Response}; +use subtle::ConstantTimeEq; + +/// The literal replaced with the presented key in `kv_key_template`. +const KEY_PLACEHOLDER: &str = ""; + +/// API-key validation policy, built once at `init`. +pub struct ApiKey { + header: String, + query_param: Option, + consumer_header: String, + /// Static `key → consumer-id` entries. Keys are stored as bytes for the + /// constant-time comparison. + keys: Vec<(Vec, String)>, + /// KV lookup template containing [`KEY_PLACEHOLDER`], e.g. `apikey:`. + kv_key_template: Option, +} + +/// Constant-time byte-slice equality. Wraps [`subtle::ConstantTimeEq`] so the +/// comparison does not short-circuit on the first differing byte (unequal +/// lengths still return `false` fast, leaking only length). This is the helper +/// the static-key match uses; it is unit-tested directly. +#[must_use] +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + a.ct_eq(b).into() +} + +/// URL-decode a query-string component (`+` → space, `%XX` → byte). Invalid +/// escapes are passed through literally rather than failing the lookup. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < bytes.len() => { + let hi = (bytes[i + 1] as char).to_digit(16); + let lo = (bytes[i + 2] as char).to_digit(16); + if let (Some(hi), Some(lo)) = (hi, lo) { + out.push((hi * 16 + lo) as u8); + i += 3; + } else { + out.push(b'%'); + i += 1; + } + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Return the (decoded) value of query parameter `name` in `query`, if present. +fn query_value(query: &str, name: &str) -> Option { + query.split('&').find_map(|pair| { + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + (k == name).then(|| percent_decode(v)) + }) +} + +impl ApiKey { + /// Extract the presented key: the configured header first, then the query + /// parameter when enabled. Empty values count as absent. + fn extract_key(&self, req: &Request<'_>) -> Option { + if let Some(v) = req.header(&self.header) { + let v = v.trim(); + if !v.is_empty() { + return Some(v.to_owned()); + } + } + if let Some(param) = &self.query_param + && let Some(v) = query_value(req.query(), param) + && !v.is_empty() + { + return Some(v); + } + None + } + + /// Constant-time match of `presented` against the static key map. Every + /// entry is compared (no early return) so the number of matching leading + /// bytes is not observable via timing. + fn match_static(&self, presented: &[u8]) -> Option<&str> { + let mut matched: Option<&str> = None; + for (key, consumer) in &self.keys { + if ct_eq(presented, key) { + matched = Some(consumer.as_str()); + } + } + matched + } + + /// Look the presented key up in the KV store via `kv_key_template`. The + /// stored value (UTF-8, non-empty) is the consumer id. + fn match_kv(&self, req: &Request<'_>, presented: &str) -> Option { + let template = self.kv_key_template.as_ref()?; + let lookup = template.replace(KEY_PLACEHOLDER, presented); + let value = req.host().kv_get(&lookup)?; + let consumer = String::from_utf8(value).ok()?; + (!consumer.is_empty()).then_some(consumer) + } + + /// Admit the request, injecting the consumer id for PHP (mirrors how `jwt` + /// forwards its claims via a request header). + fn grant(&self, consumer: &str) -> Response { + Response::rewrite().header(self.consumer_header.as_str(), consumer) + } + + /// `401` with a `WWW-Authenticate`-style hint naming the expected header. + /// The key value is deliberately absent from the body. + fn unauthorized(&self, body: &'static str) -> Response { + Response::respond(401, body) + .header("WWW-Authenticate", format!("ApiKey header=\"{}\"", self.header)) + } +} + +impl Middleware for ApiKey { + fn init(config: &serde_json::Value) -> Result { + let opt_str = |key: &str| -> Result, String> { + match config.get(key) { + Some(serde_json::Value::String(s)) if !s.is_empty() => Ok(Some(s.clone())), + None | Some(serde_json::Value::Null | serde_json::Value::String(_)) => Ok(None), + Some(other) => Err(format!("`{key}` must be a string, got {other}")), + } + }; + + let header = opt_str("header")?.unwrap_or_else(|| "X-Api-Key".to_owned()); + let query_param = opt_str("query_param")?; + let consumer_header = + opt_str("consumer_header")?.unwrap_or_else(|| "X-Consumer-Id".to_owned()); + + let keys = match config.get("keys") { + None | Some(serde_json::Value::Null) => Vec::new(), + Some(v) => { + let map = v.as_object().ok_or("`keys` must be an object of key -> consumer-id")?; + map.iter() + .map(|(key, consumer)| { + if key.is_empty() { + return Err("`keys` entries must have a non-empty key".to_owned()); + } + let consumer = consumer.as_str().ok_or_else(|| { + format!("`keys[\"{key}\"]` must be a string consumer-id") + })?; + Ok((key.as_bytes().to_vec(), consumer.to_owned())) + }) + .collect::, String>>()? + } + }; -pub use ephpm_middleware_modules::api_key::ApiKey; + let kv_key_template = opt_str("kv_key_template")?; + if let Some(template) = &kv_key_template + && !template.contains(KEY_PLACEHOLDER) + { + return Err(format!( + "`kv_key_template` must contain the `{KEY_PLACEHOLDER}` placeholder" + )); + } + if keys.is_empty() && kv_key_template.is_none() { + return Err("at least one of `keys` or `kv_key_template` must be configured".into()); + } + + Ok(Self { header, query_param, consumer_header, keys, kv_key_template }) + } + + fn invoke(&self, req: &Request<'_>) -> Response { + let Some(key) = self.extract_key(req) else { + return self.unauthorized("missing api key"); + }; + if let Some(consumer) = self.match_static(key.as_bytes()) { + return self.grant(consumer); + } + if let Some(consumer) = self.match_kv(req, &key) { + return self.grant(&consumer); + } + self.unauthorized("invalid api key") + } +} + +// ── C ABI export ──────────────────────────────────────────────────────────── +// `declare!` generates the `extern "C"` entry points ePHPm's module loader +// calls (init / invoke / free) and bakes in the ABI-major compatibility check, +// so a module built against the wrong host ABI refuses to load instead of +// corrupting memory. This is the ONLY line that turns the plain `Middleware` +// impl above into a loadable `.so`/`.dylib`/`.dll`. ephpm_middleware::declare!(ApiKey); + +#[cfg(test)] +mod tests { + #![allow(unsafe_code)] // tests build the FFI Request view by hand. + + use ephpm_middleware::abi::{ACTION_RESPOND, ACTION_REWRITE}; + use ephpm_middleware::host::{RequestCtx, host_table, set_kv_store}; + + use super::*; + + fn api_key(config: serde_json::Value) -> ApiKey { + ApiKey::init(&config).expect("init") + } + + /// Invoke with headers and an optional query string against a fresh ctx. + fn invoke_q(mw: &ApiKey, query: &str, headers: &[(String, String)]) -> Response { + let ctx = RequestCtx::new("GET", "/api/x", query, "203.0.113.9", "example.test", headers); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + mw.invoke(&req) + } + + fn invoke(mw: &ApiKey, headers: &[(String, String)]) -> Response { + invoke_q(mw, "", headers) + } + + fn hdr(name: &str, value: &str) -> Vec<(String, String)> { + vec![(name.to_owned(), value.to_owned())] + } + + /// Wire a real in-memory Store into the host table (first call wins; all + /// tests in this binary share it) and seed one `apikey:*` entry via the + /// host's own `kv_set`. + fn setup_kv_with(entries: &[(&str, &str)]) { + set_kv_store(&ephpm_kv::store::Store::new(ephpm_kv::store::StoreConfig::default())); + let ctx = RequestCtx::new("GET", "/", "", "127.0.0.1", "seed", &[]); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + for (k, v) in entries { + assert!(req.host().kv_set(k, v.as_bytes(), 0), "seed kv_set failed for {k}"); + } + } + + fn consumer_header(resp: &Response) -> Option { + resp.__headers() + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case("X-Consumer-Id")) + .map(|(_, v)| v.clone()) + } + + fn assert_401(resp: &Response, body: &str) { + assert_eq!(resp.__action(), ACTION_RESPOND); + assert_eq!(resp.__status(), 401); + assert_eq!(resp.__body(), body.as_bytes()); + // Never leak the key; always hint via WWW-Authenticate. + assert!( + resp.__headers().iter().any(|(n, _)| n.eq_ignore_ascii_case("WWW-Authenticate")), + "401 must carry a WWW-Authenticate hint", + ); + } + + #[test] + fn ct_eq_is_correct() { + assert!(ct_eq(b"correct-key", b"correct-key")); + assert!(!ct_eq(b"correct-key", b"correct-keZ")); + assert!(!ct_eq(b"correct-key", b"correct-ke")); // length mismatch + assert!(ct_eq(b"", b"")); + assert!(!ct_eq(b"a", b"")); + } + + #[test] + fn init_requires_a_store() { + // No keys and no KV template → misconfiguration. + assert!(ApiKey::init(&serde_json::json!({})).is_err()); + assert!(ApiKey::init(&serde_json::json!({ "header": "X-Api-Key" })).is_err()); + // `keys` must be an object; entries must be string consumer-ids. + assert!(ApiKey::init(&serde_json::json!({ "keys": "nope" })).is_err()); + assert!(ApiKey::init(&serde_json::json!({ "keys": { "k": 42 } })).is_err()); + // `kv_key_template` must contain the placeholder. + assert!(ApiKey::init(&serde_json::json!({ "kv_key_template": "apikey:" })).is_err()); + // Valid minimal configs. + assert!(ApiKey::init(&serde_json::json!({ "keys": { "k": "c" } })).is_ok()); + assert!(ApiKey::init(&serde_json::json!({ "kv_key_template": "apikey:" })).is_ok()); + } + + #[test] + fn valid_static_key_rewrites_with_consumer() { + let mw = api_key(serde_json::json!({ "keys": { "secret-abc": "consumer-7" } })); + let resp = invoke(&mw, &hdr("X-Api-Key", "secret-abc")); + assert_eq!(resp.__action(), ACTION_REWRITE); + assert_eq!(consumer_header(&resp).as_deref(), Some("consumer-7")); + } + + #[test] + fn invalid_static_key_is_401() { + let mw = api_key(serde_json::json!({ "keys": { "secret-abc": "consumer-7" } })); + assert_401(&invoke(&mw, &hdr("X-Api-Key", "wrong")), "invalid api key"); + } + + #[test] + fn missing_key_is_401() { + let mw = api_key(serde_json::json!({ "keys": { "secret-abc": "consumer-7" } })); + assert_401(&invoke(&mw, &[]), "missing api key"); + // Present-but-empty header also counts as missing. + assert_401(&invoke(&mw, &hdr("X-Api-Key", " ")), "missing api key"); + } + + #[test] + fn custom_header_and_consumer_header() { + let mw = api_key(serde_json::json!({ + "header": "X-Key", + "consumer_header": "X-Who", + "keys": { "k1": "alice" }, + })); + let resp = invoke(&mw, &hdr("X-Key", "k1")); + assert_eq!(resp.__action(), ACTION_REWRITE); + let who = resp + .__headers() + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case("X-Who")) + .map(|(_, v)| v.as_str()); + assert_eq!(who, Some("alice")); + } + + #[test] + fn query_param_disabled_by_default() { + let mw = api_key(serde_json::json!({ "keys": { "qk": "qc" } })); + // Key only in the query string, but query_param is off → 401 missing. + assert_401(&invoke_q(&mw, "api_key=qk", &[]), "missing api key"); + } + + #[test] + fn query_param_when_enabled() { + let mw = api_key(serde_json::json!({ + "query_param": "api_key", + "keys": { "qk": "qc" }, + })); + let resp = invoke_q(&mw, "foo=1&api_key=qk&bar=2", &[]); + assert_eq!(resp.__action(), ACTION_REWRITE); + assert_eq!(consumer_header(&resp).as_deref(), Some("qc")); + // Header still takes precedence over the query param. + let resp = invoke_q(&mw, "api_key=wrong", &hdr("X-Api-Key", "qk")); + assert_eq!(resp.__action(), ACTION_REWRITE); + assert_eq!(consumer_header(&resp).as_deref(), Some("qc")); + // URL-encoded value round-trips. + let mw2 = api_key(serde_json::json!({ + "query_param": "api_key", + "keys": { "a b": "spaced" }, + })); + let resp = invoke_q(&mw2, "api_key=a%20b", &[]); + assert_eq!(consumer_header(&resp).as_deref(), Some("spaced")); + } + + #[test] + fn kv_backed_valid_and_invalid() { + setup_kv_with(&[("apikey:live-key", "kv-consumer-1")]); + let mw = api_key(serde_json::json!({ "kv_key_template": "apikey:" })); + // Valid: value in the store is the consumer id. + let resp = invoke(&mw, &hdr("X-Api-Key", "live-key")); + assert_eq!(resp.__action(), ACTION_REWRITE); + assert_eq!(consumer_header(&resp).as_deref(), Some("kv-consumer-1")); + // Absent key → 401 invalid. + assert_401(&invoke(&mw, &hdr("X-Api-Key", "no-such-key")), "invalid api key"); + } + + #[test] + fn static_map_takes_precedence_then_kv() { + setup_kv_with(&[("apikey:kv-only", "from-kv")]); + let mw = api_key(serde_json::json!({ + "keys": { "static-only": "from-static" }, + "kv_key_template": "apikey:", + })); + // Static hit. + assert_eq!( + consumer_header(&invoke(&mw, &hdr("X-Api-Key", "static-only"))).as_deref(), + Some("from-static"), + ); + // Falls through to KV. + assert_eq!( + consumer_header(&invoke(&mw, &hdr("X-Api-Key", "kv-only"))).as_deref(), + Some("from-kv"), + ); + } +} diff --git a/crates/ephpm-middleware-cors/Cargo.toml b/crates/ephpm-middleware-cors/Cargo.toml deleted file mode 100644 index 4db14a6..0000000 --- a/crates/ephpm-middleware-cors/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "ephpm-middleware-cors" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "ePHPm native middleware: CORS preflight handling and response headers (loadable cdylib; implementation in ephpm-middleware-modules)" - -[lib] -# cdylib = the loadable module for the dlopen lane; rlib for tests + the -# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision -# rationale behind the impl/shell split. -crate-type = ["cdylib", "rlib"] - -[dependencies] -ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true - -[lints] -workspace = true diff --git a/crates/ephpm-middleware-cors/src/lib.rs b/crates/ephpm-middleware-cors/src/lib.rs deleted file mode 100644 index c3064e9..0000000 --- a/crates/ephpm-middleware-cors/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! `cors` — loadable cdylib shell around the shared implementation in -//! [`ephpm_middleware_modules::cors`]. -//! -//! The middleware itself (CORS preflight handling and response headers, docs -//! and tests included) lives in `ephpm-middleware-modules`. This crate only -//! adds the C ABI exports (`declare!`) so the module can be `dlopen`ed by -//! dynamically linked ePHPm builds. - -pub use ephpm_middleware_modules::cors::Cors; - -ephpm_middleware::declare!(Cors); diff --git a/crates/ephpm-middleware-header-transform/Cargo.toml b/crates/ephpm-middleware-header-transform/Cargo.toml index 08642fe..8cff936 100644 --- a/crates/ephpm-middleware-header-transform/Cargo.toml +++ b/crates/ephpm-middleware-header-transform/Cargo.toml @@ -5,17 +5,21 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "ePHPm native middleware: set request headers seen by PHP and set/remove response headers out (request + response phase; loadable cdylib; implementation in ephpm-middleware-modules)" +description = "Example ePHPm native middleware: set request headers seen by PHP and set/remove response headers out (request + response phase)" [lib] -# cdylib = the loadable module for the dlopen lane; rlib for tests + the -# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision -# rationale behind the impl/shell split. +# cdylib = the loadable module ePHPm dlopen()s; rlib so the unit tests can link +# the crate as a library. crate-type = ["cdylib", "rlib"] [dependencies] ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true +serde_json.workspace = true + +[dev-dependencies] +# `host` gives the tests RequestCtx / ResponseCtx / host_table to fabricate a +# request and response view. Dev-only. +ephpm-middleware = { workspace = true, features = ["host"] } [lints] workspace = true diff --git a/crates/ephpm-middleware-header-transform/src/lib.rs b/crates/ephpm-middleware-header-transform/src/lib.rs index cb560f4..f97f053 100644 --- a/crates/ephpm-middleware-header-transform/src/lib.rs +++ b/crates/ephpm-middleware-header-transform/src/lib.rs @@ -1,11 +1,319 @@ -//! `header-transform` — loadable cdylib shell around the shared implementation -//! in [`ephpm_middleware_modules::header_transform`]. +//! # Example: header-transform (request + response phase) middleware //! -//! The middleware itself (request/response header set + response header remove, -//! the request + response phase logic, docs and tests included) lives in -//! `ephpm-middleware-modules`. This crate only adds the C ABI exports -//! (`declare!(HeaderTransform, response)`) for the `dlopen` lane. +//! A self-contained, loadable ePHPm native-middleware module, kept as a +//! reference you can copy to write your own. The official build of this +//! module is compiled into ePHPm itself; nothing here needs to be fetched or +//! installed separately. See the repository README for the ABI, the +//! `declare!` macro, and the request- vs response-phase model. +//! + +//! `header-transform` — ePHPm native middleware that rewrites request headers +//! seen by PHP and response headers sent to the client. +//! +//! Analogous to Traefik's `headers` (`customRequestHeaders` / +//! `customResponseHeaders`), Kong's request/response transformer, or nginx's +//! `proxy_set_header` / `add_header` / `more_clear_headers`. +//! +//! # Two phases +//! +//! - **Request phase** ([`Middleware::invoke`]) — set request headers before +//! PHP runs (PHP reads them as `$_SERVER['HTTP_']`). +//! - **Response phase** ([`ResponseMiddleware::invoke_response`]) — set or +//! remove response headers on the way out, on **every** response (PHP, +//! static file, error page). +//! +//! Configuration (`[[middleware]] config = { ... }`), all optional: +//! +//! ```toml +//! [middleware.config.request] +//! set = { "X-Env" = "prod", "X-Tenant" = "acme" } +//! +//! [middleware.config.response] +//! set = { "X-Served-By" = "ephpm" } +//! remove = ["Server", "X-Powered-By"] +//! ``` +//! +//! | section | key | effect | +//! |---------|-----|--------| +//! | `request` | `set` (object) | replace-or-add each request header PHP sees | +//! | `response` | `set` (object) | replace-or-add each response header | +//! | `response` | `remove` (array) | delete each response header (case-insensitive) | +//! +//! # v1 ABI scope (why there is no `add` or request `remove`) +//! +//! The host applies both request-header overrides and response `set` as +//! **replace-or-add** (one occurrence, case-insensitive) — there is no +//! duplicate-append primitive in the v1 middleware ABI, so `add` and `set` +//! would be identical; only `set` is offered. And the request phase can only +//! *override* a request header, not delete one, so request-side `remove` is +//! not offered (it would be a silent no-op). Header **removal is response-side +//! only**, where the ABI supports it. Both are honest reflections of the ABI, +//! not omissions. + +use ephpm_middleware::{Middleware, Request, Response, ResponseMiddleware, ResponseView}; + +/// A parsed set of `(name, value)` header assignments, order preserved. +type Assignments = Vec<(String, String)>; + +/// Header rewrite policy, built once at `init`. +pub struct HeaderTransform { + request_set: Assignments, + response_set: Assignments, + response_remove: Vec, +} + +/// Parse an optional `{ name: value, ... }` object into ordered string pairs. +/// Rejects non-string values and empty names. +fn parse_set(section: &serde_json::Value, path: &str) -> Result { + match section.get("set") { + None | Some(serde_json::Value::Null) => Ok(Vec::new()), + Some(serde_json::Value::Object(map)) => { + let mut out = Vec::with_capacity(map.len()); + for (name, value) in map { + if name.is_empty() { + return Err(format!("`{path}.set` has an empty header name")); + } + let v = value.as_str().ok_or_else(|| { + format!("`{path}.set` values must be strings, got {value} for `{name}`") + })?; + out.push((name.clone(), v.to_owned())); + } + Ok(out) + } + Some(other) => Err(format!("`{path}.set` must be an object, got {other}")), + } +} + +/// Parse an optional `["Name", ...]` array of header names to remove. +fn parse_remove(section: &serde_json::Value, path: &str) -> Result, String> { + match section.get("remove") { + None | Some(serde_json::Value::Null) => Ok(Vec::new()), + Some(serde_json::Value::Array(items)) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + let name = item.as_str().ok_or_else(|| { + format!("`{path}.remove` entries must be strings, got {item}") + })?; + if name.is_empty() { + return Err(format!("`{path}.remove` has an empty header name")); + } + out.push(name.to_owned()); + } + Ok(out) + } + Some(other) => Err(format!("`{path}.remove` must be an array, got {other}")), + } +} + +/// Fetch a top-level section object (`request` / `response`), defaulting to a +/// JSON null (an empty section) when absent. Rejects a non-object section. +fn section<'a>(config: &'a serde_json::Value, key: &str) -> Result<&'a serde_json::Value, String> { + const NULL: serde_json::Value = serde_json::Value::Null; + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(&NULL), + Some(v @ serde_json::Value::Object(_)) => Ok(v), + Some(other) => Err(format!("`{key}` must be an object, got {other}")), + } +} + +impl Middleware for HeaderTransform { + fn init(config: &serde_json::Value) -> Result { + let request = section(config, "request")?; + let response = section(config, "response")?; + + // `request.remove` cannot be honored (see module docs); reject it loudly + // rather than silently ignore it. + if request.get("remove").is_some_and(|v| !v.is_null()) { + return Err("`request.remove` is not supported: the v1 ABI request phase can \ + only set/override request headers, not delete them" + .into()); + } + + Ok(Self { + request_set: parse_set(request, "request")?, + response_set: parse_set(response, "response")?, + response_remove: parse_remove(response, "response")?, + }) + } -pub use ephpm_middleware_modules::header_transform::HeaderTransform; + fn invoke(&self, _req: &Request<'_>) -> Response { + if self.request_set.is_empty() { + return Response::cont(); + } + let mut r = Response::rewrite(); + for (name, value) in &self.request_set { + r = r.header(name.clone(), value.clone()); + } + r + } +} +impl ResponseMiddleware for HeaderTransform { + fn invoke_response(&self, _req: &Request<'_>, resp: &mut ResponseView<'_>) { + for name in &self.response_remove { + resp.remove_header(name.clone()); + } + for (name, value) in &self.response_set { + resp.set_header(name.clone(), value.clone()); + } + } +} + +// ── C ABI export ──────────────────────────────────────────────────────────── +// `declare!` generates the `extern "C"` entry points ePHPm's module loader +// calls (init / invoke / free) and bakes in the ABI-major compatibility check, +// so a module built against the wrong host ABI refuses to load instead of +// corrupting memory. This is the ONLY line that turns the plain `Middleware` +// impl above into a loadable `.so`/`.dylib`/`.dll`. ephpm_middleware::declare!(HeaderTransform, response); + +#[cfg(test)] +mod tests { + #![allow(unsafe_code)] // tests build the FFI Request / Response views by hand. + + use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_REWRITE}; + use ephpm_middleware::host::{RequestCtx, ResponseCtx, host_table}; + + use super::*; + + fn init(config: serde_json::Value) -> HeaderTransform { + HeaderTransform::init(&config).expect("init") + } + + fn hdr(name: &str, value: &str) -> (String, String) { + (name.to_owned(), value.to_owned()) + } + + fn invoke(mw: &HeaderTransform) -> Response { + let ctx = RequestCtx::new("GET", "/index.php", "", "203.0.113.9", "example.test", &[]); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + mw.invoke(&req) + } + + fn invoke_response( + mw: &HeaderTransform, + resp_headers: Vec<(String, String)>, + ) -> Vec<(String, String)> { + let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", &[]); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + let mut rctx = ResponseCtx::new(200, resp_headers, b"body".to_vec()); + { + // SAFETY: `rctx` outlives the view; host_table() is 'static. + let mut view = unsafe { ResponseView::from_raw(rctx.as_ptr(), host_table()) }; + mw.invoke_response(&req, &mut view); + let (status, body, set, remove) = view.__into_parts(); + for name in remove { + rctx.remove_header(&name); + } + for (n, v) in set { + rctx.set_header(&n, &v); + } + if let Some(s) = status { + rctx.set_status(s); + } + if let Some(b) = body { + rctx.replace_body(b); + } + } + let (_status, headers, _body) = rctx.into_parts(); + headers + } + + fn get<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) + } + + // ── request phase ───────────────────────────────────────────────────── + + #[test] + fn request_set_injects_headers() { + let mw = init(serde_json::json!({ + "request": { "set": { "X-Env": "prod", "X-Tenant": "acme" } } + })); + let resp = invoke(&mw); + assert_eq!(resp.__action(), ACTION_REWRITE); + assert_eq!(get(resp.__headers(), "X-Env"), Some("prod")); + assert_eq!(get(resp.__headers(), "X-Tenant"), Some("acme")); + } + + #[test] + fn no_request_config_continues() { + let mw = init(serde_json::json!({ + "response": { "set": { "X-A": "b" } } + })); + assert_eq!(invoke(&mw).__action(), ACTION_CONTINUE); + } + + // ── response phase ──────────────────────────────────────────────────── + + #[test] + fn response_set_replaces_or_adds() { + let mw = init(serde_json::json!({ + "response": { "set": { "X-Served-By": "ephpm", "Content-Type": "text/plain" } } + })); + let out = invoke_response(&mw, vec![hdr("Content-Type", "text/html")]); + assert_eq!(get(&out, "X-Served-By"), Some("ephpm")); + // Replace, not duplicate. + assert_eq!(get(&out, "Content-Type"), Some("text/plain")); + assert_eq!(out.iter().filter(|(n, _)| n.eq_ignore_ascii_case("Content-Type")).count(), 1); + } + + #[test] + fn response_remove_deletes() { + let mw = init(serde_json::json!({ + "response": { "remove": ["Server", "X-Powered-By"] } + })); + let out = invoke_response( + &mw, + vec![hdr("Server", "nginx"), hdr("X-Powered-By", "PHP/8.5"), hdr("X-Keep", "1")], + ); + assert_eq!(get(&out, "Server"), None); + assert_eq!(get(&out, "X-Powered-By"), None); + assert_eq!(get(&out, "X-Keep"), Some("1")); + } + + #[test] + fn remove_then_set_same_header_nets_set() { + let mw = init(serde_json::json!({ + "response": { "set": { "Server": "ephpm" }, "remove": ["Server"] } + })); + let out = invoke_response(&mw, vec![hdr("Server", "nginx")]); + assert_eq!(get(&out, "Server"), Some("ephpm")); + assert_eq!(out.iter().filter(|(n, _)| n.eq_ignore_ascii_case("Server")).count(), 1); + } + + // ── config validation ───────────────────────────────────────────────── + + #[test] + fn request_remove_is_rejected() { + assert!( + HeaderTransform::init(&serde_json::json!({ + "request": { "remove": ["X-Foo"] } + })) + .is_err() + ); + } + + #[test] + fn bad_config_fails_init() { + assert!( + HeaderTransform::init(&serde_json::json!({ "request": { "set": { "X": 1 } } })) + .is_err() + ); + assert!( + HeaderTransform::init(&serde_json::json!({ "response": { "remove": [42] } })).is_err() + ); + assert!(HeaderTransform::init(&serde_json::json!({ "request": "nope" })).is_err()); + assert!(HeaderTransform::init(&serde_json::json!({ "response": { "set": [] } })).is_err()); + } + + #[test] + fn empty_config_is_a_noop() { + let mw = init(serde_json::Value::Null); + assert_eq!(invoke(&mw).__action(), ACTION_CONTINUE); + let out = invoke_response(&mw, vec![hdr("X-A", "b")]); + assert_eq!(get(&out, "X-A"), Some("b")); + } +} diff --git a/crates/ephpm-middleware-ip-allowlist/Cargo.toml b/crates/ephpm-middleware-ip-allowlist/Cargo.toml deleted file mode 100644 index 7eef4c8..0000000 --- a/crates/ephpm-middleware-ip-allowlist/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "ephpm-middleware-ip-allowlist" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "ePHPm native middleware: per-site IP allow/deny by CIDR (loadable cdylib; implementation in ephpm-middleware-modules)" - -[lib] -# cdylib = the loadable module for the dlopen lane; rlib for tests + the -# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision -# rationale behind the impl/shell split. -crate-type = ["cdylib", "rlib"] - -[dependencies] -ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true - -[dev-dependencies] -# The deny-path integration test drives the loaded `IpAllowlist` through the -# shell crate's re-export and the `host` feature's `RequestCtx`/`host_table`, -# asserting a real 403 RESPOND verdict. No KV store is wired in — this gate is -# pure CIDR policy, so the test is deterministic and never flaky. -ephpm-middleware = { workspace = true, features = ["host"] } -serde_json.workspace = true - -[lints] -workspace = true diff --git a/crates/ephpm-middleware-ip-allowlist/src/lib.rs b/crates/ephpm-middleware-ip-allowlist/src/lib.rs deleted file mode 100644 index 1dd0f0a..0000000 --- a/crates/ephpm-middleware-ip-allowlist/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! `ip-allowlist` — loadable cdylib shell around the shared implementation in -//! [`ephpm_middleware_modules::ip_allowlist`]. -//! -//! The middleware itself (per-site IP allow/deny by CIDR, docs and tests -//! included) lives in `ephpm-middleware-modules`. This crate only adds the C -//! ABI exports (`declare!`) so the module can be `dlopen`ed by dynamically -//! linked ePHPm builds. - -pub use ephpm_middleware_modules::ip_allowlist::IpAllowlist; - -ephpm_middleware::declare!(IpAllowlist); diff --git a/crates/ephpm-middleware-ip-allowlist/tests/deny.rs b/crates/ephpm-middleware-ip-allowlist/tests/deny.rs deleted file mode 100644 index 4ece7a0..0000000 --- a/crates/ephpm-middleware-ip-allowlist/tests/deny.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Fail-CLOSED deny path, driven through the loadable shell crate. -//! -//! The sibling `ratelimit` / `maintenance-mode` integration tests only assert -//! the fail-OPEN `CONTINUE` verdict. This one exercises the opposite — the -//! access-control gate producing a real `403` `RESPOND` — end to end through -//! the same surface the host uses: the module type as re-exported by the -//! *shell* crate (`ephpm_middleware_ip_allowlist::IpAllowlist`, the crate that -//! becomes the shipped cdylib), driven via the `host` feature's fabricated -//! `RequestCtx` and the real `host_table()`. -//! -//! It needs no ephpm binary and no KV store — the verdict is pure CIDR policy, -//! so the test is deterministic (never flaky). It proves that a built module, -//! reached through the ABI-facing `Request`/`Response` types, denies an -//! out-of-policy client with the exact status and body the module documents. -#![allow(unsafe_code)] // builds the FFI Request view by hand, like the unit tests. - -use ephpm_middleware::Middleware; -use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_RESPOND}; -use ephpm_middleware::host::{RequestCtx, host_table}; -use ephpm_middleware_ip_allowlist::IpAllowlist; - -/// Drive the module for one client IP and return the ABI `Response`. -fn invoke(mw: &IpAllowlist, ip: &str) -> ephpm_middleware::Response { - let ctx = RequestCtx::new("GET", "/index.php", "", ip, "example.test", &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { ephpm_middleware::Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) -} - -#[test] -fn out_of_policy_ip_is_denied_403() { - // Allow only the RFC1918 10/8 block; everything else hits the default deny. - let mw = IpAllowlist::init(&serde_json::json!({ "allow": ["10.0.0.0/8"] })).expect("init"); - - // An in-range client passes straight through to PHP. - assert_eq!(invoke(&mw, "10.1.2.3").__action(), ACTION_CONTINUE); - - // An out-of-range client is rejected with a real 403 RESPOND verdict — - // action, status, and the plain-text body all asserted. - let resp = invoke(&mw, "203.0.113.9"); - assert_eq!(resp.__action(), ACTION_RESPOND); - assert_eq!(resp.__status(), 403); - assert!(!resp.__body().is_empty()); -} - -#[test] -fn explicit_deny_beats_allow() { - // Same address in both lists with default=allow: deny must still win (403). - let mw = IpAllowlist::init(&serde_json::json!({ - "allow": ["10.0.0.0/8"], - "deny": ["10.6.6.6/32"], - "default": "allow", - })) - .expect("init"); - assert_eq!(invoke(&mw, "10.6.6.6").__action(), ACTION_RESPOND); - assert_eq!(invoke(&mw, "10.6.6.7").__action(), ACTION_CONTINUE); -} diff --git a/crates/ephpm-middleware-jwt/Cargo.toml b/crates/ephpm-middleware-jwt/Cargo.toml deleted file mode 100644 index 55853ff..0000000 --- a/crates/ephpm-middleware-jwt/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "ephpm-middleware-jwt" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "ePHPm native middleware: HS256 JWT bearer-token validation (loadable cdylib; implementation in ephpm-middleware-modules)" - -[lib] -# cdylib = the loadable module for the dlopen lane; rlib so the module can be -# unit/integration-tested and so ePHPm's `vendor-middleware` feature can link -# the type. The C ABI exports (`declare!`) live in the cdylib and must never be -# linked into the host binary alongside the sibling shells — multiple modules -# exporting the same `ephpm_middleware_*` symbols collide. -crate-type = ["cdylib", "rlib"] - -[dependencies] -ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true - -[lints] -workspace = true diff --git a/crates/ephpm-middleware-jwt/src/lib.rs b/crates/ephpm-middleware-jwt/src/lib.rs deleted file mode 100644 index 6221fc6..0000000 --- a/crates/ephpm-middleware-jwt/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! `jwt` — loadable cdylib shell around the shared implementation in -//! [`ephpm_middleware_modules::jwt`]. -//! -//! The middleware itself (HS256 bearer-token validation, docs and tests -//! included) lives in `ephpm-middleware-modules`. This crate only adds the C -//! ABI exports (`declare!`) so the module can be `dlopen`ed by dynamically -//! linked ePHPm builds. `describe()` reports this crate's name -//! (`ephpm-middleware-jwt`) in the host's startup log. - -pub use ephpm_middleware_modules::jwt::Jwt; - -ephpm_middleware::declare!(Jwt); diff --git a/crates/ephpm-middleware-maintenance-mode/Cargo.toml b/crates/ephpm-middleware-maintenance-mode/Cargo.toml deleted file mode 100644 index 7ca6a5c..0000000 --- a/crates/ephpm-middleware-maintenance-mode/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "ephpm-middleware-maintenance-mode" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "ePHPm native middleware: flip a tenant into a 503 holding page via a per-site KV flag, no redeploy (loadable cdylib; implementation in ephpm-middleware-modules)" - -[lib] -# cdylib = the loadable module for the dlopen lane; rlib for tests + the -# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision -# rationale behind the impl/shell split. -crate-type = ["cdylib", "rlib"] - -[dependencies] -ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true - -[dev-dependencies] -# The fail-open integration test needs its own process (the host KV store is -# process-global and can only be set once) and drives `MaintenanceMode` -# directly with NO store wired in. -ephpm-middleware = { workspace = true, features = ["host"] } -serde_json.workspace = true - -[lints] -workspace = true diff --git a/crates/ephpm-middleware-maintenance-mode/src/lib.rs b/crates/ephpm-middleware-maintenance-mode/src/lib.rs deleted file mode 100644 index 6a6ab2d..0000000 --- a/crates/ephpm-middleware-maintenance-mode/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! `maintenance-mode` — loadable cdylib shell around the shared -//! implementation in [`ephpm_middleware_modules::maintenance_mode`]. -//! -//! The middleware itself (a per-site KV flag that short-circuits the request -//! with a 503 holding page, docs and tests included) lives in -//! `ephpm-middleware-modules`. This crate only adds the C ABI exports -//! (`declare!`) so the module can be `dlopen`ed by dynamically linked ePHPm -//! builds. - -pub use ephpm_middleware_modules::maintenance_mode::MaintenanceMode; - -ephpm_middleware::declare!(MaintenanceMode); diff --git a/crates/ephpm-middleware-maintenance-mode/tests/fail_open.rs b/crates/ephpm-middleware-maintenance-mode/tests/fail_open.rs deleted file mode 100644 index d216ed8..0000000 --- a/crates/ephpm-middleware-maintenance-mode/tests/fail_open.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Fail-OPEN behaviour when the KV store is unavailable. -//! -//! This lives in its own integration-test binary (= its own process) because -//! the host KV store is process-global: once set it cannot be unset. Here -//! `set_kv_store` is never called, so every `kv_get` returns `None` — the -//! module must let the request through (fail-OPEN), never black-hole the -//! tenant. This is the deliberate opposite of an auth/allowlist gate, which -//! must fail closed. See the module docs for the rationale. -#![allow(unsafe_code)] // builds the FFI Request view by hand, like the unit tests. - -use ephpm_middleware::Middleware; -use ephpm_middleware::abi::ACTION_CONTINUE; -use ephpm_middleware::host::{RequestCtx, host_table}; -use ephpm_middleware_maintenance_mode::MaintenanceMode; - -#[test] -fn kv_unavailable_fails_open() { - // Even with an aggressive key template, no store means no flag can ever - // read truthy — every request must continue. - let mw = MaintenanceMode::init(&serde_json::json!({ "retry_after": 60 })).expect("init"); - let ctx = RequestCtx::new("GET", "/", "", "198.51.100.9", "vhost-open", &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { ephpm_middleware::Request::from_raw(ctx.as_abi(), host_table()) }; - for _ in 0..50 { - assert_eq!(mw.invoke(&req).__action(), ACTION_CONTINUE); - } -} diff --git a/crates/ephpm-middleware-modules/Cargo.toml b/crates/ephpm-middleware-modules/Cargo.toml deleted file mode 100644 index e306184..0000000 --- a/crates/ephpm-middleware-modules/Cargo.toml +++ /dev/null @@ -1,40 +0,0 @@ -[package] -name = "ephpm-middleware-modules" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "The official ePHPm native middleware implementations as plain Rust types — shared by the loadable cdylib shells here and by ePHPm's optional compile-time `vendor-middleware` feature" - -# Deliberately rlib-only and free of C ABI exports. The sibling cdylib shells -# (`ephpm-middleware-`) re-export these types and add `declare!`; -# multiple crates each exporting the same -# `ephpm_middleware_*` symbols cannot be linked into one binary, which is -# exactly why the implementations live here. This crate is also what ePHPm's -# off-by-default `vendor-middleware` feature git-deps to compile the modules -# back into a fully-static (musl) binary that cannot dlopen. - -[dependencies] -ephpm-middleware.workspace = true -serde_json.workspace = true -hmac.workspace = true -sha2.workspace = true -base64ct.workspace = true -# ip-allowlist: CIDR containment for v4+v6. Tiny, widely-used, MIT/Apache-2.0, -# no transitive deps beyond `serde` (which we do not enable). std handles IP -# parsing; ipnetwork only does the network-membership test. -ipnetwork.workspace = true -# api-key: constant-time key comparison (no_std, no transitive deps). -subtle.workspace = true - -[dev-dependencies] -# `host` gives the tests `RequestCtx` / `host_table` to fabricate a request, -# and pulls in the real embedded KV store for the ratelimit tests. Dev-only — -# resolver 3 keeps the `host` feature and `ephpm-kv` out of the shipped cdylib -# builds, which need neither. -ephpm-middleware = { workspace = true, features = ["host"] } -ephpm-kv.workspace = true - -[lints] -workspace = true diff --git a/crates/ephpm-middleware-modules/src/api_key.rs b/crates/ephpm-middleware-modules/src/api_key.rs deleted file mode 100644 index 0bf3695..0000000 --- a/crates/ephpm-middleware-modules/src/api_key.rs +++ /dev/null @@ -1,420 +0,0 @@ -//! `api-key` — ePHPm native middleware validating an API key on the request -//! before PHP runs, then forwarding the resolved **consumer identity** to PHP. -//! -//! Analogous to Kong's `key-auth`, AWS API Gateway API keys / usage plans, and -//! Tyk: a request carrying a recognised key is admitted and tagged with the -//! consumer it belongs to; a request with a missing or unrecognised key is -//! short-circuited with `401` and PHP never runs. -//! -//! The key is read from a configurable request header (default `X-Api-Key`) -//! and, only when explicitly enabled, from a query parameter (default off — -//! see the security note). It is validated against either a static -//! `key → consumer-id` map baked into the config, a KV lookup (`kv_get` on a -//! `kv_key_template` like `apikey:` whose value is the consumer id), or -//! both (the static map is consulted first). On success the module `REWRITE`s -//! the request, injecting the consumer id in a header (default -//! `X-Consumer-Id`) that PHP reads — the exact mechanism `jwt` uses to forward -//! claims. The injected header **overwrites** any same-named header the client -//! sent (the host's `override_header` replaces, not appends), so a client -//! cannot spoof its consumer identity. -//! -//! ## Security -//! -//! * **Constant-time comparison.** Static keys are compared with a -//! constant-time equality check (`subtle::ConstantTimeEq`) so the match does -//! not leak how many leading bytes were correct — closing the timing oracle -//! that a naive `==` would open. All configured keys are compared on every -//! request (no early return on the first match). Only the *lengths* of keys -//! can differ in timing, which is not a practical attack surface. The KV -//! path is an exact-key store lookup and does not compare secrets in Rust. -//! * **The key value is never logged.** This module emits no logs containing -//! the presented key. -//! * **Query parameter is off by default.** Query strings routinely end up in -//! access logs, proxy logs, browser history and `Referer` headers, so a key -//! in the URL leaks far more readily than one in a header. Enable -//! `query_param` only when a client genuinely cannot set a header. -//! * **Composes with `ratelimit`.** Point the `ratelimit` module's -//! `key_headers` at the same header (e.g. `["X-Api-Key"]`) to get per-key -//! rate limiting in front of, or alongside, this auth gate. -//! -//! Configuration (`[[middleware]] config = { ... }`): -//! -//! | key | default | meaning | -//! |-----|---------|---------| -//! | `header` (string) | `"X-Api-Key"` | request header carrying the key | -//! | `query_param` (string) | unset (disabled) | also accept the key from this query parameter — see the security note | -//! | `keys` (object) | unset | static `key → consumer-id` map | -//! | `kv_key_template` (string) | unset | KV lookup key with a `` placeholder, e.g. `apikey:`; the value is the consumer id | -//! | `consumer_header` (string) | `"X-Consumer-Id"` | header injected for PHP with the resolved consumer id | -//! -//! At least one of `keys` / `kv_key_template` must be configured. - -use ephpm_middleware::{Middleware, Request, Response}; -use subtle::ConstantTimeEq; - -/// The literal replaced with the presented key in `kv_key_template`. -const KEY_PLACEHOLDER: &str = ""; - -/// API-key validation policy, built once at `init`. -pub struct ApiKey { - header: String, - query_param: Option, - consumer_header: String, - /// Static `key → consumer-id` entries. Keys are stored as bytes for the - /// constant-time comparison. - keys: Vec<(Vec, String)>, - /// KV lookup template containing [`KEY_PLACEHOLDER`], e.g. `apikey:`. - kv_key_template: Option, -} - -/// Constant-time byte-slice equality. Wraps [`subtle::ConstantTimeEq`] so the -/// comparison does not short-circuit on the first differing byte (unequal -/// lengths still return `false` fast, leaking only length). This is the helper -/// the static-key match uses; it is unit-tested directly. -#[must_use] -fn ct_eq(a: &[u8], b: &[u8]) -> bool { - a.ct_eq(b).into() -} - -/// URL-decode a query-string component (`+` → space, `%XX` → byte). Invalid -/// escapes are passed through literally rather than failing the lookup. -fn percent_decode(s: &str) -> String { - let bytes = s.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'+' => { - out.push(b' '); - i += 1; - } - b'%' if i + 2 < bytes.len() => { - let hi = (bytes[i + 1] as char).to_digit(16); - let lo = (bytes[i + 2] as char).to_digit(16); - if let (Some(hi), Some(lo)) = (hi, lo) { - out.push((hi * 16 + lo) as u8); - i += 3; - } else { - out.push(b'%'); - i += 1; - } - } - b => { - out.push(b); - i += 1; - } - } - } - String::from_utf8_lossy(&out).into_owned() -} - -/// Return the (decoded) value of query parameter `name` in `query`, if present. -fn query_value(query: &str, name: &str) -> Option { - query.split('&').find_map(|pair| { - let (k, v) = pair.split_once('=').unwrap_or((pair, "")); - (k == name).then(|| percent_decode(v)) - }) -} - -impl ApiKey { - /// Extract the presented key: the configured header first, then the query - /// parameter when enabled. Empty values count as absent. - fn extract_key(&self, req: &Request<'_>) -> Option { - if let Some(v) = req.header(&self.header) { - let v = v.trim(); - if !v.is_empty() { - return Some(v.to_owned()); - } - } - if let Some(param) = &self.query_param - && let Some(v) = query_value(req.query(), param) - && !v.is_empty() - { - return Some(v); - } - None - } - - /// Constant-time match of `presented` against the static key map. Every - /// entry is compared (no early return) so the number of matching leading - /// bytes is not observable via timing. - fn match_static(&self, presented: &[u8]) -> Option<&str> { - let mut matched: Option<&str> = None; - for (key, consumer) in &self.keys { - if ct_eq(presented, key) { - matched = Some(consumer.as_str()); - } - } - matched - } - - /// Look the presented key up in the KV store via `kv_key_template`. The - /// stored value (UTF-8, non-empty) is the consumer id. - fn match_kv(&self, req: &Request<'_>, presented: &str) -> Option { - let template = self.kv_key_template.as_ref()?; - let lookup = template.replace(KEY_PLACEHOLDER, presented); - let value = req.host().kv_get(&lookup)?; - let consumer = String::from_utf8(value).ok()?; - (!consumer.is_empty()).then_some(consumer) - } - - /// Admit the request, injecting the consumer id for PHP (mirrors how `jwt` - /// forwards its claims via a request header). - fn grant(&self, consumer: &str) -> Response { - Response::rewrite().header(self.consumer_header.as_str(), consumer) - } - - /// `401` with a `WWW-Authenticate`-style hint naming the expected header. - /// The key value is deliberately absent from the body. - fn unauthorized(&self, body: &'static str) -> Response { - Response::respond(401, body) - .header("WWW-Authenticate", format!("ApiKey header=\"{}\"", self.header)) - } -} - -impl Middleware for ApiKey { - fn init(config: &serde_json::Value) -> Result { - let opt_str = |key: &str| -> Result, String> { - match config.get(key) { - Some(serde_json::Value::String(s)) if !s.is_empty() => Ok(Some(s.clone())), - None | Some(serde_json::Value::Null | serde_json::Value::String(_)) => Ok(None), - Some(other) => Err(format!("`{key}` must be a string, got {other}")), - } - }; - - let header = opt_str("header")?.unwrap_or_else(|| "X-Api-Key".to_owned()); - let query_param = opt_str("query_param")?; - let consumer_header = - opt_str("consumer_header")?.unwrap_or_else(|| "X-Consumer-Id".to_owned()); - - let keys = match config.get("keys") { - None | Some(serde_json::Value::Null) => Vec::new(), - Some(v) => { - let map = v.as_object().ok_or("`keys` must be an object of key -> consumer-id")?; - map.iter() - .map(|(key, consumer)| { - if key.is_empty() { - return Err("`keys` entries must have a non-empty key".to_owned()); - } - let consumer = consumer.as_str().ok_or_else(|| { - format!("`keys[\"{key}\"]` must be a string consumer-id") - })?; - Ok((key.as_bytes().to_vec(), consumer.to_owned())) - }) - .collect::, String>>()? - } - }; - - let kv_key_template = opt_str("kv_key_template")?; - if let Some(template) = &kv_key_template - && !template.contains(KEY_PLACEHOLDER) - { - return Err(format!( - "`kv_key_template` must contain the `{KEY_PLACEHOLDER}` placeholder" - )); - } - - if keys.is_empty() && kv_key_template.is_none() { - return Err("at least one of `keys` or `kv_key_template` must be configured".into()); - } - - Ok(Self { header, query_param, consumer_header, keys, kv_key_template }) - } - - fn invoke(&self, req: &Request<'_>) -> Response { - let Some(key) = self.extract_key(req) else { - return self.unauthorized("missing api key"); - }; - if let Some(consumer) = self.match_static(key.as_bytes()) { - return self.grant(consumer); - } - if let Some(consumer) = self.match_kv(req, &key) { - return self.grant(&consumer); - } - self.unauthorized("invalid api key") - } -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request view by hand. - - use ephpm_middleware::abi::{ACTION_RESPOND, ACTION_REWRITE}; - use ephpm_middleware::host::{RequestCtx, host_table, set_kv_store}; - - use super::*; - - fn api_key(config: serde_json::Value) -> ApiKey { - ApiKey::init(&config).expect("init") - } - - /// Invoke with headers and an optional query string against a fresh ctx. - fn invoke_q(mw: &ApiKey, query: &str, headers: &[(String, String)]) -> Response { - let ctx = RequestCtx::new("GET", "/api/x", query, "203.0.113.9", "example.test", headers); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) - } - - fn invoke(mw: &ApiKey, headers: &[(String, String)]) -> Response { - invoke_q(mw, "", headers) - } - - fn hdr(name: &str, value: &str) -> Vec<(String, String)> { - vec![(name.to_owned(), value.to_owned())] - } - - /// Wire a real in-memory Store into the host table (first call wins; all - /// tests in this binary share it) and seed one `apikey:*` entry via the - /// host's own `kv_set`. - fn setup_kv_with(entries: &[(&str, &str)]) { - set_kv_store(&ephpm_kv::store::Store::new(ephpm_kv::store::StoreConfig::default())); - let ctx = RequestCtx::new("GET", "/", "", "127.0.0.1", "seed", &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - for (k, v) in entries { - assert!(req.host().kv_set(k, v.as_bytes(), 0), "seed kv_set failed for {k}"); - } - } - - fn consumer_header(resp: &Response) -> Option { - resp.__headers() - .iter() - .find(|(n, _)| n.eq_ignore_ascii_case("X-Consumer-Id")) - .map(|(_, v)| v.clone()) - } - - fn assert_401(resp: &Response, body: &str) { - assert_eq!(resp.__action(), ACTION_RESPOND); - assert_eq!(resp.__status(), 401); - assert_eq!(resp.__body(), body.as_bytes()); - // Never leak the key; always hint via WWW-Authenticate. - assert!( - resp.__headers().iter().any(|(n, _)| n.eq_ignore_ascii_case("WWW-Authenticate")), - "401 must carry a WWW-Authenticate hint", - ); - } - - #[test] - fn ct_eq_is_correct() { - assert!(ct_eq(b"correct-key", b"correct-key")); - assert!(!ct_eq(b"correct-key", b"correct-keZ")); - assert!(!ct_eq(b"correct-key", b"correct-ke")); // length mismatch - assert!(ct_eq(b"", b"")); - assert!(!ct_eq(b"a", b"")); - } - - #[test] - fn init_requires_a_store() { - // No keys and no KV template → misconfiguration. - assert!(ApiKey::init(&serde_json::json!({})).is_err()); - assert!(ApiKey::init(&serde_json::json!({ "header": "X-Api-Key" })).is_err()); - // `keys` must be an object; entries must be string consumer-ids. - assert!(ApiKey::init(&serde_json::json!({ "keys": "nope" })).is_err()); - assert!(ApiKey::init(&serde_json::json!({ "keys": { "k": 42 } })).is_err()); - // `kv_key_template` must contain the placeholder. - assert!(ApiKey::init(&serde_json::json!({ "kv_key_template": "apikey:" })).is_err()); - // Valid minimal configs. - assert!(ApiKey::init(&serde_json::json!({ "keys": { "k": "c" } })).is_ok()); - assert!(ApiKey::init(&serde_json::json!({ "kv_key_template": "apikey:" })).is_ok()); - } - - #[test] - fn valid_static_key_rewrites_with_consumer() { - let mw = api_key(serde_json::json!({ "keys": { "secret-abc": "consumer-7" } })); - let resp = invoke(&mw, &hdr("X-Api-Key", "secret-abc")); - assert_eq!(resp.__action(), ACTION_REWRITE); - assert_eq!(consumer_header(&resp).as_deref(), Some("consumer-7")); - } - - #[test] - fn invalid_static_key_is_401() { - let mw = api_key(serde_json::json!({ "keys": { "secret-abc": "consumer-7" } })); - assert_401(&invoke(&mw, &hdr("X-Api-Key", "wrong")), "invalid api key"); - } - - #[test] - fn missing_key_is_401() { - let mw = api_key(serde_json::json!({ "keys": { "secret-abc": "consumer-7" } })); - assert_401(&invoke(&mw, &[]), "missing api key"); - // Present-but-empty header also counts as missing. - assert_401(&invoke(&mw, &hdr("X-Api-Key", " ")), "missing api key"); - } - - #[test] - fn custom_header_and_consumer_header() { - let mw = api_key(serde_json::json!({ - "header": "X-Key", - "consumer_header": "X-Who", - "keys": { "k1": "alice" }, - })); - let resp = invoke(&mw, &hdr("X-Key", "k1")); - assert_eq!(resp.__action(), ACTION_REWRITE); - let who = resp - .__headers() - .iter() - .find(|(n, _)| n.eq_ignore_ascii_case("X-Who")) - .map(|(_, v)| v.as_str()); - assert_eq!(who, Some("alice")); - } - - #[test] - fn query_param_disabled_by_default() { - let mw = api_key(serde_json::json!({ "keys": { "qk": "qc" } })); - // Key only in the query string, but query_param is off → 401 missing. - assert_401(&invoke_q(&mw, "api_key=qk", &[]), "missing api key"); - } - - #[test] - fn query_param_when_enabled() { - let mw = api_key(serde_json::json!({ - "query_param": "api_key", - "keys": { "qk": "qc" }, - })); - let resp = invoke_q(&mw, "foo=1&api_key=qk&bar=2", &[]); - assert_eq!(resp.__action(), ACTION_REWRITE); - assert_eq!(consumer_header(&resp).as_deref(), Some("qc")); - // Header still takes precedence over the query param. - let resp = invoke_q(&mw, "api_key=wrong", &hdr("X-Api-Key", "qk")); - assert_eq!(resp.__action(), ACTION_REWRITE); - assert_eq!(consumer_header(&resp).as_deref(), Some("qc")); - // URL-encoded value round-trips. - let mw2 = api_key(serde_json::json!({ - "query_param": "api_key", - "keys": { "a b": "spaced" }, - })); - let resp = invoke_q(&mw2, "api_key=a%20b", &[]); - assert_eq!(consumer_header(&resp).as_deref(), Some("spaced")); - } - - #[test] - fn kv_backed_valid_and_invalid() { - setup_kv_with(&[("apikey:live-key", "kv-consumer-1")]); - let mw = api_key(serde_json::json!({ "kv_key_template": "apikey:" })); - // Valid: value in the store is the consumer id. - let resp = invoke(&mw, &hdr("X-Api-Key", "live-key")); - assert_eq!(resp.__action(), ACTION_REWRITE); - assert_eq!(consumer_header(&resp).as_deref(), Some("kv-consumer-1")); - // Absent key → 401 invalid. - assert_401(&invoke(&mw, &hdr("X-Api-Key", "no-such-key")), "invalid api key"); - } - - #[test] - fn static_map_takes_precedence_then_kv() { - setup_kv_with(&[("apikey:kv-only", "from-kv")]); - let mw = api_key(serde_json::json!({ - "keys": { "static-only": "from-static" }, - "kv_key_template": "apikey:", - })); - // Static hit. - assert_eq!( - consumer_header(&invoke(&mw, &hdr("X-Api-Key", "static-only"))).as_deref(), - Some("from-static"), - ); - // Falls through to KV. - assert_eq!( - consumer_header(&invoke(&mw, &hdr("X-Api-Key", "kv-only"))).as_deref(), - Some("from-kv"), - ); - } -} diff --git a/crates/ephpm-middleware-modules/src/cors.rs b/crates/ephpm-middleware-modules/src/cors.rs deleted file mode 100644 index 9aa8e3c..0000000 --- a/crates/ephpm-middleware-modules/src/cors.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! `cors` — ePHPm native middleware implementing CORS. -//! -//! Preflight requests (`OPTIONS` with `Access-Control-Request-Method`) from -//! an allowed origin are answered directly with `204` — PHP never runs. -//! Other requests from an allowed origin `CONTINUE` to PHP with -//! `Access-Control-Allow-Origin` (and friends) appended to the eventual -//! response. Requests without an `Origin` header, or from an origin not in -//! the allow list, pass through untouched (per spec: no CORS headers). -//! -//! Configuration (`[[middleware]] config = { ... }`): -//! -//! | key | default | meaning | -//! |-----|---------|---------| -//! | `allow_origins` (array of strings) | **required** | allowed origins; `"*"` allows all | -//! | `allow_methods` (string) | `"GET, POST, PUT, PATCH, DELETE, OPTIONS"` | preflight `Access-Control-Allow-Methods` | -//! | `allow_headers` (string) | `"Content-Type, Authorization"` | preflight `Access-Control-Allow-Headers` | -//! | `allow_credentials` (bool) | `false` | emit `Access-Control-Allow-Credentials: true` (and echo the origin instead of `*`) | -//! | `max_age` (integer seconds) | `86400` | preflight `Access-Control-Max-Age` | - -use ephpm_middleware::{Middleware, Request, Response}; - -/// Default `Access-Control-Allow-Methods` value. -const DEFAULT_METHODS: &str = "GET, POST, PUT, PATCH, DELETE, OPTIONS"; -/// Default `Access-Control-Allow-Headers` value. -const DEFAULT_HEADERS: &str = "Content-Type, Authorization"; - -/// CORS policy, built once at `init`. -pub struct Cors { - allow_origins: Vec, - /// True when `allow_origins` contains `"*"`. - wildcard: bool, - allow_methods: String, - allow_headers: String, - allow_credentials: bool, - max_age: u64, -} - -impl Cors { - /// The `Access-Control-Allow-Origin` value for an allowed `origin`. - /// Credentialed responses must echo the origin — `*` is forbidden there. - fn allow_origin_value<'a>(&'a self, origin: &'a str) -> &'a str { - if self.wildcard && !self.allow_credentials { "*" } else { origin } - } -} - -impl Middleware for Cors { - fn init(config: &serde_json::Value) -> Result { - let origins = config - .get("allow_origins") - .ok_or("`allow_origins` is required (array of origins; \"*\" allows all)")?; - let origins = origins - .as_array() - .ok_or_else(|| format!("`allow_origins` must be an array, got {origins}"))?; - let allow_origins: Vec = origins - .iter() - .map(|v| { - v.as_str() - .map(str::to_owned) - .ok_or_else(|| format!("`allow_origins` entries must be strings, got {v}")) - }) - .collect::>()?; - if allow_origins.is_empty() { - return Err("`allow_origins` must not be empty".into()); - } - - let string_or = |key: &str, default: &str| -> Result { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(default.to_owned()), - Some(serde_json::Value::String(s)) => Ok(s.clone()), - Some(other) => Err(format!("`{key}` must be a string, got {other}")), - } - }; - let allow_credentials = match config.get("allow_credentials") { - None | Some(serde_json::Value::Null) => false, - Some(serde_json::Value::Bool(b)) => *b, - Some(other) => { - return Err(format!("`allow_credentials` must be a boolean, got {other}")); - } - }; - let max_age = match config.get("max_age") { - None | Some(serde_json::Value::Null) => 86_400, - Some(v) => v - .as_u64() - .ok_or_else(|| format!("`max_age` must be a non-negative integer, got {v}"))?, - }; - - Ok(Self { - wildcard: allow_origins.iter().any(|o| o == "*"), - allow_origins, - allow_methods: string_or("allow_methods", DEFAULT_METHODS)?, - allow_headers: string_or("allow_headers", DEFAULT_HEADERS)?, - allow_credentials, - max_age, - }) - } - - fn invoke(&self, req: &Request<'_>) -> Response { - // Not a cross-origin request: nothing to do. - let Some(origin) = req.header("Origin") else { - return Response::cont(); - }; - // Origin not allowed: per spec, simply omit the CORS headers — the - // browser enforces the failure; the server stays silent. - if !self.wildcard && !self.allow_origins.iter().any(|o| o == origin) { - return Response::cont(); - } - let allow_origin = self.allow_origin_value(origin); - - // Preflight: answer directly, PHP never runs. - if req.method().eq_ignore_ascii_case("OPTIONS") - && req.header("Access-Control-Request-Method").is_some() - { - let mut r = Response::respond(204, "") - .header("Access-Control-Allow-Origin", allow_origin) - .header("Access-Control-Allow-Methods", self.allow_methods.as_str()) - .header("Access-Control-Allow-Headers", self.allow_headers.as_str()) - .header("Access-Control-Max-Age", self.max_age.to_string()) - .header("Vary", "Origin"); - if self.allow_credentials { - r = r.header("Access-Control-Allow-Credentials", "true"); - } - return r; - } - - // Actual request: continue to PHP, appending the CORS headers to the - // eventual response. - let mut r = Response::cont() - .response_header("Access-Control-Allow-Origin", allow_origin) - .response_header("Vary", "Origin"); - if self.allow_credentials { - r = r.response_header("Access-Control-Allow-Credentials", "true"); - } - r - } -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request view by hand. - - use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_RESPOND}; - use ephpm_middleware::host::{RequestCtx, host_table}; - - use super::*; - - fn cors(config: serde_json::Value) -> Cors { - Cors::init(&config).expect("init") - } - - fn invoke(mw: &Cors, method: &str, headers: &[(String, String)]) -> Response { - let ctx = RequestCtx::new(method, "/api/x", "", "203.0.113.9", "example.test", headers); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) - } - - fn hdr(name: &str, value: &str) -> (String, String) { - (name.to_owned(), value.to_owned()) - } - - fn find<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { - headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) - } - - #[test] - fn init_requires_allow_origins() { - assert!(Cors::init(&serde_json::Value::Null).is_err()); - assert!(Cors::init(&serde_json::json!({ "allow_origins": [] })).is_err()); - assert!(Cors::init(&serde_json::json!({ "allow_origins": "https://a" })).is_err()); - } - - #[test] - fn no_origin_header_passes_through() { - let mw = cors(serde_json::json!({ "allow_origins": ["*"] })); - let resp = invoke(&mw, "GET", &[]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - assert!(resp.__response_headers().is_empty()); - } - - #[test] - fn disallowed_origin_gets_no_cors_headers() { - let mw = cors(serde_json::json!({ "allow_origins": ["https://good.test"] })); - let resp = invoke(&mw, "GET", &[hdr("Origin", "https://evil.test")]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - assert!(resp.__response_headers().is_empty()); - } - - #[test] - fn allowed_origin_is_echoed_on_actual_request() { - let mw = cors(serde_json::json!({ "allow_origins": ["https://good.test"] })); - let resp = invoke(&mw, "GET", &[hdr("Origin", "https://good.test")]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - let rh = resp.__response_headers(); - assert_eq!(find(rh, "Access-Control-Allow-Origin"), Some("https://good.test")); - assert_eq!(find(rh, "Vary"), Some("Origin")); - assert_eq!(find(rh, "Access-Control-Allow-Credentials"), None); - } - - #[test] - fn wildcard_origin_without_credentials_is_star() { - let mw = cors(serde_json::json!({ "allow_origins": ["*"] })); - let resp = invoke(&mw, "GET", &[hdr("Origin", "https://any.test")]); - assert_eq!(find(resp.__response_headers(), "Access-Control-Allow-Origin"), Some("*")); - } - - #[test] - fn wildcard_with_credentials_echoes_the_origin() { - let mw = cors(serde_json::json!({ "allow_origins": ["*"], "allow_credentials": true })); - let resp = invoke(&mw, "GET", &[hdr("Origin", "https://any.test")]); - let rh = resp.__response_headers(); - assert_eq!(find(rh, "Access-Control-Allow-Origin"), Some("https://any.test")); - assert_eq!(find(rh, "Access-Control-Allow-Credentials"), Some("true")); - } - - #[test] - fn preflight_responds_204_with_policy_headers() { - let mw = cors(serde_json::json!({ - "allow_origins": ["https://good.test"], - "max_age": 600, - })); - let resp = invoke( - &mw, - "OPTIONS", - &[hdr("Origin", "https://good.test"), hdr("Access-Control-Request-Method", "PUT")], - ); - assert_eq!(resp.__action(), ACTION_RESPOND); - assert_eq!(resp.__status(), 204); - let h = resp.__headers(); - assert_eq!(find(h, "Access-Control-Allow-Origin"), Some("https://good.test")); - assert_eq!(find(h, "Access-Control-Allow-Methods"), Some(DEFAULT_METHODS)); - assert_eq!(find(h, "Access-Control-Allow-Headers"), Some(DEFAULT_HEADERS)); - assert_eq!(find(h, "Access-Control-Max-Age"), Some("600")); - assert_eq!(find(h, "Vary"), Some("Origin")); - } - - #[test] - fn options_without_request_method_is_not_a_preflight() { - let mw = cors(serde_json::json!({ "allow_origins": ["*"] })); - let resp = invoke(&mw, "OPTIONS", &[hdr("Origin", "https://any.test")]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } -} diff --git a/crates/ephpm-middleware-modules/src/header_transform.rs b/crates/ephpm-middleware-modules/src/header_transform.rs deleted file mode 100644 index 1825552..0000000 --- a/crates/ephpm-middleware-modules/src/header_transform.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! `header-transform` — ePHPm native middleware that rewrites request headers -//! seen by PHP and response headers sent to the client. -//! -//! Analogous to Traefik's `headers` (`customRequestHeaders` / -//! `customResponseHeaders`), Kong's request/response transformer, or nginx's -//! `proxy_set_header` / `add_header` / `more_clear_headers`. -//! -//! # Two phases -//! -//! - **Request phase** ([`Middleware::invoke`]) — set request headers before -//! PHP runs (PHP reads them as `$_SERVER['HTTP_']`). -//! - **Response phase** ([`ResponseMiddleware::invoke_response`]) — set or -//! remove response headers on the way out, on **every** response (PHP, -//! static file, error page). -//! -//! Configuration (`[[middleware]] config = { ... }`), all optional: -//! -//! ```toml -//! [middleware.config.request] -//! set = { "X-Env" = "prod", "X-Tenant" = "acme" } -//! -//! [middleware.config.response] -//! set = { "X-Served-By" = "ephpm" } -//! remove = ["Server", "X-Powered-By"] -//! ``` -//! -//! | section | key | effect | -//! |---------|-----|--------| -//! | `request` | `set` (object) | replace-or-add each request header PHP sees | -//! | `response` | `set` (object) | replace-or-add each response header | -//! | `response` | `remove` (array) | delete each response header (case-insensitive) | -//! -//! # v1 ABI scope (why there is no `add` or request `remove`) -//! -//! The host applies both request-header overrides and response `set` as -//! **replace-or-add** (one occurrence, case-insensitive) — there is no -//! duplicate-append primitive in the v1 middleware ABI, so `add` and `set` -//! would be identical; only `set` is offered. And the request phase can only -//! *override* a request header, not delete one, so request-side `remove` is -//! not offered (it would be a silent no-op). Header **removal is response-side -//! only**, where the ABI supports it. Both are honest reflections of the ABI, -//! not omissions. - -use ephpm_middleware::{Middleware, Request, Response, ResponseMiddleware, ResponseView}; - -/// A parsed set of `(name, value)` header assignments, order preserved. -type Assignments = Vec<(String, String)>; - -/// Header rewrite policy, built once at `init`. -pub struct HeaderTransform { - request_set: Assignments, - response_set: Assignments, - response_remove: Vec, -} - -/// Parse an optional `{ name: value, ... }` object into ordered string pairs. -/// Rejects non-string values and empty names. -fn parse_set(section: &serde_json::Value, path: &str) -> Result { - match section.get("set") { - None | Some(serde_json::Value::Null) => Ok(Vec::new()), - Some(serde_json::Value::Object(map)) => { - let mut out = Vec::with_capacity(map.len()); - for (name, value) in map { - if name.is_empty() { - return Err(format!("`{path}.set` has an empty header name")); - } - let v = value.as_str().ok_or_else(|| { - format!("`{path}.set` values must be strings, got {value} for `{name}`") - })?; - out.push((name.clone(), v.to_owned())); - } - Ok(out) - } - Some(other) => Err(format!("`{path}.set` must be an object, got {other}")), - } -} - -/// Parse an optional `["Name", ...]` array of header names to remove. -fn parse_remove(section: &serde_json::Value, path: &str) -> Result, String> { - match section.get("remove") { - None | Some(serde_json::Value::Null) => Ok(Vec::new()), - Some(serde_json::Value::Array(items)) => { - let mut out = Vec::with_capacity(items.len()); - for item in items { - let name = item.as_str().ok_or_else(|| { - format!("`{path}.remove` entries must be strings, got {item}") - })?; - if name.is_empty() { - return Err(format!("`{path}.remove` has an empty header name")); - } - out.push(name.to_owned()); - } - Ok(out) - } - Some(other) => Err(format!("`{path}.remove` must be an array, got {other}")), - } -} - -/// Fetch a top-level section object (`request` / `response`), defaulting to a -/// JSON null (an empty section) when absent. Rejects a non-object section. -fn section<'a>(config: &'a serde_json::Value, key: &str) -> Result<&'a serde_json::Value, String> { - const NULL: serde_json::Value = serde_json::Value::Null; - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(&NULL), - Some(v @ serde_json::Value::Object(_)) => Ok(v), - Some(other) => Err(format!("`{key}` must be an object, got {other}")), - } -} - -impl Middleware for HeaderTransform { - fn init(config: &serde_json::Value) -> Result { - let request = section(config, "request")?; - let response = section(config, "response")?; - - // `request.remove` cannot be honored (see module docs); reject it loudly - // rather than silently ignore it. - if request.get("remove").is_some_and(|v| !v.is_null()) { - return Err("`request.remove` is not supported: the v1 ABI request phase can \ - only set/override request headers, not delete them" - .into()); - } - - Ok(Self { - request_set: parse_set(request, "request")?, - response_set: parse_set(response, "response")?, - response_remove: parse_remove(response, "response")?, - }) - } - - fn invoke(&self, _req: &Request<'_>) -> Response { - if self.request_set.is_empty() { - return Response::cont(); - } - let mut r = Response::rewrite(); - for (name, value) in &self.request_set { - r = r.header(name.clone(), value.clone()); - } - r - } -} - -impl ResponseMiddleware for HeaderTransform { - fn invoke_response(&self, _req: &Request<'_>, resp: &mut ResponseView<'_>) { - for name in &self.response_remove { - resp.remove_header(name.clone()); - } - for (name, value) in &self.response_set { - resp.set_header(name.clone(), value.clone()); - } - } -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request / Response views by hand. - - use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_REWRITE}; - use ephpm_middleware::host::{RequestCtx, ResponseCtx, host_table}; - - use super::*; - - fn init(config: serde_json::Value) -> HeaderTransform { - HeaderTransform::init(&config).expect("init") - } - - fn hdr(name: &str, value: &str) -> (String, String) { - (name.to_owned(), value.to_owned()) - } - - fn invoke(mw: &HeaderTransform) -> Response { - let ctx = RequestCtx::new("GET", "/index.php", "", "203.0.113.9", "example.test", &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) - } - - fn invoke_response( - mw: &HeaderTransform, - resp_headers: Vec<(String, String)>, - ) -> Vec<(String, String)> { - let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - let mut rctx = ResponseCtx::new(200, resp_headers, b"body".to_vec()); - { - // SAFETY: `rctx` outlives the view; host_table() is 'static. - let mut view = unsafe { ResponseView::from_raw(rctx.as_ptr(), host_table()) }; - mw.invoke_response(&req, &mut view); - let (status, body, set, remove) = view.__into_parts(); - for name in remove { - rctx.remove_header(&name); - } - for (n, v) in set { - rctx.set_header(&n, &v); - } - if let Some(s) = status { - rctx.set_status(s); - } - if let Some(b) = body { - rctx.replace_body(b); - } - } - let (_status, headers, _body) = rctx.into_parts(); - headers - } - - fn get<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { - headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) - } - - // ── request phase ───────────────────────────────────────────────────── - - #[test] - fn request_set_injects_headers() { - let mw = init(serde_json::json!({ - "request": { "set": { "X-Env": "prod", "X-Tenant": "acme" } } - })); - let resp = invoke(&mw); - assert_eq!(resp.__action(), ACTION_REWRITE); - assert_eq!(get(resp.__headers(), "X-Env"), Some("prod")); - assert_eq!(get(resp.__headers(), "X-Tenant"), Some("acme")); - } - - #[test] - fn no_request_config_continues() { - let mw = init(serde_json::json!({ - "response": { "set": { "X-A": "b" } } - })); - assert_eq!(invoke(&mw).__action(), ACTION_CONTINUE); - } - - // ── response phase ──────────────────────────────────────────────────── - - #[test] - fn response_set_replaces_or_adds() { - let mw = init(serde_json::json!({ - "response": { "set": { "X-Served-By": "ephpm", "Content-Type": "text/plain" } } - })); - let out = invoke_response(&mw, vec![hdr("Content-Type", "text/html")]); - assert_eq!(get(&out, "X-Served-By"), Some("ephpm")); - // Replace, not duplicate. - assert_eq!(get(&out, "Content-Type"), Some("text/plain")); - assert_eq!(out.iter().filter(|(n, _)| n.eq_ignore_ascii_case("Content-Type")).count(), 1); - } - - #[test] - fn response_remove_deletes() { - let mw = init(serde_json::json!({ - "response": { "remove": ["Server", "X-Powered-By"] } - })); - let out = invoke_response( - &mw, - vec![hdr("Server", "nginx"), hdr("X-Powered-By", "PHP/8.5"), hdr("X-Keep", "1")], - ); - assert_eq!(get(&out, "Server"), None); - assert_eq!(get(&out, "X-Powered-By"), None); - assert_eq!(get(&out, "X-Keep"), Some("1")); - } - - #[test] - fn remove_then_set_same_header_nets_set() { - let mw = init(serde_json::json!({ - "response": { "set": { "Server": "ephpm" }, "remove": ["Server"] } - })); - let out = invoke_response(&mw, vec![hdr("Server", "nginx")]); - assert_eq!(get(&out, "Server"), Some("ephpm")); - assert_eq!(out.iter().filter(|(n, _)| n.eq_ignore_ascii_case("Server")).count(), 1); - } - - // ── config validation ───────────────────────────────────────────────── - - #[test] - fn request_remove_is_rejected() { - assert!( - HeaderTransform::init(&serde_json::json!({ - "request": { "remove": ["X-Foo"] } - })) - .is_err() - ); - } - - #[test] - fn bad_config_fails_init() { - assert!( - HeaderTransform::init(&serde_json::json!({ "request": { "set": { "X": 1 } } })) - .is_err() - ); - assert!( - HeaderTransform::init(&serde_json::json!({ "response": { "remove": [42] } })).is_err() - ); - assert!(HeaderTransform::init(&serde_json::json!({ "request": "nope" })).is_err()); - assert!(HeaderTransform::init(&serde_json::json!({ "response": { "set": [] } })).is_err()); - } - - #[test] - fn empty_config_is_a_noop() { - let mw = init(serde_json::Value::Null); - assert_eq!(invoke(&mw).__action(), ACTION_CONTINUE); - let out = invoke_response(&mw, vec![hdr("X-A", "b")]); - assert_eq!(get(&out, "X-A"), Some("b")); - } -} diff --git a/crates/ephpm-middleware-modules/src/ip_allowlist.rs b/crates/ephpm-middleware-modules/src/ip_allowlist.rs deleted file mode 100644 index 1fffdde..0000000 --- a/crates/ephpm-middleware-modules/src/ip_allowlist.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! `ip-allowlist` — ePHPm native middleware that allows or denies requests by -//! client IP against CIDR lists. The analog of Traefik's `ipallowlist`, Kong's -//! `ip-restriction`, and nginx `allow`/`deny`. -//! -//! The client IP is taken from [`Request::remote_ip`], which the host has -//! already resolved through the trusted-proxy configuration — this module -//! never parses `X-Forwarded-For` itself. -//! -//! Decision order (a request is evaluated exactly once): -//! -//! 1. If the client IP matches any `deny` CIDR → **`403`** (deny wins over -//! allow, always). -//! 2. Else if it matches any `allow` CIDR → **`CONTINUE`**. -//! 3. Else apply the `default` policy (`"allow"` → CONTINUE, `"deny"` → 403). -//! -//! **Fail-closed security gate.** This is an access-control filter, so it fails -//! closed, in two senses: -//! -//! * **Config errors fail startup.** A malformed CIDR, a non-array `allow`/ -//! `deny`, or an unknown `default` value makes `init` return `Err`, which the -//! host treats as a hard startup failure — the module never loads half-parsed. -//! * **An unparseable client IP is denied.** If `remote_ip()` is empty or not a -//! valid address it cannot match `allow`, so it is rejected with `403` unless -//! `default = "allow"` explicitly opens the gate. -//! -//! Request-phase only: the verdict is decided before PHP runs and this module -//! touches no response-phase API. -//! -//! ## Configuration (`[[middleware]] config = { ... }`) -//! -//! | key | default | meaning | -//! |-----|---------|---------| -//! | `allow` (array of CIDR strings) | `[]` | client IPs allowed through; v4 and v6, e.g. `"10.0.0.0/8"`, `"2001:db8::/32"`. A bare address is a `/32` (v4) or `/128` (v6). | -//! | `deny` (array of CIDR strings) | `[]` | client IPs rejected with `403`; takes precedence over `allow`. | -//! | `default` (string) | `"deny"` | verdict when no rule matches: `"allow"` or `"deny"`. | -//! -//! ### Scope -//! -//! Configuration is per `[[middleware]]` block. Like the sibling modules -//! (`cors`, `security-headers`), the policy is read once at `init` and applied -//! to every request the block sees; the module does not itself branch on -//! [`Request::vhost_id`]. Per-site policies are expressed the same way per-site -//! behaviour is expressed for the other modules — by scoping the `[[middleware]]` -//! block to a site in the host configuration — not by a per-vhost config map -//! inside this module (v1). - -use std::net::IpAddr; - -use ephpm_middleware::{Middleware, Request, Response}; -use ipnetwork::IpNetwork; - -/// Plain-text body returned on a `403` denial. -const DENIED_BODY: &str = "Forbidden: your IP address is not permitted."; - -/// The verdict applied when a client IP matches neither list. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum DefaultPolicy { - /// Continue to PHP. - Allow, - /// Reject with `403`. - Deny, -} - -/// IP allow/deny policy, built once at `init`. -pub struct IpAllowlist { - allow: Vec, - deny: Vec, - default: DefaultPolicy, -} - -/// Parse an optional array-of-CIDR config key into networks. A missing or null -/// key is an empty list; any non-array, non-string, or unparseable entry is a -/// hard error (fail-closed at startup). -fn parse_cidrs(config: &serde_json::Value, key: &str) -> Result, String> { - let arr = match config.get(key) { - None | Some(serde_json::Value::Null) => return Ok(Vec::new()), - Some(serde_json::Value::Array(a)) => a, - Some(other) => { - return Err(format!("`{key}` must be an array of CIDR strings, got {other}")); - } - }; - arr.iter() - .map(|v| { - let s = v - .as_str() - .ok_or_else(|| format!("`{key}` entries must be CIDR strings, got {v}"))?; - s.parse::() - .map_err(|e| format!("`{key}` entry {s:?} is not a valid CIDR: {e}")) - }) - .collect() -} - -impl IpAllowlist { - /// True when `ip` falls inside any network in `nets`. - fn matches(nets: &[IpNetwork], ip: IpAddr) -> bool { - nets.iter().any(|n| n.contains(ip)) - } -} - -impl Middleware for IpAllowlist { - fn init(config: &serde_json::Value) -> Result { - let allow = parse_cidrs(config, "allow")?; - let deny = parse_cidrs(config, "deny")?; - let default = match config.get("default") { - None | Some(serde_json::Value::Null) => DefaultPolicy::Deny, - Some(serde_json::Value::String(s)) => match s.as_str() { - "allow" => DefaultPolicy::Allow, - "deny" => DefaultPolicy::Deny, - other => { - return Err(format!("`default` must be \"allow\" or \"deny\", got {other:?}")); - } - }, - Some(other) => { - return Err(format!("`default` must be \"allow\" or \"deny\", got {other}")); - } - }; - Ok(Self { allow, deny, default }) - } - - fn invoke(&self, req: &Request<'_>) -> Response { - // The host has already applied trusted-proxy resolution; parse the - // client address. An unparseable/empty IP cannot match `allow`, so it - // is treated as unmatched and subject to the fail-closed default below. - let parsed = req.remote_ip().parse::().ok(); - - if let Some(ip) = parsed { - // Deny always wins. - if Self::matches(&self.deny, ip) { - return denied(); - } - if Self::matches(&self.allow, ip) { - return Response::cont(); - } - } - - match self.default { - DefaultPolicy::Allow => Response::cont(), - DefaultPolicy::Deny => denied(), - } - } -} - -/// The `403` response with a small plain-text body. -fn denied() -> Response { - Response::respond(403, DENIED_BODY).header("Content-Type", "text/plain; charset=utf-8") -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request view by hand. - - use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_RESPOND}; - use ephpm_middleware::host::{RequestCtx, host_table}; - - use super::*; - - fn build(config: serde_json::Value) -> IpAllowlist { - IpAllowlist::init(&config).expect("init") - } - - fn invoke(mw: &IpAllowlist, ip: &str) -> Response { - let ctx = RequestCtx::new("GET", "/index.php", "", ip, "example.test", &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) - } - - #[test] - fn ipv4_allow_and_default_deny() { - let mw = build(serde_json::json!({ "allow": ["10.0.0.0/8"] })); - // In-range → continue. - assert_eq!(invoke(&mw, "10.1.2.3").__action(), ACTION_CONTINUE); - // Out-of-range → default deny → 403. - let resp = invoke(&mw, "192.168.1.1"); - assert_eq!(resp.__action(), ACTION_RESPOND); - assert_eq!(resp.__status(), 403); - assert_eq!(resp.__body(), DENIED_BODY.as_bytes()); - } - - #[test] - fn ipv6_allow_matches() { - let mw = build(serde_json::json!({ "allow": ["2001:db8::/32"] })); - assert_eq!(invoke(&mw, "2001:db8::dead:beef").__action(), ACTION_CONTINUE); - assert_eq!(invoke(&mw, "2001:dead::1").__action(), ACTION_RESPOND); - } - - #[test] - fn cidr_boundary_is_respected() { - // /24 covers .0–.255 only. - let mw = build(serde_json::json!({ "allow": ["203.0.113.0/24"], "default": "deny" })); - assert_eq!(invoke(&mw, "203.0.113.255").__action(), ACTION_CONTINUE); - assert_eq!(invoke(&mw, "203.0.114.0").__action(), ACTION_RESPOND); - // A /32 host route: exact match only. - let host = mw2_host(); - assert_eq!(invoke(&host, "198.51.100.7").__action(), ACTION_CONTINUE); - assert_eq!(invoke(&host, "198.51.100.8").__action(), ACTION_RESPOND); - } - - fn mw2_host() -> IpAllowlist { - build(serde_json::json!({ "allow": ["198.51.100.7/32"] })) - } - - #[test] - fn deny_takes_precedence_over_allow() { - // Same address is in both lists — deny must win. - let mw = build(serde_json::json!({ - "allow": ["10.0.0.0/8"], - "deny": ["10.6.6.6/32"], - "default": "allow", - })); - assert_eq!(invoke(&mw, "10.6.6.6").__action(), ACTION_RESPOND); - // A different in-allow address still continues. - assert_eq!(invoke(&mw, "10.6.6.7").__action(), ACTION_CONTINUE); - // A broader deny range wins over a narrower allow. - let broad = build(serde_json::json!({ - "allow": ["192.168.1.0/24"], - "deny": ["192.168.0.0/16"], - })); - assert_eq!(invoke(&broad, "192.168.1.50").__action(), ACTION_RESPOND); - } - - #[test] - fn default_allow_lets_unmatched_through() { - let mw = build(serde_json::json!({ "deny": ["10.0.0.0/8"], "default": "allow" })); - // Not denied, not in any allow list → default allow. - assert_eq!(invoke(&mw, "8.8.8.8").__action(), ACTION_CONTINUE); - // Still denied when in the deny list. - assert_eq!(invoke(&mw, "10.0.0.1").__action(), ACTION_RESPOND); - } - - #[test] - fn default_deny_is_the_default_when_unset() { - // No allow, no deny, no default: everything is denied (fail-closed). - let mw = build(serde_json::json!({})); - assert_eq!(invoke(&mw, "8.8.8.8").__action(), ACTION_RESPOND); - assert_eq!(invoke(&mw, "10.0.0.1").__action(), ACTION_RESPOND); - } - - #[test] - fn unparseable_client_ip_is_denied_by_default() { - let mw = build(serde_json::json!({ "allow": ["0.0.0.0/0"] })); - // A valid any-v4 address is allowed… - assert_eq!(invoke(&mw, "1.2.3.4").__action(), ACTION_CONTINUE); - // …but a garbage/empty IP cannot match allow → fail closed. - assert_eq!(invoke(&mw, "not-an-ip").__action(), ACTION_RESPOND); - assert_eq!(invoke(&mw, "").__action(), ACTION_RESPOND); - } - - #[test] - fn unparseable_client_ip_follows_default_allow() { - // With default=allow, an unparseable IP that hits no deny rule passes. - let mw = build(serde_json::json!({ "deny": ["10.0.0.0/8"], "default": "allow" })); - assert_eq!(invoke(&mw, "not-an-ip").__action(), ACTION_CONTINUE); - } - - #[test] - fn malformed_config_fails_init_closed() { - // Bad CIDR string. - assert!(IpAllowlist::init(&serde_json::json!({ "allow": ["10.0.0.0/999"] })).is_err()); - assert!(IpAllowlist::init(&serde_json::json!({ "deny": ["nonsense"] })).is_err()); - // Wrong types. - assert!(IpAllowlist::init(&serde_json::json!({ "allow": "10.0.0.0/8" })).is_err()); - assert!(IpAllowlist::init(&serde_json::json!({ "allow": [42] })).is_err()); - // Unknown default policy. - assert!(IpAllowlist::init(&serde_json::json!({ "default": "maybe" })).is_err()); - assert!(IpAllowlist::init(&serde_json::json!({ "default": true })).is_err()); - } -} diff --git a/crates/ephpm-middleware-modules/src/jwt.rs b/crates/ephpm-middleware-modules/src/jwt.rs deleted file mode 100644 index 4bb1545..0000000 --- a/crates/ephpm-middleware-modules/src/jwt.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! `jwt` — ePHPm native middleware validating HS256 JWT bearer tokens -//! before PHP runs. -//! -//! v1 supports **HS256 only** (HMAC-SHA256 via the `hmac`/`sha2` crates — no -//! heavyweight JWT dependency). A missing or malformed token short-circuits -//! with `401`; a valid token continues to PHP, optionally forwarding the raw -//! claims JSON in a request header (`claims_header`) so PHP can read them -//! without re-verifying. -//! -//! Verification: constant-time HMAC check (`hmac::Mac::verify_slice`), the -//! token's `alg` must be `HS256`, `exp` is **required** and must be in the -//! future, `nbf` is honoured when present, and `iss`/`aud` are enforced when -//! configured. -//! -//! Configuration (`[[middleware]] config = { ... }`): -//! -//! | key | default | meaning | -//! |-----|---------|---------| -//! | `secret` (string) | **required** | HS256 shared secret | -//! | `issuer` (string) | unset | required `iss` claim value | -//! | `audience` (string) | unset | required `aud` claim value (string or array member) | -//! | `header` (string) | `"Authorization"` | request header carrying the token; a `Bearer ` prefix is stripped | -//! | `claims_header` (string) | unset | when set, REWRITE with this request header = the raw claims JSON | - -use std::time::{SystemTime, UNIX_EPOCH}; - -use base64ct::{Base64UrlUnpadded, Encoding}; -use ephpm_middleware::{Middleware, Request, Response}; -use hmac::{Hmac, Mac}; -use sha2::Sha256; - -/// JWT validation policy, built once at `init`. -pub struct Jwt { - secret: Vec, - issuer: Option, - audience: Option, - header: String, - claims_header: Option, -} - -/// Strip an optional (case-insensitive) `Bearer` prefix. A bare `Bearer` -/// with nothing after it yields the empty string (= missing token). -fn strip_bearer(value: &str) -> &str { - let trimmed = value.trim(); - if let (Some(scheme), Some(rest)) = (trimmed.get(..6), trimmed.get(6..)) - && scheme.eq_ignore_ascii_case("bearer") - && (rest.is_empty() || rest.starts_with(' ')) - { - return rest.trim_start(); - } - trimmed -} - -impl Jwt { - /// Verify `token` against this policy at time `now` (unix seconds). - /// Returns the raw claims JSON on success, `None` on any failure. - fn verify(&self, token: &str, now: u64) -> Option { - let mut parts = token.split('.'); - let (header_b64, payload_b64, sig_b64) = (parts.next()?, parts.next()?, parts.next()?); - if parts.next().is_some() { - return None; - } - - // Signature first — never parse unauthenticated JSON. - let sig = Base64UrlUnpadded::decode_vec(sig_b64).ok()?; - let mut mac = Hmac::::new_from_slice(&self.secret).ok()?; - mac.update(header_b64.as_bytes()); - mac.update(b"."); - mac.update(payload_b64.as_bytes()); - // Constant-time comparison via the hmac crate. - mac.verify_slice(&sig).ok()?; - - // The signature is ours, but still pin the algorithm: HS256 only. - let header: serde_json::Value = - serde_json::from_slice(&Base64UrlUnpadded::decode_vec(header_b64).ok()?).ok()?; - if header.get("alg").and_then(serde_json::Value::as_str) != Some("HS256") { - return None; - } - - let payload = Base64UrlUnpadded::decode_vec(payload_b64).ok()?; - let claims: serde_json::Value = serde_json::from_slice(&payload).ok()?; - - // `exp` is required — a token that cannot expire is a config bug. - // RFC 7519 NumericDate allows non-integer values, so accept a JSON - // float (floored) as well as an integer rather than 401-ing a valid - // token. - let exp = claims.get("exp").and_then(numeric_date)?; - if exp <= now { - return None; - } - if let Some(nbf) = claims.get("nbf") - && numeric_date(nbf)? > now - { - return None; - } - if let Some(expected) = &self.issuer - && claims.get("iss").and_then(serde_json::Value::as_str) != Some(expected.as_str()) - { - return None; - } - if let Some(expected) = &self.audience { - let ok = match claims.get("aud") { - Some(serde_json::Value::String(aud)) => aud == expected, - Some(serde_json::Value::Array(auds)) => { - auds.iter().any(|a| a.as_str() == Some(expected.as_str())) - } - _ => false, - }; - if !ok { - return None; - } - } - - String::from_utf8(payload).ok() - } -} - -/// Parse an RFC 7519 NumericDate claim (`exp`/`nbf`) as seconds since the -/// epoch. Accepts a JSON integer or a JSON float (floored to whole seconds, -/// negatives rejected); returns `None` for any other JSON type. This keeps -/// enforcement identical while tolerating the non-integer NumericDates the -/// spec permits. -fn numeric_date(v: &serde_json::Value) -> Option { - if let Some(u) = v.as_u64() { - return Some(u); - } - let f = v.as_f64()?; - // floor() keeps the "not valid until this whole second" semantics; only - // finite, non-negative values within u64 range map to a NumericDate. - if f.is_finite() && (0.0..18_446_744_073_709_551_616.0).contains(&f) { - #[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "bounds checked: finite, in [0, u64::MAX), floored" - )] - Some(f.floor() as u64) - } else { - None - } -} - -impl Middleware for Jwt { - fn init(config: &serde_json::Value) -> Result { - let secret = config - .get("secret") - .ok_or("`secret` is required (HS256 shared secret)")? - .as_str() - .ok_or("`secret` must be a string")?; - if secret.is_empty() { - return Err("`secret` must not be empty".into()); - } - let opt_str = |key: &str| -> Result, String> { - match config.get(key) { - Some(serde_json::Value::String(s)) if !s.is_empty() => Ok(Some(s.clone())), - None | Some(serde_json::Value::Null | serde_json::Value::String(_)) => Ok(None), - Some(other) => Err(format!("`{key}` must be a string, got {other}")), - } - }; - Ok(Self { - secret: secret.as_bytes().to_vec(), - issuer: opt_str("issuer")?, - audience: opt_str("audience")?, - header: opt_str("header")?.unwrap_or_else(|| "Authorization".to_owned()), - claims_header: opt_str("claims_header")?, - }) - } - - fn invoke(&self, req: &Request<'_>) -> Response { - let Some(raw) = req.header(&self.header) else { - return Response::respond(401, "missing bearer token"); - }; - let token = strip_bearer(raw); - if token.is_empty() { - return Response::respond(401, "missing bearer token"); - } - let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()); - match self.verify(token, now) { - Some(claims_json) => match &self.claims_header { - Some(name) => Response::rewrite().header(name.as_str(), claims_json), - None => Response::cont(), - }, - None => Response::respond(401, "invalid token"), - } - } -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request view by hand. - - use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_RESPOND, ACTION_REWRITE}; - use ephpm_middleware::host::{RequestCtx, host_table}; - - use super::*; - - const SECRET: &str = "test-secret-please-rotate"; - - /// Forge a token through the same HMAC code path the module verifies - /// with (independent of `Jwt::verify`'s parsing). - fn sign(secret: &str, header_json: &str, claims_json: &str) -> String { - let h = Base64UrlUnpadded::encode_string(header_json.as_bytes()); - let p = Base64UrlUnpadded::encode_string(claims_json.as_bytes()); - let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("hmac key"); - mac.update(format!("{h}.{p}").as_bytes()); - let sig = Base64UrlUnpadded::encode_string(&mac.finalize().into_bytes()); - format!("{h}.{p}.{sig}") - } - - fn token(secret: &str, claims_json: &str) -> String { - sign(secret, r#"{"alg":"HS256","typ":"JWT"}"#, claims_json) - } - - fn future_exp() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_secs() + 3600 - } - - fn jwt(config: serde_json::Value) -> Jwt { - Jwt::init(&config).expect("init") - } - - fn invoke(mw: &Jwt, headers: &[(String, String)]) -> Response { - let ctx = RequestCtx::new("GET", "/api/x", "", "203.0.113.9", "example.test", headers); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) - } - - fn bearer(token: &str) -> Vec<(String, String)> { - vec![("Authorization".to_owned(), format!("Bearer {token}"))] - } - - fn assert_401(resp: &Response, body: &str) { - assert_eq!(resp.__action(), ACTION_RESPOND); - assert_eq!(resp.__status(), 401); - assert_eq!(resp.__body(), body.as_bytes()); - } - - #[test] - fn init_requires_secret() { - assert!(Jwt::init(&serde_json::Value::Null).is_err()); - assert!(Jwt::init(&serde_json::json!({ "secret": "" })).is_err()); - assert!(Jwt::init(&serde_json::json!({ "secret": 42 })).is_err()); - } - - #[test] - fn valid_token_continues() { - let mw = jwt(serde_json::json!({ "secret": SECRET })); - let t = token(SECRET, &format!(r#"{{"sub":"u1","exp":{}}}"#, future_exp())); - let resp = invoke(&mw, &bearer(&t)); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - #[test] - fn float_exp_and_nbf_are_accepted() { - // RFC 7519 NumericDate allows non-integer values. A JSON float exp - // (and nbf) in the future must not be spuriously rejected. - let mw = jwt(serde_json::json!({ "secret": SECRET })); - let exp = future_exp(); - let claims = format!(r#"{{"sub":"u1","exp":{exp}.75,"nbf":{}.5}}"#, exp - 3700); - let resp = invoke(&mw, &bearer(&token(SECRET, &claims))); - assert_eq!(resp.__action(), ACTION_CONTINUE, "float exp/nbf must be honoured"); - - // An expired float exp is still rejected (floor keeps enforcement). - let expired = token(SECRET, r#"{"sub":"u1","exp":1000.9}"#); - assert_401(&invoke(&mw, &bearer(&expired)), "invalid token"); - } - - #[test] - fn valid_token_with_claims_header_rewrites() { - let mw = jwt(serde_json::json!({ "secret": SECRET, "claims_header": "X-Jwt-Claims" })); - let claims = format!(r#"{{"sub":"u1","exp":{}}}"#, future_exp()); - let resp = invoke(&mw, &bearer(&token(SECRET, &claims))); - assert_eq!(resp.__action(), ACTION_REWRITE); - let forwarded = - resp.__headers().iter().find(|(n, _)| n == "X-Jwt-Claims").map(|(_, v)| v.as_str()); - assert_eq!(forwarded, Some(claims.as_str())); - } - - #[test] - fn missing_header_is_401() { - let mw = jwt(serde_json::json!({ "secret": SECRET })); - assert_401(&invoke(&mw, &[]), "missing bearer token"); - assert_401(&invoke(&mw, &bearer("")), "missing bearer token"); - } - - #[test] - fn bad_signature_is_401() { - let mw = jwt(serde_json::json!({ "secret": SECRET })); - let t = token("wrong-secret", &format!(r#"{{"exp":{}}}"#, future_exp())); - assert_401(&invoke(&mw, &bearer(&t)), "invalid token"); - } - - #[test] - fn malformed_token_is_401() { - let mw = jwt(serde_json::json!({ "secret": SECRET })); - assert_401(&invoke(&mw, &bearer("not-a-jwt")), "invalid token"); - assert_401(&invoke(&mw, &bearer("a.b.c.d")), "invalid token"); - } - - #[test] - fn expired_or_missing_exp_is_401() { - let mw = jwt(serde_json::json!({ "secret": SECRET })); - assert_401(&invoke(&mw, &bearer(&token(SECRET, r#"{"exp":1000}"#))), "invalid token"); - assert_401(&invoke(&mw, &bearer(&token(SECRET, r#"{"sub":"u1"}"#))), "invalid token"); - } - - #[test] - fn nbf_is_honoured() { - let mw = jwt(serde_json::json!({ "secret": SECRET })); - let exp = future_exp(); // now + 3600 - // nbf in the past: valid. - let past = token(SECRET, &format!(r#"{{"exp":{exp},"nbf":{}}}"#, exp - 3700)); - assert_eq!(invoke(&mw, &bearer(&past)).__action(), ACTION_CONTINUE); - // nbf still in the future: rejected. - let future = token(SECRET, &format!(r#"{{"exp":{exp},"nbf":{exp}}}"#)); - assert_401(&invoke(&mw, &bearer(&future)), "invalid token"); - } - - #[test] - fn wrong_issuer_is_401() { - let mw = jwt(serde_json::json!({ "secret": SECRET, "issuer": "auth.example" })); - let exp = future_exp(); - let good = token(SECRET, &format!(r#"{{"exp":{exp},"iss":"auth.example"}}"#)); - assert_eq!(invoke(&mw, &bearer(&good)).__action(), ACTION_CONTINUE); - let bad = token(SECRET, &format!(r#"{{"exp":{exp},"iss":"evil.example"}}"#)); - assert_401(&invoke(&mw, &bearer(&bad)), "invalid token"); - let none = token(SECRET, &format!(r#"{{"exp":{exp}}}"#)); - assert_401(&invoke(&mw, &bearer(&none)), "invalid token"); - } - - #[test] - fn audience_string_or_array_is_enforced() { - let mw = jwt(serde_json::json!({ "secret": SECRET, "audience": "api" })); - let exp = future_exp(); - let s = token(SECRET, &format!(r#"{{"exp":{exp},"aud":"api"}}"#)); - assert_eq!(invoke(&mw, &bearer(&s)).__action(), ACTION_CONTINUE); - let arr = token(SECRET, &format!(r#"{{"exp":{exp},"aud":["web","api"]}}"#)); - assert_eq!(invoke(&mw, &bearer(&arr)).__action(), ACTION_CONTINUE); - let bad = token(SECRET, &format!(r#"{{"exp":{exp},"aud":"web"}}"#)); - assert_401(&invoke(&mw, &bearer(&bad)), "invalid token"); - } - - #[test] - fn alg_none_is_rejected_even_with_valid_hmac() { - let mw = jwt(serde_json::json!({ "secret": SECRET })); - let t = sign(SECRET, r#"{"alg":"none"}"#, &format!(r#"{{"exp":{}}}"#, future_exp())); - assert_401(&invoke(&mw, &bearer(&t)), "invalid token"); - } - - #[test] - fn custom_header_without_bearer_prefix_works() { - let mw = jwt(serde_json::json!({ "secret": SECRET, "header": "X-Auth-Token" })); - let t = token(SECRET, &format!(r#"{{"exp":{}}}"#, future_exp())); - let resp = invoke(&mw, &[("X-Auth-Token".to_owned(), t)]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } -} diff --git a/crates/ephpm-middleware-modules/src/lib.rs b/crates/ephpm-middleware-modules/src/lib.rs deleted file mode 100644 index ed00a9b..0000000 --- a/crates/ephpm-middleware-modules/src/lib.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! The official ePHPm native middleware modules as plain Rust library code. -//! -//! Each module here is an ordinary [`ephpm_middleware::Middleware`] -//! implementation with **no C ABI exports**. That is what lets them all be -//! linked into one binary — either into the loadable cdylib shells one at a -//! time, or all together into an ePHPm built with the `vendor-middleware` -//! feature, which runs them in-process through the static builtin registry -//! (so `library = "jwt"` works even in a custom fully static build, where -//! `dlopen` does not exist). -//! -//! `ephpm-middleware-` crates are thin cdylib shells: they re-export -//! these types and add the `declare!` C ABI glue, producing the loadable -//! `.so`/`.dylib`/`.dll` artifacts for the dynamic (dlopen) lane. The shells -//! cannot be merged into one binary — several copies of the same -//! `ephpm_middleware_*` export symbols collide at link time — which is exactly -//! why the implementations live here. - -pub mod api_key; -pub mod cors; -pub mod header_transform; -pub mod ip_allowlist; -pub mod jwt; -pub mod maintenance_mode; -pub mod ratelimit; -pub mod redirect; -pub mod request_id; -pub mod security_headers; diff --git a/crates/ephpm-middleware-modules/src/maintenance_mode.rs b/crates/ephpm-middleware-modules/src/maintenance_mode.rs deleted file mode 100644 index b7eb906..0000000 --- a/crates/ephpm-middleware-modules/src/maintenance_mode.rs +++ /dev/null @@ -1,405 +0,0 @@ -//! `maintenance-mode` — ePHPm native middleware that flips a tenant into a -//! 503 holding page the instant a per-site flag appears in the embedded -//! (cluster-replicated) KV store — no redeploy, no restart. The flagship demo -//! of the KV store as a control plane: analogous to Cloudflare's maintenance -//! mode or an HAProxy `monitor-uri`, but driven by a single KV key you can set -//! from PHP (`ephpm_kv_set`) or the RESP interface. -//! -//! Per request the module builds a per-site key from a configurable template -//! (default `mw:maintenance:`, with `` replaced by the request's -//! vhost id) and `kv_get`s it. If the key is present and *truthy* the request -//! is short-circuited with a `503` holding page carrying a `Retry-After` -//! header. If the key is absent the request `CONTINUE`s to PHP untouched. -//! -//! **Bypass.** Operators need to verify a site while it is "down". Two escape -//! hatches let a request `CONTINUE` even during maintenance: -//! - `bypass_ips` — exact IPs or CIDR ranges (client IP is taken *after* -//! trusted-proxy resolution, so it is the real client, not the proxy). -//! - `bypass_paths` — path prefixes kept live (e.g. `/healthz` so the load -//! balancer's health check still passes and the tenant is not evicted). -//! -//! Bypass is checked *before* the KV lookup, so a health check never even -//! touches the KV store. -//! -//! **Fail-OPEN by design.** The embedded `kv_get` accessor returns `None` for -//! both "key absent" and "KV store unavailable" — and both paths `CONTINUE`. -//! That is deliberate: a KV blip must **not** take *every* tenant down. A -//! maintenance flag is a soft, operator-driven signal; failing closed here -//! would turn a transient KV hiccup into a fleet-wide outage. This is the -//! **opposite** of an IP-allowlist / auth gate (which must fail *closed* — see -//! the `jwt` module): there, availability must never beat correctness; here it -//! must. Choose this module only for maintenance signalling, never for access -//! control. -//! -//! Configuration (`[[middleware]] config = { ... }`), all optional: -//! -//! | key | default | meaning | -//! |-----|---------|---------| -//! | `key_template` (string) | `"mw:maintenance:"` | KV key checked per request; `` is replaced with the request's vhost id | -//! | `retry_after` (integer seconds) | `300` | value of the `Retry-After` header on the 503 | -//! | `body` (string) | built-in minimal HTML | holding-page body served with the 503 | -//! | `content_type` (string) | `"text/html; charset=utf-8"` | `Content-Type` of the holding page | -//! | `bypass_ips` (array of strings) | unset | exact IPs or CIDR ranges whose requests continue during maintenance | -//! | `bypass_paths` (array of strings) | unset | path prefixes that stay live during maintenance | - -use std::net::IpAddr; - -use ephpm_middleware::abi::LOG_DEBUG; -use ephpm_middleware::{Middleware, Request, Response}; - -/// Default KV key template. `` is substituted per request. -const DEFAULT_KEY_TEMPLATE: &str = "mw:maintenance:"; -/// Placeholder replaced with the request's vhost id in the key template. -const VHOST_PLACEHOLDER: &str = ""; -/// Default `Retry-After` (seconds). -const DEFAULT_RETRY_AFTER: u64 = 300; -/// Default holding-page `Content-Type`. -const DEFAULT_CONTENT_TYPE: &str = "text/html; charset=utf-8"; -/// Minimal built-in holding page. -const DEFAULT_BODY: &str = "\n\n\n\n\nDown for maintenance\n\n\n

We’ll be right back

\n

This site is temporarily down for maintenance. Please try again shortly.

\n\n\n"; - -/// One entry from `bypass_ips`: a single address or a CIDR range. -enum IpMatcher { - /// Exact IP match. - Exact(IpAddr), - /// CIDR: network address plus prefix length (bits). - Cidr { network: IpAddr, prefix: u8 }, -} - -impl IpMatcher { - /// Parse an exact IP (`203.0.113.4`, `2001:db8::1`) or CIDR - /// (`203.0.113.0/24`, `2001:db8::/32`). - fn parse(spec: &str) -> Result { - if let Some((net, bits)) = spec.split_once('/') { - let network: IpAddr = net - .parse() - .map_err(|_| format!("`bypass_ips`: invalid CIDR network in {spec:?}"))?; - let prefix: u8 = - bits.parse().map_err(|_| format!("`bypass_ips`: invalid prefix in {spec:?}"))?; - let max = if network.is_ipv4() { 32 } else { 128 }; - if prefix > max { - return Err(format!("`bypass_ips`: prefix /{prefix} too large in {spec:?}")); - } - Ok(IpMatcher::Cidr { network, prefix }) - } else { - let ip: IpAddr = - spec.parse().map_err(|_| format!("`bypass_ips`: invalid IP {spec:?}"))?; - Ok(IpMatcher::Exact(ip)) - } - } - - /// Does `ip` fall within this matcher? - fn matches(&self, ip: IpAddr) -> bool { - match self { - IpMatcher::Exact(want) => *want == ip, - IpMatcher::Cidr { network, prefix } => cidr_contains(*network, *prefix, ip), - } - } -} - -/// Compare the high `prefix` bits of two same-family addresses. -fn cidr_contains(network: IpAddr, prefix: u8, ip: IpAddr) -> bool { - match (network, ip) { - (IpAddr::V4(net), IpAddr::V4(addr)) => prefix_match(&net.octets(), &addr.octets(), prefix), - (IpAddr::V6(net), IpAddr::V6(addr)) => prefix_match(&net.octets(), &addr.octets(), prefix), - // Mixed families never match. - _ => false, - } -} - -/// True when `a` and `b` agree on the first `prefix` bits. -fn prefix_match(a: &[u8], b: &[u8], prefix: u8) -> bool { - let mut bits = usize::from(prefix); - for (x, y) in a.iter().zip(b.iter()) { - if bits == 0 { - break; - } - if bits >= 8 { - if x != y { - return false; - } - bits -= 8; - } else { - // Compare the top `bits` bits of this byte. - let mask = 0xFFu8 << (8 - bits); - return (x & mask) == (y & mask); - } - } - true -} - -/// A KV maintenance value is "on" unless it is empty or an explicit falsey -/// marker. This lets an operator disable maintenance by *setting* the flag to -/// `0`/`false`/`off` without having to delete the key. -fn is_truthy(value: &[u8]) -> bool { - let s = std::str::from_utf8(value).unwrap_or("").trim(); - if s.is_empty() { - return false; - } - !matches!(s.to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no") -} - -/// Maintenance-mode policy, built once at `init`. -pub struct MaintenanceMode { - key_template: String, - retry_after: u64, - body: String, - content_type: String, - bypass_ips: Vec, - bypass_paths: Vec, -} - -impl MaintenanceMode { - /// Build the per-request KV key by substituting the vhost id. - fn key_for(&self, vhost: &str) -> String { - self.key_template.replace(VHOST_PLACEHOLDER, vhost) - } - - /// Does this request qualify for a bypass (path prefix or client IP)? - fn is_bypassed(&self, req: &Request<'_>) -> bool { - let path = req.path(); - if self.bypass_paths.iter().any(|p| path.starts_with(p.as_str())) { - return true; - } - if !self.bypass_ips.is_empty() - && let Ok(ip) = req.remote_ip().parse::() - && self.bypass_ips.iter().any(|m| m.matches(ip)) - { - return true; - } - false - } -} - -/// Read an optional array-of-strings config key. -fn opt_string_array(config: &serde_json::Value, key: &str) -> Result, String> { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(Vec::new()), - Some(v) => v - .as_array() - .ok_or_else(|| format!("`{key}` must be an array of strings"))? - .iter() - .map(|e| { - e.as_str() - .map(str::to_owned) - .ok_or_else(|| format!("`{key}` entries must be strings, got {e}")) - }) - .collect(), - } -} - -/// Read an optional string config key with a default. -fn opt_string(config: &serde_json::Value, key: &str, default: &str) -> Result { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(default.to_owned()), - Some(serde_json::Value::String(s)) => Ok(s.clone()), - Some(other) => Err(format!("`{key}` must be a string, got {other}")), - } -} - -impl Middleware for MaintenanceMode { - fn init(config: &serde_json::Value) -> Result { - let key_template = opt_string(config, "key_template", DEFAULT_KEY_TEMPLATE)?; - if key_template.is_empty() { - return Err("`key_template` must not be empty".into()); - } - let retry_after = match config.get("retry_after") { - None | Some(serde_json::Value::Null) => DEFAULT_RETRY_AFTER, - Some(v) => v - .as_u64() - .ok_or_else(|| format!("`retry_after` must be a non-negative integer, got {v}"))?, - }; - let body = opt_string(config, "body", DEFAULT_BODY)?; - let content_type = opt_string(config, "content_type", DEFAULT_CONTENT_TYPE)?; - let bypass_ips = opt_string_array(config, "bypass_ips")? - .iter() - .map(|s| IpMatcher::parse(s)) - .collect::>()?; - let bypass_paths = opt_string_array(config, "bypass_paths")?; - - Ok(Self { key_template, retry_after, body, content_type, bypass_ips, bypass_paths }) - } - - fn invoke(&self, req: &Request<'_>) -> Response { - // Bypass first — a health check must never depend on the KV store. - if self.is_bypassed(req) { - return Response::cont(); - } - - let key = self.key_for(req.vhost_id()); - let host = req.host(); - // `kv_get` returns None for BOTH "absent" and "KV unavailable" — both - // fall through to CONTINUE. That is the fail-OPEN choice (see the - // module docs): a KV blip must not black-hole every tenant. - match host.kv_get(&key) { - Some(value) if is_truthy(&value) => Response::respond(503, self.body.clone()) - .header("Retry-After", self.retry_after.to_string()) - .header("Content-Type", self.content_type.clone()), - _ => { - host.log(LOG_DEBUG, &format!("maintenance-mode: {key} not set — continuing")); - Response::cont() - } - } - } -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request view by hand. - - use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_RESPOND}; - use ephpm_middleware::host::{RequestCtx, host_table, set_kv_store}; - - use super::*; - - /// Wire a real in-memory Store into the host table (first call wins; all - /// tests in this binary share it, so each uses a unique vhost). - fn setup_kv() { - set_kv_store(&ephpm_kv::store::Store::new(ephpm_kv::store::StoreConfig::default())); - } - - fn invoke(mw: &MaintenanceMode, path: &str, ip: &str, vhost: &str) -> Response { - let ctx = RequestCtx::new("GET", path, "", ip, vhost, &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) - } - - /// Turn maintenance on for a vhost via the same KV key the module reads. - fn set_flag(vhost: &str, value: &[u8]) { - let key = format!("mw:maintenance:{vhost}"); - let ctx = RequestCtx::new("GET", "/", "", "127.0.0.1", vhost, &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - assert!(req.host().kv_set(&key, value, 0), "kv_set failed"); - } - - #[test] - fn init_defaults_and_validation() { - let mw = MaintenanceMode::init(&serde_json::Value::Null).expect("init"); - assert_eq!(mw.key_template, DEFAULT_KEY_TEMPLATE); - assert_eq!(mw.retry_after, DEFAULT_RETRY_AFTER); - assert_eq!(mw.key_for("acme.test"), "mw:maintenance:acme.test"); - // Bad config is rejected. - assert!(MaintenanceMode::init(&serde_json::json!({ "key_template": "" })).is_err()); - assert!(MaintenanceMode::init(&serde_json::json!({ "retry_after": "soon" })).is_err()); - assert!( - MaintenanceMode::init(&serde_json::json!({ "bypass_ips": ["not-an-ip"] })).is_err() - ); - assert!( - MaintenanceMode::init(&serde_json::json!({ "bypass_ips": ["10.0.0.0/40"] })).is_err() - ); - assert!(MaintenanceMode::init(&serde_json::json!({ "bypass_paths": "/healthz" })).is_err()); - } - - #[test] - fn flag_unset_continues() { - setup_kv(); - let mw = MaintenanceMode::init(&serde_json::Value::Null).expect("init"); - let resp = invoke(&mw, "/", "198.51.100.1", "vhost-unset"); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - #[test] - fn flag_set_returns_503_with_retry_after_and_body() { - setup_kv(); - let mw = MaintenanceMode::init(&serde_json::json!({ "retry_after": 120 })).expect("init"); - set_flag("vhost-503", b"1"); - let resp = invoke(&mw, "/anything", "198.51.100.2", "vhost-503"); - assert_eq!(resp.__action(), ACTION_RESPOND); - assert_eq!(resp.__status(), 503); - assert_eq!(resp.__body(), DEFAULT_BODY.as_bytes()); - let find = |name: &str| { - resp.__headers() - .iter() - .find(|(n, _)| n.eq_ignore_ascii_case(name)) - .map(|(_, v)| v.as_str()) - }; - assert_eq!(find("Retry-After"), Some("120")); - assert_eq!(find("Content-Type"), Some(DEFAULT_CONTENT_TYPE)); - } - - #[test] - fn falsey_flag_value_continues() { - setup_kv(); - let mw = MaintenanceMode::init(&serde_json::Value::Null).expect("init"); - set_flag("vhost-falsey", b"0"); - assert_eq!(invoke(&mw, "/", "198.51.100.3", "vhost-falsey").__action(), ACTION_CONTINUE); - set_flag("vhost-falsey", b"off"); - assert_eq!(invoke(&mw, "/", "198.51.100.3", "vhost-falsey").__action(), ACTION_CONTINUE); - } - - #[test] - fn custom_body_is_served() { - setup_kv(); - let mw = - MaintenanceMode::init(&serde_json::json!({ "body": "gone fishing" })).expect("init"); - set_flag("vhost-body", b"true"); - let resp = invoke(&mw, "/", "198.51.100.4", "vhost-body"); - assert_eq!(resp.__body(), b"gone fishing"); - } - - #[test] - fn bypass_exact_ip_continues_during_maintenance() { - setup_kv(); - let mw = MaintenanceMode::init(&serde_json::json!({ - "bypass_ips": ["203.0.113.7"], - })) - .expect("init"); - set_flag("vhost-ip", b"1"); - // Operator's IP sails through. - assert_eq!(invoke(&mw, "/", "203.0.113.7", "vhost-ip").__action(), ACTION_CONTINUE); - // Everyone else gets the 503. - assert_eq!(invoke(&mw, "/", "203.0.113.8", "vhost-ip").__action(), ACTION_RESPOND); - } - - #[test] - fn bypass_cidr_continues_during_maintenance() { - setup_kv(); - let mw = MaintenanceMode::init(&serde_json::json!({ - "bypass_ips": ["10.0.0.0/8", "2001:db8::/32"], - })) - .expect("init"); - set_flag("vhost-cidr", b"1"); - assert_eq!(invoke(&mw, "/", "10.4.5.6", "vhost-cidr").__action(), ACTION_CONTINUE); - assert_eq!(invoke(&mw, "/", "2001:db8::dead", "vhost-cidr").__action(), ACTION_CONTINUE); - // Outside the range → still down. - assert_eq!(invoke(&mw, "/", "11.0.0.1", "vhost-cidr").__action(), ACTION_RESPOND); - } - - #[test] - fn bypass_path_continues_during_maintenance() { - setup_kv(); - let mw = MaintenanceMode::init(&serde_json::json!({ - "bypass_paths": ["/healthz", "/status"], - })) - .expect("init"); - set_flag("vhost-path", b"1"); - assert_eq!( - invoke(&mw, "/healthz", "198.51.100.9", "vhost-path").__action(), - ACTION_CONTINUE - ); - assert_eq!( - invoke(&mw, "/status/live", "198.51.100.9", "vhost-path").__action(), - ACTION_CONTINUE - ); - // A normal path is still down. - assert_eq!(invoke(&mw, "/", "198.51.100.9", "vhost-path").__action(), ACTION_RESPOND); - } - - #[test] - fn custom_key_template_is_used() { - setup_kv(); - let mw = MaintenanceMode::init(&serde_json::json!({ - "key_template": "flags:down:", - })) - .expect("init"); - // Set the flag under the CUSTOM key. - let key = "flags:down:vhost-tmpl"; - let ctx = RequestCtx::new("GET", "/", "", "127.0.0.1", "vhost-tmpl", &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - assert!(req.host().kv_set(key, b"1", 0)); - assert_eq!(invoke(&mw, "/", "198.51.100.10", "vhost-tmpl").__action(), ACTION_RESPOND); - } -} diff --git a/crates/ephpm-middleware-modules/src/ratelimit.rs b/crates/ephpm-middleware-modules/src/ratelimit.rs deleted file mode 100644 index 9c203cf..0000000 --- a/crates/ephpm-middleware-modules/src/ratelimit.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! `ratelimit` — ePHPm native middleware: fixed-window per-client rate -//! limiting backed by the embedded (cluster-replicated) KV store. -//! -//! Requests are counted in 10-second windows (less KV churn than 1-second -//! windows): each window allows `per_ip_rps * 10 + burst` requests per -//! client. The counter key is `mw:rl:{vhost}:{client}:{window_index}`, -//! bumped with a single atomic `kv_incr_ttl` — one round trip that both -//! increments the counter and (on the first request of a window) stamps the -//! window TTL, so a counter can never be created without an expiry. That -//! single call is also what makes the limit cluster-wide when KV replication -//! is on. Over the limit the client gets `429` with a `Retry-After` for the -//! seconds left in the window. -//! -//! **Fail-open by design:** when the KV store is unavailable (`kv_incr` -//! errors), the request is allowed through with a warning log. For a rate -//! limiter, availability beats strictness — dropping every request because -//! the KV tier hiccuped would turn a soft protection into a hard outage. If -//! you need fail-closed admission control, use an auth middleware instead. -//! -//! Configuration (`[[middleware]] config = { ... }`): -//! -//! | key | default | meaning | -//! |-----|---------|---------| -//! | `per_ip_rps` (integer) | **required**, > 0 | sustained requests/second per client | -//! | `burst` (integer) | `per_ip_rps` | extra headroom on top of the per-window allowance | -//! | `key_headers` (array of strings) | unset | identify clients by the first present header (e.g. `X-Api-Key`) instead of the client IP | - -use std::time::{SystemTime, UNIX_EPOCH}; - -use ephpm_middleware::abi::LOG_WARN; -use ephpm_middleware::{Middleware, Request, Response}; - -/// Fixed window length in seconds. -const WINDOW_SECS: u64 = 10; -/// Counter-key TTL: one window plus slack for clock skew across nodes. -const KEY_TTL_SECS: i64 = 30; - -/// Rate-limit policy, built once at `init`. -pub struct RateLimit { - per_ip_rps: u64, - burst: u64, - key_headers: Vec, -} - -impl RateLimit { - /// Requests allowed per client per window. - fn allowance(&self) -> i64 { - i64::try_from(self.per_ip_rps.saturating_mul(WINDOW_SECS).saturating_add(self.burst)) - .unwrap_or(i64::MAX) - } - - /// Client identity: the first present `key_headers` header, else the IP. - fn client_key<'a>(&self, req: &'a Request<'_>) -> &'a str { - self.key_headers.iter().find_map(|h| req.header(h)).unwrap_or_else(|| req.remote_ip()) - } -} - -impl Middleware for RateLimit { - fn init(config: &serde_json::Value) -> Result { - let per_ip_rps = config - .get("per_ip_rps") - .ok_or("`per_ip_rps` is required (requests/second per client, > 0)")? - .as_u64() - .ok_or("`per_ip_rps` must be a positive integer")?; - if per_ip_rps == 0 { - return Err("`per_ip_rps` must be > 0".into()); - } - let burst = match config.get("burst") { - None | Some(serde_json::Value::Null) => per_ip_rps, - Some(v) => v.as_u64().ok_or("`burst` must be a non-negative integer")?, - }; - let key_headers = match config.get("key_headers") { - None | Some(serde_json::Value::Null) => Vec::new(), - Some(v) => v - .as_array() - .ok_or("`key_headers` must be an array of header names")? - .iter() - .map(|h| { - h.as_str() - .map(str::to_owned) - .ok_or_else(|| format!("`key_headers` entries must be strings, got {h}")) - }) - .collect::>()?, - }; - Ok(Self { per_ip_rps, burst, key_headers }) - } - - fn invoke(&self, req: &Request<'_>) -> Response { - let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()); - let window = now / WINDOW_SECS; - let client = self.client_key(req); - let key = format!("mw:rl:{}:{}:{}", req.vhost_id(), client, window); - - let host = req.host(); - // Atomically bump the counter, applying the window TTL only when this - // call creates the key. This makes it impossible for a counter to be - // created without an expiry (which would leak the key and could pin a - // client "limited" into the next window). Existing keys keep their - // original TTL — fixed-window semantics. - let Some(count) = host.kv_incr_ttl(&key, 1, KEY_TTL_SECS) else { - // Fail-open: see the crate docs for the rationale. - host.log( - LOG_WARN, - &format!("ratelimit: KV store unavailable — failing open for client {client}"), - ); - return Response::cont(); - }; - - if count > self.allowance() { - let retry_after = WINDOW_SECS - (now % WINDOW_SECS); - return Response::respond(429, "rate limit exceeded") - .header("Retry-After", retry_after.to_string()); - } - Response::cont() - } -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request view by hand. - - use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_RESPOND}; - use ephpm_middleware::host::{RequestCtx, host_table, set_kv_store}; - - use super::*; - - /// Wire a real in-memory Store into the host table (first call wins; - /// all tests in this binary share it, so each uses a unique vhost). - fn setup_kv() { - set_kv_store(&ephpm_kv::store::Store::new(ephpm_kv::store::StoreConfig::default())); - } - - fn invoke(mw: &RateLimit, vhost: &str, ip: &str, headers: &[(String, String)]) -> Response { - let ctx = RequestCtx::new("GET", "/api/x", "", ip, vhost, headers); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) - } - - #[test] - fn init_validates_config() { - assert!(RateLimit::init(&serde_json::Value::Null).is_err()); - assert!(RateLimit::init(&serde_json::json!({ "per_ip_rps": 0 })).is_err()); - assert!(RateLimit::init(&serde_json::json!({ "per_ip_rps": "fast" })).is_err()); - assert!( - RateLimit::init(&serde_json::json!({ "per_ip_rps": 5, "key_headers": "X-Api-Key" })) - .is_err() - ); - let mw = RateLimit::init(&serde_json::json!({ "per_ip_rps": 5 })).expect("init"); - // burst defaults to per_ip_rps: 5*10 + 5. - assert_eq!(mw.allowance(), 55); - } - - #[test] - fn first_request_is_always_allowed() { - setup_kv(); - let mw = RateLimit::init(&serde_json::json!({ "per_ip_rps": 1 })).expect("init"); - let resp = invoke(&mw, "vhost-first", "198.51.100.1", &[]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - #[test] - fn over_limit_gets_429_with_retry_after() { - setup_kv(); - let mw = - RateLimit::init(&serde_json::json!({ "per_ip_rps": 1, "burst": 0 })).expect("init"); - // Allowance is 10/window. Even if a window boundary lands mid-loop - // (resetting the counter once), 3x the allowance must trip the limit. - let mut limited = None; - for _ in 0..30 { - let resp = invoke(&mw, "vhost-429", "198.51.100.2", &[]); - if resp.__action() == ACTION_RESPOND { - limited = Some(resp); - break; - } - } - let resp = limited.expect("rate limit never tripped within 3x the allowance"); - assert_eq!(resp.__status(), 429); - assert_eq!(resp.__body(), b"rate limit exceeded"); - let retry: u64 = resp - .__headers() - .iter() - .find(|(n, _)| n.eq_ignore_ascii_case("Retry-After")) - .map(|(_, v)| v.parse().expect("numeric Retry-After")) - .expect("Retry-After present"); - assert!((1..=WINDOW_SECS).contains(&retry), "retry_after = {retry}"); - } - - #[test] - fn key_header_separates_clients() { - setup_kv(); - let mw = RateLimit::init(&serde_json::json!({ - "per_ip_rps": 1, - "burst": 0, - "key_headers": ["X-Api-Key"], - })) - .expect("init"); - let key_a = [("X-Api-Key".to_owned(), "alpha".to_owned())]; - let key_b = [("X-Api-Key".to_owned(), "beta".to_owned())]; - // Exhaust client alpha (same IP for everyone — the header is the key). - let mut tripped = false; - for _ in 0..30 { - if invoke(&mw, "vhost-keys", "198.51.100.3", &key_a).__action() == ACTION_RESPOND { - tripped = true; - break; - } - } - assert!(tripped, "alpha never rate-limited"); - // Client beta's first request in any window is always allowed. - let resp = invoke(&mw, "vhost-keys", "198.51.100.3", &key_b); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } -} diff --git a/crates/ephpm-middleware-modules/src/redirect.rs b/crates/ephpm-middleware-modules/src/redirect.rs deleted file mode 100644 index 4c7effd..0000000 --- a/crates/ephpm-middleware-modules/src/redirect.rs +++ /dev/null @@ -1,541 +0,0 @@ -//! `redirect` — ePHPm native middleware that enforces canonical URLs with a -//! single `301`/`308` redirect **before** PHP runs. -//! -//! Analogous to Traefik's `redirectscheme`/`redirectregex`, Caddy's `redir`, -//! Cloudflare redirect rules, or an nginx `return 301`. It composes several -//! canonicalization rules — scheme, host, trailing slash — computes the final -//! canonical URL **once**, and redirects a single time only when the request -//! is not already canonical (so it can never loop). -//! -//! Configuration (`[[middleware]] config = { ... }`), all optional: -//! -//! | key | default | meaning | -//! |-----|---------|---------| -//! | `force_https` (bool) | `false` | redirect `http` → `https` | -//! | `canonical_host` (string) | unset | `"www"` forces the apex → `www.`; `"apex"` (alias `"non-www"`) strips a leading `www.` | -//! | `host_map` (object) | unset | explicit `source-host` → `canonical-host` map (exact, case-insensitive key); wins over `canonical_host` on a match | -//! | `trailing_slash` (string) | unset | `"add"` appends a `/` (except the root and paths whose last segment looks like a file, i.e. contains a `.`); `"strip"` removes trailing `/` (except the root) | -//! | `status` (integer) | `308` | redirect status — `301` or `308`; `308` preserves the request method | -//! | `forwarded_proto_header` (string) | `"X-Forwarded-Proto"` | header the current scheme is derived from | -//! -//! **Scheme derivation.** The v1 middleware ABI exposes no request scheme or -//! "is secure" flag, so the current scheme is read from -//! `forwarded_proto_header` (default `X-Forwarded-Proto`); a request with no -//! such header is treated as `http`. Behind a TLS-terminating proxy the proxy -//! **must** set that header, or `force_https` would redirect an -//! already-secure request and loop — the same requirement nginx/Traefik place -//! on the operator. -//! -//! **Scope.** Config is per-mount (there is no per-vhost config idiom in the -//! ABI). Use `host_map` to canonicalize several hosts from one mount; the -//! request's own `Host` header is what every rule is computed against. - -use ephpm_middleware::{Middleware, Request, Response}; - -/// The canonical-host policy. -#[derive(Clone, Copy, PartialEq, Eq)] -enum HostPolicy { - /// Force the apex form to `www.` (`example.com` → `www.example.com`). - Www, - /// Strip a leading `www.` (`www.example.com` → `example.com`). - Apex, -} - -/// The trailing-slash policy. -#[derive(Clone, Copy, PartialEq, Eq)] -enum SlashPolicy { - /// Append a trailing `/` (except the root and file-like paths). - Add, - /// Remove trailing `/` (except the root). - Strip, -} - -/// Redirect policy, built once at `init`. -pub struct Redirect { - force_https: bool, - canonical_host: Option, - /// Exact source→canonical host map; keys are stored lower-cased. - host_map: Vec<(String, String)>, - trailing_slash: Option, - status: u16, - forwarded_proto_header: String, -} - -/// Read an optional boolean config key with a default. -fn opt_bool(config: &serde_json::Value, key: &str, default: bool) -> Result { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(default), - Some(serde_json::Value::Bool(b)) => Ok(*b), - Some(other) => Err(format!("`{key}` must be a boolean, got {other}")), - } -} - -/// Read an optional string config key with a default. -fn opt_string(config: &serde_json::Value, key: &str, default: &str) -> Result { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(default.to_owned()), - Some(serde_json::Value::String(s)) => Ok(s.clone()), - Some(other) => Err(format!("`{key}` must be a string, got {other}")), - } -} - -/// True when `host` begins with a `www.` label (case-insensitive). -fn has_www_prefix(host: &str) -> bool { - host.len() > 4 && host[..4].eq_ignore_ascii_case("www.") -} - -/// The host with a leading `www.` removed, or `None` when there is no such -/// prefix (or removing it would leave the host empty). -fn strip_www_prefix(host: &str) -> Option<&str> { - if has_www_prefix(host) { - let rest = &host[4..]; - (!rest.is_empty()).then_some(rest) - } else { - None - } -} - -/// Split a `Host` header value into `(host, Option)`. Handles bracketed -/// IPv6 literals (`[::1]:8080`) and only treats an all-digit tail after the -/// last `:` as a port. -fn split_host(value: &str) -> (&str, Option<&str>) { - if value.starts_with('[') { - // Bracketed IPv6 literal: the host is everything through `]`. - if let Some(idx) = value.find(']') { - let host = &value[..=idx]; - let port = value[idx + 1..].strip_prefix(':').filter(|p| !p.is_empty()); - return (host, port); - } - return (value, None); - } - match value.rsplit_once(':') { - Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => { - (host, Some(port)) - } - _ => (value, None), - } -} - -/// True when the last path segment looks like a file (contains a `.`). -fn last_segment_has_dot(path: &str) -> bool { - path.rsplit('/').next().is_some_and(|seg| seg.contains('.')) -} - -impl Redirect { - /// The canonical host for `host` (case preserved when nothing applies). - fn canonical_host(&self, host: &str) -> String { - for (src, dst) in &self.host_map { - if host.eq_ignore_ascii_case(src) { - return dst.clone(); - } - } - match self.canonical_host { - Some(HostPolicy::Www) => { - if has_www_prefix(host) { - host.to_owned() - } else { - format!("www.{host}") - } - } - Some(HostPolicy::Apex) => strip_www_prefix(host).unwrap_or(host).to_owned(), - None => host.to_owned(), - } - } - - /// The canonical path for `path` under the trailing-slash policy. - fn canonical_path(&self, path: &str) -> String { - match self.trailing_slash { - Some(SlashPolicy::Strip) => { - if path.len() > 1 && path.ends_with('/') { - let trimmed = path.trim_end_matches('/'); - if trimmed.is_empty() { "/".to_owned() } else { trimmed.to_owned() } - } else { - path.to_owned() - } - } - Some(SlashPolicy::Add) => { - if path.ends_with('/') || last_segment_has_dot(path) { - path.to_owned() - } else { - format!("{path}/") - } - } - None => path.to_owned(), - } - } - - /// The current request scheme, derived from `forwarded_proto_header` - /// (first value of a comma list); `http` when the header is absent. - fn current_scheme<'a>(&self, req: &'a Request<'_>) -> &'a str { - match req.header(&self.forwarded_proto_header) { - Some(v) => { - let first = v.split(',').next().unwrap_or(v).trim(); - if first.eq_ignore_ascii_case("https") { "https" } else { "http" } - } - None => "http", - } - } -} - -impl Middleware for Redirect { - fn init(config: &serde_json::Value) -> Result { - let canonical_host = match config.get("canonical_host") { - None | Some(serde_json::Value::Null) => None, - Some(serde_json::Value::String(s)) => match s.to_ascii_lowercase().as_str() { - "www" => Some(HostPolicy::Www), - "apex" | "non-www" => Some(HostPolicy::Apex), - other => { - return Err(format!( - "`canonical_host` must be \"www\" or \"apex\", got \"{other}\"" - )); - } - }, - Some(other) => return Err(format!("`canonical_host` must be a string, got {other}")), - }; - - let host_map = match config.get("host_map") { - None | Some(serde_json::Value::Null) => Vec::new(), - Some(serde_json::Value::Object(map)) => { - let mut out = Vec::with_capacity(map.len()); - for (k, v) in map { - let dst = v.as_str().ok_or_else(|| { - format!("`host_map` values must be strings, got {v} for key `{k}`") - })?; - if dst.is_empty() { - return Err(format!("`host_map` value for key `{k}` must not be empty")); - } - out.push((k.to_ascii_lowercase(), dst.to_owned())); - } - out - } - Some(other) => return Err(format!("`host_map` must be an object, got {other}")), - }; - - let trailing_slash = match config.get("trailing_slash") { - None | Some(serde_json::Value::Null) => None, - Some(serde_json::Value::String(s)) => match s.to_ascii_lowercase().as_str() { - "add" => Some(SlashPolicy::Add), - "strip" => Some(SlashPolicy::Strip), - other => { - return Err(format!( - "`trailing_slash` must be \"add\" or \"strip\", got \"{other}\"" - )); - } - }, - Some(other) => return Err(format!("`trailing_slash` must be a string, got {other}")), - }; - - let status = match config.get("status") { - None | Some(serde_json::Value::Null) => 308, - Some(v) => { - let n = - v.as_u64().ok_or_else(|| format!("`status` must be 301 or 308, got {v}"))?; - if n != 301 && n != 308 { - return Err(format!("`status` must be 301 or 308, got {n}")); - } - u16::try_from(n).unwrap_or(308) - } - }; - - let forwarded_proto_header = - opt_string(config, "forwarded_proto_header", "X-Forwarded-Proto")?; - if forwarded_proto_header.is_empty() { - return Err("`forwarded_proto_header` must not be empty".into()); - } - - Ok(Self { - force_https: opt_bool(config, "force_https", false)?, - canonical_host, - host_map, - trailing_slash, - status, - forwarded_proto_header, - }) - } - - fn invoke(&self, req: &Request<'_>) -> Response { - // Without an authority we cannot build an absolute Location; pass through. - let Some(host_hdr) = req.header("Host").filter(|h| !h.is_empty()) else { - return Response::cont(); - }; - let (host, port) = split_host(host_hdr); - - let scheme_cur = self.current_scheme(req); - let scheme_can = if self.force_https { "https" } else { scheme_cur }; - - let host_can = self.canonical_host(host); - - let path_cur = { - let p = req.path(); - if p.is_empty() { "/" } else { p } - }; - let path_can = self.canonical_path(path_cur); - - let changed = scheme_cur != scheme_can - || !host.eq_ignore_ascii_case(&host_can) - || path_cur != path_can; - if !changed { - return Response::cont(); - } - - let query = req.query(); - let mut location = String::with_capacity( - scheme_can.len() + 3 + host_can.len() + path_can.len() + query.len() + 8, - ); - location.push_str(scheme_can); - location.push_str("://"); - location.push_str(&host_can); - if let Some(p) = port { - location.push(':'); - location.push_str(p); - } - location.push_str(&path_can); - if !query.is_empty() { - location.push('?'); - location.push_str(query); - } - - Response::respond(self.status, "").header("Location", location) - } -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request view by hand. - - use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_RESPOND}; - use ephpm_middleware::host::{RequestCtx, host_table}; - - use super::*; - - fn redirect(config: serde_json::Value) -> Redirect { - Redirect::init(&config).expect("init") - } - - fn invoke( - mw: &Redirect, - method: &str, - path: &str, - query: &str, - headers: &[(String, String)], - ) -> Response { - let ctx = RequestCtx::new(method, path, query, "203.0.113.9", "example.test", headers); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) - } - - fn hdr(name: &str, value: &str) -> (String, String) { - (name.to_owned(), value.to_owned()) - } - - fn location(resp: &Response) -> Option<&str> { - resp.__headers() - .iter() - .find(|(n, _)| n.eq_ignore_ascii_case("Location")) - .map(|(_, v)| v.as_str()) - } - - // ── force_https ──────────────────────────────────────────────────────── - - #[test] - fn force_https_redirects_http_to_https() { - let mw = redirect(serde_json::json!({ "force_https": true })); - let resp = invoke(&mw, "GET", "/page", "", &[hdr("Host", "example.com")]); - assert_eq!(resp.__action(), ACTION_RESPOND); - assert_eq!(resp.__status(), 308); - assert_eq!(location(&resp), Some("https://example.com/page")); - } - - #[test] - fn force_https_is_a_noop_when_already_https() { - let mw = redirect(serde_json::json!({ "force_https": true })); - let resp = invoke( - &mw, - "GET", - "/page", - "", - &[hdr("Host", "example.com"), hdr("X-Forwarded-Proto", "https")], - ); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - #[test] - fn scheme_read_from_first_forwarded_proto_value() { - let mw = redirect(serde_json::json!({ "force_https": true })); - // A list "https, http" means the edge saw https — no redirect. - let resp = invoke( - &mw, - "GET", - "/", - "", - &[hdr("Host", "example.com"), hdr("X-Forwarded-Proto", "https, http")], - ); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - #[test] - fn custom_forwarded_proto_header_is_honored() { - let mw = redirect(serde_json::json!({ - "force_https": true, - "forwarded_proto_header": "X-Scheme", - })); - let resp = - invoke(&mw, "GET", "/", "", &[hdr("Host", "example.com"), hdr("X-Scheme", "https")]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - // ── canonical host ───────────────────────────────────────────────────── - - #[test] - fn www_to_apex() { - let mw = redirect(serde_json::json!({ "canonical_host": "apex" })); - let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "www.example.com")]); - assert_eq!(resp.__action(), ACTION_RESPOND); - assert_eq!(location(&resp), Some("http://example.com/p")); - } - - #[test] - fn apex_already_canonical_continues() { - let mw = redirect(serde_json::json!({ "canonical_host": "apex" })); - let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "example.com")]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - #[test] - fn apex_to_www_other_direction() { - let mw = redirect(serde_json::json!({ "canonical_host": "www" })); - let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "example.com")]); - assert_eq!(resp.__action(), ACTION_RESPOND); - assert_eq!(location(&resp), Some("http://www.example.com/p")); - } - - #[test] - fn www_already_canonical_continues() { - let mw = redirect(serde_json::json!({ "canonical_host": "www" })); - let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "www.example.com")]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - #[test] - fn host_map_exact_match_wins() { - let mw = redirect(serde_json::json!({ - "host_map": { "old.example.com": "new.example.com" }, - })); - let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "Old.Example.com")]); - assert_eq!(location(&resp), Some("http://new.example.com/p")); - } - - #[test] - fn host_case_only_difference_does_not_redirect() { - // No policy → an uppercase Host is not forced to lowercase (no loop-y - // cosmetic redirect). - let mw = redirect(serde_json::json!({ "force_https": false })); - let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "Example.COM")]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - // ── trailing slash ───────────────────────────────────────────────────── - - #[test] - fn trailing_slash_add() { - let mw = redirect(serde_json::json!({ "trailing_slash": "add" })); - let resp = invoke(&mw, "GET", "/blog", "", &[hdr("Host", "example.com")]); - assert_eq!(location(&resp), Some("http://example.com/blog/")); - } - - #[test] - fn trailing_slash_add_skips_file_like_and_root() { - let mw = redirect(serde_json::json!({ "trailing_slash": "add" })); - assert_eq!( - invoke(&mw, "GET", "/style.css", "", &[hdr("Host", "example.com")]).__action(), - ACTION_CONTINUE - ); - assert_eq!( - invoke(&mw, "GET", "/", "", &[hdr("Host", "example.com")]).__action(), - ACTION_CONTINUE - ); - } - - #[test] - fn trailing_slash_strip() { - let mw = redirect(serde_json::json!({ "trailing_slash": "strip" })); - let resp = invoke(&mw, "GET", "/blog/", "", &[hdr("Host", "example.com")]); - assert_eq!(location(&resp), Some("http://example.com/blog")); - } - - #[test] - fn trailing_slash_strip_keeps_root() { - let mw = redirect(serde_json::json!({ "trailing_slash": "strip" })); - let resp = invoke(&mw, "GET", "/", "", &[hdr("Host", "example.com")]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - // ── query preservation, status, combined rules ───────────────────────── - - #[test] - fn query_string_is_preserved() { - let mw = redirect(serde_json::json!({ "force_https": true })); - let resp = invoke(&mw, "GET", "/s", "q=1&x=2", &[hdr("Host", "example.com")]); - assert_eq!(location(&resp), Some("https://example.com/s?q=1&x=2")); - } - - #[test] - fn status_301_selection() { - let mw = redirect(serde_json::json!({ "force_https": true, "status": 301 })); - let resp = invoke(&mw, "GET", "/", "", &[hdr("Host", "example.com")]); - assert_eq!(resp.__status(), 301); - } - - #[test] - fn default_status_is_308() { - let mw = redirect(serde_json::json!({ "force_https": true })); - let resp = invoke(&mw, "GET", "/", "", &[hdr("Host", "example.com")]); - assert_eq!(resp.__status(), 308); - } - - #[test] - fn all_rules_collapse_into_one_redirect() { - let mw = redirect(serde_json::json!({ - "force_https": true, - "canonical_host": "apex", - "trailing_slash": "add", - })); - let resp = invoke(&mw, "GET", "/blog", "page=2", &[hdr("Host", "www.example.com")]); - assert_eq!(resp.__action(), ACTION_RESPOND); - assert_eq!(location(&resp), Some("https://example.com/blog/?page=2")); - } - - #[test] - fn port_is_preserved() { - let mw = redirect(serde_json::json!({ "canonical_host": "apex" })); - let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "www.example.com:8080")]); - assert_eq!(location(&resp), Some("http://example.com:8080/p")); - } - - #[test] - fn already_canonical_no_config_continues() { - let mw = redirect(serde_json::Value::Null); - let resp = invoke(&mw, "GET", "/p", "a=1", &[hdr("Host", "example.com")]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - #[test] - fn missing_host_header_passes_through() { - let mw = redirect(serde_json::json!({ "force_https": true })); - let resp = invoke(&mw, "GET", "/p", "", &[]); - assert_eq!(resp.__action(), ACTION_CONTINUE); - } - - // ── config validation ────────────────────────────────────────────────── - - #[test] - fn bad_config_fails_init() { - assert!(Redirect::init(&serde_json::json!({ "status": 302 })).is_err()); - assert!(Redirect::init(&serde_json::json!({ "canonical_host": "root" })).is_err()); - assert!(Redirect::init(&serde_json::json!({ "trailing_slash": "keep" })).is_err()); - assert!(Redirect::init(&serde_json::json!({ "force_https": "yes" })).is_err()); - assert!(Redirect::init(&serde_json::json!({ "host_map": { "a": 1 } })).is_err()); - assert!(Redirect::init(&serde_json::json!({ "forwarded_proto_header": "" })).is_err()); - } -} diff --git a/crates/ephpm-middleware-modules/src/request_id.rs b/crates/ephpm-middleware-modules/src/request_id.rs deleted file mode 100644 index 9d4b794..0000000 --- a/crates/ephpm-middleware-modules/src/request_id.rs +++ /dev/null @@ -1,388 +0,0 @@ -//! `request-id` — ePHPm native middleware that gives every request a stable -//! correlation id, injects it for PHP, and echoes it on the response. -//! -//! Analogous to Caddy's `request_id`, Kong's `correlation-id`, Traefik's -//! request-id plugins, or nginx's `$request_id`. One id per request ties the -//! access log, the PHP application log, and the client's copy of the header -//! together. -//! -//! # Two phases -//! -//! - **Request phase** ([`Middleware::invoke`]) — resolve the id (honor a -//! trusted inbound header, otherwise generate a fresh UUIDv4), inject it as a -//! request header so PHP sees `$_SERVER['HTTP_
']`, and stage the same -//! value as a response header so the dynamic response carries exactly the id -//! PHP logged. -//! - **Response phase** ([`ResponseMiddleware::invoke_response`]) — guarantee -//! the header on responses the request phase never touched (the static-file -//! path runs **no** request phase) and stay idempotent on the PHP path. -//! -//! # Why the request phase also stages the response header -//! -//! The v1 response phase cannot see state its own request phase set — it is -//! handed a request view rebuilt from the *original* inbound headers, and it -//! may run with no preceding `invoke` at all (static files, an upstream -//! short-circuit). So a *generated* id known only to the request phase could -//! not be re-derived in the response phase; regenerating there would echo a -//! different id than PHP received. The request phase therefore carries the id -//! to the response itself (`response_header`), and the response phase only -//! *fills in* the header when it is absent — never overwrites it. -//! -//! Configuration (`[[middleware]] config = { ... }`), all optional: -//! -//! | key | default | meaning | -//! |-----|---------|---------| -//! | `header` (string) | `"X-Request-Id"` | the request/response header name carrying the id | -//! | `trust_inbound` (bool) | `false` | when true, reuse a well-formed inbound `header` value instead of generating; when false, always generate (the inbound value is ignored) | -//! -//! An inbound id is only trusted when it is a short, printable ASCII token -//! (no control characters, no whitespace, ≤ 200 bytes). A trusted value that -//! fails that check is replaced with a generated id rather than reflected — a -//! client must not be able to smuggle CR/LF or oversized junk into logs and -//! downstream headers through a "trusted" correlation id. - -use std::cell::Cell; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use ephpm_middleware::{Middleware, Request, Response, ResponseMiddleware, ResponseView}; - -/// Max length of an inbound id we are willing to reflect. -const MAX_INBOUND_LEN: usize = 200; - -/// Resolved request-id policy, built once at `init`. -pub struct RequestId { - /// Header name carrying the id (as configured; used verbatim on the wire). - header: String, - /// Whether a well-formed inbound value is reused instead of generated. - trust_inbound: bool, -} - -/// Read an optional string config key with a default. -fn opt_string(config: &serde_json::Value, key: &str, default: &str) -> Result { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(default.to_owned()), - Some(serde_json::Value::String(s)) => Ok(s.clone()), - Some(other) => Err(format!("`{key}` must be a string, got {other}")), - } -} - -/// Read an optional boolean config key with a default. -fn opt_bool(config: &serde_json::Value, key: &str, default: bool) -> Result { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(default), - Some(serde_json::Value::Bool(b)) => Ok(*b), - Some(other) => Err(format!("`{key}` must be a boolean, got {other}")), - } -} - -/// True when `id` is a safe correlation token: non-empty, ≤ [`MAX_INBOUND_LEN`] -/// bytes, and every byte a printable ASCII graphic (no controls, no spaces). -fn is_safe_id(id: &str) -> bool { - !id.is_empty() && id.len() <= MAX_INBOUND_LEN && id.bytes().all(|b| b.is_ascii_graphic()) -} - -/// Per-process entropy mixed into every generated id, so two processes (or two -/// restarts) do not produce the same id stream. Seeded once from wall-clock -/// nanos and the address of a stack local. -static SEED: AtomicU64 = AtomicU64::new(0); - -thread_local! { - /// Per-thread SplitMix64 state. Lazily seeded from the process seed, the - /// thread identity, and a monotonic counter so distinct threads never - /// walk the same sequence. - static RNG: Cell = const { Cell::new(0) }; -} - -/// SplitMix64 — a tiny, fast, well-distributed 64-bit generator. Not -/// cryptographic; request ids need collision resistance, not unpredictability. -fn splitmix64(state: &mut u64) -> u64 { - *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = *state; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) -} - -/// Initialise the process seed exactly once. -fn ensure_seed() { - if SEED.load(Ordering::Relaxed) == 0 { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0x1234_5678, |d| d.as_nanos() as u64); - let local = 0u8; - let addr = std::ptr::from_ref(&local) as u64; - let mut s = nanos ^ addr.rotate_left(17) ^ 0xA5A5_5A5A_1234_ABCD; - if s == 0 { - s = 0xDEAD_BEEF_CAFE_F00D; - } - // Racy is fine: any winner leaves a usable non-zero seed. - SEED.store(s, Ordering::Relaxed); - } -} - -/// Draw the next two 64-bit words of randomness from the thread-local RNG. -fn next_u128() -> (u64, u64) { - ensure_seed(); - RNG.with(|cell| { - let mut state = cell.get(); - if state == 0 { - static THREAD_COUNTER: AtomicU64 = AtomicU64::new(1); - let tc = THREAD_COUNTER.fetch_add(1, Ordering::Relaxed); - state = SEED - .load(Ordering::Relaxed) - .wrapping_mul(0x2545_F491_4F6C_DD1D) - .wrapping_add(tc.rotate_left(32)); - if state == 0 { - state = 0x1; - } - } - let hi = splitmix64(&mut state); - let lo = splitmix64(&mut state); - cell.set(state); - (hi, lo) - }) -} - -/// Generate a random UUIDv4 string (`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`). -fn generate_id() -> String { - let (mut hi, mut lo) = next_u128(); - // Version 4 in the high nibble of byte 6. - hi = (hi & 0xFFFF_FFFF_FFFF_0FFF) | 0x0000_0000_0000_4000; - // Variant 10xx in the two high bits of byte 8. - lo = (lo & 0x3FFF_FFFF_FFFF_FFFF) | 0x8000_0000_0000_0000; - let b = |v: u64, shift: u32| ((v >> shift) & 0xFF) as u8; - format!( - "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", - b(hi, 56), - b(hi, 48), - b(hi, 40), - b(hi, 32), - b(hi, 24), - b(hi, 16), - b(hi, 8), - b(hi, 0), - b(lo, 56), - b(lo, 48), - b(lo, 40), - b(lo, 32), - b(lo, 24), - b(lo, 16), - b(lo, 8), - b(lo, 0), - ) -} - -impl RequestId { - /// The id to use for this request: a trusted, well-formed inbound value - /// when `trust_inbound` is on, otherwise a freshly generated UUIDv4. - fn resolve(&self, inbound: Option<&str>) -> String { - if self.trust_inbound - && let Some(v) = inbound - && is_safe_id(v) - { - return v.to_owned(); - } - generate_id() - } -} - -impl Middleware for RequestId { - fn init(config: &serde_json::Value) -> Result { - let header = opt_string(config, "header", "X-Request-Id")?; - if header.is_empty() { - return Err("`header` must not be empty".into()); - } - Ok(Self { header, trust_inbound: opt_bool(config, "trust_inbound", false)? }) - } - - fn invoke(&self, req: &Request<'_>) -> Response { - let id = self.resolve(req.header(&self.header)); - // Inject the request header (PHP sees $_SERVER['HTTP_...']) AND echo the - // same value on the response, so the dynamic path carries exactly the - // id PHP logged. The response phase fills the header in only when it is - // still absent (e.g. the static-file path, which runs no request phase). - Response::rewrite() - .header(self.header.clone(), id.clone()) - .response_header(self.header.clone(), id) - } -} - -impl ResponseMiddleware for RequestId { - fn invoke_response(&self, req: &Request<'_>, resp: &mut ResponseView<'_>) { - // Idempotent: if the header is already present (request phase echoed it, - // or PHP set its own), leave it untouched — never overwrite or duplicate. - if resp.header(&self.header).is_some() { - return; - } - // No request phase ran for this response (static file / short-circuit), - // so honor a trusted inbound value or generate a fresh id. - let id = self.resolve(req.header(&self.header)); - resp.set_header(self.header.clone(), id); - } -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request / Response views by hand. - - use ephpm_middleware::abi::ACTION_REWRITE; - use ephpm_middleware::host::{RequestCtx, ResponseCtx, host_table}; - - use super::*; - - fn init(config: serde_json::Value) -> RequestId { - RequestId::init(&config).expect("init") - } - - fn hdr(name: &str, value: &str) -> (String, String) { - (name.to_owned(), value.to_owned()) - } - - fn invoke(mw: &RequestId, headers: &[(String, String)]) -> Response { - let ctx = RequestCtx::new("GET", "/index.php", "", "203.0.113.9", "example.test", headers); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) - } - - /// Drive the response phase against a fabricated response, returning the - /// resulting `(status, headers, body)`. - fn invoke_response( - mw: &RequestId, - req_headers: &[(String, String)], - resp_status: u16, - resp_headers: Vec<(String, String)>, - resp_body: &[u8], - ) -> (u16, Vec<(String, String)>, Vec) { - let ctx = RequestCtx::new("GET", "/", "", "203.0.113.9", "example.test", req_headers); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - let mut rctx = ResponseCtx::new(resp_status, resp_headers, resp_body.to_vec()); - { - // SAFETY: `rctx` outlives the view; host_table() is 'static. - let mut view = unsafe { ResponseView::from_raw(rctx.as_ptr(), host_table()) }; - mw.invoke_response(&req, &mut view); - let (status, body, set, remove) = view.__into_parts(); - for name in remove { - rctx.remove_header(&name); - } - for (n, v) in set { - rctx.set_header(&n, &v); - } - if let Some(s) = status { - rctx.set_status(s); - } - if let Some(b) = body { - rctx.replace_body(b); - } - } - rctx.into_parts() - } - - fn get<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { - headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()) - } - - fn looks_like_uuid(v: &str) -> bool { - v.len() == 36 && v.as_bytes()[14] == b'4' && v.chars().filter(|c| *c == '-').count() == 4 - } - - // ── request phase ───────────────────────────────────────────────────── - - #[test] - fn generates_and_injects_when_absent() { - let mw = init(serde_json::Value::Null); - let resp = invoke(&mw, &[]); - assert_eq!(resp.__action(), ACTION_REWRITE); - // Request header override (PHP-visible). - let req_id = get(resp.__headers(), "X-Request-Id").expect("request header"); - assert!(looks_like_uuid(req_id), "{req_id}"); - // Response echo — same value. - let resp_id = get(resp.__response_headers(), "X-Request-Id").expect("response header"); - assert_eq!(req_id, resp_id, "PHP and the client must see the same id"); - } - - #[test] - fn ignores_inbound_when_not_trusted() { - let mw = init(serde_json::Value::Null); - let resp = invoke(&mw, &[hdr("X-Request-Id", "client-supplied-123")]); - let id = get(resp.__headers(), "X-Request-Id").unwrap(); - assert_ne!(id, "client-supplied-123"); - assert!(looks_like_uuid(id), "{id}"); - } - - #[test] - fn honors_trusted_inbound() { - let mw = init(serde_json::json!({ "trust_inbound": true })); - let resp = invoke(&mw, &[hdr("X-Request-Id", "abc-123-DEF")]); - assert_eq!(get(resp.__headers(), "X-Request-Id"), Some("abc-123-DEF")); - assert_eq!(get(resp.__response_headers(), "X-Request-Id"), Some("abc-123-DEF")); - } - - #[test] - fn trusted_but_unsafe_inbound_is_regenerated() { - let mw = init(serde_json::json!({ "trust_inbound": true })); - // CR/LF injection attempt — must not be reflected. - let resp = invoke(&mw, &[hdr("X-Request-Id", "bad\r\nInjected: 1")]); - let id = get(resp.__headers(), "X-Request-Id").unwrap(); - assert!(looks_like_uuid(id), "{id}"); - } - - #[test] - fn custom_header_name() { - let mw = init(serde_json::json!({ "header": "X-Correlation-Id", "trust_inbound": true })); - let resp = invoke(&mw, &[hdr("X-Correlation-Id", "corr-1")]); - assert_eq!(get(resp.__headers(), "X-Correlation-Id"), Some("corr-1")); - } - - #[test] - fn generated_ids_are_unique() { - let mw = init(serde_json::Value::Null); - let a = get(invoke(&mw, &[]).__headers(), "X-Request-Id").unwrap().to_owned(); - let b = get(invoke(&mw, &[]).__headers(), "X-Request-Id").unwrap().to_owned(); - assert_ne!(a, b); - } - - // ── response phase ──────────────────────────────────────────────────── - - #[test] - fn response_phase_adds_header_when_absent() { - // Static-file path: no request phase ran, response has no id yet. - let mw = init(serde_json::Value::Null); - let (_status, headers, _body) = invoke_response(&mw, &[], 200, vec![], b"body"); - let id = get(&headers, "X-Request-Id").expect("id added"); - assert!(looks_like_uuid(id), "{id}"); - } - - #[test] - fn response_phase_is_idempotent_when_present() { - // PHP path: the request phase already echoed the id — do not overwrite. - let mw = init(serde_json::Value::Null); - let (_status, headers, _body) = - invoke_response(&mw, &[], 200, vec![hdr("X-Request-Id", "existing-id-42")], b"body"); - assert_eq!(get(&headers, "X-Request-Id"), Some("existing-id-42")); - // Exactly one occurrence — no duplicate. - assert_eq!( - headers.iter().filter(|(n, _)| n.eq_ignore_ascii_case("X-Request-Id")).count(), - 1 - ); - } - - #[test] - fn response_phase_honors_trusted_inbound_on_static_path() { - let mw = init(serde_json::json!({ "trust_inbound": true })); - let (_status, headers, _body) = - invoke_response(&mw, &[hdr("X-Request-Id", "inbound-77")], 200, vec![], b"body"); - assert_eq!(get(&headers, "X-Request-Id"), Some("inbound-77")); - } - - // ── config validation ───────────────────────────────────────────────── - - #[test] - fn bad_config_fails_init() { - assert!(RequestId::init(&serde_json::json!({ "header": "" })).is_err()); - assert!(RequestId::init(&serde_json::json!({ "header": 5 })).is_err()); - assert!(RequestId::init(&serde_json::json!({ "trust_inbound": "yes" })).is_err()); - } -} diff --git a/crates/ephpm-middleware-modules/src/security_headers.rs b/crates/ephpm-middleware-modules/src/security_headers.rs deleted file mode 100644 index ddeab98..0000000 --- a/crates/ephpm-middleware-modules/src/security_headers.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! `security-headers` — ePHPm native middleware that appends standard -//! security headers to every client response. -//! -//! The chain verdict is always `CONTINUE`: PHP runs normally and the headers -//! ride along on whatever response it produces. -//! -//! Configuration (`[[middleware]] config = { ... }`), all optional: -//! -//! | key | default | header | -//! |-----|---------|--------| -//! | `hsts` (bool) | `true` | `Strict-Transport-Security: max-age=63072000; includeSubDomains` | -//! | `csp` (string) | unset | `Content-Security-Policy` | -//! | `frame_options` (string) | `"DENY"` | `X-Frame-Options` (empty string disables) | -//! | `content_type_options` (bool) | `true` | `X-Content-Type-Options: nosniff` | -//! | `referrer_policy` (string) | `"strict-origin-when-cross-origin"` | `Referrer-Policy` (empty string disables) | - -use ephpm_middleware::{Middleware, Request, Response}; - -/// Configured security-header set, built once at `init`. -pub struct SecurityHeaders { - hsts: bool, - csp: Option, - frame_options: Option, - content_type_options: bool, - referrer_policy: Option, -} - -/// Read an optional boolean config key with a default. -fn opt_bool(config: &serde_json::Value, key: &str, default: bool) -> Result { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(default), - Some(serde_json::Value::Bool(b)) => Ok(*b), - Some(other) => Err(format!("`{key}` must be a boolean, got {other}")), - } -} - -/// Read an optional string config key with a default. -fn opt_string( - config: &serde_json::Value, - key: &str, - default: Option<&str>, -) -> Result, String> { - match config.get(key) { - None | Some(serde_json::Value::Null) => Ok(default.map(str::to_owned)), - Some(serde_json::Value::String(s)) => Ok(Some(s.clone())), - Some(other) => Err(format!("`{key}` must be a string, got {other}")), - } -} - -impl Middleware for SecurityHeaders { - fn init(config: &serde_json::Value) -> Result { - Ok(Self { - hsts: opt_bool(config, "hsts", true)?, - csp: opt_string(config, "csp", None)?.filter(|s| !s.is_empty()), - frame_options: opt_string(config, "frame_options", Some("DENY"))? - .filter(|s| !s.is_empty()), - content_type_options: opt_bool(config, "content_type_options", true)?, - referrer_policy: opt_string( - config, - "referrer_policy", - Some("strict-origin-when-cross-origin"), - )? - .filter(|s| !s.is_empty()), - }) - } - - fn invoke(&self, _req: &Request<'_>) -> Response { - let mut r = Response::cont(); - if self.hsts { - r = r.response_header( - "Strict-Transport-Security", - "max-age=63072000; includeSubDomains", - ); - } - if let Some(csp) = &self.csp { - r = r.response_header("Content-Security-Policy", csp.as_str()); - } - if let Some(fo) = &self.frame_options { - r = r.response_header("X-Frame-Options", fo.as_str()); - } - if self.content_type_options { - r = r.response_header("X-Content-Type-Options", "nosniff"); - } - if let Some(rp) = &self.referrer_policy { - r = r.response_header("Referrer-Policy", rp.as_str()); - } - r - } -} - -#[cfg(test)] -mod tests { - #![allow(unsafe_code)] // tests build the FFI Request view by hand. - - use ephpm_middleware::abi::ACTION_CONTINUE; - use ephpm_middleware::host::{RequestCtx, host_table}; - - use super::*; - - fn ctx() -> RequestCtx { - RequestCtx::new("GET", "/index.php", "", "203.0.113.9", "example.test", &[]) - } - - fn invoke_with(config: serde_json::Value) -> Response { - let mw = SecurityHeaders::init(&config).expect("init"); - let ctx = ctx(); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; - mw.invoke(&req) - } - - fn header_value<'a>(resp: &'a Response, name: &str) -> Option<&'a str> { - resp.__response_headers() - .iter() - .find(|(n, _)| n.eq_ignore_ascii_case(name)) - .map(|(_, v)| v.as_str()) - } - - #[test] - fn defaults_emit_four_headers_and_continue() { - let resp = invoke_with(serde_json::Value::Null); - assert_eq!(resp.__action(), ACTION_CONTINUE); - assert_eq!( - header_value(&resp, "Strict-Transport-Security"), - Some("max-age=63072000; includeSubDomains") - ); - assert_eq!(header_value(&resp, "X-Frame-Options"), Some("DENY")); - assert_eq!(header_value(&resp, "X-Content-Type-Options"), Some("nosniff")); - assert_eq!(header_value(&resp, "Referrer-Policy"), Some("strict-origin-when-cross-origin")); - assert_eq!(header_value(&resp, "Content-Security-Policy"), None); - assert_eq!(resp.__response_headers().len(), 4); - } - - #[test] - fn csp_is_emitted_when_configured() { - let resp = invoke_with(serde_json::json!({ "csp": "default-src 'self'" })); - assert_eq!(header_value(&resp, "Content-Security-Policy"), Some("default-src 'self'")); - } - - #[test] - fn knobs_disable_individual_headers() { - let resp = invoke_with(serde_json::json!({ - "hsts": false, - "frame_options": "", - "content_type_options": false, - "referrer_policy": "", - })); - assert_eq!(resp.__action(), ACTION_CONTINUE); - assert!(resp.__response_headers().is_empty(), "{:?}", resp.__response_headers()); - } - - #[test] - fn frame_options_value_is_respected() { - let resp = invoke_with(serde_json::json!({ "frame_options": "SAMEORIGIN" })); - assert_eq!(header_value(&resp, "X-Frame-Options"), Some("SAMEORIGIN")); - } - - #[test] - fn wrong_typed_config_fails_init() { - assert!(SecurityHeaders::init(&serde_json::json!({ "hsts": "yes" })).is_err()); - assert!(SecurityHeaders::init(&serde_json::json!({ "csp": 42 })).is_err()); - } -} diff --git a/crates/ephpm-middleware-ratelimit/Cargo.toml b/crates/ephpm-middleware-ratelimit/Cargo.toml deleted file mode 100644 index 0be59e2..0000000 --- a/crates/ephpm-middleware-ratelimit/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "ephpm-middleware-ratelimit" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "ePHPm native middleware: fixed-window per-client rate limiting over the embedded KV store (loadable cdylib; implementation in ephpm-middleware-modules)" - -[lib] -# cdylib = the loadable module for the dlopen lane; rlib for tests + the -# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision -# rationale behind the impl/shell split. -crate-type = ["cdylib", "rlib"] - -[dependencies] -ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true - -[dev-dependencies] -# The fail-open integration test needs its own process (the host KV store is -# process-global and can only be set once) and drives `RateLimit` directly. -ephpm-middleware = { workspace = true, features = ["host"] } -serde_json.workspace = true - -[lints] -workspace = true diff --git a/crates/ephpm-middleware-ratelimit/src/lib.rs b/crates/ephpm-middleware-ratelimit/src/lib.rs deleted file mode 100644 index abe314e..0000000 --- a/crates/ephpm-middleware-ratelimit/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! `ratelimit` — loadable cdylib shell around the shared implementation in -//! [`ephpm_middleware_modules::ratelimit`]. -//! -//! The middleware itself (fixed-window per-client rate limiting over the -//! embedded KV store, docs and tests included) lives in -//! `ephpm-middleware-modules`. This crate only adds the C ABI exports -//! (`declare!`) so the module can be `dlopen`ed by dynamically linked ePHPm -//! builds. - -pub use ephpm_middleware_modules::ratelimit::RateLimit; - -ephpm_middleware::declare!(RateLimit); diff --git a/crates/ephpm-middleware-ratelimit/tests/fail_open.rs b/crates/ephpm-middleware-ratelimit/tests/fail_open.rs deleted file mode 100644 index f97f9c8..0000000 --- a/crates/ephpm-middleware-ratelimit/tests/fail_open.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Fail-open behaviour when the KV store is unavailable. -//! -//! This lives in its own integration-test binary (= its own process) because -//! the host KV store is process-global: the unit tests wire one in, and once -//! set it cannot be unset. Here `set_kv_store` is never called, so every -//! `kv_incr` fails — the limiter must let requests through. -#![allow(unsafe_code)] // builds the FFI Request view by hand, like the unit tests. - -use ephpm_middleware::Middleware; -use ephpm_middleware::abi::ACTION_CONTINUE; -use ephpm_middleware::host::{RequestCtx, host_table}; -use ephpm_middleware_ratelimit::RateLimit; - -#[test] -fn kv_unavailable_fails_open() { - let mw = RateLimit::init(&serde_json::json!({ "per_ip_rps": 1, "burst": 0 })).expect("init"); - let ctx = RequestCtx::new("GET", "/api/x", "", "198.51.100.9", "vhost-open", &[]); - // SAFETY: `ctx` outlives the view; host_table() is 'static. - let req = unsafe { ephpm_middleware::Request::from_raw(ctx.as_abi(), host_table()) }; - // Way past the allowance — every single one must still continue. - for _ in 0..50 { - assert_eq!(mw.invoke(&req).__action(), ACTION_CONTINUE); - } -} diff --git a/crates/ephpm-middleware-redirect/Cargo.toml b/crates/ephpm-middleware-redirect/Cargo.toml index 78842dc..137cc99 100644 --- a/crates/ephpm-middleware-redirect/Cargo.toml +++ b/crates/ephpm-middleware-redirect/Cargo.toml @@ -5,17 +5,20 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "ePHPm native middleware: canonical-host / scheme / trailing-slash 301-308 redirects (loadable cdylib; implementation in ephpm-middleware-modules)" +description = "Example ePHPm native middleware: canonical-host / scheme / trailing-slash 301-308 redirects" [lib] -# cdylib = the loadable module for the dlopen lane; rlib for tests + the -# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision -# rationale behind the impl/shell split. +# cdylib = the loadable module ePHPm dlopen()s; rlib so the unit tests can link +# the crate as a library. crate-type = ["cdylib", "rlib"] [dependencies] ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true +serde_json.workspace = true + +[dev-dependencies] +# `host` gives the tests RequestCtx / host_table to fabricate a request. +ephpm-middleware = { workspace = true, features = ["host"] } [lints] workspace = true diff --git a/crates/ephpm-middleware-redirect/src/lib.rs b/crates/ephpm-middleware-redirect/src/lib.rs index 5ca2ae9..d5252f0 100644 --- a/crates/ephpm-middleware-redirect/src/lib.rs +++ b/crates/ephpm-middleware-redirect/src/lib.rs @@ -1,11 +1,558 @@ -//! `redirect` — loadable cdylib shell around the shared implementation in -//! [`ephpm_middleware_modules::redirect`]. +//! # Example: redirect (canonical URL) middleware //! -//! The middleware itself (canonical-host / scheme / trailing-slash redirect -//! logic, docs and tests included) lives in `ephpm-middleware-modules`. This -//! crate only adds the C ABI exports (`declare!`) so the module can be -//! `dlopen`ed by dynamically linked ePHPm builds. +//! A self-contained, loadable ePHPm native-middleware module, kept as a +//! reference you can copy to write your own. The official build of this +//! module is compiled into ePHPm itself; nothing here needs to be fetched or +//! installed separately. See the repository README for the ABI, the +//! `declare!` macro, and the request- vs response-phase model. +//! + +//! `redirect` — ePHPm native middleware that enforces canonical URLs with a +//! single `301`/`308` redirect **before** PHP runs. +//! +//! Analogous to Traefik's `redirectscheme`/`redirectregex`, Caddy's `redir`, +//! Cloudflare redirect rules, or an nginx `return 301`. It composes several +//! canonicalization rules — scheme, host, trailing slash — computes the final +//! canonical URL **once**, and redirects a single time only when the request +//! is not already canonical (so it can never loop). +//! +//! Configuration (`[[middleware]] config = { ... }`), all optional: +//! +//! | key | default | meaning | +//! |-----|---------|---------| +//! | `force_https` (bool) | `false` | redirect `http` → `https` | +//! | `canonical_host` (string) | unset | `"www"` forces the apex → `www.`; `"apex"` (alias `"non-www"`) strips a leading `www.` | +//! | `host_map` (object) | unset | explicit `source-host` → `canonical-host` map (exact, case-insensitive key); wins over `canonical_host` on a match | +//! | `trailing_slash` (string) | unset | `"add"` appends a `/` (except the root and paths whose last segment looks like a file, i.e. contains a `.`); `"strip"` removes trailing `/` (except the root) | +//! | `status` (integer) | `308` | redirect status — `301` or `308`; `308` preserves the request method | +//! | `forwarded_proto_header` (string) | `"X-Forwarded-Proto"` | header the current scheme is derived from | +//! +//! **Scheme derivation.** The v1 middleware ABI exposes no request scheme or +//! "is secure" flag, so the current scheme is read from +//! `forwarded_proto_header` (default `X-Forwarded-Proto`); a request with no +//! such header is treated as `http`. Behind a TLS-terminating proxy the proxy +//! **must** set that header, or `force_https` would redirect an +//! already-secure request and loop — the same requirement nginx/Traefik place +//! on the operator. +//! +//! **Scope.** Config is per-mount (there is no per-vhost config idiom in the +//! ABI). Use `host_map` to canonicalize several hosts from one mount; the +//! request's own `Host` header is what every rule is computed against. + +use ephpm_middleware::{Middleware, Request, Response}; + +/// The canonical-host policy. +#[derive(Clone, Copy, PartialEq, Eq)] +enum HostPolicy { + /// Force the apex form to `www.` (`example.com` → `www.example.com`). + Www, + /// Strip a leading `www.` (`www.example.com` → `example.com`). + Apex, +} + +/// The trailing-slash policy. +#[derive(Clone, Copy, PartialEq, Eq)] +enum SlashPolicy { + /// Append a trailing `/` (except the root and file-like paths). + Add, + /// Remove trailing `/` (except the root). + Strip, +} + +/// Redirect policy, built once at `init`. +pub struct Redirect { + force_https: bool, + canonical_host: Option, + /// Exact source→canonical host map; keys are stored lower-cased. + host_map: Vec<(String, String)>, + trailing_slash: Option, + status: u16, + forwarded_proto_header: String, +} + +/// Read an optional boolean config key with a default. +fn opt_bool(config: &serde_json::Value, key: &str, default: bool) -> Result { + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(default), + Some(serde_json::Value::Bool(b)) => Ok(*b), + Some(other) => Err(format!("`{key}` must be a boolean, got {other}")), + } +} + +/// Read an optional string config key with a default. +fn opt_string(config: &serde_json::Value, key: &str, default: &str) -> Result { + match config.get(key) { + None | Some(serde_json::Value::Null) => Ok(default.to_owned()), + Some(serde_json::Value::String(s)) => Ok(s.clone()), + Some(other) => Err(format!("`{key}` must be a string, got {other}")), + } +} + +/// True when `host` begins with a `www.` label (case-insensitive). +fn has_www_prefix(host: &str) -> bool { + host.len() > 4 && host[..4].eq_ignore_ascii_case("www.") +} + +/// The host with a leading `www.` removed, or `None` when there is no such +/// prefix (or removing it would leave the host empty). +fn strip_www_prefix(host: &str) -> Option<&str> { + if has_www_prefix(host) { + let rest = &host[4..]; + (!rest.is_empty()).then_some(rest) + } else { + None + } +} + +/// Split a `Host` header value into `(host, Option)`. Handles bracketed +/// IPv6 literals (`[::1]:8080`) and only treats an all-digit tail after the +/// last `:` as a port. +fn split_host(value: &str) -> (&str, Option<&str>) { + if value.starts_with('[') { + // Bracketed IPv6 literal: the host is everything through `]`. + if let Some(idx) = value.find(']') { + let host = &value[..=idx]; + let port = value[idx + 1..].strip_prefix(':').filter(|p| !p.is_empty()); + return (host, port); + } + return (value, None); + } + match value.rsplit_once(':') { + Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => { + (host, Some(port)) + } + _ => (value, None), + } +} + +/// True when the last path segment looks like a file (contains a `.`). +fn last_segment_has_dot(path: &str) -> bool { + path.rsplit('/').next().is_some_and(|seg| seg.contains('.')) +} -pub use ephpm_middleware_modules::redirect::Redirect; +impl Redirect { + /// The canonical host for `host` (case preserved when nothing applies). + fn canonical_host(&self, host: &str) -> String { + for (src, dst) in &self.host_map { + if host.eq_ignore_ascii_case(src) { + return dst.clone(); + } + } + match self.canonical_host { + Some(HostPolicy::Www) => { + if has_www_prefix(host) { + host.to_owned() + } else { + format!("www.{host}") + } + } + Some(HostPolicy::Apex) => strip_www_prefix(host).unwrap_or(host).to_owned(), + None => host.to_owned(), + } + } + /// The canonical path for `path` under the trailing-slash policy. + fn canonical_path(&self, path: &str) -> String { + match self.trailing_slash { + Some(SlashPolicy::Strip) => { + if path.len() > 1 && path.ends_with('/') { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { "/".to_owned() } else { trimmed.to_owned() } + } else { + path.to_owned() + } + } + Some(SlashPolicy::Add) => { + if path.ends_with('/') || last_segment_has_dot(path) { + path.to_owned() + } else { + format!("{path}/") + } + } + None => path.to_owned(), + } + } + + /// The current request scheme, derived from `forwarded_proto_header` + /// (first value of a comma list); `http` when the header is absent. + fn current_scheme<'a>(&self, req: &'a Request<'_>) -> &'a str { + match req.header(&self.forwarded_proto_header) { + Some(v) => { + let first = v.split(',').next().unwrap_or(v).trim(); + if first.eq_ignore_ascii_case("https") { "https" } else { "http" } + } + None => "http", + } + } +} + +impl Middleware for Redirect { + fn init(config: &serde_json::Value) -> Result { + let canonical_host = match config.get("canonical_host") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(s)) => match s.to_ascii_lowercase().as_str() { + "www" => Some(HostPolicy::Www), + "apex" | "non-www" => Some(HostPolicy::Apex), + other => { + return Err(format!( + "`canonical_host` must be \"www\" or \"apex\", got \"{other}\"" + )); + } + }, + Some(other) => return Err(format!("`canonical_host` must be a string, got {other}")), + }; + + let host_map = match config.get("host_map") { + None | Some(serde_json::Value::Null) => Vec::new(), + Some(serde_json::Value::Object(map)) => { + let mut out = Vec::with_capacity(map.len()); + for (k, v) in map { + let dst = v.as_str().ok_or_else(|| { + format!("`host_map` values must be strings, got {v} for key `{k}`") + })?; + if dst.is_empty() { + return Err(format!("`host_map` value for key `{k}` must not be empty")); + } + out.push((k.to_ascii_lowercase(), dst.to_owned())); + } + out + } + Some(other) => return Err(format!("`host_map` must be an object, got {other}")), + }; + + let trailing_slash = match config.get("trailing_slash") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(s)) => match s.to_ascii_lowercase().as_str() { + "add" => Some(SlashPolicy::Add), + "strip" => Some(SlashPolicy::Strip), + other => { + return Err(format!( + "`trailing_slash` must be \"add\" or \"strip\", got \"{other}\"" + )); + } + }, + Some(other) => return Err(format!("`trailing_slash` must be a string, got {other}")), + }; + + let status = match config.get("status") { + None | Some(serde_json::Value::Null) => 308, + Some(v) => { + let n = + v.as_u64().ok_or_else(|| format!("`status` must be 301 or 308, got {v}"))?; + if n != 301 && n != 308 { + return Err(format!("`status` must be 301 or 308, got {n}")); + } + u16::try_from(n).unwrap_or(308) + } + }; + + let forwarded_proto_header = + opt_string(config, "forwarded_proto_header", "X-Forwarded-Proto")?; + if forwarded_proto_header.is_empty() { + return Err("`forwarded_proto_header` must not be empty".into()); + } + + Ok(Self { + force_https: opt_bool(config, "force_https", false)?, + canonical_host, + host_map, + trailing_slash, + status, + forwarded_proto_header, + }) + } + + fn invoke(&self, req: &Request<'_>) -> Response { + // Without an authority we cannot build an absolute Location; pass through. + let Some(host_hdr) = req.header("Host").filter(|h| !h.is_empty()) else { + return Response::cont(); + }; + let (host, port) = split_host(host_hdr); + + let scheme_cur = self.current_scheme(req); + let scheme_can = if self.force_https { "https" } else { scheme_cur }; + + let host_can = self.canonical_host(host); + + let path_cur = { + let p = req.path(); + if p.is_empty() { "/" } else { p } + }; + let path_can = self.canonical_path(path_cur); + + let changed = scheme_cur != scheme_can + || !host.eq_ignore_ascii_case(&host_can) + || path_cur != path_can; + if !changed { + return Response::cont(); + } + + let query = req.query(); + let mut location = String::with_capacity( + scheme_can.len() + 3 + host_can.len() + path_can.len() + query.len() + 8, + ); + location.push_str(scheme_can); + location.push_str("://"); + location.push_str(&host_can); + if let Some(p) = port { + location.push(':'); + location.push_str(p); + } + location.push_str(&path_can); + if !query.is_empty() { + location.push('?'); + location.push_str(query); + } + + Response::respond(self.status, "").header("Location", location) + } +} + +// ── C ABI export ──────────────────────────────────────────────────────────── +// `declare!` generates the `extern "C"` entry points ePHPm's module loader +// calls (init / invoke / free) and bakes in the ABI-major compatibility check, +// so a module built against the wrong host ABI refuses to load instead of +// corrupting memory. This is the ONLY line that turns the plain `Middleware` +// impl above into a loadable `.so`/`.dylib`/`.dll`. ephpm_middleware::declare!(Redirect); + +#[cfg(test)] +mod tests { + #![allow(unsafe_code)] // tests build the FFI Request view by hand. + + use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_RESPOND}; + use ephpm_middleware::host::{RequestCtx, host_table}; + + use super::*; + + fn redirect(config: serde_json::Value) -> Redirect { + Redirect::init(&config).expect("init") + } + + fn invoke( + mw: &Redirect, + method: &str, + path: &str, + query: &str, + headers: &[(String, String)], + ) -> Response { + let ctx = RequestCtx::new(method, path, query, "203.0.113.9", "example.test", headers); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { Request::from_raw(ctx.as_abi(), host_table()) }; + mw.invoke(&req) + } + + fn hdr(name: &str, value: &str) -> (String, String) { + (name.to_owned(), value.to_owned()) + } + + fn location(resp: &Response) -> Option<&str> { + resp.__headers() + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case("Location")) + .map(|(_, v)| v.as_str()) + } + + // ── force_https ──────────────────────────────────────────────────────── + + #[test] + fn force_https_redirects_http_to_https() { + let mw = redirect(serde_json::json!({ "force_https": true })); + let resp = invoke(&mw, "GET", "/page", "", &[hdr("Host", "example.com")]); + assert_eq!(resp.__action(), ACTION_RESPOND); + assert_eq!(resp.__status(), 308); + assert_eq!(location(&resp), Some("https://example.com/page")); + } + + #[test] + fn force_https_is_a_noop_when_already_https() { + let mw = redirect(serde_json::json!({ "force_https": true })); + let resp = invoke( + &mw, + "GET", + "/page", + "", + &[hdr("Host", "example.com"), hdr("X-Forwarded-Proto", "https")], + ); + assert_eq!(resp.__action(), ACTION_CONTINUE); + } + + #[test] + fn scheme_read_from_first_forwarded_proto_value() { + let mw = redirect(serde_json::json!({ "force_https": true })); + // A list "https, http" means the edge saw https — no redirect. + let resp = invoke( + &mw, + "GET", + "/", + "", + &[hdr("Host", "example.com"), hdr("X-Forwarded-Proto", "https, http")], + ); + assert_eq!(resp.__action(), ACTION_CONTINUE); + } + + #[test] + fn custom_forwarded_proto_header_is_honored() { + let mw = redirect(serde_json::json!({ + "force_https": true, + "forwarded_proto_header": "X-Scheme", + })); + let resp = + invoke(&mw, "GET", "/", "", &[hdr("Host", "example.com"), hdr("X-Scheme", "https")]); + assert_eq!(resp.__action(), ACTION_CONTINUE); + } + + // ── canonical host ───────────────────────────────────────────────────── + + #[test] + fn www_to_apex() { + let mw = redirect(serde_json::json!({ "canonical_host": "apex" })); + let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "www.example.com")]); + assert_eq!(resp.__action(), ACTION_RESPOND); + assert_eq!(location(&resp), Some("http://example.com/p")); + } + + #[test] + fn apex_already_canonical_continues() { + let mw = redirect(serde_json::json!({ "canonical_host": "apex" })); + let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "example.com")]); + assert_eq!(resp.__action(), ACTION_CONTINUE); + } + + #[test] + fn apex_to_www_other_direction() { + let mw = redirect(serde_json::json!({ "canonical_host": "www" })); + let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "example.com")]); + assert_eq!(resp.__action(), ACTION_RESPOND); + assert_eq!(location(&resp), Some("http://www.example.com/p")); + } + + #[test] + fn www_already_canonical_continues() { + let mw = redirect(serde_json::json!({ "canonical_host": "www" })); + let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "www.example.com")]); + assert_eq!(resp.__action(), ACTION_CONTINUE); + } + + #[test] + fn host_map_exact_match_wins() { + let mw = redirect(serde_json::json!({ + "host_map": { "old.example.com": "new.example.com" }, + })); + let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "Old.Example.com")]); + assert_eq!(location(&resp), Some("http://new.example.com/p")); + } + + #[test] + fn host_case_only_difference_does_not_redirect() { + // No policy → an uppercase Host is not forced to lowercase (no loop-y + // cosmetic redirect). + let mw = redirect(serde_json::json!({ "force_https": false })); + let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "Example.COM")]); + assert_eq!(resp.__action(), ACTION_CONTINUE); + } + + // ── trailing slash ───────────────────────────────────────────────────── + + #[test] + fn trailing_slash_add() { + let mw = redirect(serde_json::json!({ "trailing_slash": "add" })); + let resp = invoke(&mw, "GET", "/blog", "", &[hdr("Host", "example.com")]); + assert_eq!(location(&resp), Some("http://example.com/blog/")); + } + + #[test] + fn trailing_slash_add_skips_file_like_and_root() { + let mw = redirect(serde_json::json!({ "trailing_slash": "add" })); + assert_eq!( + invoke(&mw, "GET", "/style.css", "", &[hdr("Host", "example.com")]).__action(), + ACTION_CONTINUE + ); + assert_eq!( + invoke(&mw, "GET", "/", "", &[hdr("Host", "example.com")]).__action(), + ACTION_CONTINUE + ); + } + + #[test] + fn trailing_slash_strip() { + let mw = redirect(serde_json::json!({ "trailing_slash": "strip" })); + let resp = invoke(&mw, "GET", "/blog/", "", &[hdr("Host", "example.com")]); + assert_eq!(location(&resp), Some("http://example.com/blog")); + } + + #[test] + fn trailing_slash_strip_keeps_root() { + let mw = redirect(serde_json::json!({ "trailing_slash": "strip" })); + let resp = invoke(&mw, "GET", "/", "", &[hdr("Host", "example.com")]); + assert_eq!(resp.__action(), ACTION_CONTINUE); + } + + // ── query preservation, status, combined rules ───────────────────────── + + #[test] + fn query_string_is_preserved() { + let mw = redirect(serde_json::json!({ "force_https": true })); + let resp = invoke(&mw, "GET", "/s", "q=1&x=2", &[hdr("Host", "example.com")]); + assert_eq!(location(&resp), Some("https://example.com/s?q=1&x=2")); + } + + #[test] + fn status_301_selection() { + let mw = redirect(serde_json::json!({ "force_https": true, "status": 301 })); + let resp = invoke(&mw, "GET", "/", "", &[hdr("Host", "example.com")]); + assert_eq!(resp.__status(), 301); + } + + #[test] + fn default_status_is_308() { + let mw = redirect(serde_json::json!({ "force_https": true })); + let resp = invoke(&mw, "GET", "/", "", &[hdr("Host", "example.com")]); + assert_eq!(resp.__status(), 308); + } + + #[test] + fn all_rules_collapse_into_one_redirect() { + let mw = redirect(serde_json::json!({ + "force_https": true, + "canonical_host": "apex", + "trailing_slash": "add", + })); + let resp = invoke(&mw, "GET", "/blog", "page=2", &[hdr("Host", "www.example.com")]); + assert_eq!(resp.__action(), ACTION_RESPOND); + assert_eq!(location(&resp), Some("https://example.com/blog/?page=2")); + } + + #[test] + fn port_is_preserved() { + let mw = redirect(serde_json::json!({ "canonical_host": "apex" })); + let resp = invoke(&mw, "GET", "/p", "", &[hdr("Host", "www.example.com:8080")]); + assert_eq!(location(&resp), Some("http://example.com:8080/p")); + } + + #[test] + fn already_canonical_no_config_continues() { + let mw = redirect(serde_json::Value::Null); + let resp = invoke(&mw, "GET", "/p", "a=1", &[hdr("Host", "example.com")]); + assert_eq!(resp.__action(), ACTION_CONTINUE); + } + + #[test] + fn missing_host_header_passes_through() { + let mw = redirect(serde_json::json!({ "force_https": true })); + let resp = invoke(&mw, "GET", "/p", "", &[]); + assert_eq!(resp.__action(), ACTION_CONTINUE); + } + + // ── config validation ────────────────────────────────────────────────── + + #[test] + fn bad_config_fails_init() { + assert!(Redirect::init(&serde_json::json!({ "status": 302 })).is_err()); + assert!(Redirect::init(&serde_json::json!({ "canonical_host": "root" })).is_err()); + assert!(Redirect::init(&serde_json::json!({ "trailing_slash": "keep" })).is_err()); + assert!(Redirect::init(&serde_json::json!({ "force_https": "yes" })).is_err()); + assert!(Redirect::init(&serde_json::json!({ "host_map": { "a": 1 } })).is_err()); + assert!(Redirect::init(&serde_json::json!({ "forwarded_proto_header": "" })).is_err()); + } +} diff --git a/crates/ephpm-middleware-request-id/Cargo.toml b/crates/ephpm-middleware-request-id/Cargo.toml deleted file mode 100644 index 933d300..0000000 --- a/crates/ephpm-middleware-request-id/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "ephpm-middleware-request-id" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "ePHPm native middleware: per-request correlation id — generate/propagate X-Request-Id for PHP and echo it on the response (request + response phase; loadable cdylib; implementation in ephpm-middleware-modules)" - -[lib] -# cdylib = the loadable module for the dlopen lane; rlib for tests + the -# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision -# rationale behind the impl/shell split. -crate-type = ["cdylib", "rlib"] - -[dependencies] -ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true - -[lints] -workspace = true diff --git a/crates/ephpm-middleware-request-id/src/lib.rs b/crates/ephpm-middleware-request-id/src/lib.rs deleted file mode 100644 index da83ba8..0000000 --- a/crates/ephpm-middleware-request-id/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! `request-id` — loadable cdylib shell around the shared implementation in -//! [`ephpm_middleware_modules::request_id`]. -//! -//! The middleware itself (id generation/propagation, the request + response -//! phase logic, docs and tests included) lives in `ephpm-middleware-modules`. -//! This crate only adds the C ABI exports (`declare!(RequestId, response)`, so -//! both the request and response phase are exported) for the `dlopen` lane. - -pub use ephpm_middleware_modules::request_id::RequestId; - -ephpm_middleware::declare!(RequestId, response); diff --git a/crates/ephpm-middleware-security-headers/Cargo.toml b/crates/ephpm-middleware-security-headers/Cargo.toml deleted file mode 100644 index 7d4daa9..0000000 --- a/crates/ephpm-middleware-security-headers/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "ephpm-middleware-security-headers" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "ePHPm native middleware: standard security response headers (loadable cdylib; implementation in ephpm-middleware-modules)" - -[lib] -# cdylib = the loadable module for the dlopen lane; rlib for tests + the -# `vendor-middleware` feature. See ephpm-middleware-jwt for the symbol-collision -# rationale behind the impl/shell split. -crate-type = ["cdylib", "rlib"] - -[dependencies] -ephpm-middleware.workspace = true -ephpm-middleware-modules.workspace = true - -[lints] -workspace = true diff --git a/crates/ephpm-middleware-security-headers/src/lib.rs b/crates/ephpm-middleware-security-headers/src/lib.rs deleted file mode 100644 index a192fe1..0000000 --- a/crates/ephpm-middleware-security-headers/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! `security-headers` — loadable cdylib shell around the shared -//! implementation in [`ephpm_middleware_modules::security_headers`]. -//! -//! The middleware itself (standard security response headers, docs and tests -//! included) lives in `ephpm-middleware-modules`. This crate only adds the C -//! ABI exports (`declare!`) so the module can be `dlopen`ed by dynamically -//! linked ePHPm builds. - -pub use ephpm_middleware_modules::security_headers::SecurityHeaders; - -ephpm_middleware::declare!(SecurityHeaders);