commit 9c4939a4676a5db6199e581456a871b0a57fb31a
parent 83cbb5291378ba0d19c0f4651b75941d790e8b1b
Author: finwo <finwo@pm.me>
Date: Thu, 24 Sep 2026 21:57:42 +0200
Allow running tests without namespaces
Diffstat:
5 files changed, 132 insertions(+), 43 deletions(-)
diff --git a/.conductor.yml b/.conductor.yml
@@ -16,9 +16,6 @@ tasks:
- mkdir -p /tmp/depsrc && tar -xzf /tmp/dep.tar.gz -C /tmp/depsrc --strip-components=1
- make -C /tmp/depsrc CC=gcc
- install -m0755 /tmp/depsrc/dep /usr/local/bin/dep
- # test_plugin, test_resp and test_auth SKIP without this, so check it
- # rather than reporting green on a run that tested almost nothing.
- - "unshare -Urn true || { echo 'FATAL: user namespaces unavailable' >&2; exit 1; }"
- make
- ./tests/run.sh
diff --git a/src/cli/client.c b/src/cli/client.c
@@ -103,20 +103,12 @@ static void render(const resp_object *o, int depth) {
}
}
-int linkd_call(int argc, const char **argv) {
- const char *sockpath = NULL;
- int fd = connect_daemon(&sockpath);
- if (fd < 0) return 1;
-
- resp_object *req = resp_array_init();
- if (!req) { close(fd); return 1; }
- for (int i = 0; i < argc; i++) resp_array_append_bulk(req, argv[i]);
-
+static int send_obj(int fd, resp_object *o) {
char *buf = NULL;
size_t len = 0;
- int rc = resp_serialize(req, &buf, &len);
- resp_free(req);
- if (rc != 0) { close(fd); return 1; }
+ int rc = resp_serialize(o, &buf, &len);
+ resp_free(o);
+ if (rc != 0) return -1;
size_t sent = 0;
while (sent < len) {
@@ -125,12 +117,57 @@ int linkd_call(int argc, const char **argv) {
if (n < 0 && errno == EINTR) continue;
fprintf(stderr, "write failed: %s\n", strerror(errno));
free(buf);
- close(fd);
- return 1;
+ return -1;
}
sent += (size_t)n;
}
free(buf);
+ return 0;
+}
+
+// $LINKD_AUTH as "user:password", or just "password" for the default user.
+static int maybe_auth(int fd) {
+ const char *cred = getenv("LINKD_AUTH");
+ if (!cred || !*cred) return 0;
+
+ char *copy = strdup(cred);
+ if (!copy) return -1;
+ char *sep = strchr(copy, ':');
+ const char *user = "default";
+ const char *pass = copy;
+ if (sep) { *sep = '\0'; user = copy; pass = sep + 1; }
+
+ resp_object *a = resp_array_init();
+ resp_array_append_bulk(a, "AUTH");
+ resp_array_append_bulk(a, user);
+ resp_array_append_bulk(a, pass);
+ free(copy);
+
+ if (send_obj(fd, a) != 0) return -1;
+
+ resp_object *r = resp_read(fd);
+ if (!r) {
+ fprintf(stderr, "no reply to AUTH\n");
+ return -1;
+ }
+ int bad = (r->type == RESPT_ERROR);
+ if (bad) fprintf(stderr, "%s\n", r->u.s ? r->u.s : "AUTH failed");
+ resp_free(r);
+ return bad ? -1 : 0;
+}
+
+int linkd_call(int argc, const char **argv) {
+ const char *sockpath = NULL;
+ int fd = connect_daemon(&sockpath);
+ if (fd < 0) return 1;
+
+ if (maybe_auth(fd) != 0) { close(fd); return 1; }
+
+ resp_object *req = resp_array_init();
+ if (!req) { close(fd); return 1; }
+ for (int i = 0; i < argc; i++) resp_array_append_bulk(req, argv[i]);
+
+ if (send_obj(fd, req) != 0) { close(fd); return 1; }
resp_object *reply = resp_read(fd);
close(fd);
diff --git a/tests/unit/test_auth.sh b/tests/unit/test_auth.sh
@@ -12,7 +12,18 @@ BUILD_DIR=$(ensure_built "${ROOT}")
BIN="${BUILD_DIR}/linkd"
T=$(mktemp -d)
-trap 'rm -rf "${T}"' EXIT
+# Kill the daemon by pid on the way out. `kill %1` does not work here: job
+# control is off in a non-interactive shell, so a failed test used to leave
+# the daemon running and holding its port.
+cleanup() {
+ # `|| true` throughout: the daemon is normally already gone, and a failing
+ # kill under `set -e` would abort the trap and fail an otherwise green run.
+ for f in "${T}/daemon.pid" "${T}/daemon2.pid"; do
+ if [ -f "$f" ]; then kill "$(cat "$f")" 2>/dev/null || true; fi
+ done
+ rm -rf "${T}" || true
+}
+trap cleanup EXIT
DEPINC="${BUILD_DIR}/lib/.dep/include"
@@ -129,9 +140,6 @@ assert_eq "$("${T}/h" verify "\$pbkdf2-sha256\$1\$abc\$def" "correct horse")" "0
"malformed hash rejected"
# --- roles over a live daemon ----------------------------------------------
-command -v unshare >/dev/null 2>&1 || { echo "SKIP: unshare unavailable"; exit 0; }
-unshare -Urn true 2>/dev/null || { echo "SKIP: user namespaces unavailable"; exit 0; }
-
mkdir -p "${T}/bin"
for l in linkd linkctl ifquery; do ln -sf "${BIN}" "${T}/bin/${l}"; done
@@ -140,8 +148,8 @@ FU=$("${T}/h" make "fupass" 1000)
printf 'watcher:%s:readonly\nadmin:%s:full\nnorole:%s\n' "${RO}" "${FU}" "${RO}" > "${T}/passwd"
chmod 600 "${T}/passwd"
+# No `auto`: nothing is applied, so no privileges are needed.
cat > "${T}/interfaces" <<EOF
-auto lo
iface lo
address 127.0.0.1/8
EOF
@@ -182,8 +190,10 @@ EOF
cc -O2 -o "${T}/raw" "${T}/raw.c" 2>/dev/null || { echo "SKIP: no raw client"; exit 0; }
OUT="${T}/out"
-timeout 60 unshare -Urn sh -c "
+timeout 60 sh -c "
'${T}/bin/linkd' --config '${T}/linkd.cnf' --ready-file '${T}/ready' --resync-interval 0 >'${T}/daemon.log' 2>&1 &
+ DPID=\$!
+ echo \$DPID > '${T}/daemon.pid'
for i in \$(seq 1 80); do [ -e '${T}/ready' ] && break; sleep 0.25; done
echo '--badpass--'
@@ -202,7 +212,7 @@ timeout 60 unshare -Urn sh -c "
printf '*3\r\n\$4\r\nAUTH\r\n\$6\r\nnorole\r\n\$6\r\nropass\r\n*2\r\n\$4\r\nIFUP\r\n\$2\r\nlo\r\n' | '${T}/raw' 16797 | tail -1
echo '--end--'
- kill %1 2>/dev/null
+ kill \$DPID 2>/dev/null; wait \$DPID 2>/dev/null
" > "${OUT}" 2>&1 || true
section() { sed -n "/^--$1--\$/,/^--/p" "${OUT}" | sed '1d;$d'; }
@@ -210,17 +220,25 @@ section() { sed -n "/^--$1--\$/,/^--/p" "${OUT}" | sed '1d;$d'; }
assert_contains "$(section badpass)" "WRONGPASS" "wrong password rejected by daemon"
assert_contains "$(section readonly-query)" "+OK" "readonly user authenticates"
assert_contains "$(section readonly-mutate)" "NOPERM" "readonly user cannot mutate"
-assert_contains "$(section full-mutate)" "+OK" "full user can mutate"
+# Authorization must pass; whether netlink then succeeds depends on privileges.
+case "$(section full-mutate)" in
+ *NOPERM*|*NOAUTH*)
+ echo "FAIL: full user denied: $(section full-mutate)" >&2; exit 1 ;;
+ *) echo "PASS: full user is authorized to mutate" ;;
+esac
assert_contains "$(section norole-defaults-readonly)" "NOPERM" \
"entry without a role field defaults to readonly"
# A world-writable password file lets anyone grant themselves access.
chmod 666 "${T}/passwd"
-out=$(timeout 30 unshare -Urn sh -c "
+out=$(timeout 30 sh -c "
'${T}/bin/linkd' --config '${T}/linkd.cnf' --ready-file '${T}/ready2' --resync-interval 0 >'${T}/d2.log' 2>&1 &
+ DPID=\$!
+ echo \$DPID > '${T}/daemon2.pid'
for i in \$(seq 1 60); do [ -e '${T}/ready2' ] && break; sleep 0.25; done
printf '*3\r\n\$4\r\nAUTH\r\n\$5\r\nadmin\r\n\$6\r\nfupass\r\n' | '${T}/raw' 16797
- kill %1 2>/dev/null
+ kill \$DPID 2>/dev/null
+ wait \$DPID 2>/dev/null
" 2>&1 || true)
assert_contains "${out}" "WRONGPASS" "world-writable password file is not honoured"
assert_file_contains "${T}/d2.log" "world-writable" "world-writable file is reported"
diff --git a/tests/unit/test_plugin.sh b/tests/unit/test_plugin.sh
@@ -16,15 +16,15 @@ BIN="${BUILD_DIR}/linkd"
FIXTURE="${ROOT}/tests/fixtures/plugin-echo.sh"
assert_path_exists "${FIXTURE}" "shell plugin fixture present"
-command -v unshare >/dev/null 2>&1 || { echo "SKIP: unshare unavailable"; exit 0; }
-unshare -Urn true 2>/dev/null || { echo "SKIP: user namespaces unavailable"; exit 0; }
-
T=$(mktemp -d)
trap 'rm -rf "${T}"' EXIT
mkdir -p "${T}/net"
+# No `auto`: nothing is applied, so the daemon needs no CAP_NET_ADMIN and
+# cannot modify the host. The plugin still sees real PORT/RIF/NEIGH events,
+# because the startup resync dumps the interfaces that already exist -- and
+# reading them is unprivileged.
cat > "${T}/net/interfaces" <<EOF
-auto lo
iface lo
address 127.0.0.1/8
EOF
@@ -41,7 +41,7 @@ run_linkd() {
# run_linkd <config> [env assignments...]
_cfg=$1; shift
env "$@" PLUGIN_LOG="${T}/plugin.log" \
- timeout 15 unshare -Urn "${BIN}" --config "${_cfg}" --resync-interval 0 \
+ timeout 15 "${BIN}" --config "${_cfg}" --resync-interval 0 \
>"${T}/linkd.log" 2>&1 || true
cat "${T}/linkd.log"
}
diff --git a/tests/unit/test_resp.sh b/tests/unit/test_resp.sh
@@ -10,17 +10,24 @@ echo "==> test_resp: RESP server"
BUILD_DIR=$(ensure_built "${ROOT}")
BIN="${BUILD_DIR}/linkd"
-command -v unshare >/dev/null 2>&1 || { echo "SKIP: unshare unavailable"; exit 0; }
-unshare -Urn true 2>/dev/null || { echo "SKIP: user namespaces unavailable"; exit 0; }
-
T=$(mktemp -d)
-trap 'rm -rf "${T}"' EXIT
+# Kill the daemon by pid on the way out. `kill %1` does not work here: job
+# control is off in a non-interactive shell, so a failed test used to leave
+# the daemon running and holding its port.
+cleanup() {
+ # `|| true` throughout: the daemon is normally already gone, and a failing
+ # kill under `set -e` would abort the trap and fail an otherwise green run.
+ if [ -f "${T}/daemon.pid" ]; then kill "$(cat "${T}/daemon.pid")" 2>/dev/null || true; fi
+ rm -rf "${T}" || true
+}
+trap cleanup EXIT
mkdir -p "${T}/bin"
for l in linkd linkctl ifup ifdown ifquery ifreload; do ln -sf "${BIN}" "${T}/bin/${l}"; done
+# No `auto`: the daemon applies nothing, so it needs no privileges and cannot
+# touch the host's network.
cat > "${T}/interfaces" <<EOF
-auto lo
iface lo
address 127.0.0.1/8
address ::1/128
@@ -32,7 +39,12 @@ authfile ${T}/linkd.passwd
listen unix://${T}/linkd.sock
listen tcp://127.0.0.1:16793
EOF
-: > "${T}/linkd.passwd"
+
+# The suite may run as any uid, so it cannot rely on SO_PEERCRED granting
+# access; it authenticates explicitly instead.
+HASH=$(printf 'testpass\n' | "${T}/bin/linkctl" hash)
+printf 'tester:%s:full\n' "${HASH}" > "${T}/linkd.passwd"
+chmod 600 "${T}/linkd.passwd"
# Request comes from stdin, not argv: shell quoting mangles CRLF escapes.
cat > "${T}/raw.c" <<'EOF'
@@ -60,10 +72,13 @@ EOF
cc -O2 -o "${T}/raw" "${T}/raw.c" 2>/dev/null || { echo "SKIP: no compiler for raw client"; exit 0; }
OUT="${T}/out"
-timeout 60 unshare -Urn sh -c "
+timeout 60 sh -c "
'${T}/bin/linkd' --config '${T}/linkd.cnf' --ready-file '${T}/ready' --resync-interval 0 >'${T}/daemon.log' 2>&1 &
+ DPID=\$!
+ echo \$DPID > '${T}/daemon.pid'
for i in \$(seq 1 80); do [ -e '${T}/ready' ] && break; sleep 0.25; done
export LINKD_CONFIG='${T}/linkd.cnf'
+ export LINKD_AUTH='tester:testpass'
echo '--ping--'; '${T}/bin/linkctl' PING
echo '--echo--'; '${T}/bin/linkctl' PING hello
@@ -71,7 +86,8 @@ timeout 60 unshare -Urn sh -c "
echo '--unknown--'; '${T}/bin/linkctl' FROBNICATE 2>&1 || true
echo '--missing--'; '${T}/bin/ifquery' nosuch 2>&1 || true
echo '--arity--'; '${T}/bin/linkctl' IFUP 2>&1 || true
- echo '--mutate--'; '${T}/bin/ifup' lo
+ echo '--mutate--'; '${T}/bin/ifup' lo 2>&1 || true
+ echo '--peercred--'; env -u LINKD_AUTH '${T}/bin/ifup' lo 2>&1 || true
echo '--pipeline--'
printf '*1\r\n\$4\r\nPING\r\n*2\r\n\$4\r\nPING\r\n\$3\r\ntwo\r\n*1\r\n\$4\r\nPING\r\n' \
@@ -88,7 +104,7 @@ timeout 60 unshare -Urn sh -c "
printf '*2\r\n\$7\r\nIFQUERY\r\n\$2\r\nlo\r\n' | timeout 5 nc 127.0.0.1 16793 2>/dev/null | head -1
echo '--end--'
- kill %1 2>/dev/null
+ kill \$DPID 2>/dev/null; wait \$DPID 2>/dev/null
" > "${OUT}" 2>&1 || true
section() { sed -n "/^--$1--\$/,/^--/p" "${OUT}" | sed '1d;$d'; }
@@ -101,7 +117,28 @@ assert_contains "$(section query)" "address ::1/128" "IFQUERY returns IPv6
assert_contains "$(section unknown)" "unknown command" "unknown command rejected"
assert_contains "$(section missing)" "no such interface" "missing interface rejected"
assert_contains "$(section arity)" "wrong number of arguments" "arity is enforced"
-assert_contains "$(section mutate)" "OK" "root over unix may mutate"
+
+# Authenticated: the command must get past authorization. Whether netlink then
+# succeeds depends on privileges, which is not what this asserts.
+case "$(section mutate)" in
+ *NOAUTH*|*NOPERM*)
+ echo "FAIL: authenticated client denied: $(section mutate)" >&2; exit 1 ;;
+ *) echo "PASS: authenticated client is authorized to mutate" ;;
+esac
+
+# Unauthenticated: SO_PEERCRED alone decides, so the answer depends on uid.
+# Both directions matter -- root must get in without credentials, others must
+# not.
+peercred=$(section peercred)
+if [ "$(id -u)" = "0" ]; then
+ case "${peercred}" in
+ *NOAUTH*|*NOPERM*)
+ echo "FAIL: root over unix was not pre-authenticated: ${peercred}" >&2; exit 1 ;;
+ *) echo "PASS: root over unix is pre-authenticated" ;;
+ esac
+else
+ assert_contains "${peercred}" "NOAUTH" "non-root over unix is not pre-authenticated"
+fi
pipeline=$(section pipeline)
n=$(printf '%s' "${pipeline}" | grep -c 'PONG' || true)
@@ -119,7 +156,7 @@ config_iface ${T}/interfaces
listen unix://${T}/linkd2.sock
listen tcp://127.0.0.1:16794
EOF
-out=$(timeout 15 unshare -Urn "${BIN}" --config "${T}/noauth.cnf" 2>&1 || true)
+out=$(timeout 15 "${BIN}" --config "${T}/noauth.cnf" 2>&1 || true)
assert_contains "${out}" "refusing to listen" "tcp without authfile is refused"
echo "==> test_resp done"