mirror of
https://github.com/NixOS/nixpkgs.git
synced 2026-06-05 21:03:40 +00:00
503 lines
16 KiB
Nix
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 '✓'"))
|
|
'';
|
|
}
|