]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
varlinkctl: add new `serve` verb to allow wrapping command in varlink
authorMichael Vogt <michael@amutable.com>
Thu, 2 Apr 2026 07:38:41 +0000 (09:38 +0200)
committerDaan De Meyer <daan@amutable.com>
Thu, 9 Apr 2026 11:02:09 +0000 (13:02 +0200)
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.

man/varlinkctl.xml
src/varlinkctl/varlinkctl.c
test/units/TEST-74-AUX-UTILS.varlinkctl.sh

index 6aff5a05e1349bf549c00c24ec7b091463b5f59a..adf26b8fe6150e822a53b95b6829a9ae6c042fc5 100644 (file)
       <arg choice="plain"><replaceable>CMDLINE</replaceable></arg>
     </cmdsynopsis>
 
+    <cmdsynopsis>
+      <command>varlinkctl</command>
+      <arg choice="opt" rep="repeat">OPTIONS</arg>
+      <arg choice="plain">serve</arg>
+      <arg choice="plain"><replaceable>METHOD</replaceable></arg>
+      <arg choice="req" rep="repeat"><replaceable>CMDLINE</replaceable></arg>
+    </cmdsynopsis>
+
     <cmdsynopsis>
       <command>varlinkctl</command>
       <arg choice="opt" rep="repeat">OPTIONS</arg>
         <xi:include href="version-info.xml" xpointer="v255"/></listitem>
       </varlistentry>
 
+      <varlistentry>
+        <term><command>serve</command> <replaceable>METHOD</replaceable> <replaceable>CMDLINE…</replaceable></term>
+
+        <listitem><para>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 <command>call</command> <option>--upgrade</option>.</para>
+
+        <para>The listening socket must be passed via socket activation (i.e. the
+        <varname>$LISTEN_FDS</varname> 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.</para>
+
+        <para>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 <varname>ProtectSystem=</varname>, etc.) and does not operate in the
+        caller's environment.</para>
+
+        <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+      </varlistentry>
+
       <varlistentry>
         <term><command>list-registry</command></term>
 
@@ -533,6 +563,46 @@ method Extend(
       <programlisting># varlinkctl call ssh-exec:somehost:systemd-creds org.varlink.service.GetInfo '{}'</programlisting>
     </example>
 
+    <example>
+      <title>Serving a Sandboxed Decompressor via Protocol Upgrade</title>
+
+      <para>The following socket and service units expose <command>xz</command> decompression as a Varlink
+      service. Clients connect and send compressed data over the upgraded connection, receiving decompressed
+      output in return.</para>
+
+      <programlisting># /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</programlisting>
+
+      <para>A client can then decompress data through this service:</para>
+
+      <programlisting>$ echo "hello" | xz | varlinkctl call --upgrade \
+        unix:/run/varlink/registry/com.example.Decompress.XZ \
+        com.example.Decompress.XZ '{}'
+hello</programlisting>
+
+      <para>For quick testing without unit files, <command>systemd-socket-activate</command> can be used
+      to provide the listening socket:</para>
+
+      <programlisting>$ systemd-socket-activate -l /tmp/decompress.sock -- varlinkctl serve com.example.Decompress.XZ xz -d &amp;
+$ echo "hello" | xz | varlinkctl call --upgrade unix:/tmp/decompress.sock com.example.Decompress.XZ '{}'
+hello</programlisting>
+    </example>
+
   </refsect1>
 
   <refsect1>
index 00bd71f34dedc561f1043a7f60bb8c88793e7e72..1876b90bde00f614371122cd319d609eedf99e3a 100644 (file)
@@ -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;
 
index b6d270cfd4703527c3418afa0b6bfa34b3d4e9dd..782a6dc6e973cbd8de2a282ebda35f006b43826d 100755 (executable)
@@ -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"