From 087fa20166f0becc7b41056d48e1efb76e4c20f3 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 23 Apr 2026 08:59:20 +0200 Subject: [PATCH 1/6] shared: add io.systemd.StorageProvider Varlink interface Generic Varlink API for services that hand out file descriptors to storage volumes. Three methods: Acquire() returns an fd for a named volume (optionally creating it from a template), ListVolumes() enumerates available volumes, ListTemplates() enumerates supported creation templates. Volume types follow kernel inode-type naming: blk (block device), reg (regular file), dir (directory). Intent is that multiple providers can sit behind AF_UNIX sockets in a well-known directory and be consumed uniformly by nspawn, vmspawn, the service manager (BindVolume=) and similar tools. --- src/libsystemd/sd-varlink/test-varlink-idl.c | 2 + src/shared/meson.build | 1 + .../varlink-io.systemd.StorageProvider.c | 119 ++++++++++++++++++ .../varlink-io.systemd.StorageProvider.h | 6 + 4 files changed, 128 insertions(+) create mode 100644 src/shared/varlink-io.systemd.StorageProvider.c create mode 100644 src/shared/varlink-io.systemd.StorageProvider.h diff --git a/src/libsystemd/sd-varlink/test-varlink-idl.c b/src/libsystemd/sd-varlink/test-varlink-idl.c index a645d4d9d3..a519089702 100644 --- a/src/libsystemd/sd-varlink/test-varlink-idl.c +++ b/src/libsystemd/sd-varlink/test-varlink-idl.c @@ -44,6 +44,7 @@ #include "varlink-io.systemd.Resolve.h" #include "varlink-io.systemd.Resolve.Hook.h" #include "varlink-io.systemd.Resolve.Monitor.h" +#include "varlink-io.systemd.StorageProvider.h" #include "varlink-io.systemd.Udev.h" #include "varlink-io.systemd.Unit.h" #include "varlink-io.systemd.UserDatabase.h" @@ -212,6 +213,7 @@ TEST(parse_format) { &vl_interface_io_systemd_Resolve, &vl_interface_io_systemd_Resolve_Hook, &vl_interface_io_systemd_Resolve_Monitor, + &vl_interface_io_systemd_StorageProvider, &vl_interface_io_systemd_Udev, &vl_interface_io_systemd_Unit, &vl_interface_io_systemd_UserDatabase, diff --git a/src/shared/meson.build b/src/shared/meson.build index c28fe040b6..cd34b02f85 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -245,6 +245,7 @@ shared_sources = files( 'varlink-io.systemd.Resolve.Hook.c', 'varlink-io.systemd.Resolve.Monitor.c', 'varlink-io.systemd.Shutdown.c', + 'varlink-io.systemd.StorageProvider.c', 'varlink-io.systemd.Udev.c', 'varlink-io.systemd.Unit.c', 'varlink-io.systemd.UserDatabase.c', diff --git a/src/shared/varlink-io.systemd.StorageProvider.c b/src/shared/varlink-io.systemd.StorageProvider.c new file mode 100644 index 0000000000..cd2a4f3fda --- /dev/null +++ b/src/shared/varlink-io.systemd.StorageProvider.c @@ -0,0 +1,119 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "bus-polkit.h" +#include "varlink-io.systemd.StorageProvider.h" + +static SD_VARLINK_DEFINE_ENUM_TYPE( + VolumeType, + SD_VARLINK_FIELD_COMMENT("Block device storage volumes, block-addressable"), + SD_VARLINK_DEFINE_ENUM_VALUE(blk), + SD_VARLINK_FIELD_COMMENT("Regular file storage volumes, byte-addressable"), + SD_VARLINK_DEFINE_ENUM_VALUE(reg), + SD_VARLINK_FIELD_COMMENT("POSIX file system storage volumes, path/offset-addressable"), + SD_VARLINK_DEFINE_ENUM_VALUE(dir)); + +static SD_VARLINK_DEFINE_ENUM_TYPE( + CreateMode, + SD_VARLINK_FIELD_COMMENT("Open if exists already, create if missing"), + SD_VARLINK_DEFINE_ENUM_VALUE(any), + SD_VARLINK_FIELD_COMMENT("Create if missing, fail if exists already"), + SD_VARLINK_DEFINE_ENUM_VALUE(new), + SD_VARLINK_FIELD_COMMENT("Open if exists already, fail if missing"), + SD_VARLINK_DEFINE_ENUM_VALUE(open)); + +static SD_VARLINK_DEFINE_METHOD( + Acquire, + SD_VARLINK_FIELD_COMMENT("The name of the storage volume to acquire"), + SD_VARLINK_DEFINE_INPUT(name, SD_VARLINK_STRING, 0), + SD_VARLINK_FIELD_COMMENT("Determines whether to open or create a storage volume"), + SD_VARLINK_DEFINE_INPUT_BY_TYPE(createMode, CreateMode, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The template to use when creating a new storage volume"), + SD_VARLINK_DEFINE_INPUT(template, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Controls read/write access to the storage volume. If false and the storage volume cannot be opened in writable mode the call will fail. If null, storage volume will be acquired in writable mode if possible, read-only otherwise. If true, storage volume will be opened in read-only mode (and fail if that's not possible)."), + SD_VARLINK_DEFINE_INPUT(readOnly, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Dictates what kind of storage volume to request. Some storage volumes can be acquired either as regular file or as block device. In all other cases if this value doesn't match the volume type, the request will fail."), + SD_VARLINK_DEFINE_INPUT_BY_TYPE(requestAs, VolumeType, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The size of the storage volume, if one is created. Has no effect if no storage volume is created."), + SD_VARLINK_DEFINE_INPUT(createSizeBytes, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + VARLINK_DEFINE_POLKIT_INPUT, + SD_VARLINK_FIELD_COMMENT("Returns an index into the array of file descriptors associated with this reply. This may be used to get the file descriptor of the volume. The file descriptor must be properly opened, i.e. not an O_PATH file descriptor."), + SD_VARLINK_DEFINE_OUTPUT(fileDescriptorIndex, SD_VARLINK_INT, 0), + SD_VARLINK_FIELD_COMMENT("The storage volume type, i.e. ultimately the inode type of the returned file descriptor"), + SD_VARLINK_DEFINE_OUTPUT_BY_TYPE(type, VolumeType, 0), + SD_VARLINK_FIELD_COMMENT("Whether storage volume has been opened in read-only mode"), + SD_VARLINK_DEFINE_OUTPUT(readOnly, SD_VARLINK_BOOL, 0), + SD_VARLINK_FIELD_COMMENT("Base UID for the returned file descriptor (if directory). If not specified shall default to 0."), + SD_VARLINK_DEFINE_OUTPUT(baseUID, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Base GID for the returned file descriptor (if directory). If not specified shall default to 0."), + SD_VARLINK_DEFINE_OUTPUT(baseGID, SD_VARLINK_INT, SD_VARLINK_NULLABLE)); + +static SD_VARLINK_DEFINE_METHOD_FULL( + ListVolumes, + SD_VARLINK_REQUIRES_MORE, + SD_VARLINK_FIELD_COMMENT("Specifies a shell glob to filter enumeration by"), + SD_VARLINK_DEFINE_INPUT(matchName, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The storage volume's primary name"), + SD_VARLINK_DEFINE_OUTPUT(name, SD_VARLINK_STRING, 0), + SD_VARLINK_FIELD_COMMENT("Additional names"), + SD_VARLINK_DEFINE_OUTPUT(aliases, SD_VARLINK_STRING, SD_VARLINK_NULLABLE|SD_VARLINK_ARRAY), + SD_VARLINK_FIELD_COMMENT("The type of the storage volume"), + SD_VARLINK_DEFINE_OUTPUT_BY_TYPE(type, VolumeType, 0), + SD_VARLINK_FIELD_COMMENT("Whether the storage volume is read-only."), + SD_VARLINK_DEFINE_OUTPUT(readOnly, SD_VARLINK_BOOL, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Size in bytes, if known"), + SD_VARLINK_DEFINE_OUTPUT(sizeBytes, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Used bytes, if known"), + SD_VARLINK_DEFINE_OUTPUT(usedBytes, SD_VARLINK_INT, SD_VARLINK_NULLABLE)); + +static SD_VARLINK_DEFINE_METHOD_FULL( + ListTemplates, + SD_VARLINK_REQUIRES_MORE, + SD_VARLINK_FIELD_COMMENT("Specifies a shell glob to filter enumeration by"), + SD_VARLINK_DEFINE_INPUT(matchName, SD_VARLINK_STRING, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The template's name"), + SD_VARLINK_DEFINE_OUTPUT(name, SD_VARLINK_STRING, 0), + SD_VARLINK_FIELD_COMMENT("The type of the storage volumes defined by this template"), + SD_VARLINK_DEFINE_OUTPUT_BY_TYPE(type, VolumeType, 0)); + +static SD_VARLINK_DEFINE_ERROR(NoSuchVolume); +static SD_VARLINK_DEFINE_ERROR(VolumeExists); +static SD_VARLINK_DEFINE_ERROR(NoSuchTemplate); +static SD_VARLINK_DEFINE_ERROR(TypeNotSupported); +static SD_VARLINK_DEFINE_ERROR(WrongType); +static SD_VARLINK_DEFINE_ERROR(CreateNotSupported); +static SD_VARLINK_DEFINE_ERROR(CreateSizeRequired); +static SD_VARLINK_DEFINE_ERROR(ReadOnlyVolume); +static SD_VARLINK_DEFINE_ERROR(BadTemplate); + +SD_VARLINK_DEFINE_INTERFACE( + io_systemd_StorageProvider, + "io.systemd.StorageProvider", + SD_VARLINK_INTERFACE_COMMENT("Storage Provider API, a generic interface for acquiring access to storage volumes"), + SD_VARLINK_SYMBOL_COMMENT("Encodes three classes of storage volumes. This follows the kernel's nomenclature for inode types, i.e. reg, dir, blk."), + &vl_type_VolumeType, + SD_VARLINK_SYMBOL_COMMENT("Determines whether to open existing or create a new storage volume."), + &vl_type_CreateMode, + SD_VARLINK_SYMBOL_COMMENT("Acquires a file descriptor for a storage volume."), + &vl_method_Acquire, + SD_VARLINK_SYMBOL_COMMENT("Lists available storage volumes."), + &vl_method_ListVolumes, + SD_VARLINK_SYMBOL_COMMENT("Lists available templates."), + &vl_method_ListTemplates, + SD_VARLINK_SYMBOL_COMMENT("No storage volume under the specified name exists."), + &vl_error_NoSuchVolume, + SD_VARLINK_SYMBOL_COMMENT("A storage volume under the specified name already exists."), + &vl_error_VolumeExists, + SD_VARLINK_SYMBOL_COMMENT("No template under the specified name exists."), + &vl_error_NoSuchTemplate, + SD_VARLINK_SYMBOL_COMMENT("The specified volume type is not supported by this backend or system."), + &vl_error_TypeNotSupported, + SD_VARLINK_SYMBOL_COMMENT("The volume's type does not match the requested volume type."), + &vl_error_WrongType, + SD_VARLINK_SYMBOL_COMMENT("This backend does not support storage volume creation of the requested type."), + &vl_error_CreateNotSupported, + SD_VARLINK_SYMBOL_COMMENT("This backend or selected volume type requires a storage volume size to be specified if the storage volume does not exist yet and needs to be created."), + &vl_error_CreateSizeRequired, + SD_VARLINK_SYMBOL_COMMENT("A storage volume was to be acquired in writable mode, but only read-only access is permitted."), + &vl_error_ReadOnlyVolume, + SD_VARLINK_SYMBOL_COMMENT("Template not suitable for this storage volume type."), + &vl_error_BadTemplate); diff --git a/src/shared/varlink-io.systemd.StorageProvider.h b/src/shared/varlink-io.systemd.StorageProvider.h new file mode 100644 index 0000000000..707d05644f --- /dev/null +++ b/src/shared/varlink-io.systemd.StorageProvider.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "sd-varlink-idl.h" + +extern const sd_varlink_interface vl_interface_io_systemd_StorageProvider; From 4fd0df2a4b0aeb7aa317666f7e25626b0129c87a Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 23 Apr 2026 09:00:06 +0200 Subject: [PATCH 2/6] storage: add systemd-storage-block@.service provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First implementation of io.systemd.StorageProvider, exposing all block devices known to udev (disks, partitions, dm nodes, …) as volumes of type "blk". Names are picked from stable /dev/mapper and /dev/disk/by-* symlinks; content-derived identifiers (by-uuid, by-label, …) are intentionally avoided for security. Volume creation is not supported by this backend. Socket-activated via /run/systemd/io.systemd.StorageProvider/block. Also adds shared storage-util.[ch] (VolumeType / CreateMode helpers) that subsequent providers reuse. --- man/rules/meson.build | 4 + man/systemd-storage-block@.service.xml | 97 ++++++ meson.build | 1 + src/storage/io.systemd.storage.policy | 30 ++ src/storage/meson.build | 11 + src/storage/storage-block.c | 439 ++++++++++++++++++++++++ src/storage/storage-util.c | 23 ++ src/storage/storage-util.h | 43 +++ units/meson.build | 7 + units/systemd-storage-block.socket | 24 ++ units/systemd-storage-block@.service.in | 18 + 11 files changed, 697 insertions(+) create mode 100644 man/systemd-storage-block@.service.xml create mode 100644 src/storage/io.systemd.storage.policy create mode 100644 src/storage/meson.build create mode 100644 src/storage/storage-block.c create mode 100644 src/storage/storage-util.c create mode 100644 src/storage/storage-util.h create mode 100644 units/systemd-storage-block.socket create mode 100644 units/systemd-storage-block@.service.in diff --git a/man/rules/meson.build b/man/rules/meson.build index 4aae561512..439c33d5ab 100644 --- a/man/rules/meson.build +++ b/man/rules/meson.build @@ -1186,6 +1186,10 @@ manpages = [ ['systemd-ssh-issue', '1', [], ''], ['systemd-ssh-proxy', '1', [], ''], ['systemd-stdio-bridge', '1', [], ''], + ['systemd-storage-block@.service', + '8', + ['systemd-storage-block', 'systemd-storage-block.socket'], + ''], ['systemd-storagetm.service', '8', ['systemd-storagetm'], 'ENABLE_STORAGETM'], ['systemd-stub', '7', diff --git a/man/systemd-storage-block@.service.xml b/man/systemd-storage-block@.service.xml new file mode 100644 index 0000000000..ee6022af05 --- /dev/null +++ b/man/systemd-storage-block@.service.xml @@ -0,0 +1,97 @@ + + + + + + + + systemd-storage-block@.service + systemd + + + + systemd-storage-block@.service + 8 + + + + systemd-storage-block@.service + systemd-storage-block.socket + systemd-storage-block + Storage provider exposing local block devices as storage volumes + + + + systemd-storage-block@.service + systemd-storage-block.socket + + + + Description + + systemd-storage-block@.service is a system service that implements the + io.systemd.StorageProvider Varlink + interface, exposing the system's block devices (such as disks, partitions, and device-mapper + nodes) as storage volumes that may be acquired by other programs as file descriptors. + + The service is socket-activated via systemd-storage-block.socket, which + listens on the AF_UNIX socket /run/systemd/io.systemd.StorageProvider/block. The + socket directory /run/systemd/io.systemd.StorageProvider/ is the well-known location + where storage providers register, see + storagectl1 for an + enumeration tool. + + See also + systemd-storage-fs@.service8 + for a complementary implementation that exposes regular files and directories from a backing file + system. + + + + Volumes + + The volumes exposed via the provider are identified by an absolute path (which must begin with + /dev/), i.e. as a kernel block device node such as /dev/sda or + /dev/disk/by-id/…. Volume names that are not normalized or that do not begin with + /dev/ are not accepted. + + + + Options + + The following options are understood: + + + + + + + + + Files + + + + /run/systemd/io.systemd.StorageProvider/block + + AF_UNIX socket the service listens on. This is the canonical location + for the block storage provider, and is enumerated by + storagectl providers. + + + + + + + + See Also + + systemd1 + storagectl1 + systemd-storage-fs@.service8 + + + + diff --git a/meson.build b/meson.build index 4f1a791bc7..325b954a78 100644 --- a/meson.build +++ b/meson.build @@ -2139,6 +2139,7 @@ subdir('src/socket-activate') subdir('src/socket-proxy') subdir('src/ssh-generator') subdir('src/stdio-bridge') +subdir('src/storage') subdir('src/storagetm') subdir('src/sulogin-shell') subdir('src/sysctl') diff --git a/src/storage/io.systemd.storage.policy b/src/storage/io.systemd.storage.policy new file mode 100644 index 0000000000..06af278a5a --- /dev/null +++ b/src/storage/io.systemd.storage.policy @@ -0,0 +1,30 @@ + + + + + + + + The systemd Project + https://systemd.io + + + Allow access to block storage volumes + Authentication is required for an application to gain access to block storage volume '$(name)'. + + auth_admin + auth_admin + auth_admin_keep + + + diff --git a/src/storage/meson.build b/src/storage/meson.build new file mode 100644 index 0000000000..714e50ad9a --- /dev/null +++ b/src/storage/meson.build @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +executables += [ + libexec_template + { + 'name' : 'systemd-storage-block', + 'sources' : files('storage-block.c', 'storage-util.c'), + }, +] + +install_data('io.systemd.storage.policy', + install_dir : polkitpolicydir) diff --git a/src/storage/storage-block.c b/src/storage/storage-block.c new file mode 100644 index 0000000000..4c21795c36 --- /dev/null +++ b/src/storage/storage-block.c @@ -0,0 +1,439 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include + +#include "sd-device.h" +#include "sd-json.h" +#include "sd-varlink.h" + +#include "blockdev-list.h" +#include "build.h" +#include "bus-polkit.h" +#include "device-private.h" +#include "device-util.h" +#include "errno-util.h" +#include "fd-util.h" +#include "format-table.h" +#include "hashmap.h" +#include "help-util.h" +#include "json-util.h" +#include "log.h" +#include "main-func.h" +#include "options.h" +#include "path-util.h" +#include "storage-util.h" +#include "strv.h" +#include "varlink-io.systemd.StorageProvider.h" +#include "varlink-util.h" + +static int block_device_pick_name( + const BlockDevice *d, + const char **ret_name, + char ***ret_aliases) { + + int r; + + assert(d); + assert(d->node); + assert(ret_name); + assert(ret_aliases); + + static const char *const prefixes[] = { + /* The list of preferred prefixes, in order of preference. Note: for security reasons we only + * use identifiers that do not depend on the *contents* of the device, i.e. we restrict + * ourselves to IDs whose fields are either chosen by whoever created the kernel device or are + * hardware properties, but not names generated from superblock metainformation or similar. */ + "/dev/mapper", + "/dev/disk/by-loop-ref", + "/dev/disk/by-id", + "/dev/disk/by-path", + }; + + const char* found[ELEMENTSOF(prefixes)] = {}; + _cleanup_strv_free_ char **aliases = NULL; + size_t best = SIZE_MAX; + STRV_FOREACH(sl, d->symlinks) { + bool matched = false; + for (size_t i = 0; i < ELEMENTSOF(prefixes); i++) { + if (!path_startswith(*sl, prefixes[i])) + continue; + + if (found[i]) { + /* Two symlinks with the same prefix? Then keep the lower one. */ + if (path_compare(*sl, found[i]) > 0) + continue; + + r = strv_extend(&aliases, found[i]); + if (r < 0) + return r; + } + + found[i] = *sl; + if (i < best) + best = i; + matched = true; + } + + if (!matched) { + r = strv_extend(&aliases, *sl); + if (r < 0) + return r; + } + } + + if (best == SIZE_MAX) /* No preferred prefix found, use the kernel device name */ + *ret_name = d->node; + else { + /* We found a preferred prefix, add the kernel device name to the aliases then. */ + r = strv_extend(&aliases, d->node); + if (r < 0) + return r; + + /* If there are any less preferred prefixes also add them to the aliases array */ + for (size_t i = best + 1; i < ELEMENTSOF(prefixes); i++) { + if (!found[i]) + continue; + + r = strv_extend(&aliases, found[i]); + if (r < 0) + return r; + } + + *ret_name = found[best]; + } + + strv_sort(aliases); + *ret_aliases = TAKE_PTR(aliases); + + return 0; +} + +static bool block_device_match(const BlockDevice *d, const char *match) { + assert(d); + assert(d->node); + + if (!match) + return true; + + if (fnmatch(match, d->node, FNM_NOESCAPE) == 0) + return true; + + STRV_FOREACH(sl, d->symlinks) + if (fnmatch(match, *sl, FNM_NOESCAPE) == 0) + return true; + + return false; +} + +static int vl_method_list_volumes( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + int r; + + assert(link); + assert(FLAGS_SET(flags, SD_VARLINK_METHOD_MORE)); + + struct { + const char *match_name; + } p = {}; + + static const sd_json_dispatch_field dispatch_table[] = { + { "matchName", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, match_name), 0 }, + {} + }; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &p); + if (r != 0) + return r; + + BlockDevice *l = NULL; + size_t n = 0; + CLEANUP_ARRAY(l, n, block_device_array_free); + + r = blockdev_list( + BLOCKDEV_LIST_SHOW_SYMLINKS| + BLOCKDEV_LIST_IGNORE_ROOT| + BLOCKDEV_LIST_IGNORE_EMPTY| + BLOCKDEV_LIST_METADATA, + &l, + &n); + if (r < 0) + return r; + + r = sd_varlink_set_sentinel(link, "io.systemd.StorageProvider.NoSuchVolume"); + if (r < 0) + return r; + + FOREACH_ARRAY(d, l, n) { + const char *name = NULL; + _cleanup_strv_free_ char **aliases = NULL; + + if (!block_device_match(d, p.match_name)) + continue; + + r = block_device_pick_name(d, &name, &aliases); + if (r < 0) + return r; + + r = sd_varlink_replybo( + link, + SD_JSON_BUILD_PAIR_STRING("name", name), + JSON_BUILD_PAIR_STRV_NON_EMPTY("aliases", aliases), + SD_JSON_BUILD_PAIR_STRING("type", "blk"), + SD_JSON_BUILD_PAIR_CONDITION(d->read_only >= 0, "readOnly", SD_JSON_BUILD_BOOLEAN(d->read_only)), + JSON_BUILD_PAIR_UNSIGNED_NOT_EQUAL("sizeBytes", d->size, UINT64_MAX)); + if (r < 0) + return r; + } + + return 0; +} + +static int vl_method_list_templates( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + /* This storage provider does not support templates */ + assert(link); + assert(FLAGS_SET(flags, SD_VARLINK_METHOD_MORE)); + + return sd_varlink_error(link, "io.systemd.StorageProvider.NoSuchTemplate", NULL); +} + +static int device_open_disk_auto_rw(sd_device *d, int *read_only) { + assert(d); + assert(read_only); + + int fd = sd_device_open(d, *read_only > 0 ? O_RDONLY : O_RDWR); + if (fd < 0) { + if (!ERRNO_IS_NEG_FS_WRITE_REFUSED(fd) || *read_only >= 0) + return log_device_debug_errno(d, fd, "Failed to open device in %s mode: %m", *read_only > 0 ? "read-only" : "read-write"); + + /* Try again in read-only mode */ + fd = sd_device_open(d, O_RDONLY); + if (fd < 0) + return log_device_debug_errno(d, fd, "Failed to open device in read-only mode, too: %m"); + + *read_only = true; + } else + *read_only = *read_only > 0; + + return fd; +} + +static int vl_method_acquire( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + Hashmap **polkit_registry = ASSERT_PTR(userdata); + int r; + + assert(link); + + struct { + const char *name; + CreateMode create_mode; + const char *template; + int read_only; + VolumeType request_as; + uint64_t create_size; + } p = { + .create_mode = CREATE_ANY, + .read_only = -1, + .request_as = _VOLUME_TYPE_INVALID, + .create_size = UINT64_MAX, /* never actually used here, just validated; we don't allow creation of block devices here */ + }; + + static const sd_json_dispatch_field dispatch_table[] = { + { "name", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, name), SD_JSON_MANDATORY }, + { "createMode", SD_JSON_VARIANT_STRING, json_dispatch_create_mode, voffsetof(p, create_mode), 0 }, + { "template", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, template), 0 }, + { "readOnly", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate, voffsetof(p, read_only), 0 }, + { "requestAs", SD_JSON_VARIANT_STRING, json_dispatch_volume_type, voffsetof(p, request_as), 0 }, + { "createSizeBytes", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, voffsetof(p, create_size), 0 }, + VARLINK_DISPATCH_POLKIT_FIELD, + {} + }; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &p); + if (r != 0) + return r; + + if (!storage_volume_name_is_valid(p.name)) + return sd_varlink_error_invalid_parameter_name(link, "name"); + if (!path_startswith(p.name, "/dev") || !path_is_normalized(p.name)) + return sd_varlink_error(link, "io.systemd.StorageProvider.NoSuchVolume", NULL); + + if (!IN_SET(p.create_mode, CREATE_ANY, CREATE_OPEN)) + return sd_varlink_error(link, "io.systemd.StorageProvider.CreateNotSupported", NULL); + + /* off_t is signed, hence refuse overly long requests */ + if (p.create_size != UINT64_MAX && p.create_size > INT64_MAX) + return sd_varlink_error_invalid_parameter_name(link, "createSizeBytes"); + + if (!isempty(p.template)) { + if (!storage_template_name_is_valid(p.template)) + return sd_varlink_error_invalid_parameter_name(link, "template"); + + return sd_varlink_error(link, "io.systemd.StorageProvider.NoSuchTemplate", NULL); + } + + if (p.request_as >= 0 && p.request_as != VOLUME_BLK) + return sd_varlink_error(link, "io.systemd.StorageProvider.TypeNotSupported", NULL); + + const char *details[] = { + "name", p.name, + NULL + }; + + r = varlink_verify_polkit_async( + link, + /* bus= */ NULL, + "io.systemd.storage.block.acquire", + details, + polkit_registry); + if (r <= 0) + return r; + + _cleanup_(sd_device_unrefp) sd_device *d = NULL; + r = sd_device_new_from_devname(&d, p.name); + if (ERRNO_IS_NEG_DEVICE_ABSENT(r)) + return sd_varlink_error(link, "io.systemd.StorageProvider.NoSuchVolume", NULL); + if (r < 0) + return r; + + if (!device_in_subsystem(d, "block")) + return sd_varlink_error(link, "io.systemd.StorageProvider.NoSuchVolume", NULL); + + /* The error returns are sometimes a bit inconclusive (i.e. read-only media might appear as + * inaccessible due to a permission issue), hence let's do an explicit check first, to give good + * answers */ + if (p.read_only <= 0) { + r = device_get_sysattr_bool(d, "ro"); + if (r < 0) + log_device_debug_errno(d, r, "Failed to acquire read-only flag of device '%s', ignoring: %m", p.name); + else if (r > 0) { + if (p.read_only == 0) + return sd_varlink_error(link, "io.systemd.StorageProvider.ReadOnlyVolume", NULL); + + p.read_only = true; + } + } + + _cleanup_close_ int fd = device_open_disk_auto_rw(d, &p.read_only); + if (ERRNO_IS_NEG_FS_WRITE_REFUSED(fd)) + return sd_varlink_error(link, "io.systemd.StorageProvider.ReadOnlyVolume", NULL); + if (fd < 0) + return fd; + + assert(p.read_only >= 0); /* flag is now definitely initialized to either true or false, not negative anymore */ + + int idx = sd_varlink_push_fd(link, fd); + if (idx < 0) + return idx; + + TAKE_FD(fd); + + return sd_varlink_replybo( + link, + SD_JSON_BUILD_PAIR_INTEGER("fileDescriptorIndex", idx), + SD_JSON_BUILD_PAIR_STRING("type", "blk"), + SD_JSON_BUILD_PAIR_BOOLEAN("readOnly", p.read_only)); +} + +static int vl_server(void) { + int r; + + _cleanup_(hashmap_freep) Hashmap *polkit_registry = NULL; + _cleanup_(sd_varlink_server_unrefp) sd_varlink_server *varlink_server = NULL; + r = varlink_server_new( + &varlink_server, + SD_VARLINK_SERVER_HANDLE_SIGINT| + SD_VARLINK_SERVER_HANDLE_SIGTERM| + SD_VARLINK_SERVER_ALLOW_FD_PASSING_OUTPUT| + SD_VARLINK_SERVER_INHERIT_USERDATA, + &polkit_registry); + if (r < 0) + return log_error_errno(r, "Failed to allocate Varlink server: %m"); + + r = sd_varlink_server_add_interface(varlink_server, &vl_interface_io_systemd_StorageProvider); + if (r < 0) + return log_error_errno(r, "Failed to add Varlink interface: %m"); + + r = sd_varlink_server_bind_method_many( + varlink_server, + "io.systemd.StorageProvider.Acquire", vl_method_acquire, + "io.systemd.StorageProvider.ListVolumes", vl_method_list_volumes, + "io.systemd.StorageProvider.ListTemplates", vl_method_list_templates); + if (r < 0) + return log_error_errno(r, "Failed to bind Varlink methods: %m"); + + r = sd_varlink_server_loop_auto(varlink_server); + if (r < 0) + return log_error_errno(r, "Failed to run Varlink event loop: %m"); + + return 0; +} + +static int help(void) { + int r; + + help_cmdline("[OPTIONS...]"); + help_abstract("Simple block device backed storage provider"); + + _cleanup_(table_unrefp) Table *options = NULL; + r = option_parser_get_help_table(&options); + if (r < 0) + return r; + + help_section("Options:"); + + r = table_print_or_warn(options); + if (r < 0) + return r; + + help_man_page_reference("systemd-storage-block", "8"); + return 0; +} + +static int parse_argv(int argc, char *argv[]) { + assert(argc >= 0); + assert(argv); + + OptionParser opts = { argc, argv }; + FOREACH_OPTION(c, &opts, /* on_error= */ return c) + switch (c) { + + OPTION_COMMON_HELP: + return help(); + + OPTION_COMMON_VERSION: + return version(); + } + + if (option_parser_get_n_args(&opts) > 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "This program takes no arguments."); + + return 1; +} + +static int run(int argc, char* argv[]) { + int r; + + log_setup(); + + r = parse_argv(argc, argv); + if (r <= 0) + return r; + + return vl_server(); +} + +DEFINE_MAIN_FUNCTION(run); diff --git a/src/storage/storage-util.c b/src/storage/storage-util.c new file mode 100644 index 0000000000..793946c03a --- /dev/null +++ b/src/storage/storage-util.c @@ -0,0 +1,23 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "json-util.h" +#include "string-table.h" +#include "storage-util.h" + +static const char *volume_type_table[_VOLUME_TYPE_MAX] = { + [VOLUME_BLK] = "blk", + [VOLUME_REG] = "reg", + [VOLUME_DIR] = "dir", +}; + +static const char *create_mode_table[_CREATE_MODE_MAX] = { + [CREATE_ANY] = "any", + [CREATE_NEW] = "new", + [CREATE_OPEN] = "open", +}; + +DEFINE_STRING_TABLE_LOOKUP(volume_type, VolumeType); +DEFINE_STRING_TABLE_LOOKUP(create_mode, CreateMode); + +JSON_DISPATCH_ENUM_DEFINE(json_dispatch_volume_type, VolumeType, volume_type_from_string); +JSON_DISPATCH_ENUM_DEFINE(json_dispatch_create_mode, CreateMode, create_mode_from_string); diff --git a/src/storage/storage-util.h b/src/storage/storage-util.h new file mode 100644 index 0000000000..f7a62aeec0 --- /dev/null +++ b/src/storage/storage-util.h @@ -0,0 +1,43 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "sd-json.h" + +#include "string-table-fundamental.h" +#include "string-util.h" + +/* This closely follows the kernel's inode type naming, i.e. is supposed to be a subset of what + * inode_type_from_string() parses. */ +typedef enum VolumeType { + VOLUME_BLK, + VOLUME_REG, + VOLUME_DIR, + _VOLUME_TYPE_MAX, + _VOLUME_TYPE_INVALID = -EINVAL, +} VolumeType; + +typedef enum CreateMode { + CREATE_ANY, + CREATE_NEW, + CREATE_OPEN, + _CREATE_MODE_MAX, + _CREATE_MODE_INVALID = -EINVAL, +} CreateMode; + +DECLARE_STRING_TABLE_LOOKUP(volume_type, VolumeType); +DECLARE_STRING_TABLE_LOOKUP(create_mode, CreateMode); + +int json_dispatch_volume_type(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata); +int json_dispatch_create_mode(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata); + +static inline bool storage_volume_name_is_valid(const char *n) { + return string_is_safe(n, /* flags= */ 0); +} + +static inline bool storage_template_name_is_valid(const char *n) { + return string_is_safe(n, /* flags= */ 0); +} + +static inline bool storage_provider_name_is_valid(const char *n) { + return string_is_safe(n, STRING_FILENAME); +} diff --git a/units/meson.build b/units/meson.build index 622e1e69cf..3cac3c876a 100644 --- a/units/meson.build +++ b/units/meson.build @@ -804,6 +804,13 @@ units = [ 'conditions' : ['ENABLE_SYSUSERS'], 'symlinks' : ['sysinit.target.wants/'], }, + { + 'file' : 'systemd-storage-block.socket', + 'symlinks' : ['sockets.target.wants/'] + }, + { + 'file' : 'systemd-storage-block@.service.in', + }, { 'file' : 'systemd-storagetm.service.in', 'conditions' : ['ENABLE_STORAGETM'], diff --git a/units/systemd-storage-block.socket b/units/systemd-storage-block.socket new file mode 100644 index 0000000000..1d18b481a3 --- /dev/null +++ b/units/systemd-storage-block.socket @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Simple Block Device Backed Storage Provider +Documentation=man:systemd-storage-block@..service(8) +DefaultDependencies=no +Before=sockets.target + +[Socket] +ListenStream=/run/systemd/io.systemd.StorageProvider/block +FileDescriptorName=varlink +SocketMode=0666 +Accept=yes +MaxConnectionsPerSource=16 + +[Install] +WantedBy=sockets.target diff --git a/units/systemd-storage-block@.service.in b/units/systemd-storage-block@.service.in new file mode 100644 index 0000000000..801551e2ff --- /dev/null +++ b/units/systemd-storage-block@.service.in @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Simple Block Device Backed Storage Provider +Documentation=man:systemd-storage-block@.service(8) +DefaultDependencies=no +Conflicts=shutdown.target initrd-switch-root.target +Before=shutdown.target initrd-switch-root.target + +[Service] +ExecStart=-{{LIBEXECDIR}}/systemd-storage-block From 9825195c255e7818627a3d52c2544c9e635e14fd Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 23 Apr 2026 09:00:46 +0200 Subject: [PATCH 3/6] storage: add systemd-storage-fs@.service provider Second StorageProvider implementation, exposing regular files and directories from a backing filesystem. In system mode the backing directory is /var/lib/storage/, in user mode $XDG_STATE_HOME/storage/; entries with a .volume suffix are exposed, with the inode type determining whether the volume is reported as reg, dir or (via symlinked/bind-mounted device node) blk. Unlike the block provider, this one supports creating volumes on-demand from a small set of built-in templates: sparse-file, allocated-file, directory and subvolume. --- man/rules/meson.build | 4 + man/systemd-storage-fs@.service.xml | 199 ++++++ src/storage/io.systemd.storage.policy | 10 + src/storage/meson.build | 8 +- src/storage/storage-fs.c | 807 ++++++++++++++++++++++ units/meson.build | 7 + units/systemd-storage-fs.socket | 25 + units/systemd-storage-fs@.service.in | 19 + units/user/meson.build | 7 + units/user/systemd-storage-fs.socket | 23 + units/user/systemd-storage-fs@.service.in | 15 + 11 files changed, 1123 insertions(+), 1 deletion(-) create mode 100644 man/systemd-storage-fs@.service.xml create mode 100644 src/storage/storage-fs.c create mode 100644 units/systemd-storage-fs.socket create mode 100644 units/systemd-storage-fs@.service.in create mode 100644 units/user/systemd-storage-fs.socket create mode 100644 units/user/systemd-storage-fs@.service.in diff --git a/man/rules/meson.build b/man/rules/meson.build index 439c33d5ab..7f4fa07f7b 100644 --- a/man/rules/meson.build +++ b/man/rules/meson.build @@ -1190,6 +1190,10 @@ manpages = [ '8', ['systemd-storage-block', 'systemd-storage-block.socket'], ''], + ['systemd-storage-fs@.service', + '8', + ['systemd-storage-fs', 'systemd-storage-fs.socket'], + ''], ['systemd-storagetm.service', '8', ['systemd-storagetm'], 'ENABLE_STORAGETM'], ['systemd-stub', '7', diff --git a/man/systemd-storage-fs@.service.xml b/man/systemd-storage-fs@.service.xml new file mode 100644 index 0000000000..4fe0734398 --- /dev/null +++ b/man/systemd-storage-fs@.service.xml @@ -0,0 +1,199 @@ + + + + + + + + systemd-storage-fs@.service + systemd + + + + systemd-storage-fs@.service + 8 + + + + systemd-storage-fs@.service + systemd-storage-fs.socket + systemd-storage-fs + Storage provider exposing regular files and directories as storage volumes + + + + systemd-storage-fs@.service + systemd-storage-fs.socket + + + + Description + + systemd-storage-fs@.service is a service that implements the + io.systemd.StorageProvider Varlink + interface, exposing regular files and directories in /var/lib/storage/*.volume (if + used in system mode) or $XDG_STATE_HOME/storage (when used in user mode) as storage + volumes. Acquired volumes are returned to the caller as file descriptors. Unlike + systemd-storage-block@.service8, + this implementation also supports creating new volumes on demand from a small set of built-in + templates. + + The service is socket-activated via systemd-storage-fs.socket. In system mode + it listens on the AF_UNIX socket /run/systemd/io.systemd.StorageProvider/fs, in user + mode on $XDG_RUNTIME_DIR/systemd/io.systemd.StorageProvider/fs. See + storagectl1 for an + enumeration tool. + + See also + systemd-storage-block@.service8 + for a complementary implementation that exposes local block devices as storage volumes. + + + + Volumes + + Volumes are stored below the storage directory: + + + /var/lib/storage/ when run in system mode. + + $XDG_STATE_HOME/storage/ (typically + ~/.local/state/storage/) when run in user mode. + + + Each volume on disk is stored as a directory entry with a .volume suffix in + the storage directory. Entries which are regular files are exposed as volumes of type + reg; entries which are directories are exposed as volumes of type + dir. Moreover, block device nodes may be symlinked (or bind mounted) into the + directory, which are then exposed as volumes of type blk. + + For directory volumes, the root of the file system passed to clients is placed in a subdirectory + root/ of the NAME.volume directory. The former (and all inodes + below it) must be owned by the foreign UID range, the latter by the host's root. + + When acquiring a volume, symlinks are followed. + + An administrator is permitted to freely manipulate the volume hierarchy directly as long as the + rules described above are followed. In particular, it's permitted to copy, mount or symlink arbitrary + external resources (regardless if directory, regular file or block) into the volume directory, so that + they are exposed as additional volumes. + + + + Templates + + The provider supports creating new volumes automatically when they are acquired. The caller may + select a template that determines configuration details of the volume to create. The + following built-in templates are available: + + + + sparse-file + + Creates a volume backed by a sparsely populated regular file. This is the default + template when creating a regular file volume. (Volume type is reg.) + + + + + + allocated-file + + Creates a volume backed by a fully allocated regular file. (Volume type is + reg.) + + + + + + directory + + Creates a volume backed by a regular directory. (Volume type is + dir.) + + + + + + subvolume + + Creates a btrfs subvolume as backing inode (falling back to a regular directory if + the storage directory is not on btrfs). This is the default template when creating a directory + volume. (Volume type is dir.) + + + + + + + + Options + + The following command-line options are understood: + + + + + + Operate in system mode. Volumes are stored below + /var/lib/storage/. This is the default when invoked from + systemd-storage-fs@.service in the system manager. + + + + + + + + Operate in user mode. Volumes are stored below + $XDG_STATE_HOME/storage/. This is the default when invoked from + systemd-storage-fs@.service in the user manager. + + + + + + + + + + + Files + + + + /var/lib/storage/ + $XDG_STATE_HOME/storage/ + + The storage directory used to back the system mode and user mode service + instances respectively. Each volume is stored as an entry with a + .volume suffix below this directory. + + + + + + /run/systemd/io.systemd.StorageProvider/fs + $XDG_RUNTIME_DIR/systemd/io.systemd.StorageProvider/fs + + AF_UNIX sockets the service listens on, in system and user mode + respectively. These are the canonical locations for the fs storage + provider, and are enumerated by storagectl providers. + + + + + + + + See Also + + systemd1 + storagectl1 + systemd-storage-block@.service8 + + + + diff --git a/src/storage/io.systemd.storage.policy b/src/storage/io.systemd.storage.policy index 06af278a5a..7b25553501 100644 --- a/src/storage/io.systemd.storage.policy +++ b/src/storage/io.systemd.storage.policy @@ -27,4 +27,14 @@ auth_admin_keep + + + Allow access to file system storage volumes + Authentication is required for an application to gain access to file system storage volume '$(name)'. + + auth_admin + auth_admin + auth_admin_keep + + diff --git a/src/storage/meson.build b/src/storage/meson.build index 714e50ad9a..05c5e24ece 100644 --- a/src/storage/meson.build +++ b/src/storage/meson.build @@ -3,7 +3,13 @@ executables += [ libexec_template + { 'name' : 'systemd-storage-block', - 'sources' : files('storage-block.c', 'storage-util.c'), + 'sources' : files('storage-block.c'), + 'extract' : files('storage-util.c') + }, + libexec_template + { + 'name' : 'systemd-storage-fs', + 'sources' : files('storage-fs.c'), + 'objects' : ['systemd-storage-block'], }, ] diff --git a/src/storage/storage-fs.c b/src/storage/storage-fs.c new file mode 100644 index 0000000000..47ec9829c4 --- /dev/null +++ b/src/storage/storage-fs.c @@ -0,0 +1,807 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include +#include +#include +#include + +#include "sd-device.h" +#include "sd-json.h" + +#include "alloc-util.h" +#include "build.h" +#include "bus-polkit.h" +#include "chase.h" +#include "chattr-util.h" +#include "device-private.h" +#include "device-util.h" +#include "errno-util.h" +#include "fd-util.h" +#include "format-table.h" +#include "fs-util.h" +#include "hashmap.h" +#include "help-util.h" +#include "log.h" +#include "main-func.h" +#include "mount-util.h" +#include "options.h" +#include "path-lookup.h" +#include "path-util.h" +#include "recurse-dir.h" +#include "runtime-scope.h" +#include "stat-util.h" +#include "storage-util.h" +#include "string-table.h" +#include "tmpfile-util.h" +#include "uid-classification.h" +#include "varlink-io.systemd.StorageProvider.h" +#include "varlink-util.h" + +static RuntimeScope arg_runtime_scope = RUNTIME_SCOPE_SYSTEM; + +/* For now we maintain a simple, compiled-in list of templates. One of those days we might want to move these + * into configurable drop-in files on disk. */ +typedef enum Template { + TEMPLATE_SPARSE_FILE, + TEMPLATE_ALLOCATED_FILE, + TEMPLATE_DIRECTORY, + TEMPLATE_SUBVOLUME, + _TEMPLATE_MAX, + _TEMPLATE_INVALID = -EINVAL, +} Template; + +static const char *template_table[_TEMPLATE_MAX] = { + [TEMPLATE_SPARSE_FILE] = "sparse-file", + [TEMPLATE_ALLOCATED_FILE] = "allocated-file", + [TEMPLATE_DIRECTORY] = "directory", + [TEMPLATE_SUBVOLUME] = "subvolume", +}; + +DEFINE_PRIVATE_STRING_TABLE_LOOKUP(template, Template); + +static VolumeType volume_type_from_template(Template t) { + switch (t) { + + case TEMPLATE_SPARSE_FILE: + case TEMPLATE_ALLOCATED_FILE: + return VOLUME_REG; + + case TEMPLATE_DIRECTORY: + case TEMPLATE_SUBVOLUME: + return VOLUME_DIR; + + default: + return _VOLUME_TYPE_INVALID; + } +} + +static int open_storage_dir(void) { + int r; + + _cleanup_free_ char *state_dir = NULL; + r = state_directory_generic(arg_runtime_scope, /* suffix= */ NULL, &state_dir); + if (r < 0) + return log_error_errno(r, "Failed to get state directory path: %m"); + + _cleanup_close_ int state_fd = chase_and_open(state_dir, /* root= */ NULL, CHASE_TRIGGER_AUTOFS|CHASE_MKDIR_0755|CHASE_MUST_BE_DIRECTORY, O_CLOEXEC|O_CREAT|O_DIRECTORY, /* ret_path= */ NULL); + if (state_fd < 0) + return log_error_errno(state_fd, "Failed to open '%s': %m", state_dir); + + /* First we try to open the storage directory. If it exists this will work and we are happy. If we + * get ENOENT we'll try to create it. If that works, great. If we get EEXIST we'll try to reopen it + * again, to deal with other instances of ourselves racing with us. We only do this exactly once + * though, under the assumption that the dir is never removed, only created during runtime. */ + _cleanup_close_ int storage_fd = chase_and_openat(XAT_FDROOT, state_fd, "storage", CHASE_TRIGGER_AUTOFS|CHASE_MUST_BE_DIRECTORY, O_CLOEXEC|O_DIRECTORY, /* ret_path= */ NULL); + if (storage_fd == -ENOENT) { + storage_fd = xopenat_full(state_fd, "storage", O_EXCL|O_CREAT|O_CLOEXEC|O_DIRECTORY|O_NOFOLLOW, XO_LABEL|XO_SUBVOLUME, 0700); + if (storage_fd == -EEXIST) + storage_fd = chase_and_openat(XAT_FDROOT, state_fd, "storage", CHASE_TRIGGER_AUTOFS|CHASE_MUST_BE_DIRECTORY, O_CLOEXEC|O_DIRECTORY, /* ret_path= */ NULL); + } + if (storage_fd < 0) + return log_error_errno(storage_fd, "Failed to open '%s/storage/': %m", state_dir); + + return TAKE_FD(storage_fd); +} + +static int vl_method_list_volumes( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + int r; + + assert(link); + assert(FLAGS_SET(flags, SD_VARLINK_METHOD_MORE)); + + struct { + const char *match_name; + } p = {}; + + static const sd_json_dispatch_field dispatch_table[] = { + { "matchName", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, match_name), 0 }, + {} + }; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &p); + if (r != 0) + return r; + + _cleanup_close_ int fd = open_storage_dir(); + if (fd < 0) + return fd; + + _cleanup_free_ DirectoryEntries *dentries = NULL; + r = readdir_all(fd, RECURSE_DIR_SORT, &dentries); + if (r < 0) + return r; + + r = sd_varlink_set_sentinel(link, "io.systemd.StorageProvider.NoSuchVolume"); + if (r < 0) + return r; + + FOREACH_ARRAY(dp, dentries->entries, dentries->n_entries) { + struct dirent *d = *dp; + + if (!IN_SET(d->d_type, DT_REG, DT_DIR, DT_LNK, DT_BLK, DT_UNKNOWN)) + continue; + + const char *e = endswith(d->d_name, ".volume"); + if (!e) + continue; + + _cleanup_free_ char *n = strndup(d->d_name, e - d->d_name); + if (!n) + return log_oom_debug(); + + if (!storage_volume_name_is_valid(n)) + continue; + + if (p.match_name && fnmatch(p.match_name, n, FNM_NOESCAPE) != 0) + continue; + + _cleanup_close_ int pin_fd = -EBADF; + r = chaseat(XAT_FDROOT, fd, d->d_name, CHASE_TRIGGER_AUTOFS, /* ret_path= */ NULL, &pin_fd); + if (r < 0) { + log_debug_errno(r, "Failed to stat() '%s' in storage directory, ignoring: %m", d->d_name); + continue; + } + + struct stat st; + if (fstat(pin_fd, &st) < 0) + return log_debug_errno(errno, "Failed to stat() '%s' in storage directory: %m", d->d_name); + + uint64_t size = UINT64_MAX, used = UINT64_MAX; + bool ro = false; + + switch (st.st_mode & S_IFMT) { + case S_IFREG: + ro = (st.st_mode & 0222) == 0; + size = st.st_size; + used = (uint64_t) st.st_blocks * UINT64_C(512); + break; + + case S_IFDIR: + r = fd_is_read_only_fs(pin_fd); + if (r < 0) + log_debug_errno(r, "Failed to determine if '%s' is read-only, ignoring", d->d_name); + else + ro = r > 0; + break; + + case S_IFBLK: { + _cleanup_(sd_device_unrefp) sd_device *dev = NULL; + + r = sd_device_new_from_stat_rdev(&dev, &st); + if (r < 0) + log_debug_errno(r, "Failed to acquire device for '%s', ignoring: %m", d->d_name); + else { + r = device_get_sysattr_bool(dev, "ro"); + if (r < 0) + log_device_debug_errno(dev, r, "Failed to get read/only state of '%s', ignoring: %m", d->d_name); + else + ro = r > 0; + + r = device_get_sysattr_u64(dev, "size", &size); + if (r < 0) + log_device_debug_errno(dev, r, "Failed to acquire size of device '%s', ignoring: %m", d->d_name); + else + /* the 'size' sysattr is always in multiples of 512, even on 4K sector block devices! */ + assert_se(MUL_ASSIGN_SAFE(&size, 512)); /* Overflow check for coverity */ + } + + break; + } + + default: + log_debug("Volume of unexpected inode type, ignoring: %s", d->d_name); + continue; + } + + r = sd_varlink_replybo( + link, + SD_JSON_BUILD_PAIR_STRING("name", n), + SD_JSON_BUILD_PAIR_STRING("type", inode_type_to_string(st.st_mode)), + SD_JSON_BUILD_PAIR_BOOLEAN("readOnly", ro), + SD_JSON_BUILD_PAIR_CONDITION(size != UINT64_MAX, "sizeBytes", SD_JSON_BUILD_UNSIGNED(size)), + SD_JSON_BUILD_PAIR_CONDITION(used != UINT64_MAX, "usedBytes", SD_JSON_BUILD_UNSIGNED(used))); + if (r < 0) + return r; + } + + return 0; +} + +static int vl_method_list_templates( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + int r; + + assert(link); + assert(FLAGS_SET(flags, SD_VARLINK_METHOD_MORE)); + + struct { + const char *match_name; + } p = {}; + + static const sd_json_dispatch_field dispatch_table[] = { + { "matchName", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, match_name), 0 }, + {} + }; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &p); + if (r != 0) + return r; + + r = sd_varlink_set_sentinel(link, "io.systemd.StorageProvider.NoSuchTemplate"); + if (r < 0) + return r; + + for (Template t = 0; t < _TEMPLATE_MAX; t++) { + const char *n = template_to_string(t); + + if (p.match_name && fnmatch(p.match_name, n, FNM_NOESCAPE) != 0) + continue; + + r = sd_varlink_replybo( + link, + SD_JSON_BUILD_PAIR_STRING("name", n), + SD_JSON_BUILD_PAIR_STRING("type", volume_type_to_string(volume_type_from_template(t)))); + if (r < 0) + return r; + } + + return 0; +} + +static int create_volume_dir( + int storage_fd, + const char *filename, + Template t) { + + int r; + + assert(storage_fd >= 0); + assert(filename); + + XOpenFlags xopen_flags; + switch (t) { + + case TEMPLATE_DIRECTORY: + xopen_flags = 0; + break; + + case TEMPLATE_SUBVOLUME: + xopen_flags = XO_SUBVOLUME; + break; + + default: + return -ENOMEDIUM; /* Recognizable error for: template doesn't apply here */ + } + + _cleanup_free_ char *tf = NULL; + r = tempfn_random(filename, /* extra= */ NULL, &tf); + if (r < 0) + return r; + + _cleanup_close_ int volume_fd = xopenat_full(storage_fd, tf, O_CREAT|O_EXCL|O_RDONLY|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW, xopen_flags, 0700); + if (volume_fd < 0) + return volume_fd; + + _cleanup_close_ int root_fd = xopenat_full(volume_fd, "root", O_CREAT|O_EXCL|O_RDONLY|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW, xopen_flags, 0755); + if (root_fd < 0) { + r = root_fd; + goto fail; + } + + r = RET_NERRNO(fchown(root_fd, FOREIGN_UID_MIN, FOREIGN_UID_MIN)); + if (r < 0) + goto fail; + + r = rename_noreplace(storage_fd, tf, storage_fd, filename); + if (r < 0) + goto fail; + + return TAKE_FD(root_fd); + +fail: + if (root_fd >= 0) { + assert(volume_fd >= 0); + root_fd = safe_close(root_fd); + (void) unlinkat(volume_fd, "root", AT_REMOVEDIR); + } + + if (volume_fd >= 0) { + volume_fd = safe_close(volume_fd); + (void) unlinkat(storage_fd, tf, AT_REMOVEDIR); + } + + return r; +} + +static int create_volume_reg( + int storage_fd, + const char *filename, + Template t, + uint64_t create_size) { + int r; + + assert(storage_fd >= 0); + assert(filename); + + bool sparse; + switch (t) { + + case TEMPLATE_SPARSE_FILE: + sparse = true; + break; + + case TEMPLATE_ALLOCATED_FILE: + sparse = false; + break; + + default: + return -ENOMEDIUM; /* Recognizable error for: template doesn't apply here */ + } + + _cleanup_free_ char *tf = NULL; + _cleanup_close_ int fd = open_tmpfile_linkable_at(storage_fd, filename, O_RDWR|O_CLOEXEC, &tf); + if (fd < 0) + return fd; + + CLEANUP_TMPFILE_AT(storage_fd, tf); + + r = chattr_fd(fd, FS_NOCOW_FL, FS_NOCOW_FL); + if (r < 0 && !ERRNO_IS_IOCTL_NOT_SUPPORTED(r)) + return r; + + if (create_size > 0) { + if (sparse) + r = RET_NERRNO(ftruncate(fd, create_size)); + else + r = RET_NERRNO(fallocate(fd, /* mode= */ 0, /* offset= */ 0, create_size)); + if (r < 0) + return r; + } + + r = RET_NERRNO(fchmod(fd, 0600)); + if (r < 0) + return r; + + r = link_tmpfile_at(fd, storage_fd, tf, filename, /* flags= */ 0); + if (r < 0) + return r; + + tf = mfree(tf); /* disarm clean-up */ + + return TAKE_FD(fd); +} + +static int vl_method_acquire( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + Hashmap **polkit_registry = ASSERT_PTR(userdata); + int r; + + assert(link); + + struct { + const char *name; + CreateMode create_mode; + const char *template; + int read_only; + VolumeType request_as; + uint64_t create_size; + } p = { + .create_mode = CREATE_ANY, + .read_only = -1, + .request_as = _VOLUME_TYPE_INVALID, + .create_size = UINT64_MAX, + }; + + static const sd_json_dispatch_field dispatch_table[] = { + { "name", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, name), SD_JSON_MANDATORY }, + { "createMode", SD_JSON_VARIANT_STRING, json_dispatch_create_mode, voffsetof(p, create_mode), 0 }, + { "template", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, template), 0 }, + { "readOnly", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate, voffsetof(p, read_only), 0 }, + { "requestAs", SD_JSON_VARIANT_STRING, json_dispatch_volume_type, voffsetof(p, request_as), 0 }, + { "createSizeBytes", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, voffsetof(p, create_size), 0 }, + VARLINK_DISPATCH_POLKIT_FIELD, + {} + }; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &p); + if (r != 0) + return r; + + if (!storage_volume_name_is_valid(p.name)) + return sd_varlink_error_invalid_parameter_name(link, "name"); + + if (!IN_SET(p.create_mode, CREATE_ANY, CREATE_OPEN, CREATE_NEW)) + return sd_varlink_error(link, "io.systemd.StorageProvider.CreateNotSupported", NULL); + + /* off_t is signed, hence refuse overly long requests */ + if (p.create_size != UINT64_MAX && p.create_size > INT64_MAX) + return sd_varlink_error_invalid_parameter_name(link, "createSizeBytes"); + + Template t = _TEMPLATE_INVALID; + if (!isempty(p.template)) { + if (!storage_template_name_is_valid(p.template)) + return sd_varlink_error_invalid_parameter_name(link, "template"); + + t = template_from_string(p.template); + if (t < 0) + return sd_varlink_error(link, "io.systemd.StorageProvider.NoSuchTemplate", NULL); + } + + if (p.read_only > 0) { + if (p.create_mode == CREATE_NEW) + return sd_varlink_error_invalid_parameter_name(link, "readOnly"); + + p.create_mode = CREATE_OPEN; + } + + /* Add a suffix so that we are never attempted to open a temporary file assuming it was a valid + * volume. */ + _cleanup_free_ char *filename = strjoin(p.name, ".volume"); + if (!filename) + return log_oom_debug(); + + if (!filename_is_valid(filename)) + return sd_varlink_error_invalid_parameter_name(link, "name"); + + if (arg_runtime_scope != RUNTIME_SCOPE_USER) { + const char *details[] = { + "name", p.name, + NULL + }; + + r = varlink_verify_polkit_async( + link, + /* bus= */ NULL, + "io.systemd.storage.fs.acquire", + details, + polkit_registry); + if (r <= 0) + return r; + } + + _cleanup_close_ int storage_fd = open_storage_dir(); + if (storage_fd < 0) + return storage_fd; + + _cleanup_close_ int pin_fd = -EBADF, real_fd = -EBADF; + r = chaseat(XAT_FDROOT, storage_fd, filename, CHASE_TRIGGER_AUTOFS, /* ret_path= */ NULL, &pin_fd); + if (r < 0) { + if (r != -ENOENT) + return r; + if (p.create_mode == CREATE_OPEN || p.read_only > 0) + return sd_varlink_error(link, "io.systemd.StorageProvider.NoSuchVolume", NULL); + + /* Doesn't exist yet: create it now */ + + if (p.request_as < 0) /* Make a choice: pick default type */ + p.request_as = t < 0 ? VOLUME_DIR : volume_type_from_template(t); + + /* Try to create the volume */ + switch (p.request_as) { + + case VOLUME_DIR: { + + if (t < 0) /* Make a choice: pick default template */ + t = TEMPLATE_SUBVOLUME; + + real_fd = create_volume_dir(storage_fd, filename, t); + break; + } + + case VOLUME_REG: { + if (p.create_size == UINT64_MAX) + return sd_varlink_error(link, "io.systemd.StorageProvider.CreateSizeRequired", NULL); + + if (t < 0) /* Make a choice: pick default template */ + t = TEMPLATE_SPARSE_FILE; + + real_fd = create_volume_reg(storage_fd, filename, t, p.create_size); + break; + } + + case VOLUME_BLK: + /* We don't support creating block devices, we only support if they are symlinked + * into the storage directory. */ + return sd_varlink_error(link, "io.systemd.StorageProvider.CreateNotSupported", NULL); + + default: + assert_not_reached(); + } + + if (real_fd == -ENOMEDIUM) + return sd_varlink_error(link, "io.systemd.StorageProvider.BadTemplate", NULL); + if (real_fd == -EEXIST) { + if (p.create_mode == CREATE_NEW) + return sd_varlink_error(link, "io.systemd.StorageProvider.VolumeExists", NULL); + + /* If we failed to open the volume and reached this point, then the volume already + * exists by now (i.e. we ran into a race). In that case, try to pin it a second time + * (but only once, let's never loop around this). */ + r = chaseat(XAT_FDROOT, storage_fd, filename, CHASE_TRIGGER_AUTOFS, /* ret_path= */ NULL, &pin_fd); + if (r < 0) + return r; + } else if (real_fd < 0) + return real_fd; + + } else if (p.create_mode == CREATE_NEW) + return sd_varlink_error(link, "io.systemd.StorageProvider.VolumeExists", NULL); + + /* At this point, we either already opened the real fd, or we managed to pin it (but not both) */ + assert((real_fd >= 0) != (pin_fd >= 0)); + + /* Let's first settle the volume type */ + struct stat st; + if (fstat(real_fd >= 0 ? real_fd : pin_fd, &st) < 0) + return -errno; + + if (p.request_as == VOLUME_REG) { + /* First, check for the other supported types and generate a nice error */ + if (IN_SET(st.st_mode & S_IFMT, S_IFDIR, S_IFBLK)) + return sd_varlink_error(link, "io.systemd.StorageProvider.WrongType", NULL); + + /* Second verify cover all other types */ + r = stat_verify_regular(&st); + if (r < 0) + return r; + } else if (p.request_as == VOLUME_DIR) { + if (IN_SET(st.st_mode & S_IFMT, S_IFREG, S_IFBLK)) + return sd_varlink_error(link, "io.systemd.StorageProvider.WrongType", NULL); + + r = stat_verify_directory(&st); + if (r < 0) + return r; + } else if (p.request_as == VOLUME_BLK) { + if (IN_SET(st.st_mode & S_IFMT, S_IFREG, S_IFDIR)) + return sd_varlink_error(link, "io.systemd.StorageProvider.WrongType", NULL); + + r = stat_verify_block(&st); + if (r < 0) + return r; + + } else if (S_ISREG(st.st_mode)) + p.request_as = VOLUME_REG; + else if (S_ISDIR(st.st_mode)) + p.request_as = VOLUME_DIR; + else if (S_ISBLK(st.st_mode)) + p.request_as = VOLUME_BLK; + else + return log_debug_errno(SYNTHETIC_ERRNO(EBADF), "Unexpected inode type, refusing."); + + /* Let's now acquire a real fd for the pinned fd, if we still need to */ + if (real_fd < 0) { + assert(pin_fd >= 0); + + XOpenFlags xopen_flags = + (p.read_only < 0 && !S_ISDIR(st.st_mode) ? XO_AUTO_RW_RO : 0); + int open_flags = + (p.read_only < 0 ? 0 : (p.read_only > 0 || S_ISDIR(st.st_mode) ? O_RDONLY : O_RDWR)); + + const char *subdir = NULL; + if (p.request_as == VOLUME_DIR) { + /* We place the root of the directory tree one level down, to separate ownership of + * the inode: the upper inode is owned by the host, the lower one by the volume. This + * matters so that the host one can be owned by the host's root, and the volume one + * by the foreign UID range. */ + subdir = "root"; + open_flags |= O_DIRECTORY|O_NOFOLLOW; + } + + real_fd = xopenat_full(pin_fd, subdir, open_flags|O_CLOEXEC, xopen_flags, /* mode= */ MODE_INVALID); + if (real_fd < 0) + return log_debug_errno(real_fd, "Failed to reopen volume fd for '%s': %m", filename); + + /* In directory mode we might be looking at a different inode node, refresh the stat data */ + if (p.request_as == VOLUME_DIR && fstat(real_fd, &st) < 0) + return -errno; + } + + assert(real_fd >= 0); + + bool ro; + switch (p.request_as) { + + case VOLUME_REG: + case VOLUME_BLK: { + assert(IN_SET(st.st_mode & S_IFMT, S_IFREG, S_IFBLK)); + + int open_flags = fcntl(real_fd, F_GETFL, 0); + if (open_flags < 0) + return -errno; + + ro = (open_flags & O_ACCMODE_STRICT) == O_RDONLY; + break; + } + + case VOLUME_DIR: { + assert(S_ISDIR(st.st_mode)); + + if (!uid_is_foreign(st.st_uid) || + !gid_is_foreign(st.st_gid)) + return log_debug_errno(SYNTHETIC_ERRNO(EPERM), "Storage directory not owned by foreign UID/GID range."); + + /* Let's now generate a new mount for the directory tree, where propagation is disabled, and the + * flags are all set to good defaults */ + _cleanup_close_ int mount_fd = open_tree_attr_with_fallback( + real_fd, + /* path= */ NULL, + OPEN_TREE_CLONE|OPEN_TREE_CLOEXEC|AT_SYMLINK_NOFOLLOW, + &(struct mount_attr) { + .attr_set = (p.read_only > 0 ? MOUNT_ATTR_RDONLY : 0), + .attr_clr = MOUNT_ATTR_NOSUID|MOUNT_ATTR_NOEXEC|MOUNT_ATTR_NODEV, + .propagation = MS_PRIVATE, + }); + if (mount_fd < 0) + return log_debug_errno(mount_fd, "Failed to generate per-volume mount: %m"); + + /* Let's turn on propagation again now that it is disconnected, simply because MS_SHARED is + * generally the default for everything we return. */ + + if (mount_setattr(mount_fd, "", AT_EMPTY_PATH|AT_SYMLINK_NOFOLLOW, + &(struct mount_attr) { + .propagation = MS_SHARED, + }, MOUNT_ATTR_SIZE_VER0) < 0) + return log_debug_errno(errno, "Failed to enable propagation on per-volume mount: %m"); + + close_and_replace(real_fd, mount_fd); + + r = fd_is_read_only_fs(real_fd); + if (r < 0) + return r; + + ro = r > 0; + break; + } + + default: + assert_not_reached(); + } + + if (p.read_only == 0 && ro) + return sd_varlink_error(link, "io.systemd.StorageProvider.ReadOnlyVolume", NULL); + + int idx = sd_varlink_push_fd(link, real_fd); + if (idx < 0) + return idx; + + TAKE_FD(real_fd); + + return sd_varlink_replybo( + link, + SD_JSON_BUILD_PAIR_INTEGER("fileDescriptorIndex", idx), + SD_JSON_BUILD_PAIR_STRING("type", inode_type_to_string(st.st_mode)), + SD_JSON_BUILD_PAIR_BOOLEAN("readOnly", ro), + SD_JSON_BUILD_PAIR_CONDITION(p.request_as == VOLUME_DIR, "baseUID", SD_JSON_BUILD_INTEGER(FOREIGN_UID_BASE)), + SD_JSON_BUILD_PAIR_CONDITION(p.request_as == VOLUME_DIR, "baseGID", SD_JSON_BUILD_INTEGER(FOREIGN_UID_BASE))); +} + +static int vl_server(void) { + int r; + + _cleanup_(hashmap_freep) Hashmap *polkit_registry = NULL; + _cleanup_(sd_varlink_server_unrefp) sd_varlink_server *varlink_server = NULL; + r = varlink_server_new( + &varlink_server, + SD_VARLINK_SERVER_HANDLE_SIGINT| + SD_VARLINK_SERVER_HANDLE_SIGTERM| + SD_VARLINK_SERVER_ALLOW_FD_PASSING_OUTPUT| + SD_VARLINK_SERVER_INHERIT_USERDATA, + &polkit_registry); + if (r < 0) + return log_error_errno(r, "Failed to allocate Varlink server: %m"); + + r = sd_varlink_server_add_interface(varlink_server, &vl_interface_io_systemd_StorageProvider); + if (r < 0) + return log_error_errno(r, "Failed to add Varlink interface: %m"); + + r = sd_varlink_server_bind_method_many( + varlink_server, + "io.systemd.StorageProvider.Acquire", vl_method_acquire, + "io.systemd.StorageProvider.ListVolumes", vl_method_list_volumes, + "io.systemd.StorageProvider.ListTemplates", vl_method_list_templates); + if (r < 0) + return log_error_errno(r, "Failed to bind Varlink methods: %m"); + + r = sd_varlink_server_loop_auto(varlink_server); + if (r < 0) + return log_error_errno(r, "Failed to run Varlink event loop: %m"); + + return 0; +} + +static int help(void) { + int r; + + help_cmdline("[OPTIONS...]"); + help_abstract("Simple file system backed storage provider"); + + _cleanup_(table_unrefp) Table *options = NULL; + r = option_parser_get_help_table(&options); + if (r < 0) + return r; + + help_section("Options:"); + + r = table_print_or_warn(options); + if (r < 0) + return r; + + help_man_page_reference("systemd-storage-fs", "8"); + return 0; +} + +static int parse_argv(int argc, char *argv[]) { + assert(argc >= 0); + assert(argv); + + OptionParser opts = { argc, argv }; + FOREACH_OPTION(c, &opts, /* on_error= */ return c) + switch (c) { + + OPTION_COMMON_HELP: + return help(); + + OPTION_COMMON_VERSION: + return version(); + + OPTION_LONG("system", NULL, "Operate in system mode"): + arg_runtime_scope = RUNTIME_SCOPE_SYSTEM; + break; + + OPTION_LONG("user", NULL, "Operate in user mode"): + arg_runtime_scope = RUNTIME_SCOPE_USER; + break; + } + + if (option_parser_get_n_args(&opts) > 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "This program takes no arguments."); + + return 1; +} + +static int run(int argc, char* argv[]) { + int r; + + log_setup(); + + r = parse_argv(argc, argv); + if (r <= 0) + return r; + + return vl_server(); +} + +DEFINE_MAIN_FUNCTION(run); diff --git a/units/meson.build b/units/meson.build index 3cac3c876a..0f7ce75bd8 100644 --- a/units/meson.build +++ b/units/meson.build @@ -811,6 +811,13 @@ units = [ { 'file' : 'systemd-storage-block@.service.in', }, + { + 'file' : 'systemd-storage-fs.socket', + 'symlinks' : ['sockets.target.wants/'] + }, + { + 'file' : 'systemd-storage-fs@.service.in', + }, { 'file' : 'systemd-storagetm.service.in', 'conditions' : ['ENABLE_STORAGETM'], diff --git a/units/systemd-storage-fs.socket b/units/systemd-storage-fs.socket new file mode 100644 index 0000000000..c83cf0a11f --- /dev/null +++ b/units/systemd-storage-fs.socket @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Simple File System Backed Storage Provider +Documentation=man:systemd-storage-fs@.service(8) +DefaultDependencies=no +RequiresMountsFor=/var/lib/storage +Before=sockets.target + +[Socket] +ListenStream=/run/systemd/io.systemd.StorageProvider/fs +FileDescriptorName=varlink +SocketMode=0666 +Accept=yes +MaxConnectionsPerSource=16 + +[Install] +WantedBy=sockets.target diff --git a/units/systemd-storage-fs@.service.in b/units/systemd-storage-fs@.service.in new file mode 100644 index 0000000000..39b6da36ee --- /dev/null +++ b/units/systemd-storage-fs@.service.in @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Simple File System Backed Storage Provider +Documentation=man:systemd-storage-fs@.service(8) +DefaultDependencies=no +RequiresMountsFor=/var/lib/storage +Conflicts=shutdown.target initrd-switch-root.target +Before=shutdown.target initrd-switch-root.target + +[Service] +ExecStart=-{{LIBEXECDIR}}/systemd-storage-fs diff --git a/units/user/meson.build b/units/user/meson.build index a9c6d44281..39c41a4c1c 100644 --- a/units/user/meson.build +++ b/units/user/meson.build @@ -61,6 +61,13 @@ units = [ 'file' : 'systemd-journalctl.socket', 'symlinks' : ['sockets.target.wants/'], }, + { + 'file' : 'systemd-storage-fs.socket', + 'symlinks' : ['sockets.target.wants/'] + }, + { + 'file' : 'systemd-storage-fs@.service.in', + }, { 'file' : 'systemd-tmpfiles-clean.service' }, { 'file' : 'systemd-tmpfiles-clean.timer' }, { 'file' : 'systemd-tmpfiles-setup.service' }, diff --git a/units/user/systemd-storage-fs.socket b/units/user/systemd-storage-fs.socket new file mode 100644 index 0000000000..fa8018b2e8 --- /dev/null +++ b/units/user/systemd-storage-fs.socket @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Simple File System Backed Storage Provider +Documentation=man:systemd-storage-fs.service(8) +Before=sockets.target + +[Socket] +ListenStream=%t/systemd/io.systemd.StorageProvider/fs +FileDescriptorName=varlink +SocketMode=0600 +Accept=yes +MaxConnectionsPerSource=16 + +[Install] +WantedBy=sockets.target diff --git a/units/user/systemd-storage-fs@.service.in b/units/user/systemd-storage-fs@.service.in new file mode 100644 index 0000000000..95afa9165f --- /dev/null +++ b/units/user/systemd-storage-fs@.service.in @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Simple File System Backed Storage Provider +Documentation=man:systemd-storage-fs.service(8) + +[Service] +ExecStart=-{{LIBEXECDIR}}/systemd-storage-fs --user From 4002a7e8be0965b3bbc4aa1833298314464778d8 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 22 Apr 2026 23:44:04 +0200 Subject: [PATCH 4/6] storage: add 'storagectl' command-line tool CLI for inspecting and using storage providers. Scans /run/systemd/io.systemd.StorageProvider/ (or the user-mode equivalent) for AF_UNIX sockets and talks to each one over Varlink. Verbs: "volumes" lists volumes across all providers, "templates" lists supported creation templates, "providers" lists the endpoints themselves. Also installed as a mount.storage helper, so 'mount -t storage PROVIDER:VOLUME /mnt' (or 'mount -t storage.' to put a fresh filesystem on a block volume) acquires the volume and mounts it. Ships with bash/zsh completions and a man page. --- man/rules/meson.build | 1 + man/storagectl.xml | 281 +++++++++++ shell-completion/bash/meson.build | 1 + shell-completion/bash/storagectl | 74 +++ shell-completion/zsh/_storagectl | 35 ++ shell-completion/zsh/meson.build | 1 + src/storage/meson.build | 10 + src/storage/storagectl.c | 812 ++++++++++++++++++++++++++++++ 8 files changed, 1215 insertions(+) create mode 100644 man/storagectl.xml create mode 100644 shell-completion/bash/storagectl create mode 100644 shell-completion/zsh/_storagectl create mode 100644 src/storage/storagectl.c diff --git a/man/rules/meson.build b/man/rules/meson.build index 7f4fa07f7b..719838064c 100644 --- a/man/rules/meson.build +++ b/man/rules/meson.build @@ -972,6 +972,7 @@ manpages = [ ['sd_watchdog_enabled', '3', [], ''], ['shutdown', '8', [], ''], ['smbios-type-11', '7', [], ''], + ['storagectl', '1', ['mount.storage'], ''], ['sysctl.d', '5', [], ''], ['sysext.conf', '5', diff --git a/man/storagectl.xml b/man/storagectl.xml new file mode 100644 index 0000000000..5fddf3ca08 --- /dev/null +++ b/man/storagectl.xml @@ -0,0 +1,281 @@ + + + + + + + + storagectl + systemd + + + + storagectl + 1 + + + + storagectl + mount.storage + Enumerate and mount storage volumes provided by storage providers + + + + + storagectl + OPTIONS + COMMAND + NAME + + + + mount + -t + storage + PROVIDER:VOLUME + DIRECTORY + + + + mount + -t + storage.FSTYPE + PROVIDER:VOLUME + DIRECTORY + + + + + Description + + storagectl may be used to inspect storage providers and the storage + volumes they expose. A storage provider is a service implementing the + io.systemd.StorageProvider Varlink + interface, registered as an AF_UNIX socket below the well-known socket directory + /run/systemd/io.systemd.StorageProvider/ (in system mode) or + $XDG_RUNTIME_DIR/systemd/io.systemd.StorageProvider/ (in user mode). The two + storage providers shipped with systemd are + systemd-storage-block@.service8, + which exposes the system's block devices, and + systemd-storage-fs@.service8, + which exposes regular files and directories from a backing file system. + + The tool also provides a mount8 helper + for the file system type storage, which permits mounting storage volumes to arbitrary + places. See "Use as a mount helper" below for details. + + + + Commands + + The following commands are understood: + + + + + volumes GLOB + + List storage volumes provided by all storage providers running on the + system (or, with , in the user runtime). The optional + GLOB argument is a shell-style pattern (see + fnmatch3) + that filters the result by volume name. The output is a table containing the providing + service, the volume name, its type (blk, reg or + dir), whether it is read-only, and — if known — its size and the number + of bytes used. + + This is the default command if none is specified. + + + + + + templates GLOB + + List volume templates supported by the running storage providers. Templates + encapsulate a configuration to use when creating volumes on-the-fly, when they are acquired. Template + support is an optional feature for providers, and only applies to providers that allow creation + of volumes on-the-fly. See the respective provider documentation for details, for example + systemd-storage-fs@.service8. The + optional GLOB argument filters by template name. Storage providers that do + not implement template-based volume creation (such as the block-device provider) do not contribute to + this output. + + + + + + providers + + List the storage providers known to the system. This is determined by scanning the + well-known socket directory for AF_UNIX sockets that look like + io.systemd.StorageProvider endpoints. For each provider it is also reported + whether the socket can currently be connected to. + + + + + + + + Options + + The following options are understood: + + + + + + Operate on system-wide storage providers. Sockets are looked for in + /run/systemd/io.systemd.StorageProvider/. This is the default. + + + + + + + + Operate on per-user storage providers. Sockets are looked for in + $XDG_RUNTIME_DIR/systemd/io.systemd.StorageProvider/. + + + + + + + + + + + + + + + Use as a mount helper + + The tool provides the /sbin/mount.storage alias, implementing the + mount8 + "external helper" interface, allowing storage volumes to be mounted with the regular + mount command. The volume to mount is encoded as the source of the mount, + in the form + PROVIDER:VOLUME, where + PROVIDER is the name of a storage provider (as listed by + storagectl providers) and VOLUME is the volume + name. Two file system type spellings are recognized: + + + + storage + + Acquires a directory volume and bind-mounts its directory tree onto the + target. + + + + + + storage.FSTYPE + + Acquires a regular file or block device volume and mounts it as a file system of type + FSTYPE (for example storage.ext4, + storage.btrfs, …). + + + + + + The standard mount options are forwarded to + mount. In addition, the following storage.-prefixed + options are interpreted by mount.storage itself and stripped from the + forwarded list: + + + + MODE + + Takes one of any (open if it exists, otherwise create — the + default), open (fail if the volume does not yet exist) or new + (fail if the volume already exists). + + + + + + NAME + + The template to use when creating a new volume, if it is missing and the provider + supports on-the-fly creation of volumes. + + + + + + BYTES + + When creating a new volume on-the-fly, the size in bytes to allocate. Accepts the + usual K/M/G/T suffixes + (base 1024). Required when creating a regular file volume. + + + + + + + + + Examples + + + Enumerate available storage providers, volumes and templates + + $ storagectl providers +$ storagectl volumes +$ storagectl volumes '*foo*' +$ storagectl templates + + + + Mount a directory volume from the file system provider + + # mount -t storage fs:myvol /mnt/myvol + + If the volume myvol does not yet exist, it will be created using + the default subvolume template. + + + + Create and mount an ext4 file system from a regular file. + + # mount -t storage.ext4 fs:scratch /mnt/scratch -o loop + + + + Mount a block device volume read-only + + # mount -t storage.ext4 -o ro block:/dev/disk/by-id/usb-foo /mnt/foo + + + + + Exit status + + On success, 0 is returned, a non-zero failure code otherwise. + + + + + + See Also + + systemd1 + systemd-storage-block@.service8 + systemd-storage-fs@.service8 + varlinkctl1 + mount8 + + + + diff --git a/shell-completion/bash/meson.build b/shell-completion/bash/meson.build index 154910979e..b0e56608e8 100644 --- a/shell-completion/bash/meson.build +++ b/shell-completion/bash/meson.build @@ -36,6 +36,7 @@ foreach item : [ ['portablectl', 'ENABLE_PORTABLED'], ['resolvectl', 'ENABLE_RESOLVE'], ['run0', ''], + ['storagectl', ''], ['systemd-analyze', ''], ['systemd-cat', ''], ['systemd-cgls', ''], diff --git a/shell-completion/bash/storagectl b/shell-completion/bash/storagectl new file mode 100644 index 0000000000..5aefc30ed1 --- /dev/null +++ b/shell-completion/bash/storagectl @@ -0,0 +1,74 @@ +# shellcheck shell=bash +# storagectl(1) completion -*- shell-script -*- +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# systemd is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with systemd; If not, see . + +__contains_word () { + local w word=$1; shift + for w in "$@"; do + [[ $w = "$word" ]] && return + done +} + +_storagectl() { + local i verb comps + local cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD-1]} + + local -A OPTS=( + [STANDALONE]='-h --help --version --no-pager --no-legend --no-ask-password + --system --user' + [ARG]='--json' + ) + + if __contains_word "$prev" ${OPTS[ARG]}; then + case $prev in + --json) + comps=$( storagectl --json=help 2>/dev/null ) + ;; + esac + COMPREPLY=( $(compgen -W '$comps' -- "$cur") ) + return 0 + fi + + if [[ "$cur" = -* ]]; then + COMPREPLY=( $(compgen -W '${OPTS[*]}' -- "$cur") ) + return 0 + fi + + local -A VERBS=( + [STANDALONE]='volumes templates providers help' + ) + + for ((i=0; i < COMP_CWORD; i++)); do + if __contains_word "${COMP_WORDS[i]}" ${VERBS[*]} && + ! __contains_word "${COMP_WORDS[i-1]}" ${OPTS[ARG]}; then + verb=${COMP_WORDS[i]} + break + fi + done + + if [[ -z ${verb-} ]]; then + comps=${VERBS[*]} + elif __contains_word "$verb" ${VERBS[STANDALONE]}; then + comps='' + fi + + COMPREPLY=( $(compgen -W '$comps' -- "$cur") ) + return 0 +} + +complete -F _storagectl storagectl diff --git a/shell-completion/zsh/_storagectl b/shell-completion/zsh/_storagectl new file mode 100644 index 0000000000..b2fdf595a1 --- /dev/null +++ b/shell-completion/zsh/_storagectl @@ -0,0 +1,35 @@ +#compdef storagectl +# SPDX-License-Identifier: LGPL-2.1-or-later + +(( $+functions[_storagectl_commands] )) || _storagectl_commands() +{ + local -a _storagectl_cmds + _storagectl_cmds=( + "volumes:List storage volumes" + "templates:List storage volume templates" + "providers:List storage providers" + "help:Prints a short help text and exits" + ) + if (( CURRENT == 1 )); then + _describe -t commands 'storagectl command' _storagectl_cmds + else + local curcontext="$curcontext" + cmd="${${_storagectl_cmds[(r)$words[1]:*]%%:*}}" + if (( $+functions[_storagectl_$cmd] )); then + _storagectl_$cmd + else + _message "no more options" + fi + fi +} + +_arguments \ + '(- *)'{-h,--help}'[Prints a short help text and exits.]' \ + '(- *)--version[Prints a short version string and exits.]' \ + '--no-pager[Do not pipe output into a pager]' \ + '--no-legend[Do not show the headers and footers]' \ + '--no-ask-password[Do not query the user for authentication]' \ + '--json=[Show output as JSON]:mode:(pretty short off help)' \ + '--system[Operate in system mode]' \ + '--user[Operate in user mode]' \ + '*::storagectl command:_storagectl_commands' diff --git a/shell-completion/zsh/meson.build b/shell-completion/zsh/meson.build index b1bff151e4..6cc8a2d57f 100644 --- a/shell-completion/zsh/meson.build +++ b/shell-completion/zsh/meson.build @@ -33,6 +33,7 @@ foreach item : [ ['_sd_machines', 'ENABLE_MACHINED'], ['_sd_outputmodes', ''], ['_sd_unit_files', ''], + ['_storagectl', ''], ['_systemd', ''], ['_systemd-analyze', ''], ['_systemd-delta', ''], diff --git a/src/storage/meson.build b/src/storage/meson.build index 05c5e24ece..21456141de 100644 --- a/src/storage/meson.build +++ b/src/storage/meson.build @@ -11,7 +11,17 @@ executables += [ 'sources' : files('storage-fs.c'), 'objects' : ['systemd-storage-block'], }, + executable_template + { + 'name' : 'storagectl', + 'public' : true, + 'sources' : files('storagectl.c'), + 'objects' : ['systemd-storage-block'], + }, ] +install_symlink('mount.storage', + pointing_to : sbin_to_bin + 'storagectl', + install_dir : sbindir) + install_data('io.systemd.storage.policy', install_dir : polkitpolicydir) diff --git a/src/storage/storagectl.c b/src/storage/storagectl.c new file mode 100644 index 0000000000..a21072e78f --- /dev/null +++ b/src/storage/storagectl.c @@ -0,0 +1,812 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-varlink.h" + +#include +#include +#include +#include + +#include "alloc-util.h" +#include "ansi-color.h" +#include "argv-util.h" +#include "build.h" +#include "bus-util.h" +#include "errno-list.h" +#include "escape.h" +#include "extract-word.h" +#include "fd-util.h" +#include "format-table.h" +#include "format-util.h" +#include "help-util.h" +#include "json-util.h" +#include "main-func.h" +#include "mount-util.h" +#include "namespace-util.h" +#include "options.h" +#include "parse-argument.h" +#include "parse-util.h" +#include "path-lookup.h" +#include "path-util.h" +#include "polkit-agent.h" +#include "recurse-dir.h" +#include "runtime-scope.h" +#include "socket-util.h" +#include "stat-util.h" +#include "stdio-util.h" +#include "storage-util.h" +#include "string-util.h" +#include "strv.h" +#include "user-util.h" +#include "varlink-util.h" +#include "verbs.h" + +static sd_json_format_flags_t arg_json_format_flags = SD_JSON_FORMAT_OFF; +static PagerFlags arg_pager_flags = 0; +static bool arg_legend = true; +static bool arg_ask_password = true; +static RuntimeScope arg_runtime_scope = RUNTIME_SCOPE_SYSTEM; + +static int help(void) { + int r; + + help_cmdline("[OPTIONS...] COMMAND"); + help_abstract("Enumerate storage volumes and providers."); + + _cleanup_(table_unrefp) Table *verbs = NULL; + r = verbs_get_help_table(&verbs); + if (r < 0) + return r; + + _cleanup_(table_unrefp) Table *options = NULL; + r = option_parser_get_help_table(&options); + if (r < 0) + return r; + + (void) table_sync_column_widths(0, verbs, options); + + help_section("Commands:"); + + r = table_print_or_warn(verbs); + if (r < 0) + return r; + + help_section("Options:"); + + r = table_print_or_warn(options); + if (r < 0) + return r; + + help_man_page_reference("storagectl", "1"); + return 0; +} + +VERB_COMMON_HELP_HIDDEN(help); + +static const char *ro_color(int ro) { + if (ro > 0) + return ansi_highlight_red(); + if (ro == 0) + return ansi_highlight_green(); + + return NULL; +} + +static int on_list_reply( + sd_varlink *link, + sd_json_variant *parameters, + const char *error_id, + sd_varlink_reply_flags_t flags, + void* userdata) { + + Table *t = ASSERT_PTR(userdata); + int r; + + assert(link); + + const char *d = ASSERT_PTR(sd_varlink_get_description(link)); + + if (error_id) { + log_debug("%s: Received error '%s', ignoring.", d, error_id); + return 0; + } + + _cleanup_free_ char *provider = NULL; + r = path_extract_filename(d, &provider); + if (r < 0) + return log_error_errno(r, "Failed to extract provider name from socket path: %m"); + + struct { + const char *name; + const char *type; + int read_only; + uint64_t size_bytes; + uint64_t used_bytes; + } p = { + .read_only = -1, + .size_bytes = UINT64_MAX, + .used_bytes = UINT64_MAX, + }; + + static const sd_json_dispatch_field dispatch_table[] = { + { "name", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, name), 0 }, + { "type", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, type), 0 }, + { "readOnly", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate, voffsetof(p, read_only), 0 }, + { "sizeBytes", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, voffsetof(p, size_bytes), 0 }, + { "usedBytes", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint64, voffsetof(p, used_bytes), 0 }, + {} + }; + + r = sd_json_dispatch(parameters, dispatch_table, SD_JSON_ALLOW_EXTENSIONS, &p); + if (r < 0) + return log_error_errno(r, "Failed to decode List() reply: %m"); + + r = table_add_many( + t, + TABLE_STRING, provider, + TABLE_STRING, p.name, + TABLE_STRING, p.type, + TABLE_TRISTATE, p.read_only, + TABLE_SET_COLOR, ro_color(p.read_only)); + if (r < 0) + return table_log_add_error(r); + + if (p.size_bytes == UINT64_MAX) + r = table_add_many(t, TABLE_EMPTY, TABLE_SET_ALIGN_PERCENT, 100); + else + r = table_add_many(t, TABLE_SIZE, p.size_bytes, TABLE_SET_ALIGN_PERCENT, 100); + if (r < 0) + return table_log_add_error(r); + + if (p.used_bytes == UINT64_MAX) + r = table_add_many(t, TABLE_EMPTY, TABLE_SET_ALIGN_PERCENT, 100); + else + r = table_add_many(t, TABLE_SIZE, p.used_bytes, TABLE_SET_ALIGN_PERCENT, 100); + if (r < 0) + return table_log_add_error(r); + + return 0; +} + +VERB(verb_list_volumes, "volumes", "GLOB", /* min_args= */ VERB_ANY, /* max_args= */ 2, VERB_DEFAULT, "List storage volumes"); +static int verb_list_volumes(int argc, char *argv[], uintptr_t data, void *userdata) { + int r; + + assert(argc <= 2); + + _cleanup_free_ char *socket_path = NULL; + r = runtime_directory_generic(arg_runtime_scope, "systemd/io.systemd.StorageProvider", &socket_path); + if (r < 0) + return log_error_errno(r, "Failed to determine socket directory: %m"); + + _cleanup_(table_unrefp) Table *t = table_new("provider", "name", "type", "ro", "size", "used"); + if (!t) + return log_oom(); + + (void) table_set_sort(t, (size_t) 0, (size_t) 1); + table_set_ersatz_string(t, TABLE_ERSATZ_DASH); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + if (argc >= 2) { + r = sd_json_buildo( + &v, + SD_JSON_BUILD_PAIR_STRING("matchName", argv[1])); + if (r < 0) + return log_oom(); + } + + ssize_t n = varlink_execute_directory( + socket_path, + "io.systemd.StorageProvider.ListVolumes", + v, + /* more= */ true, + /* timeout_usec= */ 0, /* 0 means default */ + on_list_reply, + t); + if (n < 0 && n != -ENOENT) + return log_error_errno(n, "Failed to enumerate storage volumes: %m"); + + if (!table_isempty(t)) { + r = table_print_with_pager(t, arg_json_format_flags, arg_pager_flags, arg_legend); + if (r < 0) + return r; + } + + if (arg_legend && FLAGS_SET(arg_json_format_flags, SD_JSON_FORMAT_OFF)) { + if (table_isempty(t)) + printf("No storage volumes.\n"); + else + printf("\n%zu storage volumes listed.\n", table_get_rows(t) - 1); + } + + return 0; +} + +static int on_list_templates_reply( + sd_varlink *link, + sd_json_variant *parameters, + const char *error_id, + sd_varlink_reply_flags_t flags, + void* userdata) { + + Table *t = ASSERT_PTR(userdata); + int r; + + assert(link); + + const char *d = ASSERT_PTR(sd_varlink_get_description(link)); + + if (error_id) { + log_debug("%s: Received error '%s', ignoring.", d, error_id); + return 0; + } + + _cleanup_free_ char *provider = NULL; + r = path_extract_filename(d, &provider); + if (r < 0) + return log_error_errno(r, "Failed to extract provider name from socket path: %m"); + + struct { + const char *name; + const char *type; + } p = { + }; + + static const sd_json_dispatch_field dispatch_table[] = { + { "name", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, name), 0 }, + { "type", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, type), 0 }, + {} + }; + + r = sd_json_dispatch(parameters, dispatch_table, SD_JSON_ALLOW_EXTENSIONS, &p); + if (r < 0) + return log_error_errno(r, "Failed to decode ListTemplates() reply: %m"); + + r = table_add_many( + t, + TABLE_STRING, provider, + TABLE_STRING, p.name, + TABLE_STRING, p.type); + if (r < 0) + return table_log_add_error(r); + + return 0; +} + +VERB(verb_templates, "templates", "GLOB", /* min_args= */ VERB_ANY, /* max_args= */ 2, /* flags= */ 0, "List storage volume templates"); +static int verb_templates(int argc, char *argv[], uintptr_t data, void *userdata) { + int r; + + assert(argc <= 2); + + _cleanup_free_ char *socket_path = NULL; + r = runtime_directory_generic(arg_runtime_scope, "systemd/io.systemd.StorageProvider", &socket_path); + if (r < 0) + return log_error_errno(r, "Failed to determine socket directory: %m"); + + _cleanup_(table_unrefp) Table *t = table_new("provider", "name", "type"); + if (!t) + return log_oom(); + + (void) table_set_sort(t, (size_t) 0, (size_t) 1); + table_set_ersatz_string(t, TABLE_ERSATZ_DASH); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + if (argc >= 2) { + r = sd_json_buildo( + &v, + SD_JSON_BUILD_PAIR_STRING("matchName", argv[1])); + if (r < 0) + return log_oom(); + } + + ssize_t n = varlink_execute_directory( + socket_path, + "io.systemd.StorageProvider.ListTemplates", + v, + /* more= */ true, + /* timeout_usec= */ 0, /* 0 means default */ + on_list_templates_reply, + t); + if (n < 0 && n != -ENOENT) + return log_error_errno(n, "Failed to enumerate storage volume templates: %m"); + + if (!table_isempty(t)) { + r = table_print_with_pager(t, arg_json_format_flags, arg_pager_flags, arg_legend); + if (r < 0) + return r; + } + + if (arg_legend && FLAGS_SET(arg_json_format_flags, SD_JSON_FORMAT_OFF)) { + if (table_isempty(t)) + printf("No templates.\n"); + else + printf("\n%zu templates listed.\n", table_get_rows(t) - 1); + } + + return 0; +} + +VERB_NOARG(verb_providers, "providers", "List storage providers"); +static int verb_providers(int argc, char *argv[], uintptr_t data, void *userdata) { + int r; + + _cleanup_free_ char *socket_path = NULL; + r = runtime_directory_generic(arg_runtime_scope, "systemd/io.systemd.StorageProvider", &socket_path); + if (r < 0) + return log_error_errno(r, "Failed to determine socket directory: %m"); + + _cleanup_(table_unrefp) Table *t = table_new("provider", "listening"); + if (!t) + return log_oom(); + + (void) table_set_sort(t, (size_t) 0); + table_set_ersatz_string(t, TABLE_ERSATZ_DASH); + + _cleanup_close_ int fd = open(socket_path, O_RDONLY|O_CLOEXEC|O_DIRECTORY); + if (fd < 0) { + if (errno != ENOENT) + return log_error_errno(errno, "Failed to open '%s': %m", socket_path); + } else { + _cleanup_free_ DirectoryEntries *dentries = NULL; + r = readdir_all(fd, RECURSE_DIR_SORT|RECURSE_DIR_IGNORE_DOT|RECURSE_DIR_ENSURE_TYPE, &dentries); + if (r < 0) + return log_error_errno(r, "Failed to enumerate '%s': %m", socket_path); + + FOREACH_ARRAY(dp, dentries->entries, dentries->n_entries) { + struct dirent *de = *dp; + + if (de->d_type != DT_SOCK) + continue; + + if (!storage_provider_name_is_valid(de->d_name)) + continue; + + _cleanup_close_ int socket_fd = socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0); + if (socket_fd < 0) + return log_error_errno(errno, "Failed to allocate AF_UNIX/SOCK_STREAM socket: %m"); + + _cleanup_free_ char *no = NULL; + r = connect_unix_path(socket_fd, fd, de->d_name); + if (r < 0) { + no = strjoin("no (", ERRNO_NAME(r), ")"); + if (!no) + return log_oom(); + } + + r = table_add_many(t, + TABLE_STRING, de->d_name, + TABLE_STRING, no ?: "yes", + TABLE_SET_COLOR, ansi_highlight_green_red(!no)); + if (r < 0) + return table_log_add_error(r); + } + } + + if (!table_isempty(t)) { + r = table_print_with_pager(t, arg_json_format_flags, arg_pager_flags, arg_legend); + if (r < 0) + return r; + } + + if (arg_legend && FLAGS_SET(arg_json_format_flags, SD_JSON_FORMAT_OFF)) { + if (table_isempty(t)) + printf("No providers.\n"); + else + printf("\n%zu providers listed.\n", table_get_rows(t) - 1); + } + + return 0; +} + +static int parse_argv(int argc, char *argv[], char ***args) { + int r; + + assert(argc >= 0); + assert(argv); + + OptionParser opts = { argc, argv }; + FOREACH_OPTION(c, &opts, /* on_error= */ return c) + switch (c) { + + OPTION_COMMON_HELP: + return help(); + + OPTION_COMMON_VERSION: + return version(); + + OPTION_COMMON_NO_PAGER: + arg_pager_flags |= PAGER_DISABLE; + break; + + OPTION_COMMON_NO_LEGEND: + arg_legend = false; + break; + + OPTION_COMMON_JSON: + r = parse_json_argument(opts.arg, &arg_json_format_flags); + if (r <= 0) + return r; + break; + + OPTION_COMMON_NO_ASK_PASSWORD: + arg_ask_password = false; + break; + + OPTION_LONG("system", NULL, "Operate in system mode"): + arg_runtime_scope = RUNTIME_SCOPE_SYSTEM; + break; + + OPTION_LONG("user", NULL, "Operate in user mode"): + arg_runtime_scope = RUNTIME_SCOPE_USER; + break; + } + + *args = option_parser_get_args(&opts); + return 1; +} + +static int run_as_mount_helper(int argc, char *argv[]) { + int c, r; + + /* Implements util-linux "external helper" command line interface, as per mount(8) man page. + * + * Usage: + * + * mount -t storage fs:mydirvolume /some/place # Directory volumes + * mount -t storage.ext4 fs:myblkvolume /some/place # Block volumes + */ + + const char *fstype = NULL, *options = NULL; + bool fake = false; + + while ((c = getopt(argc, argv, "sfnvN:o:t:")) >= 0) { + switch (c) { + + case 'f': + fake = true; + break; + + case 'o': + options = optarg; + break; + + case 't': + fstype = startswith(optarg, "storage."); + if (fstype) { + /* Paranoia: don't allow "storage.storage.storage.…" chains... */ + if (startswith(fstype, "storage.") || streq(fstype, "storage")) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Refusing nested storage volumes."); + } else if (!streq(optarg, "storage")) + log_warning("Unexpected file system type '%s', ignoring.", optarg); + + break; + + case 's': /* sloppy mount options */ + case 'n': /* aka --no-mtab */ + case 'v': /* aka --verbose */ + log_debug("Ignoring option -%c, not implemented.", c); + break; + + case 'N': /* aka --namespace= */ + return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "Option -%c is not implemented, refusing.", c); + + case '?': + return -EINVAL; + } + } + + if (optind + 2 != argc) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "Expected a storage volume specification and target directory as only arguments."); + + const char *colon = strchr(argv[optind], ':'); + if (!colon) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid storage volume specification, refusing: %s", argv[optind]); + + _cleanup_free_ char *provider = strndup(argv[optind], colon - argv[optind]); + if (!provider) + return log_oom(); + if (!storage_provider_name_is_valid(provider)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid storage provider name: %s", provider); + + _cleanup_free_ char *name = strdup(colon + 1); + if (!name) + return log_oom(); + if (!storage_volume_name_is_valid(name)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid storage volume name: %s", name); + + _cleanup_free_ char *path = NULL; + r = parse_path_argument(argv[optind+1], /* suppress_root= */ false, &path); + if (r < 0) + return r; + + _cleanup_free_ char *filtered = NULL, *template = NULL; + CreateMode create_mode = _CREATE_MODE_INVALID; + uint64_t create_size = UINT64_MAX; + int read_only = -1; + for (const char *p = options;;) { + _cleanup_free_ char *word = NULL; + + r = extract_first_word(&p, &word, ",", EXTRACT_KEEP_QUOTE|EXTRACT_UNESCAPE_SEPARATORS); + if (r < 0) + return log_error_errno(r, "Failed to extract mount option: %m"); + if (r == 0) + break; + + const char *t = startswith(word, "storage."); + if (t) { + const char *v; + if ((v = startswith(t, "create="))) { + create_mode = create_mode_from_string(v); + if (create_mode < 0) + return log_error_errno(create_mode, "Failed to parse storage.create= parameter: %s", v); + } else if ((v = startswith(t, "create-size="))) { + r = parse_size(v, /* base= */ 1024, &create_size); + if (r < 0) + return log_error_errno(r, "Failed to parse storage.create-size= parameter: %s", v); + } else if ((v = startswith(t, "template="))) { + if (!storage_template_name_is_valid(v)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Invalid template name, refusing: %s", v); + + r = free_and_strdup(&template, v); + if (r < 0) + return log_oom(); + } else + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Unknown mount option '%s', refusing.", word); + } else if (streq(word, "ro")) + read_only = true; + else if (streq(word, "rw")) + read_only = false; + else if (!strextend_with_separator(&filtered, ",", word)) + return log_oom(); + } + + if (fake) + return 0; + + _cleanup_free_ char *socket_path = NULL; + r = runtime_directory_generic(arg_runtime_scope, "systemd/io.systemd.StorageProvider", &socket_path); + if (r < 0) + return log_error_errno(r, "Failed to determine socket directory: %m"); + + if (!path_extend(&socket_path, provider)) + return log_oom(); + + _cleanup_(sd_varlink_unrefp) sd_varlink *link = NULL; + r = sd_varlink_connect_address(&link, socket_path); + if (r < 0) + return log_error_errno(r, "Failed to connect to '%s': %m", socket_path); + + r = sd_varlink_set_allow_fd_passing_input(link, true); + if (r < 0) + return log_error_errno(r, "Failed to enable file descriptor passing: %m"); + + (void) polkit_agent_open_if_enabled(BUS_TRANSPORT_LOCAL, arg_ask_password); + + sd_json_variant *mreply = NULL; + const char *merror_id = NULL, *vtype = fstype ? "reg" : "dir"; + r = sd_varlink_callbo( + link, + "io.systemd.StorageProvider.Acquire", + &mreply, + &merror_id, + SD_JSON_BUILD_PAIR_STRING("name", name), + SD_JSON_BUILD_PAIR_CONDITION(create_mode >= 0, "createMode", SD_JSON_BUILD_STRING(create_mode_to_string(create_mode))), + JSON_BUILD_PAIR_STRING_NON_EMPTY("template", template), + SD_JSON_BUILD_PAIR_CONDITION(read_only >= 0, "readOnly", SD_JSON_BUILD_BOOLEAN(read_only)), + SD_JSON_BUILD_PAIR_STRING("requestAs", vtype), + SD_JSON_BUILD_PAIR_CONDITION(create_size != UINT64_MAX, "createSizeBytes", SD_JSON_BUILD_UNSIGNED(create_size)), + SD_JSON_BUILD_PAIR_BOOLEAN("allowInteractiveAuthentication", arg_ask_password)); + if (r < 0) + return log_error_errno(r, "Failed to issue io.systemd.StorageProvider.Acquire() varlink call: %m"); + _cleanup_(sd_json_variant_unrefp) sd_json_variant *reply = sd_json_variant_ref(mreply); + if (merror_id) { + /* Copy out the error ID, as the follow-up call will invalidate it */ + _cleanup_free_ char *error_id = strdup(merror_id); + if (!error_id) + return log_oom(); + + /* Hmm, the type might not have been right for the backend or the volume? then try + * again, and switch from "reg" to "blk", maybe it works then. (We keep the original + * reply referenced, since we prefer generating an error for the first error.) */ + if (streq(vtype, "reg") && STR_IN_SET(error_id, + "io.systemd.StorageProvider.TypeNotSupported", + "io.systemd.StorageProvider.WrongType")) { + + sd_json_variant *freply = NULL; + const char *ferror_id = NULL; + r = sd_varlink_callbo( + link, + "io.systemd.StorageProvider.Acquire", + &freply, + &ferror_id, + SD_JSON_BUILD_PAIR_STRING("name", name), + SD_JSON_BUILD_PAIR_CONDITION(create_mode >= 0, "createMode", SD_JSON_BUILD_STRING(create_mode_to_string(create_mode))), + JSON_BUILD_PAIR_STRING_NON_EMPTY("template", template), + SD_JSON_BUILD_PAIR_CONDITION(read_only >= 0, "readOnly", SD_JSON_BUILD_BOOLEAN(read_only)), + SD_JSON_BUILD_PAIR_STRING("requestAs", "blk"), + SD_JSON_BUILD_PAIR_CONDITION(create_size != UINT64_MAX, "createSizeBytes", SD_JSON_BUILD_UNSIGNED(create_size)), + SD_JSON_BUILD_PAIR_BOOLEAN("allowInteractiveAuthentication", arg_ask_password)); + if (r < 0) + return log_error_errno(r, "Failed to issue io.systemd.StorageProvider.Acquire() varlink call: %m"); + if (!ferror_id) { + /* The 2nd call worked? then let's forget about the first failure */ + sd_json_variant_unref(reply); + reply = sd_json_variant_ref(freply); + error_id = mfree(error_id); + } + + /* NB: if both fail we show the Varlink error of the first call here, i.e. of the preferred type */ + } + + if (error_id) { + if (streq(error_id, "io.systemd.StorageProvider.NoSuchVolume")) + return log_error_errno(SYNTHETIC_ERRNO(ENOENT), "Volume '%s' not known.", name); + if (streq(error_id, "io.systemd.StorageProvider.NoSuchTemplate")) + return log_error_errno(SYNTHETIC_ERRNO(ENOENT), "Template '%s' not known.", template); + if (streq(error_id, "io.systemd.StorageProvider.VolumeExists")) + return log_error_errno(SYNTHETIC_ERRNO(EEXIST), "Volume '%s' exists already.", name); + if (streq(error_id, "io.systemd.StorageProvider.TypeNotSupported")) + return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "Storage provider does not support the specified volume type '%s'.", vtype); + if (streq(error_id, "io.systemd.StorageProvider.WrongType")) + return log_error_errno(SYNTHETIC_ERRNO(EADDRNOTAVAIL), "Volume '%s' is not of type '%s'.", name, vtype); + if (streq(error_id, "io.systemd.StorageProvider.CreateNotSupported")) + return log_error_errno(SYNTHETIC_ERRNO(EOPNOTSUPP), "Storage provider does not support creating volumes."); + if (streq(error_id, "io.systemd.StorageProvider.CreateSizeRequired")) + return log_error_errno(SYNTHETIC_ERRNO(ENODATA), "Storage provider requires a create size to be provided when creating volumes on-the-fly. Use 'storage.create-size=' mount option."); + if (streq(error_id, "io.systemd.StorageProvider.ReadOnlyVolume")) + return log_error_errno(SYNTHETIC_ERRNO(EROFS), "Volume '%s' is read-only.", name); + if (streq(error_id, "io.systemd.StorageProvider.BadTemplate")) + return log_error_errno(SYNTHETIC_ERRNO(EADDRNOTAVAIL), "Template does not apply to this volume type."); + + r = sd_varlink_error_to_errno(error_id, reply); /* If this is a system errno style error, output it with %m */ + if (r != -EBADR) + return log_error_errno(r, "Failed to issue io.systemd.StorageProvider.Acquire() varlink call: %m"); + + return log_error_errno(r, "Failed to issue io.systemd.StorageProvider.Acquire() varlink call: %s", error_id); + } + } + + struct { + unsigned fd_idx; + int read_only; + const char *type; + uid_t base_uid; + gid_t base_gid; + } p = { + .fd_idx = UINT_MAX, + .read_only = -1, + .base_uid = UID_INVALID, + .base_gid = GID_INVALID, + }; + + static const sd_json_dispatch_field dispatch_table[] = { + { "fileDescriptorIndex", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uint, voffsetof(p, fd_idx), SD_JSON_MANDATORY }, + { "readOnly", SD_JSON_VARIANT_BOOLEAN, sd_json_dispatch_tristate, voffsetof(p, read_only), 0 }, + { "type", SD_JSON_VARIANT_STRING, sd_json_dispatch_const_string, voffsetof(p, type), SD_JSON_MANDATORY }, + { "baseUID", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uid_gid, voffsetof(p, base_uid), 0 }, + { "baseGID", _SD_JSON_VARIANT_TYPE_INVALID, sd_json_dispatch_uid_gid, voffsetof(p, base_gid), 0 }, + {} + }; + + r = sd_json_dispatch(reply, dispatch_table, SD_JSON_ALLOW_EXTENSIONS, &p); + if (r < 0) + return log_error_errno(r, "Failed to decode Acquire() reply: %m"); + + _cleanup_close_ int fd = sd_varlink_take_fd(link, p.fd_idx); + if (fd < 0) + return log_error_errno(fd, "Failed to acquire fd from Varlink connection: %m"); + + struct stat st; + if (fstat(fd, &st) < 0) + return log_error_errno(errno, "Failed to stat returned file descriptor: %m"); + + _cleanup_strv_free_ char **cmdline = strv_new("mount", "-c"); + if (!cmdline) + return log_oom(); + + if (fstype) { + if (!STR_IN_SET(p.type, "reg", "blk")) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Mounting as file system type '%s' requested, but volume is not a block device or regular file.", fstype); + + r = stat_verify_regular_or_block(&st); + if (r < 0) + return log_error_errno(r, "File descriptor for block/regular volume is not a block or regular inode: %m"); + + if (strv_extend_strv(&cmdline, STRV_MAKE("-t", fstype), /* filter_duplicates= */ false) < 0) + return log_oom(); + } else { + if (!streq(p.type, "dir")) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Mount as directory requested, but volume is not a directory."); + + if (!uid_is_valid(p.base_uid) || !gid_is_valid(p.base_gid)) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Provider did not report base UID/GID, cannot mount."); + + if (p.base_uid > UINT32_MAX - 0x10000U || + p.base_gid > UINT32_MAX - 0x10000U) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Returned base UID/GID out of range."); + + r = stat_verify_directory(&st); + if (r < 0) + return log_error_errno(r, "File descriptor for directory volume is not a directory inode: %m"); + + if (st.st_uid < p.base_uid || st.st_uid >= p.base_uid + 0x10000 || + st.st_gid < p.base_gid || st.st_gid >= p.base_gid + 0x10000) + return log_error_errno(SYNTHETIC_ERRNO(EPERM), "File descriptor for directory volume is not owned by base UID/GID range, refusing."); + + /* Now move the mount into our own UID/GID range */ + _cleanup_free_ char *uid_line = asprintf_safe( + UID_FMT " " UID_FMT " " UID_FMT "\n", + p.base_uid, (uid_t) 0, (uid_t) 0x10000); + _cleanup_free_ char *gid_line = asprintf_safe( + GID_FMT " " GID_FMT " " GID_FMT "\n", + p.base_gid, (gid_t) 0, (gid_t) 0x10000); + if (!uid_line || !gid_line) + return log_oom(); + + _cleanup_close_ int userns_fd = userns_acquire(uid_line, gid_line, /* setgroups_deny= */ true); + if (userns_fd < 0) + return log_error_errno(userns_fd, "Failed to acquire new user namespace: %m"); + + _cleanup_close_ int remapped_fd = open_tree_attr_with_fallback( + fd, + /* path= */ NULL, + OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC, + &(struct mount_attr) { + .attr_set = MOUNT_ATTR_IDMAP, + .userns_fd = userns_fd, + }); + if (remapped_fd < 0) + return log_error_errno(remapped_fd, "Failed to set ID mapping on returned mount: %m"); + + close_and_replace(fd, remapped_fd); + + if (strv_extend(&cmdline, "--bind") < 0) + return log_oom(); + } + + if (p.read_only > 0) + read_only = true; + + if (!strextend_with_separator(&filtered, ",", read_only > 0 ? "ro" : "rw")) + return log_oom(); + + if (strv_extend_strv(&cmdline, STRV_MAKE("-o", filtered), /* filter_duplicates= */ false) < 0) + return log_oom(); + + if (strv_extend_strv(&cmdline, STRV_MAKE(FORMAT_PROC_FD_PATH(fd), path), /* filter_duplicates= */ false) < 0) + return log_oom(); + + r = fd_cloexec(fd, false); + if (r < 0) + return log_error_errno(r, "Failed to disable O_CLOEXEC for mount fd: %m"); + + if (DEBUG_LOGGING) { + _cleanup_free_ char *q = quote_command_line(cmdline, SHELL_ESCAPE_EMPTY); + log_debug("Chain-loading: %s", strna(q)); + } + + /* NB: we do not honour $PATH here, since as plugin to /bin/mount we might be called in a setuid() + * context, and hence don't want to chain to programs potentially under user control. */ + execv("/bin/mount", cmdline); + return log_error_errno(errno, "Failed to execute mount tool: %m"); +} + +static int run(int argc, char *argv[]) { + int r; + + log_setup(); + + if (invoked_as(argv, "mount.storage")) + return run_as_mount_helper(argc, argv); + + char **args = NULL; + r = parse_argv(argc, argv, &args); + if (r <= 0) + return r; + + return dispatch_verb_with_args(args, /* userdata= */ NULL); +} + +DEFINE_MAIN_FUNCTION(run); From 804bf405d932fd1305b1c8f7a09990fb03cb8fcc Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 29 Apr 2026 02:25:07 +0200 Subject: [PATCH 5/6] test: add integration test for storagectl and storage providers VM-only test that exercises both shipped providers through storagectl: verifies the well-known sockets exist, lists providers/volumes/ templates, creates and acquires volumes from each template (sparse-file, allocated-file, directory, subvolume), attaches a loop device to cover the block provider, and exercises the mount.storage helper. --- test/units/TEST-87-AUX-UTILS-VM.storagectl.sh | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100755 test/units/TEST-87-AUX-UTILS-VM.storagectl.sh diff --git a/test/units/TEST-87-AUX-UTILS-VM.storagectl.sh b/test/units/TEST-87-AUX-UTILS-VM.storagectl.sh new file mode 100755 index 0000000000..a11a952a8e --- /dev/null +++ b/test/units/TEST-87-AUX-UTILS-VM.storagectl.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +set -eux +set -o pipefail + +if ! command -v storagectl >/dev/null; then + echo "storagectl not found, skipping." + exit 77 +fi + +# shellcheck source=test/units/util.sh +. "$(dirname "$0")"/util.sh + +# Unset $PAGER so we don't have to use --no-pager everywhere +export PAGER= + +# storagectl runs in a VM-only test +if systemd-detect-virt -cq ; then + echo "can't run in a container, skipping." + exit 77 +fi + +at_exit() { + set +e + + if [[ -n "${MOUNT_DIR:-}" ]] && mountpoint -q "$MOUNT_DIR"; then + umount "$MOUNT_DIR" + fi + if [[ -n "${LOOP:-}" ]]; then + systemd-dissect --detach "$LOOP" + fi + if [[ -n "${WORK_DIR:-}" ]]; then + rm -fr "$WORK_DIR" + fi + rm -fr /var/lib/storage/test-87-storage-*.volume +} +trap at_exit EXIT + +# The storage providers are socket-activated by sockets.target, so the listening +# AF_UNIX sockets should already exist. +test -S /run/systemd/io.systemd.StorageProvider/block +test -S /run/systemd/io.systemd.StorageProvider/fs + +WORK_DIR="$(mktemp -d /tmp/test-storagectl.XXXXXXXXXX)" +MOUNT_DIR="$WORK_DIR/mnt" +mkdir -p "$MOUNT_DIR" + +# --- storagectl basic --- + +storagectl --help +storagectl --version +storagectl help + +# Unknown verb / option +(! storagectl this-verb-does-not-exist) +(! storagectl --no-such-option providers) + +# --- storagectl providers --- + +storagectl providers +storagectl providers --no-legend +storagectl providers --no-pager +storagectl providers --json=pretty | jq . +storagectl providers --json=short | jq . + +providers_output="$(storagectl providers --no-legend)" +assert_in 'block' "$providers_output" +assert_in 'fs' "$providers_output" +assert_in 'yes' "$providers_output" + +# --- storagectl volumes --- + +# 'volumes' is the default verb +storagectl +storagectl volumes +storagectl volumes --no-legend +storagectl volumes --no-pager +storagectl volumes --json=pretty | jq . +storagectl volumes --json=short | jq . + +# Glob filter that matches nothing should not error +storagectl volumes 'no-such-volume-*' + +# --- storagectl templates --- + +storagectl templates +storagectl templates --no-legend --no-pager +storagectl templates --json=pretty | jq . +storagectl templates --json=short | jq --seq . + +templates_output="$(storagectl templates --no-legend)" +assert_in 'sparse-file' "$templates_output" +assert_in 'allocated-file' "$templates_output" +assert_in 'directory' "$templates_output" +assert_in 'subvolume' "$templates_output" + +# Glob filter +storagectl templates 'sparse-*' --no-legend | grep sparse-file >/dev/null +(! storagectl templates 'sparse-*' --no-legend | grep allocated-file >/dev/null) +storagectl templates 'no-such-template-*' + +# --- direct varlink calls --- + +varlinkctl introspect /run/systemd/io.systemd.StorageProvider/block io.systemd.StorageProvider +varlinkctl introspect /run/systemd/io.systemd.StorageProvider/fs io.systemd.StorageProvider + +# Block provider does not expose templates +varlinkctl call --more /run/systemd/io.systemd.StorageProvider/block \ + io.systemd.StorageProvider.ListTemplates '{}' \ + --graceful=io.systemd.StorageProvider.NoSuchTemplate + +# fs provider lists the four built-in templates +varlinkctl call --more --json=short /run/systemd/io.systemd.StorageProvider/fs \ + io.systemd.StorageProvider.ListTemplates '{}' | grep '"name":"sparse-file"' >/dev/null + +# Block provider rejects names not under /dev/ +varlinkctl call /run/systemd/io.systemd.StorageProvider/block \ + io.systemd.StorageProvider.Acquire '{"name":"/tmp/no-such-dev"}' \ + --graceful=io.systemd.StorageProvider.NoSuchVolume + +# fs provider rejects bad volume names (contain '/' → not a valid filename) +varlinkctl call /run/systemd/io.systemd.StorageProvider/fs \ + io.systemd.StorageProvider.Acquire '{"name":"bad/name"}' \ + --graceful=org.varlink.service.InvalidParameter + +# --- mount.storage: regular file via fs provider --- + +TESTVOL_REG="test-87-storage-reg-$RANDOM" +truncate -s 32M "/var/lib/storage/$TESTVOL_REG.volume" +mkfs.ext4 "/var/lib/storage/$TESTVOL_REG.volume" +mount -t storage.ext4 "fs:$TESTVOL_REG" "$MOUNT_DIR" +mountpoint -q "$MOUNT_DIR" +echo "hello reg" >"$MOUNT_DIR/hello" +umount "$MOUNT_DIR" + +# Volume now appears in 'storagectl volumes' +volumes_after_create="$(storagectl volumes "$TESTVOL_REG" --no-legend)" +assert_in "$TESTVOL_REG" "$volumes_after_create" +assert_in 'reg' "$volumes_after_create" + +# Re-mount existing (default storage.create=any) +mount -t storage.ext4 "fs:$TESTVOL_REG" "$MOUNT_DIR" +test -f "$MOUNT_DIR/hello" +umount "$MOUNT_DIR" + +# storage.create=open succeeds for existing volume +mount -t storage.ext4 -o "storage.create=open" "fs:$TESTVOL_REG" "$MOUNT_DIR" +umount "$MOUNT_DIR" + +# storage.create=new on existing volume must fail +(! mount -t storage.ext4 -o "storage.create=new,storage.create-size=16M" "fs:$TESTVOL_REG" "$MOUNT_DIR") + +# Read-only mount +mount -t storage.ext4 -o ro "fs:$TESTVOL_REG" "$MOUNT_DIR" +findmnt -n -o options "$MOUNT_DIR" | grep -E '(^|,)ro(,|$)' >/dev/null +(! touch "$MOUNT_DIR/readonly-test") +umount "$MOUNT_DIR" + +rm -f "/var/lib/storage/$TESTVOL_REG.volume" + +# storage.create=open on missing volume must fail +(! mount -t storage.ext4 -o "storage.create=open" "fs:test-87-storage-missing-$RANDOM" "$MOUNT_DIR") + +# --- mount.storage: directory volume via fs provider (requires idmapped mounts) --- + +TESTVOL_DIR="test-87-storage-dir-$RANDOM" +if mount -t storage "fs:$TESTVOL_DIR" "$MOUNT_DIR"; then + mountpoint -q "$MOUNT_DIR" + test -d "/var/lib/storage/$TESTVOL_DIR.volume/root" + echo "dir test" >"$MOUNT_DIR/hello" + test -f "/var/lib/storage/$TESTVOL_DIR.volume/root/hello" + umount "$MOUNT_DIR" + rm -fr "/var/lib/storage/$TESTVOL_DIR.volume" +else + echo "Directory volume mounting failed (idmapped mounts unsupported?), skipping." + rm -fr "/var/lib/storage/$TESTVOL_DIR.volume" +fi + +# --- mount.storage: block device via block provider --- + +truncate -s 32M "$WORK_DIR/block.img" +mkfs.ext4 -L sd-storage-blk "$WORK_DIR/block.img" +LOOP="$(systemd-dissect --attach --loop-ref=test-storagectl "$WORK_DIR/block.img")" + +mount -t storage.ext4 "block:$LOOP" "$MOUNT_DIR" +mountpoint -q "$MOUNT_DIR" +echo "hello blk" >"$MOUNT_DIR/hello" +umount "$MOUNT_DIR" + +# Read-only mount of the block volume +mount -t storage.ext4 -o ro "block:$LOOP" "$MOUNT_DIR" +findmnt -n -o options "$MOUNT_DIR" | grep -E '(^|,)ro(,|$)' >/dev/null +test -f "$MOUNT_DIR/hello" +umount "$MOUNT_DIR" + +# Block volume is enumerable; matchName globs over device node and aliases +varlinkctl call --more --json=short /run/systemd/io.systemd.StorageProvider/block \ + io.systemd.StorageProvider.ListVolumes "{\"matchName\":\"$LOOP\"}" | + grep '"type":"blk"' >/dev/null + +systemd-dissect --detach "$LOOP" +unset LOOP + +# --- error cases --- + +# Bad provider name (no such socket) +(! mount -t storage.ext4 "no-such-provider:foo" "$MOUNT_DIR") +# Bad volume specification (no colon) +(! mount -t storage.ext4 "no-colon-here" "$MOUNT_DIR") +# Refuse nested storage volumes (FS type "storage.storage") +(! mount -t storage.storage "fs:something" "$MOUNT_DIR") From eccfd2c97b66694ee1b7c18f47837f1dc17ea839 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Thu, 23 Apr 2026 12:16:24 +0200 Subject: [PATCH 6/6] TODO: track StorageProvider follow-ups, sketch a NetworkProvider sibling Records the still-missing StorageProvider integrations (nspawn, vmspawn, service-manager BindVolume=) and replaces the now-obsolete generic "storage API via varlink" entry with a NetworkProvider proposal modelled on it. --- TODO.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index e51f6734f0..304f6c3a5e 100644 --- a/TODO.md +++ b/TODO.md @@ -128,6 +128,11 @@ SPDX-License-Identifier: LGPL-2.1-or-later ## Features +- StorageProvider interface + storagectl + - hook-up in systemd-nspawn + - hook-up in systemd-vmspawn + - hook-up in service manager (BindVolume=) + - a small tool that can do basic btrfs raid policy mgmt. i.e. gets started as part of the initial transaction for some btrfs raid fs, waits for some time, then puts message on screen (plymouth, console) that some devices apparently @@ -2545,8 +2550,9 @@ SPDX-License-Identifier: LGPL-2.1-or-later - systemd-tpm2-support: add a some logic that detects if system is in DA lockout mode, and queries the user for TPM recovery PIN then. -- systemd: add storage API via varlink, where everyone can drop a socket in a - dir, similar, do the same thing for networking +- add a networking provider API, inspired by the StorageProvider. Make networkd + a provider that exposes interfaces for adding tap, tun, veth via the api, + base this on .netdev logic somehow. - $SYSTEMD_EXECPID that the service manager sets should be augmented with $SYSTEMD_EXECPIDFD (and similar for