mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-01 04:26:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13a98ae702 | ||
|
|
77ed64bb88 | ||
|
|
bf2e7a0892 | ||
|
|
d6457fa74f | ||
|
|
7d0a110f4e | ||
|
|
2fdc831ce3 | ||
|
|
fbff52d85e | ||
|
|
93800a503e | ||
|
|
cdda660f10 | ||
|
|
49163befd5 | ||
|
|
af847c68b5 | ||
|
|
a718cef8ce | ||
|
|
cbbdf2c13c | ||
|
|
24bf4d46d4 | ||
|
|
cf129cde77 | ||
|
|
4315328f2b | ||
|
|
44e04eb2e2 | ||
|
|
fa49dc1210 | ||
|
|
0eea7dae13 | ||
|
|
1077fd6a7d | ||
|
|
0e42c789be | ||
|
|
611f045d33 | ||
|
|
0cb5574077 | ||
|
|
989ae65fc5 | ||
|
|
1eabe08575 | ||
|
|
9846e85705 | ||
|
|
41ce64ba82 | ||
|
|
7cbe1d3d4e | ||
|
|
08a226fb63 | ||
|
|
be5deee814 | ||
|
|
bb9bca4d83 | ||
|
|
2bc0345174 | ||
|
|
9b7a27f219 | ||
|
|
f478f51afe | ||
|
|
9d92cfeab4 | ||
|
|
6319c7f577 | ||
|
|
d4a2504f99 | ||
|
|
bf173d8d98 | ||
|
|
34a2561af7 | ||
|
|
c8a0ac5fca | ||
|
|
55331a80b5 | ||
|
|
3181861341 | ||
|
|
0f27bdbd2c | ||
|
|
d3eaad543d | ||
|
|
2a6039995d | ||
|
|
1f149bda32 | ||
|
|
38a60c61da | ||
|
|
3d0a388511 | ||
|
|
5d3a3d7cad | ||
|
|
e9ca741e53 | ||
|
|
5a11cf091d | ||
|
|
aab149e215 | ||
|
|
73e6917341 | ||
|
|
300deb0476 | ||
|
|
700ac581ee | ||
|
|
98560594cf | ||
|
|
ebf34ae712 | ||
|
|
291c4312dc | ||
|
|
d21f818659 | ||
|
|
6c52b0e01e | ||
|
|
428773490f | ||
|
|
56f00058d4 | ||
|
|
e578a11b7a | ||
|
|
99cb0a51fd | ||
|
|
e9ca16f3a1 | ||
|
|
ab750f60b3 | ||
|
|
226c6994d3 | ||
|
|
4f55a281ac | ||
|
|
2ef36c0071 | ||
|
|
5be7c6d0cb | ||
|
|
8ca2362e6c | ||
|
|
75d7077880 |
@@ -5,6 +5,23 @@ junit = { path = "junit.xml" }
|
||||
# occasional msg1 under burst load even with the per-edge repair loop.
|
||||
# Allow a retry rather than failing the whole CI run on a single
|
||||
# dropped packet.
|
||||
#
|
||||
# Deliberately scoped to [profile.ci] and NOT applied locally, which makes
|
||||
# the two gates disagree: the hosted runner retries a flaky test twice, the
|
||||
# local sweep fails on the first failure. The asymmetry is intended and this
|
||||
# is the record of why, since an undocumented one is indistinguishable from
|
||||
# an oversight.
|
||||
#
|
||||
# It is here for shared-runner packet loss, a property of the hosted
|
||||
# environment and not of the code. Applying it locally would suppress a real
|
||||
# local flake, and a failure that only reproduces under load is a robustness
|
||||
# bug to fix rather than to retry past. Keeping the local sweep strict is
|
||||
# what makes it the sharper of the two gates.
|
||||
#
|
||||
# The cost, stated rather than hidden: a test that fails once and passes on
|
||||
# retry is reported green here with no separate signal, so a genuine
|
||||
# intermittent failure can be absorbed. If that starts mattering, the fix is
|
||||
# to surface retried-but-passed tests, not to drop the retries.
|
||||
retries = 2
|
||||
|
||||
[test-groups]
|
||||
|
||||
+29
-87
@@ -38,12 +38,12 @@ env:
|
||||
# unreliable on GitHub-hosted runners.
|
||||
# tor-directory — same; live Tor dependency.
|
||||
#
|
||||
# Granularity-only differences (same coverage, different matrix shape —
|
||||
# NOT a divergence):
|
||||
# deb-install — split here into per-distro legs (debian12/debian13/
|
||||
# ubuntu22/ubuntu24/ubuntu26) for parallelism; local runs the
|
||||
# same distro set in one suite.
|
||||
# dns-resolver — single leg here; runs all scenarios (same as local).
|
||||
# The two runners express the same work in different matrix shapes, and the
|
||||
# parity guard compares through that shape rather than around it: chaos legs
|
||||
# are compared per scenario (and per flag) via their `scenario:` field,
|
||||
# deb-install legs per distro. The one leg still compared at leg granularity
|
||||
# is dns-resolver — a single leg here, running all of its scenarios
|
||||
# internally, exactly as the local suite does.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -52,6 +52,29 @@ env:
|
||||
# Builds on Linux x86_64, Linux aarch64, and macOS.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
ci-parity:
|
||||
name: CI parity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Python deps
|
||||
run: pip3 install --quiet pyyaml
|
||||
- name: Check local and GitHub runners cover the same work
|
||||
run: bash testing/check-ci-parity.sh
|
||||
- name: Check test log matchers against the strings src/ emits
|
||||
run: python3 testing/check-log-strings.py
|
||||
- name: Check no tested function's exit status is a log call's
|
||||
run: python3 testing/check-trailing-log.py
|
||||
- name: Check nothing resolves the shared mutable test image
|
||||
run: bash testing/check-image-scoping.sh
|
||||
# Hermetic: synthetic ping functions, no containers, ~45s. Lives beside
|
||||
# the other two so both runners gate on it identically — putting it in
|
||||
# only one would create exactly the drift check-ci-parity.sh exists to
|
||||
# catch, and it is invisible to that checker either way since it is not
|
||||
# a matrix suite.
|
||||
- name: Run convergence-gate unit tests
|
||||
run: bash testing/lib/wait-converge-test.sh
|
||||
|
||||
fmt:
|
||||
name: Format check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -361,12 +384,6 @@ jobs:
|
||||
- suite: rekey-outbound-only
|
||||
type: rekey-outbound-only
|
||||
topology: rekey-outbound-only
|
||||
# ── Inbound max_peers admission-cap test ───────────────────────
|
||||
- suite: admission-cap
|
||||
type: admission-cap
|
||||
topology: mesh
|
||||
- suite: acl-allowlist
|
||||
type: acl-allowlist
|
||||
# ── Firewall baseline (fips0 nftables default-deny) ────────────
|
||||
- suite: firewall
|
||||
type: firewall
|
||||
@@ -375,9 +392,6 @@ jobs:
|
||||
type: gateway
|
||||
topology: gateway
|
||||
# ── Chaos / stochastic scenarios ───────────────────────────────────
|
||||
- suite: chaos-smoke-10
|
||||
type: chaos
|
||||
scenario: smoke-10
|
||||
- suite: churn-mixed-10
|
||||
type: chaos
|
||||
scenario: churn-mixed
|
||||
@@ -391,30 +405,9 @@ jobs:
|
||||
- suite: tcp-mesh
|
||||
type: chaos
|
||||
scenario: tcp-mesh
|
||||
- suite: bottleneck-parent
|
||||
type: chaos
|
||||
scenario: bottleneck-parent
|
||||
- suite: cost-avoidance
|
||||
type: chaos
|
||||
scenario: cost-avoidance
|
||||
- suite: cost-reeval
|
||||
type: chaos
|
||||
scenario: cost-reeval
|
||||
- suite: cost-stability
|
||||
type: chaos
|
||||
scenario: cost-stability
|
||||
- suite: depth-vs-cost
|
||||
type: chaos
|
||||
scenario: depth-vs-cost
|
||||
- suite: mixed-technology
|
||||
type: chaos
|
||||
scenario: mixed-technology
|
||||
- suite: congestion-stress
|
||||
type: chaos
|
||||
scenario: congestion-stress
|
||||
- suite: bloom-storm
|
||||
type: chaos
|
||||
scenario: bloom-storm
|
||||
# ── Sidecar deployment ──────────────────────────────────────────
|
||||
- suite: sidecar
|
||||
type: sidecar
|
||||
@@ -626,21 +619,6 @@ jobs:
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey-outbound-only down --volumes --remove-orphans
|
||||
|
||||
# ── ACL allowlist integration test ─────────────────────────────────────
|
||||
- name: Run ACL allowlist integration test
|
||||
if: matrix.type == 'acl-allowlist'
|
||||
run: bash testing/acl-allowlist/test.sh --skip-build --keep-up
|
||||
|
||||
- name: Collect logs on failure (acl-allowlist)
|
||||
if: matrix.type == 'acl-allowlist' && failure()
|
||||
run: |
|
||||
docker compose -f testing/acl-allowlist/docker-compose.yml logs --no-color
|
||||
|
||||
- name: Stop containers (acl-allowlist)
|
||||
if: matrix.type == 'acl-allowlist' && always()
|
||||
run: |
|
||||
docker compose -f testing/acl-allowlist/docker-compose.yml down --volumes --remove-orphans
|
||||
|
||||
# ── Firewall baseline integration test ─────────────────────────────────
|
||||
- name: Run firewall baseline integration test
|
||||
if: matrix.type == 'firewall'
|
||||
@@ -771,42 +749,6 @@ jobs:
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile gateway down --volumes --remove-orphans
|
||||
|
||||
# ── Inbound max_peers admission-cap integration test ────────────────
|
||||
# Lowers node.max_peers on one mesh node and asserts the inbound cap
|
||||
# holds under sustained retry pressure: denied peers keep retrying but
|
||||
# are never promoted to an active session. The admission-cap-test.sh
|
||||
# assertions are tailored per link-layer handshake variant; the leg
|
||||
# itself is uniform. Static-style harness on the shared mesh profile.
|
||||
- name: Generate configs (admission-cap)
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: bash testing/static/scripts/generate-configs.sh mesh
|
||||
|
||||
- name: Inject admission-cap config (admission-cap)
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: bash testing/static/scripts/admission-cap-test.sh inject-config
|
||||
|
||||
- name: Start containers (admission-cap)
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile mesh up -d
|
||||
|
||||
- name: Run admission-cap test
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: bash testing/static/scripts/admission-cap-test.sh
|
||||
|
||||
- name: Collect logs on failure (admission-cap)
|
||||
if: matrix.type == 'admission-cap' && failure()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile mesh logs --no-color | tail -300
|
||||
|
||||
- name: Stop containers (admission-cap)
|
||||
if: matrix.type == 'admission-cap' && always()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile mesh down --volumes --remove-orphans
|
||||
|
||||
# ── Real-deb install integration ────────────────────────────────────
|
||||
# The deb-install harness builds its own .deb from source in a
|
||||
# cargo-deb builder image; the pre-built Linux binary from the
|
||||
|
||||
@@ -33,6 +33,11 @@ __pycache__/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# Per-run build contexts created by testing/ci-local.sh. Its teardown normally
|
||||
# removes them, but the CI worker's SIGKILL runs no trap, so one can survive a
|
||||
# preempted run; ci-cleanup.sh sweeps the survivors.
|
||||
/testing/docker-*/
|
||||
|
||||
# Runtime artifacts from running fips in-tree during local testing.
|
||||
# Root-anchored so legitimately-tracked fips.yaml under packaging/ and
|
||||
# examples/ stays included.
|
||||
|
||||
@@ -11,8 +11,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- `SessionDatagram::decrement_ttl` and `SessionDatagram::can_forward` now match
|
||||
the forwarder's IP hop-limit semantics: `decrement_ttl` decrements first and
|
||||
reports false when the result is zero, and `can_forward` is true only at a
|
||||
TTL of 2 or more.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Nostr NAT traversal no longer breaks after the host suspends. The traversal
|
||||
clock cached a Unix timestamp once at startup and advanced it with a
|
||||
monotonic `Instant`, which does not tick while a machine is asleep, so after
|
||||
a suspend the daemon's idea of the time trailed real time by the suspend
|
||||
duration for the rest of the process lifetime. Every NIP-40 expiration it
|
||||
computed was therefore published already in the past: relays dropped the
|
||||
offers as expired, the initiator logged a signal timeout waiting for an
|
||||
answer, and traversal stayed broken until the daemon was restarted. The
|
||||
clock now reads the wall clock on every call. This is not macOS-specific,
|
||||
though a laptop that sleeps is where it is easiest to hit; any host that
|
||||
suspends or hibernates was affected. Reported in
|
||||
[#128](https://github.com/jmcorgan/fips/issues/128).
|
||||
|
||||
- `SessionDatagram` hop-limit handling now follows IP semantics. Delivery to
|
||||
the addressed node is no longer TTL-gated, and a forwarder decrements before
|
||||
deciding rather than after, so a datagram that would leave with a TTL of zero
|
||||
is dropped instead of transmitted. Previously the TTL check ran ahead of the
|
||||
local-delivery test, so a datagram addressed to this node that arrived with
|
||||
TTL 0 was dropped, and a forwarder receiving a transit datagram at TTL 1
|
||||
transmitted it at TTL 0 for the next hop to discard, wasting one transmission
|
||||
per expiring datagram. The reachable radius is unchanged, because the two
|
||||
behaviors compensated exactly: a path of `h` links still delivers for any
|
||||
source TTL of `h` or more. During a rolling upgrade, an unupgraded forwarder
|
||||
feeding an upgraded destination delivers one hop further than either version
|
||||
does on its own; no version mix delivers less far. The `TtlExhausted` reject
|
||||
counter now charges at the node that makes the decision rather than at the
|
||||
hop after it.
|
||||
|
||||
## [0.4.1] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
Generated
+1
-1
@@ -1074,7 +1074,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
|
||||
[[package]]
|
||||
name = "fips"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2-dev"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bech32",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fips"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2-dev"
|
||||
edition = "2024"
|
||||
description = "A distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports"
|
||||
license = "MIT"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||

|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](#status--roadmap)
|
||||
[](#status--roadmap)
|
||||
|
||||
A self-organizing encrypted mesh network built on Nostr identities,
|
||||
capable of operating over arbitrary transports without central
|
||||
@@ -210,7 +210,7 @@ testing/ Docker-based integration test harnesses + chaos simulation
|
||||
|
||||
## Status & roadmap
|
||||
|
||||
FIPS is at **v0.4.1** on the `maint` branch.
|
||||
FIPS is at **v0.4.2-dev** on the `maint` branch.
|
||||
[v0.4.1](https://github.com/jmcorgan/fips/releases/tag/v0.4.1) has
|
||||
shipped; this line carries patch-level fixes for the 0.4.x series. The
|
||||
core protocol works end-to-end over
|
||||
|
||||
@@ -1189,6 +1189,15 @@ fn mmp_focused_pane_indicator() {
|
||||
});
|
||||
// The focused Session MMP title is cyan; the unfocused Link MMP title is not.
|
||||
assert_eq!(testkit::fg_at(&buf, "Session MMP"), Some(Color::Cyan));
|
||||
// Presence before colour. `fg_at` is `find(..)?` mapped to the cell's fg, so
|
||||
// it returns None for a title that was never drawn, and None != Some(Cyan) --
|
||||
// meaning the assertion below passed when the Link MMP pane was missing
|
||||
// entirely. Asserting it is on screen first is what makes the next line read
|
||||
// "not highlighted" rather than "not there".
|
||||
assert!(
|
||||
testkit::find(&buf, "Link MMP").is_some(),
|
||||
"the unfocused Link MMP title should still be rendered"
|
||||
);
|
||||
assert_ne!(testkit::fg_at(&buf, "Link MMP"), Some(Color::Cyan));
|
||||
}
|
||||
|
||||
|
||||
+65
-7
@@ -172,21 +172,79 @@ impl BloomState {
|
||||
peer_addrs: &[NodeAddr],
|
||||
peer_filters: &HashMap<NodeAddr, BloomFilter>,
|
||||
) {
|
||||
for peer_addr in peer_addrs {
|
||||
if peer_addr == exclude_from {
|
||||
continue;
|
||||
}
|
||||
let new_filter = self.compute_outgoing_filter(peer_addr, peer_filters);
|
||||
let changed = match self.last_sent_filters.get(peer_addr) {
|
||||
let targets: Vec<NodeAddr> = peer_addrs
|
||||
.iter()
|
||||
.filter(|addr| *addr != exclude_from)
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
for (peer_addr, new_filter) in self.compute_outgoing_filters(&targets, peer_filters) {
|
||||
let changed = match self.last_sent_filters.get(&peer_addr) {
|
||||
Some(last) => *last != new_filter,
|
||||
None => true, // never sent → must send
|
||||
};
|
||||
if changed {
|
||||
self.pending_updates.insert(*peer_addr);
|
||||
self.pending_updates.insert(peer_addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the outgoing filter for many peers in one pass.
|
||||
///
|
||||
/// Equivalent to calling [`compute_outgoing_filter`](Self::compute_outgoing_filter)
|
||||
/// once per target, but linear in the number of contributing peer
|
||||
/// filters instead of quadratic. The per-peer call rebuilds the whole
|
||||
/// union from scratch, so computing it for every peer costs
|
||||
/// O(targets × filters) 1 KB merges; announce fan-out on a
|
||||
/// large node does exactly that, once per tick and again on every
|
||||
/// inbound announce.
|
||||
///
|
||||
/// The split-horizon exclusion is the only thing that differs between
|
||||
/// targets, so the union of "everything except peer i" is assembled
|
||||
/// from a running prefix union and a precomputed suffix union. Merging
|
||||
/// is a bytewise OR, which is commutative and associative, so the
|
||||
/// result is bit-identical to the per-peer computation.
|
||||
pub fn compute_outgoing_filters(
|
||||
&self,
|
||||
targets: &[NodeAddr],
|
||||
peer_filters: &HashMap<NodeAddr, BloomFilter>,
|
||||
) -> HashMap<NodeAddr, BloomFilter> {
|
||||
let base = self.base_filter();
|
||||
let keys: Vec<NodeAddr> = peer_filters.keys().copied().collect();
|
||||
let n = keys.len();
|
||||
|
||||
// suffix[i] = union of peer_filters[keys[i..]]; suffix[n] is empty.
|
||||
let mut suffix = vec![BloomFilter::new(); n + 1];
|
||||
for i in (0..n).rev() {
|
||||
let mut acc = suffix[i + 1].clone();
|
||||
// Size mismatches are skipped, exactly as in the per-peer path.
|
||||
let _ = acc.merge(&peer_filters[&keys[i]]);
|
||||
suffix[i] = acc;
|
||||
}
|
||||
|
||||
// Filter for a target that contributes nothing: everything merged.
|
||||
let mut all = base.clone();
|
||||
let _ = all.merge(&suffix[0]);
|
||||
|
||||
let mut per_key: HashMap<NodeAddr, BloomFilter> = HashMap::with_capacity(n);
|
||||
let mut prefix = BloomFilter::new();
|
||||
for i in 0..n {
|
||||
let mut outgoing = base.clone();
|
||||
let _ = outgoing.merge(&prefix);
|
||||
let _ = outgoing.merge(&suffix[i + 1]);
|
||||
per_key.insert(keys[i], outgoing);
|
||||
let _ = prefix.merge(&peer_filters[&keys[i]]);
|
||||
}
|
||||
|
||||
targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
let filter = per_key.get(target).cloned().unwrap_or_else(|| all.clone());
|
||||
(*target, filter)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute the outgoing filter for a specific peer.
|
||||
///
|
||||
/// The filter includes:
|
||||
|
||||
@@ -684,3 +684,70 @@ fn test_bloom_state_mark_changed_peers_excludes_source() {
|
||||
|
||||
assert!(!state.needs_update(&peer1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_outgoing_filters_matches_per_peer() {
|
||||
let node = make_node_addr(0);
|
||||
let mut state = BloomState::new(node);
|
||||
state.add_leaf_dependent(make_node_addr(200));
|
||||
state.add_leaf_dependent(make_node_addr(201));
|
||||
|
||||
// Six contributing peers with overlapping content, plus one whose
|
||||
// filter is a different size and must be skipped by both paths.
|
||||
let mut peer_filters = HashMap::new();
|
||||
for i in 1u8..=6 {
|
||||
let mut filter = BloomFilter::new();
|
||||
for j in 0..5u8 {
|
||||
filter.insert(&make_node_addr(i.wrapping_mul(7).wrapping_add(j)));
|
||||
}
|
||||
peer_filters.insert(make_node_addr(i), filter);
|
||||
}
|
||||
let odd_peer = make_node_addr(7);
|
||||
let mut odd = BloomFilter::with_params(4096, DEFAULT_HASH_COUNT).unwrap();
|
||||
odd.insert(&make_node_addr(99));
|
||||
peer_filters.insert(odd_peer, odd);
|
||||
|
||||
// Targets: every contributing peer, the odd-sized one, and two peers
|
||||
// that contribute nothing (non-tree peers get announces too).
|
||||
let mut targets: Vec<NodeAddr> = (1u8..=7).map(make_node_addr).collect();
|
||||
targets.push(make_node_addr(120));
|
||||
targets.push(make_node_addr(121));
|
||||
|
||||
let batch = state.compute_outgoing_filters(&targets, &peer_filters);
|
||||
|
||||
assert_eq!(batch.len(), targets.len());
|
||||
for target in &targets {
|
||||
let expected = state.compute_outgoing_filter(target, &peer_filters);
|
||||
assert_eq!(
|
||||
batch.get(target),
|
||||
Some(&expected),
|
||||
"batch filter for {:?} differs from per-peer computation",
|
||||
target
|
||||
);
|
||||
}
|
||||
|
||||
// Split horizon is real, not vacuous: a contributing peer's own
|
||||
// entries must be absent from its own outgoing filter, and present
|
||||
// in another peer's.
|
||||
let peer3 = make_node_addr(3);
|
||||
let own_entry = make_node_addr(3u8.wrapping_mul(7));
|
||||
assert!(!batch[&peer3].contains(&own_entry));
|
||||
assert!(batch[&make_node_addr(1)].contains(&own_entry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_outgoing_filters_empty_inputs() {
|
||||
let node = make_node_addr(0);
|
||||
let state = BloomState::new(node);
|
||||
let peer_filters = HashMap::new();
|
||||
|
||||
assert!(
|
||||
state
|
||||
.compute_outgoing_filters(&[], &peer_filters)
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let target = make_node_addr(1);
|
||||
let batch = state.compute_outgoing_filters(&[target], &peer_filters);
|
||||
assert_eq!(batch[&target], state.base_filter());
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ mod node;
|
||||
mod peer;
|
||||
mod transport;
|
||||
|
||||
use crate::node::REKEY_JITTER_SECS;
|
||||
use crate::upper::config::{DnsConfig, TunConfig};
|
||||
use crate::{Identity, IdentityError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -686,6 +687,32 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Reject rekey triggers that fire immediately and forever. Both
|
||||
// arms are checked regardless of `node.rekey.enabled` so that
|
||||
// turning rekey on later cannot surface a config error at a
|
||||
// surprising moment. There is deliberately no upper bound:
|
||||
// u64::MAX is the idiom for disabling one arm of the trigger.
|
||||
let rekey = &self.node.rekey;
|
||||
|
||||
if rekey.after_messages == 0 {
|
||||
return Err(ConfigError::Validation(
|
||||
"`node.rekey.after_messages` must be at least 1; 0 fires the message-count trigger on every poll instead of disabling it. \
|
||||
Use a very large value to effectively disable the message-count trigger."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let jitter_secs = REKEY_JITTER_SECS.unsigned_abs();
|
||||
if rekey.after_secs <= jitter_secs {
|
||||
return Err(ConfigError::Validation(format!(
|
||||
"`node.rekey.after_secs` is {}, but must be greater than the per-session rekey jitter of {jitter_secs}s; \
|
||||
each session offsets the interval by a random value in [-{jitter_secs}, +{jitter_secs}] seconds, so a smaller interval saturates to zero \
|
||||
and rekeys on sight for roughly half of sessions. \
|
||||
Use a very large value to effectively disable the timer trigger.",
|
||||
rekey.after_secs
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1459,6 +1486,86 @@ peers:
|
||||
.expect("outbound_only should be exempt from the loopback check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_default_rekey_settings_ok() {
|
||||
Config::default()
|
||||
.validate()
|
||||
.expect("shipped default rekey settings must validate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_after_messages_zero_rejected() {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.after_messages = 0;
|
||||
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("after_messages"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_after_messages_one_accepted() {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.after_messages = 1;
|
||||
|
||||
config
|
||||
.validate()
|
||||
.expect("after_messages = 1 rekeys every message, which is wasteful but well defined");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_after_secs_at_or_below_jitter_rejected() {
|
||||
let jitter = REKEY_JITTER_SECS.unsigned_abs();
|
||||
|
||||
for after_secs in [0, 1, jitter - 1, jitter] {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.after_secs = after_secs;
|
||||
|
||||
match config.validate() {
|
||||
Err(e) => assert!(e.to_string().contains("after_secs"), "got: {e}"),
|
||||
Ok(()) => panic!("after_secs = {after_secs} should be rejected"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_after_secs_just_above_jitter_accepted() {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.after_secs = REKEY_JITTER_SECS.unsigned_abs() + 1;
|
||||
|
||||
config
|
||||
.validate()
|
||||
.expect("one second above the jitter bound leaves a non-zero effective interval");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_unbounded_values_accepted() {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.after_secs = u64::MAX;
|
||||
config.node.rekey.after_messages = u64::MAX;
|
||||
|
||||
config
|
||||
.validate()
|
||||
.expect("u64::MAX disables an arm of the trigger and must stay legal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_checked_even_when_disabled() {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.enabled = false;
|
||||
config.node.rekey.after_messages = 0;
|
||||
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("after_messages"));
|
||||
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.enabled = false;
|
||||
config.node.rekey.after_secs = REKEY_JITTER_SECS.unsigned_abs();
|
||||
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("after_secs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outbound_only_forces_ephemeral_bind() {
|
||||
let cfg = UdpConfig {
|
||||
|
||||
@@ -1302,6 +1302,16 @@ impl NostrDiscovery {
|
||||
|
||||
self.mark_session_seen(&offer.session_id).await?;
|
||||
|
||||
// Resolve the answer's relays before binding a socket and running STUN.
|
||||
// Nothing in the relay choice depends on what STUN observes, and an offer
|
||||
// from a peer we share no relay with cannot be answered at all — doing it
|
||||
// in this order spends a STUN round trip, and holds an offer slot for its
|
||||
// duration, only to discard the result.
|
||||
let relays = self.preferred_signal_relays(sender, None).await?;
|
||||
if relays.is_empty() {
|
||||
return Err(BootstrapError::MissingRelays(offer.sender_npub.clone()));
|
||||
}
|
||||
|
||||
let base_socket = std::net::UdpSocket::bind(("0.0.0.0", 0))?;
|
||||
base_socket.set_nonblocking(true)?;
|
||||
let (reflexive_address, local_addresses, stun_server) = observe_traversal_addresses(
|
||||
@@ -1336,7 +1346,6 @@ impl NostrDiscovery {
|
||||
(!accepted).then_some("no-usable-addresses".to_string()),
|
||||
Some(offer_received_at),
|
||||
);
|
||||
let relays = self.preferred_signal_relays(sender, None).await?;
|
||||
let answer_event = self.send_signal(&relays, sender, &answer).await?;
|
||||
debug!(
|
||||
peer = %peer_short,
|
||||
@@ -1466,22 +1475,21 @@ impl NostrDiscovery {
|
||||
target_pubkey: PublicKey,
|
||||
advert: Option<&OverlayAdvert>,
|
||||
) -> Result<Vec<String>, BootstrapError> {
|
||||
let mut merged = self.find_recipient_inbox_relays(target_pubkey).await?;
|
||||
if let Some(advert) = advert
|
||||
&& let Some(relays) = advert.signal_relays.as_ref()
|
||||
{
|
||||
for relay in relays {
|
||||
if !merged.contains(relay) {
|
||||
merged.push(relay.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for relay in &self.config.dm_relays {
|
||||
if !merged.contains(relay) {
|
||||
merged.push(relay.clone());
|
||||
}
|
||||
}
|
||||
Ok(merged)
|
||||
let inbox = self.find_recipient_inbox_relays(target_pubkey).await?;
|
||||
let pool: HashSet<RelayUrl> = self.client.pool().all_relays().await.into_keys().collect();
|
||||
let usable = signal_relays(
|
||||
&inbox,
|
||||
advert.and_then(|advert| advert.signal_relays.as_deref()),
|
||||
&self.config.dm_relays,
|
||||
&pool,
|
||||
);
|
||||
debug!(
|
||||
peer = %target_pubkey.to_bech32().map(|npub| short_npub(&npub)).unwrap_or_default(),
|
||||
inbox = inbox.len(),
|
||||
usable = usable.len(),
|
||||
"traversal: signal relays resolved against the client pool"
|
||||
);
|
||||
Ok(usable)
|
||||
}
|
||||
|
||||
async fn find_recipient_inbox_relays(
|
||||
@@ -1732,6 +1740,56 @@ impl NostrDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retain only the candidates the client pool actually holds.
|
||||
///
|
||||
/// `send_event_to` rejects the whole send with `RelayNotFound` if any single URL
|
||||
/// is outside the pool, so a signal addressed to a peer's advertised relays fails
|
||||
/// entirely on one relay we are not configured with. Filtering first turns that
|
||||
/// into a send to the relays we share.
|
||||
///
|
||||
/// Comparison is on the normalized `RelayUrl` rather than the raw string, because
|
||||
/// the pool is keyed that way: a candidate spelled `wss://relay.example/` matches
|
||||
/// a configured `wss://relay.example`. Order is preserved, candidates that fail
|
||||
/// to parse are dropped, and duplicates that normalize alike are collapsed.
|
||||
fn retain_pooled_relays(candidates: &[String], pool: &HashSet<RelayUrl>) -> Vec<String> {
|
||||
let mut seen: HashSet<RelayUrl> = HashSet::new();
|
||||
let mut usable = Vec::with_capacity(candidates.len());
|
||||
for candidate in candidates {
|
||||
let Ok(url) = RelayUrl::parse(candidate) else {
|
||||
continue;
|
||||
};
|
||||
if pool.contains(&url) && seen.insert(url.clone()) {
|
||||
usable.push(url.to_string());
|
||||
}
|
||||
}
|
||||
usable
|
||||
}
|
||||
|
||||
/// Choose the relays a traversal signal for one peer should be sent to.
|
||||
///
|
||||
/// The candidates are the peer's NIP-17 inbox relays, then the relays its advert
|
||||
/// nominates for signaling, then our own DM relays — remote-supplied first, ours
|
||||
/// last, so a peer's preference is honored where we can act on it. The result is
|
||||
/// whatever survives [`retain_pooled_relays`].
|
||||
///
|
||||
/// This is the whole decision, kept synchronous so it can be exercised without a
|
||||
/// relay client: the caller's only job is to supply the fetched inbox list and
|
||||
/// the pool.
|
||||
pub(super) fn signal_relays(
|
||||
inbox: &[String],
|
||||
advert_signal: Option<&[String]>,
|
||||
dm_relays: &[String],
|
||||
pool: &HashSet<RelayUrl>,
|
||||
) -> Vec<String> {
|
||||
let mut merged: Vec<String> = inbox.to_vec();
|
||||
for relay in advert_signal.unwrap_or_default().iter().chain(dm_relays) {
|
||||
if !merged.contains(relay) {
|
||||
merged.push(relay.clone());
|
||||
}
|
||||
}
|
||||
retain_pooled_relays(&merged, pool)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl NostrDiscovery {
|
||||
/// Build a minimal `NostrDiscovery` for unit tests. No relay client is
|
||||
@@ -1803,6 +1861,18 @@ impl NostrDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the test instance's advert relays at explicit URLs. Unit tests
|
||||
/// that exercise `refetch_advert_for_stale_check` use this to replace the
|
||||
/// default public relay list with a local blackhole, so the refetch runs
|
||||
/// its full 2s timeout without touching the network.
|
||||
pub(crate) async fn set_advert_relays_for_test(&mut self, relays: Vec<String>) {
|
||||
for url in &relays {
|
||||
let _ = self.client.add_relay(url.as_str()).await;
|
||||
}
|
||||
self.client.connect().await;
|
||||
self.config.advert_relays = relays;
|
||||
}
|
||||
|
||||
/// Insert a cached advert directly into the in-memory cache. Used by
|
||||
/// unit tests to set up consumer-side state without needing live relays.
|
||||
pub(crate) async fn insert_advert_for_test(&self, npub: String, advert: CachedOverlayAdvert) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use nostr::prelude::{EventBuilder, Kind, Tag, Timestamp};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::runtime::{NostrDiscovery, suppress_responder_for_own_initiator};
|
||||
use nostr::prelude::{EventBuilder, Kind, RelayUrl, Tag, Timestamp};
|
||||
|
||||
use super::runtime::{NostrDiscovery, signal_relays, suppress_responder_for_own_initiator};
|
||||
use super::signal::{
|
||||
FreshnessOutcome, build_signal_event, create_traversal_answer, create_traversal_offer,
|
||||
estimate_clock_skew, validate_offer_freshness, validate_traversal_answer_for_offer,
|
||||
};
|
||||
use super::stun::{parse_stun_binding_success, parse_stun_url};
|
||||
use super::traversal::{
|
||||
PunchStrategy, build_punch_packet, parse_punch_packet, plan_punch_targets,
|
||||
PunchStrategy, build_punch_packet, now_ms, parse_punch_packet, plan_punch_targets,
|
||||
planned_remote_endpoints, session_hash,
|
||||
};
|
||||
use super::{
|
||||
@@ -670,3 +672,187 @@ fn responder_suppression_election() {
|
||||
&smaller, &smaller, true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_ms_tracks_the_wall_clock() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn wall_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock is after the Unix epoch")
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
// Bracket a sample between two independent wall-clock reads taken either
|
||||
// side of it. This is the property the traversal clock has to hold for the
|
||||
// NIP-40 expiration tags it computes to be in the future when published.
|
||||
//
|
||||
// Read this for what it is: it pins the contract (Unix epoch, milliseconds,
|
||||
// tracking real time) and it fires on a host that has actually suspended,
|
||||
// where the sample falls below `before` by the suspend duration. It is NOT a
|
||||
// regression guard for the anchored-clock defect. Nothing reachable from a
|
||||
// unit test can simulate a suspend, so on a machine that has not slept, an
|
||||
// anchored implementation passes this -- deterministically when this is the
|
||||
// first caller of `now_ms()` in the binary, and otherwise with a probability
|
||||
// set by the fractional millisecond the anchor happened to capture.
|
||||
let before = wall_ms();
|
||||
let sampled = now_ms();
|
||||
let after = wall_ms();
|
||||
|
||||
assert!(
|
||||
sampled >= before,
|
||||
"now_ms() is behind the wall clock: {sampled} < {before}"
|
||||
);
|
||||
assert!(
|
||||
sampled <= after,
|
||||
"now_ms() is ahead of the wall clock: {sampled} > {after}"
|
||||
);
|
||||
}
|
||||
|
||||
fn pool(urls: &[&str]) -> HashSet<RelayUrl> {
|
||||
urls.iter()
|
||||
.map(|url| RelayUrl::parse(url).expect("test pool url parses"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn candidates(urls: &[&str]) -> Vec<String> {
|
||||
urls.iter().map(|url| url.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_pool_relay_does_not_suppress_the_shared_ones() {
|
||||
let usable = signal_relays(
|
||||
&candidates(&[
|
||||
"wss://relay.damus.io",
|
||||
"wss://temp.iris.to",
|
||||
"wss://nos.lol",
|
||||
]),
|
||||
None,
|
||||
&[],
|
||||
&pool(&[
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://offchain.pub",
|
||||
]),
|
||||
);
|
||||
assert_eq!(
|
||||
usable,
|
||||
vec![
|
||||
"wss://relay.damus.io".to_string(),
|
||||
"wss://nos.lol".to_string()
|
||||
],
|
||||
"the unknown relay must be dropped without taking the shared ones with it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slash_and_host_case_variants_are_retained() {
|
||||
let usable = signal_relays(
|
||||
&candidates(&["wss://Relay.Damus.io/", "wss://nos.lol"]),
|
||||
None,
|
||||
&[],
|
||||
&pool(&["wss://relay.damus.io", "wss://nos.lol"]),
|
||||
);
|
||||
assert_eq!(
|
||||
usable.len(),
|
||||
2,
|
||||
"normalized spellings of a configured relay are the same relay: {usable:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_that_normalize_alike_are_collapsed() {
|
||||
let usable = signal_relays(
|
||||
&candidates(&["wss://nos.lol", "wss://nos.lol/", "wss://NOS.LOL"]),
|
||||
None,
|
||||
&[],
|
||||
&pool(&["wss://nos.lol"]),
|
||||
);
|
||||
assert_eq!(usable, vec!["wss://nos.lol".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unparseable_candidates_are_dropped_rather_than_failing_the_set() {
|
||||
let usable = signal_relays(
|
||||
&candidates(&["not a url", "wss://nos.lol"]),
|
||||
None,
|
||||
&[],
|
||||
&pool(&["wss://nos.lol"]),
|
||||
);
|
||||
assert_eq!(usable, vec!["wss://nos.lol".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_shared_relay_yields_an_empty_set_for_the_caller_to_reject() {
|
||||
let usable = signal_relays(
|
||||
&candidates(&["wss://temp.iris.to"]),
|
||||
None,
|
||||
&[],
|
||||
&pool(&["wss://nos.lol"]),
|
||||
);
|
||||
assert!(
|
||||
usable.is_empty(),
|
||||
"with no overlap the caller must see nothing to send to, not a doomed send"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_relays_merges_all_three_sources_then_filters() {
|
||||
let usable = signal_relays(
|
||||
&candidates(&["wss://temp.iris.to", "wss://nos.lol"]),
|
||||
Some(&candidates(&[
|
||||
"wss://relay.damus.io",
|
||||
"wss://unknown.example",
|
||||
])),
|
||||
&candidates(&["wss://offchain.pub"]),
|
||||
&pool(&[
|
||||
"wss://nos.lol",
|
||||
"wss://relay.damus.io",
|
||||
"wss://offchain.pub",
|
||||
]),
|
||||
);
|
||||
assert_eq!(
|
||||
usable,
|
||||
vec![
|
||||
"wss://nos.lol".to_string(),
|
||||
"wss://relay.damus.io".to_string(),
|
||||
"wss://offchain.pub".to_string(),
|
||||
],
|
||||
"every source must contribute, and only the out-of-pool entries drop out"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_relays_keeps_our_dm_relays_when_the_peer_shares_nothing() {
|
||||
let usable = signal_relays(
|
||||
&candidates(&["wss://temp.iris.to"]),
|
||||
Some(&candidates(&["wss://also.unknown"])),
|
||||
&candidates(&["wss://nos.lol"]),
|
||||
&pool(&["wss://nos.lol"]),
|
||||
);
|
||||
assert_eq!(
|
||||
usable,
|
||||
vec!["wss://nos.lol".to_string()],
|
||||
"our own DM relays are always in the pool, so the result is never empty \
|
||||
while any are configured"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_relays_without_an_advert_still_resolves() {
|
||||
let usable = signal_relays(
|
||||
&candidates(&["wss://nos.lol", "wss://temp.iris.to"]),
|
||||
None,
|
||||
&candidates(&["wss://offchain.pub"]),
|
||||
&pool(&["wss://nos.lol", "wss://offchain.pub"]),
|
||||
);
|
||||
assert_eq!(
|
||||
usable,
|
||||
vec![
|
||||
"wss://nos.lol".to_string(),
|
||||
"wss://offchain.pub".to_string()
|
||||
],
|
||||
"the responder path passes no advert and must still produce a target set"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
@@ -197,25 +197,35 @@ pub(super) fn nonce() -> String {
|
||||
format!("{}-{:016x}", now_ms(), rand::random::<u64>())
|
||||
}
|
||||
|
||||
/// Current Unix time in milliseconds, read from the wall clock on every call.
|
||||
///
|
||||
/// This deliberately does not cache a start-of-process anchor and advance it
|
||||
/// with a monotonic `Instant`. A monotonic clock does not advance while the host
|
||||
/// is suspended, so an anchored value trails real time by the suspend duration
|
||||
/// for the remaining life of the process. Every expiry computed from it is then
|
||||
/// published already in the past, the relay drops the event as expired, and
|
||||
/// traversal signalling fails until the daemon is restarted.
|
||||
///
|
||||
/// About half the consumers publish or serialize the value as an absolute
|
||||
/// timestamp: the NIP-40 expiration tags on adverts and traversal signals, and
|
||||
/// the `issuedAt`/`expiresAt` fields of offers and answers. The rest compare it
|
||||
/// against timestamps on the same basis, including the peer-authored, signed
|
||||
/// `created_at` of a received advert, so they need it to track real time too.
|
||||
///
|
||||
/// The interval-shaped consumers survive a step in the wall clock. A forward
|
||||
/// step, which is what a resume produces, saturates the punch start delay to
|
||||
/// zero so punching begins immediately; the attempt's own bounds are monotonic
|
||||
/// `Instant` deadlines, so its length is unaffected. A backward step lengthens
|
||||
/// that delay instead and can cost a single punch attempt, which retries. Early
|
||||
/// eviction from the replay window cannot admit a replay under the shipped
|
||||
/// defaults, because the freshness window a replayed offer would also have to
|
||||
/// satisfy (`signal_ttl_secs` plus `FRESHNESS_SKEW_TOLERANCE_MS`, 180s) is
|
||||
/// strictly narrower than the replay window itself (`replay_window_secs`, 300s).
|
||||
pub(super) fn now_ms() -> u64 {
|
||||
struct ClockAnchor {
|
||||
started_at: Instant,
|
||||
started_unix_ms: u64,
|
||||
}
|
||||
|
||||
static ANCHOR: OnceLock<ClockAnchor> = OnceLock::new();
|
||||
|
||||
let anchor = ANCHOR.get_or_init(|| ClockAnchor {
|
||||
started_at: Instant::now(),
|
||||
started_unix_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
});
|
||||
|
||||
anchor
|
||||
.started_unix_ms
|
||||
.saturating_add(anchor.started_at.elapsed().as_millis() as u64)
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(super) fn session_hash(session_id: &str) -> [u8; 16] {
|
||||
|
||||
+24
-16
@@ -29,27 +29,19 @@ impl Node {
|
||||
filters
|
||||
}
|
||||
|
||||
/// Build a FilterAnnounce for a specific peer.
|
||||
///
|
||||
/// The outgoing filter excludes the destination peer's own filter
|
||||
/// to prevent routing loops (don't tell a peer about destinations
|
||||
/// reachable only through them).
|
||||
fn build_filter_announce(&mut self, exclude_peer: &NodeAddr) -> FilterAnnounce {
|
||||
let peer_filters = self.peer_inbound_filters();
|
||||
let filter = self
|
||||
.bloom_state
|
||||
.compute_outgoing_filter(exclude_peer, &peer_filters);
|
||||
let sequence = self.bloom_state.next_sequence();
|
||||
FilterAnnounce::new(filter, sequence)
|
||||
}
|
||||
|
||||
/// Send a FilterAnnounce to a specific peer, respecting debounce.
|
||||
///
|
||||
/// `filter` is the outgoing filter for this peer, already computed
|
||||
/// with the destination peer's own contribution excluded to prevent
|
||||
/// routing loops (don't tell a peer about destinations reachable
|
||||
/// only through them).
|
||||
///
|
||||
/// If the peer is rate-limited, the update stays pending for
|
||||
/// delivery on the next tick cycle.
|
||||
pub(super) async fn send_filter_announce_to_peer(
|
||||
&mut self,
|
||||
peer_addr: &NodeAddr,
|
||||
filter: BloomFilter,
|
||||
) -> Result<(), NodeError> {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -64,7 +56,7 @@ impl Node {
|
||||
}
|
||||
|
||||
// Build and encode
|
||||
let announce = self.build_filter_announce(peer_addr);
|
||||
let announce = FilterAnnounce::new(filter, self.bloom_state.next_sequence());
|
||||
let sent_filter = announce.filter.clone();
|
||||
let encoded = announce.encode().map_err(|e| NodeError::SendFailed {
|
||||
node_addr: *peer_addr,
|
||||
@@ -142,8 +134,24 @@ impl Node {
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if ready.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// One snapshot and one union pass for the whole ready set. The
|
||||
// send path never mutates peer inbound filters or the tree state,
|
||||
// and the rx loop holds `&mut self` across the awaits, so the
|
||||
// snapshot cannot go stale mid-loop.
|
||||
let peer_filters = self.peer_inbound_filters();
|
||||
let mut outgoing = self
|
||||
.bloom_state
|
||||
.compute_outgoing_filters(&ready, &peer_filters);
|
||||
|
||||
for peer_addr in ready {
|
||||
if let Err(e) = self.send_filter_announce_to_peer(&peer_addr).await {
|
||||
let Some(filter) = outgoing.remove(&peer_addr) else {
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = self.send_filter_announce_to_peer(&peer_addr, filter).await {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_addr),
|
||||
error = %e,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! SessionDatagram forwarding handler.
|
||||
//!
|
||||
//! Handles incoming SessionDatagram (0x00) link messages: decodes the
|
||||
//! envelope, enforces hop limits, performs coordinate cache warming from
|
||||
//! plaintext session-layer headers, routes to the next hop or delivers
|
||||
//! locally, and generates error signals on routing failure.
|
||||
//! envelope, performs coordinate cache warming from plaintext session-layer
|
||||
//! headers, delivers locally when the datagram is addressed to this node,
|
||||
//! otherwise enforces the transit hop limit and routes to the next hop, and
|
||||
//! generates error signals on routing failure.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::node::reject::ForwardingReject;
|
||||
@@ -43,26 +44,16 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
// TTL enforcement: decrement for forwarding and drop only if the
|
||||
// received datagram was already exhausted.
|
||||
if datagram_ref.ttl == 0 {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::TtlExhausted, payload.len());
|
||||
debug!(
|
||||
src = %datagram_ref.src_addr,
|
||||
dest = %datagram_ref.dest_addr,
|
||||
"SessionDatagram TTL exhausted, dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let forwarded_ttl = datagram_ref.ttl - 1;
|
||||
|
||||
// Coordinate cache warming from plaintext session-layer headers
|
||||
// Coordinate cache warming from plaintext session-layer headers.
|
||||
// Runs ahead of both the delivery and the TTL decisions: the coords
|
||||
// a peer put on the wire are equally valid whichever way those go.
|
||||
self.try_warm_coord_cache_ref(&datagram_ref);
|
||||
|
||||
// Local delivery: dispatch to session layer handlers without
|
||||
// materializing an owned SessionDatagram payload Vec.
|
||||
// materializing an owned SessionDatagram payload Vec. Delivery to
|
||||
// the addressed node is *not* TTL-gated — under IP semantics the
|
||||
// TTL governs forwarding, not delivery to the addressed host — so
|
||||
// this test precedes the TTL gate below.
|
||||
if datagram_ref.dest_addr == *self.node_addr() {
|
||||
self.metrics().forwarding.record_delivered(payload.len());
|
||||
self.handle_session_payload(
|
||||
@@ -75,6 +66,24 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
// TTL enforcement on the transit path: decrement first, then drop if
|
||||
// the datagram would leave with a TTL of zero. `saturating_sub` folds
|
||||
// the already-exhausted arrival (ttl=0) into the same test as the
|
||||
// last-hop arrival (ttl=1); neither is transmitted.
|
||||
let forwarded_ttl = datagram_ref.ttl.saturating_sub(1);
|
||||
if forwarded_ttl == 0 {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::TtlExhausted, payload.len());
|
||||
debug!(
|
||||
src = %datagram_ref.src_addr,
|
||||
dest = %datagram_ref.dest_addr,
|
||||
ttl = datagram_ref.ttl,
|
||||
"SessionDatagram TTL exhausted, dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut datagram = datagram_ref.into_owned();
|
||||
datagram.ttl = forwarded_ttl;
|
||||
|
||||
@@ -408,13 +417,10 @@ impl Node {
|
||||
for (&tid, transport) in &self.transports {
|
||||
let congestion = transport.congestion();
|
||||
let state = self.transport_drops.entry(tid).or_default();
|
||||
if let Some(current) = congestion.recv_drops {
|
||||
let new_drops = current > state.prev_drops;
|
||||
if new_drops && !state.dropping {
|
||||
new_drop_events.push(tid);
|
||||
}
|
||||
state.dropping = new_drops;
|
||||
state.prev_drops = current;
|
||||
if let Some(current) = congestion.recv_drops
|
||||
&& state.observe_drops(current)
|
||||
{
|
||||
new_drop_events.push(tid);
|
||||
}
|
||||
}
|
||||
for tid in new_drop_events {
|
||||
|
||||
+44
-3
@@ -266,6 +266,32 @@ struct TransportDropState {
|
||||
dropping: bool,
|
||||
}
|
||||
|
||||
impl TransportDropState {
|
||||
/// Fold a new cumulative `recv_drops` sample into the state and report
|
||||
/// whether it marks the *transition* into a dropping condition.
|
||||
///
|
||||
/// Returns true only on the edge where the cumulative `SO_RXQ_OVFL`
|
||||
/// counter rose since the previous sample **and** the transport was not
|
||||
/// already flagged as dropping. That edge is what `kernel_drop_events`
|
||||
/// counts: a first observation of a new drop burst, not every sample in
|
||||
/// which the counter happens to be non-zero. A sample with no rise
|
||||
/// clears the flag, so a later rise counts as a fresh event.
|
||||
///
|
||||
/// Pure and sans-IO by design: the tick handler reads the kernel
|
||||
/// counter from the socket and does the logging, but the detection
|
||||
/// decision lives here so it can be tested without a socket, a
|
||||
/// transport, or a running node — which is the only way it can be
|
||||
/// tested at all, since the kernel drop itself cannot be provoked
|
||||
/// deterministically.
|
||||
fn observe_drops(&mut self, current: u64) -> bool {
|
||||
let rose = current > self.prev_drops;
|
||||
let new_event = rose && !self.dropping;
|
||||
self.dropping = rose;
|
||||
self.prev_drops = current;
|
||||
new_event
|
||||
}
|
||||
}
|
||||
|
||||
/// State for a link waiting for transport-level connection establishment.
|
||||
///
|
||||
/// For connection-oriented transports (TCP, Tor), the transport connect runs
|
||||
@@ -292,8 +318,23 @@ struct PendingConnect {
|
||||
/// 1. **Connection phase** (`connections`): Handshake in progress, indexed by LinkId
|
||||
/// 2. **Active phase** (`peers`): Authenticated, indexed by NodeAddr
|
||||
///
|
||||
/// The `addr_to_link` map enables dispatching incoming packets to the right
|
||||
/// connection before authentication completes.
|
||||
/// The `addr_to_link` map is a reverse lookup from `(transport, address)` to
|
||||
/// link. It is **not** a packet-dispatch path, despite what this comment used
|
||||
/// to say: `find_link_by_addr` has no callers outside its own tests, and
|
||||
/// encrypted frames are dispatched by session index. Its live readers are the
|
||||
/// `should_admit_msg1` fast path and the duplicate-inbound-handshake check in
|
||||
/// `handle_msg1`.
|
||||
///
|
||||
/// **Do not key a peer-identity question on it.** The address form is not
|
||||
/// canonical: an outbound dial registers the literal configured string, which
|
||||
/// may be a hostname (`"node-b:2121"`), while an inbound packet carries the
|
||||
/// resolved form (`"10.128.2.4:2121"`). `TransportAddr` compares byte-wise, so
|
||||
/// those never match, and a lookup keyed on an inbound address silently returns
|
||||
/// "no such link" for every hostname-configured peer rather than failing. The
|
||||
/// entry is also single-valued per key, so an inbound handshake overwrites an
|
||||
/// outbound dial's entry for the same address. The readers above tolerate this
|
||||
/// because each compares a key written in the same form it reads; a new reader
|
||||
/// that does not will be quietly wrong.
|
||||
// Discovery lookup constants moved to config: node.discovery.attempt_timeouts_secs, node.discovery.ttl
|
||||
pub struct Node {
|
||||
// === Immutable Context ===
|
||||
@@ -1182,7 +1223,7 @@ impl Node {
|
||||
return name.clone();
|
||||
}
|
||||
if let Some(peer) = self.peers.get(addr) {
|
||||
return peer.identity().short_npub();
|
||||
return peer.short_npub().to_string();
|
||||
}
|
||||
if let Some(entry) = self.sessions.get(addr) {
|
||||
let (xonly, _) = entry.remote_pubkey().x_only_public_key();
|
||||
|
||||
+5
-1
@@ -232,7 +232,11 @@ pub enum ForwardingReject {
|
||||
/// `SessionDatagramRef::decode` returned an error. Tracked via
|
||||
/// [`ForwardingStats::decode_error_packets`](crate::node::stats::ForwardingStats).
|
||||
DecodeError,
|
||||
/// Datagram arrived with TTL=0 — already exhausted, no forward.
|
||||
/// Transit datagram whose TTL would reach zero on this hop, so it is
|
||||
/// dropped rather than forwarded. Charged for an arrival at TTL 1 as
|
||||
/// well as an already-exhausted arrival at TTL 0. Never charged for a
|
||||
/// datagram addressed to this node, whose delivery is not TTL-gated and
|
||||
/// is decided ahead of this test.
|
||||
/// Tracked via
|
||||
/// [`ForwardingStats::ttl_exhausted_packets`](crate::node::stats::ForwardingStats).
|
||||
TtlExhausted,
|
||||
|
||||
+15
-7
@@ -279,7 +279,7 @@ impl Node {
|
||||
|
||||
let peer_config = state.peer_config.clone();
|
||||
|
||||
// Refresh the peer's overlay advert before retrying. The cache is
|
||||
// Kick off a refresh of the peer's overlay advert. The cache is
|
||||
// read-only on hit (see fetch_advert), so every retry without a
|
||||
// refetch dials the same cached endpoint — and the most common
|
||||
// reason a peer ended up in retry_pending is that the cached
|
||||
@@ -289,13 +289,21 @@ impl Node {
|
||||
//
|
||||
// refetch_advert_for_stale_check uses the relay's advert as
|
||||
// ground truth: replaces the cache if there's a newer one,
|
||||
// evicts if the relay has nothing, otherwise leaves it. Cheap
|
||||
// (one Filter fetch with 2s timeout) and bounded by the retry
|
||||
// backoff cadence.
|
||||
// evicts if the relay has nothing, otherwise leaves it.
|
||||
//
|
||||
// Fire-and-forget, NOT awaited: this runs inline on the 1s
|
||||
// rx-loop tick, and the fetch carries a 2s relay timeout that
|
||||
// would stall the tick — and every other rx-loop arm with it —
|
||||
// by up to 2s per due peer, MAX_RETRY_CONNECTIONS_PER_TICK times
|
||||
// over. So the dial below uses whatever advert is cached now and
|
||||
// the refreshed one lands for the *next* retry of this peer.
|
||||
// Retries are backoff-paced, so that defers the benefit by one
|
||||
// backoff interval rather than losing it.
|
||||
if let Some(bootstrap) = self.nostr_discovery.clone() {
|
||||
let _ = bootstrap
|
||||
.refetch_advert_for_stale_check(&peer_config.npub)
|
||||
.await;
|
||||
let npub = peer_config.npub.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = bootstrap.refetch_advert_for_stale_check(&npub).await;
|
||||
});
|
||||
}
|
||||
|
||||
match self.initiate_peer_connection(&peer_config).await {
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
//! Ethernet transport integration tests.
|
||||
//!
|
||||
//! Tests that the Ethernet transport works end-to-end using veth pairs.
|
||||
//! All tests require root or CAP_NET_RAW and are marked `#[ignore]`.
|
||||
|
||||
use super::*;
|
||||
use crate::config::EthernetConfig;
|
||||
use crate::transport::ethernet::EthernetTransport;
|
||||
use crate::transport::{TransportAddr, TransportHandle, TransportId, packet_channel};
|
||||
use spanning_tree::{TestNode, cleanup_nodes, drain_all_packets, initiate_handshake};
|
||||
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// Atomic counter for unique veth names across tests.
|
||||
static VETH_COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// RAII wrapper for a veth pair.
|
||||
///
|
||||
/// Creates a pair of connected virtual Ethernet interfaces. Destroying
|
||||
/// one end automatically destroys the other.
|
||||
struct VethPair {
|
||||
name_a: String,
|
||||
name_b: String,
|
||||
}
|
||||
|
||||
impl VethPair {
|
||||
/// Create a new veth pair with unique interface names.
|
||||
///
|
||||
/// Names are kept under 15 chars (IFNAMSIZ limit). Format: `ftXXa`/`ftXXb`
|
||||
/// where XX is an atomic counter combined with PID for cross-process uniqueness.
|
||||
fn create() -> Self {
|
||||
let id = VETH_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let pid = std::process::id() % 10000;
|
||||
let name_a = format!("ft{}{}a", pid, id);
|
||||
let name_b = format!("ft{}{}b", pid, id);
|
||||
|
||||
assert!(name_a.len() <= 15, "veth name too long: {}", name_a);
|
||||
assert!(name_b.len() <= 15, "veth name too long: {}", name_b);
|
||||
|
||||
// Create veth pair
|
||||
let status = Command::new("ip")
|
||||
.args([
|
||||
"link", "add", &name_a, "type", "veth", "peer", "name", &name_b,
|
||||
])
|
||||
.status()
|
||||
.expect("failed to run 'ip link add'");
|
||||
assert!(status.success(), "failed to create veth pair");
|
||||
|
||||
// Bring both ends up
|
||||
let status = Command::new("ip")
|
||||
.args(["link", "set", &name_a, "up"])
|
||||
.status()
|
||||
.expect("failed to run 'ip link set up'");
|
||||
assert!(status.success(), "failed to bring up {}", name_a);
|
||||
|
||||
let status = Command::new("ip")
|
||||
.args(["link", "set", &name_b, "up"])
|
||||
.status()
|
||||
.expect("failed to run 'ip link set up'");
|
||||
assert!(status.success(), "failed to bring up {}", name_b);
|
||||
|
||||
VethPair { name_a, name_b }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VethPair {
|
||||
fn drop(&mut self) {
|
||||
// Deleting one end destroys both
|
||||
let _ = Command::new("ip")
|
||||
.args(["link", "delete", &self.name_a])
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a test node with a live Ethernet transport on the given interface.
|
||||
///
|
||||
/// Parallel to `make_test_node()` in spanning_tree.rs but uses
|
||||
/// EthernetTransport instead of UDP.
|
||||
async fn make_test_node_ethernet(interface: &str) -> TestNode {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
|
||||
let config = EthernetConfig {
|
||||
interface: interface.to_string(),
|
||||
discovery: Some(false),
|
||||
announce: Some(false),
|
||||
accept_connections: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (packet_tx, packet_rx) = packet_channel(256);
|
||||
let mut transport = EthernetTransport::new(transport_id, None, config, packet_tx);
|
||||
transport.start_async().await.unwrap();
|
||||
|
||||
let mac = transport
|
||||
.local_mac()
|
||||
.expect("transport should have MAC after start");
|
||||
let addr = TransportAddr::from_bytes(&mac);
|
||||
|
||||
node.transports
|
||||
.insert(transport_id, TransportHandle::Ethernet(transport));
|
||||
|
||||
TestNode {
|
||||
node,
|
||||
transport_id,
|
||||
packet_rx: spanning_tree::bridge_to_unbounded(packet_rx),
|
||||
addr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two nodes on a veth pair complete a Noise handshake and establish peering.
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires root or CAP_NET_RAW
|
||||
async fn test_ethernet_two_node_handshake() {
|
||||
let veth = VethPair::create();
|
||||
|
||||
let mut nodes = vec![
|
||||
make_test_node_ethernet(&veth.name_a).await,
|
||||
make_test_node_ethernet(&veth.name_b).await,
|
||||
];
|
||||
|
||||
// Initiate handshake from node 0 to node 1
|
||||
initiate_handshake(&mut nodes, 0, 1).await;
|
||||
|
||||
// Drain all packets (handshake + tree announce)
|
||||
let total = drain_all_packets(&mut nodes, false).await;
|
||||
assert!(total > 0, "should have processed packets");
|
||||
|
||||
// Verify bidirectional peering
|
||||
let addr_0 = *nodes[0].node.node_addr();
|
||||
let addr_1 = *nodes[1].node.node_addr();
|
||||
assert!(
|
||||
nodes[0].node.get_peer(&addr_1).is_some(),
|
||||
"node 0 should have node 1 as peer"
|
||||
);
|
||||
assert!(
|
||||
nodes[1].node.get_peer(&addr_0).is_some(),
|
||||
"node 1 should have node 0 as peer"
|
||||
);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// Two Ethernet nodes converge to a correct spanning tree (2-node tree).
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires root or CAP_NET_RAW
|
||||
async fn test_ethernet_data_exchange() {
|
||||
use spanning_tree::verify_tree_convergence;
|
||||
|
||||
let veth = VethPair::create();
|
||||
|
||||
let mut nodes = vec![
|
||||
make_test_node_ethernet(&veth.name_a).await,
|
||||
make_test_node_ethernet(&veth.name_b).await,
|
||||
];
|
||||
|
||||
initiate_handshake(&mut nodes, 0, 1).await;
|
||||
let total = drain_all_packets(&mut nodes, false).await;
|
||||
assert!(total > 0);
|
||||
|
||||
// Verify spanning tree convergence
|
||||
verify_tree_convergence(&nodes);
|
||||
|
||||
// The root should be the node with the smallest NodeAddr
|
||||
let expected_root = std::cmp::min(*nodes[0].node.node_addr(), *nodes[1].node.node_addr());
|
||||
assert_eq!(*nodes[0].node.tree_state().root(), expected_root);
|
||||
assert_eq!(*nodes[1].node.tree_state().root(), expected_root);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// Mixed transport: 2 Ethernet nodes + 2 UDP nodes coexist.
|
||||
///
|
||||
/// Each transport forms its own connected component. Validates that
|
||||
/// `process_available_packets()` handles heterogeneous transport types.
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires root or CAP_NET_RAW
|
||||
async fn test_mixed_transport_coexistence() {
|
||||
use spanning_tree::{make_test_node, verify_tree_convergence_components};
|
||||
|
||||
let veth = VethPair::create();
|
||||
|
||||
// Create 2 Ethernet nodes and 2 UDP nodes
|
||||
let eth_0 = make_test_node_ethernet(&veth.name_a).await;
|
||||
let eth_1 = make_test_node_ethernet(&veth.name_b).await;
|
||||
let udp_0 = make_test_node().await;
|
||||
let udp_1 = make_test_node().await;
|
||||
|
||||
let mut nodes = vec![eth_0, eth_1, udp_0, udp_1];
|
||||
|
||||
// Handshake within each component
|
||||
initiate_handshake(&mut nodes, 0, 1).await; // Ethernet pair
|
||||
initiate_handshake(&mut nodes, 2, 3).await; // UDP pair
|
||||
|
||||
// Drain all packets across both transports
|
||||
let total = drain_all_packets(&mut nodes, false).await;
|
||||
assert!(total > 0);
|
||||
|
||||
// Verify each component converges independently
|
||||
verify_tree_convergence_components(&nodes, &[vec![0, 1], vec![2, 3]]);
|
||||
|
||||
// Ethernet component has its own root
|
||||
let eth_root = std::cmp::min(*nodes[0].node.node_addr(), *nodes[1].node.node_addr());
|
||||
assert_eq!(*nodes[0].node.tree_state().root(), eth_root);
|
||||
assert_eq!(*nodes[1].node.tree_state().root(), eth_root);
|
||||
|
||||
// UDP component has its own root
|
||||
let udp_root = std::cmp::min(*nodes[2].node.node_addr(), *nodes[3].node.node_addr());
|
||||
assert_eq!(*nodes[2].node.tree_state().root(), udp_root);
|
||||
assert_eq!(*nodes[3].node.tree_state().root(), udp_root);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
@@ -40,22 +40,116 @@ async fn test_forwarding_hop_limit_exhausted() {
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
// No panic, no send (node has no peers)
|
||||
let fwd = &node.metrics().forwarding;
|
||||
assert_eq!(
|
||||
fwd.ttl_exhausted_packets.get(),
|
||||
1,
|
||||
"transit ttl=0 should be charged to TtlExhausted"
|
||||
);
|
||||
assert_eq!(
|
||||
fwd.drop_no_route_packets.get(),
|
||||
0,
|
||||
"transit ttl=0 should never reach the routing step"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_hop_limit_one_drops_at_transit() {
|
||||
// ttl=1 means after decrement it becomes 0 — the datagram can
|
||||
// still be delivered this hop but would be dropped at the next.
|
||||
// decrement_ttl returns true (1 > 0), so the handler proceeds.
|
||||
async fn test_forwarding_ttl_one_local_delivery_is_not_gated() {
|
||||
// dest == self, so this is local delivery, not transit: the TTL gate
|
||||
// does not apply and the datagram is handed to the session layer.
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let my_addr = *node.node_addr();
|
||||
let src = make_node_addr(0x01);
|
||||
let dg = SessionDatagram::new(src, my_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(1);
|
||||
let encoded = dg.encode();
|
||||
// Should succeed — ttl=1 decrements to 0 but packet is still processed
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
let fwd = &node.metrics().forwarding;
|
||||
assert_eq!(fwd.delivered_packets.get(), 1, "ttl=1 should be delivered");
|
||||
assert_eq!(fwd.ttl_exhausted_packets.get(), 0);
|
||||
}
|
||||
|
||||
/// Acceptance: a datagram addressed to this node with ttl=0 is delivered
|
||||
/// locally. The TTL governs forwarding, not delivery to the addressed host,
|
||||
/// so the gate must sit after the local-delivery test — and the
|
||||
/// `TtlExhausted` reject must not be charged for a delivered datagram.
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_ttl_zero_local_delivery_is_not_gated() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let my_addr = *node.node_addr();
|
||||
let src = make_node_addr(0x01);
|
||||
let dg = SessionDatagram::new(src, my_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(0);
|
||||
let encoded = dg.encode();
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
let fwd = &node.metrics().forwarding;
|
||||
assert_eq!(
|
||||
fwd.delivered_packets.get(),
|
||||
1,
|
||||
"ttl=0 addressed to this node must still be delivered locally"
|
||||
);
|
||||
assert_eq!(
|
||||
fwd.ttl_exhausted_packets.get(),
|
||||
0,
|
||||
"local delivery must not be charged to the TtlExhausted reject"
|
||||
);
|
||||
assert_eq!(fwd.drop_no_route_packets.get(), 0);
|
||||
}
|
||||
|
||||
/// Acceptance: a transit datagram arriving with ttl=1 would leave with ttl=0,
|
||||
/// so it is dropped here rather than transmitted. Reaching the routing step at
|
||||
/// all (`drop_no_route`) would mean it had been handed to the forwarder.
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_ttl_one_transit_dropped_before_routing() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let src = make_node_addr(0x01);
|
||||
let dest = make_node_addr(0x02);
|
||||
let dg = SessionDatagram::new(src, dest, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(1);
|
||||
let encoded = dg.encode();
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
let fwd = &node.metrics().forwarding;
|
||||
assert_eq!(
|
||||
fwd.ttl_exhausted_packets.get(),
|
||||
1,
|
||||
"transit ttl=1 must be dropped as TTL-exhausted, not forwarded"
|
||||
);
|
||||
assert_eq!(
|
||||
fwd.drop_no_route_packets.get(),
|
||||
0,
|
||||
"transit ttl=1 must not reach the routing step"
|
||||
);
|
||||
assert_eq!(fwd.forwarded_packets.get(), 0);
|
||||
assert_eq!(fwd.delivered_packets.get(), 0);
|
||||
}
|
||||
|
||||
/// The other side of the same boundary: ttl=2 clears the gate. This node has
|
||||
/// no peers, so it fails at the routing step instead — which is the evidence
|
||||
/// that the TTL gate passed it through.
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_ttl_two_transit_clears_the_gate() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let src = make_node_addr(0x01);
|
||||
let dest = make_node_addr(0x02);
|
||||
let dg = SessionDatagram::new(src, dest, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(2);
|
||||
let encoded = dg.encode();
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
let fwd = &node.metrics().forwarding;
|
||||
assert_eq!(
|
||||
fwd.ttl_exhausted_packets.get(),
|
||||
0,
|
||||
"transit ttl=2 must clear the TTL gate"
|
||||
);
|
||||
assert_eq!(
|
||||
fwd.drop_no_route_packets.get(),
|
||||
1,
|
||||
"transit ttl=2 should have reached the routing step and found no route"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Local delivery ---
|
||||
@@ -392,9 +486,9 @@ async fn test_forwarding_multi_hop() {
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_hop_limit_prevents_infinite_loops() {
|
||||
// 3-node chain: 0 -- 1 -- 2
|
||||
// Send a datagram with ttl=1. It should be forwarded by node 1
|
||||
// (decrement to 0) and delivered at node 2 (local delivery). If node 2
|
||||
// tried to forward further, the 0 ttl would prevent it.
|
||||
// Send a datagram with ttl=2. Node 1 forwards it as transit (2 -> 1) and
|
||||
// node 2 delivers it locally, which is not TTL-gated. Had node 2 been
|
||||
// transit instead, the arriving ttl=1 would have stopped it there.
|
||||
let edges = vec![(0, 1), (1, 2)];
|
||||
let mut nodes = run_tree_test(3, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
@@ -409,7 +503,7 @@ async fn test_forwarding_hop_limit_prevents_infinite_loops() {
|
||||
node2_addr,
|
||||
vec![0x10, 0x00, 0x04, 0x00, 1, 2, 3, 4],
|
||||
)
|
||||
.with_ttl(2); // Enough for 0->1 (decrement to 1) and 1->2 (decrement to 0, local delivery)
|
||||
.with_ttl(2); // Node 1 forwards with ttl=1; node 2 is the destination
|
||||
|
||||
let encoded = dg.encode();
|
||||
|
||||
@@ -428,6 +522,115 @@ async fn test_forwarding_hop_limit_prevents_infinite_loops() {
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// Acceptance: a transit datagram arriving with ttl=2 leaves with ttl=1.
|
||||
///
|
||||
/// Pinned on a live 3-node chain (0 -- 1 -- 2) by where the datagram stops,
|
||||
/// since the TTL that leaves node 0 is only observable through what the next
|
||||
/// hop does with it. Both injections are transit at node 0 (external source,
|
||||
/// destined for node 2), so node 1 is a forwarder in both.
|
||||
///
|
||||
/// - ttl=2 in: node 0 must emit ttl=1, which node 1 (transit) drops. If node 0
|
||||
/// emitted ttl=2 unchanged, node 1 would forward and node 2 would deliver.
|
||||
/// - ttl=3 in: node 0 emits 2, node 1 emits 1, node 2 delivers (delivery is
|
||||
/// not TTL-gated). If either hop decremented by more than one, the datagram
|
||||
/// would have died at node 1 instead.
|
||||
///
|
||||
/// Together the two pin the decrement at exactly one per hop and the drop at
|
||||
/// would-leave-zero.
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_ttl_decrement_is_one_per_hop() {
|
||||
let edges = vec![(0, 1), (1, 2)];
|
||||
let mut nodes = run_tree_test(3, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
populate_all_coord_caches(&mut nodes);
|
||||
|
||||
let node0_addr = *nodes[0].node.node_addr();
|
||||
let node2_addr = *nodes[2].node.node_addr();
|
||||
let external_src = make_node_addr(0xEE);
|
||||
|
||||
// --- ttl=2: must die at node 1, one hop short of the destination ---
|
||||
let dg =
|
||||
SessionDatagram::new(external_src, node2_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(2);
|
||||
let encoded = dg.encode();
|
||||
nodes[0]
|
||||
.node
|
||||
.handle_session_datagram(&node0_addr, &encoded[1..], false)
|
||||
.await;
|
||||
|
||||
for _ in 0..3 {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
process_available_packets(&mut nodes).await;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
nodes[0].node.metrics().forwarding.forwarded_packets.get(),
|
||||
1,
|
||||
"node 0 should have forwarded the ttl=2 datagram"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[1]
|
||||
.node
|
||||
.metrics()
|
||||
.forwarding
|
||||
.ttl_exhausted_packets
|
||||
.get(),
|
||||
1,
|
||||
"node 1 should have received ttl=1 and dropped it as TTL-exhausted"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[1].node.metrics().forwarding.forwarded_packets.get(),
|
||||
0,
|
||||
"node 1 must not forward a datagram that would leave with ttl=0"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[2].node.metrics().forwarding.delivered_packets.get(),
|
||||
0,
|
||||
"node 2 must never see the ttl=2 datagram"
|
||||
);
|
||||
|
||||
// --- ttl=3: must survive both transit hops and be delivered at node 2 ---
|
||||
let dg =
|
||||
SessionDatagram::new(external_src, node2_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(3);
|
||||
let encoded = dg.encode();
|
||||
nodes[0]
|
||||
.node
|
||||
.handle_session_datagram(&node0_addr, &encoded[1..], false)
|
||||
.await;
|
||||
|
||||
for _ in 0..3 {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
process_available_packets(&mut nodes).await;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
nodes[0].node.metrics().forwarding.forwarded_packets.get(),
|
||||
2,
|
||||
"node 0 should have forwarded the ttl=3 datagram too"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[1].node.metrics().forwarding.forwarded_packets.get(),
|
||||
1,
|
||||
"node 1 should have forwarded the ttl=2 it received"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[1]
|
||||
.node
|
||||
.metrics()
|
||||
.forwarding
|
||||
.ttl_exhausted_packets
|
||||
.get(),
|
||||
1,
|
||||
"node 1 should not have dropped the second datagram"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[2].node.metrics().forwarding.delivered_packets.get(),
|
||||
1,
|
||||
"node 2 should have delivered the datagram that arrived with ttl=1"
|
||||
);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_no_route_generates_error() {
|
||||
// 2-node network: 0 -- 1
|
||||
|
||||
@@ -13,8 +13,6 @@ mod bootstrap;
|
||||
mod decrypt_failure;
|
||||
mod disconnect;
|
||||
mod discovery;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ethernet;
|
||||
mod forwarding;
|
||||
mod handshake;
|
||||
mod heartbeat;
|
||||
|
||||
@@ -1707,6 +1707,93 @@ async fn process_pending_retries_gated_at_capacity() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A TCP listener that accepts connections and then never speaks. A relay
|
||||
/// URL pointed at it makes the nostr client's websocket handshake hang, so
|
||||
/// `refetch_advert_for_stale_check` burns its full 2s fetch timeout without
|
||||
/// any network egress.
|
||||
fn spawn_blackhole_relay() -> String {
|
||||
use std::net::TcpListener;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind blackhole listener");
|
||||
let port = listener.local_addr().expect("blackhole local addr").port();
|
||||
std::thread::spawn(move || {
|
||||
let mut held = Vec::new();
|
||||
while let Ok((stream, _)) = listener.accept() {
|
||||
held.push(stream);
|
||||
}
|
||||
});
|
||||
format!("ws://127.0.0.1:{port}")
|
||||
}
|
||||
|
||||
/// The per-tick retry loop must not await the pre-dial advert refetch.
|
||||
///
|
||||
/// `process_pending_retries` runs inline on the node's 1s rx-loop tick. Each
|
||||
/// due peer's refetch carries a 2s relay-fetch timeout, so awaiting it stalls
|
||||
/// the whole tick by 2s per peer — up to `MAX_RETRY_CONNECTIONS_PER_TICK`
|
||||
/// times in one tick body. The refresh is fire-and-forget: it exists to make
|
||||
/// the *next* retry dial a fresh endpoint, and retries are backoff-paced.
|
||||
///
|
||||
/// Discriminator: wall-clock duration of one `process_pending_retries` call
|
||||
/// with several due peers whose refetches all hang. Awaited, the call takes
|
||||
/// `2s * peers`; spawned, it returns without waiting on any of them.
|
||||
#[tokio::test]
|
||||
async fn process_pending_retries_does_not_await_advert_refetch() {
|
||||
use std::time::Instant;
|
||||
|
||||
const DUE_PEERS: usize = 4;
|
||||
// Awaited: >= 8s (4 x 2s). Spawned: milliseconds. A 3s bound sits far
|
||||
// from both, so neither machine load nor the 2s timeout's own slack can
|
||||
// flip the verdict.
|
||||
const MAX_TICK_MS: u128 = 3_000;
|
||||
|
||||
let mut node = make_node_with_max_peers(64);
|
||||
|
||||
let mut bootstrap = NostrDiscovery::new_for_test();
|
||||
bootstrap
|
||||
.set_advert_relays_for_test(vec![spawn_blackhole_relay()])
|
||||
.await;
|
||||
node.nostr_discovery = Some(Arc::new(bootstrap));
|
||||
|
||||
let mut queued = Vec::new();
|
||||
for _ in 0..DUE_PEERS {
|
||||
let peer_npub = Identity::generate().npub();
|
||||
let peer_node_addr = *PeerIdentity::from_npub(&peer_npub).unwrap().node_addr();
|
||||
let mut state = super::super::retry::RetryState::new(crate::config::PeerConfig::new(
|
||||
peer_npub,
|
||||
"udp",
|
||||
"127.0.0.1:9",
|
||||
));
|
||||
state.retry_after_ms = 0;
|
||||
state.reconnect = true;
|
||||
node.retry_pending.insert(peer_node_addr, state);
|
||||
queued.push(peer_node_addr);
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
node.process_pending_retries(1_000).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
elapsed.as_millis() < MAX_TICK_MS,
|
||||
"retry tick must not block on the advert refetch: took {}ms for {} due peers \
|
||||
(a per-peer 2s relay-fetch timeout awaited inline is the fingerprint)",
|
||||
elapsed.as_millis(),
|
||||
DUE_PEERS
|
||||
);
|
||||
|
||||
// The rest of the loop body is unchanged: every due peer was still
|
||||
// attempted, failed for want of a transport, and was rescheduled.
|
||||
for addr in &queued {
|
||||
let state = node
|
||||
.retry_pending
|
||||
.get(addr)
|
||||
.expect("due peer must remain queued after a failed attempt");
|
||||
assert_eq!(
|
||||
state.retry_count, 1,
|
||||
"each due peer must still have been attempted and rescheduled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_nostr_discovery_established_gated_at_capacity() {
|
||||
use crate::discovery::EstablishedTraversal;
|
||||
@@ -1995,3 +2082,86 @@ async fn handle_msg1_admits_existing_peer_at_cap() {
|
||||
"rate limiter must rebalance after the (bypass-admitted) handler returns"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Transport kernel-drop detection (sans-IO) =====
|
||||
//
|
||||
// The drop-detection edge-detector, tested directly. It replaces the
|
||||
// congestion-drops docker scenario, which could not provoke SO_RXQ_OVFL
|
||||
// deterministically (a fresh daemon reader keeps up with container-speed
|
||||
// traffic, so the kernel never overflows the socket queue). The kernel
|
||||
// dropping datagrams is not FIPS behaviour to test; the FIPS behaviour is
|
||||
// reading the SO_RXQ_OVFL counter and firing kernel_drop_events on the
|
||||
// transition into a new drop burst, which is exactly this decision.
|
||||
|
||||
#[test]
|
||||
fn test_transport_drop_state_fires_on_edge_and_rearms() {
|
||||
let mut s = TransportDropState::default();
|
||||
// Cumulative counter still 0: no rise, no event.
|
||||
assert!(!s.observe_drops(0));
|
||||
// First rise (0 -> 5): a new drop burst is observed, so it fires.
|
||||
assert!(s.observe_drops(5));
|
||||
// Counter keeps rising (5 -> 9) but we are already dropping: this is
|
||||
// the "first observed" contract, so it must NOT fire again.
|
||||
assert!(!s.observe_drops(9));
|
||||
// A sample with no further rise clears the dropping flag (no event).
|
||||
assert!(!s.observe_drops(9));
|
||||
// A later rise (9 -> 12) is a fresh burst and fires again.
|
||||
assert!(s.observe_drops(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transport_drop_state_steady_counter_fires_once() {
|
||||
let mut s = TransportDropState::default();
|
||||
// A cumulative counter that jumps once and then holds steady must
|
||||
// register exactly one event, not one per sample — otherwise a single
|
||||
// historical drop burst would report congestion forever.
|
||||
assert!(s.observe_drops(7));
|
||||
assert!(!s.observe_drops(7));
|
||||
assert!(!s.observe_drops(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_display_name_uses_cached_short_npub() {
|
||||
// Path 3 of `peer_display_name` (no host entry, no alias) reads the
|
||||
// per-peer cached short npub; it must still equal the value derived
|
||||
// from the peer's identity.
|
||||
let mut node = make_node();
|
||||
let peer_identity_full = Identity::generate();
|
||||
let peer_addr = *peer_identity_full.node_addr();
|
||||
let peer_identity = PeerIdentity::from_pubkey(peer_identity_full.pubkey());
|
||||
node.peers
|
||||
.insert(peer_addr, ActivePeer::new(peer_identity, LinkId::new(1), 0));
|
||||
|
||||
assert_eq!(
|
||||
node.peer_display_name(&peer_addr),
|
||||
peer_identity.short_npub()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_display_name_tracks_alias_change() {
|
||||
// The display name is NOT cached on the peer: `peer_aliases` is a
|
||||
// runtime-mutable map (`update_peers` inserts and removes entries), so
|
||||
// a cached name would go stale. Caching only the immutable short npub
|
||||
// must leave that tracking intact.
|
||||
let mut node = make_node();
|
||||
let peer_identity_full = Identity::generate();
|
||||
let peer_addr = *peer_identity_full.node_addr();
|
||||
let peer_identity = PeerIdentity::from_pubkey(peer_identity_full.pubkey());
|
||||
node.peers
|
||||
.insert(peer_addr, ActivePeer::new(peer_identity, LinkId::new(1), 0));
|
||||
|
||||
assert_eq!(
|
||||
node.peer_display_name(&peer_addr),
|
||||
peer_identity.short_npub()
|
||||
);
|
||||
|
||||
node.peer_aliases.insert(peer_addr, "gateway".to_string());
|
||||
assert_eq!(node.peer_display_name(&peer_addr), "gateway");
|
||||
|
||||
node.peer_aliases.remove(&peer_addr);
|
||||
assert_eq!(
|
||||
node.peer_display_name(&peer_addr),
|
||||
peer_identity.short_npub()
|
||||
);
|
||||
}
|
||||
|
||||
+82
-1
@@ -81,6 +81,16 @@ pub struct ActivePeer {
|
||||
// === Identity (Verified) ===
|
||||
/// Cryptographic identity (verified via handshake).
|
||||
identity: PeerIdentity,
|
||||
/// Bech32 npub, derived once at construction.
|
||||
///
|
||||
/// The npub is a pure function of `identity`'s public key, and
|
||||
/// `identity` is never mutated after construction, so this can never
|
||||
/// go stale. Deriving it costs a bech32 encode, which the per-tick
|
||||
/// stats snapshot was paying once per peer per tick.
|
||||
npub: String,
|
||||
/// Shortened npub for log/UI display, derived once at construction.
|
||||
/// Immutable for the same reason as [`ActivePeer::npub`].
|
||||
short_npub: String,
|
||||
|
||||
// === Connection ===
|
||||
/// Link used to reach this peer.
|
||||
@@ -224,6 +234,8 @@ impl ActivePeer {
|
||||
pub fn new(identity: PeerIdentity, link_id: LinkId, authenticated_at: u64) -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
npub: identity.npub(),
|
||||
short_npub: identity.short_npub(),
|
||||
identity,
|
||||
link_id,
|
||||
connectivity: ConnectivityState::Connected,
|
||||
@@ -310,6 +322,8 @@ impl ActivePeer {
|
||||
) -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
npub: identity.npub(),
|
||||
short_npub: identity.short_npub(),
|
||||
identity,
|
||||
link_id,
|
||||
connectivity: ConnectivityState::Connected,
|
||||
@@ -423,8 +437,21 @@ impl ActivePeer {
|
||||
}
|
||||
|
||||
/// Get the peer's npub string.
|
||||
///
|
||||
/// Returns a clone of the value cached at construction; the bech32
|
||||
/// encode is not repeated.
|
||||
pub fn npub(&self) -> String {
|
||||
self.identity.npub()
|
||||
self.npub.clone()
|
||||
}
|
||||
|
||||
/// Borrow the peer's cached npub without allocating.
|
||||
pub fn npub_str(&self) -> &str {
|
||||
&self.npub
|
||||
}
|
||||
|
||||
/// Borrow the peer's cached shortened npub (e.g. `npub1abcd...wxyz`).
|
||||
pub fn short_npub(&self) -> &str {
|
||||
&self.short_npub
|
||||
}
|
||||
|
||||
// === Connection Accessors ===
|
||||
@@ -1219,6 +1246,60 @@ mod tests {
|
||||
assert!(peer.needs_filter_update()); // New peers need filter
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_npub_cache_matches_identity() {
|
||||
let identity = make_peer_identity();
|
||||
let peer = ActivePeer::new(identity, LinkId::new(1), 1000);
|
||||
|
||||
assert_eq!(peer.npub(), identity.npub());
|
||||
assert_eq!(peer.npub_str(), identity.npub());
|
||||
assert_eq!(peer.short_npub(), identity.short_npub());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_npub_cache_matches_identity_with_session() {
|
||||
// `with_session` builds its own struct literal, so it needs its
|
||||
// own check that the cache is populated from the same identity.
|
||||
let identity = make_peer_identity();
|
||||
let (session, _peer_session) = ik_session_pair();
|
||||
|
||||
let peer = ActivePeer::with_session(
|
||||
identity,
|
||||
LinkId::new(1),
|
||||
1000,
|
||||
session,
|
||||
SessionIndex::new(1),
|
||||
SessionIndex::new(2),
|
||||
TransportId::new(1),
|
||||
TransportAddr::from_string("127.0.0.1:9000"),
|
||||
LinkStats::new(),
|
||||
true,
|
||||
&MmpConfig::default(),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(peer.npub(), identity.npub());
|
||||
assert_eq!(peer.short_npub(), identity.short_npub());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_npub_is_memoized_not_rederived() {
|
||||
// The whole point of the fix: the strings are stored on the peer,
|
||||
// not recomputed per call. A stored string keeps one heap buffer,
|
||||
// so repeated borrows have a stable address. A per-call bech32
|
||||
// encode would hand back a fresh allocation each time.
|
||||
let identity = make_peer_identity();
|
||||
let peer = ActivePeer::new(identity, LinkId::new(1), 1000);
|
||||
|
||||
let first = peer.npub_str().as_ptr();
|
||||
let second = peer.npub_str().as_ptr();
|
||||
assert_eq!(first, second);
|
||||
|
||||
let short_first = peer.short_npub().as_ptr();
|
||||
let short_second = peer.short_npub().as_ptr();
|
||||
assert_eq!(short_first, short_second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connectivity_transitions() {
|
||||
let identity = make_peer_identity();
|
||||
|
||||
+57
-9
@@ -337,19 +337,27 @@ impl SessionDatagram {
|
||||
self
|
||||
}
|
||||
|
||||
/// Decrement TTL, returning false if exhausted.
|
||||
/// Decrement the TTL for a transit hop, returning whether the result may
|
||||
/// still be transmitted.
|
||||
///
|
||||
/// Follows IP semantics: the decrement happens first, and a datagram that
|
||||
/// would leave with a TTL of zero is not transmitted. `saturating_sub`
|
||||
/// folds an already-exhausted arrival (TTL 0) into the same outcome as a
|
||||
/// last-hop arrival (TTL 1); both leave `ttl` at 0 and return false.
|
||||
///
|
||||
/// This governs forwarding only. Delivery to the addressed node is not
|
||||
/// TTL-gated and must not consult this method.
|
||||
pub fn decrement_ttl(&mut self) -> bool {
|
||||
if self.ttl > 0 {
|
||||
self.ttl -= 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
self.ttl = self.ttl.saturating_sub(1);
|
||||
self.ttl > 0
|
||||
}
|
||||
|
||||
/// Check if the datagram can be forwarded.
|
||||
/// Check whether this datagram would survive a transit hop.
|
||||
///
|
||||
/// True only at TTL 2 or more: at TTL 1 the decrement leaves zero, so the
|
||||
/// datagram is dropped rather than forwarded.
|
||||
pub fn can_forward(&self) -> bool {
|
||||
self.ttl > 0
|
||||
self.ttl > 1
|
||||
}
|
||||
|
||||
/// Encode as link-layer message (msg_type + ttl + path_mtu + src_addr + dest_addr + payload).
|
||||
@@ -675,4 +683,44 @@ mod tests {
|
||||
assert_eq!(decoded.ttl, hop);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_datagram_can_forward() {
|
||||
let dg = SessionDatagram::new(make_node_addr(1), make_node_addr(2), vec![0x42]);
|
||||
|
||||
assert!(!dg.clone().with_ttl(0).can_forward());
|
||||
assert!(
|
||||
!dg.clone().with_ttl(1).can_forward(),
|
||||
"ttl=1 would leave at zero, so it is not forwardable"
|
||||
);
|
||||
assert!(
|
||||
dg.clone().with_ttl(2).can_forward(),
|
||||
"ttl=2 leaves at one, so it is forwardable"
|
||||
);
|
||||
assert!(dg.with_ttl(255).can_forward());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_datagram_decrement_ttl() {
|
||||
let base = SessionDatagram::new(make_node_addr(1), make_node_addr(2), vec![0x42]);
|
||||
|
||||
let mut dg = base.clone().with_ttl(0);
|
||||
assert!(!dg.decrement_ttl(), "ttl=0 is already exhausted");
|
||||
assert_eq!(dg.ttl, 0, "decrement must saturate rather than wrap");
|
||||
|
||||
let mut dg = base.clone().with_ttl(1);
|
||||
assert!(
|
||||
!dg.decrement_ttl(),
|
||||
"ttl=1 leaves at zero, so it is dropped"
|
||||
);
|
||||
assert_eq!(dg.ttl, 0);
|
||||
|
||||
let mut dg = base.clone().with_ttl(2);
|
||||
assert!(dg.decrement_ttl());
|
||||
assert_eq!(dg.ttl, 1);
|
||||
|
||||
let mut dg = base.with_ttl(64);
|
||||
assert!(dg.decrement_ttl());
|
||||
assert_eq!(dg.ttl, 63);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -853,7 +853,7 @@ mod tests {
|
||||
/// find N packets already buffered, and one syscall reaps the burst.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
#[ignore = "microbenchmark; run explicitly with --ignored --nocapture"]
|
||||
async fn bench_udp_recv_amortization() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -536,6 +536,172 @@ fn test_evaluate_parent_picks_loop_free_over_loopy() {
|
||||
assert_eq!(result, Some(peer2));
|
||||
}
|
||||
|
||||
// ===== Cost-based parent selection =====
|
||||
//
|
||||
// These exercise evaluate_parent's MMP-cost path directly, as a sans-IO
|
||||
// unit test of the exact decision. They replace six Docker chaos
|
||||
// scenarios — cost-reeval, cost-avoidance, cost-stability, depth-vs-cost,
|
||||
// mixed-technology and bottleneck-parent — whose subject was this
|
||||
// decision but which could not test it reliably: the mesh's root is
|
||||
// whichever node holds the smallest NodeAddr, MMP costs take several
|
||||
// measurement windows to settle, and hold-down plus hysteresis timing all
|
||||
// confounded the assertion. Here the peer ancestry, depths and costs are
|
||||
// constructed directly, so the decision is deterministic and each check
|
||||
// can fail on a real regression.
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_parent_cost_prefers_cheaper_link_at_equal_depth() {
|
||||
// mixed-technology / cost-avoidance subject: two candidate parents at
|
||||
// the SAME depth, one over a cheap (fiber) link and one over an
|
||||
// expensive (Bluetooth) link. The cheaper link must win.
|
||||
//
|
||||
// The cheap peer is given the LARGER NodeAddr on purpose: with cost
|
||||
// ignored the two candidates tie on depth and the NodeAddr tiebreak
|
||||
// would pick the expensive, smaller-addr peer. Only a cost-aware
|
||||
// decision picks the cheaper, larger-addr one, so the assertion
|
||||
// discriminates.
|
||||
let my_node = make_node_addr(5);
|
||||
let mut state = TreeState::new(my_node);
|
||||
|
||||
let expensive = make_node_addr(2); // smaller addr, high cost (Bluetooth)
|
||||
let cheap = make_node_addr(3); // larger addr, low cost (fiber)
|
||||
let root = make_node_addr(0);
|
||||
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(expensive, root, 1, 1000),
|
||||
make_coords(&[2, 0]), // depth 1
|
||||
);
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(cheap, root, 1, 1000),
|
||||
make_coords(&[3, 0]), // depth 1
|
||||
);
|
||||
|
||||
// eff_depth(expensive) = 1 + 4.0 = 5.0; eff_depth(cheap) = 1 + 1.0 = 2.0
|
||||
let costs = HashMap::from([(expensive, 4.0_f64), (cheap, 1.0_f64)]);
|
||||
assert_eq!(state.evaluate_parent(&costs), Some(cheap));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_parent_cost_switches_when_link_to_parent_degrades() {
|
||||
// cost-reeval subject: the node is parented to A over a cheap link;
|
||||
// that link then degrades so the alternative B is strictly cheaper.
|
||||
// Re-evaluation must switch to B. This is the periodic-reeval decision,
|
||||
// taken here without any timer, netem or MMP-measurement latency.
|
||||
let my_node = make_node_addr(5);
|
||||
let mut state = TreeState::new(my_node);
|
||||
|
||||
let peer_a = make_node_addr(2);
|
||||
let peer_b = make_node_addr(3);
|
||||
let root = make_node_addr(0);
|
||||
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_a, root, 1, 1000),
|
||||
make_coords(&[2, 0]), // depth 1
|
||||
);
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_b, root, 1, 1000),
|
||||
make_coords(&[3, 0]), // depth 1
|
||||
);
|
||||
|
||||
// Adopt A as parent (both links cheap at first).
|
||||
state.set_parent(peer_a, 1, 1000);
|
||||
state.recompute_coords();
|
||||
assert!(!state.is_root());
|
||||
|
||||
// A's link degrades: eff(A) = 1 + 5.0 = 6.0, eff(B) = 1 + 1.0 = 2.0.
|
||||
// Default hysteresis is zero, so the strictly-cheaper B wins.
|
||||
let costs = HashMap::from([(peer_a, 5.0_f64), (peer_b, 1.0_f64)]);
|
||||
assert_eq!(state.evaluate_parent(&costs), Some(peer_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_parent_hysteresis_suppresses_marginal_cost_change() {
|
||||
// cost-stability subject: a cost change smaller than the hysteresis
|
||||
// band must NOT trigger a reparent. This is the property the scenario
|
||||
// was named for and could only approximate with a switch-count ceiling.
|
||||
let my_node = make_node_addr(5);
|
||||
let mut state = TreeState::new(my_node);
|
||||
state.set_parent_hysteresis(0.2);
|
||||
|
||||
let peer_a = make_node_addr(2);
|
||||
let peer_b = make_node_addr(3);
|
||||
let root = make_node_addr(0);
|
||||
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_a, root, 1, 1000),
|
||||
make_coords(&[2, 0]),
|
||||
);
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_b, root, 1, 1000),
|
||||
make_coords(&[3, 0]),
|
||||
);
|
||||
state.set_parent(peer_a, 1, 1000);
|
||||
state.recompute_coords();
|
||||
|
||||
// eff(A) = 1 + 1.0 = 2.0; eff(B) = 1 + 0.9 = 1.9. B is cheaper, but
|
||||
// 1.9 is not below 2.0 * (1 - 0.2) = 1.6, so hysteresis holds the parent.
|
||||
let costs = HashMap::from([(peer_a, 1.0_f64), (peer_b, 0.9_f64)]);
|
||||
assert_eq!(state.evaluate_parent(&costs), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_parent_hysteresis_allows_significant_cost_change() {
|
||||
// cost-stability healthy-path companion: a change LARGER than the band
|
||||
// must still switch, so the hysteresis test above is not passing merely
|
||||
// because the node never reparents.
|
||||
let my_node = make_node_addr(5);
|
||||
let mut state = TreeState::new(my_node);
|
||||
state.set_parent_hysteresis(0.2);
|
||||
|
||||
let peer_a = make_node_addr(2);
|
||||
let peer_b = make_node_addr(3);
|
||||
let root = make_node_addr(0);
|
||||
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_a, root, 1, 1000),
|
||||
make_coords(&[2, 0]),
|
||||
);
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_b, root, 1, 1000),
|
||||
make_coords(&[3, 0]),
|
||||
);
|
||||
state.set_parent(peer_a, 1, 1000);
|
||||
state.recompute_coords();
|
||||
|
||||
// eff(A) = 2.0; eff(B) = 1 + 0.3 = 1.3 < 1.6 threshold → switch to B.
|
||||
let costs = HashMap::from([(peer_a, 1.0_f64), (peer_b, 0.3_f64)]);
|
||||
assert_eq!(state.evaluate_parent(&costs), Some(peer_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_parent_effective_depth_weighs_depth_against_cost() {
|
||||
// depth-vs-cost / bottleneck-parent subject: a shallow parent reached
|
||||
// over an expensive (bottleneck) link versus a deeper parent over a
|
||||
// cheap link. effective_depth = depth + link_cost decides, and here the
|
||||
// deeper-but-cheaper path wins — the outcome the depth-vs-cost scenario
|
||||
// named no falsifiable answer for.
|
||||
let my_node = make_node_addr(5);
|
||||
let mut state = TreeState::new(my_node);
|
||||
|
||||
let shallow = make_node_addr(2); // depth 1, bottleneck link
|
||||
let deep = make_node_addr(3); // depth 3, cheap link
|
||||
let root = make_node_addr(0);
|
||||
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(shallow, root, 1, 1000),
|
||||
make_coords(&[2, 0]), // depth 1
|
||||
);
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(deep, make_node_addr(6), 1, 1000),
|
||||
make_coords(&[3, 6, 7, 0]), // depth 3
|
||||
);
|
||||
|
||||
// eff(shallow) = 1 + 3.0 = 4.0; eff(deep) = 3 + 0.5 = 3.5 → pick deep.
|
||||
// A depth-only decision would take the shallow bottleneck instead.
|
||||
let costs = HashMap::from([(shallow, 3.0_f64), (deep, 0.5_f64)]);
|
||||
assert_eq!(state.evaluate_parent(&costs), Some(deep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_parent_lost_finds_alternative() {
|
||||
let my_node = make_node_addr(5);
|
||||
|
||||
+39
-6
@@ -16,8 +16,6 @@ configurations.
|
||||
| ----------- | ----- | --------- | -------------------------------- |
|
||||
| mesh | 5 | UDP | Sparse mesh, 6 links, multi-hop |
|
||||
| chain | 5 | UDP | Linear chain, max 4-hop paths |
|
||||
| mesh-public | 5+1 | UDP | Mesh with external public node |
|
||||
| tcp-chain | 3 | TCP | Linear chain over TCP (port 8443) |
|
||||
| rekey | 5 | UDP | Rekey integration test topology |
|
||||
|
||||
### [tor/](tor/) -- Tor Transport Integration
|
||||
@@ -79,8 +77,11 @@ a per-commit gate; not part of `ci-local.sh`.
|
||||
clippy, unit tests, and the integration suites (including the chaos
|
||||
scenarios) — mirroring the GitHub `ci.yml` integration matrix. Run
|
||||
`./ci-local.sh --help` for the full option list and `--list` for the
|
||||
available suites. `--check-parity` verifies the local suite set matches
|
||||
the GitHub matrix (see [check-ci-parity.sh](check-ci-parity.sh)).
|
||||
available suites. Every run starts with a parity check that verifies the
|
||||
local suite set covers the same work as the GitHub matrix, per scenario for
|
||||
chaos and per distro for deb-install; a divergence fails the run. GitHub
|
||||
runs the same check as its own `ci-parity` job. `--check-parity` runs it
|
||||
alone (see [check-ci-parity.sh](check-ci-parity.sh)).
|
||||
|
||||
### Per-run isolation and the `FIPS_CI_RUN_ID` override
|
||||
|
||||
@@ -92,8 +93,21 @@ flight) never collide:
|
||||
- **Compose projects** are named `fipsci_<run-id>_<suite>`, so
|
||||
container, network, and volume names are all prefixed per run.
|
||||
- **Build images** are tagged `fips-test:<run-id>` and
|
||||
`fips-test-app:<run-id>` (exported as `FIPS_TEST_IMAGE` /
|
||||
`FIPS_TEST_APP_IMAGE` for the compose consumers).
|
||||
`fips-test-app:<run-id>`, exported as `FIPS_TEST_IMAGE` /
|
||||
`FIPS_TEST_APP_IMAGE`, and **every** compose file and suite script reads
|
||||
those. The run does not write `fips-test:latest` at all: a bridge back to
|
||||
that shared mutable name would let a consumer that had been missed keep
|
||||
working while resolving whichever concurrent run wrote the tag last.
|
||||
`:latest` stays the hand-build name, produced by
|
||||
`testing/scripts/build.sh`, and remains the default every consumer falls
|
||||
back to when the variables are unset.
|
||||
- **The build context** is a per-run copy at `testing/docker-<run-id>/`,
|
||||
exported as `FIPS_BUILD_CONTEXT`. It is absolute because compose resolves
|
||||
a relative build context against the compose file's own directory rather
|
||||
than the working directory. `testing/docker/` is the hand-run context and
|
||||
a CI run does not write to it. Without this, two runs race on the contents
|
||||
of one directory and either can build a correctly-per-run-tagged image
|
||||
from the other's binaries.
|
||||
- Each parallel chaos child gets a unique, non-overlapping `/24` in
|
||||
`10.30.x` (via the sim `--subnet` override). `10.30.x` sits outside
|
||||
Docker's default address pool and the fixed-subnet suites' `172.x`
|
||||
@@ -144,3 +158,22 @@ and leaves resources behind, reap them with:
|
||||
label or a `fipsci_` compose-project prefix; it is safe to run when there
|
||||
is nothing to reap and safe to run repeatedly. Pass `--project-prefix` to
|
||||
scope the sweep to a single run.
|
||||
|
||||
It also removes the chaos simulation's leftover host-namespace veth
|
||||
interfaces (`vh…a`/`vh…b`), the one resource it touches that is neither a
|
||||
docker object nor labelled — a host interface can carry neither a label
|
||||
nor a compose project, so it is matched by name shape alone. That makes
|
||||
the reach here asymmetric with everything above, and worth stating
|
||||
plainly:
|
||||
|
||||
- A bare `chaos.sh` run's **containers** survive a broad reap. Its
|
||||
compose project is not `fipsci_`, and the simulation labels only the
|
||||
network, not the services.
|
||||
- A bare `chaos.sh` run's **veth interfaces do not.** An unscoped reap
|
||||
deletes them while they are in use, severing the Ethernet links of a
|
||||
live simulation and leaving its containers running.
|
||||
|
||||
So do not run a broad `--reap` while a bare simulation is up. Scope the
|
||||
interface sweep with `--veth-suffixes` (which is what `ci-local.sh`'s own
|
||||
teardown passes) or wait for the simulation to finish. `--project-prefix`
|
||||
does not help here: it scopes only the compose-project sweep.
|
||||
|
||||
@@ -1 +1 @@
|
||||
generated-configs
|
||||
generated-configs*
|
||||
|
||||
@@ -79,6 +79,12 @@ Docker service/container/hostname identifiers in this harness intentionally use
|
||||
`node-a` through `node-f`. For data-plane checks and operator examples, use the
|
||||
explicit FIPS names such as `node-a.fips` and `node-d.fips`.
|
||||
|
||||
The bridge network requests no subnet, so docker assigns one from its own
|
||||
address pool and two concurrent runs of this harness never contend for a fixed
|
||||
range. Consequently no node's IPv4 address is known before startup, and the
|
||||
generated peer stanzas address each other by docker hostname (`host-a` …
|
||||
`host-f`), resolved through the container's dnsmasq to docker's embedded DNS.
|
||||
|
||||
ACL paths are fixed in this branch:
|
||||
|
||||
- `/etc/fips/peers.allow`
|
||||
@@ -93,7 +99,9 @@ Mounted ACL files in this harness:
|
||||
|
||||
Generated fixture location:
|
||||
|
||||
- `testing/acl-allowlist/generated-configs/`
|
||||
- `testing/acl-allowlist/generated-configs/`, or
|
||||
`generated-configs<suffix>/` when `FIPS_CI_NAME_SUFFIX` is set, which is how
|
||||
concurrent runs keep their fixtures apart
|
||||
|
||||
Inspect peer state:
|
||||
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
networks:
|
||||
# No subnet is requested: docker assigns one from the daemon's address pool.
|
||||
# A fixed request is honoured verbatim, so two runs asking for the same range
|
||||
# collide on "Pool overlaps" — which is why the generated peer addresses are
|
||||
# docker hostnames (host-a … host-f) rather than literal IPs. Docker's
|
||||
# embedded DNS is per-network, so the same hostname in two concurrent runs
|
||||
# resolves inside each run's own subnet.
|
||||
#
|
||||
# That removes one of the two obstacles to running this suite twice at once,
|
||||
# not both. The compose project name is still fixed, so two runs that do not
|
||||
# set COMPOSE_PROJECT_NAME share a project, and the second `up` recreates the
|
||||
# first's containers while either `down` removes both. Nothing scopes it for
|
||||
# this suite: it is run by hand only, so the caller has to. Do not read the
|
||||
# floating subnet as making concurrent bare runs safe.
|
||||
acl-net:
|
||||
driver: bridge
|
||||
labels:
|
||||
- "com.corganlabs.fips-ci=1"
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.31.0.0/24
|
||||
|
||||
x-fips-common: &fips-common
|
||||
build:
|
||||
context: ../docker
|
||||
image: fips-test:latest
|
||||
# The harness scopes its build context per run and passes it here; the
|
||||
# shared directory is the hand-run default. Compose resolves a relative
|
||||
# value against THIS file's directory, so the harness must export an
|
||||
# absolute path.
|
||||
context: ${FIPS_BUILD_CONTEXT:-../docker}
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
entrypoint: ["/usr/local/bin/entrypoint.sh"]
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
@@ -32,14 +46,13 @@ services:
|
||||
hostname: host-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/node-a/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs/node-a/peers.allow:/etc/fips/peers.allow:ro
|
||||
- ./generated-configs/node-a/peers.deny:/etc/fips/peers.deny:ro
|
||||
- ./generated-configs/node-a/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs/node-a/fips.key:/etc/fips/fips.key:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-a/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-a/peers.allow:/etc/fips/peers.allow:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-a/peers.deny:/etc/fips/peers.deny:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-a/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-a/fips.key:/etc/fips/fips.key:ro
|
||||
networks:
|
||||
acl-net:
|
||||
ipv4_address: 172.31.0.10
|
||||
- acl-net
|
||||
|
||||
service-b:
|
||||
<<: *fips-common
|
||||
@@ -47,14 +60,13 @@ services:
|
||||
hostname: host-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/node-b/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs/node-b/peers.allow:/etc/fips/peers.allow:ro
|
||||
- ./generated-configs/node-b/peers.deny:/etc/fips/peers.deny:ro
|
||||
- ./generated-configs/node-b/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs/node-b/fips.key:/etc/fips/fips.key:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-b/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-b/peers.allow:/etc/fips/peers.allow:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-b/peers.deny:/etc/fips/peers.deny:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-b/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-b/fips.key:/etc/fips/fips.key:ro
|
||||
networks:
|
||||
acl-net:
|
||||
ipv4_address: 172.31.0.11
|
||||
- acl-net
|
||||
|
||||
service-c:
|
||||
<<: *fips-common
|
||||
@@ -62,14 +74,13 @@ services:
|
||||
hostname: host-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/node-c/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs/node-c/peers.allow:/etc/fips/peers.allow:ro
|
||||
- ./generated-configs/node-c/peers.deny:/etc/fips/peers.deny:ro
|
||||
- ./generated-configs/node-c/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs/node-c/fips.key:/etc/fips/fips.key:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-c/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-c/peers.allow:/etc/fips/peers.allow:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-c/peers.deny:/etc/fips/peers.deny:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-c/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-c/fips.key:/etc/fips/fips.key:ro
|
||||
networks:
|
||||
acl-net:
|
||||
ipv4_address: 172.31.0.12
|
||||
- acl-net
|
||||
|
||||
service-d:
|
||||
<<: *fips-common
|
||||
@@ -77,14 +88,13 @@ services:
|
||||
hostname: host-d
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/node-d/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs/node-d/peers.allow:/etc/fips/peers.allow:ro
|
||||
- ./generated-configs/node-d/peers.deny:/etc/fips/peers.deny:ro
|
||||
- ./generated-configs/node-d/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs/node-d/fips.key:/etc/fips/fips.key:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-d/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-d/peers.allow:/etc/fips/peers.allow:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-d/peers.deny:/etc/fips/peers.deny:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-d/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-d/fips.key:/etc/fips/fips.key:ro
|
||||
networks:
|
||||
acl-net:
|
||||
ipv4_address: 172.31.0.13
|
||||
- acl-net
|
||||
|
||||
service-e:
|
||||
<<: *fips-common
|
||||
@@ -92,12 +102,11 @@ services:
|
||||
hostname: host-e
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/node-e/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs/node-e/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs/node-e/fips.key:/etc/fips/fips.key:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-e/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-e/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-e/fips.key:/etc/fips/fips.key:ro
|
||||
networks:
|
||||
acl-net:
|
||||
ipv4_address: 172.31.0.14
|
||||
- acl-net
|
||||
|
||||
service-f:
|
||||
<<: *fips-common
|
||||
@@ -105,9 +114,8 @@ services:
|
||||
hostname: host-f
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/node-f/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs/node-f/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs/node-f/fips.key:/etc/fips/fips.key:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-f/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-f/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-f/fips.key:/etc/fips/fips.key:ro
|
||||
networks:
|
||||
acl-net:
|
||||
ipv4_address: 172.31.0.15
|
||||
- acl-net
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
GENERATED_DIR="$SCRIPT_DIR/generated-configs"
|
||||
|
||||
# Scoped by the per-run suffix because this directory is wiped and rewritten
|
||||
# below: two runs sharing one output directory would delete each other's
|
||||
# fixtures out from under running containers. Unset (a bare hand run, or the
|
||||
# GitHub-hosted path) it collapses to the historical "generated-configs".
|
||||
GENERATED_DIR="$SCRIPT_DIR/generated-configs${FIPS_CI_NAME_SUFFIX:-}"
|
||||
|
||||
write_file() {
|
||||
local path="$1"
|
||||
@@ -23,6 +28,10 @@ node-f npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c
|
||||
EOF
|
||||
}
|
||||
|
||||
# Peers are addressed by the docker hostname the compose file assigns
|
||||
# (host-a … host-f), not by IP. The network requests no subnet so that two
|
||||
# concurrent runs cannot collide on one address range, which means no node's
|
||||
# address is knowable before `docker compose up`.
|
||||
echo "Generating ACL allowlist fixtures..."
|
||||
rm -rf "$GENERATED_DIR"
|
||||
|
||||
@@ -48,31 +57,31 @@ peers:
|
||||
alias: "node-b"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.11:2121"
|
||||
addr: "host-b:2121"
|
||||
connect_policy: auto_connect
|
||||
- npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
|
||||
alias: "node-c"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.12:2121"
|
||||
addr: "host-c:2121"
|
||||
connect_policy: auto_connect
|
||||
- npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl"
|
||||
alias: "node-d"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.13:2121"
|
||||
addr: "host-d:2121"
|
||||
connect_policy: auto_connect
|
||||
- npub: "npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6"
|
||||
alias: "node-e"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.14:2121"
|
||||
addr: "host-e:2121"
|
||||
connect_policy: auto_connect
|
||||
- npub: "npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
||||
alias: "node-f"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.15:2121"
|
||||
addr: "host-f:2121"
|
||||
connect_policy: auto_connect
|
||||
EOF
|
||||
|
||||
@@ -113,19 +122,19 @@ peers:
|
||||
alias: "node-a"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.10:2121"
|
||||
addr: "host-a:2121"
|
||||
connect_policy: auto_connect
|
||||
- npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
|
||||
alias: "node-c"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.12:2121"
|
||||
addr: "host-c:2121"
|
||||
connect_policy: auto_connect
|
||||
- npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl"
|
||||
alias: "node-d"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.13:2121"
|
||||
addr: "host-d:2121"
|
||||
connect_policy: auto_connect
|
||||
EOF
|
||||
|
||||
@@ -166,7 +175,7 @@ peers:
|
||||
alias: "node-a"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.10:2121"
|
||||
addr: "host-a:2121"
|
||||
connect_policy: auto_connect
|
||||
EOF
|
||||
|
||||
@@ -209,7 +218,7 @@ peers:
|
||||
alias: "node-a"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.10:2121"
|
||||
addr: "host-a:2121"
|
||||
connect_policy: auto_connect
|
||||
EOF
|
||||
|
||||
@@ -252,7 +261,7 @@ peers:
|
||||
alias: "node-a"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.10:2121"
|
||||
addr: "host-a:2121"
|
||||
connect_policy: auto_connect
|
||||
EOF
|
||||
|
||||
@@ -282,7 +291,7 @@ peers:
|
||||
alias: "node-a"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.31.0.10:2121"
|
||||
addr: "host-a:2121"
|
||||
connect_policy: auto_connect
|
||||
EOF
|
||||
|
||||
|
||||
+117
-23
@@ -75,47 +75,103 @@ assert_acl_field() {
|
||||
echo "PASS: $container ACL field $field matches expected value"
|
||||
}
|
||||
|
||||
# Connected-peer count for a container, or the empty string if it did not
|
||||
# answer.
|
||||
#
|
||||
# Empty is deliberately distinct from a real 0, and here the distinction is
|
||||
# the whole point: the ACL denial checks below expect exactly 0, so an
|
||||
# `|| echo 0` fallback lets an unreachable container satisfy them on the first
|
||||
# iteration and the security property is never observed. Same shape as
|
||||
# admission-cap-test.sh's read_peer_count.
|
||||
read_connected_peers() {
|
||||
local container="$1"
|
||||
docker exec "$container" fipsctl show peers 2>/dev/null \
|
||||
| python3 -c 'import json,sys; data=json.load(sys.stdin); print(sum(1 for p in data.get("peers", []) if p.get("connectivity") == "connected"))' 2>/dev/null \
|
||||
|| true
|
||||
}
|
||||
|
||||
wait_for_peers_exact() {
|
||||
local container="$1"
|
||||
local expected_count="$2"
|
||||
local timeout="${3:-30}"
|
||||
|
||||
local count="" answered=false
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
local count
|
||||
count=$(docker exec "$container" fipsctl show peers 2>/dev/null \
|
||||
| python3 -c 'import json,sys; data=json.load(sys.stdin); print(sum(1 for p in data.get("peers", []) if p.get("connectivity") == "connected"))' 2>/dev/null || echo 0)
|
||||
if [ "$count" -eq "$expected_count" ]; then
|
||||
return 0
|
||||
count=$(read_connected_peers "$container")
|
||||
if [ -n "$count" ]; then
|
||||
answered=true
|
||||
if [ "$count" -eq "$expected_count" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "FAIL: $container did not reach $expected_count connected peers in ${timeout}s" >&2
|
||||
if [ "$answered" = false ]; then
|
||||
echo "FAIL: $container never answered a peer query in ${timeout}s, so a count of $expected_count was never actually observed" >&2
|
||||
else
|
||||
echo "FAIL: $container did not reach $expected_count connected peers in ${timeout}s (last answer: $count)" >&2
|
||||
fi
|
||||
docker exec "$container" fipsctl show peers >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_log_contains() {
|
||||
# Assert that ONE log line in $container contains every one of the given
|
||||
# fixed strings.
|
||||
#
|
||||
# Single-line matching is the point. Independent whole-log greps for an
|
||||
# npub and for `decision=denylist match` are jointly satisfied by "this
|
||||
# npub appears somewhere" plus "somebody was rejected by denylist", which
|
||||
# is not the property this suite exists to prove. Node-a lists the denied
|
||||
# peers as auto_connect peers, so it logs their npubs on the outbound
|
||||
# connect path whether or not a rejection ever happened; the npub has to
|
||||
# be on the rejection line itself to mean anything.
|
||||
#
|
||||
# Strings match in any order, by chaining fixed-string greps over the
|
||||
# surviving lines, so the assertion does not depend on the order the
|
||||
# tracing formatter emits a message and its fields in. A grep over empty
|
||||
# input yields the empty string rather than a value that could satisfy
|
||||
# the caller, so a container that cannot be read times out and fails
|
||||
# rather than passing.
|
||||
#
|
||||
# Polls rather than reading once: under the XX handshake the
|
||||
# cross-connection tie-breaker decides which side reaches its ACL check
|
||||
# first, so an inbound-handshake rejection may not emit until a later
|
||||
# retry. Same wait-with-timeout shape as wait_for_peers_exact above.
|
||||
#
|
||||
# Deliberately NOT registered in check-log-strings.py's SHELL_HELPERS,
|
||||
# for two reasons, and note that registering it would in fact capture
|
||||
# nothing: that extractor reads only a helper's FIRST argument and only
|
||||
# when it is a quoted literal free of `$` (check-log-strings.py:116),
|
||||
# whereas the first argument here is the unquoted container name
|
||||
# carrying ${FIPS_CI_NAME_SUFFIX}. Verified by running the extractor
|
||||
# with this helper added: zero hits. The same is true of the
|
||||
# `assert_log_contains` this replaced, so nothing left the check's scope.
|
||||
#
|
||||
# It should stay out of scope regardless: that check exists for strings
|
||||
# whose disappearance from src/ would let an assertion silently pass,
|
||||
# and every string here is a positive requirement, so a missing one
|
||||
# exhausts the poll and exits 1, which is loud.
|
||||
assert_log_line_contains_all() {
|
||||
local container="$1"
|
||||
local pattern="$2"
|
||||
local timeout="${3:-15}"
|
||||
local logs
|
||||
local timeout="$2"
|
||||
shift 2
|
||||
|
||||
# Poll docker logs instead of one-shot reading: under XX handshake,
|
||||
# the cross-connection tie-breaker determines which side reaches
|
||||
# its ACL-check point first, so the inbound-handshake-context
|
||||
# rejection may not emit until a later retry. Same wait-with-timeout
|
||||
# shape as wait_for_peers_exact above.
|
||||
local logs surviving pattern
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
logs="$(docker logs "$container" 2>&1 | python3 -c 'import re,sys; print(re.sub(r"\x1b\[[0-9;]*m", "", sys.stdin.read()), end="")' || true)"
|
||||
if printf '%s' "$logs" | grep -F "$pattern" >/dev/null; then
|
||||
echo "PASS: $container logs contain expected ACL rejection"
|
||||
surviving="$logs"
|
||||
for pattern in "$@"; do
|
||||
surviving="$(printf '%s' "$surviving" | grep -F -- "$pattern" || true)"
|
||||
done
|
||||
if [ -n "$surviving" ]; then
|
||||
echo "PASS: $container has a log line matching all of: $*"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "FAIL: missing log pattern in $container: $pattern (waited ${timeout}s)" >&2
|
||||
echo "FAIL: no single log line in $container contains all of: $* (waited ${timeout}s)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -129,7 +185,15 @@ log "Generating ACL allowlist fixtures"
|
||||
|
||||
log "Starting ACL allowlist harness"
|
||||
docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
# --build only on the hand path. Under a harness, --skip-build means the caller
|
||||
# has already built the image this compose file names, and rebuilding it here
|
||||
# would overwrite that image from whatever the shared build context happens to
|
||||
# hold — which is how a suite ends up certifying binaries it was never given.
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
else
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
fi
|
||||
|
||||
log "Waiting for expected peer convergence"
|
||||
wait_for_peers_exact fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} 3 40
|
||||
@@ -154,9 +218,39 @@ assert_acl_field fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-} allow_file_entries
|
||||
assert_acl_field fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-} allow_entries "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6 npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
||||
|
||||
log "Checking ACL rejection logs"
|
||||
assert_log_contains fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
|
||||
assert_log_contains fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl"
|
||||
assert_log_contains fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} "context=inbound_handshake"
|
||||
assert_log_contains fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} "decision=denylist match"
|
||||
# One assertion per denied peer, each requiring the real message, that
|
||||
# peer's npub and the denylist decision on the SAME line. The npub being
|
||||
# on the rejection line is what carries the weight here: node-a rejected
|
||||
# THIS peer. The `decision=` conjunct adds no discrimination, since
|
||||
# PeerAclDecision::allowed() returns early for AllowList and DefaultAllow
|
||||
# (src/node/acl.rs:44-46), so DenyList is the only value that can reach
|
||||
# that warn! and every rejection line carries it. It is kept because the
|
||||
# remediation prescribes it and it documents the expected decision.
|
||||
#
|
||||
# Residual, recorded rather than hidden: node-a has auto_connect stanzas
|
||||
# for both denied peers and authorizes before dialing, so it emits a
|
||||
# fully-formed rejection line for each on the outbound_connect path.
|
||||
# These two assertions are therefore satisfiable without the inbound ACL
|
||||
# check running at all. The suite still catches that, because the denied
|
||||
# peer would then connect and the peer-count assertions above would time
|
||||
# out; but these two lines alone do not prove the inbound path.
|
||||
assert_log_line_contains_all fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} 15 \
|
||||
"Rejected peer by ACL" \
|
||||
"npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6" \
|
||||
"decision=denylist match"
|
||||
assert_log_line_contains_all fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} 15 \
|
||||
"Rejected peer by ACL" \
|
||||
"npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl" \
|
||||
"decision=denylist match"
|
||||
# The outsider-initiated path specifically, asserted separately and not
|
||||
# per-npub. Node-a carries static stanzas for both denied peers, so each
|
||||
# can be rejected on the outbound_connect path as well and which context
|
||||
# a given npub lands in is not deterministic (see README). Requiring an
|
||||
# inbound rejection of a *named* peer would red on scheduling rather than
|
||||
# on a regression; requiring that one exists at all does not.
|
||||
assert_log_line_contains_all fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} 15 \
|
||||
"Rejected peer by ACL" \
|
||||
"context=inbound_handshake" \
|
||||
"decision=denylist match"
|
||||
|
||||
log "ACL allowlist integration test passed"
|
||||
|
||||
+63
-46
@@ -18,7 +18,7 @@ Ethernet). Logs are collected and analyzed automatically.
|
||||
|
||||
```bash
|
||||
./testing/chaos/scripts/build.sh
|
||||
./testing/chaos/scripts/chaos.sh smoke-10
|
||||
./testing/chaos/scripts/chaos.sh churn-mixed
|
||||
```
|
||||
|
||||
## Available Scenarios
|
||||
@@ -29,12 +29,10 @@ Random topologies with increasing stressor intensity.
|
||||
|
||||
| Scenario | Nodes | Topology | Duration | Netem | Link Flaps | Traffic | Node Churn | Bandwidth |
|
||||
| -------- | ----- | ---------------- | -------- | ----- | ---------- | ------- | ---------- | --------- |
|
||||
| smoke-10 | 10 | random_geometric | 60s | -- | -- | -- | -- | -- |
|
||||
| chaos-10 | 10 | random_geometric | 120s | yes | yes | yes | -- | -- |
|
||||
| churn-10 | 10 | random_geometric | 600s | yes | yes | yes | yes | -- |
|
||||
| churn-20 | 20 | erdos_renyi | 600s | yes | yes | yes | yes | yes |
|
||||
|
||||
- **smoke-10**: Baseline sanity check. No stressors, just verify tree convergence.
|
||||
- **chaos-10**: Network degradation (5-50ms delay, 0-2% loss), link flaps (max 2
|
||||
down, 10-30s), and iperf traffic (max 3 concurrent). Netem mutates 30% of
|
||||
links every 15-30s between normal and degraded policies.
|
||||
@@ -44,41 +42,22 @@ Random topologies with increasing stressor intensity.
|
||||
simultaneously, bandwidth tiers (1/10/100/1000 Mbps), `protect_connectivity`
|
||||
disabled (partitions allowed).
|
||||
|
||||
### Cost-based parent selection
|
||||
### Cost-based parent selection — retired, now sans-IO unit tests
|
||||
|
||||
Explicit topologies with heterogeneous link types (fiber, Bluetooth, WiFi) to
|
||||
test that the spanning tree selects optimal parents based on link cost.
|
||||
The cost-selection scenarios (cost-avoidance, depth-vs-cost, bottleneck-parent,
|
||||
cost-reeval, cost-stability, mixed-technology) were retired on 2026-07-23.
|
||||
Their subject was the pure `TreeState::evaluate_parent` decision — which parent
|
||||
wins on `effective_depth = depth + link_cost`, when periodic re-evaluation
|
||||
switches, and when hysteresis suppresses a flap. A Docker mesh could not test
|
||||
that reliably: the root is whichever node holds the smallest `NodeAddr`, MMP
|
||||
costs take several measurement windows to settle, and hold-down plus hysteresis
|
||||
timing all confound the outcome (a deterministic `link_swap` attempt still
|
||||
produced zero periodic switches in a full run).
|
||||
|
||||
| Scenario | Nodes | Shape | Link types | Duration | What it tests |
|
||||
| ----------------- | ----- | --------------- | ------------------------ | -------- | ------------------------------------------------------------------- |
|
||||
| cost-avoidance | 4 | Diamond | Fiber + Bluetooth | 120s | n04 picks fiber parent (n03) over Bluetooth parent (n02) |
|
||||
| depth-vs-cost | 4 | Linear tree | Fiber + Bluetooth | 120s | Cost tradeoff: depth vs. Bluetooth link quality |
|
||||
| bottleneck-parent | 10 | Tree with BT | Fiber + Bluetooth | 120s | n06 avoids Bluetooth bottleneck via n02, picks fiber via n03 |
|
||||
| cost-mixed-7node | 7 | Multi-type tree | Fiber + Bluetooth + WiFi | 180s | n06 prefers fiber (n03) over WiFi (n04) |
|
||||
| cost-reeval | 4 | Diamond | Fiber (mutated) | 180s | Periodic re-evaluation triggers parent switch (reeval_interval=15s) |
|
||||
| cost-stability | 4 | Diamond | WiFi (all) | 180s | Hysteresis prevents flapping when costs vary within 20% band |
|
||||
|
||||
- **cost-avoidance**, **depth-vs-cost**: Minimal scenarios validating the core
|
||||
cost formula. Bluetooth (L2CAP) links use 15-40ms delay and 2-8% loss;
|
||||
fiber uses 1-5ms delay and 0-1% loss.
|
||||
- **bottleneck-parent**: Larger topology where some nodes have both fiber and
|
||||
Bluetooth paths to choose from, and one node (n09) is stuck with Bluetooth
|
||||
(no alternative).
|
||||
- **cost-mixed-7node**: Three link technologies in one mesh. Traffic enabled.
|
||||
- **cost-reeval**: Netem mutation (50% fraction, every 12-18s) degrades random
|
||||
links. FIPS override sets `reeval_interval_secs=15` so periodic re-evaluation
|
||||
catches cost asymmetry. Look for `trigger=periodic` in logs.
|
||||
- **cost-stability**: All links are WiFi. Mutation swings costs between
|
||||
`slightly_better` and `slightly_worse` — within the hysteresis band. Expect
|
||||
≤ 5 parent switches over 180s.
|
||||
|
||||
### Mixed-technology
|
||||
|
||||
Larger explicit topologies combining multiple link technologies.
|
||||
|
||||
| Scenario | Nodes | Link types | Duration | Netem mutation | What it tests |
|
||||
| ---------------- | ----- | ------------------------ | -------- | -------------- | ------------------------------------------------ |
|
||||
| mixed-technology | 10 | Fiber + Bluetooth + WiFi | 180s | 20%/30-60s | Tree convergence across heterogeneous link types |
|
||||
That logic is now covered by deterministic sans-IO unit tests in
|
||||
`src/tree/tests.rs` (`test_evaluate_parent_cost_*`, `..._hysteresis_*`,
|
||||
`..._effective_depth_*`), which run in the cargo quartet on every commit and
|
||||
can each be shown to fail by breaking the cost or hysteresis logic.
|
||||
|
||||
### Transport-specific
|
||||
|
||||
@@ -88,19 +67,12 @@ Explicit topologies exercising non-UDP transports.
|
||||
| ------------- | ----- | -------------- | ----- | -------- | ----- | ---------- | ------------------------------------------ |
|
||||
| ethernet-only | 4 | Ethernet | Ring | 90s | yes | -- | AF_PACKET transport with beacon discovery |
|
||||
| ethernet-mesh | 6 | UDP + Ethernet | Mesh | 120s | yes | yes | Mixed UDP/Ethernet, netem mutation + flaps |
|
||||
| tcp-only | 4 | TCP | Ring | 90s | yes | -- | TCP transport with static peer config |
|
||||
| tcp-chain | 4 | TCP | Chain | 90s | yes | -- | TCP multi-hop routing through chain |
|
||||
| tcp-mesh | 6 | UDP + TCP | Mesh | 120s | yes | yes | Mixed UDP/TCP, netem mutation + flaps |
|
||||
|
||||
- **ethernet-only**: 4-node ring on raw Ethernet (AF_PACKET). Peers discovered
|
||||
via beacons, not static config. Minimal netem (1-5ms delay).
|
||||
- **ethernet-mesh**: Mirrors `tcp-mesh` topology but with Ethernet instead of
|
||||
TCP. UDP edges use static config; Ethernet edges use beacon discovery.
|
||||
- **tcp-only**: 4-node ring using TCP on port 8443. Tests connect-on-send,
|
||||
FMP framing over TCP, and reconnection. Netem enabled (1-10ms delay, 0-1%
|
||||
loss).
|
||||
- **tcp-chain**: 4-node linear chain, all TCP. Tests multi-hop routing over
|
||||
TCP-only mesh.
|
||||
- **tcp-mesh**: 6-node mesh with 4 UDP and 3 TCP edges. Both transports use
|
||||
static peer config. Netem mutation (30% fraction, every 20-40s) and link
|
||||
flaps (1 link max, 10-20s down).
|
||||
@@ -124,8 +96,14 @@ detection.
|
||||
- **ecn-ab-on / ecn-ab-off**: Paired scenarios with identical conditions
|
||||
(6-node tree, 10 Mbps egress, 1000 kbps ingress policing, 10ms link
|
||||
delay, 8 KB recv buffer) differing only in `ecn.enabled`.
|
||||
`ecn-ab-test.sh` runs both and compares throughput and congestion
|
||||
counters. Initial results: +10.2% recv throughput with ECN enabled.
|
||||
`ecn-ab-compare.sh` runs both and prints a side-by-side of throughput
|
||||
and congestion counters. It is a manual tool, not a test: it asserts
|
||||
nothing and no runner invokes it. The "+10.2% recv throughput with ECN
|
||||
enabled" figure once recorded here is not reproducible from anything on
|
||||
disk — the script read a fixed `sim-results/ecn-ab-on/` path while the
|
||||
runner has written timestamped directories since 2026-03-20, and no
|
||||
ecn-ab result directory survives. The path bug is fixed; the figure is
|
||||
left out until a run produces one.
|
||||
|
||||
### Ingress Traffic Control
|
||||
|
||||
@@ -257,12 +235,51 @@ since they use beacon discovery.
|
||||
Results written to `sim-results/` (configurable via
|
||||
`logging.output_dir`):
|
||||
|
||||
- `status.txt` -- How the run ended, plus the scenario, the seed and the
|
||||
container names it used; one `key=value` per line
|
||||
- `analysis.txt` -- Summary: panics, errors, sessions, metrics
|
||||
- `metadata.txt` -- Seed, node count, edges, adjacency list
|
||||
- `runner.log` -- Orchestration events (topology, netem, churn, traffic) with timestamps
|
||||
- `fips-node-nXX.log` -- Per-node log output
|
||||
|
||||
Exit code 0 on success, 2 if panics detected.
|
||||
The `status` field reads:
|
||||
|
||||
- `completed` -- ran for its configured duration
|
||||
- `interrupted` -- a signal cut the run short, so the artifacts are real
|
||||
but describe less time than the scenario asked for
|
||||
- `aborted` -- the run raised part way through; same caveat, and
|
||||
`runner.log` carries the traceback
|
||||
- `setup-failed` -- the containers never started
|
||||
- `teardown-failed` -- the mesh ran but its logs or analysis could not be
|
||||
produced
|
||||
|
||||
A `setup-failed` directory holds `runner.log` and `status.txt` and nothing
|
||||
else. Nothing is harvested, because container names are global to the host
|
||||
and reading them after a failed setup describes whichever run holds them
|
||||
now. So `analysis.txt` in a result directory is proof that this scenario's
|
||||
own mesh existed. A directory with no `status.txt` was written before this
|
||||
was the case and says nothing either way.
|
||||
|
||||
Exit codes:
|
||||
|
||||
- `0` -- Ran to completion, no panics, every assertion passed
|
||||
- `1` -- The scenario file could not be loaded, or a second interrupt
|
||||
arrived while the first was being handled
|
||||
- `2` -- Panics found in the collected node logs. Also what the argument
|
||||
parser exits with when it rejects the command line, before any run starts
|
||||
- `3` -- A post-run assertion failed
|
||||
- `4` -- Setup, warmup, the simulation loop or teardown raised, so the run
|
||||
did not complete; `runner.log` carries the traceback
|
||||
|
||||
Codes 2 and 3 describe what a mesh that ran did. Code 4 says there is
|
||||
nothing to describe, and takes precedence over both. Code 2 is dual-use:
|
||||
a run that never started cannot have panicked, so read it together with
|
||||
whether `runner.log` exists.
|
||||
|
||||
A run stopped by a signal exits on this same ladder rather than one of its
|
||||
own: what it collected before stopping is still worth reporting, and
|
||||
`status.txt` says it was cut short. `chaos.sh` reports 130 for a Ctrl-C of
|
||||
its own accord.
|
||||
|
||||
## Creating Custom Scenarios
|
||||
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# ECN A/B Throughput Test
|
||||
# ECN A/B Throughput Comparison (a manual tool, NOT a test)
|
||||
#
|
||||
# Runs two identical chaos scenarios — one with ECN enabled, one disabled —
|
||||
# and compares iperf3 throughput and congestion counter results.
|
||||
# and prints a side-by-side of iperf3 throughput and congestion counters.
|
||||
#
|
||||
# Usage: ./ecn-ab-test.sh [--seed N] [--duration N]
|
||||
# Renamed from ecn-ab-test.sh on 2026-07-23. The old name claimed a verdict
|
||||
# this has never produced: it asserts nothing, applies no threshold, and no
|
||||
# runner invokes it. Naming it a test made it look like coverage.
|
||||
#
|
||||
# It also could not have worked. It read sim-results/ecn-ab-on/... while the
|
||||
# runner has written sim-results/<timestamp>-<scenario>/ since 2026-03-20, so
|
||||
# it has found neither input for at least four months, and there is not one
|
||||
# archived ecn-ab result directory on disk. That path bug is fixed below.
|
||||
#
|
||||
# WHAT IS STILL MISSING, and why it was not added: turning this into a real
|
||||
# test needs a threshold — how much throughput ECN should buy, or how much
|
||||
# lower the congestion counters should run — and there is no corpus to derive
|
||||
# one from, precisely because the tool has never produced a kept result. A
|
||||
# number invented here would assert the author's guess. The prerequisite is a
|
||||
# calibration run set, and that is a protocol question about what ECN is
|
||||
# expected to deliver, not a harness one.
|
||||
#
|
||||
# Usage: ./ecn-ab-compare.sh [--seed N] [--duration N]
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
@@ -24,12 +41,12 @@ echo ""
|
||||
|
||||
# --- Run A: ECN ON ---
|
||||
echo "--- Phase A: ECN ENABLED ---"
|
||||
sudo python3 -m sim scenarios/ecn-ab-on.yaml "${EXTRA_ARGS[@]}" || true
|
||||
sudo python3 -m sim scenarios/ecn-ab-on.yaml "${EXTRA_ARGS[@]}"
|
||||
echo ""
|
||||
|
||||
# --- Run B: ECN OFF ---
|
||||
echo "--- Phase B: ECN DISABLED ---"
|
||||
sudo python3 -m sim scenarios/ecn-ab-off.yaml "${EXTRA_ARGS[@]}" || true
|
||||
sudo python3 -m sim scenarios/ecn-ab-off.yaml "${EXTRA_ARGS[@]}"
|
||||
echo ""
|
||||
|
||||
# --- Compare results ---
|
||||
@@ -37,7 +54,27 @@ echo "=== Results ==="
|
||||
echo ""
|
||||
|
||||
python3 - <<'PYEOF'
|
||||
import glob
|
||||
import json
|
||||
|
||||
|
||||
def latest(scenario, filename):
|
||||
"""Newest run directory for a scenario, or None.
|
||||
|
||||
The runner writes sim-results/<timestamp>-<scenario>/, so a fixed path
|
||||
such as sim-results/ecn-ab-on/ has never matched anything. Sorting the
|
||||
glob works because the timestamp is the leading, fixed-width component.
|
||||
"""
|
||||
dirs = sorted(glob.glob(f"sim-results/*-{scenario}"))
|
||||
if not dirs:
|
||||
print(f" no run directory found for {scenario}")
|
||||
return None
|
||||
path = f"{dirs[-1]}/{filename}"
|
||||
if not glob.glob(path):
|
||||
print(f" {scenario}: {filename} missing from {dirs[-1]}")
|
||||
return None
|
||||
return path
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -92,8 +129,10 @@ def print_sessions(label, sessions):
|
||||
print(f" {'':>14} completed={n} incomplete={incomplete}")
|
||||
return total_recv / n
|
||||
|
||||
on_results = load_results("sim-results/ecn-ab-on/iperf3-results.json")
|
||||
off_results = load_results("sim-results/ecn-ab-off/iperf3-results.json")
|
||||
on_path = latest("ecn-ab-on", "iperf3-results.json")
|
||||
off_path = latest("ecn-ab-off", "iperf3-results.json")
|
||||
on_results = load_results(on_path) if on_path else []
|
||||
off_results = load_results(off_path) if off_path else []
|
||||
|
||||
on_sessions = extract_throughput(on_results)
|
||||
off_sessions = extract_throughput(off_results)
|
||||
@@ -111,8 +150,10 @@ if avg_on and avg_off:
|
||||
|
||||
# Congestion counters
|
||||
print("Congestion Counters (final snapshot):")
|
||||
for label, path in [("ECN ON", "sim-results/ecn-ab-on/congestion-snapshot-final.json"),
|
||||
("ECN OFF", "sim-results/ecn-ab-off/congestion-snapshot-final.json")]:
|
||||
for label, scenario in [("ECN ON", "ecn-ab-on"), ("ECN OFF", "ecn-ab-off")]:
|
||||
path = latest(scenario, "congestion-snapshot-final.json")
|
||||
if path is None:
|
||||
continue
|
||||
snap = load_congestion(path)
|
||||
if not snap:
|
||||
print(f" {label}: no snapshot")
|
||||
@@ -1,92 +0,0 @@
|
||||
# Bottleneck Parent: 10-node focused Bluetooth bottleneck test
|
||||
#
|
||||
# Explicit topology with two Bluetooth (L2CAP) links that create
|
||||
# bottleneck parent candidates. Tests that nodes avoid choosing
|
||||
# Bluetooth parents when fiber alternatives exist at the same or
|
||||
# slightly greater depth.
|
||||
#
|
||||
# Topology:
|
||||
#
|
||||
# n01 (root)
|
||||
# / | \ \
|
||||
# f f f f
|
||||
# / | \ \
|
||||
# n02 n03 n04 n05
|
||||
# | / | \ |
|
||||
# BT f f BT
|
||||
# | / | \ |
|
||||
# n06 n07 n08 n09
|
||||
# |
|
||||
# f
|
||||
# |
|
||||
# n10
|
||||
#
|
||||
# Edges and link types:
|
||||
# Fiber: n01-n02, n01-n03, n01-n04, n01-n05,
|
||||
# n03-n06, n03-n07, n04-n08, n08-n10
|
||||
# Bluetooth: n02-n06, n05-n09
|
||||
#
|
||||
# Cross-links: n03-n08 (fiber) — gives n08 a fiber alternative to n04
|
||||
#
|
||||
# Test subjects:
|
||||
# - n06 has Bluetooth (n02) and fiber (n03) at depth 1 — should pick n03
|
||||
# - n09 has only Bluetooth (n05) — no alternative, stuck with BT parent
|
||||
|
||||
scenario:
|
||||
name: "bottleneck-parent"
|
||||
seed: 42
|
||||
duration_secs: 60
|
||||
|
||||
topology:
|
||||
algorithm: explicit
|
||||
num_nodes: 10
|
||||
params:
|
||||
adjacency:
|
||||
- [n01, n02]
|
||||
- [n01, n03]
|
||||
- [n01, n04]
|
||||
- [n01, n05]
|
||||
- [n02, n06]
|
||||
- [n03, n06]
|
||||
- [n03, n07]
|
||||
- [n03, n08]
|
||||
- [n04, n08]
|
||||
- [n05, n09]
|
||||
- [n08, n10]
|
||||
subnet: "172.20.0.0/24"
|
||||
ip_start: 10
|
||||
|
||||
netem:
|
||||
enabled: true
|
||||
default_policy:
|
||||
delay_ms: [1, 5]
|
||||
jitter_ms: [0, 1]
|
||||
loss_pct: [0, 0.5]
|
||||
link_policies:
|
||||
# Bluetooth (L2CAP) links
|
||||
- edges: ["n02-n06", "n05-n09"]
|
||||
policy:
|
||||
delay_ms: [15, 40]
|
||||
jitter_ms: [5, 15]
|
||||
loss_pct: [2, 8]
|
||||
mutation:
|
||||
interval_secs: {min: 30, max: 60}
|
||||
fraction: 0.2
|
||||
policies:
|
||||
normal:
|
||||
delay_ms: [1, 5]
|
||||
loss_pct: [0, 0.5]
|
||||
|
||||
link_flaps:
|
||||
enabled: false
|
||||
|
||||
traffic:
|
||||
enabled: true
|
||||
max_concurrent: 2
|
||||
interval_secs: {min: 15, max: 30}
|
||||
duration_secs: {min: 5, max: 10}
|
||||
parallel_streams: 2
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
@@ -67,6 +67,66 @@ bandwidth:
|
||||
enabled: true
|
||||
tiers_mbps: [1, 10, 100, 1000]
|
||||
|
||||
# Baseline: the mesh came up, agreed on a root, and took parents. This
|
||||
# asserts nothing about which nodes survive the churn; it exists so that
|
||||
# a run in which the mesh never formed cannot report success, which
|
||||
# until now it could, because this scenario carried no assertions at
|
||||
# all.
|
||||
#
|
||||
# This one is calibrated rather than derived, because a scenario that
|
||||
# stops and starts nodes on purpose does not hold a single spanning
|
||||
# tree.
|
||||
#
|
||||
# Calibrated 2026-07-26 against fourteen runs that recorded a baseline
|
||||
# verdict, read from their assertions.txt files. Every one used this
|
||||
# file's fixed seed 42 and therefore an identical chaos schedule, so the
|
||||
# spread below is container timing rather than differing scenarios:
|
||||
#
|
||||
# distinct roots 1 2 3 4 5 -> 2 3 5 3 1 runs
|
||||
# nodes parented 9 8 7 6 5 -> the exact complement, in all 14
|
||||
# sessions 12 to 20, minimum 12
|
||||
#
|
||||
# The previous ceiling of 4 roots sat at roughly the 93rd percentile of
|
||||
# that distribution: one run in fourteen exceeded it and four sat at or
|
||||
# above it, so it reddened a share of runs whatever the daemon did. It
|
||||
# was set from six runs whose observed maximum was 3, which is how a
|
||||
# threshold one step outside a small sample ends up inside the real one.
|
||||
#
|
||||
# The ceiling is now 6, one step beyond the observed maximum of 5, and
|
||||
# the parented floor is its complement at 4. That still fails a mesh
|
||||
# that collapsed — seven or more of ten nodes islanded — which is the
|
||||
# only thing this assertion was ever meant to catch. Do not read a pass
|
||||
# as convergence: four of the six gating scenarios assert a floor of
|
||||
# this kind, and it means the mesh formed and nobody errored.
|
||||
#
|
||||
# min_sessions stays at 10 against an observed minimum of 12. It has
|
||||
# never fired and there is no evidence it is mis-set, but the margin is
|
||||
# thin and a future failure there should be read as calibration before
|
||||
# it is read as a defect.
|
||||
#
|
||||
# Tighten any of these only against a larger sample, and against one
|
||||
# gathered at the invocation CI actually runs.
|
||||
#
|
||||
# READ THIS BEFORE RETUNING: the numbers above describe the invocation CI
|
||||
# gates on, which is not this file's own defaults. ci-local runs it as
|
||||
# "churn-mixed --nodes 10 --duration 120", and --nodes sed-patches
|
||||
# num_nodes in a copy of this file before the scenario is loaded, so the
|
||||
# gating run is a 10-node 120-second one while a bare `chaos.sh
|
||||
# churn-mixed` runs the 20-node 600-second scenario written here. This is
|
||||
# the only scenario CI overrides that way.
|
||||
#
|
||||
# The floors hold for both but are calibrated for the smaller. A bare
|
||||
# 20-node run clears them easily: 20 answering, 1 root, 19 parented, 69
|
||||
# sessions, measured 2026-07-23. Retuning against numbers like those would
|
||||
# put them past what the 10-node gating run can reach, and CI would go red
|
||||
# while a manual run stayed green.
|
||||
assertions:
|
||||
baseline:
|
||||
min_nodes_reporting: 10
|
||||
max_roots: 6
|
||||
min_nodes_parented: 4
|
||||
min_sessions: 10
|
||||
|
||||
logging:
|
||||
rust_log: "debug"
|
||||
output_dir: "./sim-results"
|
||||
|
||||
@@ -7,17 +7,22 @@
|
||||
# Congestion detection signals exercised:
|
||||
# 1. MMP loss detection: netem loss exceeds the 5% loss_threshold,
|
||||
# triggering detect_congestion() via MMP metrics on transit nodes.
|
||||
# 2. Kernel socket drops: small recv_buf_size (8KB) combined with
|
||||
# traffic saturation causes SO_RXQ_OVFL on the UDP socket,
|
||||
# triggering the transport drop detection path.
|
||||
# 3. Ingress policing: tc policer on the receive side drops excess
|
||||
# 2. Ingress policing: tc policer on the receive side drops excess
|
||||
# inbound packets, creating bursty arrival patterns.
|
||||
# 3. ECN CE marking under the shaped bottleneck queue.
|
||||
#
|
||||
# The kernel socket-drop signal (SO_RXQ_OVFL) is NOT exercised here. This
|
||||
# scenario's 1 Mbps cap and ingress policer, which its ECN/MMP signals
|
||||
# require, make socket overflow impossible. It also cannot be provoked
|
||||
# deterministically anywhere in Docker — a fresh daemon reader keeps up
|
||||
# with container-speed traffic — so the FIPS drop-detection logic is
|
||||
# unit-tested directly in src/node/tests/unit.rs instead. See the note at
|
||||
# the assertions block below.
|
||||
#
|
||||
# ECN is explicitly enabled via fips_overrides.
|
||||
#
|
||||
# Success criteria (verified via post-run congestion snapshot):
|
||||
# - congestion_detected > 0 on at least one forwarding node
|
||||
# - kernel_drop_events > 0 on at least one node
|
||||
# - ce_forwarded > 0 on transit nodes
|
||||
# - ce_received > 0 on destination nodes
|
||||
#
|
||||
@@ -91,6 +96,56 @@ traffic:
|
||||
node_churn:
|
||||
enabled: false
|
||||
|
||||
# Three of the four success criteria above, encoded. Until now all four
|
||||
# existed only as that comment and were checked by nothing, so the scenario
|
||||
# could not fail on any of them.
|
||||
#
|
||||
# The floors are 1 node each because that is what the criteria say ("on at
|
||||
# least one forwarding node", "on transit nodes", "on destination nodes").
|
||||
# They are deliberately not tightened to the observed counts: the criterion
|
||||
# is the spec, and a floor invented from six runs would assert something
|
||||
# nobody wrote down.
|
||||
#
|
||||
# What the floors are worth, from the archived corpus. The six runs that
|
||||
# carry a status.txt -- the only ones provably completed, all between
|
||||
# 2026-07-22 22:14 and 2026-07-23 02:02 -- meet all three with 5 to 9 nodes
|
||||
# reporting each signal, so the margin over a floor of 1 is comfortable.
|
||||
# The 176 older runs meet none of them. Those older runs did reach teardown
|
||||
# (each wrote an analysis.txt), and ECN landed 2026-03-05, before all but
|
||||
# one of them, so they are valid runs that observed no congestion rather
|
||||
# than runs that died early. What changed on 2026-07-22 is not established:
|
||||
# this file has not been touched since it was created, and neither have
|
||||
# netem.py, traffic.py or control.py. Worth knowing before trusting a green
|
||||
# result here, and worth its own investigation.
|
||||
assertions:
|
||||
congestion_signals:
|
||||
min_nodes_detected: 1
|
||||
min_nodes_ce_forwarded: 1
|
||||
min_nodes_ce_received: 1
|
||||
|
||||
# The kernel socket-drop criterion is NOT asserted here and no longer
|
||||
# belongs to this scenario. The FIPS drop-DETECTION logic is unit-tested in
|
||||
# src/node/tests/unit.rs (2026-07-23); the kernel dropping datagrams is a
|
||||
# kernel behaviour, not FIPS's to test, and could not be provoked in Docker.
|
||||
#
|
||||
# Why it could never be met here: across all 182 archived runs of this
|
||||
# scenario, including the six that meet the three signals above, no node
|
||||
# ever reported a non-zero kernel_drop_events. SO_RXQ_OVFL counts datagrams
|
||||
# arriving at a FULL receive queue, and the 1 Mbps cap sets the arrival rate
|
||||
# to 125 kB/s per link. Linux doubles a requested SO_RCVBUF, so the queue is
|
||||
# 8192 bytes and filling it would take ~65 ms of reader stall (~22 ms even at
|
||||
# the busiest node's 3 Mbps aggregate). Traffic volume cannot cause the
|
||||
# overflow because the volume is capped below the rate the buffer drains, and
|
||||
# the ingress policer discards excess in tc before the socket ever sees it.
|
||||
# An unshaped attempt (congestion-drops, 2026-07-23) recorded zero raw drops
|
||||
# on every node even with a 4 KB buffer and heavy iperf: a fresh daemon
|
||||
# reader keeps up, so the overflow cannot be provoked deterministically. That
|
||||
# is why the detection edge is tested as a sans-IO unit test.
|
||||
#
|
||||
# The recv_buf_size: 4096 override below is kept because the three asserted
|
||||
# signals were validated with it in place; it is inert for socket overflow
|
||||
# under this scenario's cap.
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Cost-Based Parent Selection: Bottleneck Avoidance Test
|
||||
#
|
||||
# Topology (explicit 4-node diamond):
|
||||
#
|
||||
# n01 (root — smallest addr)
|
||||
# / \
|
||||
# fiber fiber
|
||||
# / \
|
||||
# n02 n03
|
||||
# \ /
|
||||
# BT fiber
|
||||
# \ /
|
||||
# n04 (test subject)
|
||||
#
|
||||
# n04 has two candidate parents at depth 1: n02 (via Bluetooth L2CAP)
|
||||
# and n03 (via fiber). Cost-based selection should pick n03 because:
|
||||
# effective_depth(n02) = 1 + ~1.4 (Bluetooth) = ~2.4
|
||||
# effective_depth(n03) = 1 + ~1.01 (fiber) = ~2.01
|
||||
#
|
||||
# Validation: tree snapshot shows n04's parent is n03.
|
||||
|
||||
scenario:
|
||||
name: "cost-avoidance"
|
||||
seed: 42
|
||||
duration_secs: 45
|
||||
|
||||
topology:
|
||||
algorithm: explicit
|
||||
num_nodes: 4
|
||||
params:
|
||||
adjacency:
|
||||
- [n01, n02]
|
||||
- [n01, n03]
|
||||
- [n02, n04]
|
||||
- [n03, n04]
|
||||
subnet: "172.20.0.0/24"
|
||||
ip_start: 10
|
||||
|
||||
netem:
|
||||
enabled: true
|
||||
default_policy:
|
||||
# Fiber-like
|
||||
delay_ms: [1, 5]
|
||||
jitter_ms: [0, 1]
|
||||
loss_pct: [0, 0.5]
|
||||
link_policies:
|
||||
# Bluetooth (L2CAP) link from n02 to n04
|
||||
- edges: ["n02-n04"]
|
||||
policy:
|
||||
delay_ms: [15, 40]
|
||||
jitter_ms: [5, 15]
|
||||
loss_pct: [2, 8]
|
||||
|
||||
link_flaps:
|
||||
enabled: false
|
||||
|
||||
traffic:
|
||||
enabled: true
|
||||
max_concurrent: 2
|
||||
interval_secs: {min: 15, max: 30}
|
||||
duration_secs: {min: 5, max: 10}
|
||||
parallel_streams: 2
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
@@ -1,86 +0,0 @@
|
||||
# Periodic Cost Re-evaluation Test
|
||||
#
|
||||
# Topology (explicit 4-node diamond):
|
||||
#
|
||||
# n01 (root)
|
||||
# / \
|
||||
# fiber fiber
|
||||
# / \
|
||||
# n02 n03
|
||||
# \ /
|
||||
# fiber fiber
|
||||
# \ /
|
||||
# n04 (test subject)
|
||||
#
|
||||
# Initial state: All links are fiber. n04 has two candidate parents at
|
||||
# depth 1: n02 and n03. Both have identical costs (~1.01), so n04 picks
|
||||
# n02 (smaller NodeAddr, tiebreak rule).
|
||||
#
|
||||
# Mutation: Stochastic netem mutation with a single "degraded" policy
|
||||
# (Bluetooth-like: 15-40ms delay, 2-8% loss). With fraction=0.5, on
|
||||
# average 2 of 4 edges degrade each round. Over 12 mutation rounds
|
||||
# (180s / 15s interval), n02-n04 will be degraded in some rounds.
|
||||
#
|
||||
# Expected behavior: When n02-n04 is degraded and n03-n04 stays fiber
|
||||
# (or vice versa), periodic re-evaluation detects the cost asymmetry
|
||||
# and switches parents. Look for "trigger = periodic" in logs.
|
||||
#
|
||||
# FIPS overrides: reeval_interval_secs=15 (vs default 60) to increase
|
||||
# the chance of catching a cost asymmetry within the mutation window.
|
||||
#
|
||||
# Validation: grep n04 logs for "Parent switched via periodic cost
|
||||
# re-evaluation" (trigger=periodic). If mutation never creates enough
|
||||
# asymmetry in a particular seed, the test still validates that periodic
|
||||
# re-eval runs without interference.
|
||||
|
||||
scenario:
|
||||
name: "cost-reeval"
|
||||
seed: 42
|
||||
duration_secs: 180
|
||||
|
||||
topology:
|
||||
algorithm: explicit
|
||||
num_nodes: 4
|
||||
params:
|
||||
adjacency:
|
||||
- [n01, n02]
|
||||
- [n01, n03]
|
||||
- [n02, n04]
|
||||
- [n03, n04]
|
||||
subnet: "172.20.0.0/24"
|
||||
ip_start: 10
|
||||
|
||||
netem:
|
||||
enabled: true
|
||||
default_policy:
|
||||
# Fiber-like baseline
|
||||
delay_ms: [1, 5]
|
||||
jitter_ms: [0, 1]
|
||||
loss_pct: [0, 0.5]
|
||||
mutation:
|
||||
interval_secs: {min: 12, max: 18}
|
||||
fraction: 0.5
|
||||
policies:
|
||||
degraded:
|
||||
delay_ms: [15, 40]
|
||||
jitter_ms: [5, 15]
|
||||
loss_pct: [2, 8]
|
||||
|
||||
link_flaps:
|
||||
enabled: false
|
||||
|
||||
traffic:
|
||||
enabled: true
|
||||
max_concurrent: 1
|
||||
interval_secs: {min: 15, max: 30}
|
||||
duration_secs: {min: 5, max: 10}
|
||||
parallel_streams: 2
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
|
||||
fips_overrides:
|
||||
node:
|
||||
tree:
|
||||
reeval_interval_secs: 15
|
||||
@@ -1,72 +0,0 @@
|
||||
# Cost-Based Parent Selection: Hysteresis Stability Test
|
||||
#
|
||||
# Topology (explicit 4-node diamond, symmetric):
|
||||
#
|
||||
# n01 (root)
|
||||
# / \
|
||||
# wifi wifi
|
||||
# / \
|
||||
# n02 n03
|
||||
# \ /
|
||||
# wifi wifi
|
||||
# \ /
|
||||
# n04 (test subject)
|
||||
#
|
||||
# All links are WiFi-like with similar characteristics. Aggressive
|
||||
# netem mutation shifts link qualities every 10-20s, but the changes
|
||||
# stay within the 20% hysteresis band. n04 should pick one parent
|
||||
# and mostly stick with it — flapping indicates insufficient hysteresis.
|
||||
#
|
||||
# Validation: count "Parent switched" in n04 logs, expect <= 5 switches
|
||||
# over the full 180s duration.
|
||||
|
||||
scenario:
|
||||
name: "cost-stability"
|
||||
seed: 42
|
||||
duration_secs: 180
|
||||
|
||||
topology:
|
||||
algorithm: explicit
|
||||
num_nodes: 4
|
||||
params:
|
||||
adjacency:
|
||||
- [n01, n02]
|
||||
- [n01, n03]
|
||||
- [n02, n04]
|
||||
- [n03, n04]
|
||||
subnet: "172.20.0.0/24"
|
||||
ip_start: 10
|
||||
|
||||
netem:
|
||||
enabled: true
|
||||
default_policy:
|
||||
# WiFi baseline
|
||||
delay_ms: [5, 20]
|
||||
jitter_ms: [2, 5]
|
||||
loss_pct: [1, 3]
|
||||
mutation:
|
||||
interval_secs: {min: 10, max: 20}
|
||||
fraction: 1.0
|
||||
policies:
|
||||
slightly_better:
|
||||
delay_ms: [3, 8]
|
||||
jitter_ms: [1, 3]
|
||||
loss_pct: [0, 1]
|
||||
slightly_worse:
|
||||
delay_ms: [15, 25]
|
||||
jitter_ms: [3, 8]
|
||||
loss_pct: [2, 4]
|
||||
|
||||
link_flaps:
|
||||
enabled: false
|
||||
|
||||
traffic:
|
||||
enabled: true
|
||||
max_concurrent: 2
|
||||
interval_secs: {min: 15, max: 30}
|
||||
duration_secs: {min: 5, max: 15}
|
||||
parallel_streams: 2
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
@@ -1,71 +0,0 @@
|
||||
# Cost-Based Parent Selection: Deeper Fiber Beats Shallow Bluetooth
|
||||
#
|
||||
# Topology (explicit 4-node):
|
||||
#
|
||||
# n01 (root)
|
||||
# / \
|
||||
# fiber BT
|
||||
# / \
|
||||
# n02 n04 (test subject)
|
||||
# | /
|
||||
# fiber fiber
|
||||
# | /
|
||||
# n03
|
||||
#
|
||||
# n04 has two candidate parents:
|
||||
# - n01 (root, depth 0) via Bluetooth L2CAP: effective_depth = 0 + ~1.4 = ~1.4
|
||||
# - n03 (depth 2) via fiber: effective_depth = 2 + ~1.01 = ~3.01
|
||||
#
|
||||
# Without cost-based selection, n04 would pick n01 (depth 0 < depth 2).
|
||||
# With cost-based selection and these Bluetooth impairments, n04 may
|
||||
# still pick n01 since the Bluetooth cost (~1.4) is modest. This tests
|
||||
# that the cost formula correctly weighs depth against link quality.
|
||||
#
|
||||
# Validation: tree snapshot shows n04's parent selection reflects the
|
||||
# actual cost tradeoff between depth and link quality.
|
||||
|
||||
scenario:
|
||||
name: "depth-vs-cost"
|
||||
seed: 42
|
||||
duration_secs: 45
|
||||
|
||||
topology:
|
||||
algorithm: explicit
|
||||
num_nodes: 4
|
||||
params:
|
||||
adjacency:
|
||||
- [n01, n02]
|
||||
- [n02, n03]
|
||||
- [n03, n04]
|
||||
- [n01, n04]
|
||||
subnet: "172.20.0.0/24"
|
||||
ip_start: 10
|
||||
|
||||
netem:
|
||||
enabled: true
|
||||
default_policy:
|
||||
# Fiber-like
|
||||
delay_ms: [1, 5]
|
||||
jitter_ms: [0, 1]
|
||||
loss_pct: [0, 0.5]
|
||||
link_policies:
|
||||
# Bluetooth (L2CAP) link from n01 to n04
|
||||
- edges: ["n01-n04"]
|
||||
policy:
|
||||
delay_ms: [15, 40]
|
||||
jitter_ms: [5, 15]
|
||||
loss_pct: [2, 8]
|
||||
|
||||
link_flaps:
|
||||
enabled: false
|
||||
|
||||
traffic:
|
||||
enabled: true
|
||||
max_concurrent: 1
|
||||
interval_secs: {min: 15, max: 30}
|
||||
duration_secs: {min: 5, max: 10}
|
||||
parallel_streams: 2
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
@@ -62,6 +62,21 @@ link_flaps:
|
||||
traffic:
|
||||
enabled: false
|
||||
|
||||
# Baseline: the mesh came up, agreed on a root, and took parents. This
|
||||
# asserts nothing about Ethernet link behaviour under flaps; it exists
|
||||
# so that a run in which the mesh never formed cannot report success,
|
||||
# which until now it could, because this scenario carried no assertions
|
||||
# at all.
|
||||
#
|
||||
# Six nodes, one root, five parented in all six provably-completed
|
||||
# archived runs. The other assertion covering Ethernet transport in
|
||||
# CI; see ethernet-only for why that matters.
|
||||
assertions:
|
||||
baseline:
|
||||
min_nodes_reporting: 6
|
||||
max_roots: 1
|
||||
min_nodes_parented: 5
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
|
||||
@@ -41,6 +41,26 @@ link_flaps:
|
||||
traffic:
|
||||
enabled: false
|
||||
|
||||
# Baseline: the mesh came up, agreed on a root, and took parents. This
|
||||
# asserts nothing about what Ethernet transport does; it exists so that
|
||||
# a run in which the mesh never formed cannot report success, which
|
||||
# until now it could, because this scenario carried no assertions at
|
||||
# all.
|
||||
#
|
||||
# A 4-node mesh forms a spanning tree: one root and three nodes
|
||||
# with a parent. All six provably-completed archived runs show exactly
|
||||
# that, so these are the shape of a converged mesh rather than a
|
||||
# tolerance fitted to observations. This is the only assertion covering
|
||||
# Ethernet transport anywhere in CI, and it covers the control plane
|
||||
# only: traffic is disabled above, so no datagram crosses an Ethernet
|
||||
# link in any test. Framing, the length field that trims NIC minimum-
|
||||
# frame padding, and AEAD over Ethernet are all unexercised as a result.
|
||||
assertions:
|
||||
baseline:
|
||||
min_nodes_reporting: 4
|
||||
max_roots: 1
|
||||
min_nodes_parented: 3
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
# Mixed Technology: 10-node heterogeneous network
|
||||
#
|
||||
# Explicit topology with Bluetooth (L2CAP), WiFi, and fiber links.
|
||||
# Tests that cost-based parent selection produces a tree favoring
|
||||
# low-cost paths when multiple link technologies coexist.
|
||||
#
|
||||
# Topology:
|
||||
#
|
||||
# n01 (root)
|
||||
# / | \
|
||||
# f f f
|
||||
# / | \
|
||||
# n02 n03 n04
|
||||
# | \ | \ | \
|
||||
# f BT f f BT f
|
||||
# | \ | | \ |
|
||||
# n05 n06 n07 n08 n09
|
||||
# \ /
|
||||
# wifi---wifi
|
||||
# n10
|
||||
#
|
||||
# Edges and link types:
|
||||
# Fiber: n01-n02, n01-n03, n01-n04, n02-n05, n03-n07, n04-n09
|
||||
# Bluetooth: n02-n06, n04-n08
|
||||
# WiFi: n03-n06, n08-n10
|
||||
# Fiber: n03-n08, n06-n10
|
||||
#
|
||||
# Test subjects:
|
||||
# - n06 has fiber (n03) and Bluetooth (n02) parents — should pick n03
|
||||
# - n08 has fiber (n03) and Bluetooth (n04) parents — should pick n03
|
||||
#
|
||||
# Netem mutation shifts fiber-only links between normal and degraded.
|
||||
|
||||
scenario:
|
||||
name: "mixed-technology"
|
||||
seed: 42
|
||||
duration_secs: 90
|
||||
|
||||
topology:
|
||||
algorithm: explicit
|
||||
num_nodes: 10
|
||||
params:
|
||||
adjacency:
|
||||
- [n01, n02]
|
||||
- [n01, n03]
|
||||
- [n01, n04]
|
||||
- [n02, n05]
|
||||
- [n02, n06]
|
||||
- [n03, n06]
|
||||
- [n03, n07]
|
||||
- [n03, n08]
|
||||
- [n04, n08]
|
||||
- [n04, n09]
|
||||
- [n06, n10]
|
||||
- [n08, n10]
|
||||
subnet: "172.20.0.0/24"
|
||||
ip_start: 10
|
||||
|
||||
netem:
|
||||
enabled: true
|
||||
default_policy:
|
||||
# Fiber-like defaults
|
||||
delay_ms: [1, 5]
|
||||
jitter_ms: [0, 1]
|
||||
loss_pct: [0, 0.5]
|
||||
link_policies:
|
||||
# Bluetooth (L2CAP) links
|
||||
- edges: ["n02-n06", "n04-n08"]
|
||||
policy:
|
||||
delay_ms: [15, 40]
|
||||
jitter_ms: [5, 15]
|
||||
loss_pct: [2, 8]
|
||||
# WiFi links (moderate latency, low loss)
|
||||
- edges: ["n03-n06", "n08-n10"]
|
||||
policy:
|
||||
delay_ms: [5, 20]
|
||||
jitter_ms: [2, 5]
|
||||
loss_pct: [1, 3]
|
||||
mutation:
|
||||
interval_secs: {min: 30, max: 60}
|
||||
fraction: 0.2
|
||||
policies:
|
||||
normal:
|
||||
delay_ms: [1, 10]
|
||||
loss_pct: [0, 1]
|
||||
degraded:
|
||||
delay_ms: [50, 100]
|
||||
jitter_ms: [10, 30]
|
||||
loss_pct: [3, 8]
|
||||
|
||||
link_flaps:
|
||||
enabled: false
|
||||
|
||||
traffic:
|
||||
enabled: true
|
||||
max_concurrent: 3
|
||||
interval_secs: {min: 10, max: 30}
|
||||
duration_secs: {min: 5, max: 15}
|
||||
parallel_streams: 4
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
@@ -1,27 +0,0 @@
|
||||
scenario:
|
||||
name: "smoke-10"
|
||||
seed: 42
|
||||
duration_secs: 30
|
||||
|
||||
topology:
|
||||
num_nodes: 10
|
||||
algorithm: random_geometric
|
||||
params:
|
||||
radius: 0.5
|
||||
ensure_connected: true
|
||||
subnet: "172.20.0.0/24"
|
||||
ip_start: 10
|
||||
|
||||
# Phase 2+ features (disabled for MVP)
|
||||
netem:
|
||||
enabled: false
|
||||
|
||||
link_flaps:
|
||||
enabled: false
|
||||
|
||||
traffic:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
@@ -61,6 +61,19 @@ link_flaps:
|
||||
traffic:
|
||||
enabled: false
|
||||
|
||||
# Baseline: the mesh came up, agreed on a root, and took parents. This
|
||||
# asserts nothing about TCP transport specifically; it exists so that a
|
||||
# run in which the mesh never formed cannot report success, which until
|
||||
# now it could, because this scenario carried no assertions at all.
|
||||
#
|
||||
# Six nodes, one root, five parented in all six provably-completed
|
||||
# archived runs.
|
||||
assertions:
|
||||
baseline:
|
||||
min_nodes_reporting: 6
|
||||
max_roots: 1
|
||||
min_nodes_parented: 5
|
||||
|
||||
logging:
|
||||
rust_log: "info"
|
||||
output_dir: "./sim-results"
|
||||
|
||||
@@ -125,7 +125,11 @@ if ! docker info &> /dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOCKER_DIR="$CHAOS_DIR/../docker"
|
||||
# A harness that scopes its build context per run passes it in
|
||||
# FIPS_BUILD_CONTEXT and stops writing to the shared directory, so checking the
|
||||
# shared one would either fail on a clean checkout or, worse, pass while
|
||||
# reading a stale binary that is not the one under test.
|
||||
DOCKER_DIR="${FIPS_BUILD_CONTEXT:-$CHAOS_DIR/../docker}"
|
||||
if [ ! -f "$DOCKER_DIR/fips" ]; then
|
||||
echo "Error: FIPS binary not found at $DOCKER_DIR/fips" >&2
|
||||
echo "Run testing/scripts/build.sh first" >&2
|
||||
|
||||
@@ -27,9 +27,10 @@ def main():
|
||||
)
|
||||
parser.add_argument(
|
||||
"--subnet", type=str, default=None,
|
||||
help="Override topology subnet CIDR (e.g. 10.30.0.0/24); node IPs "
|
||||
"derive from it. Used by CI to give each parallel run a "
|
||||
"non-overlapping network.",
|
||||
help="Pin the topology subnet CIDR (e.g. 10.30.0.0/24) instead of "
|
||||
"claiming a free one. The sim normally claims a range so "
|
||||
"concurrent runs cannot collide; pin only when you need a known "
|
||||
"range, and expect a hard failure if it is already taken.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -55,11 +56,19 @@ def main():
|
||||
sys.exit(1)
|
||||
scenario.duration_secs = args.duration
|
||||
if args.subnet is not None:
|
||||
scenario.topology.subnet = args.subnet
|
||||
# Pinning opts out of claiming. The runner still *creates* the network,
|
||||
# so a range already in use fails loudly here rather than silently
|
||||
# overlapping a concurrent run.
|
||||
scenario.topology.pinned_subnet = args.subnet
|
||||
|
||||
runner = SimRunner(scenario)
|
||||
result = runner.run()
|
||||
|
||||
# Liveness before content. A simulation that raised on its way up, or part
|
||||
# way through, has no panic count or assertion outcome worth reporting, and
|
||||
# saying so is more useful than whatever partial content it did produce.
|
||||
if runner.aborted:
|
||||
sys.exit(4)
|
||||
if result and result.panics:
|
||||
sys.exit(2)
|
||||
if runner.assertions_failed:
|
||||
|
||||
@@ -18,7 +18,15 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .control import snapshot_all_bloom
|
||||
from .scenario import BloomSendRateAssertion, MinParentSwitchesAssertion
|
||||
from .scenario import (
|
||||
BaselineAssertion,
|
||||
BloomSendRateAssertion,
|
||||
CongestionSignalsAssertion,
|
||||
MaxErrorsAssertion,
|
||||
MaxParentSwitchesAssertion,
|
||||
MinParentSwitchesAssertion,
|
||||
TreeParentsAssertion,
|
||||
)
|
||||
from .topology import SimTopology
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -131,6 +139,278 @@ class BloomSendRateMonitor:
|
||||
)
|
||||
|
||||
|
||||
def evaluate_max_parent_switches(
|
||||
cfg: MaxParentSwitchesAssertion,
|
||||
parent_switch_count: int,
|
||||
scope: str,
|
||||
) -> AssertionOutcome:
|
||||
"""Stability ceiling on parent switches over the run.
|
||||
|
||||
``scope`` describes what was counted and appears in the message, so a
|
||||
reader can tell a per-node result from a mesh-wide one. Resolving a
|
||||
per-node scope to a real node is the caller's job, and so is failing
|
||||
loudly when it cannot: an unresolvable node would count zero switches
|
||||
and sail under any ceiling without having observed anything.
|
||||
"""
|
||||
if parent_switch_count <= cfg.max_total:
|
||||
return AssertionOutcome(
|
||||
name="max_parent_switches",
|
||||
passed=True,
|
||||
detail=(
|
||||
f"PASS max_parent_switches: {parent_switch_count} switches "
|
||||
f"({scope}) <= ceiling {cfg.max_total}"
|
||||
),
|
||||
)
|
||||
return AssertionOutcome(
|
||||
name="max_parent_switches",
|
||||
passed=False,
|
||||
detail=(
|
||||
f"FAIL max_parent_switches: {parent_switch_count} switches "
|
||||
f"({scope}) > ceiling {cfg.max_total} — the tree is reparenting "
|
||||
f"more than the hysteresis band should allow. Check whether a "
|
||||
f"cost change smaller than the hysteresis margin is still "
|
||||
f"triggering a switch."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def evaluate_baseline(
|
||||
cfg: BaselineAssertion,
|
||||
snapshot: dict | None,
|
||||
sessions: int,
|
||||
) -> AssertionOutcome:
|
||||
"""Floor on the mesh having formed: nodes answered, agreed a root, took parents."""
|
||||
if not snapshot:
|
||||
return AssertionOutcome(
|
||||
name="baseline",
|
||||
passed=False,
|
||||
detail=(
|
||||
"FAIL baseline: no final tree snapshot was taken, so nothing "
|
||||
"about the mesh was observed. This is a harness failure."
|
||||
),
|
||||
)
|
||||
|
||||
reporting = len(snapshot)
|
||||
roots = {v.get("root") for v in snapshot.values() if v.get("root")}
|
||||
parented = sum(
|
||||
1 for v in snapshot.values()
|
||||
if v.get("parent") and v.get("parent") != v.get("my_node_addr")
|
||||
)
|
||||
|
||||
parts, failures = [], []
|
||||
|
||||
def note(ok, text):
|
||||
parts.append(text)
|
||||
if not ok:
|
||||
failures.append(text)
|
||||
|
||||
if cfg.min_nodes_reporting is not None:
|
||||
note(reporting >= cfg.min_nodes_reporting,
|
||||
f"{reporting} node(s) answered (need {cfg.min_nodes_reporting})")
|
||||
if cfg.max_roots is not None:
|
||||
note(len(roots) <= cfg.max_roots and len(roots) >= 1,
|
||||
f"{len(roots)} distinct root(s) (allowed {cfg.max_roots})")
|
||||
if cfg.min_nodes_parented is not None:
|
||||
note(parented >= cfg.min_nodes_parented,
|
||||
f"{parented} node(s) have a parent (need {cfg.min_nodes_parented})")
|
||||
if cfg.min_sessions is not None:
|
||||
note(sessions >= cfg.min_sessions,
|
||||
f"{sessions} session(s) established (need {cfg.min_sessions})")
|
||||
|
||||
summary = "; ".join(parts)
|
||||
if failures:
|
||||
return AssertionOutcome(
|
||||
name="baseline",
|
||||
passed=False,
|
||||
detail=(
|
||||
f"FAIL baseline: {'; '.join(failures)}. Full: {summary}"
|
||||
),
|
||||
)
|
||||
return AssertionOutcome(
|
||||
name="baseline", passed=True, detail=f"PASS baseline: {summary}"
|
||||
)
|
||||
|
||||
|
||||
def evaluate_tree_parents(
|
||||
cfg: TreeParentsAssertion,
|
||||
snapshot: dict | None,
|
||||
) -> AssertionOutcome:
|
||||
"""Check each node's parent in the final tree snapshot.
|
||||
|
||||
Parents are compared by node address, resolved from the snapshot's own
|
||||
``my_node_addr`` fields, so the check does not depend on the display
|
||||
name a node happened to publish.
|
||||
|
||||
Every way of not knowing the answer is a failure: no snapshot, the
|
||||
node absent from it, the expected parent absent from it, or the node
|
||||
still claiming to be its own root. Each of those produces the same
|
||||
"no match" that a genuinely wrong parent does, and only saying so
|
||||
separately keeps a harness problem from reading as a routing verdict.
|
||||
"""
|
||||
if not snapshot:
|
||||
return AssertionOutcome(
|
||||
name="tree_parents",
|
||||
passed=False,
|
||||
detail=(
|
||||
"FAIL tree_parents: no final tree snapshot was taken, so no "
|
||||
"node's parent was observed. This is a harness failure, not "
|
||||
"a statement about the tree."
|
||||
),
|
||||
)
|
||||
|
||||
addr_of = {
|
||||
nid: data.get("my_node_addr")
|
||||
for nid, data in snapshot.items()
|
||||
if data.get("my_node_addr")
|
||||
}
|
||||
id_of = {addr: nid for nid, addr in addr_of.items()}
|
||||
|
||||
good, bad = [], []
|
||||
for child, want_parent in sorted(cfg.expected.items()):
|
||||
entry = snapshot.get(child)
|
||||
if entry is None:
|
||||
bad.append(
|
||||
f"{child} is absent from the snapshot ({len(snapshot)} node(s) "
|
||||
f"present: {', '.join(sorted(snapshot))})"
|
||||
)
|
||||
continue
|
||||
want_addr = addr_of.get(want_parent)
|
||||
if want_addr is None:
|
||||
bad.append(
|
||||
f"{child}: expected parent {want_parent} is absent from the "
|
||||
f"snapshot, so its address cannot be resolved"
|
||||
)
|
||||
continue
|
||||
got_addr = entry.get("parent")
|
||||
if got_addr == entry.get("my_node_addr"):
|
||||
bad.append(
|
||||
f"{child} is its own parent — it still believes it is root, "
|
||||
f"so the tree never converged around it (wanted {want_parent})"
|
||||
)
|
||||
continue
|
||||
if got_addr == want_addr:
|
||||
good.append(f"{child}->{want_parent}")
|
||||
continue
|
||||
got_id = id_of.get(got_addr) or entry.get("parent_display_name") or got_addr
|
||||
bad.append(f"{child} chose {got_id}, wanted {want_parent}")
|
||||
|
||||
if bad:
|
||||
detail = f"FAIL tree_parents: {'; '.join(bad)}"
|
||||
if good:
|
||||
detail += f". Correct: {', '.join(good)}"
|
||||
return AssertionOutcome(name="tree_parents", passed=False, detail=detail)
|
||||
return AssertionOutcome(
|
||||
name="tree_parents",
|
||||
passed=True,
|
||||
detail=f"PASS tree_parents: {', '.join(good)}",
|
||||
)
|
||||
|
||||
|
||||
_CONGESTION_FLOORS = (
|
||||
("min_nodes_detected", "congestion_detected"),
|
||||
("min_nodes_ce_forwarded", "ce_forwarded"),
|
||||
("min_nodes_ce_received", "ce_received"),
|
||||
)
|
||||
|
||||
|
||||
def evaluate_congestion_signals(
|
||||
cfg: CongestionSignalsAssertion,
|
||||
snapshot: dict | None,
|
||||
) -> AssertionOutcome:
|
||||
"""Floors on how many nodes observed each congestion counter.
|
||||
|
||||
``snapshot`` is the final congestion snapshot keyed by node id. None
|
||||
means the snapshot never ran, which fails: a missing snapshot and a
|
||||
mesh that observed no congestion produce the same zero counts, and
|
||||
treating them alike is how an assertion comes to pass on an absence
|
||||
of evidence.
|
||||
"""
|
||||
if not snapshot:
|
||||
return AssertionOutcome(
|
||||
name="congestion_signals",
|
||||
passed=False,
|
||||
detail=(
|
||||
"FAIL congestion_signals: no final congestion snapshot was "
|
||||
"taken, so no node was observed at all. This is a harness "
|
||||
"failure, not a statement about congestion."
|
||||
),
|
||||
)
|
||||
|
||||
parts, failures = [], []
|
||||
for attr, counter in _CONGESTION_FLOORS:
|
||||
floor = getattr(cfg, attr)
|
||||
if floor is None:
|
||||
continue
|
||||
hits = sorted(
|
||||
nid for nid, data in snapshot.items()
|
||||
if (data.get("congestion") or {}).get(counter, 0) > 0
|
||||
)
|
||||
parts.append(f"{counter}: {len(hits)} node(s) >0 (floor {floor})")
|
||||
if len(hits) < floor:
|
||||
failures.append(
|
||||
f"{counter} non-zero on {len(hits)} node(s), need {floor}"
|
||||
)
|
||||
else:
|
||||
parts[-1] += f" [{', '.join(hits)}]"
|
||||
|
||||
summary = "; ".join(parts)
|
||||
if failures:
|
||||
return AssertionOutcome(
|
||||
name="congestion_signals",
|
||||
passed=False,
|
||||
detail=(
|
||||
f"FAIL congestion_signals: {'; '.join(failures)} — across "
|
||||
f"{len(snapshot)} node(s) sampled. Full counts: {summary}"
|
||||
),
|
||||
)
|
||||
return AssertionOutcome(
|
||||
name="congestion_signals",
|
||||
passed=True,
|
||||
detail=f"PASS congestion_signals: {summary}",
|
||||
)
|
||||
|
||||
|
||||
def evaluate_max_errors(
|
||||
cfg: MaxErrorsAssertion,
|
||||
errors: list[tuple[str, str]],
|
||||
) -> AssertionOutcome:
|
||||
"""Ceiling on ERROR-level log lines across the whole mesh.
|
||||
|
||||
``errors`` is the ``AnalysisResult.errors`` list of ``(source, line)``
|
||||
pairs rather than a bare count, so a failure can name the nodes and
|
||||
quote the lines. A ceiling breach that only reports a number sends the
|
||||
reader back to the logs it was supposed to save them reading.
|
||||
"""
|
||||
count = len(errors)
|
||||
if count <= cfg.max_total:
|
||||
return AssertionOutcome(
|
||||
name="max_errors",
|
||||
passed=True,
|
||||
detail=(
|
||||
f"PASS max_errors: {count} ERROR line(s) mesh-wide <= "
|
||||
f"ceiling {cfg.max_total}"
|
||||
),
|
||||
)
|
||||
|
||||
per_node: dict[str, int] = {}
|
||||
for source, _line in errors:
|
||||
per_node[source] = per_node.get(source, 0) + 1
|
||||
worst = sorted(per_node.items(), key=lambda kv: -kv[1])
|
||||
breakdown = ", ".join(f"{src}={n}" for src, n in worst)
|
||||
samples = "\n".join(
|
||||
f" [{src}] {line.strip()}" for src, line in errors[:5]
|
||||
)
|
||||
return AssertionOutcome(
|
||||
name="max_errors",
|
||||
passed=False,
|
||||
detail=(
|
||||
f"FAIL max_errors: {count} ERROR line(s) mesh-wide > ceiling "
|
||||
f"{cfg.max_total} — per node: {breakdown}. First "
|
||||
f"{min(5, count)}:\n{samples}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def evaluate_min_parent_switches(
|
||||
cfg: MinParentSwitchesAssertion,
|
||||
parent_switch_count: int,
|
||||
@@ -147,7 +427,7 @@ def evaluate_min_parent_switches(
|
||||
passed=True,
|
||||
detail=(
|
||||
f"PASS min_parent_switches: {parent_switch_count} switches "
|
||||
f">= floor {cfg.min_total}"
|
||||
f"(mesh-wide) >= floor {cfg.min_total}"
|
||||
),
|
||||
)
|
||||
return AssertionOutcome(
|
||||
@@ -155,7 +435,8 @@ def evaluate_min_parent_switches(
|
||||
passed=False,
|
||||
detail=(
|
||||
f"FAIL min_parent_switches: {parent_switch_count} switches "
|
||||
f"< floor {cfg.min_total} — harness did not induce sufficient "
|
||||
f"(mesh-wide) < floor {cfg.min_total} — harness did not induce "
|
||||
f"sufficient "
|
||||
f"parent flapping; bloom-rate assertion would be trivially "
|
||||
f"true. Check tree-snapshot-warmup.json: did the expected "
|
||||
f"node win the root election?"
|
||||
|
||||
@@ -10,8 +10,13 @@ from .scenario import Scenario
|
||||
from .topology import SimTopology
|
||||
|
||||
# Image name for the pre-built FIPS test image.
|
||||
# The runner builds this once before starting containers.
|
||||
FIPS_SIM_IMAGE = "fips-test:latest"
|
||||
#
|
||||
# A harness that has already built an image passes it in FIPS_TEST_IMAGE, and
|
||||
# it is then the caller's image: the runner uses it and must not rebuild it.
|
||||
# Unset means a bare run, where the shared tag is the right name and the runner
|
||||
# still builds it. Read at import, which is safe because the simulation always
|
||||
# starts as a child process with the environment already set.
|
||||
FIPS_SIM_IMAGE = os.environ.get("FIPS_TEST_IMAGE", "fips-test:latest")
|
||||
|
||||
# Jinja2 template for the compose file.
|
||||
# Uses a pre-built image instead of per-service build to support large topologies.
|
||||
@@ -19,12 +24,12 @@ _COMPOSE_TEMPLATE = Template(
|
||||
"""\
|
||||
networks:
|
||||
fips-net:
|
||||
driver: bridge
|
||||
labels:
|
||||
- "com.corganlabs.fips-ci=1"
|
||||
ipam:
|
||||
config:
|
||||
- subnet: {{ subnet }}
|
||||
# External, and created by the sim rather than by compose. The range has to
|
||||
# be *claimed* -- attempt-create, advance on docker's own overlap error --
|
||||
# so that two concurrent runs cannot select the same one, and only the
|
||||
# process that creates the network can do that. See sim/netclaim.py.
|
||||
external: true
|
||||
name: {{ network_name }}
|
||||
|
||||
x-fips-common: &fips-common
|
||||
image: {{ image }}
|
||||
@@ -47,7 +52,7 @@ services:
|
||||
{% for node in nodes %}
|
||||
{{ node.node_id }}:
|
||||
<<: *fips-common
|
||||
container_name: fips-node-{{ node.node_id }}
|
||||
container_name: {{ topology.container_name(node.node_id) }}
|
||||
hostname: {{ node.node_id }}
|
||||
volumes:
|
||||
- ./{{ node.node_id }}.yaml:/etc/fips/fips.yaml:ro
|
||||
@@ -64,8 +69,14 @@ def generate_compose(
|
||||
topology: SimTopology,
|
||||
scenario: Scenario,
|
||||
output_dir: str,
|
||||
network_name: str,
|
||||
) -> str:
|
||||
"""Render docker-compose.yml and write to output_dir. Returns the file path."""
|
||||
"""Render docker-compose.yml and write to output_dir. Returns the file path.
|
||||
|
||||
``network_name`` is the docker network the sim has already claimed; the
|
||||
compose file refers to it as external rather than declaring a subnet, so
|
||||
that the claim and the creation are the same operation.
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
nodes = [topology.nodes[nid] for nid in sorted(topology.nodes)]
|
||||
@@ -77,11 +88,12 @@ def generate_compose(
|
||||
)
|
||||
|
||||
content = _COMPOSE_TEMPLATE.render(
|
||||
subnet=scenario.topology.subnet,
|
||||
network_name=network_name,
|
||||
rust_log=scenario.logging.rust_log,
|
||||
image=FIPS_SIM_IMAGE,
|
||||
nodes=nodes,
|
||||
resolv_conf=resolv_conf,
|
||||
topology=topology,
|
||||
)
|
||||
|
||||
path = os.path.join(output_dir, "docker-compose.yml")
|
||||
|
||||
@@ -7,6 +7,34 @@ import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# `docker compose` renders its progress UI on stderr, so a failed command can
|
||||
# have hundreds of lines of captured output behind it. Log the tail: whatever
|
||||
# the daemon refused is the last thing it wrote.
|
||||
OUTPUT_TAIL_CHARS = 2000
|
||||
|
||||
|
||||
def _tail(text: str) -> str:
|
||||
"""Trim captured output to its last few KB for logging."""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return "(empty)"
|
||||
if len(text) <= OUTPUT_TAIL_CHARS:
|
||||
return text
|
||||
return "...\n" + text[-OUTPUT_TAIL_CHARS:]
|
||||
|
||||
|
||||
def _decode(output) -> str:
|
||||
"""Render captured output as text.
|
||||
|
||||
A timed-out command hands back what it had written as bytes, even when
|
||||
the call asked for text, so the timeout path cannot assume either.
|
||||
"""
|
||||
if output is None:
|
||||
return ""
|
||||
if isinstance(output, bytes):
|
||||
return output.decode(errors="replace")
|
||||
return output
|
||||
|
||||
|
||||
class DockerExecError(Exception):
|
||||
def __init__(self, container, cmd, returncode, stderr):
|
||||
@@ -47,16 +75,50 @@ def docker_compose(
|
||||
timeout: int = 300,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a docker compose command with the given compose file."""
|
||||
"""Run a docker compose command with the given compose file.
|
||||
|
||||
Output is captured, so a failure's stderr is logged here before anything
|
||||
else sees it. `CalledProcessError` reports only the argv and the exit
|
||||
status, and a `check=False` caller reads neither, so without this the one
|
||||
place the daemon says what it objected to -- a container name already in
|
||||
use, an unusable subnet, a missing image -- is captured and then thrown
|
||||
away. Nothing about the success path changes.
|
||||
"""
|
||||
cmd = ["docker", "compose", "-f", compose_file] + args
|
||||
log.info("Running: %s", " ".join(cmd))
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=check,
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
# A timeout raises from inside communicate(), so the return-code
|
||||
# branch below never runs and this is the only chance to say what
|
||||
# the command had managed to emit. The partials arrive as bytes
|
||||
# even under text=True.
|
||||
log.error(
|
||||
"%s timed out after %ds\nstderr: %s\nstdout: %s",
|
||||
" ".join(cmd),
|
||||
timeout,
|
||||
_tail(_decode(e.stderr)),
|
||||
_tail(_decode(e.stdout)),
|
||||
)
|
||||
raise
|
||||
if result.returncode != 0:
|
||||
log.error(
|
||||
"%s exited %d\nstderr: %s\nstdout: %s",
|
||||
" ".join(cmd),
|
||||
result.returncode,
|
||||
_tail(result.stderr),
|
||||
_tail(result.stdout),
|
||||
)
|
||||
if check:
|
||||
raise subprocess.CalledProcessError(
|
||||
result.returncode, cmd, result.stdout, result.stderr
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def is_container_running(container: str) -> bool:
|
||||
|
||||
@@ -8,4 +8,4 @@ _TESTING_DIR = os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
if _TESTING_DIR not in sys.path:
|
||||
sys.path.insert(0, _TESTING_DIR)
|
||||
|
||||
from lib.derive_keys import derive # noqa: E402
|
||||
from lib.derive_keys import derive, derive_full # noqa: E402,F401
|
||||
|
||||
+38
-11
@@ -29,9 +29,18 @@ __all__ = ["AnalysisResult", "analyze_logs", "collect_logs", "write_sim_metadata
|
||||
|
||||
|
||||
def collect_logs(container_names: list[str], output_dir: str) -> dict[str, str]:
|
||||
"""Collect all output (stdout + stderr) from all containers."""
|
||||
"""Collect all output (stdout + stderr) from all containers.
|
||||
|
||||
Raises RuntimeError if any container's output could not be read, or if
|
||||
there was nothing to read. `docker logs` writes its own failures to
|
||||
stderr and exits non-zero, so without the returncode check the daemon's
|
||||
"No such container" reply was stored as though it were the node's log
|
||||
and analysed as a mesh with no panics, no errors and no sessions --
|
||||
which reads exactly like a clean run.
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
logs = {}
|
||||
failed = []
|
||||
|
||||
for name in container_names:
|
||||
try:
|
||||
@@ -41,17 +50,35 @@ def collect_logs(container_names: list[str], output_dir: str) -> dict[str, str]:
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
raw = result.stdout + result.stderr
|
||||
log_text = strip_ansi(raw)
|
||||
logs[name] = log_text
|
||||
|
||||
path = os.path.join(output_dir, f"{name}.log")
|
||||
with open(path, "w") as f:
|
||||
f.write(log_text)
|
||||
|
||||
except (subprocess.TimeoutExpired, Exception) as e:
|
||||
except Exception as e:
|
||||
log.warning("Failed to collect logs from %s: %s", name, e)
|
||||
logs[name] = ""
|
||||
failed.append(name)
|
||||
continue
|
||||
|
||||
if result.returncode != 0:
|
||||
log.warning(
|
||||
"docker logs %s exited %d: %s",
|
||||
name, result.returncode, result.stderr.strip(),
|
||||
)
|
||||
failed.append(name)
|
||||
continue
|
||||
|
||||
# A container's own stderr comes back on our stderr, so both
|
||||
# streams are log content on the success path.
|
||||
log_text = strip_ansi(result.stdout + result.stderr)
|
||||
logs[name] = log_text
|
||||
|
||||
path = os.path.join(output_dir, f"{name}.log")
|
||||
with open(path, "w") as f:
|
||||
f.write(log_text)
|
||||
|
||||
if failed:
|
||||
raise RuntimeError(
|
||||
f"could not collect logs from {len(failed)}/{len(container_names)} "
|
||||
f"containers: {', '.join(failed)}"
|
||||
)
|
||||
if not logs:
|
||||
raise RuntimeError("no container logs were collected")
|
||||
|
||||
return logs
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Scoping suffix for names that live in a global namespace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def name_suffix() -> str:
|
||||
"""Return the suffix appended to globally-scoped names.
|
||||
|
||||
Docker container names and the generated-config directory are shared
|
||||
across every simulation running on the host, so the harness exports
|
||||
``FIPS_CI_NAME_SUFFIX`` to keep concurrent runs and concurrent
|
||||
scenarios apart. The suffix is empty when the variable is unset, so a
|
||||
bare ``chaos.sh`` run renders exactly the same names as it always has.
|
||||
"""
|
||||
return os.environ.get("FIPS_CI_NAME_SUFFIX", "")
|
||||
|
||||
|
||||
def veth_token(suffix: str) -> str:
|
||||
"""Shorten a name suffix to four hex characters.
|
||||
|
||||
Host interface names have only 15 characters to work with, far fewer
|
||||
than the suffix needs, so concurrent scenarios are told apart by a
|
||||
hash of it instead. Empty for an empty suffix, so a bare run's
|
||||
interface names are unchanged.
|
||||
"""
|
||||
if not suffix:
|
||||
return ""
|
||||
return hashlib.sha1(suffix.encode()).hexdigest()[:4]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Print the token for each suffix given on the command line, one per
|
||||
# line. ci-cleanup.sh reaps host interfaces by token and calls this
|
||||
# rather than re-deriving the hash, so widening the token here cannot
|
||||
# leave the reaper matching the old width.
|
||||
for arg in sys.argv[1:]:
|
||||
print(veth_token(arg))
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Claim a free /24 for a scenario's network, atomically.
|
||||
|
||||
Two concurrent chaos runs used to request identical ranges: `ci-local.sh`
|
||||
derived each child's subnet from its position in the suite list, so both runs
|
||||
walked `10.30.0` through `10.30.12` and collided on every one of them. A run
|
||||
index or a hashed offset only makes a collision unlikely, and a collision is
|
||||
precisely the failure being removed, so this claims instead.
|
||||
|
||||
Claim-and-advance: attempt to create the network on a candidate range and, on
|
||||
docker's own "Pool overlaps" error, move to the next. Docker's address pool is
|
||||
then the arbiter and an overlap between two concurrent runs is *impossible*
|
||||
rather than improbable. The pattern is taken from `testing/sidecar/scripts/
|
||||
test-sidecar.sh:75-98`, which has been carrying it since 2026-07-19.
|
||||
|
||||
The claim has to happen before the topology is generated, not before the
|
||||
compose file is written: node IPs derive from the subnet inside
|
||||
`generate_topology`, and traffic shaping keys its filters on those addresses.
|
||||
Claiming later yields a network on one range and `tc` filters on another, which
|
||||
does not fail at bring-up and instead leaves the shaping matching nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Stay inside 10.30.0.0/16, which ci-local.sh already documents as clear of
|
||||
# docker's default pool (172.17-31, 192.168) and of the fixed-subnet suites in
|
||||
# 172.x. Sidecar has claimed 10.40.0.0/16; do not overlap it.
|
||||
NET_BASE = "10.30"
|
||||
NET_CANDIDATES = 200
|
||||
|
||||
|
||||
class NetworkClaimError(RuntimeError):
|
||||
"""No range could be claimed, or docker refused for another reason."""
|
||||
|
||||
|
||||
def claim_network(
|
||||
name: str,
|
||||
labels: dict[str, str] | None = None,
|
||||
candidates: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Create `name` on the first free /24 and return its CIDR.
|
||||
|
||||
`candidates` pins the search to an explicit list, which is how `--subnet`
|
||||
opts out of claiming. A single pinned candidate that is already taken
|
||||
raises rather than advancing, so pinning fails loudly instead of silently
|
||||
overlapping a concurrent run.
|
||||
|
||||
Raises NetworkClaimError if every candidate is taken, or immediately if
|
||||
docker fails for any reason other than an address-pool conflict. Retrying
|
||||
a real error 200 times would bury the reason for it.
|
||||
"""
|
||||
ranges = candidates or [
|
||||
f"{NET_BASE}.{i}.0/24" for i in range(NET_CANDIDATES)
|
||||
]
|
||||
label_args: list[str] = []
|
||||
for key, value in (labels or {}).items():
|
||||
label_args += ["--label", f"{key}={value}"]
|
||||
|
||||
for subnet in ranges:
|
||||
proc = subprocess.run(
|
||||
["docker", "network", "create", "--subnet", subnet]
|
||||
+ label_args
|
||||
+ [name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
log.info("Claimed network %s on %s", name, subnet)
|
||||
return subnet
|
||||
err = (proc.stderr or "") + (proc.stdout or "")
|
||||
if "ool overlaps" in err:
|
||||
continue
|
||||
raise NetworkClaimError(
|
||||
f"docker network create {name} on {subnet} failed: {err.strip()}"
|
||||
)
|
||||
|
||||
if candidates:
|
||||
raise NetworkClaimError(
|
||||
f"pinned subnet(s) {', '.join(candidates)} already in use; "
|
||||
f"another run holds the range"
|
||||
)
|
||||
raise NetworkClaimError(
|
||||
f"no free /24 in {NET_BASE}.0.0/16 after {NET_CANDIDATES} attempts"
|
||||
)
|
||||
|
||||
|
||||
def remove_network(name: str) -> None:
|
||||
"""Remove a claimed network, tolerating its absence.
|
||||
|
||||
The network is `external:` to the generated compose file, so `compose down`
|
||||
leaves it behind and this is the only thing that reclaims the range.
|
||||
Failures are logged rather than raised: teardown runs on the failure path
|
||||
too, and losing a /24 is a leak, not a reason to mask the original error.
|
||||
"""
|
||||
proc = subprocess.run(
|
||||
["docker", "network", "rm", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
err = ((proc.stderr or "") + (proc.stdout or "")).strip()
|
||||
if "not found" in err or "No such network" in err:
|
||||
return
|
||||
log.warning("Could not remove network %s: %s", name, err)
|
||||
@@ -144,6 +144,27 @@ class NetemManager:
|
||||
len(self._edge_overrides),
|
||||
)
|
||||
|
||||
# Edges the periodic mutation must never touch (canonical "nXX-nYY").
|
||||
# Unlike a link_policy typo, which merely fails to shape a link, a typo
|
||||
# here would silently leave an asserted link unprotected and reintroduce
|
||||
# the exact flakiness the exclusion exists to remove — so an unknown
|
||||
# edge is a hard error, not a warning.
|
||||
self._mutation_exclude: set[str] = set()
|
||||
for edge_str in config.mutation.exclude_edges:
|
||||
canonical = "-".join(sorted(edge_str.split("-")))
|
||||
if canonical not in topo_edge_strs:
|
||||
raise ValueError(
|
||||
f"netem.mutation.exclude_edges references {edge_str!r}, "
|
||||
"which is not an edge in the topology"
|
||||
)
|
||||
self._mutation_exclude.add(canonical)
|
||||
if self._mutation_exclude:
|
||||
log.info(
|
||||
"Mutation excludes %d edge(s): %s",
|
||||
len(self._mutation_exclude),
|
||||
", ".join(sorted(self._mutation_exclude)),
|
||||
)
|
||||
|
||||
def _htb_rate(self, node_id: str, peer_id: str) -> str:
|
||||
"""Return the HTB rate string for a link direction."""
|
||||
rate = self._edge_rates.get((node_id, peer_id), 0)
|
||||
@@ -370,10 +391,12 @@ class NetemManager:
|
||||
if not self.config.mutation.policies:
|
||||
return
|
||||
|
||||
# Only consider edges where both endpoints are up
|
||||
# Only consider edges where both endpoints are up, and never the
|
||||
# explicitly excluded ones (links an assertion depends on).
|
||||
live_edges = [
|
||||
(a, b) for a, b in self.topology.edges
|
||||
if a not in self.down_nodes and b not in self.down_nodes
|
||||
and "-".join(sorted([a, b])) not in self._mutation_exclude
|
||||
]
|
||||
if not live_edges:
|
||||
return
|
||||
|
||||
+277
-15
@@ -12,7 +12,16 @@ import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from .assertions import AssertionOutcome, BloomSendRateMonitor, evaluate_min_parent_switches
|
||||
from .assertions import (
|
||||
AssertionOutcome,
|
||||
BloomSendRateMonitor,
|
||||
evaluate_baseline,
|
||||
evaluate_congestion_signals,
|
||||
evaluate_max_errors,
|
||||
evaluate_max_parent_switches,
|
||||
evaluate_min_parent_switches,
|
||||
evaluate_tree_parents,
|
||||
)
|
||||
from .compose import generate_compose
|
||||
from .config_gen import write_configs
|
||||
from .control import snapshot_all_congestion, snapshot_all_mmp, snapshot_all_trees
|
||||
@@ -20,6 +29,8 @@ from .docker_exec import docker_compose
|
||||
from .link_swap import LinkSwapManager
|
||||
from .links import LinkManager
|
||||
from .logs import AnalysisResult, analyze_logs, collect_logs, write_sim_metadata
|
||||
from .naming import name_suffix
|
||||
from .netclaim import claim_network, remove_network
|
||||
from .netem import NetemManager
|
||||
from .nodes import NodeManager
|
||||
from .peer_churn import PeerChurnManager
|
||||
@@ -37,9 +48,24 @@ class SimRunner:
|
||||
self.rng = random.Random(scenario.seed)
|
||||
self.topology: SimTopology | None = None
|
||||
self.compose_file: str | None = None
|
||||
# Claimed in _setup; the compose file refers to it as external, so
|
||||
# `compose down` does not remove it and teardown must.
|
||||
self.network_name: str | None = None
|
||||
self.output_dir: str = self._resolve_output_dir(scenario)
|
||||
self._interrupted = False
|
||||
|
||||
# Set when setup, warmup or the simulation loop raises. The exception
|
||||
# is logged rather than propagated, so nothing else on the return path
|
||||
# of run() carries the fact that the simulation did not complete.
|
||||
self.aborted = False
|
||||
|
||||
# Whether this run ever owned containers. Teardown runs from a
|
||||
# finally, so it runs after a failed setup too, and container names
|
||||
# are global: without this it would harvest whatever currently
|
||||
# answers to them. topology and compose_file are both set well
|
||||
# before the containers start and so cannot stand in for it.
|
||||
self._containers_started = False
|
||||
|
||||
# Shared set of currently-down node IDs (updated by NodeManager,
|
||||
# read by NetemManager, LinkManager, TrafficManager)
|
||||
self._down_nodes: set[str] = set()
|
||||
@@ -56,6 +82,51 @@ class SimRunner:
|
||||
# Post-run assertion monitors (sampled near end of run).
|
||||
self.bloom_rate_monitor: BloomSendRateMonitor | None = None
|
||||
self.assertion_outcomes: list[AssertionOutcome] = []
|
||||
# Set by the final snapshot. None means the snapshot never ran,
|
||||
# which the congestion assertion must treat as a failure rather
|
||||
# than as an absence of congestion.
|
||||
self.final_congestion: dict | None = None
|
||||
self.final_tree: dict | None = None
|
||||
|
||||
def _evaluate_max_parent_switches(
|
||||
self, cfg, parent_switches: list[tuple[str, str]]
|
||||
) -> AssertionOutcome:
|
||||
"""Count parent switches in the configured scope and apply the ceiling.
|
||||
|
||||
A per-node scope is resolved through the topology and fails
|
||||
explicitly if the node id is not in it. That is the whole reason
|
||||
this lives here rather than in assertions.py: filtering log lines
|
||||
by an unknown container name yields zero matches, and zero
|
||||
trivially satisfies a ceiling, so a typo in ``node:`` would turn
|
||||
the assertion into an unconditional pass.
|
||||
|
||||
Note the limit of that guard. It covers an unresolvable node id
|
||||
and nothing else. A node that is in the topology but whose log is
|
||||
empty, or whose log level suppressed the event, still counts zero
|
||||
and still passes. Only the misspelling is caught here; the log
|
||||
level is guarded at load time, and an empty log for a live node
|
||||
is not guarded at all.
|
||||
"""
|
||||
if cfg.node is None:
|
||||
return evaluate_max_parent_switches(
|
||||
cfg, len(parent_switches), "mesh-wide"
|
||||
)
|
||||
|
||||
if cfg.node not in self.topology.nodes:
|
||||
known = ", ".join(sorted(self.topology.nodes))
|
||||
return AssertionOutcome(
|
||||
name="max_parent_switches",
|
||||
passed=False,
|
||||
detail=(
|
||||
f"FAIL max_parent_switches: node '{cfg.node}' is not in "
|
||||
f"this topology (nodes: {known}). Nothing was counted, so "
|
||||
f"the ceiling was never actually tested."
|
||||
),
|
||||
)
|
||||
|
||||
source = self.topology.container_name(cfg.node)
|
||||
count = sum(1 for src, _ in parent_switches if src == source)
|
||||
return evaluate_max_parent_switches(cfg, count, f"node {cfg.node}")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_output_dir(scenario: Scenario) -> str:
|
||||
@@ -82,7 +153,12 @@ class SimRunner:
|
||||
self._warmup()
|
||||
self._simulation_loop()
|
||||
except Exception:
|
||||
# Log rather than re-raise: the default excepthook writes to stderr
|
||||
# and would not reach the file handler installed in _setup(), so a
|
||||
# re-raise would lose the traceback from runner.log, which is the
|
||||
# artifact that survives into CI. The flag carries the abort out.
|
||||
log.exception("Simulation failed")
|
||||
self.aborted = True
|
||||
finally:
|
||||
result = self._teardown()
|
||||
|
||||
@@ -113,6 +189,28 @@ class SimRunner:
|
||||
logging.getLogger().addHandler(fh)
|
||||
log.info("Runner log: %s", runner_log_path)
|
||||
|
||||
# 0. Claim this run's network range, before anything derives from it.
|
||||
#
|
||||
# The ordering is not obvious and it matters: node IPs are computed
|
||||
# from the subnet inside generate_topology, and traffic shaping keys
|
||||
# its filters on those addresses. Claiming after the topology exists
|
||||
# would give a network on one range and `tc` filters on another, which
|
||||
# does not fail at bring-up — it silently leaves the shaping matching
|
||||
# nothing.
|
||||
self.network_name = f"fips-sim{name_suffix()}-net"
|
||||
s.topology.subnet = claim_network(
|
||||
self.network_name,
|
||||
labels={
|
||||
"com.corganlabs.fips-ci": "1",
|
||||
"com.corganlabs.fips-ci.run": os.environ.get(
|
||||
"FIPS_CI_RUN_ID", "manual"
|
||||
),
|
||||
},
|
||||
candidates=(
|
||||
[s.topology.pinned_subnet] if s.topology.pinned_subnet else None
|
||||
),
|
||||
)
|
||||
|
||||
# 1. Generate topology
|
||||
log.info(
|
||||
"Generating %d-node %s topology (seed=%d)...",
|
||||
@@ -133,9 +231,17 @@ class SimRunner:
|
||||
log.info(" %s: peers=%s", nid, ",".join(peers))
|
||||
|
||||
# 2. Generate configs
|
||||
#
|
||||
# The directory carries the same suffix as the container names, so
|
||||
# parallel scenarios cannot overwrite each other's compose file
|
||||
# between writing it and starting containers from it.
|
||||
docker_network_dir = os.path.join(os.path.dirname(__file__), "..")
|
||||
config_dir = os.path.normpath(
|
||||
os.path.join(docker_network_dir, "generated-configs", "sim")
|
||||
os.path.join(
|
||||
docker_network_dir,
|
||||
"generated-configs",
|
||||
f"sim{self.topology.name_suffix}",
|
||||
)
|
||||
)
|
||||
# Select ephemeral identity nodes (if peer churn enabled)
|
||||
self._ephemeral_nodes: set[str] = set()
|
||||
@@ -157,21 +263,44 @@ class SimRunner:
|
||||
log.info("Wrote node configs to %s", config_dir)
|
||||
|
||||
# 3. Generate docker-compose.yml
|
||||
self.compose_file = generate_compose(self.topology, self.scenario, config_dir)
|
||||
self.compose_file = generate_compose(
|
||||
self.topology, self.scenario, config_dir, self.network_name
|
||||
)
|
||||
log.info("Wrote %s", self.compose_file)
|
||||
|
||||
# 4. Build the test image once (avoids per-service build at scale)
|
||||
log.info("Building Docker image...")
|
||||
# 4. Obtain the test image (once, rather than per-service at scale).
|
||||
#
|
||||
# Building it is right for a bare run and wrong under a harness. When
|
||||
# FIPS_TEST_IMAGE is set the image belongs to the caller, and every
|
||||
# scenario of a parallel run would otherwise rebuild it into one shared
|
||||
# name from one shared context — so assert it exists and fail loudly if
|
||||
# it does not, rather than manufacture a substitute nobody asked for.
|
||||
from .compose import FIPS_SIM_IMAGE
|
||||
docker_dir = os.path.join(os.path.dirname(__file__), "..", "..", "docker")
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", FIPS_SIM_IMAGE, docker_dir],
|
||||
check=True,
|
||||
)
|
||||
if os.environ.get("FIPS_TEST_IMAGE"):
|
||||
log.info("Using caller-supplied image %s", FIPS_SIM_IMAGE)
|
||||
probe = subprocess.run(
|
||||
["docker", "image", "inspect", FIPS_SIM_IMAGE],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"FIPS_TEST_IMAGE names {FIPS_SIM_IMAGE}, which is not present. "
|
||||
"The harness that set it is expected to have built it."
|
||||
)
|
||||
else:
|
||||
log.info("Building Docker image...")
|
||||
docker_dir = os.environ.get("FIPS_BUILD_CONTEXT") or os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "docker"
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "build", "-t", FIPS_SIM_IMAGE, docker_dir],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# 5. Start containers
|
||||
log.info("Starting %d containers...", len(self.topology.nodes))
|
||||
docker_compose(self.compose_file, ["up", "-d"])
|
||||
self._containers_started = True
|
||||
|
||||
# 6. Set up veth pairs for Ethernet edges (before netem)
|
||||
#
|
||||
@@ -409,11 +538,69 @@ class SimRunner:
|
||||
def assertions_failed(self) -> bool:
|
||||
return any(not o.passed for o in self.assertion_outcomes)
|
||||
|
||||
def _run_status(self) -> str:
|
||||
"""Name for how the run itself ended, before teardown is considered."""
|
||||
if self.aborted:
|
||||
return "aborted"
|
||||
if self._interrupted:
|
||||
return "interrupted"
|
||||
return "completed"
|
||||
|
||||
def _write_status(self, status: str) -> None:
|
||||
"""Record how the run ended, for a reader who has only this directory.
|
||||
|
||||
One field per line so it can be grepped. The container names are
|
||||
included because they are the handle on everything else the run
|
||||
touched, and because a directory that names them cannot be mistaken
|
||||
for a directory assembled from some other scenario's mesh.
|
||||
|
||||
Never raises. Both callers run this immediately before stopping the
|
||||
containers, so letting a full disk or a vanished output directory
|
||||
out of here would leak the mesh it was about to tear down.
|
||||
"""
|
||||
try:
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
path = os.path.join(self.output_dir, "status.txt")
|
||||
with open(path, "w") as f:
|
||||
f.write(f"status={status}\n")
|
||||
f.write(f"scenario={self.scenario.name}\n")
|
||||
f.write(f"seed={self.scenario.seed}\n")
|
||||
f.write(f"containers=fips-node-nNN{name_suffix()}\n")
|
||||
except Exception:
|
||||
log.exception("Could not write status file")
|
||||
|
||||
def _release_network(self) -> None:
|
||||
"""Give this run's claimed /24 back.
|
||||
|
||||
The compose file declares the network `external:`, so `compose down`
|
||||
leaves it alone and nothing else reclaims the range. Called from both
|
||||
teardown paths, including the setup-failed one, because a run that
|
||||
fell over after claiming still holds a range.
|
||||
"""
|
||||
if self.network_name:
|
||||
remove_network(self.network_name)
|
||||
|
||||
def _teardown(self) -> AnalysisResult | None:
|
||||
"""Stop dynamic elements, collect logs, analyze, stop containers."""
|
||||
result = None
|
||||
if not self._containers_started:
|
||||
# There is no mesh to harvest. Snapshots, logs, analysis and
|
||||
# assertions all address containers by a name that is global to
|
||||
# the host, so running them here would describe whoever holds
|
||||
# those names now and leave a plausible report of a run that
|
||||
# never happened. Leaving them out makes the presence of
|
||||
# analysis.txt proof that this scenario's own mesh existed.
|
||||
self._write_status("setup-failed")
|
||||
if self.compose_file:
|
||||
# `up -d` can fail part way through, so this run may own
|
||||
# containers or a network even with no mesh to speak of.
|
||||
log.info("Stopping containers...")
|
||||
docker_compose(self.compose_file, ["down"], check=False)
|
||||
self._release_network()
|
||||
return None
|
||||
|
||||
if self.topology and self.compose_file:
|
||||
result = None
|
||||
status = self._run_status()
|
||||
try:
|
||||
# Evaluate post-run assertions before doing any teardown so
|
||||
# control sockets are still reachable.
|
||||
self._evaluate_assertions()
|
||||
@@ -460,7 +647,10 @@ class SimRunner:
|
||||
print(result.summary())
|
||||
|
||||
# Log-derived assertions (evaluated after analyze_logs so
|
||||
# parent_switches and similar are populated).
|
||||
# parent_switches and similar are populated). See
|
||||
# _evaluate_max_parent_switches for why the per-node variant
|
||||
# resolves its node against the topology rather than filtering
|
||||
# optimistically.
|
||||
mps_cfg = self.scenario.assertions.min_parent_switches
|
||||
if mps_cfg is not None:
|
||||
outcome = evaluate_min_parent_switches(
|
||||
@@ -472,6 +662,60 @@ class SimRunner:
|
||||
else:
|
||||
log.error("%s", outcome.detail)
|
||||
|
||||
xps_cfg = self.scenario.assertions.max_parent_switches
|
||||
if xps_cfg is not None:
|
||||
outcome = self._evaluate_max_parent_switches(
|
||||
xps_cfg, result.parent_switches
|
||||
)
|
||||
self.assertion_outcomes.append(outcome)
|
||||
if outcome.passed:
|
||||
log.info("%s", outcome.detail)
|
||||
else:
|
||||
log.error("%s", outcome.detail)
|
||||
|
||||
# Applied to every scenario by default, so this is the one
|
||||
# assertion that is present even when the YAML declares no
|
||||
# assertions block at all.
|
||||
err_cfg = self.scenario.assertions.max_errors
|
||||
if err_cfg is not None:
|
||||
outcome = evaluate_max_errors(err_cfg, result.errors)
|
||||
self.assertion_outcomes.append(outcome)
|
||||
if outcome.passed:
|
||||
log.info("%s", outcome.detail)
|
||||
else:
|
||||
log.error("%s", outcome.detail)
|
||||
|
||||
bl_cfg = self.scenario.assertions.baseline
|
||||
if bl_cfg is not None:
|
||||
outcome = evaluate_baseline(
|
||||
bl_cfg, self.final_tree, len(result.sessions_established)
|
||||
)
|
||||
self.assertion_outcomes.append(outcome)
|
||||
if outcome.passed:
|
||||
log.info("%s", outcome.detail)
|
||||
else:
|
||||
log.error("%s", outcome.detail)
|
||||
|
||||
tp_cfg = self.scenario.assertions.tree_parents
|
||||
if tp_cfg is not None:
|
||||
outcome = evaluate_tree_parents(tp_cfg, self.final_tree)
|
||||
self.assertion_outcomes.append(outcome)
|
||||
if outcome.passed:
|
||||
log.info("%s", outcome.detail)
|
||||
else:
|
||||
log.error("%s", outcome.detail)
|
||||
|
||||
cong_cfg = self.scenario.assertions.congestion_signals
|
||||
if cong_cfg is not None:
|
||||
outcome = evaluate_congestion_signals(
|
||||
cong_cfg, self.final_congestion
|
||||
)
|
||||
self.assertion_outcomes.append(outcome)
|
||||
if outcome.passed:
|
||||
log.info("%s", outcome.detail)
|
||||
else:
|
||||
log.error("%s", outcome.detail)
|
||||
|
||||
# Write assertion outcomes
|
||||
if self.assertion_outcomes:
|
||||
assertions_path = os.path.join(self.output_dir, "assertions.txt")
|
||||
@@ -498,14 +742,26 @@ class SimRunner:
|
||||
if self.veth_mgr:
|
||||
log.info("Cleaning up veth pairs...")
|
||||
self.veth_mgr.teardown_all()
|
||||
|
||||
# Stop containers
|
||||
except Exception:
|
||||
# Same reasoning as run(): logging keeps the traceback in
|
||||
# runner.log, where re-raising would send it to stderr instead
|
||||
# and past the exit ladder, which would report a harvest that
|
||||
# fell over as a bad command line.
|
||||
log.exception("Teardown failed")
|
||||
self.aborted = True
|
||||
status = "teardown-failed"
|
||||
result = None
|
||||
finally:
|
||||
# Status first: it is the one artifact that must exist whatever
|
||||
# else happens, and stopping containers can still time out.
|
||||
self._write_status(status)
|
||||
log.info("Stopping containers...")
|
||||
docker_compose(
|
||||
self.compose_file,
|
||||
["down"],
|
||||
check=False,
|
||||
)
|
||||
self._release_network()
|
||||
|
||||
return result
|
||||
|
||||
@@ -521,6 +777,12 @@ class SimRunner:
|
||||
tree_path = os.path.join(self.output_dir, f"tree-snapshot-{label}.json")
|
||||
mmp_path = os.path.join(self.output_dir, f"mmp-snapshot-{label}.json")
|
||||
congestion_path = os.path.join(self.output_dir, f"congestion-snapshot-{label}.json")
|
||||
# Retained for the congestion assertion, which needs the same
|
||||
# responses the file gets. Reading the file back would let a write
|
||||
# failure present as an assertion that saw nothing.
|
||||
if label == "final":
|
||||
self.final_congestion = congestion_snap
|
||||
self.final_tree = tree_snap
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
with open(tree_path, "w") as f:
|
||||
json.dump(tree_snap, f, indent=2)
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
# Node ids are rendered nNN by the topology generator, zero-padded to two
|
||||
# digits. Matching the shape here keeps a typo such as "no4" or "n4x" from
|
||||
# reaching an assertion as a node that simply never appears.
|
||||
_NODE_ID_RE = re.compile(r"n\d+")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Range:
|
||||
@@ -38,6 +44,18 @@ class TopologyConfig:
|
||||
# When set, each edge is randomly assigned a transport based on weights.
|
||||
# Only valid for non-explicit algorithms (explicit uses per-edge syntax).
|
||||
transport_mix: dict[str, float] | None = None
|
||||
# Assign derived identities in NodeAddr order so n01 holds the smallest and
|
||||
# is therefore the root every scenario diagram draws. Default on: without it
|
||||
# the root is whichever node happens to hold the smallest key, which is
|
||||
# effectively arbitrary and leaves any scenario that reasons about a
|
||||
# specific root (e.g. bloom-storm's diamond, or a baseline max_roots check)
|
||||
# describing a tree that does not form. Set false where election from an
|
||||
# arbitrary key distribution is itself the subject.
|
||||
pin_root: bool = True
|
||||
# Set by --subnet to opt out of claiming a free range. Not a scenario-file
|
||||
# key: a scenario that hardcoded its range would reintroduce the collision
|
||||
# claiming exists to remove.
|
||||
pinned_subnet: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -55,6 +73,11 @@ class NetemMutationConfig:
|
||||
interval_secs: Range = field(default_factory=lambda: Range(15, 30))
|
||||
fraction: float = 0.3
|
||||
policies: dict[str, NetemPolicy] = field(default_factory=dict)
|
||||
# Edges the periodic mutation must never touch, "nXX-nYY" strings. Use it
|
||||
# to pin the links a tree_parents assertion depends on so a random
|
||||
# degradation cannot flip the very comparison being asserted. Validated
|
||||
# against the topology in NetemManager — an unknown edge is a hard error.
|
||||
exclude_edges: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -203,12 +226,114 @@ class MinParentSwitchesAssertion:
|
||||
min_total: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaxParentSwitchesAssertion:
|
||||
"""Stability ceiling: fail if parent switches exceed ``max_total``.
|
||||
|
||||
``node`` scopes the count to a single node id (e.g. ``n04``) instead
|
||||
of the mesh-wide total. The distinction is not cosmetic: a criterion
|
||||
written about one node's log is a different number from the sum over
|
||||
every node, and checking the sum against a per-node threshold
|
||||
silently tests something other than what was specified.
|
||||
"""
|
||||
|
||||
max_total: int = 0
|
||||
node: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaselineAssertion:
|
||||
"""Floor on the mesh having formed at all.
|
||||
|
||||
Deliberately weak and deliberately universal. It does not describe any
|
||||
scenario's subject; it says the nodes came up, agreed on a root, and
|
||||
took parents. Its value is that a scenario with no other assertion
|
||||
still cannot pass while the mesh is dead, which is the state twelve of
|
||||
thirteen scenarios were previously unable to distinguish from success.
|
||||
|
||||
``max_roots`` is how many distinct root values the snapshot may carry.
|
||||
One means the whole mesh agreed; a churn scenario that partitions on
|
||||
purpose needs a higher number, and setting it above one is a statement
|
||||
that partition is expected rather than an oversight.
|
||||
"""
|
||||
|
||||
min_nodes_reporting: int | None = None
|
||||
max_roots: int | None = None
|
||||
min_nodes_parented: int | None = None
|
||||
min_sessions: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeParentsAssertion:
|
||||
"""Expected parent per node in the final tree snapshot.
|
||||
|
||||
``expected`` maps a node id to the node id its parent must be, e.g.
|
||||
``{"n04": "n03"}``. Both sides are node ids rather than node
|
||||
addresses: an address is a per-run key derived from the generated
|
||||
identity, so a scenario could not name one ahead of time.
|
||||
|
||||
A node absent from the snapshot fails rather than being skipped. That
|
||||
case is not hypothetical — more than half the archived runs of these
|
||||
scenarios have no entry for the node under test, and a skip would
|
||||
have reported those as satisfied.
|
||||
"""
|
||||
|
||||
expected: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CongestionSignalsAssertion:
|
||||
"""Floors on how many nodes observed each congestion signal.
|
||||
|
||||
Each floor is the number of nodes whose final congestion snapshot
|
||||
reports a non-zero counter, not the counter's own magnitude: the
|
||||
scenario's written criteria are about *whether* a signal reached a
|
||||
class of node, and a single node with a huge count would satisfy a
|
||||
magnitude test while proving the signal never propagated.
|
||||
|
||||
A floor left unset is not asserted. At least one must be set, since a
|
||||
block that asserts nothing is the failure this assertion exists to
|
||||
remove.
|
||||
"""
|
||||
|
||||
min_nodes_detected: int | None = None
|
||||
min_nodes_ce_forwarded: int | None = None
|
||||
min_nodes_ce_received: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaxErrorsAssertion:
|
||||
"""Ceiling on ERROR-level lines across every node's log.
|
||||
|
||||
Unlike the other assertions this one is applied to every scenario
|
||||
whether or not the YAML asks for it, because it is a floor on what a
|
||||
green run means rather than a property of one scenario: without it a
|
||||
run in which every node errors on every line still exits 0.
|
||||
|
||||
``max_total`` defaults to 0, which is what the archived corpus
|
||||
supports. Across 2416 archived run directories no node log contains a
|
||||
single ERROR-level line, while the sibling WARN counter extracted by
|
||||
the same code path ranges from 0 to 1135 — so 0 is an observed value
|
||||
and not an aspiration, and the counter is known to discriminate.
|
||||
|
||||
A scenario that legitimately induces errors raises the ceiling in its
|
||||
own YAML and must say at that site why the errors are expected.
|
||||
"""
|
||||
|
||||
max_total: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssertionsConfig:
|
||||
"""Optional post-run assertions evaluated against control-socket data."""
|
||||
|
||||
bloom_send_rate: BloomSendRateAssertion | None = None
|
||||
min_parent_switches: MinParentSwitchesAssertion | None = None
|
||||
max_parent_switches: MaxParentSwitchesAssertion | None = None
|
||||
max_errors: MaxErrorsAssertion | None = None
|
||||
congestion_signals: CongestionSignalsAssertion | None = None
|
||||
tree_parents: TreeParentsAssertion | None = None
|
||||
baseline: BaselineAssertion | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -239,15 +364,105 @@ class Scenario:
|
||||
fips_overrides: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
# Keys each fixed-schema section understands. A key absent from these sets is a
|
||||
# typo, and silently ignoring it leaves the block default-constructed while the
|
||||
# YAML still looks correct — a mistyped "assertion:" disarms a scenario's only
|
||||
# assertions, a mistyped "link_flap:" turns off the chaos it was meant to inject.
|
||||
#
|
||||
# Four mappings are deliberately NOT listed, because their keys are names the
|
||||
# scenario author chooses rather than a schema: netem.mutation.policies,
|
||||
# link_swap.policies, topology.transport_mix and assertions.tree_parents (whose
|
||||
# keys are node ids). Two sub-trees are passed through whole and are likewise
|
||||
# not checked: fips_overrides and topology.params. tree_parents is not thereby
|
||||
# unchecked -- both sides of every entry are validated as node ids that exist in
|
||||
# this scenario's topology, which is the check that matters for it.
|
||||
#
|
||||
# NOTE: adding a new assertion type means registering it in TWO places below —
|
||||
# _SECTION_KEYS["assertions"] (so the block accepts its name) and _ASSERTION_KEYS
|
||||
# (so its own members are checked) — or scenarios using it are rejected at load.
|
||||
_TOP_KEYS = {
|
||||
"scenario", "topology", "netem", "link_flaps", "traffic", "node_churn",
|
||||
"peer_churn", "bandwidth", "ingress", "link_swap", "assertions", "logging",
|
||||
"fips_overrides",
|
||||
}
|
||||
_SECTION_KEYS = {
|
||||
"scenario": {"name", "seed", "duration_secs"},
|
||||
"topology": {
|
||||
"num_nodes", "algorithm", "params", "ensure_connected", "subnet",
|
||||
"ip_start", "default_transport", "transport_mix", "pin_root",
|
||||
},
|
||||
"netem": {"enabled", "default_policy", "link_policies", "mutation"},
|
||||
"netem.link_policies[]": {"edges", "policy", "policy_name"},
|
||||
"netem.mutation": {"interval_secs", "fraction", "policies", "exclude_edges"},
|
||||
"link_flaps": {
|
||||
"enabled", "interval_secs", "max_down_links", "down_duration_secs",
|
||||
"protect_connectivity",
|
||||
},
|
||||
"traffic": {
|
||||
"enabled", "max_concurrent", "interval_secs", "duration_secs",
|
||||
"parallel_streams",
|
||||
},
|
||||
"node_churn": {
|
||||
"enabled", "interval_secs", "max_down_nodes", "down_duration_secs",
|
||||
"protect_connectivity",
|
||||
},
|
||||
"peer_churn": {"enabled", "interval_secs", "ephemeral_fraction"},
|
||||
"bandwidth": {"enabled", "tiers_mbps"},
|
||||
"ingress": {"enabled", "tiers_kbps", "burst_bytes"},
|
||||
"link_swap": {"enabled", "interval_secs", "policies", "edges"},
|
||||
"link_swap.edges[]": {"edge", "policy"},
|
||||
"assertions": {
|
||||
"bloom_send_rate", "min_parent_switches", "max_parent_switches",
|
||||
"max_errors", "congestion_signals", "tree_parents", "baseline",
|
||||
},
|
||||
"logging": {"rust_log", "output_dir"},
|
||||
}
|
||||
_ASSERTION_KEYS = {
|
||||
"bloom_send_rate": {"window_secs", "max_per_node"},
|
||||
"min_parent_switches": {"min_total"},
|
||||
"max_parent_switches": {"max_total", "node"},
|
||||
"max_errors": {"max_total"},
|
||||
"congestion_signals": {
|
||||
"min_nodes_detected", "min_nodes_ce_forwarded", "min_nodes_ce_received",
|
||||
},
|
||||
"baseline": {
|
||||
"min_nodes_reporting", "max_roots", "min_nodes_parented", "min_sessions",
|
||||
},
|
||||
}
|
||||
_NETEM_POLICY_KEYS = {
|
||||
"delay_ms", "jitter_ms", "loss_pct", "duplicate_pct", "reorder_pct",
|
||||
"corrupt_pct",
|
||||
}
|
||||
_RANGE_KEYS = {"min", "max"}
|
||||
|
||||
|
||||
def _reject_unknown(data, known, context: str):
|
||||
"""Raise unless every key in `data` is one the loader understands."""
|
||||
if data is None:
|
||||
raise ValueError(
|
||||
f"{context}: section is present but empty; give it a body or remove it"
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"{context}: expected a mapping, got {type(data).__name__}")
|
||||
unknown = sorted(str(k) for k in set(data) - set(known))
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"{context}: unknown key(s): {', '.join(unknown)} "
|
||||
f"(known: {', '.join(sorted(known))})"
|
||||
)
|
||||
|
||||
|
||||
def _parse_range(data, name: str) -> Range:
|
||||
"""Parse a {min, max} dict into a Range."""
|
||||
if isinstance(data, dict):
|
||||
_reject_unknown(data, _RANGE_KEYS, name)
|
||||
return Range(min=float(data["min"]), max=float(data["max"]))
|
||||
raise ValueError(f"{name}: expected {{min, max}} dict, got {type(data).__name__}")
|
||||
|
||||
|
||||
def _parse_netem_policy(data: dict) -> NetemPolicy:
|
||||
def _parse_netem_policy(data: dict, name: str = "netem policy") -> NetemPolicy:
|
||||
"""Parse a netem policy from a dict with [min, max] lists or {min, max} dicts."""
|
||||
_reject_unknown(data, _NETEM_POLICY_KEYS, name)
|
||||
policy = NetemPolicy()
|
||||
for attr in (
|
||||
"delay_ms",
|
||||
@@ -273,16 +488,22 @@ def load_scenario(path: str) -> Scenario:
|
||||
with open(path) as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
if raw is None:
|
||||
raise ValueError(f"{path}: file is empty")
|
||||
_reject_unknown(raw, _TOP_KEYS, "(top level)")
|
||||
|
||||
s = Scenario()
|
||||
|
||||
# Scenario section
|
||||
sc = raw.get("scenario", {})
|
||||
_reject_unknown(sc, _SECTION_KEYS["scenario"], "scenario")
|
||||
s.name = sc.get("name", os.path.splitext(os.path.basename(path))[0])
|
||||
s.seed = int(sc.get("seed", 42))
|
||||
s.duration_secs = int(sc.get("duration_secs", 120))
|
||||
|
||||
# Topology section
|
||||
tc = raw.get("topology", {})
|
||||
_reject_unknown(tc, _SECTION_KEYS["topology"], "topology")
|
||||
s.topology.num_nodes = int(tc.get("num_nodes", 10))
|
||||
s.topology.algorithm = tc.get("algorithm", "random_geometric")
|
||||
s.topology.params = tc.get("params", {})
|
||||
@@ -290,6 +511,13 @@ def load_scenario(path: str) -> Scenario:
|
||||
s.topology.subnet = tc.get("subnet", "172.20.0.0/24")
|
||||
s.topology.ip_start = int(tc.get("ip_start", 10))
|
||||
s.topology.default_transport = tc.get("default_transport", "udp")
|
||||
if "pin_root" in tc:
|
||||
pin = tc["pin_root"]
|
||||
if not isinstance(pin, bool):
|
||||
raise ValueError(
|
||||
f"topology.pin_root must be a boolean, got {pin!r}"
|
||||
)
|
||||
s.topology.pin_root = pin
|
||||
if "transport_mix" in tc:
|
||||
mix = tc["transport_mix"]
|
||||
if not isinstance(mix, dict) or not mix:
|
||||
@@ -298,33 +526,44 @@ def load_scenario(path: str) -> Scenario:
|
||||
|
||||
# Netem section
|
||||
nc = raw.get("netem", {})
|
||||
_reject_unknown(nc, _SECTION_KEYS["netem"], "netem")
|
||||
s.netem.enabled = nc.get("enabled", False)
|
||||
if "default_policy" in nc:
|
||||
s.netem.default_policy = _parse_netem_policy(nc["default_policy"])
|
||||
s.netem.default_policy = _parse_netem_policy(
|
||||
nc["default_policy"], "netem.default_policy"
|
||||
)
|
||||
if "link_policies" in nc:
|
||||
for lp_data in nc["link_policies"]:
|
||||
_reject_unknown(
|
||||
lp_data, _SECTION_KEYS["netem.link_policies[]"], "netem.link_policies[]"
|
||||
)
|
||||
override = LinkPolicyOverride(
|
||||
edges=lp_data.get("edges", []),
|
||||
)
|
||||
if "policy" in lp_data:
|
||||
override.policy = _parse_netem_policy(lp_data["policy"])
|
||||
override.policy = _parse_netem_policy(
|
||||
lp_data["policy"], "netem.link_policies[].policy"
|
||||
)
|
||||
if "policy_name" in lp_data:
|
||||
override.policy_name = lp_data["policy_name"]
|
||||
s.netem.link_policies.append(override)
|
||||
if "mutation" in nc:
|
||||
mc = nc["mutation"]
|
||||
_reject_unknown(mc, _SECTION_KEYS["netem.mutation"], "netem.mutation")
|
||||
s.netem.mutation.interval_secs = _parse_range(
|
||||
mc.get("interval_secs", {"min": 15, "max": 30}), "netem.mutation.interval_secs"
|
||||
)
|
||||
s.netem.mutation.fraction = float(mc.get("fraction", 0.3))
|
||||
s.netem.mutation.exclude_edges = list(mc.get("exclude_edges", []))
|
||||
if "policies" in mc:
|
||||
s.netem.mutation.policies = {
|
||||
name: _parse_netem_policy(pdata)
|
||||
name: _parse_netem_policy(pdata, f"netem.mutation.policies.{name}")
|
||||
for name, pdata in mc["policies"].items()
|
||||
}
|
||||
|
||||
# Link flaps section
|
||||
lf = raw.get("link_flaps", {})
|
||||
_reject_unknown(lf, _SECTION_KEYS["link_flaps"], "link_flaps")
|
||||
s.link_flaps.enabled = lf.get("enabled", False)
|
||||
if "interval_secs" in lf:
|
||||
s.link_flaps.interval_secs = _parse_range(lf["interval_secs"], "link_flaps.interval_secs")
|
||||
@@ -337,6 +576,7 @@ def load_scenario(path: str) -> Scenario:
|
||||
|
||||
# Traffic section
|
||||
tf = raw.get("traffic", {})
|
||||
_reject_unknown(tf, _SECTION_KEYS["traffic"], "traffic")
|
||||
s.traffic.enabled = tf.get("enabled", False)
|
||||
s.traffic.max_concurrent = int(tf.get("max_concurrent", 3))
|
||||
if "interval_secs" in tf:
|
||||
@@ -347,6 +587,7 @@ def load_scenario(path: str) -> Scenario:
|
||||
|
||||
# Node churn section
|
||||
nc2 = raw.get("node_churn", {})
|
||||
_reject_unknown(nc2, _SECTION_KEYS["node_churn"], "node_churn")
|
||||
s.node_churn.enabled = nc2.get("enabled", False)
|
||||
if "interval_secs" in nc2:
|
||||
s.node_churn.interval_secs = _parse_range(nc2["interval_secs"], "node_churn.interval_secs")
|
||||
@@ -359,6 +600,7 @@ def load_scenario(path: str) -> Scenario:
|
||||
|
||||
# Peer churn section
|
||||
pc = raw.get("peer_churn", {})
|
||||
_reject_unknown(pc, _SECTION_KEYS["peer_churn"], "peer_churn")
|
||||
s.peer_churn.enabled = pc.get("enabled", False)
|
||||
if "interval_secs" in pc:
|
||||
s.peer_churn.interval_secs = _parse_range(pc["interval_secs"], "peer_churn.interval_secs")
|
||||
@@ -366,6 +608,7 @@ def load_scenario(path: str) -> Scenario:
|
||||
|
||||
# Bandwidth section
|
||||
bw = raw.get("bandwidth", {})
|
||||
_reject_unknown(bw, _SECTION_KEYS["bandwidth"], "bandwidth")
|
||||
s.bandwidth.enabled = bw.get("enabled", False)
|
||||
if "tiers_mbps" in bw:
|
||||
tiers = bw["tiers_mbps"]
|
||||
@@ -375,6 +618,7 @@ def load_scenario(path: str) -> Scenario:
|
||||
|
||||
# Ingress section
|
||||
ig = raw.get("ingress", {})
|
||||
_reject_unknown(ig, _SECTION_KEYS["ingress"], "ingress")
|
||||
s.ingress.enabled = ig.get("enabled", False)
|
||||
if "tiers_kbps" in ig:
|
||||
tiers = ig["tiers_kbps"]
|
||||
@@ -385,18 +629,22 @@ def load_scenario(path: str) -> Scenario:
|
||||
|
||||
# Link swap section (deterministic asymmetric link-cost flapping).
|
||||
ls = raw.get("link_swap", {})
|
||||
_reject_unknown(ls, _SECTION_KEYS["link_swap"], "link_swap")
|
||||
s.link_swap.enabled = ls.get("enabled", False)
|
||||
if "interval_secs" in ls:
|
||||
s.link_swap.interval_secs = float(ls["interval_secs"])
|
||||
if "policies" in ls:
|
||||
s.link_swap.policies = {
|
||||
name: _parse_netem_policy(pdata)
|
||||
name: _parse_netem_policy(pdata, f"link_swap.policies.{name}")
|
||||
for name, pdata in ls["policies"].items()
|
||||
}
|
||||
if "edges" in ls:
|
||||
for edata in ls["edges"]:
|
||||
if not isinstance(edata, dict):
|
||||
raise ValueError("link_swap.edges entries must be dicts")
|
||||
_reject_unknown(
|
||||
edata, _SECTION_KEYS["link_swap.edges[]"], "link_swap.edges[]"
|
||||
)
|
||||
edge = str(edata.get("edge", ""))
|
||||
policy = str(edata.get("policy", ""))
|
||||
if not edge or not policy:
|
||||
@@ -405,20 +653,202 @@ def load_scenario(path: str) -> Scenario:
|
||||
|
||||
# Assertions section (post-run control-socket-based checks).
|
||||
asrt = raw.get("assertions", {})
|
||||
_reject_unknown(asrt, _SECTION_KEYS["assertions"], "assertions")
|
||||
if "bloom_send_rate" in asrt:
|
||||
bsr = asrt["bloom_send_rate"]
|
||||
_reject_unknown(
|
||||
bsr, _ASSERTION_KEYS["bloom_send_rate"], "assertions.bloom_send_rate"
|
||||
)
|
||||
s.assertions.bloom_send_rate = BloomSendRateAssertion(
|
||||
window_secs=int(bsr.get("window_secs", 30)),
|
||||
max_per_node=int(bsr.get("max_per_node", 30)),
|
||||
)
|
||||
if "min_parent_switches" in asrt:
|
||||
mps = asrt["min_parent_switches"]
|
||||
_reject_unknown(
|
||||
mps, _ASSERTION_KEYS["min_parent_switches"],
|
||||
"assertions.min_parent_switches",
|
||||
)
|
||||
s.assertions.min_parent_switches = MinParentSwitchesAssertion(
|
||||
min_total=int(mps.get("min_total", 1)),
|
||||
)
|
||||
if "max_parent_switches" in asrt:
|
||||
xps = asrt["max_parent_switches"]
|
||||
_reject_unknown(
|
||||
xps, _ASSERTION_KEYS["max_parent_switches"],
|
||||
"assertions.max_parent_switches",
|
||||
)
|
||||
if "max_total" not in xps:
|
||||
raise ValueError(
|
||||
"assertions.max_parent_switches: max_total is required "
|
||||
"(a defaulted ceiling would assert an arbitrary number)"
|
||||
)
|
||||
max_total = xps["max_total"]
|
||||
# bool is a subclass of int, and `max_total: yes` would coerce to 1
|
||||
# -- a ceiling low enough to change the verdict, arrived at by typo.
|
||||
if isinstance(max_total, bool) or not isinstance(max_total, int):
|
||||
raise ValueError(
|
||||
f"assertions.max_parent_switches: max_total must be a "
|
||||
f"non-negative integer, got {max_total!r}"
|
||||
)
|
||||
if max_total < 0:
|
||||
raise ValueError(
|
||||
f"assertions.max_parent_switches: max_total must be "
|
||||
f"non-negative, got {max_total}"
|
||||
)
|
||||
# Distinguish "key absent" from "key present but empty". Only the
|
||||
# first means mesh-wide. YAML renders a bare `node:` as None, and
|
||||
# treating that as absent would silently swap a per-node ceiling
|
||||
# for a mesh-wide one -- the exact conflation this assertion was
|
||||
# added to stop, and a live flake rather than a theoretical one:
|
||||
# archived runs of this scenario reach 6 mesh-wide against an n04
|
||||
# maximum of 2.
|
||||
node = None
|
||||
if "node" in xps:
|
||||
node = xps["node"]
|
||||
if not isinstance(node, str) or not node.strip():
|
||||
raise ValueError(
|
||||
f"assertions.max_parent_switches: node must be a node id "
|
||||
f"string such as 'n04', got {node!r}. Omit the key "
|
||||
f"entirely for the mesh-wide total."
|
||||
)
|
||||
node = node.strip()
|
||||
s.assertions.max_parent_switches = MaxParentSwitchesAssertion(
|
||||
max_total=max_total,
|
||||
node=node,
|
||||
)
|
||||
if "max_errors" in asrt:
|
||||
mev = asrt["max_errors"]
|
||||
_reject_unknown(
|
||||
mev, _ASSERTION_KEYS["max_errors"], "assertions.max_errors",
|
||||
)
|
||||
if "max_total" not in mev:
|
||||
raise ValueError(
|
||||
"assertions.max_errors: max_total is required (the point of "
|
||||
"overriding the default ceiling is to name the new number)"
|
||||
)
|
||||
err_total = mev["max_total"]
|
||||
if isinstance(err_total, bool) or not isinstance(err_total, int):
|
||||
raise ValueError(
|
||||
f"assertions.max_errors: max_total must be a non-negative "
|
||||
f"integer, got {err_total!r}"
|
||||
)
|
||||
if err_total < 0:
|
||||
raise ValueError(
|
||||
f"assertions.max_errors: max_total must be non-negative, "
|
||||
f"got {err_total}"
|
||||
)
|
||||
s.assertions.max_errors = MaxErrorsAssertion(max_total=err_total)
|
||||
else:
|
||||
# Default-on. See MaxErrorsAssertion for why this one assertion is
|
||||
# applied without being asked for: it is the floor on what a green
|
||||
# run means, and a scenario that has to opt in is a scenario that
|
||||
# can forget to.
|
||||
s.assertions.max_errors = MaxErrorsAssertion()
|
||||
if "congestion_signals" in asrt:
|
||||
cs = asrt["congestion_signals"]
|
||||
_reject_unknown(
|
||||
cs, _ASSERTION_KEYS["congestion_signals"],
|
||||
"assertions.congestion_signals",
|
||||
)
|
||||
floors = {}
|
||||
for key in _ASSERTION_KEYS["congestion_signals"]:
|
||||
if key not in cs:
|
||||
continue
|
||||
val = cs[key]
|
||||
if isinstance(val, bool) or not isinstance(val, int):
|
||||
raise ValueError(
|
||||
f"assertions.congestion_signals.{key}: must be a positive "
|
||||
f"integer number of nodes, got {val!r}"
|
||||
)
|
||||
if val < 1:
|
||||
raise ValueError(
|
||||
f"assertions.congestion_signals.{key}: must be at least 1, "
|
||||
f"got {val}. A floor of 0 is satisfied by a mesh that "
|
||||
f"observed nothing, which is the case this assertion exists "
|
||||
f"to catch; omit the key instead."
|
||||
)
|
||||
floors[key] = val
|
||||
if not floors:
|
||||
raise ValueError(
|
||||
"assertions.congestion_signals: set at least one floor "
|
||||
"(min_nodes_detected, min_nodes_ce_forwarded, "
|
||||
"min_nodes_ce_received); a block with none asserts nothing"
|
||||
)
|
||||
s.assertions.congestion_signals = CongestionSignalsAssertion(**floors)
|
||||
if "tree_parents" in asrt:
|
||||
tp = asrt["tree_parents"]
|
||||
if not isinstance(tp, dict) or not tp:
|
||||
raise ValueError(
|
||||
"assertions.tree_parents: give it at least one "
|
||||
"'<node>: <expected parent>' entry; an empty block asserts "
|
||||
"nothing"
|
||||
)
|
||||
expected = {}
|
||||
for child, parent in tp.items():
|
||||
for role, val in (("node", child), ("parent", parent)):
|
||||
if not isinstance(val, str) or not _NODE_ID_RE.fullmatch(val):
|
||||
raise ValueError(
|
||||
f"assertions.tree_parents: {role} {val!r} is not a node "
|
||||
f"id of the form 'n04'"
|
||||
)
|
||||
idx = int(val[1:])
|
||||
if idx < 1 or idx > s.topology.num_nodes:
|
||||
raise ValueError(
|
||||
f"assertions.tree_parents: {role} '{val}' is outside "
|
||||
f"this scenario's {s.topology.num_nodes} nodes. An "
|
||||
f"assertion about a node that cannot exist would fail "
|
||||
f"for the wrong reason every run."
|
||||
)
|
||||
if child == parent:
|
||||
raise ValueError(
|
||||
f"assertions.tree_parents: '{child}' is given itself as "
|
||||
f"its parent. A node is its own parent only when it "
|
||||
f"believes it is root, which is what an unconverged tree "
|
||||
f"looks like; assert that some other way."
|
||||
)
|
||||
expected[child] = parent
|
||||
s.assertions.tree_parents = TreeParentsAssertion(expected=expected)
|
||||
if "baseline" in asrt:
|
||||
bl = asrt["baseline"]
|
||||
_reject_unknown(bl, _ASSERTION_KEYS["baseline"], "assertions.baseline")
|
||||
vals = {}
|
||||
for key in _ASSERTION_KEYS["baseline"]:
|
||||
if key not in bl:
|
||||
continue
|
||||
val = bl[key]
|
||||
if isinstance(val, bool) or not isinstance(val, int):
|
||||
raise ValueError(
|
||||
f"assertions.baseline.{key}: must be an integer, "
|
||||
f"got {val!r}"
|
||||
)
|
||||
floor = 1 if key == "max_roots" else 0
|
||||
if val < floor:
|
||||
raise ValueError(
|
||||
f"assertions.baseline.{key}: must be at least {floor}, "
|
||||
f"got {val}"
|
||||
)
|
||||
vals[key] = val
|
||||
if not vals:
|
||||
raise ValueError(
|
||||
"assertions.baseline: set at least one of "
|
||||
+ ", ".join(sorted(_ASSERTION_KEYS["baseline"]))
|
||||
+ "; a block with none asserts nothing"
|
||||
)
|
||||
if vals.get("min_nodes_parented", 0) > 0 or vals.get("max_roots"):
|
||||
n = s.topology.num_nodes
|
||||
if vals.get("min_nodes_parented", 0) > n - 1:
|
||||
raise ValueError(
|
||||
f"assertions.baseline.min_nodes_parented: "
|
||||
f"{vals['min_nodes_parented']} exceeds {n - 1}, the most a "
|
||||
f"{n}-node mesh can reach — the root is its own parent, so "
|
||||
f"this could never pass"
|
||||
)
|
||||
s.assertions.baseline = BaselineAssertion(**vals)
|
||||
|
||||
# Logging section
|
||||
lg = raw.get("logging", {})
|
||||
_reject_unknown(lg, _SECTION_KEYS["logging"], "logging")
|
||||
s.logging.rust_log = lg.get("rust_log", "info")
|
||||
s.logging.output_dir = lg.get("output_dir", "./sim-results")
|
||||
|
||||
@@ -431,8 +861,65 @@ def load_scenario(path: str) -> Scenario:
|
||||
return s
|
||||
|
||||
|
||||
_SUPPRESSING_LOG_LEVELS = ("off", "error", "warn")
|
||||
|
||||
|
||||
def _validate_parent_switch_observability(s: Scenario):
|
||||
"""Refuse a parent-switch assertion the log level cannot observe.
|
||||
|
||||
The events these assertions count are emitted at ``info``. A scenario
|
||||
that declares one while setting ``logging.rust_log`` to ``warn`` or
|
||||
below counts zero switches: the minimum assertion then fails for the
|
||||
wrong reason, and the maximum assertion passes without observing
|
||||
anything, which is the failure mode the whole assertion effort exists
|
||||
to remove. Catch it at load rather than after the run.
|
||||
|
||||
Deliberately conservative. It rejects only a default level that is
|
||||
demonstrably too coarse, and says nothing about per-target directives
|
||||
such as ``info,fips::node=debug``, which raise verbosity rather than
|
||||
lower it.
|
||||
"""
|
||||
if (
|
||||
s.assertions.min_parent_switches is None
|
||||
and s.assertions.max_parent_switches is None
|
||||
):
|
||||
return
|
||||
default_level = s.logging.rust_log.split(",")[0].strip().lower()
|
||||
if default_level in _SUPPRESSING_LOG_LEVELS:
|
||||
raise ValueError(
|
||||
f"logging.rust_log is '{s.logging.rust_log}', whose default level "
|
||||
f"'{default_level}' suppresses the info-level parent-switch events "
|
||||
f"that a parent-switch assertion counts. The assertion would see "
|
||||
f"zero switches regardless of what the tree did. Use 'info' or "
|
||||
f"more verbose, or drop the assertion."
|
||||
)
|
||||
|
||||
|
||||
def _validate_error_observability(s: Scenario):
|
||||
"""Refuse an error ceiling the log level cannot observe.
|
||||
|
||||
Narrower than the parent-switch guard on purpose: ERROR lines survive
|
||||
every level except ``off``, so ``off`` is the only setting that turns
|
||||
this assertion into one that counts zero whatever the mesh did. Since
|
||||
the ceiling is applied by default, a scenario silencing its logs would
|
||||
otherwise acquire an assertion that cannot fail.
|
||||
"""
|
||||
if s.assertions.max_errors is None:
|
||||
return
|
||||
default_level = s.logging.rust_log.split(",")[0].strip().lower()
|
||||
if default_level == "off":
|
||||
raise ValueError(
|
||||
"logging.rust_log is 'off', which suppresses the ERROR-level "
|
||||
"lines the max_errors assertion counts. The assertion would see "
|
||||
"zero errors regardless of what the mesh did. Raise the level, "
|
||||
"or state a deliberate override in assertions.max_errors."
|
||||
)
|
||||
|
||||
|
||||
def _validate(s: Scenario):
|
||||
"""Validate scenario constraints."""
|
||||
_validate_parent_switch_observability(s)
|
||||
_validate_error_observability(s)
|
||||
if s.topology.num_nodes < 2:
|
||||
raise ValueError("topology.num_nodes must be >= 2")
|
||||
if s.topology.num_nodes > 250:
|
||||
|
||||
@@ -7,7 +7,8 @@ import random
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .keys import derive
|
||||
from .keys import derive_full
|
||||
from .naming import name_suffix, veth_token
|
||||
from .scenario import TopologyConfig
|
||||
|
||||
|
||||
@@ -28,6 +29,18 @@ class SimTopology:
|
||||
edges: set[tuple[str, str]] = field(default_factory=set)
|
||||
# Per-edge transport type; edges not in this dict default to "udp"
|
||||
edge_transport: dict[tuple[str, str], str] = field(default_factory=dict)
|
||||
# Suffix scoping globally-visible names to this run and scenario; empty
|
||||
# outside the CI harness, which keeps a bare run's names unchanged.
|
||||
name_suffix: str = ""
|
||||
|
||||
@property
|
||||
def veth_token(self) -> str:
|
||||
"""Short stand-in for the suffix, for names bound by IFNAMSIZ.
|
||||
|
||||
Derived rather than stored so no caller can build a topology whose
|
||||
host names are scoped differently from its container names.
|
||||
"""
|
||||
return veth_token(self.name_suffix)
|
||||
|
||||
def transport_for_edge(self, a: str, b: str) -> str:
|
||||
"""Get the transport type for an edge (defaults to 'udp')."""
|
||||
@@ -107,7 +120,27 @@ class SimTopology:
|
||||
return not connected
|
||||
|
||||
def container_name(self, node_id: str) -> str:
|
||||
return f"fips-node-{node_id}"
|
||||
return f"fips-node-{node_id}{self.name_suffix}"
|
||||
|
||||
def veth_host_name(self, node_a: str, node_b: str, end: str) -> str:
|
||||
"""Generate the host-namespace veth name for one end of an edge.
|
||||
|
||||
Format: ``vh{token}{NN}{MM}{end}`` (max 15 chars for IFNAMSIZ).
|
||||
Host interfaces are global, so the token keeps a scenario from
|
||||
deleting a concurrent scenario's pair; it is empty outside the CI
|
||||
harness, yielding the same "vh0104a" this has always produced.
|
||||
|
||||
``node_a`` and ``node_b`` must be in canonical edge order. Unlike
|
||||
``veth_interface_name()`` this is not symmetric: the far end is
|
||||
``end="b"`` on the same ordering, so swapping the arguments names
|
||||
an interface that does not exist.
|
||||
"""
|
||||
nn_local = node_a.replace("n", "")
|
||||
nn_peer = node_b.replace("n", "")
|
||||
name = f"vh{self.veth_token}{nn_local}{nn_peer}{end}"
|
||||
if len(name) > 15:
|
||||
raise ValueError(f"veth host name too long: {name!r} ({len(name)} > 15)")
|
||||
return name
|
||||
|
||||
def directed_outbound(self) -> dict[str, list[str]]:
|
||||
"""Assign each static-config edge to exactly one node for outbound connection.
|
||||
@@ -170,12 +203,30 @@ def generate_topology(
|
||||
n = config.num_nodes
|
||||
subnet_base = config.subnet.rsplit(".", 1)[0] # "172.20.0"
|
||||
|
||||
# Create nodes with IPs and keys
|
||||
# Create nodes with IPs and keys.
|
||||
#
|
||||
# The mesh roots itself at the numerically smallest NodeAddr
|
||||
# (`src/tree/state.rs:363-390`), which is a hash of the node's public key
|
||||
# and so bears no relation to the node numbering. Every scenario diagram in
|
||||
# this tree draws n01 at the top, and before this ordering was applied the
|
||||
# root landed on an arbitrary node in most scenarios — which is why the
|
||||
# cost-selection scenarios that reasoned about a specific root could never
|
||||
# be turned into reliable assertions and were moved to sans-IO unit tests.
|
||||
#
|
||||
# So derive the identities from the mesh name as before, then *assign* them
|
||||
# in NodeAddr order: n01 receives the smallest and is the root, n02 the next,
|
||||
# and so on. The keys are unchanged and still deterministic; only which node
|
||||
# id holds which one changes. Scenarios that want an arbitrary root set
|
||||
# `pin_root: false` and keep exercising election.
|
||||
node_ids_ordered = [f"n{i + 1:02d}" for i in range(n)]
|
||||
identities = [derive_full(mesh_name, nid) for nid in node_ids_ordered]
|
||||
if config.pin_root:
|
||||
identities.sort(key=lambda t: t[2])
|
||||
|
||||
nodes: dict[str, SimNode] = {}
|
||||
for i in range(n):
|
||||
node_id = f"n{i + 1:02d}"
|
||||
for i, node_id in enumerate(node_ids_ordered):
|
||||
docker_ip = f"{subnet_base}.{config.ip_start + i}"
|
||||
nsec, npub = derive(mesh_name, node_id)
|
||||
nsec, npub, _ = identities[i]
|
||||
nodes[node_id] = SimNode(
|
||||
node_id=node_id,
|
||||
docker_ip=docker_ip,
|
||||
@@ -219,7 +270,14 @@ def generate_topology(
|
||||
nodes[a].peers.append(b)
|
||||
nodes[b].peers.append(a)
|
||||
|
||||
topo = SimTopology(nodes=nodes, edges=edges, edge_transport=edge_transport)
|
||||
# Read the environment once, here, so every name a run produces comes
|
||||
# from the same value.
|
||||
topo = SimTopology(
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
edge_transport=edge_transport,
|
||||
name_suffix=name_suffix(),
|
||||
)
|
||||
|
||||
# Connectivity check with retry
|
||||
if config.ensure_connected:
|
||||
|
||||
+13
-10
@@ -4,7 +4,8 @@ Creates veth pairs between Docker containers for Ethernet-transport
|
||||
edges. Each Ethernet edge gets a veth pair with one end moved into
|
||||
each container's network namespace. Naming:
|
||||
|
||||
Host (temporary): vh{NN}{MM}a / vh{NN}{MM}b
|
||||
Host (temporary): vh{token}{NN}{MM}a / vh{token}{NN}{MM}b
|
||||
(via SimTopology.veth_host_name())
|
||||
Container: ve-{local}-{peer} (via veth_interface_name())
|
||||
|
||||
After creation, the container-side MAC addresses are queried and
|
||||
@@ -113,9 +114,7 @@ class VethManager:
|
||||
if a != node_id and b != node_id:
|
||||
continue
|
||||
# Remove existing pair if any (host-side might still exist)
|
||||
nn_a = a.replace("n", "")
|
||||
nn_b = b.replace("n", "")
|
||||
host_a = f"vh{nn_a}{nn_b}a"
|
||||
host_a = self.topology.veth_host_name(a, b, "a")
|
||||
_run_host(["ip", "link", "delete", host_a], image, check=False)
|
||||
# Re-create
|
||||
self._create_veth_pair(a, b, image)
|
||||
@@ -142,14 +141,14 @@ class VethManager:
|
||||
return
|
||||
|
||||
# Generate names
|
||||
nn_a = node_a.replace("n", "")
|
||||
nn_b = node_b.replace("n", "")
|
||||
host_a = f"vh{nn_a}{nn_b}a"
|
||||
host_b = f"vh{nn_a}{nn_b}b"
|
||||
host_a = self.topology.veth_host_name(node_a, node_b, "a")
|
||||
host_b = self.topology.veth_host_name(node_a, node_b, "b")
|
||||
final_a = veth_interface_name(node_a, node_b)
|
||||
final_b = veth_interface_name(node_b, node_a)
|
||||
|
||||
# Clean up any stale pair
|
||||
# Clean up a stale pair left by this scenario. The token makes the
|
||||
# name unique to this run, so a pair orphaned by an earlier run is
|
||||
# no longer reclaimed here — `ci-cleanup.sh` reaps those instead.
|
||||
_run_host(["ip", "link", "delete", host_a], image, check=False)
|
||||
|
||||
# Create veth pair on host
|
||||
@@ -253,7 +252,11 @@ def _run_host(cmd: list[str], image: str, check: bool = True) -> bool:
|
||||
timeout=30,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
log.debug(
|
||||
# Warning, not debug: the runner logs at INFO unless asked for
|
||||
# -v, so at debug this never reached runner.log and the callers
|
||||
# below report only that a pair could not be created. The
|
||||
# check=False deletes are expected to fail and stay silent.
|
||||
log.warning(
|
||||
"ip cmd failed: %s -> %s",
|
||||
" ".join(cmd),
|
||||
result.stderr.strip(),
|
||||
|
||||
+201
-68
@@ -11,19 +11,30 @@
|
||||
# unreliable on GitHub-hosted runners.
|
||||
# tor-directory — same; live Tor dependency.
|
||||
#
|
||||
# Granularity-only differences folded before comparison (same coverage,
|
||||
# different matrix shape — NOT a divergence):
|
||||
# deb-install — GitHub splits into per-distro legs (deb-install-debian12/
|
||||
# debian13/ubuntu22/ubuntu24/ubuntu26); local runs the same
|
||||
# distro set in one suite. Folded to "deb-install".
|
||||
# chaos-* — GitHub fans each chaos scenario into its own matrix leg
|
||||
# (type: chaos); local runs them all via the one CHAOS_SUITES
|
||||
# path. The individual scenario names also differ cosmetically
|
||||
# between runners (e.g. chaos-smoke-10 vs churn-mixed-10).
|
||||
# Folded to a single "chaos" token on both sides.
|
||||
# dns-resolver — single leg / single suite both sides; runs all scenarios.
|
||||
# What is compared, and at what granularity:
|
||||
# chaos — per scenario, plus its flags. GitHub fans each scenario
|
||||
# into its own matrix leg carrying `scenario:` (and
|
||||
# optionally `chaos_flags:`); local lists the same scenarios
|
||||
# in CHAOS_SUITES as "display scenario flags". The `suite:`
|
||||
# names differ cosmetically between runners and are ignored
|
||||
# — `scenario:` is the identity.
|
||||
# deb-install — per distro. GitHub splits into per-distro legs carrying
|
||||
# `scenario:`; local runs the same distro set in one suite,
|
||||
# enumerated by ALL_SCENARIOS in deb-install/test.sh.
|
||||
# everything else — per suite name.
|
||||
#
|
||||
# Exit 0 = parity clean. Exit 1 = unexpected divergence (suite names printed).
|
||||
# dns-resolver is the one leg still compared at leg granularity rather than
|
||||
# per scenario: it is a single leg and a single suite on both sides, and it
|
||||
# runs all of its scenarios internally. Its scenario list is NOT cross-checked.
|
||||
#
|
||||
# The local suite set is discovered by sweeping ci-local.sh for *_SUITES arrays
|
||||
# rather than from a hardcoded list of variable names, and every run_suite
|
||||
# dispatch arm is then checked to have a backing array — a suite dispatched
|
||||
# without one is invisible to a name-list sweep, which is how a real divergence
|
||||
# went unnoticed.
|
||||
#
|
||||
# Exit 0 = parity clean. Exit 1 = unexpected divergence. Exit 2 = the guard
|
||||
# could not run (missing file or missing dependency); never treated as a pass.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -uo pipefail
|
||||
|
||||
@@ -32,113 +43,235 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
CI_LOCAL="$SCRIPT_DIR/ci-local.sh"
|
||||
CI_YML="$PROJECT_ROOT/.github/workflows/ci.yml"
|
||||
DEB_TEST="$SCRIPT_DIR/deb-install/test.sh"
|
||||
|
||||
# Deliberate local-only allowlist (suites intentionally absent from GitHub).
|
||||
ALLOWLIST="tor-socks5 tor-directory"
|
||||
|
||||
for f in "$CI_LOCAL" "$CI_YML"; do
|
||||
for f in "$CI_LOCAL" "$CI_YML" "$DEB_TEST"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
echo "check-ci-parity: missing file: $f" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
# Extract and normalize both suite sets in Python (robust YAML parse of the
|
||||
# matrix; regex extraction of the bash suite arrays). Folding rules above are
|
||||
# applied identically to both sides so only genuine divergence surfaces.
|
||||
python3 - "$CI_LOCAL" "$CI_YML" "$ALLOWLIST" <<'PY'
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "check-ci-parity: python3 not found; cannot verify CI parity" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! python3 -c "import yaml" >/dev/null 2>&1; then
|
||||
echo "check-ci-parity: python3 module 'yaml' not found; cannot verify CI parity" >&2
|
||||
echo "check-ci-parity: install it with 'pip3 install pyyaml'" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
python3 - "$CI_LOCAL" "$CI_YML" "$DEB_TEST" "$ALLOWLIST" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
ci_local_path, ci_yml_path, allowlist_raw = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
import yaml
|
||||
|
||||
ci_local_path, ci_yml_path, deb_test_path, allowlist_raw = sys.argv[1:5]
|
||||
allowlist = set(allowlist_raw.split())
|
||||
|
||||
|
||||
def fold(name):
|
||||
"""Collapse granularity-only matrix shape into canonical suite identity."""
|
||||
if name.startswith("chaos-") or name == "chaos":
|
||||
return "chaos"
|
||||
if name.startswith("deb-install"):
|
||||
return "deb-install"
|
||||
return name
|
||||
|
||||
|
||||
# ── Local: parse the suite arrays from ci-local.sh ───────────────────────────
|
||||
with open(ci_local_path, encoding="utf-8") as fh:
|
||||
local_src = fh.read()
|
||||
with open(deb_test_path, encoding="utf-8") as fh:
|
||||
deb_src = fh.read()
|
||||
|
||||
|
||||
def bash_array(var):
|
||||
def bash_array_entries(var):
|
||||
"""Full entries of a bash array, in order, quotes stripped."""
|
||||
m = re.search(rf"^{var}=\((.*?)\)", local_src, re.MULTILINE | re.DOTALL)
|
||||
if not m:
|
||||
return []
|
||||
body = m.group(1)
|
||||
# Quoted entries (chaos uses "display scenario flags"): first token is name.
|
||||
quoted = re.findall(r'"([^"]*)"', body)
|
||||
if quoted:
|
||||
return [entry.split()[0] for entry in quoted if entry.strip()]
|
||||
return [e.strip() for e in quoted if e.strip()]
|
||||
return [tok for tok in body.split() if tok.strip()]
|
||||
|
||||
|
||||
def discovered_arrays():
|
||||
"""Every *_SUITES array in ci-local.sh, by name.
|
||||
|
||||
Swept rather than hardcoded: a suite whose array is not on a fixed list
|
||||
would otherwise be invisible to this guard.
|
||||
"""
|
||||
return {
|
||||
name + "_SUITES": bash_array_entries(name + "_SUITES")
|
||||
for name in re.findall(r"^([A-Z_]+)_SUITES=\(", local_src, re.MULTILINE)
|
||||
}
|
||||
|
||||
|
||||
arrays = discovered_arrays()
|
||||
|
||||
# ── Local side ───────────────────────────────────────────────────────────────
|
||||
# Chaos: "display scenario flags" — compare the scenario and its flags.
|
||||
local_chaos = {}
|
||||
for entry in arrays.get("CHAOS_SUITES", []):
|
||||
parts = entry.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
local_chaos[parts[1]] = " ".join(parts[2:])
|
||||
|
||||
# deb-install: one local suite that runs the distro set enumerated in its script.
|
||||
m = re.search(r'^ALL_SCENARIOS="([^"]*)"', deb_src, re.MULTILINE)
|
||||
local_deb = set(m.group(1).split()) if m else set()
|
||||
|
||||
# Everything else: suite names, with NAT stored bare and prefixed at use.
|
||||
local = set()
|
||||
# Static, rekey, gateway, sidecar, acl, firewall, nostr, stun, dns, deb.
|
||||
for var in ("STATIC_SUITES", "REKEY_SUITES", "ADMISSION_SUITES",
|
||||
"GATEWAY_SUITES", "SIDECAR_SUITES", "ACL_SUITES",
|
||||
"FIREWALL_SUITES", "NOSTR_RELAY_SUITES", "STUN_FAULTS_SUITES",
|
||||
"DNS_RESOLVER_SUITES", "DEB_INSTALL_SUITES"):
|
||||
local.update(bash_array(var))
|
||||
# Chaos display names → fold to "chaos".
|
||||
for _ in bash_array("CHAOS_SUITES"):
|
||||
local.add("chaos")
|
||||
# NAT scenarios are stored bare (cone/symmetric/lan) and prefixed nat- at use.
|
||||
for scen in bash_array("NAT_SUITES"):
|
||||
local.add(f"nat-{scen}")
|
||||
# TOR_SUITES is the deliberate local-only set — excluded from the default path.
|
||||
|
||||
local = {fold(n) for n in local}
|
||||
|
||||
# ── GitHub: parse the integration matrix suite: values from ci.yml ───────────
|
||||
import yaml # noqa: E402
|
||||
for name, entries in arrays.items():
|
||||
if name in ("CHAOS_SUITES", "DEB_INSTALL_SUITES"):
|
||||
continue
|
||||
names = [e.split()[0] for e in entries]
|
||||
if name == "NAT_SUITES":
|
||||
local |= {f"nat-{n}" for n in names}
|
||||
else:
|
||||
local |= set(names)
|
||||
|
||||
# ── GitHub side ──────────────────────────────────────────────────────────────
|
||||
with open(ci_yml_path, encoding="utf-8") as fh:
|
||||
doc = yaml.safe_load(fh)
|
||||
|
||||
include = doc["jobs"]["integration"]["strategy"]["matrix"]["include"]
|
||||
github = set()
|
||||
github_chaos, github_deb, github = {}, set(), set()
|
||||
malformed = []
|
||||
for leg in include:
|
||||
if "suite" not in leg:
|
||||
if "suite" not in leg and "scenario" not in leg:
|
||||
continue
|
||||
# Chaos legs carry inconsistent suite: names (chaos-smoke-10 vs
|
||||
# churn-mixed-10) but a uniform type: chaos — fold via type, not name.
|
||||
if str(leg.get("type", "")) == "chaos":
|
||||
github.add("chaos")
|
||||
kind = str(leg.get("type", ""))
|
||||
if kind in ("chaos", "deb-install"):
|
||||
# scenario: is the identity for these; suite: is cosmetic.
|
||||
if "scenario" not in leg:
|
||||
malformed.append(f"{leg.get('suite', '(unnamed leg)')} has type "
|
||||
f"{kind} but no scenario:")
|
||||
continue
|
||||
if kind == "chaos":
|
||||
github_chaos[str(leg["scenario"])] = str(leg.get("chaos_flags", ""))
|
||||
else:
|
||||
github_deb.add(str(leg["scenario"]))
|
||||
elif "suite" in leg:
|
||||
github.add(str(leg["suite"]))
|
||||
else:
|
||||
github.add(fold(str(leg["suite"])))
|
||||
malformed.append(f"leg with scenario {leg['scenario']} has no suite: "
|
||||
f"and no chaos/deb-install type")
|
||||
|
||||
# ── Diff (subtract allowlist from local before comparison) ───────────────────
|
||||
# ── Dispatch cross-check: every run_suite arm needs a backing array ──────────
|
||||
# A suite dispatched without an array is invisible to the sweep above, so the
|
||||
# guard would report it as GitHub-only forever without ever naming the cause.
|
||||
body = re.search(r"^run_suite\(\).*?^\}", local_src, re.MULTILINE | re.DOTALL)
|
||||
dispatch_uncovered = []
|
||||
if body is None:
|
||||
print("check-ci-parity: could not locate run_suite() in ci-local.sh", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Arms sit at one fixed indentation inside the case block. Pin to it, taken from
|
||||
# the first arm rather than assumed, so a body line that happens to end in ')'
|
||||
# cannot be read as an arm.
|
||||
arm_re = re.compile(r"^([ \t]+)['\"]?([a-z0-9|*_.-]+)['\"]?\)", re.MULTILINE)
|
||||
first = arm_re.search(body.group(0))
|
||||
if first is None:
|
||||
print("check-ci-parity: no dispatch arms found in run_suite()", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
indent = first.group(1)
|
||||
# An arm-shaped line at a different indent is not skipped silently: it would
|
||||
# make this check quietly stop covering a suite, which is the failure mode the
|
||||
# check exists to prevent.
|
||||
odd_arms = [
|
||||
m.group(2) for m in arm_re.finditer(body.group(0)) if m.group(1) != indent
|
||||
]
|
||||
if odd_arms:
|
||||
print("check-ci-parity: run_suite has arm-shaped lines at an unexpected "
|
||||
f"indent, so the dispatch check cannot be trusted: {', '.join(odd_arms)}",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
known = (set(local) | set(local_chaos) | local_deb
|
||||
| {e.split()[0] for e in arrays.get("DEB_INSTALL_SUITES", [])})
|
||||
for m in arm_re.finditer(body.group(0)):
|
||||
if m.group(1) != indent:
|
||||
continue
|
||||
for arm in m.group(2).split("|"):
|
||||
if arm == "*":
|
||||
continue # the unknown-suite error arm, not a suite
|
||||
if arm == "chaos-*":
|
||||
# Dispatches any chaos-<name> through a fallback, so its vocabulary
|
||||
# is unbounded; the chaos scenario comparison covers it instead.
|
||||
continue
|
||||
if arm not in known:
|
||||
dispatch_uncovered.append(arm)
|
||||
|
||||
# ── Diff ─────────────────────────────────────────────────────────────────────
|
||||
local_cmp = {n for n in local if n not in allowlist}
|
||||
|
||||
local_only = sorted(local_cmp - github)
|
||||
github_only = sorted(github - local_cmp)
|
||||
chaos_local_only = sorted(set(local_chaos) - set(github_chaos))
|
||||
chaos_github_only = sorted(set(github_chaos) - set(local_chaos))
|
||||
chaos_flag_drift = sorted(
|
||||
(s, local_chaos[s], github_chaos[s])
|
||||
for s in set(local_chaos) & set(github_chaos)
|
||||
if local_chaos[s] != github_chaos[s]
|
||||
)
|
||||
deb_local_only = sorted(local_deb - github_deb)
|
||||
deb_github_only = sorted(github_deb - local_deb)
|
||||
|
||||
if local_only or github_only:
|
||||
print("CI parity FAILED: integration suite sets diverge.\n")
|
||||
problems = (local_only or github_only or chaos_local_only or chaos_github_only
|
||||
or chaos_flag_drift or deb_local_only or deb_github_only
|
||||
or dispatch_uncovered or malformed)
|
||||
|
||||
if problems:
|
||||
print("CI parity FAILED: the two runners do not cover the same work.\n")
|
||||
if local_only:
|
||||
print(" Local-only (in ci-local.sh, missing from ci.yml, "
|
||||
"not in deliberate allowlist):")
|
||||
print(" Suites local-only (in ci-local.sh, missing from ci.yml, "
|
||||
"not in the deliberate allowlist):")
|
||||
for n in local_only:
|
||||
print(f" - {n}")
|
||||
if github_only:
|
||||
print(" GitHub-only (in ci.yml, missing from local default path):")
|
||||
print(" Suites GitHub-only (in ci.yml, missing from the local default path):")
|
||||
for n in github_only:
|
||||
print(f" - {n}")
|
||||
print("\n Resolve by adding the suite to the other runner, or by adding "
|
||||
"it\n to the deliberate local-only allowlist in "
|
||||
"check-ci-parity.sh with a\n stated reason.")
|
||||
if chaos_local_only:
|
||||
print(" Chaos scenarios local-only:")
|
||||
for n in chaos_local_only:
|
||||
print(f" - {n}")
|
||||
if chaos_github_only:
|
||||
print(" Chaos scenarios GitHub-only:")
|
||||
for n in chaos_github_only:
|
||||
print(f" - {n}")
|
||||
if chaos_flag_drift:
|
||||
print(" Chaos scenarios whose flags differ between runners:")
|
||||
for name, lflags, gflags in chaos_flag_drift:
|
||||
print(f" - {name}: local '{lflags}' vs GitHub '{gflags}'")
|
||||
if deb_local_only:
|
||||
print(" deb-install distros local-only:")
|
||||
for n in deb_local_only:
|
||||
print(f" - {n}")
|
||||
if deb_github_only:
|
||||
print(" deb-install distros GitHub-only:")
|
||||
for n in deb_github_only:
|
||||
print(f" - {n}")
|
||||
if malformed:
|
||||
print(" Matrix legs this guard cannot identify:")
|
||||
for n in malformed:
|
||||
print(f" - {n}")
|
||||
if dispatch_uncovered:
|
||||
print(" run_suite dispatches these with no backing *_SUITES array, so "
|
||||
"this guard\n cannot see them in the local set:")
|
||||
for n in dispatch_uncovered:
|
||||
print(f" - {n}")
|
||||
print("\n Resolve by adding the suite to the other runner, by giving a "
|
||||
"dispatchable\n suite a *_SUITES array, or by adding it to the "
|
||||
"deliberate local-only\n allowlist in check-ci-parity.sh with a "
|
||||
"stated reason.")
|
||||
sys.exit(1)
|
||||
|
||||
print("CI parity OK: integration suite sets match "
|
||||
total = len(github) + len(github_chaos) + len(github_deb)
|
||||
print("CI parity OK: both runners cover the same work "
|
||||
"(allowlist: " + ", ".join(sorted(allowlist)) + ").")
|
||||
print(f" {len(github)} canonical suites compared on each side.")
|
||||
print(f" {len(github)} suites, {len(github_chaos)} chaos scenarios "
|
||||
f"(flags compared), {len(github_deb)} deb-install distros "
|
||||
f"— {total} legs on each side.")
|
||||
sys.exit(0)
|
||||
PY
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
# ── Test-image scoping guard ────────────────────────────────────────────────
|
||||
# A local CI run builds fips-test:<run-id> and hands it to every suite through
|
||||
# FIPS_TEST_IMAGE / FIPS_TEST_APP_IMAGE. It does NOT write fips-test:latest,
|
||||
# deliberately: while a bridge back to that shared mutable name existed, a
|
||||
# consumer that named it directly kept working while resolving whichever
|
||||
# concurrent run wrote the tag last, and the verdict was then recorded against
|
||||
# a commit whose binaries had not run. Nothing in the harness compares a
|
||||
# running container's binary against the commit under test, so that failure is
|
||||
# silent and leaves no artifact.
|
||||
#
|
||||
# The bridge is gone, so a consumer that names the shared tag now fails loudly
|
||||
# at run time. This guard is the static half: it stops one being reintroduced,
|
||||
# because the reintroduction is invisible on any host where a hand build has
|
||||
# left an fips-test:latest lying around.
|
||||
#
|
||||
# What counts as a violation: a reference to the shared tag that is neither a
|
||||
# comment, nor a documented default of the ${FIPS_TEST_IMAGE:-...} form, nor in
|
||||
# a file on the allowlist below.
|
||||
#
|
||||
# Exit 0 = clean. Exit 1 = an unexpected reference. Exit 2 = the guard could
|
||||
# not run; never treated as a pass.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Files permitted to name the shared tag outright, each for a stated reason.
|
||||
# Every one of these is a hand-run path: none is reachable from ci-local.sh
|
||||
# with FIPS_TEST_IMAGE set.
|
||||
#
|
||||
# scripts/build.sh the developer build; :latest IS its product
|
||||
# sidecar/scripts/test-sidecar.sh hand build, behind its --skip-build guard,
|
||||
# which ci-local always passes
|
||||
# static/scripts/iperf-compare-refs.sh hand-run A/B against two refs
|
||||
# ci-cleanup.sh last-resort name for the image ip(8) runs in,
|
||||
# after the caller's and the run's have failed
|
||||
# check-image-scoping.sh this guard, which has to name what it looks
|
||||
# for; it resolves no image
|
||||
ALLOWED=(
|
||||
"scripts/build.sh"
|
||||
"sidecar/scripts/test-sidecar.sh"
|
||||
"static/scripts/iperf-compare-refs.sh"
|
||||
"ci-cleanup.sh"
|
||||
"check-image-scoping.sh"
|
||||
)
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "check-image-scoping: git not available, cannot sweep" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -d "$SCRIPT_DIR" ]]; then
|
||||
echo "check-image-scoping: $SCRIPT_DIR missing" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Tracked files only, and no documentation: prose naming the tag is describing
|
||||
# it, not resolving it.
|
||||
mapfile -t files < <(git -C "$SCRIPT_DIR/.." ls-files -- testing/ | grep -vE '\.md$')
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
echo "check-image-scoping: no tracked files under testing/, refusing to pass" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
violations=0
|
||||
for f in "${files[@]}"; do
|
||||
rel="${f#testing/}"
|
||||
skip=0
|
||||
for a in "${ALLOWED[@]}"; do
|
||||
[[ "$rel" == "$a" ]] && skip=1 && break
|
||||
done
|
||||
[[ $skip -eq 1 ]] && continue
|
||||
[[ -f "$SCRIPT_DIR/../$f" ]] || continue
|
||||
|
||||
while IFS= read -r hit; do
|
||||
n="${hit%%:*}"
|
||||
text="${hit#*:}"
|
||||
# A comment line is describing the tag, not resolving it. Shell, python
|
||||
# and yaml all use #; nothing under testing/ uses // for comments.
|
||||
[[ "$text" =~ ^[[:space:]]*# ]] && continue
|
||||
# The documented indirection: the shared tag as a FALLBACK, which is
|
||||
# what a bare hand run is supposed to get.
|
||||
[[ "$text" == *'${FIPS_TEST_IMAGE:-fips-test:latest}'* ]] && continue
|
||||
[[ "$text" == *'${FIPS_TEST_APP_IMAGE:-fips-test-app:latest}'* ]] && continue
|
||||
[[ "$text" == *'os.environ.get("FIPS_TEST_IMAGE", "fips-test:latest")'* ]] && continue
|
||||
echo "FAIL $f:$n names the shared test image directly:"
|
||||
echo " $text"
|
||||
violations=$((violations + 1))
|
||||
done < <(grep -n 'fips-test:latest\|fips-test-app:latest' "$SCRIPT_DIR/../$f" 2>/dev/null)
|
||||
done
|
||||
|
||||
if [[ $violations -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "check-image-scoping: $violations reference(s) to the shared mutable test image."
|
||||
echo "Read FIPS_TEST_IMAGE (default \${FIPS_TEST_IMAGE:-fips-test:latest}) instead."
|
||||
echo "A run that resolves the shared tag can execute a concurrent run's binaries"
|
||||
echo "and record the verdict against this commit."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "check-image-scoping: no unscoped references to the shared test image"
|
||||
exit 0
|
||||
Executable
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify that every daemon log string a test matches on still exists in src/.
|
||||
|
||||
A test that greps the daemon's log for a message the daemon no longer emits
|
||||
does not fail — it quietly stops observing anything, and an assertion built on
|
||||
it (especially one expecting a count of zero) passes for the wrong reason.
|
||||
That class has produced several findings, so it is checked mechanically here
|
||||
rather than re-discovered by reading.
|
||||
|
||||
The check extracts the string literals that test code matches against daemon
|
||||
log lines, reduces each to its longest literal run (patterns carry regex
|
||||
syntax), and requires that run to appear somewhere under src/. Anything that
|
||||
legitimately does not originate in src/ — runtime panic text, tracing's own
|
||||
level tokens — must be named in ALLOWED with a reason, so the exceptions are
|
||||
reviewable instead of invisible.
|
||||
|
||||
Usage: testing/check-log-strings.py [--verbose]
|
||||
Exit: 0 all matched strings are live, 1 otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SRC = REPO / "src"
|
||||
TESTING = REPO / "testing"
|
||||
|
||||
# Strings matched against log text that do not come from src/, each with the
|
||||
# reason it is legitimately absent. Anything here is exempt from the src/
|
||||
# existence requirement — keep the list short and the reasons specific.
|
||||
ALLOWED = {
|
||||
"panicked": "emitted by the Rust runtime's panic hook, not by our code",
|
||||
"PANIC": "panic-adjacent marker matched defensively alongside 'panicked'",
|
||||
"ERROR": "tracing's own level token, produced by the subscriber's formatter",
|
||||
" ERROR ": "tracing's own level token, produced by the subscriber's formatter",
|
||||
" WARN ": "tracing's own level token, produced by the subscriber's formatter",
|
||||
"Bootstrapped 100%": (
|
||||
"read from the tor-daemon container's log, not the fips daemon's — "
|
||||
"Tor's own bootstrap progress line"
|
||||
),
|
||||
"panicked at": "the Rust runtime's panic hook writes this, not our code",
|
||||
"RUST_BACKTRACE": "the runtime's backtrace hint, printed alongside a panic",
|
||||
"fatal runtime error": "emitted by the Rust runtime on an abort",
|
||||
}
|
||||
|
||||
# Shell helpers whose first argument is a pattern matched against daemon logs.
|
||||
SHELL_HELPERS = ("count_log_pattern", "assert_zero_count")
|
||||
|
||||
# A literal run shorter than this is too weak to search for meaningfully.
|
||||
MIN_ANCHOR = 8
|
||||
|
||||
|
||||
META = set("[](){}?*+.^$")
|
||||
|
||||
|
||||
def literal_anchors(pattern: str) -> list[str]:
|
||||
"""Longest literal run of each alternation branch of a grep pattern.
|
||||
|
||||
Walks the pattern rather than substituting, because escaping has to be
|
||||
resolved in the same pass as the split: `\\.` is a literal dot and ends
|
||||
nothing, while a bare `.` is a wildcard and ends the run. Unescaping first
|
||||
and splitting after would conflate the two and treat `directory.mode` as
|
||||
though it were literal text.
|
||||
"""
|
||||
branches, current, i = [], [], 0
|
||||
while i < len(pattern):
|
||||
ch = pattern[i]
|
||||
if ch == "\\" and i + 1 < len(pattern):
|
||||
nxt = pattern[i + 1]
|
||||
if nxt == "|": # BRE alternation
|
||||
branches.append("".join(current))
|
||||
current = []
|
||||
elif nxt in "wsdbWSDB": # a character class, not a literal
|
||||
current.append("\0")
|
||||
else:
|
||||
current.append(nxt)
|
||||
i += 2
|
||||
continue
|
||||
if ch == "|": # ERE alternation
|
||||
branches.append("".join(current))
|
||||
current = []
|
||||
elif ch in META:
|
||||
current.append("\0")
|
||||
else:
|
||||
current.append(ch)
|
||||
i += 1
|
||||
branches.append("".join(current))
|
||||
|
||||
anchors = []
|
||||
for branch in branches:
|
||||
runs = [r.strip() for r in branch.split("\0")]
|
||||
runs = [r for r in runs if r]
|
||||
if runs:
|
||||
anchors.append(max(runs, key=len))
|
||||
return anchors
|
||||
|
||||
|
||||
def python_candidates() -> list[tuple[Path, int, str]]:
|
||||
"""`"literal" in line` tests in testing/ python."""
|
||||
found = []
|
||||
for path in sorted(TESTING.rglob("*.py")):
|
||||
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
for m in re.finditer(r'"([^"]+)"\s+in\s+line\b', line):
|
||||
found.append((path, n, m.group(1)))
|
||||
return found
|
||||
|
||||
|
||||
def shell_candidates() -> list[tuple[Path, int, str]]:
|
||||
"""Literal first argument to a log-matching shell helper."""
|
||||
helpers = "|".join(SHELL_HELPERS)
|
||||
# Quoted literal only; a variable argument is resolved elsewhere and is
|
||||
# reported as unscannable rather than silently skipped.
|
||||
literal = re.compile(rf"\b(?:{helpers})\s+(\"[^\"$]+\"|'[^']+')")
|
||||
variable = re.compile(rf"\b(?:{helpers})\s+[\"']?\$")
|
||||
found = []
|
||||
for path in sorted(TESTING.rglob("*.sh")):
|
||||
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
for m in literal.finditer(line):
|
||||
found.append((path, n, m.group(1)[1:-1]))
|
||||
if variable.search(line):
|
||||
found.append((path, n, None))
|
||||
return found
|
||||
|
||||
|
||||
def pattern_table_candidates() -> list[tuple[Path, int, str]]:
|
||||
"""Bash associative-array keys used as log patterns.
|
||||
|
||||
A suite that iterates a table of patterns into a log-matching helper hides
|
||||
every string behind a variable, so the helper rule above sees only
|
||||
`count_log_pattern "$pat"` and reports it unscannable. The keys are the
|
||||
patterns; read them where they are written.
|
||||
"""
|
||||
key = re.compile(r'^\s*\[\s*"([^"$]+)"\s*\]=')
|
||||
found = []
|
||||
for path in sorted(TESTING.rglob("*.sh")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if not any(h in text for h in SHELL_HELPERS):
|
||||
continue
|
||||
for n, line in enumerate(text.splitlines(), 1):
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
m = key.match(line)
|
||||
if m:
|
||||
found.append((path, n, m.group(1)))
|
||||
return found
|
||||
|
||||
|
||||
def grep_candidates() -> list[tuple[Path, int, str]]:
|
||||
"""Literal grep patterns whose input is daemon log text.
|
||||
|
||||
Scoped by what the grep READS, not by what the file mentions. A suite
|
||||
greps several unrelated sources — its own analyzer output, fipsctl JSON,
|
||||
Tor's log, ping output — and only the daemon's log has to correspond to a
|
||||
string in src/. Two shapes qualify: a grep piped directly from
|
||||
`docker logs`, and a grep fed a variable that was assigned from it.
|
||||
"""
|
||||
grep_lit = r"\bgrep\b[^|;]*?\s(\"[^\"$]+\"|'[^'$]+')"
|
||||
assign = re.compile(r"(\w+)=\"?\$\(\s*docker logs\b")
|
||||
found = []
|
||||
for path in sorted(TESTING.rglob("*.sh")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if "docker logs" not in text:
|
||||
continue
|
||||
log_vars = set(assign.findall(text))
|
||||
# A grep reading one of those variables, by herestring or by pipe.
|
||||
var_alt = "|".join(re.escape(v) for v in log_vars) or r"\0"
|
||||
reads_var = re.compile(rf"[\"']?\$\{{?(?:{var_alt})\b")
|
||||
for n, line in enumerate(text.splitlines(), 1):
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
if "docker logs" not in line and not reads_var.search(line):
|
||||
continue
|
||||
for m in re.finditer(grep_lit, line):
|
||||
found.append((path, n, m.group(1)[1:-1]))
|
||||
return found
|
||||
|
||||
|
||||
def main() -> int:
|
||||
verbose = "--verbose" in sys.argv
|
||||
src_text = "\n".join(
|
||||
p.read_text(encoding="utf-8", errors="replace")
|
||||
for p in SRC.rglob("*.rs")
|
||||
)
|
||||
|
||||
candidates = (
|
||||
python_candidates()
|
||||
+ shell_candidates()
|
||||
+ pattern_table_candidates()
|
||||
+ grep_candidates()
|
||||
)
|
||||
dead, checked, exempt, unscannable = [], 0, 0, 0
|
||||
|
||||
for path, lineno, raw in candidates:
|
||||
rel = path.relative_to(REPO)
|
||||
if raw is None:
|
||||
unscannable += 1
|
||||
if verbose:
|
||||
print(f" skip {rel}:{lineno}: pattern comes from a variable")
|
||||
continue
|
||||
if raw in ALLOWED:
|
||||
exempt += 1
|
||||
if verbose:
|
||||
print(f" allow {rel}:{lineno}: {raw!r} ({ALLOWED[raw]})")
|
||||
continue
|
||||
anchors = literal_anchors(raw)
|
||||
# An alternation may mix daemon strings with runtime ones, so the
|
||||
# allowlist applies per branch and not only to the whole pattern.
|
||||
if any(a in ALLOWED for a in anchors):
|
||||
exempt += 1
|
||||
if verbose:
|
||||
print(f" allow {rel}:{lineno}: {raw!r} (branch in ALLOWED)")
|
||||
anchors = [a for a in anchors if a not in ALLOWED]
|
||||
usable = [a for a in anchors if len(a) >= MIN_ANCHOR]
|
||||
if not usable:
|
||||
unscannable += 1
|
||||
if verbose:
|
||||
print(f" skip {rel}:{lineno}: {raw!r} has no literal run >= {MIN_ANCHOR}")
|
||||
continue
|
||||
checked += 1
|
||||
# Every alternation branch must be live: one dead branch is a matcher
|
||||
# that has silently narrowed.
|
||||
for anchor in usable:
|
||||
if anchor not in src_text:
|
||||
dead.append((rel, lineno, raw, anchor))
|
||||
elif verbose:
|
||||
print(f" ok {rel}:{lineno}: {anchor!r}")
|
||||
|
||||
print(
|
||||
f"log-string check: {checked} matched, {exempt} allowed, "
|
||||
f"{unscannable} unscannable, {len(dead)} dead"
|
||||
)
|
||||
if dead:
|
||||
print("\nStrings matched against daemon logs that src/ never emits:\n")
|
||||
for rel, lineno, raw, anchor in dead:
|
||||
print(f" {rel}:{lineno}")
|
||||
print(f" pattern: {raw!r}")
|
||||
print(f" missing: {anchor!r}")
|
||||
print(
|
||||
"\nEither correct the string to what the daemon emits, or add it to "
|
||||
"ALLOWED\nin this script with the reason it does not come from src/."
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find shell functions that end in a logging call and whose status a caller tests.
|
||||
|
||||
A bash function's exit status is the status of its last command. A function
|
||||
whose last statement is `echo`/`log`/`info`/... therefore returns 0 on every
|
||||
path that reaches the end, and any caller written as `if func`, `func || fail`
|
||||
or `func && ...` has a gate that cannot fire.
|
||||
|
||||
This is not hypothetical and not a style rule. Two instances were found in one
|
||||
day in this tree:
|
||||
|
||||
* `run_chaos` ended in `record`, whose own last statement is an `echo`, so
|
||||
every chaos row was unconditionally green from 2026-03-09.
|
||||
* `build_fips_for_e2e` ended in `log`, so a failed binary extraction left the
|
||||
previous run's cache in place and five end-to-end legs tested the previous
|
||||
commit's code and reported green — a green run certifying software that was
|
||||
never built.
|
||||
|
||||
Both were repaired individually. This exists so the next one is caught by a
|
||||
gate rather than by a reader.
|
||||
|
||||
WHAT IT ENFORCES, stated precisely, because it is a convention and not a bug
|
||||
hunt: a function whose exit status a caller tests must END WITH AN EXPLICIT
|
||||
`return`. It must not leave its success value to be whatever the last logging
|
||||
call happened to produce.
|
||||
|
||||
That is deliberately stricter than "has a bug". Every instance in the tree when
|
||||
this check was written was in fact benign -- each failure path already returned
|
||||
early, so the trailing `echo` reported a genuine success. The point is that
|
||||
reading the function is the only way to know that, and the next edit that adds
|
||||
an unguarded command before the final log turns a benign shape into the exact
|
||||
defect above with nothing to notice it. An explicit terminal `return` costs one
|
||||
line and makes the class unreachable.
|
||||
|
||||
WHAT IT DELIBERATELY DOES NOT FLAG: a function ending in a logging call whose
|
||||
status nobody consumes. That is idiomatic and harmless — a reporting helper is
|
||||
supposed to end by reporting. The defect needs both halves, and scoping on the
|
||||
call sites rather than on the definitions is what keeps the finding list short
|
||||
enough to stay read. Same reasoning as check-log-strings.py scoping on what a
|
||||
grep reads rather than on what its file mentions.
|
||||
|
||||
Exit codes:
|
||||
0 — no function has both the shape and a status-testing caller
|
||||
1 — at least one does
|
||||
2 — the checker could not run (bad tree, unreadable file)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TESTING = Path(__file__).resolve().parent
|
||||
|
||||
# Commands whose exit status is always 0 in practice and which exist to print.
|
||||
# `record` and `skip` are project helpers that end in `echo` themselves, so a
|
||||
# function ending in one of those inherits the same problem transitively.
|
||||
LOG_COMMANDS = {
|
||||
"echo", "printf", "log", "info", "warn", "warning", "note", "stage",
|
||||
"pass", "ok", "record", "skip", "report", "summary", "header",
|
||||
}
|
||||
|
||||
# Functions allowed to end in a logging call even though a caller tests them,
|
||||
# each with the reason. Keep this list short: a long one means the check has
|
||||
# been miscalibrated rather than satisfied.
|
||||
ALLOWED: dict[str, str] = {}
|
||||
|
||||
FUNC_RE = re.compile(r"^\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*\{")
|
||||
|
||||
|
||||
def function_bodies(lines: list[str]) -> list[tuple[str, int, list[str]]]:
|
||||
"""Yield (name, start_line, body_lines) for each top-level function.
|
||||
|
||||
Brace counting rather than a real parser. Good enough because the tree's
|
||||
functions are conventionally formatted, and a miscount fails safe: it
|
||||
produces a body that ends somewhere odd, which at worst misreads the last
|
||||
statement of one function rather than silently skipping every function.
|
||||
"""
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
m = FUNC_RE.match(lines[i])
|
||||
if not m:
|
||||
i += 1
|
||||
continue
|
||||
name = m.group(1)
|
||||
depth = lines[i].count("{") - lines[i].count("}")
|
||||
body_start = i
|
||||
j = i + 1
|
||||
body: list[str] = []
|
||||
while j < len(lines) and depth > 0:
|
||||
depth += lines[j].count("{") - lines[j].count("}")
|
||||
if depth > 0:
|
||||
body.append(lines[j])
|
||||
j += 1
|
||||
out.append((name, body_start + 1, body))
|
||||
i = j
|
||||
return out
|
||||
|
||||
|
||||
def last_statement(body: list[str]) -> str | None:
|
||||
"""The last executable line of a function body, or None if there is none."""
|
||||
for line in reversed(body):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
return stripped
|
||||
return None
|
||||
|
||||
|
||||
def leading_command(statement: str) -> str | None:
|
||||
"""The command word a statement starts with, ignoring shell decoration."""
|
||||
s = statement.strip()
|
||||
# A trailing-log defect cannot hide behind these, and treating them as the
|
||||
# command word would miss the real one.
|
||||
for prefix in ("}", "fi", "done", "esac", "else", ";;"):
|
||||
if s == prefix or s.startswith(prefix + " "):
|
||||
return None
|
||||
s = s.lstrip("&|;( ")
|
||||
m = re.match(r"([A-Za-z_][A-Za-z0-9_\-]*)", s)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def status_tested(name: str, all_text: str, defining_file: Path) -> list[str]:
|
||||
"""Call sites that consume the function's exit status, as evidence strings."""
|
||||
patterns = [
|
||||
(rf"\bif\s+{re.escape(name)}\b", "if <fn>"),
|
||||
(rf"\bif\s+!\s+{re.escape(name)}\b", "if ! <fn>"),
|
||||
(rf"\bwhile\s+{re.escape(name)}\b", "while <fn>"),
|
||||
(rf"^\s*{re.escape(name)}\s+[^\n|&]*\|\|", "<fn> ||"),
|
||||
(rf"^\s*{re.escape(name)}\s*\|\|", "<fn> ||"),
|
||||
(rf"^\s*{re.escape(name)}\s+[^\n|&]*&&", "<fn> &&"),
|
||||
(rf"^\s*{re.escape(name)}\s*&&", "<fn> &&"),
|
||||
(rf"\bif\s+\S*\s*{re.escape(name)}\b.*;\s*then", "if ... <fn> ; then"),
|
||||
# Command substitution. Found 2026-07-23 while fixing a function this
|
||||
# check reported clean: `total=$(count_log_pattern "$p") || { ... }`
|
||||
# consumes the status, but the line begins with the variable, so none
|
||||
# of the patterns above match it. That is not an exotic form — it is
|
||||
# how a shell function returns a *value*, and it is the shape of the
|
||||
# `|| true`-swallowed-failure family this check exists to catch.
|
||||
(rf"=\$\(\s*{re.escape(name)}\b", "<var>=$(<fn>)"),
|
||||
(rf"\bif\s+!?\s*\S*=?\$\(\s*{re.escape(name)}\b", "if <var>=$(<fn>)"),
|
||||
(rf"\[\s+\"?\$\(\s*{re.escape(name)}\b", "[ $(<fn>) ]"),
|
||||
]
|
||||
hits = []
|
||||
for pat, label in patterns:
|
||||
if re.search(pat, all_text, re.M):
|
||||
hits.append(label)
|
||||
return sorted(set(hits))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sh_files = sorted(
|
||||
p for p in TESTING.rglob("*.sh")
|
||||
if "sim-results" not in p.parts and ".cache" not in p.parts
|
||||
)
|
||||
if not sh_files:
|
||||
print("trailing-log check: found no shell scripts to scan", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
corpus = {}
|
||||
for p in sh_files:
|
||||
try:
|
||||
corpus[p] = p.read_text(errors="replace")
|
||||
except OSError as e:
|
||||
print(f"trailing-log check: cannot read {p}: {e}", file=sys.stderr)
|
||||
return 2
|
||||
all_text = "\n".join(corpus.values())
|
||||
|
||||
findings = []
|
||||
scanned = 0
|
||||
shaped = 0
|
||||
for path, text in corpus.items():
|
||||
lines = text.splitlines()
|
||||
for name, lineno, body in function_bodies(lines):
|
||||
scanned += 1
|
||||
stmt = last_statement(body)
|
||||
if stmt is None:
|
||||
continue
|
||||
cmd = leading_command(stmt)
|
||||
if cmd is None or cmd not in LOG_COMMANDS:
|
||||
continue
|
||||
shaped += 1
|
||||
if name in ALLOWED:
|
||||
continue
|
||||
callers = status_tested(name, all_text, path)
|
||||
if callers:
|
||||
rel = path.relative_to(TESTING.parent)
|
||||
findings.append((rel, lineno, name, cmd, callers, stmt))
|
||||
|
||||
for rel, lineno, name, cmd, callers, stmt in sorted(findings):
|
||||
print(f"{rel}:{lineno}: {name}() has a caller that tests its status, "
|
||||
f"but ends in `{cmd}` rather than an explicit return")
|
||||
print(f" last statement: {stmt[:100]}")
|
||||
print(f" status consumed by: {', '.join(callers)}")
|
||||
print(f" fix: add an explicit `return 0` as the last statement. Its "
|
||||
f"success value is currently the trailing command's, which is 0 "
|
||||
f"whatever the function did.")
|
||||
|
||||
print(f"trailing-log check: {scanned} function(s) scanned, "
|
||||
f"{shaped} end in a logging call, {len(ALLOWED)} allowed, "
|
||||
f"{len(findings)} with a status-testing caller")
|
||||
return 1 if findings else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+228
-12
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Reap FIPS CI docker resources: containers, networks, volumes, images.
|
||||
# Reap FIPS CI resources: containers, networks, volumes, images, veth pairs.
|
||||
#
|
||||
# Force-removes everything created by ci-local.sh that is still around —
|
||||
# whether a run finished cleanly, was preempted (SIGTERM/SIGKILL), OOM-killed,
|
||||
@@ -15,42 +15,122 @@
|
||||
# with that prefix).
|
||||
#
|
||||
# The generic CI label is shared by every run on the host, so an unscoped label
|
||||
# sweep would tear down a CONCURRENT run's resources. Pass --run-id to narrow
|
||||
# the label sweep to one run; a run's own teardown always does. Without it the
|
||||
# label sweep stays broad, which is what a manual "reap everything" wants.
|
||||
# sweep would tear down a CONCURRENT run's resources. So would an unscoped
|
||||
# compose-project sweep, and reap_containers runs BOTH. Neither flag alone is
|
||||
# therefore enough to spare a concurrent run: --run-id narrows only the label
|
||||
# sweep, --project-prefix only the project sweep, and whichever is left broad
|
||||
# reaps everything by itself. This cost three concurrent runs on 2026-07-29,
|
||||
# via a caller that passed --run-id alone and reasonably believed that scoped
|
||||
# it. --run-id now implies the matching project prefix, and --project-prefix
|
||||
# without --run-id is refused, so the dangerous half-scoped states are no
|
||||
# longer reachable. Passing neither is still the broad "reap everything" form.
|
||||
#
|
||||
# Host-namespace veth interfaces are the one non-docker resource reaped here.
|
||||
# The chaos simulation creates each pair in the host namespace and then moves
|
||||
# the two ends into container namespaces, so a run killed in between leaves the
|
||||
# pair behind, and nothing else on the box removes it. Their names carry a
|
||||
# short token derived from the scenario's name suffix, so --veth-suffixes takes
|
||||
# the suffixes a run used and reaps only the tokens those could produce. Only a
|
||||
# reap with neither --run-id nor --veth-suffixes matches every simulation veth
|
||||
# name, in step with the broad label sweep. --project-prefix does not scope the
|
||||
# veth sweep at all: a host interface carries no compose project.
|
||||
#
|
||||
# These interfaces are also the one resource reaped WITHOUT regard to the CI
|
||||
# label — they cannot carry one — so an unscoped reap takes the host ends of a
|
||||
# bare `chaos.sh` simulation's veth pairs too, even though it leaves that
|
||||
# simulation's unlabelled containers running. Scope with --veth-suffixes (or
|
||||
# just don't reap) while a bare simulation is up.
|
||||
#
|
||||
# Usage:
|
||||
# ci-cleanup.sh Reap ALL fips-ci resources (any run)
|
||||
# ci-cleanup.sh --run-id ID Scope the reap to one run: narrows the
|
||||
# label sweep to ID and, unless
|
||||
# --project-prefix says otherwise,
|
||||
# narrows the project sweep to that run
|
||||
# too. Does NOT scope the host veth
|
||||
# sweep — see --veth-suffixes
|
||||
# ci-cleanup.sh --project-prefix P Restrict the compose-project sweep to
|
||||
# names starting with P (scopes the reap
|
||||
# to a single run)
|
||||
# ci-cleanup.sh --run-id ID Restrict the label sweep to the run
|
||||
# labelled ID (leaves other runs alone)
|
||||
# names starting with P. Requires
|
||||
# --run-id: on its own it leaves the
|
||||
# label sweep broad, which reaps every
|
||||
# concurrent run regardless of P
|
||||
# ci-cleanup.sh --label L Override the CI label (default above)
|
||||
# ci-cleanup.sh --images "a b,c" Also `docker rmi -f` these image tags
|
||||
# (space- or comma-separated)
|
||||
# ci-cleanup.sh --veth-suffixes "a b" Restrict the host veth sweep to the
|
||||
# name suffixes a single run used
|
||||
# (space- or comma-separated). Without
|
||||
# it the sweep reaches every simulation
|
||||
# veth name on the host, including a
|
||||
# bare `chaos.sh` run's live ones
|
||||
#
|
||||
# Safe to run when there is nothing to reap, and safe to run repeatedly.
|
||||
# Also reachable as `ci-local.sh --reap`.
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
LABEL="com.corganlabs.fips-ci=1"
|
||||
RUN_LABEL_KEY="com.corganlabs.fips-ci.run"
|
||||
PROJECT_PREFIX="fipsci_" # broad default: every CI run
|
||||
RUN_ID="" # broad default: every CI run
|
||||
IMAGES=""
|
||||
VETH_SUFFIXES="" # empty AND no --run-id: every simulation veth name
|
||||
# ip(8) runs inside this image, the same way the simulation creates the
|
||||
# interfaces, so the reap works wherever the simulation does. Any fips test
|
||||
# image will do; it is wanted only for its iproute2.
|
||||
#
|
||||
# Empty here and resolved after the argument loop, because the resolution has
|
||||
# to consider --veth-image. The old default of fips-test:latest is no longer
|
||||
# safe on its own: ci-local.sh does not write that tag, so on a host that has
|
||||
# only ever run the harness it need not exist at all, and the reap this script
|
||||
# advertises as the remedy for orphaned interfaces would be a permanent no-op.
|
||||
VETH_IMAGE=""
|
||||
PROJECT_PREFIX_GIVEN=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--label) LABEL="$2"; shift 2 ;;
|
||||
--project-prefix) PROJECT_PREFIX="$2"; shift 2 ;;
|
||||
--project-prefix) PROJECT_PREFIX="$2"; PROJECT_PREFIX_GIVEN=1; shift 2 ;;
|
||||
--run-id) RUN_ID="$2"; shift 2 ;;
|
||||
--images) IMAGES="$2"; shift 2 ;;
|
||||
--veth-suffixes) VETH_SUFFIXES="$2"; shift 2 ;;
|
||||
--veth-image) VETH_IMAGE="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,/^set /{ /^set /d; s/^# \?//; p }' "$0"; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# A reap scoped on one axis and broad on the other still destroys every
|
||||
# concurrent run, because both selectors run. Close both half-scoped states
|
||||
# here rather than trusting each caller to pass the pair.
|
||||
if [[ -n "$RUN_ID" && "$PROJECT_PREFIX_GIVEN" -eq 0 ]]; then
|
||||
# Every run's projects are named "<base prefix><run id>_<suite>", so the
|
||||
# run id is all that is needed. Derived from the base rather than a second
|
||||
# copy of the literal, so the two cannot drift.
|
||||
PROJECT_PREFIX="${PROJECT_PREFIX}${RUN_ID}"
|
||||
fi
|
||||
if [[ -z "$RUN_ID" && "$PROJECT_PREFIX_GIVEN" -eq 1 ]]; then
|
||||
echo "ci-cleanup.sh: --project-prefix requires --run-id." >&2
|
||||
echo " Without it the label sweep stays broad and reaps every concurrent" >&2
|
||||
echo " run regardless of the prefix. Pass both, or neither for a full reap." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Resolve the image to run ip(8) in: the caller's choice, then the run's own
|
||||
# image, then any surviving fips test image. That last fallback is what keeps
|
||||
# an unscoped `ci-local.sh --reap` working — it execs this script from inside
|
||||
# its own argument loop, before the run identity is exported, so it can pass
|
||||
# neither. The empty case is handled at the point of use, which already warns
|
||||
# and skips rather than failing the sweep.
|
||||
if [[ -z "$VETH_IMAGE" ]]; then
|
||||
VETH_IMAGE="${FIPS_TEST_IMAGE:-}"
|
||||
fi
|
||||
if [[ -z "$VETH_IMAGE" ]] && command -v docker >/dev/null 2>&1; then
|
||||
VETH_IMAGE="$(timeout 10 docker image ls --format '{{.Repository}}:{{.Tag}}' fips-test 2>/dev/null | head -n1)"
|
||||
fi
|
||||
[[ -z "$VETH_IMAGE" ]] && VETH_IMAGE="fips-test:latest"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
# No docker, nothing to reap.
|
||||
exit 0
|
||||
@@ -64,8 +144,9 @@ fi
|
||||
# never wedge a caller (ci-local's signal trap relies on this being bounded).
|
||||
TMO=30
|
||||
|
||||
# Selector for the label sweep. With --run-id it matches only the named run, so
|
||||
# a concurrent run's resources are left alone; without it, every CI run.
|
||||
# Selector for the label sweep. With --run-id it matches only the named run;
|
||||
# without it, every CI run. Note this is only half of what scopes a reap — the
|
||||
# compose-project sweep below is the other half, and both must be narrow.
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
SWEEP_LABEL="${RUN_LABEL_KEY}=${RUN_ID}"
|
||||
else
|
||||
@@ -107,6 +188,117 @@ reap_volumes() {
|
||||
| xargs -r timeout "$TMO" docker volume rm >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Every failure below leaves interfaces behind rather than widening the sweep,
|
||||
# so each one is silent by construction. Say so on stderr instead, or a reap
|
||||
# that reclaimed nothing looks exactly like a reap that had nothing to reclaim.
|
||||
veth_warn() { echo "ci-cleanup: host veth sweep skipped: $*" >&2; }
|
||||
|
||||
# One node id, exactly as chaos/sim/topology.py renders it (f"n{i+1:02d}"):
|
||||
# zero-padded to two digits, and never zero-padded beyond that. Spelling it out
|
||||
# keeps shapes the simulation cannot emit (vh01020a) out of the sweep.
|
||||
VETH_NODE_ID='(0[0-9]|[1-9][0-9]+)'
|
||||
|
||||
# Regex matching the host veth names to remove. With --veth-suffixes it covers
|
||||
# only the tokens those suffixes hash to, so a concurrent run's interfaces —
|
||||
# which carry a different token — cannot match. The token derivation is read
|
||||
# from the simulation itself rather than repeated here, so widening it cannot
|
||||
# leave this matching the old width. Empty output means "reap nothing".
|
||||
#
|
||||
# Two producers, two shapes. The chaos simulation makes vh{token}{NN}{MM}{a,b};
|
||||
# the NAT lab (nat/scripts/setup-topology.sh) makes vn{a,b}{token}{0,1}, using
|
||||
# the RUN-wide suffix rather than any chaos scenario's. Widening this regex is
|
||||
# only half the fix: the token set is derived separately below, so a suffix
|
||||
# list carrying no NAT suffix leaves the NAT half matching nothing while
|
||||
# looking correct. ci-local.sh's ci_teardown therefore appends the run-wide
|
||||
# suffix to --veth-suffixes.
|
||||
veth_pattern() {
|
||||
# vh{token}{NN}{MM}{a,b}: the token is 4 hex or wholly absent — never a
|
||||
# part of one — and the two node ids follow. Anchored and shaped this
|
||||
# tightly so the unscoped sweep cannot reach an interface the simulation
|
||||
# never made. Broad only for an unscoped reap: once --run-id names a
|
||||
# single run, no missing or empty suffix list may widen this back out to
|
||||
# every run.
|
||||
if [[ -z "$RUN_ID" && -z "$VETH_SUFFIXES" ]]; then
|
||||
printf '^vh([0-9a-f]{4})?%s%s[ab]$|^vn[ab]([0-9a-f]{4})?[01]$' \
|
||||
"$VETH_NODE_ID" "$VETH_NODE_ID"
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "$VETH_SUFFIXES" ]]; then
|
||||
veth_warn "--run-id given with no --veth-suffixes"
|
||||
return 0
|
||||
fi
|
||||
local sfx=() tok alt="" out rc
|
||||
read -ra sfx <<< "${VETH_SUFFIXES//,/ }"
|
||||
if [[ ${#sfx[@]} -eq 0 ]]; then
|
||||
veth_warn "--veth-suffixes is empty"
|
||||
return 0
|
||||
fi
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
veth_warn "python3 not found, cannot derive interface tokens"
|
||||
return 0
|
||||
fi
|
||||
# SCRIPT_DIR comes from $0, so invoking this script through a symlink or a
|
||||
# copy on $PATH points PYTHONPATH at a tree with no simulation in it. Say
|
||||
# which tree was tried when the derivation fails, rather than no-opping.
|
||||
out="$(PYTHONPATH="$SCRIPT_DIR/chaos" python3 -m sim.naming "${sfx[@]}" 2>&1)"
|
||||
rc=$?
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
veth_warn "token derivation failed under $SCRIPT_DIR/chaos: $out"
|
||||
return 0
|
||||
fi
|
||||
for tok in $out; do
|
||||
[[ "$tok" =~ ^[0-9a-f]{4}$ ]] && alt="${alt:+$alt|}$tok"
|
||||
done
|
||||
if [[ -z "$alt" ]]; then
|
||||
veth_warn "no interface tokens derived from: ${sfx[*]}"
|
||||
return 0
|
||||
fi
|
||||
printf '^vh(%s)%s%s[ab]$|^vn[ab](%s)[01]$' \
|
||||
"$alt" "$VETH_NODE_ID" "$VETH_NODE_ID" "$alt"
|
||||
}
|
||||
|
||||
# ip(8) in a privileged --net=host container, matching how the simulation
|
||||
# creates these interfaces (see chaos/sim/veth.py).
|
||||
veth_ip() {
|
||||
timeout "$TMO" docker run --rm --privileged --net=host \
|
||||
--entrypoint ip "$VETH_IMAGE" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
# The same, taking `ip -batch` commands on stdin so any number of deletes costs
|
||||
# one container. -force keeps ip going past an interface that vanished under us.
|
||||
veth_ip_batch() {
|
||||
timeout "$TMO" docker run --rm -i --privileged --net=host \
|
||||
--entrypoint ip "$VETH_IMAGE" -force -batch - >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
reap_veths() {
|
||||
local pattern
|
||||
pattern="$(veth_pattern)"
|
||||
[[ -z "$pattern" ]] && return 0
|
||||
# Without the image there is no way to run ip(8). Orphans can outlive it —
|
||||
# `docker image prune -a`, a build host that prunes between runs, or a run
|
||||
# whose per-run image was already reaped all remove it while interfaces are
|
||||
# still up — so this is a real skip, not "nothing was ever run here".
|
||||
if ! docker image inspect "$VETH_IMAGE" >/dev/null 2>&1; then
|
||||
veth_warn "image $VETH_IMAGE not present, cannot run ip(8)"
|
||||
return 0
|
||||
fi
|
||||
# Both ends of a pair match, but deleting either removes both, so collapse
|
||||
# each pair to one delete and issue the lot in a single container. The
|
||||
# whole script runs under a caller-imposed timeout, and a container spawn
|
||||
# per name would put a large orphan set at risk of exhausting it. `sort -u`
|
||||
# orders `...a` before `...b`, so the surviving end of a pair the
|
||||
# simulation was killed part-way through moving is still the one picked.
|
||||
local names
|
||||
names="$(veth_ip -o link show \
|
||||
| sed -E 's/^[0-9]+: ([^:@]+).*/\1/' \
|
||||
| grep -E "$pattern" | sort -u \
|
||||
| awk '{ k = substr($0, 1, length($0) - 1)
|
||||
if (!(k in seen)) { seen[k] = 1; print } }')"
|
||||
[[ -z "$names" ]] && return 0
|
||||
sed 's/^/link delete /' <<< "$names" | veth_ip_batch
|
||||
}
|
||||
|
||||
reap_images() {
|
||||
[[ -z "$IMAGES" ]] && return 0
|
||||
local imgs
|
||||
@@ -115,10 +307,34 @@ reap_images() {
|
||||
timeout "$TMO" docker rmi -f "${imgs[@]}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Order matters: containers reference networks/volumes, so drop them first.
|
||||
# Per-run build contexts left in the working tree. ci-local.sh removes its own
|
||||
# from the EXIT trap, but the CI worker sends SIGKILL after SIGTERM and a
|
||||
# SIGKILL runs no trap, so a preempted run can leave an 18 MB directory behind
|
||||
# with nothing else that would ever notice it.
|
||||
#
|
||||
# Scoped mode takes only the named run's. Broad mode cannot tell a live run's
|
||||
# context from an abandoned one by name, so it goes by age instead: a run lasts
|
||||
# well under an hour, and a day is far outside that.
|
||||
reap_build_contexts() {
|
||||
local dir
|
||||
if [[ -n "$RUN_ID" ]]; then
|
||||
dir="$SCRIPT_DIR/docker-$RUN_ID"
|
||||
[[ -d "$dir" ]] && rm -rf "$dir"
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r dir; do
|
||||
[[ -n "$dir" ]] && rm -rf "$dir"
|
||||
done < <(find "$SCRIPT_DIR" -maxdepth 1 -type d -name 'docker-*' -mtime +0 2>/dev/null)
|
||||
return 0
|
||||
}
|
||||
|
||||
# Order matters: containers reference networks/volumes, so drop them first, and
|
||||
# the veth sweep needs an image to run ip(8) in, so it precedes the image reap.
|
||||
reap_containers
|
||||
reap_networks
|
||||
reap_volumes
|
||||
reap_veths
|
||||
reap_images
|
||||
reap_build_contexts
|
||||
|
||||
exit 0
|
||||
|
||||
+526
-140
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run the CI pipeline locally: build, unit tests, integration tests.
|
||||
# Run the CI pipeline locally: CI parity check, build, unit tests,
|
||||
# integration tests.
|
||||
#
|
||||
# Usage: ./ci-local.sh [options]
|
||||
#
|
||||
@@ -14,26 +15,71 @@
|
||||
# --list List available integration suites
|
||||
# --check-parity Verify this suite set matches ci.yml's integration
|
||||
# matrix (see testing/check-ci-parity.sh), then exit
|
||||
# --reap Force-remove all leftover FIPS CI docker resources
|
||||
# --reap Force-remove all leftover FIPS CI resources
|
||||
# (containers/networks/volumes carrying the CI label or a
|
||||
# fipsci_ compose project), then exit. See ci-cleanup.sh.
|
||||
# fipsci_ compose project, plus every chaos-simulation
|
||||
# host veth interface), then exit. See ci-cleanup.sh.
|
||||
# Host interfaces carry no label, so they are matched by
|
||||
# name shape: this reaps a bare chaos.sh run's live
|
||||
# interfaces too, though not its containers. Don't run
|
||||
# it while a bare simulation is up.
|
||||
# -h, --help Show this help
|
||||
#
|
||||
# Integration suites (default coverage):
|
||||
# static-mesh, static-chain, rekey, rekey-accept-off,
|
||||
# rekey-outbound-only, gateway,
|
||||
# acl-allowlist, firewall, nat-cone, nat-symmetric, nat-lan,
|
||||
# nostr-publish-consume, stun-faults,
|
||||
# chaos-smoke-10, chaos-churn-mixed-10, chaos-ethernet-mesh,
|
||||
# chaos-ethernet-only, chaos-tcp-mesh, chaos-bottleneck-parent,
|
||||
# chaos-cost-avoidance, chaos-cost-reeval, chaos-cost-stability,
|
||||
# chaos-depth-vs-cost, chaos-mixed-technology, chaos-congestion-stress,
|
||||
# chaos-bloom-storm,
|
||||
# firewall, nat-cone, nat-symmetric,
|
||||
# nat-lan, nostr-publish-consume, stun-faults,
|
||||
# chaos-churn-mixed-10, chaos-ethernet-mesh,
|
||||
# chaos-ethernet-only, chaos-tcp-mesh, chaos-congestion-stress,
|
||||
# sidecar, dns-resolver, deb-install
|
||||
#
|
||||
# Opt-in (require --with-tor; depend on live Tor network):
|
||||
# tor-socks5, tor-directory
|
||||
#
|
||||
# Deliberately not run by either runner, with the reason for each. Recorded
|
||||
# here so that "not in the suite list" stops being indistinguishable from
|
||||
# "forgotten", which is what it was until 2026-07-23:
|
||||
# interop/ Manual. Driven by interop-stress.sh, which runs N
|
||||
# repetitions serially under netem and takes far longer
|
||||
# than a CI slot. No CI-sized entry point exists yet;
|
||||
# writing one is the work, not adding a line here.
|
||||
# boringtun/ Comparative benchmark against a non-FIPS implementation.
|
||||
# Measures rather than asserts, and needs a boringtun
|
||||
# build CI does not have.
|
||||
# iperf-test.sh Bandwidth measurement, no pass/fail. Driven manually by
|
||||
# iperf-compare-refs.sh.
|
||||
# ecn-ab-compare.sh Manual A/B comparison, renamed from ecn-ab-test.sh
|
||||
# because it asserts nothing. Making it gateable needs a
|
||||
# calibration corpus that does not exist; see its header.
|
||||
# mesh-lab/ Long-running multi-host lab, not a suite.
|
||||
# acl-allowlist/ Retired from CI 2026-07-24 as redundant, not unrunnable.
|
||||
# The ACL decision is exhaustively unit-tested per npub over
|
||||
# real loaded allow/deny files (src/node/acl.rs mod tests:
|
||||
# allow-wins, allowlist-miss, deny-only, deny-all,
|
||||
# allow_all-override), and the inbound/outbound handshake-
|
||||
# admission path is covered in-process over loopback
|
||||
# (src/node/tests/acl.rs). The Docker suite's only unique
|
||||
# coverage was real-UDP admission, exercised by every other
|
||||
# real-transport suite. Still runnable by hand:
|
||||
# bash testing/acl-allowlist/test.sh
|
||||
# admission-cap Retired from CI 2026-07-24 as redundant, not unrunnable.
|
||||
# The inbound max_peers early-gate in handle_msg1 is unit-
|
||||
# tested over a real UDP socket by
|
||||
# handle_msg1_silent_drops_at_cap_for_new_peer in
|
||||
# src/node/tests/unit.rs: it sends a Msg1 from a fresh
|
||||
# identity at saturation and polls the sender socket to
|
||||
# assert no Msg2 returns — the same wire-observable
|
||||
# discriminator the Docker tcpdump used — plus a sibling
|
||||
# test for the existing-peer bypass. Still runnable by hand:
|
||||
# bash testing/static/scripts/admission-cap-test.sh
|
||||
# ecn-ab-on, ecn-ab-off, maelstrom, maelstrom-sparse
|
||||
# Chaos scenarios excluded from CHAOS_SUITES. The two
|
||||
# ecn-ab ones are halves of the manual comparison above.
|
||||
# The two maelstrom ones are 600 s stress runs kept for
|
||||
# manual investigation. All four still load-check and
|
||||
# carry the default max_errors ceiling if run by hand.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — all stages passed
|
||||
# 1 — one or more stages failed
|
||||
@@ -48,19 +94,19 @@
|
||||
# (.github/workflows/ci.yml) MUST run the same integration suites, EXCEPT for
|
||||
# the deliberate local-only entries below. Adding a suite to one runner
|
||||
# without the other means "local green" and "GitHub green" stop being
|
||||
# equivalent. testing/check-ci-parity.sh enforces this and fails on drift.
|
||||
# equivalent. testing/check-ci-parity.sh enforces this and fails on drift; it
|
||||
# runs as the first stage of every local run, before the build.
|
||||
#
|
||||
# Deliberate local-only (NOT on the GitHub gate), with reason:
|
||||
# tor-socks5 — requires live Tor network; opt-in via --with-tor,
|
||||
# unreliable on GitHub-hosted runners.
|
||||
# tor-directory — same; live Tor dependency.
|
||||
#
|
||||
# Granularity-only differences (same coverage, different matrix shape —
|
||||
# NOT a divergence):
|
||||
# deb-install — local runs all distros sequentially in one suite; GitHub
|
||||
# splits into per-distro legs (debian12/debian13/ubuntu22/
|
||||
# ubuntu24/ubuntu26 — the same distro set).
|
||||
# dns-resolver — single suite both sides; runs all scenarios.
|
||||
# The two runners express the same work in different matrix shapes, and the
|
||||
# guard compares through that shape rather than around it: chaos legs are
|
||||
# compared per scenario (and per flag), deb-install legs per distro. The one
|
||||
# leg still compared at leg granularity is dns-resolver — a single suite on
|
||||
# both sides that runs all of its scenarios internally.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -uo pipefail
|
||||
|
||||
@@ -87,26 +133,55 @@ ONLY_SUITE=""
|
||||
# All integration suites matching ci.yml
|
||||
STATIC_SUITES=(static-mesh static-chain)
|
||||
REKEY_SUITES=(rekey rekey-accept-off rekey-outbound-only)
|
||||
ADMISSION_SUITES=(admission-cap)
|
||||
# Each entry: "display-name scenario [--flag value ...]"
|
||||
CHAOS_SUITES=(
|
||||
"smoke-10 smoke-10"
|
||||
"churn-mixed-10 churn-mixed --nodes 10 --duration 120"
|
||||
"ethernet-mesh ethernet-mesh"
|
||||
"ethernet-only ethernet-only"
|
||||
"tcp-mesh tcp-mesh"
|
||||
"bottleneck-parent bottleneck-parent"
|
||||
"cost-avoidance cost-avoidance"
|
||||
"cost-reeval cost-reeval"
|
||||
"cost-stability cost-stability"
|
||||
"depth-vs-cost depth-vs-cost"
|
||||
"mixed-technology mixed-technology"
|
||||
"congestion-stress congestion-stress"
|
||||
"bloom-storm bloom-storm"
|
||||
)
|
||||
# Scenarios retired 2026-07-23 because their subject was a pure decision the
|
||||
# Docker harness could not test reliably, now covered by sans-IO unit tests:
|
||||
# - cost-reeval, cost-avoidance, cost-stability, depth-vs-cost,
|
||||
# mixed-technology, bottleneck-parent: the evaluate_parent() cost/depth/
|
||||
# hysteresis decision (root election, MMP measurement lag and hold-down
|
||||
# timing all confounded the assertions) -> src/tree/tests.rs.
|
||||
# - congestion-drops (never landed): the SO_RXQ_OVFL detection edge. A
|
||||
# fresh daemon reader keeps up with container-speed traffic, so the
|
||||
# socket overflow could not be provoked deterministically; the FIPS
|
||||
# detection logic is now unit-tested in src/node/tests/unit.rs.
|
||||
# congestion-stress stays: it exercises the ECN/MMP congestion signals,
|
||||
# which do need the real shaped bottleneck queue.
|
||||
#
|
||||
# Retired 2026-07-24 for a different reason — redundant, not unreliable:
|
||||
# - smoke-10: a no-stressor 10-node tree-convergence sanity check (netem
|
||||
# off, no ping). The convergence logic it exercised is covered in-process,
|
||||
# faster and deterministically, by the loopback spanning-tree harness
|
||||
# (src/node/tests/spanning_tree.rs: ring/star/chain/100-node/disconnected)
|
||||
# plus end-to-end datagram delivery (src/node/tests/forwarding.rs). Real-
|
||||
# UDP convergence smoke still runs via static-mesh and the other chaos
|
||||
# scenarios' baseline assertions, so no Docker coverage is lost.
|
||||
#
|
||||
# Retired 2026-07-26 for a third reason — the guard's power was never
|
||||
# established, and unlike the two blocks above this one DOES lose coverage:
|
||||
# - bloom-storm: guarded the regression in 0caef2a (fixed by 4cdf382), where
|
||||
# a mid-chain tree update changing neither root nor depth leaked downstream
|
||||
# as a sustained bloom announce storm. It was never once run against that
|
||||
# regressed binary; its ceiling was inferred from a separate post-mortem
|
||||
# harness that no longer exists in the tree. On the only regressed
|
||||
# measurement that survives, the tail node's rate scales to ~7 sends per
|
||||
# 30s, well under the scenario's ceiling of 40 — so it is not established
|
||||
# that the assertion could ever have fired on its own bug class. The
|
||||
# ceiling is also uniform per node, calibrated against the flap target
|
||||
# (legitimately ~24) while the storm's actual signature is at the tail,
|
||||
# which sits at 0 on fixed code. COVERAGE GAP: nothing now exercises
|
||||
# downstream containment of a mid-chain ancestor swap. The scenario, its
|
||||
# README, the link_swap sim primitive and the mesh-lab dispatch all stay
|
||||
# on disk and it remains runnable by hand via
|
||||
# testing/chaos/scripts/chaos.sh bloom-storm.
|
||||
GATEWAY_SUITES=(gateway)
|
||||
SIDECAR_SUITES=(sidecar)
|
||||
ACL_SUITES=(acl-allowlist)
|
||||
FIREWALL_SUITES=(firewall)
|
||||
NAT_SUITES=(cone symmetric lan)
|
||||
NOSTR_RELAY_SUITES=(nostr-publish-consume)
|
||||
@@ -142,15 +217,9 @@ list_suites() {
|
||||
echo " Rekey:"
|
||||
for s in "${REKEY_SUITES[@]}"; do echo " $s"; done
|
||||
echo ""
|
||||
echo " Admission cap:"
|
||||
for s in "${ADMISSION_SUITES[@]}"; do echo " $s"; done
|
||||
echo ""
|
||||
echo " Gateway:"
|
||||
for s in "${GATEWAY_SUITES[@]}"; do echo " $s"; done
|
||||
echo ""
|
||||
echo " ACL allowlist:"
|
||||
for s in "${ACL_SUITES[@]}"; do echo " $s"; done
|
||||
echo ""
|
||||
echo " Firewall baseline:"
|
||||
for s in "${FIREWALL_SUITES[@]}"; do echo " $s"; done
|
||||
echo ""
|
||||
@@ -227,8 +296,9 @@ record() {
|
||||
#
|
||||
# This script may be preempted (a CI worker sends SIGTERM, waits ~30s, then
|
||||
# SIGKILL) so it can restart on a newer tip. To make that safe:
|
||||
# * every docker resource is namespaced to THIS run (compose project prefix
|
||||
# + per-run image tags) so a restart never collides with a dying run;
|
||||
# * every docker resource is namespaced to THIS run (compose project prefix,
|
||||
# per-run image tags, per-run build context) so a restart never collides
|
||||
# with a dying run, and neither does a concurrent one;
|
||||
# * a trap tears down everything this run created on signal/exit, bounded by
|
||||
# `timeout` so a stuck `down` cannot wedge the trap (SIGKILL is the backstop).
|
||||
|
||||
@@ -265,27 +335,51 @@ CI_LABEL_RUN="com.corganlabs.fips-ci.run=${CI_RUN_ID}"
|
||||
export FIPS_CI_RUN_ID="$CI_RUN_ID"
|
||||
export FIPS_TEST_IMAGE="$CI_IMAGE_TEST"
|
||||
export FIPS_TEST_APP_IMAGE="$CI_IMAGE_APP"
|
||||
# The build context is this run's too. testing/docker/ is a single directory in
|
||||
# the working tree, so two runs racing on its CONTENTS produce a per-run-tagged
|
||||
# image built from the other run's binaries — scoping the tag alone does not
|
||||
# close that. Absolute, and it has to be: compose interpolates this into
|
||||
# build.context but resolves a relative result against the compose FILE's
|
||||
# directory, not the working directory.
|
||||
CI_BUILD_CONTEXT="$SCRIPT_DIR/docker-${CI_RUN_ID}"
|
||||
export FIPS_BUILD_CONTEXT="$CI_BUILD_CONTEXT"
|
||||
# Docker container names are GLOBAL — a compose project name does not scope
|
||||
# them — so the suite compose files append this suffix to every explicit
|
||||
# container_name, and the suite scripts append it wherever they address a
|
||||
# container by name. Empty when unset, so a bare `docker compose up` outside
|
||||
# this harness still produces today's plain names.
|
||||
export FIPS_CI_NAME_SUFFIX="-${CI_RUN_ID}"
|
||||
# run_chaos narrows that export to one scenario, and bash scopes a `local`
|
||||
# dynamically: a function called while run_chaos is on the stack — the signal
|
||||
# trap, on the `--only chaos-*` path where run_chaos is not a subshell — reads
|
||||
# the narrowed value, not this one. Snapshot the run-wide value so the rebuild
|
||||
# below always sees it, and make it readonly so no scope can shadow it either.
|
||||
CI_RUN_NAME_SUFFIX="$FIPS_CI_NAME_SUFFIX"
|
||||
readonly CI_RUN_NAME_SUFFIX
|
||||
|
||||
# Per-suite compose project name: ${prefix}_<suite-or-compose-basename>. Keeps
|
||||
# today's intra-run distinctness (one project per compose file / chaos child)
|
||||
# while adding the cross-run prefix that scopes the reap.
|
||||
ci_project() { printf '%s_%s' "$CI_PROJECT_PREFIX" "$1"; }
|
||||
|
||||
# The name suffix one chaos scenario runs under. Its container names, its
|
||||
# generated-config directory and the token in its host veth names all derive
|
||||
# from this, so teardown recomputes it to know which interfaces are ours. Reads
|
||||
# the snapshot rather than the export for the reason given above.
|
||||
ci_chaos_suffix() { printf -- '-%s%s' "$1" "$CI_RUN_NAME_SUFFIX"; }
|
||||
|
||||
# PIDs of in-flight parallel chaos children (subshells). The trap signals these.
|
||||
CI_CHAOS_PIDS=()
|
||||
CI_CLEANED=0
|
||||
|
||||
# Best-effort, BOUNDED teardown of every docker resource THIS run may have
|
||||
# created. Idempotent (guarded), so the signal and EXIT paths don't double-run.
|
||||
# Takes the run's exit status; defaults to non-zero, which is the conservative
|
||||
# reading for the signal path.
|
||||
ci_teardown() {
|
||||
[[ $CI_CLEANED -eq 1 ]] && return 0
|
||||
CI_CLEANED=1
|
||||
local run_status="${1:-1}"
|
||||
|
||||
# 1. Propagate to parallel chaos children and reap them (bounded).
|
||||
if [[ ${#CI_CHAOS_PIDS[@]} -gt 0 ]]; then
|
||||
@@ -301,13 +395,57 @@ ci_teardown() {
|
||||
fi
|
||||
|
||||
# 2. Remove all compose projects + direct-run resources + per-run images
|
||||
# for this run. ci-cleanup.sh wraps each docker op in `timeout`; bound
|
||||
# the whole sweep too so the trap can never wedge.
|
||||
# for this run, plus any host veth interface a chaos scenario was
|
||||
# killed part-way through creating. Host interfaces carry no docker
|
||||
# label, so hand over the suffixes this run's scenarios used and let
|
||||
# the reap derive their names — a blind sweep would take a concurrent
|
||||
# run's live interfaces with it. ci-cleanup.sh wraps each docker op in
|
||||
# `timeout`; bound the whole sweep too so the trap can never wedge.
|
||||
# Its stdout is routine progress and goes nowhere, but stderr carries
|
||||
# only a skipped sweep or a bad option, and a sweep that quietly stops
|
||||
# reaping is how interfaces would accumulate unnoticed. Let it through.
|
||||
local _suffixes=() _entry
|
||||
for _entry in "${CHAOS_SUITES[@]}"; do
|
||||
_suffixes+=("$(ci_chaos_suffix "${_entry%% *}")")
|
||||
done
|
||||
# The NAT lab's host veths (vn{a,b}{token}{0,1}) carry the RUN-wide
|
||||
# suffix, not any chaos scenario's, so its token appears in no suffix
|
||||
# above and the reaper's NAT shape would match nothing at all. This is
|
||||
# the half of that fix that fails silently: widening the regex in
|
||||
# ci-cleanup.sh without this line looks correct and reaps nothing.
|
||||
_suffixes+=("$CI_RUN_NAME_SUFFIX")
|
||||
timeout 150 bash "$SCRIPT_DIR/ci-cleanup.sh" \
|
||||
--label "$CI_LABEL" \
|
||||
--run-id "$CI_RUN_ID" \
|
||||
--project-prefix "$CI_PROJECT_PREFIX" \
|
||||
--images "$CI_IMAGE_TEST $CI_IMAGE_APP" >/dev/null 2>&1 || true
|
||||
--images "$CI_IMAGE_TEST $CI_IMAGE_APP" \
|
||||
--veth-suffixes "${_suffixes[*]}" >/dev/null || true
|
||||
|
||||
# 3. The static, firewall and nat suites' generated configs are per-run (a
|
||||
# shared directory would let concurrent runs overwrite each other's node
|
||||
# configs), so they are this run's to remove. Only on a green run: after
|
||||
# a failure they are the evidence of what the failing nodes were actually
|
||||
# configured with. Guarded on a non-empty suffix too, since without one
|
||||
# the path is the unscoped working directory a developer uses by hand,
|
||||
# which is not ours to delete.
|
||||
#
|
||||
# Every suite whose configs become per-run owes a line here. acl-allowlist
|
||||
# also generates per-run now but is not in this runner's suite list, so
|
||||
# this teardown never creates its directory and must not remove one.
|
||||
if [[ $run_status -eq 0 && -n "${CI_RUN_NAME_SUFFIX:-}" ]]; then
|
||||
rm -rf "$SCRIPT_DIR/static/generated-configs${CI_RUN_NAME_SUFFIX}"
|
||||
rm -rf "$SCRIPT_DIR/firewall/generated-configs${CI_RUN_NAME_SUFFIX}"
|
||||
rm -rf "$SCRIPT_DIR/nat/generated-configs${CI_RUN_NAME_SUFFIX}"
|
||||
fi
|
||||
|
||||
# 4. This run's build context. Unlike the generated configs it is removed
|
||||
# on a red run too: it holds the binaries and the Dockerfiles, both
|
||||
# reproducible from the commit, so it is never the evidence of a
|
||||
# failure. Guarded on the path having been derived at all, so an early
|
||||
# exit cannot turn this into `rm -rf $SCRIPT_DIR/docker-`.
|
||||
if [[ -n "${CI_BUILD_CONTEXT:-}" && "$CI_BUILD_CONTEXT" != "$SCRIPT_DIR/docker-" ]]; then
|
||||
rm -rf "$CI_BUILD_CONTEXT"
|
||||
fi
|
||||
}
|
||||
|
||||
on_signal() {
|
||||
@@ -324,7 +462,7 @@ on_signal() {
|
||||
on_exit() {
|
||||
local code=$?
|
||||
trap '' TERM INT EXIT
|
||||
ci_teardown
|
||||
ci_teardown "$code"
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
@@ -482,43 +620,22 @@ run_rekey() {
|
||||
record "rekey" $rc
|
||||
}
|
||||
|
||||
# Run the admission-cap integration test
|
||||
# Verifies the inbound max_peers early-gate silent-drops at scale by
|
||||
# lowering node.max_peers on one mesh node and asserting via tcpdump
|
||||
# that no Msg2 responses go to the sustained-retrying denied peers.
|
||||
run_admission_cap() {
|
||||
local compose="testing/static/docker-compose.yml"
|
||||
local rc=0
|
||||
export COMPOSE_PROJECT_NAME="$(ci_project static)"
|
||||
|
||||
info "[admission-cap] Generating configs"
|
||||
bash testing/static/scripts/generate-configs.sh mesh || { record "admission-cap" 1; return; }
|
||||
bash testing/static/scripts/admission-cap-test.sh inject-config || { record "admission-cap" 1; return; }
|
||||
|
||||
info "[admission-cap] Starting containers (mesh profile)"
|
||||
docker compose -f "$compose" --profile mesh up -d || { record "admission-cap" 1; return; }
|
||||
|
||||
info "[admission-cap] Running admission-cap test"
|
||||
if bash testing/static/scripts/admission-cap-test.sh; then
|
||||
rc=0
|
||||
else
|
||||
rc=1
|
||||
info "[admission-cap] Collecting failure logs"
|
||||
docker compose -f "$compose" --profile mesh logs --no-color 2>&1 | tail -100
|
||||
fi
|
||||
|
||||
docker compose -f "$compose" --profile mesh down --volumes --remove-orphans 2>/dev/null
|
||||
record "admission-cap" $rc
|
||||
}
|
||||
|
||||
# Run a chaos scenario
|
||||
run_chaos() {
|
||||
local name="$1"
|
||||
shift
|
||||
local rc=0
|
||||
# Distinct project per scenario (chaos children run in parallel). When
|
||||
# invoked from a background subshell this export is local to that child.
|
||||
export COMPOSE_PROJECT_NAME="$(ci_project "chaos-$name")"
|
||||
# Distinct project per scenario (chaos children run in parallel). Scoped to
|
||||
# this function so the --only path cannot leak it into a later suite.
|
||||
local -x COMPOSE_PROJECT_NAME="$(ci_project "chaos-$name")"
|
||||
|
||||
# Container names and the generated-config directory are GLOBAL and are not
|
||||
# scoped by the compose project, so narrow the run-wide suffix to this
|
||||
# scenario. Parallel children then cannot claim each other's names or
|
||||
# overwrite each other's compose file.
|
||||
local suffix
|
||||
suffix="$(ci_chaos_suffix "$name")"
|
||||
local -x FIPS_CI_NAME_SUFFIX="$suffix"
|
||||
|
||||
info "[chaos/$name] Running simulation"
|
||||
if bash testing/chaos/scripts/chaos.sh "$@" 2>&1; then
|
||||
@@ -528,31 +645,90 @@ run_chaos() {
|
||||
fi
|
||||
|
||||
record "chaos-$name" $rc
|
||||
|
||||
# record() ends in pass()/fail(), which are echoes, so it returns 0 for any
|
||||
# rc — and without this return so does run_chaos. On the parallel path the
|
||||
# function's status is the child subshell's status, and that is the only
|
||||
# thing the waiting parent can see: the child's own RESULTS entry and its
|
||||
# OVERALL=1 die with its process, and its FAIL line goes to a logfile only
|
||||
# the unreachable branch reads. So a bare record() left every scenario
|
||||
# recorded as a pass whatever the simulation reported. On the --only path
|
||||
# record() already wrote the true rc into the parent's own RESULTS, and
|
||||
# returning it as well is inert: this script does not set -e.
|
||||
return $rc
|
||||
}
|
||||
|
||||
# Claim a free /64 for this run's gateway-lan network (Mechanism B).
|
||||
#
|
||||
# gateway-lan cannot float like fips-net: the LAN clients' resolv.conf pins the
|
||||
# gateway's LAN address as their nameserver, which must be a literal known
|
||||
# before they start. So instead of a fixed fd02::/64 that two concurrent runs
|
||||
# both request (the second failing on "Pool overlaps"), claim-and-advance a
|
||||
# candidate /64 and let docker's create be the atomic arbiter. The claimed
|
||||
# prefix is threaded — via the exported FIPS_GW_LAN6_PREFIX — into the compose
|
||||
# ipv6_address pins, the generated resolv.conf, and gateway-test.sh, so every
|
||||
# LAN address moves with the claim. i=0 renders today's fd02::/64.
|
||||
#
|
||||
# Mirrors sidecar/scripts/test-sidecar.sh:alloc_network. Deliberately does NOT
|
||||
# discard stderr: only an address-pool conflict is worth advancing on; any other
|
||||
# failure is real and burning through 256 candidates would bury the reason.
|
||||
claim_gateway_lan6() {
|
||||
local net="$1" i err
|
||||
for (( i = 0; i < 256; i++ )); do
|
||||
if err=$(docker network create --ipv6 \
|
||||
--subnet "fd02:0:0:${i}::/64" \
|
||||
--label "$CI_LABEL" --label "$CI_LABEL_RUN" \
|
||||
"$net" 2>&1); then
|
||||
export FIPS_GW_LAN6_PREFIX="fd02:0:0:${i}"
|
||||
info "[gateway] Claimed $net on fd02:0:0:${i}::/64"
|
||||
return 0
|
||||
fi
|
||||
case "$err" in
|
||||
*"Pool overlaps"*|*"pool overlaps"*) continue ;;
|
||||
*) fail "[gateway] docker network create: $err"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
fail "[gateway] no free /64 in fd02:0:0:0-255::/64 after 256 attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Run gateway integration test
|
||||
run_gateway() {
|
||||
local compose="testing/static/docker-compose.yml"
|
||||
local ext="testing/static/docker-compose.gateway-external-net.yml"
|
||||
local rc=0
|
||||
export COMPOSE_PROJECT_NAME="$(ci_project static)"
|
||||
export FIPS_GW_LAN_NET="fips-gateway-lan${FIPS_CI_NAME_SUFFIX:-}"
|
||||
|
||||
info "[gateway] Generating configs"
|
||||
bash testing/static/scripts/generate-configs.sh gateway gateway-test || { record "gateway" 1; return; }
|
||||
bash testing/static/scripts/gateway-test.sh inject-config || { record "gateway" 1; return; }
|
||||
|
||||
info "[gateway] Starting containers"
|
||||
docker compose -f "$compose" --profile gateway up -d || { record "gateway" 1; return; }
|
||||
|
||||
info "[gateway] Running gateway test"
|
||||
if bash testing/static/scripts/gateway-test.sh; then
|
||||
rc=0
|
||||
else
|
||||
rc=1
|
||||
info "[gateway] Collecting failure logs"
|
||||
docker compose -f "$compose" --profile gateway logs --no-color 2>&1 | tail -100
|
||||
# Claim the LAN /64 first: generate-configs writes resolv.conf from the
|
||||
# claimed prefix, and inject-config renders the port-forward targets from
|
||||
# it, so both must see FIPS_GW_LAN6_PREFIX before they run.
|
||||
info "[gateway] Claiming LAN network"
|
||||
if ! claim_gateway_lan6 "$FIPS_GW_LAN_NET"; then
|
||||
record "gateway" 1
|
||||
return
|
||||
fi
|
||||
|
||||
docker compose -f "$compose" --profile gateway down --volumes --remove-orphans 2>/dev/null
|
||||
info "[gateway] Generating configs"
|
||||
if bash testing/static/scripts/generate-configs.sh gateway gateway-test \
|
||||
&& bash testing/static/scripts/gateway-test.sh inject-config \
|
||||
&& { info "[gateway] Starting containers"; \
|
||||
docker compose -f "$compose" -f "$ext" --profile gateway up -d; }; then
|
||||
info "[gateway] Running gateway test"
|
||||
if bash testing/static/scripts/gateway-test.sh; then
|
||||
rc=0
|
||||
else
|
||||
rc=1
|
||||
info "[gateway] Collecting failure logs"
|
||||
docker compose -f "$compose" -f "$ext" --profile gateway logs --no-color 2>&1 | tail -100
|
||||
fi
|
||||
else
|
||||
rc=1
|
||||
fi
|
||||
|
||||
# compose down does not remove an external network, so drop it explicitly.
|
||||
docker compose -f "$compose" -f "$ext" --profile gateway down --volumes --remove-orphans 2>/dev/null
|
||||
docker network rm "$FIPS_GW_LAN_NET" >/dev/null 2>&1 || true
|
||||
record "gateway" $rc
|
||||
}
|
||||
|
||||
@@ -638,17 +814,6 @@ run_rekey_outbound_only() {
|
||||
record "rekey-outbound-only" $rc
|
||||
}
|
||||
|
||||
# Run ACL allowlist integration test
|
||||
run_acl_allowlist() {
|
||||
export COMPOSE_PROJECT_NAME="$(ci_project acl)"
|
||||
info "[acl-allowlist] Running integration test"
|
||||
if bash testing/acl-allowlist/test.sh --skip-build 2>&1; then
|
||||
record "acl-allowlist" 0
|
||||
else
|
||||
record "acl-allowlist" 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run firewall baseline integration test
|
||||
run_firewall() {
|
||||
export COMPOSE_PROJECT_NAME="$(ci_project firewall)"
|
||||
@@ -660,16 +825,138 @@ run_firewall() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ── NAT lab: per-run network claim ─────────────────────────────────────────
|
||||
#
|
||||
# The lab's two bridges pinned 172.31.254.0/24 (wan) and 172.31.10.0/24
|
||||
# (shared-lan), so two concurrent runs asked the daemon for the same address
|
||||
# space and the second died with `Pool overlaps`. Claim a free /24 for each
|
||||
# instead and let docker's own create be the atomic arbiter, exactly as
|
||||
# claim_gateway_lan6 and sidecar's alloc_network do. The run-id-derived offset
|
||||
# this task considered earlier stays rejected: it makes a collision unlikely
|
||||
# rather than impossible, and a collision is the failure being removed.
|
||||
#
|
||||
# 10.41.0.0/16 is unclaimed in-tree, sits below this host's docker pool
|
||||
# (10.128.0.0/9, per /etc/docker/daemon.json), and is also outside docker's
|
||||
# stock 172.17-31 / 192.168 default, so the choice holds either way.
|
||||
#
|
||||
# The claim lives here rather than in the suite scripts because
|
||||
# .github/workflows/ci.yml invokes nat-test.sh, nostr-relay-test.sh and
|
||||
# stun-faults-test.sh directly and testing/mesh-lab/run-loop.sh invokes
|
||||
# `nat-test.sh lan`; none of those want a claim, and all tear down with the
|
||||
# base compose file only.
|
||||
#
|
||||
# One claim per SUITE invocation, not per run — matching run_gateway. Both
|
||||
# labels are stamped so ci-cleanup.sh's label sweep can recover the networks
|
||||
# when a run is SIGKILLed, which no inline removal can cover.
|
||||
CI_NAT_NET_BASE="10.41"
|
||||
CI_NAT_NET_CANDIDATES=256
|
||||
CI_NAT_CLAIMED_PREFIX=""
|
||||
|
||||
# Deliberately does NOT discard stderr: only an address-pool conflict is worth
|
||||
# advancing on. Any other failure is real, and burning through 256 candidates
|
||||
# would bury the reason.
|
||||
ci_claim_nat_net() {
|
||||
local net="$1" i err
|
||||
CI_NAT_CLAIMED_PREFIX=""
|
||||
for (( i = 0; i < CI_NAT_NET_CANDIDATES; i++ )); do
|
||||
if err=$(docker network create \
|
||||
--subnet "${CI_NAT_NET_BASE}.${i}.0/24" \
|
||||
--label "$CI_LABEL" --label "$CI_LABEL_RUN" \
|
||||
"$net" 2>&1); then
|
||||
CI_NAT_CLAIMED_PREFIX="${CI_NAT_NET_BASE}.${i}"
|
||||
info "[nat] Claimed $net on ${CI_NAT_CLAIMED_PREFIX}.0/24"
|
||||
return 0
|
||||
fi
|
||||
case "$err" in
|
||||
*"Pool overlaps"*|*"pool overlaps"*) continue ;;
|
||||
*) fail "[nat] docker network create: $err"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
fail "[nat] no free /24 in ${CI_NAT_NET_BASE}.0.0/16 after ${CI_NAT_NET_CANDIDATES} attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Claim both lab networks and export the prefixes every lab address derives
|
||||
# from. A partial claim is rolled back here, because nothing downstream will
|
||||
# run to release it.
|
||||
ci_claim_nat_networks() {
|
||||
unset NAT_WAN_PREFIX NAT_LAN_PREFIX
|
||||
export FIPS_NAT_WAN_NET="fips-nat-wan${FIPS_CI_NAME_SUFFIX:-}"
|
||||
export FIPS_NAT_LAN_NET="fips-nat-shared-lan${FIPS_CI_NAME_SUFFIX:-}"
|
||||
|
||||
ci_claim_nat_net "$FIPS_NAT_WAN_NET" || return 1
|
||||
local wan="$CI_NAT_CLAIMED_PREFIX"
|
||||
if ! ci_claim_nat_net "$FIPS_NAT_LAN_NET"; then
|
||||
docker network rm "$FIPS_NAT_WAN_NET" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
export NAT_WAN_PREFIX="$wan"
|
||||
export NAT_LAN_PREFIX="$CI_NAT_CLAIMED_PREFIX"
|
||||
}
|
||||
|
||||
# Release both claimed networks. A complete claim left behind would not merely
|
||||
# leak: the next nat-family suite in this run would hit `network with name ...
|
||||
# already exists`, which is not a pool overlap, so the allocator correctly
|
||||
# refuses to advance and fails. One suite failure would cascade into four.
|
||||
#
|
||||
# Both halves of run_gateway's shape are needed, and the second alone is a trap.
|
||||
# `docker network rm` refuses a network that still has endpoints attached, and
|
||||
# the nat suites deliberately leave their containers UP on a failure path so the
|
||||
# diagnostics they just dumped can be inspected — which is precisely the path
|
||||
# this release exists for. Without the `down` the removal silently no-ops
|
||||
# exactly when it matters and reports success. Nothing is lost by tearing down
|
||||
# here: the diagnostics are already in the run log, and ci_teardown reaps the
|
||||
# containers at the end of the run regardless.
|
||||
ci_release_nat_networks() {
|
||||
docker compose \
|
||||
-f testing/nat/docker-compose.yml \
|
||||
-f testing/nat/docker-compose.external-net.yml \
|
||||
--profile cone --profile symmetric --profile lan \
|
||||
--profile nostr-publish-consume --profile stun-faults \
|
||||
down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
[[ -n "${FIPS_NAT_WAN_NET:-}" ]] && \
|
||||
{ docker network rm "$FIPS_NAT_WAN_NET" >/dev/null 2>&1 || true; }
|
||||
[[ -n "${FIPS_NAT_LAN_NET:-}" ]] && \
|
||||
{ docker network rm "$FIPS_NAT_LAN_NET" >/dev/null 2>&1 || true; }
|
||||
return 0
|
||||
}
|
||||
|
||||
# The overlay pointing compose at the claimed networks. APPENDED to any
|
||||
# caller-supplied chain rather than replacing it: mesh-lab/run-loop.sh sets
|
||||
# FIPS_NAT_EXTRA_COMPOSE for its trace overlay, and overwriting would silently
|
||||
# drop that.
|
||||
ci_nat_extra_compose() {
|
||||
printf '%s' \
|
||||
"${FIPS_NAT_EXTRA_COMPOSE:+${FIPS_NAT_EXTRA_COMPOSE}:}testing/nat/docker-compose.external-net.yml"
|
||||
}
|
||||
|
||||
# Run a NAT scenario (cone, symmetric, lan)
|
||||
run_nat() {
|
||||
local scenario="$1"
|
||||
local rc=0
|
||||
export COMPOSE_PROJECT_NAME="$(ci_project nat)"
|
||||
info "[nat-$scenario] Running NAT lab"
|
||||
if bash testing/nat/scripts/nat-test.sh "$scenario" 2>&1; then
|
||||
record "nat-$scenario" 0
|
||||
else
|
||||
|
||||
# Early return only BEFORE the claim succeeds, so nothing can leak. Every
|
||||
# path after it falls through to the single release below — run_gateway's
|
||||
# shape. Not a trap: bash traps are not function-scoped, so a `trap ... EXIT`
|
||||
# here would replace the script-level on_exit handler for the rest of the
|
||||
# run and disable ci_teardown entirely.
|
||||
info "[nat-$scenario] Claiming lab networks"
|
||||
if ! ci_claim_nat_networks; then
|
||||
record "nat-$scenario" 1
|
||||
return
|
||||
fi
|
||||
|
||||
local _extra
|
||||
_extra="$(ci_nat_extra_compose)"
|
||||
local -x FIPS_NAT_EXTRA_COMPOSE="$_extra"
|
||||
|
||||
info "[nat-$scenario] Running NAT lab"
|
||||
bash testing/nat/scripts/nat-test.sh "$scenario" 2>&1 || rc=1
|
||||
|
||||
ci_release_nat_networks
|
||||
record "nat-$scenario" $rc
|
||||
}
|
||||
|
||||
# Run the Nostr overlay advert publish/consume integration test.
|
||||
@@ -677,13 +964,24 @@ run_nat() {
|
||||
# (A→B publish/consume), Phase 2 (B→A reverse), and Phase 3 (malformed
|
||||
# advert injected directly to the relay; consumer-liveness assertion).
|
||||
run_nostr_publish_consume() {
|
||||
local rc=0
|
||||
export COMPOSE_PROJECT_NAME="$(ci_project nat)"
|
||||
info "[nostr-publish-consume] Running Nostr publish/consume test"
|
||||
if bash testing/nat/scripts/nostr-relay-test.sh 2>&1; then
|
||||
record "nostr-publish-consume" 0
|
||||
else
|
||||
|
||||
info "[nostr-publish-consume] Claiming lab networks"
|
||||
if ! ci_claim_nat_networks; then
|
||||
record "nostr-publish-consume" 1
|
||||
return
|
||||
fi
|
||||
|
||||
local _extra
|
||||
_extra="$(ci_nat_extra_compose)"
|
||||
local -x FIPS_NAT_EXTRA_COMPOSE="$_extra"
|
||||
|
||||
info "[nostr-publish-consume] Running Nostr publish/consume test"
|
||||
bash testing/nat/scripts/nostr-relay-test.sh 2>&1 || rc=1
|
||||
|
||||
ci_release_nat_networks
|
||||
record "nostr-publish-consume" $rc
|
||||
}
|
||||
|
||||
# Run the STUN fault-injection integration test.
|
||||
@@ -692,13 +990,24 @@ run_nostr_publish_consume() {
|
||||
# kill. Asserts the daemon detects each fault, recovers from delay, and
|
||||
# never panics.
|
||||
run_stun_faults() {
|
||||
local rc=0
|
||||
export COMPOSE_PROJECT_NAME="$(ci_project nat)"
|
||||
info "[stun-faults] Running STUN fault-injection test"
|
||||
if bash testing/nat/scripts/stun-faults-test.sh 2>&1; then
|
||||
record "stun-faults" 0
|
||||
else
|
||||
|
||||
info "[stun-faults] Claiming lab networks"
|
||||
if ! ci_claim_nat_networks; then
|
||||
record "stun-faults" 1
|
||||
return
|
||||
fi
|
||||
|
||||
local _extra
|
||||
_extra="$(ci_nat_extra_compose)"
|
||||
local -x FIPS_NAT_EXTRA_COMPOSE="$_extra"
|
||||
|
||||
info "[stun-faults] Running STUN fault-injection test"
|
||||
bash testing/nat/scripts/stun-faults-test.sh 2>&1 || rc=1
|
||||
|
||||
ci_release_nat_networks
|
||||
record "stun-faults" $rc
|
||||
}
|
||||
|
||||
# Run dns-resolver harness (multi-distro + e2e scenarios)
|
||||
@@ -747,24 +1056,39 @@ run_tor_directory() {
|
||||
run_integration() {
|
||||
stage "Stage 3: Integration Tests"
|
||||
|
||||
# Install binaries to shared docker context
|
||||
# Populate THIS run's build context, then install the binaries into it.
|
||||
# Everything but the binaries is copied from the tracked context directory;
|
||||
# the binaries are installed fresh, and a previous run's are deliberately
|
||||
# not carried over, since inheriting them is the failure this scoping
|
||||
# exists to prevent.
|
||||
info "Preparing build context $CI_BUILD_CONTEXT"
|
||||
rm -rf "$CI_BUILD_CONTEXT"
|
||||
mkdir -p "$CI_BUILD_CONTEXT" || { record "docker-build" 1; return; }
|
||||
local _f
|
||||
for _f in "$SCRIPT_DIR"/docker/*; do
|
||||
case "$(basename "$_f")" in
|
||||
fips|fipsctl|fipstop|fips-gateway) continue ;;
|
||||
esac
|
||||
cp -a "$_f" "$CI_BUILD_CONTEXT/" || { record "docker-build" 1; return; }
|
||||
done
|
||||
|
||||
info "Installing release binaries"
|
||||
install_binaries testing/docker
|
||||
install_binaries "$CI_BUILD_CONTEXT"
|
||||
|
||||
# Build unified test image once (used by all harnesses). Tag per-run
|
||||
# (fips-test:${run}) so a build killed mid-flight never wedges the next
|
||||
# run's rebuild, and concurrent runs never clobber each other's image.
|
||||
# Then retag :latest for the compose files / harness scripts that still
|
||||
# reference fips-test:latest directly; the retag happens only after BOTH
|
||||
# builds succeed, so :latest never points at a half-built image.
|
||||
info "Building $CI_IMAGE_TEST Docker image"
|
||||
docker build -t "$CI_IMAGE_TEST" --label "$CI_LABEL" --label "$CI_LABEL_RUN" testing/docker --quiet \
|
||||
docker build -t "$CI_IMAGE_TEST" --label "$CI_LABEL" --label "$CI_LABEL_RUN" "$CI_BUILD_CONTEXT" --quiet \
|
||||
|| { record "docker-build" 1; return; }
|
||||
docker build -t "$CI_IMAGE_APP" --label "$CI_LABEL" --label "$CI_LABEL_RUN" \
|
||||
-f testing/docker/Dockerfile.app testing/docker --quiet \
|
||||
-f "$CI_BUILD_CONTEXT/Dockerfile.app" "$CI_BUILD_CONTEXT" --quiet \
|
||||
|| { record "docker-build-app" 1; return; }
|
||||
docker tag "$CI_IMAGE_TEST" fips-test:latest
|
||||
docker tag "$CI_IMAGE_APP" fips-test-app:latest
|
||||
# Deliberately NOT retagged to fips-test:latest. Every consumer reads
|
||||
# FIPS_TEST_IMAGE, and a bridge back to the shared mutable name would let a
|
||||
# consumer that does not keep working silently — resolving whichever
|
||||
# concurrent run wrote the tag last, and recording that run's binaries under
|
||||
# this run's commit. Without the bridge a missed consumer fails loudly.
|
||||
|
||||
# Single suite mode
|
||||
if [[ -n "$ONLY_SUITE" ]]; then
|
||||
@@ -783,17 +1107,9 @@ run_integration() {
|
||||
run_rekey_accept_off
|
||||
run_rekey_outbound_only
|
||||
|
||||
# Admission cap (mesh profile, max_peers=1 on one node)
|
||||
for _suite in "${ADMISSION_SUITES[@]}"; do
|
||||
run_admission_cap
|
||||
done
|
||||
|
||||
# Gateway
|
||||
run_gateway
|
||||
|
||||
# ACL allowlist
|
||||
run_acl_allowlist
|
||||
|
||||
# Firewall baseline
|
||||
run_firewall
|
||||
|
||||
@@ -818,7 +1134,6 @@ run_integration() {
|
||||
local pids=()
|
||||
local suite_names=()
|
||||
local running=0
|
||||
local chaos_idx=0
|
||||
|
||||
for entry in "${CHAOS_SUITES[@]}"; do
|
||||
# Parse: "display-name scenario [flags...]"
|
||||
@@ -826,14 +1141,15 @@ run_integration() {
|
||||
local name="${parts[0]}"
|
||||
local args=("${parts[@]:1}")
|
||||
|
||||
# Give each chaos child a unique, non-overlapping /24 in 10.30.x so
|
||||
# parallel children never collide with each other, and so a chaos
|
||||
# net can never swallow a fixed-subnet suite (sidecar/static/nat in
|
||||
# 172.x). 10.30.x sits outside docker's default-address-pool range
|
||||
# (172.17-31 / 192.168), so auto-assigned nets can't land on it
|
||||
# either. Node IPs derive from this subnet inside the sim.
|
||||
args+=("--subnet" "10.30.${chaos_idx}.0/24")
|
||||
chaos_idx=$((chaos_idx + 1))
|
||||
# No --subnet: the sim claims a free /24 itself (sim/netclaim.py).
|
||||
# This used to pass 10.30.${chaos_idx}.0/24, which uniquified the
|
||||
# children of ONE run against each other and was byte-identical
|
||||
# between runs -- so two concurrent ci-local runs both walked
|
||||
# 10.30.0 through 10.30.12 and collided on every one. A claim makes
|
||||
# the daemon the arbiter, so an overlap is impossible rather than
|
||||
# merely unlikely. The range still sits in 10.30.0.0/16, clear of
|
||||
# docker's default pool (172.17-31 / 192.168) and of the
|
||||
# fixed-subnet suites in 172.x.
|
||||
|
||||
# Throttle: wait for a slot
|
||||
while [[ $running -ge $PARALLEL_JOBS ]]; do
|
||||
@@ -904,12 +1220,8 @@ run_suite() {
|
||||
run_rekey_accept_off ;;
|
||||
rekey-outbound-only)
|
||||
run_rekey_outbound_only ;;
|
||||
admission-cap)
|
||||
run_admission_cap ;;
|
||||
gateway)
|
||||
run_gateway ;;
|
||||
acl-allowlist)
|
||||
run_acl_allowlist ;;
|
||||
firewall)
|
||||
run_firewall ;;
|
||||
nat-cone|nat-symmetric|nat-lan)
|
||||
@@ -930,7 +1242,12 @@ run_suite() {
|
||||
fi
|
||||
done
|
||||
if [[ "$found" != true ]]; then
|
||||
# Fall back to using the name as the scenario directly
|
||||
# Fall back to using the name as the scenario directly. Note
|
||||
# teardown rebuilds veth suffixes from CHAOS_SUITES only, so a
|
||||
# scenario named here but absent from that list is outside the
|
||||
# host-interface reap. Harmless while every ethernet-transport
|
||||
# scenario is declared there; add one that isn't and its
|
||||
# orphaned pairs will need an unscoped `--reap` to clear.
|
||||
run_chaos "$chaos_name" "$chaos_name"
|
||||
fi
|
||||
;;
|
||||
@@ -980,6 +1297,66 @@ print_summary() {
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Verify the local default suite set and the GitHub matrix still cover the
|
||||
# same work. Runs first: it takes about a second, and a divergence should be
|
||||
# reported before a half-hour suite rather than after it.
|
||||
run_ci_parity() {
|
||||
local rc=0
|
||||
info "[ci-parity] Comparing the local suite set against the GitHub matrix"
|
||||
"$SCRIPT_DIR/check-ci-parity.sh" || rc=$?
|
||||
record "ci-parity" $rc
|
||||
}
|
||||
|
||||
# Nothing may resolve the shared mutable test image. This run does not write
|
||||
# fips-test:latest, so a consumer naming it fails loudly here and now — but only
|
||||
# on a host with no hand-built copy lying around, which is not a property to
|
||||
# rely on. This is the static half of that.
|
||||
run_image_scoping() {
|
||||
local rc=0
|
||||
info "[image-scoping] Checking that nothing names the shared test image"
|
||||
"$SCRIPT_DIR/check-image-scoping.sh" || rc=$?
|
||||
record "image-scoping" $rc
|
||||
}
|
||||
|
||||
# Every daemon log string a test matches on must still be emitted by src/.
|
||||
# A stale one does not fail — it stops observing, and an expect-zero assertion
|
||||
# built on it then passes for the wrong reason.
|
||||
run_log_strings() {
|
||||
local rc=0
|
||||
info "[log-strings] Checking test log matchers against the strings src/ emits"
|
||||
python3 "$SCRIPT_DIR/check-log-strings.py" || rc=$?
|
||||
record "log-strings" $rc
|
||||
}
|
||||
|
||||
# A shell function's exit status is its last command's. One ending in a log
|
||||
# call returns 0 whatever it did, so a caller testing that status has a dead
|
||||
# gate — the run_chaos and build_fips_for_e2e defects, one of which certified
|
||||
# unbuilt code as green. This enforces an explicit terminal return wherever a
|
||||
# caller consumes the status, which makes the class unreachable rather than
|
||||
# repairing instances of it.
|
||||
run_trailing_log() {
|
||||
local rc=0
|
||||
info "[trailing-log] Checking for functions whose status is a log call's"
|
||||
python3 "$SCRIPT_DIR/check-trailing-log.py" || rc=$?
|
||||
record "trailing-log" $rc
|
||||
}
|
||||
|
||||
# Unit tests for the convergence gate every static suite waits on. Hermetic —
|
||||
# synthetic ping functions against the same SECONDS clock, no containers — but
|
||||
# it drives real timeouts, so it costs about 45 seconds rather than the "few
|
||||
# seconds" its own header used to claim.
|
||||
#
|
||||
# It is here with the other two rather than in the integration stage because it
|
||||
# needs nothing built. Note what that buys: wait_until_connected decides whether
|
||||
# every static suite proceeds or gives up, so a regression in it turns those
|
||||
# suites' verdicts into noise, and until now nothing ran this at all.
|
||||
run_wait_converge() {
|
||||
local rc=0
|
||||
info "[wait-converge] Running convergence-gate unit tests"
|
||||
bash "$SCRIPT_DIR/lib/wait-converge-test.sh" || rc=$?
|
||||
record "wait-converge" $rc
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
@@ -988,6 +1365,15 @@ main() {
|
||||
stage "FIPS Local CI"
|
||||
info "Project root: $PROJECT_ROOT"
|
||||
|
||||
# Above the mode branches deliberately, so --only, --test-only and
|
||||
# --build-only are gated too: a divergence invalidates any claim that a
|
||||
# local run means what a GitHub run means, whichever subset was asked for.
|
||||
run_ci_parity
|
||||
run_log_strings
|
||||
run_trailing_log
|
||||
run_image_scoping
|
||||
run_wait_converge
|
||||
|
||||
if [[ "$TEST_ONLY" == true ]]; then
|
||||
run_tests
|
||||
elif [[ "$BUILD_ONLY" == true ]]; then
|
||||
|
||||
+47
-11
@@ -32,7 +32,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
CACHE_DIR="$SCRIPT_DIR/.cache"
|
||||
DEB_CACHE_DIR="$CACHE_DIR/deb"
|
||||
|
||||
# Timeouts
|
||||
# Timeouts. Each wait loop below exits as soon as its condition is met.
|
||||
BOOT_TIMEOUT=30
|
||||
SERVICE_TIMEOUT=20
|
||||
DAEMON_TIMEOUT=15
|
||||
@@ -75,15 +75,26 @@ start_systemd_container_with_tun() {
|
||||
}
|
||||
|
||||
wait_for_systemd() {
|
||||
local name="$1"
|
||||
local name="$1" state
|
||||
for _i in $(seq 1 "$BOOT_TIMEOUT"); do
|
||||
if docker exec "$name" systemctl is-system-running --wait 2>/dev/null | grep -qE 'running|degraded'; then
|
||||
return 0
|
||||
fi
|
||||
# `is-system-running` exits non-zero for `degraded` (a unit failed to
|
||||
# start -- e.g. systemd-modules-load, which cannot load kernel modules
|
||||
# inside a container -- even though the system did finish booting). This
|
||||
# script runs `set -o pipefail`, so a piped `grep` would inherit that
|
||||
# non-zero exit and reject an acceptable state, which timed out the
|
||||
# newest distros (they reach `degraded`, older ones reach `running`).
|
||||
# Capture the state string and test it directly instead of the pipe.
|
||||
state=$(docker exec "$name" systemctl is-system-running --wait 2>/dev/null || true)
|
||||
case "$state" in
|
||||
running | degraded) return 0 ;;
|
||||
esac
|
||||
sleep 1
|
||||
done
|
||||
echo " WARNING: systemd did not reach running state in ${BOOT_TIMEOUT}s (may still work)"
|
||||
return 0
|
||||
# A boot that never reached `running` or `degraded` is not a warning: every
|
||||
# check after this point reads a system that may not have started its units,
|
||||
# and returning 0 here made the timeout indistinguishable from a clean boot.
|
||||
echo " ERROR: systemd did not reach running state in ${BOOT_TIMEOUT}s" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_service_active() {
|
||||
@@ -229,18 +240,32 @@ DOCKERFILE
|
||||
rm -f "$CACHE_DIR/deb-for-image"
|
||||
|
||||
start_systemd_container_with_tun "$name" "$image"
|
||||
wait_for_systemd "$name"
|
||||
wait_for_systemd "$name" || {
|
||||
fail "systemd did not boot in $name; the install checks below would read an unstarted system"
|
||||
cleanup_container "$name"
|
||||
return
|
||||
}
|
||||
|
||||
# Install the .deb. apt handles dependencies (libc6, systemd,
|
||||
# libdbus-1-3) and runs the maintainer scripts (postinst →
|
||||
# systemctl enable fips.service; fips-dns.service starts and
|
||||
# runs fips-dns-setup).
|
||||
log "Installing .deb (apt install /opt/fips-deb/${deb_basename})"
|
||||
local install_output
|
||||
# `|| true` here used to discard apt's exit status, and an empty capture (a
|
||||
# failed `docker exec`) matches neither error pattern below, so a install
|
||||
# that never ran reached `pass "apt install completed"`. Keep the capture on
|
||||
# failure so the diagnostics below can print it, but remember the status.
|
||||
local install_output install_rc=0
|
||||
install_output=$(docker exec "$name" bash -c "
|
||||
apt-get update >/dev/null 2>&1
|
||||
cd /opt/fips-deb && apt-get install -y --no-install-recommends ./${deb_basename} 2>&1
|
||||
") || true
|
||||
") || install_rc=$?
|
||||
if [ "$install_rc" -ne 0 ]; then
|
||||
fail "apt install exited $install_rc"
|
||||
echo "$install_output" | tail -20
|
||||
cleanup_container "$name"
|
||||
return
|
||||
fi
|
||||
if echo "$install_output" | grep -qE "^E:|errors? were encountered"; then
|
||||
fail "apt install reported errors"
|
||||
echo "$install_output" | tail -20
|
||||
@@ -490,4 +515,15 @@ echo "════════════════════════
|
||||
echo "Results: $PASS passed, $FAIL failed, $SKIP skipped"
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
[ "$FAIL" -eq 0 ]
|
||||
# A skip must not read as a pass. Nothing calls skip() today, so SKIP is
|
||||
# always 0 and this changes no current outcome -- which is exactly why it is
|
||||
# worth adding now: the helper and the counter already existed and were
|
||||
# reported, so a later skip path would have printed "N skipped" next to a
|
||||
# zero exit and looked like coverage. Gate on it before that happens.
|
||||
if [ "$SKIP" -ne 0 ]; then
|
||||
echo "FAIL: $SKIP check(s) skipped; a skipped check is not a passed one." >&2
|
||||
echo " Either make the check run here, or remove it and record the" >&2
|
||||
echo " gap deliberately rather than skipping it at runtime." >&2
|
||||
fi
|
||||
|
||||
[ "$FAIL" -eq 0 ] && [ "$SKIP" -eq 0 ]
|
||||
|
||||
@@ -270,13 +270,49 @@ DOCKERFILE
|
||||
fi
|
||||
|
||||
log "Extracting fips + fips-gateway binaries from builder image"
|
||||
local cid
|
||||
cid=$(docker create "$builder_tag")
|
||||
docker cp "$cid:/src/target/release/fips" "$FIPS_BIN_CACHE" >/dev/null 2>&1
|
||||
docker cp "$cid:/src/target/release/fips-gateway" "$FIPS_GATEWAY_BIN_CACHE" >/dev/null 2>&1
|
||||
# Drop the previous run's binaries before extracting. Without this, a failed
|
||||
# extraction below leaves them in place, they satisfy the caller's -x check,
|
||||
# and the e2e scenarios silently exercise the previous commit's code.
|
||||
rm -f "$FIPS_BIN_CACHE" "$FIPS_GATEWAY_BIN_CACHE"
|
||||
|
||||
# stderr goes to its own file rather than into $cid: docker prints
|
||||
# warnings (a platform mismatch, say) on success too, and folding them
|
||||
# into the id would leave every later reference pointing at nothing.
|
||||
local cid err errfile
|
||||
errfile=$(mktemp)
|
||||
if ! cid=$(docker create "$builder_tag" 2>"$errfile"); then
|
||||
echo " ERROR: docker create failed: $(cat "$errfile")"
|
||||
rm -f "$errfile"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$errfile"
|
||||
|
||||
local rc=0 spec bin dest
|
||||
for spec in "fips:$FIPS_BIN_CACHE" "fips-gateway:$FIPS_GATEWAY_BIN_CACHE"; do
|
||||
bin="${spec%%:*}"
|
||||
dest="${spec#*:}"
|
||||
if ! err=$(docker cp "$cid:/src/target/release/$bin" "$dest" 2>&1); then
|
||||
echo " ERROR: extracting $bin from the builder image failed: $err"
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
docker rm "$cid" >/dev/null
|
||||
chmod +x "$FIPS_BIN_CACHE" "$FIPS_GATEWAY_BIN_CACHE"
|
||||
[ "$rc" -eq 0 ] || return 1
|
||||
|
||||
if ! chmod +x "$FIPS_BIN_CACHE" "$FIPS_GATEWAY_BIN_CACHE"; then
|
||||
echo " ERROR: chmod +x failed on the extracted binaries"
|
||||
return 1
|
||||
fi
|
||||
|
||||
for dest in "$FIPS_BIN_CACHE" "$FIPS_GATEWAY_BIN_CACHE"; do
|
||||
if [ ! -s "$dest" ] || [ ! -x "$dest" ]; then
|
||||
echo " ERROR: extracted binary missing, empty or not executable: $dest"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
log "Cached fips ($(stat -c %s "$FIPS_BIN_CACHE") bytes) + fips-gateway ($(stat -c %s "$FIPS_GATEWAY_BIN_CACHE") bytes)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1 +1 @@
|
||||
generated-configs
|
||||
generated-configs*
|
||||
|
||||
@@ -24,10 +24,18 @@ actively DROP'd by the fips chain (not silently unrouted).
|
||||
|
||||
Two FIPS nodes peered over UDP on a Docker bridge network:
|
||||
|
||||
| Container | Hostname | docker IPv4 | Firewall |
|
||||
|-------------------------|----------|---------------|----------|
|
||||
| `fips-fw-container-a` | `host-a` | 172.32.0.10 | none (probe) |
|
||||
| `fips-fw-container-b` | `host-b` | 172.32.0.11 | `fips.nft` + drop-in |
|
||||
| Container | Hostname | Firewall |
|
||||
|-------------------------|----------|----------|
|
||||
| `fips-fw-container-a` | `host-a` | none (probe) |
|
||||
| `fips-fw-container-b` | `host-b` | `fips.nft` + drop-in |
|
||||
|
||||
The bridge network requests no subnet, so docker assigns one from its own
|
||||
address pool and two concurrent runs never contend for a fixed range. No
|
||||
node's IPv4 address is therefore known before startup, and the generated peer
|
||||
stanzas address each other by docker hostname, resolved through the
|
||||
container's dnsmasq to docker's embedded DNS. The firewall assertions
|
||||
themselves are unaffected: they run over the fips0 overlay, whose addresses
|
||||
are derived from the node npubs.
|
||||
|
||||
`node-b` mounts the production `packaging/common/fips.nft` read-only at
|
||||
`/etc/fips/fips.nft`, plus a drop-in at `/etc/fips/fips.d/services.nft`
|
||||
@@ -84,7 +92,7 @@ PASS: fips-fw-container-b: fips.nft baseline + drop-in loaded
|
||||
=== Case (c): ICMPv6 echo-request to firewalled node
|
||||
PASS: (c) ICMPv6 ping node-a → node-b accepted
|
||||
=== Case (a): unallowed inbound TCP/8000 from node-a → node-b
|
||||
PASS: (a) inbound TCP/8000 blocked (curl rc=28)
|
||||
PASS: (a) inbound TCP/8000 dropped (curl rc=28, timed out as expected)
|
||||
=== Case (b): node-b initiates outbound TCP, expects reply via conntrack
|
||||
PASS: (b) outbound from node-b got HTTP 200 via conntrack reply path
|
||||
=== Case (d): drop-in allowlisted TCP/22 from node-a → node-b
|
||||
@@ -108,4 +116,6 @@ docker compose -f testing/firewall/docker-compose.yml down
|
||||
|
||||
## Generated fixture location
|
||||
|
||||
`testing/firewall/generated-configs/` (gitignored).
|
||||
`testing/firewall/generated-configs/` (gitignored), or
|
||||
`generated-configs<suffix>/` when `FIPS_CI_NAME_SUFFIX` is set, which is how
|
||||
concurrent runs keep their fixtures apart.
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
networks:
|
||||
# No subnet is requested: docker assigns one from the daemon's address pool.
|
||||
# A fixed request is honoured verbatim, so two runs asking for the same range
|
||||
# collide on "Pool overlaps" — which is why the generated peer addresses are
|
||||
# docker hostnames (host-a, host-b) rather than literal IPs. Docker's embedded
|
||||
# DNS is per-network, so the same hostname in two concurrent runs resolves
|
||||
# inside each run's own subnet.
|
||||
#
|
||||
# That removes one of the two obstacles to running this suite twice at once,
|
||||
# not both. The compose project name is still fixed, so two runs that do not
|
||||
# set COMPOSE_PROJECT_NAME share a project, and the second `up` recreates the
|
||||
# first's containers while either `down` removes both. The local CI runner
|
||||
# scopes it externally (run_firewall in ci-local.sh); a bare hand run does
|
||||
# not. Do not read the floating subnet as making concurrent bare runs safe.
|
||||
fw-net:
|
||||
driver: bridge
|
||||
labels:
|
||||
- "com.corganlabs.fips-ci=1"
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.32.0.0/24
|
||||
|
||||
x-fips-common: &fips-common
|
||||
build:
|
||||
context: ../docker
|
||||
image: fips-test:latest
|
||||
# The harness scopes its build context per run and passes it here; the
|
||||
# shared directory is the hand-run default. Compose resolves a relative
|
||||
# value against THIS file's directory, so the harness must export an
|
||||
# absolute path.
|
||||
context: ${FIPS_BUILD_CONTEXT:-../docker}
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
entrypoint: ["/usr/local/bin/entrypoint.sh"]
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
@@ -31,12 +45,11 @@ services:
|
||||
hostname: host-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/node-a/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs/node-a/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs/node-a/fips.key:/etc/fips/fips.key:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-a/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-a/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-a/fips.key:/etc/fips/fips.key:ro
|
||||
networks:
|
||||
fw-net:
|
||||
ipv4_address: 172.32.0.10
|
||||
- fw-net
|
||||
|
||||
service-b:
|
||||
<<: *fips-common
|
||||
@@ -44,11 +57,10 @@ services:
|
||||
hostname: host-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/node-b/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs/node-b/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs/node-b/fips.key:/etc/fips/fips.key:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-b/hosts:/etc/fips/hosts:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-b/fips.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-b/fips.key:/etc/fips/fips.key:ro
|
||||
- ../../packaging/common/fips.nft:/etc/fips/fips.nft:ro
|
||||
- ./generated-configs/node-b/fips.d:/etc/fips/fips.d:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/node-b/fips.d:/etc/fips/fips.d:ro
|
||||
networks:
|
||||
fw-net:
|
||||
ipv4_address: 172.32.0.11
|
||||
- fw-net
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
GENERATED_DIR="$SCRIPT_DIR/generated-configs"
|
||||
|
||||
# Scoped by the per-run suffix because this directory is wiped and rewritten
|
||||
# below: two runs sharing one output directory would delete each other's
|
||||
# fixtures out from under running containers. Unset (a bare hand run, or the
|
||||
# GitHub-hosted path) it collapses to the historical "generated-configs".
|
||||
GENERATED_DIR="$SCRIPT_DIR/generated-configs${FIPS_CI_NAME_SUFFIX:-}"
|
||||
|
||||
# Deterministic test identities (mirrors the acl-allowlist style).
|
||||
NPUB_A="npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
@@ -32,6 +37,10 @@ node-b $NPUB_B
|
||||
EOF
|
||||
}
|
||||
|
||||
# Peers are addressed by the docker hostname the compose file assigns (host-a,
|
||||
# host-b), not by IP. The network requests no subnet so that two concurrent
|
||||
# runs cannot collide on one address range, which means neither node's address
|
||||
# is knowable before `docker compose up`.
|
||||
echo "Generating firewall fixtures..."
|
||||
rm -rf "$GENERATED_DIR"
|
||||
|
||||
@@ -58,7 +67,7 @@ peers:
|
||||
alias: "node-b"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.32.0.11:2121"
|
||||
addr: "host-b:2121"
|
||||
connect_policy: auto_connect
|
||||
EOF
|
||||
|
||||
@@ -89,7 +98,7 @@ peers:
|
||||
alias: "node-a"
|
||||
addresses:
|
||||
- transport: udp
|
||||
addr: "172.32.0.10:2121"
|
||||
addr: "host-a:2121"
|
||||
connect_policy: auto_connect
|
||||
EOF
|
||||
|
||||
|
||||
+97
-15
@@ -81,21 +81,46 @@ wait_for_fips0() {
|
||||
fail "$container fips0 did not come up within ${timeout}s"
|
||||
}
|
||||
|
||||
# Connected-peer count for a container, or the empty string if it did not
|
||||
# answer.
|
||||
#
|
||||
# Empty is deliberately distinct from a real 0. An `|| echo 0` fallback here
|
||||
# would make "the container is unreachable, or its daemon never came up" and
|
||||
# "the daemon answered, and the answer was zero" the same value, so any caller
|
||||
# expecting zero would be satisfied on the first iteration without the
|
||||
# property it is checking ever being observed. No caller in this file expects
|
||||
# zero today, which is exactly why the fallback has to go now rather than when
|
||||
# one is added. Same shape as the acl-allowlist suite's read_connected_peers.
|
||||
read_connected_peers() {
|
||||
local container="$1"
|
||||
docker exec "$container" fipsctl show peers 2>/dev/null \
|
||||
| python3 -c 'import json,sys; data=json.load(sys.stdin); print(sum(1 for p in data.get("peers", []) if p.get("connectivity") == "connected"))' 2>/dev/null \
|
||||
|| true
|
||||
}
|
||||
|
||||
# Wait for the peer count on a container to reach the expected value.
|
||||
wait_for_peers_exact() {
|
||||
local container="$1"
|
||||
local expected_count="$2"
|
||||
local timeout="${3:-30}"
|
||||
|
||||
local count="" answered=false
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
local count
|
||||
count=$(docker exec "$container" fipsctl show peers 2>/dev/null \
|
||||
| python3 -c 'import json,sys; data=json.load(sys.stdin); print(sum(1 for p in data.get("peers", []) if p.get("connectivity") == "connected"))' 2>/dev/null || echo 0)
|
||||
if [ "$count" -eq "$expected_count" ]; then
|
||||
return 0
|
||||
count=$(read_connected_peers "$container")
|
||||
if [ -n "$count" ]; then
|
||||
answered=true
|
||||
if [ "$count" -eq "$expected_count" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fail "$container did not reach $expected_count connected peers in ${timeout}s"
|
||||
|
||||
if [ "$answered" = false ]; then
|
||||
fail "$container never answered a peer query in ${timeout}s, so a count of $expected_count was never actually observed"
|
||||
fi
|
||||
docker exec "$container" fipsctl show peers >&2 || true
|
||||
fail "$container did not reach $expected_count connected peers in ${timeout}s (last answer: $count)"
|
||||
}
|
||||
|
||||
# Resolve `<npub>.fips` inside a container and print the AAAA answer.
|
||||
@@ -124,8 +149,20 @@ assert_baseline_loaded() {
|
||||
listing="$(docker exec "$container" nft list table inet fips)"
|
||||
# Default-deny is achieved via the trailing `counter drop` (chain
|
||||
# policy is `accept` for return-on-non-fips0 to work safely).
|
||||
if ! printf '%s' "$listing" | grep -q 'counter packets'; then
|
||||
fail "$container: counter drop rule missing from inet fips"
|
||||
# Require the verdict, not just the counter: `counter packets` alone
|
||||
# matches a counter rule with any verdict, including one that accepts.
|
||||
# nft renders the rule as `counter packets N bytes M drop`.
|
||||
#
|
||||
# This checks that a counted drop rule exists somewhere in the table,
|
||||
# not that it is the baseline's trailing one. A drop-in under
|
||||
# /etc/fips/fips.d/ contributing its own counted drop would satisfy
|
||||
# this even with the baseline rule removed. Asserting the rule's
|
||||
# position is a larger change than this finding calls for; the
|
||||
# drop-counter check at the end of the run is the one that reads the
|
||||
# trailing rule specifically.
|
||||
if ! printf '%s' "$listing" \
|
||||
| grep -qE 'counter packets [0-9]+ bytes [0-9]+ drop'; then
|
||||
fail "$container: trailing 'counter drop' rule missing from inet fips"
|
||||
fi
|
||||
if ! printf '%s' "$listing" | grep -q 'iifname != "fips0" return'; then
|
||||
fail "$container: non-fips0 early return rule missing"
|
||||
@@ -154,7 +191,15 @@ log "Generating firewall fixtures"
|
||||
|
||||
log "Starting firewall harness"
|
||||
docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
# --build only on the hand path. Under a harness, --skip-build means the caller
|
||||
# has already built the image this compose file names, and rebuilding it here
|
||||
# would overwrite that image from whatever the shared build context happens to
|
||||
# hold — which is how a suite ends up certifying binaries it was never given.
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
else
|
||||
docker compose -f "$COMPOSE_FILE" up -d
|
||||
fi
|
||||
|
||||
log "Waiting for fips0 on both nodes"
|
||||
wait_for_fips0 "$CONTAINER_A" 40
|
||||
@@ -191,7 +236,15 @@ fi
|
||||
# ── (a) Unallowed inbound is dropped ───────────────────────────────────
|
||||
log "Case (a): unallowed inbound TCP/${UNALLOWED_PORT} from node-a → node-b"
|
||||
# python3 http.server is already listening on :: per entrypoint default mode.
|
||||
# Use curl --max-time 5 — must time out (exit 28) or otherwise fail.
|
||||
#
|
||||
# The rule is a DROP, so the SYN is discarded with no RST and curl must
|
||||
# hit --max-time and exit 28. Assert exactly that rather than "any
|
||||
# non-zero rc": a REJECT, a closed port, an unroutable address or a
|
||||
# missing listener all fail too, with rc 7 or similar, and accepting
|
||||
# those would let the suite report a blocked connection when nothing was
|
||||
# ever blocked. Only 28 distinguishes "silently dropped" from "failed for
|
||||
# some other reason".
|
||||
CURL_TIMEOUT_RC=28
|
||||
set +e
|
||||
docker exec "$CONTAINER_A" curl -6 --silent --output /dev/null \
|
||||
--max-time 5 "http://[${ADDR_B}]:${UNALLOWED_PORT}/"
|
||||
@@ -200,21 +253,30 @@ set -e
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
fail "(a) connection to ${UNALLOWED_PORT} succeeded but should have been DROP'd (rc=0)"
|
||||
fi
|
||||
pass "(a) inbound TCP/${UNALLOWED_PORT} blocked (curl rc=$RC)"
|
||||
if [ "$RC" -ne "$CURL_TIMEOUT_RC" ]; then
|
||||
fail "(a) connection to ${UNALLOWED_PORT} failed with curl rc=$RC, expected $CURL_TIMEOUT_RC (timeout). A DROP produces no RST, so anything else means the connection failed for a reason other than the firewall dropping it"
|
||||
fi
|
||||
pass "(a) inbound TCP/${UNALLOWED_PORT} dropped (curl rc=$RC, timed out as expected)"
|
||||
|
||||
# ── (b) Outbound-initiated flow + conntrack reply ──────────────────────
|
||||
log "Case (b): node-b initiates outbound TCP, expects reply via conntrack"
|
||||
# node-b → node-a:8000 on the fips overlay. node-a has http.server on
|
||||
# [::]:8000 and is NOT firewalled, so this is purely a test of node-b's
|
||||
# outbound + ct state established,related path on the way back.
|
||||
#
|
||||
# mktemp rather than a fixed /tmp name: two concurrent runs of this suite
|
||||
# would otherwise share one host file, and either one's `rm` between the
|
||||
# other's write and read leaves an empty read that fails the http_code check
|
||||
# for a reason that has nothing to do with the firewall.
|
||||
CURL_OUT="$(mktemp)"
|
||||
set +e
|
||||
docker exec "$CONTAINER_B" curl -6 --silent --max-time 5 \
|
||||
--output /dev/null --write-out '%{http_code}' \
|
||||
"http://[${ADDR_A}]:${OUTBOUND_TARGET_PORT}/" >/tmp/fw_b_rc 2>/dev/null
|
||||
"http://[${ADDR_A}]:${OUTBOUND_TARGET_PORT}/" >"$CURL_OUT" 2>/dev/null
|
||||
RC=$?
|
||||
set -e
|
||||
HTTP_CODE="$(cat /tmp/fw_b_rc 2>/dev/null || true)"
|
||||
rm -f /tmp/fw_b_rc
|
||||
HTTP_CODE="$(cat "$CURL_OUT" 2>/dev/null || true)"
|
||||
rm -f "$CURL_OUT"
|
||||
if [ "$RC" -ne 0 ]; then
|
||||
fail "(b) outbound from node-b failed (curl rc=$RC, http=$HTTP_CODE) — conntrack reply path broken"
|
||||
fi
|
||||
@@ -237,8 +299,28 @@ fi
|
||||
|
||||
# ── Drop-counter sanity ────────────────────────────────────────────────
|
||||
log "Drop counter incremented (case a should have ticked it)"
|
||||
# Scope to a drop rule rather than any counter rule: `/counter packets/`
|
||||
# alone takes the first counter rule of whatever verdict.
|
||||
#
|
||||
# Extract the count by position within the matched text, not by field
|
||||
# number. `$3` assumes the line starts with `counter`, so on a rule like
|
||||
# `tcp dport 9 counter packets 42 bytes 3000 drop` it prints the port,
|
||||
# not the packet count -- a plausible shape, since fips.nft includes
|
||||
# /etc/fips/fips.d/*.nft ahead of the trailing drop and a drop-in may
|
||||
# legitimately add its own counted drop.
|
||||
#
|
||||
# Take the LAST match, not the first. Case (a) is an unallowed inbound
|
||||
# that matches no accept rule and falls through to the baseline
|
||||
# default-deny, which fips.nft emits as the final rule of the chain
|
||||
# (packaging/common/fips.nft, `counter drop` after the drop-in include).
|
||||
# A drop-in's own counted drop would otherwise be read instead, and it
|
||||
# has nothing to do with what case (a) exercised.
|
||||
DROP_PKTS="$(docker exec "$CONTAINER_B" nft list table inet fips \
|
||||
| awk '/counter packets/ && !seen { print $3; seen=1 }')"
|
||||
| awk 'match($0, /counter packets [0-9]+ bytes [0-9]+ drop/) {
|
||||
line = substr($0, RSTART); sub(/^counter packets /, "", line);
|
||||
split(line, f, " "); val = f[1]
|
||||
}
|
||||
END { if (val != "") print val }')"
|
||||
if [ -z "${DROP_PKTS:-}" ] || [ "$DROP_PKTS" -lt 1 ]; then
|
||||
fail "drop counter is $DROP_PKTS — case (a) should have produced drops"
|
||||
fi
|
||||
|
||||
@@ -476,13 +476,24 @@ phase_result() {
|
||||
}
|
||||
|
||||
# Count a pattern across all node logs.
|
||||
#
|
||||
# A node whose logs cannot be read makes the whole count unusable rather than
|
||||
# contributing 0. The previous form ended each read `| grep -cE … || true`, so a
|
||||
# failed `docker logs` yielded 0 for that node and the eight expect-zero
|
||||
# assertions in the GLOBAL_PATTERNS loop read a clean result from a node that was
|
||||
# never consulted. Same defect and same fix as rekey-test.sh.
|
||||
count_log_pattern() {
|
||||
local pattern="$1" total=0 n count
|
||||
local pattern="$1" total=0 n logs count
|
||||
for n in "${NODES[@]}"; do
|
||||
count=$(docker logs "${CONTAINER[$n]}" 2>&1 | grep -cE "$pattern" || true)
|
||||
if ! logs=$(docker logs "${CONTAINER[$n]}" 2>&1); then
|
||||
echo "unreadable:${CONTAINER[$n]}"
|
||||
return 1
|
||||
fi
|
||||
count=$(grep -cE "$pattern" <<<"$logs" || true)
|
||||
total=$((total + count))
|
||||
done
|
||||
echo "$total"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Per-node count of a pattern.
|
||||
@@ -846,15 +857,20 @@ declare -A GLOBAL_PATTERNS=(
|
||||
["ERROR"]="error-level log lines"
|
||||
["unknown FMP version|Unknown FMP version"]="unknown-FMP-version drops"
|
||||
["MMP link teardown"]="MMP link teardowns"
|
||||
["Excessive decrypt failures"]="excessive-decrypt-failure removals"
|
||||
["Excessive decryption failures"]="excessive-decryption-failure removals"
|
||||
["Session AEAD decryption failed"]="FSP AEAD decrypt failures"
|
||||
["Rekey msg2 processing failed"]="rekey msg2 failures"
|
||||
["Handshake failed|handshake failed|Handshake error"]="handshake failures"
|
||||
["Handshake failed|handshake failed"]="handshake failures"
|
||||
)
|
||||
|
||||
for pat in "${!GLOBAL_PATTERNS[@]}"; do
|
||||
desc="${GLOBAL_PATTERNS[$pat]}"
|
||||
total="$(count_log_pattern "$pat")"
|
||||
if ! total="$(count_log_pattern "$pat")"; then
|
||||
echo " FAIL $desc: node logs unreadable ($total), zero not established"
|
||||
FAILED=$((FAILED + 1))
|
||||
INTEROP_FAILURES+=("[log] $desc: node logs unreadable ($total)")
|
||||
continue
|
||||
fi
|
||||
if [ "$total" -eq 0 ]; then
|
||||
echo " PASS $desc: 0"
|
||||
PASSED=$((PASSED + 1))
|
||||
|
||||
@@ -93,13 +93,28 @@ def _convertbits(data, frombits, tobits):
|
||||
# --- public API ---
|
||||
|
||||
def derive(mesh_name, node_name):
|
||||
nsec_hex, npub, _ = derive_full(mesh_name, node_name)
|
||||
return nsec_hex, npub
|
||||
|
||||
|
||||
def derive_full(mesh_name, node_name):
|
||||
"""As `derive`, plus the NodeAddr the daemon will compute for this key.
|
||||
|
||||
NodeAddr is the first 16 bytes of SHA-256 over the serialized x-only
|
||||
public key (`src/identity/node_addr.rs:36-43`), and the mesh elects the
|
||||
numerically smallest one as root (`src/tree/state.rs:363-390`). Callers
|
||||
that need the tree's root to be predictable derive it from here rather
|
||||
than assuming it follows the node numbering.
|
||||
"""
|
||||
nsec_hex = hashlib.sha256(f"{mesh_name}|{node_name}".encode()).hexdigest()
|
||||
k = int(nsec_hex, 16)
|
||||
pub = _scalar_mult(k, (Gx, Gy))
|
||||
x_hex = format(pub[0], "064x")
|
||||
data_5bit = _convertbits(list(bytes.fromhex(x_hex)), 8, 5)
|
||||
x_bytes = bytes.fromhex(x_hex)
|
||||
data_5bit = _convertbits(list(x_bytes), 8, 5)
|
||||
npub = _bech32_encode("npub", data_5bit)
|
||||
return nsec_hex, npub
|
||||
node_addr = hashlib.sha256(x_bytes).hexdigest()[:32]
|
||||
return nsec_hex, npub, node_addr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -148,8 +148,11 @@ def _analyze_lines(result: AnalysisResult, source: str, log_text: str):
|
||||
# Session establishment
|
||||
if "Session established" in line:
|
||||
result.sessions_established.append((source, line))
|
||||
# Peer promotion
|
||||
if "Inbound peer promoted" in line or "Outbound handshake completed" in line:
|
||||
# Peer promotion. Match only the info-level string. "Outbound handshake
|
||||
# completed" is emitted from handle_msg2 on the same call path for the
|
||||
# same promotion, so matching it too double-counted every outbound
|
||||
# promotion in any run at debug level.
|
||||
if "Peer promoted to active" in line:
|
||||
result.peers_promoted.append((source, line))
|
||||
# Peer removal
|
||||
if "Peer removed" in line:
|
||||
@@ -174,7 +177,7 @@ def _analyze_lines(result: AnalysisResult, source: str, log_text: str):
|
||||
if "Rekey cutover complete" in line or "FSP rekey cutover complete" in line:
|
||||
result.rekey_cutovers.append((source, line))
|
||||
# Discovery
|
||||
if "Initiating LookupRequest" in line or "Discovery lookup initiated" in line:
|
||||
if "Discovery lookup initiated" in line:
|
||||
result.discovery_initiated.append((source, line))
|
||||
if "proof verified, route cached" in line:
|
||||
result.discovery_succeeded.append((source, line))
|
||||
@@ -197,8 +200,18 @@ def _analyze_lines(result: AnalysisResult, source: str, log_text: str):
|
||||
|
||||
|
||||
def collect_docker_logs(containers: list[str]) -> dict[str, str]:
|
||||
"""Collect logs from Docker containers, stripping ANSI codes."""
|
||||
"""Collect logs from Docker containers, stripping ANSI codes.
|
||||
|
||||
Raises RuntimeError if any container's log could not be read. `docker
|
||||
logs` reports a missing container on stderr and exits non-zero, so
|
||||
without the returncode check that reply was stored as though it were the
|
||||
node's own log and analysed as a mesh with no panics, no errors and no
|
||||
sessions — which reads exactly like a clean run. Substituting "" on the
|
||||
exception path has the same effect. Mirrors the fix already landed in
|
||||
chaos/sim/logs.py's collect_logs.
|
||||
"""
|
||||
logs = {}
|
||||
failed = []
|
||||
for name in containers:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -207,10 +220,26 @@ def collect_docker_logs(containers: list[str]) -> dict[str, str]:
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
raw = result.stdout + result.stderr
|
||||
logs[name] = strip_ansi(raw)
|
||||
except (subprocess.TimeoutExpired, Exception):
|
||||
logs[name] = ""
|
||||
except Exception as e:
|
||||
print(f"failed to collect logs from {name}: {e}", file=sys.stderr)
|
||||
failed.append(name)
|
||||
continue
|
||||
|
||||
if result.returncode != 0:
|
||||
print(
|
||||
f"docker logs {name} exited {result.returncode}: "
|
||||
f"{result.stderr.strip()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
failed.append(name)
|
||||
continue
|
||||
|
||||
# A container's own stderr arrives on our stderr, so on the success
|
||||
# path both streams are log content.
|
||||
logs[name] = strip_ansi(result.stdout + result.stderr)
|
||||
|
||||
if failed:
|
||||
raise RuntimeError("could not read logs from: " + ", ".join(failed))
|
||||
return logs
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
# These tests drive the convergence gate with synthetic connectivity
|
||||
# checks (ping_fn stand-ins) that report scripted PASSED/FAILED counts
|
||||
# keyed off the same SECONDS clock the gate uses. No containers or
|
||||
# network are involved, so the whole suite runs in a few seconds and is
|
||||
# safe to run in CI.
|
||||
# network are involved, so the suite is hermetic and safe to run in CI.
|
||||
#
|
||||
# It is not fast, though: it drives real timeouts against the real clock
|
||||
# and takes about 45 seconds, measured 2026-07-23. The header claimed "a
|
||||
# few seconds" from the day it was written until then, which nothing had
|
||||
# contradicted because no runner had ever invoked it.
|
||||
#
|
||||
# Run:
|
||||
# ./wait-converge-test.sh
|
||||
@@ -176,6 +180,47 @@ check "case4: returns 0 with 4 args" "$c4_rc_ok" "rc=$rc"
|
||||
c4_hold_ok=1; echo "$out" | grep -q "$HOLD_MSG" && c4_hold_ok=0
|
||||
check "case4: default slack triggered near-converged hold" "$c4_hold_ok"
|
||||
|
||||
# --- Case 5: wait_for_peers refuses a floor of zero -------------------
|
||||
#
|
||||
# The reader inside wait_for_peers falls back to 0 when a container does
|
||||
# not answer. That is safe against a floor of 1 or more, where 0 reads as
|
||||
# "not converged yet", and unsafe against a floor of 0, where the first
|
||||
# read from a dead container satisfies the wait immediately. This case is
|
||||
# the break-what-it-guards check for the rejection: drive the guard with a
|
||||
# floor of 0 and confirm it refuses, then drive the same dead container
|
||||
# with a floor of 1 and confirm the refusal is specific to the dangerous
|
||||
# input rather than a blanket failure.
|
||||
#
|
||||
# `docker` is stubbed to fail, which is what an unreachable container looks
|
||||
# like to this reader, so no container or network is involved and the suite
|
||||
# stays hermetic.
|
||||
echo
|
||||
echo "== Case 5: wait_for_peers refuses a zero floor =="
|
||||
docker() { return 1; }
|
||||
|
||||
out=$(wait_for_peers stub-container 0 2 2>&1); rc=$?
|
||||
echo "$out"
|
||||
c5_reject_ok=1; [ "$rc" -eq 2 ] && c5_reject_ok=0
|
||||
check "case5: floor of 0 is refused" "$c5_reject_ok" "rc=$rc"
|
||||
c5_msg_ok=1; echo "$out" | grep -q "refusing a minimum" && c5_msg_ok=0
|
||||
check "case5: refusal names the reason" "$c5_msg_ok"
|
||||
# The pre-guard behaviour, asserted so a regression is visible rather than
|
||||
# quiet: without the rejection this returned 0 on its first iteration
|
||||
# against a container that never answered.
|
||||
c5_notpass_ok=1; [ "$rc" -ne 0 ] && c5_notpass_ok=0
|
||||
check "case5: floor of 0 does not report success" "$c5_notpass_ok" "rc=$rc"
|
||||
|
||||
start=$SECONDS
|
||||
out=$(wait_for_peers stub-container 1 2 2>&1); rc=$?
|
||||
elapsed=$((SECONDS - start))
|
||||
echo "$out"
|
||||
c5_floor1_ok=1; [ "$rc" -eq 1 ] && c5_floor1_ok=0
|
||||
check "case5: floor of 1 times out rather than being refused" "$c5_floor1_ok" "rc=$rc"
|
||||
c5_polled_ok=1; [ "$elapsed" -ge 2 ] && c5_polled_ok=0
|
||||
check "case5: floor of 1 polled its full budget" "$c5_polled_ok" "elapsed=${elapsed}s >= 2s"
|
||||
|
||||
unset -f docker
|
||||
|
||||
# --- Summary ----------------------------------------------------------
|
||||
echo
|
||||
echo "=============================================="
|
||||
|
||||
@@ -1,44 +1,47 @@
|
||||
#!/bin/bash
|
||||
# Shared convergence wait helpers for FIPS integration tests.
|
||||
#
|
||||
# Source this file to get wait_for_links(), wait_for_peers(), and
|
||||
# wait_until_connected().
|
||||
# Source this file to get wait_for_peers() and wait_until_connected().
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "$0")/../../lib/wait-converge.sh"
|
||||
# wait_for_links <container> <min_links> [timeout_secs]
|
||||
# wait_for_peers <container> <min_peers> [timeout_secs]
|
||||
# wait_until_connected <ping_fn> <max_secs> <stall_secs> [poll_secs] \
|
||||
# [near_converged_slack]
|
||||
|
||||
# Wait until a container has at least min_links active links.
|
||||
# Returns 0 on success, 1 on timeout.
|
||||
wait_for_links() {
|
||||
local container="$1"
|
||||
local min_links="$2"
|
||||
local timeout="${3:-30}"
|
||||
|
||||
for i in $(seq 1 "$timeout"); do
|
||||
local count
|
||||
count=$(docker exec "$container" fipsctl show links 2>/dev/null \
|
||||
| python3 -c "import sys,json; print(len(json.load(sys.stdin).get('links',[])))" 2>/dev/null || echo 0)
|
||||
if [ "$count" -ge "$min_links" ]; then
|
||||
echo " $container: $count link(s) after ${i}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " $container: TIMEOUT waiting for $min_links link(s) after ${timeout}s"
|
||||
return 1
|
||||
}
|
||||
#
|
||||
# There was a wait_for_links() here. It was removed rather than kept for
|
||||
# symmetry: it had no caller anywhere in the tree on any branch, and its
|
||||
# reader carried the same failure-to-zero fallback wait_for_peers does. An
|
||||
# uncalled helper cannot be wrong today, so the risk was that the first
|
||||
# caller to appear would inherit the hazard below without the reasoning
|
||||
# that goes with it. `git log` has the implementation if one is needed.
|
||||
|
||||
# Wait until a container has at least min_peers connected peers.
|
||||
# Returns 0 on success, 1 on timeout.
|
||||
#
|
||||
# The read below falls back to 0 when the container does not answer, which
|
||||
# is safe ONLY because this is a floor: a fallback of 0 reads as "not
|
||||
# converged yet", the loop keeps polling, and a container that never answers
|
||||
# times out and returns 1. That safety is a property of the comparison, not
|
||||
# of the reader.
|
||||
#
|
||||
# A minimum of 0 inverts it. `[ 0 -ge 0 ]` is true, so the first read from a
|
||||
# dead container would satisfy the wait immediately and the caller would
|
||||
# proceed as though convergence had been observed. No caller passes 0, and
|
||||
# rejecting it here means none can start to — which is cheaper than auditing
|
||||
# every future caller, and is why this is a hard error rather than a warning.
|
||||
# A caller that genuinely wants to assert "exactly zero peers" needs a reader
|
||||
# that distinguishes no-answer from zero, not this floor.
|
||||
wait_for_peers() {
|
||||
local container="$1"
|
||||
local min_peers="$2"
|
||||
local timeout="${3:-30}"
|
||||
|
||||
if [ "$min_peers" -lt 1 ]; then
|
||||
echo " wait_for_peers: refusing a minimum of $min_peers for $container — a floor of 0 is satisfied by a container that never answered" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
for i in $(seq 1 "$timeout"); do
|
||||
local count
|
||||
count=$(docker exec "$container" fipsctl show peers 2>/dev/null \
|
||||
|
||||
@@ -135,9 +135,10 @@ require_docker() {
|
||||
}
|
||||
|
||||
require_test_image() {
|
||||
if ! docker image inspect fips-test:latest >/dev/null 2>&1; then
|
||||
echo "ERROR: fips-test:latest not present" >&2
|
||||
echo "Build it once with: bash testing/ci-local.sh --build-only" >&2
|
||||
local img="${FIPS_TEST_IMAGE:-fips-test:latest}"
|
||||
if ! docker image inspect "$img" >/dev/null 2>&1; then
|
||||
echo "ERROR: $img not present" >&2
|
||||
echo "Build it once with: bash testing/scripts/build.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
generated-configs
|
||||
generated-configs*
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Override: attach the NAT lab's two bridges to pre-created external networks
|
||||
# instead of letting compose create them from the base file's fixed pins.
|
||||
#
|
||||
# Applied only by ci-local.sh's run_nat / run_nostr_publish_consume /
|
||||
# run_stun_faults, which claim a free /24 per network per suite invocation
|
||||
# (ci_claim_nat_networks) and export FIPS_NAT_WAN_NET / FIPS_NAT_LAN_NET and
|
||||
# NAT_WAN_PREFIX / NAT_LAN_PREFIX before `up`. That is what makes two
|
||||
# concurrent local NAT runs collision-safe: each claims distinct ranges, so
|
||||
# neither requests address space the other holds.
|
||||
#
|
||||
# The GitHub matrix, testing/mesh-lab/run-loop.sh, the README invocation and
|
||||
# any standalone `docker compose up` do NOT apply this overlay; they use the
|
||||
# base file's normal networks with the `:-` defaults, which render today's
|
||||
# exact addresses. This mirrors static/docker-compose.gateway-external-net.yml
|
||||
# and sidecar/docker-compose.external-net.yml.
|
||||
#
|
||||
# One file carries both networks: they are claimed and released together, and
|
||||
# splitting them would let a suite attach to one claimed and one compose-made
|
||||
# bridge.
|
||||
networks:
|
||||
wan:
|
||||
external: true
|
||||
name: ${FIPS_NAT_WAN_NET:-fips-nat-wan}
|
||||
shared-lan:
|
||||
external: true
|
||||
name: ${FIPS_NAT_LAN_NET:-fips-nat-shared-lan}
|
||||
@@ -5,20 +5,20 @@ networks:
|
||||
- "com.corganlabs.fips-ci=1"
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.31.254.0/24
|
||||
- subnet: ${NAT_WAN_PREFIX:-172.31.254}.0/24
|
||||
shared-lan:
|
||||
driver: bridge
|
||||
labels:
|
||||
- "com.corganlabs.fips-ci=1"
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.31.10.0/24
|
||||
- subnet: ${NAT_LAN_PREFIX:-172.31.10}.0/24
|
||||
|
||||
volumes:
|
||||
relay-data:
|
||||
|
||||
x-fips-common: &fips-common
|
||||
image: fips-test:latest
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
@@ -42,9 +42,9 @@ services:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
networks:
|
||||
wan:
|
||||
ipv4_address: 172.31.254.30
|
||||
ipv4_address: ${NAT_WAN_PREFIX:-172.31.254}.30
|
||||
shared-lan:
|
||||
ipv4_address: 172.31.10.30
|
||||
ipv4_address: ${NAT_LAN_PREFIX:-172.31.10}.30
|
||||
|
||||
stun:
|
||||
build:
|
||||
@@ -53,9 +53,9 @@ services:
|
||||
restart: "no"
|
||||
networks:
|
||||
wan:
|
||||
ipv4_address: 172.31.254.40
|
||||
ipv4_address: ${NAT_WAN_PREFIX:-172.31.254}.40
|
||||
shared-lan:
|
||||
ipv4_address: 172.31.10.40
|
||||
ipv4_address: ${NAT_LAN_PREFIX:-172.31.10}.40
|
||||
|
||||
nat-a:
|
||||
build:
|
||||
@@ -74,11 +74,11 @@ services:
|
||||
- WAN_IF=eth0
|
||||
- LAN_HOST=172.31.1.10
|
||||
- LAN_SUBNET=172.31.1.0/24
|
||||
- WAN_SUBNET=172.31.254.0/24
|
||||
- WAN_GATEWAY=172.31.254.1
|
||||
- WAN_SUBNET=${NAT_WAN_PREFIX:-172.31.254}.0/24
|
||||
- WAN_GATEWAY=${NAT_WAN_PREFIX:-172.31.254}.1
|
||||
networks:
|
||||
wan:
|
||||
ipv4_address: 172.31.254.10
|
||||
ipv4_address: ${NAT_WAN_PREFIX:-172.31.254}.10
|
||||
|
||||
nat-b:
|
||||
build:
|
||||
@@ -97,11 +97,11 @@ services:
|
||||
- WAN_IF=eth0
|
||||
- LAN_HOST=172.31.2.10
|
||||
- LAN_SUBNET=172.31.2.0/24
|
||||
- WAN_SUBNET=172.31.254.0/24
|
||||
- WAN_GATEWAY=172.31.254.1
|
||||
- WAN_SUBNET=${NAT_WAN_PREFIX:-172.31.254}.0/24
|
||||
- WAN_GATEWAY=${NAT_WAN_PREFIX:-172.31.254}.1
|
||||
networks:
|
||||
wan:
|
||||
ipv4_address: 172.31.254.11
|
||||
ipv4_address: ${NAT_WAN_PREFIX:-172.31.254}.11
|
||||
|
||||
cone-a:
|
||||
<<: *fips-common
|
||||
@@ -117,16 +117,16 @@ services:
|
||||
environment:
|
||||
- RUST_LOG=info,fips::discovery::nostr=debug,fips::node::lifecycle=debug
|
||||
- DATA_IF=eth0
|
||||
- ROUTE_SUBNET=172.31.254.0/24
|
||||
- ROUTE_SUBNET=${NAT_WAN_PREFIX:-172.31.254}.0/24
|
||||
- ROUTE_VIA=172.31.1.254
|
||||
- RELAY_HOST=172.31.254.30
|
||||
- RELAY_HOST=${NAT_WAN_PREFIX:-172.31.254}.30
|
||||
- RELAY_PORT=7777
|
||||
- STUN_HOST=172.31.254.40
|
||||
- STUN_HOST=${NAT_WAN_PREFIX:-172.31.254}.40
|
||||
- STUN_PORT=3478
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./node/entrypoint.sh:/usr/local/bin/nat-node-entrypoint.sh:ro
|
||||
- ./generated-configs/cone/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/cone/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
network_mode: none
|
||||
|
||||
cone-b:
|
||||
@@ -143,16 +143,16 @@ services:
|
||||
environment:
|
||||
- RUST_LOG=info,fips::discovery::nostr=debug,fips::node::lifecycle=debug
|
||||
- DATA_IF=eth0
|
||||
- ROUTE_SUBNET=172.31.254.0/24
|
||||
- ROUTE_SUBNET=${NAT_WAN_PREFIX:-172.31.254}.0/24
|
||||
- ROUTE_VIA=172.31.2.254
|
||||
- RELAY_HOST=172.31.254.30
|
||||
- RELAY_HOST=${NAT_WAN_PREFIX:-172.31.254}.30
|
||||
- RELAY_PORT=7777
|
||||
- STUN_HOST=172.31.254.40
|
||||
- STUN_HOST=${NAT_WAN_PREFIX:-172.31.254}.40
|
||||
- STUN_PORT=3478
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./node/entrypoint.sh:/usr/local/bin/nat-node-entrypoint.sh:ro
|
||||
- ./generated-configs/cone/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/cone/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
network_mode: none
|
||||
|
||||
symmetric-a:
|
||||
@@ -169,16 +169,16 @@ services:
|
||||
environment:
|
||||
- RUST_LOG=info,fips::discovery::nostr=debug,fips::node::lifecycle=debug
|
||||
- DATA_IF=eth0
|
||||
- ROUTE_SUBNET=172.31.254.0/24
|
||||
- ROUTE_SUBNET=${NAT_WAN_PREFIX:-172.31.254}.0/24
|
||||
- ROUTE_VIA=172.31.1.254
|
||||
- RELAY_HOST=172.31.254.30
|
||||
- RELAY_HOST=${NAT_WAN_PREFIX:-172.31.254}.30
|
||||
- RELAY_PORT=7777
|
||||
- STUN_HOST=172.31.254.40
|
||||
- STUN_HOST=${NAT_WAN_PREFIX:-172.31.254}.40
|
||||
- STUN_PORT=3478
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./node/entrypoint.sh:/usr/local/bin/nat-node-entrypoint.sh:ro
|
||||
- ./generated-configs/symmetric/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/symmetric/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
network_mode: none
|
||||
|
||||
symmetric-b:
|
||||
@@ -195,16 +195,16 @@ services:
|
||||
environment:
|
||||
- RUST_LOG=info,fips::discovery::nostr=debug,fips::node::lifecycle=debug
|
||||
- DATA_IF=eth0
|
||||
- ROUTE_SUBNET=172.31.254.0/24
|
||||
- ROUTE_SUBNET=${NAT_WAN_PREFIX:-172.31.254}.0/24
|
||||
- ROUTE_VIA=172.31.2.254
|
||||
- RELAY_HOST=172.31.254.30
|
||||
- RELAY_HOST=${NAT_WAN_PREFIX:-172.31.254}.30
|
||||
- RELAY_PORT=7777
|
||||
- STUN_HOST=172.31.254.40
|
||||
- STUN_HOST=${NAT_WAN_PREFIX:-172.31.254}.40
|
||||
- STUN_PORT=3478
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./node/entrypoint.sh:/usr/local/bin/nat-node-entrypoint.sh:ro
|
||||
- ./generated-configs/symmetric/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/symmetric/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
network_mode: none
|
||||
|
||||
lan-a:
|
||||
@@ -217,10 +217,10 @@ services:
|
||||
- stun
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/lan/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/lan/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
shared-lan:
|
||||
ipv4_address: 172.31.10.10
|
||||
ipv4_address: ${NAT_LAN_PREFIX:-172.31.10}.10
|
||||
|
||||
lan-b:
|
||||
<<: *fips-common
|
||||
@@ -232,10 +232,10 @@ services:
|
||||
- stun
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/lan/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/lan/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
shared-lan:
|
||||
ipv4_address: 172.31.10.11
|
||||
ipv4_address: ${NAT_LAN_PREFIX:-172.31.10}.11
|
||||
|
||||
# ── Nostr publish/consume profile ──────────────────────────────────────
|
||||
# Two FIPS daemons + the existing strfry relay, exercising the overlay
|
||||
@@ -254,10 +254,10 @@ services:
|
||||
- stun
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/nostr-publish-consume/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/nostr-publish-consume/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
shared-lan:
|
||||
ipv4_address: 172.31.10.20
|
||||
ipv4_address: ${NAT_LAN_PREFIX:-172.31.10}.20
|
||||
|
||||
nostr-pub-b:
|
||||
<<: *fips-common
|
||||
@@ -269,10 +269,10 @@ services:
|
||||
- stun
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/nostr-publish-consume/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/nostr-publish-consume/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
shared-lan:
|
||||
ipv4_address: 172.31.10.21
|
||||
ipv4_address: ${NAT_LAN_PREFIX:-172.31.10}.21
|
||||
|
||||
# ── STUN fault-injection profile ───────────────────────────────────────
|
||||
# One FIPS daemon + a netns-sharing shim that injects tc/iptables faults
|
||||
@@ -298,10 +298,10 @@ services:
|
||||
- stun
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/stun-faults/stun-fault-node.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/stun-faults/stun-fault-node.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
shared-lan:
|
||||
ipv4_address: 172.31.10.50
|
||||
ipv4_address: ${NAT_LAN_PREFIX:-172.31.10}.50
|
||||
|
||||
# Fault-free peer that publishes a valid overlay advert, so the
|
||||
# fault-node's NAT-traversal attempt actually reaches
|
||||
@@ -319,13 +319,13 @@ services:
|
||||
- stun
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/stun-faults/stun-fault-peer.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/stun-faults/stun-fault-peer.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
shared-lan:
|
||||
ipv4_address: 172.31.10.51
|
||||
ipv4_address: ${NAT_LAN_PREFIX:-172.31.10}.51
|
||||
|
||||
stun-fault-shim:
|
||||
image: fips-test:latest
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
profiles: ["stun-faults"]
|
||||
container_name: fips-nat-stun-fault-shim${FIPS_CI_NAME_SUFFIX:-}
|
||||
depends_on:
|
||||
|
||||
@@ -6,7 +6,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NAT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ROOT_DIR="$(cd "$NAT_DIR/../.." && pwd)"
|
||||
DERIVE_KEYS="$ROOT_DIR/testing/lib/derive_keys.py"
|
||||
OUTPUT_DIR="$NAT_DIR/generated-configs"
|
||||
# Per-run, for the same reason static's and firewall's generators are: this
|
||||
# directory is bind-mounted by compose and read back by the suite scripts
|
||||
# AFTER the containers are up, so a shared path lets a second run's generator
|
||||
# overwrite the npubs a first run is about to ping. Empty suffix renders
|
||||
# today's plain path, so a bare invocation is unchanged.
|
||||
OUTPUT_DIR="$NAT_DIR/generated-configs${FIPS_CI_NAME_SUFFIX:-}"
|
||||
SCENARIO="${1:?usage: generate-configs.sh <cone|symmetric|lan> [mesh-name]}"
|
||||
MESH_NAME="${2:-nat-lab-$(date +%s)-$$}"
|
||||
|
||||
@@ -28,12 +33,18 @@ npub_a="$(echo "$keys_a" | awk -F= '/^npub=/{print $2}')"
|
||||
nsec_b="$(echo "$keys_b" | awk -F= '/^nsec=/{print $2}')"
|
||||
npub_b="$(echo "$keys_b" | awk -F= '/^npub=/{print $2}')"
|
||||
|
||||
relay_addr="ws://172.31.254.30:7777"
|
||||
stun_addr="stun:172.31.254.40:3478"
|
||||
# The two lab bridges. ci-local.sh claims a free /24 for each per run and
|
||||
# exports these; unset renders the addresses the lab has always used, so a
|
||||
# bare invocation and the GitHub matrix are unaffected.
|
||||
wan="${NAT_WAN_PREFIX:-172.31.254}"
|
||||
lan="${NAT_LAN_PREFIX:-172.31.10}"
|
||||
|
||||
relay_addr="ws://${wan}.30:7777"
|
||||
stun_addr="stun:${wan}.40:3478"
|
||||
if [ "$SCENARIO" = "lan" ] || [ "$SCENARIO" = "nostr-publish-consume" ] \
|
||||
|| [ "$SCENARIO" = "stun-faults" ]; then
|
||||
relay_addr="ws://172.31.10.30:7777"
|
||||
stun_addr="stun:172.31.10.40:3478"
|
||||
relay_addr="ws://${lan}.30:7777"
|
||||
stun_addr="stun:${lan}.40:3478"
|
||||
fi
|
||||
|
||||
peer_block_a=$(cat <<EOF
|
||||
@@ -58,10 +69,10 @@ EOF
|
||||
|
||||
if [ "$SCENARIO" = "symmetric" ]; then
|
||||
peer_block_a="$peer_block_a"$'\n'" - transport: tcp
|
||||
addr: \"172.31.254.11:8443\"
|
||||
addr: \"${wan}.11:8443\"
|
||||
priority: 20"
|
||||
peer_block_b="$peer_block_b"$'\n'" - transport: tcp
|
||||
addr: \"172.31.254.10:8443\"
|
||||
addr: \"${wan}.10:8443\"
|
||||
priority: 20"
|
||||
fi
|
||||
|
||||
|
||||
@@ -9,6 +9,19 @@ BUILD_SCRIPT="$ROOT_DIR/testing/scripts/build.sh"
|
||||
GENERATE_SCRIPT="$SCRIPT_DIR/generate-configs.sh"
|
||||
TOPOLOGY_SCRIPT="$SCRIPT_DIR/setup-topology.sh"
|
||||
WAIT_LIB="$ROOT_DIR/testing/lib/wait-converge.sh"
|
||||
# Must track generate-configs.sh's OUTPUT_DIR and the compose bind-mounts: the
|
||||
# npubs are read back here after the containers are up, so reading a different
|
||||
# directory than the one the generator wrote pings an npub no node owns.
|
||||
CONFIG_DIR="$NAT_DIR/generated-configs${FIPS_CI_NAME_SUFFIX:-}"
|
||||
|
||||
# The two lab bridges. ci-local.sh claims a free /24 for each per run and
|
||||
# exports these; unset renders the addresses the lab has always used, so the
|
||||
# GitHub matrix, mesh-lab/run-loop.sh and a bare run are unaffected. The
|
||||
# router-side LANs (172.31.1.x / 172.31.2.x) are deliberately NOT parameterized:
|
||||
# they live inside per-container network namespaces, never become docker
|
||||
# networks, and so cannot collide across runs.
|
||||
NAT_WAN="${NAT_WAN_PREFIX:-172.31.254}"
|
||||
NAT_LAN="${NAT_LAN_PREFIX:-172.31.10}"
|
||||
|
||||
SCENARIO="${1:-all}"
|
||||
COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml")
|
||||
@@ -87,9 +100,9 @@ PY
|
||||
|
||||
dump_fips_state() {
|
||||
local container="$1"
|
||||
local relay_host="${2:-172.31.254.30}"
|
||||
local relay_host="${2:-${NAT_WAN}.30}"
|
||||
local relay_port="${3:-7777}"
|
||||
local stun_host="${4:-172.31.254.40}"
|
||||
local stun_host="${4:-${NAT_WAN}.40}"
|
||||
local stun_port="${5:-3478}"
|
||||
dump_container_state "$container"
|
||||
echo ""
|
||||
@@ -114,7 +127,7 @@ dump_fips_state() {
|
||||
|
||||
dump_node_udp_probe() {
|
||||
local node="$1"
|
||||
local stun_host="${2:-172.31.254.40}"
|
||||
local stun_host="${2:-${NAT_WAN}.40}"
|
||||
local stun_port="${3:-3478}"
|
||||
|
||||
echo ""
|
||||
@@ -122,7 +135,12 @@ dump_node_udp_probe() {
|
||||
docker exec "$node" sh -lc 'ss -H -uanp 2>/dev/null || ss -H -uan 2>/dev/null || netstat -anu 2>/dev/null' 2>&1 || true
|
||||
echo ""
|
||||
echo "--- $node: UDP routes to STUN and peer WANs ---"
|
||||
docker exec "$node" sh -lc 'for ip in 172.31.254.40 172.31.254.10 172.31.254.11; do ip route get "$ip"; done' 2>&1 || true
|
||||
# Double-quoted so THIS shell expands NAT_WAN; the loop variable is escaped
|
||||
# so the container's shell still expands that one. Left single-quoted, the
|
||||
# literal string ${NAT_WAN}.40 would reach `ip route get` and the probe
|
||||
# would stop probing without anything going red — these are diagnostic
|
||||
# paths, so the loss would only surface during a failure investigation.
|
||||
docker exec "$node" sh -lc "for ip in ${NAT_WAN}.40 ${NAT_WAN}.10 ${NAT_WAN}.11; do ip route get \"\$ip\"; done" 2>&1 || true
|
||||
|
||||
local capture_file
|
||||
capture_file="$(mktemp)"
|
||||
@@ -151,7 +169,7 @@ dump_node_udp_probe() {
|
||||
dump_router_udp_probe() {
|
||||
local router="$1"
|
||||
local source_node="$2"
|
||||
local stun_host="${3:-172.31.254.40}"
|
||||
local stun_host="${3:-${NAT_WAN}.40}"
|
||||
local stun_port="${4:-3478}"
|
||||
|
||||
echo ""
|
||||
@@ -163,7 +181,9 @@ dump_router_udp_probe() {
|
||||
docker exec "$router" sh -lc 'iptables -vnL FORWARD; echo; iptables -t nat -vnL POSTROUTING' 2>&1 || true
|
||||
echo ""
|
||||
echo "--- $router: UDP routes to STUN and peer WANs ---"
|
||||
docker exec "$router" sh -lc 'for ip in 172.31.254.40 172.31.254.10 172.31.254.11; do ip route get "$ip"; done' 2>&1 || true
|
||||
# See the note in dump_node_udp_probe: outer shell expands NAT_WAN, inner
|
||||
# shell expands the loop variable.
|
||||
docker exec "$router" sh -lc "for ip in ${NAT_WAN}.40 ${NAT_WAN}.10 ${NAT_WAN}.11; do ip route get \"\$ip\"; done" 2>&1 || true
|
||||
|
||||
local capture_file
|
||||
capture_file="$(mktemp)"
|
||||
@@ -195,7 +215,7 @@ dump_router_udp_probe() {
|
||||
|
||||
dump_stun_udp_probe() {
|
||||
local source_node="$1"
|
||||
local stun_host="${2:-172.31.254.40}"
|
||||
local stun_host="${2:-${NAT_WAN}.40}"
|
||||
local stun_port="${3:-3478}"
|
||||
local helper_image
|
||||
helper_image="$(helper_tcpdump_image)"
|
||||
@@ -225,9 +245,9 @@ dump_stun_udp_probe() {
|
||||
dump_cone_diagnostics() {
|
||||
echo ""
|
||||
echo "=== cone diagnostics ==="
|
||||
dump_fips_state fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_fips_state fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} ${NAT_WAN}.30 7777 ${NAT_WAN}.40 3478
|
||||
dump_node_udp_probe fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_fips_state fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_fips_state fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} ${NAT_WAN}.30 7777 ${NAT_WAN}.40 3478
|
||||
dump_node_udp_probe fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-router-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_router_udp_probe fips-nat-router-a${FIPS_CI_NAME_SUFFIX:-} fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
@@ -242,8 +262,8 @@ dump_cone_diagnostics() {
|
||||
dump_symmetric_diagnostics() {
|
||||
echo ""
|
||||
echo "=== symmetric diagnostics ==="
|
||||
dump_fips_state fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_fips_state fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} 172.31.254.30 7777 172.31.254.40 3478
|
||||
dump_fips_state fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} ${NAT_WAN}.30 7777 ${NAT_WAN}.40 3478
|
||||
dump_fips_state fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} ${NAT_WAN}.30 7777 ${NAT_WAN}.40 3478
|
||||
dump_container_state fips-nat-router-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-router-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-relay${FIPS_CI_NAME_SUFFIX:-}
|
||||
@@ -253,8 +273,8 @@ dump_symmetric_diagnostics() {
|
||||
dump_lan_diagnostics() {
|
||||
echo ""
|
||||
echo "=== lan diagnostics ==="
|
||||
dump_fips_state fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} 172.31.10.30 7777 172.31.10.40 3478
|
||||
dump_fips_state fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} 172.31.10.30 7777 172.31.10.40 3478
|
||||
dump_fips_state fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} ${NAT_LAN}.30 7777 ${NAT_LAN}.40 3478
|
||||
dump_fips_state fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} ${NAT_LAN}.30 7777 ${NAT_LAN}.40 3478
|
||||
dump_container_state fips-nat-relay${FIPS_CI_NAME_SUFFIX:-}
|
||||
dump_container_state fips-nat-stun${FIPS_CI_NAME_SUFFIX:-}
|
||||
}
|
||||
@@ -262,10 +282,21 @@ dump_lan_diagnostics() {
|
||||
trap 'echo ""; echo "NAT test interrupted"; cleanup; exit 130' INT TERM
|
||||
|
||||
require_test_image() {
|
||||
if ! docker image inspect fips-test:latest >/dev/null 2>&1; then
|
||||
echo "fips-test:latest not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
local img="${FIPS_TEST_IMAGE:-fips-test:latest}"
|
||||
if docker image inspect "$img" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# Building here is right for a hand run and wrong under a harness. When
|
||||
# FIPS_TEST_IMAGE is set the caller has already built the image it named, so
|
||||
# a miss means something upstream is broken; building a substitute would
|
||||
# hide that and run binaries nobody asked for.
|
||||
if [ -n "${FIPS_TEST_IMAGE:-}" ]; then
|
||||
echo "ERROR: $img not present, and FIPS_TEST_IMAGE names the caller's own image" >&2
|
||||
echo "The harness that set it is expected to have built it." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$img not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
}
|
||||
|
||||
require_docker_daemon() {
|
||||
@@ -316,7 +347,7 @@ require_bootstrap_activity() {
|
||||
local container="$1"
|
||||
local logs
|
||||
logs="$(docker logs "$container" 2>&1 || true)"
|
||||
if ! grep -Eq "bootstrap failed|Started Nostr( UDP)? NAT traversal attempt" <<<"$logs"; then
|
||||
if ! grep -Eq "Started Nostr( UDP)? NAT traversal attempt" <<<"$logs"; then
|
||||
echo "Expected bootstrap activity in ${container} logs" >&2
|
||||
return 1
|
||||
fi
|
||||
@@ -342,12 +373,12 @@ run_cone() {
|
||||
dump_cone_diagnostics
|
||||
return 1
|
||||
}
|
||||
assert_peer_path fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} udp 172.31.254.
|
||||
assert_peer_path fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} udp 172.31.254.
|
||||
assert_link_path fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} 172.31.254.
|
||||
assert_link_path fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} 172.31.254.
|
||||
assert_peer_path fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} udp ${NAT_WAN}.
|
||||
assert_peer_path fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} udp ${NAT_WAN}.
|
||||
assert_link_path fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} ${NAT_WAN}.
|
||||
assert_link_path fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} ${NAT_WAN}.
|
||||
# shellcheck disable=SC1090
|
||||
source "$NAT_DIR/generated-configs/cone/npubs.env"
|
||||
source "$CONFIG_DIR/cone/npubs.env"
|
||||
ping_peer fips-nat-cone-a${FIPS_CI_NAME_SUFFIX:-} "$NPUB_B"
|
||||
ping_peer fips-nat-cone-b${FIPS_CI_NAME_SUFFIX:-} "$NPUB_A"
|
||||
cleanup
|
||||
@@ -367,14 +398,14 @@ run_symmetric() {
|
||||
dump_symmetric_diagnostics
|
||||
return 1
|
||||
}
|
||||
assert_peer_path fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} tcp 172.31.254.11:
|
||||
assert_peer_path fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} tcp 172.31.254.10:
|
||||
assert_link_path fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} 172.31.254.11:
|
||||
assert_link_path fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} 172.31.254.10:
|
||||
assert_peer_path fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} tcp ${NAT_WAN}.11:
|
||||
assert_peer_path fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} tcp ${NAT_WAN}.10:
|
||||
assert_link_path fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} ${NAT_WAN}.11:
|
||||
assert_link_path fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} ${NAT_WAN}.10:
|
||||
require_bootstrap_activity fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
require_bootstrap_activity fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
# shellcheck disable=SC1090
|
||||
source "$NAT_DIR/generated-configs/symmetric/npubs.env"
|
||||
source "$CONFIG_DIR/symmetric/npubs.env"
|
||||
ping_peer fips-nat-symmetric-a${FIPS_CI_NAME_SUFFIX:-} "$NPUB_B"
|
||||
ping_peer fips-nat-symmetric-b${FIPS_CI_NAME_SUFFIX:-} "$NPUB_A"
|
||||
cleanup
|
||||
@@ -393,12 +424,12 @@ run_lan() {
|
||||
dump_lan_diagnostics
|
||||
return 1
|
||||
}
|
||||
assert_peer_path fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} udp 172.31.10.
|
||||
assert_peer_path fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} udp 172.31.10.
|
||||
assert_link_path fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} 172.31.10.
|
||||
assert_link_path fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} 172.31.10.
|
||||
assert_peer_path fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} udp ${NAT_LAN}.
|
||||
assert_peer_path fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} udp ${NAT_LAN}.
|
||||
assert_link_path fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} ${NAT_LAN}.
|
||||
assert_link_path fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} ${NAT_LAN}.
|
||||
# shellcheck disable=SC1090
|
||||
source "$NAT_DIR/generated-configs/lan/npubs.env"
|
||||
source "$CONFIG_DIR/lan/npubs.env"
|
||||
ping_peer fips-nat-lan-a${FIPS_CI_NAME_SUFFIX:-} "$NPUB_B"
|
||||
ping_peer fips-nat-lan-b${FIPS_CI_NAME_SUFFIX:-} "$NPUB_A"
|
||||
# Skip the final teardown when the mesh-lab harness wraps this
|
||||
|
||||
@@ -21,13 +21,32 @@ ROOT_DIR="$(cd "$NAT_DIR/../.." && pwd)"
|
||||
BUILD_SCRIPT="$ROOT_DIR/testing/scripts/build.sh"
|
||||
GENERATE_SCRIPT="$SCRIPT_DIR/generate-configs.sh"
|
||||
WAIT_LIB="$ROOT_DIR/testing/lib/wait-converge.sh"
|
||||
# Must track generate-configs.sh's OUTPUT_DIR and the compose bind-mounts.
|
||||
CONFIG_DIR="$NAT_DIR/generated-configs${FIPS_CI_NAME_SUFFIX:-}"
|
||||
|
||||
PROFILE="nostr-publish-consume"
|
||||
SCENARIO="$PROFILE"
|
||||
COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml")
|
||||
|
||||
# Optional extra compose-file overlay chain (colon-separated paths), matching
|
||||
# nat-test.sh. ci-local.sh appends testing/nat/docker-compose.external-net.yml
|
||||
# here so this suite attaches to the networks the run already claimed; without
|
||||
# the hook compose would create its own from the base file's subnet and collide
|
||||
# with the run's own claim. Paths are relative to ROOT_DIR unless absolute.
|
||||
if [ -n "${FIPS_NAT_EXTRA_COMPOSE:-}" ]; then
|
||||
IFS=':' read -ra _NAT_EXTRA <<< "${FIPS_NAT_EXTRA_COMPOSE}"
|
||||
for _f in "${_NAT_EXTRA[@]}"; do
|
||||
case "$_f" in
|
||||
/*) COMPOSE+=(-f "$_f") ;;
|
||||
*) COMPOSE+=(-f "$ROOT_DIR/$_f") ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
NODE_A="fips-nat-nostr-pub-a${FIPS_CI_NAME_SUFFIX:-}"
|
||||
NODE_B="fips-nat-nostr-pub-b${FIPS_CI_NAME_SUFFIX:-}"
|
||||
RELAY_HOST="172.31.10.30"
|
||||
# Claimed per run by ci-local.sh; unset renders the lab's historical address.
|
||||
RELAY_HOST="${NAT_LAN_PREFIX:-172.31.10}.30"
|
||||
RELAY_PORT=7777
|
||||
RELAY_CONTAINER="fips-nat-relay${FIPS_CI_NAME_SUFFIX:-}"
|
||||
|
||||
@@ -49,10 +68,21 @@ require_docker_daemon() {
|
||||
}
|
||||
|
||||
require_test_image() {
|
||||
if ! docker image inspect fips-test:latest >/dev/null 2>&1; then
|
||||
echo "fips-test:latest not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
local img="${FIPS_TEST_IMAGE:-fips-test:latest}"
|
||||
if docker image inspect "$img" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# Building here is right for a hand run and wrong under a harness. When
|
||||
# FIPS_TEST_IMAGE is set the caller has already built the image it named, so
|
||||
# a miss means something upstream is broken; building a substitute would
|
||||
# hide that and run binaries nobody asked for.
|
||||
if [ -n "${FIPS_TEST_IMAGE:-}" ]; then
|
||||
echo "ERROR: $img not present, and FIPS_TEST_IMAGE names the caller's own image" >&2
|
||||
echo "The harness that set it is expected to have built it." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$img not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
}
|
||||
|
||||
dump_diagnostics() {
|
||||
@@ -288,16 +318,24 @@ assert_process_alive() {
|
||||
return 1
|
||||
fi
|
||||
echo " $container: fips daemon still alive after malformed advert"
|
||||
return 0
|
||||
}
|
||||
|
||||
# A container whose logs cannot be read has not been shown to be panic-free.
|
||||
# See the companion note in stun-faults-test.sh: the previous `|| true` made
|
||||
# this assertion's failure mode indistinguishable from its success condition.
|
||||
assert_no_panic() {
|
||||
local container="$1"
|
||||
local logs
|
||||
logs="$(docker logs "$container" 2>&1 || true)"
|
||||
if ! logs="$(docker logs "$container" 2>&1)"; then
|
||||
echo "could not read logs from $container; absence of panics is not established" >&2
|
||||
return 1
|
||||
fi
|
||||
if grep -Eq "panicked at|RUST_BACKTRACE|fatal runtime error" <<<"$logs"; then
|
||||
echo "panic detected in $container logs" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
run_test() {
|
||||
@@ -323,7 +361,7 @@ run_test() {
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$NAT_DIR/generated-configs/$SCENARIO/npubs.env"
|
||||
source "$CONFIG_DIR/$SCENARIO/npubs.env"
|
||||
echo " NPUB_A=$NPUB_A"
|
||||
echo " NPUB_B=$NPUB_B"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NAT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ROOT_DIR="$(cd "$NAT_DIR/../.." && pwd)"
|
||||
|
||||
SCENARIO="${1:?usage: setup-topology.sh <cone|symmetric>}"
|
||||
|
||||
@@ -25,6 +26,29 @@ esac
|
||||
router_a="fips-nat-router-a${FIPS_CI_NAME_SUFFIX:-}"
|
||||
router_b="fips-nat-router-b${FIPS_CI_NAME_SUFFIX:-}"
|
||||
|
||||
# Host interface names live in one global namespace and are capped at
|
||||
# IFNAMSIZ-1 = 15 characters — far short of the run suffix, which is 20-plus at
|
||||
# the default run-id length. Two concurrent runs both creating `vna0` damage
|
||||
# each other: setup_pair deletes, adds and moves the interface in three
|
||||
# separate `docker run`s, so B's re-add inside A's window sends B's interface
|
||||
# into A's namespace and fails B's own move.
|
||||
#
|
||||
# Scope the names with the same four-hex token the chaos simulation uses, and
|
||||
# derive it by calling sim.naming rather than re-implementing sha1 here, so
|
||||
# ci-cleanup.sh's reaper (which calls the same module) cannot end up matching a
|
||||
# different width. Empty suffix yields an empty token and today's exact names.
|
||||
#
|
||||
# No fallback on a derivation failure: silently reverting to `vna0` would
|
||||
# reinstate the collision this exists to remove, so let it fail loudly.
|
||||
veth_token() {
|
||||
local suffix="${FIPS_CI_NAME_SUFFIX:-}"
|
||||
if [ -z "$suffix" ]; then
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
PYTHONPATH="$ROOT_DIR/testing/chaos" python3 -m sim.naming "$suffix"
|
||||
}
|
||||
|
||||
helper_image() {
|
||||
if [ -n "${IP_HELPER_IMAGE:-}" ]; then
|
||||
echo "$IP_HELPER_IMAGE"
|
||||
@@ -122,11 +146,14 @@ setup_pair() {
|
||||
main() {
|
||||
cd "$NAT_DIR"
|
||||
|
||||
local image
|
||||
local image token
|
||||
image="$(helper_image)"
|
||||
token="$(veth_token)"
|
||||
|
||||
setup_pair "$image" "$node_a" "$router_a" vna0 vna1 172.31.1.10/24 172.31.1.254/24
|
||||
setup_pair "$image" "$node_b" "$router_b" vnb0 vnb1 172.31.2.10/24 172.31.2.254/24
|
||||
setup_pair "$image" "$node_a" "$router_a" \
|
||||
"vna${token}0" "vna${token}1" 172.31.1.10/24 172.31.1.254/24
|
||||
setup_pair "$image" "$node_b" "$router_b" \
|
||||
"vnb${token}0" "vnb${token}1" 172.31.2.10/24 172.31.2.254/24
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -33,11 +33,28 @@ GENERATE_SCRIPT="$SCRIPT_DIR/generate-configs.sh"
|
||||
PROFILE="stun-faults"
|
||||
SCENARIO="$PROFILE"
|
||||
COMPOSE=(docker compose -f "$NAT_DIR/docker-compose.yml")
|
||||
|
||||
# Optional extra compose-file overlay chain (colon-separated paths), matching
|
||||
# nat-test.sh. ci-local.sh appends testing/nat/docker-compose.external-net.yml
|
||||
# here so this suite attaches to the networks the run already claimed; without
|
||||
# the hook compose would create its own from the base file's subnet and collide
|
||||
# with the run's own claim. Paths are relative to ROOT_DIR unless absolute.
|
||||
if [ -n "${FIPS_NAT_EXTRA_COMPOSE:-}" ]; then
|
||||
IFS=':' read -ra _NAT_EXTRA <<< "${FIPS_NAT_EXTRA_COMPOSE}"
|
||||
for _f in "${_NAT_EXTRA[@]}"; do
|
||||
case "$_f" in
|
||||
/*) COMPOSE+=(-f "$_f") ;;
|
||||
*) COMPOSE+=(-f "$ROOT_DIR/$_f") ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
NODE="fips-nat-stun-fault-node${FIPS_CI_NAME_SUFFIX:-}"
|
||||
PEER="fips-nat-stun-fault-peer${FIPS_CI_NAME_SUFFIX:-}"
|
||||
SHIM="fips-nat-stun-fault-shim${FIPS_CI_NAME_SUFFIX:-}"
|
||||
STUN_CONTAINER="fips-nat-stun${FIPS_CI_NAME_SUFFIX:-}"
|
||||
STUN_HOST="172.31.10.40"
|
||||
# Claimed per run by ci-local.sh; unset renders the lab's historical address.
|
||||
STUN_HOST="${NAT_LAN_PREFIX:-172.31.10}.40"
|
||||
STUN_PORT=3478
|
||||
DEV="eth0"
|
||||
|
||||
@@ -60,10 +77,21 @@ require_docker_daemon() {
|
||||
}
|
||||
|
||||
require_test_image() {
|
||||
if ! docker image inspect fips-test:latest >/dev/null 2>&1; then
|
||||
echo "fips-test:latest not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
local img="${FIPS_TEST_IMAGE:-fips-test:latest}"
|
||||
if docker image inspect "$img" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
# Building here is right for a hand run and wrong under a harness. When
|
||||
# FIPS_TEST_IMAGE is set the caller has already built the image it named, so
|
||||
# a miss means something upstream is broken; building a substitute would
|
||||
# hide that and run binaries nobody asked for.
|
||||
if [ -n "${FIPS_TEST_IMAGE:-}" ]; then
|
||||
echo "ERROR: $img not present, and FIPS_TEST_IMAGE names the caller's own image" >&2
|
||||
echo "The harness that set it is expected to have built it." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$img not found; building test image"
|
||||
"$BUILD_SCRIPT"
|
||||
}
|
||||
|
||||
dump_diagnostics() {
|
||||
@@ -126,6 +154,7 @@ apply_delay() {
|
||||
docker exec "$SHIM" tc qdisc add dev "$DEV" root netem delay 5000ms 2>/dev/null \
|
||||
|| { echo " delay: tc netem unavailable, skipping" >&2; return 1; }
|
||||
echo " delay: tc netem 5000ms applied"
|
||||
return 0
|
||||
}
|
||||
|
||||
clear_delay() {
|
||||
@@ -139,15 +168,24 @@ assert_process_alive() {
|
||||
return 1
|
||||
fi
|
||||
echo " $NODE: fips daemon alive"
|
||||
return 0
|
||||
}
|
||||
|
||||
# A container whose logs cannot be read has not been shown to be panic-free.
|
||||
# The previous form read `docker logs … || true`, so an unreadable container
|
||||
# yielded empty output, matched no panic pattern, and returned success — the
|
||||
# assertion's failure mode was indistinguishable from its success condition.
|
||||
assert_no_panic() {
|
||||
local logs
|
||||
logs="$(docker logs "$NODE" 2>&1 || true)"
|
||||
if ! logs="$(docker logs "$NODE" 2>&1)"; then
|
||||
echo "could not read logs from $NODE; absence of panics is not established" >&2
|
||||
return 1
|
||||
fi
|
||||
if grep -Eq "panicked at|RUST_BACKTRACE|fatal runtime error" <<<"$logs"; then
|
||||
echo "panic detected in $NODE logs" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Look for STUN-related fault evidence in the daemon's logs. The
|
||||
@@ -212,6 +250,11 @@ preflight_assert_stun_active() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Phases that did not run, with the reason. Surfaced in the final verdict:
|
||||
# the suite reports a pass per phase-level assertion, so a phase that never
|
||||
# ran otherwise leaves a clean "passed" standing for work not done.
|
||||
SKIPPED_PHASES=()
|
||||
|
||||
run_test() {
|
||||
echo "=== stun-faults-test: setup ==="
|
||||
cleanup
|
||||
@@ -293,6 +336,7 @@ run_test() {
|
||||
sleep 10
|
||||
else
|
||||
echo " Phase 2 skipped (no tc netem available); proceeding to Phase 3"
|
||||
SKIPPED_PHASES+=("2 (delay): tc netem unavailable")
|
||||
fi
|
||||
|
||||
assert_process_alive || { dump_diagnostics; return 1; }
|
||||
@@ -321,7 +365,23 @@ run_test() {
|
||||
}
|
||||
|
||||
cleanup
|
||||
echo "stun-faults-test passed"
|
||||
if [ ${#SKIPPED_PHASES[@]} -eq 0 ]; then
|
||||
echo "stun-faults-test passed (3/3 phases ran)"
|
||||
return 0
|
||||
fi
|
||||
# A phase that did not run is not a phase that passed. Say so in the line
|
||||
# a reader takes the verdict from, rather than leaving it in scrollback
|
||||
# several hundred lines up where the skip was announced.
|
||||
local ran=$(( 3 - ${#SKIPPED_PHASES[@]} ))
|
||||
echo "stun-faults-test passed ($ran/3 phases ran, ${#SKIPPED_PHASES[@]} skipped)"
|
||||
local phase
|
||||
for phase in "${SKIPPED_PHASES[@]}"; do
|
||||
echo " SKIPPED phase $phase"
|
||||
done
|
||||
if [ "$ran" -eq 0 ]; then
|
||||
echo "stun-faults-test: every phase was skipped, so nothing was tested" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
|
||||
@@ -14,7 +14,7 @@ networks:
|
||||
|
||||
services:
|
||||
fips:
|
||||
image: fips-test:latest
|
||||
image: ${FIPS_TEST_IMAGE:-fips-test:latest}
|
||||
hostname: fips-sidecar
|
||||
labels:
|
||||
- "com.corganlabs.fips-ci=1"
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
ipv4_address: ${FIPS_IPV4:-172.20.1.20}
|
||||
|
||||
app:
|
||||
image: fips-test-app:latest
|
||||
image: ${FIPS_TEST_APP_IMAGE:-fips-test-app:latest}
|
||||
labels:
|
||||
- "com.corganlabs.fips-ci=1"
|
||||
network_mode: "service:fips"
|
||||
|
||||
@@ -115,7 +115,7 @@ trap cleanup EXIT
|
||||
|
||||
if [[ "${1:-}" != "--skip-build" ]]; then
|
||||
log "Building test images..."
|
||||
DOCKER_DIR="$(cd "$SIDECAR_DIR/../docker" && pwd)"
|
||||
DOCKER_DIR="${FIPS_BUILD_CONTEXT:-$(cd "$SIDECAR_DIR/../docker" && pwd)}"
|
||||
docker build -t fips-test:latest "$DOCKER_DIR"
|
||||
docker build -t fips-test-app:latest -f "$DOCKER_DIR/Dockerfile.app" "$DOCKER_DIR"
|
||||
fi
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
fips
|
||||
fipsctl
|
||||
fipstop
|
||||
generated-configs
|
||||
generated-configs*
|
||||
|
||||
+19
-10
@@ -119,19 +119,13 @@ testing/static/
|
||||
│ └── topologies/
|
||||
│ ├── mesh.yaml # Mesh topology definition
|
||||
│ ├── chain.yaml # Chain topology definition
|
||||
│ ├── mesh-public.yaml # Mesh + external public node
|
||||
│ ├── tcp-chain.yaml # TCP chain (3 nodes, port 8443)
|
||||
│ └── rekey.yaml # Rekey integration test (5 nodes)
|
||||
├── generated-configs/ # Auto-generated (gitignored)
|
||||
├── generated-configs/ # Auto-generated, run-scoped (gitignored)
|
||||
│ ├── npubs.env # NPUB_A=..., NPUB_B=..., etc.
|
||||
│ ├── mesh/
|
||||
│ │ ├── node-a.yaml ... node-e.yaml
|
||||
│ ├── mesh-public/
|
||||
│ │ ├── node-a.yaml ... node-e.yaml
|
||||
│ ├── chain/
|
||||
│ │ ├── node-a.yaml ... node-e.yaml
|
||||
│ └── tcp-chain/
|
||||
│ ├── node-a.yaml ... node-c.yaml
|
||||
│ └── chain/
|
||||
│ ├── node-a.yaml ... node-e.yaml
|
||||
├── scripts/
|
||||
│ ├── build.sh # Build binary + generate configs
|
||||
│ ├── generate-configs.sh # Generate node configs from topology
|
||||
@@ -151,6 +145,16 @@ Each topology file in `configs/topologies/` defines:
|
||||
- **Addresses**: `docker_ip` for Docker-managed nodes, `external_ip` for
|
||||
remote nodes not managed by Docker
|
||||
- **Peer connections**: which nodes peer with each other
|
||||
- **`docker_host`** (optional): the compose `hostname:` this node answers to,
|
||||
when that is not `node-<id>`. Only the gateway topology needs it
|
||||
|
||||
Generated peer addresses use the **docker hostname**, not `docker_ip`.
|
||||
`fips-net` requests no subnet, so docker assigns one from its own pool and two
|
||||
concurrent CI runs can bring the topology up at the same time instead of one
|
||||
of them failing with `Pool overlaps`. `docker_ip` is retained as documentation
|
||||
of the topology's shape and as the internal/external discriminator; an
|
||||
external node keeps its `external_ip` in peer blocks, its address not being
|
||||
ours to assign.
|
||||
|
||||
Example entry:
|
||||
|
||||
@@ -178,6 +182,11 @@ This reads the topology definition and generates:
|
||||
1. Per-node YAML config files in `generated-configs/<topology>/`
|
||||
2. `generated-configs/npubs.env` with all node npubs as environment variables
|
||||
|
||||
Under `ci-local.sh` the directory is `generated-configs-<run-id>`, so
|
||||
concurrent runs cannot overwrite each other's node configs; the compose file
|
||||
and every test script read the same `FIPS_CI_NAME_SUFFIX` and follow it. A
|
||||
bare invocation leaves the suffix unset and writes the plain path.
|
||||
|
||||
The `npubs.env` file is sourced by the test scripts and injected into
|
||||
Docker containers via `env_file` in `docker-compose.yml`.
|
||||
|
||||
@@ -204,7 +213,7 @@ each mesh needs unique node identities to avoid key conflicts. The optional
|
||||
|
||||
# Or generate configs directly
|
||||
./testing/static/scripts/generate-configs.sh mesh my-mesh-1
|
||||
./testing/static/scripts/generate-configs.sh mesh-public my-mesh-1
|
||||
./testing/static/scripts/generate-configs.sh chain my-mesh-1
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
@@ -12,16 +12,23 @@
|
||||
# A non-FIPS client container connects via the gateway's LAN interface.
|
||||
#
|
||||
# Uses deterministic key derivation (mesh-name: gateway-test).
|
||||
#
|
||||
# docker_host is the compose service's `hostname:`, which is what peers dial.
|
||||
# This is the one static topology whose hostnames are not node-<id>, so it is
|
||||
# the one that has to declare them.
|
||||
|
||||
nodes:
|
||||
a:
|
||||
docker_ip: "172.20.0.10"
|
||||
docker_host: "gw-gateway"
|
||||
peers: [b, c]
|
||||
|
||||
b:
|
||||
docker_ip: "172.20.0.11"
|
||||
docker_host: "gw-server"
|
||||
peers: [a]
|
||||
|
||||
c:
|
||||
docker_ip: "172.20.0.12"
|
||||
docker_host: "gw-server-2"
|
||||
peers: [a]
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Mesh Topology Definition
|
||||
#
|
||||
# Five nodes with 6 bidirectional UDP links forming a sparse, fully connected
|
||||
# graph. Not all nodes are direct peers — non-adjacent pairs require
|
||||
# discovery-driven multi-hop routing to establish end-to-end sessions.
|
||||
|
||||
nodes:
|
||||
a:
|
||||
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
|
||||
npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
docker_ip: "172.20.0.10"
|
||||
peers: [d, e, pub]
|
||||
|
||||
b:
|
||||
nsec: "b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0"
|
||||
npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
|
||||
docker_ip: "172.20.0.11"
|
||||
peers: [c, pub]
|
||||
|
||||
c:
|
||||
nsec: "c102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fc0"
|
||||
npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
|
||||
docker_ip: "172.20.0.12"
|
||||
peers: [b, d, e, pub]
|
||||
|
||||
d:
|
||||
nsec: "d102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fd0"
|
||||
npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl"
|
||||
docker_ip: "172.20.0.13"
|
||||
peers: [a, c, e]
|
||||
|
||||
e:
|
||||
nsec: "e102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fe0"
|
||||
npub: "npub1wf8akf8lu2zdkjkmwhl75pqvven654mpv4sz2x2tprl5265mgrzq8nhak4"
|
||||
docker_ip: "172.20.0.14"
|
||||
peers: [a, c, d]
|
||||
|
||||
# External/public node (not a Docker container)
|
||||
pub:
|
||||
npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
|
||||
external_ip: "test-us01.fips.network"
|
||||
peers: [a, b, c]
|
||||
|
||||
# Spanning Tree Structure (rooted at node A):
|
||||
# - A — D (tree edge, D's parent is A)
|
||||
# - A — E (tree edge, E's parent is A)
|
||||
# - C — D (tree edge, C's parent is D)
|
||||
# - B — C (tree edge, B's parent is C)
|
||||
# - D — E (non-tree link)
|
||||
# - C — E (non-tree link)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# TCP Chain Topology Definition
|
||||
#
|
||||
# Three nodes with TCP links forming a linear chain. Tests basic TCP
|
||||
# transport connectivity, spanning tree convergence, and multi-hop
|
||||
# routing over TCP. All peer connections use TCP on port 443.
|
||||
#
|
||||
# default_transport: tcp tells the config generator to use TCP
|
||||
# transport and port 443 instead of the default UDP/2121.
|
||||
|
||||
default_transport: tcp
|
||||
|
||||
nodes:
|
||||
a:
|
||||
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
|
||||
npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
||||
docker_ip: "172.20.0.10"
|
||||
peers: [b]
|
||||
|
||||
b:
|
||||
nsec: "b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0"
|
||||
npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
|
||||
docker_ip: "172.20.0.11"
|
||||
peers: [a, c]
|
||||
|
||||
c:
|
||||
nsec: "c102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fc0"
|
||||
npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
|
||||
docker_ip: "172.20.0.12"
|
||||
peers: [b]
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<!-- Docker network boundary -->
|
||||
<rect x="30" y="65" width="640" height="175" class="net-box"/>
|
||||
<text x="40" y="82" class="net-label">172.20.0.0/24 (docker: fips-net)</text>
|
||||
<text x="40" y="82" class="net-label">auto-assigned subnet (docker: fips-net)</text>
|
||||
|
||||
<!--
|
||||
Horizontal chain layout:
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,15 @@
|
||||
# Override: attach gateway-lan to a pre-created external network instead of
|
||||
# letting compose create it from the base file's fd02::/64 pin.
|
||||
#
|
||||
# Applied only by ci-local.sh's run_gateway, which claims a free /64 per run
|
||||
# (claim_gateway_lan6) and exports FIPS_GW_LAN_NET / FIPS_GW_LAN6_PREFIX before
|
||||
# `up`. This is what makes two concurrent local gateway runs collision-safe:
|
||||
# each claims a distinct /64, so neither requests a fixed range the other holds.
|
||||
#
|
||||
# The GitHub matrix and any standalone `docker compose up` do NOT apply this
|
||||
# overlay; they use the base file's normal gateway-lan network unchanged. This
|
||||
# mirrors sidecar/docker-compose.external-net.yml.
|
||||
networks:
|
||||
gateway-lan:
|
||||
external: true
|
||||
name: ${FIPS_GW_LAN_NET:-fips-gateway-lan}
|
||||
@@ -1,11 +1,17 @@
|
||||
networks:
|
||||
# No subnet is requested: docker assigns one from the daemon's address pool,
|
||||
# which is what lets two concurrent CI runs bring this topology up at the
|
||||
# same time. A fixed request is honoured verbatim, so two runs asking for the
|
||||
# same range collide on "Pool overlaps" — the whole reason mesh nodes now
|
||||
# peer by docker hostname rather than by address.
|
||||
fips-net:
|
||||
driver: bridge
|
||||
labels:
|
||||
- "com.corganlabs.fips-ci=1"
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/24
|
||||
# IPv4 is dropped so docker auto-assigns it (nothing reads a LAN IPv4 — the
|
||||
# whole gateway LAN path is IPv6). The fd02::/64 pin stays for the standalone /
|
||||
# GitHub path; concurrent local runs replace this network with a per-run
|
||||
# claimed /64 via docker-compose.gateway-external-net.yml (see run_gateway).
|
||||
gateway-lan:
|
||||
driver: bridge
|
||||
labels:
|
||||
@@ -13,7 +19,6 @@ networks:
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.1.0/24
|
||||
- subnet: fd02::/64
|
||||
|
||||
x-fips-common: &fips-common
|
||||
@@ -26,7 +31,7 @@ x-fips-common: &fips-common
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
restart: "no"
|
||||
env_file:
|
||||
- ./generated-configs/npubs.env
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/npubs.env
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
# Passthrough for A/B benchmarking — set on the host shell before
|
||||
@@ -45,10 +50,9 @@ services:
|
||||
hostname: node-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/mesh/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/mesh/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.10
|
||||
|
||||
node-b:
|
||||
<<: *fips-common
|
||||
@@ -57,10 +61,9 @@ services:
|
||||
hostname: node-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/mesh/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/mesh/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.11
|
||||
|
||||
node-c:
|
||||
<<: *fips-common
|
||||
@@ -69,10 +72,9 @@ services:
|
||||
hostname: node-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/mesh/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/mesh/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.12
|
||||
|
||||
node-d:
|
||||
<<: *fips-common
|
||||
@@ -81,10 +83,9 @@ services:
|
||||
hostname: node-d
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/mesh/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/mesh/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.13
|
||||
|
||||
node-e:
|
||||
<<: *fips-common
|
||||
@@ -93,71 +94,9 @@ services:
|
||||
hostname: node-e
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/mesh/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/mesh/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.14
|
||||
|
||||
# ── Mesh-public topology (mesh + external public node) ────────
|
||||
pub-a:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/mesh-public/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.10
|
||||
|
||||
pub-b:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/mesh-public/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.11
|
||||
|
||||
pub-c:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/mesh-public/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.12
|
||||
|
||||
pub-d:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-d${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-d
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/mesh-public/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.13
|
||||
|
||||
pub-e:
|
||||
<<: *fips-common
|
||||
profiles: ["mesh-public"]
|
||||
container_name: fips-node-e${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-e
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/mesh-public/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.14
|
||||
|
||||
# ── Chain topology (A-B-C-D-E) ────────────────────────────────
|
||||
chain-a:
|
||||
@@ -167,10 +106,9 @@ services:
|
||||
hostname: node-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/chain/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/chain/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.10
|
||||
|
||||
chain-b:
|
||||
<<: *fips-common
|
||||
@@ -179,10 +117,9 @@ services:
|
||||
hostname: node-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/chain/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/chain/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.11
|
||||
|
||||
chain-c:
|
||||
<<: *fips-common
|
||||
@@ -191,10 +128,9 @@ services:
|
||||
hostname: node-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/chain/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/chain/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.12
|
||||
|
||||
chain-d:
|
||||
<<: *fips-common
|
||||
@@ -203,10 +139,9 @@ services:
|
||||
hostname: node-d
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/chain/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/chain/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.13
|
||||
|
||||
chain-e:
|
||||
<<: *fips-common
|
||||
@@ -215,10 +150,9 @@ services:
|
||||
hostname: node-e
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/chain/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/chain/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.14
|
||||
|
||||
# ── Rekey integration test (mesh + aggressive rekey timers) ──
|
||||
rekey-a:
|
||||
@@ -230,10 +164,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.10
|
||||
|
||||
rekey-b:
|
||||
<<: *fips-common
|
||||
@@ -244,10 +177,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.11
|
||||
|
||||
rekey-c:
|
||||
<<: *fips-common
|
||||
@@ -258,10 +190,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.12
|
||||
|
||||
rekey-d:
|
||||
<<: *fips-common
|
||||
@@ -272,10 +203,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.13
|
||||
|
||||
rekey-e:
|
||||
<<: *fips-common
|
||||
@@ -286,10 +216,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.14
|
||||
|
||||
# ── Rekey + accept_connections=false on node b ──────────────────
|
||||
# Exercises the auto_connect-initiator-with-accept-off regression
|
||||
@@ -305,10 +234,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey-accept-off/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey-accept-off/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.10
|
||||
|
||||
rekey-accept-off-b:
|
||||
<<: *fips-common
|
||||
@@ -319,10 +247,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey-accept-off/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey-accept-off/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.11
|
||||
|
||||
rekey-accept-off-c:
|
||||
<<: *fips-common
|
||||
@@ -333,10 +260,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey-accept-off/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey-accept-off/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.12
|
||||
|
||||
rekey-accept-off-d:
|
||||
<<: *fips-common
|
||||
@@ -347,10 +273,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey-accept-off/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey-accept-off/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.13
|
||||
|
||||
rekey-accept-off-e:
|
||||
<<: *fips-common
|
||||
@@ -361,10 +286,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey-accept-off/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey-accept-off/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.14
|
||||
|
||||
# ── Rekey topology with udp.outbound_only=true on node b ────
|
||||
# Exercises the udp.outbound_only rekey-msg1 admission regression
|
||||
@@ -385,10 +309,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey-outbound-only/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey-outbound-only/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.10
|
||||
|
||||
rekey-outbound-only-b:
|
||||
<<: *fips-common
|
||||
@@ -399,10 +322,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey-outbound-only/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey-outbound-only/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.11
|
||||
|
||||
rekey-outbound-only-c:
|
||||
<<: *fips-common
|
||||
@@ -413,10 +335,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey-outbound-only/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey-outbound-only/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.12
|
||||
|
||||
rekey-outbound-only-d:
|
||||
<<: *fips-common
|
||||
@@ -427,10 +348,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey-outbound-only/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey-outbound-only/node-d.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.13
|
||||
|
||||
rekey-outbound-only-e:
|
||||
<<: *fips-common
|
||||
@@ -441,47 +361,9 @@ services:
|
||||
- RUST_LOG=info,fips::node::handlers::rekey=debug,fips::node::handlers::handshake=debug
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/rekey-outbound-only/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/rekey-outbound-only/node-e.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.14
|
||||
|
||||
# ── TCP chain topology (A-B-C) ───────────────────────────────
|
||||
tcp-a:
|
||||
<<: *fips-common
|
||||
profiles: ["tcp-chain"]
|
||||
container_name: fips-node-a${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-a
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/tcp-chain/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.10
|
||||
|
||||
tcp-b:
|
||||
<<: *fips-common
|
||||
profiles: ["tcp-chain"]
|
||||
container_name: fips-node-b${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-b
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/tcp-chain/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.11
|
||||
|
||||
tcp-c:
|
||||
<<: *fips-common
|
||||
profiles: ["tcp-chain"]
|
||||
container_name: fips-node-c${FIPS_CI_NAME_SUFFIX:-}
|
||||
hostname: node-c
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/tcp-chain/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.12
|
||||
|
||||
# ── Gateway integration test (gateway + server + non-FIPS client) ─
|
||||
gw-gateway:
|
||||
@@ -502,13 +384,11 @@ services:
|
||||
- net.ipv6.conf.all.proxy_ndp=1
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/gateway/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/gateway/node-a.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.10
|
||||
gateway-lan:
|
||||
ipv4_address: 172.20.1.10
|
||||
ipv6_address: fd02::10
|
||||
ipv6_address: ${FIPS_GW_LAN6_PREFIX:-fd02}::10
|
||||
|
||||
gw-server:
|
||||
<<: *fips-common
|
||||
@@ -517,10 +397,9 @@ services:
|
||||
hostname: gw-server
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/gateway/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/gateway/node-b.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.11
|
||||
|
||||
# Second mesh destination — gives gw-client-2 a distinct npub to target
|
||||
# so the gateway allocates a separate virtual-IP mapping per LAN client.
|
||||
@@ -532,10 +411,9 @@ services:
|
||||
hostname: gw-server-2
|
||||
volumes:
|
||||
- ../docker/resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs/gateway/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/gateway/node-c.yaml:/etc/fips/fips.yaml:ro
|
||||
networks:
|
||||
fips-net:
|
||||
ipv4_address: 172.20.0.12
|
||||
|
||||
gw-client:
|
||||
image: ${FIPS_TEST_APP_IMAGE:-fips-test-app:latest}
|
||||
@@ -547,14 +425,13 @@ services:
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
volumes:
|
||||
- ./configs/gateway-resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/gateway/resolv.conf:/etc/resolv.conf:ro
|
||||
networks:
|
||||
gateway-lan:
|
||||
ipv4_address: 172.20.1.20
|
||||
ipv6_address: fd02::20
|
||||
ipv6_address: ${FIPS_GW_LAN6_PREFIX:-fd02}::20
|
||||
restart: "no"
|
||||
env_file:
|
||||
- ./generated-configs/npubs.env
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/npubs.env
|
||||
|
||||
# Second LAN client — exercises concurrent multi-client mappings.
|
||||
# Same image and gateway-lan attachment as
|
||||
@@ -569,11 +446,10 @@ services:
|
||||
sysctls:
|
||||
- net.ipv6.conf.all.disable_ipv6=0
|
||||
volumes:
|
||||
- ./configs/gateway-resolv.conf:/etc/resolv.conf:ro
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/gateway/resolv.conf:/etc/resolv.conf:ro
|
||||
networks:
|
||||
gateway-lan:
|
||||
ipv4_address: 172.20.1.21
|
||||
ipv6_address: fd02::21
|
||||
ipv6_address: ${FIPS_GW_LAN6_PREFIX:-fd02}::21
|
||||
restart: "no"
|
||||
env_file:
|
||||
- ./generated-configs/npubs.env
|
||||
- ./generated-configs${FIPS_CI_NAME_SUFFIX:-}/npubs.env
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<!-- Docker network boundary -->
|
||||
<rect x="40" y="62" width="620" height="310" class="net-box"/>
|
||||
<text x="52" y="78" class="net-label">172.20.0.0/24 (docker: fips-net)</text>
|
||||
<text x="52" y="78" class="net-label">auto-assigned subnet (docker: fips-net)</text>
|
||||
|
||||
<!--
|
||||
Pentagon layout (centered at 350,225):
|
||||
|
||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
@@ -32,7 +32,7 @@ TOPO_FILE="$SCRIPT_DIR/../configs/topologies/$TOPOLOGY.yaml"
|
||||
# before building Docker images.
|
||||
if [ "${1:-}" = "inject-config" ]; then
|
||||
echo "Injecting node.limits.max_peers: $MAX_PEERS into node-$CAP_NODE ($TOPOLOGY topology)..."
|
||||
cfg="$SCRIPT_DIR/../generated-configs/$TOPOLOGY/node-$CAP_NODE.yaml"
|
||||
cfg="$SCRIPT_DIR/../generated-configs${FIPS_CI_NAME_SUFFIX:-}/$TOPOLOGY/node-$CAP_NODE.yaml"
|
||||
if [ ! -f "$cfg" ]; then
|
||||
echo " Error: $cfg not found (run generate-configs.sh $TOPOLOGY first)" >&2
|
||||
exit 1
|
||||
@@ -60,11 +60,22 @@ info() { echo "[$(stamp)] $*"; }
|
||||
fail() { echo "[$(stamp)] FAIL: $*"; exit 1; }
|
||||
pass() { echo "[$(stamp)] PASS: $*"; }
|
||||
|
||||
# Extract docker_ip for a node from the topology file
|
||||
# A node's docker address, read from the running container.
|
||||
#
|
||||
# NOT from the topology file's docker_ip: fips-net requests no subnet, so that
|
||||
# two concurrent CI runs cannot collide on one fixed range, and docker assigns
|
||||
# the addresses at `up`. A topology literal would no longer match anything on
|
||||
# the wire, and the phase-3 tcpdump assertions are built from these addresses
|
||||
# — a stale one turns "no Msg2 leaked" into a check that cannot fail.
|
||||
# Takes the first attachment only: these nodes have one, and concatenating two
|
||||
# would yield a string that is not an address at all. The `|| true` keeps a
|
||||
# missing container from killing the script under `set -e` before the caller
|
||||
# can say which container it was.
|
||||
node_ip() {
|
||||
grep -A 5 "^ $1:" "$TOPO_FILE" \
|
||||
| grep -m1 'docker_ip:' \
|
||||
| sed 's/.*: *"*\([^"]*\)".*/\1/'
|
||||
docker inspect \
|
||||
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' \
|
||||
"fips-node-${1}${FIPS_CI_NAME_SUFFIX:-}" 2>/dev/null \
|
||||
| awk '{print $1}' || true
|
||||
}
|
||||
|
||||
# Extract npub for a node from the topology file
|
||||
@@ -84,7 +95,7 @@ node_peers() {
|
||||
}
|
||||
|
||||
CAP_IP=$(node_ip "$CAP_NODE")
|
||||
[ -n "$CAP_IP" ] || fail "could not resolve docker_ip for node-$CAP_NODE in $TOPO_FILE"
|
||||
[ -n "$CAP_IP" ] || fail "could not read the docker address of container fips-node-${CAP_NODE}${FIPS_CI_NAME_SUFFIX:-}"
|
||||
info "cap'd node: node-$CAP_NODE (ip $CAP_IP, max_peers=$MAX_PEERS)"
|
||||
|
||||
# Read the cap'd node's peer_count, or the empty string if it did not answer.
|
||||
@@ -131,6 +142,32 @@ info "denied (sustained-retry): ${DENIED:-<none>}"
|
||||
[ -n "$DENIED" ] \
|
||||
|| fail "no denied peers — test setup wrong (cap=$MAX_PEERS too high vs configured peers)"
|
||||
|
||||
# Every address a denied peer holds during the capture, one "node ip" line per
|
||||
# observation.
|
||||
#
|
||||
# It is not one address per node. `fips-net` requests no subnet so that
|
||||
# concurrent CI runs cannot collide on one, and the load driver below restarts
|
||||
# these containers repeatedly — docker frees the address on stop and may hand
|
||||
# back a different one, which was impossible while the compose pinned
|
||||
# ipv4_address. Observed live: node-d went 10.128.2.4 → 10.128.2.6 mid-window.
|
||||
# Phase 3 matches against the union, because an address that held for only part
|
||||
# of the window under-counts Msg1 and, worse, satisfies the expect-zero Msg2
|
||||
# assertion for the wrong reason.
|
||||
ADDR_FILE=$(mktemp /tmp/admission-cap-addrs.XXXXXX)
|
||||
record_denied_addrs() {
|
||||
local n n_ip
|
||||
for n in $DENIED; do
|
||||
n_ip=$(node_ip "$n")
|
||||
[ -n "$n_ip" ] || continue
|
||||
grep -qxF "$n $n_ip" "$ADDR_FILE" 2>/dev/null || echo "$n $n_ip" >> "$ADDR_FILE"
|
||||
done
|
||||
}
|
||||
record_denied_addrs
|
||||
for n in $DENIED; do
|
||||
grep -q "^$n " "$ADDR_FILE" \
|
||||
|| fail "could not read the docker address of denied peer node-$n"
|
||||
done
|
||||
|
||||
# ── Phase 2: capture wire traffic for CAPTURE_SECS seconds ───────────
|
||||
# Drives sustained load by restarting denied peer containers on a cadence
|
||||
# during the capture window. Each restart resets the auto-reconnect
|
||||
@@ -149,10 +186,18 @@ HELPER_IMAGE=$(docker inspect -f '{{.Config.Image}}' "fips-node-${CAP_NODE}${FIP
|
||||
while [ $elapsed -lt $((CAPTURE_SECS - 5)) ]; do
|
||||
sleep 15
|
||||
elapsed=$((elapsed + 15))
|
||||
# ONE AT A TIME, deliberately. Restarting them together frees both
|
||||
# addresses at once and docker reallocates in completion order, so the
|
||||
# two peers SWAP — observed live, and it destroys per-peer attribution
|
||||
# because both then match the same address set. Restarted singly, a
|
||||
# container frees its address and immediately reclaims it as the
|
||||
# lowest free one, so each keeps its own.
|
||||
for n in $DENIED; do
|
||||
docker restart "fips-node-${n}${FIPS_CI_NAME_SUFFIX:-}" >/dev/null 2>&1 &
|
||||
docker restart "fips-node-${n}${FIPS_CI_NAME_SUFFIX:-}" >/dev/null 2>&1 || true
|
||||
done
|
||||
wait
|
||||
# Belt and braces: a restart may still move an address, so re-read
|
||||
# rather than assuming the pre-capture snapshot still holds.
|
||||
record_denied_addrs
|
||||
info " [load-driver] restarted denied peers ($DENIED) at t+${elapsed}s"
|
||||
done
|
||||
) &
|
||||
@@ -176,13 +221,41 @@ info "phase 3: per-denied-peer assertion (inbound Msg1 > 0, outbound Msg2 == 0)"
|
||||
OVERALL=0
|
||||
TOTAL_MSG1_IN=0
|
||||
TOTAL_MSG2_OUT=0
|
||||
# One last observation: the final restart round may have moved an address after
|
||||
# the driver's own record.
|
||||
record_denied_addrs
|
||||
|
||||
# The cap'd node is never restarted, so its address must not have moved. If it
|
||||
# did, every pattern below covers only part of the window and the counts mean
|
||||
# nothing — that is a harness failure, not a cap regression.
|
||||
cap_ip_now=$(node_ip "$CAP_NODE")
|
||||
[ "$cap_ip_now" = "$CAP_IP" ] \
|
||||
|| fail "cap'd node address moved during the capture ($CAP_IP → ${cap_ip_now:-<unreadable>}) though it was never restarted"
|
||||
cap_re=$(printf '%s' "$CAP_IP" | sed 's/\./\\./g')
|
||||
|
||||
# Two denied peers must never have held the same address, or the per-peer
|
||||
# counts below are not per-peer: each would match the other's traffic and the
|
||||
# "this peer is sustained-retrying" assertion could be satisfied entirely by
|
||||
# its neighbour. Serialized restarts above are what prevent it; this is the
|
||||
# check that says so out loud if they ever stop working.
|
||||
dup=$(awk '{ if (seen[$2] != "" && seen[$2] != $1) print $2; seen[$2] = $1 }' "$ADDR_FILE" | sort -u)
|
||||
[ -z "$dup" ] \
|
||||
|| fail "denied peers shared an address during the capture ($(echo "$dup" | paste -sd, -)); per-peer attribution is not possible"
|
||||
|
||||
for n in $DENIED; do
|
||||
n_ip=$(node_ip "$n")
|
||||
# Match every address this peer held during the window, not just its last:
|
||||
# a restart can move it, and grepping for one of several under-counts Msg1
|
||||
# and leaves the expect-zero Msg2 assertion unable to see a leak sent to
|
||||
# the addresses it no longer holds.
|
||||
n_re=$(awk -v n="$n" '$1 == n { gsub(/\./, "\\.", $2); printf "%s%s", (c++ ? "|" : ""), $2 }' "$ADDR_FILE")
|
||||
[ -n "$n_re" ] \
|
||||
|| fail "no docker address was ever recorded for denied peer node-$n"
|
||||
n_seen=$(awk -v n="$n" '$1 == n {print $2}' "$ADDR_FILE" | paste -sd, -)
|
||||
# Inbound: src=n_ip:* → dst=cap_ip:2121; FMP-IK Msg1 wire size = 84 B
|
||||
msg1_in=$(grep -cE "IP $n_ip\.[0-9]+ > $CAP_IP\.2121: UDP, length 84" "$CAP_FILE" || true)
|
||||
msg1_in=$(grep -cE "IP ($n_re)\.[0-9]+ > $cap_re\.2121: UDP, length 84" "$CAP_FILE" || true)
|
||||
# Outbound: src=cap_ip:2121 → dst=n_ip:*; FMP-IK Msg2 wire size = 104 B
|
||||
msg2_out=$(grep -cE "IP $CAP_IP\.2121 > $n_ip\.[0-9]+: UDP, length 104" "$CAP_FILE" || true)
|
||||
info " node-$n ($n_ip): inbound Msg1 (len 84) = $msg1_in, outbound Msg2 (len 104) = $msg2_out"
|
||||
msg2_out=$(grep -cE "IP $cap_re\.2121 > ($n_re)\.[0-9]+: UDP, length 104" "$CAP_FILE" || true)
|
||||
info " node-$n ($n_seen): inbound Msg1 (len 84) = $msg1_in, outbound Msg2 (len 104) = $msg2_out"
|
||||
TOTAL_MSG1_IN=$((TOTAL_MSG1_IN + msg1_in))
|
||||
TOTAL_MSG2_OUT=$((TOTAL_MSG2_OUT + msg2_out))
|
||||
if [ "$msg1_in" -eq 0 ]; then
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user