~dblsaiko/nix-extras

11f2c8a31097872efe0b934d0c7cd2cd5ae4065b — Marco Rebhan 2 months ago e3ce996
nixos: Add nftables extensions
M nixos/ext/default.nix => nixos/ext/default.nix +2 -1
@@ 1,5 1,6 @@
{
  imports = [
    ./rspamd.nix
    ./networking
    ./services
  ];
}

A nixos/ext/networking/default.nix => nixos/ext/networking/default.nix +5 -0
@@ 0,0 1,5 @@
{
  imports = [
    ./nftables
  ];
}

A nixos/ext/networking/nftables/chains.nix => nixos/ext/networking/nftables/chains.nix +198 -0
@@ 0,0 1,198 @@
{
  config,
  lib,
  ...
}: let
  inherit (lib) attrValues concatMapStringsSep concatStringsSep filter genList head id length mapAttrsToList mkDefault mkEnableOption mkMerge mkOption mkOrder pipe singleton sortOn tail zipLists;
  inherit (lib.types) attrsOf bool enum int lines listOf nullOr str submodule;
  inherit (import ./lib.nix lib) formatPriority indent strLines flatMap flatMapAttrsToList;

  indices = l: genList id (length l);
  enumerate = l: zipLists (indices l) l;

  formatRule = {components, ...}: let
    addEscapes = l:
      (map (v:
        if v.fst != length components - 1
        then ''${v.snd} \''
        else v.snd)) (enumerate l);
    indent = l: [(head l)] ++ map (v: "  ${v}") (tail l);
  in
    pipe components [
      addEscapes
      (flatMap strLines)
      indent
      (concatStringsSep "\n")
    ];

  namedRuleType = submodule {
    options = {
      enable = mkEnableOption "this rule";

      order = mkOption {
        default = 1000;
        description = "Determines order of rules in the chain. Rules with lower values get put further at the beginning of the chain.";
        type = int;
      };
    };
  };

  ruleType = submodule ({config, ...}: {
    options = {
      components = mkOption {
        default = [];
        description = "Rule components";
        example = [
          ''iifname lo''
          ''accept''
          ''comment "Allow traffic from ourselves"''
        ];
        type = listOf str;
      };
    };
  });

  chainType = submodule ({
    config,
    name,
    options,
    ...
  }: {
    options = {
      enable = mkOption {
        default = true;
        description = "Whether to enable this chain.";
        type = bool;
      };

      name = mkOption {
        description = "The chain name.";
        type = str;
      };

      type = mkOption {
        description = "Chain type";
        type = enum ["filter" "nat" "route"];
      };

      hook = mkOption {
        description = "Chain hook";
        type = enum ["prerouting" "input" "output" "postrouting" "forward"];
      };

      priorityBase = mkOption {
        default = null;
        description = "Sets the base value `priority` is relative to";
        type = nullOr (enum ["raw" "mangle" "dstnat" "filter" "security" "out" "srcnat"]);
      };

      priority = mkOption {
        default = null;
        description = "Chain priority";
        type = nullOr int;
      };

      policy = mkOption {
        default = "accept";
        description = "Policy for packets not explicitly matched in a rule";
        type = enum ["accept" "drop"];
      };

      comment = mkOption {
        default = "";
        description = "Chain comment";
        type = str;
      };

      namedRules = mkOption {
        default = {};
        description = "Named rules";
        type = attrsOf (options.rules.type.nestedTypes.elemType.typeMerge namedRuleType.functor);
      };

      rules = mkOption {
        default = [];
        description = "Rules in this chain";
        type = listOf ruleType;
      };

      content = mkOption {
        default = "";
        description = "Content of this chain";
        internal = true;
        type = lines;
      };
    };

    config = {
      name = mkDefault name;

      rules = pipe config.namedRules [
        attrValues
        (filter (v: v.enable))
        (sortOn (v: v.order))
        (map ({order, ...} @ v: mkOrder order (removeAttrs v ["enable" "order"])))
        mkMerge
      ];

      content = mkMerge [
        (mkOrder 250 "type ${config.type} hook ${config.hook} priority ${formatPriority config.priorityBase config.priority}; policy ${config.policy};")
        (concatMapStringsSep "\n" formatRule config.rules)
      ];
    };
  });

  tableExt = submodule ({config, ...}: {
    options = {
      chains = mkOption {
        default = {};
        description = "Chains in this table.";
        type = attrsOf chainType;
      };

      # FIXME: why is this needed?
      content = mkOption {};
    };

    config = {
      content = mkMerge (
        mapAttrsToList (
          k: v: ''
            chain ${k} {
            ${indent v.content}
            }
          ''
        )
        config.chains
      );
    };
  });

  cfg = config.networking.nftables;
in {
  options = {
    networking.nftables = {
      tables = mkOption {
        type = attrsOf tableExt;
      };
    };
  };

  config = {
    assertions =
      flatMapAttrsToList
      (tableName: table:
        flatMapAttrsToList (chainName: chain:
          map (rule: {
            assertion = rule.expressions != [];
            message = "nftables rule must not be empty";
          })
          chain.rules
          ++ singleton {
            assertion = chain.priorityBase != null || chain.priority != null;
            message = "At least one of `priorityBase' and `priority' in `networking.nftables.tables.${tableName}.chains.${chainName}' must be non-null";
          })
        table.chains)
      cfg.tables;
  };
}

A nixos/ext/networking/nftables/default.nix => nixos/ext/networking/nftables/default.nix +8 -0
@@ 0,0 1,8 @@
{
  imports = [
    ./chains.nix
    ./expressions.nix
    ./flowtables.nix
    ./statements.nix
  ];
}

A nixos/ext/networking/nftables/expressions.nix => nixos/ext/networking/nftables/expressions.nix +451 -0
@@ 0,0 1,451 @@
{lib, ...}: let
  inherit (lib) concatStringsSep elemAt filter foldl length mapAttrs mapAttrsToList mkMerge mkOption optional optionalAttrs;
  inherit (lib.types) attrsOf enum int listOf nonEmptyListOf nullOr oneOf str submodule;
  inherit (import ./lib.nix lib) flatMap flatMapAttrsToList formatList;
  inherit (import ./types.nix lib) arp_op bitmask boolean cgroupv2 ct_dir ct_id ct_label ct_state ct_status dccp_pkttype devgroup dscp ecn ether_addr ether_type exprElemType fib_addrtype gid icmp_type icmpv6_type iface_index iface_type ifname igmp_type inet_proto inet_service ip_addr ipv4_addr ipv6_addr l4proto mark nf_proto pkt_type realm tc_handle tcp_flag time u1 u3 u4 u8 u12 u16 u20 u32 u64 uid;

  fromParams = desc:
    {
      keyword = elemAt desc 0;
      description = elemAt desc 1;
      type = elemAt desc 2;
    }
    // optionalAttrs (length desc > 3) {
      extraOptions = elemAt desc 3;
    };

  fromTable = foldl (acc: a: let
    attrs = fromParams a;
    attrs' = removeAttrs attrs ["keyword"];
    row = {"${attrs.keyword}" = attrs';};
  in
    acc // row) {};

  dropNulls = vs: builtins.trace vs (filter (v: v != null) vs);
  map' = f: v:
    if v == null
    then null
    else f v;
  prepend = what: s: "${what}${s}";
  prepend' = what: map' (prepend what);
  words = concatStringsSep " ";

  withIpFamily = {
    ipFamily = mkOption {
      description = "IP family";
      example = "ip6";
      type = enum ["ip" "ip6"];
    };
    formatter = self: config: prefix: keyword: "${prefix} ${config.ipFamily} ${keyword}";
  };

  metaExpressions = fromTable [
    ["length" "length of the packet in bytes" u32]
    ["nfproto" "real hook protocol family, useful only in inet table" u32]
    ["l4proto" "layer 4 protocol, skips ipv6 extension headers" l4proto]
    ["protocol" "EtherType protocol value" ether_type]
    ["priority" "TC packet priority" tc_handle]
    ["mark" "packet mark" mark]
    ["iif" "input interface index" iface_index]
    ["iifname" "input interface name" ifname]
    ["iiftype" "input interface type" iface_type]
    ["oif" "output interface index" iface_index]
    ["oifname" "output interface name" ifname]
    ["oiftype" "output interface hardware type" iface_type]
    ["sdif" "slave device input interface index" iface_index]
    ["sdifname" "slave device interface name" ifname]
    ["skuid" "UID associated with originating socket" uid]
    ["skgid" "GID associated with originating socket" gid]
    ["rtclassid" "routing realm" realm]
    ["ibrname" "input bridge interface name" ifname]
    ["obrname" "output bridge interface name" ifname]
    ["pkttype" "packet type" pkt_type]
    ["cpu" "cpu number processing the packet" u32]
    ["iifgroup" "incoming device group" devgroup]
    ["oifgroup" "outgoing device group" devgroup]
    ["cgroup" "control group id" u32]
    ["random" "pseudo-random number" u32]
    ["ipsec" "true if packet was ipsec encrypted" boolean]
    ["iifkind" "input interface kind" exprElemType]
    ["oifkind" "output interface kind" exprElemType]
    ["time" "absolute time of packet reception" (oneOf [u32 str])]
    ["day" "day of week" (oneOf [u8 str])]
    ["hour" "hour of day" str]
  ];

  socketExpressions = fromTable [
    ["transparent" "value of the IP_TRANSPARENT socket option in the found socket" boolean]
    ["mark" "value of the socket mark (SOL_SOCKET, SO_MARK)" mark]
    ["wildcard" "indicates whether the socket is wildcard-bound (e.g. 0.0.0.0 or ::0)" boolean]
    ["cgroupv2" "cgroup version 2 for this socket (path from /sys/fs/cgroup)" cgroupv2]
  ];

  withTtl = {
    ttl = mkOption {
      default = null;
      description = "Do TTL checks on the packet to determine the operating system.";
      type = nullOr (enum ["loose" "skip"]);
    };
    formatter = self: config: prefix: keyword:
      words (dropNulls [prefix (prepend' "ttl " config.ttl) keyword]);
  };

  osfExpressions = fromTable [
    ["version" "operating system version" exprElemType withTtl]
    ["name" "operating system name, or \"unknown\" for OS signatures that the expression could not detect" str withTtl]
  ];

  # missing: nested lookup thing
  fibExpressions = fromTable [
    ["oif" "output interface index" u32]
    ["oifname" "output interface name" str]
    ["type" "address type" fib_addrtype]
  ];

  routingExpressions = fromTable [
    ["classid" "routing realm" realm withIpFamily]
    ["nexthop" "routing nexthop" ip_addr withIpFamily]
    ["mtu" "TCP maximum segment size of route" u16 withIpFamily]
    ["ipsec" "route via ipsec tunnel or transport" boolean withIpFamily]
  ];

  withIpsecParams = {
    direction = mkOption {
      description = "Whether to examine inbound or outbound policies";
      type = enum ["in" "out"];
    };

    spnum = mkOption {
      default = 0;
      description = "Match specific state in the chain";
      type = int;
    };

    formatter = self: config: prefix: keyword:
      words (dropNulls [prefix config.direction (prepend' "spnum " (toString config.spnum)) keyword]);
  };
  withIpsecParamsAndIpFamily =
    withIpsecParams
    // withIpFamily
    // {
      formatter = self: config: prefix: keyword:
        words (dropNulls [prefix config.direction (prepend' "spnum " (toString config.spnum)) config.ipFamily keyword]);
    };
  ipsecExpressions = fromTable [
    ["reqid" "request ID" u32 withIpsecParams]
    ["spi" "Security Parameter Index" u32 withIpsecParams]
    ["saddr" "source address of the tunnel" ip_addr withIpsecParamsAndIpFamily]
    ["daddr" "destination address of the tunnel" ip_addr withIpsecParamsAndIpFamily]
  ];

  etherExpressions = fromTable [
    ["daddr" "destination MAC address" ether_addr]
    ["saddr" "source MAC address" ether_addr]
    ["type" "EtherType" ether_type]
  ];

  vlanExpressions = fromTable [
    ["id" "VLAN ID (VID)" u12]
    ["dei" "Drop Eligible Indicator" u1]
    ["pcp" "priority code point" u3]
    ["type" "EtherType" ether_type]
  ];

  arpExpressions = fromTable [
    ["htype" "ARP hardware type" u16]
    ["ptype" "EtherType" ether_type]
    ["hlen" "hardware address len" u8]
    ["plen" "protocol address len" u8]
    ["operation" "operation" arp_op]
    ["saddr ether" "ethernet sender address" ether_addr]
    ["daddr ether" "ethernet target address" ether_addr]
    ["saddr ip" "IPv4 sender address" ipv4_addr]
    ["daddr ip" "IPv4 target address" ipv4_addr]
  ];

  ipv4Expressions = fromTable [
    ["version" "IP header version (4)" u4]
    ["hdrlength" "IP header length including options" u4]
    ["dscp" "Differentiated Services Code Point" dscp]
    ["ecn" "Explicit Congestion Notification" ecn]
    ["length" "total packet length" u16]
    ["id" "IP ID" u16]
    ["frag-off" "Fragment offset" u16]
    ["ttl" "time to live" u8]
    ["protocol" "upper layer protocol" inet_proto]
    ["checksum" "IP header checksum" u16]
    ["saddr" "source address" ipv4_addr]
    ["daddr" "destination address" ipv4_addr]
  ];

  icmpExpressions = fromTable [
    ["type" "ICMP type field" icmp_type]
    ["code" "ICMP code field" u8]
    ["checksum" "ICMP checksum field" u16]
    ["id" "ID of echo request/response" u16]
    ["sequence" "sequence number of echo request/response" u16]
    ["gateway" "gateway of redirects" u32]
    ["mtu" "MTU of path MTU discovery" u16]
  ];

  igmpExpressions = fromTable [
    ["type" "IGMP type field" igmp_type]
    ["mrt" "IGMP maximum response type field" u8]
    ["checksum" "IGMP checksum field" u16]
    ["group" "group address" u32]
  ];

  ipv6Expressions = fromTable [
    ["version" "IP header version (6)" u4]
    ["dscp" "Differentiated Services Code Point" dscp]
    ["ecn" "Explicit Congestion Notification" ecn]
    ["flowlabel" "flow label" u20]
    ["length" "payload length" u16]
    ["nexthdr" "nexthdr protocol" inet_proto]
    ["hoplimit" "hop limit" u8]
    ["saddr" "source address" ipv6_addr]
    ["daddr" "destination address" ipv6_addr]
  ];

  icmpv6Expressions = fromTable [
    ["type" "ICMPv6 type field" icmpv6_type]
    ["code" "ICMPv6 code field" u8]
    ["checksum" "ICMPv6 checksum field" u16]
    ["parameter-problem" "pointer to problem" u32]
    ["packet-too-big" "oversized MTU" u32]
    ["id" "ID of echo request/response" u16]
    ["sequence" "sequence number of echo request/response" u16]
    ["max-delay" "maximum response delay of MLD queries" u16]
  ];

  tcpExpressions = fromTable [
    ["sport" "source port" inet_service]
    ["dport" "destination port" inet_service]
    ["sequence" "sequence number" u32]
    ["ackseq" "acknowledgement number" u32]
    ["doff" "data offset" u4]
    ["reserved" "reserved area" u4]
    ["flags" "TCP flags" tcp_flag]
    ["window" "window" u16]
    ["checksum" "checksum" u16]
    ["urgptr" "urgent pointer" u16]
  ];

  udpExpressions = fromTable [
    ["sport" "source port" inet_service]
    ["dport" "destination port" inet_service]
    ["length" "total packet length" u16]
    ["checksum" "checksum" u16]
  ];

  udpliteExpressions = fromTable [
    ["sport" "source port" inet_service]
    ["dport" "destination port" inet_service]
    ["checksum" "checksum" u16]
  ];

  sctpExpressions = fromTable [
    ["sport" "source port" inet_service]
    ["dport" "destination port" inet_service]
    ["vtag" "verification tag" u32]
    ["checksum" "checksum" u32]
    # missing: chunk
  ];

  dccpExpressions = fromTable [
    ["sport" "source port" inet_service]
    ["dport" "destination port" inet_service]
    ["type" "packet type" dccp_pkttype]
  ];

  ahExpressions = fromTable [
    ["nexthdr" "next header protocol" inet_proto]
    ["hdrlength" "AH header length" u8]
    ["reserved" "reserved area" u16]
    ["spi" "Security Parameter Index" u32]
    ["sequence" "sequence number" u32]
  ];

  espExpressions = fromTable [
    ["spi" "Security Parameter Index" u32]
    ["sequence" "sequence number" u32]
  ];

  ipcompExpressions = fromTable [
    ["nexthdr" "next header protocol" inet_proto]
    ["flags" "flags" bitmask]
    ["cpi" "Compression Parameter Index" u16]
  ];

  ctDirection = enum ["original" "reply"];
  maybeDirection = {
    direction = mkOption {
      default = null;
      description = "Flow direction";
      type = nullOr ctDirection;
    };
    formatter = self: config: prefix: keyword:
      words (dropNulls [prefix config.direction keyword]);
  };
  withDirection = {
    direction = mkOption {
      description = "Flow direction";
      type = ctDirection;
    };
    formatter = self: config: prefix: keyword: "${prefix} ${config.direction} ${keyword}";
  };
  withDirectionAndIpFamily =
    withDirection
    // withIpFamily
    // {
      formatter = self: config: prefix: keyword: "${prefix} ${config.direction} ${config.ipFamily} ${keyword}";
    };

  ctExpressions = fromTable [
    ["state" "state of the connection" ct_state]
    ["direction" "direction of the packet relative to the connection" ct_dir]
    ["status" "status of the connection" ct_status]
    ["mark" "connection mark" mark]
    ["expiration" "connection expiration time" time]
    ["helper" "helper associated with the connection" str]
    ["label" "connection tracking label bit or symbolic name defined in connlabel.conf in the nftables include path" ct_label]
    ["l3proto" "layer 3 protocol of the connection" nf_proto maybeDirection]
    ["saddr" "source address of the connection for the given direction" ip_addr withDirectionAndIpFamily]
    ["daddr" "destination address of the connection for the given direction" ip_addr withDirectionAndIpFamily]
    ["protocol" "layer 4 protocol of the connection for the given direction" inet_proto]
    ["proto-src" "layer 4 protocol source for the given direction" u16 withDirection]
    ["proto-dst" "layer 4 protocol destination for the given direction" u16 withDirection]
    ["packets" "packet count seen in the given direction or sum of original and reply" u64 maybeDirection]
    ["bytes" "byte count seen, see description for packets keyword" u64 maybeDirection]
    ["avgpkt" "average bytes per packet, see description for `packets` keyword" u64 maybeDirection]
    ["zone" "conntrack zone" u16 maybeDirection]
    ["count" "number of current connections" u32]
    ["id" "connection id" ct_id]
  ];

  allExpressions = {
    meta = metaExpressions;
    socket = socketExpressions;
    osf = osfExpressions;
    fib = fibExpressions;
    ipsec = ipsecExpressions;
    rt = routingExpressions;

    ether = etherExpressions;
    vlan = vlanExpressions;
    arp = arpExpressions;
    ip = ipv4Expressions;
    icmp = icmpExpressions;
    igmp = igmpExpressions;
    ip6 = ipv6Expressions;
    icmpv6 = icmpv6Expressions;
    tcp = tcpExpressions;
    udp = udpExpressions;
    udplite = udpliteExpressions;
    sctp = sctpExpressions;
    dccp = dccpExpressions;
    ah = ahExpressions;
    esp = espExpressions;
    ipcomp = ipcompExpressions;
    ct = ctExpressions;

    # missing: numgen, hash, raw, extension header
  };

  mkExprType = desc:
    if !desc ? extraOptions
    then listOf desc.type
    else
      listOf (submodule {
        options =
          removeAttrs desc.extraOptions ["formatter"]
          // {
            match = mkOption {
              default = [];
              description = "Values to match";
              type = listOf desc.type;
            };
          };
      });

  mkExpressionsGroup = mapAttrs (_: desc:
    mkOption {
      default = [];
      description = "Match ${desc.description}";
      type = mkExprType desc;
    });

  formatCustomExpression = config: prefix: keyword: desc:
    flatMap (config: let
      formatted = desc.extraOptions.formatter desc config prefix keyword;
      match = config.match;
    in
      optional (match != []) {"${formatted}" = match;})
    config;

  formatSimpleExpression = config: prefix: keyword: desc: let
    formatted = "${prefix} ${keyword}";
    match = config;
  in
    optional (match != []) {"${formatted}" = match;};

  formatExpression = config: prefix: keyword: desc: let
    config' = config."${prefix}"."${keyword}";
  in
    if desc ? extraOptions
    then formatCustomExpression config' prefix keyword desc
    else formatSimpleExpression config' prefix keyword desc;

  formatExpressionsGroup = config: prefix: descs:
    flatMapAttrsToList (formatExpression config prefix) descs;

  allExpressionsAsOptions = mapAttrs (_: mkExpressionsGroup) allExpressions;

  allExpressionsAsConfig = config: flatMapAttrsToList (formatExpressionsGroup config) allExpressions;

  ruleExt = submodule ({config, ...}: {
    options =
      allExpressionsAsOptions
      // {
        expressions = mkOption {
          default = {};
          description = "nftables expressions part of this rule";
          example = {
            "iifname" = ["eth0" "wlan0"];
            "tcp dport" = [22];
          };
          type = attrsOf (nonEmptyListOf exprElemType);
        };
      };

    config = {
      expressions = mkMerge (allExpressionsAsConfig config);

      components = mapAttrsToList (k: v: "${k} ${formatList v}") config.expressions;
    };
  });

  chainExt = submodule {
    options = {
      rules = mkOption {
        type = listOf ruleExt;
      };
    };
  };

  tableExt = submodule {
    options = {
      chains = mkOption {
        type = attrsOf chainExt;
      };
    };
  };
in {
  options = {
    networking.nftables = {
      tables = mkOption {
        type = attrsOf tableExt;
      };
    };
  };
}

A nixos/ext/networking/nftables/flowtables.nix => nixos/ext/networking/nftables/flowtables.nix +100 -0
@@ 0,0 1,100 @@
{lib, ...}: let
  inherit (lib) concatStringsSep mapAttrsToList mkBefore mkDefault mkOption;
  inherit (lib.types) attrsOf bool enum int lines nonEmptyListOf nullOr str submodule;
  inherit (import ./lib.nix lib) formatList' formatPriority indent;

  flowtableType = submodule ({
    config,
    name,
    ...
  }: {
    options = {
      enable = mkOption {
        default = true;
        description = "Whether to enable this flowtable.";
        type = bool;
      };

      name = mkOption {
        description = "The flowtable name.";
        type = str;
      };

      hook = mkOption {
        default = "ingress";
        description = "Chain hook";
        type = enum ["ingress"];
      };

      priorityBase = mkOption {
        default = null;
        description = "Sets the base value `priority` is relative to";
        type = nullOr (enum ["filter"]);
      };

      priority = mkOption {
        default = null;
        description = "Flowtable priority";
        type = nullOr int;
      };

      devices = mkOption {
        description = "Devices part of this flowtable";
        type = nonEmptyListOf str;
      };

      content = mkOption {
        default = "";
        description = "Content of this flowtable";
        internal = true;
        type = lines;
      };
    };

    config = {
      name = mkDefault name;

      content = ''
        hook ${config.hook} priority ${formatPriority config.priorityBase config.priority};
        devices = ${formatList' config.devices};
      '';
    };
  });

  tableExt = submodule ({
    config,
    name,
    ...
  }: {
    options = {
      flowtables = mkOption {
        default = {};
        description = "Flowtables in this table.";
        type = attrsOf flowtableType;
      };

      content = mkOption {};
    };

    config = {
      content = mkBefore (concatStringsSep "\n" (
        mapAttrsToList (
          k: v: ''
            flowtable ${k} {
            ${indent v.content}
            }
          ''
        )
        config.flowtables
      ));
    };
  });
in {
  options = {
    networking.nftables = {
      tables = mkOption {
        type = attrsOf tableExt;
      };
    };
  };
}

A nixos/ext/networking/nftables/lib.nix => nixos/ext/networking/nftables/lib.nix +33 -0
@@ 0,0 1,33 @@
lib: let
  inherit (lib) concatMapStringsSep foldl head isInt isString length mapAttrsToList splitString;
in rec {
  formatAtom = v:
    if isInt v
    then toString v
    else if isString v
    then ''"${v}"''
    else throw "unimplemented";

  formatList = l:
    if length l == 1
    then formatAtom (head l)
    else formatList' l;

  formatList' = l: "{ ${concatMapStringsSep ", " formatAtom l} }";

  formatPriority = priorityBase: priority:
    if priorityBase != null && priority == null
    then priorityBase
    else if priorityBase == null && priority != null
    then toString priority
    else if priority < 0
    then "${priorityBase} - ${toString - priority}"
    else "${priorityBase} + ${toString priority}";

  strLines = splitString "\n";
  indent = str: concatMapStringsSep "\n" (v: "  ${v}") (strLines str);

  flatten = foldl (acc: a: acc ++ a) [];
  flatMap = f: l: flatten (map f l);
  flatMapAttrsToList = f: l: flatten (mapAttrsToList f l);
}

A nixos/ext/networking/nftables/statements.nix => nixos/ext/networking/nftables/statements.nix +141 -0
@@ 0,0 1,141 @@
{lib, ...}: let
  inherit (lib) concatStringsSep isAttrs isInt mkAfter mkMerge mkOption optional singleton;
  inherit (lib.types) attrsOf bool enum int listOf nullOr oneOf port str submodule;

  portRange = submodule {
    options = {
      from = mkOption {
        description = "Port range lower bound";
        type = port;
      };

      to = mkOption {
        description = "Port range upper bound";
        type = port;
      };
    };
  };

  ruleExt = submodule ({config, ...}: {
    options = {
      comment = mkOption {
        default = "";
        description = "Rule comment";
        type = str;
      };

      counter = mkOption {
        default = null;
        description = "Whether to count number of packets matched by this rule";
        type = nullOr (submodule {});
      };

      flow = {
        add = mkOption {
          default = [];
          description = "Adds this flow to the specified flowtable";
          type = listOf str;
        };
      };

      masquerade = mkOption {
        default = null;
        description = "Rewrite packet source address to outgoing interface's IP address";
        type = nullOr (submodule {
          options = {
            port = mkOption {
              default = null;
              description = "";
              type = nullOr (oneOf [port portRange]);
            };

            persistent = mkOption {
              default = false;
              description = "Gives a client the same source-/destination-address for each connection.";
              type = bool;
            };

            random = mkOption {
              default = false;
              description = "In kernel 5.0 and newer this is the same as fully-random. In earlier kernels the port mapping will be randomized using a seeded MD5 hash mix using source and destination address and destination port.";
              type = bool;
            };

            fullyRandom = mkOption {
              default = false;
              description = "If used then port mapping is generated based on a 32-bit pseudo-random algorithm.";
              type = bool;
            };
          };
        });
      };

      meta = {
        setMark = mkOption {
          default = 0;
          description = "Sets packet mark";
          type = int;
        };
      };

      verdict = mkOption {
        default = null;
        description = "Alters control flow in the ruleset and issues policy decisions for packets.";
        type = nullOr (enum ["accept" "drop" "queue" "continue" "return"]);
      };

      statements = mkOption {
        default = [];
        description = "nftables statements part of this rule";
        example = ["counter" "accept"];
        type = listOf str;
      };
    };

    config = {
      statements = mkMerge [
        (optional (config.counter != null) "counter")
        (optional (config.meta.setMark != 0) ''meta mark set ${toString config.meta.setMark}'')
        (optional (config.masquerade != null) (concatStringsSep " " (
          singleton "masquerade"
          ++ optional (isInt config.masquerade.port) "to :${config.masquerade.port}"
          ++ optional (isAttrs config.masquerade.port) "to :${config.masquerade.port.from}-${config.masquerade.port.to}"
          ++ concatStringsSep "," (
            optional (config.masquerade.persistent) "persistent"
            ++ optional (config.masquerade.random) "random"
            ++ optional (config.masquerade.fullyRandom) "fully-random"
          )
        )))
        (map (ft: ''flow add @${ft}'') config.flow.add)
        (optional (config.verdict != null) config.verdict)
        (optional (config.comment != "") ''comment "${config.comment}"'')
      ];

      components = mkAfter config.statements;
    };
  });

  chainExt = submodule ({options, ...}: {
    options = {
      rules = mkOption {
        type = listOf ruleExt;
      };
    };
  });

  tableExt = submodule {
    options = {
      chains = mkOption {
        type = attrsOf chainExt;
      };
    };
  };
in {
  options = {
    networking.nftables = {
      tables = mkOption {
        type = attrsOf tableExt;
      };
    };
  };
}

A nixos/ext/networking/nftables/types.nix => nixos/ext/networking/nftables/types.nix +358 -0
@@ 0,0 1,358 @@
lib: let
  inherit (lib) attrNames;
  inherit (lib.types) enum int ints oneOf str;
  inherit (lib.types.ints) u8 u16 u32 unsigned;
in rec {
  inherit u8 u16 u32;

  exprElemType = oneOf [int str];

  # Assigned to types where it's unknown what they actually are
  placeholder = exprElemType;

  intEnum = baseType: attrs: oneOf [baseType (enum (attrNames attrs))];

  boolean = u1;

  u1 = ints.between 0 1;
  u3 = ints.between 0 7;
  u4 = ints.between 0 15;
  u6 = ints.between 0 63;
  u12 = ints.between 0 4095;
  u20 = ints.between 0 1048575;
  u64 = unsigned;
  bitmask = unsigned;

  # Collected from nft(8) and using `nft describe'.

  ether_addr = str;
  ether_type = intEnum u16 {
    "ip" = 8; # 0x0008
    "arp" = 1544; # 0x0608
    "ip6" = 56710; # 0xdd86
    "8021q" = 129; # 0x0081
    "8021ad" = 43144; # 0xa888
    "vlan" = 129; # 0x0081
  };
  ip_addr = str;
  ipv4_addr = str;
  ipv6_addr = str;

  inet_service = exprElemType;

  tc_handle = placeholder;
  iface_index = u32;
  ifname = str;
  iface_type = intEnum u16 {
    "ether" = 1; # 0x0001
    "ppp" = 512; # 0x0200
    "ipip" = 768; # 0x0300
    "ipip6" = 769; # 0x0301
    "loopback" = 772; # 0x0304
    "sit" = 776; # 0x0308
    "ipgre" = 778; # 0x030a
  };
  uid = u32;
  gid = u32;
  realm = u32;
  pkt_type = intEnum u8 {
    "host" = 0;
    "unicast" = 0;
    "broadcast" = 1;
    "multicast" = 2;
    "other" = 3;
  };
  devgroup = u32;

  cgroupv2 = placeholder;

  fib_addrtype = intEnum u32 {
    "unspec" = 0;
    "unicast" = 1;
    "local" = 2;
    "broadcast" = 3;
    "anycast" = 4;
    "multicast" = 5;
    "blackhole" = 6;
    "unreachable" = 7;
    "prohibit" = 8;
  };

  arp_op = intEnum u16 {
    "request" = 256; # 0x0100
    "reply" = 512; # 0x0200
    "rrequest" = 768; # 0x0300
    "rreply" = 1024; # 0x0400
    "inrequest" = 2048; # 0x0800
    "inreply" = 2304; # 0x0900
    "nak" = 2560; # 0x0a00
  };

  dscp = intEnum u6 {
    "cs0" = 0; # 0x00
    "cs1" = 8; # 0x08
    "cs2" = 16; # 0x10
    "cs3" = 24; # 0x18
    "cs4" = 32; # 0x20
    "cs5" = 40; # 0x28
    "cs6" = 48; # 0x30
    "cs7" = 56; # 0x38
    "df" = 0; # 0x00
    "be" = 0; # 0x00
    "lephb" = 1; # 0x01
    "af11" = 10; # 0x0a
    "af12" = 12; # 0x0c
    "af13" = 14; # 0x0e
    "af21" = 18; # 0x12
    "af22" = 20; # 0x14
    "af23" = 22; # 0x16
    "af31" = 26; # 0x1a
    "af32" = 28; # 0x1c
    "af33" = 30; # 0x1e
    "af41" = 34; # 0x22
    "af42" = 36; # 0x24
    "af43" = 38; #0x26
    "va" = 44; # 0x2c
    "ef" = 46; # 0x2e
  };
  ecn = placeholder;

  icmp_type = intEnum u8 {
    "echo-reply" = 0;
    "destination-unreachable" = 3;
    "source-quench" = 4;
    "redirect" = 5;
    "echo-request" = 8;
    "router-advertisement" = 9;
    "router-solicitation" = 10;
    "time-exceeded" = 11;
    "parameter-problem" = 12;
    "timestamp-request" = 13;
    "timestamp-reply" = 14;
    "info-request" = 15;
    "info-reply" = 16;
    "address-mask-request" = 17;
    "address-mask-reply" = 18;
  };
  icmpv6_type = intEnum u8 {
    "destination-unreachable" = 1;
    "packet-too-big" = 2;
    "time-exceeded" = 3;
    "parameter-problem" = 4;
    "echo-request" = 128;
    "echo-reply" = 129;
    "mld-listener-query" = 130;
    "mld-listener-report" = 131;
    "mld-listener-done" = 132;
    "mld-listener-reduction" = 132;
    "nd-router-solicit" = 133;
    "nd-router-advert" = 134;
    "nd-neighbor-solicit" = 135;
    "nd-neighbor-advert" = 136;
    "nd-redirect" = 137;
    "router-renumbering" = 138;
    "ind-neighbor-solicit" = 141;
    "ind-neighbor-advert" = 142;
    "mld2-listener-report" = 143;
  };

  igmp_type = intEnum u8 {
    "membership-query" = 17;
    "membership-report-v1" = 18;
    "membership-report-v2" = 22;
    "membership-report-v3" = 34;
    "leave-group" = 23;
  };

  tcp_flag = intEnum u8 {
    "fin" = 1; # 0x01
    "syn" = 2; # 0x02
    "rst" = 4; # 0x04
    "psh" = 8; # 0x08
    "ack" = 16; # 0x10
    "urg" = 32; # 0x20
    "ecn" = 64; # 0x40
    "cwr" = 128; # 0x80
  };

  dccp_pkttype = intEnum u4 {
    "request" = 0; # 0x00
    "response" = 1; # 0x01
    "data" = 2; # 0x02
    "ack" = 3; # 0x03
    "dataack" = 4; # 0x04
    "closereq" = 5; # 0x05
    "close" = 6; # 0x06
    "reset" = 7; # 0x07
    "sync" = 8; # 0x08
    "syncack" = 9; # 0x09
  };

  ct_state = intEnum u32 {
    "invalid" = 1; # 0x00000001
    "new" = 8; # 0x00000008
    "established" = 2; # 0x00000002
    "related" = 4; # 0x00000004
    "untracked" = 64; # 0x00000040
  };
  ct_dir = intEnum u8 {
    "original" = 0;
    "reply" = 1;
  };
  ct_status = intEnum u32 {
    "expected" = 1; # 0x00000001
    "seen-reply" = 2; # 0x00000002
    "assured" = 4; # 0x00000004
    "confirmed" = 8; # 0x00000008
    "snat" = 16; # 0x00000010
    "dnat" = 32; # 0x00000020
    "dying" = 512; # 0x00000200
  };
  mark = u32;
  time = int;
  ct_label = int;
  nf_proto = intEnum u8 {
    "ipv4" = 2;
    "ipv6" = 10;
  };
  inet_proto = intEnum u8 {
    "hopopt" = 0;
    "icmp" = 1;
    "igmp" = 2;
    "ggp" = 3;
    "ipv4" = 4;
    "st" = 5;
    "tcp" = 6;
    "cbt" = 7;
    "egp" = 8;
    "igp" = 9;
    "bbn-rcc-mon" = 10;
    "nvp-ii" = 11;
    "pup" = 12;
    "emcon" = 14;
    "xnet" = 15;
    "chaos" = 16;
    "udp" = 17;
    "mux" = 18;
    "dcn-meas" = 19;
    "hmp" = 20;
    "prm" = 21;
    "xns-idp" = 22;
    "trunk-1" = 23;
    "trunk-2" = 24;
    "leaf-1" = 25;
    "leaf-2" = 26;
    "rdp" = 27;
    "irtp" = 28;
    "iso-tp4" = 29;
    "netblt" = 30;
    "mfe-nsp" = 31;
    "merit-inp" = 32;
    "dccp" = 33;
    "3pc" = 34;
    "idpr" = 35;
    "xtp" = 36;
    "ddp" = 37;
    "idpr-cmtp" = 38;
    "tp++" = 39;
    "il" = 40;
    "ipv6" = 41;
    "sdrp" = 42;
    "ipv6-route" = 43;
    "ipv6-frag" = 44;
    "idrp" = 45;
    "rsvp" = 46;
    "gre" = 47;
    "dsr" = 48;
    "bna" = 49;
    "esp" = 50;
    "ah" = 51;
    "i-nlsp" = 52;
    "narp" = 54;
    "min-ipv4" = 55;
    "tlsp" = 56;
    "skip" = 57;
    "ipv6-icmp" = 58;
    "ipv6-nonxt" = 59;
    "ipv6-opts" = 60;
    "cftp" = 62;
    "sat-expak" = 64;
    "kryptolan" = 65;
    "rvd" = 66;
    "ippc" = 67;
    "sat-mon" = 69;
    "visa" = 70;
    "ipcv" = 71;
    "cpnx" = 72;
    "cphb" = 73;
    "wsn" = 74;
    "pvp" = 75;
    "br-sat-mon" = 76;
    "sun-nd" = 77;
    "wb-mon" = 78;
    "wb-expak" = 79;
    "iso-ip" = 80;
    "vmtp" = 81;
    "secure-vmtp" = 82;
    "vines" = 83;
    "iptm" = 84;
    "nsfnet-igp" = 85;
    "dgp" = 86;
    "tcf" = 87;
    "eigrp" = 88;
    "ospfigp" = 89;
    "sprite-rpc" = 90;
    "larp" = 91;
    "mtp" = 92;
    "ax.25" = 93;
    "ipip" = 94;
    "scc-sp" = 96;
    "etherip" = 97;
    "encap" = 98;
    "gmtp" = 100;
    "ifmp" = 101;
    "pnni" = 102;
    "pim" = 103;
    "aris" = 104;
    "scps" = 105;
    "qnx" = 106;
    "a/n" = 107;
    "ipcomp" = 108;
    "snp" = 109;
    "compaq-peer" = 110;
    "ipx-in-ip" = 111;
    "vrrp" = 112;
    "pgm" = 113;
    "l2tp" = 115;
    "ddx" = 116;
    "iatp" = 117;
    "stp" = 118;
    "srp" = 119;
    "uti" = 120;
    "smp" = 121;
    "ptp" = 123;
    "fire" = 125;
    "crtp" = 126;
    "crudp" = 127;
    "sscopmce" = 128;
    "iplt" = 129;
    "sps" = 130;
    "pipe" = 131;
    "sctp" = 132;
    "fc" = 133;
    "rsvp-e2e-ignore" = 134;
    "udplite" = 136;
    "mpls-in-ip" = 137;
    "manet" = 138;
    "hip" = 139;
    "shim6" = 140;
    "wesp" = 141;
    "rohc" = 142;
    "ethernet" = 143;
    "aggfrag" = 144;
    "nsh" = 145;
  };
  ct_id = placeholder;

  l4proto = exprElemType;
}

A nixos/ext/services/default.nix => nixos/ext/services/default.nix +5 -0
@@ 0,0 1,5 @@
{
  imports = [
    ./rspamd.nix
  ];
}

R nixos/ext/rspamd.nix => nixos/ext/services/rspamd.nix +0 -0