linkd

Control plane daemon for unos
git clone git://git.finwo.net/app/linkd
Log | Files | Refs | README

commit 9c4939a4676a5db6199e581456a871b0a57fb31a
parent 83cbb5291378ba0d19c0f4651b75941d790e8b1b
Author: finwo <finwo@pm.me>
Date:   Thu, 24 Sep 2026 21:57:42 +0200

Allow running tests without namespaces

Diffstat:
M.conductor.yml | 3---
Msrc/cli/client.c | 65+++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mtests/unit/test_auth.sh | 38++++++++++++++++++++++++++++----------
Mtests/unit/test_plugin.sh | 10+++++-----
Mtests/unit/test_resp.sh | 59++++++++++++++++++++++++++++++++++++++++++++++++-----------
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"