feat(user): add dormant userspace DHCP service (#2245)

Introduce an opt-in dragon-network control plane for DHCP, static, and externally managed interfaces without changing the default kernel DHCP owner.

The service validates root-owned data-only configuration, serializes per-interface control and lease mutations, records crash-recoverable network transactions, preserves external address and route ownership, and derives managed DNS atomically under /run.

Add bounded and identity-checked udhcpc lifecycle handling, including DragonOS-compatible zombie detection, and mount /run as tmpfs during early userspace initialization. No interface configuration or startup hook is installed by default.

Validated with make fmt, make kernel, make user, isolated Linux network namespaces using BusyBox, fault-injected transaction and route-ownership scenarios, and DragonOS QEMU boot plus DHCP lifecycle smoke tests.

Signed-off-by: longjin <longjin@dragonos.org>
This commit is contained in:
LoGin
2026-09-01 15:05:59 +08:00
committed by GitHub
parent c00c0cc51c
commit 564b3cb412
6 changed files with 1624 additions and 0 deletions
@@ -0,0 +1,44 @@
dragon-network interface configuration
======================================
This directory is intentionally empty by default. Files named IFACE.conf are
only read after an administrator explicitly runs:
/usr/sbin/dragon-network start IFACE
PR-07 does not start this service from rcS. Until the default ownership switch
is made in PR-08, do not enable DHCP on the physical eth* interface already
managed by the kernel DHCP worker. Use an isolated interface such as veth1.
The format is data, not shell syntax. Each non-comment line is a key followed
by one value. Unknown keys, duplicate scalar keys, unsafe ownership/modes, and
invalid values are rejected. dns and search are repeatable list keys.
DHCP:
mode dhcp
Static IPv4:
mode static
address 192.0.2.10/24
gateway 192.0.2.1
dns 192.0.2.53
dns 192.0.2.54
search example.test
External ownership (unmanaged is an alias):
mode external
Commands:
dragon-network start IFACE
dragon-network stop IFACE
dragon-network renew IFACE
dragon-network status IFACE
The service uses only /bin/busybox ip and stores private per-interface state in
/run/dragon-network/interfaces. Its atomically generated, multi-interface DNS
view is /run/dragon-network/resolv.conf. PR-07 does not replace or rewrite
/etc/resolv.conf.
+13
View File
@@ -11,6 +11,19 @@ if [ ! -d /tmp ]; then
fi
mount -t tmpfs tmpfs /tmp || /bin/busybox mount -t tmpfs tmpfs /tmp
# Runtime state must not survive reboot. Network services are not started here;
# PR-08 owns the later switch from kernel DHCP to userspace policy.
if [ ! -d /run ]; then
mkdir -p /run
fi
if ! /bin/busybox mountpoint -q /run; then
if mount -t tmpfs tmpfs /run || /bin/busybox mount -t tmpfs tmpfs /run; then
/bin/busybox chmod 0755 /run
else
echo "[rcS] Warning: failed to mount /run tmpfs"
fi
fi
# 如果 rootfs 中带有 apt,则按 Ubuntu 24.04 的 deb822 sources 格式切换到 USTC HTTP 源。
if command -v apt >/dev/null 2>&1 || command -v apt-get >/dev/null 2>&1; then
echo "[rcS] apt detected, switching Ubuntu 24.04 apt sources to USTC..."
+1
View File
@@ -0,0 +1 @@
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
#!/bin/busybox sh
set -f
. /usr/lib/dragon-network/common.sh
dn_first_router() {
local value first
first=
for value in ${router:-}; do
dn_valid_host_ipv4 "$value" || return 1
[ -n "$first" ] || first=$value
done
DN_DHCP_ROUTER=$first
}
dn_dhcp_prefix() {
if [ -z "${subnet:-}" ]; then
[ -z "${mask:-}" ] || return 1
DN_PREFIX=32
return 0
fi
dn_prefix_from_netmask "$subnet" || return 1
if [ -n "${mask:-}" ]; then
dn_valid_prefix "$mask" && [ "$mask" -eq "$DN_PREFIX" ] || return 1
fi
return 0
}
dn_validate_dhcp_event() {
local value search_values
dn_valid_host_ipv4 "${ip:-}" || return 1
dn_dhcp_prefix || return 1
dn_first_router || return 1
dn_valid_ipv4_list "${dns:-}" || return 1
if [ -n "${search:-}" ]; then
search_values=$search
else
search_values=${domain:-}
fi
dn_valid_domain_list "$search_values" || return 1
if [ -n "${lease:-}" ]; then
dn_valid_uint "$lease" || return 1
fi
DN_DHCP_ADDRESS="$ip/$DN_PREFIX"
DN_DHCP_GATEWAY_ROUTE=
if [ "$DN_PREFIX" -eq 32 ] && [ -n "$DN_DHCP_ROUTER" ]; then
DN_DHCP_GATEWAY_ROUTE="$DN_DHCP_ROUTER/32"
fi
DN_DHCP_DNS=${dns:-}
DN_DHCP_SEARCH=$search_values
return 0
}
[ "$#" -eq 1 ] || {
dn_log udhcpc "missing DHCP event"
exit 2
}
event=$1
iface=${interface:-}
dn_valid_ifname "$iface" || {
dn_log udhcpc "invalid interface name"
exit 1
}
dn_init_runtime && dn_init_interface_runtime "$iface" || exit 1
case "$event" in
deconfig)
dn_deconfigure "$iface"
;;
bound | renew)
dn_load_config "$iface" && [ "$DN_CONFIG_MODE" = dhcp ] || {
dn_write_error "$iface" "DHCP event rejected because mode is not dhcp"
exit 1
}
dn_validate_dhcp_event || {
dn_write_error "$iface" "invalid DHCP lease data for $event"
exit 1
}
dn_apply "$iface" dhcp "$DN_DHCP_ADDRESS" "$DN_DHCP_ROUTER" \
"$DN_DHCP_GATEWAY_ROUTE" "$DN_DHCP_DNS" "$DN_DHCP_SEARCH"
;;
nak | leasefail)
# BusyBox drives the following state transition and deconfig event.
exit 0
;;
*)
dn_write_error "$iface" "unsupported DHCP event: $event"
exit 1
;;
esac
+287
View File
@@ -0,0 +1,287 @@
#!/bin/busybox sh
set -f
. /usr/lib/dragon-network/common.sh
dn_usage() {
echo "usage: dragon-network {start|stop|renew|status} IFACE" >&2
exit 2
}
dn_pid_value() {
local pid
[ -f "$DN_PID_FILE" ] && [ ! -L "$DN_PID_FILE" ] || return 1
IFS= read -r pid < "$DN_PID_FILE" || return 1
dn_valid_uint "$pid" && [ "$pid" -gt 1 ] || return 1
DN_CLIENT_PID=$pid
return 0
}
dn_pid_matches_client() {
local iface pid cmdline old_ifs arg previous saw_udhcpc iface_count pid_count script_count saw_foreground saw_release
iface=$1
dn_pid_value || return 1
pid=$DN_CLIENT_PID
[ -r "/proc/$pid/cmdline" ] || return 1
cmdline=$($DN_BUSYBOX tr '\000' '\n' < "/proc/$pid/cmdline" 2>/dev/null) || return 1
old_ifs=$IFS
IFS='
'
set -- $cmdline
IFS=$old_ifs
saw_udhcpc=0
iface_count=0
pid_count=0
script_count=0
saw_foreground=0
saw_release=0
previous=
for arg in "$@"; do
[ "$arg" = udhcpc ] && saw_udhcpc=1
if [ "$previous" = -i ]; then
[ "$arg" = "$iface" ] || return 1
iface_count=$((iface_count + 1))
fi
if [ "$previous" = -p ]; then
[ "$arg" = "$DN_PID_FILE" ] || return 1
pid_count=$((pid_count + 1))
fi
if [ "$previous" = -s ]; then
[ "$arg" = "$DN_UDHCPC_SCRIPT" ] || return 1
script_count=$((script_count + 1))
fi
[ "$arg" = -f ] && saw_foreground=1
[ "$arg" = -R ] && saw_release=1
previous=$arg
done
[ "$saw_udhcpc" -eq 1 ] && [ "$iface_count" -eq 1 ] && [ "$pid_count" -eq 1 ] &&
[ "$script_count" -eq 1 ] && [ "$saw_foreground" -eq 1 ] &&
[ "$saw_release" -eq 1 ] && $DN_BUSYBOX kill -0 "$pid" 2>/dev/null
}
dn_remove_stale_pid() {
[ ! -e "$DN_PID_FILE" ] || $DN_BUSYBOX rm -f "$DN_PID_FILE"
}
dn_process_alive() {
local pid stat rest state
pid=$1
$DN_BUSYBOX kill -0 "$pid" 2>/dev/null || return 1
[ -r "/proc/$pid/stat" ] || return 0
IFS= read -r stat < "/proc/$pid/stat" || return 0
# Field 2 is parenthesized and may contain spaces. Stripping the longest
# `*) ` prefix leaves the Linux-compatible state character as field 3.
rest=${stat##*) }
[ "$rest" != "$stat" ] || return 0
state=${rest%% *}
[ "$state" != Z ] && [ "$state" != X ]
}
dn_wait_for_exit() {
local pid limit attempt
pid=$1
limit=$2
attempt=0
while [ "$attempt" -lt "$limit" ]; do
dn_process_alive "$pid" || return 0
$DN_BUSYBOX sleep 1
attempt=$((attempt + 1))
done
! dn_process_alive "$pid"
}
dn_finish_failed_start() {
local iface child_pid message
iface=$1
child_pid=$2
message=$3
wait "$child_pid" 2>/dev/null || :
dn_remove_stale_pid || {
dn_write_error "$iface" "failed to remove invalid udhcpc pidfile"
return 1
}
if dn_deconfigure "$iface"; then
dn_write_error "$iface" "$message"
else
dn_write_error "$iface" "failed to clean network after invalid udhcpc startup"
fi
return 1
}
dn_stop_client_locked() {
local iface pid
iface=$1
if ! dn_pid_matches_client "$iface"; then
dn_remove_stale_pid || return 1
return 0
fi
pid=$DN_CLIENT_PID
if ! $DN_BUSYBOX kill -TERM "$pid" 2>/dev/null; then
# The validated client may exit between the identity check and kill(2).
# Treat that as an idempotent stop, but never hide a real signalling
# failure while the same client is still alive.
if ! dn_pid_matches_client "$iface"; then
dn_remove_stale_pid || return 1
return 0
fi
return 1
fi
if ! dn_wait_for_exit "$pid" 5; then
dn_write_error "$iface" "udhcpc did not stop within 5 seconds"
return 1
fi
dn_remove_stale_pid || return 1
return 0
}
dn_start_dhcp_locked() {
local iface attempt child_pid pid_from_file
iface=$1
if dn_pid_matches_client "$iface"; then
return 0
fi
dn_remove_stale_pid || return 1
dn_deconfigure "$iface" || return 1
$DN_BUSYBOX ip link set dev "$iface" up || return 1
(
exec 9>&-
exec $DN_BUSYBOX env -i $DN_BUSYBOX udhcpc -f -R -i "$iface" \
-s "$DN_UDHCPC_SCRIPT" -p "$DN_PID_FILE"
) </dev/null >/dev/null 2>&1 &
child_pid=$!
attempt=0
while [ "$attempt" -lt 3 ]; do
if ! dn_process_alive "$child_pid"; then
dn_finish_failed_start "$iface" "$child_pid" "udhcpc exited during startup"
return 1
fi
if dn_pid_value; then
pid_from_file=$DN_CLIENT_PID
if [ "$pid_from_file" = "$child_pid" ] && dn_pid_matches_client "$iface"; then
dn_clear_error
return 0
fi
fi
$DN_BUSYBOX sleep 1
attempt=$((attempt + 1))
done
$DN_BUSYBOX kill -TERM "$child_pid" 2>/dev/null || :
if ! dn_wait_for_exit "$child_pid" 5; then
# No valid pidfile exists on this path, so leaving the known child
# alive would let a later start create a competing DHCP client.
$DN_BUSYBOX kill -KILL "$child_pid" 2>/dev/null || :
if ! dn_wait_for_exit "$child_pid" 5; then
dn_write_error "$iface" "invalid udhcpc startup could not be terminated"
return 1
fi
fi
dn_finish_failed_start "$iface" "$child_pid" "udhcpc did not publish a valid pidfile"
return 1
}
dn_start_static_locked() {
local iface prefix gateway_route
iface=$1
dn_stop_client_locked "$iface" || return 1
$DN_BUSYBOX ip link set dev "$iface" up || return 1
prefix=${DN_CONFIG_ADDRESS#*/}
gateway_route=
if [ "$prefix" = 32 ] && [ -n "$DN_CONFIG_GATEWAY" ]; then
gateway_route="$DN_CONFIG_GATEWAY/32"
fi
dn_apply "$iface" static "$DN_CONFIG_ADDRESS" "$DN_CONFIG_GATEWAY" "$gateway_route" \
"$DN_CONFIG_DNS" "$DN_CONFIG_SEARCH"
}
dn_start_unmanaged_locked() {
local iface
iface=$1
dn_stop_client_locked "$iface" || return 1
dn_deconfigure "$iface"
}
dn_status_locked() {
local iface running result
iface=$1
running=0
result=0
if dn_pid_matches_client "$iface"; then
echo "$iface: dhcp client running (pid $DN_CLIENT_PID)"
running=1
else
dn_remove_stale_pid || return 1
fi
dn_acquire_state_lock || return 1
if ! dn_load_pending; then
echo "$iface: invalid pending transaction state" >&2
result=1
elif [ "$DN_PENDING_PRESENT" -eq 1 ]; then
echo "$iface: dragon-network transaction recovery pending" >&2
result=1
elif ! dn_load_lease "$DN_LEASE_FILE"; then
echo "$iface: invalid committed lease state" >&2
result=1
elif [ "$DN_LEASE_PRESENT" -eq 1 ]; then
echo "$iface: $DN_LEASE_OWNER configuration committed"
elif [ "$running" -eq 1 ]; then
echo "$iface: awaiting DHCP lease"
else
echo "$iface: unmanaged"
fi
[ ! -f "$DN_ERROR_FILE" ] || {
printf '%s: last error: ' "$iface"
$DN_BUSYBOX cat "$DN_ERROR_FILE"
}
exec 7>&-
return "$result"
}
[ "$#" -eq 2 ] || dn_usage
command=$1
iface=$2
dn_valid_ifname "$iface" || {
dn_log "$iface" "invalid interface name"
exit 2
}
dn_init_runtime && dn_init_interface_runtime "$iface" || exit 1
control_lock="$DN_IFACE_RUN/control.lock"
[ ! -L "$control_lock" ] || exit 1
: > "$control_lock" || exit 1
$DN_BUSYBOX chmod 0600 "$control_lock" || exit 1
exec 9>"$control_lock" || exit 1
$DN_BUSYBOX flock -x 9 || exit 1
case "$command" in
start)
dn_load_config "$iface" || {
dn_write_error "$iface" "invalid interface configuration"
exit 1
}
case "$DN_CONFIG_MODE" in
dhcp) dn_start_dhcp_locked "$iface" ;;
static) dn_start_static_locked "$iface" ;;
unmanaged) dn_start_unmanaged_locked "$iface" ;;
esac
;;
stop)
dn_stop_client_locked "$iface" && dn_deconfigure "$iface"
;;
renew)
if dn_pid_matches_client "$iface"; then
$DN_BUSYBOX kill -USR1 "$DN_CLIENT_PID"
else
dn_remove_stale_pid
dn_write_error "$iface" "no running DHCP client to renew"
false
fi
;;
status)
dn_status_locked "$iface"
;;
*) dn_usage ;;
esac