From 1b94530b14b07b0429099a125ea04f70d6812a50 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Wed, 20 May 2026 18:45:12 +0200 Subject: [PATCH] hostnamed: introduce "machine tags" concept For management purposes it's useful to be able to "tag" a machine with various labels. Let's add a field for that to /etc/machine-info and make it settable. Fixes: #38591 --- man/machine-info.xml | 24 +++++- man/org.freedesktop.hostname1.xml | 29 +++++++- src/basic/hostname-util.c | 69 +++++++++++++++++ src/basic/hostname-util.h | 6 ++ src/hostname/hostnamed.c | 120 ++++++++++++++++++++++++++++-- src/test/test-hostname-util.c | 64 ++++++++++++++++ 6 files changed, 299 insertions(+), 13 deletions(-) diff --git a/man/machine-info.xml b/man/machine-info.xml index 53c9e64ec4..252f341bba 100644 --- a/man/machine-info.xml +++ b/man/machine-info.xml @@ -139,6 +139,27 @@ + + TAGS= + + A colon-separated list of tags attached to this machine. Tags are short labels that + may be used to classify and group machines for management purposes, for example to identify the role + a machine plays in a deployment (webserver, database), the + fleet or organizational unit it belongs to, or any other administrator-defined attribute. Example: + TAGS=webserver:frontend:berlin. + + Each individual tag must be 1…255 characters long and may consist only of the ASCII + alphanumeric characters, - and .. + + The configured tags may be matched against with the + ConditionMachineTag= and AssertMachineTag= unit settings, see + systemd.unit5 + for details. They may be queried and changed with the tags command of + hostnamectl1. + + + + HARDWARE_VENDOR= @@ -195,7 +216,8 @@ PRETTY_HOSTNAME="Lennart's Tablet" ICON_NAME=computer-tablet CHASSIS=tablet -DEPLOYMENT=production +DEPLOYMENT=production +TAGS=demo:berlin diff --git a/man/org.freedesktop.hostname1.xml b/man/org.freedesktop.hostname1.xml index 3d98b88ebc..d5571de207 100644 --- a/man/org.freedesktop.hostname1.xml +++ b/man/org.freedesktop.hostname1.xml @@ -56,6 +56,7 @@ node /org/freedesktop/hostname1 { in b interactive); SetLocation(in s location, in b interactive); + SetTags(in as tags); GetProductUUID(in b interactive, out ay uuid); GetHardwareSerial(out s serial); @@ -73,6 +74,7 @@ node /org/freedesktop/hostname1 { readonly s Chassis = '...'; readonly s Deployment = '...'; readonly s Location = '...'; + readonly as Tags = ['...', ...]; @org.freedesktop.DBus.Property.EmitsChangedSignal("const") readonly s KernelName = '...'; @org.freedesktop.DBus.Property.EmitsChangedSignal("const") @@ -142,6 +144,8 @@ node /org/freedesktop/hostname1 { + + @@ -168,6 +172,8 @@ node /org/freedesktop/hostname1 { + + @@ -268,6 +274,15 @@ node /org/freedesktop/hostname1 { configure the chassis type if it could not be auto-detected. Set this property to the empty string to reenable the automatic detection of the chassis type from firmware information. + The Tags property exposes a list of machine tags: short + labels that may be used to classify and group machines for management purposes, for example to identify + the role a machine plays in a deployment, the fleet or organizational unit it belongs to, or any other + administrator-defined attribute. Each individual tag is 1…255 characters long and consists only of the + ASCII alphanumeric characters, - and .. The tags are stored as a + colon-separated list in the TAGS= field of /etc/machine-info, see + machine-info5 for + details. If no tags are configured this property is the empty list. + Note that systemd-hostnamed starts only on request and terminates after a short idle period. This effectively means that PropertyChanged messages are not sent out for changes made directly on the files (as in: administrator edits the files with vi). This is @@ -365,8 +380,13 @@ node /org/freedesktop/hostname1 { deployment environment), and Location (physical system location), respectively. + SetTags() sets the machine tags exposed by the Tags + property. It takes a list of strings, each of which must be a valid machine tag (1…255 ASCII + alphanumeric characters, - and .). Passing an empty list removes + the TAGS= field from /etc/machine-info. + PrettyHostname, IconName, Chassis, - Deployment, and Location are stored in + Deployment, Location, and Tags are stored in /etc/machine-info. See machine-info5 for the semantics of those settings. @@ -400,8 +420,8 @@ node /org/freedesktop/hostname1 { org.freedesktop.hostname1.set-hostname. For SetStaticHostname() and SetPrettyHostname() it is org.freedesktop.hostname1.set-static-hostname. For - SetIconName(), SetChassis(), SetDeployment() - and SetLocation() it is + SetIconName(), SetChassis(), SetDeployment(), + SetLocation() and SetTags() it is org.freedesktop.hostname1.set-machine-info. @@ -505,7 +525,8 @@ node /org/freedesktop/hostname1 { OperatingSystemImageVersion, HardwareSKU, and HardwareVersion were added in version 258. OperatingSystemFancyName was added in version 260. - GetMachineInfo() was added in version 261. + GetMachineInfo(), Tags and + SetTags() were added in version 261. diff --git a/src/basic/hostname-util.c b/src/basic/hostname-util.c index 01434d3d64..baa735ab10 100644 --- a/src/basic/hostname-util.c +++ b/src/basic/hostname-util.c @@ -253,3 +253,72 @@ int machine_spec_valid(const char *s) { return true; } + +bool machine_tag_is_valid(const char *s) { + size_t n = strlen_ptr(s); + if (n <= 0 || n >= 256) + return false; + + return in_charset(s, ALPHANUMERICAL "-."); +} + +bool machine_tag_list_is_valid(char **l) { + size_t n = 0; + STRV_FOREACH(i, l) { + n++; + if (n > MACHINE_TAGS_MAX) + return false; + + if (!machine_tag_is_valid(*i)) + return false; + } + + return true; +} + +int machine_tags_from_string(const char *s, bool graceful, char ***ret) { + int r; + + assert(ret); + + /* Parses the colon-separated TAGS= machine-info field into a sorted, deduplicated strv. Each tag is + * validated: if 'graceful' is true invalid tags are silently dropped, otherwise an invalid tag makes + * us fail with -EINVAL. The result is NULL if no (valid) tags remain. */ + + if (isempty(s)) { + *ret = NULL; + return 0; + } + + _cleanup_strv_free_ char **l = strv_split(s, ":"); + if (!l) + return -ENOMEM; + + strv_sort_uniq(l); + + if (!graceful) { + if (!machine_tag_list_is_valid(l)) + return -EINVAL; + + *ret = strv_isempty(l) ? NULL : TAKE_PTR(l); + return 0; + } + + size_t n = 0; + _cleanup_strv_free_ char **cleaned = NULL; + STRV_FOREACH(i, l) { + if (!machine_tag_is_valid(*i)) + continue; + + n++; + if (n > MACHINE_TAGS_MAX) + return -E2BIG; + + r = strv_extend(&cleaned, *i); + if (r < 0) + return r; + } + + *ret = TAKE_PTR(cleaned); + return 0; +} diff --git a/src/basic/hostname-util.h b/src/basic/hostname-util.h index 73cd83fbb8..f3d904a1bb 100644 --- a/src/basic/hostname-util.h +++ b/src/basic/hostname-util.h @@ -47,3 +47,9 @@ int get_pretty_hostname(char **ret); int machine_spec_valid(const char *s); int split_user_at_host(const char *s, char **ret_user, char **ret_host); + +#define MACHINE_TAGS_MAX 1024U + +bool machine_tag_is_valid(const char *s); +bool machine_tag_list_is_valid(char **l); +int machine_tags_from_string(const char *s, bool graceful, char ***ret); diff --git a/src/hostname/hostnamed.c b/src/hostname/hostnamed.c index f8c20ff9c7..a39bf5a57e 100644 --- a/src/hostname/hostnamed.c +++ b/src/hostname/hostnamed.c @@ -43,7 +43,6 @@ #include "string-util.h" #include "strv.h" #include "time-util.h" -#include "utf8.h" #include "varlink-io.systemd.Hostname.h" #include "varlink-io.systemd.service.h" #include "varlink-util.h" @@ -58,12 +57,15 @@ typedef enum { PROP_STATIC_HOSTNAME, PROP_STATIC_HOSTNAME_SUBSTITUTED_WILDCARDS, - /* Read from /etc/machine-info */ + /* Read from /etc/machine-info (with fallbacks) */ PROP_PRETTY_HOSTNAME, + _PROP_MACHINE_INFO_SETTABLE_FIRST = PROP_PRETTY_HOSTNAME, PROP_ICON_NAME, PROP_CHASSIS, PROP_DEPLOYMENT, PROP_LOCATION, + PROP_TAGS, + _PROP_MACHINE_INFO_SETTABLE_LAST = PROP_TAGS, PROP_HARDWARE_VENDOR, PROP_HARDWARE_MODEL, PROP_HARDWARE_SKU, @@ -77,6 +79,7 @@ typedef enum { PROP_OS_SUPPORT_END, PROP_OS_IMAGE_ID, PROP_OS_IMAGE_VERSION, + _PROP_MAX, _PROP_INVALID = -EINVAL, } HostProperty; @@ -173,6 +176,7 @@ static void context_read_machine_info(Context *c) { PROP_CHASSIS, PROP_DEPLOYMENT, PROP_LOCATION, + PROP_TAGS, PROP_HARDWARE_VENDOR, PROP_HARDWARE_MODEL, PROP_HARDWARE_SKU, @@ -184,6 +188,7 @@ static void context_read_machine_info(Context *c) { "CHASSIS", &c->data[PROP_CHASSIS], "DEPLOYMENT", &c->data[PROP_DEPLOYMENT], "LOCATION", &c->data[PROP_LOCATION], + "TAGS", &c->data[PROP_TAGS], "HARDWARE_VENDOR", &c->data[PROP_HARDWARE_VENDOR], "HARDWARE_MODEL", &c->data[PROP_HARDWARE_MODEL], "HARDWARE_SKU", &c->data[PROP_HARDWARE_SKU], @@ -840,11 +845,12 @@ static int context_write_data_static_hostname(Context *c) { static int context_write_data_machine_info(Context *c) { _cleanup_(unset_statp) struct stat *s = NULL; static const char * const name[_PROP_MAX] = { - [PROP_PRETTY_HOSTNAME] = "PRETTY_HOSTNAME", - [PROP_ICON_NAME] = "ICON_NAME", - [PROP_CHASSIS] = "CHASSIS", - [PROP_DEPLOYMENT] = "DEPLOYMENT", - [PROP_LOCATION] = "LOCATION", + [PROP_PRETTY_HOSTNAME] = "PRETTY_HOSTNAME", + [PROP_ICON_NAME] = "ICON_NAME", + [PROP_CHASSIS] = "CHASSIS", + [PROP_DEPLOYMENT] = "DEPLOYMENT", + [PROP_LOCATION] = "LOCATION", + [PROP_TAGS] = "TAGS", }; _cleanup_strv_free_ char **l = NULL; int r; @@ -859,7 +865,7 @@ static int context_write_data_machine_info(Context *c) { if (r < 0 && r != -ENOENT) return r; - for (HostProperty p = PROP_PRETTY_HOSTNAME; p <= PROP_LOCATION; p++) { + for (HostProperty p = _PROP_MACHINE_INFO_SETTABLE_FIRST; p <= _PROP_MACHINE_INFO_SETTABLE_LAST; p++) { assert(name[p]); r = strv_env_assign(&l, name[p], empty_to_null(c->data[p])); @@ -1163,6 +1169,29 @@ static int property_get_machine_info_field( return sd_bus_message_append(reply, "s", *(char**) userdata); } +static int property_get_tags( + sd_bus *bus, + const char *path, + const char *interface, + const char *property, + sd_bus_message *reply, + void *userdata, + sd_bus_error *error) { + + Context *c = ASSERT_PTR(userdata); + int r; + + context_read_machine_info(c); + + /* Silently drop any invalid tags that might have been written into the file by hand */ + _cleanup_strv_free_ char **l = NULL; + r = machine_tags_from_string(c->data[PROP_TAGS], /* graceful= */ true, &l); + if (r < 0) + log_warning_errno(r, "Failed to parse machine tags '%s', ignoring: %m", strnull(c->data[PROP_TAGS])); + + return sd_bus_message_append_strv(reply, l); +} + static int property_get_os_release_field( sd_bus *bus, const char *path, @@ -1561,6 +1590,74 @@ static int method_set_location(sd_bus_message *m, void *userdata, sd_bus_error * return set_machine_info(userdata, m, PROP_LOCATION, method_set_location, error); } +static int method_set_tags(sd_bus_message *m, void *userdata, sd_bus_error *error) { + Context *c = ASSERT_PTR(userdata); + int r; + + assert(m); + + _cleanup_strv_free_ char **tags = NULL; + r = sd_bus_message_read_strv(m, &tags); + if (r < 0) + return r; + + strv_sort_uniq(tags); + + if (strv_length(tags) > MACHINE_TAGS_MAX) + return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Too many machine tags specified."); + + _cleanup_free_ char *j = strv_join(tags, ":"); + if (!j) + return log_oom(); + + if (!machine_tag_list_is_valid(tags)) + return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Invalid tags '%s'", j); + + context_read_machine_info(c); + + if (streq_ptr(empty_to_null(j), empty_to_null(c->data[PROP_TAGS]))) + return sd_bus_reply_method_return(m, NULL); + + r = bus_verify_polkit_async_full( + m, + "org.freedesktop.hostname1.set-machine-info", + /* details= */ NULL, + /* good_user= */ UID_INVALID, + /* flags= */ 0, + &c->polkit_registry, + error); + if (r < 0) + return r; + if (r == 0) + return 1; /* No authorization for now, but the async polkit stuff will call us again when it has it */ + + if (strv_isempty(tags)) + c->data[PROP_TAGS] = mfree(c->data[PROP_TAGS]); + else + free_and_replace(c->data[PROP_TAGS], j); + + r = context_write_data_machine_info(c); + if (r < 0) { + log_error_errno(r, "Failed to write machine info: %m"); + if (ERRNO_IS_PRIVILEGE(r)) + return sd_bus_error_set(error, BUS_ERROR_FILE_IS_PROTECTED, "Not allowed to update /etc/machine-info."); + if (r == -EROFS) + return sd_bus_error_set(error, BUS_ERROR_READ_ONLY_FILESYSTEM, "/etc/machine-info is in a read-only filesystem."); + return sd_bus_error_set_errnof(error, r, "Failed to write machine info: %m"); + } + + log_info("Changed tags to '%s'", strempty(c->data[PROP_TAGS])); + + (void) sd_bus_emit_properties_changed( + sd_bus_message_get_bus(m), + "/org/freedesktop/hostname1", + "org.freedesktop.hostname1", + "Tags", + NULL); + + return sd_bus_reply_method_return(m, NULL); +} + static int method_get_product_uuid(sd_bus_message *m, void *userdata, sd_bus_error *error) { _cleanup_(sd_bus_message_unrefp) sd_bus_message *reply = NULL; Context *c = ASSERT_PTR(userdata); @@ -1645,6 +1742,7 @@ static int method_get_machine_info(sd_bus_message *m, void *userdata, sd_bus_err { "CHASSIS", PROP_CHASSIS }, { "DEPLOYMENT", PROP_DEPLOYMENT }, { "LOCATION", PROP_LOCATION }, + { "TAGS", PROP_TAGS }, { "HARDWARE_VENDOR", PROP_HARDWARE_VENDOR }, { "HARDWARE_MODEL", PROP_HARDWARE_MODEL }, { "HARDWARE_SKU", PROP_HARDWARE_SKU }, @@ -1853,6 +1951,7 @@ static const sd_bus_vtable hostname_vtable[] = { SD_BUS_PROPERTY("Chassis", "s", property_get_chassis, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE), SD_BUS_PROPERTY("Deployment", "s", property_get_machine_info_field, offsetof(Context, data[PROP_DEPLOYMENT]), SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE), SD_BUS_PROPERTY("Location", "s", property_get_machine_info_field, offsetof(Context, data[PROP_LOCATION]), SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE), + SD_BUS_PROPERTY("Tags", "as", property_get_tags, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE), SD_BUS_PROPERTY("KernelName", "s", property_get_uname_field, offsetof(struct utsname, sysname), SD_BUS_VTABLE_ABSOLUTE_OFFSET|SD_BUS_VTABLE_PROPERTY_CONST), SD_BUS_PROPERTY("KernelRelease", "s", property_get_uname_field, offsetof(struct utsname, release), SD_BUS_VTABLE_ABSOLUTE_OFFSET|SD_BUS_VTABLE_PROPERTY_CONST), SD_BUS_PROPERTY("KernelVersion", "s", property_get_uname_field, offsetof(struct utsname, version), SD_BUS_VTABLE_ABSOLUTE_OFFSET|SD_BUS_VTABLE_PROPERTY_CONST), @@ -1910,6 +2009,11 @@ static const sd_bus_vtable hostname_vtable[] = { SD_BUS_NO_RESULT, method_set_location, SD_BUS_VTABLE_UNPRIVILEGED), + SD_BUS_METHOD_WITH_ARGS("SetTags", + SD_BUS_ARGS("as", tags), + SD_BUS_NO_RESULT, + method_set_tags, + SD_BUS_VTABLE_UNPRIVILEGED), SD_BUS_METHOD_WITH_ARGS("GetProductUUID", SD_BUS_ARGS("b", interactive), SD_BUS_RESULT("ay", uuid), diff --git a/src/test/test-hostname-util.c b/src/test/test-hostname-util.c index b3e3b1b84a..1a3cb1dfff 100644 --- a/src/test/test-hostname-util.c +++ b/src/test/test-hostname-util.c @@ -1,7 +1,9 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include "alloc-util.h" #include "hostname-util.h" #include "string-util.h" +#include "strv.h" #include "tests.h" TEST(hostname_is_valid) { @@ -117,4 +119,66 @@ TEST(split_user_at_host) { test_split_user_at_host_one("aa@@@bb", "aa", "@@bb", 1); } +TEST(machine_tag_is_valid) { + assert_se(machine_tag_is_valid("foo")); + assert_se(machine_tag_is_valid("foo-bar.baz")); + assert_se(machine_tag_is_valid("Webserver01")); + assert_se(machine_tag_is_valid("a")); + + assert_se(!machine_tag_is_valid(NULL)); + assert_se(!machine_tag_is_valid("")); + assert_se(!machine_tag_is_valid("foo:bar")); /* colon is the separator */ + assert_se(!machine_tag_is_valid("foo bar")); + assert_se(!machine_tag_is_valid("fööbar")); /* non-ASCII */ + assert_se(!machine_tag_is_valid("foo/bar")); + assert_se(!machine_tag_is_valid("foo_bar")); + + /* Length boundary: 255 characters is fine, 256 is too long */ + _cleanup_free_ char *max = strrep("a", 255), *over = strrep("a", 256); + assert_se(max); + assert_se(over); + assert_se(machine_tag_is_valid(max)); + assert_se(!machine_tag_is_valid(over)); +} + +TEST(machine_tag_list_is_valid) { + assert_se(machine_tag_list_is_valid(NULL)); /* empty list is valid */ + assert_se(machine_tag_list_is_valid(STRV_MAKE("a"))); + assert_se(machine_tag_list_is_valid(STRV_MAKE("foo", "bar", "c-d.e"))); + + assert_se(!machine_tag_list_is_valid(STRV_MAKE("foo", "b:c"))); + assert_se(!machine_tag_list_is_valid(STRV_MAKE("foo", ""))); +} + +TEST(machine_tags_from_string) { + _cleanup_strv_free_ char **l = NULL; + + ASSERT_OK(machine_tags_from_string(NULL, /* graceful= */ false, &l)); + assert_se(strv_isempty(l)); + l = strv_free(l); + + ASSERT_OK(machine_tags_from_string("", /* graceful= */ true, &l)); + assert_se(strv_isempty(l)); + l = strv_free(l); + + /* Sorted and deduplicated */ + ASSERT_OK(machine_tags_from_string("foo:bar:foo:baz", /* graceful= */ false, &l)); + assert_se(strv_equal(l, STRV_MAKE("bar", "baz", "foo"))); + l = strv_free(l); + + /* Graceful: invalid tags are dropped, valid ones kept (sorted/deduplicated) */ + ASSERT_OK(machine_tags_from_string("foo:in valid:bar:foo", /* graceful= */ true, &l)); + assert_se(strv_equal(l, STRV_MAKE("bar", "foo"))); + l = strv_free(l); + + /* Graceful: all tags invalid → empty list */ + ASSERT_OK(machine_tags_from_string("in valid:also invalid", /* graceful= */ true, &l)); + assert_se(strv_isempty(l)); + l = strv_free(l); + + /* Fatal: a single invalid tag fails the whole parse */ + ASSERT_ERROR(machine_tags_from_string("foo:in valid:bar", /* graceful= */ false, &l), EINVAL); + assert_se(!l); +} + DEFINE_TEST_MAIN(LOG_DEBUG);