10 inherit (config.users) users groups;
11 cfg = config.services.upnpc;
13 conf: "upnpc" + lib.optionalString (conf.addressOrInterface != null) "-${conf.addressOrInterface}";
18 (lib.getExe cfg.package)
20 # Warning(correctness): -m must come first, or it will be ignored.
21 (lib.optionals (conf.addressOrInterface != null) ([
23 conf.addressOrInterface
27 while IFS=: read -r k v; do
28 k=$(printf %s "$k" | sed -e 's/^\s*//' -e 's/\s*$//')
29 v=$(printf %s "$v" | sed -e 's/^\s*//' -e 's/\s*$//')
32 ("Local LAN ip address") localIP=$v;;
36 lib.optionalString (conf.addressOrInterface != null) "-m \"${conf.addressOrInterface}\""
42 options.services.upnpc = {
43 enable = lib.mkEnableOption "UPnP redirections";
44 package = lib.mkPackageOption pkgs "miniupnpc" { };
45 redirections = lib.mkOption {
46 description = "UPnP redirections to request.";
52 options.addressOrInterface = lib.mkOption {
54 Provide IPv4 address or interface name (IPv4 or IPv6)
55 to use for sending SSDP multicast packets.
57 Beware that under Linux multicast packets
58 are only sent on a single interface,
59 as shown by `ip route get 239.255.255.250`.
60 See https://unix.stackexchange.com/questions/719148/multicast-to-all-interfaces-how-to-set-up-the-routes
62 type = with types; nullOr str;
65 options.description = lib.mkOption {
66 description = "Description of the port mapping";
70 options.duration = lib.mkOption {
71 description = "Duration of the redirection, in seconds. 0 means indefinitely.";
75 options.externalPort = lib.mkOption {
76 description = "External port to open on the redirecting device.";
79 options.internalPort = lib.mkOption {
80 description = "Internal port, target of the redirection.";
82 default = config.externalPort;
84 options.maintainPeriod = lib.mkOption {
85 description = "Period (in seconds) between runs to maintain the redirection.";
86 type = with types; nullOr int;
87 default = if config.duration > 0 then config.duration / 2 else null;
88 defaultText = "if duration > 0 then duration / 2 else null";
90 options.override = lib.mkOption {
91 description = "Try to override the redirection in case of conflict in mapping entry.";
95 options.protocol = lib.mkOption {
96 description = "Protocol to redirect.";
105 options.service = lib.mkOption {
106 description = "Configuration specific to the systemd service handling this UPnP redirecting.";
115 config = lib.mkIf cfg.enable {
116 systemd.slices = lib.listToAttrs (
119 lib.nameValuePair (getSlice conf) {
121 description = "UPnP slice for ${conf.addressOrInterface or "any interface"}";
126 systemd.services = lib.listToAttrs (
130 "upnpc-${conf.addressOrInterface or ""}-${conf.protocol}-${toString conf.internalPort}"
134 description = "UPnP for ${
135 conf.addressOrInterface or "any interface"
136 }, protocol ${conf.protocol} and port ${toString conf.internalPort}";
137 after = [ "network-pre.target" ];
138 wantedBy = lib.mkDefault [ "multi-user.target" ];
139 path = [ cfg.package ];
141 Slice = "${getSlice conf}.slice";
142 Type = if conf.maintainPeriod == null then "oneshot" else "simple";
143 RemainAfterExit = conf.maintainPeriod == null;
144 ExecStart = pkgs.writeShellScript "upnpc-start-${conf.addressOrInterface or ""}-${conf.protocol}-${toString conf.internalPort}" ''
148 while IFS= read -r line; do
151 (*" is redirected to internal $localIP:${toString conf.internalPort}"*) result=ok ;;
152 (*ConflictInMappingEntry*) result=conflict ;;
156 lib.escapeShellArgs (
159 (lib.optionals (conf.description != "") [
161 (lib.escapeShellArg conf.description)
166 lib.optionalString (conf.description != "") "-e ${lib.escapeShellArg conf.description}"
167 } -a "$localIP" ${toString conf.internalPort} ${toString conf.externalPort} ${conf.protocol} ${toString conf.duration} 2>&1
174 ${lib.optionalString conf.override ''
175 test "$result" != conflict || {
176 ${lib.escapeShellArgs (getExe conf)} -u "$desc" -d ${toString conf.externalPort} ${conf.protocol}
182 if conf.maintainPeriod == null then "break" else "sleep " + toString conf.maintainPeriod
188 ExecStop = utils.escapeSystemdExecArgs (
193 (toString conf.externalPort)
198 Restart = "on-failure";
200 User = users."upnpc".name;
202 // lib.optionalAttrs (conf.maintainPeriod != null) {
203 RestartSec = lib.mkDefault conf.maintainPeriod;
212 environment.systemPackages = [ cfg.package ];
214 # This enables to match on the uid in the firewall.
215 users.users."upnpc" = {
217 group = groups."upnpc".name;
219 users.groups."upnpc" = { };
220 networking.nftables.ruleset = lib.concatStringsSep "\n" [
223 # A set containing the udp port(s) to which SSDP replies are allowed.
229 # Create a rule for accepting any SSDP packets going to a remembered port.
230 udp dport @upnpc-ssdp counter accept comment "SSDP answer"
233 skuid ${users.upnpc.name} \
236 comment "SSDP automatic opening of input-net"
237 skuid ${users.upnpc.name} \
238 ip daddr 239.255.255.250 udp dport ssdp \
239 set add udp sport @upnpc-ssdp \
240 comment "SSDP automatic opening of input-net"
241 skuid ${users.upnpc.name} \
242 ip daddr 239.255.255.250 udp dport ssdp \
248 (lib.optionalString config.networking.enableIPv6 ''
251 skuid ${users.upnpc.name} \
252 ip6 daddr { FF02::C, FF05::C, FF08::C, FF0E::C } \
254 set add udp sport @upnpc-ssdp \
255 comment "SSDP automatic opening of input-net"
256 skuid ${users.upnpc.name} \
257 ip6 daddr { FF02::C, FF05::C, FF08::C, FF0E::C } \
259 counter accept comment "SSDP"
265 meta.maintainers = with lib.maintainers; [ julm ];