diff --git a/nixos/doc/manual/release-notes/rl-2411.section.md b/nixos/doc/manual/release-notes/rl-2411.section.md index 7fb67789265f..de090d6d4c7a 100644 --- a/nixos/doc/manual/release-notes/rl-2411.section.md +++ b/nixos/doc/manual/release-notes/rl-2411.section.md @@ -68,6 +68,8 @@ - [OpenGFW](https://github.com/apernet/OpenGFW), an implementation of the Great Firewall on Linux. Available as [services.opengfw](#opt-services.opengfw.enable). +- [Rathole](https://github.com/rapiz1/rathole), a lightweight and high-performance reverse proxy for NAT traversal. Available as [services.rathole](#opt-services.rathole.enable). + ## Backward Incompatibilities {#sec-release-24.11-incompatibilities} - `transmission` package has been aliased with a `trace` warning to `transmission_3`. Since [Transmission 4 has been released last year](https://github.com/transmission/transmission/releases/tag/4.0.0), and Transmission 3 will eventually go away, it was decided perform this warning alias to make people aware of the new version. The `services.transmission.package` defaults to `transmission_3` as well because the upgrade can cause data loss in certain specific usage patterns (examples: [#5153](https://github.com/transmission/transmission/issues/5153), [#6796](https://github.com/transmission/transmission/issues/6796)). Please make sure to back up to your data directory per your usage: diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index f97a6b47512c..2da17105a848 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1160,6 +1160,7 @@ ./services/networking/r53-ddns.nix ./services/networking/radicale.nix ./services/networking/radvd.nix + ./services/networking/rathole.nix ./services/networking/rdnssd.nix ./services/networking/realm.nix ./services/networking/redsocks.nix diff --git a/nixos/modules/services/networking/rathole.nix b/nixos/modules/services/networking/rathole.nix new file mode 100644 index 000000000000..b6cd3ff89d9c --- /dev/null +++ b/nixos/modules/services/networking/rathole.nix @@ -0,0 +1,165 @@ +{ + pkgs, + lib, + config, + ... +}: + +let + cfg = config.services.rathole; + settingsFormat = pkgs.formats.toml { }; + py-toml-merge = + pkgs.writers.writePython3Bin "py-toml-merge" + { + libraries = with pkgs.python3Packages; [ + tomli-w + mergedeep + ]; + } + '' + import argparse + from pathlib import Path + from typing import Any + + import tomli_w + import tomllib + from mergedeep import merge + + parser = argparse.ArgumentParser(description="Merge multiple TOML files") + parser.add_argument( + "files", + type=Path, + nargs="+", + help="List of TOML files to merge", + ) + + args = parser.parse_args() + merged: dict[str, Any] = {} + + for file in args.files: + with open(file, "rb") as fh: + loaded_toml = tomllib.load(fh) + merged = merge(merged, loaded_toml) + + print(tomli_w.dumps(merged)) + ''; +in + +{ + options = { + services.rathole = { + enable = lib.mkEnableOption "Rathole"; + + package = lib.mkPackageOption pkgs "rathole" { }; + + role = lib.mkOption { + type = lib.types.enum [ + "server" + "client" + ]; + description = '' + Select whether rathole needs to be run as a `client` or a `server`. + Server is a machine with a public IP and client is a device behind NAT, + but running some services that need to be exposed to the Internet. + ''; + }; + + credentialsFile = lib.mkOption { + type = lib.types.path; + default = "/dev/null"; + description = '' + Path to a TOML file to be merged with the settings. + Useful to set secret config parameters like tokens, which + should not appear in the Nix Store. + ''; + example = "/var/lib/secrets/rathole/config.toml"; + }; + + settings = lib.mkOption { + type = settingsFormat.type; + default = { }; + description = '' + Rathole configuration, for options reference + see the [example](https://github.com/rapiz1/rathole?tab=readme-ov-file#configuration) on GitHub. + Both server and client configurations can be specified at the same time, regardless of the selected role. + ''; + example = { + server = { + bind_addr = "0.0.0.0:2333"; + services.my_nas_ssh = { + token = "use_a_secret_that_only_you_know"; + bind_addr = "0.0.0.0:5202"; + }; + }; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.rathole = { + requires = [ "network.target" ]; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + description = "Rathole ${cfg.role} Service"; + + serviceConfig = + let + name = "rathole"; + configFile = settingsFormat.generate "${name}.toml" cfg.settings; + runtimeDir = "/run/${name}"; + ratholePrestart = + "+" + + (pkgs.writeShellScript "rathole-prestart" '' + DYNUSER_UID=$(stat -c %u ${runtimeDir}) + DYNUSER_GID=$(stat -c %g ${runtimeDir}) + ${lib.getExe py-toml-merge} ${configFile} '${cfg.credentialsFile}' | + install -m 600 -o $DYNUSER_UID -g $DYNUSER_GID /dev/stdin ${runtimeDir}/${mergedConfigName} + ''); + mergedConfigName = "merged.toml"; + in + { + Type = "simple"; + Restart = "on-failure"; + RestartSec = 5; + ExecStartPre = ratholePrestart; + ExecStart = "${lib.getExe cfg.package} --${cfg.role} ${runtimeDir}/${mergedConfigName}"; + DynamicUser = true; + LimitNOFILE = "1048576"; + RuntimeDirectory = name; + RuntimeDirectoryMode = "0700"; + # Hardening + AmbientCapabilities = "CAP_NET_BIND_SERVICE"; + CapabilityBoundingSet = "CAP_NET_BIND_SERVICE"; + LockPersonality = true; + MemoryDenyWriteExecute = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateTmp = true; + # PrivateUsers=true breaks AmbientCapabilities=CAP_NET_BIND_SERVICE + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + UMask = "0066"; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ xokdvium ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index f485b6a77844..c7ab5cead460 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -823,6 +823,7 @@ in { radicle = runTest ./radicle.nix; ragnarwm = handleTest ./ragnarwm.nix {}; rasdaemon = handleTest ./rasdaemon.nix {}; + rathole = handleTest ./rathole.nix {}; readarr = handleTest ./readarr.nix {}; realm = handleTest ./realm.nix {}; redis = handleTest ./redis.nix {}; diff --git a/nixos/tests/rathole.nix b/nixos/tests/rathole.nix new file mode 100644 index 000000000000..56d7a0129f80 --- /dev/null +++ b/nixos/tests/rathole.nix @@ -0,0 +1,89 @@ +import ./make-test-python.nix ( + { pkgs, lib, ... }: + + let + successMessage = "Success 3333115147933743662"; + in + { + name = "rathole"; + meta.maintainers = with lib.maintainers; [ xokdvium ]; + nodes = { + server = { + networking = { + useNetworkd = true; + useDHCP = false; + firewall.enable = false; + }; + + systemd.network.networks."01-eth1" = { + name = "eth1"; + networkConfig.Address = "10.0.0.1/24"; + }; + + services.rathole = { + enable = true; + role = "server"; + settings = { + server = { + bind_addr = "0.0.0.0:2333"; + services = { + success-message = { + bind_addr = "0.0.0.0:80"; + token = "hunter2"; + }; + }; + }; + }; + }; + }; + + client = { + networking = { + useNetworkd = true; + useDHCP = false; + }; + + systemd.network.networks."01-eth1" = { + name = "eth1"; + networkConfig.Address = "10.0.0.2/24"; + }; + + services.nginx = { + enable = true; + virtualHosts."127.0.0.1" = { + root = pkgs.writeTextDir "success-message.txt" successMessage; + }; + }; + + services.rathole = { + enable = true; + role = "client"; + credentialsFile = pkgs.writeText "rathole-credentials.toml" '' + [client.services.success-message] + token = "hunter2" + ''; + settings = { + client = { + remote_addr = "10.0.0.1:2333"; + services.success-message = { + local_addr = "127.0.0.1:80"; + }; + }; + }; + }; + }; + }; + + testScript = '' + start_all() + server.wait_for_unit("rathole.service") + server.wait_for_open_port(2333) + client.wait_for_unit("rathole.service") + server.wait_for_open_port(80) + response = server.succeed("curl http://127.0.0.1/success-message.txt") + assert "${successMessage}" in response, "Got invalid response" + response = client.succeed("curl http://10.0.0.1/success-message.txt") + assert "${successMessage}" in response, "Got invalid response" + ''; + } +)