Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nixos/rathole: init module #330522

Merged
merged 1 commit into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions nixos/doc/manual/release-notes/rl-2411.section.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions nixos/modules/module-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
165 changes: 165 additions & 0 deletions nixos/modules/services/networking/rathole.nix
Original file line number Diff line number Diff line change
@@ -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 ];
}
1 change: 1 addition & 0 deletions nixos/tests/all-tests.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
Expand Down
89 changes: 89 additions & 0 deletions nixos/tests/rathole.nix
Original file line number Diff line number Diff line change
@@ -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"
'';
}
)