From: Michael Vogt Date: Thu, 2 Apr 2026 07:38:41 +0000 (+0200) Subject: varlinkctl: add new `serve` verb to allow wrapping command in varlink X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=34f29079fdba7eb3820e6e79e370671fd293bd87;p=thirdparty%2Fsystemd.git varlinkctl: add new `serve` verb to allow wrapping command in varlink With the new protocol upgrade support in varlinkctl client we can now do the equivalent for the server side. This commit adds a new `serve` verb that will serve any command that speaks stdin/stdout via varlink and its protocol upgrade feature. This is the "inetd for varlink". This is useful for various reasons: 1. Allows to e.g. provide a heavily sandboxed io.myorg.xz.Decompress varlink endpoint, c.f. xz CVE-2024-3094) 2. Allow sftp over varlink which is quite useful with the varlink-http-bridge (that has more flexible auth mechanism than plain sftp). 3. Makes testing the varlinkctl client protocol upgrade simpler. 4. Because we can. --- diff --git a/man/varlinkctl.xml b/man/varlinkctl.xml index 6aff5a05e13..adf26b8fe61 100644 --- a/man/varlinkctl.xml +++ b/man/varlinkctl.xml @@ -73,6 +73,14 @@ CMDLINE + + varlinkctl + OPTIONS + serve + METHOD + CMDLINE + + varlinkctl OPTIONS @@ -181,6 +189,28 @@ + + serve METHOD CMDLINE… + + Run a Varlink server that accepts protocol upgrade requests for the specified method + and connects the upgraded connection to the standard input and output of the specified command. This + can act as a server-side counterpart to call . + + The listening socket must be passed via socket activation (i.e. the + $LISTEN_FDS protocol), making this command suitable for use in socket-activated + service units. When a client calls the specified method with the upgrade flag, the server sends a + reply confirming the upgrade, then forks and executes the given command line with the upgraded + connection on its standard input and output. + + This effectively turns any command that speaks a protocol over standard input/output into a + Varlink service, discoverable via the service registry and authenticated via socket credentials. + Because each connection is handled by a forked child process, the service unit can apply systemd's + sandboxing options (such as ProtectSystem=, etc.) and does not operate in the + caller's environment. + + + + list-registry @@ -533,6 +563,46 @@ method Extend( # varlinkctl call ssh-exec:somehost:systemd-creds org.varlink.service.GetInfo '{}' + + Serving a Sandboxed Decompressor via Protocol Upgrade + + The following socket and service units expose xz decompression as a Varlink + service. Clients connect and send compressed data over the upgraded connection, receiving decompressed + output in return. + + # /etc/systemd/system/varlink-decompress-xz.socket +[Socket] +ListenStream=/run/varlink/registry/com.example.Decompress.XZ + +[Install] +WantedBy=sockets.target + +# /etc/systemd/system/varlink-decompress-xz.service +[Service] +ExecStart=varlinkctl serve com.example.Decompress.XZ xz -d +DynamicUser=yes +PrivateNetwork=yes +ProtectSystem=strict +ProtectHome=yes +NoNewPrivileges=yes +SystemCallFilter=~@privileged @resources +MemoryMax=256M + + A client can then decompress data through this service: + + $ echo "hello" | xz | varlinkctl call --upgrade \ + unix:/run/varlink/registry/com.example.Decompress.XZ \ + com.example.Decompress.XZ '{}' +hello + + For quick testing without unit files, systemd-socket-activate can be used + to provide the listening socket: + + $ systemd-socket-activate -l /tmp/decompress.sock -- varlinkctl serve com.example.Decompress.XZ xz -d & +$ echo "hello" | xz | varlinkctl call --upgrade unix:/tmp/decompress.sock com.example.Decompress.XZ '{}' +hello + + diff --git a/src/varlinkctl/varlinkctl.c b/src/varlinkctl/varlinkctl.c index 00bd71f34de..1876b90bde0 100644 --- a/src/varlinkctl/varlinkctl.c +++ b/src/varlinkctl/varlinkctl.c @@ -1169,6 +1169,158 @@ static int verb_list_registry(int argc, char *argv[], uintptr_t _data, void *use return 0; } +/* Build a minimal IDL from a qualified method name so that introspection works. The parsed interface is + * returned to the caller who must keep it alive for the lifetime of the server + * (sd_varlink_server_add_interface() borrows the pointer). */ +static int varlink_server_add_interface_from_method(sd_varlink_server *s, const char *method, sd_varlink_interface **ret_interface) { + assert(s); + assert(method); + assert(ret_interface); + + const char *dot = strrchr(method, '.'); + assert(dot); + + _cleanup_free_ char *interface_name = strndup(method, dot - method); + if (!interface_name) + return log_oom(); + + /* Note that we do not need to put the upgrade flag comment here, it is added automatically + * by varlink_idl_format_symbol() because of the SD_VARLINK_REQUIRES_UPGRADE flag. */ + _cleanup_free_ char *idl_text = strjoin( + "interface ", interface_name, "\n" + "\n" + "method ", dot + 1, " () -> ()\n"); + if (!idl_text) + return log_oom(); + + _cleanup_(sd_varlink_interface_freep) sd_varlink_interface *iface = NULL; + int r = sd_varlink_idl_parse(idl_text, /* reterr_line= */ NULL, /* reterr_column= */ NULL, &iface); + if (r < 0) + return log_error_errno(r, "Failed to parse IDL for method '%s': %m", method); + + /* Mark the method as requiring the upgrade flag so introspection shows the annotation */ + assert(iface->symbols[0] && iface->symbols[0]->symbol_type == SD_VARLINK_METHOD); + ((sd_varlink_symbol*) iface->symbols[0])->symbol_flags |= SD_VARLINK_REQUIRES_UPGRADE; + + r = sd_varlink_server_add_interface(s, iface); + if (r < 0) + return r; + + *ret_interface = TAKE_PTR(iface); + + return 0; +} + +static int method_serve_upgrade(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + char **exec_cmdline = ASSERT_PTR(userdata); + _cleanup_close_ int input_fd = -EBADF, _output_fd = -EBADF; + int output_fd, r; + + if (!FLAGS_SET(flags, SD_VARLINK_METHOD_UPGRADE)) + return sd_varlink_error(link, SD_VARLINK_ERROR_EXPECTED_UPGRADE, NULL); + + r = sd_varlink_reply_and_upgrade(link, /* parameters= */ NULL, &input_fd, &output_fd); + if (r < 0) + return log_error_errno(r, "Failed to upgrade connection: %m"); + + if (output_fd != input_fd) + _output_fd = output_fd; + + /* Copy exec_cmdline before forking: pidref_safe_fork() calls rename_process() which + * overwrites the argv area that exec_cmdline points into. */ + _cleanup_strv_free_ char **cmdline_copy = strv_copy(exec_cmdline); + if (!cmdline_copy) + return log_oom(); + + r = pidref_safe_fork_full( + "(serve)", + (int[]) { input_fd, output_fd, STDERR_FILENO }, + /* except_fds= */ NULL, /* n_except_fds= */ 0, + FORK_RESET_SIGNALS|FORK_CLOSE_ALL_FDS|FORK_REARRANGE_STDIO|FORK_DETACH|FORK_LOG, + /* ret= */ NULL); + if (r < 0) + return r; + if (r == 0) { + execvp(cmdline_copy[0], cmdline_copy); + log_error_errno(errno, "Failed to execute '%s': %m", cmdline_copy[0]); + _exit(EXIT_FAILURE); + } + + return 0; +} + +VERB(verb_serve, "serve", "METHOD CMDLINE…", 3, VERB_ANY, 0, "Serve a command via varlink protocol upgrade"); +static int verb_serve(int argc, char *argv[], uintptr_t _data, void *userdata) { + _cleanup_(sd_varlink_server_unrefp) sd_varlink_server *s = NULL; + _cleanup_(sd_event_unrefp) sd_event *event = NULL; + const char *method; + char **exec_cmdline; + int r, n; + + assert(argc >= 3); /* Guaranteed by verb dispatch table */ + + method = argv[1]; + exec_cmdline = argv + 2; + + r = varlink_idl_qualified_symbol_name_is_valid(method); + if (r < 0) + return log_error_errno(r, "Failed to validate method name '%s': %m", method); + if (r == 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Not a valid qualified method name: '%s'", method); + + /* Require socket activation */ + n = sd_listen_fds(/* unset_environment= */ true); + if (n < 0) + return log_error_errno(n, "Failed to determine passed file descriptors: %m"); + if (n == 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "No file descriptors passed via socket activation."); + if (n > 1) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Expected exactly one socket activation fd, got %d.", n); + + r = sd_event_default(&event); + if (r < 0) + return log_error_errno(r, "Failed to get event loop: %m"); + + r = sd_varlink_server_new(&s, SD_VARLINK_SERVER_INHERIT_USERDATA); + if (r < 0) + return log_error_errno(r, "Failed to allocate varlink server: %m"); + + _cleanup_free_ char *description = strjoin("serve:", method); + if (!description) + return log_oom(); + + r = sd_varlink_server_set_description(s, description); + if (r < 0) + return log_error_errno(r, "Failed to set server description: %m"); + + r = sd_varlink_server_bind_method(s, method, method_serve_upgrade); + if (r < 0) + return log_error_errno(r, "Failed to bind method '%s': %m", method); + + _cleanup_(sd_varlink_interface_freep) sd_varlink_interface *iface = NULL; + r = varlink_server_add_interface_from_method(s, method, &iface); + if (r < 0) + return log_error_errno(r, "Failed to add interface for method '%s': %m", method); + + sd_varlink_server_set_userdata(s, exec_cmdline); + + r = sd_varlink_server_listen_fd(s, SD_LISTEN_FDS_START); + if (r < 0) + return log_error_errno(r, "Failed to listen on socket activation fd: %m"); + + r = sd_varlink_server_attach_event(s, event, SD_EVENT_PRIORITY_NORMAL); + if (r < 0) + return log_error_errno(r, "Failed to attach varlink server to event loop: %m"); + + (void) sd_notify(/* unset_environment= */ false, "READY=1"); + + r = sd_event_loop(event); + if (r < 0) + return log_error_errno(r, "Failed to run event loop: %m"); + + return 0; +} + static int run(int argc, char *argv[]) { int r; diff --git a/test/units/TEST-74-AUX-UTILS.varlinkctl.sh b/test/units/TEST-74-AUX-UTILS.varlinkctl.sh index b6d270cfd47..782a6dc6e97 100755 --- a/test/units/TEST-74-AUX-UTILS.varlinkctl.sh +++ b/test/units/TEST-74-AUX-UTILS.varlinkctl.sh @@ -257,6 +257,9 @@ systemd-run --wait --pipe --user --machine testuser@ \ varlinkctl --more call "/run/user/$testuser_uid/systemd/io.systemd.Manager" io.systemd.Unit.List '{}' # test --upgrade (protocol upgrade) +# The basic --upgrade proxy test is covered by the "varlinkctl serve" tests below (which use +# serve+rev/gunzip as the server). The tests here exercise features that need the Python +# server: file-input (defer fallback), ssh-exec transport (pipe pairs) and --exec mode. UPGRADE_SOCKET="$(mktemp -d)/upgrade.sock" UPGRADE_SERVER="$(mktemp)" cat >"$UPGRADE_SERVER" <<'PYEOF' @@ -320,15 +323,6 @@ if sock: PYEOF chmod +x "$UPGRADE_SERVER" -# Start the server in the background, wait for readiness via sd_notify -systemd-notify --fork -q -- python3 "$UPGRADE_SERVER" "$UPGRADE_SOCKET" - -# Test proxy mode: pipe data through --upgrade, passing parameters and validate -result="$(echo "hello world" | varlinkctl call --upgrade "unix:$UPGRADE_SOCKET" io.systemd.test.Reverse '{"foo":"bar"}')" -echo "$result" | grep "<<< UPGRADED >>>" >/dev/null -echo "$result" | grep '"foo": "bar"' >/dev/null -echo "$result" | grep "dlrow olleh" >/dev/null - # Test --upgrade with stdin redirected from a regular file (epoll can't poll regular files, # so this exercises the sd_event_add_defer fallback path) UPGRADE_SOCKET2="$(mktemp -d)/upgrade.sock" @@ -370,3 +364,39 @@ rm -f "$EXEC_RESULT" rm -f "$UPGRADE_SOCKET" "$UPGRADE_SOCKET2" "$UPGRADE_SERVER" /tmp/test-upgrade-input rm -rf "$(dirname "$UPGRADE_SOCKET")" "$(dirname "$UPGRADE_SOCKET2")" + +# Test varlinkctl serve: expose a stdio command via varlink protocol upgrade with socket activation. +# This is the "inetd for varlink" pattern: any stdio tool becomes a varlink service. +SERVE_SOCKET="$(mktemp -d)/serve.sock" + +# Test 1: serve rev: proves bidirectional data flow through the upgrade +SERVE_PID=$(systemd-notify --fork -- \ + systemd-socket-activate -l "$SERVE_SOCKET" -- \ + varlinkctl serve io.systemd.test.Reverse rev) + +# Verify introspection works on the serve endpoint and shows the upgrade annotation +varlinkctl introspect "unix:$SERVE_SOCKET" io.systemd.test | grep "method Reverse" >/dev/null +varlinkctl introspect "unix:$SERVE_SOCKET" io.systemd.test | grep "Requires 'upgrade' flag" >/dev/null + +result="$(echo "hello world" | varlinkctl call --upgrade "unix:$SERVE_SOCKET" io.systemd.test.Reverse '{}')" +echo "$result" | grep "dlrow olleh" >/dev/null +kill "$SERVE_PID" 2>/dev/null || true +wait "$SERVE_PID" 2>/dev/null || true +rm -f "$SERVE_SOCKET" + +# Test 2: decompress via serve: the "sandboxed decompressor" use-case (the real thing would be a proper +# unit with real sandboxing). +# Pipe gzip-compressed data through a varlinkctl serve + gunzip endpoint and verify round-trip. +SERVE_PID=$(systemd-notify --fork -- \ + systemd-socket-activate -l "$SERVE_SOCKET" -- \ + varlinkctl serve io.systemd.Compress.Decompress gunzip) + +SERVE_TMPDIR="$(mktemp -d)" +echo "untrusted data decompressed safely via varlink serve" | gzip > "$SERVE_TMPDIR/compressed.gz" +result="$(varlinkctl call --upgrade "unix:$SERVE_SOCKET" io.systemd.Compress.Decompress '{}' < "$SERVE_TMPDIR/compressed.gz")" +echo "$result" | grep "untrusted data decompressed safely" >/dev/null +kill "$SERVE_PID" 2>/dev/null || true +wait "$SERVE_PID" 2>/dev/null || true + +rm -f "$SERVE_SOCKET" +rm -rf "$(dirname "$SERVE_SOCKET")" "$SERVE_TMPDIR"