Files
nixpkgs/nixos/tests/tor.nix
2026-05-13 23:49:32 +01:00

503 lines
16 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
# Helper to get a node's auto-assigned primary IPv4 address.
nodeIP = name: config.nodes.${name}.networking.primaryIPAddress;
nodeIPv6 = name: config.nodes.${name}.networking.primaryIPv6Address;
# Generate all keys (both relay identity and authority) for a
# directory authority in a single derivation.
#
# tor --list-fingerprint generates the relay RSA/ed25519 identity
# keys and writes the fingerprint file. tor-gencert then generates
# the authority identity key, signing key, and certificate.
mkDAKeys =
name:
pkgs.runCommand "tor-da-keys-${name}"
{
nativeBuildInputs = [ pkgs.tor ];
}
''
DATADIR=$(mktemp -d)
mkdir -p "$DATADIR/keys"
# Generate relay identity keys.
tor --list-fingerprint \
--DataDirectory "$DATADIR" \
--ORPort 9001 \
--Nickname "${name}" \
--SocksPort 0 \
>/dev/null 2>&1
# Generate authority keys in the keys directory
(
cd "$DATADIR/keys"
echo "" | tor-gencert --create-identity-key -m 24 \
-a ${nodeIP name}:80 \
--passphrase-fd 0 \
>/dev/null 2>&1
)
# Prepare output: keys in a subdirectory, fingerprints as plain files
mkdir -p $out/keys
cp "$DATADIR/keys/"* $out/keys/
# Extract relay fingerprint
# fingerprint file format: "Nickname XXXX XXXX XXXX XXXX ..."
cut -d' ' -f2- "$DATADIR/fingerprint" | tr -d ' \n' > $out/relay-fingerprint
# Extract v3ident from authority certificate
# certificate format: "fingerprint ABCDEF1234..."
grep "^fingerprint " $out/keys/authority_certificate \
| awk '{print $2}' | tr -d '\n' > $out/v3ident
# Extract ed25519 identity
tail -c 32 "$DATADIR/keys/ed25519_master_id_public_key" \
| base64 -w0 | tr -d '=' > $out/ed25519-identity
'';
# Node name lists - used to generate keys, node configs, and DA vote targets
daNames = [
"da1"
"da2"
"da3"
];
relayNames = [
"relay1"
"relay2"
"relay3"
"relay4"
"relay5"
];
exitNames = [
"exit1"
"exit2"
"exit3"
];
# Relays that receive the Guard flag. DAs are excluded here (they only
# do directory serving and voting) and exits are excluded so Guard
# and Exit remain distinct roles.
guardNames = relayNames;
# Pre-generate keys for all directory authorities
daKeysets = lib.genAttrs daNames mkDAKeys;
# Script that writes /run/tor/dirauth.conf by reading the DA fingerprints
# from the pre-generated key derivations at runtime. The ${daKeysets.<name>}
# interpolations resolve to store-path strings at evaluation time (the
# derivations become build dependencies of the node closure), but their
# contents are only read when the script runs on the VM, so no
# import-from-derivation is required.
#
# Written into /run/tor (tor's RuntimeDirectory, which systemd bind-mounts
# into the service's chroot at RootDirectory=/run/tor/root) so that tor
# can read the file despite ProtectSystem=strict.
torDirAuthExecStartPre = pkgs.writeShellScript "tor-dirauth-conf" ''
set -eu
{
${lib.concatMapStringsSep "\n" (name: ''
printf 'DirAuthority %s orport=9001 ipv6=[%s]:9001 v3ident=%s %s:80 %s\n' \
${name} \
'${nodeIPv6 name}' \
"$(tr -d '\n' < ${daKeysets.${name}}/v3ident)" \
'${nodeIP name}' \
"$(tr -d '\n' < ${daKeysets.${name}}/relay-fingerprint)"
'') daNames}
} > /run/tor/dirauth.conf
chown tor:tor /run/tor/dirauth.conf
chmod 0400 /run/tor/dirauth.conf
'';
# Tor settings shared by all node types. DirAuthority lines are loaded
# from /run/tor/dirauth.conf, written by torDirAuthExecStartPre before
# tor starts.
commonTorSettings = {
TestingTorNetwork = true;
AssumeReachable = true;
AssumeReachableIPv6 = true;
ControlPort = 9051;
CookieAuthentication = true;
"%include" = "/run/tor/dirauth.conf";
};
# Tor settings shared by non-DA nodes (relays and exits)
nonDATorSettings =
name:
commonTorSettings
// {
Address = nodeIP name;
Nickname = name;
ContactInfo = "${name} <${name} AT localhost>";
DirPort = 9030;
ORPort = [
9001
{
addr = "[${nodeIPv6 name}]";
port = 9001;
}
];
SocksPort = 0;
PublishServerDescriptor = "1";
PathsNeededToBuildCircuits = "0.25";
};
# Build a directory authority node configuration
mkDANode = name: {
networking.firewall.allowedTCPPorts = [
80
9001
];
systemd.services.tor = {
after = [ "network-online.target" ];
requires = [ "network-online.target" ];
};
# Deploy pre-generated relay and authority keys before Tor starts.
# This ensures the relay fingerprint matches what's in DirAuthority lines.
system.activationScripts.tor-keys = lib.stringAfter [ "users" "groups" ] ''
mkdir -p /var/lib/tor/keys
cp ${daKeysets.${name}}/keys/* /var/lib/tor/keys/
touch /var/lib/tor/sr-state
chown -R tor:tor /var/lib/tor
chmod 700 /var/lib/tor /var/lib/tor/keys
chmod 600 /var/lib/tor/keys/*
'';
services.tor = {
enable = true;
relay.enable = true;
relay.role = "relay";
settings = commonTorSettings // {
AuthoritativeDirectory = true;
V3AuthoritativeDirectory = true;
Address = nodeIP name;
Nickname = name;
ContactInfo = "${name} <${name} AT localhost>";
DirPort = 80;
ORPort = [
9001
{
addr = "[${nodeIPv6 name}]";
port = 9001;
}
];
SocksPort = 0;
# Only assign circuit-selection flags to non-DA relays (makes DAs only
# do directory serving).
TestingDirAuthVoteExit = lib.concatStringsSep "," exitNames;
TestingDirAuthVoteGuard = lib.concatStringsSep "," guardNames;
TestingDirAuthVoteHSDir = lib.concatStringsSep "," (relayNames ++ exitNames);
TestingMinExitFlagThreshold = 0;
V3AuthNIntervalsValid = 2;
};
};
};
# Build a relay node configuration
mkRelayNode = name: {
networking.firewall.allowedTCPPorts = [
9001
9030
];
services.tor = {
enable = true;
relay.enable = true;
relay.role = "relay";
settings = nonDATorSettings name;
};
};
# Build an exit node configuration.
mkExitNode = name: {
networking.firewall.allowedTCPPorts = [
9001
9030
];
services.tor = {
enable = true;
relay.enable = true;
relay.role = "exit";
settings = nonDATorSettings name // {
# relay.role = "exit" prevents the NixOS module from force-setting
# ExitPolicy to "reject *:*", but the option's default is still "reject
# *:*". We must explicitly set a permissive ExitPolicy for the exit to
# be usable.
ExitRelay = true;
ExitPolicy = [ "accept *:*" ];
};
};
};
hiddenServiceResponse = "Hello from the hidden service";
# Hidden service node: Caddy serves a static page, Tor exposes it as an onion service
mkHiddenServiceNode = {
services.caddy = {
enable = true;
virtualHosts."http://:8080" = {
extraConfig = ''
respond "${hiddenServiceResponse}"
'';
};
};
services.tor = {
enable = true;
relay.onionServices.web = {
map = [
{
port = 80;
target = {
addr = "127.0.0.1";
port = 8080;
};
}
];
};
settings = commonTorSettings // {
SocksPort = 0;
};
};
};
# Client node: uses Tor SOCKS proxy to access onion services
mkClientNode = {
environment.systemPackages = [ pkgs.curl ];
services.tor = {
enable = true;
client.enable = true;
settings = commonTorSettings;
};
};
clearnetResponse = "Hello from the clearnet";
# Clearnet webserver to test exit node traffic
mkWebServerNode = {
networking.firewall.allowedTCPPorts = [ 80 ];
services.caddy = {
enable = true;
virtualHosts."http://:80" = {
extraConfig = ''
respond "${clearnetResponse}"
'';
};
};
};
# Arti configuration - static parts only. The tor_network section
# embeds fingerprints from the DA key derivations, so it is generated
# at service startup instead (see artiNetworkConfigScript) to avoid IFD.
artiStaticConfig = (pkgs.formats.toml { }).generate "arti.toml" {
proxy.socks_listen = 9150;
storage = {
cache_dir = "/var/cache/arti";
state_dir = "/var/lib/arti";
port_info_file = "/var/lib/arti/public/port_info.json";
permissions.dangerously_trust_everyone = true;
};
address_filter.allow_local_addrs = true;
# Disable subnet restrictions since all nodes are on the same network
path_rules = {
ipv4_subnet_family_prefix = 33;
ipv6_subnet_family_prefix = 129;
};
# Disable vanguards - the small test network doesn't have enough relay
# diversity for arti to satisfy vanguard selection requirements
vanguards.mode = "disabled";
# Override Tor consensus parameters for the small test network.
# Arti's guard sampling defaults are configured for the real Tor
# network.
override_net_params = {
guard-max-sample-size = 4;
guard-min-filtered-sample-size = 2;
guard-n-primary-guards-to-use = 2;
};
};
# Emit the tor_network section of the arti config at service startup,
# reading fingerprints from the DA key derivations at runtime. The
# ${daKeysets.<name>} interpolations resolve to store paths at
# evaluation time (build dependencies), but their contents are only
# read by the shell when the script runs - no import-from-derivation.
artiNetworkConfigScript = pkgs.writeShellScript "arti-network-config" ''
set -eu
out=/run/arti/network.toml
daIpPorts='[["${nodeIP "da1"}:80", "[${nodeIPv6 "da1"}]:80"], ["${nodeIP "da2"}:80", "[${nodeIPv6 "da2"}]:80"], ["${nodeIP "da3"}:80", "[${nodeIPv6 "da3"}]:80"]]'
{
echo '[tor_network.authorities]'
printf 'v3idents = ["%s", "%s", "%s"]\n' \
"$(tr -d '\n' < ${daKeysets.da1}/v3ident)" \
"$(tr -d '\n' < ${daKeysets.da2}/v3ident)" \
"$(tr -d '\n' < ${daKeysets.da3}/v3ident)"
echo "uploads = $daIpPorts"
echo "downloads = $daIpPorts"
echo "votes = $daIpPorts"
${lib.concatMapStringsSep "\n" (name: ''
echo
echo '[[tor_network.fallback_caches]]'
printf 'rsa_identity = "%s"\n' "$(tr -d '\n' < ${daKeysets.${name}}/relay-fingerprint)"
printf 'ed_identity = "%s"\n' "$(tr -d '\n' < ${daKeysets.${name}}/ed25519-identity)"
echo 'orports = ["${nodeIP name}:9001", "[${nodeIPv6 name}]:9001"]'
'') daNames}
} > "$out"
'';
# Arti client node
mkArtiClientNode = {
environment.systemPackages = [ pkgs.curl ];
systemd.services.arti = {
description = "Arti Tor Client";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
RuntimeDirectory = "arti";
ExecStartPre = artiNetworkConfigScript;
ExecStart = "${lib.getExe pkgs.arti} proxy -c ${artiStaticConfig} -c /run/arti/network.toml";
DynamicUser = true;
StateDirectory = "arti";
CacheDirectory = "arti";
};
};
};
in
{
name = "tor";
meta.maintainers = with lib.maintainers; [ jpds ];
defaults =
{ config, ... }:
lib.mkIf config.services.tor.enable {
# Generate /run/tor/dirauth.conf before tor's own `--verify-config`
# ExecStartPre runs. The `+` prefix makes this run as root in the
# full system context so it can write into tor's RuntimeDirectory
# and chown the file to tor:tor.
systemd.services.tor.serviceConfig.ExecStartPre = lib.mkBefore [
"+${torDirAuthExecStartPre}"
];
};
nodes =
lib.genAttrs daNames mkDANode
// lib.genAttrs relayNames mkRelayNode
// lib.genAttrs exitNames mkExitNode
// {
hiddenservice = mkHiddenServiceNode;
webserver = mkWebServerNode;
client = mkClientNode;
articlient = mkArtiClientNode;
};
testScript = ''
# Start directory authorities and wait for consensus
for machine in da1, da2, da3:
machine.start()
machine.wait_for_unit("tor.service")
machine.wait_for_open_port(9051)
for machine in da1, da2, da3:
machine.wait_until_succeeds(
"journalctl -o cat -u tor.service | grep 'Scheduling voting'"
)
machine.wait_until_succeeds(
"journalctl -o cat -u tor.service | grep 'Consensus computed; uploading signature(s)'"
)
# Start relays and exits
for machine in relay1, relay2, relay3, relay4, relay5, exit1, exit2, exit3:
machine.start()
machine.wait_for_unit("tor.service")
machine.wait_for_open_port(9051)
# Wait for all DAs to fully bootstrap
for machine in da1, da2, da3:
machine.wait_until_succeeds(
"journalctl -o cat -u tor.service | grep 'Bootstrapped 100%'"
)
# Wait for relays and exits to self-test and bootstrap
for machine in relay1, relay2, relay3, relay4, relay5, exit1, exit2, exit3:
machine.wait_until_succeeds(
"journalctl -o cat -u tor.service | grep 'Self-testing indicates your ORPort .* is reachable'"
)
machine.wait_until_succeeds(
"journalctl -o cat -u tor.service | grep 'Bootstrapped 100%'"
)
# Verify the Tor control port is functional
assert "514 Authentication required." in da1.succeed(
"echo GETINFO version | nc 127.0.0.1 9051"
)
# Start hidden service and clearnet webserver - then web client
hiddenservice.start()
hiddenservice.wait_for_unit("caddy.service")
hiddenservice.wait_for_unit("tor.service")
webserver.start()
webserver.wait_for_unit("caddy.service")
webserver.wait_for_open_port(80)
client.start()
client.wait_for_unit("tor.service")
# Wait for the hidden service to generate its .onion hostname
hiddenservice.wait_until_succeeds(
"test -f /var/lib/tor/onion/web/hostname"
)
onion_addr = hiddenservice.succeed("cat /var/lib/tor/onion/web/hostname").strip()
# Wait for the client to bootstrap
client.wait_until_succeeds(
"journalctl -o cat -u tor.service | grep 'Bootstrapped 100%'"
)
# Access the hidden service from the client via Tor SOCKS proxy
client.wait_until_succeeds(
f"curl --max-time 60 --socks5-hostname 127.0.0.1:9050 http://{onion_addr} | grep '${hiddenServiceResponse}'"
)
# Access the clearnet webserver through the Tor exit node
webserver_ip = "${nodeIP "webserver"}"
client.wait_until_succeeds(
f"curl --max-time 60 --socks5-hostname 127.0.0.1:9050 http://{webserver_ip} | grep '${clearnetResponse}'"
)
articlient.start()
articlient.wait_for_unit("arti.service")
articlient.wait_for_open_port(9150)
# Access the hidden service from the client via arti
# Onion service access is not tested with arti. The HS client
# doesn't work reliably on small private networks.
# articlient.wait_until_succeeds(
# f"curl --max-time 60 --socks5-hostname 127.0.0.1:9150 http://{onion_addr} | grep '${hiddenServiceResponse}'"
# )
# Access the clearnet webserver through the Tor exit node with arti
articlient.wait_until_succeeds(
f"curl --max-time 60 --socks5-hostname 127.0.0.1:9150 http://{webserver_ip} | grep '${clearnetResponse}'"
)
da1.log(da1.succeed("systemd-analyze security tor.service | grep -v ''"))
'';
}