From c8f965411e812060a9377fa4c2d7d0f84e8b10e0 Mon Sep 17 00:00:00 2001 From: "Remy D. Farley" Date: Fri, 7 Aug 2026 19:42:03 +0000 Subject: [PATCH 1/4] nixos/i2pd: refactor with freeform (RFC42), harden service, add secrets handling --- .../manual/release-notes/rl-2611.section.md | 2 + nixos/modules/services/networking/i2pd.nix | 1266 +++++++---------- 2 files changed, 516 insertions(+), 752 deletions(-) diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index b05d7808451b..4482f1039a00 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -205,6 +205,8 @@ - `boot.supportedFilesystems.ntfs` installs `ntfsprogs-plus` instead of `ntfs3g` on kernel version 7.1 and later, unless `boot.supportedFilesystems.ntfs-3g` is explicitly enabled. +- `services.i2pd` has been refactored to take [RFC42](https://github.com/NixOS/rfcs/blob/master/rfcs/0042-config-option.md)-compliant `settings`. In order to migrate, you will need to move existing config under `settings` and rename them in accordance with the [upstream config format](https://docs.i2pd.website/en/latest/user-guide/configuration/#available-options). In addition, `inTunnels` and `outTunnels` needs to be renamed to `serverTunnels` and `clientTunnels` respectively. + - The `programs.fuse` module, which provides the `fusermount3` executable and the `/etc/fuse.conf` config file, is now opt-in. The obligation to enable it has been shifted to its various consumers (e.g. gvfs, flatpak, appimage, sshfs). This can break fuse consumers at runtime, that don't explicitly declare that dependency with a module, e.g the mounting functionality in various backup tools (borg, restic, rclone, ...). - `services.plausible` can now again seed an initial admin user declaratively via [`services.plausible.adminUser.email`](#opt-services.plausible.adminUser.email). diff --git a/nixos/modules/services/networking/i2pd.nix b/nixos/modules/services/networking/i2pd.nix index a8b04c4ebfe1..8f327c1952ad 100644 --- a/nixos/modules/services/networking/i2pd.nix +++ b/nixos/modules/services/networking/i2pd.nix @@ -6,784 +6,546 @@ }: let - inherit (lib) - mkIf - mkOption - mkDefault - mkEnableOption - types - optional - optionals - ; - inherit (lib.types) - nullOr - bool - listOf - str - attrsOf - submodule - ; + inherit (lib) types; - cfg = config.services.i2pd; + coerceMap = + mapping: finalType: + types.coercedTo (types.enum (lib.attrNames mapping)) (x: mapping.${x}) finalType; - homeDir = "/var/lib/i2pd"; - - strOpt = k: v: k + " = " + v; - boolOpt = k: v: k + " = " + lib.boolToString v; - intOpt = k: v: k + " = " + toString v; - lstOpt = k: xs: k + " = " + lib.concatStringsSep "," xs; - optionalNullString = o: s: optional (s != null) (strOpt o s); - optionalNullBool = o: b: optional (b != null) (boolOpt o b); - optionalNullInt = o: i: optional (i != null) (intOpt o i); - optionalEmptyList = o: l: optional ([ ] != l) (lstOpt o l); - - mkEnableTrueOption = name: mkEnableOption name // { default = true; }; - - mkEndpointOpt = name: addr: port: { - enable = mkEnableOption name; - name = mkOption { - type = types.str; - default = name; - description = "The endpoint name."; - }; - address = mkOption { - type = types.str; - default = addr; - description = "Bind address for ${name} endpoint."; - }; - port = mkOption { - type = types.port; - default = port; - description = "Bind port for ${name} endpoint."; + /* + Credential handling pipeline: + - Buildtime + - User sets { _secret = ...; } for every value expected to be substituted at runtime + - credential.finalize recursively traverses configuration inserting placeholder values like "" + - Runtime + - Systemd ensures that given path exists (`RequiresMountsFor`) + - Systemd reads value of each credential to `$CREDENTIALS_DIRECTORY/` + - loadCredentialsScript copies config files from /nix/store to /tmp and finally substitutes credentials + */ + credAttrType = "_secret"; + credPlaceholderAttrType = "_secretPlaceholder"; + credType = types.addCheck types.attrs (attrs: attrs ? ${credAttrType}) // { + merge = loc: defs: { + ${credAttrType} = + let + def = lib.mergeEqualOption loc defs; + val = def.${credAttrType}; + path = + if types.path.check val then + val + else + throw "Provided `{ ${credAttrType} = ...; }` is not of type `lib.types.path`"; + id = builtins.hashString "sha256" path; + placeholder = if def ? ${credPlaceholderAttrType} then def.${credPlaceholderAttrType} else id; + in + { + inherit path id placeholder; + }; }; }; - - i2cpOpts = name: { - length = mkOption { - type = types.int; - description = "Guaranteed minimum hops for ${name} tunnels."; - default = 3; - }; - quantity = mkOption { - type = types.int; - description = "Number of simultaneous ${name} tunnels."; - default = 5; - }; - }; - - mkKeyedEndpointOpt = - name: addr: port: keyloc: - (mkEndpointOpt name addr port) - // { - keys = mkOption { - type = nullOr str; - default = keyloc; - description = '' - File to persist ${lib.toUpper name} keys. - ''; - }; - inbound = i2cpOpts name; - outbound = i2cpOpts name; - latency.min = mkOption { - type = with types; nullOr int; - description = "Min latency for tunnels."; - default = null; - }; - latency.max = mkOption { - type = with types; nullOr int; - description = "Max latency for tunnels."; - default = null; - }; - }; - - commonTunOpts = - name: - { - outbound = i2cpOpts name; - inbound = i2cpOpts name; - crypto.tagsToSend = mkOption { - type = types.int; - description = "Number of ElGamal/AES tags to send."; - default = 40; - }; - keys = mkOption { - type = types.str; - default = name + "-keys.dat"; - description = "Keyset used for tunnel identity."; - }; - } - // mkEndpointOpt name "127.0.0.1" 0; - - sec = name: "\n[" + name + "]"; - notice = "# DO NOT EDIT -- this file has been generated automatically."; - i2pdConf = - let - opts = [ - notice - (strOpt "loglevel" cfg.logLevel) - (boolOpt "logclftime" cfg.logCLFTime) - (boolOpt "ipv4" cfg.enableIPv4) - (boolOpt "ipv6" cfg.enableIPv6) - (boolOpt "notransit" cfg.notransit) - (boolOpt "floodfill" cfg.floodfill) - (intOpt "netid" cfg.netid) - ] - ++ (optionalNullInt "bandwidth" cfg.bandwidth) - ++ (optionalNullInt "port" cfg.port) - ++ (optionalNullString "family" cfg.family) - ++ (optionalNullString "datadir" cfg.dataDir) - ++ (optionalNullInt "share" cfg.share) - ++ (optionalNullBool "ssu" cfg.ssu) - ++ (optionalNullBool "ntcp" cfg.ntcp) - ++ (optionalNullString "ntcpproxy" cfg.ntcpProxy) - ++ (optionalNullString "ifname" cfg.ifname) - ++ (optionalNullString "ifname4" cfg.ifname4) - ++ (optionalNullString "ifname6" cfg.ifname6) - ++ [ - (sec "limits") - (intOpt "transittunnels" cfg.limits.transittunnels) - (intOpt "coresize" cfg.limits.coreSize) - (intOpt "openfiles" cfg.limits.openFiles) - (intOpt "ntcphard" cfg.limits.ntcpHard) - (intOpt "ntcpsoft" cfg.limits.ntcpSoft) - (intOpt "ntcpthreads" cfg.limits.ntcpThreads) - (sec "upnp") - (boolOpt "enabled" cfg.upnp.enable) - (sec "precomputation") - (boolOpt "elgamal" cfg.precomputation.elgamal) - (sec "reseed") - (boolOpt "verify" cfg.reseed.verify) - ] - ++ (optionalNullString "file" cfg.reseed.file) - ++ (optionalEmptyList "urls" cfg.reseed.urls) - ++ (optionalNullString "floodfill" cfg.reseed.floodfill) - ++ (optionalNullString "zipfile" cfg.reseed.zipfile) - ++ (optionalNullString "proxy" cfg.reseed.proxy) - ++ [ - (sec "trust") - (boolOpt "enabled" cfg.trust.enable) - (boolOpt "hidden" cfg.trust.hidden) - ] - ++ (optionalEmptyList "routers" cfg.trust.routers) - ++ (optionalNullString "family" cfg.trust.family) - ++ [ - (sec "websockets") - (boolOpt "enabled" cfg.websocket.enable) - (strOpt "address" cfg.websocket.address) - (intOpt "port" cfg.websocket.port) - (sec "exploratory") - (intOpt "inbound.length" cfg.exploratory.inbound.length) - (intOpt "inbound.quantity" cfg.exploratory.inbound.quantity) - (intOpt "outbound.length" cfg.exploratory.outbound.length) - (intOpt "outbound.quantity" cfg.exploratory.outbound.quantity) - (sec "ntcp2") - (boolOpt "enabled" cfg.ntcp2.enable) - (boolOpt "published" cfg.ntcp2.published) - (intOpt "port" cfg.ntcp2.port) - (sec "ssu2") - (boolOpt "enabled" cfg.ssu2.enable) - (boolOpt "published" cfg.ssu2.published) - (intOpt "port" cfg.ssu2.port) - (sec "addressbook") - (strOpt "defaulturl" cfg.addressbook.defaulturl) - ] - ++ (optionalEmptyList "subscriptions" cfg.addressbook.subscriptions) - ++ [ - (sec "meshnets") - (boolOpt "yggdrasil" cfg.yggdrasil.enable) - ] - ++ (optionalNullString "yggaddress" cfg.yggdrasil.address) - ++ (lib.flip map (lib.collect (proto: proto ? port && proto ? address) cfg.proto) ( - proto: - let - protoOpts = [ - (sec proto.name) - (boolOpt "enabled" proto.enable) - (strOpt "address" proto.address) - (intOpt "port" proto.port) - ] - ++ (optionals (proto ? keys) (optionalNullString "keys" proto.keys)) - ++ (optionals (proto ? auth) (optionalNullBool "auth" proto.auth)) - ++ (optionals (proto ? user) (optionalNullString "user" proto.user)) - ++ (optionals (proto ? pass) (optionalNullString "pass" proto.pass)) - ++ (optionals (proto ? strictHeaders) (optionalNullBool "strictheaders" proto.strictHeaders)) - ++ (optionals (proto ? hostname) (optionalNullString "hostname" proto.hostname)) - ++ (optionals (proto ? outproxy) (optionalNullString "outproxy" proto.outproxy)) - ++ (optionals (proto ? outproxyPort) (optionalNullInt "outproxyport" proto.outproxyPort)) - ++ (optionals (proto ? outproxyEnable) (optionalNullBool "outproxy.enabled" proto.outproxyEnable)); - in - (lib.concatStringsSep "\n" protoOpts) - )); - in - pkgs.writeText "i2pd.conf" (lib.concatStringsSep "\n" opts); - - tunnelConf = - let - mkOutTunnel = - tun: - let - outTunOpts = [ - (sec tun.name) - (intOpt "type" tun.type) - (intOpt "port" tun.port) - (strOpt "destination" tun.destination) - ] - ++ (optionals (tun ? destinationPort) (optionalNullInt "destinationport" tun.destinationPort)) - ++ (optionals (tun ? keys) (optionalNullString "keys" tun.keys)) - ++ (optionals (tun ? address) (optionalNullString "address" tun.address)) - ++ (optionals (tun ? inbound.length) (optionalNullInt "inbound.length" tun.inbound.length)) - ++ (optionals (tun ? inbound.quantity) (optionalNullInt "inbound.quantity" tun.inbound.quantity)) - ++ (optionals (tun ? outbound.length) (optionalNullInt "outbound.length" tun.outbound.length)) - ++ (optionals (tun ? outbound.quantity) (optionalNullInt "outbound.quantity" tun.outbound.quantity)) - ++ (optionals (tun ? crypto.tagsToSend) ( - optionalNullInt "crypto.tagstosend" tun.crypto.tagsToSend - )); - in - lib.concatStringsSep "\n" outTunOpts; - - mkInTunnel = - tun: - let - inTunOpts = [ - (sec tun.name) - (intOpt "type" tun.type) - (intOpt "port" tun.port) - (strOpt "host" tun.address) - ] - ++ (optionals (tun ? keys) (optionalNullString "keys" tun.keys)) - ++ (optionals (tun ? inPort) (optionalNullInt "inport" tun.inPort)) - ++ (optionals (tun ? accessList) (optionalEmptyList "accesslist" tun.accessList)) - ++ (optionals (tun ? inbound.length) (optionalNullInt "inbound.length" tun.inbound.length)) - ++ (optionals (tun ? inbound.quantity) (optionalNullInt "inbound.quantity" tun.inbound.quantity)) - ++ (optionals (tun ? outbound.length) (optionalNullInt "outbound.length" tun.outbound.length)) - ++ (optionals (tun ? outbound.quantity) (optionalNullInt "outbound.quantity" tun.outbound.quantity)) - ++ (optionals (tun ? crypto.tagsToSend) ( - optionalNullInt "crypto.tagstosend" tun.crypto.tagsToSend - )); - in - lib.concatStringsSep "\n" inTunOpts; - - allOutTunnels = lib.collect (tun: tun ? port && tun ? destination) cfg.outTunnels; - allInTunnels = lib.collect (tun: tun ? port && tun ? address) cfg.inTunnels; - - opts = [ notice ] ++ (map mkOutTunnel allOutTunnels) ++ (map mkInTunnel allInTunnels); - in - pkgs.writeText "i2pd-tunnels.conf" (lib.concatStringsSep "\n" opts); - - i2pdFlags = lib.concatStringsSep " " ( - optional (cfg.address != null) ("--host=" + cfg.address) - ++ [ - "--service" - ("--conf=" + i2pdConf) - ("--tunconf=" + tunnelConf) - ] - ); - + credSubstituteRec = + attr: x: + if credType.check x then + x.${credAttrType}.${attr} + else if lib.isList x then + map (credSubstituteRec attr) x + else if lib.isAttrs x then + lib.mapAttrs (_: v: credSubstituteRec attr v) x + else + x; + credCollectRec = + x: + if credType.check x then + [ x.${credAttrType} ] + else if lib.isList x then + lib.flatten (lib.map credCollectRec x) + else if lib.isAttrs x then + lib.flatten (lib.mapAttrsToList (_: v: credCollectRec v) x) + else + [ ]; in - { + ###### Interface ##### - imports = [ - (lib.mkRenamedOptionModule [ "services" "i2pd" "extIp" ] [ "services" "i2pd" "address" ]) - ]; + options.services.i2pd = + let + freeformType = + with types; + let + base = [ + bool + int + str + credType + ]; + in + attrsOf ( + nullOr ( + oneOf ( + base + ++ [ + (listOf (oneOf base)) + freeformType + ] + ) + ) + ) + // { + description = "nested (bool, int, string or list of bool, int or string)"; + }; - ###### interface + intOrCoerceMap = + mapping: description: + lib.mkOption { + type = with types; nullOr (coerceMap mapping int); + default = null; + inherit description; + }; - options = { - - services.i2pd = { - - enable = mkEnableOption "I2Pd daemon" // { - description = '' - Enables I2Pd as a running service upon activation. - Please read for further - configuration help. - ''; + # Hopefully helpful enum mappings + templates = { + # https://i2pd.readthedocs.io/en/latest/user-guide/tunnels/#i2cp-parameters + i2cp = { + leaseSetType = intOrCoerceMap { + "standard" = 3; + "encrypted" = 5; + } "Type of LeaseSet to be sent"; + leaseSetEncType = intOrCoerceMap { + "ELGAMAL" = 0; + "ECIES_P256_SHA256_AES256CBC" = 1; + "ECIES_X25519_AEAD" = 4; + "ECIES_MLKEM512_X25519_AEAD" = 5; + "ECIES_MLKEM768_X25519_AEAD" = 6; + "ECIES_MLKEM1024_X25519_AEAD" = 7; + } "List of LeaseSet encryption types"; + leaseSetAuthType = intOrCoerceMap { + "none" = 0; + "DH" = 1; + "PSK" = 2; + } "Authentication type for encrypted LeaseSet"; + }; + i2p.streaming.profile = intOrCoerceMap { + "bulk" = 1; + "interactive" = 2; + } "Bandwidth usage profile"; + # This option is part of both client and server tunnels, but not documented as i2cp parameter + signaturetype = + intOrCoerceMap + { + "ECDSA-P256" = 1; + "ECDSA-P384" = 2; + "ECDSA-P521" = 3; + "ED25519-SHA512" = 7; + "GOSTR3410-A-GOSTR3411-256" = 9; + "GOSTR3410-TC26-A-GOSTR3411-512" = 10; + "RED25519-SHA512" = 11; + "ML-DSA-44" = 12; + } + '' + Signature type for new keys. + `ED25519-SHA512` is default. + `RED25519-SHA512` is recommended for encrypted leaseset. + ''; }; - + in + { + enable = lib.mkEnableOption "`i2pd` (I2P network router)"; package = lib.mkPackageOption pkgs "i2pd" { }; - - logLevel = mkOption { - type = types.enum [ - "debug" - "info" - "warn" - "error" - ]; - default = "error"; + gracefulShutdown = lib.mkEnableOption "" // { description = '' - The log level. {command}`i2pd` defaults to "info" - but that generates copious amounts of log messages. - - We default to "error" which is similar to the default log - level of {command}`tor`. + If true, i2pd will wait for transit connections to close. + Enabling this option **may delay system shutdown/reboot/rebuild-switch up to 10 minutes!** ''; }; - - logCLFTime = mkEnableOption "full CLF-formatted date and time to log"; - - address = mkOption { - type = nullOr str; - default = null; - description = '' - Your external IP or hostname. - ''; + autoRestart = lib.mkEnableOption "" // { + default = true; + description = "If true, i2pd will be restarted on failure (does not affect clean exit)"; }; - - family = mkOption { - type = nullOr str; - default = null; + settings = lib.mkOption { description = '' - Specify a family the router belongs to. + Free-form main i2pd configuration. Options are passed to `i2pd.conf`. + See + + Any free-formed option value can be substituted with contents of a + provided file by setting it to `{ ${credAttrType} = ; }`. The + file is read **at runtime** before i2pd service starts, file + permissions are ignored. ''; - }; + type = types.submodule { + inherit freeformType; + options = { + loglevel = lib.mkOption { + type = types.enum [ + "debug" + "info" + "warn" + "error" + "critical" + "none" + ]; + default = "error"; + description = "The log level"; + }; + bandwidth = lib.mkOption { + type = + with types; + nullOr ( + coerceMap + { + "32KBps" = "L"; + "256KBps" = "O"; + "2048KBps" = "P"; + "UNLIMITED" = "X"; + } + (oneOf [ + ints.positive + (enum [ + "L" + "O" + "P" + "X" + ]) + ]) + ); + default = null; + description = '' + Set a router bandwidth limit: integer in KBps or alias. + Note that integer bandwidth will be rounded. + If not set, i2pd defaults to `32KBps`. + ''; + }; + }; + config = { + http.enabled = lib.mkDefault true; + httpproxy.enabled = lib.mkDefault true; + socksproxy.enabled = lib.mkDefault true; + sam.enabled = lib.mkDefault false; + bob.enabled = lib.mkDefault false; + i2cp.enabled = lib.mkDefault false; + i2pcontrol.enabled = lib.mkDefault false; - dataDir = mkOption { - type = nullOr str; - default = null; - description = '' - Alternative path to storage of i2pd data (RI, keys, peer profiles, ...) - ''; - }; + precomputation.elgamal = lib.mkDefault true; - share = mkOption { - type = types.int; - default = 100; - description = '' - Limit of transit traffic from max bandwidth in percents. - ''; - }; - - ifname = mkOption { - type = nullOr str; - default = null; - description = '' - Network interface to bind to. - ''; - }; - - ifname4 = mkOption { - type = nullOr str; - default = null; - description = '' - IPv4 interface to bind to. - ''; - }; - - ifname6 = mkOption { - type = nullOr str; - default = null; - description = '' - IPv6 interface to bind to. - ''; - }; - - ntcpProxy = mkOption { - type = nullOr str; - default = null; - description = '' - Proxy URL for NTCP transport. - ''; - }; - - ntcp = mkEnableTrueOption "ntcp"; - ssu = mkEnableTrueOption "ssu"; - - notransit = mkEnableOption "notransit" // { - description = '' - Tells the router to not accept transit tunnels during startup. - ''; - }; - - floodfill = mkEnableOption "floodfill" // { - description = '' - Makes your router a floodfill, that means what other routers will - publish and get LeaseSets and RouterInfos on your router. - ''; - }; - - netid = mkOption { - type = types.int; - default = 2; - description = '' - I2P overlay netid. - ''; - }; - - bandwidth = mkOption { - type = with types; nullOr int; - default = null; - description = '' - Set a router bandwidth limit integer in KBps. - If not set, {command}`i2pd` defaults to 32KBps. - ''; - }; - - port = mkOption { - type = with types; nullOr port; - default = null; - description = '' - I2P listen port. If no one is given the router will pick between 9111 and 30777. - ''; - }; - - enableIPv4 = mkEnableTrueOption "IPv4 connectivity"; - enableIPv6 = mkEnableOption "IPv6 connectivity"; - nat = mkEnableTrueOption "NAT bypass"; - - upnp.enable = mkEnableOption "UPnP service discovery"; - upnp.name = mkOption { - type = types.str; - default = "I2Pd"; - description = '' - Name i2pd appears in UPnP forwardings list. - ''; - }; - - precomputation.elgamal = mkEnableTrueOption "Precomputed ElGamal tables" // { - description = '' - Whenever to use precomputated tables for ElGamal. - {command}`i2pd` defaults to `false` - to save 64M of memory (and looses some performance). - - We default to `true` as that is what most - users want anyway. - ''; - }; - - reseed.verify = mkEnableOption "SU3 signature verification"; - - reseed.file = mkOption { - type = nullOr str; - default = null; - description = '' - Full path to SU3 file to reseed from. - ''; - }; - - reseed.urls = mkOption { - type = listOf str; - default = [ ]; - description = '' - Reseed URLs. - ''; - }; - - reseed.floodfill = mkOption { - type = nullOr str; - default = null; - description = '' - Path to router info of floodfill to reseed from. - ''; - }; - - reseed.zipfile = mkOption { - type = nullOr str; - default = null; - description = '' - Path to local .zip file to reseed from. - ''; - }; - - reseed.proxy = mkOption { - type = nullOr str; - default = null; - description = '' - URL for reseed proxy, supports http/socks. - ''; - }; - - addressbook.defaulturl = mkOption { - type = types.str; - default = "http://joajgazyztfssty4w2on5oaqksz6tqoxbduy553y34mf4byv6gpq.b32.i2p/export/alive-hosts.txt"; - description = '' - AddressBook subscription URL for initial setup - ''; - }; - addressbook.subscriptions = mkOption { - type = listOf str; - default = [ - "http://inr.i2p/export/alive-hosts.txt" - "http://i2p-projekt.i2p/hosts.txt" - "http://stats.i2p/cgi-bin/newhosts.txt" - ]; - description = '' - AddressBook subscription URLs - ''; - }; - - trust.enable = mkEnableOption "explicit trust options"; - - trust.family = mkOption { - type = nullOr str; - default = null; - description = '' - Router Family to trust for first hops. - ''; - }; - - trust.routers = mkOption { - type = listOf str; - default = [ ]; - description = '' - Only connect to the listed routers. - ''; - }; - - trust.hidden = mkEnableOption "router concealment"; - - websocket = mkEndpointOpt "websockets" "127.0.0.1" 7666; - - exploratory.inbound = i2cpOpts "exploratory"; - exploratory.outbound = i2cpOpts "exploratory"; - - ntcp2.enable = mkEnableTrueOption "NTCP2"; - ntcp2.published = mkEnableOption "NTCP2 publication"; - ntcp2.port = mkOption { - type = types.port; - default = 0; - description = '' - Port to listen for incoming NTCP2 connections (0=auto). - ''; - }; - - ssu2 = { - enable = mkEnableTrueOption "SSU2"; - published = mkEnableOption "SSU2 publication"; - port = mkOption { - type = types.port; - default = 0; - description = '' - Port to listen for incoming SSU2 connections (0=auto). - ''; + # Overridden as CLI args + conf = null; + tunconf = null; + datadir = null; + # May not work as expected with DynamicUser=true + pidfile = null; + log = null; + logfile = null; + # May interfere with the systemd service + daemon = null; + service = null; + }; }; - }; - - limits.transittunnels = mkOption { - type = types.int; - default = 2500; - description = '' - Maximum number of active transit sessions. - ''; - }; - - limits.coreSize = mkOption { - type = types.int; - default = 0; - description = '' - Maximum size of corefile in Kb (0 - use system limit). - ''; - }; - - limits.openFiles = mkOption { - type = types.int; - default = 0; - description = '' - Maximum number of open files (0 - use system default). - ''; - }; - - limits.ntcpHard = mkOption { - type = types.int; - default = 0; - description = '' - Maximum number of active transit sessions. - ''; - }; - - limits.ntcpSoft = mkOption { - type = types.int; - default = 0; - description = '' - Threshold to start probabalistic backoff with ntcp sessions (default: use system limit). - ''; - }; - - limits.ntcpThreads = mkOption { - type = types.int; - default = 1; - description = '' - Maximum number of threads used by NTCP DH worker. - ''; - }; - - yggdrasil.enable = mkEnableOption "Yggdrasil"; - - yggdrasil.address = mkOption { - type = nullOr str; - default = null; - description = '' - Your local yggdrasil address. Specify it if you want to bind your router to a - particular address. - ''; - }; - - proto.http = (mkEndpointOpt "http" "127.0.0.1" 7070) // { - - auth = mkEnableOption "webconsole authentication"; - - user = mkOption { - type = types.str; - default = "i2pd"; - description = '' - Username for webconsole access - ''; - }; - - pass = mkOption { - type = types.str; - default = "i2pd"; - description = '' - Password for webconsole access. - ''; - }; - - strictHeaders = mkOption { - type = nullOr bool; - default = null; - description = '' - Enable strict host checking on WebUI. - ''; - }; - - hostname = mkOption { - type = nullOr str; - default = null; - description = '' - Expected hostname for WebUI. - ''; - }; - }; - - proto.httpProxy = (mkKeyedEndpointOpt "httpproxy" "127.0.0.1" 4444 "httpproxy-keys.dat") // { - outproxy = mkOption { - type = nullOr str; - default = null; - description = "Upstream outproxy bind address."; - }; - }; - proto.socksProxy = (mkKeyedEndpointOpt "socksproxy" "127.0.0.1" 4447 "socksproxy-keys.dat") // { - outproxyEnable = mkEnableOption "SOCKS outproxy"; - outproxy = mkOption { - type = types.str; - default = "127.0.0.1"; - description = "Upstream outproxy bind address."; - }; - outproxyPort = mkOption { - type = types.port; - default = 4444; - description = "Upstream outproxy bind port."; - }; - }; - - proto.sam = mkEndpointOpt "sam" "127.0.0.1" 7656; - proto.bob = mkEndpointOpt "bob" "127.0.0.1" 2827; - proto.i2cp = mkEndpointOpt "i2cp" "127.0.0.1" 7654; - proto.i2pControl = mkEndpointOpt "i2pcontrol" "127.0.0.1" 7650; - - outTunnels = mkOption { default = { }; - type = attrsOf ( - submodule ( - { name, ... }: - { - options = { - type = mkOption { - type = types.enum [ - "client" - "udpclient" - ]; - default = "client"; - description = "Tunnel type."; - }; - destination = mkOption { - type = types.str; - description = "Remote endpoint, I2P hostname or b32.i2p address."; - }; - destinationPort = mkOption { - type = with types; nullOr port; - default = null; - description = "Connect to particular port at destination."; - }; - } - // commonTunOpts name; - config = { - name = mkDefault name; + example = lib.literalExpression '' + { + meshnets.yggdrasil = true; # Enable yggdrasil network support + + port = { + ${credAttrType} = "/run/secrets/i2pd-port"; + ${credPlaceholderAttrType} = 0; # An optional placeholder value used when checking configuration + }; + } + ''; + }; + + # Server/generic tunnels + serverTunnels = lib.mkOption { + description = '' + Free-form "server" tunnels. Options are passed to `tunnels.conf`. + Mnemonic: we serving some service to others. + See + ''; + type = types.attrsOf ( + types.submodule { + inherit freeformType; + options = { + host = lib.mkOption { + type = types.either types.str credType; + description = "IP address of server (on this address i2pd will send data from I2P)"; }; - } + port = lib.mkOption { + type = types.port; + description = "Port of server tunnel (on this port i2pd will send data from I2P)"; + }; + inherit (templates) signaturetype i2cp i2p; + }; + } + ); + default = { }; + }; + + # Client tunnels + clientTunnels = lib.mkOption { + description = '' + Free-form "client" tunnels. Options are passed to `tunnels.conf`. + Mnemonic: we connect to someone as a client. + See + ''; + type = types.attrsOf ( + types.submodule { + inherit freeformType; + options = { + port = lib.mkOption { + type = types.port; + description = "Port of client tunnel (on this port i2pd will receive data)"; + }; + destination = lib.mkOption { + type = types.either types.str credType; + description = "Remote endpoint, I2P hostname or b32.i2p address"; + }; + inherit (templates) signaturetype i2cp i2p; + }; + } + ); + default = { }; + # Taken from i2pd's contrib/tunnels.conf + # LiteralExpression prevents unpacking of `i2p.streaming.profile` + example = lib.literalExpression '' + { + "irc-ilita" = { + address = "127.0.0.1"; + port = 6668; + destination = "irc.ilita.i2p"; + destinationport = 6667; + keys = "irc-keys.dat"; + i2p.streaming.profile = "interactive"; + }; + } + ''; + }; + + # TODO: Remove in NixOS 27.11 + mkSecret = lib.mkOption { + type = types.anything; + readOnly = true; + default = + path: + lib.warn "`mkSecret` function is deprecated. Replace it with `{ ${credAttrType} = ; }`" { + ${credAttrType} = path; + }; + description = "Deprecated. Use `{ ${credAttrType} = ; }` directly"; + }; + }; + + imports = + let + option = option: lib.splitString "." "services.i2pd.${option}"; + rename = from: to: lib.mkRenamedOptionModule (option from) (option to); + in + [ + (rename "inTunnels" "serverTunnels") + (rename "outTunnels" "clientTunnels") + ]; + + ###### Implementation ###### + + config = + let + cfg = config.services.i2pd; + + /* + Configuration generator. Similar to `pkgs.formats.ini`, but with few distinctions: + - Out-of-section options are allowed and printed on top of a file. + - Nested sub-values (`a.b.c = ...`) coerced to (`"a.b.c" = ...`). + */ + unwrapPrefixes = + attrset: + let + unwrap = ( + prefix: attrset: + lib.concatLists ( + lib.mapAttrsToList ( + k: v: + if lib.isAttrs v then + unwrap (prefix + k + ".") v + else + [ + { + name = prefix + k; + value = v; + } + ] + ) attrset + ) + ); + in + lib.listToAttrs (unwrap "" attrset); + + removeNulls = lib.filterAttrsRecursive (_: v: !isNull v); + + # I2pd-style ini allows lists, dented by comma separated values, and spaces between key and value + ini = pkgs.formats.iniWithGlobalSection { + listToValue = lib.concatMapStringsSep "," (lib.generators.mkValueStringDefault { }); + mkKeyValue = lib.generators.mkKeyValueDefault { } " = "; + }; + + genConfig = + name: attrs: + ini.generate name { + globalSection = removeNulls (lib.filterAttrs (_: v: !lib.isAttrs v) attrs); + sections = removeNulls ( + lib.mapAttrs (_: v: unwrapPrefixes v) (lib.filterAttrs (_: v: lib.isAttrs v) attrs) + ); + }; + + genTunnels = + name: attrs: + ini.generate name { + sections = (lib.mapAttrs (_: unwrapPrefixes) (removeNulls attrs)); + }; + + gen = attr: { + conf = genConfig "i2pd.conf" (credSubstituteRec attr cfg.settings); + tunconf = genTunnels "i2pd-tunnels.conf" ( + lib.mapAttrs' (k: v: lib.nameValuePair "client-${k}" (v // { "type" = "client"; })) ( + credSubstituteRec attr cfg.clientTunnels + ) + // lib.mapAttrs' (k: v: lib.nameValuePair "server-${k}" (v // { "type" = "server"; })) ( + credSubstituteRec attr cfg.serverTunnels ) ); - description = '' - Connect to someone as a client and establish a local accept endpoint - ''; }; - inTunnels = mkOption { - default = { }; - type = attrsOf ( - submodule ( - { name, ... }: - { - options = { - type = mkOption { - type = types.enum [ - "server" - "http" - "irc" - "udpserver" - ]; - default = "server"; - description = "Tunnel type."; - }; - inPort = mkOption { - type = types.port; - default = 0; - description = "Service port. Default to the tunnel's listen port."; - }; - accessList = mkOption { - type = listOf str; - default = [ ]; - description = "I2P nodes that are allowed to connect to this service."; - }; - } - // commonTunOpts name; - config = { - name = mkDefault name; - }; - } - ) - ); - description = '' - Serve something on I2P network at port and delegate requests to address inPort. - ''; + i2pdConfig = gen "id"; + i2pdCheckedConfig = gen "placeholder"; + + # List of all passed credentials: `[ { id = ...; path = ...; } ... ]` + credentials = credCollectRec [ + cfg.settings + cfg.clientTunnels + cfg.serverTunnels + ]; + + loadCredentialsScript = + pkgs.writeShellScript "i2pd-load-credentials" + # sh + '' + set -euo pipefail + + # If no credential declared, `CREDENTIALS_DIRECTORY` is unset + ids=(''${CREDENTIALS_DIRECTORY:+$(ls "$CREDENTIALS_DIRECTORY")}) + + # For every cli argument + for arg in "$@"; do + # Split argument at "=", assign first part to `out` and second part to `in` + arg=(''${arg//=/ }) + out="''${arg[0]}" + in="''${arg[1]}" + + # Copy file, set permissions + cp "$in" "$out" + chmod u=rw,g=,o= "$out" + + # Try substitute all known credentials + for id in "''${ids[@]}"; do + ${lib.getExe pkgs.replace-secret} "$id" "$CREDENTIALS_DIRECTORY/$id" "$out" + done + done + ''; + in + lib.mkIf cfg.enable { + system.checks = lib.optional (with pkgs.stdenv; buildPlatform.system == hostPlatform.system) ( + pkgs.runCommand "services.i2pd.check-i2pd.conf" { } + # sh + '' + set -euo pipefail + i2pd="${lib.getExe cfg.package}" + conf="${i2pdCheckedConfig.conf}" + tunconf="${i2pdCheckedConfig.tunconf}" + + # Disable connectivity, in case build sandbox is disabled + echo "ipv4 = false" >>conf + echo "ipv6 = false" >>conf + grep -Pv "^(ipv4|ipv6)[\s=]" "$conf" >>conf + opts="$("$i2pd" --help | grep -Eo "^ --\S*port(udp)? arg" | sed "s/ arg\$/=0/g")" + + echo Checking "$conf" + ok= + while read line; do + case "$line" in + *none*i2pd*starting...*) + [[ -z "$ok" ]] && ok=1 + kill -s INT $(cat pidfile) + ;; + *critical*) + ok=0 + ;; + esac + done < <( + "$i2pd" \ + --pidfile=pidfile --loglevel=critical --datadir=datadir \ + --conf="$conf" --tunconf="$tunconf" \ + $opts 2>&1 \ + | tee /dev/stderr + ) + [[ "$ok" != "1" ]] && exit 1 + + touch $out + '' + ); + + systemd.services.i2pd = { + description = "Minimal I2P router"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + unitConfig = { + RequiresMountsFor = map (cred: cred.path) credentials; + }; + + serviceConfig = { + User = "i2pd"; + Group = "i2pd"; + DynamicUser = true; + StateDirectory = [ "i2pd" ]; + + # Load credentials + LoadCredential = lib.forEach credentials (cred: "${cred.id}:${cred.path}"); + ExecStartPre = lib.escapeShellArgs [ + loadCredentialsScript + "%T/conf=${i2pdConfig.conf}" # "%T" is temporary directory, usually `/tmp` + "%T/tunconf=${i2pdConfig.tunconf}" + ]; + + ExecStart = lib.escapeShellArgs [ + "${lib.getExe cfg.package}" + "--datadir=%S/i2pd" # "%S" is systemd state directory, usually `/var/lib` + "--conf=%T/conf" + "--tunconf=%T/tunconf" + ]; + Restart = if cfg.autoRestart then "on-failure" else "no"; + KillSignal = if cfg.gracefulShutdown then "SIGINT" else "SIGTERM"; + TimeoutStopSec = if cfg.gracefulShutdown then "10m" else "30s"; + SendSIGKILL = true; + # Hardening + # Taken from https://gitlab.archlinux.org/archlinux/packaging/packages/i2pd/-/blob/8b18a2084e3955fa14a1853fc7fcaa58cc05e21a/030-i2pd-systemd-service-hardening.patch + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + NoNewPrivileges = true; + MemoryDenyWriteExecute = true; + LockPersonality = true; + SystemCallFilter = "@system-service"; + RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK"; + ProtectHostname = true; + ProtectClock = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + PrivateMounts = true; + PrivateUsers = true; + RemoveIPC = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + }; }; }; - }; - - ###### implementation - - config = mkIf cfg.enable { - - users.users.i2pd = { - group = "i2pd"; - description = "I2Pd User"; - home = homeDir; - createHome = true; - uid = config.ids.uids.i2pd; - }; - - users.groups.i2pd.gid = config.ids.gids.i2pd; - - systemd.services.i2pd = { - description = "Minimal I2P router"; - after = [ "network.target" ]; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - User = "i2pd"; - WorkingDirectory = homeDir; - Restart = "on-abort"; - ExecStart = "${cfg.package}/bin/i2pd ${i2pdFlags}"; - }; - }; - }; } From 42ddd277fbe4848bc145d539acbc0caa18c9b46c Mon Sep 17 00:00:00 2001 From: "Remy D. Farley" Date: Fri, 7 Aug 2026 19:42:03 +0000 Subject: [PATCH 2/4] nixos/i2pd: add N4CH723HR3R and one-d-wide as maintainers --- nixos/modules/services/networking/i2pd.nix | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/nixos/modules/services/networking/i2pd.nix b/nixos/modules/services/networking/i2pd.nix index 8f327c1952ad..611662fbfb96 100644 --- a/nixos/modules/services/networking/i2pd.nix +++ b/nixos/modules/services/networking/i2pd.nix @@ -548,4 +548,11 @@ in }; }; }; + + meta = { + maintainers = with lib.maintainers; [ + N4CH723HR3R + one-d-wide + ]; + }; } From ae8c3e1f75fd5c981c63eae7ecd1ea80198d7bc4 Mon Sep 17 00:00:00 2001 From: "Remy D. Farley" Date: Fri, 7 Aug 2026 19:42:03 +0000 Subject: [PATCH 3/4] nixos/i2pd: remove static uid and gid allocations --- nixos/modules/misc/ids.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nixos/modules/misc/ids.nix b/nixos/modules/misc/ids.nix index 30278aa4fc41..a80947a02063 100644 --- a/nixos/modules/misc/ids.nix +++ b/nixos/modules/misc/ids.nix @@ -194,7 +194,7 @@ in redmine = 147; #seeks = 148; # removed 2020-06-21 prosody = 149; - i2pd = 150; + # i2pd = 150; # dynamically allocated as of 2026-08-07 systemd-coredump = 151; systemd-network = 152; systemd-resolve = 153; @@ -534,7 +534,7 @@ in redmine = 147; #seeks = 148; # removed 2020-06-21 prosody = 149; - i2pd = 150; + # i2pd = 150; # dynamically allocated as of 2026-08-07 systemd-network = 152; systemd-resolve = 153; systemd-timesync = 154; From 88ad1c6b1634ab58495bbae672411d0ac6da6259 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Sat, 8 Aug 2026 08:45:46 +0900 Subject: [PATCH 4/4] nixos/tests/i2pd: init --- nixos/tests/all-tests.nix | 1 + nixos/tests/i2pd.nix | 210 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 nixos/tests/i2pd.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index f9a730ac833c..3abb1628bd09 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -809,6 +809,7 @@ in hub = runTest ./git/hub.nix; hydra = runTest ./hydra; i18n = runTest ./i18n.nix; + i2pd = runTest ./i2pd.nix; i3wm = runTest ./i3wm.nix; icecast = runTest ./icecast.nix; icingaweb2 = runTest ./icingaweb2.nix; diff --git a/nixos/tests/i2pd.nix b/nixos/tests/i2pd.nix new file mode 100644 index 000000000000..663290df05fe --- /dev/null +++ b/nixos/tests/i2pd.nix @@ -0,0 +1,210 @@ +{ lib, ... }: +{ + name = "i2pd"; + meta.maintainers = with lib.maintainers; [ h7x4 ]; + + nodes = { + server = + { config, pkgs, ... }: + { + virtualisation.vlans = [ 1 ]; + networking = { + useDHCP = false; + interfaces.eth1.useDHCP = false; + firewall.allowedTCPPorts = [ 12345 ]; + firewall.allowedUDPPorts = [ 12345 ]; + }; + + systemd.services."test-web-server" = { + wantedBy = [ "multi-user.target" ]; + before = [ "i2pd.service" ]; + serviceConfig = { + ExecStart = '' + ${lib.getExe' pkgs.python3 "python3"} \ + -m http.server 8080 \ + --bind 127.0.0.1 \ + --directory ${pkgs.writeTextDir "index.html" "hello world"} + ''; + DynamicUser = true; + }; + }; + + services.i2pd = { + enable = true; + settings = { + # Needed to retrieve b32 address + loglevel = "info"; + # Avoid real I2P network ID (2), just in case + # there are any hidden assumptions tied to it. + netid = 77; + host = config.networking.primaryIPAddress; + port = 12345; + # Allow use of local addresses + reservedrange = false; + # No reseed infra available, and we seed netDb manually anyway + reseed.urls = ""; + reseed.yggurls = ""; + + # "router" is the only other node reachable, so every tunnel + # is a single hop through it. + shareddest.inbound.length = 1; + shareddest.outbound.length = 1; + exploratory.inbound.length = 1; + exploratory.outbound.length = 1; + }; + + serverTunnels.testserver = { + host = "127.0.0.1"; + port = 8080; + keys = "testserver-keys.dat"; + inbound.length = 1; + outbound.length = 1; + }; + }; + }; + + router = + { config, ... }: + { + virtualisation.vlans = [ 1 ]; + networking = { + useDHCP = false; + interfaces.eth1.useDHCP = false; + firewall.allowedTCPPorts = [ 12345 ]; + firewall.allowedUDPPorts = [ 12345 ]; + }; + + services.i2pd = { + enable = true; + settings = { + loglevel = "info"; + netid = 77; + host = config.networking.primaryIPAddress; + port = 12345; + reservedrange = false; + reseed.urls = ""; + reseed.yggurls = ""; + floodfill = true; + + # "router" has no peer to hop through for its own pools. + shareddest.inbound.length = 0; + shareddest.outbound.length = 0; + exploratory.inbound.length = 0; + exploratory.outbound.length = 0; + }; + }; + }; + + client = + { config, ... }: + { + virtualisation.vlans = [ 1 ]; + networking = { + useDHCP = false; + interfaces.eth1.useDHCP = false; + }; + + # i2pd asserts this exists before it starts, the test script + # overwrites it with the server's actual address once known. + systemd.tmpfiles.rules = [ + "f /run/i2pd-secrets/server-destination 0400 root root - unknown.b32.i2p" + ]; + + services.i2pd = { + enable = true; + settings = { + loglevel = "info"; + netid = 77; + host = config.networking.primaryIPAddress; + reservedrange = false; + reseed.urls = ""; + reseed.yggurls = ""; + + httpproxy.inbound.length = 1; + httpproxy.outbound.length = 1; + shareddest.inbound.length = 1; + shareddest.outbound.length = 1; + exploratory.inbound.length = 1; + exploratory.outbound.length = 1; + }; + + clientTunnels.toServer = { + port = 10800; + destination._secret = "/run/i2pd-secrets/server-destination"; + inbound.length = 1; + outbound.length = 1; + }; + }; + }; + }; + + testScript = + # python + '' + import re + + start_all() + server.wait_for_unit("i2pd.service") + client.wait_for_unit("i2pd.service") + router.wait_for_unit("i2pd.service") + server.wait_for_file("/var/lib/i2pd/router.info") + client.wait_for_file("/var/lib/i2pd/router.info") + router.wait_for_file("/var/lib/i2pd/router.info") + + with subtest("Exchange router info"): + server.wait_until_succeeds( + "journalctl -u i2pd -o cat --grep 'Local address \\S+ created'" + ) + server_log_line = server.succeed( + "journalctl -u i2pd -o cat --grep 'Local address \\S+ created' --reverse -n 1" + ) + server_b32_match = re.search(r"Local address (\S+) created", server_log_line) + assert server_b32_match is not None + server_b32 = server_b32_match.group(1) + client.succeed( + "mkdir -p /run/i2pd-secrets", + f"printf '%s' '{server_b32}.b32.i2p' > /run/i2pd-secrets/server-destination", + ) + + # We don't have any reseed infra available, so we manually seed each + # of server/client with "router"'s identity, and vice versa. + router.copy_from_machine("/var/lib/i2pd/router.info", "router-identity") + server.copy_from_machine("/var/lib/i2pd/router.info", "server-identity") + client.copy_from_machine("/var/lib/i2pd/router.info", "client-identity") + + router_identity = str(router.out_dir / "router-identity" / "router.info") + server.copy_from_host(router_identity, "/var/lib/i2pd/netDb/r0/router.dat") + client.copy_from_host(router_identity, "/var/lib/i2pd/netDb/r0/router.dat") + + router.copy_from_host( + str(server.out_dir / "server-identity" / "router.info"), + "/var/lib/i2pd/netDb/r0/server.dat", + ) + router.copy_from_host( + str(client.out_dir / "client-identity" / "router.info"), + "/var/lib/i2pd/netDb/r0/client.dat", + ) + + client.systemctl("restart i2pd.service") + server.systemctl("restart i2pd.service") + router.systemctl("restart i2pd.service") + client.wait_for_unit("i2pd.service") + server.wait_for_unit("i2pd.service") + router.wait_for_unit("i2pd.service") + client.wait_for_open_port(4444) + + server.wait_for_unit("test-web-server.service") + + with subtest("Request content using the HTTP proxy"): + # This is intended to keep on failing until the client eventually receives + # a LeaseSet from the server. + client.wait_until_succeeds( + f"curl --fail -x 127.0.0.1:4444 http://{server_b32}.b32.i2p/ | grep -q 'hello world'", + ) + + with subtest("Request content using a port-forwarding tunnel"): + client.wait_until_succeeds( + "curl --fail http://127.0.0.1:10800/ | grep -q 'hello world'", + ) + ''; +}