From 9110ae428261cdf5103fa0b467ee31f2f080a93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:20:41 +0200 Subject: [PATCH 1/3] Unify network bring-up into the micronet daemon. Replace configure-ethernet and configure-dhcp with one JSON-configured daemon that probes foreign DHCP, pings gateway.ip, and applies client, gateway, or static .252 modes with dnsmasq only when needed. Co-authored-by: Cursor --- .github/workflows/ci.yml | 3 +- .github/workflows/release.yml | 2 +- ARCHITECTURE.md | 185 ++ CODING-GUIDELINES.md | 1625 +++++++++++++++++ Cargo.lock | 1005 +++++++++- Cargo.toml | 19 +- Makefile | 11 +- README.md | 167 +- crates/configure-dhcp/Cargo.toml | 39 - crates/configure-dhcp/README.md | 44 - crates/configure-dhcp/src/dhcp/conf.rs | 218 --- crates/configure-dhcp/src/dhcp/leases.rs | 109 -- crates/configure-dhcp/src/dhcp/mod.rs | 15 - crates/configure-dhcp/src/dhcp/run.rs | 128 -- crates/configure-dhcp/src/lib.rs | 9 - crates/configure-dhcp/src/main.rs | 83 - crates/configure-dhcp/src/run.rs | 196 -- crates/configure-dhcp/src/stack/mod.rs | 239 --- crates/configure-dhcp/src/stack/omada.rs | 214 --- crates/configure-dhcp/src/sticky.rs | 72 - crates/configure-dhcp/tests/gate_test.rs | 112 -- crates/configure-ethernet/Cargo.toml | 32 - crates/configure-ethernet/README.md | 16 - crates/configure-ethernet/src/main.rs | 405 ---- crates/micronet/Cargo.toml | 36 + crates/micronet/build.rs | 54 + crates/micronet/src/apply/mod.rs | 454 +++++ crates/micronet/src/config/mod.rs | 334 ++++ crates/micronet/src/config/watch.rs | 171 ++ crates/micronet/src/constants.rs | 31 + crates/micronet/src/daemon/mod.rs | 161 ++ crates/micronet/src/datadir.rs | 52 + crates/micronet/src/dhcp/conf.rs | 78 + crates/micronet/src/dhcp/mod.rs | 7 + crates/micronet/src/dhcp/run.rs | 139 ++ .../{configure-dhcp => micronet}/src/error.rs | 21 +- crates/micronet/src/ipc/mod.rs | 238 +++ crates/micronet/src/ipc/protocol.rs | 82 + crates/micronet/src/lib.rs | 15 + crates/micronet/src/main.rs | 209 +++ crates/micronet/src/net/addr.rs | 41 + crates/micronet/src/net/mod.rs | 336 ++++ crates/micronet/src/net/probe.rs | 146 ++ crates/micronet/src/signals.rs | 16 + crates/micronet/src/version.rs | 222 +++ crates/micronet/tests/ipc.rs | 71 + docs/networking/README.md | 196 ++ README_pl.md => docs/networking/README_pl.md | 89 +- plans/2026-07-14-eap613-konfiguracja.md | 27 +- plans/2026-07-14-topologia-wifi-hala.md | 29 +- 50 files changed, 6040 insertions(+), 2163 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 CODING-GUIDELINES.md delete mode 100644 crates/configure-dhcp/Cargo.toml delete mode 100644 crates/configure-dhcp/README.md delete mode 100644 crates/configure-dhcp/src/dhcp/conf.rs delete mode 100644 crates/configure-dhcp/src/dhcp/leases.rs delete mode 100644 crates/configure-dhcp/src/dhcp/mod.rs delete mode 100644 crates/configure-dhcp/src/dhcp/run.rs delete mode 100644 crates/configure-dhcp/src/lib.rs delete mode 100644 crates/configure-dhcp/src/main.rs delete mode 100644 crates/configure-dhcp/src/run.rs delete mode 100644 crates/configure-dhcp/src/stack/mod.rs delete mode 100644 crates/configure-dhcp/src/stack/omada.rs delete mode 100644 crates/configure-dhcp/src/sticky.rs delete mode 100644 crates/configure-dhcp/tests/gate_test.rs delete mode 100644 crates/configure-ethernet/Cargo.toml delete mode 100644 crates/configure-ethernet/README.md delete mode 100644 crates/configure-ethernet/src/main.rs create mode 100644 crates/micronet/Cargo.toml create mode 100644 crates/micronet/build.rs create mode 100644 crates/micronet/src/apply/mod.rs create mode 100644 crates/micronet/src/config/mod.rs create mode 100644 crates/micronet/src/config/watch.rs create mode 100644 crates/micronet/src/constants.rs create mode 100644 crates/micronet/src/daemon/mod.rs create mode 100644 crates/micronet/src/datadir.rs create mode 100644 crates/micronet/src/dhcp/conf.rs create mode 100644 crates/micronet/src/dhcp/mod.rs create mode 100644 crates/micronet/src/dhcp/run.rs rename crates/{configure-dhcp => micronet}/src/error.rs (75%) create mode 100644 crates/micronet/src/ipc/mod.rs create mode 100644 crates/micronet/src/ipc/protocol.rs create mode 100644 crates/micronet/src/lib.rs create mode 100644 crates/micronet/src/main.rs create mode 100644 crates/micronet/src/net/addr.rs create mode 100644 crates/micronet/src/net/mod.rs create mode 100644 crates/micronet/src/net/probe.rs create mode 100644 crates/micronet/src/signals.rs create mode 100644 crates/micronet/src/version.rs create mode 100644 crates/micronet/tests/ipc.rs create mode 100644 docs/networking/README.md rename README_pl.md => docs/networking/README_pl.md (52%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c4fcd0..f7f9d79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,5 @@ jobs: with: binaries: | [ - {"name":"configure-dhcp","dist":"configure-dhcp-linux"}, - {"name":"configure-ethernet","dist":"configure-ethernet-linux"} + {"name":"micronet","dist":"micronet-linux"} ] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1e55ff..1d79918 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: contents: write uses: dcc-bigfred/common/.github/workflows/rust-release.yml@v2 with: - elf_binaries: configure-dhcp-linux-arm64,configure-ethernet-linux-arm64 + elf_binaries: micronet-linux-arm64 elf_section: .micronet.version ci_workflow: ci.yml secrets: inherit diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..a86ad68 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,185 @@ +# micronet — Architecture + +This file is the canonical architecture source. Event WiFi cabling and +operator checklists live in [`docs/networking/`](docs/networking/README.md). + +`micronet` is the network daemon for BigFred OS: it brings up **physical +Ethernet**, probes for a foreign DHCP server, optionally pings +`gateway.ip`, and applies one of three modes (`client` / `gateway` / +`static`). Clients on the same L2 subnet need **no extra routing table**: +dnsmasq `option:router` and `option:dns-server` are enough; `ip addr add` +installs the connected route. This task does **not** enable +`ip_forward` or NAT. + +--- + +## 1. Assumptions + +1. **One daemon, one crate.** Replaces `configure-ethernet` + + `configure-dhcp`. Aliases of those names may still invoke the same ELF + (`argv0` → `apply` / `check`) for one release. +2. **Data root.** `--data-dir` sets `DATA_DIR`; env `DATA_DIR` must be + absolute; otherwise `/data`. All files are `{root}/etc/…` and + `{root}/run/…`. JSON **MUST NOT** contain hardcoded `/data/...`. +3. **Three modes** as `enum Mode { Client, Gateway, Static }` — not a + bool ladder. dnsmasq runs only in `gateway`. +4. **Physical Ethernet only.** `ARPHRD_ETHER`, no `wireless`, sysfs + realpath without `/devices/virtual/`, no bridge master, no + `IFF_LOOPBACK`. Configured `interface` MUST pass the same filter. +5. **No NAT / `ip_forward`.** Isolated event LAN. Gateway mode has **no** + default route. Static mode **does** `default via gateway.ip`. +6. **dnsmasq** is DHCP+DNS for the event pool only (`listen-address` = + `gateway.ip`). Lease stickiness is `dhcp-range=…,` (default + `7d`) plus `$DATA_DIR/etc/dnsmasq.leases`. No Omada `dhcp-host=`. +7. **IPC** is 4-byte little-endian length + JSON (`status` / `info` / + `reconfigure`). Max frame `MAX_IPC_FRAME_BYTES`; max concurrent + clients `MAX_IPC_CLIENTS`. +8. **`std::thread`**, no tokio. `unsafe_code = "forbid"`. +9. **arm64 musl** static binary. Clippy deny `unwrap_used` / `expect_used` + / `panic` / `todo` (workspace lints). +10. **Binary defaults** `192.168.0.1/24`. BigFred OS seeds + `$DATA_DIR/etc/micronet.json` to **`10.0.10.1` / `10.0.10.0/24`** + when the file is missing. + +--- + +## 2. Coding Rules + +New and changed code **MUST** follow +[CODING-GUIDELINES.md](CODING-GUIDELINES.md) (verbatim copy of microinit). +Review filter: + +- Administrative crate = **allocation-conscious**. Bounds on IPC frames, + probe timeouts, watcher debounce, client count. +- No God-struct in `daemon`. One job per directory (`config` does not + start dnsmasq). +- Modes as enum. Illegal combinations (dnsmasq in `client`) are not + representable in `Status`. +- `thiserror`; **MUST NOT** `unwrap` / `expect` / `panic` on production + paths. Operator validation returns `Err`, not `debug_assert`. +- Bounded channels; `std::thread`. + +--- + +## 3. High-level + +```mermaid +flowchart TD + watch[inotify JSON] --> apply[apply] + ipc[Unix socket] --> apply + start[serve] --> apply + apply --> probe[DHCPDISCOVER] + probe -->|DHCPOFFER| client[client: dhclient] + probe -->|no offer| ping[temp .252 then ping gateway.ip] + ping -->|OK| static[static: stay .252 plus default via] + ping -->|fail| gw[gateway: gateway.ip plus dnsmasq] +``` + +--- + +## 4. Workspace layout + +``` +crates/micronet/src/ + main.rs, lib.rs, error.rs, datadir.rs, constants.rs, version.rs, signals.rs + config/ JSON + inotify watch + net/ physical Ethernet, probe, addr + dhcp/ dnsmasq conf + process + apply/ Mode + state machine + ipc/ Unix socket + daemon/ loop +docs/networking/ operator mount (EN + PL) +plans/ event WiFi design notes +CODING-GUIDELINES.md +ARCHITECTURE.md +``` + +--- + +## 5. Module responsibilities + +| Dir | Job | +|---|---| +| `config` | camelCase JSON, validate, load_or_create (example **without** `socket`), inotify debounce ~300 ms | +| `net` | iface filter, `ip` / `ping` / `dhclient`, DHCPDISCOVER encode/probe | +| `dhcp` | render `dnsmasq.conf`, start / SIGHUP / restart / stop | +| `apply` | probe policy, mode apply | +| `ipc` | `bind_singleton`, framing | +| `daemon` | watch + IPC + apply; socket path is **not** hot-reloaded | + +`net` and `dhcp` MUST NOT import `ipc`. + +--- + +## 6. Mode selection + +1. Link up, no address; kill leftover `dhclient`. +2. If currently serving DHCP, stop dnsmasq before a full probe (do not + offer to ourselves). +3. DHCPDISCOVER, wait `probeTimeoutSecs` for DHCPOFFER. +4. Offer → `client`. +5. Else assign `staticHost` (default **252**), ping `gateway.ip` + (`-c 1 -W 2`). If `gateway.ip` is already local, treat ping as fail + (stay / become gateway). +6. Ping OK → `static` (keep `.252`, `default via gateway.ip`, stop dnsmasq). +7. Ping fail → `gateway` (drop `.252`, `gateway.ip/prefix`, dnsmasq, + **no** default route). + +`staticHost` MUST lie in the `/24`, differ from `gateway.ip`, and sit +outside `[rangeStart, rangeEnd]`. + +Two operator kits (daemon only sees DHCP + ping): + +- **TL-SF1006P** — empty LAN → `gateway`, BigFred DHCP. +- **MikroTik hEX PoE lite RB750UPr2** — ether1 BigFred (no PoE), + ether2–5 PoE to APs; router DHCP → `client` / `static`. + +--- + +## 7. Hot-reload (JSON + dnsmasq) + +Invalid JSON: keep previous config, log a warning. + +- Socket path is not hot-reloaded. +- Reload while **not** `gateway`: full probe (DHCP + ping). +- Reload while **`gateway`**: skip DHCPDISCOVER; re-ping only if + `gateway.ip` is not ours; rewrite address/pool as needed. +- IPC `reconfigure`: always full probe (stop own dnsmasq first). + +dnsmasq when the new mode is `gateway` and generated conf changed: + +1. Write `$DATA_DIR/etc/dnsmasq.conf`. +2. If not running → start. +3. If running: SIGHUP; **restart** (TERM then `dnsmasq -C`) when SIGHUP + fails **or** the main conf changed (`dhcp-range`, `listen-address`, + `interface`, `dhcp-option`, lease time — SIGHUP does not re-read these). +4. If the process vanished after SIGHUP → start. +5. Mode `client` / `static`: **stop** leftover dnsmasq; do not rewrite + conf as a server. + +Unchanged conf → do not touch the process. + +--- + +## 8. IPC + +Requests `{ "type": "status" | "info" | "reconfigure" }`. + +`status` fields (camelCase): `mode`, `iface`, `cidr`, `foreignDhcp`, +`gatewayReachable`, `dnsmasqRunning`. + +CLI: `serve` / `run` (default), `apply`, `status`, `check` (exit 0 when +iface + IPv4 via socket), `reconfigure`, `info`. Global `--config`, +`--socket`, `--data-dir`. Relative `--socket` / `--config` join under +the data root; absolute `--socket` is CLI-only (tests). + +--- + +## 9. Integration + +- microinit service `network`: `daemon: true`, `exec /usr/sbin/micronet serve`, + liveness `micronet check` (~20 s). +- `configure-dhcp` service is removed. +- bigfred-os fetch installs `/usr/sbin/micronet` (optional argv0 aliases). +- Overlay `etc/micronet/micronet.json` seeds `$DATA_DIR/etc/micronet.json` + **only if missing** (operator edits survive), event subnet `10.0.10.0/24`. diff --git a/CODING-GUIDELINES.md b/CODING-GUIDELINES.md new file mode 100644 index 0000000..de93972 --- /dev/null +++ b/CODING-GUIDELINES.md @@ -0,0 +1,1625 @@ +# Rust Engineering Best Practices + +> A complete project standard for writing correct, explicit, allocation-aware, production-grade Rust. +> +> This document is intended to replace the previous `rust-best-practices.md` Gist in full. It integrates ownership, API design, error handling, testing, linting, performance, `debug_assert!()` usage, and heap-allocation discipline into one coherent set of rules. + +## Status and terminology + +This is a normative engineering guide, not an introductory Rust tutorial. + +The words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** describe requirement strength: + +- **MUST / MUST NOT**: required unless an approved architecture decision explicitly documents an exception. +- **SHOULD / SHOULD NOT**: the default; deviations require a concrete reason in code review. +- **MAY**: optional and context-dependent. + +Correctness, safety, determinism, and maintainability take priority over cleverness. Performance work must be driven by measurements, but allocation behavior and boundedness should be designed into APIs before profiling because they are architectural properties, not merely local optimizations. + +--- + +## 1. Core engineering principles + +### 1.1 Make invalid states difficult or impossible to represent + +Use the type system to encode domain meaning and legal states: + +- newtypes for identifiers, offsets, lengths, units, scores, and protocol values; +- enums instead of loosely related booleans or sentinel integers; +- `Option` for optional values rather than magic values; +- `Result` for recoverable failures; +- type-state when legal operations depend on an object's lifecycle state; +- fixed-width integer types at serialization and FFI boundaries; +- private fields unless direct representation access is intentionally part of the contract. + +```rust +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct NodeId(u32); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResolutionStatus { + Exact, + BackedOff, + Unsupported, +} +``` + +Do not use a raw `u32` for every identifier merely because the underlying representation is the same. Distinct types prevent accidental interchange and make signatures self-documenting. + +### 1.2 Prefer explicit control flow over hidden behavior + +Important behavior should be visible in types and function signatures: + +- allocation behavior; +- capacity limits; +- error behavior; +- ownership transfer; +- mutation; +- blocking versus asynchronous behavior; +- determinism requirements; +- panic conditions; +- thread-safety expectations. + +Avoid APIs whose correctness depends on undocumented global state, implicit initialization, hidden retries, lazy allocation, or environment-dependent defaults. + +### 1.3 Keep runtime work bounded + +Runtime and hot-path operations MUST have explicit bounds on: + +- iterations; +- candidate counts; +- recursion depth; +- queue length; +- output size; +- temporary storage; +- retry count; +- concurrency; +- bytes read or written where practical. + +When a limit is reached, return a typed error or explicit status. Do not silently allocate more memory, recurse without a bound, grow an unbounded queue, or switch to an unbounded fallback algorithm. + +### 1.4 Separate rich construction from lean execution + +Compiler, CLI, migration, test, and offline-analysis code may need rich owned structures. Runtime execution should consume compact validated views and caller-owned state. + +A common architecture is: + +```text +allocating authoring/compiler layer + -> validates, normalizes, sorts, packs, and serializes +immutable packed artifact + -> borrowed by +a bounded, allocation-free runtime +``` + +Do not deserialize a packed artifact into a heap-resident object graph merely for convenience when the runtime can borrow validated slices directly. + +### 1.5 Preserve a clear reference implementation + +Optimized implementations MUST remain behaviorally equivalent to a clear, safe reference path. Keep the reference implementation readable enough to serve as: + +- the normative semantics; +- a differential-testing oracle; +- a portability fallback; +- a basis for property tests; +- a reviewable specification for optimized code. + +--- + +## 2. Memory and allocation contracts + +Every crate, module, and performance-sensitive public operation MUST declare one of the following memory profiles. + +| Profile | Contract | Typical use | +|---|---|---| +| **Strict heapless** | No heap-backed storage is used. Prefer `#![no_std]`; do not import `alloc`. | Core runtimes, embedded code, parsers and kernels requiring proof of no heap use. | +| **Allocation-free steady state** | Initialization may allocate, but the named operation and its complete transitive call graph perform zero allocation or reallocation after initialization. | Servers, reusable engines, prepared runtimes, per-request or per-token execution. | +| **Allocation-conscious** | Allocation is permitted only at explicit boundaries and must be justified, bounded where possible, and measured when performance-sensitive. | CLI, compilers, build tools, administrative services, offline analysis. | + +The default for runtime, protocol, parser hot paths, deterministic kernels, and repeated request processing is **strict heapless** or **allocation-free steady state**. + +### 2.1 Be precise about what is guaranteed + +These claims are different: + +- “This function does not call `Vec::new()`.” +- “This function performs no allocation on the exercised path.” +- “This function and every transitive callee perform no allocation for all valid inputs.” +- “This subsystem never uses heap-backed storage.” + +Only the last statement is a strict no-heap guarantee. + +A function accepting `&[T]` backed by a caller-created `Vec` may itself be allocation-free, but the overall system is not heapless. A preallocated `Vec` may satisfy a steady-state zero-allocation contract if it never grows, but it still uses heap memory and does not satisfy a strict heapless contract. + +### 2.2 Allocation behavior is part of the API + +Public runtime APIs SHOULD make allocation unnecessary and obvious from their signatures. + +Prefer: + +- `&T`, `&mut T`, `&[T]`, `&mut [T]`, and `&str`; +- caller-owned output buffers; +- caller-owned scratch buffers; +- fixed-size arrays for genuinely small bounds; +- fixed-capacity containers that cannot spill to the heap; +- borrowed views into validated bytes; +- iterators instead of collected results; +- returned lengths, ranges, and status values instead of owned collections; +- static dispatch or enum dispatch instead of boxed trait objects; +- small, copyable error enums. + +Avoid signatures such as: + +```rust +fn parse(input: String) -> Vec; +``` + +Prefer a shape such as: + +```rust +fn parse(input: &[u8], output: &mut [Record]) -> Result; +``` + +The second signature makes ownership, capacity, and failure behavior explicit and permits both heapless and heap-backed callers. + +### 2.3 Do not move unbounded work onto the stack + +Avoiding the heap does not mean placing arbitrarily large arrays on every thread stack. + +Large storage SHOULD be: + +- supplied by the caller; +- static when lifetime and synchronization permit; +- stored in a bounded arena with a documented lifecycle; +- memory-mapped; +- partitioned into bounded chunks; +- placed in a reusable worker-owned scratch region. + +Review stack consumption against the smallest supported thread stack. Avoid unbounded recursion. Recursion is acceptable only when depth is statically or structurally bounded and documented. + +### 2.4 Capacity exhaustion must be explicit + +A fixed-capacity structure MUST report exhaustion deterministically. + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CapacityError { + Full { capacity: usize }, +} +``` + +It MUST NOT silently: + +- spill into a heap allocation; +- discard existing entries; +- overwrite an unrelated entry; +- retry indefinitely; +- switch to an unbounded representation. + +### 2.5 Allocation-free output-buffer pattern + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FilterError { + OutputTooSmall { + required: usize, + provided: usize, + }, +} + +/// Copies even values into `output` and returns the initialized prefix length. +/// +/// # Allocation +/// +/// This function performs no heap allocation. +pub fn retain_even(input: &[u32], output: &mut [u32]) -> Result { + let required = input + .iter() + .filter(|value| **value & 1 == 0) + .count(); + + if output.len() < required { + return Err(FilterError::OutputTooSmall { + required, + provided: output.len(), + }); + } + + // The release-active check above establishes this internal invariant. + debug_assert!(required <= output.len()); + + let mut written = 0usize; + + for &value in input { + if value & 1 == 0 { + debug_assert!(written < output.len()); + output[written] = value; + written += 1; + } + } + + debug_assert_eq!(written, required); + Ok(written) +} +``` + +The initial capacity branch is required in all builds because it handles caller-controlled input. The subsequent debug assertions verify internal invariants established by checked control flow. + +### 2.6 Direct and hidden allocation to review + +The following are forbidden in strict heapless code. They are also forbidden in an allocation-free operation unless they are constructed outside the operation and the exercised call path is proven not to grow, allocate, or reallocate: + +- `Vec`, `String`, `Box`, `Rc`, `Arc`, `PathBuf`, `OsString`; +- `HashMap`, `HashSet`, `BTreeMap`, `BTreeSet`, `VecDeque`, `BinaryHeap`; +- `Box::new`, `Box::pin`, `Rc::new`, `Arc::new`; +- `vec![]`, `format!()`, `to_vec()`, allocating `to_owned()`, and `to_string()`; +- `collect::>()`, `collect::()`, and equivalent owned collections; +- growth through `push`, `insert`, `extend`, `reserve`, or implicit reallocation; +- boxed trait objects, boxed iterators, and boxed futures; +- `Cow::into_owned()` and APIs that may silently transition from borrowed to owned; +- spill-capable “small” containers unless spilling is structurally impossible; +- lazy initialization that creates owned heap data on first use; +- logging, tracing, metrics, serialization, backtrace, and error-context paths that have not been allocation-audited; +- callbacks, trait methods, FFI functions, or third-party dependencies whose transitive behavior is unknown. + +Syntax alone does not determine allocation. Iterators, closures, formatting arguments, trait calls, and `async fn` are not inherently allocating, but a specific adapter, receiver, executor, or implementation may allocate. Review the complete call graph. + +### 2.7 Formatting without owned strings + +`format_args!()` creates borrowed formatting arguments without itself creating an owned `String`. The destination still determines whether formatting allocates. + +Prefer writing directly to a caller-supplied or fixed-capacity sink: + +```rust +use core::fmt::{self, Write as _}; + +pub fn write_record( + sink: &mut impl fmt::Write, + id: u32, + score: i32, +) -> fmt::Result { + write!(sink, "id={id} score={score}") +} +``` + +The function above is allocation-free only when the supplied sink is allocation-free. A `String` sink may grow; a fixed-capacity sink should return `fmt::Error` when full. + +Avoid constructing owned diagnostic strings in runtime code. Defer rich formatting to a higher-level adapter after the core operation returns a typed error. + +### 2.8 Sorting and selection + +Where unstable ordering is acceptable, slice methods such as `sort_unstable*` and `select_nth_unstable*` are in-place and do not allocate. Do not replace a stable ordering requirement merely to avoid allocation; instead define the required semantics and choose or implement a bounded algorithm that satisfies them. + +If deterministic output matters, explicitly define: + +- total ordering; +- tie-breaking; +- treatment of equal keys; +- architecture-independent integer behavior; +- canonical output order. + +### 2.9 Keep errors heapless at the core boundary + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ParseError { + OffsetOutOfBounds { + offset: usize, + input_len: usize, + }, + OutputTooSmall { + required: usize, + provided: usize, + }, + CapacityExceeded { + capacity: usize, + }, + IntegerOverflow, +} +``` + +Implement `Display` by writing directly to the formatter. Do not store a preformatted `String` merely to add context. Application code may translate a core error into a richer allocating diagnostic after crossing the allocation-free boundary. + +### 2.10 Isolate strict code structurally + +A strict core crate SHOULD begin from a posture similar to: + +```rust +#![no_std] +#![forbid(unsafe_code)] +#![deny(clippy::disallowed_macros)] +#![deny(clippy::disallowed_types)] +``` + +Do not add `extern crate alloc` to a crate claiming strict no-heap behavior. Put filesystem, network, CLI, telemetry, and rich diagnostic integrations in adapter crates. + +A recommended workspace shape is: + +```text +crates/ + project-core/ # no_std, no alloc, bounded algorithms + project-format/ # packed types and validation + project-runtime/ # allocation-free repeated execution + project-std/ # std adapters, I/O, threading, telemetry + project-cli/ # allocating application boundary +``` + +--- + +## 3. Ownership, borrowing, and values + +### 3.1 Borrow instead of clone by default + +Take borrowed inputs unless ownership transfer is required. + +Prefer: + +```rust +fn checksum(bytes: &[u8]) -> u32 { + bytes.iter().fold(0u32, |sum, byte| sum.wrapping_add(u32::from(*byte))) +} +``` + +Avoid: + +```rust +fn checksum(bytes: Vec) -> u32 { + // Ownership was unnecessary. + bytes.iter().fold(0u32, |sum, byte| sum.wrapping_add(u32::from(*byte))) +} +``` + +Use: + +- `&str`, not `&String`; +- `&[T]`, not `&Vec`; +- `&Path`, not `&PathBuf`; +- borrowed domain views rather than cloned domain objects. + +A clone is appropriate when independent ownership is semantically required. When cloning, make the cost visible and intentional. Avoid cloning to satisfy the borrow checker before understanding the ownership model. + +### 3.2 Pass small `Copy` values by value + +Pass scalar values, compact newtypes, and small `Copy` structs by value when that is clearer. Do not establish a universal byte threshold without measuring the relevant ABI and target. + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Range32 { + pub start: u32, + pub end: u32, +} + +fn contains(range: Range32, value: u32) -> bool { + value >= range.start && value < range.end +} +``` + +Large structs and non-`Copy` values should usually be borrowed unless the function consumes them intentionally. + +### 3.3 Make ownership transitions obvious + +Names such as `into_*`, `to_*`, and `as_*` should follow Rust conventions: + +- `into_*`: consumes `self`; +- `to_*`: usually creates or converts to an owned value and may allocate; +- `as_*`: returns a borrowed or inexpensive view. + +Document allocation when a conversion creates owned storage. + +### 3.4 Avoid self-referential and pointer-heavy designs without need + +Prefer contiguous data and index-based relationships over linked object graphs. Indexes and offsets are easier to: + +- serialize; +- validate; +- borrow; +- cache efficiently; +- bound; +- move across FFI; +- execute without allocation. + +Use pointer-rich structures only when their semantics and measured workload justify the complexity. + +--- + +## 4. `Option`, `Result`, panics, and arithmetic + +### 4.1 Use `Option` for absence and `Result` for failure + +Do not encode absence with empty strings, zero IDs, negative numbers, or invalid pointers. + +```rust +pub fn record_at(records: &[Record], index: usize) -> Result<&Record, LookupError> { + let record = records.get(index).ok_or(LookupError::OutOfBounds { + index, + len: records.len(), + })?; + + debug_assert!(index < records.len()); + Ok(record) +} +``` + +Use combinators when they improve clarity. Use `match`, `if let`, or `let ... else` when control flow or error context is clearer explicitly. + +### 4.2 Avoid `unwrap()` and `expect()` in production paths + +Production libraries MUST NOT use `unwrap()` or `expect()` for recoverable conditions. Tests, examples specifically demonstrating panic behavior, and compile-time-proven constants may use them sparingly, but even there a clearer assertion is often better. + +Do not convert a recoverable error into a panic merely because handling it is inconvenient. + +### 4.3 Prefer typed library errors + +Library errors should be focused, stable, and domain-specific. Do not expose a third-party error type as the core public contract unless that coupling is intentional. + +At a rich application boundary, additional context may be attached. In strict or allocation-free code, error context must remain allocation-free. + +`thiserror` may be useful for allocating or `std`-facing crates when compatible with the project’s MSRV and feature policy. `anyhow` is appropriate at binary/application boundaries, not in core library APIs or strict heapless code. + +### 4.4 Use `?` for propagation without hiding policy + +The `?` operator is preferred for straightforward propagation. Do not use it to obscure meaningful translation, retry, rollback, or cleanup policy. + +```rust +let header = parse_header(input)?; +let body = parse_body(input, header.body_range) + .map_err(ParsePacketError::Body)?; +``` + +### 4.5 Panic only for genuine programmer defects + +Caller-controlled input, malformed artifacts, capacity exhaustion, I/O failures, and unavailable resources are not programmer defects. Return a typed error. + +A panic MAY be appropriate when an internal invariant is violated and continuing would indicate a defect. In libraries, keep panic conditions rare and documented under `# Panics`. + +### 4.6 Use checked arithmetic at trust boundaries + +Offsets, lengths, capacities, and serialized values MUST use checked arithmetic before indexing or allocation decisions. + +```rust +let end = start + .checked_add(length) + .ok_or(ParseError::IntegerOverflow)?; + +if end > input.len() { + return Err(ParseError::OffsetOutOfBounds { + offset: end, + input_len: input.len(), + }); +} + +debug_assert!(start <= end); +debug_assert!(end <= input.len()); +``` + +Choose and document overflow semantics for domain arithmetic: + +- checked and erroring; +- saturating; +- wrapping; +- explicitly proven impossible. + +Do not rely on debug-only overflow behavior as the release contract. + +--- + +## 5. `debug_assert!()` and internal invariants + +### 5.1 Use debug assertions deliberately + +Use `debug_assert!()`, `debug_assert_eq!()`, and `debug_assert_ne!()` for internal invariants when all of the following are true: + +1. The condition is established by types or release-active control flow. +2. The condition represents a programming invariant, not caller validation. +3. Release correctness does not depend on the assertion executing. +4. Removing the assertion cannot introduce undefined behavior. +5. Evaluating the assertion has no required side effects. +6. The condition and message do not intentionally allocate. + +Good examples: + +```rust +debug_assert!(cursor <= input.len()); +debug_assert!(written <= output.len()); +debug_assert_eq!(range.end - range.start, record_count); +debug_assert_ne!(capacity, 0); +``` + +Use debug assertions after important state transitions, checked bounds calculations, fixed-capacity writes, parser cursor movement, and canonicalization steps when they provide meaningful defect detection. + +Do not add assertions mechanically. Every assertion should communicate a real invariant. + +### 5.2 Validate external input in all builds + +Incorrect: + +```rust +pub fn read_byte(input: &[u8], index: usize) -> u8 { + debug_assert!(index < input.len()); + input[index] +} +``` + +The function accepts caller-controlled input but provides no recoverable contract. + +Prefer: + +```rust +pub fn read_byte(input: &[u8], index: usize) -> Result { + let value = input.get(index).copied().ok_or(LookupError::OutOfBounds { + index, + len: input.len(), + })?; + + debug_assert!(index < input.len()); + Ok(value) +} +``` + +### 5.3 Never use a debug assertion as an unsafe precondition + +Incorrect: + +```rust +// Incorrect: the bounds proof normally disappears in optimized builds. +debug_assert!(index < values.len()); +let value = unsafe { *values.get_unchecked(index) }; +``` + +Unsafe code MUST rely on: + +- types that enforce the requirement; +- release-active validation; +- a documented invariant proven independently of debug assertions. + +A debug assertion may duplicate a valid proof for diagnostics, but it cannot be the proof. + +### 5.4 Assertions must not contain required side effects + +Incorrect: + +```rust +// The state update may disappear in optimized builds. +debug_assert!(advance_cursor(&mut cursor)); +``` + +Correct: + +```rust +let advanced = advance_cursor(&mut cursor); +debug_assert!(advanced); +``` + +The program must behave correctly whether debug assertions are enabled or disabled. + +### 5.5 Keep assertion diagnostics allocation-aware + +Prefer simple conditions and static messages: + +```rust +debug_assert!(written <= capacity, "written length exceeded capacity"); +``` + +Avoid constructing owned values: + +```rust +// Avoid in allocation-free code. +debug_assert!(is_valid(&input.to_vec())); +debug_assert!(ok, "state={}", state.to_string()); +``` + +`debug_assert_eq!()` and `debug_assert_ne!()` format values with `Debug` when they fail. Ensure custom `Debug` implementations do not create owned strings in strict paths. + +### 5.6 Do not count invariant-panic behavior as a recoverable path + +Panic hooks, backtraces, and diagnostic output may allocate depending on the target and configuration. Unless the panic path is separately audited, an allocation-free guarantee should cover: + +- successful execution; +- all documented recoverable error paths; +- capacity exhaustion; +- malformed external input handling. + +An internal invariant panic is a defect path, not an ordinary result path. + +### 5.7 Test optimized code with debug assertions enabled + +Add a release-like profile: + +```toml +[profile.release-assertions] +inherits = "release" +debug-assertions = true +overflow-checks = true +``` + +Run it in CI: + +```bash +cargo test --workspace --profile release-assertions +``` + +This catches invariants under optimized control flow while preserving a separate normal release profile. + +--- + +## 6. Iterators, loops, and collection behavior + +### 6.1 Iterators are not inherently allocating + +Iterator adapters are generally lazy. Allocation usually occurs when collecting into an owned container or when a particular adapter or closure performs allocation. + +Prefer a borrowed iterator when callers can consume results incrementally: + +```rust +pub fn active_records( + records: &[Record], +) -> impl Iterator { + records.iter().filter(|record| record.active) +} +``` + +Do not collect solely to return a convenient intermediate `Vec`. + +### 6.2 Use `for` loops when they are clearer + +A direct loop is often the clearest form for: + +- multiple mutable accumulators; +- explicit bounds and capacity checks; +- early exits; +- state machines; +- hot kernels whose generated code is inspected. + +```rust +let mut written = 0usize; +for item in input { + if keep(item) { + if written == output.len() { + return Err(CapacityError::Full { + capacity: output.len(), + }); + } + + debug_assert!(written < output.len()); + output[written] = *item; + written += 1; + } +} +``` + +Choose the form that makes correctness and bounds easiest to review. Do not rewrite readable loops into complex iterator chains merely to appear idiomatic. + +### 6.3 Avoid intermediate collections + +Instead of: + +```rust +let normalized: Vec<_> = input.iter().map(normalize).collect(); +let selected: Vec<_> = normalized.iter().filter(|value| accept(value)).collect(); +``` + +Use a fused iterator or caller-owned output: + +```rust +for value in input.iter().map(normalize).filter(accept) { + // Consume immediately or place into bounded caller-owned storage. +} +``` + +### 6.4 Do not assume a closure is free + +A closure can capture owned state, clone data, call allocating code, or force dynamic dispatch. Review captures and generated types in hot paths. + +Prefer borrowing captures where possible. Use `move` only when ownership transfer is required. + +--- + +## 7. Function and module design + +### 7.1 Keep functions single-purpose + +Extract a function when it creates a meaningful semantic boundary, improves testing, centralizes an invariant, or removes duplicated policy. + +Do not extract tiny fragments that: + +- obscure a simple control flow; +- require many pass-through parameters; +- hide performance-critical work; +- make ownership harder to understand; +- create abstractions with no stable meaning. + +### 7.2 Keep hot paths easy to inspect + +Performance-sensitive loops SHOULD make these properties visible: + +- bounds; +- memory access pattern; +- temporary state; +- allocation behavior; +- error exits; +- branch structure; +- ordering and tie-breaking. + +Abstraction is welcome when it compiles cleanly and preserves visibility. Keep a benchmark and reference implementation when the optimized path becomes non-obvious. + +### 7.3 Avoid boolean blindness + +Instead of: + +```rust +fn execute(strict: bool, retry: bool, audit: bool) -> Result<(), Error>; +``` + +Prefer: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ValidationMode { + Strict, + Compatible, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ExecutionPolicy { + pub validation: ValidationMode, + pub retry: RetryPolicy, + pub audit: AuditPolicy, +} +``` + +### 7.4 Keep imports explicit + +Avoid wildcard imports outside controlled preludes and test modules. Import traits intentionally when needed for extension methods. + +Group imports consistently and let `rustfmt` determine formatting. Do not use aliases that obscure well-known types unless resolving a real collision or expressing domain meaning. + +### 7.5 Keep visibility narrow + +Start private. Expand to `pub(crate)` or `pub` only when a real consumer requires it. Public APIs create compatibility obligations. + +Avoid exposing implementation collections, synchronization primitives, or third-party types if callers do not need them. + +--- + +## 8. Generics, static dispatch, dynamic dispatch, and async + +### 8.1 Prefer static dispatch in hot and strict paths + +Generics and `impl Trait` allow specialization without requiring a heap allocation or virtual call. + +```rust +pub fn encode(sink: &mut W, value: &Record) -> Result<(), W::Error> { + // ... + Ok(()) +} +``` + +Use static dispatch when: + +- the set of implementations is known at compile time; +- performance matters; +- the code belongs to a strict allocation-free core; +- monomorphization cost is acceptable. + +### 8.2 Use `dyn Trait` only for genuine runtime polymorphism + +A borrowed `&dyn Trait` does not itself require heap allocation. A `Box` does. + +Dynamic dispatch is appropriate when: + +- implementations are selected at runtime; +- heterogeneous values must be stored behind one interface; +- reducing code size matters more than virtual-call overhead; +- the dynamic boundary is outside the hot path. + +Prefer enum dispatch when the implementation set is closed and small. + +### 8.3 Put allocation at an explicit outer boundary + +A control plane may choose a backend dynamically while the chosen backend runs statically inside a hot operation. Do not force all internal APIs to use boxed trait objects merely because one outer layer needs runtime selection. + +### 8.4 Async syntax does not prove allocation freedom + +An `async fn` returns a future value and does not inherently require a box. Allocation may still occur through: + +- boxed futures; +- dynamic async trait adapters; +- task spawning; +- executor task storage; +- channels and queues; +- captured owned buffers; +- I/O libraries; +- telemetry. + +An allocation-free async claim must include the executor, spawning policy, adapters, and full call graph. + +### 8.5 Bound concurrency + +Concurrency MUST NOT multiply memory usage without a bound. + +Define: + +- maximum workers; +- queue capacities; +- per-worker scratch usage; +- backpressure behavior; +- cancellation behavior; +- deterministic merge ordering where output is canonical. + +Prefer worker-local reusable scratch buffers over shared `Mutex>` accumulation. Avoid assigning semantic IDs through scheduling-dependent atomics when deterministic output matters. + +--- + +## 9. Type-state and lifecycle safety + +Use type-state when legal operations depend strongly on lifecycle state and the additional types make the API clearer. + +```rust +use core::marker::PhantomData; + +pub struct Unvalidated; +pub struct Validated; + +pub struct Artifact<'a, State> { + bytes: &'a [u8], + _state: PhantomData, +} + +impl<'a> Artifact<'a, Unvalidated> { + pub fn validate(self) -> Result, ValidationError> { + validate_bytes(self.bytes)?; + + Ok(Artifact { + bytes: self.bytes, + _state: PhantomData, + }) + } +} + +impl Artifact<'_, Validated> { + pub fn records(&self) -> RecordIter<'_> { + debug_assert!(header_is_valid(self.bytes)); + RecordIter::new(self.bytes) + } +} +``` + +The debug assertion above checks a property already established by construction. It must not be the only validation. + +Use type-state for a small number of meaningful states. Avoid creating an explosion of generic parameters for incidental flags. When state is dynamic, externally supplied, or persisted, a runtime enum may be clearer. + +--- + +## 10. Pointers, sharing, concurrency, and unsafe Rust + +### 10.1 Prefer references and slices + +Use references for borrowing and slices for contiguous data. Reach for smart pointers only when their ownership semantics are actually required. + +Remember: + +- `Box` owns heap storage; +- `Rc` owns heap storage with non-atomic reference counting; +- `Arc` owns heap storage with atomic reference counting; +- cloning `Rc` or `Arc` may not allocate, but the value remains heap-backed and therefore is not strict heapless; +- interior mutability changes aliasing and synchronization reasoning. + +### 10.2 Do not use shared ownership as a default escape hatch + +Frequent `Arc>` use can signal unclear ownership. Prefer: + +- a single explicit owner; +- message passing with bounded queues; +- immutable shared data; +- scoped threads borrowing state; +- partitioned state; +- IDs or handles into a controlled store. + +Use locks when they are the clearest correct design. Lock-free code is not automatically faster or safer. + +### 10.3 Treat `Send` and `Sync` as semantic commitments + +Do not add unsafe `Send` or `Sync` implementations without a written proof covering all interior state, aliases, callbacks, and FFI interactions. + +### 10.4 Forbid unsafe code by default + +Use: + +```rust +#![forbid(unsafe_code)] +``` + +in crates that do not require unsafe Rust. + +When unsafe code is necessary: + +- isolate it in the smallest possible module; +- expose a safe API; +- document every unsafe block with a `SAFETY:` comment; +- state all pointer, alignment, initialization, aliasing, lifetime, and concurrency invariants; +- enforce preconditions in release-active code or types; +- add focused tests and Miri coverage where applicable; +- retain a safe reference implementation when optimizing; +- never rely solely on `debug_assert!()` for safety. + +### 10.5 Audit FFI as a complete boundary + +FFI documentation MUST define: + +- ownership transfer; +- who allocates and deallocates; +- allocator compatibility; +- pointer validity and alignment; +- buffer length and capacity; +- lifetime; +- thread affinity; +- panic behavior; +- error representation; +- callback reentrancy. + +Rust panics must not unwind across an FFI boundary unless the ABI explicitly supports and documents it. + +--- + +## 11. Performance mindset + +### 11.1 Measure before and after + +Do not optimize based only on intuition. Establish: + +- a representative workload; +- release-mode measurements; +- hardware and compiler metadata; +- latency distribution, not only averages; +- throughput; +- peak memory; +- allocation counts; +- bytes processed; +- cache behavior when relevant; +- regression thresholds. + +### 11.2 Improve algorithms and data layout first + +Prioritize: + +1. asymptotic behavior; +2. bounded candidate sets; +3. avoiding unnecessary work; +4. contiguous data layout; +5. reducing bytes read and written; +6. avoiding allocation and copies; +7. cache locality; +8. branch predictability; +9. vectorization or architecture-specific kernels only after the above. + +A reduction in arithmetic count is not necessarily a speedup if memory traffic, cache misses, or synchronization dominate. + +### 11.3 Avoid redundant clones and copies + +Use profiling and code review to identify: + +- cloning inside loops; +- copying large structs by value; +- converting repeatedly between string and byte representations; +- serializing only to deserialize immediately; +- collecting intermediate results; +- copying buffers across abstraction boundaries. + +Do not remove a clone if doing so makes ownership unsound or materially harms clarity. Fix the ownership model rather than introducing fragile references. + +### 11.4 Reuse prepared state + +For steady-state allocation-free systems: + +- validate and prepare once; +- precompute immutable tables; +- allocate permitted capacity during initialization; +- reuse worker-local scratch; +- reset lengths and cursors rather than reconstructing containers; +- keep repeated operations free of lazy initialization. + +### 11.5 Inspect generated code selectively + +Assembly or LLVM IR inspection is useful for critical kernels, especially when verifying: + +- bounds-check elimination; +- vectorization; +- unexpected calls; +- hidden allocation; +- integer operations; +- branch structure; +- architecture-specific instruction use. + +Generated code inspection supplements behavioral tests; it does not replace them. + +### 11.6 Keep debug assertions in a dedicated optimized test lane + +Normal release benchmarks should reflect production settings. Separately run optimized tests with `debug-assertions = true` so invariant checks execute under optimized code generation. + +--- + +## 12. Clippy, formatting, and lint discipline + +### 12.1 Treat warnings as failures in CI + +Run: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +``` + +If workspace features are mutually exclusive, replace `--all-features` with an explicit tested feature matrix. + +### 12.2 Configure workspace lints centrally + +```toml +[workspace.lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" +unused_must_use = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "warn", priority = -1 } +dbg_macro = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +unwrap_used = "deny" +``` + +Each member crate adopts the workspace policy: + +```toml +[lints] +workspace = true +``` + +Do not enable a broad lint set blindly and then scatter suppressions. Tune the policy to the codebase and MSRV. + +### 12.3 Fix warnings instead of hiding them + +Prefer `#[expect(...)]` with a reason when the project MSRV supports it. Otherwise use the narrowest possible `#[allow(...)]` and explain why. + +A suppression MUST be: + +- local; +- tied to a specific lint; +- justified; +- removable when the underlying constraint changes. + +Do not disable a lint at workspace scope to avoid fixing one call site. + +### 12.4 Add allocation guardrails for strict crates + +A workspace can reject obvious heap-backed types and macros: + +```toml +# clippy.toml + +disallowed-types = [ + { path = "alloc::boxed::Box", reason = "heap allocation is forbidden in strict runtime code", allow-invalid = true }, + { path = "alloc::string::String", reason = "borrow text or use fixed-capacity storage", allow-invalid = true }, + { path = "alloc::vec::Vec", reason = "use caller-owned slices or fixed-capacity storage", allow-invalid = true }, + { path = "std::boxed::Box", reason = "heap allocation is forbidden in strict runtime code", allow-invalid = true }, + { path = "std::string::String", reason = "borrow text or use fixed-capacity storage", allow-invalid = true }, + { path = "std::vec::Vec", reason = "use caller-owned slices or fixed-capacity storage", allow-invalid = true }, +] + +disallowed-macros = [ + { path = "alloc::format", reason = "write into a caller-owned or fixed-capacity sink", allow-invalid = true }, + { path = "alloc::vec", reason = "use arrays, slices, or fixed-capacity storage", allow-invalid = true }, + { path = "std::format", reason = "write into a caller-owned or fixed-capacity sink", allow-invalid = true }, + { path = "std::vec", reason = "use arrays, slices, or fixed-capacity storage", allow-invalid = true }, +] +``` + +Enable the corresponding deny lints in strict crates: + +```rust +#![deny(clippy::disallowed_macros)] +#![deny(clippy::disallowed_types)] +``` + +This is a guardrail, not a proof. It cannot see allocation hidden behind custom types, dependencies, callbacks, FFI, logging, or trait methods. + +### 12.5 Keep formatting mechanical + +Use `rustfmt`. Do not spend review time debating formatting that the formatter owns. Keep manual style decisions focused on naming, module shape, visibility, API semantics, and control flow. + +--- + +## 13. Automated testing + +### 13.1 Tests are executable documentation + +Tests should demonstrate behavior, boundaries, and failure policy. A test name should describe the condition and expected result. + +```rust +#[test] +fn parse_returns_output_too_small_when_capacity_is_insufficient() { + // ... +} +``` + +Prefer one behavioral reason for failure per test. Multiple assertions are fine when they jointly establish that one behavior. + +### 13.2 Use the right test layer + +- **Unit tests**: local algorithms, invariants, edge cases, error variants. +- **Integration tests**: public APIs, crate boundaries, feature combinations, allocation contracts. +- **Doc tests**: public usage examples that should continue compiling. +- **Property tests**: broad invariant exploration. +- **Fuzz tests**: parsers, protocol decoders, unsafe boundaries, malformed input. +- **Differential tests**: optimized implementation versus safe reference implementation. +- **Concurrency model tests**: ordering and synchronization behavior when needed. + +### 13.3 Test boundaries, not only happy paths + +Allocation-free and bounded APIs MUST test: + +- empty input; +- one element; +- exact capacity; +- one less than required capacity; +- maximum declared capacity; +- malformed offsets and lengths; +- integer overflow boundaries; +- duplicate and equal-key behavior; +- deterministic tie-breaking; +- repeated warm execution; +- every recoverable error variant; +- optional instrumentation paths; +- feature-disabled configurations. + +### 13.4 Test debug assertions intentionally + +Ordinary debug tests execute debug assertions. Also run the release-like assertion profile: + +```bash +cargo test --workspace --profile release-assertions +``` + +When an invariant should panic in debug mode, isolate that behavior in a focused test rather than relying on an incidental panic in a broad test. + +### 13.5 Count allocations in a dedicated integration test + +The test-only allocator wrapper below contains a narrow `unsafe` boundary because `GlobalAlloc` is unsafe to implement. It is not part of production code. + +```rust +// tests/no_alloc.rs + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct CountingAllocator; + +static ALLOCATION_CALLS: AtomicUsize = AtomicUsize::new(0); +static DEALLOCATION_CALLS: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOCATION_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + ALLOCATION_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + DEALLOCATION_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn realloc( + &self, + ptr: *mut u8, + layout: Layout, + new_size: usize, + ) -> *mut u8 { + ALLOCATION_CALLS.fetch_add(1, Ordering::Relaxed); + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static GLOBAL: CountingAllocator = CountingAllocator; + +fn allocation_calls() -> usize { + ALLOCATION_CALLS.load(Ordering::Relaxed) +} + +fn deallocation_calls() -> usize { + DEALLOCATION_CALLS.load(Ordering::Relaxed) +} + +#[test] +fn retain_even_performs_no_heap_activity() { + let input = [1, 2, 3, 4, 5, 6, 7, 8]; + let mut output = [0u32; 4]; + + // Complete all fixture and runtime setup before capturing the baseline. + let allocations_before = allocation_calls(); + let deallocations_before = deallocation_calls(); + + // Replace this path with the allocation-free API being certified. + let result = crate_under_test::retain_even(&input, &mut output); + + let allocations_after = allocation_calls(); + let deallocations_after = deallocation_calls(); + + assert_eq!(allocations_before, allocations_after); + assert_eq!(deallocations_before, deallocations_after); + assert_eq!(result, Ok(4)); + assert_eq!(output, [2, 4, 6, 8]); +} +``` + +Run the target alone and serially: + +```bash +cargo test --test no_alloc -- --test-threads=1 +``` + +Allocation-counting tests prove only the paths and inputs they exercise. Combine them with: + +- strict API review; +- dependency and feature review; +- `no_std` / no-`alloc` compilation where applicable; +- malformed-input tests; +- capacity tests; +- call-graph inspection; +- Clippy guardrails. + +### 13.6 Keep snapshots reviewable + +Snapshot tests are useful for structured diagnostics, generated code, canonical serialization, and CLI output. Keep snapshots small, deterministic, and human-reviewable. Do not use snapshots as a substitute for precise semantic assertions. + +### 13.7 Test documentation + +Public examples SHOULD be doc tests when practical. Documentation that compiles is less likely to drift. + +--- + +## 14. Documentation and comments + +### 14.1 Comments explain why + +Use comments for: + +- non-obvious invariants; +- algorithmic rationale; +- compatibility constraints; +- safety proofs; +- measured performance tradeoffs; +- protocol or specification references; +- reasons a simpler-looking implementation is incorrect. + +Do not narrate obvious syntax. + +Bad: + +```rust +// Increment the index. +index += 1; +``` + +Useful: + +```rust +// Advance only after the record has been committed so an error leaves the +// caller-visible prefix unchanged. +index += 1; +``` + +### 14.2 Public documentation explains the contract + +Public APIs SHOULD document applicable sections: + +- purpose and semantics; +- inputs and outputs; +- ownership and lifetimes; +- allocation behavior under `# Allocation`; +- finite limits and capacity behavior; +- `# Errors`; +- `# Panics`; +- `# Safety` for unsafe APIs; +- determinism and ordering; +- complexity when meaningful; +- examples. + +Example: + +```rust +/// Parses records into caller-owned storage. +/// +/// # Allocation +/// +/// Performs no heap allocation. Temporary state is held in scalar locals and +/// the caller-provided `output` slice. +/// +/// # Errors +/// +/// Returns [`ParseError::OutputTooSmall`] without modifying elements beyond +/// the returned initialized prefix. +/// +/// # Panics +/// +/// Does not panic for malformed input. +``` + +### 14.3 Keep TODOs traceable + +Every committed TODO SHOULD reference an issue or decision: + +```rust +// TODO(#421): Replace the scalar verifier after SIMD equivalence tests exist. +``` + +Do not leave vague TODOs that have no owner, scope, or removal condition. + +### 14.4 Replace stale comments with code or types + +If a comment describes a requirement that can be enforced by a type, constructor, enum, validation step, or test, prefer enforcement. Comments are not proofs. + +--- + +## 15. Dependencies, features, and workspace boundaries + +### 15.1 Minimize the strict dependency graph + +Core runtime crates SHOULD have the smallest practical dependency surface. Every dependency can introduce: + +- allocation; +- feature unification; +- platform assumptions; +- unsafe code; +- build scripts; +- transitive vulnerabilities; +- larger binaries; +- MSRV pressure. + +Do not add a dependency for a trivial helper that is clearer to implement locally. + +### 15.2 Disable default features intentionally + +Inspect dependency features rather than accepting defaults automatically: + +```toml +[dependencies] +some-crate = { version = "1", default-features = false, features = ["required-feature"] } +``` + +Test the intended feature matrix, especially: + +```bash +cargo check -p project-core --no-default-features +cargo check -p project-core --no-default-features --features feature_a +``` + +A feature enabled elsewhere in a workspace can change the unified dependency graph. Audit the resolved graph, not only one manifest entry. + +### 15.3 Separate core and adapters + +Do not make a heapless core depend on CLI parsing, async runtimes, telemetry, filesystem abstractions, HTTP clients, or rich error-reporting frameworks. Put those integrations in outer crates. + +### 15.4 Pin and audit appropriately + +Keep `Cargo.lock` committed for applications and workspaces. Use dependency, license, and vulnerability auditing appropriate to the project. Review build scripts and proc macros as supply-chain code. + +### 15.5 Maintain a documented MSRV when promised + +If the project promises a minimum supported Rust version, test it in CI. Do not use new syntax, attributes, or library APIs without either updating the MSRV intentionally or providing a compatible alternative. + +--- + +## 16. Determinism and canonical output + +When output is content-addressed, signed, cached, compared byte-for-byte, or used as a reproducibility artifact, determinism is a correctness requirement. + +Define and test: + +- input ordering; +- stable IDs; +- sorting and tie-breaking; +- hash-map independence; +- random seed policy; +- concurrency merge order; +- integer overflow semantics; +- architecture-independent widths and endianness; +- serialization field order; +- canonical padding and alignment; +- error ordering where multiple failures are possible. + +Parallel execution MAY change completion time but MUST NOT change canonical bytes when determinism is part of the contract. + +Use debug assertions for internal canonicalization invariants after the release-active algorithm has established them: + +```rust +canonicalize(records)?; + +debug_assert!(records.windows(2).all(|pair| pair[0].key <= pair[1].key)); +``` + +Do not rely on iteration order from a container unless that order is explicitly guaranteed and appropriate for the artifact format. + +--- + +## 17. Recommended Cargo and CI baseline + +### 17.1 Cargo profiles + +```toml +[profile.release] +overflow-checks = true + +[profile.release-assertions] +inherits = "release" +debug-assertions = true +overflow-checks = true +``` + +Choose LTO, codegen units, panic strategy, and symbol stripping based on measured build, binary-size, diagnostics, and deployment requirements. Do not copy a profile blindly across every crate and target. + +### 17.2 Required checks + +A strong baseline is: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo test --workspace --locked +cargo test --workspace --doc --locked +cargo test --workspace --profile release-assertions +cargo test --test no_alloc -- --test-threads=1 +``` + +Add as applicable: + +```bash +cargo check -p project-core --no-default-features +cargo miri test -p project-core +cargo test --workspace --release +cargo audit +cargo deny check +``` + +If `--all-features` represents an invalid combination, use an explicit feature matrix instead of skipping feature coverage. + +### 17.3 Performance gates + +Benchmarks used as merge gates MUST have: + +- stable fixtures; +- known warmup behavior; +- hardware metadata; +- noise-aware thresholds; +- allocation counters where relevant; +- separate correctness tests; +- recorded baseline changes. + +Do not make fragile microbenchmark noise a hard correctness gate. + +--- + +## 18. Code review checklist + +### Correctness and API + +- Are invalid states represented with types rather than conventions? +- Are caller-controlled failures returned as typed errors? +- Are integer and range calculations checked before indexing? +- Are panic conditions rare and documented? +- Is ownership transfer intentional and visible? +- Are public fields and types truly required? + +### Allocation and bounds + +- Is the memory profile declared? +- Do hot/repeated operations avoid heap allocation and reallocation? +- Are inputs borrowed and outputs or scratch buffers caller-owned where practical? +- Are all capacities finite and documented? +- Does exhaustion return an explicit error without spilling to the heap? +- Have hidden paths through formatting, telemetry, async, traits, callbacks, FFI, and dependencies been reviewed? +- Are large buffers kept off limited thread stacks? + +### `debug_assert!()` + +- Does each debug assertion express a real internal invariant? +- Is the invariant already established by types or release-active checks? +- Would release behavior remain correct if the assertion were removed? +- Is the assertion free of required side effects? +- Is it independent of unsafe-code soundness? +- Does the condition or message avoid owned allocation? + +### Performance and determinism + +- Is optimization supported by measurement? +- Is data layout contiguous and cache-conscious where relevant? +- Are unnecessary clones, copies, and intermediate collections avoided? +- Is canonical output independent of thread scheduling and unordered containers? +- Does an optimized implementation have a reference oracle and equivalence tests? + +### Safety and concurrency + +- Is unsafe code forbidden or tightly isolated? +- Does every unsafe block have a complete `SAFETY:` explanation? +- Are FFI ownership and allocator rules explicit? +- Are queues, workers, retries, and temporary storage bounded? +- Is shared ownership used intentionally rather than as an ownership escape hatch? + +### Tests and documentation + +- Are success, capacity, malformed-input, and error paths tested? +- Is allocation behavior tested after initialization? +- Are optimized tests run with debug assertions enabled? +- Do public APIs document allocation, errors, panics, bounds, and determinism? +- Are TODOs linked to issues? +- Do examples compile as doc tests where practical? + +--- + +## 19. Common anti-patterns + +Avoid these patterns unless a documented exception explains why they are correct: + +- cloning to silence the borrow checker; +- accepting `String` or `Vec` when only a borrow is needed; +- returning a `Vec` from every query or parser; +- using `unwrap()` for external input; +- using `debug_assert!()` as validation or a safety precondition; +- putting state changes inside debug assertions; +- using `format!()` in a strict runtime error path; +- preallocating a `Vec` and calling the subsystem “heapless”; +- relying on a small-vector type that may spill to the heap; +- hiding allocations behind logging or error context; +- boxing futures or traits in a hot path without measuring or documenting it; +- using unbounded channels, retries, recursion, or task creation; +- assigning canonical IDs based on thread completion order; +- replacing clear code with an abstraction that obscures bounds and memory access; +- writing unsafe code before proving safe code is insufficient; +- disabling lints globally to accommodate one call site; +- claiming allocation freedom based only on source inspection or one benchmark input. + +--- + +## 20. Final standard + +Production Rust should be easy to reason about under both success and failure. The preferred design has: + +- explicit domain types; +- borrowed inputs; +- caller-owned or fixed-capacity output and scratch storage; +- bounded execution; +- typed, allocation-free core errors; +- release-active validation of external input; +- `debug_assert!()` checks for internal invariants already established by correct code; +- no unsafe code by default; +- deterministic output where artifacts or proofs depend on it; +- measurement-backed optimization; +- tests that verify allocation behavior and boundary conditions; +- rich application adapters separated from a lean runtime core. + +A zero-allocation claim is a contract. Treat it with the same rigor as memory safety, wire-format compatibility, and deterministic output. + +--- + +## References + +- Rust API Guidelines: +- Rust Style Guide: +- Rust `debug_assert!()` documentation: +- Rust `assert!()` documentation: +- Rust `no_std` documentation: +- Rust `alloc` crate documentation: +- Rust `format_args!()` documentation: +- Rust slice methods, including allocation-free unstable sorting: +- Cargo profile reference: +- Clippy configuration: +- Clippy lint configuration options: +- Apollo GraphQL Rust Programming Best Practices Handbook: diff --git a/Cargo.lock b/Cargo.lock index 9b00de0..48c5dba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,9 +58,26 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "1.3.2" @@ -113,7 +130,7 @@ version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 3.0.3", @@ -132,29 +149,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "configure-dhcp" -version = "0.1.0" -dependencies = [ - "clap", - "env_logger", - "log", - "nix", - "serde", - "serde_json", - "tempfile", - "thiserror", -] - -[[package]] -name = "configure-ethernet" -version = "0.1.0" -dependencies = [ - "clap", - "env_logger", - "log", - "tempfile", - "thiserror", -] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "defmt" @@ -184,7 +182,51 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror", + "thiserror 2.0.19", +] + +[[package]] +name = "dhcproto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6794294f2c4665aae452e950c2803a1e487c5672dc8448f0bfa3f52ff67e270" +dependencies = [ + "dhcproto-macros", + "hex", + "ipnet", + "rand", + "thiserror 1.0.69", + "trust-dns-proto", + "url", +] + +[[package]] +name = "dhcproto-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7993efb860416547839c115490d4951c6d0f8ec04a3594d9dd99d50ed7ec170" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "enum-as-inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -217,7 +259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -226,6 +268,74 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -237,12 +347,168 @@ dependencies = [ "r-efi", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +dependencies = [ + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -291,6 +557,32 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -303,18 +595,70 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "micronet" +version = "0.1.0" +dependencies = [ + "clap", + "dhcproto", + "env_logger", + "ipnet", + "log", + "nix", + "notify", + "serde", + "serde_json", + "signal-hook", + "socket2", + "tempfile", + "thiserror 2.0.19", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.29.0" @@ -325,6 +669,34 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", ] [[package]] @@ -339,6 +711,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "portable-atomic" version = "1.14.0" @@ -354,6 +738,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -379,24 +781,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "regex" -version = "1.13.1" +name = "rand" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "libc", + "rand_chacha", + "rand_core", ] [[package]] -name = "regex-automata" -version = "0.4.18" +name = "rand_chacha" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ - "aho-corasick", + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", "memchr", "regex-syntax", ] @@ -417,7 +849,16 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", ] [[package]] @@ -463,12 +904,71 @@ dependencies = [ "zmij", ] +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -491,6 +991,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -498,10 +1009,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", ] [[package]] @@ -510,7 +1030,18 @@ version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -524,24 +1055,180 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trust-dns-proto" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f7f83d1e4a0e4358ac54c5c3681e5d7da5efc5a7a632c90bb6d6669ddd9bc26" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 0.2.3", + "ipnet", + "lazy_static", + "rand", + "smallvec", + "thiserror 1.0.69", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna 1.1.0", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -551,6 +1238,238 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index ce0264b..f7eb0a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,25 @@ [workspace] resolver = "2" -members = [ - "crates/configure-dhcp", - "crates/configure-ethernet", -] +members = ["crates/micronet"] [workspace.package] edition = "2021" license = "MIT" authors = ["BigFred"] +version = "0.1.0" + +[workspace.lints.rust] +unsafe_code = "forbid" +unused_must_use = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +dbg_macro = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +unwrap_used = "deny" [profile.release] opt-level = "z" diff --git a/Makefile b/Makefile index d957c55..9ff3eac 100644 --- a/Makefile +++ b/Makefile @@ -20,10 +20,13 @@ release-musl: RUSTFLAGS='-C target-feature=+crt-static' \ $(CARGO) build --workspace --release --target $(TARGET_MUSL) @mkdir -p dist - cp -f target/$(TARGET_MUSL)/release/configure-dhcp dist/configure-dhcp-linux-arm64 - cp -f target/$(TARGET_MUSL)/release/configure-ethernet dist/configure-ethernet-linux-arm64 - @chmod 755 dist/configure-dhcp-linux-arm64 dist/configure-ethernet-linux-arm64 - @echo "wrote dist/configure-*-linux-arm64" + cp -f target/$(TARGET_MUSL)/release/micronet dist/micronet-linux-arm64 + # One-release aliases so older fetch scripts still find a file. + cp -f dist/micronet-linux-arm64 dist/configure-dhcp-linux-arm64 + cp -f dist/micronet-linux-arm64 dist/configure-ethernet-linux-arm64 + @chmod 755 dist/micronet-linux-arm64 \ + dist/configure-dhcp-linux-arm64 dist/configure-ethernet-linux-arm64 + @echo "wrote dist/micronet-linux-arm64 (+ configure-* aliases)" check: $(CARGO) check --workspace diff --git a/README.md b/README.md index ae6a636..cef4885 100644 --- a/README.md +++ b/README.md @@ -1,151 +1,30 @@ -# BigFred event WiFi — mount and configure +# micronet -**Language:** English | [Polski](./README_pl.md) +Ethernet bring-up and DHCP gateway daemon for BigFred OS. One process on +the hub's physical Ethernet: JSON config, Unix-socket IPC, inotify +hot-reload. Chooses **client**, **gateway**, or **static `.252`** from a +DHCPDISCOVER probe and a ping of `gateway.ip`. -Related plans: [topology](./plans/2026-07-14-topologia-wifi-hala.md), [EAP613 settings](./plans/2026-07-14-eap613-konfiguracja.md) +## Features -For a non-technical operator. Goal: low-latency WiFi for throttles (`bigfred2`, 2.4 GHz) and phones (`bigfred5`, 5 GHz). +- Three modes: foreign DHCP → `client` (`dhclient`); live `gateway.ip` → `static`; empty LAN → `gateway` + dnsmasq +- DHCPDISCOVER only (no REQUEST); ICMP ping of `gateway.ip` after a temporary `.252` +- dnsmasq only in `gateway` (pool `.50–.200`, sticky MAC→IP lease **7d**, `option:router` / `dns-server`) +- Physical Ethernet only (not `lo`, bridge, virtual, Wi-Fi) +- JSON camelCase under `$DATA_DIR/etc/micronet.json` (no hardcoded `/data/...`); invalid reload keeps the previous config +- Unix socket `$DATA_DIR/run/micronet.sock` (4-byte LE length + JSON) +- `std::thread` (no tokio); musl arm64 -## What you need +## Docs -- Raspberry Pi 3 + Ethernet = **BigFred** (server) -- Omada **EAP610/613 × 3** (access points) -- Switch PoE **TL-SF1006P** (ports 1–4 PoE+, 5–6 plain) -- **Omada OC200** is optional (central controller). Without it, configure each AP in **standalone** mode (same SSIDs/settings on every AP; only channels differ). -- 4–5 Ethernet cables, PSUs (Pi3, switch; OC200 if used), 3 stands at **2 m**, laptop/phone for setup, optional UPS +- [ARCHITECTURE.md](ARCHITECTURE.md) — canonical design +- [CODING-GUIDELINES.md](CODING-GUIDELINES.md) — engineering standard (copy of microinit) +- Event WiFi mount: [docs/networking](docs/networking/README.md) (EN) / [PL](docs/networking/README_pl.md) -## How BigFred networking works +## Build -On boot, BigFred OS: - -1. Brings Ethernet up (`configure-ethernet`). -2. Runs **`configure-dhcp`**, which probes the LAN for an event WiFi stack (today: **Omada** AP or OC200). -3. **Only if Omada gear is detected** does it set BigFred to `10.0.10.1/24` and start **dnsmasq** (pool `10.0.10.50–10.0.10.200`, **7-day** lease, gateway/DNS = BigFred). Detected Omada MACs get sticky DHCP reservations. -4. On a club LAN **without** Omada, DHCP is **not** started (no conflict with the club DHCP server). - -You do not edit dnsmasq by hand for the event setup. - ---- - -## 1. Cabling (power off) - -| Switch port | Device | Notes | -|---|---|---| -| 1 | BigFred | Priority Mode | -| 2 | AP1 | PoE | -| 3 | AP2 | PoE | -| 4 | AP3 | PoE | -| 5 | OC200 (optional) | Plain port; OC200 has its own PSU | -| 6 | free | Laptop for setup | - -- [ ] BigFred → port 1 -- [ ] AP1 → 2, AP2 → 3, AP3 → 4 -- [ ] OC200 → 5 (if used) -- [ ] Plug in switch, BigFred, and OC200 PSUs - -## 2. Switch rear switches - -- [ ] **Priority Mode = ON** (port 1 = BigFred) -- [ ] **Extend Mode = OFF** (otherwise ports drop to 10 Mb/s) - -## 3. Power-on order - -BigFred (DHCP) first: - -- [ ] 1. Switch -- [ ] 2. BigFred — wait ~2 min (`configure-dhcp` detects Omada and starts DHCP) -- [ ] 3. OC200 (if used) — wait ~3 min -- [ ] 4. AP1/2/3 via PoE — wait ~3 min - -## 4. Join the network with a laptop - -- [ ] Ethernet to switch port 6 (laptop gets an address from BigFred, e.g. `10.0.10.51`) - -## 5. Configure WiFi — choose one path - -### Path A — with OC200 (controller) - -- [ ] Find OC200 IP (TP-Link **Omada Discovery**, or on BigFred: `configure-dhcp check`) -- [ ] Open `https://`, accept the cert warning -- [ ] Login `admin` / `admin`, set a new admin password -- [ ] Wizard: region/timezone; skip creating SSIDs here -- [ ] **Devices** → Adopt all three APs → wait until **Connected** -- [ ] Create WLAN group + SSIDs (step 6) and radio tweaks (step 7) **once** in the controller - -### Path B — standalone (no OC200) - -Do steps 6–7 **on each AP** (AP1, then AP2, then AP3). Default first access: join the sticker SSID or open `https://tplinkeap.net` / `https://192.168.0.254`, then set a management password and preferably a static/management IP once on the BigFred subnet. Channels differ per AP (step 7.1); SSIDs and passwords are identical. - -## 6. SSIDs: `bigfred2` and `bigfred5` - -Same password for both. - -### `bigfred2` (2.4 GHz only — throttles) - -- [ ] SSID `bigfred2`, broadcast ON, band **2.4 GHz only** -- [ ] WPA2-PSK, AES, your password -- [ ] VLAN 0, Portal OFF, SSID/Client Isolation **OFF**, Save - -### `bigfred5` (5 GHz only — phones) - -- [ ] SSID `bigfred5`, broadcast ON, band **5 GHz only** -- [ ] Same security and password, VLAN 0, Portal OFF, Isolation OFF, Save - -## 7. Low-latency radio tweaks - -### 7.1 Channels (per AP) - -2.4 GHz, **20 MHz**, Manual: - -| AP | Channel | Width | Tx | -|---|---|---|---| -| AP1 | 1 | 20 MHz | Medium | -| AP2 | 6 | 20 MHz | Medium | -| AP3 | 11 | 20 MHz | Medium | - -5 GHz, **40 MHz**, non-DFS: - -| AP | Channel | Width | Tx | -|---|---|---|---| -| AP1 | 36 | 40 MHz | Medium | -| AP2 | 149 | 40 MHz | Medium | -| AP3 | 44 (or 157) | 40 MHz | Medium | - -- [ ] Channel selection = **Manual** (not Auto) -- [ ] Do **not** use DFS channels 52–144 - -### 7.2 Advanced - -- [ ] Airtime Fairness ON, OFDMA ON, MU-MIMO ON -- [ ] Beacon 100, DTIM 1, min data rate 2.4 GHz = 6 Mbps (if available) -- [ ] Mesh OFF, Band Steering OFF - -### 7.3 WMM / multicast / roaming - -- [ ] WMM Enable on both SSIDs -- [ ] Multicast filter OFF (mDNS `224.0.0.251` must pass); IGMP snooping + multicast-to-unicast ON if available -- [ ] Client Isolation OFF -- [ ] Load balance 2.4 GHz: max ~18 clients; 802.11k/v/r ON - -## 8. Validation - -- [ ] Phone sees `bigfred2` and `bigfred5` -- [ ] On `bigfred5`, open `http://10.0.10.1` (BigFred UI) -- [ ] Throttle on `bigfred2` -- [ ] Ping to `10.0.10.1` < 25 ms -- [ ] RSSI at operator seats > −65 dBm - -## 9. Event-day checklist - -- [ ] Three APs at 2 m around operators (not behind the layout) -- [ ] BigFred on port 1 (Priority), DHCP running -- [ ] Spectrum check — adjust 1/6/11 if needed -- [ ] 3–5 test throttles OK -- [ ] Ask audience to disable personal hotspots -- [ ] Spare AP + PoE injector ready - -## Technical notes - -- Tools (Rust workspace): [`crates/configure-dhcp`](./crates/configure-dhcp/), [`crates/configure-ethernet`](./crates/configure-ethernet/) → `/usr/sbin/` on BigFred OS (GitHub Actions artifacts / Releases) -- Shared CI: reusable workflows in [`dcc-bigfred/common`](https://github.com/dcc-bigfred/common) (`@v2`); binary fetch via `go run github.com/dcc-bigfred/common/cmd/fetch@latest` -- Detailed EAP613 menu paths: [plans/2026-07-14-eap613-konfiguracja.md](./plans/2026-07-14-eap613-konfiguracja.md). +```bash +make build +make test +make release-musl +``` diff --git a/crates/configure-dhcp/Cargo.toml b/crates/configure-dhcp/Cargo.toml deleted file mode 100644 index 65b58ed..0000000 --- a/crates/configure-dhcp/Cargo.toml +++ /dev/null @@ -1,39 +0,0 @@ -[package] -name = "configure-dhcp" -version = "0.1.0" -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "Event WiFi DHCP for BigFred OS — starts dnsmasq when a pluggable stack (Omada) is detected" -readme = "README.md" - -[lib] -name = "configure_dhcp" -path = "src/lib.rs" - -[[bin]] -name = "configure-dhcp" -path = "src/main.rs" - -[dependencies] -clap = { version = "4", features = ["derive"] } -log = "0.4" -env_logger = "0.11" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -thiserror = "2" -nix = { version = "0.29", features = ["signal", "process"] } - -[dev-dependencies] -tempfile = "3" - -[lints.rust] -unused_must_use = "deny" - -[lints.clippy] -dbg_macro = "deny" -expect_used = "deny" -panic = "deny" -todo = "deny" -unimplemented = "deny" -unwrap_used = "deny" diff --git a/crates/configure-dhcp/README.md b/crates/configure-dhcp/README.md deleted file mode 100644 index b37f925..0000000 --- a/crates/configure-dhcp/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# configure-dhcp - -Rust micro-CLI for BigFred OS. Starts **dnsmasq** only when a pluggable event -WiFi **stack** detects gear on the LAN (today: **Omada** AP / OC200). Otherwise -exits 0 and leaves club networking alone. - -## Build - -```bash -cargo build --release -p configure-dhcp -# from this directory: -cargo build --release -``` - -Binary: `target/release/configure-dhcp` → install as `/usr/sbin/configure-dhcp`. - -## Commands - -```bash -configure-dhcp up # detect → maybe DHCP + reservations (default) -configure-dhcp check # report stacks / gate / dnsmasq -``` - -## Behaviour - -1. Probe registered stacks (`OmadaStack`: TP-Link OUI in ARP + UDP discovery + hostname match). -2. Gate **ON** if any device found **or** sticky state `/data/etc/configure-dhcp.state` lists a stack. -3. Gate **OFF** → skip (no `10.0.10.1`, no dnsmasq). -4. Gate **ON** → set `10.0.10.1/24`, write `/data/etc/dnsmasq.conf` (pool `.50–.200`, lease **7d**), start dnsmasq, promote matched MAC→IP into `/data/etc/dnsmasq.reservations.conf`. - -## Extending stacks - -Implement `configure_dhcp::stack::Stack` and `registry.register(Box::new(...))` in `main.rs`. - -## Dependencies - -- `dnsmasq` on the image (`/usr/sbin/dnsmasq`) — required only when the gate wants DHCP. -- microinit service `configure-dhcp` after `network` (see bigfred-os overlays). - -## Tests - -```bash -cargo test -``` diff --git a/crates/configure-dhcp/src/dhcp/conf.rs b/crates/configure-dhcp/src/dhcp/conf.rs deleted file mode 100644 index 128aafb..0000000 --- a/crates/configure-dhcp/src/dhcp/conf.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! dnsmasq config generation and reservation merge. - -use std::collections::BTreeMap; -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::stack::MacAddr; -use crate::{Error, Result}; - -pub const DEFAULT_GATEWAY: &str = "10.0.10.1"; -pub const DEFAULT_PREFIX: u8 = 24; -pub const DEFAULT_RANGE_START: &str = "10.0.10.50"; -pub const DEFAULT_RANGE_END: &str = "10.0.10.200"; -pub const DEFAULT_LEASE: &str = "7d"; -pub const DEFAULT_CONF_PATH: &str = "/data/etc/dnsmasq.conf"; -pub const DEFAULT_RESERVATIONS_PATH: &str = "/data/etc/dnsmasq.reservations.conf"; -pub const DEFAULT_LEASEFILE: &str = "/data/etc/dnsmasq.leases"; -pub const DEFAULT_ETHERNET_CONF: &str = "/data/etc/configure-ethernet.conf"; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct DhcpDefaults { - pub iface: String, - pub gateway: String, - pub range_start: String, - pub range_end: String, - pub lease: String, - pub conf_path: PathBuf, - pub reservations_path: PathBuf, - pub leasefile: PathBuf, -} - -impl Default for DhcpDefaults { - fn default() -> Self { - Self { - iface: "eth0".to_string(), - gateway: DEFAULT_GATEWAY.to_string(), - range_start: DEFAULT_RANGE_START.to_string(), - range_end: DEFAULT_RANGE_END.to_string(), - lease: DEFAULT_LEASE.to_string(), - conf_path: PathBuf::from(DEFAULT_CONF_PATH), - reservations_path: PathBuf::from(DEFAULT_RESERVATIONS_PATH), - leasefile: PathBuf::from(DEFAULT_LEASEFILE), - } - } -} - -/// Render base dnsmasq.conf (idempotent content). -#[must_use] -pub fn render_base_conf(d: &DhcpDefaults) -> String { - format!( - "# Generated by configure-dhcp — do not hand-edit the managed block.\n\ -interface={iface}\n\ -bind-interfaces\n\ -listen-address={gw}\n\ -dhcp-range={start},{end},255.255.255.0,{lease}\n\ -dhcp-option=option:router,{gw}\n\ -dhcp-option=option:dns-server,{gw}\n\ -dhcp-authoritative\n\ -dhcp-leasefile={leasefile}\n\ -conf-file={reservations}\n\ -", - iface = d.iface, - gw = d.gateway, - start = d.range_start, - end = d.range_end, - lease = d.lease, - leasefile = d.leasefile.display(), - reservations = d.reservations_path.display(), - ) -} - -/// Ensure base conf exists / matches defaults. Returns true if file changed. -pub fn ensure_base_conf(d: &DhcpDefaults) -> Result { - let want = render_base_conf(d); - let changed = match fs::read_to_string(&d.conf_path) { - Ok(have) => have != want, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => true, - Err(e) => return Err(Error::io_at(&d.conf_path, e)), - }; - if changed { - if let Some(parent) = d.conf_path.parent() { - fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; - } - fs::write(&d.conf_path, want).map_err(|e| Error::io_at(&d.conf_path, e))?; - } - Ok(changed) -} - -/// Parse existing dhcp-host lines from a reservations file. -pub fn parse_reservations(text: &str) -> BTreeMap { - let mut map = BTreeMap::new(); - for line in text.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let Some(rest) = line.strip_prefix("dhcp-host=") else { - continue; - }; - let mut parts = rest.splitn(2, ','); - let Some(mac) = parts.next() else { continue }; - let Some(ip) = parts.next() else { continue }; - map.insert(mac.to_ascii_lowercase(), ip.trim().to_string()); - } - map -} - -/// Merge new MAC→IP into reservations; preserves unknown keys. Returns (new text, changed). -pub fn merge_reservations(existing_text: &str, additions: &[(MacAddr, String)]) -> (String, bool) { - let mut map = parse_reservations(existing_text); - let mut changed = false; - for (mac, ip) in additions { - let key = mac.to_string(); - match map.get(&key) { - Some(old) if old == ip => {} - _ => { - map.insert(key, ip.clone()); - changed = true; - } - } - } - let mut out = String::from("# Generated/merged by configure-dhcp\n"); - for (mac, ip) in &map { - out.push_str(&format!("dhcp-host={mac},{ip}\n")); - } - let semantic_same = parse_reservations(existing_text) == map && !changed; - if semantic_same { - return (out, false); - } - let file_changed = existing_text != out; - (out, file_changed) -} - -/// Write reservations file; returns whether content changed. -pub fn write_reservations(path: &Path, additions: &[(MacAddr, String)]) -> Result { - let existing = match fs::read_to_string(path) { - Ok(t) => t, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(e) => return Err(Error::io_at(path, e)), - }; - let (new_text, changed) = merge_reservations(&existing, additions); - if !changed { - return Ok(false); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; - } - fs::write(path, new_text).map_err(|e| Error::io_at(path, e))?; - Ok(true) -} - -/// Ensure configure-ethernet.conf PRIMARY points at event gateway. -pub fn ensure_ethernet_primary(path: &Path, primary: &str) -> Result { - let secondary = "192.168.0.120"; - let want = format!( - "# configure-ethernet static addresses (managed by configure-dhcp when Omada present)\n\ -PRIMARY={primary}\n\ -SECONDARY={secondary}\n" - ); - let changed = match fs::read_to_string(path) { - Ok(have) => !have.lines().any(|l| { - let l = l.trim(); - l.eq_ignore_ascii_case(&format!("PRIMARY={primary}")) - || l.eq_ignore_ascii_case(&format!("PRIMARY = {primary}")) - }), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => true, - Err(e) => return Err(Error::io_at(path, e)), - }; - if changed { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; - } - fs::write(path, want).map_err(|e| Error::io_at(path, e))?; - } - Ok(changed) -} - -#[cfg(test)] -mod tests { - #![allow( - clippy::expect_used, - clippy::unwrap_used, - clippy::field_reassign_with_default - )] - - use super::*; - use tempfile::tempdir; - - #[test] - fn render_contains_lease_7d() { - let d = DhcpDefaults::default(); - let conf = render_base_conf(&d); - assert!(conf.contains("7d")); - assert!(conf.contains("10.0.10.50")); - assert!(conf.contains("10.0.10.1")); - } - - #[test] - fn merge_idempotent() { - let mac = MacAddr::parse("50:c7:bf:01:02:03").expect("mac"); - let (t1, c1) = merge_reservations("", &[(mac.clone(), "10.0.10.11".into())]); - assert!(c1); - let (t2, c2) = merge_reservations(&t1, &[(mac, "10.0.10.11".into())]); - assert!(!c2 || t1 == t2); - assert!(t2.contains("dhcp-host=50:c7:bf:01:02:03,10.0.10.11")); - } - - #[test] - fn ensure_base_conf_writes_once() { - let dir = tempdir().expect("tmp"); - let mut d = DhcpDefaults::default(); - d.conf_path = dir.path().join("dnsmasq.conf"); - d.reservations_path = dir.path().join("res.conf"); - d.leasefile = dir.path().join("leases"); - assert!(ensure_base_conf(&d).expect("w1")); - assert!(!ensure_base_conf(&d).expect("w2")); - } -} diff --git a/crates/configure-dhcp/src/dhcp/leases.rs b/crates/configure-dhcp/src/dhcp/leases.rs deleted file mode 100644 index d83b23b..0000000 --- a/crates/configure-dhcp/src/dhcp/leases.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Parse dnsmasq leases and /proc/net/arp. - -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::net::IpAddr; -use std::path::Path; - -use crate::stack::MacAddr; -use crate::{Error, Result}; - -#[derive(Clone, Debug)] -pub struct LeaseEntry { - pub mac: MacAddr, - pub ip: IpAddr, - pub hostname: String, -} - -/// dnsmasq.leases: ` ` -pub fn parse_leases_file(path: &Path) -> Result> { - let file = match File::open(path) { - Ok(f) => f, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), - Err(e) => return Err(Error::io_at(path, e)), - }; - let mut out = Vec::new(); - for line in BufReader::new(file).lines() { - let line = line.map_err(|e| Error::io_at(path, e))?; - let line = line.trim(); - if line.is_empty() { - continue; - } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 4 { - continue; - } - let Some(mac) = MacAddr::parse(parts[1]) else { - continue; - }; - let Ok(ip) = parts[2].parse::() else { - continue; - }; - let hostname = if parts[3] == "*" { - String::new() - } else { - parts[3].to_string() - }; - out.push(LeaseEntry { mac, ip, hostname }); - } - Ok(out) -} - -#[derive(Clone, Debug)] -pub struct ArpEntry { - pub ip: IpAddr, - pub mac: MacAddr, -} - -pub fn parse_arp_file(path: &Path) -> Result> { - let file = match File::open(path) { - Ok(f) => f, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), - Err(e) => return Err(Error::io_at(path, e)), - }; - let mut out = Vec::new(); - for (i, line) in BufReader::new(file).lines().enumerate() { - let line = line.map_err(|e| Error::io_at(path, e))?; - if i == 0 { - continue; - } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 4 { - continue; - } - let Ok(ip) = parts[0].parse::() else { - continue; - }; - let Some(mac) = MacAddr::parse(parts[3]) else { - continue; - }; - if mac.is_zero() { - continue; - } - out.push(ArpEntry { ip, mac }); - } - Ok(out) -} - -#[cfg(test)] -mod tests { - #![allow(clippy::expect_used, clippy::unwrap_used)] - - use super::*; - use std::io::Write; - use tempfile::NamedTempFile; - - #[test] - fn parse_lease_line() { - let mut f = NamedTempFile::new().expect("tmp"); - writeln!( - f, - "1700000000 50:c7:bf:01:02:03 10.0.10.11 EAP613-Lobby 01:50:c7:bf:01:02:03" - ) - .expect("w"); - let leases = parse_leases_file(f.path()).expect("p"); - assert_eq!(leases.len(), 1); - assert_eq!(leases[0].hostname, "EAP613-Lobby"); - assert_eq!(leases[0].ip.to_string(), "10.0.10.11"); - } -} diff --git a/crates/configure-dhcp/src/dhcp/mod.rs b/crates/configure-dhcp/src/dhcp/mod.rs deleted file mode 100644 index 7f57588..0000000 --- a/crates/configure-dhcp/src/dhcp/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! DHCP helpers: conf, leases, process control. - -pub mod conf; -pub mod leases; -pub mod run; - -pub use conf::{ - ensure_base_conf, ensure_ethernet_primary, merge_reservations, render_base_conf, - write_reservations, DhcpDefaults, DEFAULT_ETHERNET_CONF, DEFAULT_GATEWAY, -}; -pub use leases::{parse_arp_file, parse_leases_file, LeaseEntry}; -pub use run::{ - dnsmasq_exists, dnsmasq_running, ensure_gateway_addr, first_ethernet_iface, iface_has_ipv4, - sighup_dnsmasq, start_dnsmasq, -}; diff --git a/crates/configure-dhcp/src/dhcp/run.rs b/crates/configure-dhcp/src/dhcp/run.rs deleted file mode 100644 index c30542d..0000000 --- a/crates/configure-dhcp/src/dhcp/run.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Bring up gateway IP and run/reload dnsmasq. - -use std::fs; -use std::path::Path; -use std::process::{Command, Stdio}; - -use nix::sys::signal::{kill, Signal}; -use nix::unistd::Pid; - -use crate::{Error, Result}; - -pub const DNSMASQ_BIN: &str = "/usr/sbin/dnsmasq"; -pub const IP_BIN: &str = "/sbin/ip"; - -#[must_use] -pub fn dnsmasq_exists() -> bool { - Path::new(DNSMASQ_BIN).is_file() -} - -/// First non-wireless interface under /sys/class/net (sorted). -pub fn first_ethernet_iface() -> Result { - let dir = Path::new("/sys/class/net"); - let entries = fs::read_dir(dir).map_err(|e| Error::io_at(dir, e))?; - let mut names = Vec::new(); - for ent in entries { - let ent = ent.map_err(|e| Error::io_at(dir, e))?; - let name = ent.file_name().to_string_lossy().into_owned(); - if name == "lo" { - continue; - } - if dir.join(&name).join("wireless").exists() { - continue; - } - names.push(name); - } - names.sort(); - names.into_iter().next().ok_or(Error::NoEthernet) -} - -pub fn ensure_gateway_addr(iface: &str, cidr: &str) -> Result<()> { - let _ = run_cmd(IP_BIN, &["link", "set", "dev", iface, "up"]); - // Add address if missing (ip addr add fails if present — ignore). - let status = Command::new(IP_BIN) - .args(["addr", "add", cidr, "dev", iface]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - match status { - Ok(s) if s.success() => Ok(()), - Ok(_) => Ok(()), // already present - Err(e) => Err(Error::Other(format!("ip addr add: {e}"))), - } -} - -pub fn iface_has_ipv4(iface: &str) -> bool { - let Ok(out) = Command::new(IP_BIN) - .args(["-4", "addr", "show", "dev", iface]) - .output() - else { - return false; - }; - String::from_utf8_lossy(&out.stdout).contains("inet ") -} - -fn run_cmd(bin: &str, args: &[&str]) -> Result<()> { - let status = Command::new(bin) - .args(args) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .map_err(|e| Error::Other(format!("{bin}: {e}")))?; - if status.success() { - Ok(()) - } else { - Err(Error::Other(format!( - "{bin} {:?} exited {}", - args, - status.code().unwrap_or(-1) - ))) - } -} - -fn dnsmasq_pids() -> Vec { - let Ok(entries) = fs::read_dir("/proc") else { - return vec![]; - }; - let mut pids = Vec::new(); - for ent in entries.flatten() { - let name = ent.file_name(); - let name = name.to_string_lossy(); - if !name.chars().all(|c| c.is_ascii_digit()) { - continue; - } - let cmdline = fs::read_to_string(ent.path().join("cmdline")).unwrap_or_default(); - if cmdline.split('\0').next() == Some(DNSMASQ_BIN) || cmdline.contains("dnsmasq") { - if let Ok(pid) = name.parse::() { - pids.push(pid); - } - } - } - pids -} - -#[must_use] -pub fn dnsmasq_running() -> bool { - !dnsmasq_pids().is_empty() -} - -pub fn start_dnsmasq(conf: &Path) -> Result<()> { - if !dnsmasq_exists() { - return Err(Error::DnsmasqMissing(Path::new(DNSMASQ_BIN).to_path_buf())); - } - if dnsmasq_running() { - return Ok(()); - } - run_cmd(DNSMASQ_BIN, &["-C", &conf.to_string_lossy()]) -} - -pub fn sighup_dnsmasq() -> Result<()> { - let pids = dnsmasq_pids(); - if pids.is_empty() { - return Ok(()); - } - for pid in pids { - kill(Pid::from_raw(pid), Signal::SIGHUP)?; - } - Ok(()) -} diff --git a/crates/configure-dhcp/src/lib.rs b/crates/configure-dhcp/src/lib.rs deleted file mode 100644 index 067b930..0000000 --- a/crates/configure-dhcp/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! configure-dhcp — start dnsmasq when an event WiFi stack is detected on the LAN. - -pub mod dhcp; -pub mod error; -pub mod run; -pub mod stack; -pub mod sticky; - -pub use error::{Error, Result}; diff --git a/crates/configure-dhcp/src/main.rs b/crates/configure-dhcp/src/main.rs deleted file mode 100644 index 0e6d010..0000000 --- a/crates/configure-dhcp/src/main.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! configure-dhcp — event WiFi DHCP for BigFred OS. - -use std::process::ExitCode; - -use clap::{Parser, Subcommand}; - -use configure_dhcp::dhcp::DhcpDefaults; -use configure_dhcp::run::{run_check, run_up, Paths}; -use configure_dhcp::stack::omada::OmadaStack; -use configure_dhcp::stack::Registry; - -#[derive(Parser, Debug)] -#[command( - name = "configure-dhcp", - about = "Start dnsmasq when an event WiFi stack (Omada) is detected on the LAN", - version -)] -struct Cli { - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand, Debug)] -enum Commands { - /// Detect stacks, enable DHCP if needed, promote reservations (default) - Up, - /// Alias for up - Configure, - /// Alias for up - Start, - /// Report stacks, detection, and DHCP status - Check, -} - -fn main() -> ExitCode { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - - let cli = Cli::parse(); - let mut registry = Registry::new(); - registry.register(Box::new(OmadaStack::new())); - - let paths = Paths::default(); - let defaults = DhcpDefaults::default(); - - match cli.command.unwrap_or(Commands::Up) { - Commands::Up | Commands::Configure | Commands::Start => { - match run_up(®istry, &paths, &defaults) { - Ok(report) => { - if report.gate_on { - log::info!( - "configure-dhcp: gate ON ({}); iface={}; reservations_changed={}", - report.reason, - report.iface, - report.reservations_changed - ); - for d in &report.devices { - log::info!( - " detected {} {:?} {:?}", - d.stack, - d.mac.as_ref().map(ToString::to_string), - d.ip - ); - } - } else { - log::info!("configure-dhcp: {}", report.reason); - } - ExitCode::SUCCESS - } - Err(e) => { - log::error!("configure-dhcp: {e}"); - ExitCode::FAILURE - } - } - } - Commands::Check => match run_check(®istry, &paths, &defaults) { - Ok(code) => ExitCode::from(code as u8), - Err(e) => { - log::error!("configure-dhcp: {e}"); - ExitCode::FAILURE - } - }, - } -} diff --git a/crates/configure-dhcp/src/run.rs b/crates/configure-dhcp/src/run.rs deleted file mode 100644 index 885a5e3..0000000 --- a/crates/configure-dhcp/src/run.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Orchestration: gate on stacks → DHCP + reservations. - -use std::path::{Path, PathBuf}; - -use crate::dhcp::{dnsmasq_exists, dnsmasq_running, ensure_gateway_addr, first_ethernet_iface}; -use crate::dhcp::{ - ensure_base_conf, ensure_ethernet_primary, parse_arp_file, parse_leases_file, sighup_dnsmasq, - start_dnsmasq, write_reservations, DhcpDefaults, DEFAULT_ETHERNET_CONF, DEFAULT_GATEWAY, -}; -use crate::stack::{Device, MacAddr, Registry}; -use crate::sticky::StickyState; -use crate::Result; - -#[derive(Clone, Debug)] -pub struct Paths { - pub state: PathBuf, - pub ethernet_conf: PathBuf, - pub arp: PathBuf, -} - -impl Default for Paths { - fn default() -> Self { - Self { - state: StickyState::path_default(), - ethernet_conf: PathBuf::from(DEFAULT_ETHERNET_CONF), - arp: PathBuf::from("/proc/net/arp"), - } - } -} - -#[derive(Debug)] -pub struct UpReport { - pub iface: String, - pub gate_on: bool, - pub reason: String, - pub devices: Vec, - pub dhcp_started: bool, - pub reservations_changed: bool, -} - -/// Run configure-dhcp up with the given registry and paths (testable). -pub fn run_up(registry: &Registry, paths: &Paths, defaults: &DhcpDefaults) -> Result { - let iface = if defaults.iface.is_empty() { - first_ethernet_iface().unwrap_or_else(|_| "eth0".to_string()) - } else { - defaults.iface.clone() - }; - - let (detected, detect_ok) = match registry.detect_any(&iface) { - Ok(v) => v, - Err(e) => { - log::warn!("detect errors: {e}"); - (vec![], false) - } - }; - - let mut sticky = StickyState::load(&paths.state)?; - let sticky_hit = sticky.has_any(); - - let gate_on = detect_ok || sticky_hit; - if !gate_on { - return Ok(UpReport { - iface, - gate_on: false, - reason: "no event WiFi stack detected; DHCP skipped".to_string(), - devices: detected, - dhcp_started: false, - reservations_changed: false, - }); - } - - let reason = if detect_ok { - for d in &detected { - sticky.remember(&d.stack); - } - sticky.save(&paths.state)?; - "stack detected".to_string() - } else { - format!("sticky stacks: {:?}", sticky.stacks) - }; - - if !dnsmasq_exists() { - return Err(crate::Error::DnsmasqMissing(PathBuf::from( - crate::dhcp::run::DNSMASQ_BIN, - ))); - } - - let _ = ensure_ethernet_primary(&paths.ethernet_conf, DEFAULT_GATEWAY)?; - let cidr = format!("{DEFAULT_GATEWAY}/24"); - ensure_gateway_addr(&iface, &cidr)?; - - let mut d = defaults.clone(); - d.iface = iface.clone(); - let conf_changed = ensure_base_conf(&d)?; - - if !dnsmasq_running() { - start_dnsmasq(&d.conf_path)?; - } else if conf_changed { - sighup_dnsmasq()?; - } - - let mut additions: Vec<(MacAddr, String)> = Vec::new(); - - // From live detect (IP known). - for dvc in &detected { - if let (Some(mac), Some(ip)) = (&dvc.mac, &dvc.ip) { - additions.push((mac.clone(), ip.to_string())); - } - } - - // From leases + ARP matched by stacks. - let leases = parse_leases_file(&d.leasefile).unwrap_or_default(); - for lease in leases { - if registry.match_device(&lease.hostname, &lease.mac).is_some() { - additions.push((lease.mac, lease.ip.to_string())); - } - } - let arp = parse_arp_file(&paths.arp).unwrap_or_default(); - for ent in arp { - if registry.match_device("", &ent.mac).is_some() { - additions.push((ent.mac, ent.ip.to_string())); - } - } - - // Dedupe by MAC (last wins). - let mut by_mac = std::collections::BTreeMap::new(); - for (mac, ip) in additions { - by_mac.insert(mac.to_string(), (mac, ip)); - } - let additions: Vec<_> = by_mac.into_values().collect(); - - let reservations_changed = write_reservations(&d.reservations_path, &additions)?; - if reservations_changed { - sighup_dnsmasq()?; - } - - Ok(UpReport { - iface, - gate_on: true, - reason, - devices: detected, - dhcp_started: true, - reservations_changed, - }) -} - -/// check: print stacks, detection, DHCP status. Returns true if healthy when gated, -/// or true when skipped (no stack). False only on hard errors is handled by caller. -pub fn run_check(registry: &Registry, paths: &Paths, defaults: &DhcpDefaults) -> Result { - let iface = first_ethernet_iface().unwrap_or_else(|_| defaults.iface.clone()); - println!("iface: {iface}"); - println!("registered stacks:"); - for s in registry.stacks() { - println!(" - {}", s.name()); - } - - let (detected, detect_ok) = registry.detect_any(&iface).unwrap_or_else(|e| { - eprintln!("detect: {e}"); - (vec![], false) - }); - let sticky = StickyState::load(&paths.state).unwrap_or_default(); - let gate = detect_ok || sticky.has_any(); - - println!("gate: {}", if gate { "ON" } else { "OFF" }); - println!("sticky: {:?}", sticky.stacks); - println!("detected devices: {}", detected.len()); - for d in &detected { - println!( - " [{}] {} mac={} ip={} host={}", - d.stack, - d.kind, - d.mac - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| "-".into()), - d.ip.map(|i| i.to_string()).unwrap_or_else(|| "-".into()), - if d.hostname.is_empty() { - "-" - } else { - &d.hostname - } - ); - } - println!("dnsmasq binary: {}", dnsmasq_exists()); - println!("dnsmasq running: {}", dnsmasq_running()); - - if gate && !dnsmasq_running() { - return Ok(1); - } - Ok(0) -} - -/// Whether path exists (for tests). -pub fn path_exists(p: &Path) -> bool { - p.exists() -} diff --git a/crates/configure-dhcp/src/stack/mod.rs b/crates/configure-dhcp/src/stack/mod.rs deleted file mode 100644 index f8f257d..0000000 --- a/crates/configure-dhcp/src/stack/mod.rs +++ /dev/null @@ -1,239 +0,0 @@ -//! Pluggable event WiFi stacks. - -pub mod omada; - -use std::fmt; -use std::net::{IpAddr, Ipv4Addr}; - -/// Kind of detected or matched device. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum DeviceKind { - Unknown, - Controller, - AccessPoint, -} - -impl fmt::Display for DeviceKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Unknown => write!(f, "unknown"), - Self::Controller => write!(f, "controller"), - Self::AccessPoint => write!(f, "ap"), - } - } -} - -/// Hardware address (6 bytes). Empty when unknown. -#[derive(Clone, Debug, Default, Eq, PartialEq, Hash)] -pub struct MacAddr([u8; 6]); - -impl MacAddr { - #[must_use] - pub fn new(bytes: [u8; 6]) -> Self { - Self(bytes) - } - - #[must_use] - pub fn octets(&self) -> [u8; 6] { - self.0 - } - - #[must_use] - pub fn is_zero(&self) -> bool { - self.0 == [0; 6] - } - - /// Parse `aa:bb:cc:dd:ee:ff` or `aa-bb-cc-dd-ee-ff`. - pub fn parse(s: &str) -> Option { - let sep = if s.contains(':') { - ':' - } else if s.contains('-') { - '-' - } else { - return None; - }; - let parts: Vec<&str> = s.split(sep).collect(); - if parts.len() != 6 { - return None; - } - let mut bytes = [0u8; 6]; - for (i, p) in parts.iter().enumerate() { - bytes[i] = u8::from_str_radix(p, 16).ok()?; - } - Some(Self(bytes)) - } -} - -impl fmt::Display for MacAddr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", - self.0[0], self.0[1], self.0[2], self.0[3], self.0[4], self.0[5] - ) - } -} - -/// Device belonging to a stack (IP/MAC may be missing before DHCP). -#[derive(Clone, Debug)] -pub struct Device { - pub stack: String, - pub kind: DeviceKind, - pub mac: Option, - pub ip: Option, - pub hostname: String, -} - -/// Vendor/event WiFi stack (Omada today; UniFi later). -pub trait Stack: Send + Sync { - fn name(&self) -> &str; - - /// Probe the LAN before DHCP (L2 / discovery). - fn detect(&self, iface: &str) -> crate::Result>; - - /// Classify a post-DHCP lease/ARP entry. - fn match_device(&self, hostname: &str, mac: &MacAddr) -> Option; -} - -/// Registry of stacks. -#[derive(Default)] -pub struct Registry { - stacks: Vec>, -} - -impl Registry { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - pub fn register(&mut self, stack: Box) { - self.stacks.push(stack); - } - - #[must_use] - pub fn stacks(&self) -> &[Box] { - &self.stacks - } - - /// Run detect on each stack; returns all devices found. - pub fn detect_any(&self, iface: &str) -> crate::Result<(Vec, bool)> { - let mut all = Vec::new(); - let mut last_err: Option = None; - for s in &self.stacks { - match s.detect(iface) { - Ok(found) => { - for mut d in found { - if d.stack.is_empty() { - d.stack = s.name().to_string(); - } - all.push(d); - } - } - Err(e) => { - log::warn!("stack {}: detect: {e}", s.name()); - last_err = Some(e); - } - } - } - let ok = !all.is_empty(); - if !ok { - if let Some(e) = last_err { - return Err(e); - } - } - Ok((all, ok)) - } - - #[must_use] - pub fn match_device(&self, hostname: &str, mac: &MacAddr) -> Option<(&str, DeviceKind)> { - for s in &self.stacks { - if let Some(kind) = s.match_device(hostname, mac) { - return Some((s.name(), kind)); - } - } - None - } -} - -/// IPv4 helper used by dhcp defaults. -#[must_use] -pub fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { - IpAddr::V4(Ipv4Addr::new(a, b, c, d)) -} - -#[cfg(test)] -mod tests { - #![allow(clippy::expect_used, clippy::unwrap_used)] - - use super::*; - - struct FakeStack { - name: &'static str, - devices: Vec, - match_mac_prefix: Option<[u8; 3]>, - } - - impl Stack for FakeStack { - fn name(&self) -> &str { - self.name - } - - fn detect(&self, _iface: &str) -> crate::Result> { - Ok(self.devices.clone()) - } - - fn match_device(&self, _hostname: &str, mac: &MacAddr) -> Option { - let p = self.match_mac_prefix?; - let o = mac.octets(); - if o[0] == p[0] && o[1] == p[1] && o[2] == p[2] { - Some(DeviceKind::AccessPoint) - } else { - None - } - } - } - - #[test] - fn registry_detect_any() { - let mut r = Registry::new(); - r.register(Box::new(FakeStack { - name: "empty", - devices: vec![], - match_mac_prefix: None, - })); - r.register(Box::new(FakeStack { - name: "omada", - devices: vec![Device { - stack: String::new(), - kind: DeviceKind::AccessPoint, - mac: Some(MacAddr::new([0x50, 0xc7, 0xbf, 1, 2, 3])), - ip: None, - hostname: String::new(), - }], - match_mac_prefix: Some([0x50, 0xc7, 0xbf]), - })); - let (devs, ok) = r.detect_any("eth0").expect("ok"); - assert!(ok); - assert_eq!(devs.len(), 1); - assert_eq!(devs[0].stack, "omada"); - } - - #[test] - fn registry_detect_empty() { - let mut r = Registry::new(); - r.register(Box::new(FakeStack { - name: "empty", - devices: vec![], - match_mac_prefix: None, - })); - let (_, ok) = r.detect_any("eth0").expect("ok"); - assert!(!ok); - } - - #[test] - fn mac_parse_display() { - let m = MacAddr::parse("50:C7:BF:01:02:03").expect("parse"); - assert_eq!(m.to_string(), "50:c7:bf:01:02:03"); - } -} diff --git a/crates/configure-dhcp/src/stack/omada.rs b/crates/configure-dhcp/src/stack/omada.rs deleted file mode 100644 index e37e997..0000000 --- a/crates/configure-dhcp/src/stack/omada.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! TP-Link Omada stack: ARP OUI + hostname match + best-effort UDP discovery. - -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::net::{Ipv4Addr, UdpSocket}; -use std::time::Duration; - -use crate::stack::{Device, DeviceKind, MacAddr, Stack}; -use crate::Result; - -/// Well-known TP-Link OUIs used on Omada APs/controllers (first 3 octets). -const TP_LINK_OUIS: &[[u8; 3]] = &[ - [0x50, 0xc7, 0xbf], - [0x14, 0xeb, 0xb6], - [0x98, 0xda, 0xc4], - [0xac, 0x84, 0xc6], - [0xc0, 0x06, 0xc3], - [0x60, 0x32, 0xb1], - [0xb0, 0x95, 0x75], - [0x00, 0x31, 0x92], - [0x18, 0xa6, 0xf7], - [0x54, 0xaf, 0x97], - [0x70, 0x4f, 0x57], - [0x90, 0x9a, 0x4a], - [0xd8, 0x07, 0xb6], - [0xf4, 0xf2, 0x6d], - [0x1c, 0x61, 0xb4], - [0x30, 0xde, 0x4b], - [0x5c, 0xa6, 0xe6], - [0x68, 0xff, 0x7b], - [0x7c, 0x8b, 0xca], - [0xb4, 0xb0, 0x24], -]; - -/// Omada / TP-Link discovery UDP ports (best-effort probe). -const DISCOVERY_PORTS: &[u16] = &[29810, 1040, 20002]; - -#[derive(Debug, Default)] -pub struct OmadaStack { - arp_path: String, -} - -impl OmadaStack { - #[must_use] - pub fn new() -> Self { - Self { - arp_path: "/proc/net/arp".to_string(), - } - } - - /// Test helper: read ARP from a custom path. - #[must_use] - pub fn with_arp_path(path: impl Into) -> Self { - Self { - arp_path: path.into(), - } - } -} - -impl Stack for OmadaStack { - fn name(&self) -> &str { - "omada" - } - - fn detect(&self, iface: &str) -> Result> { - let mut devices = Vec::new(); - devices.extend(scan_arp(&self.arp_path)?); - // UDP discovery is best-effort; failures are non-fatal. - if let Ok(extra) = udp_probe(iface) { - for d in extra { - if !devices.iter().any(|e| e.mac == d.mac && d.mac.is_some()) { - devices.push(d); - } - } - } - Ok(devices) - } - - fn match_device(&self, hostname: &str, mac: &MacAddr) -> Option { - if is_tp_link_oui(mac) { - return Some(kind_from_hostname(hostname).unwrap_or(DeviceKind::AccessPoint)); - } - kind_from_hostname(hostname) - } -} - -fn is_tp_link_oui(mac: &MacAddr) -> bool { - let o = mac.octets(); - TP_LINK_OUIS - .iter() - .any(|oui| o[0] == oui[0] && o[1] == oui[1] && o[2] == oui[2]) -} - -fn kind_from_hostname(hostname: &str) -> Option { - let h = hostname.to_ascii_lowercase(); - if h.contains("oc200") || h.contains("oc300") || h.contains("controller") { - return Some(DeviceKind::Controller); - } - if h.contains("eap") || h.contains("omada") { - return Some(DeviceKind::AccessPoint); - } - None -} - -fn scan_arp(path: &str) -> Result> { - let file = match File::open(path) { - Ok(f) => f, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), - Err(e) => return Err(crate::Error::io_at(path, e)), - }; - let reader = BufReader::new(file); - let mut out = Vec::new(); - for (i, line) in reader.lines().enumerate() { - let line = line.map_err(|e| crate::Error::io_at(path, e))?; - if i == 0 { - continue; // header - } - // IP HW type Flags HW address Mask Device - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 4 { - continue; - } - let Some(mac) = MacAddr::parse(parts[3]) else { - continue; - }; - if !is_tp_link_oui(&mac) { - continue; - } - let ip = parts[0].parse().ok(); - out.push(Device { - stack: "omada".to_string(), - kind: DeviceKind::AccessPoint, - mac: Some(mac), - ip, - hostname: String::new(), - }); - } - Ok(out) -} - -fn udp_probe(_iface: &str) -> Result> { - let sock = UdpSocket::bind("0.0.0.0:0")?; - sock.set_broadcast(true)?; - sock.set_read_timeout(Some(Duration::from_millis(400)))?; - // Minimal probe payload — many Omada units answer discovery noise on these ports. - let payload: &[u8] = b"\x01\x00\x00\x00"; - for &port in DISCOVERY_PORTS { - let addr = (Ipv4Addr::BROADCAST, port); - let _ = sock.send_to(payload, addr); - } - let mut buf = [0u8; 512]; - let mut found = Vec::new(); - for _ in 0..8 { - match sock.recv_from(&mut buf) { - Ok((n, src)) => { - if n == 0 { - continue; - } - found.push(Device { - stack: "omada".to_string(), - kind: DeviceKind::Unknown, - mac: None, - ip: Some(src.ip()), - hostname: String::new(), - }); - } - Err(_) => break, - } - } - Ok(found) -} - -#[cfg(test)] -mod tests { - #![allow(clippy::expect_used, clippy::unwrap_used)] - - use super::*; - use std::io::Write; - use tempfile::NamedTempFile; - - #[test] - fn match_oui() { - let s = OmadaStack::new(); - let mac = MacAddr::new([0x50, 0xc7, 0xbf, 1, 2, 3]); - assert_eq!(s.match_device("", &mac), Some(DeviceKind::AccessPoint)); - assert_eq!( - s.match_device("OC200-Office", &MacAddr::new([0xaa, 0xbb, 0xcc, 0, 0, 1])), - Some(DeviceKind::Controller) - ); - assert_eq!( - s.match_device("phone", &MacAddr::new([0xaa, 0xbb, 0xcc, 0, 0, 1])), - None - ); - } - - #[test] - fn detect_from_arp_file() { - let mut f = NamedTempFile::new().expect("tmp"); - writeln!( - f, - "IP address HW type Flags HW address Mask Device\n\ -10.0.10.11 0x1 0x2 50:c7:bf:11:22:33 * eth0\n\ -10.0.10.50 0x1 0x2 aa:bb:cc:11:22:33 * eth0" - ) - .expect("write"); - let s = OmadaStack::with_arp_path(f.path().to_string_lossy()); - let devs = s.detect("eth0").expect("detect"); - assert_eq!(devs.len(), 1); - assert_eq!( - devs[0].mac.as_ref().map(ToString::to_string).as_deref(), - Some("50:c7:bf:11:22:33") - ); - } -} diff --git a/crates/configure-dhcp/src/sticky.rs b/crates/configure-dhcp/src/sticky.rs deleted file mode 100644 index 03bccc0..0000000 --- a/crates/configure-dhcp/src/sticky.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Sticky state: stacks previously detected (survive reboot under /data). - -use std::collections::BTreeSet; -use std::fs; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use crate::{Error, Result}; - -const DEFAULT_STATE_PATH: &str = "/data/etc/configure-dhcp.state"; - -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct StickyState { - /// Stack names that previously gated DHCP on. - pub stacks: BTreeSet, -} - -impl StickyState { - #[must_use] - pub fn path_default() -> PathBuf { - PathBuf::from(DEFAULT_STATE_PATH) - } - - pub fn load(path: &Path) -> Result { - match fs::read_to_string(path) { - Ok(text) => { - let s: Self = serde_json::from_str(&text)?; - Ok(s) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), - Err(e) => Err(Error::io_at(path, e)), - } - } - - pub fn save(&self, path: &Path) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; - } - let text = serde_json::to_string_pretty(self)?; - fs::write(path, text).map_err(|e| Error::io_at(path, e))?; - Ok(()) - } - - #[must_use] - pub fn has_any(&self) -> bool { - !self.stacks.is_empty() - } - - pub fn remember(&mut self, stack_name: &str) { - self.stacks.insert(stack_name.to_string()); - } -} - -#[cfg(test)] -mod tests { - #![allow(clippy::expect_used, clippy::unwrap_used)] - - use super::*; - use tempfile::tempdir; - - #[test] - fn roundtrip() { - let dir = tempdir().unwrap(); - let path = dir.path().join("state.json"); - let mut s = StickyState::default(); - s.remember("omada"); - s.save(&path).unwrap(); - let loaded = StickyState::load(&path).unwrap(); - assert!(loaded.stacks.contains("omada")); - } -} diff --git a/crates/configure-dhcp/tests/gate_test.rs b/crates/configure-dhcp/tests/gate_test.rs deleted file mode 100644 index d06af2c..0000000 --- a/crates/configure-dhcp/tests/gate_test.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Gate behaviour with a fake stack (no root / dnsmasq required for skip path). - -#![allow( - clippy::expect_used, - clippy::unwrap_used, - clippy::field_reassign_with_default -)] - -use configure_dhcp::dhcp::DhcpDefaults; -use configure_dhcp::run::{run_up, Paths}; -use configure_dhcp::stack::{Device, DeviceKind, MacAddr, Registry, Stack}; -use configure_dhcp::Result; -use tempfile::tempdir; - -struct EmptyStack; - -impl Stack for EmptyStack { - fn name(&self) -> &str { - "empty" - } - fn detect(&self, _: &str) -> Result> { - Ok(vec![]) - } - fn match_device(&self, _: &str, _: &MacAddr) -> Option { - None - } -} - -struct HitStack; - -impl Stack for HitStack { - fn name(&self) -> &str { - "omada" - } - fn detect(&self, _: &str) -> Result> { - Ok(vec![Device { - stack: "omada".into(), - kind: DeviceKind::AccessPoint, - mac: Some(MacAddr::new([0x50, 0xc7, 0xbf, 1, 2, 3])), - ip: Some("10.0.10.11".parse().expect("ip")), - hostname: "EAP613".into(), - }]) - } - fn match_device(&self, _: &str, mac: &MacAddr) -> Option { - let o = mac.octets(); - if o[0] == 0x50 { - Some(DeviceKind::AccessPoint) - } else { - None - } - } -} - -#[test] -fn gate_off_when_no_stack() { - let dir = tempdir().expect("tmp"); - let mut registry = Registry::new(); - registry.register(Box::new(EmptyStack)); - let paths = Paths { - state: dir.path().join("state.json"), - ethernet_conf: dir.path().join("eth.conf"), - arp: dir.path().join("arp"), - }; - let mut defaults = DhcpDefaults::default(); - defaults.iface = "lo".into(); - defaults.conf_path = dir.path().join("dnsmasq.conf"); - defaults.reservations_path = dir.path().join("res.conf"); - defaults.leasefile = dir.path().join("leases"); - - let report = run_up(®istry, &paths, &defaults).expect("up"); - assert!(!report.gate_on); - assert!(!report.dhcp_started); -} - -#[test] -fn gate_on_writes_sticky_even_without_dnsmasq_binary() { - // When Omada is detected but dnsmasq is missing, up should error after sticky/remember path - // OR we error on missing binary. Either way gate logic ran detect. - let dir = tempdir().expect("tmp"); - let mut registry = Registry::new(); - registry.register(Box::new(HitStack)); - let paths = Paths { - state: dir.path().join("state.json"), - ethernet_conf: dir.path().join("eth.conf"), - arp: dir.path().join("arp"), - }; - let mut defaults = DhcpDefaults::default(); - defaults.iface = "lo".into(); - defaults.conf_path = dir.path().join("dnsmasq.conf"); - defaults.reservations_path = dir.path().join("res.conf"); - defaults.leasefile = dir.path().join("leases"); - - let result = run_up(®istry, &paths, &defaults); - // On developer machines without /usr/sbin/dnsmasq this is Err(DnsmasqMissing). - // Sticky should still be written before that check — currently sticky is written before - // dnsmasq check. Verify sticky if Ok or if Err after sticky. - if result.is_ok() { - let s = configure_dhcp::sticky::StickyState::load(&paths.state).expect("load"); - assert!(s.stacks.contains("omada")); - } else { - // Error path: ensure detect happened (error is dnsmasq or ip). - let err = result.expect_err("err"); - let msg = err.to_string(); - assert!( - msg.contains("dnsmasq") || msg.contains("ip") || msg.contains("No ethernet"), - "unexpected: {msg}" - ); - // Sticky is saved before dnsmasq check in run_up. - let s = configure_dhcp::sticky::StickyState::load(&paths.state).expect("load"); - assert!(s.stacks.contains("omada")); - } -} diff --git a/crates/configure-ethernet/Cargo.toml b/crates/configure-ethernet/Cargo.toml deleted file mode 100644 index 8030f37..0000000 --- a/crates/configure-ethernet/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "configure-ethernet" -version = "0.1.0" -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "One-shot Ethernet bring-up for BigFred OS (static club subnets, then DHCP)" -readme = "README.md" - -[[bin]] -name = "configure-ethernet" -path = "src/main.rs" - -[dependencies] -clap = { version = "4", features = ["derive"] } -log = "0.4" -env_logger = "0.11" -thiserror = "2" - -[dev-dependencies] -tempfile = "3" - -[lints.rust] -unused_must_use = "deny" - -[lints.clippy] -dbg_macro = "deny" -expect_used = "deny" -panic = "deny" -todo = "deny" -unimplemented = "deny" -unwrap_used = "deny" diff --git a/crates/configure-ethernet/README.md b/crates/configure-ethernet/README.md deleted file mode 100644 index 6c9903a..0000000 --- a/crates/configure-ethernet/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# configure-ethernet - -One-shot Ethernet bring-up for BigFred OS (Rust). Tries common club static -subnets, then falls back to DHCP. `check` is a cheap liveness probe for microinit. - -## Commands - -```bash -configure-ethernet # same as up -configure-ethernet up -configure-ethernet check # exit 0 if UP+IPv4 -``` - -Config: `/data/etc/configure-ethernet.conf` (`PRIMARY` / `SECONDARY`). - -Part of the [micronet](https://github.com/dcc-bigfred/micronet) workspace. diff --git a/crates/configure-ethernet/src/main.rs b/crates/configure-ethernet/src/main.rs deleted file mode 100644 index c3f70e7..0000000 --- a/crates/configure-ethernet/src/main.rs +++ /dev/null @@ -1,405 +0,0 @@ -//! configure-ethernet — bring up the first Ethernet interface. - -use std::fs; -use std::io::Write; -use std::net::Ipv4Addr; -use std::path::Path; -use std::process::{Command, ExitCode, Stdio}; -use std::thread; -use std::time::Duration; - -use clap::{Parser, Subcommand}; -use thiserror::Error; - -const DEFAULT_CONFIG_PATH: &str = "/data/etc/configure-ethernet.conf"; -const DEFAULT_PRIMARY: &str = "192.168.0.120"; -const DEFAULT_SECONDARY: &str = "192.168.1.120"; -const DEFAULT_PREFIX_LEN: u8 = 24; -const PING_COUNT: &str = "1"; -const PING_TIMEOUT_SEC: &str = "2"; -const DHCP_WAIT: Duration = Duration::from_secs(5); - -const IP_BIN: &str = "/sbin/ip"; -const DHCLIENT_BIN: &str = "/sbin/dhclient"; -const PING_BIN: &str = "/bin/ping"; - -#[derive(Debug, Error)] -enum Error { - #[error("I/O: {0}")] - Io(#[from] std::io::Error), - #[error("{0}")] - Other(String), -} - -type Result = std::result::Result; - -#[derive(Clone, Debug, PartialEq, Eq)] -struct Settings { - primary_addr: String, - secondary_addr: String, -} - -#[derive(Parser, Debug)] -#[command( - name = "configure-ethernet", - about = "Bring up Ethernet (static / DHCP) for BigFred OS", - version -)] -struct Cli { - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand, Debug)] -enum Commands { - /// Configure once and exit (default) - Up, - Configure, - Start, - /// Exit 0 if link+IPv4 look OK - Check, -} - -fn main() -> ExitCode { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - let cli = Cli::parse(); - match cli.command.unwrap_or(Commands::Up) { - Commands::Up | Commands::Configure | Commands::Start => { - if let Err(e) = run_configure(Path::new(DEFAULT_CONFIG_PATH)) { - eprintln!("configure-ethernet: {e}"); - return ExitCode::FAILURE; - } - ExitCode::SUCCESS - } - Commands::Check => { - if check_connected() { - ExitCode::SUCCESS - } else { - ExitCode::FAILURE - } - } - } -} - -fn run_configure(config_path: &Path) -> Result<()> { - let cfg = load_or_create_config(config_path, DEFAULT_PRIMARY, DEFAULT_SECONDARY)?; - let _ = run_cmd(IP_BIN, &["link", "set", "lo", "up"]); - if connect(&cfg) { - return Ok(()); - } - Err(Error::Other( - "failed to configure ethernet (static and DHCP)".into(), - )) -} - -fn check_connected() -> bool { - let Ok(iface) = first_ethernet_interface() else { - return false; - }; - iface_link_up(&iface) && iface_has_ipv4(&iface) -} - -fn connect(cfg: &Settings) -> bool { - let Ok(iface) = first_ethernet_interface() else { - eprintln!("configure-ethernet: no Ethernet interface found"); - return false; - }; - println!("configure-ethernet: using interface {iface}"); - let _ = run_cmd("/bin/killall", &["dhclient"]); - - if try_static(&iface, &cfg.primary_addr) { - println!( - "configure-ethernet: static {} OK (gateway {})", - cfg.primary_addr, - gateway_for(&cfg.primary_addr) - ); - return true; - } - if try_static(&iface, &cfg.secondary_addr) { - println!( - "configure-ethernet: static {} OK (gateway {})", - cfg.secondary_addr, - gateway_for(&cfg.secondary_addr) - ); - return true; - } - if try_dhcp(&iface) { - println!("configure-ethernet: DHCP OK"); - return true; - } - eprintln!("configure-ethernet: failed to configure {iface} (static and DHCP)"); - false -} - -fn iface_link_up(iface: &str) -> bool { - let Ok(out) = Command::new(IP_BIN) - .args(["link", "show", "dev", iface]) - .output() - else { - return false; - }; - let s = String::from_utf8_lossy(&out.stdout); - s.contains("state UP") || s.contains(",UP") -} - -fn load_or_create_config( - path: &Path, - default_primary: &str, - default_secondary: &str, -) -> Result { - let defaults = Settings { - primary_addr: default_primary.to_string(), - secondary_addr: default_secondary.to_string(), - }; - match fs::read_to_string(path) { - Ok(text) => Ok(parse_config(&text, defaults)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - if let Err(w) = write_config(path, &defaults) { - eprintln!("warning: cannot write {}: {w}", path.display()); - } - Ok(defaults) - } - Err(e) => Err(e.into()), - } -} - -fn parse_config(text: &str, defaults: Settings) -> Settings { - let mut cfg = defaults; - for line in text.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let key = key.trim(); - let value = value.trim(); - if value.is_empty() { - continue; - } - if value.parse::().is_err() { - continue; - } - match key.to_ascii_uppercase().as_str() { - "PRIMARY" | "PRIMARY_ADDRESS" | "ADDRESS" => cfg.primary_addr = value.to_string(), - "SECONDARY" | "SECONDARY_ADDRESS" | "FALLBACK" | "FALLBACK_ADDRESS" => { - cfg.secondary_addr = value.to_string(); - } - _ => {} - } - } - cfg -} - -fn write_config(path: &Path, cfg: &Settings) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let content = format!( - "# configure-ethernet static addresses (edit to match club subnet)\n\ -PRIMARY={}\n\ -SECONDARY={}\n", - cfg.primary_addr, cfg.secondary_addr - ); - let mut f = fs::File::create(path)?; - f.write_all(content.as_bytes())?; - Ok(()) -} - -fn first_ethernet_interface() -> Result { - let dir = Path::new("/sys/class/net"); - let mut names = Vec::new(); - for ent in fs::read_dir(dir)? { - let ent = ent?; - let name = ent.file_name().to_string_lossy().into_owned(); - if name == "lo" { - continue; - } - if is_wireless(&name) { - continue; - } - names.push(name); - } - names.sort(); - names - .into_iter() - .next() - .ok_or_else(|| Error::Other("no Ethernet interface found".into())) -} - -fn is_wireless(iface: &str) -> bool { - Path::new("/sys/class/net") - .join(iface) - .join("wireless") - .exists() -} - -fn try_static(iface: &str, addr: &str) -> bool { - let gw = gateway_for(addr); - if gw.is_empty() { - return false; - } - if let Err(e) = configure_static(iface, addr) { - eprintln!("configure-ethernet: static {addr} on {iface}: {e}"); - return false; - } - if ping_host(&gw) { - return true; - } - eprintln!("configure-ethernet: no reply from gateway {gw}"); - false -} - -fn configure_static(iface: &str, addr: &str) -> Result<()> { - run_cmd(IP_BIN, &["link", "set", "dev", iface, "up"])?; - run_cmd(IP_BIN, &["addr", "flush", "dev", iface])?; - let cidr = format!("{addr}/{DEFAULT_PREFIX_LEN}"); - run_cmd(IP_BIN, &["addr", "add", &cidr, "dev", iface]) -} - -fn ping_host(host: &str) -> bool { - run_cmd(PING_BIN, &["-c", PING_COUNT, "-W", PING_TIMEOUT_SEC, host]).is_ok() -} - -fn try_dhcp(iface: &str) -> bool { - let _ = run_cmd(IP_BIN, &["addr", "flush", "dev", iface]); - let _ = run_cmd(IP_BIN, &["link", "set", "dev", iface, "up"]); - if let Err(e) = run_cmd(DHCLIENT_BIN, &[iface]) { - eprintln!("configure-ethernet: dhclient on {iface}: {e}"); - return false; - } - thread::sleep(DHCP_WAIT); - if !iface_has_ipv4(iface) { - eprintln!("configure-ethernet: no IPv4 address on {iface} after DHCP"); - return false; - } - if let Some(gw) = default_gateway() { - if ping_host(&gw) { - return true; - } - } - iface_has_ipv4(iface) -} - -fn iface_has_ipv4(iface: &str) -> bool { - let Ok(out) = Command::new(IP_BIN) - .args(["-4", "addr", "show", "dev", iface]) - .output() - else { - return false; - }; - String::from_utf8_lossy(&out.stdout).contains("inet ") -} - -fn default_gateway() -> Option { - let out = Command::new(IP_BIN) - .args(["route", "show", "default"]) - .output() - .ok()?; - for line in String::from_utf8_lossy(&out.stdout).lines() { - let fields: Vec<&str> = line.split_whitespace().collect(); - for i in 0..fields.len() { - if fields[i] == "via" { - if let Some(gw) = fields.get(i + 1) { - return Some((*gw).to_string()); - } - } - } - } - None -} - -fn gateway_for(addr: &str) -> String { - let Ok(ip) = addr.parse::() else { - return String::new(); - }; - let o = ip.octets(); - Ipv4Addr::new(o[0], o[1], o[2], 1).to_string() -} - -fn run_cmd(bin: &str, args: &[&str]) -> Result<()> { - let status = Command::new(bin) - .args(args) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status()?; - if status.success() { - Ok(()) - } else { - Err(Error::Other(format!( - "{bin} {:?} exited {}", - args, - status.code().unwrap_or(-1) - ))) - } -} - -#[cfg(test)] -mod tests { - #![allow(clippy::expect_used, clippy::unwrap_used)] - - use super::*; - use tempfile::tempdir; - - #[test] - fn parse_config_defaults() { - let defaults = Settings { - primary_addr: "192.168.0.120".into(), - secondary_addr: "192.168.1.120".into(), - }; - let cfg = parse_config("", defaults.clone()); - assert_eq!(cfg, defaults); - } - - #[test] - fn parse_config_overrides() { - let text = "# club\nPRIMARY=10.0.0.50\nSECONDARY=10.0.1.50\n"; - let defaults = Settings { - primary_addr: "192.168.0.120".into(), - secondary_addr: "192.168.1.120".into(), - }; - let cfg = parse_config(text, defaults); - assert_eq!(cfg.primary_addr, "10.0.0.50"); - assert_eq!(cfg.secondary_addr, "10.0.1.50"); - } - - #[test] - fn parse_config_ignores_invalid_ip() { - let text = "PRIMARY=not-an-ip\nSECONDARY=192.168.1.99\n"; - let defaults = Settings { - primary_addr: "192.168.0.120".into(), - secondary_addr: "192.168.1.120".into(), - }; - let cfg = parse_config(text, defaults); - assert_eq!(cfg.primary_addr, "192.168.0.120"); - assert_eq!(cfg.secondary_addr, "192.168.1.99"); - } - - #[test] - fn gateway_for_works() { - assert_eq!(gateway_for("192.168.0.120"), "192.168.0.1"); - assert_eq!(gateway_for("10.20.30.40"), "10.20.30.1"); - assert_eq!(gateway_for("bad"), ""); - } - - #[test] - fn load_or_create_writes() { - let dir = tempdir().unwrap(); - let path = dir.path().join("configure-ethernet.conf"); - let cfg = load_or_create_config(&path, "192.168.0.120", "192.168.1.120").unwrap(); - assert_eq!(cfg.primary_addr, "192.168.0.120"); - let body = fs::read_to_string(&path).unwrap(); - assert!(body.contains("PRIMARY=192.168.0.120")); - } - - #[test] - fn load_reads_existing() { - let dir = tempdir().unwrap(); - let path = dir.path().join("c.conf"); - fs::write(&path, "PRIMARY=172.16.0.8\nSECONDARY=172.16.1.8\n").unwrap(); - let cfg = load_or_create_config(&path, "192.168.0.120", "192.168.1.120").unwrap(); - assert_eq!(cfg.primary_addr, "172.16.0.8"); - assert_eq!(cfg.secondary_addr, "172.16.1.8"); - } -} diff --git a/crates/micronet/Cargo.toml b/crates/micronet/Cargo.toml new file mode 100644 index 0000000..87c2d3c --- /dev/null +++ b/crates/micronet/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "micronet" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Ethernet bring-up and DHCP gateway daemon for BigFred OS" +readme = "../../README.md" + +[lib] +name = "micronet" +path = "src/lib.rs" + +[[bin]] +name = "micronet" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +dhcproto = "0.12" +env_logger = "0.11" +ipnet = { version = "2", features = ["serde"] } +log = "0.4" +nix = { version = "0.29", features = ["signal", "process", "socket", "fs"] } +notify = "8.2.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +signal-hook = "0.3" +socket2 = { version = "0.5", features = ["all"] } +thiserror = "2" + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/micronet/build.rs b/crates/micronet/build.rs new file mode 100644 index 0000000..07651ed --- /dev/null +++ b/crates/micronet/build.rs @@ -0,0 +1,54 @@ +//! Emit build-time version env for `src/version.rs`. +//! +//! Prefer CI-provided `MICRONET_GIT_COMMIT` / `MICRONET_BUILD_TIME`. +//! Fall back to `git rev-parse` and UTC timestamp when unset. + +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=.git/HEAD"); + + let commit = std::env::var("MICRONET_GIT_COMMIT") + .ok() + .filter(|s| !s.is_empty()) + .or_else(git_commit) + .unwrap_or_else(|| "unknown".into()); + println!("cargo:rustc-env=MICRONET_GIT_COMMIT={commit}"); + println!("cargo:rerun-if-env-changed=MICRONET_GIT_COMMIT"); + + let build_time = std::env::var("MICRONET_BUILD_TIME") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(utc_now); + println!("cargo:rustc-env=MICRONET_BUILD_TIME={build_time}"); + println!("cargo:rerun-if-env-changed=MICRONET_BUILD_TIME"); +} + +fn git_commit() -> Option { + let out = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?; + let s = s.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } +} + +fn utc_now() -> String { + Command::new("date") + .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_default() +} diff --git a/crates/micronet/src/apply/mod.rs b/crates/micronet/src/apply/mod.rs new file mode 100644 index 0000000..f5a4c34 --- /dev/null +++ b/crates/micronet/src/apply/mod.rs @@ -0,0 +1,454 @@ +//! Mode selection and apply: client / gateway / static. + +use std::net::Ipv4Addr; +use std::path::Path; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::config::{default_dnsmasq_conf_path, default_dnsmasq_leasefile, Config}; +use crate::constants::{DHCP_CLIENT_WAIT, REQUIRED_PREFIX}; +use crate::dhcp; +use crate::error::Result; +use crate::net::probe::{self, read_mac}; +use crate::net::{LiveNet, NetOps}; + +/// Operating mode (illegal combinations cannot be represented). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mode { + Client, + Gateway, + Static, +} + +impl Mode { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Client => "client", + Self::Gateway => "gateway", + Self::Static => "static", + } + } +} + +/// Snapshot returned by apply / IPC `status`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Status { + pub mode: Mode, + pub iface: String, + pub cidr: Option, + pub foreign_dhcp: bool, + pub gateway_reachable: bool, + pub dnsmasq_running: bool, +} + +impl Status { + #[must_use] + pub fn empty() -> Self { + Self { + mode: Mode::Gateway, + iface: String::new(), + cidr: None, + foreign_dhcp: false, + gateway_reachable: false, + dnsmasq_running: false, + } + } + + /// Liveness: interface has an IPv4 (CIDR recorded). + #[must_use] + pub fn is_up(&self) -> bool { + self.cidr.is_some() && !self.iface.is_empty() + } +} + +/// Decide mode from probe results. +#[must_use] +pub fn decide(foreign_dhcp: bool, gateway_reachable: bool) -> Mode { + if foreign_dhcp { + Mode::Client + } else if gateway_reachable { + Mode::Static + } else { + Mode::Gateway + } +} + +/// How apply should probe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProbePolicy { + /// Full DHCPDISCOVER + ping (start, IPC reconfigure, client/static reload). + Full, + /// Skip DHCPDISCOVER (we may be serving). Ping `gateway.ip` unless it is ours. + SkipDhcpWhileGateway, +} + +/// DHCP server + probe, injectable in tests (must not import `ipc`). +pub trait GatewayCtl { + fn dhcp_running(&self) -> bool; + fn dhcp_stop(&self) -> Result<()>; + fn dhcp_reload_or_restart(&self, cfg: &Config, iface: &str) -> Result<()>; + fn probe_foreign_dhcp(&self, iface: &str, timeout: Duration) -> bool; +} + +/// Live dnsmasq + DHCPDISCOVER. +pub struct LiveGateway; + +impl GatewayCtl for LiveGateway { + fn dhcp_running(&self) -> bool { + dhcp::is_running() + } + + fn dhcp_stop(&self) -> Result<()> { + dhcp::stop() + } + + fn dhcp_reload_or_restart(&self, cfg: &Config, iface: &str) -> Result<()> { + let conf_path = default_dnsmasq_conf_path(); + let leasefile = default_dnsmasq_leasefile(); + if let Some(parent) = leasefile.parent() { + let _ = std::fs::create_dir_all(parent); + } + let body = dhcp::render_conf(cfg, iface, &leasefile); + let changed = dhcp::conf::ensure_conf(&conf_path, &body)?; + dhcp::reload_or_restart(&conf_path, changed) + } + + fn probe_foreign_dhcp(&self, iface: &str, timeout: Duration) -> bool { + let mac = match read_mac(Path::new("/sys/class/net"), iface) { + Ok(m) => m, + Err(e) => { + log::warn!("MAC read failed ({e}); treating as no foreign DHCP"); + return false; + } + }; + match probe::probe_foreign_dhcp(iface, &mac, timeout) { + Ok(v) => v, + Err(e) => { + log::warn!("DHCP probe failed ({e}); treating as no offer"); + false + } + } + } +} + +/// Apply configuration to the live system. +pub fn apply(cfg: &Config, policy: ProbePolicy) -> Result { + apply_with(cfg, policy, &LiveNet::new(), &LiveGateway) +} + +/// Apply with injected net + DHCP (unit tests). +pub fn apply_with( + cfg: &Config, + policy: ProbePolicy, + net: &N, + gw: &G, +) -> Result { + cfg.validate()?; + let iface = net.resolve_iface(cfg.interface.as_deref())?; + net.bring_up(&iface)?; + net.kill_dhclient()?; + + let skip_dhcp = policy == ProbePolicy::SkipDhcpWhileGateway; + let foreign_dhcp = if skip_dhcp { + false + } else { + if gw.dhcp_running() { + log::info!("stopping own dnsmasq before DHCP probe"); + gw.dhcp_stop()?; + } + gw.probe_foreign_dhcp(&iface, Duration::from_secs(cfg.probe_timeout_secs)) + }; + + let static_cidr = cfg.static_cidr(); + let static_ip = cfg.static_addr(); + + if foreign_dhcp { + return apply_client(cfg, net, gw, &iface); + } + + net.flush_addr(&iface)?; + net.add_addr(&iface, &static_cidr)?; + + let ping_target = cfg.gateway.ip; + let gateway_reachable = if net.iface_has_addr(&iface, ping_target) { + false + } else { + net.ping(ping_target) + }; + + let mode = decide(false, gateway_reachable); + match mode { + Mode::Client => apply_client(cfg, net, gw, &iface), + Mode::Static => apply_static(cfg, net, gw, &iface, static_ip), + Mode::Gateway => apply_gateway(cfg, net, gw, &iface), + } +} + +fn apply_client( + cfg: &Config, + net: &N, + gw: &G, + iface: &str, +) -> Result { + let _ = cfg; + gw.dhcp_stop()?; + net.flush_addr(iface)?; + net.start_dhclient(iface)?; + let got = net.wait_ipv4(iface, DHCP_CLIENT_WAIT); + let cidr = if got { + Some(format!("{iface} dhcp")) + } else { + log::warn!("dhclient did not assign an address within {DHCP_CLIENT_WAIT:?}"); + None + }; + Ok(Status { + mode: Mode::Client, + iface: iface.to_string(), + cidr, + foreign_dhcp: true, + gateway_reachable: false, + dnsmasq_running: gw.dhcp_running(), + }) +} + +fn apply_static( + cfg: &Config, + net: &N, + gw: &G, + iface: &str, + static_ip: Ipv4Addr, +) -> Result { + gw.dhcp_stop()?; + net.replace_default_via(cfg.gateway.ip, iface)?; + Ok(Status { + mode: Mode::Static, + iface: iface.to_string(), + cidr: Some(format!("{static_ip}/{REQUIRED_PREFIX}")), + foreign_dhcp: false, + gateway_reachable: true, + dnsmasq_running: gw.dhcp_running(), + }) +} + +fn apply_gateway( + cfg: &Config, + net: &N, + gw: &G, + iface: &str, +) -> Result { + net.flush_addr(iface)?; + net.add_addr(iface, &cfg.gateway_cidr())?; + net.del_default()?; + gw.dhcp_reload_or_restart(cfg, iface)?; + Ok(Status { + mode: Mode::Gateway, + iface: iface.to_string(), + cidr: Some(cfg.gateway_cidr()), + foreign_dhcp: false, + gateway_reachable: false, + dnsmasq_running: gw.dhcp_running(), + }) +} + +/// Decide-only apply for unit tests (no live `ip`/`dnsmasq`). +#[must_use] +pub fn decide_status( + iface: &str, + foreign_dhcp: bool, + gateway_reachable: bool, + cfg: &Config, +) -> Status { + let mode = decide(foreign_dhcp, gateway_reachable); + let (cidr, dns) = match mode { + Mode::Client => (None, false), + Mode::Static => (Some(cfg.static_cidr()), false), + Mode::Gateway => (Some(cfg.gateway_cidr()), true), + }; + Status { + mode, + iface: iface.to_string(), + cidr, + foreign_dhcp, + gateway_reachable, + dnsmasq_running: dns, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + use crate::config::Config; + use crate::error::Error; + use std::net::Ipv4Addr; + use std::sync::Mutex; + + struct FakeNet { + ping_ok: bool, + addrs: Mutex>, + dhclient: Mutex, + default_via: Mutex>, + } + + impl FakeNet { + fn new(ping_ok: bool) -> Self { + Self { + ping_ok, + addrs: Mutex::new(Vec::new()), + dhclient: Mutex::new(false), + default_via: Mutex::new(None), + } + } + } + + impl NetOps for FakeNet { + fn list_ethernet(&self) -> Result> { + Ok(vec!["eth0".into()]) + } + fn is_physical_ethernet(&self, name: &str) -> bool { + name == "eth0" + } + fn resolve_iface(&self, configured: Option<&str>) -> Result { + match configured { + None => Ok("eth0".into()), + Some("eth0") => Ok("eth0".into()), + Some(n) => Err(Error::NotEthernet(n.into())), + } + } + fn bring_up(&self, _iface: &str) -> Result<()> { + Ok(()) + } + fn flush_addr(&self, _iface: &str) -> Result<()> { + self.addrs.lock().unwrap().clear(); + Ok(()) + } + fn add_addr(&self, _iface: &str, cidr: &str) -> Result<()> { + let ip = cidr + .split('/') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(Ipv4Addr::UNSPECIFIED); + self.addrs.lock().unwrap().push(ip); + Ok(()) + } + fn iface_has_ipv4(&self, _iface: &str) -> bool { + !self.addrs.lock().unwrap().is_empty() || *self.dhclient.lock().unwrap() + } + fn iface_has_addr(&self, _iface: &str, ip: Ipv4Addr) -> bool { + self.addrs.lock().unwrap().contains(&ip) + } + fn ping(&self, _host: Ipv4Addr) -> bool { + self.ping_ok + } + fn kill_dhclient(&self) -> Result<()> { + *self.dhclient.lock().unwrap() = false; + Ok(()) + } + fn start_dhclient(&self, _iface: &str) -> Result<()> { + *self.dhclient.lock().unwrap() = true; + Ok(()) + } + fn wait_ipv4(&self, iface: &str, _timeout: Duration) -> bool { + self.iface_has_ipv4(iface) + } + fn replace_default_via(&self, gw: Ipv4Addr, _iface: &str) -> Result<()> { + *self.default_via.lock().unwrap() = Some(gw); + Ok(()) + } + fn del_default(&self) -> Result<()> { + *self.default_via.lock().unwrap() = None; + Ok(()) + } + } + + struct FakeGw { + offer: bool, + running: Mutex, + started: Mutex, + } + + impl FakeGw { + fn new(offer: bool) -> Self { + Self { + offer, + running: Mutex::new(false), + started: Mutex::new(false), + } + } + } + + impl GatewayCtl for FakeGw { + fn dhcp_running(&self) -> bool { + *self.running.lock().unwrap() + } + fn dhcp_stop(&self) -> Result<()> { + *self.running.lock().unwrap() = false; + Ok(()) + } + fn dhcp_reload_or_restart(&self, _cfg: &Config, _iface: &str) -> Result<()> { + *self.running.lock().unwrap() = true; + *self.started.lock().unwrap() = true; + Ok(()) + } + fn probe_foreign_dhcp(&self, _iface: &str, _timeout: Duration) -> bool { + self.offer + } + } + + #[test] + fn three_outcomes() { + let cfg = Config::default(); + assert_eq!(decide(true, false), Mode::Client); + assert_eq!(decide(false, true), Mode::Static); + assert_eq!(decide(false, false), Mode::Gateway); + let s = decide_status("eth0", false, false, &cfg); + assert_eq!(s.mode, Mode::Gateway); + assert_eq!(s.cidr.as_deref(), Some("192.168.0.1/24")); + let s = decide_status("eth0", false, true, &cfg); + assert_eq!(s.mode, Mode::Static); + assert_eq!(s.cidr.as_deref(), Some("192.168.0.252/24")); + let s = decide_status("eth0", true, false, &cfg); + assert_eq!(s.mode, Mode::Client); + } + + #[test] + fn apply_foreign_dhcp_is_client() { + let cfg = Config::default(); + let net = FakeNet::new(false); + let gw = FakeGw::new(true); + let s = apply_with(&cfg, ProbePolicy::Full, &net, &gw).unwrap(); + assert_eq!(s.mode, Mode::Client); + assert!(s.foreign_dhcp); + assert!(!*gw.started.lock().unwrap()); + assert!(*net.dhclient.lock().unwrap()); + } + + #[test] + fn apply_ping_ok_is_static_252() { + let cfg = Config::default(); + let net = FakeNet::new(true); + let gw = FakeGw::new(false); + let s = apply_with(&cfg, ProbePolicy::Full, &net, &gw).unwrap(); + assert_eq!(s.mode, Mode::Static); + assert_eq!(s.cidr.as_deref(), Some("192.168.0.252/24")); + assert_eq!(*net.default_via.lock().unwrap(), Some(cfg.gateway.ip)); + assert!(!*gw.started.lock().unwrap()); + } + + #[test] + fn apply_no_offer_no_ping_is_gateway() { + let cfg = Config::default(); + let net = FakeNet::new(false); + let gw = FakeGw::new(false); + let s = apply_with(&cfg, ProbePolicy::Full, &net, &gw).unwrap(); + assert_eq!(s.mode, Mode::Gateway); + assert_eq!(s.cidr.as_deref(), Some("192.168.0.1/24")); + assert!(*gw.started.lock().unwrap()); + assert!(net.default_via.lock().unwrap().is_none()); + } +} diff --git a/crates/micronet/src/config/mod.rs b/crates/micronet/src/config/mod.rs new file mode 100644 index 0000000..2a32b64 --- /dev/null +++ b/crates/micronet/src/config/mod.rs @@ -0,0 +1,334 @@ +//! `$DATA_DIR/etc/micronet.json` — camelCase, no hardcoded `/data` paths. + +use std::fs; +use std::net::Ipv4Addr; +use std::path::{Path, PathBuf}; + +use ipnet::Ipv4Net; +use serde::{Deserialize, Serialize}; + +use crate::constants::{ + DEFAULT_PROBE_TIMEOUT_SECS, DEFAULT_RANGE_END, DEFAULT_RANGE_START, DEFAULT_STATIC_HOST, + DEFAULT_STICKY, REQUIRED_PREFIX, +}; +use crate::datadir; +use crate::error::{Error, Result}; + +pub mod watch; + +/// Default config path under the data root. +#[must_use] +pub fn default_config_path() -> PathBuf { + datadir::path(["etc", "micronet.json"]) +} + +/// Default control socket under the data root. +#[must_use] +pub fn default_socket_path() -> PathBuf { + datadir::path(["run", "micronet.sock"]) +} + +#[must_use] +pub fn default_dnsmasq_conf_path() -> PathBuf { + datadir::path(["etc", "dnsmasq.conf"]) +} + +#[must_use] +pub fn default_dnsmasq_leasefile() -> PathBuf { + datadir::path(["etc", "dnsmasq.leases"]) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GatewayConfig { + #[serde(default = "default_gateway_ip")] + pub ip: Ipv4Addr, + #[serde(default = "default_subnet")] + pub subnet: Ipv4Net, + #[serde(default = "default_static_host")] + pub static_host: u8, +} + +fn default_gateway_ip() -> Ipv4Addr { + Ipv4Addr::new(192, 168, 0, 1) +} + +fn default_subnet() -> Ipv4Net { + Ipv4Net::new(Ipv4Addr::new(192, 168, 0, 0), REQUIRED_PREFIX).unwrap_or_else(|_| { + Ipv4Net::new(Ipv4Addr::UNSPECIFIED, 32).unwrap_or_else(|_| Ipv4Net::default()) + }) +} + +fn default_static_host() -> u8 { + DEFAULT_STATIC_HOST +} + +impl Default for GatewayConfig { + fn default() -> Self { + Self { + ip: default_gateway_ip(), + subnet: default_subnet(), + static_host: default_static_host(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DhcpConfig { + #[serde(default = "default_range_start")] + pub range_start: u8, + #[serde(default = "default_range_end")] + pub range_end: u8, + /// dnsmasq lease duration (`7d`, `72h`, `3600`). Sticky MAC→IP window. + #[serde(default = "default_sticky")] + pub sticky: String, +} + +fn default_range_start() -> u8 { + DEFAULT_RANGE_START +} + +fn default_range_end() -> u8 { + DEFAULT_RANGE_END +} + +fn default_sticky() -> String { + DEFAULT_STICKY.to_string() +} + +impl Default for DhcpConfig { + fn default() -> Self { + Self { + range_start: default_range_start(), + range_end: default_range_end(), + sticky: default_sticky(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Config { + /// Physical Ethernet name. `null` / omitted → first physical Ethernet. + #[serde(default)] + pub interface: Option, + #[serde(default)] + pub gateway: GatewayConfig, + #[serde(default)] + pub dhcp: DhcpConfig, + #[serde(default = "default_probe_timeout")] + pub probe_timeout_secs: u64, +} + +fn default_probe_timeout() -> u64 { + DEFAULT_PROBE_TIMEOUT_SECS +} + +impl Default for Config { + fn default() -> Self { + Self { + interface: None, + gateway: GatewayConfig::default(), + dhcp: DhcpConfig::default(), + probe_timeout_secs: default_probe_timeout(), + } + } +} + +impl Config { + /// Validate operator input. Returns `Err` — never `debug_assert` here. + pub fn validate(&self) -> Result<()> { + if self.gateway.subnet.prefix_len() != REQUIRED_PREFIX { + return Err(Error::Config(format!( + "gateway.subnet must be /{REQUIRED_PREFIX}" + ))); + } + if !self.gateway.subnet.contains(&self.gateway.ip) { + return Err(Error::Config("gateway.ip is not in gateway.subnet".into())); + } + let gw_host = self.gateway.ip.octets()[3]; + if self.gateway.static_host == gw_host { + return Err(Error::Config( + "gateway.staticHost must differ from gateway.ip host".into(), + )); + } + if self.dhcp.range_start >= self.dhcp.range_end { + return Err(Error::Config("dhcp.rangeStart must be < rangeEnd".into())); + } + if self.gateway.static_host >= self.dhcp.range_start + && self.gateway.static_host <= self.dhcp.range_end + { + return Err(Error::Config( + "gateway.staticHost must be outside dhcp range".into(), + )); + } + if gw_host >= self.dhcp.range_start && gw_host <= self.dhcp.range_end { + return Err(Error::Config( + "gateway.ip host must be outside dhcp range".into(), + )); + } + parse_sticky(&self.dhcp.sticky)?; + if self.probe_timeout_secs == 0 { + return Err(Error::Config("probeTimeoutSecs must be > 0".into())); + } + if let Some(name) = &self.interface { + if name.is_empty() { + return Err(Error::Config("interface must not be empty".into())); + } + } + Ok(()) + } + + /// Host address `.N` in `gateway.subnet` (/24). + pub fn host_addr(&self, host: u8) -> Ipv4Addr { + debug_assert_eq!(self.gateway.subnet.prefix_len(), REQUIRED_PREFIX); + let o = self.gateway.subnet.network().octets(); + Ipv4Addr::new(o[0], o[1], o[2], host) + } + + #[must_use] + pub fn static_addr(&self) -> Ipv4Addr { + self.host_addr(self.gateway.static_host) + } + + #[must_use] + pub fn range_start_addr(&self) -> Ipv4Addr { + self.host_addr(self.dhcp.range_start) + } + + #[must_use] + pub fn range_end_addr(&self) -> Ipv4Addr { + self.host_addr(self.dhcp.range_end) + } + + #[must_use] + pub fn gateway_cidr(&self) -> String { + format!("{}/{}", self.gateway.ip, REQUIRED_PREFIX) + } + + #[must_use] + pub fn static_cidr(&self) -> String { + format!("{}/{}", self.static_addr(), REQUIRED_PREFIX) + } +} + +/// Parse a dnsmasq duration (`7d`, `72h`, `45s`, bare seconds). Must be > 0. +pub fn parse_sticky(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err(Error::Config("dhcp.sticky must not be empty".into())); + } + let (num, mul) = if let Some(rest) = s.strip_suffix('s') { + (rest, 1_u64) + } else if let Some(rest) = s.strip_suffix('m') { + (rest, 60) + } else if let Some(rest) = s.strip_suffix('h') { + (rest, 3600) + } else if let Some(rest) = s.strip_suffix('d') { + (rest, 86_400) + } else if let Some(rest) = s.strip_suffix('w') { + (rest, 604_800) + } else { + (s, 1) + }; + let n: u64 = num + .parse() + .map_err(|_| Error::Config(format!("dhcp.sticky {s:?} is not a duration")))?; + if n == 0 { + return Err(Error::Config("dhcp.sticky must be > 0".into())); + } + n.checked_mul(mul) + .ok_or_else(|| Error::Config("dhcp.sticky overflow".into())) +} + +/// Load JSON; missing file → defaults written as an example (no `socket` field). +pub fn load_or_create(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(text) => { + let cfg: Config = serde_json::from_str(&text)?; + cfg.validate()?; + Ok(cfg) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let cfg = Config::default(); + cfg.validate()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| Error::io_at(parent, err))?; + } + let body = serde_json::to_string_pretty(&cfg)?; + fs::write(path, body + "\n").map_err(|err| Error::io_at(path, err))?; + Ok(cfg) + } + Err(e) => Err(Error::io_at(path, e)), + } +} + +/// Load existing JSON; missing → defaults in memory (do not write). +pub fn load(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(text) => { + let cfg: Config = serde_json::from_str(&text)?; + cfg.validate()?; + Ok(cfg) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let cfg = Config::default(); + cfg.validate()?; + Ok(cfg) + } + Err(e) => Err(Error::io_at(path, e)), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + use tempfile::tempdir; + + #[test] + fn defaults_validate() { + Config::default().validate().unwrap(); + } + + #[test] + fn sticky_parses() { + assert_eq!(parse_sticky("7d").unwrap(), 7 * 86_400); + assert_eq!(parse_sticky("72h").unwrap(), 72 * 3600); + assert_eq!(parse_sticky("3600").unwrap(), 3600); + assert!(parse_sticky("0").is_err()); + assert!(parse_sticky("nope").is_err()); + } + + #[test] + fn static_host_in_pool_rejected() { + let mut c = Config::default(); + c.gateway.static_host = 100; + assert!(c.validate().is_err()); + } + + #[test] + fn load_or_create_writes_without_socket() { + let dir = tempdir().unwrap(); + let path = dir.path().join("micronet.json"); + let cfg = load_or_create(&path).unwrap(); + assert_eq!(cfg.gateway.ip, Ipv4Addr::new(192, 168, 0, 1)); + let body = fs::read_to_string(&path).unwrap(); + assert!(!body.contains("socket")); + assert!(body.contains("staticHost")); + } + + #[test] + fn json_camel_case() { + let text = r#"{ + "gateway": {"ip": "10.0.10.1", "subnet": "10.0.10.0/24", "staticHost": 252}, + "dhcp": {"rangeStart": 50, "rangeEnd": 200, "sticky": "7d"} + }"#; + let cfg: Config = serde_json::from_str(text).unwrap(); + cfg.validate().unwrap(); + assert_eq!(cfg.gateway.ip, Ipv4Addr::new(10, 0, 10, 1)); + assert_eq!(cfg.static_addr(), Ipv4Addr::new(10, 0, 10, 252)); + } +} diff --git a/crates/micronet/src/config/watch.rs b/crates/micronet/src/config/watch.rs new file mode 100644 index 0000000..9b2c79f --- /dev/null +++ b/crates/micronet/src/config/watch.rs @@ -0,0 +1,171 @@ +//! Linux inotify-based configuration watcher (no polling). + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; + +use crate::constants::CONFIG_DEBOUNCE; +use crate::error::{Error, Result}; + +/// Signal that the configuration file may have changed. +pub struct ReloadSignal; + +/// Filter path events relevant to the watched config basename. +#[must_use] +pub fn is_relevant_path(path: &Path, config_name: &str) -> bool { + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + return false; + }; + if name.starts_with('.') { + return false; + } + if name.ends_with('~') || name.ends_with(".swp") || name.ends_with(".tmp") { + return false; + } + name == config_name +} + +/// Spawn an inotify watcher thread. Returns a receiver of debounce-coalesced reload signals. +pub fn spawn(config_path: PathBuf) -> Result<(Receiver, Arc)> { + let (tx, rx) = mpsc::channel(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thr = Arc::clone(&stop); + + thread::Builder::new() + .name("config-watch".into()) + .spawn(move || { + if let Err(e) = watch_loop(config_path, tx, stop_thr) { + log::warn!("config watcher stopped: {e}"); + } + }) + .map_err(|e| Error::Other(e.to_string()))?; + + Ok((rx, stop)) +} + +fn watch_loop( + config_path: PathBuf, + reload_tx: Sender, + stop: Arc, +) -> Result<()> { + let config_name = config_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("micronet.json") + .to_string(); + + let watch_dir = config_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + + let (raw_tx, raw_rx) = mpsc::channel(); + + let mut watcher = RecommendedWatcher::new( + move |res: std::result::Result| { + let _ = raw_tx.send(res); + }, + notify::Config::default(), + ) + .map_err(|e| Error::Other(format!("inotify watcher: {e}")))?; + + if !watch_dir.is_dir() { + let _ = fs_create(&watch_dir); + } + if watch_dir.is_dir() { + watcher + .watch(&watch_dir, RecursiveMode::NonRecursive) + .map_err(|e| Error::Other(format!("watch {}: {e}", watch_dir.display())))?; + } else if let Some(parent) = watch_dir.parent() { + if parent.is_dir() { + let _ = watcher.watch(parent, RecursiveMode::NonRecursive); + } + } + + log::info!("config watch active on {}", watch_dir.display()); + + let mut pending: Option = None; + + loop { + if stop.load(Ordering::SeqCst) { + break; + } + + let timeout = pending + .map(|t| { + let elapsed = t.elapsed(); + if elapsed >= CONFIG_DEBOUNCE { + Duration::from_millis(0) + } else { + CONFIG_DEBOUNCE.saturating_sub(elapsed) + } + }) + .unwrap_or(Duration::from_secs(1)); + + match raw_rx.recv_timeout(timeout) { + Ok(Ok(event)) => { + let relevant = matches!( + event.kind, + EventKind::Create(_) + | EventKind::Modify(_) + | EventKind::Remove(_) + | EventKind::Any + ) && event + .paths + .iter() + .any(|p| is_relevant_path(p, &config_name)); + + if relevant { + pending = Some(Instant::now()); + } + } + Ok(Err(e)) => { + log::warn!("config watch error: {e}"); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + if pending.is_some_and(|t| t.elapsed() >= CONFIG_DEBOUNCE) { + pending = None; + let _ = reload_tx.send(ReloadSignal); + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + Ok(()) +} + +fn fs_create(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::is_relevant_path; + + #[test] + fn relevant_filters() { + assert!(is_relevant_path( + Path::new("/data/etc/micronet.json"), + "micronet.json" + )); + assert!(!is_relevant_path( + Path::new("/data/etc/.micronet.json"), + "micronet.json" + )); + assert!(!is_relevant_path( + Path::new("/data/etc/micronet.json~"), + "micronet.json" + )); + assert!(!is_relevant_path( + Path::new("/data/etc/other.json"), + "micronet.json" + )); + } +} diff --git a/crates/micronet/src/constants.rs b/crates/micronet/src/constants.rs new file mode 100644 index 0000000..74b796d --- /dev/null +++ b/crates/micronet/src/constants.rs @@ -0,0 +1,31 @@ +//! Named bounds and well-known paths (CODING-GUIDELINES §1.3). + +use std::time::Duration; + +/// Maximum IPC JSON payload (bytes). +pub const MAX_IPC_FRAME_BYTES: usize = 1024 * 1024; +/// Concurrent Unix-socket clients. +pub const MAX_IPC_CLIENTS: usize = 32; +/// Config inotify debounce. +pub const CONFIG_DEBOUNCE: Duration = Duration::from_millis(300); +/// Default DHCPDISCOVER wait. +pub const DEFAULT_PROBE_TIMEOUT_SECS: u64 = 5; +/// ICMP: one echo, 2 s wait (same as former configure-ethernet). +pub const PING_COUNT: &str = "1"; +pub const PING_TIMEOUT_SEC: &str = "2"; +/// Wait for dhclient to assign an address. +pub const DHCP_CLIENT_WAIT: Duration = Duration::from_secs(5); + +pub const IP_BIN: &str = "/sbin/ip"; +pub const DHCLIENT_BIN: &str = "/sbin/dhclient"; +pub const PING_BIN: &str = "/bin/ping"; +pub const DNSMASQ_BIN: &str = "/usr/sbin/dnsmasq"; + +pub const DEFAULT_STICKY: &str = "7d"; +pub const DEFAULT_RANGE_START: u8 = 50; +pub const DEFAULT_RANGE_END: u8 = 200; +pub const DEFAULT_STATIC_HOST: u8 = 252; +pub const REQUIRED_PREFIX: u8 = 24; + +/// Linux `ARPHRD_ETHER`. +pub const ARPHRD_ETHER: u16 = 1; diff --git a/crates/micronet/src/daemon/mod.rs b/crates/micronet/src/daemon/mod.rs new file mode 100644 index 0000000..1630ab1 --- /dev/null +++ b/crates/micronet/src/daemon/mod.rs @@ -0,0 +1,161 @@ +//! Daemon loop: apply, Unix socket, inotify config reload. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use crate::apply::{self, ProbePolicy, Status}; +use crate::config; +use crate::error::{Error, Result}; +use crate::ipc::{self, IpcEvent, Shared}; +use crate::signals; + +/// Run until SIGTERM/SIGINT. +pub fn run(config_path: &Path, socket: &Path) -> Result<()> { + let cfg = config::load_or_create(config_path)?; + log::info!("config {}", config_path.display()); + + let status = match apply::apply(&cfg, ProbePolicy::Full) { + Ok(s) => { + log::info!( + "mode {} iface {} cidr {:?}", + s.mode.as_str(), + s.iface, + s.cidr + ); + s + } + Err(e) => { + log::error!("initial apply failed: {e}"); + Status::empty() + } + }; + + let shared = Shared::new(cfg, status); + let (ev_tx, ev_rx) = mpsc::channel(); + ipc::serve(socket, Arc::clone(&shared), ev_tx)?; + + let (reload_rx, watch_stop) = config::watch::spawn(config_path.to_path_buf())?; + let stop = Arc::new(AtomicBool::new(false)); + signals::install(&stop)?; + + let config_path = config_path.to_path_buf(); + loop { + if stop.load(Ordering::SeqCst) { + break; + } + let mut event = None; + match ev_rx.recv_timeout(Duration::from_millis(200)) { + Ok(e) => event = Some(e), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + if reload_rx.try_recv().is_ok() { + on_config_reload(&shared, &config_path); + } + if event == Some(IpcEvent::Reconfigure) { + on_reconfigure(&shared); + } + thread::sleep(Duration::from_millis(0)); + } + + watch_stop.store(true, Ordering::SeqCst); + let _ = std::fs::remove_file(socket); + log::info!("micronet stopped"); + Ok(()) +} + +fn on_config_reload(shared: &Shared, path: &Path) { + match config::load(path) { + Ok(new_cfg) => { + let prev_mode = shared + .status + .read() + .map(|s| s.mode) + .unwrap_or(apply::Mode::Gateway); + { + match shared.config.write() { + Ok(mut g) => *g = new_cfg.clone(), + Err(_) => { + log::warn!("config lock poisoned; keeping previous"); + return; + } + } + } + let policy = if prev_mode == apply::Mode::Gateway { + ProbePolicy::SkipDhcpWhileGateway + } else { + ProbePolicy::Full + }; + match apply::apply(&new_cfg, policy) { + Ok(s) => { + if let Ok(mut st) = shared.status.write() { + *st = s; + } + log::info!("config reloaded"); + } + Err(e) => log::warn!("apply after reload failed: {e}"), + } + } + Err(e) => { + log::warn!("invalid config {}, keeping previous: {e}", path.display()); + } + } +} + +fn on_reconfigure(shared: &Shared) { + let cfg = match shared.config.read() { + Ok(c) => c.clone(), + Err(_) => { + log::warn!("config lock poisoned"); + return; + } + }; + match apply::apply(&cfg, ProbePolicy::Full) { + Ok(s) => { + log::info!("reconfigure → {}", s.mode.as_str()); + if let Ok(mut st) = shared.status.write() { + *st = s; + } + } + Err(e) => log::warn!("reconfigure failed: {e}"), + } +} + +/// One-shot apply (CLI `apply` / argv0 aliases). +pub fn apply_once(config_path: &Path) -> Result { + let cfg = config::load(config_path)?; + apply::apply(&cfg, ProbePolicy::Full) +} + +/// Resolve `--socket`: relative joined under data root; absolute kept. +#[must_use] +pub fn resolve_socket(cli: Option<&PathBuf>) -> PathBuf { + match cli { + None => ipc::default_socket(), + Some(p) if p.is_absolute() => p.clone(), + Some(p) => crate::datadir::root().join(p), + } +} + +/// Resolve `--config`: relative joined under data root. +#[must_use] +pub fn resolve_config(cli: Option<&PathBuf>) -> PathBuf { + match cli { + None => config::default_config_path(), + Some(p) if p.is_absolute() => p.clone(), + Some(p) => crate::datadir::root().join(p), + } +} + +pub fn check_liveness(socket: &Path) -> Result { + match ipc::call(socket, &ipc::Request::Status) { + Ok(ipc::Response::Status { cidr, iface, .. }) => Ok(cidr.is_some() && !iface.is_empty()), + Ok(_) => Ok(false), + Err(Error::IoPath { .. }) | Err(Error::Io(_)) => Ok(false), + Err(e) => Err(e), + } +} diff --git a/crates/micronet/src/datadir.rs b/crates/micronet/src/datadir.rs new file mode 100644 index 0000000..a79feac --- /dev/null +++ b/crates/micronet/src/datadir.rs @@ -0,0 +1,52 @@ +//! Persistent data root: `DATA_DIR` (absolute) then `/data`. + +use std::path::{Path, PathBuf}; + +/// Env var for the persistent data root. +pub const ENV_DATA_DIR: &str = "DATA_DIR"; +/// Hub image default when `DATA_DIR` is unset. +pub const DEFAULT_ROOT: &str = "/data"; + +/// Returns the persistent data directory. +#[must_use] +pub fn root() -> PathBuf { + if let Some(v) = root_from_env(ENV_DATA_DIR) { + return v; + } + PathBuf::from(DEFAULT_ROOT) +} + +fn root_from_env(name: &str) -> Option { + let v = std::env::var_os(name)?; + if v.is_empty() { + return None; + } + let p = PathBuf::from(v); + if p.is_absolute() { + Some(p) + } else { + None + } +} + +/// Override `DATA_DIR` for this process (absolute paths only). +pub fn set_root(path: impl AsRef) { + let p = path.as_ref(); + if p.is_absolute() { + std::env::set_var(ENV_DATA_DIR, p.as_os_str()); + } +} + +/// Join `parts` under [`root`]. +#[must_use] +pub fn path(parts: I) -> PathBuf +where + I: IntoIterator, + P: AsRef, +{ + let mut out = root(); + for part in parts { + out.push(part); + } + out +} diff --git a/crates/micronet/src/dhcp/conf.rs b/crates/micronet/src/dhcp/conf.rs new file mode 100644 index 0000000..bc43c6b --- /dev/null +++ b/crates/micronet/src/dhcp/conf.rs @@ -0,0 +1,78 @@ +//! Render `$DATA_DIR/etc/dnsmasq.conf` (no Omada reservations). + +use std::fs; +use std::path::Path; + +use crate::config::Config; +use crate::error::{Error, Result}; + +/// Render a gateway-mode dnsmasq config. +#[must_use] +pub fn render_conf(cfg: &Config, iface: &str, leasefile: &Path) -> String { + let mask = cfg.gateway.subnet.netmask(); + let start = cfg.range_start_addr(); + let end = cfg.range_end_addr(); + let gw = cfg.gateway.ip; + let sticky = cfg.dhcp.sticky.trim(); + format!( + "\ +# generated by micronet — do not edit +interface={iface} +bind-interfaces +listen-address={gw} +port=0 +dhcp-range={start},{end},{mask},{sticky} +dhcp-option=option:router,{gw} +dhcp-option=option:dns-server,{gw} +dhcp-leasefile={lease} +dhcp-authoritative +", + lease = leasefile.display(), + ) +} + +/// Write conf if contents changed. Returns true when the file was rewritten. +pub fn ensure_conf(path: &Path, body: &str) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; + } + let prev = fs::read_to_string(path).unwrap_or_default(); + if prev == body { + return Ok(false); + } + fs::write(path, body).map_err(|e| Error::io_at(path, e))?; + Ok(true) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::config::Config; + use std::net::Ipv4Addr; + use std::path::PathBuf; + + #[test] + fn render_contains_sticky_7d() { + let cfg = Config::default(); + let body = render_conf(&cfg, "eth0", Path::new("/data/etc/dnsmasq.leases")); + assert!(body.contains("7d")); + assert!(body.contains("dhcp-authoritative")); + assert!(body.contains("option:router,192.168.0.1")); + assert!(body.contains("dhcp-range=192.168.0.50,192.168.0.200")); + assert!(!body.contains("dhcp-host=")); + } + + #[test] + fn render_event_subnet() { + let mut cfg = Config::default(); + cfg.gateway.ip = Ipv4Addr::new(10, 0, 10, 1); + cfg.gateway.subnet = "10.0.10.0/24".parse().unwrap(); + cfg.dhcp.sticky = "7d".into(); + let body = render_conf(&cfg, "eth0", &PathBuf::from("/tmp/leases")); + assert!(body.contains("listen-address=10.0.10.1")); + assert!(body.contains("10.0.10.50,10.0.10.200")); + assert!(body.contains(",7d")); + } +} diff --git a/crates/micronet/src/dhcp/mod.rs b/crates/micronet/src/dhcp/mod.rs new file mode 100644 index 0000000..6cc10bb --- /dev/null +++ b/crates/micronet/src/dhcp/mod.rs @@ -0,0 +1,7 @@ +//! dnsmasq DHCP server (gateway mode only). + +pub mod conf; +pub mod run; + +pub use conf::render_conf; +pub use run::{is_running, reload_or_restart, start, stop}; diff --git a/crates/micronet/src/dhcp/run.rs b/crates/micronet/src/dhcp/run.rs new file mode 100644 index 0000000..9bec931 --- /dev/null +++ b/crates/micronet/src/dhcp/run.rs @@ -0,0 +1,139 @@ +//! Start / SIGHUP / restart / stop dnsmasq. + +use std::fs; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; + +use nix::sys::signal::{kill, Signal}; +use nix::unistd::Pid; + +use crate::constants::DNSMASQ_BIN; +use crate::error::{Error, Result}; + +/// Fields in the main conf that SIGHUP does not re-read (dnsmasq man). +#[must_use] +pub fn restart_required(old: &str, new: &str) -> bool { + if old == new { + return false; + } + true +} + +#[must_use] +pub fn is_running() -> bool { + !dnsmasq_pids().is_empty() +} + +/// Start `dnsmasq -C conf` if not already running. +pub fn start(conf: &Path) -> Result<()> { + if !Path::new(DNSMASQ_BIN).is_file() { + return Err(Error::DnsmasqMissing(Path::new(DNSMASQ_BIN).to_path_buf())); + } + if is_running() { + return Ok(()); + } + let status = Command::new(DNSMASQ_BIN) + .args(["-C", &conf.to_string_lossy()]) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|e| Error::Other(format!("dnsmasq: {e}")))?; + if status.success() { + Ok(()) + } else { + Err(Error::Other(format!( + "dnsmasq exited {}", + status.code().unwrap_or(-1) + ))) + } +} + +/// Stop all dnsmasq processes (TERM, then KILL). +pub fn stop() -> Result<()> { + let pids = dnsmasq_pids(); + if pids.is_empty() { + return Ok(()); + } + for pid in &pids { + let _ = kill(Pid::from_raw(*pid), Signal::SIGTERM); + } + thread::sleep(Duration::from_millis(400)); + for pid in dnsmasq_pids() { + let _ = kill(Pid::from_raw(pid), Signal::SIGKILL); + } + Ok(()) +} + +/// Reload via SIGHUP; restart when conf changes require it or reload failed. +pub fn reload_or_restart(conf: &Path, conf_changed: bool) -> Result<()> { + if !is_running() { + return start(conf); + } + if conf_changed { + log::info!("dnsmasq conf changed (dhcp-range/listen-address) — restart"); + stop()?; + return start(conf); + } + match sighup() { + Ok(()) => { + thread::sleep(Duration::from_millis(150)); + if is_running() { + Ok(()) + } else { + log::warn!("dnsmasq vanished after SIGHUP — start"); + start(conf) + } + } + Err(e) => { + log::warn!("dnsmasq SIGHUP failed ({e}) — restart"); + stop()?; + start(conf) + } + } +} + +fn sighup() -> Result<()> { + let pids = dnsmasq_pids(); + if pids.is_empty() { + return Err(Error::Other("dnsmasq not running".into())); + } + for pid in pids { + kill(Pid::from_raw(pid), Signal::SIGHUP).map_err(Error::from)?; + } + Ok(()) +} + +fn dnsmasq_pids() -> Vec { + let Ok(entries) = fs::read_dir("/proc") else { + return Vec::new(); + }; + let mut pids = Vec::new(); + for ent in entries.flatten() { + let name = ent.file_name(); + let Some(pid) = name.to_str().and_then(|s| s.parse::().ok()) else { + continue; + }; + let cmdline = fs::read(ent.path().join("cmdline")).unwrap_or_default(); + let text = String::from_utf8_lossy(&cmdline); + if text + .split('\0') + .any(|p| p == DNSMASQ_BIN || p.ends_with("/dnsmasq")) + { + pids.push(pid); + } + } + pids +} + +#[cfg(test)] +mod tests { + use super::restart_required; + + #[test] + fn restart_when_conf_differs() { + assert!(restart_required("a", "b")); + assert!(!restart_required("same", "same")); + } +} diff --git a/crates/configure-dhcp/src/error.rs b/crates/micronet/src/error.rs similarity index 75% rename from crates/configure-dhcp/src/error.rs rename to crates/micronet/src/error.rs index c7788c2..cfa16aa 100644 --- a/crates/configure-dhcp/src/error.rs +++ b/crates/micronet/src/error.rs @@ -1,4 +1,4 @@ -//! Error types for configure-dhcp. +//! Typed errors for micronet. use std::path::{Path, PathBuf}; @@ -22,14 +22,23 @@ pub enum Error { #[error("JSON error: {0}")] Json(#[from] serde_json::Error), - #[error("dnsmasq binary not found at {0}")] - DnsmasqMissing(PathBuf), + #[error("nix error: {0}")] + Nix(#[from] nix::Error), + + #[error("config: {0}")] + Config(String), + + #[error("IPC error: {0}")] + Ipc(String), - #[error("no ethernet interface found")] + #[error("no physical Ethernet interface found")] NoEthernet, - #[error("nix error: {0}")] - Nix(#[from] nix::Error), + #[error("interface {0} is not a physical Ethernet device")] + NotEthernet(String), + + #[error("dnsmasq binary not found at {0}")] + DnsmasqMissing(PathBuf), #[error("{0}")] Other(String), diff --git a/crates/micronet/src/ipc/mod.rs b/crates/micronet/src/ipc/mod.rs new file mode 100644 index 0000000..346cc50 --- /dev/null +++ b/crates/micronet/src/ipc/mod.rs @@ -0,0 +1,238 @@ +//! Unix control socket: 4-byte LE length + JSON. + +use std::io::{Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, RwLock}; +use std::thread; + +use crate::apply::Status; +use crate::config::Config; +use crate::constants::{MAX_IPC_CLIENTS, MAX_IPC_FRAME_BYTES}; +use crate::datadir; +use crate::error::{Error, Result}; +use crate::version; + +pub mod protocol; +pub use protocol::{Request, Response}; + +/// Default `$DATA_DIR/run/micronet.sock`. +#[must_use] +pub fn default_socket() -> PathBuf { + datadir::path(["run", "micronet.sock"]) +} + +/// Shared daemon snapshot for IPC. +pub struct Shared { + pub config: RwLock, + pub status: RwLock, +} + +impl Shared { + #[must_use] + pub fn new(config: Config, status: Status) -> Arc { + Arc::new(Self { + config: RwLock::new(config), + status: RwLock::new(status), + }) + } +} + +/// Events the daemon loop must handle (from IPC). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IpcEvent { + Reconfigure, +} + +pub fn write_frame_to(writer: &mut impl Write, msg: &impl serde::Serialize) -> Result<()> { + let payload = serde_json::to_vec(msg)?; + if payload.len() > MAX_IPC_FRAME_BYTES { + return Err(Error::Ipc(format!( + "frame length {} exceeds max {MAX_IPC_FRAME_BYTES}", + payload.len() + ))); + } + let len = u32::try_from(payload.len()) + .map_err(|_| Error::Ipc("frame too large for u32 length prefix".into()))? + .to_le_bytes(); + writer.write_all(&len)?; + writer.write_all(&payload)?; + writer.flush()?; + Ok(()) +} + +pub fn read_frame_from(reader: &mut impl Read) -> Result> { + let mut len_buf = [0u8; 4]; + reader.read_exact(&mut len_buf)?; + let len = u32::from_le_bytes(len_buf) as usize; + if len > MAX_IPC_FRAME_BYTES { + return Err(Error::Ipc(format!("frame length {len} too large"))); + } + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf)?; + Ok(buf) +} + +fn bind_singleton(socket_path: &Path) -> Result { + match UnixStream::connect(socket_path) { + Ok(stream) => { + let pid = peer_pid(&stream); + let where_ = if pid != 0 { + format!("{} (pid {pid})", socket_path.display()) + } else { + socket_path.display().to_string() + }; + return Err(Error::Ipc(format!("micronet already running at {where_}"))); + } + Err(e) if is_stale_socket_connect_error(&e) => {} + Err(e) => return Err(Error::io_at(socket_path, e)), + } + match std::fs::remove_file(socket_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(Error::io_at(socket_path, e)), + } + UnixListener::bind(socket_path).map_err(|e| Error::io_at(socket_path, e)) +} + +fn is_stale_socket_connect_error(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) +} + +fn peer_pid(stream: &UnixStream) -> u32 { + use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; + getsockopt(stream, PeerCredentials) + .map(|c| c.pid() as u32) + .unwrap_or(0) +} + +fn apply_socket_perms(socket_path: &Path) -> Result<()> { + let mut perms = std::fs::metadata(socket_path) + .map_err(|e| Error::io_at(socket_path, e))? + .permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(socket_path, perms).map_err(|e| Error::io_at(socket_path, e))?; + Ok(()) +} + +/// Bind the control socket and serve requests in a background thread. +pub fn serve(path: &Path, shared: Arc, events: Sender) -> Result<()> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; + } + } + let listener = bind_singleton(path)?; + apply_socket_perms(path)?; + log::info!("ctl listening on {}", path.display()); + + let path = path.to_path_buf(); + let clients = Arc::new(AtomicUsize::new(0)); + thread::Builder::new() + .name("ctl".into()) + .spawn(move || { + for conn in listener.incoming() { + match conn { + Ok(stream) => { + let n = clients.load(Ordering::SeqCst); + if n >= MAX_IPC_CLIENTS { + log::warn!("ipc client limit {MAX_IPC_CLIENTS} reached"); + drop(stream); + continue; + } + clients.fetch_add(1, Ordering::SeqCst); + let shared = Arc::clone(&shared); + let events = events.clone(); + let clients = Arc::clone(&clients); + thread::spawn(move || { + handle_conn(stream, &shared, &events); + clients.fetch_sub(1, Ordering::SeqCst); + }); + } + Err(_) => { + if !path.exists() { + break; + } + } + } + } + }) + .map_err(|e| Error::Other(e.to_string()))?; + Ok(()) +} + +fn handle_conn(mut stream: UnixStream, shared: &Shared, events: &Sender) { + let payload = match read_frame_from(&mut stream) { + Ok(p) => p, + Err(e) => { + log::debug!("ipc read: {e}"); + return; + } + }; + let req: Request = match serde_json::from_slice(&payload) { + Ok(r) => r, + Err(e) => { + let _ = write_frame_to( + &mut stream, + &Response::Error { + message: e.to_string(), + }, + ); + return; + } + }; + let resp = match req { + Request::Status => match shared.status.read() { + Ok(s) => Response::from_status(&s), + Err(_) => Response::Error { + message: "status lock poisoned".into(), + }, + }, + Request::Info => Response::from_info(&version::info()), + Request::Reconfigure => { + let _ = events.send(IpcEvent::Reconfigure); + Response::Ok + } + }; + let _ = write_frame_to(&mut stream, &resp); +} + +/// Client: send one request, read one response. +pub fn call(socket: &Path, req: &Request) -> Result { + let mut stream = UnixStream::connect(socket).map_err(|e| Error::io_at(socket, e))?; + write_frame_to(&mut stream, req)?; + let payload = read_frame_from(&mut stream)?; + Ok(serde_json::from_slice(&payload)?) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use std::io::Cursor; + + #[test] + fn frame_roundtrip() { + let mut buf = Vec::new(); + write_frame_to(&mut buf, &Request::Status).unwrap(); + let mut cur = Cursor::new(buf); + let payload = read_frame_from(&mut cur).unwrap(); + let req: Request = serde_json::from_slice(&payload).unwrap(); + assert_eq!(req, Request::Status); + } + + #[test] + fn oversized_frame_rejected() { + let mut too_big = (MAX_IPC_FRAME_BYTES as u32 + 1).to_le_bytes().to_vec(); + too_big.extend_from_slice(&[0u8; 8]); + let mut cur = Cursor::new(too_big); + assert!(read_frame_from(&mut cur).is_err()); + } +} diff --git a/crates/micronet/src/ipc/protocol.rs b/crates/micronet/src/ipc/protocol.rs new file mode 100644 index 0000000..eb73474 --- /dev/null +++ b/crates/micronet/src/ipc/protocol.rs @@ -0,0 +1,82 @@ +//! IPC request/response types (camelCase JSON, `type` discriminator). + +use serde::{Deserialize, Serialize}; + +use crate::apply::{Mode, Status}; +use crate::version::Info; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Request { + Status, + Info, + Reconfigure, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Response { + #[serde(rename_all = "camelCase")] + Status { + mode: Mode, + iface: String, + cidr: Option, + foreign_dhcp: bool, + gateway_reachable: bool, + dnsmasq_running: bool, + }, + Info { + version: String, + build_commit: String, + #[serde(skip_serializing_if = "String::is_empty")] + tag_commit: String, + #[serde(skip_serializing_if = "String::is_empty")] + build_time: String, + hostname: String, + }, + Ok, + Error { + message: String, + }, +} + +impl Response { + #[must_use] + pub fn from_status(s: &Status) -> Self { + Self::Status { + mode: s.mode, + iface: s.iface.clone(), + cidr: s.cidr.clone(), + foreign_dhcp: s.foreign_dhcp, + gateway_reachable: s.gateway_reachable, + dnsmasq_running: s.dnsmasq_running, + } + } + + #[must_use] + pub fn from_info(info: &Info) -> Self { + Self::Info { + version: info.version.clone(), + build_commit: info.build_commit.clone(), + tag_commit: info.tag_commit.clone(), + build_time: info.build_time.clone(), + hostname: crate::version::hostname(), + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + #[test] + fn roundtrip_status() { + let req = Request::Status; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("status")); + let back: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(back, Request::Status); + } +} diff --git a/crates/micronet/src/lib.rs b/crates/micronet/src/lib.rs new file mode 100644 index 0000000..fd89fd3 --- /dev/null +++ b/crates/micronet/src/lib.rs @@ -0,0 +1,15 @@ +//! Ethernet bring-up and DHCP gateway daemon for BigFred OS. + +pub mod apply; +pub mod config; +pub mod constants; +pub mod daemon; +pub mod datadir; +pub mod dhcp; +pub mod error; +pub mod ipc; +pub mod net; +pub mod signals; +pub mod version; + +pub use error::{Error, Result}; diff --git a/crates/micronet/src/main.rs b/crates/micronet/src/main.rs new file mode 100644 index 0000000..163fd1a --- /dev/null +++ b/crates/micronet/src/main.rs @@ -0,0 +1,209 @@ +//! micronet — Ethernet bring-up and DHCP gateway daemon. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +use micronet::daemon; +use micronet::datadir; +use micronet::ipc::{self, Request}; +use micronet::version; + +#[derive(Parser, Debug)] +#[command( + name = "micronet", + about = "Ethernet bring-up and DHCP gateway for BigFred OS", + version = env!("CARGO_PKG_VERSION") +)] +struct Cli { + /// Override DATA_DIR (absolute path) before start + #[arg(long, global = true)] + data_dir: Option, + + /// Config file path (default $DATA_DIR/etc/micronet.json) + #[arg(long, global = true)] + config: Option, + + /// Control socket (default $DATA_DIR/run/micronet.sock) + #[arg(long, global = true)] + socket: Option, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Run the network daemon (default) + Serve, + /// Alias for serve + Run, + /// One-shot probe + apply + Apply, + /// Query daemon status (JSON) + Status, + /// Exit 0 when iface is UP with IPv4 (microinit liveness) + Check, + /// Re-run DHCP probe + apply + Reconfigure, + /// Print build / release metadata + Info, +} + +fn argv0_basename() -> Option { + std::env::args_os() + .next() + .as_ref() + .map(Path::new) + .and_then(Path::file_name) + .and_then(OsStr::to_str) + .map(str::to_string) +} + +fn main() -> ExitCode { + if let Some(name) = argv0_basename() { + if name == "configure-ethernet" || name == "configure-dhcp" { + return alias_main(&name); + } + } + + let cli = Cli::parse(); + if let Some(dir) = &cli.data_dir { + datadir::set_root(dir); + } + let config_path = daemon::resolve_config(cli.config.as_ref()); + let socket = daemon::resolve_socket(cli.socket.as_ref()); + dispatch( + cli.command.unwrap_or(Commands::Serve), + &config_path, + &socket, + ) +} + +fn alias_main(argv0: &str) -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let mut data_dir = None; + let mut config = None; + let mut socket = None; + let mut cmd = "apply"; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--data-dir" if i + 1 < args.len() => { + data_dir = Some(PathBuf::from(&args[i + 1])); + i += 2; + } + "--config" if i + 1 < args.len() => { + config = Some(PathBuf::from(&args[i + 1])); + i += 2; + } + "--socket" if i + 1 < args.len() => { + socket = Some(PathBuf::from(&args[i + 1])); + i += 2; + } + "up" | "configure" | "start" | "apply" => { + cmd = "apply"; + i += 1; + } + "check" => { + cmd = "check"; + i += 1; + } + "serve" | "run" => { + cmd = "serve"; + i += 1; + } + other if other.starts_with('-') => { + eprintln!("{argv0}: unknown option {other}"); + return ExitCode::FAILURE; + } + other => { + eprintln!("{argv0}: unknown command {other}"); + return ExitCode::FAILURE; + } + } + } + if let Some(dir) = &data_dir { + datadir::set_root(dir); + } + let config_path = daemon::resolve_config(config.as_ref()); + let socket_path = daemon::resolve_socket(socket.as_ref()); + let command = match cmd { + "serve" => Commands::Serve, + "check" => Commands::Check, + _ => Commands::Apply, + }; + dispatch(command, &config_path, &socket_path) +} + +fn dispatch(command: Commands, config_path: &Path, socket: &Path) -> ExitCode { + match command { + Commands::Serve | Commands::Run => { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .init(); + match daemon::run(config_path, socket) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + log::error!("{e}"); + ExitCode::FAILURE + } + } + } + Commands::Apply => { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .init(); + match daemon::apply_once(config_path) { + Ok(s) => { + log::info!("mode {}", s.mode.as_str()); + ExitCode::SUCCESS + } + Err(e) => { + log::error!("{e}"); + ExitCode::FAILURE + } + } + } + Commands::Status => match ipc::call(socket, &Request::Status) { + Ok(resp) => match serde_json::to_string_pretty(&resp) { + Ok(s) => { + println!("{s}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + }, + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + }, + Commands::Check => match daemon::check_liveness(socket) { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::FAILURE, + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + }, + Commands::Reconfigure => match ipc::call(socket, &Request::Reconfigure) { + Ok(ipc::Response::Ok) => ExitCode::SUCCESS, + Ok(ipc::Response::Error { message }) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + Ok(_) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + }, + Commands::Info => { + println!("{}", version::format_info(&version::info())); + ExitCode::SUCCESS + } + } +} diff --git a/crates/micronet/src/net/addr.rs b/crates/micronet/src/net/addr.rs new file mode 100644 index 0000000..1a2e903 --- /dev/null +++ b/crates/micronet/src/net/addr.rs @@ -0,0 +1,41 @@ +//! Host `.N` in a `/24` subnet. + +use std::net::Ipv4Addr; + +use ipnet::Ipv4Net; + +use crate::constants::REQUIRED_PREFIX; +use crate::error::{Error, Result}; + +/// Addresses derived from gateway subnet + host octets. +pub struct IfaceAddrs { + pub gateway: Ipv4Addr, + pub static_host: Ipv4Addr, + pub range_start: Ipv4Addr, + pub range_end: Ipv4Addr, +} + +/// Last-octet host in a `/24`. +pub fn host_in_slash24(net: Ipv4Net, host: u8) -> Result { + if net.prefix_len() != REQUIRED_PREFIX { + return Err(Error::Config(format!("subnet must be /{REQUIRED_PREFIX}"))); + } + let o = net.network().octets(); + Ok(Ipv4Addr::new(o[0], o[1], o[2], host)) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + #[test] + fn host_252() { + let net: Ipv4Net = "10.0.10.0/24".parse().unwrap(); + assert_eq!( + host_in_slash24(net, 252).unwrap(), + Ipv4Addr::new(10, 0, 10, 252) + ); + } +} diff --git a/crates/micronet/src/net/mod.rs b/crates/micronet/src/net/mod.rs new file mode 100644 index 0000000..18a06ed --- /dev/null +++ b/crates/micronet/src/net/mod.rs @@ -0,0 +1,336 @@ +//! Physical Ethernet discovery, `ip`/`ping`/`dhclient`. + +use std::fs; +use std::net::Ipv4Addr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::constants::{ + ARPHRD_ETHER, DHCLIENT_BIN, IP_BIN, PING_BIN, PING_COUNT, PING_TIMEOUT_SEC, +}; +use crate::error::{Error, Result}; + +pub mod addr; +pub mod probe; + +pub use addr::{host_in_slash24, IfaceAddrs}; + +const DEFAULT_SYS_CLASS_NET: &str = "/sys/class/net"; + +/// Operations used by apply (real or fake in tests). +pub trait NetOps { + fn list_ethernet(&self) -> Result>; + fn is_physical_ethernet(&self, name: &str) -> bool; + fn resolve_iface(&self, configured: Option<&str>) -> Result; + fn bring_up(&self, iface: &str) -> Result<()>; + fn flush_addr(&self, iface: &str) -> Result<()>; + fn add_addr(&self, iface: &str, cidr: &str) -> Result<()>; + fn iface_has_ipv4(&self, iface: &str) -> bool; + fn iface_has_addr(&self, iface: &str, ip: Ipv4Addr) -> bool; + fn ping(&self, host: Ipv4Addr) -> bool; + fn kill_dhclient(&self) -> Result<()>; + fn start_dhclient(&self, iface: &str) -> Result<()>; + fn wait_ipv4(&self, iface: &str, timeout: Duration) -> bool; + fn replace_default_via(&self, gw: Ipv4Addr, iface: &str) -> Result<()>; + fn del_default(&self) -> Result<()>; +} + +/// Live Linux netlink/`ip` implementation. +#[derive(Debug, Clone)] +pub struct LiveNet { + sys_class_net: PathBuf, +} + +impl LiveNet { + #[must_use] + pub fn new() -> Self { + Self { + sys_class_net: PathBuf::from(DEFAULT_SYS_CLASS_NET), + } + } + + #[must_use] + pub fn with_sys_class_net(path: impl Into) -> Self { + Self { + sys_class_net: path.into(), + } + } +} + +impl Default for LiveNet { + fn default() -> Self { + Self::new() + } +} + +impl NetOps for LiveNet { + fn list_ethernet(&self) -> Result> { + list_physical_ethernet(&self.sys_class_net) + } + + fn is_physical_ethernet(&self, name: &str) -> bool { + is_physical_ethernet(&self.sys_class_net, name) + } + + fn resolve_iface(&self, configured: Option<&str>) -> Result { + resolve_iface(&self.sys_class_net, configured) + } + + fn bring_up(&self, iface: &str) -> Result<()> { + run_cmd(IP_BIN, &["link", "set", "dev", iface, "up"]) + } + + fn flush_addr(&self, iface: &str) -> Result<()> { + let _ = run_cmd(IP_BIN, &["addr", "flush", "dev", iface]); + Ok(()) + } + + fn add_addr(&self, iface: &str, cidr: &str) -> Result<()> { + match run_cmd(IP_BIN, &["addr", "add", cidr, "dev", iface]) { + Ok(()) => Ok(()), + Err(_) if self.iface_has_addr(iface, cidr_ip(cidr)) => Ok(()), + Err(e) => Err(e), + } + } + + fn iface_has_ipv4(&self, iface: &str) -> bool { + iface_has_ipv4(iface) + } + + fn iface_has_addr(&self, iface: &str, ip: Ipv4Addr) -> bool { + iface_has_addr(iface, ip) + } + + fn ping(&self, host: Ipv4Addr) -> bool { + run_cmd( + PING_BIN, + &["-c", PING_COUNT, "-W", PING_TIMEOUT_SEC, &host.to_string()], + ) + .is_ok() + } + + fn kill_dhclient(&self) -> Result<()> { + let _ = Command::new("/bin/killall") + .arg("dhclient") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + Ok(()) + } + + fn start_dhclient(&self, iface: &str) -> Result<()> { + run_cmd(DHCLIENT_BIN, &[iface]) + } + + fn wait_ipv4(&self, iface: &str, timeout: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + if iface_has_ipv4(iface) { + return true; + } + thread::sleep(Duration::from_millis(200)); + } + iface_has_ipv4(iface) + } + + fn replace_default_via(&self, gw: Ipv4Addr, iface: &str) -> Result<()> { + let gw = gw.to_string(); + run_cmd( + IP_BIN, + &["route", "replace", "default", "via", &gw, "dev", iface], + ) + } + + fn del_default(&self) -> Result<()> { + let _ = run_cmd(IP_BIN, &["route", "del", "default"]); + Ok(()) + } +} + +fn cidr_ip(cidr: &str) -> Ipv4Addr { + cidr.split('/') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(Ipv4Addr::UNSPECIFIED) +} + +/// First physical Ethernet (sorted names), or an explicit name after the same filter. +pub fn resolve_iface(sys_class_net: &Path, configured: Option<&str>) -> Result { + if let Some(name) = configured { + if !is_physical_ethernet(sys_class_net, name) { + return Err(Error::NotEthernet(name.to_string())); + } + return Ok(name.to_string()); + } + list_physical_ethernet(sys_class_net)? + .into_iter() + .next() + .ok_or(Error::NoEthernet) +} + +/// Physical Ethernet: `ARPHRD_ETHER`, not virtual, not wifi, not loopback, not bridge. +#[must_use] +pub fn is_physical_ethernet(sys_class_net: &Path, name: &str) -> bool { + if name.is_empty() || name.contains('/') { + return false; + } + let dir = sys_class_net.join(name); + if !dir.is_dir() { + return false; + } + let Ok(type_s) = fs::read_to_string(dir.join("type")) else { + return false; + }; + let Ok(kind) = type_s.trim().parse::() else { + return false; + }; + if kind != ARPHRD_ETHER { + return false; + } + if dir.join("wireless").exists() { + return false; + } + if dir.join("bridge").is_dir() { + return false; + } + if let Ok(canon) = fs::canonicalize(&dir) { + if canon.to_string_lossy().contains("/devices/virtual/") { + return false; + } + } + if let Ok(flags) = fs::read_to_string(dir.join("flags")) { + if let Ok(val) = u32::from_str_radix(flags.trim().trim_start_matches("0x"), 16) { + const IFF_LOOPBACK: u32 = 0x8; + if val & IFF_LOOPBACK != 0 { + return false; + } + } + } + true +} + +/// Sorted physical Ethernet names under `sys_class_net`. +pub fn list_physical_ethernet(sys_class_net: &Path) -> Result> { + let entries = fs::read_dir(sys_class_net).map_err(|e| Error::io_at(sys_class_net, e))?; + let mut names = Vec::new(); + for ent in entries { + let ent = ent.map_err(|e| Error::io_at(sys_class_net, e))?; + let name = ent.file_name().to_string_lossy().into_owned(); + if is_physical_ethernet(sys_class_net, &name) { + names.push(name); + } + } + names.sort(); + Ok(names) +} + +pub fn iface_has_ipv4(iface: &str) -> bool { + let Ok(out) = Command::new(IP_BIN) + .args(["-4", "addr", "show", "dev", iface]) + .output() + else { + return false; + }; + String::from_utf8_lossy(&out.stdout).contains("inet ") +} + +pub fn iface_has_addr(iface: &str, ip: Ipv4Addr) -> bool { + let Ok(out) = Command::new(IP_BIN) + .args(["-4", "addr", "show", "dev", iface]) + .output() + else { + return false; + }; + String::from_utf8_lossy(&out.stdout).contains(&format!("inet {ip}/")) +} + +pub fn iface_link_up(iface: &str) -> bool { + let Ok(out) = Command::new(IP_BIN) + .args(["link", "show", "dev", iface]) + .output() + else { + return false; + }; + let s = String::from_utf8_lossy(&out.stdout); + s.contains("state UP") || s.contains(",UP") +} + +fn run_cmd(bin: &str, args: &[&str]) -> Result<()> { + let status = Command::new(bin) + .args(args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|e| Error::Other(format!("{bin}: {e}")))?; + if status.success() { + Ok(()) + } else { + Err(Error::Other(format!( + "{bin} {:?} exited {}", + args, + status.code().unwrap_or(-1) + ))) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + use std::os::unix::fs::symlink; + use tempfile::tempdir; + + fn write(path: &Path, body: &str) { + if let Some(p) = path.parent() { + fs::create_dir_all(p).unwrap(); + } + fs::write(path, body).unwrap(); + } + + #[test] + fn rejects_loopback_bridge_wifi_veth() { + let dir = tempdir().unwrap(); + let sys = dir.path().join("class/net"); + fs::create_dir_all(&sys).unwrap(); + + let virt_lo = dir.path().join("devices/virtual/net/lo"); + write(&virt_lo.join("type"), "772\n"); + write(&virt_lo.join("flags"), "0x9\n"); + symlink(&virt_lo, sys.join("lo")).unwrap(); + + let virt_br = dir.path().join("devices/virtual/net/br0"); + write(&virt_br.join("type"), "1\n"); + fs::create_dir_all(virt_br.join("bridge")).unwrap(); + symlink(&virt_br, sys.join("br0")).unwrap(); + + let virt_veth = dir.path().join("devices/virtual/net/veth0"); + write(&virt_veth.join("type"), "1\n"); + symlink(&virt_veth, sys.join("veth0")).unwrap(); + + write(&sys.join("wlan0/type"), "1\n"); + fs::create_dir(sys.join("wlan0/wireless")).unwrap(); + + write(&sys.join("eth0/type"), "1\n"); + write(&sys.join("eth0/flags"), "0x1003\n"); + + assert!(!is_physical_ethernet(&sys, "lo")); + assert!(!is_physical_ethernet(&sys, "br0")); + assert!(!is_physical_ethernet(&sys, "veth0")); + assert!(!is_physical_ethernet(&sys, "wlan0")); + assert!(is_physical_ethernet(&sys, "eth0")); + let list = list_physical_ethernet(&sys).unwrap(); + assert_eq!(list, vec!["eth0".to_string()]); + } + + #[test] + fn configured_non_ethernet_errors() { + let dir = tempdir().unwrap(); + let sys = dir.path().join("net"); + write(&sys.join("lo/type"), "772\n"); + let err = resolve_iface(&sys, Some("lo")).unwrap_err(); + assert!(matches!(err, Error::NotEthernet(_))); + } +} diff --git a/crates/micronet/src/net/probe.rs b/crates/micronet/src/net/probe.rs new file mode 100644 index 0000000..6cc8d92 --- /dev/null +++ b/crates/micronet/src/net/probe.rs @@ -0,0 +1,146 @@ +//! DHCPDISCOVER probe (no REQUEST). Foreign server → DHCPOFFER. + +use std::net::{Ipv4Addr, SocketAddrV4}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use dhcproto::v4::{ + Decodable, Decoder, DhcpOption, Encodable, Encoder, Flags, HType, Message, MessageType, Opcode, + OptionCode, +}; +use socket2::{Domain, Protocol, SockAddr, Socket, Type}; + +use crate::error::{Error, Result}; + +const DHCP_CLIENT_PORT: u16 = 68; +const DHCP_SERVER_PORT: u16 = 67; +const DHCP_MAGIC_COOKIE: [u8; 4] = [0x63, 0x82, 0x53, 0x63]; + +/// Encode a DHCPDISCOVER (no I/O). +pub fn encode_discover(chaddr: &[u8; 6], xid: u32) -> Result> { + let mut msg = Message::default(); + msg.set_opcode(Opcode::BootRequest); + msg.set_htype(HType::Eth); + msg.set_xid(xid); + msg.set_flags(Flags::default().set_broadcast()); + msg.set_chaddr(chaddr); + msg.opts_mut() + .insert(DhcpOption::MessageType(MessageType::Discover)); + msg.opts_mut().insert(DhcpOption::ParameterRequestList(vec![ + OptionCode::SubnetMask, + OptionCode::Router, + OptionCode::DomainNameServer, + ])); + + let mut buf = Vec::with_capacity(300); + let mut enc = Encoder::new(&mut buf); + msg.encode(&mut enc) + .map_err(|e| Error::Other(format!("DHCP encode: {e}")))?; + debug_assert!(buf.windows(4).any(|w| w == DHCP_MAGIC_COOKIE)); + Ok(buf) +} + +/// True if `buf` is a DHCPOFFER. +#[must_use] +pub fn is_offer(buf: &[u8]) -> bool { + let Ok(msg) = Message::decode(&mut Decoder::new(buf)) else { + return false; + }; + matches!( + msg.opts().get(OptionCode::MessageType), + Some(DhcpOption::MessageType(MessageType::Offer)) + ) +} + +/// Broadcast DHCPDISCOVER on `iface`; return true if any DHCPOFFER arrives. +pub fn probe_foreign_dhcp(iface: &str, mac: &[u8; 6], timeout: Duration) -> Result { + let xid = xid_now(); + let pkt = encode_discover(mac, xid)?; + let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) + .map_err(|e| Error::Other(format!("dhcp socket: {e}")))?; + sock.set_reuse_address(true) + .map_err(|e| Error::Other(format!("SO_REUSEADDR: {e}")))?; + sock.set_broadcast(true) + .map_err(|e| Error::Other(format!("SO_BROADCAST: {e}")))?; + sock.set_read_timeout(Some(Duration::from_millis(250))) + .map_err(|e| Error::Other(format!("SO_RCVTIMEO: {e}")))?; + if let Err(e) = sock.bind_device(Some(iface.as_bytes())) { + log::debug!("SO_BINDTODEVICE {iface}: {e}"); + } + let bind = SockAddr::from(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DHCP_CLIENT_PORT)); + sock.bind(&bind) + .map_err(|e| Error::Other(format!("bind :{DHCP_CLIENT_PORT}: {e}")))?; + + let dest = SockAddr::from(SocketAddrV4::new(Ipv4Addr::BROADCAST, DHCP_SERVER_PORT)); + sock.send_to(&pkt, &dest) + .map_err(|e| Error::Other(format!("DHCPDISCOVER send: {e}")))?; + + let udp = std::net::UdpSocket::from(sock); + let deadline = Instant::now() + timeout; + let mut buf = [0u8; 1500]; + while Instant::now() < deadline { + match udp.recv_from(&mut buf) { + Ok((n, _)) => { + if is_offer(&buf[..n]) { + return Ok(true); + } + } + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => { + log::debug!("dhcp recv: {e}"); + } + } + } + Ok(false) +} + +fn xid_now() -> u32 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(1) +} + +/// Read MAC from sysfs `...//address`. +pub fn read_mac(sys_class_net: &std::path::Path, iface: &str) -> Result<[u8; 6]> { + let text = std::fs::read_to_string(sys_class_net.join(iface).join("address")) + .map_err(|e| Error::io_at(sys_class_net.join(iface).join("address"), e))?; + parse_mac(text.trim()).ok_or_else(|| Error::Other(format!("bad MAC on {iface}"))) +} + +fn parse_mac(s: &str) -> Option<[u8; 6]> { + let mut out = [0u8; 6]; + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 6 { + return None; + } + for (i, p) in parts.iter().enumerate() { + out[i] = u8::from_str_radix(p, 16).ok()?; + } + Some(out) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + #[test] + fn discover_has_cookie_and_type() { + let mac = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01]; + let buf = encode_discover(&mac, 0x1122_3344).unwrap(); + assert!(buf.windows(4).any(|w| w == DHCP_MAGIC_COOKIE)); + assert!(buf.windows(3).any(|w| w == [53, 1, 1])); + assert!(!is_offer(&buf)); + } + + #[test] + fn parse_mac_ok() { + assert_eq!( + parse_mac("aa:bb:cc:dd:ee:ff").unwrap(), + [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff] + ); + } +} diff --git a/crates/micronet/src/signals.rs b/crates/micronet/src/signals.rs new file mode 100644 index 0000000..7f51685 --- /dev/null +++ b/crates/micronet/src/signals.rs @@ -0,0 +1,16 @@ +//! SIGTERM / SIGINT via `signal-hook` (no `unsafe` in this crate). + +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use signal_hook::consts::{SIGINT, SIGTERM}; +use signal_hook::flag; + +use crate::error::{Error, Result}; + +/// Install handlers that set `flag` on SIGTERM / SIGINT. +pub fn install(flag: &Arc) -> Result<()> { + flag::register(SIGTERM, Arc::clone(flag)).map_err(|e| Error::Other(e.to_string()))?; + flag::register(SIGINT, Arc::clone(flag)).map_err(|e| Error::Other(e.to_string()))?; + Ok(()) +} diff --git a/crates/micronet/src/version.rs b/crates/micronet/src/version.rs new file mode 100644 index 0000000..76f4b8a --- /dev/null +++ b/crates/micronet/src/version.rs @@ -0,0 +1,222 @@ +//! Build and release metadata for micronet. +//! +//! - **Build-time:** `build_commit` / `build_time` from `build.rs` env. +//! - **Post-build:** optional ELF section `.micronet.version` JSON +//! `{"version":"v1.2.3","commit":"abc1234"}` injected by release retag. +//! +//! Simplified vs microinit: only reads the section from `current_exe` +//! (no Android `dladdr` library path). + +use std::fs; +use std::path::Path; +use std::sync::OnceLock; + +/// ELF section name (must match org `.github` `inject-elf-version.sh` section arg). +pub const SECTION_NAME: &str = ".micronet.version"; + +/// Public version payload returned by `micronet info` / `--version`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Info { + /// Release tag from ELF section, or `"dev"` when absent. + pub version: String, + /// Short/full commit of the release tag (ELF); empty when absent. + pub tag_commit: String, + /// CI / build-time git SHA. + pub build_commit: String, + /// UTC build timestamp (ISO-8601) when available. + pub build_time: String, +} + +#[derive(Debug, serde::Deserialize)] +struct SectionPayload { + #[serde(default)] + version: String, + #[serde(default)] + commit: String, +} + +static INFO: OnceLock = OnceLock::new(); + +/// Returns process version info (cached). +#[must_use] +pub fn info() -> Info { + INFO.get_or_init(load).clone() +} + +fn load() -> Info { + let mut out = Info { + version: "dev".into(), + tag_commit: String::new(), + build_commit: option_env!("MICRONET_GIT_COMMIT") + .unwrap_or("unknown") + .into(), + build_time: option_env!("MICRONET_BUILD_TIME").unwrap_or("").into(), + }; + if let Ok(path) = std::env::current_exe() { + if let Some((v, c)) = read_section_from(&path) { + if !v.is_empty() { + out.version = v; + } + if !c.is_empty() { + out.tag_commit = c; + } + } + } + out +} + +/// Read `.micronet.version` from an ELF path. Public for tests. +#[must_use] +pub fn read_section_from(path: &Path) -> Option<(String, String)> { + let data = fs::read(path).ok()?; + let raw = elf_section_data(&data, SECTION_NAME)?; + if raw.is_empty() { + return None; + } + if let Ok(payload) = serde_json::from_slice::(raw) { + let v = payload.version.trim().to_string(); + let c = payload.commit.trim().to_string(); + if v.is_empty() && c.is_empty() { + return None; + } + return Some((v, c)); + } + let v = String::from_utf8_lossy(raw).trim().to_string(); + if v.is_empty() { + None + } else { + Some((v, String::new())) + } +} + +fn elf_section_data<'a>(data: &'a [u8], name: &str) -> Option<&'a [u8]> { + if data.len() < 16 || &data[0..4] != b"\x7fELF" { + return None; + } + let class = data[4]; // 1=32, 2=64 + let endian = data[5]; // 1=LE, 2=BE + let le = endian == 1; + if !le && endian != 2 { + return None; + } + + match class { + 1 => elf32_section(data, name, le), + 2 => elf64_section(data, name, le), + _ => None, + } +} + +fn u16_at(data: &[u8], off: usize, le: bool) -> Option { + let b = data.get(off..off + 2)?; + Some(if le { + u16::from_le_bytes([b[0], b[1]]) + } else { + u16::from_be_bytes([b[0], b[1]]) + }) +} + +fn u32_at(data: &[u8], off: usize, le: bool) -> Option { + let b = data.get(off..off + 4)?; + Some(if le { + u32::from_le_bytes([b[0], b[1], b[2], b[3]]) + } else { + u32::from_be_bytes([b[0], b[1], b[2], b[3]]) + }) +} + +fn u64_at(data: &[u8], off: usize, le: bool) -> Option { + let b = data.get(off..off + 8)?; + Some(if le { + u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) + } else { + u64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) + }) +} + +fn elf64_section<'a>(data: &'a [u8], name: &str, le: bool) -> Option<&'a [u8]> { + let shoff = u64_at(data, 40, le)? as usize; + let shentsize = u16_at(data, 58, le)? as usize; + let shnum = u16_at(data, 60, le)? as usize; + let shstrndx = u16_at(data, 62, le)? as usize; + if shentsize < 64 || shnum == 0 || shstrndx >= shnum { + return None; + } + let str_hdr = shoff.checked_add(shstrndx.checked_mul(shentsize)?)?; + let str_off = u64_at(data, str_hdr + 24, le)? as usize; + let str_size = u64_at(data, str_hdr + 32, le)? as usize; + let strtab = data.get(str_off..str_off.checked_add(str_size)?)?; + + for i in 0..shnum { + let hdr = shoff.checked_add(i.checked_mul(shentsize)?)?; + let name_off = u32_at(data, hdr, le)? as usize; + let sec_name = cstr_at(strtab, name_off)?; + if sec_name != name { + continue; + } + let offset = u64_at(data, hdr + 24, le)? as usize; + let size = u64_at(data, hdr + 32, le)? as usize; + return data.get(offset..offset.checked_add(size)?); + } + None +} + +fn elf32_section<'a>(data: &'a [u8], name: &str, le: bool) -> Option<&'a [u8]> { + let shoff = u32_at(data, 32, le)? as usize; + let shentsize = u16_at(data, 46, le)? as usize; + let shnum = u16_at(data, 48, le)? as usize; + let shstrndx = u16_at(data, 50, le)? as usize; + if shentsize < 40 || shnum == 0 || shstrndx >= shnum { + return None; + } + let str_hdr = shoff.checked_add(shstrndx.checked_mul(shentsize)?)?; + let str_off = u32_at(data, str_hdr + 16, le)? as usize; + let str_size = u32_at(data, str_hdr + 20, le)? as usize; + let strtab = data.get(str_off..str_off.checked_add(str_size)?)?; + + for i in 0..shnum { + let hdr = shoff.checked_add(i.checked_mul(shentsize)?)?; + let name_off = u32_at(data, hdr, le)? as usize; + let sec_name = cstr_at(strtab, name_off)?; + if sec_name != name { + continue; + } + let offset = u32_at(data, hdr + 16, le)? as usize; + let size = u32_at(data, hdr + 20, le)? as usize; + return data.get(offset..offset.checked_add(size)?); + } + None +} + +fn cstr_at(data: &[u8], off: usize) -> Option<&str> { + let slice = data.get(off..)?; + let end = slice.iter().position(|&b| b == 0).unwrap_or(slice.len()); + std::str::from_utf8(&slice[..end]).ok() +} + +/// Hostname for daemon info / DNS-SD instance names. +#[must_use] +pub fn hostname() -> String { + fs::read_to_string("/proc/sys/kernel/hostname") + .or_else(|_| fs::read_to_string("/etc/hostname")) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".into()) +} + +/// Format [`Info`] for CLI output. +pub fn format_info(info: &Info) -> String { + let mut lines = vec![ + format!("version: {}", info.version), + format!("build_commit: {}", info.build_commit), + ]; + if !info.tag_commit.is_empty() { + lines.push(format!("tag_commit: {}", info.tag_commit)); + } + if !info.build_time.is_empty() { + lines.push(format!("build_time: {}", info.build_time)); + } + lines.push(format!("hostname: {}", hostname())); + lines.join("\n") +} diff --git a/crates/micronet/tests/ipc.rs b/crates/micronet/tests/ipc.rs new file mode 100644 index 0000000..bf504e9 --- /dev/null +++ b/crates/micronet/tests/ipc.rs @@ -0,0 +1,71 @@ +//! IPC framing + live Unix socket. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use micronet::apply::Status; +use micronet::config::Config; +use micronet::ipc::{self, call, read_frame_from, write_frame_to, Request, Response, Shared}; + +static SEQ: AtomicU64 = AtomicU64::new(0); + +fn tmp_sock() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("micronet-ipc-{nanos}-{seq}.sock")) +} + +#[test] +fn oversized_frame_rejected() { + let len = u32::try_from(micronet::constants::MAX_IPC_FRAME_BYTES) + .unwrap_or(u32::MAX) + .saturating_add(1); + let mut too_big = len.to_le_bytes().to_vec(); + too_big.extend_from_slice(&[0u8; 8]); + let mut cur = std::io::Cursor::new(too_big); + assert!(read_frame_from(&mut cur).is_err()); +} + +#[test] +fn status_over_socket() { + let sock = tmp_sock(); + let mut st = Status::empty(); + st.mode = micronet::apply::Mode::Gateway; + st.iface = "eth0".into(); + st.cidr = Some("10.0.10.1/24".into()); + let shared = Shared::new(Config::default(), st); + let (tx, _rx) = mpsc::channel(); + ipc::serve(&sock, shared, tx).expect("serve"); + std::thread::sleep(std::time::Duration::from_millis(50)); + let resp = call(&sock, &Request::Status).expect("call"); + match resp { + Response::Status { + iface, cidr, mode, .. + } => { + assert_eq!(iface, "eth0"); + assert_eq!(cidr.as_deref(), Some("10.0.10.1/24")); + assert_eq!(mode, micronet::apply::Mode::Gateway); + } + other => panic!("unexpected {other:?}"), + } + let _ = std::fs::remove_file(&sock); +} + +#[test] +fn write_read_request() { + let mut buf = Vec::new(); + write_frame_to(&mut buf, &Request::Reconfigure).unwrap(); + let mut cur = std::io::Cursor::new(buf); + let payload = read_frame_from(&mut cur).unwrap(); + let req: Request = serde_json::from_slice(&payload).unwrap(); + assert_eq!(req, Request::Reconfigure); + let _ = UnixStream::pair(); +} diff --git a/docs/networking/README.md b/docs/networking/README.md new file mode 100644 index 0000000..8d21b68 --- /dev/null +++ b/docs/networking/README.md @@ -0,0 +1,196 @@ +# BigFred event WiFi — mount and configure + +**Language:** English | [Polski](./README_pl.md) + +Related plans: [topology](../../plans/2026-07-14-topologia-wifi-hala.md), [EAP613 settings](../../plans/2026-07-14-eap613-konfiguracja.md) + +Architecture of the daemon: [ARCHITECTURE.md](../../ARCHITECTURE.md). + +For a non-technical operator. Goal: low-latency WiFi for throttles (`bigfred2`, 2.4 GHz) and phones (`bigfred5`, 5 GHz). + +## What you need + +- Raspberry Pi 3 + Ethernet = **BigFred** (server) +- Omada **EAP610/613 × 3** (access points) +- **One** of two L2 backhauls (operator choice; the daemon does not detect the vendor): + - **Switch PoE TL-SF1006P** (ports 1–4 PoE+, 5–6 plain) — BigFred **serves DHCP** + - **MikroTik hEX PoE lite RB750UPr2** (5× FE, **4 PoE** ports) — router **serves DHCP**; BigFred does **not** +- **Omada OC200** is optional (central controller). Without it, configure each AP in **standalone** mode (same SSIDs/settings on every AP; only channels differ). +- Ethernet cables, PSUs, 3 stands at **2 m**, laptop/phone for setup, optional UPS + +## How BigFred networking works + +On boot, the **`micronet` daemon** (`eth0` / first physical Ethernet): + +1. Brings the interface up (no address). +2. Sends **DHCPDISCOVER** and waits for a **DHCPOFFER** (no REQUEST). +3. **Offer** → mode **`client`**: `dhclient`, no dnsmasq, no `gateway.ip` on the Pi. +4. **No offer** → temporarily `.252` in the configured subnet, then `ping gateway.ip`: + - ping OK → mode **`static`**: stay on `.252`, default route via `gateway.ip`, no dnsmasq + - ping fail → mode **`gateway`**: take `gateway.ip` (image seed: **`10.0.10.1/24`**), start **dnsmasq** (pool `.50–.200`, sticky lease **7d**, router/DNS = BigFred). **No default route.** + +There is no Omada detection and no per-MAC `dhcp-host=` reservations. Stickiness is the dnsmasq leasefile + `7d`. + +Typical mapping: + +| Backhaul | Foreign DHCP / live `gateway.ip` | BigFred mode | Who leases the laptop | +|---|---|---|---| +| TL-SF1006P (dumb PoE switch) | none | `gateway` | BigFred dnsmasq | +| hEX PoE lite RB750UPr2 | yes (router) | `client` or `static` | MikroTik | + +You do not edit dnsmasq by hand for the event setup. JSON: `$DATA_DIR/etc/micronet.json` (hot-reload). + +--- + +## Kit A — Switch TL-SF1006P (BigFred = DHCP) + +### 1. Cabling (power off) + +| Switch port | Device | Notes | +|---|---|---| +| 1 | BigFred | Priority Mode | +| 2 | AP1 | PoE | +| 3 | AP2 | PoE | +| 4 | AP3 | PoE | +| 5 | OC200 (optional) | Plain port; OC200 has its own PSU | +| 6 | free | Laptop for setup | + +- [ ] BigFred → port 1 +- [ ] AP1 → 2, AP2 → 3, AP3 → 4 +- [ ] OC200 → 5 (if used) +- [ ] Plug in switch, BigFred, and OC200 PSUs + +### 2. Switch rear switches + +- [ ] **Priority Mode = ON** (port 1 = BigFred) +- [ ] **Extend Mode = OFF** (otherwise ports drop to 10 Mb/s) + +### 3. Power-on order + +Empty hall: ping to `10.0.10.1` fails → BigFred becomes gateway immediately. APs get a lease after they boot. + +- [ ] 1. Switch +- [ ] 2. BigFred — wait until UI answers at `http://10.0.10.1` (~1–2 min) +- [ ] 3. OC200 (if used) — wait ~3 min +- [ ] 4. AP1/2/3 via PoE — wait ~3 min + +### 4. Join with a laptop + +- [ ] Ethernet to switch port 6 — laptop gets an address **from BigFred**, e.g. `10.0.10.51` + +--- + +## Kit B — MikroTik hEX PoE lite RB750UPr2 (router = DHCP) + +BigFred **must not** serve DHCP (router already does). ether1 has **no PoE**. + +| Port | Device | Notes | +|---|---|---| +| ether1 | BigFred | no PoE | +| ether2 | AP1 | PoE | +| ether3 | AP2 | PoE | +| ether4 | AP3 | PoE | +| ether5 | spare AP / laptop | PoE | + +### Power-on order + +- [ ] 1. MikroTik (wait until its DHCP is up) +- [ ] 2. BigFred — joins as **`client`** (or **`static` `.252`** if the router has no DHCP but answers ping on `gateway.ip`) +- [ ] 3. APs via PoE on ether2–5 + +### Laptop + +- [ ] Plug into a spare router port — lease comes **from the MikroTik**, not from BigFred. +- [ ] Confirm `micronet status` is `client` or `static`, **not** `gateway`. + +--- + +## 5. Configure WiFi — choose one path + +### Path A — with OC200 (controller) + +- [ ] Find OC200 IP (TP-Link **Omada Discovery**) +- [ ] Open `https://`, accept the cert warning +- [ ] Login `admin` / `admin`, set a new admin password +- [ ] Wizard: region/timezone; skip creating SSIDs here +- [ ] **Devices** → Adopt all three APs → wait until **Connected** +- [ ] Create WLAN group + SSIDs (step 6) and radio tweaks (step 7) **once** in the controller + +### Path B — standalone (no OC200) + +Do steps 6–7 **on each AP** (AP1, then AP2, then AP3). Default first access: join the sticker SSID or open `https://tplinkeap.net` / `https://192.168.0.254`, then set a management password and preferably a static/management IP once on the event subnet. Channels differ per AP (step 7.1); SSIDs and passwords are identical. + +## 6. SSIDs: `bigfred2` and `bigfred5` + +Same password for both. + +### `bigfred2` (2.4 GHz only — throttles) + +- [ ] SSID `bigfred2`, broadcast ON, band **2.4 GHz only** +- [ ] WPA2-PSK, AES, your password +- [ ] VLAN 0, Portal OFF, SSID/Client Isolation **OFF**, Save + +### `bigfred5` (5 GHz only — phones) + +- [ ] SSID `bigfred5`, broadcast ON, band **5 GHz only** +- [ ] Same security and password, VLAN 0, Portal OFF, Isolation OFF, Save + +## 7. Low-latency radio tweaks + +### 7.1 Channels (per AP) + +2.4 GHz, **20 MHz**, Manual: + +| AP | Channel | Width | Tx | +|---|---|---|---| +| AP1 | 1 | 20 MHz | Medium | +| AP2 | 6 | 20 MHz | Medium | +| AP3 | 11 | 20 MHz | Medium | + +5 GHz, **40 MHz**, non-DFS: + +| AP | Channel | Width | Tx | +|---|---|---|---| +| AP1 | 36 | 40 MHz | Medium | +| AP2 | 149 | 40 MHz | Medium | +| AP3 | 44 (or 157) | 40 MHz | Medium | + +- [ ] Channel selection = **Manual** (not Auto) +- [ ] Do **not** use DFS channels 52–144 + +### 7.2 Advanced + +- [ ] Airtime Fairness ON, OFDMA ON, MU-MIMO ON +- [ ] Beacon 100, DTIM 1, min data rate 2.4 GHz = 6 Mbps (if available) +- [ ] Mesh OFF, Band Steering OFF + +### 7.3 WMM / multicast / roaming + +- [ ] WMM Enable on both SSIDs +- [ ] Multicast filter OFF (mDNS `224.0.0.251` must pass); IGMP snooping + multicast-to-unicast ON if available +- [ ] Client Isolation OFF +- [ ] Load balance 2.4 GHz: max ~18 clients; 802.11k/v/r ON + +## 8. Validation + +- [ ] Phone sees `bigfred2` and `bigfred5` +- [ ] On `bigfred5`, open `http://10.0.10.1` (BigFred UI) when using the event seed / switch kit +- [ ] Throttle on `bigfred2` +- [ ] Ping to the hub < 25 ms +- [ ] RSSI at operator seats > −65 dBm + +## 9. Event-day checklist + +- [ ] Three APs at 2 m around operators (not behind the layout) +- [ ] Switch kit: BigFred on port 1 (Priority), `micronet status` → `gateway`, laptop leased by BigFred +- [ ] MikroTik kit: ether1 = BigFred, ether2–5 = APs; `micronet status` → `client`/`static`; laptop leased by the router +- [ ] Spectrum check — adjust 1/6/11 if needed +- [ ] 3–5 test throttles OK +- [ ] Ask audience to disable personal hotspots +- [ ] Spare AP + PoE injector ready + +## Technical notes + +- Daemon: [`crates/micronet`](../../crates/micronet/) → `/usr/sbin/micronet` on BigFred OS +- Shared CI: reusable workflows in [`dcc-bigfred/common`](https://github.com/dcc-bigfred/common) (`@v2`); binary fetch via `go run github.com/dcc-bigfred/common/cmd/fetch@latest` +- Detailed EAP613 menu paths: [plans/2026-07-14-eap613-konfiguracja.md](../../plans/2026-07-14-eap613-konfiguracja.md) diff --git a/README_pl.md b/docs/networking/README_pl.md similarity index 52% rename from README_pl.md rename to docs/networking/README_pl.md index a93e27b..20a2bf1 100644 --- a/README_pl.md +++ b/docs/networking/README_pl.md @@ -2,7 +2,9 @@ **Język:** [English](./README.md) | Polski -Powiązane plany: [topologia](./plans/2026-07-14-topologia-wifi-hala.md), [ustawienia EAP613](./plans/2026-07-14-eap613-konfiguracja.md) +Powiązane plany: [topologia](../../plans/2026-07-14-topologia-wifi-hala.md), [ustawienia EAP613](../../plans/2026-07-14-eap613-konfiguracja.md) + +Architektura daemona: [ARCHITECTURE.md](../../ARCHITECTURE.md). Dla mało technicznego operatora. Cel: WiFi o niskim opóźnieniu dla pilotów (`bigfred2`, 2.4 GHz) i telefonów (`bigfred5`, 5 GHz). @@ -10,24 +12,39 @@ Dla mało technicznego operatora. Cel: WiFi o niskim opóźnieniu dla pilotów ( - Raspberry Pi 3 + Ethernet = **BigFred** (serwer) - Omada **EAP610/613 × 3** (access pointy) -- Switch PoE **TL-SF1006P** (porty 1–4 PoE+, 5–6 zwykłe) +- **Jeden** z dwóch backhaulów L2 (wybór operatora; daemon nie rozpoznaje modelu): + - **Switch PoE TL-SF1006P** (porty 1–4 PoE+, 5–6 zwykłe) — BigFred **serwuje DHCP** + - **MikroTik hEX PoE lite RB750UPr2** (5× FE, **4 porty PoE**) — DHCP na routerze; BigFred **nie** serwuje DHCP - **Omada OC200** jest opcjonalny (centralny kontroler). Bez niego konfigurujesz każdy AP w trybie **standalone** (te same SSID/ustawienia; różnią się tylko kanały). -- 4–5 kabli Ethernet, zasilacze (Pi3, switch; OC200 jeśli jest), 3 statywy na **2 m**, laptop/telefon do konfiguracji, opcjonalnie UPS +- Kable Ethernet, zasilacze, 3 statywy na **2 m**, laptop/telefon do konfiguracji, opcjonalnie UPS ## Jak działa sieć na BigFredzie -Po starcie BigFred OS: +Po starcie daemon **`micronet`** (pierwszy fizyczny Ethernet): + +1. Podnosi interfejs (bez adresu). +2. Wysyła **DHCPDISCOVER** i czeka na **DHCPOFFER** (bez REQUEST). +3. **Jest oferta** → tryb **`client`**: `dhclient`, bez dnsmasq, bez `gateway.ip` na Pi. +4. **Brak oferty** → tymczasowo `.252` w skonfigurowanej podsieci, potem `ping gateway.ip`: + - ping OK → tryb **`static`**: zostań na `.252`, default via `gateway.ip`, bez dnsmasq + - ping fail → tryb **`gateway`**: weź `gateway.ip` (seed obrazu: **`10.0.10.1/24`**), start **dnsmasq** (pula `.50–.200`, sticky **7d**, router/DNS = BigFred). **Bez default route.** + +Nie ma wykrywania Omady ani rezerwacji `dhcp-host=` per MAC. Stickiness to leasefile dnsmasq + `7d`. -1. Podnosi Ethernet (`configure-ethernet`). -2. Uruchamia **`configure-dhcp`**, które sonduje LAN pod kątem stacka WiFi eventowego (dziś: **Omada** AP lub OC200). -3. **Tylko gdy wykryje sprzęt Omada** ustawia BigFred na `10.0.10.1/24` i startuje **dnsmasq** (pula `10.0.10.50–10.0.10.200`, lease **7 dni**, brama/DNS = BigFred). Wykryte MAC Omada dostają stałe rezerwacje DHCP. -4. W sieci klubowej **bez** Omada DHCP **nie** startuje (brak konfliktu z klubowym DHCP). +Typowe mapowanie: + +| Backhaul | Obcy DHCP / żywy `gateway.ip` | Tryb BigFred | Kto daje lease laptopowi | +|---|---|---|---| +| TL-SF1006P (głupi switch PoE) | brak | `gateway` | dnsmasq na BigFredzie | +| hEX PoE lite RB750UPr2 | tak (router) | `client` albo `static` | MikroTik | -Nie edytujesz dnsmasq ręcznie pod setup eventu. +Nie edytujesz dnsmasq ręcznie pod setup eventu. JSON: `$DATA_DIR/etc/micronet.json` (hot-reload). --- -## 1. Okablowanie (przed włączeniem prądu) +## Zestaw A — Switch TL-SF1006P (BigFred = DHCP) + +### 1. Okablowanie (przed włączeniem prądu) | Port switcha | Urządzenie | Uwagi | |---|---|---| @@ -43,29 +60,56 @@ Nie edytujesz dnsmasq ręcznie pod setup eventu. - [ ] OC200 → 5 (jeśli używasz) - [ ] Zasilacze: switch, BigFred, OC200 -## 2. Przełączniki z tyłu switcha +### 2. Przełączniki z tyłu switcha - [ ] **Priority Mode = ON** (port 1 = BigFred) - [ ] **Extend Mode = OFF** (inaczej porty spadną do 10 Mb/s) -## 3. Kolejność włączania +### 3. Kolejność włączania -Najpierw BigFred (DHCP): +Pusta hala: ping na `10.0.10.1` pada → BigFred od razu jest gatewayem. AP-y dostaną lease po starcie. - [ ] 1. Switch -- [ ] 2. BigFred — poczekaj ~2 min (`configure-dhcp` wykryje Omada i uruchomi DHCP) +- [ ] 2. BigFred — poczekaj aż UI odpowie na `http://10.0.10.1` (~1–2 min) - [ ] 3. OC200 (jeśli jest) — poczekaj ~3 min - [ ] 4. AP1/2/3 przez PoE — poczekaj ~3 min -## 4. Laptop w sieci +### 4. Laptop w sieci + +- [ ] Ethernet do portu 6 — laptop dostanie adres **z BigFreda**, np. `10.0.10.51` + +--- + +## Zestaw B — MikroTik hEX PoE lite RB750UPr2 (router = DHCP) + +BigFred **nie** może serwować DHCP (router już to robi). ether1 **bez PoE**. + +| Port | Urządzenie | Uwagi | +|---|---|---| +| ether1 | BigFred | bez PoE | +| ether2 | AP1 | PoE | +| ether3 | AP2 | PoE | +| ether4 | AP3 | PoE | +| ether5 | zapasowy AP / laptop | PoE | + +### Kolejność włączania -- [ ] Ethernet do portu 6 (laptop dostanie adres z BigFreda, np. `10.0.10.51`) +- [ ] 1. MikroTik (poczekaj aż jego DHCP wstanie) +- [ ] 2. BigFred — dołącza jako **`client`** (albo **`static` `.252`**, gdy router nie ma DHCP, ale odpowiada na ping `gateway.ip`) +- [ ] 3. AP-y przez PoE na ether2–5 + +### Laptop + +- [ ] Włóż do wolnego portu routera — lease daje **MikroTik**, nie BigFred. +- [ ] `micronet status` ma być `client` albo `static`, **nie** `gateway`. + +--- ## 5. Konfiguracja WiFi — wybierz ścieżkę ### Ścieżka A — z OC200 (kontroler) -- [ ] Znajdź IP OC200 (**Omada Discovery** od TP-Link albo na BigFredzie: `configure-dhcp check`) +- [ ] Znajdź IP OC200 (**Omada Discovery** od TP-Link) - [ ] Otwórz `https://`, zignoruj ostrzeżenie certyfikatu - [ ] Login `admin` / `admin`, ustaw nowe hasło admina - [ ] Wizard: region/strefa; pomiń tworzenie SSID @@ -130,15 +174,16 @@ To samo hasło dla obu. ## 8. Walidacja - [ ] Telefon widzi `bigfred2` i `bigfred5` -- [ ] Na `bigfred5` otwórz `http://10.0.10.1` (BigFred) +- [ ] Na `bigfred5` otwórz `http://10.0.10.1` (BigFred) przy seedzie eventu / zestawie switch - [ ] Pilot na `bigfred2` -- [ ] Ping do `10.0.10.1` < 25 ms +- [ ] Ping do huba < 25 ms - [ ] RSSI na stanowiskach > −65 dBm ## 9. Checklist dnia eventu - [ ] 3 AP na 2 m wokół operatorów (nie za makietą) -- [ ] BigFred na porcie 1 (Priority), DHCP działa +- [ ] Zestaw switch: BigFred na porcie 1 (Priority), `micronet status` → `gateway`, laptop z lease’em BigFreda +- [ ] Zestaw MikroTik: ether1 = BigFred, ether2–5 = AP; `micronet status` → `client`/`static`; laptop z lease’em routera - [ ] Skan widma — ew. korekta 1/6/11 - [ ] 3–5 pilotów testowych OK - [ ] Prośba do publiczności: wyłączyć hotspoty @@ -146,6 +191,6 @@ To samo hasło dla obu. ## Uwagi techniczne -- Narzędzia (workspace Rust): [`crates/configure-dhcp`](./crates/configure-dhcp/), [`crates/configure-ethernet`](./crates/configure-ethernet/) → `/usr/sbin/` na BigFred OS (artefakty GitHub Actions / Releases) +- Daemon: [`crates/micronet`](../../crates/micronet/) → `/usr/sbin/micronet` na BigFred OS - Wspólne CI: reusable workflows w [`dcc-bigfred/common`](https://github.com/dcc-bigfred/common) (`@v2`); pobieranie binarek: `go run github.com/dcc-bigfred/common/cmd/fetch@latest` -- Szczegółowe menu EAP613: [plans/2026-07-14-eap613-konfiguracja.md](./plans/2026-07-14-eap613-konfiguracja.md). +- Szczegółowe menu EAP613: [plans/2026-07-14-eap613-konfiguracja.md](../../plans/2026-07-14-eap613-konfiguracja.md) diff --git a/plans/2026-07-14-eap613-konfiguracja.md b/plans/2026-07-14-eap613-konfiguracja.md index 5cc4a40..915970b 100644 --- a/plans/2026-07-14-eap613-konfiguracja.md +++ b/plans/2026-07-14-eap613-konfiguracja.md @@ -2,7 +2,7 @@ Data: 2026-07-14 Powiązany plan: [2026-07-14-topologia-wifi-hala.md](./2026-07-14-topologia-wifi-hala.md) -Montaż krok po kroku: [../README.md](../README.md) (EN) / [../README_pl.md](../README_pl.md) (PL) +Montaż krok po kroku: [../docs/networking/README.md](../docs/networking/README.md) (EN) / [../docs/networking/README_pl.md](../docs/networking/README_pl.md) (PL) Tryb: **Standalone** (bez kontrolera Omada, bez internetu) — OC200 opcjonalny, patrz README --- @@ -13,7 +13,7 @@ Tryb: **Standalone** (bez kontrolera Omada, bez internetu) — OC200 opcjonalny, |---------|-------|------| | TP-Link EAP613 | 3 | WiFi dla WiFredów (2.4 GHz) i telefonów (5 GHz) | | TP-Link TL-SF1006P | 1 | PoE + L2 switch | -| BigFred | 1 | Serwer, DHCP, mDNS, WebSocket DCC | +| BigFred | 1 | Serwer; DHCP tylko na zestawie switch (tryb `gateway`) | **Cel:** latency WiFi < 25 ms dla ~40 klientów sterujących. @@ -40,6 +40,29 @@ Na switchu: - **Extend Mode: OFF** (inaczej porty spadną do 10 Mb/s). - **Priority Mode: ON** (port 1 ma priorytet). +Na tym zestawie **BigFred jest serwerem DHCP** (`micronet` tryb `gateway`). + +Montaż AP: **2 m**, dysk poziomo na maszcie/statywie, wokół strefy operatorów (nie za makietą przy publiczności). + +### 2b. Alternatywa: MikroTik hEX PoE lite RB750UPr2 + +Gdy backhaulem jest router (DHCP na MikroTiku), BigFred **nie** serwuje DHCP. + +``` +BigFred ──ether1 (bez PoE)──► RB750UPr2 ◄──ether2── AP1 (PoE) + ◄──ether3── AP2 (PoE) + ◄──ether4── AP3 (PoE) + ether5 zapas +``` + +| Port | Urządzenie | Uwagi | +|------|------------|-------| +| ether1 | BigFred | bez PoE | +| ether2 | AP1 | PoE | +| ether3 | AP2 | PoE | +| ether4 | AP3 | PoE | +| ether5 | zapas / laptop | PoE | + Montaż AP: **2 m**, dysk poziomo na maszcie/statywie, wokół strefy operatorów (nie za makietą przy publiczności). --- diff --git a/plans/2026-07-14-topologia-wifi-hala.md b/plans/2026-07-14-topologia-wifi-hala.md index f3b8f43..1d20d0f 100644 --- a/plans/2026-07-14-topologia-wifi-hala.md +++ b/plans/2026-07-14-topologia-wifi-hala.md @@ -4,7 +4,7 @@ Data: 2026-07-14 Autor analizy: czyste spojrzenie (bez oparcia o `docs/`) Status: projekt do wdrożenia i walidacji na miejscu -**Instrukcja montażu (EN/PL):** [../README.md](../README.md) / [../README_pl.md](../README_pl.md) +**Instrukcja montażu (EN/PL):** [../docs/networking/README.md](../docs/networking/README.md) / [../docs/networking/README_pl.md](../docs/networking/README_pl.md) **Instrukcja konfiguracji AP:** [2026-07-14-eap613-konfiguracja.md](./2026-07-14-eap613-konfiguracja.md) --- @@ -60,9 +60,13 @@ Konsekwencja: cały projekt sprowadza się do **utrzymania czystego, mało obci ## 4. Topologia (płaski L2, wired backhaul) +Dwa zestawy L2 (wybór operatora). Daemon `micronet` nie rozpoznaje modelu — tylko DHCPDISCOVER + ping `gateway.ip`. + +**Zestaw A — switch PoE TL-SF1006P:** BigFred = brama i DHCP (`gateway`). + ```mermaid flowchart TD - BigFred["BigFred (serwer, IP statyczny)"] -->|Ethernet| SW["Switch PoE TL-SF1006P (port 1 = Priority)"] + BigFred["BigFred (gateway plus dnsmasq)"] -->|Ethernet| SW["Switch PoE TL-SF1006P (port 1 = Priority)"] SW -->|"PoE + backhaul"| AP1["AP1 WiFi6 EAP613\n2.4G ch1\n5G ch36"] SW -->|"PoE + backhaul"| AP2["AP2 WiFi6 EAP613\n2.4G ch6\n5G ch149"] SW -->|"PoE + backhaul"| AP3["AP3 WiFi6 EAP613 (wariant A)\n2.4G ch11\n5G ch44"] @@ -74,6 +78,16 @@ flowchart TD AP3 -. 5GHz .-> Fony ``` +**Zestaw B — MikroTik hEX PoE lite RB750UPr2:** DHCP na routerze; BigFred = `client` / `static` (bez własnego dnsmasq). ether1 = BigFred (bez PoE), ether2–5 PoE → AP. + +```mermaid +flowchart TD + MT["MikroTik RB750UPr2 DHCP"] -->|ether1 no PoE| BF["BigFred client"] + MT -->|ether2 PoE| AP1r[AP1] + MT -->|ether3 PoE| AP2r[AP2] + MT -->|ether4 PoE| AP3r[AP3] +``` + - **Wariant A (rekomendowany)**: 3 AP na kanałach 2.4 GHz 1/6/11. - **Wariant B (fallback)**: 2 AP na kanałach 2.4 GHz 1/11 (AP3 pomijamy). - **Jeden VLAN / jedna podsieć** dla wszystkich klientów + BigFred → mDNS działa natywnie. Bez routingu, bez internetu. @@ -108,17 +122,24 @@ Dlaczego 3 AP w tym scenariuszu: - OFDMA, MU-MIMO, airtime fairness, band steering. - Lepszy w otwartej przestrzeni / przy montażu pod sufitem. Przy montażu 2 m w tłumie **2 punkty dają gorsze pokrycie niż 3 słabsze** — stąd niższy priorytet. -### 5.3 Switch PoE (poza budżetem AP) +### 5.3 Backhaul L2 (poza budżetem AP) -**TP-Link TL-SF1006P** — w pełni wystarczający dla obu wariantów. ~130–160 zł. +**TP-Link TL-SF1006P** — zestaw A. ~130–160 zł. - 6 portów, **4× PoE+ (802.3af/at, do 30 W/port, budżet 67 W)**, unmanaged, plug-and-play. - Dla 3 AP: 3 porty PoE na AP + 1 port na BigFreda = 4/6 portów zajęte. Pobór 3× ~11 W = **~33 W ≪ 67 W** budżetu. - **Fast Ethernet 10/100 Mb/s to zero problemu** przy potwierdzonym profilu ruchu (małe pakiety DCC + sporadyczne obrazki < 200 kB, brak internetu). 100 Mb/s full-duplex daje ogromny zapas, serializacja ramki ~0,12 ms — bez wpływu na cel < 25 ms. - Unmanaged pasuje do płaskiego L2 (bez VLAN). mDNS przejdzie (flood na małej sieci pomijalny), IGMP/multicast-to-unicast realizujemy na AP. - Bonus: **Priority Mode na portach 1–2** → podłączyć BigFreda pod port 1. +- Na tym zestawie **BigFred jest bramą i serwerem DHCP** (`micronet` tryb `gateway`). - Gigabit (TL-SG1005P / SG1008P) tylko jeśli w przyszłości pojawi się cięższy ruch — obecnie zbędny. +**MikroTik hEX PoE lite RB750UPr2** — zestaw B (gdy na evencie jest router z DHCP i 4× PoE na Omady). + +- 5× Fast Ethernet; **ether2–5 PoE**, ether1 bez PoE → BigFred. +- DHCP na routerze → BigFred **nie** startuje dnsmasq (`client` albo `static` `.252`). +- 3 AP + 1 zapas na ether2–5. + ### 5.4 Czego unikać - Konsumenckich routerów w trybie AP (słabe zarządzanie high-density, brak strojenia). From e0159c92920d0071d79f845c5f00acf0ae3adef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:35:43 +0200 Subject: [PATCH 2/3] Keep dnsmasq DNS on and refresh client lease status. - Drop port=0 from dnsmasq.conf so the DNS listener stays on; we hand out option:dns-server = gateway.ip and ARCHITECTURE describes DHCP+DNS. - Poll the interface every ~3s in client mode and update the status snapshot when dhclient finally acquires a lease, so micronet check liveness sees a non-empty cidr instead of looping restarts. Co-authored-by: Cursor --- crates/micronet/src/daemon/mod.rs | 35 ++++++++++++++++++++++++++++++- crates/micronet/src/dhcp/conf.rs | 3 ++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/micronet/src/daemon/mod.rs b/crates/micronet/src/daemon/mod.rs index 1630ab1..87e18e7 100644 --- a/crates/micronet/src/daemon/mod.rs +++ b/crates/micronet/src/daemon/mod.rs @@ -5,14 +5,19 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::apply::{self, ProbePolicy, Status}; use crate::config; use crate::error::{Error, Result}; use crate::ipc::{self, IpcEvent, Shared}; +use crate::net::LiveNet; +use crate::net::NetOps; use crate::signals; +/// How often the daemon re-checks whether a slow `dhclient` lease arrived. +const CLIENT_STATUS_REFRESH: Duration = Duration::from_secs(3); + /// Run until SIGTERM/SIGINT. pub fn run(config_path: &Path, socket: &Path) -> Result<()> { let cfg = config::load_or_create(config_path)?; @@ -43,6 +48,8 @@ pub fn run(config_path: &Path, socket: &Path) -> Result<()> { signals::install(&stop)?; let config_path = config_path.to_path_buf(); + let net = LiveNet::new(); + let mut next_refresh = Instant::now() + CLIENT_STATUS_REFRESH; loop { if stop.load(Ordering::SeqCst) { break; @@ -59,6 +66,10 @@ pub fn run(config_path: &Path, socket: &Path) -> Result<()> { if event == Some(IpcEvent::Reconfigure) { on_reconfigure(&shared); } + if Instant::now() >= next_refresh { + refresh_client_status(&shared, &net); + next_refresh = Instant::now() + CLIENT_STATUS_REFRESH; + } thread::sleep(Duration::from_millis(0)); } @@ -125,6 +136,28 @@ fn on_reconfigure(shared: &Shared) { } } +/// In client mode a `dhclient` lease can arrive after the short apply wait. +/// Poll the interface and update the snapshot so `micronet check` liveness +/// sees a non-empty `cidr` instead of restarting the service in a loop. +fn refresh_client_status(shared: &Shared, net: &LiveNet) { + let (mode, iface, cidr) = match shared.status.read() { + Ok(s) => (s.mode, s.iface.clone(), s.cidr.clone()), + Err(_) => { + log::warn!("status lock poisoned"); + return; + } + }; + if mode != apply::Mode::Client || !cidr.is_none() || iface.is_empty() { + return; + } + if net.iface_has_ipv4(&iface) { + if let Ok(mut st) = shared.status.write() { + st.cidr = Some(format!("{iface} dhcp")); + log::info!("client lease acquired on {iface}"); + } + } +} + /// One-shot apply (CLI `apply` / argv0 aliases). pub fn apply_once(config_path: &Path) -> Result { let cfg = config::load(config_path)?; diff --git a/crates/micronet/src/dhcp/conf.rs b/crates/micronet/src/dhcp/conf.rs index bc43c6b..bcb863c 100644 --- a/crates/micronet/src/dhcp/conf.rs +++ b/crates/micronet/src/dhcp/conf.rs @@ -20,7 +20,6 @@ pub fn render_conf(cfg: &Config, iface: &str, leasefile: &Path) -> String { interface={iface} bind-interfaces listen-address={gw} -port=0 dhcp-range={start},{end},{mask},{sticky} dhcp-option=option:router,{gw} dhcp-option=option:dns-server,{gw} @@ -62,6 +61,8 @@ mod tests { assert!(body.contains("option:router,192.168.0.1")); assert!(body.contains("dhcp-range=192.168.0.50,192.168.0.200")); assert!(!body.contains("dhcp-host=")); + // DNS listener must stay on: we hand out option:dns-server = gateway.ip. + assert!(!body.contains("port=0")); } #[test] From 709bedb714cf5ece26417455dae5e9346a83bc7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:03:09 +0200 Subject: [PATCH 3/3] Harden DHCP probe, process ownership, and gateway yield. Fail closed on probe errors and validate DHCPOFFER xid/chaddr/opcode. Own dnsmasq and dhclient via pidfiles, start dhclient -nw, and periodically re-probe in gateway mode so a later foreign DHCP server makes us yield. Live liveness, real CIDR, teardown CLI, and legacy configure-* check fallback. Co-authored-by: Cursor --- ARCHITECTURE.md | 69 +++++++-- README.md | 1 + crates/micronet/src/apply/mod.rs | 243 +++++++++++++++++++++++++---- crates/micronet/src/config/mod.rs | 35 +++++ crates/micronet/src/constants.rs | 8 + crates/micronet/src/daemon/mod.rs | 214 ++++++++++++++++++++++--- crates/micronet/src/dhcp/conf.rs | 20 ++- crates/micronet/src/dhcp/run.rs | 84 +++++----- crates/micronet/src/error.rs | 6 + crates/micronet/src/lib.rs | 1 + crates/micronet/src/main.rs | 35 ++++- crates/micronet/src/net/addr.rs | 45 +++++- crates/micronet/src/net/mod.rs | 88 +++++++---- crates/micronet/src/net/probe.rs | 249 ++++++++++++++++++++++++++---- crates/micronet/src/pidfile.rs | 159 +++++++++++++++++++ docs/networking/README.md | 2 + docs/networking/README_pl.md | 2 + 17 files changed, 1076 insertions(+), 185 deletions(-) create mode 100644 crates/micronet/src/pidfile.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a86ad68..4f0a625 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -81,7 +81,7 @@ flowchart TD ``` crates/micronet/src/ - main.rs, lib.rs, error.rs, datadir.rs, constants.rs, version.rs, signals.rs + main.rs, lib.rs, error.rs, datadir.rs, constants.rs, version.rs, signals.rs, pidfile.rs config/ JSON + inotify watch net/ physical Ethernet, probe, addr dhcp/ dnsmasq conf + process @@ -101,11 +101,12 @@ ARCHITECTURE.md | Dir | Job | |---|---| | `config` | camelCase JSON, validate, load_or_create (example **without** `socket`), inotify debounce ~300 ms | -| `net` | iface filter, `ip` / `ping` / `dhclient`, DHCPDISCOVER encode/probe | -| `dhcp` | render `dnsmasq.conf`, start / SIGHUP / restart / stop | -| `apply` | probe policy, mode apply | +| `net` | iface filter, `ip` / `ping` / pidfile-owned `dhclient`, DHCPDISCOVER encode/probe | +| `dhcp` | render `dnsmasq.conf`, start / SIGHUP / restart / stop (pidfile only) | +| `pidfile` | TERM/KILL one process; never `killall` | +| `apply` | probe policy, mode apply, teardown, live health | | `ipc` | `bind_singleton`, framing | -| `daemon` | watch + IPC + apply; socket path is **not** hot-reloaded | +| `daemon` | watch + IPC + apply + gateway recheck; socket path is **not** hot-reloaded | `net` and `dhcp` MUST NOT import `ipc`. @@ -113,21 +114,43 @@ ARCHITECTURE.md ## 6. Mode selection -1. Link up, no address; kill leftover `dhclient`. -2. If currently serving DHCP, stop dnsmasq before a full probe (do not +1. Link up, no address; stop **our** leftover `dhclient` (per-iface pidfile). +2. If currently serving DHCP, stop **our** dnsmasq before a full probe (do not offer to ourselves). -3. DHCPDISCOVER, wait `probeTimeoutSecs` for DHCPOFFER. -4. Offer → `client`. -5. Else assign `staticHost` (default **252**), ping `gateway.ip` +3. DHCPDISCOVER, wait `probeTimeoutSecs` for a DHCPOFFER that matches + `xid`, `chaddr`, `BootReply`, Ethernet, option 53 = Offer. +4. Probe **errors** (bind, `SO_BINDTODEVICE`, send) abort apply — fail + closed. Do **not** start dnsmasq when the probe did not complete. +5. Valid offer → `client` (`dhclient -nw` with pidfile/leasefile). +6. Else assign `staticHost` (default **252**), ping `gateway.ip` (`-c 1 -W 2`). If `gateway.ip` is already local, treat ping as fail (stay / become gateway). -6. Ping OK → `static` (keep `.252`, `default via gateway.ip`, stop dnsmasq). -7. Ping fail → `gateway` (drop `.252`, `gateway.ip/prefix`, dnsmasq, +7. Ping OK → `static` (keep `.252`, `default via gateway.ip`, stop dnsmasq). +8. Ping fail → `gateway` (drop `.252`, `gateway.ip/prefix`, dnsmasq, **no** default route). +`status.cidr` is always a real IPv4 prefix (`a.b.c.d/24`) or `null`. + `staticHost` MUST lie in the `/24`, differ from `gateway.ip`, and sit outside `[rangeStart, rangeEnd]`. +### 6.1 Gateway yield (foreign DHCP appears later) + +While `mode == gateway`, every `GATEWAY_FOREIGN_DHCP_INTERVAL` (15 s) +the daemon sends DHCPDISCOVER **without** stopping dnsmasq (one in-flight +probe, off the main loop). Offers from our own server-id / local inet +are ignored. A foreign offer → stop **our** dnsmasq immediately and +become `client`. Yield is **one-way**; returning to gateway requires +`reconfigure` or a process restart. + +Periodic probe errors stay gateway (already serving; uncertainty is not +a yield). JSON reload while gateway still skips DISCOVER (`SkipDhcpWhileGateway`); +the periodic probe covers “router appeared later.” IPC `reconfigure` is +always a full probe. + +Process ownership: `$DATA_DIR/run/dnsmasq.pid` and +`$DATA_DIR/run/dhclient..pid`. Never `killall`. + Two operator kits (daemon only sees DHCP + ping): - **TL-SF1006P** — empty LAN → `gateway`, BigFred DHCP. @@ -168,17 +191,29 @@ Requests `{ "type": "status" | "info" | "reconfigure" }`. `status` fields (camelCase): `mode`, `iface`, `cidr`, `foreignDhcp`, `gatewayReachable`, `dnsmasqRunning`. -CLI: `serve` / `run` (default), `apply`, `status`, `check` (exit 0 when -iface + IPv4 via socket), `reconfigure`, `info`. Global `--config`, -`--socket`, `--data-dir`. Relative `--socket` / `--config` join under -the data root; absolute `--socket` is CLI-only (tests). +CLI: `serve` / `run` (default), `apply`, `status`, `check`, `teardown`, +`reconfigure`, `info`. Global `--config`, `--socket`, `--data-dir`. +Relative `--socket` / `--config` join under the data root; absolute +`--socket` is CLI-only (tests). + +`micronet check` (microinit): IPC must succeed, then **live** health — +ignore cached `cidr`. No carrier → success (do not restart on unplug). +Carrier up requires a live IPv4 and the owned process (`dnsmasq` in +gateway, `dhclient` in client, address only in static). + +`configure-ethernet` / `configure-dhcp check`: same live check when the +daemon socket answers; if the socket is missing, iface UP + IPv4 only +(legacy one-shot after `apply` exited). + +`micronet teardown`: stop our dnsmasq and dhclient, flush the managed +iface, delete the default route (full service stop). --- ## 9. Integration - microinit service `network`: `daemon: true`, `exec /usr/sbin/micronet serve`, - liveness `micronet check` (~20 s). + liveness `micronet check` (~20 s). Stop runs `micronet teardown`. - `configure-dhcp` service is removed. - bigfred-os fetch installs `/usr/sbin/micronet` (optional argv0 aliases). - Overlay `etc/micronet/micronet.json` seeds `$DATA_DIR/etc/micronet.json` diff --git a/README.md b/README.md index cef4885..6d2e34e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ DHCPDISCOVER probe and a ping of `gateway.ip`. ## Features - Three modes: foreign DHCP → `client` (`dhclient`); live `gateway.ip` → `static`; empty LAN → `gateway` + dnsmasq +- If a router/DHCP server appears later, gateway mode **yields**: stops dnsmasq and runs `dhclient` - DHCPDISCOVER only (no REQUEST); ICMP ping of `gateway.ip` after a temporary `.252` - dnsmasq only in `gateway` (pool `.50–.200`, sticky MAC→IP lease **7d**, `option:router` / `dns-server`) - Physical Ethernet only (not `lo`, bridge, virtual, Wi-Fi) diff --git a/crates/micronet/src/apply/mod.rs b/crates/micronet/src/apply/mod.rs index f5a4c34..5d62133 100644 --- a/crates/micronet/src/apply/mod.rs +++ b/crates/micronet/src/apply/mod.rs @@ -6,10 +6,13 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::config::{default_dnsmasq_conf_path, default_dnsmasq_leasefile, Config}; +use crate::config::{ + default_dnsmasq_conf_path, default_dnsmasq_leasefile, default_dnsmasq_pidfile, Config, +}; use crate::constants::{DHCP_CLIENT_WAIT, REQUIRED_PREFIX}; use crate::dhcp; use crate::error::Result; +use crate::net::addr::cidr_ipv4; use crate::net::probe::{self, read_mac}; use crate::net::{LiveNet, NetOps}; @@ -39,6 +42,7 @@ impl Mode { pub struct Status { pub mode: Mode, pub iface: String, + /// Real IPv4 prefix (`a.b.c.d/24`) or `None` if unassigned. pub cidr: Option, pub foreign_dhcp: bool, pub gateway_reachable: bool, @@ -58,7 +62,7 @@ impl Status { } } - /// Liveness: interface has an IPv4 (CIDR recorded). + /// Cached snapshot has an IPv4 CIDR recorded (not a live check). #[must_use] pub fn is_up(&self) -> bool { self.cidr.is_some() && !self.iface.is_empty() @@ -80,10 +84,12 @@ pub fn decide(foreign_dhcp: bool, gateway_reachable: bool) -> Mode { /// How apply should probe. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProbePolicy { - /// Full DHCPDISCOVER + ping (start, IPC reconfigure, client/static reload). + /// Stop our dnsmasq, then DHCPDISCOVER + ping (start, IPC reconfigure). Full, /// Skip DHCPDISCOVER (we may be serving). Ping `gateway.ip` unless it is ours. SkipDhcpWhileGateway, + /// Foreign DHCP already proved; stop our dnsmasq and start dhclient. + BecomeClient, } /// DHCP server + probe, injectable in tests (must not import `ipc`). @@ -91,7 +97,12 @@ pub trait GatewayCtl { fn dhcp_running(&self) -> bool; fn dhcp_stop(&self) -> Result<()>; fn dhcp_reload_or_restart(&self, cfg: &Config, iface: &str) -> Result<()>; - fn probe_foreign_dhcp(&self, iface: &str, timeout: Duration) -> bool; + fn probe_foreign_dhcp( + &self, + iface: &str, + timeout: Duration, + ignore_servers: &[Ipv4Addr], + ) -> Result; } /// Live dnsmasq + DHCPDISCOVER. @@ -109,29 +120,23 @@ impl GatewayCtl for LiveGateway { fn dhcp_reload_or_restart(&self, cfg: &Config, iface: &str) -> Result<()> { let conf_path = default_dnsmasq_conf_path(); let leasefile = default_dnsmasq_leasefile(); + let pidfile = default_dnsmasq_pidfile(); if let Some(parent) = leasefile.parent() { let _ = std::fs::create_dir_all(parent); } - let body = dhcp::render_conf(cfg, iface, &leasefile); + let body = dhcp::render_conf(cfg, iface, &leasefile, &pidfile); let changed = dhcp::conf::ensure_conf(&conf_path, &body)?; dhcp::reload_or_restart(&conf_path, changed) } - fn probe_foreign_dhcp(&self, iface: &str, timeout: Duration) -> bool { - let mac = match read_mac(Path::new("/sys/class/net"), iface) { - Ok(m) => m, - Err(e) => { - log::warn!("MAC read failed ({e}); treating as no foreign DHCP"); - return false; - } - }; - match probe::probe_foreign_dhcp(iface, &mac, timeout) { - Ok(v) => v, - Err(e) => { - log::warn!("DHCP probe failed ({e}); treating as no offer"); - false - } - } + fn probe_foreign_dhcp( + &self, + iface: &str, + timeout: Duration, + ignore_servers: &[Ipv4Addr], + ) -> Result { + let mac = read_mac(Path::new("/sys/class/net"), iface)?; + probe::probe_foreign_dhcp(iface, &mac, timeout, ignore_servers) } } @@ -150,7 +155,11 @@ pub fn apply_with( cfg.validate()?; let iface = net.resolve_iface(cfg.interface.as_deref())?; net.bring_up(&iface)?; - net.kill_dhclient()?; + net.stop_dhclient(&iface)?; + + if policy == ProbePolicy::BecomeClient { + return apply_client(cfg, net, gw, &iface); + } let skip_dhcp = policy == ProbePolicy::SkipDhcpWhileGateway; let foreign_dhcp = if skip_dhcp { @@ -160,7 +169,7 @@ pub fn apply_with( log::info!("stopping own dnsmasq before DHCP probe"); gw.dhcp_stop()?; } - gw.probe_foreign_dhcp(&iface, Duration::from_secs(cfg.probe_timeout_secs)) + gw.probe_foreign_dhcp(&iface, Duration::from_secs(cfg.probe_timeout_secs), &[])? }; let static_cidr = cfg.static_cidr(); @@ -188,6 +197,48 @@ pub fn apply_with( } } +/// Stop managed dnsmasq/dhclient and flush the resolved iface. +pub fn teardown_with(cfg: &Config, net: &N, gw: &G) -> Result { + cfg.validate()?; + let iface = net.resolve_iface(cfg.interface.as_deref())?; + gw.dhcp_stop()?; + net.stop_dhclient(&iface)?; + net.flush_addr(&iface)?; + net.del_default()?; + Ok(Status { + mode: Mode::Gateway, + iface, + cidr: None, + foreign_dhcp: false, + gateway_reachable: false, + dnsmasq_running: gw.dhcp_running(), + }) +} + +pub fn teardown(cfg: &Config) -> Result { + teardown_with(cfg, &LiveNet::new(), &LiveGateway) +} + +/// Live liveness: empty iface fails; no carrier succeeds (avoid unplug restart loops); +/// with carrier require a live IPv4 plus the process that belongs to the mode. +#[must_use] +pub fn live_health(mode: Mode, iface: &str, net: &N, gw: &G) -> bool { + if iface.is_empty() { + return false; + } + if !net.carrier_up(iface) { + return true; + } + if net.iface_ipv4_cidr(iface).is_none() { + return false; + } + match mode { + Mode::Gateway => gw.dhcp_running(), + Mode::Client => net.dhclient_running(iface), + Mode::Static => true, + } +} + fn apply_client( cfg: &Config, net: &N, @@ -198,13 +249,11 @@ fn apply_client( gw.dhcp_stop()?; net.flush_addr(iface)?; net.start_dhclient(iface)?; - let got = net.wait_ipv4(iface, DHCP_CLIENT_WAIT); - let cidr = if got { - Some(format!("{iface} dhcp")) - } else { + let _ = net.wait_ipv4(iface, DHCP_CLIENT_WAIT); + let cidr = net.iface_ipv4_cidr(iface); + if cidr.is_none() { log::warn!("dhclient did not assign an address within {DHCP_CLIENT_WAIT:?}"); - None - }; + } Ok(Status { mode: Mode::Client, iface: iface.to_string(), @@ -278,6 +327,18 @@ pub fn decide_status( } } +/// Ignore list for a periodic gateway probe: configured gateway.ip plus local inet. +#[must_use] +pub fn periodic_ignore_servers(cfg: &Config, local_cidr: Option<&str>) -> Vec { + let mut ignore = vec![cfg.gateway.ip]; + if let Some(ip) = local_cidr.and_then(cidr_ipv4) { + if !ignore.contains(&ip) { + ignore.push(ip); + } + } + ignore +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -293,6 +354,7 @@ mod tests { addrs: Mutex>, dhclient: Mutex, default_via: Mutex>, + carrier: bool, } impl FakeNet { @@ -302,6 +364,7 @@ mod tests { addrs: Mutex::new(Vec::new()), dhclient: Mutex::new(false), default_via: Mutex::new(None), + carrier: true, } } } @@ -336,16 +399,29 @@ mod tests { self.addrs.lock().unwrap().push(ip); Ok(()) } - fn iface_has_ipv4(&self, _iface: &str) -> bool { - !self.addrs.lock().unwrap().is_empty() || *self.dhclient.lock().unwrap() + fn iface_has_ipv4(&self, iface: &str) -> bool { + self.iface_ipv4_cidr(iface).is_some() } fn iface_has_addr(&self, _iface: &str, ip: Ipv4Addr) -> bool { self.addrs.lock().unwrap().contains(&ip) } + fn iface_ipv4_cidr(&self, _iface: &str) -> Option { + if *self.dhclient.lock().unwrap() { + return Some("192.168.0.50/24".into()); + } + self.addrs + .lock() + .unwrap() + .first() + .map(|ip| format!("{ip}/{REQUIRED_PREFIX}")) + } + fn carrier_up(&self, _iface: &str) -> bool { + self.carrier + } fn ping(&self, _host: Ipv4Addr) -> bool { self.ping_ok } - fn kill_dhclient(&self) -> Result<()> { + fn stop_dhclient(&self, _iface: &str) -> Result<()> { *self.dhclient.lock().unwrap() = false; Ok(()) } @@ -353,6 +429,9 @@ mod tests { *self.dhclient.lock().unwrap() = true; Ok(()) } + fn dhclient_running(&self, _iface: &str) -> bool { + *self.dhclient.lock().unwrap() + } fn wait_ipv4(&self, iface: &str, _timeout: Duration) -> bool { self.iface_has_ipv4(iface) } @@ -368,6 +447,7 @@ mod tests { struct FakeGw { offer: bool, + fail_probe: bool, running: Mutex, started: Mutex, } @@ -376,6 +456,7 @@ mod tests { fn new(offer: bool) -> Self { Self { offer, + fail_probe: false, running: Mutex::new(false), started: Mutex::new(false), } @@ -395,8 +476,16 @@ mod tests { *self.started.lock().unwrap() = true; Ok(()) } - fn probe_foreign_dhcp(&self, _iface: &str, _timeout: Duration) -> bool { - self.offer + fn probe_foreign_dhcp( + &self, + _iface: &str, + _timeout: Duration, + _ignore_servers: &[Ipv4Addr], + ) -> Result { + if self.fail_probe { + return Err(Error::DhcpProbe("bind failed".into())); + } + Ok(self.offer) } } @@ -417,7 +506,7 @@ mod tests { } #[test] - fn apply_foreign_dhcp_is_client() { + fn apply_foreign_dhcp_is_client_with_real_cidr() { let cfg = Config::default(); let net = FakeNet::new(false); let gw = FakeGw::new(true); @@ -426,6 +515,8 @@ mod tests { assert!(s.foreign_dhcp); assert!(!*gw.started.lock().unwrap()); assert!(*net.dhclient.lock().unwrap()); + assert_eq!(s.cidr.as_deref(), Some("192.168.0.50/24")); + assert!(!s.cidr.as_deref().unwrap().contains("dhcp")); } #[test] @@ -451,4 +542,88 @@ mod tests { assert!(*gw.started.lock().unwrap()); assert!(net.default_via.lock().unwrap().is_none()); } + + #[test] + fn probe_err_does_not_start_dnsmasq() { + let cfg = Config::default(); + let net = FakeNet::new(false); + let mut gw = FakeGw::new(false); + gw.fail_probe = true; + let err = apply_with(&cfg, ProbePolicy::Full, &net, &gw).unwrap_err(); + assert!(matches!(err, Error::DhcpProbe(_))); + assert!(!*gw.started.lock().unwrap()); + assert!(!*gw.running.lock().unwrap()); + } + + #[test] + fn become_client_stops_dnsmasq_without_probe() { + let cfg = Config::default(); + let net = FakeNet::new(false); + let gw = FakeGw::new(false); + *gw.running.lock().unwrap() = true; + let s = apply_with(&cfg, ProbePolicy::BecomeClient, &net, &gw).unwrap(); + assert_eq!(s.mode, Mode::Client); + assert!(!*gw.running.lock().unwrap()); + assert!(!*gw.started.lock().unwrap()); + assert!(*net.dhclient.lock().unwrap()); + assert_eq!(s.cidr.as_deref(), Some("192.168.0.50/24")); + } + + #[test] + fn teardown_stops_managed_state() { + let cfg = Config::default(); + let net = FakeNet::new(false); + let gw = FakeGw::new(false); + *gw.running.lock().unwrap() = true; + *net.dhclient.lock().unwrap() = true; + net.addrs + .lock() + .unwrap() + .push(Ipv4Addr::new(192, 168, 0, 1)); + let s = teardown_with(&cfg, &net, &gw).unwrap(); + assert!(s.cidr.is_none()); + assert!(!*gw.running.lock().unwrap()); + assert!(!*net.dhclient.lock().unwrap()); + assert!(net.addrs.lock().unwrap().is_empty()); + } + + #[test] + fn live_health_matrix() { + let net = FakeNet::new(false); + let gw = FakeGw::new(false); + assert!(!live_health(Mode::Gateway, "", &net, &gw)); + + *gw.running.lock().unwrap() = true; + net.addrs + .lock() + .unwrap() + .push(Ipv4Addr::new(192, 168, 0, 1)); + assert!(live_health(Mode::Gateway, "eth0", &net, &gw)); + *gw.running.lock().unwrap() = false; + assert!(!live_health(Mode::Gateway, "eth0", &net, &gw)); + + let mut down = FakeNet::new(false); + down.carrier = false; + assert!(live_health(Mode::Gateway, "eth0", &down, &gw)); + + *net.dhclient.lock().unwrap() = true; + assert!(live_health(Mode::Client, "eth0", &net, &gw)); + *net.dhclient.lock().unwrap() = false; + net.addrs.lock().unwrap().clear(); + net.addrs + .lock() + .unwrap() + .push(Ipv4Addr::new(192, 168, 0, 252)); + assert!(!live_health(Mode::Client, "eth0", &net, &gw)); + assert!(live_health(Mode::Static, "eth0", &net, &gw)); + } + + #[test] + fn periodic_ignore_includes_gateway_and_local() { + let cfg = Config::default(); + let v = periodic_ignore_servers(&cfg, Some("192.168.0.1/24")); + assert_eq!(v, vec![cfg.gateway.ip]); + let v = periodic_ignore_servers(&cfg, Some("192.168.0.50/24")); + assert_eq!(v, vec![cfg.gateway.ip, Ipv4Addr::new(192, 168, 0, 50)]); + } } diff --git a/crates/micronet/src/config/mod.rs b/crates/micronet/src/config/mod.rs index 2a32b64..3942158 100644 --- a/crates/micronet/src/config/mod.rs +++ b/crates/micronet/src/config/mod.rs @@ -38,6 +38,27 @@ pub fn default_dnsmasq_leasefile() -> PathBuf { datadir::path(["etc", "dnsmasq.leases"]) } +#[must_use] +pub fn default_dnsmasq_pidfile() -> PathBuf { + datadir::path(["run", "dnsmasq.pid"]) +} + +/// Per-iface dhclient pidfile (`$DATA_DIR/run/dhclient..pid`). +#[must_use] +pub fn dhclient_pidfile(iface: &str) -> PathBuf { + let mut p = datadir::path(["run"]); + p.push(format!("dhclient.{iface}.pid")); + p +} + +/// Per-iface dhclient leasefile (`$DATA_DIR/etc/dhclient..leases`). +#[must_use] +pub fn dhclient_leasefile(iface: &str) -> PathBuf { + let mut p = datadir::path(["etc"]); + p.push(format!("dhclient.{iface}.leases")); + p +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct GatewayConfig { @@ -176,6 +197,11 @@ impl Config { if name.is_empty() { return Err(Error::Config("interface must not be empty".into())); } + if name.contains('/') || name.contains('\0') { + return Err(Error::Config( + "interface must be a simple device name".into(), + )); + } } Ok(()) } @@ -293,6 +319,15 @@ mod tests { Config::default().validate().unwrap(); } + #[test] + fn interface_path_rejected() { + let c = Config { + interface: Some("eth0/evil".into()), + ..Config::default() + }; + assert!(c.validate().is_err()); + } + #[test] fn sticky_parses() { assert_eq!(parse_sticky("7d").unwrap(), 7 * 86_400); diff --git a/crates/micronet/src/constants.rs b/crates/micronet/src/constants.rs index 74b796d..966eca3 100644 --- a/crates/micronet/src/constants.rs +++ b/crates/micronet/src/constants.rs @@ -15,6 +15,14 @@ pub const PING_COUNT: &str = "1"; pub const PING_TIMEOUT_SEC: &str = "2"; /// Wait for dhclient to assign an address. pub const DHCP_CLIENT_WAIT: Duration = Duration::from_secs(5); +/// Period between gateway-mode DHCPDISCOVER starts (in-flight probes never overlap). +pub const GATEWAY_FOREIGN_DHCP_INTERVAL: Duration = Duration::from_secs(15); +/// UDP recv timeout used inside a DHCPDISCOVER wait loop. +pub const DHCP_PROBE_RECV_TIMEOUT: Duration = Duration::from_millis(250); +/// SIGTERM grace before SIGKILL for a pidfile-owned process. +pub const PROCESS_TERM_WAIT: Duration = Duration::from_millis(400); +/// How often the daemon refreshes live CIDR / process flags. +pub const STATUS_REFRESH: Duration = Duration::from_secs(3); pub const IP_BIN: &str = "/sbin/ip"; pub const DHCLIENT_BIN: &str = "/sbin/dhclient"; diff --git a/crates/micronet/src/daemon/mod.rs b/crates/micronet/src/daemon/mod.rs index 87e18e7..4ec964f 100644 --- a/crates/micronet/src/daemon/mod.rs +++ b/crates/micronet/src/daemon/mod.rs @@ -1,22 +1,50 @@ //! Daemon loop: apply, Unix socket, inotify config reload. use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; -use crate::apply::{self, ProbePolicy, Status}; +use crate::apply::{self, live_health, GatewayCtl, LiveGateway, ProbePolicy, Status}; use crate::config; +use crate::constants::{GATEWAY_FOREIGN_DHCP_INTERVAL, STATUS_REFRESH}; use crate::error::{Error, Result}; use crate::ipc::{self, IpcEvent, Shared}; use crate::net::LiveNet; use crate::net::NetOps; use crate::signals; -/// How often the daemon re-checks whether a slow `dhclient` lease arrived. -const CLIENT_STATUS_REFRESH: Duration = Duration::from_secs(3); +/// How `micronet check` vs legacy `configure-* check` treat a missing daemon socket. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckStyle { + /// Socket down → unhealthy (microinit must restart the daemon). + Daemon, + /// Socket down → iface-only check (one-shot apply already exited). + Legacy, +} + +#[must_use] +pub fn check_style_from_argv0(name: &str) -> CheckStyle { + match name { + "configure-ethernet" | "configure-dhcp" => CheckStyle::Legacy, + _ => CheckStyle::Daemon, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Recheck { + Foreign(u64), + Empty(u64), + Failed(u64), +} + +/// True when a gateway-mode recheck should yield (stop dnsmasq, become client). +#[must_use] +pub(crate) fn should_yield_gateway(current_epoch: u64, result: Recheck) -> bool { + matches!(result, Recheck::Foreign(e) if e == current_epoch) +} /// Run until SIGTERM/SIGINT. pub fn run(config_path: &Path, socket: &Path) -> Result<()> { @@ -49,7 +77,12 @@ pub fn run(config_path: &Path, socket: &Path) -> Result<()> { let config_path = config_path.to_path_buf(); let net = LiveNet::new(); - let mut next_refresh = Instant::now() + CLIENT_STATUS_REFRESH; + let gw = LiveGateway; + let epoch = Arc::new(AtomicU64::new(1)); + let probe_in_flight = Arc::new(AtomicBool::new(false)); + let (recheck_tx, recheck_rx) = mpsc::channel(); + let mut next_refresh = Instant::now() + STATUS_REFRESH; + let mut next_gateway_probe = Instant::now() + GATEWAY_FOREIGN_DHCP_INTERVAL; loop { if stop.load(Ordering::SeqCst) { break; @@ -61,16 +94,28 @@ pub fn run(config_path: &Path, socket: &Path) -> Result<()> { Err(mpsc::RecvTimeoutError::Disconnected) => break, } if reload_rx.try_recv().is_ok() { + bump_epoch(&epoch); on_config_reload(&shared, &config_path); } if event == Some(IpcEvent::Reconfigure) { + bump_epoch(&epoch); on_reconfigure(&shared); } + while let Ok(result) = recheck_rx.try_recv() { + on_recheck_result(&shared, &epoch, result); + } if Instant::now() >= next_refresh { - refresh_client_status(&shared, &net); - next_refresh = Instant::now() + CLIENT_STATUS_REFRESH; + refresh_live_status(&shared, &net, &gw); + maybe_spawn_gateway_recheck( + &shared, + &net, + &epoch, + &probe_in_flight, + &recheck_tx, + &mut next_gateway_probe, + ); + next_refresh = Instant::now() + STATUS_REFRESH; } - thread::sleep(Duration::from_millis(0)); } watch_stop.store(true, Ordering::SeqCst); @@ -79,6 +124,10 @@ pub fn run(config_path: &Path, socket: &Path) -> Result<()> { Ok(()) } +fn bump_epoch(epoch: &AtomicU64) { + epoch.fetch_add(1, Ordering::SeqCst); +} + fn on_config_reload(shared: &Shared, path: &Path) { match config::load(path) { Ok(new_cfg) => { @@ -136,25 +185,104 @@ fn on_reconfigure(shared: &Shared) { } } -/// In client mode a `dhclient` lease can arrive after the short apply wait. -/// Poll the interface and update the snapshot so `micronet check` liveness -/// sees a non-empty `cidr` instead of restarting the service in a loop. -fn refresh_client_status(shared: &Shared, net: &LiveNet) { - let (mode, iface, cidr) = match shared.status.read() { - Ok(s) => (s.mode, s.iface.clone(), s.cidr.clone()), +fn refresh_live_status(shared: &Shared, net: &LiveNet, gw: &LiveGateway) { + let (mode, iface) = match shared.status.read() { + Ok(s) => (s.mode, s.iface.clone()), Err(_) => { log::warn!("status lock poisoned"); return; } }; - if mode != apply::Mode::Client || !cidr.is_none() || iface.is_empty() { + if iface.is_empty() { return; } - if net.iface_has_ipv4(&iface) { - if let Ok(mut st) = shared.status.write() { - st.cidr = Some(format!("{iface} dhcp")); - log::info!("client lease acquired on {iface}"); + let cidr = net.iface_ipv4_cidr(&iface); + let dns = gw.dhcp_running(); + if let Ok(mut st) = shared.status.write() { + if st.cidr != cidr { + if mode == apply::Mode::Client && st.cidr.is_none() && cidr.is_some() { + log::info!("client lease acquired on {iface}"); + } + st.cidr = cidr; } + st.dnsmasq_running = dns; + } +} + +fn maybe_spawn_gateway_recheck( + shared: &Shared, + net: &LiveNet, + epoch: &Arc, + in_flight: &Arc, + tx: &mpsc::Sender, + next_gateway_probe: &mut Instant, +) { + if Instant::now() < *next_gateway_probe { + return; + } + if in_flight.load(Ordering::SeqCst) { + return; + } + let (mode, iface) = match shared.status.read() { + Ok(s) => (s.mode, s.iface.clone()), + Err(_) => return, + }; + if mode != apply::Mode::Gateway || iface.is_empty() { + return; + } + let cfg = match shared.config.read() { + Ok(c) => c.clone(), + Err(_) => return, + }; + *next_gateway_probe = Instant::now() + GATEWAY_FOREIGN_DHCP_INTERVAL; + let timeout = Duration::from_secs(cfg.probe_timeout_secs); + let ignore = apply::periodic_ignore_servers(&cfg, net.iface_ipv4_cidr(&iface).as_deref()); + let epoch_n = epoch.load(Ordering::SeqCst); + in_flight.store(true, Ordering::SeqCst); + let tx = tx.clone(); + let in_flight_thread = Arc::clone(in_flight); + if let Err(e) = thread::Builder::new() + .name("dhcp-recheck".into()) + .spawn(move || { + let result = match LiveGateway.probe_foreign_dhcp(&iface, timeout, &ignore) { + Ok(true) => Recheck::Foreign(epoch_n), + Ok(false) => Recheck::Empty(epoch_n), + Err(e) => { + log::warn!("gateway DHCP recheck failed ({e}); staying gateway"); + Recheck::Failed(epoch_n) + } + }; + in_flight_thread.store(false, Ordering::SeqCst); + let _ = tx.send(result); + }) + { + log::warn!("dhcp-recheck spawn failed: {e}"); + in_flight.store(false, Ordering::SeqCst); + } +} + +fn on_recheck_result(shared: &Shared, epoch: &AtomicU64, result: Recheck) { + let current = epoch.load(Ordering::SeqCst); + if !should_yield_gateway(current, result) { + return; + } + log::info!("foreign DHCP appeared; yielding gateway (stopping dnsmasq)"); + bump_epoch(epoch); + let cfg = match shared.config.read() { + Ok(c) => c.clone(), + Err(_) => { + log::warn!("config lock poisoned"); + return; + } + }; + match apply::apply(&cfg, ProbePolicy::BecomeClient) { + Ok(s) => { + log::info!("yielded → {}", s.mode.as_str()); + if let Ok(mut st) = shared.status.write() { + *st = s; + } + } + Err(e) => log::warn!("yield to client failed: {e}"), } } @@ -164,6 +292,12 @@ pub fn apply_once(config_path: &Path) -> Result { apply::apply(&cfg, ProbePolicy::Full) } +/// One-shot teardown of managed dnsmasq/dhclient/addresses. +pub fn teardown(config_path: &Path) -> Result { + let cfg = config::load(config_path)?; + apply::teardown(&cfg) +} + /// Resolve `--socket`: relative joined under data root; absolute kept. #[must_use] pub fn resolve_socket(cli: Option<&PathBuf>) -> PathBuf { @@ -184,11 +318,47 @@ pub fn resolve_config(cli: Option<&PathBuf>) -> PathBuf { } } -pub fn check_liveness(socket: &Path) -> Result { +pub fn check_liveness(socket: &Path, config_path: &Path, style: CheckStyle) -> Result { match ipc::call(socket, &ipc::Request::Status) { - Ok(ipc::Response::Status { cidr, iface, .. }) => Ok(cidr.is_some() && !iface.is_empty()), + Ok(ipc::Response::Status { mode, iface, .. }) => { + Ok(live_health(mode, &iface, &LiveNet::new(), &LiveGateway)) + } Ok(_) => Ok(false), - Err(Error::IoPath { .. }) | Err(Error::Io(_)) => Ok(false), + Err(Error::IoPath { .. }) | Err(Error::Io(_)) => match style { + CheckStyle::Daemon => Ok(false), + CheckStyle::Legacy => legacy_iface_up(config_path), + }, Err(e) => Err(e), } } + +fn legacy_iface_up(config_path: &Path) -> Result { + let cfg = config::load(config_path)?; + let net = LiveNet::new(); + let iface = net.resolve_iface(cfg.interface.as_deref())?; + Ok(net.carrier_up(&iface) && net.iface_has_ipv4(&iface)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_style_from_argv0_matrix() { + assert_eq!(check_style_from_argv0("micronet"), CheckStyle::Daemon); + assert_eq!( + check_style_from_argv0("configure-ethernet"), + CheckStyle::Legacy + ); + assert_eq!(check_style_from_argv0("configure-dhcp"), CheckStyle::Legacy); + } + + #[test] + fn stale_epoch_does_not_yield() { + assert!(!should_yield_gateway(1, Recheck::Foreign(0))); + assert!(should_yield_gateway(1, Recheck::Foreign(1))); + assert!(!should_yield_gateway(1, Recheck::Empty(1))); + assert!(!should_yield_gateway(1, Recheck::Failed(1))); + assert!(!should_yield_gateway(2, Recheck::Foreign(1))); + } +} diff --git a/crates/micronet/src/dhcp/conf.rs b/crates/micronet/src/dhcp/conf.rs index bcb863c..5b4436b 100644 --- a/crates/micronet/src/dhcp/conf.rs +++ b/crates/micronet/src/dhcp/conf.rs @@ -8,7 +8,7 @@ use crate::error::{Error, Result}; /// Render a gateway-mode dnsmasq config. #[must_use] -pub fn render_conf(cfg: &Config, iface: &str, leasefile: &Path) -> String { +pub fn render_conf(cfg: &Config, iface: &str, leasefile: &Path, pidfile: &Path) -> String { let mask = cfg.gateway.subnet.netmask(); let start = cfg.range_start_addr(); let end = cfg.range_end_addr(); @@ -24,9 +24,11 @@ dhcp-range={start},{end},{mask},{sticky} dhcp-option=option:router,{gw} dhcp-option=option:dns-server,{gw} dhcp-leasefile={lease} +pid-file={pid} dhcp-authoritative ", lease = leasefile.display(), + pid = pidfile.display(), ) } @@ -55,11 +57,17 @@ mod tests { #[test] fn render_contains_sticky_7d() { let cfg = Config::default(); - let body = render_conf(&cfg, "eth0", Path::new("/data/etc/dnsmasq.leases")); + let body = render_conf( + &cfg, + "eth0", + Path::new("/data/etc/dnsmasq.leases"), + Path::new("/data/run/dnsmasq.pid"), + ); assert!(body.contains("7d")); assert!(body.contains("dhcp-authoritative")); assert!(body.contains("option:router,192.168.0.1")); assert!(body.contains("dhcp-range=192.168.0.50,192.168.0.200")); + assert!(body.contains("pid-file=/data/run/dnsmasq.pid")); assert!(!body.contains("dhcp-host=")); // DNS listener must stay on: we hand out option:dns-server = gateway.ip. assert!(!body.contains("port=0")); @@ -71,9 +79,15 @@ mod tests { cfg.gateway.ip = Ipv4Addr::new(10, 0, 10, 1); cfg.gateway.subnet = "10.0.10.0/24".parse().unwrap(); cfg.dhcp.sticky = "7d".into(); - let body = render_conf(&cfg, "eth0", &PathBuf::from("/tmp/leases")); + let body = render_conf( + &cfg, + "eth0", + &PathBuf::from("/tmp/leases"), + &PathBuf::from("/tmp/dnsmasq.pid"), + ); assert!(body.contains("listen-address=10.0.10.1")); assert!(body.contains("10.0.10.50,10.0.10.200")); assert!(body.contains(",7d")); + assert!(body.contains("pid-file=/tmp/dnsmasq.pid")); } } diff --git a/crates/micronet/src/dhcp/run.rs b/crates/micronet/src/dhcp/run.rs index 9bec931..872e9b3 100644 --- a/crates/micronet/src/dhcp/run.rs +++ b/crates/micronet/src/dhcp/run.rs @@ -1,4 +1,4 @@ -//! Start / SIGHUP / restart / stop dnsmasq. +//! Start / SIGHUP / restart / stop our dnsmasq (pidfile-owned). use std::fs; use std::path::Path; @@ -9,24 +9,23 @@ use std::time::Duration; use nix::sys::signal::{kill, Signal}; use nix::unistd::Pid; +use crate::config::default_dnsmasq_pidfile; use crate::constants::DNSMASQ_BIN; use crate::error::{Error, Result}; +use crate::pidfile; /// Fields in the main conf that SIGHUP does not re-read (dnsmasq man). #[must_use] pub fn restart_required(old: &str, new: &str) -> bool { - if old == new { - return false; - } - true + old != new } #[must_use] pub fn is_running() -> bool { - !dnsmasq_pids().is_empty() + pidfile::is_alive(&default_dnsmasq_pidfile(), DNSMASQ_BIN) } -/// Start `dnsmasq -C conf` if not already running. +/// Start `dnsmasq -C conf -x pidfile` if not already running. pub fn start(conf: &Path) -> Result<()> { if !Path::new(DNSMASQ_BIN).is_file() { return Err(Error::DnsmasqMissing(Path::new(DNSMASQ_BIN).to_path_buf())); @@ -34,8 +33,16 @@ pub fn start(conf: &Path) -> Result<()> { if is_running() { return Ok(()); } + let pid_path = default_dnsmasq_pidfile(); + if let Some(parent) = pid_path.parent() { + fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; + } + if !pidfile::is_alive(&pid_path, DNSMASQ_BIN) { + let _ = fs::remove_file(&pid_path); + } + let pid_s = pid_path.to_string_lossy(); let status = Command::new(DNSMASQ_BIN) - .args(["-C", &conf.to_string_lossy()]) + .args(["-C", &conf.to_string_lossy(), "-x", pid_s.as_ref()]) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() @@ -50,20 +57,9 @@ pub fn start(conf: &Path) -> Result<()> { } } -/// Stop all dnsmasq processes (TERM, then KILL). +/// Stop our dnsmasq (TERM, then KILL). Missing pidfile is success. pub fn stop() -> Result<()> { - let pids = dnsmasq_pids(); - if pids.is_empty() { - return Ok(()); - } - for pid in &pids { - let _ = kill(Pid::from_raw(*pid), Signal::SIGTERM); - } - thread::sleep(Duration::from_millis(400)); - for pid in dnsmasq_pids() { - let _ = kill(Pid::from_raw(pid), Signal::SIGKILL); - } - Ok(()) + pidfile::stop(&default_dnsmasq_pidfile(), DNSMASQ_BIN) } /// Reload via SIGHUP; restart when conf changes require it or reload failed. @@ -95,45 +91,37 @@ pub fn reload_or_restart(conf: &Path, conf_changed: bool) -> Result<()> { } fn sighup() -> Result<()> { - let pids = dnsmasq_pids(); - if pids.is_empty() { + let pid_path = default_dnsmasq_pidfile(); + let Some(pid) = pidfile::read_pid(&pid_path) else { return Err(Error::Other("dnsmasq not running".into())); - } - for pid in pids { - kill(Pid::from_raw(pid), Signal::SIGHUP).map_err(Error::from)?; - } - Ok(()) -} - -fn dnsmasq_pids() -> Vec { - let Ok(entries) = fs::read_dir("/proc") else { - return Vec::new(); }; - let mut pids = Vec::new(); - for ent in entries.flatten() { - let name = ent.file_name(); - let Some(pid) = name.to_str().and_then(|s| s.parse::().ok()) else { - continue; - }; - let cmdline = fs::read(ent.path().join("cmdline")).unwrap_or_default(); - let text = String::from_utf8_lossy(&cmdline); - if text - .split('\0') - .any(|p| p == DNSMASQ_BIN || p.ends_with("/dnsmasq")) - { - pids.push(pid); - } + if !pidfile::is_alive(&pid_path, DNSMASQ_BIN) { + return Err(Error::Other("dnsmasq not running".into())); } - pids + kill(Pid::from_raw(pid), Signal::SIGHUP).map_err(Error::from)?; + Ok(()) } #[cfg(test)] mod tests { + #![allow(clippy::unwrap_used)] + use super::restart_required; + use crate::constants::DNSMASQ_BIN; + use crate::pidfile; + use tempfile::tempdir; #[test] fn restart_when_conf_differs() { assert!(restart_required("a", "b")); assert!(!restart_required("same", "same")); } + + #[test] + fn stop_on_missing_pidfile_is_ok() { + let dir = tempdir().unwrap(); + let path = dir.path().join("dnsmasq.pid"); + pidfile::stop(&path, DNSMASQ_BIN).unwrap(); + assert!(!pidfile::is_alive(&path, DNSMASQ_BIN)); + } } diff --git a/crates/micronet/src/error.rs b/crates/micronet/src/error.rs index cfa16aa..5adb671 100644 --- a/crates/micronet/src/error.rs +++ b/crates/micronet/src/error.rs @@ -40,6 +40,12 @@ pub enum Error { #[error("dnsmasq binary not found at {0}")] DnsmasqMissing(PathBuf), + #[error("dhclient binary not found at {0}")] + DhclientMissing(PathBuf), + + #[error("DHCP probe: {0}")] + DhcpProbe(String), + #[error("{0}")] Other(String), } diff --git a/crates/micronet/src/lib.rs b/crates/micronet/src/lib.rs index fd89fd3..73482ff 100644 --- a/crates/micronet/src/lib.rs +++ b/crates/micronet/src/lib.rs @@ -9,6 +9,7 @@ pub mod dhcp; pub mod error; pub mod ipc; pub mod net; +pub mod pidfile; pub mod signals; pub mod version; diff --git a/crates/micronet/src/main.rs b/crates/micronet/src/main.rs index 163fd1a..2b7ca6f 100644 --- a/crates/micronet/src/main.rs +++ b/crates/micronet/src/main.rs @@ -48,6 +48,8 @@ enum Commands { Check, /// Re-run DHCP probe + apply Reconfigure, + /// Stop managed dnsmasq/dhclient and flush addresses (service stop) + Teardown, /// Print build / release metadata Info, } @@ -79,6 +81,7 @@ fn main() -> ExitCode { cli.command.unwrap_or(Commands::Serve), &config_path, &socket, + daemon::CheckStyle::Daemon, ) } @@ -115,6 +118,10 @@ fn alias_main(argv0: &str) -> ExitCode { cmd = "serve"; i += 1; } + "teardown" | "stop" => { + cmd = "teardown"; + i += 1; + } other if other.starts_with('-') => { eprintln!("{argv0}: unknown option {other}"); return ExitCode::FAILURE; @@ -133,12 +140,23 @@ fn alias_main(argv0: &str) -> ExitCode { let command = match cmd { "serve" => Commands::Serve, "check" => Commands::Check, + "teardown" => Commands::Teardown, _ => Commands::Apply, }; - dispatch(command, &config_path, &socket_path) + dispatch( + command, + &config_path, + &socket_path, + daemon::check_style_from_argv0(argv0), + ) } -fn dispatch(command: Commands, config_path: &Path, socket: &Path) -> ExitCode { +fn dispatch( + command: Commands, + config_path: &Path, + socket: &Path, + check_style: daemon::CheckStyle, +) -> ExitCode { match command { Commands::Serve | Commands::Run => { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) @@ -181,7 +199,18 @@ fn dispatch(command: Commands, config_path: &Path, socket: &Path) -> ExitCode { ExitCode::FAILURE } }, - Commands::Check => match daemon::check_liveness(socket) { + Commands::Teardown => { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .init(); + match daemon::teardown(config_path) { + Ok(_) => ExitCode::SUCCESS, + Err(e) => { + log::error!("{e}"); + ExitCode::FAILURE + } + } + } + Commands::Check => match daemon::check_liveness(socket, config_path, check_style) { Ok(true) => ExitCode::SUCCESS, Ok(false) => ExitCode::FAILURE, Err(e) => { diff --git a/crates/micronet/src/net/addr.rs b/crates/micronet/src/net/addr.rs index 1a2e903..b004c7f 100644 --- a/crates/micronet/src/net/addr.rs +++ b/crates/micronet/src/net/addr.rs @@ -1,4 +1,4 @@ -//! Host `.N` in a `/24` subnet. +//! Host `.N` in a `/24` subnet and `ip` CIDR parsing. use std::net::Ipv4Addr; @@ -24,6 +24,31 @@ pub fn host_in_slash24(net: Ipv4Net, host: u8) -> Result { Ok(Ipv4Addr::new(o[0], o[1], o[2], host)) } +/// First IPv4 CIDR from `ip -4 -o addr show` stdout. Prefers non-link-local. +#[must_use] +pub fn parse_first_inet_cidr(ip_stdout: &str) -> Option { + let mut found: Vec = Vec::new(); + for line in ip_stdout.lines() { + let Some(rest) = line.split("inet ").nth(1) else { + continue; + }; + let Some(token) = rest.split_whitespace().next() else { + continue; + }; + if token.parse::().is_ok() { + found.push(token.to_string()); + } + } + let non_ll = found.iter().find(|s| !s.starts_with("169.254.")).cloned(); + non_ll.or_else(|| found.into_iter().next()) +} + +/// IPv4 address from a `a.b.c.d/nn` string. +#[must_use] +pub fn cidr_ipv4(cidr: &str) -> Option { + cidr.split('/').next().and_then(|s| s.parse().ok()) +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] @@ -38,4 +63,22 @@ mod tests { Ipv4Addr::new(10, 0, 10, 252) ); } + + #[test] + fn parse_first_inet_cidr_variants() { + assert_eq!( + parse_first_inet_cidr( + "2: eth0 inet 10.0.10.50/24 brd 10.0.10.255 scope global eth0\n" + ) + .as_deref(), + Some("10.0.10.50/24") + ); + assert!(parse_first_inet_cidr("2: eth0 inet6 fe80::1/64\n").is_none()); + let two = "\ +2: eth0 inet 169.254.1.1/16 scope link eth0 +2: eth0 inet 10.0.10.1/24 brd 10.0.10.255 scope global eth0 +"; + assert_eq!(parse_first_inet_cidr(two).as_deref(), Some("10.0.10.1/24")); + assert!(parse_first_inet_cidr("").is_none()); + } } diff --git a/crates/micronet/src/net/mod.rs b/crates/micronet/src/net/mod.rs index 18a06ed..560e500 100644 --- a/crates/micronet/src/net/mod.rs +++ b/crates/micronet/src/net/mod.rs @@ -7,10 +7,12 @@ use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; +use crate::config; use crate::constants::{ ARPHRD_ETHER, DHCLIENT_BIN, IP_BIN, PING_BIN, PING_COUNT, PING_TIMEOUT_SEC, }; use crate::error::{Error, Result}; +use crate::pidfile; pub mod addr; pub mod probe; @@ -29,9 +31,13 @@ pub trait NetOps { fn add_addr(&self, iface: &str, cidr: &str) -> Result<()>; fn iface_has_ipv4(&self, iface: &str) -> bool; fn iface_has_addr(&self, iface: &str, ip: Ipv4Addr) -> bool; + fn iface_ipv4_cidr(&self, iface: &str) -> Option; + fn carrier_up(&self, iface: &str) -> bool; fn ping(&self, host: Ipv4Addr) -> bool; - fn kill_dhclient(&self) -> Result<()>; + /// Stop the dhclient instance owned for `iface` (pidfile). Returns after daemonize, not after ACK. + fn stop_dhclient(&self, iface: &str) -> Result<()>; fn start_dhclient(&self, iface: &str) -> Result<()>; + fn dhclient_running(&self, iface: &str) -> bool; fn wait_ipv4(&self, iface: &str, timeout: Duration) -> bool; fn replace_default_via(&self, gw: Ipv4Addr, iface: &str) -> Result<()>; fn del_default(&self) -> Result<()>; @@ -103,6 +109,14 @@ impl NetOps for LiveNet { iface_has_addr(iface, ip) } + fn iface_ipv4_cidr(&self, iface: &str) -> Option { + iface_ipv4_cidr(iface) + } + + fn carrier_up(&self, iface: &str) -> bool { + carrier_up(iface) + } + fn ping(&self, host: Ipv4Addr) -> bool { run_cmd( PING_BIN, @@ -111,17 +125,33 @@ impl NetOps for LiveNet { .is_ok() } - fn kill_dhclient(&self) -> Result<()> { - let _ = Command::new("/bin/killall") - .arg("dhclient") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - Ok(()) + fn stop_dhclient(&self, iface: &str) -> Result<()> { + pidfile::stop(&config::dhclient_pidfile(iface), DHCLIENT_BIN) } + /// Spawn `dhclient -nw`; returns after the parent daemonizes, not after DHCPACK. fn start_dhclient(&self, iface: &str) -> Result<()> { - run_cmd(DHCLIENT_BIN, &[iface]) + if !Path::new(DHCLIENT_BIN).is_file() { + return Err(Error::DhclientMissing(PathBuf::from(DHCLIENT_BIN))); + } + let pid_path = config::dhclient_pidfile(iface); + let lease_path = config::dhclient_leasefile(iface); + if let Some(parent) = pid_path.parent() { + fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; + } + if let Some(parent) = lease_path.parent() { + fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; + } + let pid_s = pid_path.to_string_lossy(); + let lease_s = lease_path.to_string_lossy(); + run_cmd( + DHCLIENT_BIN, + &["-nw", "-pf", pid_s.as_ref(), "-lf", lease_s.as_ref(), iface], + ) + } + + fn dhclient_running(&self, iface: &str) -> bool { + pidfile::is_alive(&config::dhclient_pidfile(iface), DHCLIENT_BIN) } fn wait_ipv4(&self, iface: &str, timeout: Duration) -> bool { @@ -150,10 +180,7 @@ impl NetOps for LiveNet { } fn cidr_ip(cidr: &str) -> Ipv4Addr { - cidr.split('/') - .next() - .and_then(|s| s.parse().ok()) - .unwrap_or(Ipv4Addr::UNSPECIFIED) + addr::cidr_ipv4(cidr).unwrap_or(Ipv4Addr::UNSPECIFIED) } /// First physical Ethernet (sorted names), or an explicit name after the same filter. @@ -227,34 +254,37 @@ pub fn list_physical_ethernet(sys_class_net: &Path) -> Result> { } pub fn iface_has_ipv4(iface: &str) -> bool { - let Ok(out) = Command::new(IP_BIN) - .args(["-4", "addr", "show", "dev", iface]) - .output() - else { - return false; - }; - String::from_utf8_lossy(&out.stdout).contains("inet ") + iface_ipv4_cidr(iface).is_some() } pub fn iface_has_addr(iface: &str, ip: Ipv4Addr) -> bool { - let Ok(out) = Command::new(IP_BIN) - .args(["-4", "addr", "show", "dev", iface]) + iface_ipv4_cidr(iface) + .as_deref() + .is_some_and(|c| c.starts_with(&format!("{ip}/"))) +} + +pub fn iface_ipv4_cidr(iface: &str) -> Option { + let out = Command::new(IP_BIN) + .args(["-4", "-o", "addr", "show", "dev", iface]) .output() - else { - return false; - }; - String::from_utf8_lossy(&out.stdout).contains(&format!("inet {ip}/")) + .ok()?; + addr::parse_first_inet_cidr(&String::from_utf8_lossy(&out.stdout)) } -pub fn iface_link_up(iface: &str) -> bool { +/// Carrier detected (`/sys/class/net//carrier` or `ip link` `state UP`). +#[must_use] +pub fn carrier_up(iface: &str) -> bool { + let sys = Path::new(DEFAULT_SYS_CLASS_NET).join(iface).join("carrier"); + if let Ok(s) = fs::read_to_string(&sys) { + return s.trim() == "1"; + } let Ok(out) = Command::new(IP_BIN) .args(["link", "show", "dev", iface]) .output() else { return false; }; - let s = String::from_utf8_lossy(&out.stdout); - s.contains("state UP") || s.contains(",UP") + String::from_utf8_lossy(&out.stdout).contains("state UP") } fn run_cmd(bin: &str, args: &[&str]) -> Result<()> { diff --git a/crates/micronet/src/net/probe.rs b/crates/micronet/src/net/probe.rs index 6cc8d92..4c23f19 100644 --- a/crates/micronet/src/net/probe.rs +++ b/crates/micronet/src/net/probe.rs @@ -1,6 +1,6 @@ //! DHCPDISCOVER probe (no REQUEST). Foreign server → DHCPOFFER. -use std::net::{Ipv4Addr, SocketAddrV4}; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use dhcproto::v4::{ @@ -9,12 +9,19 @@ use dhcproto::v4::{ }; use socket2::{Domain, Protocol, SockAddr, Socket, Type}; +use crate::constants::DHCP_PROBE_RECV_TIMEOUT; use crate::error::{Error, Result}; const DHCP_CLIENT_PORT: u16 = 68; const DHCP_SERVER_PORT: u16 = 67; const DHCP_MAGIC_COOKIE: [u8; 4] = [0x63, 0x82, 0x53, 0x63]; +/// Fields we need from a validated DHCPOFFER. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OfferView { + pub server_id: Option, +} + /// Encode a DHCPDISCOVER (no I/O). pub fn encode_discover(chaddr: &[u8; 6], xid: u32) -> Result> { let mut msg = Message::default(); @@ -34,67 +41,133 @@ pub fn encode_discover(chaddr: &[u8; 6], xid: u32) -> Result> { let mut buf = Vec::with_capacity(300); let mut enc = Encoder::new(&mut buf); msg.encode(&mut enc) - .map_err(|e| Error::Other(format!("DHCP encode: {e}")))?; + .map_err(|e| Error::DhcpProbe(format!("DHCP encode: {e}")))?; debug_assert!(buf.windows(4).any(|w| w == DHCP_MAGIC_COOKIE)); Ok(buf) } -/// True if `buf` is a DHCPOFFER. +/// Decode a DHCPOFFER that matches our DISCOVER (`xid` + `chaddr`). #[must_use] -pub fn is_offer(buf: &[u8]) -> bool { - let Ok(msg) = Message::decode(&mut Decoder::new(buf)) else { - return false; +pub fn decode_matching_offer(buf: &[u8], xid: u32, chaddr: &[u8; 6]) -> Option { + let msg = Message::decode(&mut Decoder::new(buf)).ok()?; + if msg.opcode() != Opcode::BootReply { + return None; + } + if msg.htype() != HType::Eth { + return None; + } + if msg.hlen() != 6 { + return None; + } + if msg.xid() != xid { + return None; + } + let got = msg.chaddr(); + if got.len() < 6 || got[..6] != chaddr[..] { + return None; + } + match msg.opts().get(OptionCode::MessageType) { + Some(DhcpOption::MessageType(MessageType::Offer)) => {} + _ => return None, + } + let server_id = match msg.opts().get(OptionCode::ServerIdentifier) { + Some(DhcpOption::ServerIdentifier(ip)) => Some(*ip), + _ => None, }; - matches!( - msg.opts().get(OptionCode::MessageType), - Some(DhcpOption::MessageType(MessageType::Offer)) - ) + Some(OfferView { server_id }) } -/// Broadcast DHCPDISCOVER on `iface`; return true if any DHCPOFFER arrives. -pub fn probe_foreign_dhcp(iface: &str, mac: &[u8; 6], timeout: Duration) -> Result { +/// Broadcast DHCPDISCOVER on `iface`; true if a *foreign* DHCPOFFER arrives. +/// +/// `ignore_servers` are treated as self (our gateway.ip / local inet) and skipped. +/// Bind/send/`SO_BINDTODEVICE` failures are errors (fail closed). Timeout with only +/// self/invalid packets is `Ok(false)`. +pub fn probe_foreign_dhcp( + iface: &str, + mac: &[u8; 6], + timeout: Duration, + ignore_servers: &[Ipv4Addr], +) -> Result { let xid = xid_now(); let pkt = encode_discover(mac, xid)?; let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) - .map_err(|e| Error::Other(format!("dhcp socket: {e}")))?; + .map_err(|e| Error::DhcpProbe(format!("dhcp socket: {e}")))?; sock.set_reuse_address(true) - .map_err(|e| Error::Other(format!("SO_REUSEADDR: {e}")))?; + .map_err(|e| Error::DhcpProbe(format!("SO_REUSEADDR: {e}")))?; sock.set_broadcast(true) - .map_err(|e| Error::Other(format!("SO_BROADCAST: {e}")))?; - sock.set_read_timeout(Some(Duration::from_millis(250))) - .map_err(|e| Error::Other(format!("SO_RCVTIMEO: {e}")))?; - if let Err(e) = sock.bind_device(Some(iface.as_bytes())) { - log::debug!("SO_BINDTODEVICE {iface}: {e}"); - } + .map_err(|e| Error::DhcpProbe(format!("SO_BROADCAST: {e}")))?; + sock.set_read_timeout(Some(DHCP_PROBE_RECV_TIMEOUT)) + .map_err(|e| Error::DhcpProbe(format!("SO_RCVTIMEO: {e}")))?; + sock.bind_device(Some(iface.as_bytes())) + .map_err(|e| Error::DhcpProbe(format!("SO_BINDTODEVICE {iface}: {e}")))?; let bind = SockAddr::from(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DHCP_CLIENT_PORT)); sock.bind(&bind) - .map_err(|e| Error::Other(format!("bind :{DHCP_CLIENT_PORT}: {e}")))?; + .map_err(|e| Error::DhcpProbe(format!("bind :{DHCP_CLIENT_PORT}: {e}")))?; let dest = SockAddr::from(SocketAddrV4::new(Ipv4Addr::BROADCAST, DHCP_SERVER_PORT)); sock.send_to(&pkt, &dest) - .map_err(|e| Error::Other(format!("DHCPDISCOVER send: {e}")))?; + .map_err(|e| Error::DhcpProbe(format!("DHCPDISCOVER send: {e}")))?; let udp = std::net::UdpSocket::from(sock); let deadline = Instant::now() + timeout; let mut buf = [0u8; 1500]; while Instant::now() < deadline { match udp.recv_from(&mut buf) { - Ok((n, _)) => { - if is_offer(&buf[..n]) { - return Ok(true); + Ok((n, src)) => { + if let Some(offer) = decode_matching_offer(&buf[..n], xid, mac) { + if is_self_offer(offer.server_id, udp_src_v4(src), ignore_servers) { + continue; + } + if offer_identity(offer.server_id, udp_src_v4(src)).is_some() { + return Ok(true); + } } } Err(e) if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut => {} Err(e) => { - log::debug!("dhcp recv: {e}"); + return Err(Error::DhcpProbe(format!("dhcp recv: {e}"))); } } } Ok(false) } +fn udp_src_v4(src: SocketAddr) -> Ipv4Addr { + match src { + SocketAddr::V4(v) => *v.ip(), + SocketAddr::V6(_) => Ipv4Addr::UNSPECIFIED, + } +} + +/// Server-id, or a non-zero UDP source. `0.0.0.0` with no option 54 cannot be distinguished. +fn offer_identity(server_id: Option, udp_src: Ipv4Addr) -> Option { + if let Some(id) = server_id { + return Some(id); + } + if udp_src != Ipv4Addr::UNSPECIFIED { + return Some(udp_src); + } + None +} + +fn is_self_offer( + server_id: Option, + udp_src: Ipv4Addr, + ignore_servers: &[Ipv4Addr], +) -> bool { + if ignore_servers.is_empty() { + return false; + } + if let Some(id) = server_id { + if ignore_servers.contains(&id) { + return true; + } + } + ignore_servers.contains(&udp_src) +} + fn xid_now() -> u32 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -106,7 +179,7 @@ fn xid_now() -> u32 { pub fn read_mac(sys_class_net: &std::path::Path, iface: &str) -> Result<[u8; 6]> { let text = std::fs::read_to_string(sys_class_net.join(iface).join("address")) .map_err(|e| Error::io_at(sys_class_net.join(iface).join("address"), e))?; - parse_mac(text.trim()).ok_or_else(|| Error::Other(format!("bad MAC on {iface}"))) + parse_mac(text.trim()).ok_or_else(|| Error::DhcpProbe(format!("bad MAC on {iface}"))) } fn parse_mac(s: &str) -> Option<[u8; 6]> { @@ -127,13 +200,133 @@ mod tests { use super::*; + fn encode_offer( + chaddr: &[u8; 6], + xid: u32, + server_id: Option, + opcode: Opcode, + htype: HType, + msg_type: MessageType, + ) -> Vec { + let mut msg = Message::default(); + msg.set_opcode(opcode); + msg.set_htype(htype); + msg.set_xid(xid); + msg.set_chaddr(chaddr); + msg.opts_mut().insert(DhcpOption::MessageType(msg_type)); + if let Some(id) = server_id { + msg.opts_mut().insert(DhcpOption::ServerIdentifier(id)); + } + let mut buf = Vec::with_capacity(300); + let mut enc = Encoder::new(&mut buf); + msg.encode(&mut enc).unwrap(); + buf + } + #[test] fn discover_has_cookie_and_type() { let mac = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01]; let buf = encode_discover(&mac, 0x1122_3344).unwrap(); assert!(buf.windows(4).any(|w| w == DHCP_MAGIC_COOKIE)); assert!(buf.windows(3).any(|w| w == [53, 1, 1])); - assert!(!is_offer(&buf)); + assert!(decode_matching_offer(&buf, 0x1122_3344, &mac).is_none()); + } + + #[test] + fn matching_offer_is_accepted() { + let mac = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01]; + let xid = 0xAABB_CCDD; + let buf = encode_offer( + &mac, + xid, + Some(Ipv4Addr::new(8, 8, 8, 8)), + Opcode::BootReply, + HType::Eth, + MessageType::Offer, + ); + let view = decode_matching_offer(&buf, xid, &mac).unwrap(); + assert_eq!(view.server_id, Some(Ipv4Addr::new(8, 8, 8, 8))); + } + + #[test] + fn wrong_xid_chaddr_opcode_htype_ack_rejected() { + let mac = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01]; + let other = [0x02, 0x00, 0x00, 0x00, 0x00, 0x02]; + let xid = 1u32; + let good = || { + encode_offer( + &mac, + xid, + None, + Opcode::BootReply, + HType::Eth, + MessageType::Offer, + ) + }; + assert!(decode_matching_offer(&good(), 2, &mac).is_none()); + assert!(decode_matching_offer( + &encode_offer( + &other, + xid, + None, + Opcode::BootReply, + HType::Eth, + MessageType::Offer, + ), + xid, + &mac + ) + .is_none()); + assert!(decode_matching_offer( + &encode_offer( + &mac, + xid, + None, + Opcode::BootRequest, + HType::Eth, + MessageType::Offer, + ), + xid, + &mac + ) + .is_none()); + assert!(decode_matching_offer( + &encode_offer( + &mac, + xid, + None, + Opcode::BootReply, + HType::Eth, + MessageType::Ack, + ), + xid, + &mac + ) + .is_none()); + } + + #[test] + fn self_server_id_is_ignored_foreign_is_not() { + let self_id = Ipv4Addr::new(10, 0, 10, 1); + let foreign = Ipv4Addr::new(8, 8, 8, 8); + let ignore = [self_id]; + assert!(is_self_offer(Some(self_id), Ipv4Addr::UNSPECIFIED, &ignore)); + assert!(!is_self_offer( + Some(foreign), + Ipv4Addr::UNSPECIFIED, + &ignore + )); + assert!(is_self_offer(None, self_id, &ignore)); + assert!(!is_self_offer(None, foreign, &ignore)); + assert!(!is_self_offer(Some(self_id), self_id, &[])); + assert!(offer_identity(None, Ipv4Addr::UNSPECIFIED).is_none()); + assert_eq!(offer_identity(None, foreign), Some(foreign)); + } + + #[test] + fn truncated_buffer_is_none() { + let mac = [0u8; 6]; + assert!(decode_matching_offer(&[0, 1, 2], 1, &mac).is_none()); } #[test] diff --git a/crates/micronet/src/pidfile.rs b/crates/micronet/src/pidfile.rs new file mode 100644 index 0000000..d6d29fd --- /dev/null +++ b/crates/micronet/src/pidfile.rs @@ -0,0 +1,159 @@ +//! Own a single process via a pidfile. Never `killall` or scan all of `/proc`. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use nix::sys::signal::{kill, Signal}; +use nix::unistd::Pid; + +use crate::constants::PROCESS_TERM_WAIT; +use crate::error::Result; + +const DEFAULT_PROC: &str = "/proc"; + +/// True when `pid_path` names a live process whose cmdline still matches `bin`. +#[must_use] +pub fn is_alive(pid_path: &Path, bin: &str) -> bool { + is_alive_in(pid_path, Path::new(DEFAULT_PROC), bin) +} + +#[must_use] +pub fn is_alive_in(pid_path: &Path, proc_root: &Path, bin: &str) -> bool { + let Some(pid) = read_pid(pid_path) else { + return false; + }; + let cmdline = fs::read(proc_root.join(pid.to_string()).join("cmdline")).unwrap_or_default(); + cmdline_matches(&cmdline, bin) +} + +/// SIGTERM, then SIGKILL if the pidfile still points at `bin`. Missing pidfile is success. +pub fn stop(pid_path: &Path, bin: &str) -> Result<()> { + stop_in(pid_path, Path::new(DEFAULT_PROC), bin, PROCESS_TERM_WAIT) +} + +pub fn stop_in(pid_path: &Path, proc_root: &Path, bin: &str, term_wait: Duration) -> Result<()> { + let Some(pid) = read_pid(pid_path) else { + let _ = fs::remove_file(pid_path); + return Ok(()); + }; + if !is_alive_in(pid_path, proc_root, bin) { + let _ = fs::remove_file(pid_path); + return Ok(()); + } + let _ = kill(Pid::from_raw(pid), Signal::SIGTERM); + thread::sleep(term_wait); + if is_alive_in(pid_path, proc_root, bin) { + let _ = kill(Pid::from_raw(pid), Signal::SIGKILL); + } + let _ = fs::remove_file(pid_path); + Ok(()) +} + +#[must_use] +pub fn read_pid(pid_path: &Path) -> Option { + let text = fs::read_to_string(pid_path).ok()?; + let pid: i32 = text.trim().parse().ok()?; + (pid > 0).then_some(pid) +} + +#[must_use] +pub fn cmdline_matches(cmdline: &[u8], bin: &str) -> bool { + if cmdline.is_empty() { + return false; + } + let file_name = Path::new(bin) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(bin); + let suffix = { + let mut p = PathBuf::from("/"); + p.push(file_name); + p + }; + let suffix = suffix.to_string_lossy(); + cmdline.split(|&b| b == 0).any(|part| { + if part.is_empty() { + return false; + } + let text = String::from_utf8_lossy(part); + text == bin || text == file_name || text.ends_with(suffix.as_ref()) + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use std::os::unix::fs::PermissionsExt; + use tempfile::tempdir; + + #[test] + fn missing_pidfile_is_dead_and_stop_ok() { + let dir = tempdir().unwrap(); + let path = dir.path().join("missing.pid"); + assert!(!is_alive(&path, "/usr/sbin/dnsmasq")); + stop(&path, "/usr/sbin/dnsmasq").unwrap(); + } + + #[test] + fn garbage_pidfile_is_dead() { + let dir = tempdir().unwrap(); + let path = dir.path().join("x.pid"); + fs::write(&path, "nope\n").unwrap(); + assert!(!is_alive(&path, "dnsmasq")); + stop(&path, "dnsmasq").unwrap(); + assert!(!path.exists()); + } + + #[test] + fn stale_numeric_pid_is_dead() { + let dir = tempdir().unwrap(); + let path = dir.path().join("x.pid"); + fs::write(&path, "999999\n").unwrap(); + assert!(!is_alive(&path, "dnsmasq")); + } + + #[test] + fn cmdline_matches_bin_path_and_argv0() { + assert!(cmdline_matches( + b"/usr/sbin/dnsmasq\0-C\0/tmp/x\0", + "/usr/sbin/dnsmasq" + )); + assert!(cmdline_matches(b"dnsmasq\0", "/usr/sbin/dnsmasq")); + assert!(!cmdline_matches( + b"/usr/sbin/unbound\0", + "/usr/sbin/dnsmasq" + )); + assert!(!cmdline_matches(b"", "/usr/sbin/dnsmasq")); + assert!(cmdline_matches( + b"/sbin/dhclient\0-nw\0eth0\0", + "/sbin/dhclient" + )); + } + + #[test] + fn stop_in_signals_matching_pid() { + let dir = tempdir().unwrap(); + let proc_root = dir.path().join("proc"); + let pid_dir = proc_root.join("1234"); + fs::create_dir_all(&pid_dir).unwrap(); + fs::write(pid_dir.join("cmdline"), b"/usr/sbin/dnsmasq\0-C\0x\0").unwrap(); + // Make cmdline readable if umask is odd. + let _ = fs::set_permissions(pid_dir.join("cmdline"), fs::Permissions::from_mode(0o644)); + let pid_path = dir.path().join("dnsmasq.pid"); + fs::write(&pid_path, "1234\n").unwrap(); + assert!(is_alive_in(&pid_path, &proc_root, "/usr/sbin/dnsmasq")); + // SIGTERM/KILL will fail (no such process); pidfile must still be removed. + stop_in( + &pid_path, + &proc_root, + "/usr/sbin/dnsmasq", + Duration::from_millis(0), + ) + .unwrap(); + assert!(!pid_path.exists()); + } +} diff --git a/docs/networking/README.md b/docs/networking/README.md index 8d21b68..dac3ead 100644 --- a/docs/networking/README.md +++ b/docs/networking/README.md @@ -29,6 +29,8 @@ On boot, the **`micronet` daemon** (`eth0` / first physical Ethernet): - ping OK → mode **`static`**: stay on `.252`, default route via `gateway.ip`, no dnsmasq - ping fail → mode **`gateway`**: take `gateway.ip` (image seed: **`10.0.10.1/24`**), start **dnsmasq** (pool `.50–.200`, sticky lease **7d**, router/DNS = BigFred). **No default route.** +If a router is plugged in or boots **after** BigFred already became gateway, micronet notices the foreign DHCP (periodic probe) and **yields**: stops dnsmasq and runs `dhclient`. Kit B power-on order is still “router first,” but a late plug-in is handled. + There is no Omada detection and no per-MAC `dhcp-host=` reservations. Stickiness is the dnsmasq leasefile + `7d`. Typical mapping: diff --git a/docs/networking/README_pl.md b/docs/networking/README_pl.md index 20a2bf1..322215e 100644 --- a/docs/networking/README_pl.md +++ b/docs/networking/README_pl.md @@ -29,6 +29,8 @@ Po starcie daemon **`micronet`** (pierwszy fizyczny Ethernet): - ping OK → tryb **`static`**: zostań na `.252`, default via `gateway.ip`, bez dnsmasq - ping fail → tryb **`gateway`**: weź `gateway.ip` (seed obrazu: **`10.0.10.1/24`**), start **dnsmasq** (pula `.50–.200`, sticky **7d**, router/DNS = BigFred). **Bez default route.** +Jeśli router pojawi się **później** (po tym, jak BigFred już został gatewayem), micronet wykryje obcy DHCP (okresowa sonda) i **ustąpi**: wyłączy dnsmasq i uruchomi `dhclient`. Kolejność włączania zestawu B to nadal „najpierw router”, ale późniejsze podłączenie jest obsłużone. + Nie ma wykrywania Omady ani rezerwacji `dhcp-host=` per MAC. Stickiness to leasefile dnsmasq + `7d`. Typowe mapowanie: