You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

965 lines
34 KiB
Nix

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

{
lib, # uses some of my library extensions
...
}@flakeArg:
{ config, ... }:
let
cfg = config.x-banananetwork.routerVM;
# TODO avert to options
wanName = "wan0";
lanName = "lan0";
# my lib
escapeNftablesStr =
arg:
let
inherit (builtins) match replaceStrings;
string = toString arg;
in
if match "[[:alnum:],._+:@%/-]+" string == null then
''"${replaceStrings [ ''"'' ] [ ''\"'' ] string}"''
else
string;
filterAttrs =
filtFun:
assert builtins.isFunction filtFun;
lib.filterAttrs (_: filtFun);
filterMapAttrs =
attr: filtFun: mapFun:
assert builtins.isAttrs attr;
assert builtins.isFunction filtFun;
assert builtins.isFunction mapFun;
builtins.mapAttrs (_: mapFun) (filterAttrs filtFun attr);
filterMapAttrsToList =
attr: filtFun: mapFun:
assert builtins.isAttrs attr;
assert builtins.isFunction filtFun;
assert builtins.isFunction mapFun;
lib.mapAttrsToList (_: mapFun) (filterAttrs filtFun attr);
# useful to combine port rules
mapListJoin =
sep: list: mapFun:
assert builtins.isString sep;
assert builtins.isList list;
assert builtins.isFunction mapFun;
builtins.concatStringsSep sep (builtins.map mapFun list);
filterMap =
list: mapFun:
assert builtins.isList list;
assert builtins.isFunction mapFun;
map mapFun (builtins.filter (x: x.enable) list);
filterMapJoin =
sep: attr: mapFun:
assert builtins.isString sep;
assert builtins.isAttrs attr;
assert builtins.isFunction mapFun;
lib.trivial.pipe mapFun [
(filterMapAttrsToList attr (x: x.enable))
(builtins.concatStringsSep sep)
];
mkDisableOption = arg: (lib.mkEnableOption arg) // { default = true; };
# TODO think about to make it just readOnly, requiring defaultText
mkOutputOption =
arg:
lib.mkOption (
{
internal = true;
readOnly = true;
}
// arg
);
mkInterfaceOption =
name:
lib.mkOption {
description = ''
MAC address of device to be used as ${name} interface.
Corresponds to the MACAdress= option in the [Match] section
in {manpage}`systemd.network(5)` files.
'';
type = lib.types.eui48;
example = "AA:BB:CC:DD:EE:FF";
};
whitespaceList = list: builtins.concatStringsSep " " list;
hostnameType = lib.types.strMatching "^([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$" // {
description = "hostname as per RFC";
};
formatMAC =
let
badChars = [
"."
":"
"_"
"-"
];
goodChars = map (x: "") badChars;
in
mac:
lib.trivial.pipe mac [
(builtins.replaceStrings badChars goodChars)
];
protoList = [
"dccp"
"sctp"
"tcp"
"udp"
"udplite"
];
protoType = lib.types.enum protoList;
# required for IPv4 as handled at built time
dnatMapBuilder =
proto: destMap: devices: embedFun:
let
devFilt = filterAttrs (dev: (destMap dev) != null) devices;
protoMap = dev: (dev.nftUpdateAddressesConfig destMap).${proto}.forwarded;
mapEntry =
{
wanPort,
dest,
lanPort,
}:
"${toString wanPort} : ${dest} . ${toString lanPort}";
builtMap = dev: lib.concatMapStringsSep ", " mapEntry (protoMap dev);
built = filterMapJoin ", " devFilt builtMap;
in
if built == "" then "" else embedFun built;
# TODO allow exposure of devices without static IPs
# - by marking "dropped" packets during forwarding instead
# - and then filtering them on postrouting using dest MAC
# - disadvantage: enables use of all addresses (incl. privacy extensions)
deviceSubmodule =
{ name, ... }@device:
let
dev = device.config;
in
{
options = {
enable = lib.mkOption {
description = "Configure rules for this device";
type = lib.types.bool;
default = true;
};
name = lib.mkOption {
description = "hostname of the device";
type = hostnameType;
default = name;
};
description = lib.mkOption {
description = "Descriptive, human-readable name for that device";
type = lib.types.str;
default = name;
};
mac = lib.mkOption {
description = "MAC Address of device";
type = lib.types.eui48;
};
staticIPv4 = lib.mkOption {
description = "Static DHCPv4 lease address for device";
type = with lib.types; nullOr ipv4AddressPlain;
default = null;
};
fullyExposed = lib.mkEnableOption "to fully expose this device via IPv6, making other firewall rules except NATs irrelevant";
forwardedExposed = mkDisableOption ''
Whether to enable automatic exposure of port forwardings via IPv6.
For example, if a port forwarding from 8080 to 80 is established,
then the port 80 will also be exposed directly for the devices IPv6,
making using NAT optional'';
# TODO really limit ICMP pings to IPv6 addresses (currently fully allowed)
allowICMPEcho = lib.mkEnableOption "forwarding of ICMP echos via IPv6 to the device" // {
default = device.config.exposedPorts != [ ];
defaultText = lib.literalExpression '''';
};
exposedPorts = lib.mkOption {
description = ''
Ports which should be accessible to the public via IPv6.
Adding one port does enable ICMP pings to that host.
'';
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }@exposed:
let
cfg = exposed.config;
comment = "comment ${escapeNftablesStr cfg.comment}";
accept = "accept ${comment}";
in
{
options = {
enable = mkDisableOption "this port exposure rule";
comment = lib.mkOption {
description = ''
Comment for this port forwarding rule.
Will be added to nftables rules.
'';
default = "${device.name} - ${name}";
defaultText = lib.literalExpression ''''${device.name} - ''${name}'';
};
port = lib.mkOption {
description = "port";
type = lib.types.port;
};
protocol = lib.mkOption {
description = "Protocol for which the port forwarding will be established";
type = protoType;
default = "tcp";
};
nftablesForwardingRules = lib.mkOption {
internal = true;
default = ''
ip6 daddr ${dev.nftablesIPv6Dest} ${cfg.protocol} dport ${toString cfg.port} ${accept}
'';
};
};
}
)
);
default = { };
};
forwardedPorts = lib.mkOption {
description = ''
Ports which should be forwarded from the router WAN IPv4 & v6 address to this device.
The port is then made accessible using DNAT
on all packets sent to the routers public interface
via IPv4 (if `config`{staticIPv4}` is set) and IPv6.
'';
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }@forwarded:
let
cfg = forwarded.config;
comment = "comment ${escapeNftablesStr cfg.comment}";
accept = "accept ${comment}";
l4Rule = "${cfg.protocol} dport ${toString cfg.lanPort} ${accept}";
l4WanRule = "${cfg.protocol} dport ${toString cfg.wanPort}";
in
{
options = {
enable = mkDisableOption "this port forwarding rule";
comment = lib.mkOption {
description = ''
Comment for this port forwarding rule.
Will be added to nftables rules.
'';
default = "${device.name} - ${name}";
defaultText = lib.literalExpression ''''${device.name} - ''${name}'';
};
protocol = lib.mkOption {
description = "Protocol for which the port forwarding will be established.";
type = protoType;
default = "tcp";
};
wanPort = lib.mkOption {
description = "port on the WAN side";
type = lib.types.port;
default = cfg.lanPort;
defaultText = lib.literalExpression "cfg.lanPort";
};
lanPort = lib.mkOption {
description = "port on the LAN / client side";
type = lib.types.port;
};
expose = lib.mkOption {
description = "Enables exposure of {option}`lanPort` directly via IPv6";
type = lib.types.bool;
default = device.config.forwardedExposed;
defaultText = lib.literalExpression "cfg.forwardedExposed";
};
nftablesForwardingRules = mkOutputOption {
default = ''
${lib.optionalString (dev.staticIPv4 != null) "ip daddr ${dev.staticIPv4} ${l4Rule}"}
ip6 daddr ${dev.nftablesIPv6Dest} ${l4Rule}
'';
};
nftablesPreroutingRules = mkOutputOption {
default = ''
${lib.optionalString (
dev.nftablesIPv4Dest != null
) "${l4WanRule} dnat ip to ${dev.nftablesIPv4Dest}:${toString cfg.lanPort} ${comment}"}
'';
};
};
}
)
);
default = { };
example = {
http = {
wanPort = 8080;
lanPort = 80;
};
quic = {
wanPort = 8443;
lanPort = 443;
protocol = "udp";
};
};
};
nftablesIPv4Dest = mkOutputOption { default = dev.staticIPv4; };
nftablesIPv6Dest = mkOutputOption { default = ''@${lanName}v6_${formatMAC dev.mac}''; };
nftUpdateAddressesConfig = mkOutputOption {
default =
destMap:
let
inherit (builtins) attrValues groupBy;
inherit (lib.attrsets) genAttrs;
groupByProto = attrs: groupBy (x: x.protocol) (attrValues attrs);
exposed = groupByProto dev.exposedPorts;
forwarded = groupByProto dev.forwardedPorts;
in
genAttrs protoList (proto: {
exposed = filterMap (exposed.${proto} or [ ]) (exp: {
inherit (exp) port;
dest = destMap dev;
});
forwarded = filterMap (forwarded.${proto} or [ ]) (fw: {
inherit (fw) wanPort lanPort;
dest = destMap dev;
});
});
};
nftablesForwardingRules = mkOutputOption {
default =
if dev.fullyExposed then
''
ip6 daddr ${dev.nftablesIPv6Dest} accept comment ${escapeNftablesStr "${dev.description} full exposure"}
''
else
''
${filterMapJoin "\n" dev.exposedPorts (ep: ep.nftablesForwardingRules)}
${filterMapJoin "\n" dev.forwardedPorts (fw: fw.nftablesForwardingRules)}
ip daddr ${dev.nftablesIPv4Dest} jump drop-reject
ip6 daddr ${dev.nftablesIPv6Dest} jump drop-reject
'';
};
nftablesPreroutingRules = mkOutputOption {
default = filterMapJoin "\n" dev.forwardedPorts (fw: fw.nftablesPreroutingRules);
};
};
config = {
exposedPorts = filterMapAttrs dev.forwardedPorts (fw: fw.enable && fw.expose) (fw: {
comment = "${fw.comment} (exposed port forwarding)";
port = fw.lanPort;
inherit (fw) protocol;
});
};
};
# automatically assign mark integers for these reasons (if enabled)
nftMarksDeclared = {
inherit (cfg) protectWanSubnets;
"${wanName}_snatShortcutted" = true;
};
nftMarks = lib.trivial.pipe nftMarksDeclared [
lib.attrsToList
(builtins.filter (x: x.value))
(lib.imap1 (
i: m: {
inherit (m) name;
value = toString i; # because will mostly be used in nftables rules
}
))
builtins.listToAttrs
];
in
{
options = {
boot.loader.systemd-boot.bootCounting.enable = lib.mkOption {
description = ''
to already pin option which will be coming in the future
see https://nixos.org/manual/nixos/unstable/#sec-automatic-boot-assessment
'';
visible = false;
type = lib.types.bool;
};
x-banananetwork.routerVM = {
enable = lib.mkEnableOption "router functionality. This is intended to be disabled by a specialisation for recovery reasons";
wanMAC = mkInterfaceOption "WAN";
lanMAC = mkInterfaceOption "VM LAN";
lanDomain = lib.mkOption {
description = "domain advertised via DHCP / RA / DHCPv6";
type = lib.types.str;
default = config.networking.domain;
defaultText = lib.literalExpression "config.networking.domain";
};
lanIPv4Address = lib.mkOption {
description = ''
The IPv4 address used for the router &
its subnet used for addresses of clients.
'';
type = lib.types.str;
};
lanIPv6ULAPrefix = lib.mkOption {
description = ''
An IPv6 unique local address prefix to announce on LAN as well.
'';
type = lib.types.str;
};
trustableIPv6Prefixes = lib.mkOption {
description = ''
A list of IPv6 prefixes which should be trustable.
In general, this means following features should be locked to trustable links only:
- announced routes to these prefixes
- TODO packets from/to these prefixes to/from links
'';
type = with lib.types; listOf str;
default = [ ];
};
# TODO DNS entries for WAN & LAN side
lanDevices = lib.mkOption {
description = ''
Describes properties for a LAN device
(e.g. DHCP static leases & port forwardings).
For IPv6 based rules to work,
devices must use their stable EUI64 IPv6 address
for the prefix announced via SLAAC.
You can recognise those as they reflect the MAC address of the interface.
The router cannot forward ports for devices only using
private IPv6 addresses according to
RFC 4941 (IPv6 privacy extensions)
or RFC 7217 (stable private IPv6 addresses).
'';
type = lib.types.attrsOf (lib.types.submodule deviceSubmodule);
default = { };
};
lanEmitRejections = lib.mkEnableOption ''
Whether to emit ICMP(v6) rejections to the trusted LAN
if the routers firewall blocks a package.
Should make it easier to debug cases
where the router blocks certain packages'';
# TODO test
protectWanSubnets = lib.mkEnableOption ''
firewall rules *trying* to protect the WANs local subnets.
Despite best efforts, I cannot make any gurantees
that this can prevent all attempts from LAN devices accessing WAN devices,
especially because of possible, additional IPv6 subnets.'';
acceptableWanMACs = lib.mkOption {
description = ''
When {option}`x-banananetwork.routerVM.protectWanSubnets` is enabled,
this provides a list of devices which should still be accessible.
If you want your LAN to have connection to the Internet,
this list MUST include your gateways MAC address.
'';
type = lib.types.listOf lib.types.eui48;
default = [ ];
example = [ "AA:BB:CC:DD:EE:FF" ];
};
dns = {
upstreams = lib.mkOption {
description = "List of DNS servers used by DNS server for clients.";
type = lib.types.listOf lib.types.str;
};
fallbacks = lib.mkOption {
description = "List of DNS servers used as fallbacks (i.e. when all upstreams are unreachable). Used by DNS server for clients.";
type = lib.types.listOf lib.types.str;
default = [ ];
};
bootstraps = lib.mkOption {
description = "List of DNS servers used to bootstrap list of other DNS servers. Used by DNS server for clients.";
type = lib.types.listOf lib.types.str;
default = cfg.dns.localFallbacks;
defaultText = lib.literalExpression "config.x-banananetwork.routerVM.dns.localFallbacks";
};
localFallbacks = lib.mkOption {
description = "List of DNS servers used as local fallbacks, i.e. added aside of own DNS server to {file}`/etc/resolv.conf` of router itself.";
type = with lib.types; listOf ipAddress;
default = [ ];
};
webui.username = lib.mkOption {
description = "Username for AdGuard Home admin account.";
type = lib.types.str;
};
webui.password = lib.mkOption {
description = ''
Hash for AdGuard Home admin account.
Can be created with .e.g. {command}`mkpasswd --method=bcrypt`'
For more info, read in the [Adguard Home Wiki](https://github.com/AdguardTeam/AdguardHome/wiki/Configuration#reset-web-password)
'';
type = lib.types.str;
};
filterlists = lib.mkOption {
description = "List of URLs of filterlists which entries should be blocked";
type = with lib.types; listOf str;
default = [ ];
};
};
};
};
# TODO configure NAT64 with 464XLAT
# requires: https://github.com/systemd/systemd/issues/23674
# or services.clat.enable = true; & test how it works
# IPv6 prefix delegation config inspired by: https://major.io/p/dhcpv6-prefix-delegation-with-systemd-networkd/
config = lib.mkIf cfg.enable {
warnings = lib.singleton (
lib.mkIf (lib.versionAtLeast lib.version "24.11") "remove manual sysctl for ip forwarding, as can be replaced by systemd-networkd settings"
);
# I will pin a lot of stuff, to ensure future changes on NixOS are noticed
networking = {
enableIPv6 = true; # also just a statement
nameservers = [
# localhost
"::1"
"127.0.0.1"
] ++ cfg.dns.localFallbacks;
tempAddresses = "disabled"; # do not manage that here
useDHCP = false; # do not intervene with router config
useNetworkd = true;
};
services.resolved.enable = false; # invoked by systemd.network
# general
systemd.network = {
enable = true;
config.networkConfig = lib.mkIf (lib.versionAtLeast lib.version "24.11") {
# these options are introduced with systemd 256 -> NixOS 24.11
IPv4Forwarding = true;
IPv6Forwarding = true;
UseDomains = false;
};
wait-online = {
anyInterface = false; # all which are listed as required
enable = true;
# more is configured per netdev/network as RequiredForOnline
# TODO add that hint to nixos docs in systemd.network.wait-online.anyInterface etc.
};
};
boot.kernel.sysctl = lib.mkIf (!lib.versionAtLeast lib.version "24.11") {
"net.ipv4.conf.default.forwarding" = true;
"net.ipv4.conf.all.forwarding" = true;
"net.ipv6.conf.default.forwarding" = true;
"net.ipv6.conf.all.forwarding" = true;
};
# expose for easier debugging
environment.systemPackages = lib.singleton config.services.nft-update-addresses.package;
# following settings are ordered according to the respective man pages
# configure "real" devices
systemd.network.links =
let
# defaults
linkConfig = {
MACAddressPolicy = "persistent";
NamePolicy = ""; # -> use Name=
AlternativeNamesPolicy = "mac slot path onboard";
AutoNegotiation = true;
};
in
{
# auto-generated files are using numbers "70" & higher
"10-${wanName}" = {
matchConfig.PermanentMACAddress = cfg.wanMAC;
linkConfig = linkConfig // {
Name = wanName;
Description = "WAN device";
};
};
"10-${lanName}" = {
matchConfig.PermanentMACAddress = cfg.lanMAC;
linkConfig = linkConfig // {
Name = lanName;
Description = "LAN device";
};
};
};
# virtual devices
systemd.network.netdevs = { };
# networks
systemd.network.networks = {
"10-${wanName}" = {
matchConfig.Name = wanName;
linkConfig = {
RequiredForOnline = "degraded"; # = has addresses assigned
RequiredFamilyForOnline = "any";
};
networkConfig = {
DHCP = "yes"; # both IPv4 & IPv6 (prefix delegation)
LinkLocalAddressing = "ipv6";
IPv6LinkLocalAddressGenerationMode = "eui64";
LLMNR = false;
MulticastDNS = false;
LLDP = "routers-only";
DNSDefaultRoute = true;
IPv6AcceptRA = true;
};
dhcpV6Config = {
PrefixDelegationHint = "::/60"; # TODO make configurable
};
ipv6AcceptRAConfig = {
PrefixDenyList = whitespaceList cfg.trustableIPv6Prefixes;
RouteDenyList = whitespaceList cfg.trustableIPv6Prefixes;
};
};
"10-${lanName}" = {
matchConfig.Name = lanName;
linkConfig = {
RequiredForOnline = false;
};
networkConfig = {
DHCPServer = true;
LinkLocalAddressing = "ipv6";
IPv6LinkLocalAddressGenerationMode = "eui64";
LLMNR = true;
MulticastDNS = true;
LLDP = "routers-only";
EmitLLDP = true;
IPv6AcceptRA = false;
IPv4ReversePathFilter = "strict";
IPv6SendRA = true;
DHCPPrefixDelegation = true;
};
address = lib.singleton "fe80::1/64"; # also link-local router
dhcpPrefixDelegationConfig = {
UplinkInterface = "${wanName}";
SubnetId = "0x0";
Announce = true;
Assign = true;
Token = "static:::1";
ManageTemporaryAddress = false;
};
dhcpServerConfig = {
ServerAddress = cfg.lanIPv4Address;
# pool automatically set from CIDR
UplinkInterface = ":none";
DNS = "_server_address";
# TODO emit domain ipv4 as well
SendOption = lib.singleton "15:string:${cfg.lanDomain}";
EmitNTP = false; # IPv6 ftw.
EmitSIP = false;
EmitPOP3 = false;
EmitSMTP = false;
EmitLPR = false;
};
dhcpServerStaticLeases =
filterMapAttrsToList cfg.lanDevices (dev: dev.enable && dev.staticIPv4 != null)
(dev: {
dhcpServerStaticLeaseConfig = {
MACAddress = dev.mac;
Address = dev.staticIPv4;
};
});
ipv6SendRAConfig = {
# TODO until https://github.com/systemd/systemd/issues/29651 is fixed:
RouterLifetimeSec = lib.mkDefault (5 * 60); # set so low in case of a prefix renewal
UplinkInterface = ":none";
DNS = "_link_local";
Domains = whitespaceList (lib.singleton cfg.lanDomain);
};
ipv6Prefixes = lib.singleton {
ipv6PrefixConfig = {
Prefix = cfg.lanIPv6ULAPrefix;
Assign = true;
Token = "static:::1";
};
};
};
};
# firewall
networking.firewall = {
enable = true;
checkReversePath = "strict";
filterForward = false; # replace with own chains
extraInputRules = ''
jump router-inbound
'';
};
networking.nftables = {
enable = true;
# TODO assert that openFirewall / allowedTCP/UDPPorts does not collide with DNAT rules
tables = {
# append to OS table
"nixos-fw".content = ''
set v6ula {
type ipv6_addr
flags interval
elements = { fc00::/7 }
}
${mapListJoin "\n" protoList (proto: ''
map ${lanName}v4dnat${proto} {
type inet_service : ipv4_addr . inet_service
${
dnatMapBuilder proto (dev: dev.staticIPv4) cfg.lanDevices (built: ''
elements = { ${built} }
'')
}
}
'')}
chain drop-reject {
ct state invalid drop comment "just drop early packages"
icmp type destination-unreachable drop comment "avoid virtual loop"
icmpv6 type destination-unreachable drop comment "avoid virtual loop"
${lib.optionalString cfg.lanEmitRejections ''
iifname ${lanName} ip saddr ${lanName}v4net reject comment "emit error to trusted net"
''}
drop comment "drop anything else"
}
chain global {
ct state established,related accept
ct state invalid jump drop-reject
ct state != { new, untracked } jump drop-reject
}
# TODO export/upstream
# these assume working connection state tracking
chain rfc4890-icmpv6-site-both {
# TODO required? icmpv6 type echo-reply accept "already established"
ip6 daddr fe80::/10 ip6 nexthdr icmpv6 drop comment "do not route link-local stuff, just in case"
ip6 daddr ff00::/8 icmpv6 type echo-reply drop comment "drop ping responses to multicast"
icmpv6 type parameter-problem icmpv6 code 0 accept comment "bad header"
icmpv6 type {
mld-listener-query,
mld-listener-report,
mld-listener-done,
mld-listener-reduction,
nd-router-solicit,
nd-router-advert,
nd-neighbor-solicit,
nd-neighbor-advert,
nd-redirect,
router-renumbering,
139, 140, # node information queries / replies
} jump drop-reject
ip6 nexthdr icmpv6 jump drop-reject
}
chain rfc4890-icmpv6-site-inbound {
# TODO allow pingable hosts (but not here)
icmpv6 type time-exceeded icmpv6 code 1 accept comment "reassembly failed"
jump rfc4890-icmpv6-site-both
}
chain rfc4890-icmpv6-site-outbound {
icmpv6 type { echo-request, destination-unreachable, packet-too-big } accept;
icmpv6 type time-exceeded icmpv6 code { 0, 1 } accept comment "transit/reassembly failed"
icmpv6 type parameter-problem icmpv6 code { 1, 2 } accept comment "unknown header-type/option"
jump rfc4890-icmpv6-site-both
}
chain router-inbound {
# stuff with .openFirewall is already accepted
# to global just in case
jump global
iifname ${wanName} jump ${wanName}-inbound
iifname ${lanName} jump ${lanName}-inbound
}
chain ${wanName}-inbound {
ip6 daddr @${lanName}v6net jump drop-reject comment "drop requests from outbound to router"
}
chain ${lanName}-inbound {
ip version 4 udp sport 68 udp dport 67 accept comment "DHCPv4"
icmpv6 type nd-router-solicit accept comment "IPv6 SLAAC"
udp dport 53 accept comment "DNS"
tcp dport 53 accept comment "DNS"
}
chain router-forward {
type filter hook forward priority filter; policy drop;
jump global
ct status dnat accept
iifname ${lanName} oifname ${wanName} jump ${lanName}-to-${wanName}
iifname ${wanName} oifname ${lanName} jump ${wanName}-to-${lanName}
jump drop-reject
}
chain ${wanName}-to-${lanName} {
jump rfc4890-icmpv6-site-inbound
${filterMapJoin "\n" cfg.lanDevices (dev: dev.nftablesForwardingRules)}
}
chain ${lanName}-to-${wanName} {
jump rfc4890-icmpv6-site-outbound
${lib.optionalString cfg.protectWanSubnets ''
# TODO try with & without mark
#meta mark set ${nftMarks.protectWanSubnets} comment "protect WAN subnets"
ip daddr @${wanName}v6net reject comment "protect ${wanName} subnet"
ip6 daddr @${wanName}v6net reject comment "protect ${wanName} subnet"
''}
ip6 saddr @v6ula reject comment "no general outbound with unique link locals"
ip6 daddr @v6ula reject comment "no general outbound to unique link locals"
ip saddr @${lanName}v4net accept comment "outbound allowed"
ip6 saddr @${lanName}v6net accept comment "outbound allowed"
}
chain router-prerouting {
type nat hook prerouting priority -100; policy accept;
${
mapListJoin "\n" protoList (proto: ''
iifname != ${wanName} ip daddr @${wanName}v4addr meta mark set ${
nftMarks."${wanName}_snatShortcutted"
}
iifname != ${wanName} ip6 daddr @${wanName}v6addr meta mark set ${
nftMarks."${wanName}_snatShortcutted"
}
ip daddr @${wanName}v4addr dnat ip to ${proto} dport map @${lanName}v4dnat${proto}
ip6 daddr @${wanName}v6addr dnat ip6 to ${proto} dport map @${lanName}v6dnat${proto}
'')
}
}
chain router-postrouting {
type nat hook postrouting priority 100; policy accept;
iifname ${lanName} oifname ${wanName} ip version 4 masquerade comment "persistent not required as only one source addr available, avoid randonmness"
meta mark ${nftMarks."${wanName}_snatShortcutted"} masquerade
}
'';
"router-netdev" = lib.mkIf cfg.protectWanSubnets {
family = "netdev";
content = ''
set wan_accepted {
typeof ether daddr
elements = { ${builtins.concatStringsSep ", " cfg.acceptableWanMACs} }
}
chain egress {
meta mark ${nftMarks.protectWanSubnets} ether daddr @wan_accepted accept
meta mark ${nftMarks.protectWanSubnets} jump drop-reject
}
'';
};
};
};
boot.blockedKernelModules = [
"ip_tables"
"iptable_nat"
];
# prefix updater
services.nft-update-addresses = {
enable = true;
settings =
let
inherit (builtins) attrValues;
inherit (lib.attrsets) genAttrs;
inherit (lib.lists) flatten;
flatMap = mapFun: list: flatten (map mapFun list);
destMap = dev: dev.mac;
portMap = proto: dev: (dev.nftUpdateAddressesConfig destMap).${proto};
combinePortCfg = devCfgs: {
exposed = flatMap (devCfg: devCfg.exposed) devCfgs;
forwarded = flatMap (devCfg: devCfg.forwarded) devCfgs;
};
combinePerProto =
devices: proto:
lib.trivial.pipe proto [
portMap
(filterMap (attrValues devices))
combinePortCfg
];
protoPortCfg = devices: genAttrs protoList (combinePerProto devices);
in
{
nftTable = "nixos-fw";
interfaces = {
${wanName} = { };
${lanName}.ports = protoPortCfg cfg.lanDevices;
};
};
};
# DNS server
# TODO exclude router from filtering itself (without config)
# TODO add support for https://opennic.org/
# TODO change NixOS upstream module to remove yaml-merge dependency if not required
services.adguardhome = {
enable = true;
allowDHCP = false;
mutableSettings = false;
port = 3000;
settings = {
http = {
session_ttl = "5:00";
};
users = lib.singleton {
name = "admin";
password = ""; # TODO bcrypt htpasswd random stuff
};
auth_attempts = 5;
block_auth_min = 5;
dns = {
port = 53;
anonymize_client_ip = false;
ratelimit = 100; # queries / second
ratelimit_subnet_len_ipv4 = 32;
ratelimit_subnet_len_ipv6 = 128;
refuse_any = true;
upstream_dns = cfg.dns.upstreams;
fallback_dns = cfg.dns.fallbacks;
bootstrap_dns = cfg.dns.bootstraps;
local_ptr_upstreams = [
# TODO
];
upstream_mode = "load_balance";
upstream_timeout = "10s";
use_http3_upstream = true;
enable_dnssec = true;
cache_time = 36000;
resolve_clients = true;
serve_plain_dns = true;
hostsfile_enabled = false;
};
filtering = {
protection_enabled = true;
filtering_enabled = true;
blocking_mode = "nxdomain";
blocked_response_ttl = 60; # seconds
safe_search.enabled = false;
safebrowsing_enabled = true;
};
querylog = {
enabled = true;
file_enabled = true;
interval = "${toString (2 * 24)}h";
};
statistics = {
enabled = true;
interval = "${toString (7 * 24)}h";
};
filters = lib.trivial.flip map cfg.dns.filterlists (url: {
enabled = true;
inherit url;
name = url;
ID = builtins.hashString "sha1" url;
});
dhcp.enabled = false;
tls.enabled = false;
log.file = "syslog"; # journal
};
};
# fallback specialisation for trivial network access
specialisation.trivialNetwork.configuration = {
x-banananetwork.routerVM.enable = lib.mkForce false;
networking = {
# DHCP on all interfaces
useDHCP = lib.mkForce true;
useNetworkd = lib.mkForce true;
};
};
# order of specialisation v. older generation not clear
# and no plan which order is senseful, for now
boot.loader.systemd-boot.bootCounting.enable = false;
};
}