From ac75f366bdc2a92fe27350d7a547e12aceddb68c Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 12:24:10 +0800 Subject: [PATCH 1/6] feat(netstat): net_util socket parsing + netstat applet (Phase 3 wave 1c) net_util: extract split_fields (read_routes reuses), add SocketEntry/ UnixSocketEntry, tcp/unix state-name tables, parse_inet_sockets (pure fn, fake-data testable) + read_tcp/udp/unix_sockets wrappers, format_netstat_* BusyBox style (LISTEN hidden unless -a, remote port 0 -> *). netstat applet: -t/-u/-x/-a/-n (defaults to tcp+udp). --- cmake/Config.cmake | 2 +- document/ai/PLAN.md | 5 +- document/notes/2026-07-06-phase3-netstat.md | 50 +++++ include/cfbox/applet_config.hpp.in | 1 + include/cfbox/applets.hpp | 6 + include/cfbox/net_util.hpp | 237 ++++++++++++++++++-- src/applets/netstat.cpp | 84 +++++++ tests/integration/test_netstat.sh | 36 +++ tests/unit/test_netstat.cpp | 122 ++++++++++ 9 files changed, 526 insertions(+), 17 deletions(-) create mode 100644 document/notes/2026-07-06-phase3-netstat.md create mode 100644 src/applets/netstat.cpp create mode 100755 tests/integration/test_netstat.sh create mode 100644 tests/unit/test_netstat.cpp diff --git a/cmake/Config.cmake b/cmake/Config.cmake index 134cc58..a6ae529 100644 --- a/cmake/Config.cmake +++ b/cmake/Config.cmake @@ -29,7 +29,7 @@ set(CFBOX_APPLETS dmesg hexdump more rev cal renice clear which mount mountpoint mdev chmod chown chgrp umount swapoff reboot - nc ifconfig ip route + nc ifconfig ip route netstat ) foreach(applet IN LISTS CFBOX_APPLETS) diff --git a/document/ai/PLAN.md b/document/ai/PLAN.md index 6d8f368..0324fdd 100644 --- a/document/ai/PLAN.md +++ b/document/ai/PLAN.md @@ -3,7 +3,7 @@ > Tier 3(批级,易变)。单一事实源(批级)。全树见 [ROADMAP.md](ROADMAP.md),铁律见 [DIRECTIVES.md](DIRECTIVES.md);结构标尺见 [STRUCTURE-TASTE.md](STRUCTURE-TASTE.md),性能标尺见 [PERFORMANCE.md](PERFORMANCE.md)。 > **v0.3.0 已发布**:L2 rootfs 启动骨架(init/mount/mdev/umount/swapoff/reboot/poweroff,117→123 applet)+ tail -f —— cfbox 在 i.MX6ULL 上作为 PID 1 替代 BusyBox。 > ✅ **Phase 2 全部完成**(cp/test/ls/grep/find + sh 全收 8 项)+ ✅ **结构与性能标尺横切批**(PR#17/#18)—— STRUCTURE-TASTE + banned-pattern/layering gate、PERFORMANCE + io/tar/cmp/md5sum/sed 流式化 + google-benchmark 脚手架。当前基线 **436 GTest / 439 KB size-opt / 123 applet**。 -> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅、批3(`ip`/`route`/`hostname` 深化)✅ —— 当前基线 **452 GTest / 463 KB / 127 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 +> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅、批3(`ip`/`route`/`hostname` 深化)✅、批4(`netstat` 只读 + `split_fields` 抽取)✅ —— 当前基线 **462 GTest / 467 KB / 128 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 > 状态:✅ DONE / 🔄 NEXT / ⏳ PENDING / ⛔ BLOCKED。每批≈一 commit,完成门 `cmake --build build -j$(nproc) && ctest --test-dir build --output-on-failure` 全绿 + `bash tests/integration/run_all.sh`。 ## ✅ Phase 1.5(代码质量审查)已完成 — 2026-05-26 @@ -65,8 +65,9 @@ | 批1(Wave 0) | `include/cfbox/socket.hpp` 基础设施(复用 `io::unique_fd`:make/resolve/dial/listen_on/accept_one/format_addr,双栈、header-only)+ `nc` applet(connect/listen 模式 + poll 双向 relay stdin→sock/sock→stdout + SHUT_WR 半关) | ✅ | 10f811f | 440/1 | | 批2(Wave 1a) | `include/cfbox/net_util.hpp` 基础设施(`read_interfaces` 解析 /proc/net/dev + ioctl SIOCGIF* 取 flags/mtu/hwaddr/ipv4;`format_ifconfig` BusyBox 多行格式;`ipv4_from_ioctl` memcpy 解 cast-align)+ `ifconfig` applet(`-a`/`IFACE` 只读显示) | ✅ | f4279d6 | 446/1 | | 批3(Wave 1b) | `net_util.hpp` 扩展(`read_routes` 解析 /proc/net/route tab-hex + `format_route_table` BusyBox route -n + `hex_to_ipv4`/`prefix_len`)+ `ip addr show`(iproute2 风格 index/flags/mtu/link/inet)+ `route -n`(显示)+ `hostname` 深化(`-i`/`-f`/`-d` getaddrinfo + memcpy 解 cast-align) | ✅ | f0ff0db | 452/2 | +| 批4(Wave 1c) | `net_util.hpp` 抽公共 `split_fields`(`read_routes` 改用,DRY)+ `read_tcp/udp_sockets`(解析 /proc/net/tcp\|udp `hexIP:hexPort` + state code + tx:rx queues)+ `read_unix_sockets`(/proc/net/unix)+ `parse_inet_sockets` 纯函数化(喂假数据可单测)+ `format_netstat_inet/unix`(BusyBox 风格,LISTEN 过滤/-a)+ `netstat` applet(`-t/-u/-x/-a/-n`) | ✅ | (本批) | 462/3 | -> 下一批:Wave 1c `ifconfig` 写操作(ADDR/netmask/up/down/mtu,SIOCSIF*,需 root)+ `netstat`(依赖 net_util `read_sockets` 待建);或转 Wave 2(`ping`/`traceroute`,需 `icmp.hpp` raw socket)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md) / [notes/2026-07-06-phase3-ip-route-hostname.md](../notes/2026-07-06-phase3-ip-route-hostname.md)。 +> 下一批:批5 Wave 1c `ifconfig` 写操作(ADDR/netmask/up/down/mtu,`SIOCSIF*`,需 root + 抽 `ctl_socket` helper)。之后转 Wave 2:批6 `icmp.hpp`(checksum/build_echo/parse/open_raw/recv_icmp)+ `ping`,批7 `traceroute`(UDP 探测复用 icmp 收包)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md) / [notes/2026-07-06-phase3-ip-route-hostname.md](../notes/2026-07-06-phase3-ip-route-hostname.md) / [notes/2026-07-06-phase3-netstat.md](../notes/2026-07-06-phase3-netstat.md)。 ## OPEN GOTCHAS(跨批陷阱,改前必看) diff --git a/document/notes/2026-07-06-phase3-netstat.md b/document/notes/2026-07-06-phase3-netstat.md new file mode 100644 index 0000000..42bcf8d --- /dev/null +++ b/document/notes/2026-07-06-phase3-netstat.md @@ -0,0 +1,50 @@ +# Phase 3 批4(Wave 1c)— netstat 只读 + split_fields 抽取 + +> 2026-07-06。`netstat` applet(`-t/-u/-x/-a/-n`)+ `net_util` socket 解析基础设施。全只读, +> CI native 全程可真测,风险最低 —— 作为 Wave 1c/2 四批里的开胃菜,顺手把 `read_routes` 的 +> 内联 tab-splitter 抽成公共 helper。 + +## 触及文件 + +- [include/cfbox/net_util.hpp](../../include/cfbox/net_util.hpp) — + - 抽 `split_fields(string_view) -> vector`(公共 tab/space splitter,`read_routes` 改用) + - 新增 `SocketEntry` / `UnixSocketEntry` 结构 + - `tcp_state_name`/`unix_type_name`/`unix_state_name`(state code → BusyBox 名,返回 `const char*`) + - `parse_ipv4_port`(`hexIP:hexPort` → `{dotted, port}`,复用 `hex_to_ipv4`) + - `parse_inet_line`/`parse_inet_sockets`/`parse_unix_line`/`parse_unix_sockets`(**纯函数**,接收 content) + - `read_tcp_sockets`/`read_udp_sockets`/`read_unix_sockets`(薄包装,读 /proc/net/*) + - `format_netstat_inet(servers)`/`format_netstat_unix`(BusyBox 风格;LISTEN 默认隐藏、`-a` 显示、remote port 0 → `*`) +- [src/applets/netstat.cpp](../../src/applets/netstat.cpp) — 新 applet(`-t/-u/-x/-a/-n`,无 proto flag 默认 tcp+udp) +- 注册三件套:[cmake/Config.cmake](../../cmake/Config.cmake) `CFBOX_APPLETS` 加 `netstat` + [applet_config.hpp.in](../../include/cfbox/applet_config.hpp.in) `CFBOX_ENABLE_NETSTAT` + [applets.hpp](../../include/cfbox/applets.hpp) extern + registry +- [tests/unit/test_netstat.cpp](../../tests/unit/test_netstat.cpp) — 10 测(split_fields 3 + tcp_state 1 + parse_inet_line 2 + parse_inet_sockets 1 + format_netstat_inet 1 + parse_unix_line 1 + format_netstat_unix 1) +- [tests/integration/test_netstat.sh](../../tests/integration/test_netstat.sh) — 3 测(默认/-a/-x 表头) + +## 关键设计决策 + +1. **纯函数 + 薄包装分离**:`parse_inet_sockets(content, proto)` 接收字符串(可喂假数据单测), + `read_tcp_sockets()` 只读 `/proc/net/tcp` 再调纯函数。这是既有 `read_interfaces`/`read_routes` + 未做的改造点 —— 直接读 /proc 无法单测解析逻辑。本批为 socket 解析树立了可测模式, + 后续 ifconfig 写、icmp 解析可参照。 +2. **`split_fields` DRY**:`read_routes` 原内联 14 行 tab-splitter,抽成公共 helper 后 + `read_routes` + `parse_inet_line` + `parse_unix_line` 三处复用。验证不退化(route 单测仍绿)。 +3. **`parse_inet_line` 拒绝非数据行**:检查地址字段含 `:`,挡住 header 行(`local_address` 无冒号) + 和 malformed 行 —— 比单纯字段数检查鲁棒。 +4. **UDP 无 State**:`format_netstat_inet` 对 `proto=="udp"` 打印空 State 列(BusyBox 行为)。 +5. **state name 返回 `const char*`**:字面量,避免 `string_view::data()` 的 NUL 歧义,直接喂 `%s`。 + +## 完成门 + +- `ctest`:**462/462** 全绿(+10 测) +- `bash tests/integration/run_all.sh`:全绿(+3 netstat 测) +- size-opt:**467 KB**(+4 KB,预算 550 KB,余 83 KB) +- armhf 交叉编译 + qemu `--list`:netstat 注册成功,无新 cast-align 警告(本批纯字符串解析,未碰 sockaddr) + +## Gotcha + +- /proc/net/tcp 地址是 **host-endian hex**(`0100007F` = 127.0.0.1 on little-endian),复用 `hex_to_ipv4` + 的「直接赋给 `in_addr.s_addr`」模式 —— 但这只在 little-endian 目标对(x86/armhf 都是小端)。 + 若未来上 big-endian 目标需重审。 +- /proc/net/tcp 的 `tx_queue:rx_queue` 是**单字段**(冒号分隔),不是两个空格字段;`f[4]` 整体, + 再二次 split 冒号。Recv-Q = rx(冒号后),Send-Q = tx(冒号前)—— 顺序与 BusyBox 表头一致。 +- `format_netstat_inet` 的 `%s` 喂 `std::string{tcp_state_name(...)}.c_str()` 会构造临时 string —— + 改成返回 `const char*` 后直接喂,零临时。 diff --git a/include/cfbox/applet_config.hpp.in b/include/cfbox/applet_config.hpp.in index 0ab9af8..f2fa6fe 100644 --- a/include/cfbox/applet_config.hpp.in +++ b/include/cfbox/applet_config.hpp.in @@ -128,3 +128,4 @@ #cmakedefine01 CFBOX_ENABLE_IFCONFIG #cmakedefine01 CFBOX_ENABLE_IP #cmakedefine01 CFBOX_ENABLE_ROUTE +#cmakedefine01 CFBOX_ENABLE_NETSTAT diff --git a/include/cfbox/applets.hpp b/include/cfbox/applets.hpp index 590cd30..5549f60 100644 --- a/include/cfbox/applets.hpp +++ b/include/cfbox/applets.hpp @@ -377,6 +377,9 @@ extern auto ip_main(int argc, char* argv[]) -> int; #if CFBOX_ENABLE_ROUTE extern auto route_main(int argc, char* argv[]) -> int; #endif +#if CFBOX_ENABLE_NETSTAT +extern auto netstat_main(int argc, char* argv[]) -> int; +#endif // registry — one line per applet, conditionally compiled constexpr auto APPLET_REGISTRY = std::to_array({ @@ -755,4 +758,7 @@ constexpr auto APPLET_REGISTRY = std::to_array({ #if CFBOX_ENABLE_ROUTE {"route", route_main, "show the IP routing table"}, #endif +#if CFBOX_ENABLE_NETSTAT + {"netstat", netstat_main, "display network sockets"}, +#endif }); diff --git a/include/cfbox/net_util.hpp b/include/cfbox/net_util.hpp index e49c244..2d1d7d8 100644 --- a/include/cfbox/net_util.hpp +++ b/include/cfbox/net_util.hpp @@ -18,8 +18,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -79,6 +81,27 @@ inline auto ipv4_from_ioctl(const sockaddr* sa) -> std::string { return {}; } +// Split a line into whitespace-delimited fields (space or tab). Empty fields are +// skipped. Shared by /proc/net/route and /proc/net/{tcp,udp,unix} parsing — kept +// here so every /proc parser uses the same tokenization. +inline auto split_fields(std::string_view line) -> std::vector { + std::vector out; + std::string cur; + for (char c : line) { + if (c == '\t' || c == ' ') { + if (!cur.empty()) { + out.push_back(std::move(cur)); + cur.clear(); + } + } else { + cur += c; + } + } + if (!cur.empty()) + out.push_back(std::move(cur)); + return out; +} + // Read all interfaces: /proc/net/dev for counters + ioctl for the rest. [[nodiscard]] inline auto read_interfaces() -> base::Result> { int ctl = ::socket(AF_INET, SOCK_DGRAM, 0); @@ -281,20 +304,7 @@ inline auto prefix_len(const std::string& mask) -> int { if (line.empty()) continue; // tab-separated: Iface Dest GW Flags RefCnt Use Metric Mask MTU Window IRTT - std::vector f; - std::string cur; - for (char c : line) { - if (c == '\t' || c == ' ') { - if (!cur.empty()) { - f.push_back(cur); - cur.clear(); - } - } else { - cur += c; - } - } - if (!cur.empty()) - f.push_back(cur); + auto f = split_fields(line); if (f.size() < 8) continue; @@ -338,4 +348,203 @@ inline auto format_route_table(const std::vector& routes) -> std::st return out; } +// ---- socket tables (/proc/net/tcp /proc/net/udp /proc/net/unix) ---- + +struct SocketEntry { + std::string proto; // "tcp" / "udp" + std::string local_addr; // dotted + std::uint16_t local_port = 0; + std::string remote_addr; // dotted + std::uint16_t remote_port = 0; + int state = 0; // raw st code from /proc/net/tcp + std::uint64_t tx_queue = 0; + std::uint64_t rx_queue = 0; +}; + +// /proc/net/tcp state code → BusyBox name. 0A=LISTEN, 01=ESTABLISHED, ... +inline auto tcp_state_name(int state) -> const char* { + switch (state) { + case 0x01: return "ESTABLISHED"; + case 0x02: return "SYN_SENT"; + case 0x03: return "SYN_RECV"; + case 0x04: return "FIN_WAIT1"; + case 0x05: return "FIN_WAIT2"; + case 0x06: return "TIME_WAIT"; + case 0x07: return "CLOSE"; + case 0x08: return "CLOSE_WAIT"; + case 0x09: return "LAST_ACK"; + case 0x0A: return "LISTEN"; + case 0x0B: return "CLOSING"; + default: return "UNKNOWN"; + } +} + +// Parse "0100007F:0050" (host-endian hex ip : hex port) → dotted addr + port. +inline auto parse_ipv4_port(std::string_view s) -> std::pair { + auto colon = s.find(':'); + if (colon == std::string_view::npos) + return {}; + std::string addr = hex_to_ipv4(s.substr(0, colon)); + auto port = static_cast( + std::strtoul(std::string{s.substr(colon + 1)}.c_str(), nullptr, 16)); + return {std::move(addr), port}; +} + +// Parse one /proc/net/tcp|udp data line. nullopt on header/blank lines. +inline auto parse_inet_line(std::string_view line, std::string_view proto) + -> std::optional { + auto f = split_fields(line); + if (f.size() < 4) + return std::nullopt; + // Data rows: "N: hexIP:hexPort hexIP:hexPort ST ...". Reject rows whose + // address fields lack a ':' — that covers the header line ("local_address"). + if (f[1].find(':') == std::string::npos || f[2].find(':') == std::string::npos) + return std::nullopt; + // f[0]="N:" sl f[1]=local f[2]=remote f[3]=st f[4]=txq:rxq queues + SocketEntry e; + e.proto = std::string{proto}; + auto [la, lp] = parse_ipv4_port(f[1]); + e.local_addr = std::move(la); + e.local_port = lp; + auto [ra, rp] = parse_ipv4_port(f[2]); + e.remote_addr = std::move(ra); + e.remote_port = rp; + e.state = static_cast(std::strtoul(f[3].c_str(), nullptr, 16)); + if (f.size() > 4) { + auto c = f[4].find(':'); + if (c != std::string::npos) { + e.tx_queue = std::strtoull(f[4].substr(0, c).c_str(), nullptr, 16); + e.rx_queue = std::strtoull(f[4].substr(c + 1).c_str(), nullptr, 16); + } + } + return e; +} + +// Parse a full /proc/net/tcp|udp dump (skipping its header line). +inline auto parse_inet_sockets(std::string_view content, std::string_view proto) + -> std::vector { + auto lines = io::split_lines(content); + std::vector out; + for (std::size_t i = 1; i < lines.size(); ++i) { + if (auto e = parse_inet_line(lines[i], proto)) + out.push_back(*e); + } + return out; +} + +[[nodiscard]] inline auto read_tcp_sockets() -> base::Result> { + CFBOX_TRY(content, io::read_all("/proc/net/tcp")); + return parse_inet_sockets(*content, "tcp"); +} + +[[nodiscard]] inline auto read_udp_sockets() -> base::Result> { + CFBOX_TRY(content, io::read_all("/proc/net/udp")); + return parse_inet_sockets(*content, "udp"); +} + +// Format inet sockets in BusyBox netstat style. `servers` true → include LISTEN +// (==-a). Remote port 0 prints as "*" (BusyBox convention). UDP has no State. +inline auto format_netstat_inet(const std::vector& socks, bool servers) + -> std::string { + std::string out = servers ? "Active Internet connections (servers and established)\n" + : "Active Internet connections (w/o servers)\n"; + char buf[200]; + std::snprintf(buf, sizeof(buf), "%-5s %-6s %-6s %-23s %-23s %s\n", "Proto", "Recv-Q", + "Send-Q", "Local Address", "Foreign Address", "State"); + out += buf; + auto port_str = [](std::uint16_t p) -> std::string { + return p == 0 ? "*" : std::to_string(p); + }; + for (const auto& e : socks) { + if (!servers && e.state == 0x0A) // skip LISTEN unless -a + continue; + std::string local = e.local_addr + ':' + port_str(e.local_port); + std::string remote = e.remote_addr + ':' + port_str(e.remote_port); + const char* state = e.proto == "tcp" ? tcp_state_name(e.state) : ""; + std::snprintf(buf, sizeof(buf), "%-5s %-6llu %-6llu %-23s %-23s %s\n", e.proto.c_str(), + static_cast(e.rx_queue), + static_cast(e.tx_queue), local.c_str(), + remote.c_str(), state); + out += buf; + } + return out; +} + +// ---- unix domain sockets (/proc/net/unix) ---- + +struct UnixSocketEntry { + int refcount = 0; + int flags = 0; + int type = 0; // SOCK_STREAM=1 SOCK_DGRAM=2 SOCK_SEQPACKET=5 + int state = 0; // 01=LISTENING 03=CONNECTED 07=CLOSED + std::uint64_t inode = 0; + std::string path; +}; + +inline auto unix_type_name(int t) -> const char* { + switch (t) { + case 1: return "STREAM"; + case 2: return "DGRAM"; + case 5: return "SEQPACKET"; + default: return ""; + } +} + +inline auto unix_state_name(int s) -> const char* { + switch (s) { + case 0x01: return "LISTENING"; + case 0x02: return "CONNECTING"; + case 0x03: return "CONNECTED"; + case 0x07: return "CLOSED"; + default: return ""; + } +} + +inline auto parse_unix_line(std::string_view line) -> std::optional { + auto f = split_fields(line); + // Num: RefCount Protocol Flags Type St Inode Path? + if (f.size() < 7) + return std::nullopt; + UnixSocketEntry e; + e.refcount = static_cast(std::strtoul(f[1].c_str(), nullptr, 16)); + e.flags = static_cast(std::strtoul(f[3].c_str(), nullptr, 16)); + e.type = static_cast(std::strtoul(f[4].c_str(), nullptr, 16)); + e.state = static_cast(std::strtoul(f[5].c_str(), nullptr, 16)); + e.inode = std::strtoull(f[6].c_str(), nullptr, 10); + if (f.size() > 7) + e.path = f[7]; + return e; +} + +inline auto parse_unix_sockets(std::string_view content) -> std::vector { + auto lines = io::split_lines(content); + std::vector out; + for (std::size_t i = 1; i < lines.size(); ++i) { + if (auto e = parse_unix_line(lines[i])) + out.push_back(*e); + } + return out; +} + +[[nodiscard]] inline auto read_unix_sockets() -> base::Result> { + CFBOX_TRY(content, io::read_all("/proc/net/unix")); + return parse_unix_sockets(*content); +} + +inline auto format_netstat_unix(const std::vector& socks) -> std::string { + std::string out = "Active UNIX domain sockets (w/o servers)\n"; + char buf[200]; + std::snprintf(buf, sizeof(buf), "%-5s %-6s %-11s %-10s %-13s %-8s %s\n", "Proto", "RefCnt", + "Flags", "Type", "State", "I-Node", "Path"); + out += buf; + for (const auto& e : socks) { + std::snprintf(buf, sizeof(buf), "%-5s %-6d %-11s %-10s %-13s %-8llu %s\n", "unix", + e.refcount, (e.flags & 0x10000) ? "[ ACC ]" : "", unix_type_name(e.type), + unix_state_name(e.state), static_cast(e.inode), + e.path.c_str()); + out += buf; + } + return out; +} + } // namespace cfbox::net diff --git a/src/applets/netstat.cpp b/src/applets/netstat.cpp new file mode 100644 index 0000000..4be9c24 --- /dev/null +++ b/src/applets/netstat.cpp @@ -0,0 +1,84 @@ +// netstat.cpp — display network sockets (BusyBox netstat minimal subset). +// +// Wave 1c scope: -t/-u/-x (proto filter), -a (include LISTEN/servers), -n +// (numeric — already numeric; accepted for compat). Read-only. With no proto +// flag, defaults to tcp+udp (BusyBox behavior); unix requires -x. + +#include + +#include +#include +#include +#include + +namespace { + +constexpr cfbox::help::HelpEntry HELP = { + .name = "netstat", + .version = CFBOX_VERSION_STRING, + .one_line = "display network sockets (tcp/udp/unix, read-only)", + .usage = "netstat [-tuxan]", + .options = + " -t TCP sockets\n" + " -u UDP sockets\n" + " -x Unix domain sockets\n" + " -a show servers (LISTEN) too\n" + " -n numeric addresses (always numeric here)", + .extra = "", +}; + +} // namespace + +auto netstat_main(int argc, char* argv[]) -> int { + using namespace cfbox; + auto parsed = args::parse(argc, argv, + { + args::OptSpec{'t', false, "tcp"}, + args::OptSpec{'u', false, "udp"}, + args::OptSpec{'x', false, "unix"}, + args::OptSpec{'a', false, "all"}, + args::OptSpec{'n', false, "numeric"}, + }); + + if (parsed.has_long("help")) { + help::print_help(HELP); + return 0; + } + if (parsed.has_long("version")) { + help::print_version(HELP); + return 0; + } + + // No proto flag → tcp+udp (BusyBox default). Any flag narrows to the union requested. + const bool any_proto = parsed.has('t') || parsed.has('u') || parsed.has('x'); + const bool want_tcp = parsed.has('t') || !any_proto; + const bool want_udp = parsed.has('u') || !any_proto; + const bool want_unix = parsed.has('x'); + const bool servers = parsed.has('a'); + + if (want_tcp) { + auto socks = net::read_tcp_sockets(); + if (!socks) { + CFBOX_ERR("netstat", "%s", socks.error().msg.c_str()); + return 1; + } + std::fputs(net::format_netstat_inet(*socks, servers).c_str(), stdout); + } + if (want_udp) { + auto socks = net::read_udp_sockets(); + if (!socks) { + CFBOX_ERR("netstat", "%s", socks.error().msg.c_str()); + return 1; + } + std::fputs(net::format_netstat_inet(*socks, servers).c_str(), stdout); + } + if (want_unix) { + auto socks = net::read_unix_sockets(); + if (!socks) { + CFBOX_ERR("netstat", "%s", socks.error().msg.c_str()); + return 1; + } + std::fputs(net::format_netstat_unix(*socks).c_str(), stdout); + } + return 0; +} diff --git a/tests/integration/test_netstat.sh b/tests/integration/test_netstat.sh new file mode 100755 index 0000000..902799c --- /dev/null +++ b/tests/integration/test_netstat.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# test_netstat.sh — netstat prints headers and lists live sockets (read-only). +set -euo pipefail +source "$(dirname "$0")/helpers.sh" + +pass=0 fail=0 + +# default (tcp+udp) prints the inet header +out=$("$CFBOX" netstat 2>&1) || true +if echo "$out" | grep -q "Active Internet connections" && echo "$out" | grep -q "Local Address"; then + ((++pass)) +else + echo "FAIL [netstat default]: no inet header" + ((++fail)) +fi + +# -a includes servers (LISTEN). A Linux box almost always has something listening. +out=$("$CFBOX" netstat -a 2>&1) || true +if echo "$out" | grep -q "servers and established"; then + ((++pass)) +else + echo "FAIL [netstat -a]: no 'servers and established' header" + ((++fail)) +fi + +# -x emits the unix section +out=$("$CFBOX" netstat -x 2>&1) || true +if echo "$out" | grep -q "Active UNIX domain sockets"; then + ((++pass)) +else + echo "FAIL [netstat -x]: no unix header" + ((++fail)) +fi + +echo "netstat: $pass passed, $fail failed" +[[ $fail -eq 0 ]] diff --git a/tests/unit/test_netstat.cpp b/tests/unit/test_netstat.cpp new file mode 100644 index 0000000..a00c70f --- /dev/null +++ b/tests/unit/test_netstat.cpp @@ -0,0 +1,122 @@ +// Unit tests for net_util socket parsing (netstat path). Pure functions fed +// fixed /proc/net/tcp|udp|unix snapshots — no live privileges required. + +#include + +#include + +#include + +TEST(NetUtilSplitFieldsTest, TabsAndSpaces) { + auto f = cfbox::net::split_fields("eth0\t00000000\t00000000\t0001 \t"); + ASSERT_EQ(f.size(), 4u); + EXPECT_EQ(f[0], "eth0"); + EXPECT_EQ(f[3], "0001"); +} + +TEST(NetUtilSplitFieldsTest, CollapsesRunsAndTrims) { + auto f = cfbox::net::split_fields(" a b c "); + ASSERT_EQ(f.size(), 3u); + EXPECT_EQ(f[2], "c"); +} + +TEST(NetUtilSplitFieldsTest, Empty) { + EXPECT_TRUE(cfbox::net::split_fields("").empty()); + EXPECT_TRUE(cfbox::net::split_fields(" ").empty()); +} + +TEST(NetUtilTcpStateTest, KnownCodes) { + EXPECT_STREQ(cfbox::net::tcp_state_name(0x0A), "LISTEN"); + EXPECT_STREQ(cfbox::net::tcp_state_name(0x01), "ESTABLISHED"); + EXPECT_STREQ(cfbox::net::tcp_state_name(0x06), "TIME_WAIT"); + EXPECT_STREQ(cfbox::net::tcp_state_name(0xFF), "UNKNOWN"); +} + +TEST(NetUtilParseInetLineTest, ListenRow) { + // 0100007F = 127.0.0.1 (little-endian hex), port 0x16=22, state 0A=LISTEN + auto e = cfbox::net::parse_inet_line( + " 0: 0100007F:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345", + "tcp"); + ASSERT_TRUE(e.has_value()); + EXPECT_EQ(e->proto, "tcp"); + EXPECT_EQ(e->local_addr, "127.0.0.1"); + EXPECT_EQ(e->local_port, 22u); + EXPECT_EQ(e->remote_addr, "0.0.0.0"); + EXPECT_EQ(e->remote_port, 0u); + EXPECT_EQ(e->state, 0x0A); +} + +TEST(NetUtilParseInetLineTest, RejectsHeader) { + auto e = cfbox::net::parse_inet_line( + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt", "tcp"); + EXPECT_FALSE(e.has_value()); +} + +TEST(NetUtilParseInetSocketsTest, SkipsHeaderParsesRows) { + std::string dump = + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid " + "timeout inode\n" + " 0: 0100007F:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 " + "0 12345 1 0 0 10 0\n" + " 1: 0100007F:D0A8 0100007F:0016 01 00000000:00000000 00:00000000 00000000 0 " + "0 67890 1 0 0 20 4 30 10\n"; + auto v = cfbox::net::parse_inet_sockets(dump, "tcp"); + ASSERT_EQ(v.size(), 2u); + EXPECT_EQ(v[0].local_port, 22u); + EXPECT_EQ(v[0].state, 0x0A); + EXPECT_EQ(v[1].local_port, 0xD0A8u); + EXPECT_EQ(v[1].state, 0x01); + EXPECT_EQ(v[1].remote_port, 22u); +} + +TEST(NetUtilFormatNetstatInetTest, HidesListenByDefault) { + std::vector v(2); + v[0].proto = "tcp"; + v[0].local_addr = "127.0.0.1"; + v[0].local_port = 22; + v[0].remote_addr = "0.0.0.0"; + v[0].state = 0x0A; // LISTEN + v[1].proto = "tcp"; + v[1].local_addr = "127.0.0.1"; + v[1].local_port = 50000; + v[1].remote_addr = "127.0.0.1"; + v[1].remote_port = 22; + v[1].state = 0x01; // ESTABLISHED + + auto no_servers = cfbox::net::format_netstat_inet(v, false); + EXPECT_NE(no_servers.find("(w/o servers)"), std::string::npos); + EXPECT_EQ(no_servers.find("LISTEN"), std::string::npos); // skipped + EXPECT_NE(no_servers.find("ESTABLISHED"), std::string::npos); + + auto with_servers = cfbox::net::format_netstat_inet(v, true); + EXPECT_NE(with_servers.find("servers and established"), std::string::npos); + EXPECT_NE(with_servers.find("LISTEN"), std::string::npos); + // remote port 0 renders as "*" + EXPECT_NE(with_servers.find("0.0.0.0:*"), std::string::npos); +} + +TEST(NetUtilParseUnixLineTest, StreamListening) { + auto e = cfbox::net::parse_unix_line( + "7c8c: 00000002 00000000 00000000 0001 01 12345 /dev/log"); + ASSERT_TRUE(e.has_value()); + EXPECT_EQ(e->refcount, 2); + EXPECT_EQ(e->type, 1); + EXPECT_EQ(e->state, 0x01); + EXPECT_EQ(e->inode, 12345u); + EXPECT_EQ(e->path, "/dev/log"); + EXPECT_STREQ(cfbox::net::unix_type_name(e->type), "STREAM"); + EXPECT_STREQ(cfbox::net::unix_state_name(e->state), "LISTENING"); +} + +TEST(NetUtilFormatNetstatUnixTest, EmitsRow) { + std::vector v(1); + v[0].refcount = 2; + v[0].type = 1; + v[0].state = 0x01; + v[0].inode = 12345; + v[0].path = "/dev/log"; + auto s = cfbox::net::format_netstat_unix(v); + EXPECT_NE(s.find("Active UNIX domain sockets"), std::string::npos); + EXPECT_NE(s.find("/dev/log"), std::string::npos); + EXPECT_NE(s.find("STREAM"), std::string::npos); +} From 4986ba2269bd12263c5b27e6a0a9960e2c66ac31 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 12:33:27 +0800 Subject: [PATCH 2/6] feat(ifconfig): write path (SIOCSIF* addr/netmask/broadcast/mtu/up-down) net_util: ctl_socket helper, parse_ipv4, detail::ifreq_with_addr (memcpy into ifr.ifr_addr avoids -Wcast-align on armhf), detail::ioctl_error (msg carries strerror for EPERM vs ENODEV), set_ipv4_addr/set_netmask/ set_broadcast/set_mtu/set_if_up (read-modify-write IFF_UP). ifconfig: write codepath on IFACE ADDR [netmask NM] [broadcast BC] [mtu N] [up|down]; EPERM reported per-ioctl, never silent. --- document/ai/PLAN.md | 5 +- .../notes/2026-07-06-phase3-ifconfig-write.md | 56 +++++++ include/cfbox/net_util.hpp | 99 ++++++++++++ src/applets/ifconfig.cpp | 152 ++++++++++++++++-- tests/integration/test_ifconfig.sh | 24 +++ tests/unit/test_net_util.cpp | 15 ++ 6 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 document/notes/2026-07-06-phase3-ifconfig-write.md diff --git a/document/ai/PLAN.md b/document/ai/PLAN.md index 0324fdd..f85f43b 100644 --- a/document/ai/PLAN.md +++ b/document/ai/PLAN.md @@ -3,7 +3,7 @@ > Tier 3(批级,易变)。单一事实源(批级)。全树见 [ROADMAP.md](ROADMAP.md),铁律见 [DIRECTIVES.md](DIRECTIVES.md);结构标尺见 [STRUCTURE-TASTE.md](STRUCTURE-TASTE.md),性能标尺见 [PERFORMANCE.md](PERFORMANCE.md)。 > **v0.3.0 已发布**:L2 rootfs 启动骨架(init/mount/mdev/umount/swapoff/reboot/poweroff,117→123 applet)+ tail -f —— cfbox 在 i.MX6ULL 上作为 PID 1 替代 BusyBox。 > ✅ **Phase 2 全部完成**(cp/test/ls/grep/find + sh 全收 8 项)+ ✅ **结构与性能标尺横切批**(PR#17/#18)—— STRUCTURE-TASTE + banned-pattern/layering gate、PERFORMANCE + io/tar/cmp/md5sum/sed 流式化 + google-benchmark 脚手架。当前基线 **436 GTest / 439 KB size-opt / 123 applet**。 -> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅、批3(`ip`/`route`/`hostname` 深化)✅、批4(`netstat` 只读 + `split_fields` 抽取)✅ —— 当前基线 **462 GTest / 467 KB / 128 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 +> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅、批3(`ip`/`route`/`hostname` 深化)✅、批4(`netstat` 只读 + `split_fields` 抽取)✅、批5(`ifconfig` 写操作 SIOCSIF*)✅ —— 当前基线 **464 GTest / 471 KB / 128 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 > 状态:✅ DONE / 🔄 NEXT / ⏳ PENDING / ⛔ BLOCKED。每批≈一 commit,完成门 `cmake --build build -j$(nproc) && ctest --test-dir build --output-on-failure` 全绿 + `bash tests/integration/run_all.sh`。 ## ✅ Phase 1.5(代码质量审查)已完成 — 2026-05-26 @@ -66,8 +66,9 @@ | 批2(Wave 1a) | `include/cfbox/net_util.hpp` 基础设施(`read_interfaces` 解析 /proc/net/dev + ioctl SIOCGIF* 取 flags/mtu/hwaddr/ipv4;`format_ifconfig` BusyBox 多行格式;`ipv4_from_ioctl` memcpy 解 cast-align)+ `ifconfig` applet(`-a`/`IFACE` 只读显示) | ✅ | f4279d6 | 446/1 | | 批3(Wave 1b) | `net_util.hpp` 扩展(`read_routes` 解析 /proc/net/route tab-hex + `format_route_table` BusyBox route -n + `hex_to_ipv4`/`prefix_len`)+ `ip addr show`(iproute2 风格 index/flags/mtu/link/inet)+ `route -n`(显示)+ `hostname` 深化(`-i`/`-f`/`-d` getaddrinfo + memcpy 解 cast-align) | ✅ | f0ff0db | 452/2 | | 批4(Wave 1c) | `net_util.hpp` 抽公共 `split_fields`(`read_routes` 改用,DRY)+ `read_tcp/udp_sockets`(解析 /proc/net/tcp\|udp `hexIP:hexPort` + state code + tx:rx queues)+ `read_unix_sockets`(/proc/net/unix)+ `parse_inet_sockets` 纯函数化(喂假数据可单测)+ `format_netstat_inet/unix`(BusyBox 风格,LISTEN 过滤/-a)+ `netstat` applet(`-t/-u/-x/-a/-n`) | ✅ | (本批) | 462/3 | +| 批5(Wave 1c) | `net_util.hpp` 写 ioctl:抽 `ctl_socket`/`parse_ipv4`/`detail::ifreq_with_addr`(memcpy 写 sockaddr 解 cast-align)/`detail::ioctl_error`(msg 含 strerror)+ `set_ipv4_addr`/`set_netmask`/`set_broadcast`/`set_mtu`/`set_if_up`(read-modify-write IFF_UP)+ `ifconfig` 写 codepath(`IFACE ADDR [netmask NM] [broadcast BC] [mtu N] [up\|down]`,EPERM 不静默) | ✅ | (本批) | 464/5 | -> 下一批:批5 Wave 1c `ifconfig` 写操作(ADDR/netmask/up/down/mtu,`SIOCSIF*`,需 root + 抽 `ctl_socket` helper)。之后转 Wave 2:批6 `icmp.hpp`(checksum/build_echo/parse/open_raw/recv_icmp)+ `ping`,批7 `traceroute`(UDP 探测复用 icmp 收包)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md) / [notes/2026-07-06-phase3-ip-route-hostname.md](../notes/2026-07-06-phase3-ip-route-hostname.md) / [notes/2026-07-06-phase3-netstat.md](../notes/2026-07-06-phase3-netstat.md)。 +> 下一批:批6 Wave 2 `icmp.hpp`(checksum/build_echo_request/parse_icmp/open_raw/recv_icmp)+ `ping`(SOCK_RAW IPPROTO_ICMP)。之后批7 `traceroute`(UDP 探测 + 复用 icmp 收包)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md) / [notes/2026-07-06-phase3-ip-route-hostname.md](../notes/2026-07-06-phase3-ip-route-hostname.md) / [notes/2026-07-06-phase3-netstat.md](../notes/2026-07-06-phase3-netstat.md) / [notes/2026-07-06-phase3-ifconfig-write.md](../notes/2026-07-06-phase3-ifconfig-write.md)。 ## OPEN GOTCHAS(跨批陷阱,改前必看) diff --git a/document/notes/2026-07-06-phase3-ifconfig-write.md b/document/notes/2026-07-06-phase3-ifconfig-write.md new file mode 100644 index 0000000..034925f --- /dev/null +++ b/document/notes/2026-07-06-phase3-ifconfig-write.md @@ -0,0 +1,56 @@ +# Phase 3 批5(Wave 1c)— ifconfig 写操作(SIOCSIF*) + +> 2026-07-06。给 `ifconfig` 加写能力:`ifconfig IFACE ADDR [netmask NM] [broadcast BC] +> [mtu N] [up|down]`。`net_util` 加写 ioctl 基础设施(`ctl_socket` + 5 个 `set_*`)。 +> 需 CAP_NET_ADMIN —— CI native/qemu-user 无权,靠 skip guard 验证「EPERM 不静默」, +> qemu-system(root)真跑 MTU round-trip。 + +## 触及文件 + +- [include/cfbox/net_util.hpp](../../include/cfbox/net_util.hpp) — + - `ctl_socket() -> Result`(公共控制套接字,读/写 ioctl 共用) + - `parse_ipv4(sv) -> optional`(dotted → in_addr,纯函数可单测) + - `detail::ifreq_with_addr(iface, cmd, addr)`(建 ifreq + memcpy 写 sockaddr_in,解 cast-align) + - `detail::ioctl_error(what, errno)`(msg 含 strerror —— "SIOCSIFADDR failed: Operation not permitted") + - `set_ipv4_addr`/`set_netmask`/`set_broadcast`(SIOCSIFADDR/NETMASK/BRDADDR) + - `set_mtu`(SIOCSIFMTU) + - `set_if_up(ctl, iface, bool)`(SIOCGIFFLAGS 读改写 IFF_UP → SIOCSIFFLAGS,保留其他 flag) +- [src/applets/ifconfig.cpp](../../src/applets/ifconfig.cpp) — 加 `ifconfig_write` codepath(positional ≥2 触发),BusyBox 语法解析(`up`/`down`/`netmask`/`broadcast`/`mtu` keyword + 首个非 keyword 为 ADDR),一个 ctl_fd 上原子下发,EPERM 逐 ioctl 报错 +- [tests/unit/test_net_util.cpp](../../tests/unit/test_net_util.cpp) — +2 测 `parse_ipv4` valid/invalid +- [tests/integration/test_ifconfig.sh](../../tests/integration/test_ifconfig.sh) — +1 写测试双分支(非 root grep EPERM / root MTU round-trip + 恢复) + +## 关键设计决策 + +1. **写 path 触发条件 `positional().size() >= 2`**:`ifconfig lo`(1 个)仍走读 path 显示; + `ifconfig lo up`(2 个)走写 path。保持读语义不退化。 +2. **`detail::ifreq_with_addr` memcpy 模式**:与 `ipv4_from_ioctl` 对称 —— `sockaddr_in` 填好后 + memcpy 进 `ifr.ifr_addr`,避免 `reinterpret_cast(&ifr.ifr_addr)` 在 armhf 触发 + `-Wcast-align`(memory 警示的 B2 真 bug 模式)。 +3. **`detail::ioctl_error` 含 strerror**:既有 `socket.hpp` 错误不含 strerror(一致性弱点)。 + 写 ioctl 是用户高频诊断点("为什么 ifconfig 失败"),msg 拼 strerror 让 EPERM/ENODEV 可区分。 + 读 path 无错误可拼,不影响一致性。 +4. **`set_if_up` read-modify-write**:不能直接 `ifr.ifr_flags = IFF_UP`(会清掉 BROADCAST/MULTICAST + 等其他 flag)。先 SIOCGIFFLAGS 读现状,只改 IFF_UP 位,再 SIOCSIFFLAGS 写回。 +5. **EPERM 不静默**:applet 逐 ioctl 检查 Result,失败即 `CFBOX_ERR` + return 1。绝不会 + 「ioctl 失败但 ifconfig 返回 0」误导用户。 +6. **集成测试双分支**:非 root 验证 EPERM 报错(grep "Operation not permitted"), + root 验证 MTU round-trip(lo 默认 MTU 改 1280 再恢复 —— lo 选作目标因它安全可逆、 + 不会丢连接)。改 lo 地址太危险(覆盖 127.0.0.1),故 root 测试只动 MTU。 + +## 完成门 + +- `ctest`:**464/464** 全绿(+2 parse_ipv4 测) +- `bash tests/integration/run_all.sh`:全绿(ifconfig 5/5,含写测试) +- size-opt:**471 KB**(+4 KB,预算 550 KB,余 79 KB) +- armhf 交叉编译 + qemu:ifconfig 写 path 在 32 位正确报 "SIOCSIFADDR failed: Operation not permitted",无新 cast-align 警告 + +## Gotcha + +- **`ifr.ifr_flags` 是 `short`**:`IFF_UP` 是 int,`ifr.ifr_flags |= IFF_UP` 隐式窄化触发 + armhf `-Wconversion`。用 `static_cast(IFF_UP)` 显式窄化。 +- **alias 接口 `lo:0`**:`ifr.ifr_name="lo:0"` 对 SIOCSIFADDR 可工作(Linux alias 语义), + 但 `read_interfaces` 读 /proc/net/dev 不显示 alias(只在 ip addr show)。本轮不暴露 alias 入口。 +- **broadcast 通常自动算**:设了 ADDR+NETMASK 后 kernel 自动推 BCAST,显式 `broadcast BC` + 仅覆盖。BusyBox 行为一致。 +- **测试改 lo 的安全性**:lo 整段 127.0.0.0/8 都通(kernel 特殊处理),改 lo 的 MTU 不会断 + loopback 连接;但改 lo 的主地址会丢 127.0.0.1,故 root 测试只动 MTU。 diff --git a/include/cfbox/net_util.hpp b/include/cfbox/net_util.hpp index 2d1d7d8..801525f 100644 --- a/include/cfbox/net_util.hpp +++ b/include/cfbox/net_util.hpp @@ -168,6 +168,105 @@ inline auto split_fields(std::string_view line) -> std::vector { return out; } +// ---- write path (SIOCSIF* ioctls, requires CAP_NET_ADMIN) ---- + +// Control socket used for both SIOCGIF* (read) and SIOCSIF* (write) ioctls. +[[nodiscard]] inline auto ctl_socket() -> base::Result { + int fd = ::socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) + return std::unexpected(base::Error{errno, "control socket failed"}); + return io::unique_fd{fd}; +} + +// Parse dotted-quad → in_addr. nullopt on malformed input. +[[nodiscard]] inline auto parse_ipv4(std::string_view s) -> std::optional { + char buf[INET_ADDRSTRLEN]; + if (s.empty() || s.size() >= sizeof(buf)) + return std::nullopt; + std::memcpy(buf, s.data(), s.size()); + buf[s.size()] = '\0'; + in_addr out{}; + if (::inet_pton(AF_INET, buf, &out.s_addr) != 1) + return std::nullopt; + return out; +} + +namespace detail { + +// Build an ifreq with name + an IPv4 sockaddr_in filled from `addr`. The +// sockaddr is written via memcpy into a local then assigned, mirroring +// ipv4_from_ioctl in reverse — a direct cast into ifr_ifru would trip +// -Wcast-align on 32-bit ARM. +inline auto ifreq_with_addr(std::string_view iface, int cmd, in_addr addr) -> ifreq { + ifreq ifr{}; + iface.copy(ifr.ifr_name, IFNAMSIZ - 1); // zero-init → NUL-terminated + sockaddr_in sin{}; + sin.sin_family = AF_INET; + sin.sin_addr = addr; + std::memcpy(&ifr.ifr_addr, &sin, sizeof(sin)); + (void)cmd; + return ifr; +} + +// Build a diagnostic Error carrying errno's text (e.g. "SIOCSIFADDR failed: +// Operation not permitted") — the bare ioctl name alone is not enough for a +// user to tell EPERM (no CAP_NET_ADMIN) from ENODEV (no such interface). +inline auto ioctl_error(const char* what, int e) -> base::Error { + return base::Error{e, std::string{what} + ": " + std::strerror(e)}; +} + +} // namespace detail + +[[nodiscard]] inline auto set_ipv4_addr(int ctl, std::string_view iface, in_addr addr) + -> base::Result { + auto ifr = detail::ifreq_with_addr(iface, SIOCSIFADDR, addr); + if (::ioctl(ctl, SIOCSIFADDR, &ifr) < 0) + return std::unexpected(detail::ioctl_error("SIOCSIFADDR failed", errno)); + return {}; +} + +[[nodiscard]] inline auto set_netmask(int ctl, std::string_view iface, in_addr mask) + -> base::Result { + auto ifr = detail::ifreq_with_addr(iface, SIOCSIFNETMASK, mask); + if (::ioctl(ctl, SIOCSIFNETMASK, &ifr) < 0) + return std::unexpected(detail::ioctl_error("SIOCSIFNETMASK failed", errno)); + return {}; +} + +[[nodiscard]] inline auto set_broadcast(int ctl, std::string_view iface, in_addr bcast) + -> base::Result { + auto ifr = detail::ifreq_with_addr(iface, SIOCSIFBRDADDR, bcast); + if (::ioctl(ctl, SIOCSIFBRDADDR, &ifr) < 0) + return std::unexpected(detail::ioctl_error("SIOCSIFBRDADDR failed", errno)); + return {}; +} + +[[nodiscard]] inline auto set_mtu(int ctl, std::string_view iface, int mtu) + -> base::Result { + ifreq ifr{}; + iface.copy(ifr.ifr_name, IFNAMSIZ - 1); + ifr.ifr_mtu = mtu; + if (::ioctl(ctl, SIOCSIFMTU, &ifr) < 0) + return std::unexpected(detail::ioctl_error("SIOCSIFMTU failed", errno)); + return {}; +} + +// Bring iface up/down via read-modify-write on flags (preserve BROADCAST/etc). +[[nodiscard]] inline auto set_if_up(int ctl, std::string_view iface, bool up) + -> base::Result { + ifreq ifr{}; + iface.copy(ifr.ifr_name, IFNAMSIZ - 1); + if (::ioctl(ctl, SIOCGIFFLAGS, &ifr) < 0) + return std::unexpected(detail::ioctl_error("SIOCGIFFLAGS failed", errno)); + if (up) + ifr.ifr_flags |= static_cast(IFF_UP); + else + ifr.ifr_flags &= ~static_cast(IFF_UP); + if (::ioctl(ctl, SIOCSIFFLAGS, &ifr) < 0) + return std::unexpected(detail::ioctl_error("SIOCSIFFLAGS failed", errno)); + return {}; +} + // Format an interface in the multi-line BusyBox ifconfig style. inline auto format_ifconfig(const InterfaceInfo& it) -> std::string { auto pad = [](const std::string& s, std::size_t w) -> std::string { diff --git a/src/applets/ifconfig.cpp b/src/applets/ifconfig.cpp index e88aae6..65542c9 100644 --- a/src/applets/ifconfig.cpp +++ b/src/applets/ifconfig.cpp @@ -1,12 +1,18 @@ -// ifconfig.cpp — display network interfaces (read-only subset for now). +// ifconfig.cpp — display or configure network interfaces. // -// Implemented: `ifconfig` (up interfaces), `ifconfig -a` (all), `ifconfig IFACE` -// (one). Output mirrors the BusyBox multi-line format via net::format_ifconfig. -// Mutations (ADDR/netmask/up/down/mtu) are Wave 1 scope but deferred to a -// follow-up batch — this first cut closes the read path shared with ip/netstat. +// Read: `ifconfig` (up interfaces), `ifconfig -a` (all), `ifconfig IFACE`. +// Write: `ifconfig IFACE ADDR [netmask NM] [broadcast BC] [mtu N] [up|down]` — +// issues SIOCSIF* ioctls on one control socket; needs CAP_NET_ADMIN. +// EPERM is reported per-ioctl (this never silently succeeds). +// Output mirrors the BusyBox multi-line format via net::format_ifconfig. + +#include #include +#include #include +#include +#include #include #include @@ -18,12 +24,130 @@ namespace { constexpr cfbox::help::HelpEntry HELP = { .name = "ifconfig", .version = CFBOX_VERSION_STRING, - .one_line = "display network interfaces (read-only)", - .usage = "ifconfig [-a] [IFACE]", - .options = " -a show all interfaces, including down ones", + .one_line = "display or configure network interfaces", + .usage = "ifconfig [-a] [IFACE [ADDR [netmask NM] [broadcast BC] [mtu N] [up|down]]]", + .options = + " -a show all interfaces, including down ones\n" + " IFACE ADDR ... configure the interface (needs CAP_NET_ADMIN)", .extra = "", }; +// Apply mutations parsed from positional args (IFACE first, then ADDR/keywords). +auto ifconfig_write(const std::vector& args) -> int { + using namespace cfbox; + auto ctl = net::ctl_socket(); + if (!ctl) { + CFBOX_ERR("ifconfig", "%s", ctl.error().msg.c_str()); + return 1; + } + const std::string_view iface = args[0]; + + std::optional addr, mask, bcast; + std::optional mtu; + int up = 0; // +1 up, -1 down, 0 unspecified + + for (std::size_t i = 1; i < args.size(); ++i) { + const std::string_view t = args[i]; + + // Consume the token after the current one (advances i). Used by value + // keywords (netmask/broadcast/mtu). + auto next = [&]() -> std::optional { + if (i + 1 >= args.size()) + return std::nullopt; + return args[++i]; + }; + auto need_ipv4 = [&](std::string_view label) -> std::optional { + auto v = next(); + if (!v) { + CFBOX_ERR("ifconfig", "%.*s: missing value", static_cast(label.size()), + label.data()); + return std::nullopt; + } + auto a = net::parse_ipv4(*v); + if (!a) { + CFBOX_ERR("ifconfig", "invalid %.*s '%.*s'", static_cast(label.size()), + label.data(), static_cast(v->size()), v->data()); + return std::nullopt; + } + return a; + }; + + if (t == "up") { + up = 1; + } else if (t == "down") { + up = -1; + } else if (t == "netmask") { + auto v = need_ipv4("netmask"); + if (!v) + return 1; + mask = *v; + } else if (t == "broadcast") { + auto v = need_ipv4("broadcast"); + if (!v) + return 1; + bcast = *v; + } else if (t == "mtu") { + auto v = next(); + if (!v) { + CFBOX_ERR("ifconfig", "mtu: missing value"); + return 1; + } + auto n = args::parse_int(*v); + if (!n) { + CFBOX_ERR("ifconfig", "invalid mtu '%.*s'", static_cast(v->size()), v->data()); + return 1; + } + mtu = *n; + } else if (!addr) { + addr = net::parse_ipv4(t); + if (!addr) { + CFBOX_ERR("ifconfig", "invalid address '%.*s'", static_cast(t.size()), t.data()); + return 1; + } + } else { + CFBOX_ERR("ifconfig", "unexpected argument '%.*s'", static_cast(t.size()), t.data()); + return 1; + } + } + + if (addr) { + auto r = net::set_ipv4_addr(ctl->get(), iface, *addr); + if (!r) { + CFBOX_ERR("ifconfig", "%s", r.error().msg.c_str()); + return 1; + } + } + if (mask) { + auto r = net::set_netmask(ctl->get(), iface, *mask); + if (!r) { + CFBOX_ERR("ifconfig", "%s", r.error().msg.c_str()); + return 1; + } + } + if (bcast) { + auto r = net::set_broadcast(ctl->get(), iface, *bcast); + if (!r) { + CFBOX_ERR("ifconfig", "%s", r.error().msg.c_str()); + return 1; + } + } + if (mtu) { + auto r = net::set_mtu(ctl->get(), iface, *mtu); + if (!r) { + CFBOX_ERR("ifconfig", "%s", r.error().msg.c_str()); + return 1; + } + } + if (up != 0) { + auto r = net::set_if_up(ctl->get(), iface, up > 0); + if (!r) { + CFBOX_ERR("ifconfig", "%s", r.error().msg.c_str()); + return 1; + } + } + return 0; +} + } // namespace auto ifconfig_main(int argc, char* argv[]) -> int { @@ -42,6 +166,14 @@ auto ifconfig_main(int argc, char* argv[]) -> int { return 0; } + const auto& pos = parsed.positional(); + + // Write path: IFACE + at least one mutation token. + if (pos.size() >= 2) { + return ifconfig_write(pos); + } + + // Read path (display). auto interfaces = net::read_interfaces(); if (!interfaces) { CFBOX_ERR("ifconfig", "%s", interfaces.error().msg.c_str()); @@ -50,8 +182,8 @@ auto ifconfig_main(int argc, char* argv[]) -> int { // BusyBox semantics: no args → up interfaces only; -a → all; IFACE → that one std::string want; - if (!parsed.positional().empty()) - want = std::string{parsed.positional()[0]}; + if (!pos.empty()) + want = std::string{pos[0]}; const bool show_all = parsed.has('a') || !want.empty(); bool any = false; diff --git a/tests/integration/test_ifconfig.sh b/tests/integration/test_ifconfig.sh index d3bd502..67b77ab 100755 --- a/tests/integration/test_ifconfig.sh +++ b/tests/integration/test_ifconfig.sh @@ -41,5 +41,29 @@ else ((++fail)) fi +# write path: needs CAP_NET_ADMIN. Two branches — non-root must report EPERM +# (never silently succeed), root round-trips MTU on loopback (safe + reversible). +if [ "$(id -u)" -ne 0 ]; then + out=$("$CFBOX" ifconfig lo 127.0.0.1 2>&1) || true + if echo "$out" | grep -qi "Operation not permitted\|Permission denied"; then + ((++pass)) + else + echo "FAIL [ifconfig write non-root]: expected EPERM, got: $out" + ((++fail)) + fi +else + orig=$("$CFBOX" ifconfig lo 2>&1 | grep -o 'MTU:[0-9]*' | head -1) + "$CFBOX" ifconfig lo mtu 1280 2>&1 || true + out=$("$CFBOX" ifconfig lo 2>&1) || true + if echo "$out" | grep -q "MTU:1280"; then + ((++pass)) + else + echo "FAIL [ifconfig write root]: MTU not applied — $(echo "$out" | head -1)" + ((++fail)) + fi + # restore original MTU + [ -n "$orig" ] && "$CFBOX" ifconfig lo mtu "${orig#MTU:}" 2>/dev/null || true +fi + echo "ifconfig: $pass passed, $fail failed" [[ $fail -eq 0 ]] diff --git a/tests/unit/test_net_util.cpp b/tests/unit/test_net_util.cpp index 1b7c4d4..21460be 100644 --- a/tests/unit/test_net_util.cpp +++ b/tests/unit/test_net_util.cpp @@ -110,3 +110,18 @@ TEST(NetUtilTest, FormatRouteTableHasHeader) { EXPECT_NE(s.find("UG"), std::string::npos); EXPECT_NE(s.find("192.168.1.1"), std::string::npos); } + +TEST(NetUtilParseIpv4Test, Valid) { + EXPECT_TRUE(cfbox::net::parse_ipv4("127.0.0.1").has_value()); + EXPECT_TRUE(cfbox::net::parse_ipv4("10.0.0.1").has_value()); + EXPECT_TRUE(cfbox::net::parse_ipv4("255.255.255.255").has_value()); + EXPECT_TRUE(cfbox::net::parse_ipv4("0.0.0.0").has_value()); +} + +TEST(NetUtilParseIpv4Test, Invalid) { + EXPECT_FALSE(cfbox::net::parse_ipv4("").has_value()); + EXPECT_FALSE(cfbox::net::parse_ipv4("256.0.0.1").has_value()); + EXPECT_FALSE(cfbox::net::parse_ipv4("1.2.3").has_value()); + EXPECT_FALSE(cfbox::net::parse_ipv4("not-an-ip").has_value()); + EXPECT_FALSE(cfbox::net::parse_ipv4("127.0.0.1.5").has_value()); +} From 3feefd27bd3b74b8c8af28ced2c1d3659c24751f Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 12:46:29 +0800 Subject: [PATCH 3/6] feat(ping): icmp.hpp primitives + ping applet (Phase 3 wave 2) icmp.hpp: checksum (RFC 1071, pure), build_echo_request (id/seq network order, self-consistent checksum), parse_icmp (strips IP header via IHL, returns type/id/seq/recv_ttl), open_raw (SOCK_RAW IPPROTO_ICMP), recv_icmp (match_id filter for ping mode, monotonic deadline loop for traceroute mode). ping: -c/-i/-W/-s/-q/-n, SIGINT RAII guard prints summary, EPERM exits 2 (distinct from resolve failure exit 1), RTT in microseconds. --- cmake/Config.cmake | 2 +- document/ai/PLAN.md | 5 +- document/notes/2026-07-06-phase3-icmp-ping.md | 62 +++++ include/cfbox/applet_config.hpp.in | 1 + include/cfbox/applets.hpp | 6 + include/cfbox/icmp.hpp | 159 ++++++++++++ src/applets/ping.cpp | 244 ++++++++++++++++++ tests/integration/test_ping.sh | 37 +++ tests/unit/test_icmp.cpp | 110 ++++++++ 9 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 document/notes/2026-07-06-phase3-icmp-ping.md create mode 100644 include/cfbox/icmp.hpp create mode 100644 src/applets/ping.cpp create mode 100755 tests/integration/test_ping.sh create mode 100644 tests/unit/test_icmp.cpp diff --git a/cmake/Config.cmake b/cmake/Config.cmake index a6ae529..ef6dfd2 100644 --- a/cmake/Config.cmake +++ b/cmake/Config.cmake @@ -29,7 +29,7 @@ set(CFBOX_APPLETS dmesg hexdump more rev cal renice clear which mount mountpoint mdev chmod chown chgrp umount swapoff reboot - nc ifconfig ip route netstat + nc ifconfig ip route netstat ping ) foreach(applet IN LISTS CFBOX_APPLETS) diff --git a/document/ai/PLAN.md b/document/ai/PLAN.md index f85f43b..33c2428 100644 --- a/document/ai/PLAN.md +++ b/document/ai/PLAN.md @@ -3,7 +3,7 @@ > Tier 3(批级,易变)。单一事实源(批级)。全树见 [ROADMAP.md](ROADMAP.md),铁律见 [DIRECTIVES.md](DIRECTIVES.md);结构标尺见 [STRUCTURE-TASTE.md](STRUCTURE-TASTE.md),性能标尺见 [PERFORMANCE.md](PERFORMANCE.md)。 > **v0.3.0 已发布**:L2 rootfs 启动骨架(init/mount/mdev/umount/swapoff/reboot/poweroff,117→123 applet)+ tail -f —— cfbox 在 i.MX6ULL 上作为 PID 1 替代 BusyBox。 > ✅ **Phase 2 全部完成**(cp/test/ls/grep/find + sh 全收 8 项)+ ✅ **结构与性能标尺横切批**(PR#17/#18)—— STRUCTURE-TASTE + banned-pattern/layering gate、PERFORMANCE + io/tar/cmp/md5sum/sed 流式化 + google-benchmark 脚手架。当前基线 **436 GTest / 439 KB size-opt / 123 applet**。 -> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅、批3(`ip`/`route`/`hostname` 深化)✅、批4(`netstat` 只读 + `split_fields` 抽取)✅、批5(`ifconfig` 写操作 SIOCSIF*)✅ —— 当前基线 **464 GTest / 471 KB / 128 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 +> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅、批3(`ip`/`route`/`hostname` 深化)✅、批4(`netstat` 只读 + `split_fields` 抽取)✅、批5(`ifconfig` 写操作 SIOCSIF*)✅、批6(`icmp.hpp` + `ping`)✅ —— 当前基线 **475 GTest / 479 KB / 129 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 > 状态:✅ DONE / 🔄 NEXT / ⏳ PENDING / ⛔ BLOCKED。每批≈一 commit,完成门 `cmake --build build -j$(nproc) && ctest --test-dir build --output-on-failure` 全绿 + `bash tests/integration/run_all.sh`。 ## ✅ Phase 1.5(代码质量审查)已完成 — 2026-05-26 @@ -67,8 +67,9 @@ | 批3(Wave 1b) | `net_util.hpp` 扩展(`read_routes` 解析 /proc/net/route tab-hex + `format_route_table` BusyBox route -n + `hex_to_ipv4`/`prefix_len`)+ `ip addr show`(iproute2 风格 index/flags/mtu/link/inet)+ `route -n`(显示)+ `hostname` 深化(`-i`/`-f`/`-d` getaddrinfo + memcpy 解 cast-align) | ✅ | f0ff0db | 452/2 | | 批4(Wave 1c) | `net_util.hpp` 抽公共 `split_fields`(`read_routes` 改用,DRY)+ `read_tcp/udp_sockets`(解析 /proc/net/tcp\|udp `hexIP:hexPort` + state code + tx:rx queues)+ `read_unix_sockets`(/proc/net/unix)+ `parse_inet_sockets` 纯函数化(喂假数据可单测)+ `format_netstat_inet/unix`(BusyBox 风格,LISTEN 过滤/-a)+ `netstat` applet(`-t/-u/-x/-a/-n`) | ✅ | (本批) | 462/3 | | 批5(Wave 1c) | `net_util.hpp` 写 ioctl:抽 `ctl_socket`/`parse_ipv4`/`detail::ifreq_with_addr`(memcpy 写 sockaddr 解 cast-align)/`detail::ioctl_error`(msg 含 strerror)+ `set_ipv4_addr`/`set_netmask`/`set_broadcast`/`set_mtu`/`set_if_up`(read-modify-write IFF_UP)+ `ifconfig` 写 codepath(`IFACE ADDR [netmask NM] [broadcast BC] [mtu N] [up\|down]`,EPERM 不静默) | ✅ | (本批) | 464/5 | +| 批6(Wave 2) | `include/cfbox/icmp.hpp` 新基础设施(`checksum` RFC1071 纯函数 + `build_echo_request` + `parse_icmp` 剥 IP 头按 IHL + `open_raw` SOCK_RAW IPPROTO_ICMP + `recv_icmp` match_id 过滤/monotonic deadline + `now_us`)+ `ping` applet(`-c/-i/-W/-s/-q/-n`,SIGINT RAII 打 summary,EPERM exit 2 区分 resolve 失败 exit 1) | ✅ | (本批) | 475/3 | -> 下一批:批6 Wave 2 `icmp.hpp`(checksum/build_echo_request/parse_icmp/open_raw/recv_icmp)+ `ping`(SOCK_RAW IPPROTO_ICMP)。之后批7 `traceroute`(UDP 探测 + 复用 icmp 收包)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md) / [notes/2026-07-06-phase3-ip-route-hostname.md](../notes/2026-07-06-phase3-ip-route-hostname.md) / [notes/2026-07-06-phase3-netstat.md](../notes/2026-07-06-phase3-netstat.md) / [notes/2026-07-06-phase3-ifconfig-write.md](../notes/2026-07-06-phase3-ifconfig-write.md)。 +> 下一批:批7 Wave 2 `traceroute`(UDP 高端口探测 + 递增 TTL + 复用 `icmp::open_raw`/`recv_icmp` 收 time-exceeded/port-unreachable)—— Phase 3 网络最小闭环收口。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md) / [notes/2026-07-06-phase3-ip-route-hostname.md](../notes/2026-07-06-phase3-ip-route-hostname.md) / [notes/2026-07-06-phase3-netstat.md](../notes/2026-07-06-phase3-netstat.md) / [notes/2026-07-06-phase3-ifconfig-write.md](../notes/2026-07-06-phase3-ifconfig-write.md) / [notes/2026-07-06-phase3-icmp-ping.md](../notes/2026-07-06-phase3-icmp-ping.md)。 ## OPEN GOTCHAS(跨批陷阱,改前必看) diff --git a/document/notes/2026-07-06-phase3-icmp-ping.md b/document/notes/2026-07-06-phase3-icmp-ping.md new file mode 100644 index 0000000..807fcba --- /dev/null +++ b/document/notes/2026-07-06-phase3-icmp-ping.md @@ -0,0 +1,62 @@ +# Phase 3 批6(Wave 2)— icmp.hpp 基础设施 + ping + +> 2026-07-06。新建 `icmp.hpp`(raw ICMP 共享层)+ `ping` applet。SOCK_RAW IPPROTO_ICMP +> 需 CAP_NET_RAW —— CI native/qemu-user 无权,集成 skip guard 接受 EPERM 或真 reply 两种结果; +> qemu-system(root)能真跑。纯函数(checksum/build/parse)单测全覆盖。 + +## 触及文件 + +- [include/cfbox/icmp.hpp](../../include/cfbox/icmp.hpp) — 新(header-only): + - `now_us()`(CLOCK_MONOTONIC 微秒,RTT + poll deadline 共用) + - `checksum(span) -> uint16_t`(RFC 1071,纯函数) + - `build_echo_request(id, seq, payload, out) -> size_t`(type=ICMP_ECHO + 网络序 id/seq + 算后填 checksum) + - `ParsedIcmp{type,code,id,seq,recv_ttl}` + `parse_icmp(span) -> Result`(按 IHL 剥 IP 头,recv_ttl 取 IP 头偏移 8) + - `open_raw() -> Result`(EPERM→err msg 含 strerror) + - `RecvResult{from, msg}` + `recv_icmp(fd, match_id, timeout_ms) -> Result`(match_id≠0 过滤非己 echo,monotonic deadline 循环,EINTR continue) +- [src/applets/ping.cpp](../../src/applets/ping.cpp) — 新 applet(`-c/-i/-W/-s/-q/-n`,getaddrinfo AF_INET resolve,SIGINT RAII guard 打 summary,EPERM exit 2 / resolve 失败 exit 1) +- 注册三件套(`CFBOX_ENABLE_PING`) +- [tests/unit/test_icmp.cpp](../../tests/unit/test_icmp.cpp) — 11 测(checksum 4:全 0/全 1/奇数/carry-fold;build_echo 3:字节布局/checksum 自洽(整体 checksum=0)/out 太小;parse 4:剥 IP 头/IHL≠6 含选项/太短/坏 IHL) +- [tests/integration/test_ping.sh](../../tests/integration/test_ping.sh) — 3 测(EPERM 或 reply 二选一 + 无参 exit 非零 + 坏 -c exit 非零) + +## 关键设计决策 + +1. **`recv_icmp` match_id 双模式**:ping 传 `id`(过滤非己 echo,避免被主机其他 ICMP 干扰), + traceroute 传 `0`(收任意 ICMP —— time-exceeded/port-unreachable 的 id 对应原始 UDP 包, + 不匹配 ping 的 ICMP id)。一个 API 服务两个 applet。 +2. **monotonic deadline 循环**:`recv_icmp` 内 `now_us() + timeout*1000` 算 deadline,每轮 poll + 剩余时间。match_id 不匹配或 malformed 包 continue 不消耗整个 timeout(只减剩余)—— + ping 不会因收到一个无关 echo 就丢掉整个等待窗口。 +3. **parse_icmp 按 IHL 剥头**:raw recv 含 IP 头,`raw[0] & 0x0f * 4` 算 IP 头长。 + 测了 IHL=5(标准)和 IHL=6(含 IP 选项)两种。recv_ttl 取 IP 头偏移 8(标准 TTL 字节位置)。 +4. **checksum 自洽性测试**:build_echo_request 后对整个包跑 checksum 应返回 0(ICMP 校验和的 + 定义性质 —— 含 checksum 字段的整体求和为 0xffff,~= 0)。这比对照具体 RFC 向量更强 —— + 它验证了「我们填的 checksum 让接收端校验通过」。 +5. **SIGINT RAII guard**:`sa.sa_flags=0`(不放 SA_RESTART)让 usleep/poll 被 SIGINT EINTR 唤醒, + ping 快速响应 Ctrl-C 打 summary。析构 restore old_act —— multi-call binary 不污染后续 applet。 +6. **EPERM exit 2 ≠ resolve 失败 exit 1**:用户能区分「没权限」与「host 不存在」。 +7. **RTT 微秒精度**:`now_us()` us,显示 `rtt/1000.0`(3 位小数 ms)。本地 loopback RTT < 1ms, + ms 整数精度会显示 0.0 ms 误导。 +8. **`` 引入**:ICMP 是字节流,`std::span` 比 pointer+len 更安全现代。 + C++23 标准库支持,项目首次用,编译过 armhf。 + +## 完成门 + +- `ctest`:**475/475** 全绿(+11 icmp 测) +- `bash tests/integration/run_all.sh`:全绿(ping 3/3) +- size-opt:**479 KB**(+8 KB,预算 550 KB,余 71 KB) +- armhf 交叉编译 + qemu:ping EPERM path 在 32 位正确报 "socket(SOCK_RAW, ICMP) failed: Operation not permitted",无新 cast-align 警告(sockaddr_storage*→sockaddr* 对齐兼容,不触发) + +## Gotcha + +- **本机无 CAP_NET_RAW 不能真 ping**:开发机非 root,real ping smoke 报 EPERM exit 2(设计如此)。 + 真验证依赖 qemu-system 阶段(CFBox 当 PID 1 有 root)。本机单测 + 32 位 EPERM 报错路径已覆盖可测部分。 +- **checksum 奇数长度**:最后单字节作高位(`data[i] << 8`)。RFC 1071 规定如此。 +- **carry 折叠**:用 `while (sum >> 16)` 循环(非一次折叠),处理极端情况(多次进位)。 + 测 3×0xffff=0x2fffd → fold 0xffff → ~ = 0x0000。 +- **`recvfrom` 缓冲 1500**:足够标准 MTU 的 IP+ICMP。jumbo frame 会截断,但 ping/traceroute + 探测包远小于 1500,不影响。 +- **`sa.sa_flags=0` 的代价**:usleep 被 SIGINT 中断后不重睡 —— ping 在 reply 间隔按 Ctrl-C 立即退出。 + 但 recv_icmp 内 poll EINTR 后 continue,若 SIGINT 到达在 recv 等待期,最坏延迟到 timeout。 + 权衡接受(Ctrl-C 在 sleep 期立即响应;在 recv 期延迟 ≤ timeout)。 +- **`build_echo_request` 返回 0 表 out 太小**:调用者应检查。ping.cpp 用固定 1500 缓冲, + payload ≤ 1400,永不触发。 diff --git a/include/cfbox/applet_config.hpp.in b/include/cfbox/applet_config.hpp.in index f2fa6fe..c593505 100644 --- a/include/cfbox/applet_config.hpp.in +++ b/include/cfbox/applet_config.hpp.in @@ -129,3 +129,4 @@ #cmakedefine01 CFBOX_ENABLE_IP #cmakedefine01 CFBOX_ENABLE_ROUTE #cmakedefine01 CFBOX_ENABLE_NETSTAT +#cmakedefine01 CFBOX_ENABLE_PING diff --git a/include/cfbox/applets.hpp b/include/cfbox/applets.hpp index 5549f60..6135a04 100644 --- a/include/cfbox/applets.hpp +++ b/include/cfbox/applets.hpp @@ -380,6 +380,9 @@ extern auto route_main(int argc, char* argv[]) -> int; #if CFBOX_ENABLE_NETSTAT extern auto netstat_main(int argc, char* argv[]) -> int; #endif +#if CFBOX_ENABLE_PING +extern auto ping_main(int argc, char* argv[]) -> int; +#endif // registry — one line per applet, conditionally compiled constexpr auto APPLET_REGISTRY = std::to_array({ @@ -761,4 +764,7 @@ constexpr auto APPLET_REGISTRY = std::to_array({ #if CFBOX_ENABLE_NETSTAT {"netstat", netstat_main, "display network sockets"}, #endif +#if CFBOX_ENABLE_PING + {"ping", ping_main, "send ICMP ECHO_REQUEST to a host"}, +#endif }); diff --git a/include/cfbox/icmp.hpp b/include/cfbox/icmp.hpp new file mode 100644 index 0000000..8269fc2 --- /dev/null +++ b/include/cfbox/icmp.hpp @@ -0,0 +1,159 @@ +#pragma once + +// icmp.hpp — ICMP echo primitives shared by ping and traceroute. Header-only, +// errors via cfbox::base::Result, no exceptions/RTTI. Uses SOCK_RAW +// IPPROTO_ICMP (needs CAP_NET_RAW); without it open_raw reports EPERM and the +// applet prints a clear message instead of silently failing. +// +// Raw recv buffers carry a full IP packet (IP header + ICMP). parse_icmp strips +// the IP header via the IHL field. The internet checksum (RFC 1071) is pure and +// unit-tested with known vectors. + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace cfbox::icmp { + +// Monotonic clock in microseconds — used both for RTT math (ping) and for the +// recv_icmp deadline. CLOCK_MONOTONIC is unaffected by wall-clock jumps. +inline auto now_us() -> long long { + timespec ts{}; + ::clock_gettime(CLOCK_MONOTONIC, &ts); + return static_cast(ts.tv_sec) * 1000000LL + ts.tv_nsec / 1000; +} + +// RFC 1071 internet checksum over a byte span: sum 16-bit big-endian words, +// fold carries, complement. Endian-agnostic in result. Pure. +[[nodiscard]] inline auto checksum(std::span data) -> std::uint16_t { + std::uint32_t sum = 0; + std::size_t i = 0; + for (; i + 1 < data.size(); i += 2) + sum += (static_cast(data[i]) << 8) | data[i + 1]; + if (i < data.size()) // leftover odd byte → high octet + sum += static_cast(data[i]) << 8; + while (sum >> 16) + sum = (sum & 0xffff) + (sum >> 16); + return static_cast(~sum); +} + +// Build an ICMP echo request (type=ICMP_ECHO, code=0) into `out`: 8-byte header +// + payload, checksum filled. Returns bytes written, or 0 if `out` too small. +[[nodiscard]] inline auto build_echo_request(std::uint16_t id, std::uint16_t seq, + std::span payload, + std::span out) -> std::size_t { + if (out.size() < 8 + payload.size()) + return 0; + out[0] = ICMP_ECHO; + out[1] = 0; + out[2] = 0; + out[3] = 0; + out[4] = static_cast(id >> 8); + out[5] = static_cast(id & 0xff); + out[6] = static_cast(seq >> 8); + out[7] = static_cast(seq & 0xff); + if (!payload.empty()) + std::memcpy(out.data() + 8, payload.data(), payload.size()); + auto total = 8 + payload.size(); + auto csum = checksum(out.subspan(0, total)); + out[2] = static_cast(csum >> 8); + out[3] = static_cast(csum & 0xff); + return total; +} + +struct ParsedIcmp { + std::uint8_t type; + std::uint8_t code; + std::uint16_t id; // host byte order + std::uint16_t seq; // host byte order + std::uint8_t recv_ttl; // TTL from the received IP header (byte offset 8) +}; + +// Parse ICMP from a raw recv buffer (INCLUDES the IP header). Strips IP via IHL. +// EINVAL on too-short / malformed input. +[[nodiscard]] inline auto parse_icmp(std::span raw) -> base::Result { + if (raw.size() < 28) // 20 (min IP) + 8 (ICMP header) + return std::unexpected(base::Error{EINVAL, "icmp packet too short"}); + std::size_t ihl = static_cast(raw[0] & 0x0f) * 4; + if (ihl < 20 || raw.size() < ihl + 8) + return std::unexpected(base::Error{EINVAL, "bad IP header length"}); + const std::uint8_t* p = raw.data() + ihl; + ParsedIcmp m; + m.type = p[0]; + m.code = p[1]; + m.id = (static_cast(p[4]) << 8) | p[5]; + m.seq = (static_cast(p[6]) << 8) | p[7]; + m.recv_ttl = raw[8]; // IP header TTL is byte offset 8 + return m; +} + +// Open a SOCK_RAW IPPROTO_ICMP socket. Returns EPERM without CAP_NET_RAW. +[[nodiscard]] inline auto open_raw() -> base::Result { + int fd = ::socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); + if (fd < 0) + return std::unexpected(base::Error{ + errno, "socket(SOCK_RAW, ICMP) failed: " + std::string(std::strerror(errno))}); + return io::unique_fd{fd}; +} + +struct RecvResult { + sockaddr_storage from; + ParsedIcmp msg; +}; + +// Block up to `timeout_ms` for the next ICMP packet. When match_id != 0, packets +// whose id doesn't match are skipped (ping mode — ignores foreign echoes so the +// caller isn't confused by unrelated ICMP on the host). match_id == 0 accepts +// any ICMP (traceroute mode — receives time-exceeded for its UDP probes). +// ETIMEDOUT on timeout. +[[nodiscard]] inline auto recv_icmp(int fd, std::uint16_t match_id, int timeout_ms) + -> base::Result { + auto deadline = now_us() + static_cast(timeout_ms) * 1000; + for (;;) { + auto remaining_us = deadline - now_us(); + if (remaining_us <= 0) + return std::unexpected(base::Error{ETIMEDOUT, "request timed out"}); + pollfd pfd{}; + pfd.fd = fd; + pfd.events = POLLIN; + int pr = ::poll(&pfd, 1, static_cast(remaining_us / 1000)); + if (pr < 0) { + if (errno == EINTR) + continue; + return std::unexpected(base::Error{errno, "poll() failed"}); + } + if (pr == 0) + return std::unexpected(base::Error{ETIMEDOUT, "request timed out"}); + std::uint8_t buf[1500]; + sockaddr_storage from{}; + socklen_t fromlen = sizeof(from); + ssize_t n = ::recvfrom(fd, buf, sizeof(buf), 0, reinterpret_cast(&from), &fromlen); + if (n < 0) { + if (errno == EINTR) + continue; + return std::unexpected(base::Error{errno, "recvfrom() failed"}); + } + auto msg = parse_icmp(std::span{buf, static_cast(n)}); + if (!msg) + continue; // malformed + if (match_id != 0 && msg->id != match_id) + continue; // foreign echo + return RecvResult{from, *msg}; + } +} + +} // namespace cfbox::icmp diff --git a/src/applets/ping.cpp b/src/applets/ping.cpp new file mode 100644 index 0000000..feb8eb7 --- /dev/null +++ b/src/applets/ping.cpp @@ -0,0 +1,244 @@ +// ping.cpp — ICMP echo client (SOCK_RAW IPPROTO_ICMP, needs CAP_NET_RAW). +// +// `ping [OPTIONS] HOST` — sends ICMP echo requests and prints per-reply RTT, +// then a summary on exit (Ctrl-C or -c reached). EPERM (no CAP_NET_RAW) is +// reported clearly and exits 2, distinct from a host-resolve failure (exit 1). +// IPv4 only this cut. + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr cfbox::help::HelpEntry HELP = { + .name = "ping", + .version = CFBOX_VERSION_STRING, + .one_line = "send ICMP ECHO_REQUEST packets to a host (needs CAP_NET_RAW)", + .usage = "ping [-c COUNT] [-i INTERVAL] [-W TIMEOUT] [-s SIZE] [-qn] HOST", + .options = + " -c COUNT stop after COUNT replies (default: run until Ctrl-C)\n" + " -i INTERVAL seconds between requests (default: 1)\n" + " -W TIMEOUT seconds to wait for each reply (default: 1)\n" + " -s SIZE payload bytes (default: 56, max: 1400)\n" + " -q quiet — summary only\n" + " -n numeric output (no reverse DNS)", + .extra = "", +}; + +// SIGINT handler + RAII restore (multi-call binary must not leak signal state). +volatile sig_atomic_t g_interrupted = 0; + +struct sigint_guard { + struct sigaction old_act{}; + bool installed = false; + sigint_guard() { + struct sigaction sa{}; + sa.sa_handler = [](int) { g_interrupted = 1; }; + ::sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; // no SA_RESTART — usleep/poll wake up promptly + installed = (::sigaction(SIGINT, &sa, &old_act) == 0); + } + ~sigint_guard() { + if (installed) + ::sigaction(SIGINT, &old_act, nullptr); + } +}; + +[[nodiscard]] auto resolve_ipv4(std::string_view host, sockaddr_storage& out) -> bool { + char buf[256]; + if (host.size() >= sizeof(buf)) + return false; + std::memcpy(buf, host.data(), host.size()); + buf[host.size()] = '\0'; + addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_RAW; + hints.ai_protocol = IPPROTO_ICMP; + addrinfo* res = nullptr; + if (::getaddrinfo(buf, nullptr, &hints, &res) != 0) + return false; + bool ok = res && res->ai_addrlen <= sizeof(out); + if (ok) + std::memcpy(&out, res->ai_addr, res->ai_addrlen); + if (res) + ::freeaddrinfo(res); + return ok; +} + +auto name_of(const sockaddr_storage& ss, bool numeric) -> std::string { + char host[NI_MAXHOST]; + int flags = numeric ? NI_NUMERICHOST : 0; + if (::getnameinfo(reinterpret_cast(&ss), sizeof(ss), host, sizeof(host), + nullptr, 0, flags) == 0) + return host; + return "?"; +} + +} // namespace + +auto ping_main(int argc, char* argv[]) -> int { + using namespace cfbox; + auto parsed = args::parse(argc, argv, + { + args::OptSpec{'c', true, "count"}, + args::OptSpec{'i', true, "interval"}, + args::OptSpec{'W', true, "timeout"}, + args::OptSpec{'s', true, "size"}, + args::OptSpec{'q', false, "quiet"}, + args::OptSpec{'n', false, "numeric"}, + }); + + if (parsed.has_long("help")) { + help::print_help(HELP); + return 0; + } + if (parsed.has_long("version")) { + help::print_version(HELP); + return 0; + } + + if (parsed.positional().empty()) { + CFBOX_ERR("ping", "usage: ping [OPTIONS] HOST"); + return 2; + } + const std::string host = std::string{parsed.positional()[0]}; + + long count = -1; // -1 = infinite + if (auto v = parsed.get('c')) { + auto n = args::parse_int(*v); + if (!n || *n < 1) { + CFBOX_ERR("ping", "invalid count '%.*s'", static_cast(v->size()), v->data()); + return 2; + } + count = *n; + } + int interval = 1; + if (auto v = parsed.get('i')) { + auto n = args::parse_int(*v); + if (!n || *n < 0) { + CFBOX_ERR("ping", "invalid interval '%.*s'", static_cast(v->size()), v->data()); + return 2; + } + interval = *n; + } + int timeout_s = 1; + if (auto v = parsed.get('W')) { + auto n = args::parse_int(*v); + if (!n || *n < 1) { + CFBOX_ERR("ping", "invalid timeout '%.*s'", static_cast(v->size()), v->data()); + return 2; + } + timeout_s = *n; + } + int pktsize = 56; + if (auto v = parsed.get('s')) { + auto n = args::parse_int(*v); + if (!n || *n < 8 || *n > 1400) { + CFBOX_ERR("ping", "invalid size '%.*s'", static_cast(v->size()), v->data()); + return 2; + } + pktsize = *n; + } + const bool quiet = parsed.has('q'); + const bool numeric = parsed.has('n'); + + sockaddr_storage dst{}; + if (!resolve_ipv4(host, dst)) { + CFBOX_ERR("ping", "cannot resolve '%s'", host.c_str()); + return 1; + } + + auto raw = icmp::open_raw(); + if (!raw) { + CFBOX_ERR("ping", "%s", raw.error().msg.c_str()); + return 2; + } + + const std::string dst_name = name_of(dst, numeric); + sockaddr_in din{}; + std::memcpy(&din, &dst, sizeof(din)); // sockaddr_in memcpy (cast-align safe) + char addr_buf[INET_ADDRSTRLEN]; + ::inet_ntop(AF_INET, &din.sin_addr, addr_buf, sizeof(addr_buf)); + + if (!quiet) + std::printf("PING %s (%s): %d data bytes\n", dst_name.c_str(), addr_buf, pktsize); + + sigint_guard sig; + + const std::uint16_t id = static_cast(::getpid() & 0xffff); + long tx = 0, rx = 0; + long long rtt_min = 0, rtt_max = 0, rtt_sum = 0; + + std::uint8_t out[1500]; + std::uint8_t payload[1400]; + for (int i = 0; i < pktsize; ++i) + payload[i] = static_cast(i); + + for (long seq = 1; (count < 0 || seq <= count) && !g_interrupted; ++seq) { + const std::uint16_t sid = static_cast(seq & 0xffff); + auto n = icmp::build_echo_request( + id, sid, std::span{payload, static_cast(pktsize)}, + std::span{out, sizeof(out)}); + const auto t0 = icmp::now_us(); + if (::sendto(raw->get(), out, n, 0, reinterpret_cast(&dst), sizeof(dst)) < 0) { + CFBOX_ERR("ping", "sendto: %s", std::strerror(errno)); + return 1; + } + ++tx; + + auto reply = icmp::recv_icmp(raw->get(), id, timeout_s * 1000); + if (reply) { + ++rx; + const auto rtt = icmp::now_us() - t0; + rtt_sum += rtt; + if (rx == 1) + rtt_min = rtt_max = rtt; + else { + if (rtt < rtt_min) + rtt_min = rtt; + if (rtt > rtt_max) + rtt_max = rtt; + } + if (!quiet) + std::printf("%zu bytes from %s: icmp_seq=%ld ttl=%d time=%.3f ms\n", n, addr_buf, + seq, reply->msg.recv_ttl, static_cast(rtt) / 1000.0); + } else if (!quiet) { + if (reply.error().code == ETIMEDOUT) + std::printf("no response from %s (icmp_seq=%ld)\n", addr_buf, seq); + else + CFBOX_ERR("ping", "%s", reply.error().msg.c_str()); + } + + if (count < 0 || seq < count) { + if (interval > 0) + ::usleep(static_cast(interval) * 1000000); + } + } + + if (tx > 0) { + const long loss = (tx - rx) * 100 / tx; + std::printf("--- %s ping statistics ---\n", dst_name.c_str()); + std::printf("%ld packets transmitted, %ld received, %ld%% packet loss\n", tx, rx, loss); + if (rx > 0) + std::printf("rtt min/avg/max = %.3f/%.3f/%.3f ms\n", + static_cast(rtt_min) / 1000.0, + static_cast(rtt_sum) / static_cast(rx) / 1000.0, + static_cast(rtt_max) / 1000.0); + } + return rx > 0 ? 0 : 1; +} diff --git a/tests/integration/test_ping.sh b/tests/integration/test_ping.sh new file mode 100755 index 0000000..7c2a975 --- /dev/null +++ b/tests/integration/test_ping.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# test_ping.sh — ping needs CAP_NET_RAW. Without it → clear EPERM + exit 2. +# With it (qemu-system / root) → a 127.0.0.1 self-ping yields icmp_seq. Both pass. +set -euo pipefail +source "$(dirname "$0")/helpers.sh" + +pass=0 fail=0 + +# Either we lack CAP_NET_RAW (EPERM, exit 2) or we have it (reply line). Both OK. +out=$("$CFBOX" ping -c 1 -W 1 127.0.0.1 2>&1) || true +if echo "$out" | grep -qi "Operation not permitted\|SOCK_RAW\|CAP_NET_RAW"; then + ((++pass)) +elif echo "$out" | grep -q "icmp_seq=1"; then + ((++pass)) +else + echo "FAIL [ping 127.0.0.1]: neither EPERM nor reply — $out" + ((++fail)) +fi + +# no host → usage error exit 2 +if ! "$CFBOX" ping >/dev/null 2>&1; then + ((++pass)) +else + echo "FAIL [ping no host]: should exit non-zero" + ((++fail)) +fi + +# invalid -c value → exit 2 +if ! "$CFBOX" ping -c abc 127.0.0.1 >/dev/null 2>&1; then + ((++pass)) +else + echo "FAIL [ping bad -c]: should exit non-zero" + ((++fail)) +fi + +echo "ping: $pass passed, $fail failed" +[[ $fail -eq 0 ]] diff --git a/tests/unit/test_icmp.cpp b/tests/unit/test_icmp.cpp new file mode 100644 index 0000000..109d019 --- /dev/null +++ b/tests/unit/test_icmp.cpp @@ -0,0 +1,110 @@ +// Unit tests for icmp.hpp pure functions (checksum / build_echo_request / parse_icmp). +// No raw sockets needed — these cover the byte-level contracts ping/traceroute rely on. + +#include + +#include + +#include + +#include + +TEST(IcmpChecksumTest, AllZeroTwoBytes) { + const std::uint8_t z[] = {0, 0}; + EXPECT_EQ(cfbox::icmp::checksum(z), 0xffff); +} + +TEST(IcmpChecksumTest, AllOnesTwoBytes) { + const std::uint8_t o[] = {0xff, 0xff}; + EXPECT_EQ(cfbox::icmp::checksum(o), 0x0000); +} + +TEST(IcmpChecksumTest, OddLengthHighOctet) { + // Single 0x00 byte → treated as high octet of a 16-bit word → sum 0 → ~ = 0xffff. + const std::uint8_t o[] = {0x00}; + EXPECT_EQ(cfbox::icmp::checksum(o), 0xffff); +} + +TEST(IcmpChecksumTest, CarryFold) { + // 0xffff*3 = 0x2fffd → fold (0xfffd + 0x2) = 0xffff → ~ = 0x0000 + const std::uint8_t o[] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + EXPECT_EQ(cfbox::icmp::checksum(o), 0x0000); +} + +TEST(IcmpBuildEchoRequestTest, HeaderLayout) { + std::uint8_t out[16] = {}; + const std::uint8_t payload[] = {0xaa, 0xbb}; + auto n = cfbox::icmp::build_echo_request(0x1234, 0x0001, payload, out); + EXPECT_EQ(n, 10u); + EXPECT_EQ(out[0], ICMP_ECHO); + EXPECT_EQ(out[1], 0); + EXPECT_EQ(out[4], 0x12); // id hi + EXPECT_EQ(out[5], 0x34); // id lo + EXPECT_EQ(out[6], 0x00); // seq hi + EXPECT_EQ(out[7], 0x01); // seq lo + EXPECT_EQ(out[8], 0xaa); + EXPECT_EQ(out[9], 0xbb); +} + +TEST(IcmpBuildEchoRequestTest, ChecksumSelfConsistent) { + // A correct ICMP checksum makes the whole packet checksum to 0 (the receiver + // sums header+payload including the checksum field → 0). + std::uint8_t out[64] = {}; + std::uint8_t payload[56]; + for (int i = 0; i < 56; ++i) + payload[i] = static_cast(i); + auto n = cfbox::icmp::build_echo_request(0xbeef, 0x0007, payload, out); + ASSERT_GT(n, 0u); + EXPECT_EQ(cfbox::icmp::checksum({out, n}), 0u); +} + +TEST(IcmpBuildEchoRequestTest, TooSmallOut) { + std::uint8_t out[4] = {}; + const std::uint8_t payload[] = {1, 2}; + EXPECT_EQ(cfbox::icmp::build_echo_request(1, 1, payload, out), 0u); +} + +TEST(IcmpParseTest, EchoReplyStripsIpHeader) { + // 20-byte IP header (IHL=5) + 8-byte ICMP echo reply. + std::uint8_t pkt[28] = {}; + pkt[0] = 0x45; // version 4, IHL 5 + pkt[8] = 64; // TTL + pkt[20] = ICMP_ECHOREPLY; + pkt[24] = 0x12; + pkt[25] = 0x34; // id + pkt[26] = 0x00; + pkt[27] = 0x05; // seq + auto m = cfbox::icmp::parse_icmp({pkt, 28}); + ASSERT_TRUE(m.has_value()) << m.error().msg; + EXPECT_EQ(m->type, ICMP_ECHOREPLY); + EXPECT_EQ(m->id, 0x1234); + EXPECT_EQ(m->seq, 0x0005); + EXPECT_EQ(m->recv_ttl, 64); +} + +TEST(IcmpParseTest, HandlesIpOptions) { + // IHL=6 → 24-byte IP header (4 bytes options). ICMP starts at offset 24. + std::uint8_t pkt[32] = {}; + pkt[0] = 0x46; + pkt[24] = ICMP_ECHOREPLY; + pkt[28] = 0xab; + pkt[29] = 0xcd; // id + auto m = cfbox::icmp::parse_icmp({pkt, 32}); + ASSERT_TRUE(m.has_value()); + EXPECT_EQ(m->type, ICMP_ECHOREPLY); + EXPECT_EQ(m->id, 0xabcd); +} + +TEST(IcmpParseTest, TooShort) { + const std::uint8_t pkt[10] = {}; + auto m = cfbox::icmp::parse_icmp({pkt, 10}); + EXPECT_FALSE(m.has_value()); +} + +TEST(IcmpParseTest, BadIhl) { + // IHL=2 (8 bytes) — invalid, below the 20-byte minimum. + std::uint8_t pkt[28] = {}; + pkt[0] = 0x42; + auto m = cfbox::icmp::parse_icmp({pkt, 28}); + EXPECT_FALSE(m.has_value()); +} From 5e041991ffb3a3e84651004e10a34a450df01eb7 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 12:59:29 +0800 Subject: [PATCH 4/6] feat(traceroute): UDP-probe traceroute + net_util resolve/name (Phase 3 wave 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit traceroute: UDP socket with setsockopt(IP_TTL) per-hop, bumps destination port per probe to dodge conntrack, receives time-exceeded/port-unreachable via icmp::recv_icmp (match_id=0). -m/-q/-w/-n, SIGINT RAII, EPERM exit 2. net_util: lift resolve_ipv4/name_of out of ping.cpp (shared, DRY) — ping refactored to use them. icmp: classify_reply pure fn (time_exceeded -> intermediate, port-unreach -> reached) unit-testable without CAP_NET_RAW. Phase 3 network loop closed: 11 applets, 3 infra headers. --- cmake/Config.cmake | 2 +- document/ai/PLAN.md | 7 +- .../notes/2026-07-06-phase3-traceroute.md | 57 ++++++ include/cfbox/applet_config.hpp.in | 1 + include/cfbox/applets.hpp | 6 + include/cfbox/icmp.hpp | 13 ++ include/cfbox/net_util.hpp | 34 ++++ src/applets/ping.cpp | 35 +--- src/applets/traceroute.cpp | 187 ++++++++++++++++++ tests/integration/test_traceroute.sh | 37 ++++ tests/unit/test_icmp.cpp | 19 ++ 11 files changed, 362 insertions(+), 36 deletions(-) create mode 100644 document/notes/2026-07-06-phase3-traceroute.md create mode 100644 src/applets/traceroute.cpp create mode 100755 tests/integration/test_traceroute.sh diff --git a/cmake/Config.cmake b/cmake/Config.cmake index ef6dfd2..36ef655 100644 --- a/cmake/Config.cmake +++ b/cmake/Config.cmake @@ -29,7 +29,7 @@ set(CFBOX_APPLETS dmesg hexdump more rev cal renice clear which mount mountpoint mdev chmod chown chgrp umount swapoff reboot - nc ifconfig ip route netstat ping + nc ifconfig ip route netstat ping traceroute ) foreach(applet IN LISTS CFBOX_APPLETS) diff --git a/document/ai/PLAN.md b/document/ai/PLAN.md index 33c2428..59ba828 100644 --- a/document/ai/PLAN.md +++ b/document/ai/PLAN.md @@ -3,7 +3,7 @@ > Tier 3(批级,易变)。单一事实源(批级)。全树见 [ROADMAP.md](ROADMAP.md),铁律见 [DIRECTIVES.md](DIRECTIVES.md);结构标尺见 [STRUCTURE-TASTE.md](STRUCTURE-TASTE.md),性能标尺见 [PERFORMANCE.md](PERFORMANCE.md)。 > **v0.3.0 已发布**:L2 rootfs 启动骨架(init/mount/mdev/umount/swapoff/reboot/poweroff,117→123 applet)+ tail -f —— cfbox 在 i.MX6ULL 上作为 PID 1 替代 BusyBox。 > ✅ **Phase 2 全部完成**(cp/test/ls/grep/find + sh 全收 8 项)+ ✅ **结构与性能标尺横切批**(PR#17/#18)—— STRUCTURE-TASTE + banned-pattern/layering gate、PERFORMANCE + io/tar/cmp/md5sum/sed 流式化 + google-benchmark 脚手架。当前基线 **436 GTest / 439 KB size-opt / 123 applet**。 -> 🔄 **Phase 3 网络最小闭环进行中**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅、批3(`ip`/`route`/`hostname` 深化)✅、批4(`netstat` 只读 + `split_fields` 抽取)✅、批5(`ifconfig` 写操作 SIOCSIF*)✅、批6(`icmp.hpp` + `ping`)✅ —— 当前基线 **475 GTest / 479 KB / 129 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 +> ✅ **Phase 3 网络最小闭环完成**:批1(`socket.hpp` + `nc`)✅、批2(`net_util.hpp` + `ifconfig` 显示)✅、批3(`ip`/`route`/`hostname` 深化)✅、批4(`netstat` 只读 + `split_fields` 抽取)✅、批5(`ifconfig` 写操作 SIOCSIF*)✅、批6(`icmp.hpp` + `ping`)✅、批7(`traceroute` UDP 探测 + ICMP 收包)✅ —— 最终基线 **479 GTest / 479 KB / 130 applet**。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。 > 状态:✅ DONE / 🔄 NEXT / ⏳ PENDING / ⛔ BLOCKED。每批≈一 commit,完成门 `cmake --build build -j$(nproc) && ctest --test-dir build --output-on-failure` 全绿 + `bash tests/integration/run_all.sh`。 ## ✅ Phase 1.5(代码质量审查)已完成 — 2026-05-26 @@ -56,7 +56,7 @@ > 批级记录见 [notes/2026-07-06-structure-performance.md](../notes/2026-07-06-structure-performance.md)。两批均未增删 applet,GTest 基线沿用 Phase 2 末 436;size-opt 体积 v0.3.0 的 418 KB → 439 KB(+21 KB,主因 io/tar 流式缓冲与 benchmark 链接产物,仍在 ≤ 550 KB 预算内)。 -## 🔄 Phase 3(网络最小闭环)— 进行中 +## ✅ Phase 3(网络最小闭环)— 已完成 > 目标:socket/http/net_util/icmp 基础设施 + ip/ifconfig/route/netstat/ping/traceroute/nslookup/wget/nc/tftp + hostname 深化(11 applet)。详见 [phase-2-network.md](../todo/phases/phase-2-network.md)。每批≈一 commit + 完成门。 @@ -68,8 +68,9 @@ | 批4(Wave 1c) | `net_util.hpp` 抽公共 `split_fields`(`read_routes` 改用,DRY)+ `read_tcp/udp_sockets`(解析 /proc/net/tcp\|udp `hexIP:hexPort` + state code + tx:rx queues)+ `read_unix_sockets`(/proc/net/unix)+ `parse_inet_sockets` 纯函数化(喂假数据可单测)+ `format_netstat_inet/unix`(BusyBox 风格,LISTEN 过滤/-a)+ `netstat` applet(`-t/-u/-x/-a/-n`) | ✅ | (本批) | 462/3 | | 批5(Wave 1c) | `net_util.hpp` 写 ioctl:抽 `ctl_socket`/`parse_ipv4`/`detail::ifreq_with_addr`(memcpy 写 sockaddr 解 cast-align)/`detail::ioctl_error`(msg 含 strerror)+ `set_ipv4_addr`/`set_netmask`/`set_broadcast`/`set_mtu`/`set_if_up`(read-modify-write IFF_UP)+ `ifconfig` 写 codepath(`IFACE ADDR [netmask NM] [broadcast BC] [mtu N] [up\|down]`,EPERM 不静默) | ✅ | (本批) | 464/5 | | 批6(Wave 2) | `include/cfbox/icmp.hpp` 新基础设施(`checksum` RFC1071 纯函数 + `build_echo_request` + `parse_icmp` 剥 IP 头按 IHL + `open_raw` SOCK_RAW IPPROTO_ICMP + `recv_icmp` match_id 过滤/monotonic deadline + `now_us`)+ `ping` applet(`-c/-i/-W/-s/-q/-n`,SIGINT RAII 打 summary,EPERM exit 2 区分 resolve 失败 exit 1) | ✅ | (本批) | 475/3 | +| 批7(Wave 2) | `traceroute` applet(UDP 探测 `setsockopt(IP_TTL)` 递增 + 每探 bump 目标端口避 conntrack + 复用 `icmp::open_raw`/`recv_icmp`(match_id=0) 收包 + `icmp::classify_reply` 判 time-exceeded/port-unreach)+ `net_util` 抽 `resolve_ipv4`/`name_of`(ping/traceroute 共享,ping.cpp 重构去重) | ✅ | (本批) | 479/3 | -> 下一批:批7 Wave 2 `traceroute`(UDP 高端口探测 + 递增 TTL + 复用 `icmp::open_raw`/`recv_icmp` 收 time-exceeded/port-unreachable)—— Phase 3 网络最小闭环收口。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md) / [notes/2026-07-06-phase3-ip-route-hostname.md](../notes/2026-07-06-phase3-ip-route-hostname.md) / [notes/2026-07-06-phase3-netstat.md](../notes/2026-07-06-phase3-netstat.md) / [notes/2026-07-06-phase3-ifconfig-write.md](../notes/2026-07-06-phase3-ifconfig-write.md) / [notes/2026-07-06-phase3-icmp-ping.md](../notes/2026-07-06-phase3-icmp-ping.md)。 +> **Phase 3 收口**:11 applet 全到位(socket/nc/ifconfig/ip/route/hostname/netstat + ifconfig 写 + ping/traceroute),3 基础设施(socket.hpp/net_util.hpp/icmp.hpp)。下一焦点待定(候选:route add/del、hostname NAME、netstat -r/-i、IPv6、或转 Phase 4 用户/文件权限)。批级记录见 [notes/2026-07-06-phase3-socket-nc.md](../notes/2026-07-06-phase3-socket-nc.md) / [notes/2026-07-06-phase3-ifconfig.md](../notes/2026-07-06-phase3-ifconfig.md) / [notes/2026-07-06-phase3-ip-route-hostname.md](../notes/2026-07-06-phase3-ip-route-hostname.md) / [notes/2026-07-06-phase3-netstat.md](../notes/2026-07-06-phase3-netstat.md) / [notes/2026-07-06-phase3-ifconfig-write.md](../notes/2026-07-06-phase3-ifconfig-write.md) / [notes/2026-07-06-phase3-icmp-ping.md](../notes/2026-07-06-phase3-icmp-ping.md) / [notes/2026-07-06-phase3-traceroute.md](../notes/2026-07-06-phase3-traceroute.md)。 ## OPEN GOTCHAS(跨批陷阱,改前必看) diff --git a/document/notes/2026-07-06-phase3-traceroute.md b/document/notes/2026-07-06-phase3-traceroute.md new file mode 100644 index 0000000..6487946 --- /dev/null +++ b/document/notes/2026-07-06-phase3-traceroute.md @@ -0,0 +1,57 @@ +# Phase 3 批7(Wave 2)— traceroute + net_util resolve/name 抽取 + +> 2026-07-06。`traceroute` applet(UDP 高端口探测 + 递增 TTL + 复用 `icmp.hpp` 收 +> time-exceeded/port-unreachable)。Phase 3 网络最小闭环收口批。同时把 `resolve_ipv4`/ +> `name_of` 从 ping.cpp 抽到 `net_util.hpp`(ping/traceroute 共享,DRY),并加 +> `icmp::classify_reply` 纯函数让 traceroute 的 ICMP 判定可单测。 + +## 触及文件 + +- [include/cfbox/net_util.hpp](../../include/cfbox/net_util.hpp) — 加 `` + `resolve_ipv4(sv, sockaddr_storage&) -> bool`(AF_INET,getaddrinfo)+ `name_of(ss, numeric) -> string`(getnameinfo,-n/反查) +- [include/cfbox/icmp.hpp](../../include/cfbox/icmp.hpp) — 加 `enum class reply_class` + `classify_reply(type, code)`(time_exceeded→intermediate / dest_unreach+port→reached / 其他→other) +- [src/applets/traceroute.cpp](../../src/applets/traceroute.cpp) — 新 applet(`-m/-q/-w/-n`,UDP socket + setsockopt IP_TTL 递增 + 每探 bump 目标端口 + recv_icmp match_id=0 收任意 ICMP + classify_reply 判到达 + 本地 sigint_guard) +- [src/applets/ping.cpp](../../src/applets/ping.cpp) — 重构:删本地 resolve_ipv4/name_of,用 `net::` 版(去重) +- 注册三件套(`CFBOX_ENABLE_TRACEROUTE`) +- [tests/unit/test_icmp.cpp](../../tests/unit/test_icmp.cpp) — +4 测 classify_reply(time_exceeded/port_unreach/其他 dest_unreach/echo_reply) +- [tests/integration/test_traceroute.sh](../../tests/integration/test_traceroute.sh) — 3 测(EPERM 或 banner + 无参 exit 非零 + 坏 -m exit 非零) + +## 关键设计决策 + +1. **UDP 探测 + ICMP 收包**(BusyBox 默认):UDP socket 发探测(递增 TTL + 递增目标端口), + SOCK_RAW ICMP socket 收 time-exceeded(中间路由器 TTL=0 丢弃)或 port-unreachable + (到达目的,UDP 高端口无监听)。两条 socket,ICMP 那条需 CAP_NET_RAW。 +2. **每探 bump 目标端口**(`33000 + ttl*100 + q`):避免中间 NAT/conntrack 把同五元组 + 折叠,确保每跳每探独立路由。 +3. **`recv_icmp` match_id=0**:traceroute 收的 ICMP time-exceeded 内嵌原始 UDP 包的 id, + 不是 traceroute 的 ICMP id(traceroute 不发 ICMP echo)。match_id=0 收任意 ICMP —— + ping 模式(match_id=id 过滤非己 echo)与 traceroute 模式(match_id=0 收全部)同一 API。 +4. **`classify_reply` 抽纯函数**:traceroute 的 ICMP 判定逻辑(time_exceeded→中间跳 / + port_unreach→到达)是核心业务逻辑,但 CI 无 CAP_NET_RAW 真跑不了 traceroute。 + 抽成纯函数 + 4 单测是 CI 唯一覆盖该判定的方式。 +5. **`resolve_ipv4`/`name_of` 抽到 net_util**:ping/traceroute 都要 host→sockaddr + 反查。 + 抽到 net_util 后 ping.cpp 删本地副本(-30 行),traceroute 直接用。职责归位(IP 地址解析属 net_util)。 +6. **`sigint_guard` 保留各自**:ping/traceroute 各一份 sigint_guard(~15 行)。signal 处理 + 是 applet 行为,非共享网络基建;若第三个 applet 需要再抽 `signal_guard.hpp`。traceroute.cpp + 注释里标了 lift 机会。 +7. **EPERM exit 2 ≠ resolve 失败 exit 1 ≠ 未到达 exit 1**:与 ping 一致(特权/resolve 区分); + traceroute 走完 maxttl 未到达也 exit 1(非零)。 + +## 完成门 + +- `ctest`:**479/479** 全绿(+4 classify_reply 测) +- `bash tests/integration/run_all.sh`:全绿(traceroute 3/3) +- size-opt:**479 KB**(+0 KB —— traceroute 增量被 ping.cpp 去重 + size-opt 内联抵消) +- armhf 交叉编译 + qemu:traceroute EPERM path 在 32 位正确报错,`--list` 列出全部 4 网络新 applet(ifconfig/netstat/ping/traceroute),无新 cast-align 警告 + +## Gotcha + +- **本机无 CAP_NET_RAW 不能真 trace**:开发机非 root,traceroute 报 EPERM exit 2。 + 真验证靠 qemu-system(root)。CI 单测覆盖 classify_reply 纯判定,集成覆盖 EPERM 路径。 +- **`setsockopt(IP_TTL)` 是 int**:`int t = ttl; setsockopt(fd, IPPROTO_IP, IP_TTL, &t, sizeof(t))` + —— int 对齐,无 cast-align 问题(不像 sockaddr*→sockaddr_in*)。 +- **127.0.0.1 自环 traceroute**:第一跳 TTL=1 即到目的,收 port-unreachable,reached=true。 + 但本机无 CAP 收不到,靠 qemu-system 验证。 +- **`classify_reply` 把 echo_reply 归 other**:traceroute 不发 ICMP echo,理论上不会收到 + echo_reply;若收到(主机 ICMP 噪声)归 other 当作该跳响应,不误判到达。 +- **hop_name 取首响应**:一跳多探,hop_name 取第一个响应的 from(BusyBox 行为)。 + 全 `*` 跳 hop_name 显示 `*`。 diff --git a/include/cfbox/applet_config.hpp.in b/include/cfbox/applet_config.hpp.in index c593505..bab4c4b 100644 --- a/include/cfbox/applet_config.hpp.in +++ b/include/cfbox/applet_config.hpp.in @@ -130,3 +130,4 @@ #cmakedefine01 CFBOX_ENABLE_ROUTE #cmakedefine01 CFBOX_ENABLE_NETSTAT #cmakedefine01 CFBOX_ENABLE_PING +#cmakedefine01 CFBOX_ENABLE_TRACEROUTE diff --git a/include/cfbox/applets.hpp b/include/cfbox/applets.hpp index 6135a04..12cdf5f 100644 --- a/include/cfbox/applets.hpp +++ b/include/cfbox/applets.hpp @@ -383,6 +383,9 @@ extern auto netstat_main(int argc, char* argv[]) -> int; #if CFBOX_ENABLE_PING extern auto ping_main(int argc, char* argv[]) -> int; #endif +#if CFBOX_ENABLE_TRACEROUTE +extern auto traceroute_main(int argc, char* argv[]) -> int; +#endif // registry — one line per applet, conditionally compiled constexpr auto APPLET_REGISTRY = std::to_array({ @@ -767,4 +770,7 @@ constexpr auto APPLET_REGISTRY = std::to_array({ #if CFBOX_ENABLE_PING {"ping", ping_main, "send ICMP ECHO_REQUEST to a host"}, #endif +#if CFBOX_ENABLE_TRACEROUTE + {"traceroute", traceroute_main, "trace the route to a host"}, +#endif }); diff --git a/include/cfbox/icmp.hpp b/include/cfbox/icmp.hpp index 8269fc2..f4036c8 100644 --- a/include/cfbox/icmp.hpp +++ b/include/cfbox/icmp.hpp @@ -110,6 +110,19 @@ struct ParsedIcmp { return io::unique_fd{fd}; } +// Classify a received ICMP for traceroute: TIME_EXCEEDED → intermediate hop +// (TTL hit 0 at a router), DEST_UNREACH+PORT_UNREACH → reached destination +// (our UDP high port has no listener), anything else → "other" (logged but +// treated as a response from that hop). Pure — unit-testable without sockets. +enum class reply_class { intermediate, reached, other }; +inline auto classify_reply(std::uint8_t type, std::uint8_t code) -> reply_class { + if (type == ICMP_TIME_EXCEEDED) + return reply_class::intermediate; + if (type == ICMP_DEST_UNREACH && code == ICMP_PORT_UNREACH) + return reply_class::reached; + return reply_class::other; +} + struct RecvResult { sockaddr_storage from; ParsedIcmp msg; diff --git a/include/cfbox/net_util.hpp b/include/cfbox/net_util.hpp index 801525f..164c051 100644 --- a/include/cfbox/net_util.hpp +++ b/include/cfbox/net_util.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -102,6 +103,39 @@ inline auto split_fields(std::string_view line) -> std::vector { return out; } +// Resolve a host to an IPv4 sockaddr_storage (AF_INET only — ping/traceroute +// are IPv4 in this cut). Returns false on resolution failure. Shared by ping, +// traceroute, and any future host→addr applet. +[[nodiscard]] inline auto resolve_ipv4(std::string_view host, sockaddr_storage& out) -> bool { + char buf[256]; + if (host.size() >= sizeof(buf)) + return false; + std::memcpy(buf, host.data(), host.size()); + buf[host.size()] = '\0'; + addrinfo hints{}; + hints.ai_family = AF_INET; + addrinfo* res = nullptr; + if (::getaddrinfo(buf, nullptr, &hints, &res) != 0) + return false; + bool ok = res && res->ai_addrlen <= sizeof(out); + if (ok) + std::memcpy(&out, res->ai_addr, res->ai_addrlen); // sockaddr_in via memcpy + if (res) + ::freeaddrinfo(res); + return ok; +} + +// Reverse-DNS (or numeric with `numeric=true`) of a sockaddr. Falls back to a +// "?" literal if getnameinfo fails — callers can use NI_NUMERICHOST for -n. +inline auto name_of(const sockaddr_storage& ss, bool numeric) -> std::string { + char host[NI_MAXHOST]; + int flags = numeric ? NI_NUMERICHOST : 0; + if (::getnameinfo(reinterpret_cast(&ss), sizeof(ss), host, sizeof(host), + nullptr, 0, flags) == 0) + return host; + return "?"; +} + // Read all interfaces: /proc/net/dev for counters + ioctl for the rest. [[nodiscard]] inline auto read_interfaces() -> base::Result> { int ctl = ::socket(AF_INET, SOCK_DGRAM, 0); diff --git a/src/applets/ping.cpp b/src/applets/ping.cpp index feb8eb7..cb8a5a7 100644 --- a/src/applets/ping.cpp +++ b/src/applets/ping.cpp @@ -22,6 +22,7 @@ #include #include #include +#include namespace { @@ -59,36 +60,6 @@ struct sigint_guard { } }; -[[nodiscard]] auto resolve_ipv4(std::string_view host, sockaddr_storage& out) -> bool { - char buf[256]; - if (host.size() >= sizeof(buf)) - return false; - std::memcpy(buf, host.data(), host.size()); - buf[host.size()] = '\0'; - addrinfo hints{}; - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_RAW; - hints.ai_protocol = IPPROTO_ICMP; - addrinfo* res = nullptr; - if (::getaddrinfo(buf, nullptr, &hints, &res) != 0) - return false; - bool ok = res && res->ai_addrlen <= sizeof(out); - if (ok) - std::memcpy(&out, res->ai_addr, res->ai_addrlen); - if (res) - ::freeaddrinfo(res); - return ok; -} - -auto name_of(const sockaddr_storage& ss, bool numeric) -> std::string { - char host[NI_MAXHOST]; - int flags = numeric ? NI_NUMERICHOST : 0; - if (::getnameinfo(reinterpret_cast(&ss), sizeof(ss), host, sizeof(host), - nullptr, 0, flags) == 0) - return host; - return "?"; -} - } // namespace auto ping_main(int argc, char* argv[]) -> int { @@ -158,7 +129,7 @@ auto ping_main(int argc, char* argv[]) -> int { const bool numeric = parsed.has('n'); sockaddr_storage dst{}; - if (!resolve_ipv4(host, dst)) { + if (!net::resolve_ipv4(host, dst)) { CFBOX_ERR("ping", "cannot resolve '%s'", host.c_str()); return 1; } @@ -169,7 +140,7 @@ auto ping_main(int argc, char* argv[]) -> int { return 2; } - const std::string dst_name = name_of(dst, numeric); + const std::string dst_name = net::name_of(dst, numeric); sockaddr_in din{}; std::memcpy(&din, &dst, sizeof(din)); // sockaddr_in memcpy (cast-align safe) char addr_buf[INET_ADDRSTRLEN]; diff --git a/src/applets/traceroute.cpp b/src/applets/traceroute.cpp new file mode 100644 index 0000000..4398c2c --- /dev/null +++ b/src/applets/traceroute.cpp @@ -0,0 +1,187 @@ +// traceroute.cpp — UDP-probe traceroute with ICMP time-exceeded reception. +// +// `traceroute [OPTIONS] HOST` — sends UDP probes to high ports with increasing +// TTL, receives ICMP time-exceeded (intermediate hops) or port-unreachable +// (destination reached) via a SOCK_RAW ICMP socket. Needs CAP_NET_RAW for the +// ICMP socket; the UDP socket needs no privilege. EPERM exits 2, host-resolve +// failure exits 1. IPv4 only this cut. + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr cfbox::help::HelpEntry HELP = { + .name = "traceroute", + .version = CFBOX_VERSION_STRING, + .one_line = "trace the route to a host (UDP probes, needs CAP_NET_RAW)", + .usage = "traceroute [-m MAXTTL] [-q NQUERIES] [-w WAIT] [-n] HOST", + .options = + " -m MAXTTL max time-to-live (hops), default 30\n" + " -q NQUERIES probes per hop, default 3\n" + " -w WAIT seconds to wait per probe, default 5\n" + " -n numeric output (no reverse DNS)", + .extra = "", +}; + +// RAII SIGINT handler (shared shape with ping.cpp — could lift to a common +// signal_guard.hpp if a third applet needs it). +volatile sig_atomic_t g_interrupted = 0; + +struct sigint_guard { + struct sigaction old_act{}; + bool installed = false; + sigint_guard() { + struct sigaction sa{}; + sa.sa_handler = [](int) { g_interrupted = 1; }; + ::sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + installed = (::sigaction(SIGINT, &sa, &old_act) == 0); + } + ~sigint_guard() { + if (installed) + ::sigaction(SIGINT, &old_act, nullptr); + } +}; + +} // namespace + +auto traceroute_main(int argc, char* argv[]) -> int { + using namespace cfbox; + auto parsed = args::parse(argc, argv, + { + args::OptSpec{'m', true, "maxttl"}, + args::OptSpec{'q', true, "nqueries"}, + args::OptSpec{'w', true, "wait"}, + args::OptSpec{'n', false, "numeric"}, + }); + + if (parsed.has_long("help")) { + help::print_help(HELP); + return 0; + } + if (parsed.has_long("version")) { + help::print_version(HELP); + return 0; + } + + if (parsed.positional().empty()) { + CFBOX_ERR("traceroute", "usage: traceroute [OPTIONS] HOST"); + return 2; + } + const std::string host = std::string{parsed.positional()[0]}; + + int maxttl = 30; + if (auto v = parsed.get('m')) { + auto n = args::parse_int(*v); + if (!n || *n < 1 || *n > 64) { + CFBOX_ERR("traceroute", "invalid maxttl '%.*s'", static_cast(v->size()), v->data()); + return 2; + } + maxttl = *n; + } + int nqueries = 3; + if (auto v = parsed.get('q')) { + auto n = args::parse_int(*v); + if (!n || *n < 1 || *n > 10) { + CFBOX_ERR("traceroute", "invalid nqueries '%.*s'", static_cast(v->size()), v->data()); + return 2; + } + nqueries = *n; + } + int wait_s = 5; + if (auto v = parsed.get('w')) { + auto n = args::parse_int(*v); + if (!n || *n < 1) { + CFBOX_ERR("traceroute", "invalid wait '%.*s'", static_cast(v->size()), v->data()); + return 2; + } + wait_s = *n; + } + const bool numeric = parsed.has('n'); + + sockaddr_storage dst{}; + if (!net::resolve_ipv4(host, dst)) { + CFBOX_ERR("traceroute", "cannot resolve '%s'", host.c_str()); + return 1; + } + + auto raw = icmp::open_raw(); + if (!raw) { + CFBOX_ERR("traceroute", "%s", raw.error().msg.c_str()); + return 2; + } + int udp = ::socket(AF_INET, SOCK_DGRAM, 0); + if (udp < 0) { + CFBOX_ERR("traceroute", "socket(UDP): %s", std::strerror(errno)); + return 1; + } + io::unique_fd udp_fd{udp}; + + sockaddr_in din{}; + std::memcpy(&din, &dst, sizeof(din)); // sockaddr_in via memcpy (cast-align safe) + char addr_buf[INET_ADDRSTRLEN]; + ::inet_ntop(AF_INET, &din.sin_addr, addr_buf, sizeof(addr_buf)); + + std::printf("traceroute to %s (%s), %d hops max, 38 byte packets\n", host.c_str(), addr_buf, + maxttl); + + sigint_guard sig; + bool reached = false; + + for (int ttl = 1; ttl <= maxttl && !g_interrupted && !reached; ++ttl) { + const int t = ttl; + if (::setsockopt(udp_fd.get(), IPPROTO_IP, IP_TTL, &t, sizeof(t)) < 0) { + CFBOX_ERR("traceroute", "setsockopt(IP_TTL): %s", std::strerror(errno)); + return 1; + } + + std::printf("%2d", ttl); + std::string hop_name; + bool hop_responded = false; + + for (int q = 0; q < nqueries && !g_interrupted; ++q) { + // Bump destination port per probe so NAT/conntrack doesn't fold hops together. + din.sin_port = htons(static_cast(33000 + ttl * 100 + q)); + const char probe = '\0'; + const auto t0 = icmp::now_us(); + if (::sendto(udp_fd.get(), &probe, 1, 0, reinterpret_cast(&din), sizeof(din)) < 0) { + CFBOX_ERR("traceroute", "sendto: %s", std::strerror(errno)); + return 1; + } + // match_id=0 — accept any ICMP (time-exceeded has our UDP's inner id, not ours). + auto reply = icmp::recv_icmp(raw->get(), 0, wait_s * 1000); + if (reply) { + if (!hop_responded) { + hop_name = net::name_of(reply->from, numeric); + hop_responded = true; + } + const auto rtt = icmp::now_us() - t0; + std::printf(" %.3f ms", static_cast(rtt) / 1000.0); + if (icmp::classify_reply(reply->msg.type, reply->msg.code) == icmp::reply_class::reached) + reached = true; + } else { + std::printf(" *"); + } + std::fflush(stdout); + } + + std::printf(" %s\n", hop_responded ? hop_name.c_str() : "*"); + } + + return reached ? 0 : 1; +} diff --git a/tests/integration/test_traceroute.sh b/tests/integration/test_traceroute.sh new file mode 100755 index 0000000..e330785 --- /dev/null +++ b/tests/integration/test_traceroute.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# test_traceroute.sh — traceroute needs CAP_NET_RAW (ICMP socket). Without it → +# clear EPERM + exit 2. With it → a 127.0.0.1 self-trace prints the banner. +set -euo pipefail +source "$(dirname "$0")/helpers.sh" + +pass=0 fail=0 + +# Either EPERM or a real trace banner — both pass. +out=$("$CFBOX" traceroute -m 3 -q 1 -w 1 -n 127.0.0.1 2>&1) || true +if echo "$out" | grep -qi "Operation not permitted\|SOCK_RAW\|CAP_NET_RAW"; then + ((++pass)) +elif echo "$out" | grep -q "traceroute to"; then + ((++pass)) +else + echo "FAIL [traceroute 127.0.0.1]: neither EPERM nor banner — $out" + ((++fail)) +fi + +# no host → exit 2 +if ! "$CFBOX" traceroute >/dev/null 2>&1; then + ((++pass)) +else + echo "FAIL [traceroute no host]: should exit non-zero" + ((++fail)) +fi + +# invalid -m → exit 2 +if ! "$CFBOX" traceroute -m abc 127.0.0.1 >/dev/null 2>&1; then + ((++pass)) +else + echo "FAIL [traceroute bad -m]: should exit non-zero" + ((++fail)) +fi + +echo "traceroute: $pass passed, $fail failed" +[[ $fail -eq 0 ]] diff --git a/tests/unit/test_icmp.cpp b/tests/unit/test_icmp.cpp index 109d019..2f15d0b 100644 --- a/tests/unit/test_icmp.cpp +++ b/tests/unit/test_icmp.cpp @@ -108,3 +108,22 @@ TEST(IcmpParseTest, BadIhl) { auto m = cfbox::icmp::parse_icmp({pkt, 28}); EXPECT_FALSE(m.has_value()); } + +TEST(IcmpClassifyReplyTest, TimeExceededIsIntermediate) { + EXPECT_EQ(cfbox::icmp::classify_reply(ICMP_TIME_EXCEEDED, 0), + cfbox::icmp::reply_class::intermediate); +} + +TEST(IcmpClassifyReplyTest, PortUnreachableIsReached) { + EXPECT_EQ(cfbox::icmp::classify_reply(ICMP_DEST_UNREACH, ICMP_PORT_UNREACH), + cfbox::icmp::reply_class::reached); +} + +TEST(IcmpClassifyReplyTest, OtherDestUnreachIsOther) { + EXPECT_EQ(cfbox::icmp::classify_reply(ICMP_DEST_UNREACH, 1), // net-unreachable + cfbox::icmp::reply_class::other); +} + +TEST(IcmpClassifyReplyTest, EchoReplyIsOther) { + EXPECT_EQ(cfbox::icmp::classify_reply(ICMP_ECHOREPLY, 0), cfbox::icmp::reply_class::other); +} From 501fa0eb9779132d27aa6cb770f20bde9dd27709 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 13:02:25 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs(phase3):=20mark=20closed=20=E2=80=94?= =?UTF-8?q?=20ROADMAP/MEMORY/recap=20(11=20applets,=203=20infra)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- document/ai/ROADMAP.md | 8 ++-- document/notes/2026-07-06-phase3-recap.md | 54 +++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 document/notes/2026-07-06-phase3-recap.md diff --git a/document/ai/ROADMAP.md b/document/ai/ROADMAP.md index 3f31ed1..aff6e7c 100644 --- a/document/ai/ROADMAP.md +++ b/document/ai/ROADMAP.md @@ -11,7 +11,7 @@ | Phase 1 | ✅ | [核心系统](../todo/phases/phase-1-core-system.md) | P0 系统命令(chmod/chown/dd/mount/stty 等 24 个新 applet) | | Phase 1.5 | ✅ | [代码质量审查](../todo/phases/phase-1.5-code-quality-review.md) | 错误处理一致性、风格、测试覆盖、体积检查(A-G 扫描全过) | | **Phase 2** | ✅ | [核心深化(同 Phase 1 文档 Part 3)](../todo/phases/phase-1-core-system.md) | tail -f、cp -a、test POSIX、ls -R/--color、grep -A/-B/-C、find 布尔、sh 深化(全完成) | -| Phase 3 | ⏳ | [网络最小闭环](../todo/phases/phase-2-network.md) | 基础网络配置、诊断、下载、连接调试 | +| Phase 3 | ✅ | [网络最小闭环](../todo/phases/phase-2-network.md) | socket/net_util/icmp 基础设施 + nc/ifconfig/ip/route/hostname/netstat + ifconfig 写 + ping/traceroute(11 applet) | | Phase 4 | ⏳ | [生产质量门禁深化](../todo/phases/phase-3-quality.md) | fuzzing、benchmark、POSIX 子集、release 工程 | | Phase 5 | ⏳ | [多用户与嵌入式运行时](../todo/phases/phase-4-multiuser.md) | login/getty/syslog/mdev/storage | | Phase 6 | ⏳ | [长尾完备性](../todo/phases/phase-5-longtail.md) | vi、额外压缩格式、硬件工具、长尾 applet | @@ -22,8 +22,8 @@ - 兼容性裁决见 [compatibility-policy.md](../todo/compatibility-policy.md);v1.0 验收边界见 [v1-production-criteria.md](../todo/v1-production-criteria.md)。 ## 当前焦点 -**Phase 2 核心命令深化 ✅ 全部完成**(2026-06-28)+ **结构与性能标尺横切批 ✅ 完成**(2026-07,PR#17 / PR#18)。批2-5c:`cp -a`、`test` POSIX、`ls -R`/`--color`、`grep -A/-B/-C`、`find` 布尔、`sh` 全收(算术/case/函数/here-doc/高级`${}`/break N/read/trap);横切批落 STRUCTURE-TASTE + banned-pattern/layering gate(CI 守护)、PERFORMANCE + io/tar/cmp/md5sum/sed 流式化(line reader ~7x、tar O(1)、sed 预编译 ~4x)+ google-benchmark 脚手架。基线 **436 GTest + 57 集成(sh)/ 439 KB** size-opt(v0.3.0 基线 399/418)。批级记录见 [PLAN.md](PLAN.md) 与 [notes/](../notes/)。 -> **下一站**:Phase 3 网络最小闭环(基础网络配置/诊断/下载)。 +**Phase 3 网络最小闭环 ✅ 全部完成**(2026-07,PR 待合):`socket.hpp`/`net_util.hpp`/`icmp.hpp` 三基础设施 + 批1-7 全 ✅ —— `nc`、`ifconfig`(读+写)、`ip addr show`、`route -n`、`hostname -i/-f/-d`、`netstat`、`ping`、`traceroute`(11 applet)。基线 **479 GTest / 479 KB / 130 applet** size-opt。之前:Phase 2 核心深化 + 结构/性能标尺横切(PR#17/#18)。批级记录见 [PLAN.md](PLAN.md) 与 [notes/](../notes/)。 +> **下一站**:Phase 4 生产质量门禁深化(fuzzing/benchmark/POSIX 子集/release 工程),或 Phase 3 增量(route add/del、hostname NAME、netstat -r/-i、IPv6 ping/traceroute)。 ## 当前焦点之后下一个可启动的 -**Phase 3 网络最小闭环**(基础网络配置/诊断/下载)——Phase 2 核心命令深度到位后启动。更远:Phase 4 质量门禁(fuzzing/release 工程)→ Phase 5 多用户 → Phase 6 长尾。 +**Phase 4 生产质量门禁深化**(fuzzing/benchmark/POSIX 子集/release 工程)——Phase 3 网络闭环完成后启动。或先补 Phase 3 增量(route add/del、hostname NAME、netstat -r/-i、IPv6)。更远:Phase 5 多用户 → Phase 6 长尾。 diff --git a/document/notes/2026-07-06-phase3-recap.md b/document/notes/2026-07-06-phase3-recap.md new file mode 100644 index 0000000..74df636 --- /dev/null +++ b/document/notes/2026-07-06-phase3-recap.md @@ -0,0 +1,54 @@ +# Phase 3 网络最小闭环 — 收口总结 + +> 2026-07-06。Phase 3 全部 7 批完成,网络最小闭环到位。本文是高层回顾; +> 细节见各 wave note(socket-nc / ifconfig / ip-route-hostname / netstat / +> ifconfig-write / icmp-ping / traceroute)。 + +## 范围 + +**11 applet + 3 基础设施头**: + +| 基础设施 | 提供能力 | 服务 applet | +|----------|----------|-------------| +| `include/cfbox/socket.hpp` | TCP/UDP socket(make/resolve/dial/listen/accept/format_addr) | nc | +| `include/cfbox/net_util.hpp` | 接口/路由/socket 表查询(/proc + ioctl)+ 写 ioctl + `resolve_ipv4`/`name_of` + `split_fields` | ifconfig, ip, route, netstat, ping, traceroute | +| `include/cfbox/icmp.hpp` | ICMP echo(checksum/build/parse)+ SOCK_RAW open + `recv_icmp`(match_id 双模式)+ `classify_reply` | ping, traceroute | + +applet:`nc`(Wave 0)、`ifconfig`(读 Wave 1a + 写 Wave 1c)、`ip addr show`、`route -n`、`hostname -i/-f/-d`(Wave 1b)、`netstat`(Wave 1c)、`ping`、`traceroute`(Wave 2)。 + +## 7 批一览 + +| 批 | Wave | 范围 | 测试增量 | +|----|------|------|----------| +| 1 | 0 | socket.hpp + nc | +440→ 440/1 | +| 2 | 1a | net_util 读 + ifconfig 显示 | +6 | +| 3 | 1b | ip/route/hostname 深化 | +6 | +| 4 | 1c | split_fields + netstat | +10 | +| 5 | 1c | ifconfig 写(SIOCSIF*) | +2 | +| 6 | 2 | icmp.hpp + ping | +11 | +| 7 | 2 | traceroute + resolve/name 抽取 | +4 | + +最终基线:**130 applet / 479 GTest / 479 KB** size-opt(起点 127/452/463)。 + +## 关键设计经验 + +1. **CI 测不真桥接**:网络代码 3/4 批需特权(CAP_NET_ADMIN/CAP_NET_RAW),CI native/qemu-user 跑不了真功能。三招组合: + - 纯函数单测(checksum / parse_icmp / classify_reply / parse_inet_sockets / format_*)—— 解析/格式/判定逻辑剥离成纯函数喂假数据,CI 全覆盖。 + - 集成 skip guard(EPERM→exit 0,参照 test_nc.sh port-busy 跳过)—— 无特权阶段测「报错路径正确」。 + - qemu-system 阶段(CFBox 当 PID 1,唯一有 root 的 CI 阶段)真跑特权路径。 +2. **`recv_icmp` match_id 双模式**:一个 API 服务 ping(match_id=id 过滤非己 echo)和 traceroute(match_id=0 收任意 ICMP,因为 UDP 探测的 time-exceeded 内嵌 UDP id 不是自己的)。避免两套收包逻辑。 +3. **memcpy 解 cast-align(对称模式)**:`ipv4_from_ioctl`(读:sockaddr*→sockaddr_in memcpy)+ `detail::ifreq_with_addr`(写:sockaddr_in memcpy 进 ifr.ifr_addr)—— armhf 32 位 `-Wcast-align` 的标准解法。Wave 1c/2 四批本地 armhf 全过,零新 cast-align bug。 +4. **可测性抽取**:`parse_inet_sockets(content)` 接收字符串而非读 /proc(可喂假数据单测);`classify_reply` 把 traceroute 判定抽离成纯函数;`ctl_socket`/`split_fields`/`resolve_ipv4`/`name_of` 抽公共 helper(DRY,ping/traceroute/ifconfig 共享)。 +5. **错误诊断友好**:`detail::ioctl_error` 把 strerror 拼进 msg("SIOCSIFADDR failed: Operation not permitted"),用户能区分 EPERM/ENODEV。既有 `socket.hpp` 错误不含 strerror 的弱点在写 path 改善。 + +## 验证矩阵 + +每批完成门:`cmake --build` + `ctest`(479/479 全绿)+ `run_all.sh`(全绿)+ size-opt ≤ 550 KB + 本地 armhf 交叉编译 + qemu `--list`。 + +armhf 32 位核查全程无新 cast-align 警告(`/opt/arm-gnu-toolchain` + `qemu-arm-static`)。 + +## 下一站候选 + +Phase 3 增量:route add/del(SIOCADDRT/DELRT,同 ifconfig 写模式)、hostname NAME(sethostname)、netstat -r/-i(复用 read_routes/read_interfaces,几乎免费)、IPv6 ping/traceroute(AF_INET6)。 + +或转 Phase 4:fuzzing、benchmark 深化、POSIX 子集、release 工程。 From 2e5c0e6ce3208c5dcf181d316067baaad5816f88 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 6 Jul 2026 13:28:24 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat(scripts):=20qemu=5Fcfbox=5Fshell.sh=20?= =?UTF-8?q?=E2=80=94=20interactive=20boot=20for=20net=20applets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/user_test/qemu_cfbox_shell.sh: builds an armhf initramfs (cfbox as PID 1 + inittab that mounts fs, brings up loopback, askfirst -> /bin/sh) and boots qemu-system vexpress-a9 with the console wired to the caller's terminal. One-shot interactive verification of the privileged network applets (ifconfig write / ping / traceroute / netstat) that CI native and qemu-user can't exercise (CAP_NET_ADMIN / CAP_NET_RAW). Not for CI — hands-on only. Verified end-to-end: boot -> askfirst -> /bin/sh -> ifconfig/ping/poweroff all work, lo auto-up at boot so ping 127.0.0.1 works immediately. --- scripts/user_test/qemu_cfbox_shell.sh | 113 ++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100755 scripts/user_test/qemu_cfbox_shell.sh diff --git a/scripts/user_test/qemu_cfbox_shell.sh b/scripts/user_test/qemu_cfbox_shell.sh new file mode 100755 index 0000000..90585cd --- /dev/null +++ b/scripts/user_test/qemu_cfbox_shell.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Boot CFBox as PID 1 under qemu-system (armhf vexpress-a9) and drop into an +# interactive cfbox shell on the serial console. Loopback is brought up at boot +# so ping/traceroute/ifconfig-write work immediately. +# +# Interactive, NOT for CI — meant for hands-on verification of the privileged +# network applets (ifconfig write / ping / traceroute / netstat) that the CI +# native + qemu-user stages can't exercise (CAP_NET_ADMIN / CAP_NET_RAW). +# +# Usage: scripts/user_test/qemu_cfbox_shell.sh +# CFBOX_BIN=... KERNEL=... DTB=... scripts/user_test/qemu_cfbox_shell.sh +# +# Overrides (env): +# CFBOX_BIN path to static armhf cfbox (default build-armhf/cfbox) +# KERNEL zImage path (default third_party/linux/arch/arm/boot/zImage) +# DTB vexpress dtb path (default .../dts/arm/vexpress-v2p-ca9.dtb) +# MEM qemu RAM (default 256M) +# +# Exit the guest: poweroff -f (clean) +# Kill qemu: Ctrl-A then X +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" +project_dir="$(cd "$script_dir/../.." && pwd)" +cd "$project_dir" + +CFBOX_BIN="${CFBOX_BIN:-$project_dir/build-armhf/cfbox}" +KERNEL="${KERNEL:-$project_dir/third_party/linux/arch/arm/boot/zImage}" +DTB="${DTB:-$project_dir/third_party/linux/arch/arm/boot/dts/arm/vexpress-v2p-ca9.dtb}" +MEM="${MEM:-256M}" +INITRAMFS="${INITRAMFS:-$project_dir/build/armhf-shell-initramfs.cpio}" + +# --- dependency checks ------------------------------------------------------- +need() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: missing '$1'"; exit 1; }; } +need qemu-system-arm +need qemu-arm-static # to enumerate applets when building symlinks +need cpio + +if [[ ! -x "$CFBOX_BIN" ]]; then + echo "ERROR: cfbox binary not found: $CFBOX_BIN" + echo "Build it first:" + echo " cmake -B build-armhf \\" + echo " -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/Toolchain-armhf.cmake \\" + echo " -DCMAKE_BUILD_TYPE=Release -DCFBOX_OPTIMIZE_FOR_SIZE=ON -DCFBOX_STATIC_LINK=ON" + echo " cmake --build build-armhf -j\$(nproc)" + exit 1 +fi +[[ -f "$KERNEL" ]] || { echo "ERROR: kernel missing: $KERNEL"; exit 1; } +[[ -f "$DTB" ]] || { echo "ERROR: dtb missing: $DTB"; exit 1; } + +# --- build initramfs (cfbox + applet symlinks + inittab) --------------------- +rootfs="$(mktemp -d /tmp/cfbox-shell-rootfs-XXXX)" +mkdir -p "$rootfs"/{bin,etc,proc,sys,dev,tmp,sbin,usr/bin,usr/sbin} +cp "$CFBOX_BIN" "$rootfs/bin/cfbox" + +# applet symlinks via cfbox --list (cross-binary → qemu-arm-static) +qemu-arm-static "$rootfs/bin/cfbox" --list | awk '{print $1}' | while read -r n; do + [[ -z "$n" ]] && continue + ln -sf cfbox "$rootfs/bin/$n" +done +# seed common PATH dirs so lookups like /sbin/poweroff resolve regardless of PATH +for sub in sbin usr/bin usr/sbin; do + for name in sh ifconfig ip route hostname netstat ping traceroute mount poweroff reboot uname echo; do + ln -sf ../../bin/cfbox "$rootfs/$sub/$name" 2>/dev/null || ln -sf ../bin/cfbox "$rootfs/$sub/$name" + done +done + +# inittab: mount fs, bring up loopback (so ping 127.0.0.1 works immediately), +# then askfirst → /bin/sh on the console. askfirst waits for Enter before +# launching the shell so boot logs aren't drowned by a prompt. +cat > "$rootfs/etc/inittab" <<'EOF' +::sysinit:/bin/mount -t proc proc /proc +::sysinit:/bin/mount -t sysfs sysfs /sys +::sysinit:/bin/mount -t devtmpfs devtmpfs /dev +::sysinit:/bin/ifconfig lo up +::askfirst:/bin/sh +::ctrlaltdel:/sbin/reboot -f +EOF +ln -sf bin/cfbox "$rootfs/init" + +mkdir -p "$(dirname "$INITRAMFS")" +(cd "$rootfs" && find . | cpio -o -H newc --quiet > "$INITRAMFS") +rm -rf "$rootfs" + +# --- launch ------------------------------------------------------------------ +cat <