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.
This commit is contained in:
Michael Vogt
2026-04-09 13:02:09 +02:00
committed by Daan De Meyer
parent 2c6f9af8e5
commit 34f29079fd
3 changed files with 261 additions and 9 deletions
+70
View File
@@ -73,6 +73,14 @@
<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>
@@ -181,6 +189,28 @@
<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>
+152
View 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;
+39 -9
View File
@@ -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"