From c0548df0a2f78f3422d77c77c2149d8a7f50d8f6 Mon Sep 17 00:00:00 2001
From: Topi Miettinen <toiwoton@gmail.com>
Date: Sun, 22 May 2022 14:21:02 +0300
Subject: [PATCH] core: firewall integration with ControlGroupNFTSet=

New directive `ControlGroupNFTSet=` provides a method for integrating services
into firewall rules with NFT sets.

Example:

```
table inet filter {
...
        set timesyncd {
                type cgroupsv2
        }

        chain ntp_output {
                socket cgroupv2 != @timesyncd counter drop
                accept
        }
...
}
```

/etc/systemd/system/systemd-timesyncd.service.d/override.conf
```
[Service]
ControlGroupNFTSet=inet:filter:timesyncd
```

```
$ sudo nft list set inet filter timesyncd
table inet filter {
        set timesyncd {
                type cgroupsv2
                elements = { "system.slice/systemd-timesyncd.service" }
        }
}
```
---
 man/org.freedesktop.systemd1.xml            | 36 +++++++++
 man/systemd.resource-control.xml            | 29 +++++++
 src/core/cgroup.c                           | 52 ++++++++++++
 src/core/cgroup.h                           |  4 +
 src/core/dbus-cgroup.c                      | 85 ++++++++++++++++++++
 src/core/load-fragment-gperf.gperf.in       |  1 +
 src/core/load-fragment.c                    | 87 +++++++++++++++++++++
 src/core/load-fragment.h                    |  1 +
 src/shared/bus-unit-util.c                  |  3 +
 test/fuzz/fuzz-unit-file/directives.mount   |  1 +
 test/fuzz/fuzz-unit-file/directives.scope   |  1 +
 test/fuzz/fuzz-unit-file/directives.service |  1 +
 test/fuzz/fuzz-unit-file/directives.slice   |  1 +
 test/fuzz/fuzz-unit-file/directives.socket  |  1 +
 test/fuzz/fuzz-unit-file/directives.swap    |  1 +
 15 files changed, 304 insertions(+)

diff --git a/man/org.freedesktop.systemd1.xml b/man/org.freedesktop.systemd1.xml
index 7974833554..6625a74073 100644
--- a/man/org.freedesktop.systemd1.xml
+++ b/man/org.freedesktop.systemd1.xml
@@ -2599,6 +2599,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2eservice {
       @org.freedesktop.DBus.Property.EmitsChangedSignal("false")
       readonly (bas) RestrictNetworkInterfaces = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
+      readonly a(iss) ControlGroupNFTSet = [...];
+      @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly as Environment = ['...', ...];
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly a(sb) EnvironmentFiles = [...];
@@ -3170,6 +3172,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2eservice {
 
     <!--property RestrictNetworkInterfaces is not documented!-->
 
+    <!--property ControlGroupNFTSet is not documented!-->
+
     <!--property EnvironmentFiles is not documented!-->
 
     <!--property PassEnvironment is not documented!-->
@@ -3750,6 +3754,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2eservice {
 
     <variablelist class="dbus-property" generated="True" extra-ref="RestrictNetworkInterfaces"/>
 
+    <variablelist class="dbus-property" generated="True" extra-ref="ControlGroupNFTSet"/>
+
     <variablelist class="dbus-property" generated="True" extra-ref="Environment"/>
 
     <variablelist class="dbus-property" generated="True" extra-ref="EnvironmentFiles"/>
@@ -4487,6 +4493,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2esocket {
       @org.freedesktop.DBus.Property.EmitsChangedSignal("false")
       readonly (bas) RestrictNetworkInterfaces = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
+      readonly a(iss) ControlGroupNFTSet = [...];
+      @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly as Environment = ['...', ...];
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly a(sb) EnvironmentFiles = [...];
@@ -5082,6 +5090,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2esocket {
 
     <!--property RestrictNetworkInterfaces is not documented!-->
 
+    <!--property ControlGroupNFTSet is not documented!-->
+
     <!--property EnvironmentFiles is not documented!-->
 
     <!--property PassEnvironment is not documented!-->
@@ -5656,6 +5666,8 @@ node /org/freedesktop/systemd1/unit/avahi_2ddaemon_2esocket {
 
     <variablelist class="dbus-property" generated="True" extra-ref="RestrictNetworkInterfaces"/>
 
+    <variablelist class="dbus-property" generated="True" extra-ref="ControlGroupNFTSet"/>
+
     <variablelist class="dbus-property" generated="True" extra-ref="Environment"/>
 
     <variablelist class="dbus-property" generated="True" extra-ref="EnvironmentFiles"/>
@@ -6282,6 +6294,8 @@ node /org/freedesktop/systemd1/unit/home_2emount {
       @org.freedesktop.DBus.Property.EmitsChangedSignal("false")
       readonly (bas) RestrictNetworkInterfaces = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
+      readonly a(iss) ControlGroupNFTSet = [...];
+      @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly as Environment = ['...', ...];
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly a(sb) EnvironmentFiles = [...];
@@ -6805,6 +6819,8 @@ node /org/freedesktop/systemd1/unit/home_2emount {
 
     <!--property RestrictNetworkInterfaces is not documented!-->
 
+    <!--property ControlGroupNFTSet is not documented!-->
+
     <!--property EnvironmentFiles is not documented!-->
 
     <!--property PassEnvironment is not documented!-->
@@ -7297,6 +7313,8 @@ node /org/freedesktop/systemd1/unit/home_2emount {
 
     <variablelist class="dbus-property" generated="True" extra-ref="RestrictNetworkInterfaces"/>
 
+    <variablelist class="dbus-property" generated="True" extra-ref="ControlGroupNFTSet"/>
+
     <variablelist class="dbus-property" generated="True" extra-ref="Environment"/>
 
     <variablelist class="dbus-property" generated="True" extra-ref="EnvironmentFiles"/>
@@ -8050,6 +8068,8 @@ node /org/freedesktop/systemd1/unit/dev_2dsda3_2eswap {
       @org.freedesktop.DBus.Property.EmitsChangedSignal("false")
       readonly (bas) RestrictNetworkInterfaces = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
+      readonly a(iss) ControlGroupNFTSet = [...];
+      @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly as Environment = ['...', ...];
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly a(sb) EnvironmentFiles = [...];
@@ -8559,6 +8579,8 @@ node /org/freedesktop/systemd1/unit/dev_2dsda3_2eswap {
 
     <!--property RestrictNetworkInterfaces is not documented!-->
 
+    <!--property ControlGroupNFTSet is not documented!-->
+
     <!--property EnvironmentFiles is not documented!-->
 
     <!--property PassEnvironment is not documented!-->
@@ -9037,6 +9059,8 @@ node /org/freedesktop/systemd1/unit/dev_2dsda3_2eswap {
 
     <variablelist class="dbus-property" generated="True" extra-ref="RestrictNetworkInterfaces"/>
 
+    <variablelist class="dbus-property" generated="True" extra-ref="ControlGroupNFTSet"/>
+
     <variablelist class="dbus-property" generated="True" extra-ref="Environment"/>
 
     <variablelist class="dbus-property" generated="True" extra-ref="EnvironmentFiles"/>
@@ -9648,6 +9672,8 @@ node /org/freedesktop/systemd1/unit/system_2eslice {
       readonly a(iiqq) SocketBindDeny = [...];
       @org.freedesktop.DBus.Property.EmitsChangedSignal("false")
       readonly (bas) RestrictNetworkInterfaces = ...;
+      @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
+      readonly a(iss) ControlGroupNFTSet = [...];
   };
   interface org.freedesktop.DBus.Peer { ... };
   interface org.freedesktop.DBus.Introspectable { ... };
@@ -9800,6 +9826,8 @@ node /org/freedesktop/systemd1/unit/system_2eslice {
 
     <!--property RestrictNetworkInterfaces is not documented!-->
 
+    <!--property ControlGroupNFTSet is not documented!-->
+
     <!--Autogenerated cross-references for systemd.directives, do not edit-->
 
     <variablelist class="dbus-interface" generated="True" extra-ref="org.freedesktop.systemd1.Unit"/>
@@ -9958,6 +9986,8 @@ node /org/freedesktop/systemd1/unit/system_2eslice {
 
     <variablelist class="dbus-property" generated="True" extra-ref="RestrictNetworkInterfaces"/>
 
+    <variablelist class="dbus-property" generated="True" extra-ref="ControlGroupNFTSet"/>
+
     <!--End of Autogenerated section-->
 
     <refsect2>
@@ -10138,6 +10168,8 @@ node /org/freedesktop/systemd1/unit/session_2d1_2escope {
       @org.freedesktop.DBus.Property.EmitsChangedSignal("false")
       readonly (bas) RestrictNetworkInterfaces = ...;
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
+      readonly a(iss) ControlGroupNFTSet = [...];
+      @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly s KillMode = '...';
       @org.freedesktop.DBus.Property.EmitsChangedSignal("const")
       readonly i KillSignal = ...;
@@ -10307,6 +10339,8 @@ node /org/freedesktop/systemd1/unit/session_2d1_2escope {
 
     <!--property RestrictNetworkInterfaces is not documented!-->
 
+    <!--property ControlGroupNFTSet is not documented!-->
+
     <!--property KillMode is not documented!-->
 
     <!--property KillSignal is not documented!-->
@@ -10493,6 +10527,8 @@ node /org/freedesktop/systemd1/unit/session_2d1_2escope {
 
     <variablelist class="dbus-property" generated="True" extra-ref="RestrictNetworkInterfaces"/>
 
+    <variablelist class="dbus-property" generated="True" extra-ref="ControlGroupNFTSet"/>
+
     <variablelist class="dbus-property" generated="True" extra-ref="KillMode"/>
 
     <variablelist class="dbus-property" generated="True" extra-ref="KillSignal"/>
diff --git a/man/systemd.resource-control.xml b/man/systemd.resource-control.xml
index 1397b886c5..23b2d0f390 100644
--- a/man/systemd.resource-control.xml
+++ b/man/systemd.resource-control.xml
@@ -1173,6 +1173,35 @@ DeviceAllow=/dev/loop-control
           </para>
         </listitem>
       </varlistentry>
+      <varlistentry>
+        <term><varname>ControlGroupNFTSet=</varname><replaceable>family</replaceable>:<replaceable>table</replaceable>:<replaceable>set</replaceable></term>
+        <listitem>
+          <para>This setting provides a method for integrating dynamic cgroup IDs into firewall rules with
+          NFT sets. This option expects a whitespace separated list of NFT set definitions. Each definition
+          consists of a colon-separated tuple of NFT address family (one of <literal>arp</literal>,
+          <literal>bridge</literal>, <literal>inet</literal>, <literal>ip</literal>, <literal>ip6</literal>,
+          or <literal>netdev</literal>), table name and set name. The names of tables and sets must conform
+          to lexical restrictions of NFT table names. When a control group for a unit is realized, the cgroup
+          ID will be appended to the NFT sets and it will be be removed when the control group is
+          removed. Failures to manage the sets will be ignored.</para>
+
+          <para>Example:
+          <programlisting>[Unit]
+ControlGroupNFTSet=inet:filter:my_service
+</programlisting>
+          Corresponding NFT rules:
+          <programlisting>table inet filter {
+        set my_service {
+                type cgroupsv2
+        }
+        chain x {
+                socket cgroupv2 level 2 @my_service accept
+                drop
+        }
+}</programlisting>
+          </para>
+        </listitem>
+      </varlistentry>
     </variablelist>
   </refsect1>
 
diff --git a/src/core/cgroup.c b/src/core/cgroup.c
index 9282b1ff20..9a07a73f02 100644
--- a/src/core/cgroup.c
+++ b/src/core/cgroup.c
@@ -19,6 +19,7 @@
 #include "devnum-util.h"
 #include "fd-util.h"
 #include "fileio.h"
+#include "firewall-util.h"
 #include "in-addr-prefix-util.h"
 #include "inotify-util.h"
 #include "io-util.h"
@@ -279,6 +280,8 @@ void cgroup_context_done(CGroupContext *c) {
         cpu_set_reset(&c->startup_cpuset_cpus);
         cpu_set_reset(&c->cpuset_mems);
         cpu_set_reset(&c->startup_cpuset_mems);
+
+        c->nft_set_context = nft_set_context_free_many(c->nft_set_context, &c->n_nft_set_contexts);
 }
 
 static int unit_get_kernel_memory_limit(Unit *u, const char *file, uint64_t *ret) {
@@ -617,6 +620,11 @@ void cgroup_context_dump(Unit *u, FILE* f, const char *prefix) {
                 SET_FOREACH(iface, c->restrict_network_interfaces)
                         fprintf(f, "%sRestrictNetworkInterfaces: %s\n", prefix, iface);
         }
+
+        for (size_t i = 0; i < c->n_nft_set_contexts; i++)
+                fprintf(f, "%sControlGroupNFTSet: %s:%s:%s\n", prefix,
+                        nfproto_to_string(c->nft_set_context[i].nfproto),
+                        c->nft_set_context[i].table, c->nft_set_context[i].set);
 }
 
 void cgroup_context_dump_socket_bind_item(const CGroupSocketBindItem *item, FILE *f) {
@@ -1226,6 +1234,46 @@ static void cgroup_apply_firewall(Unit *u) {
         (void) bpf_firewall_install(u);
 }
 
+static void cgroup_apply_nft_set(Unit *u) {
+        int r;
+        CGroupContext *c;
+
+        assert(u);
+
+        assert_se(c = unit_get_cgroup_context(u));
+
+        for (size_t i = 0; i < c->n_nft_set_contexts; i++) {
+                NFTSetContext *s = &c->nft_set_context[i];
+                r = nft_set_element_add_uint64(s, u->cgroup_id);
+                if (r < 0)
+                        log_warning_errno(r, "Adding NFT family %s table %s set %s cgroup %" PRIu64 " failed, ignoring: %m",
+                                 nfproto_to_string(s->nfproto),
+                                 s->table,
+                                 s->set,
+                                 u->cgroup_id);
+        }
+}
+
+static void cgroup_delete_nft_set(Unit *u) {
+        int r;
+        CGroupContext *c;
+
+        assert(u);
+
+        assert_se(c = unit_get_cgroup_context(u));
+
+        for (size_t i = 0; i < c->n_nft_set_contexts; i++) {
+                NFTSetContext *s = &c->nft_set_context[i];
+                r = nft_set_element_del_uint64(s, u->cgroup_id);
+                if (r < 0)
+                        log_warning_errno(r, "Deleting NFT family %s table %s set %s cgroup %" PRIu64 " failed, ignoring: %m",
+                                 nfproto_to_string(s->nfproto),
+                                 s->table,
+                                 s->set,
+                                 u->cgroup_id);
+        }
+}
+
 static void cgroup_apply_socket_bind(Unit *u) {
         assert(u);
 
@@ -1658,6 +1706,8 @@ static void cgroup_context_apply(
 
         if (apply_mask & CGROUP_MASK_BPF_RESTRICT_NETWORK_INTERFACES)
                 cgroup_apply_restrict_network_interfaces(u);
+
+        cgroup_apply_nft_set(u);
 }
 
 static bool unit_get_needs_bpf_firewall(Unit *u) {
@@ -2807,6 +2857,8 @@ void unit_prune_cgroup(Unit *u) {
         (void) lsm_bpf_cleanup(u); /* Remove cgroup from the global LSM BPF map */
 #endif
 
+        cgroup_delete_nft_set(u);
+
         is_root_slice = unit_has_name(u, SPECIAL_ROOT_SLICE);
 
         r = cg_trim_everywhere(u->manager->cgroup_supported, u->cgroup_path, !is_root_slice);
diff --git a/src/core/cgroup.h b/src/core/cgroup.h
index 4413eeaaa0..6ac28d7ca7 100644
--- a/src/core/cgroup.h
+++ b/src/core/cgroup.h
@@ -6,6 +6,7 @@
 #include "bpf-lsm.h"
 #include "cgroup-util.h"
 #include "cpu-set-util.h"
+#include "firewall-util.h"
 #include "list.h"
 #include "time-util.h"
 
@@ -194,6 +195,9 @@ struct CGroupContext {
         ManagedOOMMode moom_mem_pressure;
         uint32_t moom_mem_pressure_limit; /* Normalized to 2^32-1 == 100% */
         ManagedOOMPreference moom_preference;
+
+        NFTSetContext *nft_set_context;
+        size_t n_nft_set_contexts;
 };
 
 /* Used when querying IP accounting data */
diff --git a/src/core/dbus-cgroup.c b/src/core/dbus-cgroup.c
index 9a31355a4d..6070c21c4c 100644
--- a/src/core/dbus-cgroup.c
+++ b/src/core/dbus-cgroup.c
@@ -15,6 +15,7 @@
 #include "errno-util.h"
 #include "fd-util.h"
 #include "fileio.h"
+#include "firewall-util.h"
 #include "in-addr-prefix-util.h"
 #include "ip-protocol-list.h"
 #include "limits-util.h"
@@ -443,6 +444,36 @@ static int property_get_restrict_network_interfaces(
         return sd_bus_message_close_container(reply);
 }
 
+static int property_get_cgroup_nft_set(
+                sd_bus *bus,
+                const char *path,
+                const char *interface,
+                const char *property,
+                sd_bus_message *reply,
+                void *userdata,
+                sd_bus_error *error) {
+        int r;
+        CGroupContext *c = userdata;
+
+        assert(bus);
+        assert(reply);
+        assert(c);
+
+        r = sd_bus_message_open_container(reply, 'a', "(iss)");
+        if (r < 0)
+                return r;
+
+        for (size_t i = 0; i < c->n_nft_set_contexts; i++) {
+                NFTSetContext *s = &c->nft_set_context[i];
+
+                r = sd_bus_message_append(reply, "(iss)", s->nfproto, s->table, s->set);
+                if (r < 0)
+                        return r;
+        }
+
+        return sd_bus_message_close_container(reply);
+}
+
 const sd_bus_vtable bus_cgroup_vtable[] = {
         SD_BUS_VTABLE_START(0),
         SD_BUS_PROPERTY("Delegate", "b", bus_property_get_bool, offsetof(CGroupContext, delegate), 0),
@@ -500,6 +531,7 @@ const sd_bus_vtable bus_cgroup_vtable[] = {
         SD_BUS_PROPERTY("SocketBindAllow", "a(iiqq)", property_get_socket_bind, offsetof(CGroupContext, socket_bind_allow), 0),
         SD_BUS_PROPERTY("SocketBindDeny", "a(iiqq)", property_get_socket_bind, offsetof(CGroupContext, socket_bind_deny), 0),
         SD_BUS_PROPERTY("RestrictNetworkInterfaces", "(bas)", property_get_restrict_network_interfaces, 0, 0),
+        SD_BUS_PROPERTY("ControlGroupNFTSet", "a(iss)", property_get_cgroup_nft_set, 0, SD_BUS_VTABLE_PROPERTY_CONST),
         SD_BUS_VTABLE_END
 };
 
@@ -2062,5 +2094,58 @@ int bus_cgroup_set_property(
         if (streq(name, "DisableControllers") || (u->transient && u->load_state == UNIT_STUB))
                 return bus_cgroup_set_transient_property(u, c, name, message, flags, error);
 
+        if (streq(name, "ControlGroupNFTSet")) {
+                int nfproto;
+                const char *table, *set;
+                bool empty = true;
+
+                r = sd_bus_message_enter_container(message, 'a', "(iss)");
+                if (r < 0)
+                        return r;
+
+                while ((r = sd_bus_message_read(message, "(iss)", &nfproto, &table, &set)) > 0) {
+                        const char *nfproto_name;
+
+                        nfproto_name = nfproto_to_string(nfproto);
+                        if (!nfproto_name)
+                                return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Invalid protocol %d.", nfproto);
+
+                        if (nft_identifier_bad(table))
+                                return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Invalid NFT table name %s.", table);
+
+                        if (nft_identifier_bad(set))
+                                return sd_bus_error_setf(error, SD_BUS_ERROR_INVALID_ARGS, "Invalid NFT set name %s.", set);
+
+                        if (!UNIT_WRITE_FLAGS_NOOP(flags)) {
+                                r = nft_set_context_add(&c->nft_set_context, &c->n_nft_set_contexts, nfproto, table, set);
+                                if (r < 0)
+                                        return r;
+
+                                unit_write_settingf(
+                                                u, flags|UNIT_ESCAPE_SPECIFIERS, name,
+                                                "%s=%s:%s:%s",
+                                                name,
+                                                nfproto_name,
+                                                table,
+                                                set);
+                        }
+
+                        empty = false;
+                }
+                if (r < 0)
+                        return r;
+
+                r = sd_bus_message_exit_container(message);
+                if (r < 0)
+                        return r;
+
+                if (empty) {
+                        c->nft_set_context = nft_set_context_free_many(c->nft_set_context, &c->n_nft_set_contexts);
+                        unit_write_settingf(u, flags, name, "%s=", name);
+                }
+
+                return 1;
+        }
+
         return 0;
 }
diff --git a/src/core/load-fragment-gperf.gperf.in b/src/core/load-fragment-gperf.gperf.in
index 7817c20c0b..0db24268d1 100644
--- a/src/core/load-fragment-gperf.gperf.in
+++ b/src/core/load-fragment-gperf.gperf.in
@@ -241,6 +241,7 @@
 {{type}}.SocketBindAllow,                  config_parse_cgroup_socket_bind,             0,                                  offsetof({{type}}, cgroup_context.socket_bind_allow)
 {{type}}.SocketBindDeny,                   config_parse_cgroup_socket_bind,             0,                                  offsetof({{type}}, cgroup_context.socket_bind_deny)
 {{type}}.RestrictNetworkInterfaces,        config_parse_restrict_network_interfaces,    0,                                  offsetof({{type}}, cgroup_context)
+{{type}}.ControlGroupNFTSet,               config_parse_cgroup_nft_set,                 0,                                  offsetof({{type}}, cgroup_context)
 {%- endmacro -%}
 
 %{
diff --git a/src/core/load-fragment.c b/src/core/load-fragment.c
index 3ff6eae8fc..12c1af7933 100644
--- a/src/core/load-fragment.c
+++ b/src/core/load-fragment.c
@@ -35,8 +35,10 @@
 #include "env-util.h"
 #include "errno-list.h"
 #include "escape.h"
+#include "execute.h"
 #include "fd-util.h"
 #include "fileio.h"
+#include "firewall-util.h"
 #include "fs-util.h"
 #include "hexdecoct.h"
 #include "io-util.h"
@@ -6520,3 +6522,88 @@ int config_parse_tty_size(
 
         return config_parse_unsigned(unit, filename, line, section, section_line, lvalue, ltype, rvalue, data, userdata);
 }
+
+static int config_parse_nft_set(
+                const char *unit,
+                const char *filename,
+                unsigned line,
+                const char *section,
+                unsigned section_line,
+                const char *lvalue,
+                int ltype,
+                const char *rvalue,
+                NFTSetContext **c,
+                size_t *n,
+                Unit *u) {
+        _cleanup_free_ char *family_str = NULL, *table = NULL, *set = NULL, *table_resolved = NULL, *set_resolved = NULL;
+        int nfproto, r;
+        assert(filename);
+        assert(lvalue);
+        assert(rvalue);
+        assert(u);
+
+        if (isempty(rvalue)) {
+                /* Empty assignment resets the list */
+                *c = nft_set_context_free_many(*c, n);
+                return 0;
+        }
+
+        for (const char *p = rvalue;;) {
+                r = extract_many_words(&p, ":", EXTRACT_CUNESCAPE, &family_str, &table, &set, NULL);
+                if (r == -ENOMEM)
+                        return log_oom();
+                if (r == 0)
+                        break;
+                if (r != 3) {
+                        log_syntax(unit, LOG_WARNING, filename, line, r, "Failed to parse NFT set, ignoring: %s", p);
+                        return 0;
+                }
+
+                nfproto = nfproto_from_string(family_str);
+                if (nfproto < 0) {
+                        log_syntax(unit, LOG_WARNING, filename, line, 0, "Unknown NFT protocol family, ignoring: %s", family_str);
+                        return 0;
+                }
+
+                r = unit_path_printf(u, table, &table_resolved);
+                if (r < 0) {
+                        log_syntax(unit, LOG_WARNING, filename, line, r, "Failed to resolve unit specifiers in '%s', ignoring: %m", table);
+                        return 0;
+                }
+
+                if (nft_identifier_bad(table_resolved))
+                        return log_syntax(unit, LOG_WARNING, filename, line, 0, "Invalid table name %s, ignoring", table);
+
+                r = unit_path_printf(u, set, &set_resolved);
+                if (r < 0) {
+                        log_syntax(unit, LOG_WARNING, filename, line, r, "Failed to resolve unit specifiers in '%s', ignoring: %m", set);
+                        return 0;
+                }
+
+                if (nft_identifier_bad(set_resolved))
+                        return log_syntax(unit, LOG_WARNING, filename, line, 0, "Invalid set name %s, ignoring", set);
+
+                r = nft_set_context_add(c, n, nfproto, table_resolved, set_resolved);
+                if (r < 0)
+                        return log_oom();
+        }
+
+        return 0;
+}
+
+int config_parse_cgroup_nft_set(
+                const char *unit,
+                const char *filename,
+                unsigned line,
+                const char *section,
+                unsigned section_line,
+                const char *lvalue,
+                int ltype,
+                const char *rvalue,
+                void *data,
+                void *userdata) {
+        CGroupContext *c = data;
+        Unit *u = userdata;
+
+        return config_parse_nft_set(unit, filename, line, section, section_line, lvalue, ltype, rvalue, &c->nft_set_context, &c->n_nft_set_contexts, u);
+}
diff --git a/src/core/load-fragment.h b/src/core/load-fragment.h
index 26b8de28f7..3632b5b096 100644
--- a/src/core/load-fragment.h
+++ b/src/core/load-fragment.h
@@ -150,6 +150,7 @@ CONFIG_PARSER_PROTOTYPE(config_parse_cgroup_socket_bind);
 CONFIG_PARSER_PROTOTYPE(config_parse_restrict_network_interfaces);
 CONFIG_PARSER_PROTOTYPE(config_parse_watchdog_sec);
 CONFIG_PARSER_PROTOTYPE(config_parse_tty_size);
+CONFIG_PARSER_PROTOTYPE(config_parse_cgroup_nft_set);
 
 /* gperf prototypes */
 const struct ConfigPerfItem* load_fragment_gperf_lookup(const char *key, GPERF_LEN_TYPE length);
diff --git a/src/shared/bus-unit-util.c b/src/shared/bus-unit-util.c
index a326ca30a9..e1584c2e8d 100644
--- a/src/shared/bus-unit-util.c
+++ b/src/shared/bus-unit-util.c
@@ -891,6 +891,9 @@ static int bus_append_cgroup_property(sd_bus_message *m, const char *field, cons
                 return 1;
         }
 
+        if (streq(field, "ControlGroupNFTSet"))
+                return bus_append_nft_set(m, field, eq);
+
         return 0;
 }
 
diff --git a/test/fuzz/fuzz-unit-file/directives.mount b/test/fuzz/fuzz-unit-file/directives.mount
index 0a44328e5c..2b3331a411 100644
--- a/test/fuzz/fuzz-unit-file/directives.mount
+++ b/test/fuzz/fuzz-unit-file/directives.mount
@@ -28,6 +28,7 @@ Capabilities=
 CapabilityBoundingSet=
 ConfigurationDirectory=
 ConfigurationDirectoryMode=
+ControlGroupNFTSet=
 CoredumpFilter=
 DefaultMemoryLow=
 DefaultMemoryMin=
diff --git a/test/fuzz/fuzz-unit-file/directives.scope b/test/fuzz/fuzz-unit-file/directives.scope
index 4552d0b403..c4d579065a 100644
--- a/test/fuzz/fuzz-unit-file/directives.scope
+++ b/test/fuzz/fuzz-unit-file/directives.scope
@@ -8,6 +8,7 @@ BlockIODeviceWeight=
 BlockIOReadBandwidth=
 BlockIOWeight=
 BlockIOWriteBandwidth=
+ControlGroupNFTSet=
 CPUAccounting=
 CPUQuota=
 CPUQuotaPeriodSec=
diff --git a/test/fuzz/fuzz-unit-file/directives.service b/test/fuzz/fuzz-unit-file/directives.service
index 3c33d947fe..30e6936d12 100644
--- a/test/fuzz/fuzz-unit-file/directives.service
+++ b/test/fuzz/fuzz-unit-file/directives.service
@@ -72,6 +72,7 @@ ConditionSecurity=
 ConditionUser=
 ConditionVirtualization=
 Conflicts=
+ControlGroupNFTSet=
 DefaultDependencies=
 Description=
 Documentation=
diff --git a/test/fuzz/fuzz-unit-file/directives.slice b/test/fuzz/fuzz-unit-file/directives.slice
index ab77070c5e..749f1795e3 100644
--- a/test/fuzz/fuzz-unit-file/directives.slice
+++ b/test/fuzz/fuzz-unit-file/directives.slice
@@ -8,6 +8,7 @@ BlockIODeviceWeight=
 BlockIOReadBandwidth=
 BlockIOWeight=
 BlockIOWriteBandwidth=
+ControlGroupNFTSet=
 CPUAccounting=
 CPUQuota=
 CPUQuotaPeriodSec=
diff --git a/test/fuzz/fuzz-unit-file/directives.socket b/test/fuzz/fuzz-unit-file/directives.socket
index 90358fc11a..1b1ddf8c9c 100644
--- a/test/fuzz/fuzz-unit-file/directives.socket
+++ b/test/fuzz/fuzz-unit-file/directives.socket
@@ -33,6 +33,7 @@ Capabilities=
 CapabilityBoundingSet=
 ConfigurationDirectory=
 ConfigurationDirectoryMode=
+ControlGroupNFTSet=
 CoredumpFilter=
 DefaultMemoryLow=
 DefaultMemoryMin=
diff --git a/test/fuzz/fuzz-unit-file/directives.swap b/test/fuzz/fuzz-unit-file/directives.swap
index 5d057fa630..186dedbf3e 100644
--- a/test/fuzz/fuzz-unit-file/directives.swap
+++ b/test/fuzz/fuzz-unit-file/directives.swap
@@ -28,6 +28,7 @@ Capabilities=
 CapabilityBoundingSet=
 ConfigurationDirectory=
 ConfigurationDirectoryMode=
+ControlGroupNFTSet=
 CoredumpFilter=
 DefaultMemoryLow=
 DefaultMemoryMin=