Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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.
#
# 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.

on:
pull_request:
push:
branches: [main]

# Cancel superseded runs on the same ref (a new push to a PR aborts the old
# in-flight run) so we don't burn hosted minutes on stale commits.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

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.
CARGO_NET_GIT_FETCH_WITH_CLI: "true"

permissions:
contents: read

jobs:
# ── Formatting: rustfmt uses unstable options, so nightly is required ────────
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nightly + rustfmt
run: |
rustup toolchain install nightly --profile minimal --component rustfmt
rustup default nightly
- run: cargo +nightly fmt --all -- --check

# ── Lint: pedantic clippy, warnings are errors (must not be weakened) ────────
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install stable + clippy
run: |
rustup toolchain install stable --profile minimal --component clippy
rustup default stable
- run: cargo clippy --workspace --all-targets -- -D warnings

# ── Tests: ~80 unit tests + the fail-open / deny integration binaries ────────
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install stable
run: |
rustup toolchain install stable --profile minimal
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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install stable
run: |
rustup toolchain install stable --profile minimal
rustup default stable
- run: cargo build --workspace --release
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions crates/ephpm-middleware-ip-allowlist/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,13 @@ crate-type = ["cdylib", "rlib"]
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
57 changes: 57 additions & 0 deletions crates/ephpm-middleware-ip-allowlist/tests/deny.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! 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);
}
Loading