From 5fccd437bdd9239e2cc6f64285e9b1f797324058 Mon Sep 17 00:00:00 2001 From: VasilyZa <450270247@qq.com> Date: Fri, 13 Nov 2026 05:29:50 +0800 Subject: [PATCH] feat(network): manage profiles and DHCP per interface --- AGENT.md | 2 +- .../admin-guide/network-controller.rst | 44 ++-- kernel/reliefnt | 2 +- system/rootfs/etc/init.d/reliefos-dhcp | 58 ++--- .../rootfs/usr/lib/reliefos/network-profile | 106 +++++++- .../rootfs/usr/lib/reliefos/publish-resolver | 66 +++-- system/rootfs/usr/share/udhcpc/default.script | 19 +- tests/build/test-network-controller.sh | 12 +- tests/build/test-network-profile.sh | 94 +++++-- tools/test_multi_nic_qemu.py | 242 ++++++++++++++++++ tools/test_openrc_resolver.py | 2 +- .../tests/network_controller_config_test.cpp | 18 ++ .../tests/network_controller_session_test.cpp | 37 ++- tools/tests/network_controller_test.cpp | 20 ++ userland/apps/network-controller/config.cpp | 82 +++++- userland/apps/network-controller/config.h | 8 +- userland/apps/network-controller/model.cpp | 48 +++- userland/apps/network-controller/model.h | 3 +- userland/apps/network-controller/window.cpp | 112 +++++--- userland/apps/network-controller/worker.cpp | 130 +++++++--- 20 files changed, 891 insertions(+), 214 deletions(-) create mode 100644 tools/test_multi_nic_qemu.py diff --git a/AGENT.md b/AGENT.md index a51b6c55..133e3daa 100644 --- a/AGENT.md +++ b/AGENT.md @@ -168,7 +168,7 @@ UI 修改必须横向检查,而不是只改一个应用。典型关联范围 ### 网络与 TLS -- 网络栈当前主要服务 e1000;QEMU/VMware 的网卡、DHCP、DNS、TCP 和显示/ +- 网络栈通过逐设备接口同时服务 e1000、e1000e 和 Realtek;QEMU/VMware 的网卡、DHCP、DNS、TCP 和显示/ 驱动行为并不完全相同。报告时必须说明实际运行平台。 - TLS 故障必须区分 DNS、TCP 连接、握手、证书链、主机名、时间、发送与读取。 日志可输出主机名、阶段、返回码、网络状态、证书校验标志和时钟状态;绝不 diff --git a/Documentation/admin-guide/network-controller.rst b/Documentation/admin-guide/network-controller.rst index b6e9dab5..4830b37a 100644 --- a/Documentation/admin-guide/network-controller.rst +++ b/Documentation/admin-guide/network-controller.rst @@ -23,16 +23,19 @@ TCP 连接建立不代表 TLS 校验或目标应用健康;DNS、端口与 Ping 配置与迁移 ========== -持久化编辑明确限定当前内核支持的 eth0,其他可查询接口(若有)只读显示。 -没有 IPv4 地址或处于禁用状态的网卡也按实际接口索引查询。支持自动 +左侧选择实际枚举的以太网接口并分别编辑;切换网卡前会提示丢弃未保存的修改。 +没有 IPv4 地址或处于禁用状态的网卡也会显示。支持自动 DHCP、静态 IPv4 和禁用;静态模式可以不设置默认网关,DNS 必须明确选择 自定义或 Cloudflare。自定义 DNS 最多三个 IPv4 地址,以空格分隔。 -配置位于 ``/etc/reliefos/network.conf``,是版本化数据,不作为 shell 脚本执行。 -静态地址、前缀、网关和 DNS 在 GUI、工作进程及启动链分别验证。文件目录和 -配置必须由 root 所有,不能由组或其他用户写入,不能使用符号链接替代配置文件。 -保存检查外部变动,并通过临时文件、同步与 rename 原子替换;旧内容保存在 -``network.conf.bak``。“恢复上次配置”也检查当前快照,不能绕过冲突检查。 +eth0 继续使用 ``/etc/reliefos/network.conf``;其他接口使用 +``/etc/reliefos/network.d/<接口名>.conf``。未设置的接口默认使用 DHCP。 +配置是版本化数据,不作为 shell 脚本执行。静态地址、前缀、网关和 DNS 在 GUI、 +工作进程及启动链分别验证,接口名必须对应已枚举的 ``eth`` 加十进制编号。 +文件目录和配置必须由 root 所有,不能由组或其他用户写入,不能使用符号链接 +替代配置文件或目录。保存检查外部变动,并通过临时文件、同步与 rename 原子 +替换;各接口各自保留 ``.bak`` 备份。“恢复上次配置”也检查当前快照,不能 +绕过冲突检查。 原先没有配置文件的状态可以恢复为缺省状态。 旧的 DNS-only ``network.conf`` 保持可读;旧 udhcpc DNS 策略在迁移前继续生效。 @@ -42,17 +45,23 @@ DHCP、静态 IPv4 和禁用;静态模式可以不设置默认网关,DNS 必 编辑 profile 后重启网络服务调整策略;不会在解析器忽略旧文件时谎报设置成功。 未迁移的旧配置继续支持旧 API。 -仍由 ``reliefos-dhcp`` OpenRC 服务拥有网络生命周期。DHCP 保留原有监督器, -静态/禁用执行一次性设置;停止服务依据启动时记录的实际模式,而不是刚保存的 -新模式,避免漏停旧 DHCP 进程。客户端写入 root 所有的 PID 文件,停止时校验 -该记录及实际命令行,通过 OpenRC ``start-stop-daemon`` 限时等待客户端退出, -然后清除租约与旧地址,避免残留客户端覆盖静态配置。 -``network-profile`` 工具独立于可选 GUI, -因此关闭 GUI 组件不影响基础网络启动。解析器仍由唯一的 ``publish-resolver`` -发布,DHCP 租约不会覆盖显式 DNS 策略。 +``reliefos-dhcp`` OpenRC 服务启动时从 ``/proc/net/dev`` 枚举全部以太网卡, +逐卡设置静态/禁用状态或启动各自的 ``supervise-daemon`` 和 DHCP 客户端。 +停止或应用某卡时先停其监督器,再校验客户端 PID 及接口参数并等待退出, +然后仅清理该卡地址、默认路由和租约;不会根据刚保存的新模式漏停旧客户端。 +eth0 保留原有 ``/run/reliefos/udhcpc.pid``、``dhcp-lease`` 和 ``dhcp-dns`` +路径,其他接口的运行状态带接口名后缀。``network-profile`` 独立于可选 GUI, +关闭 GUI 组件不影响基础网络启动。 + +DNS 解析器由 ``publish-resolver`` 全系统共享并串行发布。先按内核枚举顺序 +收集未禁用且未停止接口的显式自定义/Cloudflare 服务器,再收集 eth0 的自动 RDNSS +(仅 eth0 为自动 DNS 时),最后按同一顺序收集自动 DHCP 租约;去重后发布最多 +三个地址,多余地址保留在各自租约缓存中。某接口的释放、续租或停用不会删除 +其他接口的 DNS 状态。单独 eth0 的旧 DNS-only 和固定 DNS 行为保持兼容。 “仅保存”更改持久化策略,供随后服务重启、DHCP 更新或下次启动读取; -“保存并应用”同时重启现有网络服务,可能中断连接。应用失败时已保存的文件 +“保存并应用”只重新配置选中的接口,可能中断该接口的连接;如果整个服务 +已停止,则由 OpenRC 启动服务并配置全部接口。应用失败时已保存的文件 仍保留,程序显示失败和输出,可修正或恢复备份;不能将保存成功等同于联网成功。 边界 @@ -60,6 +69,5 @@ DHCP、静态 IPv4 和禁用;静态模式可以不设置默认网关,DNS 必 当前 GUI 不提供 IPv6 持久化配置;系统的 IPv6/SLAAC、RDNSS 与命令行配置见 :doc:`ipv6`。此 GUI 也不提供 Wi-Fi、VPN、桥接、VLAN、任意静态路由管理或改变内核固定的 -MTU。当前内核未导出 ARP 表和全网卡计数,不能将单条 TCP 连接的字节计数冒充 -网卡总流量。不支持的能力不会显示成已生效。以太网链路能力取决于当前内核和驱动。 +MTU。不能将单条 TCP 连接的字节计数冒充网卡总流量。不支持的能力不会显示成已生效。以太网链路能力取决于当前内核和驱动。 QEMU 运行验证与 VMware 实机验证分别记录;VMware 验证未完成。 diff --git a/kernel/reliefnt b/kernel/reliefnt index 044ce9b4..31223b10 160000 --- a/kernel/reliefnt +++ b/kernel/reliefnt @@ -1 +1 @@ -Subproject commit 044ce9b4e2d129b55bf80c1b889fd3200bc3b7ad +Subproject commit 31223b104983a1a356a999aae397c0cb29b08cd1 diff --git a/system/rootfs/etc/init.d/reliefos-dhcp b/system/rootfs/etc/init.d/reliefos-dhcp index 33f277f2..3e8a6eab 100755 --- a/system/rootfs/etc/init.d/reliefos-dhcp +++ b/system/rootfs/etc/init.d/reliefos-dhcp @@ -1,45 +1,29 @@ #!/sbin/openrc-run -description="Ethernet configuration and BusyBox DHCP client" -supervisor=supervise-daemon -retry=TERM/1/KILL/1 -input_file=/dev/null -command=/sbin/udhcpc -command_args="-f -R -i eth0 -p /run/reliefos/udhcpc.pid -s /usr/share/udhcpc/default.script -t 3 -T 1 -A 30" -respawn_delay=5 -respawn_max=3 -respawn_period=60 -output_log=/var/log/udhcpc.log -error_log=/var/log/udhcpc.log +description="Ethernet configuration and BusyBox DHCP clients" depend() { need reliefos-runtime reliefos-modules; provide net; } -start_pre() { - /usr/lib/reliefos/network-profile prepare -} - start() { - local mode - mode=$(/usr/lib/reliefos/network-profile mode) || return 1 - if [ "$mode" = dhcp ]; then - default_start || return $? - else - ebegin "Applying $mode Ethernet configuration" - eend 0 - fi - service_set_value network_profile_mode "$mode" + local iface started interfaces + started="" + interfaces=$(/usr/lib/reliefos/network-profile list) || return 1 + for iface in $interfaces; do + if ! /usr/lib/reliefos/network-profile start "$iface"; then + /usr/lib/reliefos/network-profile stop "$iface" || : + for iface in $started; do + /usr/lib/reliefos/network-profile stop "$iface" || : + done + return 1 + fi + started="$started $iface" + done } stop() { - local mode - mode=$(service_get_value network_profile_mode) - # A service started before profile support used the DHCP supervisor. - if [ -z "$mode" ] || [ "$mode" = dhcp ]; then - default_stop || return $? - # Finish the DHCP client and its deconfiguration hook before a - # static address is installed, even if the supervisor exits early. - /usr/lib/reliefos/network-profile stop-dhcp || return $? - fi -} - -stop_post() { - /usr/lib/reliefos/network-profile release + local iface result interfaces + result=0 + interfaces=$(/usr/lib/reliefos/network-profile list) || return 1 + for iface in $interfaces; do + /usr/lib/reliefos/network-profile stop "$iface" || result=1 + done + return "$result" } diff --git a/system/rootfs/usr/lib/reliefos/network-profile b/system/rootfs/usr/lib/reliefos/network-profile index ac4036ca..3d1051b4 100755 --- a/system/rootfs/usr/lib/reliefos/network-profile +++ b/system/rootfs/usr/lib/reliefos/network-profile @@ -17,13 +17,44 @@ ip_number() ( printf '%s\n' "$((($1 << 24) | ($2 << 16) | ($3 << 8) | $4))" ) print_ip() { printf '%s.%s.%s.%s\n' "$((($1 >> 24) & 255))" "$((($1 >> 16) & 255))" "$((($1 >> 8) & 255))" "$(($1 & 255))"; } +# Kernel Ethernet devices are named eth followed by their stable index. +valid_interface() { + case "$1" in eth*) number=${1#eth} ;; *) return 1 ;; esac + case "$number" in ''|*[!0-9]*|0[0-9]*) return 1 ;; esac + test "${#1}" -le 15 +} +interfaces() { + test -r /proc/net/dev || fail 'cannot enumerate network interfaces' + while IFS= read -r line; do + case "$line" in *:*) name=${line%%:*} ;; *) continue ;; esac + while :; do case "$name" in ' '*) name=${name# } ;; *) break ;; esac; done + if valid_interface "$name"; then printf '%s\n' "$name"; fi + done < /proc/net/dev +} +if test "${1:-}" = list; then interfaces; exit; fi +iface=${2:-eth0} +valid_interface "$iface" || fail 'invalid Ethernet interface' +case "${1:-}" in + start|stop|apply) + mkdir -p /run/reliefos + exec 8>"/run/reliefos/interface.$iface.lock" + flock -x 8 + ;; +esac +found=no +for name in $(interfaces); do test "$name" != "$iface" || found=yes; done +test "$found" = yes || fail 'Ethernet interface is not present' +test "${1:-}" != interface || exit 0 +case "$iface" in + eth0) client=/run/reliefos/udhcpc.pid; lease=/run/reliefos/dhcp-lease; dns_file=/run/reliefos/dhcp-dns ;; + *) client=/run/reliefos/udhcpc.$iface.pid; lease=/run/reliefos/dhcp-lease.$iface; dns_file=/run/reliefos/dhcp-dns.$iface ;; +esac clear_lease() { - /sbin/route del default dev eth0 2>/dev/null || : - /sbin/ifconfig eth0 0.0.0.0 - rm -f /run/reliefos/dhcp-lease /run/reliefos/dhcp-dns + /sbin/route del default dev "$iface" 2>/dev/null || : + /sbin/ifconfig "$iface" 0.0.0.0 + rm -f "$lease" "$dns_file" } stop_dhcp() { - client=/run/reliefos/udhcpc.pid if test ! -e "$client" && test ! -L "$client"; then return 0; fi test -f "$client" && test ! -L "$client" || fail 'unsafe DHCP PID file' test "$(stat -c %u "$client")" = 0 || fail 'DHCP PID file must belong to root' @@ -33,8 +64,8 @@ stop_dhcp() { case "$pid" in ''|*[!0-9]*) fail 'invalid DHCP PID' ;; esac test -z "$extra" && test "${#pid}" -le 10 && test "$pid" -gt 1 && test "$pid" -le 2147483647 || fail 'invalid DHCP PID' if test ! -d "/proc/$pid"; then rm -f "$client"; return 0; fi - # Match the recorded service client, including BusyBox multicall argv. - # Refuse an unrelated process if the recorded PID has been reused. + # Require both the DHCP executable and its precise -i argument: a PID + # reused by another interface must never terminate that interface. command=$(tr '\000' '\n' < "/proc/$pid/cmdline") newline=' ' @@ -42,17 +73,39 @@ stop_dhcp() { /sbin/udhcpc"$newline"*|udhcpc"$newline"*|/bin/busybox"$newline"udhcpc"$newline"*) ;; *) fail 'PID no longer identifies the DHCP client' ;; esac + case "$newline$command$newline" in *"$newline-i$newline$iface$newline"*) ;; *) fail 'DHCP PID belongs to another interface' ;; esac /sbin/start-stop-daemon --stop --retry TERM/10/KILL/2 --pidfile "$client" || return $? rm -f "$client" } +stop_interface() { + # Stop the supervisor before its child so it cannot respawn during apply. + if test "$iface" = eth0; then + RC_SVCNAME=reliefos-dhcp /sbin/supervise-daemon reliefos-dhcp --stop 8>&- || return $? + fi + RC_SVCNAME=reliefos-dhcp.$iface /sbin/supervise-daemon "reliefos-dhcp.$iface" --stop 8>&- || return $? + stop_dhcp + clear_lease + : > "/run/reliefos/network-stopped.$iface" + /usr/lib/reliefos/publish-resolver +} case "${1:-}" in stop-dhcp) stop_dhcp; exit ;; release) clear_lease; /usr/lib/reliefos/publish-resolver; exit ;; - mode|dns|prepare|check) ;; - *) fail 'expected mode, dns, prepare, check, release or stop-dhcp' ;; + stop) stop_interface; exit ;; + mode|dns|prepare|check|start|apply) ;; + *) fail 'expected list, mode, dns, prepare, check, start, stop, apply, release or stop-dhcp' ;; esac mode=dhcp address=0.0.0.0 prefix=24 gateway=0.0.0.0 dns_mode=automatic dns_servers= dns_custom= -profile=/etc/reliefos/network.conf +case "$iface" in eth0) profile=/etc/reliefos/network.conf ;; *) profile=/etc/reliefos/network.d/$iface.conf ;; esac +# Reject a replaced directory before touching any per-interface profile. +for dir in /etc/reliefos /etc/reliefos/network.d; do + if test -e "$dir" || test -L "$dir"; then + test -d "$dir" && test ! -L "$dir" || fail 'unsafe profile directory' + test "$(stat -c %u "$dir")" = 0 || fail 'profile directory must belong to root' + permissions=$(stat -c %a "$dir") + test "$((0$permissions & 022))" -eq 0 || fail 'profile directory is writable by another user' + fi +done if test -e "$profile" || test -L "$profile"; then test -f "$profile" && test ! -L "$profile" || fail 'profile is not a regular file' test "$(stat -c %u "$profile")" = 0 || fail 'profile must belong to root' @@ -114,6 +167,14 @@ if test -e "$profile" || test -L "$profile"; then fi ;; esac +elif test "$iface" = eth0 && test -r /etc/udhcpc/leonos-dns; then + read -r previous_mode previous_servers < /etc/udhcpc/leonos-dns + case "$previous_mode" in + 0) dns_mode=cloudflare ;; + 1) dns_mode=automatic ;; + 2) dns_mode=custom; dns_servers=$previous_servers ;; + *) fail 'invalid published DNS mode' ;; + esac fi case "$mode" in dhcp|static|disabled) ;; *) fail 'invalid mode' ;; esac case "$dns_mode" in automatic|custom|cloudflare) ;; *) fail 'invalid DNS mode' ;; esac @@ -156,17 +217,34 @@ case "$1" in dns) case "$dns_mode" in automatic) printf '1 0.0.0.0\n' ;; cloudflare) printf '0 0.0.0.0\n' ;; custom) printf '2 %s\n' "$dns_servers" ;; esac ;; - prepare) + prepare|start|apply) + if test "$1" = apply; then + # Preserve OpenRC service ownership if the service was stopped. + # Release the per-interface lock before OpenRC starts every NIC. + if ! /sbin/rc-service reliefos-dhcp status >/dev/null 2>&1; then + exec 8>&- + exec /sbin/rc-service reliefos-dhcp start + fi + stop_interface + fi mkdir -p /run/reliefos clear_lease + rm -f "/run/reliefos/network-stopped.$iface" case "$mode" in - dhcp) /sbin/ifconfig eth0 up ;; + dhcp) /sbin/ifconfig "$iface" up ;; static) - /sbin/ifconfig eth0 "$address" netmask "$(print_ip "$mask")" up - test "$gw" -eq 0 || /sbin/route add default gw "$gateway" dev eth0 + /sbin/ifconfig "$iface" "$address" netmask "$(print_ip "$mask")" up + test "$gw" -eq 0 || /sbin/route add default gw "$gateway" dev "$iface" ;; - disabled) /sbin/ifconfig eth0 down ;; + disabled) /sbin/ifconfig "$iface" down ;; esac /usr/lib/reliefos/publish-resolver + if test "$1" != prepare && test "$mode" = dhcp; then + RC_SVCNAME=reliefos-dhcp.$iface /sbin/supervise-daemon "reliefos-dhcp.$iface" --start \ + --retry TERM/1/KILL/1 --stdin /dev/null --stdout /var/log/udhcpc.log \ + --stderr /var/log/udhcpc.log --respawn-delay 5 --respawn-max 3 --respawn-period 60 \ + /sbin/udhcpc -- -f -R -i "$iface" -p "$client" \ + -s /usr/share/udhcpc/default.script -t 3 -T 1 -A 30 8>&- + fi ;; esac diff --git a/system/rootfs/usr/lib/reliefos/publish-resolver b/system/rootfs/usr/lib/reliefos/publish-resolver index d34befbc..c85ea495 100755 --- a/system/rootfs/usr/lib/reliefos/publish-resolver +++ b/system/rootfs/usr/lib/reliefos/publish-resolver @@ -2,8 +2,6 @@ # Sole resolver writer. Inputs are numeric data, never sourced shell text. set -eu valid_ipv4() ( - # A subshell keeps field splitting local to validation. Reject empty - # octets before splitting, including a trailing dot discarded by the shell. case "$1" in *[!0-9.]*|''|.*|*.|*..*) return 1 ;; esac IFS=. set -- $1 @@ -12,23 +10,54 @@ valid_ipv4() ( test "${#octet}" -le 3 && test "$octet" -le 255 || return 1 done ) -mode=1 -custom=0.0.0.0 -if test -e /etc/reliefos/network.conf || test -L /etc/reliefos/network.conf; then - settings=$(/usr/lib/reliefos/network-profile dns) - read -r mode custom </run/reliefos/resolver.lock +flock -x 9 +explicit= automatic= ra=no +if test -x /usr/lib/reliefos/network-profile && test -r /proc/net/dev; then + interfaces=$(/usr/lib/reliefos/network-profile list) + for iface in $interfaces; do + if test -e "/run/reliefos/network-stopped.$iface"; then continue; fi + state=$(/usr/lib/reliefos/network-profile mode "$iface") + test "$state" != disabled || continue + settings=$(/usr/lib/reliefos/network-profile dns "$iface") + read -r mode custom </dev/null || :)" + ;; + *) exit 1 ;; + esac + done +else + # Compatibility with the old DNS-only configuration and standalone hook. + mode=1 custom=0.0.0.0 + if test -e /etc/reliefos/network.conf || test -L /etc/reliefos/network.conf; then + settings=$(/usr/lib/reliefos/network-profile dns) + read -r mode custom </dev/null || :) ;; + 2) explicit=$custom ;; + *) exit 1 ;; + esac fi -case "$mode" in - 1) servers="$(cat /run/reliefos/ra-dns 2>/dev/null || :) -$(cat /run/reliefos/dhcp-dns 2>/dev/null || :)" ;; - 0) servers=1.1.1.1 ;; - 2) servers=$custom ;; - *) exit 1 ;; -esac +servers=$explicit +if test "$ra" = yes; then servers="$servers $(cat /run/reliefos/ra-dns 2>/dev/null || :)"; fi +servers="$servers $automatic" umask 022 tmp=$(mktemp /etc/.resolv.conf.XXXXXX) trap 'rm -f "$tmp"' EXIT HUP INT TERM @@ -42,8 +71,7 @@ for server in $servers; do esac case "$seen" in *" $server "*) continue ;; esac seen="$seen$server " - # musl consumes at most three nameservers. Keep additional advertised - # entries in their source cache so expiry can expose the next server. + # musl consumes at most three nameservers. Keep remaining source caches. test "$count" -lt 3 || continue printf 'nameserver %s\n' "$server" >> "$tmp" count=$((count + 1)) diff --git a/system/rootfs/usr/share/udhcpc/default.script b/system/rootfs/usr/share/udhcpc/default.script index b88de8ec..dd72047c 100755 --- a/system/rootfs/usr/share/udhcpc/default.script +++ b/system/rootfs/usr/share/udhcpc/default.script @@ -1,17 +1,22 @@ #!/bin/sh -# DHCP protocol/renewal is upstream BusyBox. This hook applies its lease. -# One interface and one client; no competing ifup DHCP provider. +# Each BusyBox client owns only its interface's address, route and cache. set -eu -test "${interface:-}" = eth0 || exit 1 +interface=${interface:-} +test -n "$interface" || exit 1 +# This also verifies that the validated name is an enumerated Ethernet device. +/usr/lib/reliefos/network-profile interface "$interface" +case "$interface" in + eth0) dns_file=/run/reliefos/dhcp-dns; lease_file=/run/reliefos/dhcp-lease ;; + *) dns_file=/run/reliefos/dhcp-dns.$interface; lease_file=/run/reliefos/dhcp-lease.$interface ;; +esac case "${1:-}" in bound|renew) /sbin/ifconfig "$interface" "$ip" netmask "$subnet" ${broadcast:+broadcast "$broadcast"} up - # This is the sole route owner for eth0. /sbin/route del default dev "$interface" 2>/dev/null || : for gateway in ${router:-}; do /sbin/route add default gw "$gateway" dev "$interface"; break; done tmp=$(mktemp /run/reliefos/.dhcp-dns.XXXXXX) printf '%s\n' "${dns:-}" > "$tmp" - mv -f "$tmp" /run/reliefos/dhcp-dns + mv -f "$tmp" "$dns_file" /usr/lib/reliefos/publish-resolver read -r uptime rest < /proc/uptime acquired=${uptime%%.*} @@ -19,12 +24,12 @@ case "${1:-}" in trap 'rm -f "$lease_tmp"' EXIT HUP INT TERM printf 'ip=%s\nsubnet=%s\nrouter=%s\ndns=%s\nlease=%s\nacquired=%s\n' "$ip" "$subnet" "${router:-}" "${dns:-}" "${lease:-}" "$acquired" > "$lease_tmp" chmod 0644 "$lease_tmp" - mv -f "$lease_tmp" /run/reliefos/dhcp-lease + mv -f "$lease_tmp" "$lease_file" trap - EXIT HUP INT TERM ;; deconfig) /sbin/ifconfig "$interface" 0.0.0.0 - rm -f /run/reliefos/dhcp-dns /run/reliefos/dhcp-lease + rm -f "$dns_file" "$lease_file" /usr/lib/reliefos/publish-resolver ;; leasefail|nak) echo "DHCP $1 on $interface" >&2; exit 1 ;; diff --git a/tests/build/test-network-controller.sh b/tests/build/test-network-controller.sh index 57f18097..0f014e73 100644 --- a/tests/build/test-network-controller.sh +++ b/tests/build/test-network-controller.sh @@ -27,13 +27,21 @@ ${HOSTCXX:-c++} -std=c++17 -Wall -Wextra -Werror -nostdinc++ -fno-exceptions -fn "$src/userland/apps/network-controller/model.cpp" "$src/userland/apps/network-controller/config.cpp" \ "$src/userland/apps/network-controller/session.cpp" "$src/userland/apps/network-controller/worker.cpp" -o "$work/session" mkdir -p "$work/root/etc/reliefos" "$work/root/bin" "$work/root/sbin" "$work/root/dev" \ - "$work/root/usr/lib/reliefos/apps/network-controller" + "$work/root/proc/net" "$work/root/usr/lib/reliefos/apps/network-controller" +printf 'Inter-| Receive | Transmit\n lo: 0\n eth0: 0\n eth1: 0\n eth998: 0\n' > "$work/root/proc/net/dev" ${HOSTCC:-cc} -static -O2 -Wall -Wextra -Werror "$src/tools/tests/network_controller_fake_command.c" -o "$work/root/bin/busybox" cp "$work/root/bin/busybox" "$work/root/sbin/rc-service" +mkdir -p "$work/root/usr/lib/reliefos" +cp "$work/root/bin/busybox" "$work/root/usr/lib/reliefos/network-profile" cp "$work/session" "$work/root/session" cp "$work/session" "$work/root/usr/lib/reliefos/apps/network-controller/network-controller.elf" ldd "$work/session" | awk '{for(i=1;i<=NF;i++)if($i~/^\//)print $i}' | while read -r lib; do mkdir -p "$work/root$(dirname "$lib")";cp "$lib" "$work/root$lib" done touch "$work/root/dev/null" -LC_ALL=C LANG=C unshare -Urn chroot "$work/root" /session +LC_ALL=C LANG=C unshare -Urn sh -c ' + ip link add eth0 type dummy + ip link add eth1 type dummy + ip link add eth998 index 1000 type dummy + exec chroot "$1" /session +' _ "$work/root" diff --git a/tests/build/test-network-profile.sh b/tests/build/test-network-profile.sh index e5fe67eb..1ea159dd 100644 --- a/tests/build/test-network-profile.sh +++ b/tests/build/test-network-profile.sh @@ -6,10 +6,10 @@ mkdir -p "$TMPDIR" work=$(mktemp -d "$TMPDIR/network-profile.XXXXXX") trap 'rm -rf "$work"' EXIT HUP INT TERM root=$work/root -mkdir -p "$root/bin" "$root/sbin" "$root/etc/reliefos" "$root/etc/udhcpc" "$root/etc/init.d" "$root/usr/lib/reliefos" "$root/run/reliefos" "$root/dev" +mkdir -p "$root/bin" "$root/sbin" "$root/etc/reliefos" "$root/etc/udhcpc" "$root/etc/init.d" "$root/usr/lib/reliefos" "$root/usr/share/udhcpc" "$root/run/reliefos" "$root/dev" "$root/proc/net" # Only this private chroot can see the fake adapter commands. No host network # command or resolver path is used by the production scripts under test. -for utility in sh stat cat mkdir mv rm chmod mktemp grep tr wc; do +for utility in sh stat cat mkdir mv rm chmod mktemp grep tr wc flock; do binary=$(command -v "$utility") cp "$binary" "$root/bin/$utility" ldd "$binary" | awk '{for(i=1;i<=NF;i++)if($i~/^\//)print $i}' | while read -r lib; do @@ -18,6 +18,9 @@ for utility in sh stat cat mkdir mv rm chmod mktemp grep tr wc; do done cp "$src/system/rootfs/usr/lib/reliefos/network-profile" "$src/system/rootfs/usr/lib/reliefos/publish-resolver" "$root/usr/lib/reliefos/" cp "$src/system/rootfs/etc/init.d/reliefos-dhcp" "$root/etc/init.d/" +cp "$src/system/rootfs/usr/share/udhcpc/default.script" "$root/usr/share/udhcpc/" +printf 'Inter-| Receive | Transmit\n eth0: 0\n eth1: 0\n' > "$root/proc/net/dev" +printf '100.00 0.00\n' > "$root/proc/uptime" cat > "$root/sbin/ifconfig" <<'SCRIPT' #!/bin/sh printf '%s' "$0" >> /commands @@ -25,7 +28,21 @@ for arg do printf ' <%s>' "$arg" >> /commands; done printf '\n' >> /commands SCRIPT cp "$root/sbin/ifconfig" "$root/sbin/route" -chmod 755 "$root/sbin/ifconfig" "$root/sbin/route" +cat > "$root/sbin/supervise-daemon" <<'SCRIPT' +#!/bin/sh +printf '%s' "$RC_SVCNAME" >> /events +for arg do printf ' <%s>' "$arg" >> /events; done +printf '\n' >> /events +SCRIPT +cat > "$root/sbin/rc-service" <<'SCRIPT' +#!/bin/sh +case "$2" in + status) test ! -e /service-stopped ;; + start) printf 'rc-service-start\n' >> /events; rm -f /service-stopped ;; + *) exit 1 ;; +esac +SCRIPT +chmod 755 "$root/sbin/ifconfig" "$root/sbin/route" "$root/sbin/supervise-daemon" "$root/sbin/rc-service" touch "$root/dev/null" cat > "$root/test.sh" <<'SCRIPT' set -eu @@ -72,26 +89,57 @@ if $tool check; then echo 'FAIL - embedded NUL accepted';exit 1;fi write_profile dhcp automatic '' chmod 666 "$profile" if $tool check; then echo 'FAIL - writable profile accepted';exit 1;fi -write_profile static cloudflare '' -# Exercise the actual service functions; the supervisor boundary is isolated. -. /etc/init.d/reliefos-dhcp -ebegin() { :; } -eend() { return "$1"; } -default_start() { printf 'dhcp-start\n' >> /events; } -default_stop() { printf 'dhcp-stop\n' >> /events; } -service_set_value() { printf '%s\n' "$2" > /mode-value; } -service_get_value() { cat /mode-value; } -start_pre;start +# Different profiles and leases belong to the named, enumerated adapter. +write_profile dhcp automatic '' +printf '10.0.0.53\n' > /run/reliefos/dhcp-dns +mkdir -p /etc/reliefos/network.d +printf 'version=1\nmode=static\naddress=192.168.9.2\nprefix=24\ngateway=192.168.9.1\ndns_mode=custom\ndns_servers=9.9.9.9\n' > /etc/reliefos/network.d/eth1.conf +test "$("$tool" mode eth1)" = static +"$tool" prepare eth1 +grep -q ' <192.168.9.2> <255.255.255.0> ' /commands +test "$(grep '^nameserver ' /etc/resolv.conf)" = "nameserver 9.9.9.9 +nameserver 10.0.0.53" +"$tool" stop eth1 +grep -q '^nameserver 10.0.0.53$' /etc/resolv.conf +if grep -q '^nameserver 9.9.9.9$' /etc/resolv.conf; then echo 'FAIL - stopped interface retained explicit DNS'; exit 1; fi +"$tool" prepare eth1 +if "$tool" mode '../eth1'; then echo 'FAIL - invalid interface accepted'; exit 1; fi +if "$tool" mode eth9; then echo 'FAIL - nonexistent interface accepted'; exit 1; fi +mkdir /proc/123 +printf '/sbin/udhcpc\000-f\000-i\000eth0\000-p\000/run/reliefos/udhcpc.pid\000' > /proc/123/cmdline +printf '123\n' > /run/reliefos/udhcpc.eth1.pid +if "$tool" stop-dhcp eth1; then echo 'FAIL - another interface PID accepted'; exit 1; fi +rm -f /run/reliefos/udhcpc.eth1.pid +printf 'version=1\nmode=dhcp\naddress=0.0.0.0\nprefix=24\ngateway=0.0.0.0\ndns_mode=automatic\ndns_servers=\n' > /etc/reliefos/network.d/eth1.conf +printf '10.0.0.53\n' > /run/reliefos/dhcp-dns +interface=eth1 ip=192.168.9.2 subnet=255.255.255.0 router=192.168.9.1 dns=192.168.9.53 lease=300 /usr/share/udhcpc/default.script bound +grep -q '^nameserver 10.0.0.53$' /etc/resolv.conf +grep -q '^nameserver 192.168.9.53$' /etc/resolv.conf +test -f /run/reliefos/dhcp-lease.eth1 +interface=eth1 /usr/share/udhcpc/default.script deconfig +test -f /run/reliefos/dhcp-dns +test ! -f /run/reliefos/dhcp-lease.eth1 +grep -q '^nameserver 10.0.0.53$' /etc/resolv.conf +if grep -q '^nameserver 192.168.9.53$' /etc/resolv.conf; then echo 'FAIL - stale DNS retained'; exit 1; fi +# OpenRC starts every real Ethernet interface, and apply only stops one. write_profile dhcp automatic '' -stop;stop_post -test ! -e /events -start_pre;start -write_profile disabled automatic '' -stop;stop_post -grep -q '^dhcp-stop$' /events -start_pre;start -grep -q ' ' /commands -test "$(cat /mode-value)" = disabled -printf 'PASS Network profile: static/DHCP/disabled, resolver compatibility, validation and old-mode stop\n' +printf 'version=1\nmode=disabled\naddress=0.0.0.0\nprefix=24\ngateway=0.0.0.0\ndns_mode=automatic\ndns_servers=\n' > /etc/reliefos/network.d/eth1.conf +. /etc/init.d/reliefos-dhcp +start +grep -q 'reliefos-dhcp.eth0 <--start>' /events +grep -q ' ' /commands +printf 'version=1\nmode=dhcp\naddress=0.0.0.0\nprefix=24\ngateway=0.0.0.0\ndns_mode=automatic\ndns_servers=\n' > /etc/reliefos/network.d/eth1.conf +: > /events +"$tool" apply eth1 +grep -q 'reliefos-dhcp.eth1 <--stop>' /events +grep -q 'reliefos-dhcp.eth1 <--start>' /events +if grep -q 'reliefos-dhcp.eth0' /events; then echo 'FAIL - applying eth1 interrupted eth0'; exit 1; fi +stop +grep -q 'reliefos-dhcp.eth0 <--stop>' /events +: > /events +: > /service-stopped +"$tool" apply eth1 +grep -q '^rc-service-start$' /events +printf 'PASS Network profiles: per-interface modes, DNS priority, lease isolation and scoped apply\n' SCRIPT LC_ALL=C LANG=C unshare -Ur chroot "$root" /bin/sh /test.sh diff --git a/tools/test_multi_nic_qemu.py b/tools/test_multi_nic_qemu.py new file mode 100644 index 00000000..df7f4715 --- /dev/null +++ b/tools/test_multi_nic_qemu.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Exercise three independent NICs using an owned minimal ReliefOS QEMU guest.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import time + +from test_e1000e_qemu import echo_peers + +ROOT = Path(__file__).resolve().parents[1] + + +def guest_script(port, modular): + load = "modprobe e1000\nmodprobe e1000e\n" if modular else "" + lifecycle = f''' +rm -f /run/multinic-hold-ready +/probe eth2 10.0.4.2 fec2::2 {port} hold & +holder=$! +for attempt in $(seq 1 100); do + test ! -f /run/multinic-hold-ready || break + sleep 0.1 +done +test -f /run/multinic-hold-ready +rmmod e1000 +if ifconfig eth0 >/dev/null 2>&1; then exit 1; fi +if ifconfig eth1 >/dev/null 2>&1; then exit 1; fi +ping -c 1 -W 3 -I eth2 10.0.4.2 +wait "$holder" +echo MULTINIC_OTHER_OWNER_SURVIVED +modprobe e1000 +configure eth3 10.0.2.2 fec0::15 fec0::2 +configure eth4 10.0.3.2 fec1::15 fec1::2 +/probe eth3 10.0.2.2 fec0::2 {port} +/probe eth4 10.0.3.2 fec1::2 {port} +echo MULTINIC_RELOAD_PASS +''' if modular else "" + return f'''#!/bin/sh +export PATH=/bin +exec /dev/ttyS0 2>&1 +finish() {{ + status=$? + echo MULTINIC_QEMU_DONE:status=$status + sync + poweroff -f + while :; do sleep 1; done +}} +trap finish EXIT +set -eu +test -c /dev/console +test -r /proc/net/dev +echo MULTINIC_QEMU_START +uname -a +{load} +configure() {{ + interface=$1 + udhcpc -f -n -q -i "$interface" -s /dhcp-script -t 4 -T 1 + ping -c 1 -W 3 -I "$interface" "$2" + ip -6 addr add "$3/64" dev "$interface" + attempts=0 + until ping6 -c 1 -W 1 -I "$interface" "$4"; do + attempts=$((attempts + 1)) + test "$attempts" -lt 6 + sleep 1 + done + echo MULTINIC_CONFIGURED:$interface +}} +configure eth0 10.0.2.2 fec0::15 fec0::2 +configure eth1 10.0.3.2 fec1::15 fec1::2 +configure eth2 10.0.4.2 fec2::15 fec2::2 +/usr/lib/reliefos/network-profile check eth1 +test "$(/usr/lib/reliefos/network-profile mode eth1)" = static +/usr/lib/reliefos/network-profile prepare eth1 +grep -q '^nameserver 10.0.3.3$' /etc/resolv.conf +ping -c 1 -W 3 -I eth0 10.0.2.2 +ping -c 1 -W 3 -I eth2 10.0.4.2 +echo MULTINIC_OS_PROFILE_PASS +cat /proc/net/dev /proc/net/route /proc/net/if_inet6 +/probe eth0 10.0.2.2 fec0::2 {port} & +first=$! +/probe eth1 10.0.3.2 fec1::2 {port} & +second=$! +/probe eth2 10.0.4.2 fec2::2 {port} & +third=$! +wait "$first" +wait "$second" +wait "$third" +echo MULTINIC_CONCURRENT_PASS +{lifecycle} +cat /proc/net/dev +awk '$1 ~ /^eth[0-9]+:/ && $4 != 0 {{exit 1}}' /proc/net/dev +ip addr show +ip route show +echo MULTINIC_QEMU_PASS +''' + + +def prepare(args, port): + work = args.output + root = work / "root" + for name in ("bin", "dev", "proc", "sys", "tmp", "run", "etc"): + (root / name).mkdir(parents=True, exist_ok=True) + shutil.copy2(args.busybox, root / "bin/busybox") + for name in subprocess.check_output([str(args.busybox), "--list"], text=True).splitlines(): + (root / "bin" / name).symlink_to("busybox") + (root / "sbin").symlink_to("bin") + (root / "usr").mkdir() + (root / "usr/bin").symlink_to("../bin") + (root / "usr/sbin").symlink_to("../bin") + profile_dir = root / "etc/reliefos/network.d" + profile_dir.mkdir(parents=True) + (profile_dir / "eth1.conf").write_text("version=1\nmode=static\naddress=10.0.3.15\nprefix=24\n" + "gateway=0.0.0.0\ndns_mode=custom\ndns_servers=10.0.3.3\n") + helper_dir = root / "usr/lib/reliefos" + helper_dir.mkdir(parents=True) + for name in ("network-profile", "publish-resolver"): + shutil.copy2(ROOT / "system/rootfs/usr/lib/reliefos" / name, helper_dir / name) + shutil.copy2(args.probe, root / "probe") + manifest = json.loads((args.install / "install.bundle.json").read_text()) + module_path = Path(manifest["module_directory"]) + shutil.copytree(args.install / module_path, root / module_path) + subprocess.run([str(args.busybox), "depmod", "-b", str(root), manifest["release"]], check=True) + (root / "init").write_text(guest_script(port, args.modular)) + (root / "init").chmod(0o755) + hook = root / "dhcp-script" + hook.write_text('''#!/bin/sh +set -eu +case "$1" in + bound|renew) + ifconfig "$interface" "$ip" netmask "$subnet" up + echo "MULTINIC_DHCP:$interface:$ip" + ;; +esac +''') + hook.chmod(0o755) + archive = work / "initramfs.cpio" + names = ["."] + sorted(str(path.relative_to(root)) for path in root.rglob("*")) + with archive.open("wb") as output: + subprocess.run(["cpio", "--null", "-o", "--format=newc", "--owner=0:0"], + input=("\0".join(names) + "\0").encode(), cwd=root, stdout=output, check=True) + iso_root = work / "iso" + boot = iso_root / "boot" + (boot / "grub").mkdir(parents=True) + kernel = args.install / "kernel.sys" + wanted = manifest["files"]["kernel.sys"]["sha256"] + if hashlib.sha256(kernel.read_bytes()).hexdigest() != wanted: + raise ValueError("installed kernel differs from its manifest") + shutil.copy2(kernel, boot / "kernel.sys") + shutil.copy2(archive, boot / "initramfs.cpio") + shutil.copy2("/usr/share/grub/unicode.pf2", boot / "grub/unicode.pf2") + (boot / "grub/grub.cfg").write_text('''set timeout=0 +serial --unit=0 --speed=115200 +terminal_input serial +terminal_output serial +insmod all_video +insmod gfxterm +loadfont /boot/grub/unicode.pf2 +set gfxmode=1024x768x32 +set gfxpayload=keep +terminal_output gfxterm serial +menuentry "ReliefOS multiple NIC test" { + multiboot2 /boot/kernel.sys root=/ log=serial,screen rdinit=/init + module2 /boot/initramfs.cpio initramfs + boot +} +''') + iso = work / "qa.iso" + with (work / "iso.log").open("w") as output: + subprocess.run(["grub-mkrescue", "-o", str(iso), str(iso_root)], + stdout=output, stderr=output, check=True) + return iso, manifest + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("install", "busybox", "probe", "output"): + parser.add_argument("--" + name, type=Path, required=True) + parser.add_argument("--modular", action="store_true") + parser.add_argument("--timeout", type=float, default=180) + args = parser.parse_args() + for name in ("install", "busybox", "probe", "output"): + setattr(args, name, getattr(args, name).resolve()) + if args.output.exists() or not args.output.is_relative_to(ROOT / "out"): + parser.error("output must be a new directory inside this checkout's out directory") + args.output.mkdir(parents=True) + result = {"passed": False, "vmware_verified": False, "physical_hardware_verified": False} + with echo_peers() as (port, counts): + iso, manifest = prepare(args, port) + serial = args.output / "serial.log" + command = ["qemu-system-x86_64", "-machine", "q35", "-accel", "kvm", "-cpu", "host", + "-m", "1024", "-smp", "2", "-vga", "std", "-display", "none", + "-serial", "file:" + str(serial), "-cdrom", str(iso), "-boot", "d", + "-nic", "none", "-no-reboot"] + for index, model in enumerate(("e1000", "e1000", "e1000e")): + network = (f"user,id=net{index},net=10.0.{index+2}.0/24," + f"dhcpstart=10.0.{index+2}.15,ipv6-prefix=fec{index}::") + command += ["-netdev", network, "-device", f"{model},netdev=net{index}"] + result.update(command=command, release=manifest["release"], + kernel_sha256=manifest["files"]["kernel.sys"]["sha256"], + probe_sha256=hashlib.sha256(args.probe.read_bytes()).hexdigest(), + busybox_sha256=hashlib.sha256(args.busybox.read_bytes()).hexdigest()) + with (args.output / "qemu.log").open("w") as log: + process = subprocess.Popen(command, stdout=log, stderr=log, + env={**os.environ, "TMPDIR": str(ROOT / "out/tmp")}) + try: + deadline = time.monotonic() + args.timeout + while process.poll() is None and time.monotonic() < deadline: + time.sleep(.25) + if process.poll() is None: + raise TimeoutError("multiple NIC guest timed out") + text = serial.read_text(errors="replace") if serial.exists() else "" + for marker in ("MULTINIC_CONCURRENT_PASS", "MULTINIC_OS_PROFILE_PASS", "MULTINIC_QEMU_PASS", "MULTINIC_QEMU_DONE:status=0"): + if marker not in text: + raise AssertionError("missing guest result " + marker) + if "MULTINIC_FAIL" in text or "Kernel Panic" in text or "SIOCGIFTXQLEN:" in text or process.returncode: + raise AssertionError("guest failed") + if args.modular: + for marker in ("MULTINIC_OTHER_OWNER_SURVIVED", "MULTINIC_RELOAD_PASS"): + if marker not in text: + raise AssertionError("missing module lifecycle result " + marker) + result.update(passed=True, returncode=process.returncode, echoes=dict(counts)) + except Exception as error: + result["error"] = repr(error) + raise + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + (args.output / "result.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_openrc_resolver.py b/tools/test_openrc_resolver.py index e818056c..83002e78 100644 --- a/tools/test_openrc_resolver.py +++ b/tools/test_openrc_resolver.py @@ -25,7 +25,7 @@ def libraries(binary): shutil.copy2(source, destination) # BusyBox no longer provides every coreutils applet. Exercise the # POSIX utility boundary, without assuming an obsolete applet set. - for name in ('sh', 'cat', 'rm', 'mktemp', 'chmod', 'mv'): + for name in ('sh', 'cat', 'rm', 'mktemp', 'chmod', 'mv', 'mkdir', 'flock'): source = Path(shutil.which(name)) shutil.copy2(source, root / 'bin' / name) libraries(source) diff --git a/tools/tests/network_controller_config_test.cpp b/tools/tests/network_controller_config_test.cpp index 49e219bd..8913ab43 100644 --- a/tools/tests/network_controller_config_test.cpp +++ b/tools/tests/network_controller_config_test.cpp @@ -59,6 +59,24 @@ int main(int argc, char **argv) { assert(unlink(path) == 0 && symlink(outside, path) == 0); assert(!store.save(current, updated, error)); assert(!store.restore(current, error)); + // Saving and restoring a second adapter must not alter eth0's profile. + ConfigStore second(argv[1], "eth1"); + Snapshot second_original, second_current, eth0_before; + assert(store.read(eth0_before, error)); + assert(second.read(second_original, error) && !second_original.exists); + assert(second.save(second_original, updated, error)); + assert(second.read(second_current, error) && second_current.bytes == updated); + assert(store.read(current, error) && current.bytes == eth0_before.bytes); + assert(second.restore(second_current, error)); + assert(second.read(second_current, error) && !second_current.exists); + ConfigStore unsafe(argv[1], "../eth1"); + assert(!unsafe.read(second_current, error)); + char dir_path[1024], saved_dir[1024]; + snprintf(dir_path, sizeof(dir_path), "%s/network.d", argv[1]); + snprintf(saved_dir, sizeof(saved_dir), "%s/network.d.saved", argv[1]); + assert(rename(dir_path, saved_dir) == 0 && symlink(saved_dir, dir_path) == 0); + assert(!second.read(second_current, error)); + assert(unlink(dir_path) == 0 && rename(saved_dir, dir_path) == 0); puts("PASS Network Controller configuration: save, absence restore, backup, " "conflict and symlink isolation"); } diff --git a/tools/tests/network_controller_session_test.cpp b/tools/tests/network_controller_session_test.cpp index 4aceefd0..043706af 100644 --- a/tools/tests/network_controller_session_test.cpp +++ b/tools/tests/network_controller_session_test.cpp @@ -58,8 +58,14 @@ int main(int argc, char **argv) { // SIOCGIFCONF omits interfaces without IPv4 addresses. They must still be // visible, otherwise disabling an adapter makes it impossible to select. assert(parse_snapshot(out, interfaces, gw, dns)); - assert(interfaces.size() == 1 && interfaces[0].name == "lo" && - interfaces[0].address == "0.0.0.0" && !(interfaces[0].flags & 1)); + bool found_second = false, found_sparse = false; + for (size_t i = 0; i < interfaces.size(); ++i) { + if (interfaces[i].name == "eth1" && interfaces[i].address == "0.0.0.0") + found_second = true; + if (interfaces[i].name == "eth998" && interfaces[i].address == "0.0.0.0") + found_sparse = true; + } + assert(found_second && found_sparse); r.op = Op::ReadConfig; out.clear(); assert(s.send(r, 2)); @@ -82,6 +88,33 @@ int main(int argc, char **argv) { assert(s.send(r, 4)); assert(result(s, 4, out) == 1); // Stale snapshot cannot overwrite saved settings. + // Worker selects eth1 independently; eth0 must retain its own snapshot. + r = Request{}; + r.op = Op::ReadConfig; + r.a = text("eth1"); + out.clear(); + assert(s.send(r, 30)); + assert(!result(s, 30, out)); + assert(decode_profile(out, exists, original, initial) && !exists); + r = Request{}; + r.op = Op::WriteConfig; + r.a = bytes; + r.c = text("0"); + r.d = text("eth1"); + out.clear(); + assert(s.send(r, 31)); + assert(!result(s, 31, out)); + r = Request{}; + r.op = Op::ReadConfig; + r.a = text("eth0"); + out.clear(); + assert(s.send(r, 32)); + assert(!result(s, 32, out)); + assert(decode_profile(out, exists, original, initial) && exists && original == bytes); + r.a = text("eth99"); + out.clear(); + assert(s.send(r, 33)); + assert(result(s, 33, out) != 0); r = Request{}; r.op = Op::Ping; r.a = text("example.com"); diff --git a/tools/tests/network_controller_test.cpp b/tools/tests/network_controller_test.cpp index fa9f99e0..3a533187 100644 --- a/tools/tests/network_controller_test.cpp +++ b/tools/tests/network_controller_test.cpp @@ -70,6 +70,19 @@ int main() { assert(target("example.com") && target("10.0.2.2") && !target("-c") && !target("x;id") && !target("a b") && !target("$(id)")); Request r; + r.op = Op::ReadConfig; + r.a = text("eth1"); + assert(validate(r, error)); + r.a = text("../eth1"); + assert(!validate(r, error)); + r.a = text("eth1"); + r.op = Op::Apply; + Array apply_args; + assert(command(r, apply_args, error)); + assert(apply_args.size() == 3 && + apply_args[0] == "/usr/lib/reliefos/network-profile" && + apply_args[1] == "apply" && apply_args[2] == "eth1"); + r = Request{}; r.op = Op::Ping; r.a = text("-f"); assert(!validate(r, error)); @@ -122,6 +135,13 @@ int main() { assert(interfaces.size() == 1 && interfaces[0].rx == 4096 && interfaces[0].tx_errors == 1 && gateway == "10.0.2.2" && dns == "10.0.2.3"); + assert(parse_snapshot( + text("I\teth0\t10.0.2.15\t255.255.255.0\t-\t65\t1500\t0\t0\t0\t0\t0\t0\t0\n" + "I\teth1\t192.168.9.2\t255.255.255.0\t-\t65\t1500\t0\t0\t0\t0\t0\t0\t0\n" + "G\teth0\t10.0.2.2\nG\teth1\t192.168.9.1\n"), + interfaces, gateway, dns)); + assert(gateway == "10.0.2.2" && interfaces[0].gateway == "10.0.2.2" && + interfaces[1].gateway == "192.168.9.1"); parse_counters("Inter-| Receive | Transmit\n eth0: 9000 32 2 0 0 0 0 0 5000 " "19 1 0 0 0 0 0\n", interfaces); diff --git a/userland/apps/network-controller/config.cpp b/userland/apps/network-controller/config.cpp index e3dd0f25..6a71ded2 100644 --- a/userland/apps/network-controller/config.cpp +++ b/userland/apps/network-controller/config.cpp @@ -131,19 +131,76 @@ bool atomic(int dir, const char *name, const Text &bytes, Text &error) { return true; } } // namespace +int ConfigStore::profile_directory(bool create, Text &error) { + if (!ethernet_name(interface_)) { + errno = EINVAL; + failure(error, "Invalid Ethernet interface"); + return -1; + } + int root = directory(directory_, error); + if (root < 0 || !strcmp(interface_, "eth0")) + return root; + int dir = openat(root, "network.d", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (dir < 0 && errno == ENOENT && create) { + int made = mkdirat(root, "network.d", 0755); + if (made < 0 && errno != EEXIST) { + failure(error, "Cannot create network configuration directory"); + close(root); + return -1; + } + if (made == 0 && fsync(root) < 0) { + failure(error, "Cannot synchronize network configuration directory"); + close(root); + return -1; + } + dir = openat(root, "network.d", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + } + int saved = errno; + close(root); + errno = saved; + if (dir < 0) { + if (errno == ENOENT && !create) + return -2; + failure(error, "Cannot open network configuration directory"); + return -1; + } + struct stat st{}; + if (fstat(dir, &st) < 0 || st.st_uid != geteuid() || (st.st_mode & 0022)) { + close(dir); + errno = EPERM; + failure(error, "Unsafe configuration directory"); + return -1; + } + return dir; +} +void ConfigStore::profile_names(char (&name)[32], char (&backup)[40]) const { + if (!strcmp(interface_, "eth0")) + snprintf(name, sizeof(name), "network.conf"); + else + snprintf(name, sizeof(name), "%s.conf", interface_); + snprintf(backup, sizeof(backup), "%s.bak", name); +} bool ConfigStore::read(Snapshot &out, Text &error) { - int dir = directory(directory_, error); + int dir = profile_directory(false, error); + if (dir == -2) { + out = Snapshot{}; + return true; + } if (dir < 0) return false; - bool ok = load(dir, "network.conf", out, error); + char name[32], backup[40]; + profile_names(name, backup); + bool ok = load(dir, name, out, error); close(dir); return ok; } bool ConfigStore::replace(const Snapshot &expected, const Snapshot &next, Text &error) { - int dir = directory(directory_, error); + int dir = profile_directory(true, error); if (dir < 0) return false; + char name[32], backup_name[40]; + profile_names(name, backup_name); int lock = openat(dir, ".network-controller.lock", O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0600); struct stat st{}; @@ -156,7 +213,7 @@ bool ConfigStore::replace(const Snapshot &expected, const Snapshot &next, return failure(error, "Network configuration is locked or unsafe"); } Snapshot current; - ok = load(dir, "network.conf", current, error); + ok = load(dir, name, current, error); if (ok && (current.exists != expected.exists || !(current.bytes == expected.bytes))) { error.set( @@ -169,12 +226,12 @@ bool ConfigStore::replace(const Snapshot &expected, const Snapshot &next, backup = current.bytes; else backup.set(Absent); - ok = atomic(dir, "network.conf.bak", backup, error); + ok = atomic(dir, backup_name, backup, error); } if (ok) { if (next.exists) - ok = atomic(dir, "network.conf", next.bytes, error); - else if (unlinkat(dir, "network.conf", 0) < 0 && errno != ENOENT) + ok = atomic(dir, name, next.bytes, error); + else if (unlinkat(dir, name, 0) < 0 && errno != ENOENT) ok = failure(error, "Cannot restore absent network profile"); else if (fsync(dir) < 0) ok = failure(error, "Cannot synchronize restored network profile"); @@ -196,11 +253,16 @@ bool ConfigStore::save(const Snapshot &expected, const Text &bytes, return replace(expected, next, error); } bool ConfigStore::restore(const Snapshot &expected, Text &error) { - int dir = directory(directory_, error); - if (dir < 0) + int dir = profile_directory(false, error); + if (dir < 0) { + if (dir == -2) + error.set("No previous network configuration backup is available."); return false; + } + char name[32], backup[40]; + profile_names(name, backup); Snapshot next; - bool ok = load(dir, "network.conf.bak", next, error); + bool ok = load(dir, backup, next, error); close(dir); if (!ok) return false; diff --git a/userland/apps/network-controller/config.h b/userland/apps/network-controller/config.h index 45146489..6174ca22 100644 --- a/userland/apps/network-controller/config.h +++ b/userland/apps/network-controller/config.h @@ -8,11 +8,15 @@ struct Snapshot { }; class ConfigStore { const char *directory_; + const char *interface_; + int profile_directory(bool create, Text &error); + void profile_names(char (&name)[32], char (&backup)[40]) const; bool replace(const Snapshot &, const Snapshot &, Text &); public: - explicit ConfigStore(const char *directory = "/etc/reliefos") - : directory_(directory) {} + explicit ConfigStore(const char *directory = "/etc/reliefos", + const char *interface = "eth0") + : directory_(directory), interface_(interface) {} bool read(Snapshot &, Text &); bool save(const Snapshot &, const Text &, Text &); bool restore(const Snapshot &, Text &); diff --git a/userland/apps/network-controller/model.cpp b/userland/apps/network-controller/model.cpp index aedb2207..a853d62c 100644 --- a/userland/apps/network-controller/model.cpp +++ b/userland/apps/network-controller/model.cpp @@ -252,6 +252,18 @@ bool interface_name(const char *s) { return false; return true; } +bool ethernet_name(const char *s) { + if (!s || strncmp(s, "eth", 3) || !s[3] || strlen(s) > 15 || + (s[3] == '0' && s[4])) + return false; + for (s += 3; *s; ++s) + if (*s < '0' || *s > '9') + return false; + return true; +} +static bool ethernet_or_legacy(const Text &s) { + return s.empty() || ethernet_name(s.c_str()); +} bool validate(const Request &r, Text &e) { const Text *fields[] = {&r.a, &r.b, &r.c, &r.d}; for (auto f : fields) @@ -263,25 +275,28 @@ bool validate(const Request &r, Text &e) { Config config; switch (r.op) { case Op::Snapshot: - case Op::ReadConfig: - case Op::Apply: - case Op::Renew: case Op::Cancel: case Op::Quit: ok = r.a.empty() && r.b.empty() && r.c.empty() && r.d.empty(); break; + case Op::ReadConfig: + case Op::Apply: + case Op::Renew: + ok = ethernet_or_legacy(r.a) && r.b.empty() && r.c.empty() && r.d.empty(); + break; case Op::WriteConfig: - ok = (r.c == "0" || r.c == "1") && r.d.empty() && + ok = (r.c == "0" || r.c == "1") && ethernet_or_legacy(r.d) && parse_config(r.a, config, e); break; case Op::RestoreConfig: - ok = r.a.empty() && (r.c == "0" || r.c == "1") && r.d.empty(); + ok = r.a.empty() && (r.c == "0" || r.c == "1") && ethernet_or_legacy(r.d); break; case Op::View: ok = (r.a == "routes" || r.a == "arp" || r.a == "connections" || r.a == "lease" || r.a == "log" || r.a == "resolver" || r.a == "report") && - r.b.empty() && r.c.empty() && r.d.empty(); + (r.a == "lease" ? ethernet_or_legacy(r.b) : r.b.empty()) && + r.c.empty() && r.d.empty(); break; case Op::Ping: ok = target(r.a.c_str()) && r.b.empty() && r.c.empty() && r.d.empty(); @@ -307,8 +322,8 @@ bool command(const Request &r, Array &v, Text &e) { if (!validate(r, e)) return false; if (r.op == Op::Apply || r.op == Op::Renew) - return arg(v, "/sbin/rc-service") && arg(v, "reliefos-dhcp") && - arg(v, "restart"); + return arg(v, "/usr/lib/reliefos/network-profile") && arg(v, "apply") && + arg(v, r.a.empty() ? "eth0" : r.a.c_str()); if (r.op == Op::Ping) return arg(v, "/bin/busybox") && arg(v, "ping") && arg(v, "-c") && arg(v, "4") && arg(v, "-W") && arg(v, "2") && arg(v, r.a.c_str()); @@ -496,8 +511,23 @@ bool parse_snapshot(const Text &t, Array &out, Text &gateway, if (!next.push(i)) return false; } else if (line[0] == 'G' && line[1] == '\t') { - if (!g.set(line + 2)) + const char *name = line + 2; + char *address = strchr(line + 2, '\t'); + if (address) + *address++ = 0; + else { + address = line + 2; + name = "eth0"; + } + uint32_t ip; + if (!interface_name(name) || !ipv4(address, ip)) + return false; + if (!strcmp(name, "eth0") && g.empty() && !g.set(address)) return false; + for (size_t n = 0; n < next.size(); ++n) + if (next[n].name == name && next[n].gateway.empty() && + !next[n].gateway.set(address)) + return false; } else if (line[0] == 'D' && line[1] == '\t') { if (!d.empty() && !append(d, ", ")) return false; diff --git a/userland/apps/network-controller/model.h b/userland/apps/network-controller/model.h index a43ec061..1569099a 100644 --- a/userland/apps/network-controller/model.h +++ b/userland/apps/network-controller/model.h @@ -29,6 +29,7 @@ bool parse_config(const Text &, Config &, Text &); bool serialize_config(const Config &, Text &, Text &); bool target(const char *); bool interface_name(const char *); +bool ethernet_name(const char *); bool append(Text &, const char *, size_t); inline bool append(Text &t, const char *s) { return append(t, s, strlen(s)); } enum class Op : unsigned { @@ -65,7 +66,7 @@ bool encode_profile(bool exists, const Text &original, const Config &, Text &); bool decode_profile(const Text &, bool &exists, Text &original, Config &); struct Interface { bool counters = false; - Text name, address, mask, mac; + Text name, address, mask, mac, gateway; unsigned flags = 0, mtu = 0; uint64_t rx = 0, tx = 0, rx_packets = 0, tx_packets = 0, rx_errors = 0, tx_errors = 0; diff --git a/userland/apps/network-controller/window.cpp b/userland/apps/network-controller/window.cpp index 4d76c10f..848c5d9c 100644 --- a/userland/apps/network-controller/window.cpp +++ b/userland/apps/network-controller/window.cpp @@ -195,7 +195,6 @@ App::App(Session &s) : session_(s) { controls(); Fl::add_timeout(.025, timer, this); enqueue(Op::Snapshot, Sample, T("Refresh interfaces")); - enqueue(Op::ReadConfig, Load, T("Read configuration")); } App::~App() { closing_ = true; @@ -306,6 +305,7 @@ void App::next() { } void App::finish(int code) { unsigned purpose = current_.purpose; + bool load_selection = false; if (!code && purpose == Sample) { Array next; Text gw, dns; @@ -338,15 +338,29 @@ void App::finish(int code) { if (interfaces_[i].name == selected_) selected = i + 1; } - if (!selected) + if (!selected && dirty_) { + adapters_->value(0); + overview(); + } else if (!selected) for (size_t i = 0; i < interfaces_.size(); ++i) if (interfaces_[i].name == "eth0") selected = i + 1; - if (!selected && !interfaces_.empty()) + if (!selected && !dirty_) + for (size_t i = 0; i < interfaces_.size(); ++i) + if (ethernet_name(interfaces_[i].name.c_str())) { + selected = i + 1; + break; + } + if (!selected && !dirty_ && !interfaces_.empty()) selected = 1; adapters_->value(selected); - if (selected) + if (selected && !(interfaces_[selected - 1].name == selected_)) { selected_ = interfaces_[selected - 1].name; + loaded_ = dirty_ = false; + original_ = Snapshot{}; + populate(Config{}); + load_selection = ethernet_name(selected_.c_str()); + } overview(); } else { code = 1; @@ -394,6 +408,8 @@ void App::finish(int code) { } else if (queue_.empty()) status(dirty_ ? T("Unsaved configuration changes") : T("Ready")); controls(); + if (!code && load_selection) + enqueue(Op::ReadConfig, Load, T("Read configuration"), selected_.c_str()); next(); } void App::pump() { @@ -436,10 +452,25 @@ void App::dispatch(Fl_Widget *w, void *p) { if (w == a->adapters_) { int i = a->adapters_->value() - 1; if (i >= 0 && static_cast(i) < a->interfaces_.size()) { + if (a->interfaces_[i].name == a->selected_) + return; + if (a->busy_ || (a->dirty_ && + fl_choice("%s", T("Cancel"), T("Discard"), nullptr, + T("Discard unsaved changes and select another adapter?")) != 1)) { + for (size_t n = 0; n < a->interfaces_.size(); ++n) + if (a->interfaces_[n].name == a->selected_) + a->adapters_->value(n + 1); + return; + } a->selected_ = a->interfaces_[i].name; + a->loaded_ = a->dirty_ = false; + a->original_ = Snapshot{}; + a->populate(Config{}); a->rx_rate_ = a->tx_rate_ = 0; a->overview(); a->controls(); + if (ethernet_name(a->selected_.c_str())) + a->enqueue(Op::ReadConfig, Load, T("Read configuration"), a->selected_.c_str()); } } } @@ -449,11 +480,11 @@ void App::edited(Fl_Widget *widget, void *p) { return; if (widget == a->mode_ && a->mode_->value() == 1) { for (size_t n = 0; n < a->interfaces_.size(); ++n) - if (a->interfaces_[n].name == "eth0") { + if (a->interfaces_[n].name == a->selected_) { if (!*a->address_->value() && !(a->interfaces_[n].address == "0.0.0.0")) a->address_->value(a->interfaces_[n].address.c_str()); - if (!*a->gateway_->value()) - a->gateway_->value(a->default_gateway_.c_str()); + if (!*a->gateway_->value() && !a->interfaces_[n].gateway.empty()) + a->gateway_->value(a->interfaces_[n].gateway.c_str()); } if (a->dns_mode_->value() == 0) { a->dns_mode_->value(1); @@ -486,7 +517,7 @@ void App::overview() { : T("Disabled")); information_[1]->value(i->address.c_str()); information_[2]->value(i->mask.c_str()); - information_[3]->value(i->name == "eth0" ? default_gateway_.c_str() : "-"); + information_[3]->value(i->gateway.empty() ? "-" : i->gateway.c_str()); information_[4]->value(i->mac.c_str()); char b[160]; snprintf(b, sizeof(b), "%u", i->mtu); @@ -518,9 +549,10 @@ void App::overview() { "diagnostics report actual results.") : T("Adapter counters are not exported by this kernel. Address, " "gateway, DNS and connection diagnostics remain available.")); - if (!i->counters) - overview_note_->resize(tabs_->x() + 16, tabs_->y() + 330, tabs_->w() - 32, - 80); + overview_note_->resize(tabs_->x() + 16, + tabs_->y() + (i->counters ? 448 : 330), + tabs_->w() - 32, + i->counters ? tabs_->h() - 458 : 80); } void App::populate(const Config &c) { setting_ = true; @@ -546,12 +578,15 @@ void App::populate(const Config &c) { } void App::controls() { bool live = session_.ready() && session_.alive(), idle = live && !busy_, - ethernet = selected_ == "eth0", + ethernet = ethernet_name(selected_.c_str()), editable = live && loaded_ && ethernet && (!busy_ || current_.purpose == Sample); - Fl_Widget *general[] = {refresh_, renew_, reload_, ping_, dns_button_, tcp_}; + Fl_Widget *general[] = {refresh_, ping_, dns_button_, tcp_}; for (auto w : general) idle ? w->activate() : w->deactivate(); + idle ? adapters_->activate() : adapters_->deactivate(); + (idle && loaded_ && ethernet) ? renew_->activate() : renew_->deactivate(); + (idle && ethernet) ? reload_->activate() : reload_->deactivate(); Fl_Widget *mutations[] = {apply_, save_, restore_}; for (auto w : mutations) (idle && loaded_ && ethernet) ? w->activate() : w->deactivate(); @@ -563,13 +598,17 @@ void App::controls() { (editable && mode_->value() == 1) ? w->activate() : w->deactivate(); (editable && dns_mode_->value() == 1) ? servers_->activate() : servers_->deactivate(); - config_note_->copy_label( - !ethernet - ? T("Persistent configuration is available for eth0. Loopback and " - "other interfaces are shown for inspection.") - : T("Settings: /etc/reliefos/network.conf\nSaving keeps a backup and " - "detects external changes. Applying may briefly disconnect the " - "network. Static mode requires explicit DNS.")); + char note[512]; + if (!ethernet) + snprintf(note, sizeof(note), "%s", T("Select an Ethernet adapter to edit its configuration.")); + else + snprintf(note, sizeof(note), "%s: %s%s%s%s\n%s", + T("Settings"), "/etc/reliefos/", + selected_ == "eth0" ? "network.conf" : "network.d/", + selected_ == "eth0" ? "" : selected_.c_str(), + selected_ == "eth0" ? "" : ".conf", + T("Saving keeps a backup and detects external changes. Applying may interrupt this adapter. Static mode requires explicit DNS.")); + config_note_->copy_label(note); } bool App::form(Config &c, Text &e) { c = Config{}; @@ -608,7 +647,7 @@ bool App::form(Config &c, Text &e) { return valid_config(c, e); } void App::save(bool apply) { - if (busy_ || !loaded_ || !(selected_ == "eth0")) + if (busy_ || !loaded_ || !ethernet_name(selected_.c_str())) return; Config c; Text e, canonical; @@ -637,13 +676,14 @@ void App::save(bool apply) { r.a = canonical; r.b = original_.bytes; r.c.set(original_.exists ? "1" : "0"); + r.d = selected_; enqueue(r, Write, T("Save network configuration")); if (apply) - enqueue(Op::Apply, Mutation, T("Apply Ethernet configuration")); + enqueue(Op::Apply, Mutation, T("Apply Ethernet configuration"), selected_.c_str()); enqueue(Op::Snapshot, Sample, T("Refresh interfaces")); } void App::restore() { - if (busy_ || !loaded_ || !(selected_ == "eth0")) + if (busy_ || !loaded_ || !ethernet_name(selected_.c_str())) return; if (fl_choice("%s", T("Cancel"), T("Restore and apply"), nullptr, T("Restore the previous profile and apply it? The current " @@ -653,9 +693,10 @@ void App::restore() { r.op = Op::RestoreConfig; r.b = original_.bytes; r.c.set(original_.exists ? "1" : "0"); + r.d = selected_; enqueue(r, RestoreJob, T("Restore previous configuration")); - enqueue(Op::ReadConfig, Load, T("Read configuration")); - enqueue(Op::Apply, Mutation, T("Apply Ethernet configuration")); + enqueue(Op::ReadConfig, Load, T("Read configuration"), selected_.c_str()); + enqueue(Op::Apply, Mutation, T("Apply Ethernet configuration"), selected_.c_str()); enqueue(Op::Snapshot, Sample, T("Refresh interfaces")); } void App::export_output() { @@ -743,7 +784,7 @@ void App::action(Action a) { } if (a == Help) { fl_message("%s", - T("Select an adapter to inspect its live state. Configure eth0 " + T("Select an adapter to inspect and configure its live state. Configure Ethernet " "with DHCP, static IPv4 or disabled mode. Save and apply uses " "the existing OpenRC network service.\n\nStatic mode requires " "custom or Cloudflare DNS. Use spaces between custom DNS " @@ -784,11 +825,12 @@ void App::action(Action a) { T("Discard unsaved edits and reload the network profile?")) != 1) return; - enqueue(Op::ReadConfig, Load, T("Read configuration")); + if (ethernet_name(selected_.c_str())) + enqueue(Op::ReadConfig, Load, T("Read configuration"), selected_.c_str()); return; } if (a == Enable || a == Disable) { - if (!(selected_ == "eth0") || !loaded_) + if (!ethernet_name(selected_.c_str()) || !loaded_) return; mode_->value(a == Enable ? 0 : 2); dirty_ = true; @@ -798,7 +840,9 @@ void App::action(Action a) { return; } if (a == Renew) { - enqueue(Op::Renew, Mutation, T("Renew DHCP")); + if (!ethernet_name(selected_.c_str())) + return; + enqueue(Op::Renew, Mutation, T("Renew DHCP"), selected_.c_str()); enqueue(Op::Snapshot, Sample, T("Refresh interfaces")); return; } @@ -832,8 +876,12 @@ void App::action(Action a) { : a == Log ? "log" : "report"; tabs_->value(pages_[3]); - enqueue(Op::View, Display, - a == Report ? T("Diagnostic report") : T("Network information"), - view); + Request r; + r.op = Op::View; + r.a.set(view); + if (a == Lease && ethernet_name(selected_.c_str())) + r.b = selected_; + enqueue(r, Display, + a == Report ? T("Diagnostic report") : T("Network information")); } } // namespace nc diff --git a/userland/apps/network-controller/worker.cpp b/userland/apps/network-controller/worker.cpp index 55ddb2f2..6ceddbf0 100644 --- a/userland/apps/network-controller/worker.cpp +++ b/userland/apps/network-controller/worker.cpp @@ -377,24 +377,53 @@ bool snapshot(Text &out) { size_t count = conf.ifc_len / sizeof(struct ifreq); if (count > 64) count = 64; - // SIOCGIFCONF lists configured IPv4 addresses, not all adapters. Include - // real interfaces without an address so Disabled remains reversible. - // ReliefOS currently exposes eth0 at index 2; probe bounded indices using - // the standard name ioctl rather than inventing interface names. - for (int index = 1; index <= 64 && count < 64; ++index) { - struct ifreq candidate{}; - candidate.ifr_ifindex = index; - if (ioctl(fd, SIOCGIFNAME, &candidate) < 0) - continue; - candidate.ifr_name[IFNAMSIZ - 1] = 0; - if (!interface_name(candidate.ifr_name)) - continue; - bool duplicate = false; - for (size_t n = 0; n < count; ++n) - if (!strncmp(entries[n].ifr_name, candidate.ifr_name, IFNAMSIZ)) - duplicate = true; - if (!duplicate) - entries[count++] = candidate; + // /proc/net/dev enumerates even unconfigured devices and sparse indices. + Text devices; + if (read_text("/proc/net/dev", devices, 65536)) { + const char *line = devices.c_str(); + while (line && *line && count < 64) { + const char *end = strchr(line, '\n'); + if (!end) + end = line + strlen(line); + const char *name = line; + while (name < end && (*name == ' ' || *name == '\t')) + ++name; + const char *colon = static_cast(memchr(name, ':', end - name)); + size_t length = colon ? static_cast(colon - name) : 0; + while (length && (name[length - 1] == ' ' || name[length - 1] == '\t')) + --length; + if (length && length < IFNAMSIZ) { + struct ifreq candidate{}; + memcpy(candidate.ifr_name, name, length); + if (interface_name(candidate.ifr_name) && + ioctl(fd, SIOCGIFINDEX, &candidate) == 0) { + bool duplicate = false; + for (size_t n = 0; n < count; ++n) + if (!strncmp(entries[n].ifr_name, candidate.ifr_name, IFNAMSIZ)) + duplicate = true; + if (!duplicate) + entries[count++] = candidate; + } + } + line = *end ? end + 1 : nullptr; + } + } else { + // Some host fixtures lack procfs; retain the standard bounded fallback. + for (int index = 1; index <= 64 && count < 64; ++index) { + struct ifreq candidate{}; + candidate.ifr_ifindex = index; + if (ioctl(fd, SIOCGIFNAME, &candidate) < 0) + continue; + candidate.ifr_name[IFNAMSIZ - 1] = 0; + if (!interface_name(candidate.ifr_name)) + continue; + bool duplicate = false; + for (size_t n = 0; n < count; ++n) + if (!strncmp(entries[n].ifr_name, candidate.ifr_name, IFNAMSIZ)) + duplicate = true; + if (!duplicate) + entries[count++] = candidate; + } } for (size_t n = 0; n < count; ++n) { entries[n].ifr_name[IFNAMSIZ - 1] = 0; @@ -467,33 +496,33 @@ bool snapshot(Text &out) { if (!append(out, b)) return false; } - struct reliefos_net_control config{}; - if (management(RELIEFOS_NET_CONTROL_CONFIG, config)) { - if (config.data.config.gateway_ip) { - char ip[16]; - format_ip(config.data.config.gateway_ip, ip); - snprintf(b, sizeof(b), "G\t%s\n", ip); - if (!append(out, b)) - return false; - } - } else if (read_text("/proc/net/route", bytes, 65536)) { + if (read_text("/proc/net/route", bytes, 65536)) { const char *s = bytes.c_str(); while (s && *s) { char name[16]; unsigned dst, gw, flags; - if (sscanf(s, "%15s %x %x %x", name, &dst, &gw, &flags) == 4 && !dst && - (flags & 2) && !strcmp(name, "eth0")) { + if (sscanf(s, "%15s %x %x %x", name, &dst, &gw, &flags) == 4 && + !dst && (flags & 2) && interface_name(name)) { char ip[16]; format_ip(ntohl(gw), ip); - snprintf(b, sizeof(b), "G\t%s\n", ip); + snprintf(b, sizeof(b), "G\t%s\t%s\n", name, ip); if (!append(out, b)) return false; - break; } s = strchr(s, '\n'); if (s) ++s; } + } else { + struct reliefos_net_control config{}; + if (management(RELIEFOS_NET_CONTROL_CONFIG, config) && + config.data.config.gateway_ip) { + char ip[16]; + format_ip(config.data.config.gateway_ip, ip); + snprintf(b, sizeof(b), "G\teth0\t%s\n", ip); + if (!append(out, b)) + return false; + } } if (read_text("/etc/resolv.conf", bytes, 16384)) { const char *s = bytes.c_str(); @@ -603,12 +632,22 @@ bool native_information(const Text &what, Text &text) { } int view(const Request &r, unsigned id) { Text bytes; + char lease_path[64]; + const char *iface = r.b.empty() ? "eth0" : r.b.c_str(); + if (!strcmp(iface, "eth0")) + snprintf(lease_path, sizeof(lease_path), "/run/reliefos/dhcp-lease"); + else + snprintf(lease_path, sizeof(lease_path), "/run/reliefos/dhcp-lease.%s", iface); const char *path = r.a == "routes" ? "/proc/net/route" : r.a == "arp" ? "/proc/net/arp" : r.a == "connections" ? "/proc/net/tcp" - : r.a == "lease" ? "/run/reliefos/dhcp-lease" + : r.a == "lease" ? lease_path : r.a == "log" ? "/var/log/udhcpc.log" : "/etc/resolv.conf"; + if (r.a == "routes" && read_text("/proc/net/route", bytes, 65536, true)) { + output(id, bytes.c_str()); + return 0; + } if ((r.a == "routes" || r.a == "connections") && native_information(r.a, bytes)) { output(id, bytes.c_str()); @@ -647,9 +686,30 @@ int view(const Request &r, unsigned id) { output(id, bytes.c_str()); return 0; } +bool present_ethernet(const char *name) { + if (!ethernet_name(name)) + return false; + int fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return false; + struct ifreq request{}; + snprintf(request.ifr_name, sizeof(request.ifr_name), "%s", name); + bool found = ioctl(fd, SIOCGIFINDEX, &request) == 0; + close(fd); + return found; +} int dispatch(const Request &r, unsigned id, Text &input, bool &quit) { Text text, error; - ConfigStore store; + const Text &field = (r.op == Op::WriteConfig || r.op == Op::RestoreConfig) + ? r.d : r.a; + const char *iface = field.empty() ? "eth0" : field.c_str(); + if ((r.op == Op::ReadConfig || r.op == Op::WriteConfig || + r.op == Op::RestoreConfig || r.op == Op::Apply || r.op == Op::Renew) && + !present_ethernet(iface)) { + output(id, "The selected Ethernet interface is no longer present.\n"); + return 1; + } + ConfigStore store("/etc/reliefos", iface); Snapshot state; switch (r.op) { case Op::Snapshot: @@ -669,7 +729,7 @@ int dispatch(const Request &r, unsigned id, Text &input, bool &quit) { if (!parse_config(state.bytes, c, error)) c = Config{}; } - if (!state.exists || c.legacy) + if (!strcmp(iface, "eth0") && (!state.exists || c.legacy)) legacy_dns(c); if (!encode_profile(state.exists, state.bytes, c, text)) return 1;