summaryrefslogtreecommitdiffstats
path: root/nixos/tests/tor.nix
blob: 401df0cf89217184d4b13026bb57be6f143cf36f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
{
  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.
    # Runs as root (`+` prefix) so it can chown to tor:tor.
    systemd.services.tor.serviceConfig.ExecStartPre = lib.mkBefore [
      "+${pkgs.writeShellScript "tor-keys-${name}" ''
        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 '✓'"))
  '';
}