From e6cb32b530a742d6cc763d3c967dc484a3049ff7 Mon Sep 17 00:00:00 2001 From: Shane Ringrose Date: Wed, 24 Jun 2026 09:44:43 +0000 Subject: [PATCH 1/3] Device Groups: nftables backend for named device groups Adds the backend that maps named device groups to nftables sets: - known_devices.sh / manage_groups.sh: enumerate groups from dhcp host sections and build/seed an nftables set (grp_) per group from the current lease table - post_lease.sh: dnsmasq lease hook that adds/removes a device IP from its group set at runtime (no firewall restart) - gargoyle_firewall_util.sh: build the group sets before inserting restriction rules that reference them (ordering fix) - make_nftables_rules.c / restore_quotas.c: resolve GROUP: targets to the corresponding nftables set - uci-defaults/gargoyle-init: initialise group support --- .../files/gargoyle_firewall_util.sh | 8 ++ .../src/make_nftables_rules.c | 102 ++++++++++++--- .../src/restore_quotas.c | 68 +++++++++- .../files/etc/uci-defaults/gargoyle-init | 7 ++ .../files/usr/lib/gargoyle/known_devices.sh | 116 ++++++++++++++++++ .../files/usr/lib/gargoyle/manage_groups.sh | 62 ++++++++++ .../files/usr/lib/gargoyle/post_lease.sh | 54 ++++++++ 7 files changed, 395 insertions(+), 22 deletions(-) create mode 100644 package/gargoyle/files/usr/lib/gargoyle/known_devices.sh create mode 100755 package/gargoyle/files/usr/lib/gargoyle/manage_groups.sh create mode 100755 package/gargoyle/files/usr/lib/gargoyle/post_lease.sh diff --git a/package/gargoyle-firewall-util/files/gargoyle_firewall_util.sh b/package/gargoyle-firewall-util/files/gargoyle_firewall_util.sh index c8c520cf7..f28bafb4b 100644 --- a/package/gargoyle-firewall-util/files/gargoyle_firewall_util.sh +++ b/package/gargoyle-firewall-util/files/gargoyle_firewall_util.sh @@ -723,8 +723,16 @@ isolate_guest_networks() } +manage_device_groups() +{ + if [ -x /usr/lib/gargoyle/manage_groups.sh ] ; then + /usr/lib/gargoyle/manage_groups.sh + fi +} + ifup_firewall() { + manage_device_groups insert_restriction_rules initialize_quotas insert_pf_loopback_rules diff --git a/package/gargoyle-firewall-util/src/make_nftables_rules.c b/package/gargoyle-firewall-util/src/make_nftables_rules.c index 0bfedfd64..12b21bde7 100644 --- a/package/gargoyle-firewall-util/src/make_nftables_rules.c +++ b/package/gargoyle-firewall-util/src/make_nftables_rules.c @@ -2,7 +2,7 @@ * Originally designed for use with Gargoyle router firmware (gargoyle-router.com) * * - * Copyright © 2009 by Eric Bishop + * Copyright � 2009 by Eric Bishop * Rewritten for nftables 2025 by Michael Gray * * This file is free software: you may copy, redistribute and/or modify it @@ -49,6 +49,7 @@ char** parse_marks(char* list_str, unsigned long max_mask); list* parse_quoted_list(char* list_str, char quote_char, char escape_char, char add_remainder_if_uneven_quotes); int truncate_if_starts_with(char* test_str, char* prefix); +char* group_name_to_set_name(char* group_name); char* invert_bitmask(const char* input, int force_32bit); @@ -796,11 +797,41 @@ char* get_option_value_string(struct uci_option* uopt) return opt_str; } +char* group_name_to_set_name(char* group_name) +{ + int src_len = strlen(group_name); + char* sanitized = (char*)malloc(src_len + 1); + int i; + int out = 0; + int last_was_under = 0; + for(i = 0; i < src_len; i++) + { + char c = group_name[i]; + char out_c; + if(c >= 'A' && c <= 'Z') { out_c = c + 32; } + else if((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { out_c = c; } + else { out_c = '_'; } + /* squeeze consecutive underscores (match shell: tr -cs 'a-z0-9_' '_') */ + if(out_c == '_' && last_was_under) { continue; } + sanitized[out++] = out_c; + last_was_under = (out_c == '_'); + } + sanitized[out] = '\0'; + char* set_name = dynamic_strcat(2, "grp_", sanitized); + free(sanitized); + if(strlen(set_name) > 31) + { + set_name[31] = '\0'; + } + return set_name; +} + char*** parse_ips_and_macs(char* addr_str) { unsigned long num_pieces; char** addr_parts = split_on_separators(addr_str, ",", 1, -1, 0, &num_pieces); - list* ip_list = initialize_list(); + list* ip_list = initialize_list(); /* plain IPv4 addresses/ranges/CIDRs */ + list* set_ref_list = initialize_list(); /* @setname refs from GROUP: entries */ list* ip6_list = initialize_list(); list* mac_list = initialize_list(); @@ -808,6 +839,14 @@ char*** parse_ips_and_macs(char* addr_str) for(ip_part_index=0; addr_parts[ip_part_index] != NULL; ip_part_index++) { char* next_str = addr_parts[ip_part_index]; + if(strncmp(next_str, "GROUP:", 6) == 0) + { + char* setname = group_name_to_set_name(next_str + 6); + char* setref = dynamic_strcat(2, "@", setname); + free(setname); + push_list(set_ref_list, setref); + continue; + } //ipv4, ipv6 or any can be MAC address. Test it first if(strchr(next_str, ':')) { @@ -858,20 +897,22 @@ char*** parse_ips_and_macs(char* addr_str) char*** return_value = (char***)malloc(3*sizeof(char**)); - unsigned long num1, num2, num3; - char** ip_strs = (char**)destroy_list(ip_list, DESTROY_MODE_RETURN_VALUES, &num1); - char** ip6_strs = (char**)destroy_list(ip6_list, DESTROY_MODE_RETURN_VALUES, &num2); - char** mac_strs = (char**)destroy_list(mac_list, DESTROY_MODE_RETURN_VALUES, &num3); + unsigned long num_plain_ip, num_set_refs, num2, num3; + char** plain_ip_strs = (char**)destroy_list(ip_list, DESTROY_MODE_RETURN_VALUES, &num_plain_ip); + char** set_ref_strs = (char**)destroy_list(set_ref_list, DESTROY_MODE_RETURN_VALUES, &num_set_refs); + char** ip6_strs = (char**)destroy_list(ip6_list, DESTROY_MODE_RETURN_VALUES, &num2); + char** mac_strs = (char**)destroy_list(mac_list, DESTROY_MODE_RETURN_VALUES, &num3); - char* match_ip_str = join_strs(",", ip_strs, -1, 1, 1); - char* match_ip6_str = join_strs(",", ip6_strs, -1, 1, 1); - char* match_mac_str = join_strs(",", mac_strs, -1, 1, 1); + /* plain IPs: join with commas and wrap in { } when multiple (nftables inline set) */ + char* match_plain_ip_str = join_strs(",", plain_ip_strs, -1, 1, 1); + char* match_ip6_str = join_strs(",", ip6_strs, -1, 1, 1); + char* match_mac_str = join_strs(",", mac_strs, -1, 1, 1); - if(num1 > 1) + if(num_plain_ip > 1) { - char* tmp = dynamic_strcat(3, "{", match_ip_str, "}"); - free(match_ip_str); - match_ip_str = tmp; + char* tmp = dynamic_strcat(3, "{", match_plain_ip_str, "}"); + free(match_plain_ip_str); + match_plain_ip_str = tmp; } if(num2 > 1) { @@ -885,20 +926,45 @@ char*** parse_ips_and_macs(char* addr_str) free(match_mac_str); match_mac_str = tmp; } - - if(num1 + num2 + num3 == 0) + + if(num_plain_ip + num_set_refs + num2 + num3 == 0) { free(return_value); return_value = NULL; } else { - return_value[MATCH_IP_INDEX] = (char**)malloc(2*sizeof(char*)); + /* + * Build the IPv4 match array so that: + * - plain IPs (if any) appear as one entry, already formatted as a + * single address or "{ip1,ip2,...}" inline set + * - each GROUP:-derived @setname is a SEPARATE entry so that + * compute_multi_rules emits one rule per set, letting the connmark + * OR logic handle them correctly. Mixing named sets with { } is + * not valid nftables syntax so they must never be combined. + */ + unsigned long ip_entry_count = (num_plain_ip > 0 ? 1 : 0) + num_set_refs; + char** ip_return = (char**)malloc((ip_entry_count + 1) * sizeof(char*)); + unsigned long ip_idx = 0; + if(num_plain_ip > 0) + { + ip_return[ip_idx++] = match_plain_ip_str; + } + else + { + free(match_plain_ip_str); /* NULL-safe */ + } + unsigned long sr; + for(sr = 0; sr < num_set_refs; sr++) + { + ip_return[ip_idx++] = set_ref_strs[sr]; + } + ip_return[ip_idx] = NULL; + + return_value[MATCH_IP_INDEX] = ip_return; return_value[MATCH_IP6_INDEX] = (char**)malloc(2*sizeof(char*)); return_value[MATCH_MAC_INDEX] = (char**)malloc(2*sizeof(char*)); - return_value[MATCH_IP_INDEX][0] = match_ip_str; - return_value[MATCH_IP_INDEX][1] = NULL; return_value[MATCH_IP6_INDEX][0] = match_ip6_str; return_value[MATCH_IP6_INDEX][1] = NULL; return_value[MATCH_MAC_INDEX][0] = match_mac_str; diff --git a/package/gargoyle-firewall-util/src/restore_quotas.c b/package/gargoyle-firewall-util/src/restore_quotas.c index c16a96eff..94689f3b9 100644 --- a/package/gargoyle-firewall-util/src/restore_quotas.c +++ b/package/gargoyle-firewall-util/src/restore_quotas.c @@ -47,6 +47,7 @@ list* filter_group_from_list(list** orig_ip_list, char* ip_group_str); int get_ipstr_family(char* ip_str); char* invert_bitmask(const char* input, int force_32bit); +char* group_name_to_set_name(char* group_name); void delete_chain_from_table(char* family, char* table, char* delete_chain); void run_shell_command(char* command, int free_command_str); void free_split_pieces(char** split_pieces); @@ -330,8 +331,9 @@ int main(int argc, char** argv) ip = dynamic_replace(ip, "- ", "-"); free(tmp_ip); } - - + + int is_group_target = (strncmp(ip, "GROUP:", 6) == 0) ? 1 : 0; + if( (strcmp(ip, "ALL_OTHERS_COMBINED") == 0 || strcmp(ip, "ALL_OTHERS_INDIVIDUAL") == 0) && (!process_other_quota) ) { push_list(other_quota_section_names, strdup(next_quota)); @@ -339,7 +341,7 @@ int main(int argc, char** argv) else { unsigned char is_individual_other = strcmp(ip, "ALL_OTHERS_INDIVIDUAL") == 0 ? 1 : 0; - if( strcmp(ip, "ALL_OTHERS_COMBINED") != 0 && strcmp(ip, "ALL_OTHERS_INDIVIDUAL") != 0 && strcmp(ip, "ALL") != 0 ) + if( strcmp(ip, "ALL_OTHERS_COMBINED") != 0 && strcmp(ip, "ALL_OTHERS_INDIVIDUAL") != 0 && strcmp(ip, "ALL") != 0 && !is_group_target ) { /* this is an explicitly defined ip or ip range, so save it for later, to deal with individual other overlap problem */ push_list(defined_ip_groups, strdup(ip)); @@ -497,7 +499,7 @@ int main(int argc, char** argv) int foundip6 = 0; char* ip4_list = NULL; char* ip6_list = NULL; - if( strcmp(ip, "ALL_OTHERS_COMBINED") != 0 && strcmp(ip, "ALL_OTHERS_INDIVIDUAL") != 0 && strcmp(ip, "ALL") != 0 ) + if( strcmp(ip, "ALL_OTHERS_COMBINED") != 0 && strcmp(ip, "ALL_OTHERS_INDIVIDUAL") != 0 && strcmp(ip, "ALL") != 0 && !is_group_target ) { char* src_test = dynamic_strcat(3, "saddr ", ip, " "); char* dst_test = dynamic_strcat(3, "daddr ", ip, " "); @@ -628,6 +630,33 @@ int main(int argc, char** argv) free(dst_test); free(src_test); } + else if(is_group_target) + { + char* setname = group_name_to_set_name(ip + 6); + char* src_test = dynamic_strcat(3, " ip saddr @", setname, " "); + char* dst_test = dynamic_strcat(3, " ip daddr @", setname, " "); + free(setname); + foundip4 = 1; + foundip6 = 0; + if(strcmp(types[type_index], "combined_limit") == 0) + { + run_shell_command(dynamic_strcat(9, "nft add rule ", quota_family_table, " ", quota_chain_prefix, chains[type_index], " iifname ", wan_if, dst_test, " ct mark set ct mark \\& 0xF0FFFFFF \\| 0x0F000000 2>/dev/null"), 1); + run_shell_command(dynamic_strcat(9, "nft add rule ", quota_family_table, " ", quota_chain_prefix, chains[type_index], " oifname ", wan_if, src_test, " ct mark set ct mark \\& 0xF0FFFFFF \\| 0x0F000000 2>/dev/null"), 1); + char* rule_end = strdup(" ct mark \\& 0x0F000000 == 0x0F000000 "); + ip_test = dcat_and_free(&ip_test, &rule_end, 1, 1); + } + else if(strcmp(types[type_index], "egress_limit") == 0) + { + ip_test = dcat_and_free(&ip_test, &src_test, 1, 0); + } + else + { + ip_test = dcat_and_free(&ip_test, &dst_test, 1, 0); + } + run_shell_command(dynamic_strcat(7, "nft add rule ", quota_family_table, " ", quota_chain_prefix, chains[type_index], ip_test, " ct mark set ct mark \\& 0x0FFFFFFF \\| 0xF0000000 2>/dev/null"), 1); + free(dst_test); + free(src_test); + } else if( strcmp(ip, "ALL_OTHERS_COMBINED") == 0 || strcmp(ip, "ALL_OTHERS_INDIVIDUAL") == 0 ) { char* rule_end = strdup(" ct mark \\& 0xF0000000 == 0x0"); @@ -787,6 +816,15 @@ int main(int argc, char** argv) { run_shell_command(dynamic_strcat(7, "nft add rule ", quota_family_table, " nat_quota_redirects tcp dport {80,443} ", time_match_str, " ct mark \\& 0xF0000000 == 0x0 bandwidth bcheck id \\\"", type_id, "\\\" redirect"), 1); } + else if(is_group_target) + { + char* setname = group_name_to_set_name(ip + 6); + run_shell_command(dynamic_strcat(5, "nft add rule ", quota_family_table, " nat_quota_redirects ip saddr @", setname, " ct mark set ct mark \\& 0xF0FFFFFF \\| 0x0F000000 2>/dev/null"), 1); + run_shell_command(dynamic_strcat(7, "nft add rule ", quota_family_table, " nat_quota_redirects tcp dport {80,443} ", time_match_str, " ct mark \\& 0x0F000000 == 0x0F000000 bandwidth bcheck id \\\"", type_id, "\\\" redirect"), 1); + run_shell_command(dynamic_strcat(3, "nft add rule ", quota_family_table, " nat_quota_redirects ct mark \\& 0x0F000000 == 0x0F000000 ct mark set ct mark \\& 0x0FFFFFFF \\| 0xF0000000 2>/dev/null"), 1); + run_shell_command(dynamic_strcat(3, "nft add rule ", quota_family_table, " nat_quota_redirects ct mark set ct mark \\& 0xF0FFFFFF \\| 0x0 2>/dev/null"), 1); + free(setname); + } else { if(ip4_list != NULL) @@ -1289,6 +1327,28 @@ char* invert_bitmask(const char* input, int force_32bit) return result; } +char* group_name_to_set_name(char* group_name) +{ + int src_len = strlen(group_name); + char* sanitized = (char*)malloc(src_len + 1); + int i; + for(i = 0; i < src_len; i++) + { + char c = group_name[i]; + if(c >= 'A' && c <= 'Z') { sanitized[i] = c + 32; } + else if((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { sanitized[i] = c; } + else { sanitized[i] = '_'; } + } + sanitized[src_len] = '\0'; + char* set_name = dynamic_strcat(2, "grp_", sanitized); + free(sanitized); + if(strlen(set_name) > 31) + { + set_name[31] = '\0'; + } + return set_name; +} + void delete_chain_from_table(char* family, char* table, char* delete_chain) { char *command = dynamic_strcat(5, "nft -a list table ", family, " ", table, " 2>/dev/null"); diff --git a/package/gargoyle/files/etc/uci-defaults/gargoyle-init b/package/gargoyle/files/etc/uci-defaults/gargoyle-init index 8f263ee9d..e412c0dde 100755 --- a/package/gargoyle/files/etc/uci-defaults/gargoyle-init +++ b/package/gargoyle/files/etc/uci-defaults/gargoyle-init @@ -161,3 +161,10 @@ cat << EOF >/etc/sysupgrade.conf EOF + +# Register dnsmasq lease hook for Device Groups (only if no existing script is set) +existing_dhcp_script=$(uci get dhcp.@dnsmasq[0].dhcpscript 2>/dev/null) +if [ -z "$existing_dhcp_script" ] ; then + uci set dhcp.@dnsmasq[0].dhcpscript=/usr/lib/gargoyle/post_lease.sh + uci commit dhcp +fi diff --git a/package/gargoyle/files/usr/lib/gargoyle/known_devices.sh b/package/gargoyle/files/usr/lib/gargoyle/known_devices.sh new file mode 100644 index 000000000..df905b71c --- /dev/null +++ b/package/gargoyle/files/usr/lib/gargoyle/known_devices.sh @@ -0,0 +1,116 @@ +#!/bin/sh +# This program is copyright © 2026 and is distributed under the terms of the GNU GPL +# version 2.0 with a special clarification/exception that permits adapting the program to +# configure proprietary "back end" software provided that all modifications to the web interface +# itself remain covered by the GPL. +# See http://gargoyle-router.com/faq.html#qfoss for more information +# +# Known Devices / Device Groups — shared UCI helper library +# +# Data model (stored in /etc/config/dhcp): +# +# config host 'static_host_1' +# option name 'Johns-Laptop' +# option mac 'AA:BB:CC:DD:EE:FF' +# option ip '192.168.1.100' # optional static IP +# option group 'family' # optional group membership +# +# Groups are identified purely by their name string — there is no separate +# 'config group' section. All unique group values across all host sections +# are the set of defined groups. +# +# nftables set naming: +# Group names are sanitized to lowercase, with any character outside +# [a-z0-9_] replaced by '_', and prefixed with 'grp_' to avoid collisions +# with other nftables objects. Names are truncated to 31 characters total +# (nftables identifier limit). +# e.g. "Family Devices" -> "grp_family_devices" +# "Dad's Phone!" -> "grp_dad_s_phone_" + + +# group_to_set_name +# Converts a human-readable group name to a safe nftables set name. +group_to_set_name() +{ + printf 'grp_%s' "$(printf '%s' "$1" | tr 'A-Z' 'a-z' | tr -cs 'a-z0-9_' '_')" | cut -c1-31 +} + +# get_all_known_device_sections +# Prints UCI section names for every 'host' section in /etc/config/dhcp. +get_all_known_device_sections() +{ + uci show dhcp 2>/dev/null | grep '=host$' | sed 's/dhcp\.\(.*\)=host/\1/' +} + +# get_device_field
+# Returns the value of a field for a host section, or empty string if unset. +get_device_field() +{ + uci get "dhcp.$1.$2" 2>/dev/null +} + +# get_device_group
+# Returns the group name for a host section, or empty string if unset. +get_device_group() +{ + get_device_field "$1" "group" +} + +# set_device_group
+# Assigns a host section to a group. Pass empty string to remove. +set_device_group() +{ + local section="$1" + local group="$2" + if [ -z "$group" ] + then + uci del "dhcp.$section.group" 2>/dev/null + else + uci set "dhcp.$section.group=$group" + fi +} + +# get_all_groups +# Prints each unique group name (one per line) found across all host sections. +get_all_groups() +{ + local sections + sections=$(get_all_known_device_sections) + local section + for section in $sections + do + get_device_group "$section" + done | sort -u | grep -v '^$' +} + +# get_sections_in_group +# Prints the UCI section names of every host that belongs to the given group. +get_sections_in_group() +{ + local target_group="$1" + local sections + sections=$(get_all_known_device_sections) + local section + for section in $sections + do + local grp + grp=$(get_device_group "$section") + if [ "$grp" = "$target_group" ] + then + echo "$section" + fi + done +} + +# get_macs_in_group +# Prints the MAC address of every host that belongs to the given group. +get_macs_in_group() +{ + local sections + sections=$(get_sections_in_group "$1") + local section + for section in $sections + do + get_device_field "$section" "mac" + done | grep -v '^$' +} diff --git a/package/gargoyle/files/usr/lib/gargoyle/manage_groups.sh b/package/gargoyle/files/usr/lib/gargoyle/manage_groups.sh new file mode 100755 index 000000000..2d2be6c81 --- /dev/null +++ b/package/gargoyle/files/usr/lib/gargoyle/manage_groups.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# This program is copyright © 2026 and is distributed under the terms of the GNU GPL +# version 2.0 with a special clarification/exception that permits adapting the program to +# configure proprietary "back end" software provided that all modifications to the web interface +# itself remain covered by the GPL. +# See http://gargoyle-router.com/faq.html#qfoss for more information +# +# manage_groups.sh — Device Group nftables set management +# +# Maintains one nftables set per Device Group in inet fw4. +# Called from ifup_firewall() after fw4 initialises, so the table is guaranteed +# to exist. Safe to call multiple times (idempotent). +# +# Set lifecycle: +# - Sets for groups that exist in UCI are (re)created and seeded from +# /tmp/dhcp.leases so IPs are correct even if dnsmasq is already running. +# - Sets whose group name has been removed from UCI are deleted. +# +# Runtime updates are handled by post_lease.sh via the dnsmasq dhcpscript hook. + +. /usr/lib/gargoyle/known_devices.sh + +NFT_FAMILY="inet" +NFT_TABLE="fw4" + +# Guard: do nothing if fw4 table isn't up yet +nft list table "$NFT_FAMILY" "$NFT_TABLE" >/dev/null 2>&1 || exit 0 + +groups=$(get_all_groups) + +# Build a space-separated list of desired set names +desired_sets="" +for group in $groups; do + desired_sets="$desired_sets $(group_to_set_name "$group")" +done + +# Remove sets whose group no longer exists in UCI +for existing in $(nft list sets "$NFT_FAMILY" "$NFT_TABLE" 2>/dev/null | awk '/set grp_/{print $2}'); do + found=0 + for wanted in $desired_sets; do + [ "$existing" = "$wanted" ] && found=1 && break + done + [ "$found" = "0" ] && nft delete set "$NFT_FAMILY" "$NFT_TABLE" "$existing" 2>/dev/null +done + +# Create/recreate each group's set and seed it from the current lease table +for group in $groups; do + setname=$(group_to_set_name "$group") + + # Recreate to flush stale IPs (rules referencing this set survive deletion + # in nftables if we add it back before the transaction commits, but since + # restore_quotas/make_nftables_rules recreate rules anyway this is fine) + nft delete set "$NFT_FAMILY" "$NFT_TABLE" "$setname" 2>/dev/null + nft add set "$NFT_FAMILY" "$NFT_TABLE" "$setname" \{ type ipv4_addr\; \} 2>/dev/null || continue + + # Seed from /tmp/dhcp.leases (format: ) + for mac in $(get_macs_in_group "$group"); do + lc_mac=$(printf '%s' "$mac" | tr 'A-Z' 'a-z') + ip=$(awk -v m="$lc_mac" 'tolower($2)==m && $3!="*" {print $3; exit}' /tmp/dhcp.leases 2>/dev/null) + [ -n "$ip" ] && nft add element "$NFT_FAMILY" "$NFT_TABLE" "$setname" \{ "$ip" \} 2>/dev/null + done +done diff --git a/package/gargoyle/files/usr/lib/gargoyle/post_lease.sh b/package/gargoyle/files/usr/lib/gargoyle/post_lease.sh new file mode 100755 index 000000000..98cecee11 --- /dev/null +++ b/package/gargoyle/files/usr/lib/gargoyle/post_lease.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# This program is copyright © 2026 and is distributed under the terms of the GNU GPL +# version 2.0 with a special clarification/exception that permits adapting the program to +# configure proprietary "back end" software provided that all modifications to the web interface +# itself remain covered by the GPL. +# See http://gargoyle-router.com/faq.html#qfoss for more information +# +# post_lease.sh — dnsmasq DHCP lease hook for Device Groups +# +# Registered via UCI: dhcp.@dnsmasq[0].dhcpscript=/usr/lib/gargoyle/post_lease.sh +# +# dnsmasq invokes this script with: +# $1 event type: add | del | old +# $2 MAC address (e.g. aa:bb:cc:dd:ee:ff) +# $3 IP address (e.g. 192.168.1.100) +# $4 hostname (may be '*' or empty) +# +# 'old' events are sent for existing leases at dnsmasq startup, which means +# group sets are populated automatically whenever dnsmasq (re)starts. + +. /usr/lib/gargoyle/known_devices.sh + +NFT_FAMILY="inet" +NFT_TABLE="fw4" + +event="$1" +mac="$(printf '%s' "$2" | tr 'a-z' 'A-Z')" +ip="$3" + +# Sanity checks +[ -z "$event" ] || [ -z "$mac" ] || [ -z "$ip" ] && exit 0 +[ "$ip" = "*" ] && exit 0 +[ "$event" != "add" ] && [ "$event" != "del" ] && [ "$event" != "old" ] && exit 0 + +# Find which group this MAC belongs to by scanning UCI host sections +for section in $(get_all_known_device_sections); do + stored_mac=$(get_device_field "$section" "mac" | tr 'a-z' 'A-Z') + [ "$stored_mac" != "$mac" ] && continue + + group=$(get_device_group "$section") + [ -z "$group" ] && exit 0 + + setname=$(group_to_set_name "$group") + + if [ "$event" = "add" ] || [ "$event" = "old" ]; then + nft add element "$NFT_FAMILY" "$NFT_TABLE" "$setname" \{ "$ip" \} 2>/dev/null + else + nft delete element "$NFT_FAMILY" "$NFT_TABLE" "$setname" \{ "$ip" \} 2>/dev/null + fi + + exit 0 +done + +exit 0 From bd0c869c0001ad5bf45700fb1871a7ff64f646a2 Mon Sep 17 00:00:00 2001 From: Shane Ringrose Date: Wed, 24 Jun 2026 09:44:43 +0000 Subject: [PATCH 2/3] Device Groups: unified Devices table on the DHCP page Replaces the separate Static IPs and Known Devices panels with one Devices table. Each device is a single config host section with an optional fixed IP, optional IPv6 reservation (behind an Advanced toggle), and optional group. MAC is the only required field; the connected-hosts dropdown pre-fills name/MAC and the current lease IP. Group names are validated to a safe charset. One host section per device avoids the duplicate dhcp-host that previously crashed dnsmasq. --- package/gargoyle/files/www/dhcp.sh | 30 +- package/gargoyle/files/www/js/dhcp.js | 595 ++++++++++-------- .../files/www/templates/device_template | 34 + .../files/www/i18n/English-EN/dhcp.js | 15 + 4 files changed, 400 insertions(+), 274 deletions(-) mode change 100755 => 100644 package/gargoyle/files/www/dhcp.sh create mode 100644 package/gargoyle/files/www/templates/device_template diff --git a/package/gargoyle/files/www/dhcp.sh b/package/gargoyle/files/www/dhcp.sh old mode 100755 new mode 100644 index 2083b5964..4946428bf --- a/package/gargoyle/files/www/dhcp.sh +++ b/package/gargoyle/files/www/dhcp.sh @@ -47,6 +47,12 @@ echo "var ip6neighData = new Array();"; ip -6 neigh | grep -v "FAILED" | grep -v "^fe80:" | awk '{print "ip6neighData[\""$1"\"] = \""$5"\";"};' + + echo "var knownDeviceGroups = [];" + uci show dhcp 2>/dev/null | grep '^dhcp\.[^.]*\.group=' | sed "s/^[^=]*=//; s/'//g" | sort -u | grep -v '^$' | while read grp ; do + escaped=$(printf '%s' "$grp" | sed 's/\\/\\\\/g; s/"/\\"/g') + printf 'knownDeviceGroups.push("%s");\n' "$escaped" + done %> var ipHostHash = new Array(); @@ -182,7 +188,7 @@ ipHostHash["::1"] = "localhost6";
-

<%~ SIPs %>

+

<%~ dhcp.Devs %>

@@ -192,28 +198,28 @@ ipHostHash["::1"] = "localhost6";
-
- +
+
- (<%~ opt %>) - +
-
- <%~ AsSIP %>: +
+ <%~ dhcp.AsDevs %>:
-
+
@@ -229,16 +235,16 @@ ipHostHash["::1"] = "localhost6";
-