Skip to content

Commit ab5a1d9

Browse files
author
j4kuuu
committed
engine: run tor as the tor user on Arch with reliable start/stop
1 parent 4e8b512 commit ab5a1d9

4 files changed

Lines changed: 194 additions & 130 deletions

File tree

.configs/arch-torrc

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1+
DataDirectory /var/lib/tor
2+
PidFile /run/tor/tor.pid
13
RunAsDaemon 1
4+
User tor
25

6+
ControlSocket /run/tor/control
7+
ControlSocketsGroupWritable 1
8+
9+
CookieAuthentication 1
10+
CookieAuthFileGroupReadable 1
11+
CookieAuthFile /run/tor/control.authcookie
12+
13+
Log notice file /var/log/tor/log
14+
15+
ClientOnly 1
316
SOCKSPort 0
4-
TransPort 9051
5-
DNSPort 9061
6-
DNSListenAddress 127.0.0.1
17+
TransPort 9051 IsolateClientAddr IsolateClientProtocol IsolateDestAddr IsolateDestPort
18+
DNSPort 9061
719

820
VirtualAddrNetwork 10.66.0.0/255.255.0.0
921
VirtualAddrNetworkIPv6 fd00::/8

lib/Nipe/Component/Engine/Start.pm

Lines changed: 90 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,108 @@
11
package Nipe::Component::Engine::Start {
22
use strict;
33
use warnings;
4+
use FindBin;
45
use Nipe::Component::Utils::Device;
56
use Nipe::Component::Utils::Status;
6-
use Nipe::Component::Engine::Stop;
7-
8-
our $VERSION = '0.0.7';
7+
use Nipe::Component::Engine::Stop;
8+
9+
our $VERSION = '0.0.8';
10+
11+
# Pure rule builder: returns the ordered list of iptables/ip6tables commands
12+
# that torify the OUTPUT chain. No side effects, so the firewall policy can be
13+
# asserted in unit tests. The owner RETURN/ACCEPT rule for the tor user is
14+
# emitted before any REDIRECT, which is what lets tor's own traffic reach the
15+
# network instead of being redirected back into itself.
16+
sub build_rules {
17+
my %opts = @_;
18+
19+
my $user = $opts{username};
20+
my $dns_port = $opts{dns_port} // '9061';
21+
my $trans_port = $opts{trans_port} // '9051';
22+
my $network = $opts{network} // '10.66.0.0/255.255.0.0';
23+
my $ipv6 = $opts{ipv6} // 0;
24+
25+
my @local_nets = qw(127.0.0.1/8 192.168.0.0/16 172.16.0.0/12 10.0.0.0/8);
26+
my @rules;
27+
28+
foreach my $table (qw(nat filter)) {
29+
my $pass = $table eq 'nat' ? 'RETURN' : 'ACCEPT';
30+
my $dns_match = $table eq 'nat' ? '53' : $dns_port;
31+
my $dns_target = $table eq 'nat' ? "REDIRECT --to-ports $dns_port" : 'ACCEPT';
32+
my $trans_target = $table eq 'nat' ? "REDIRECT --to-ports $trans_port" : 'ACCEPT';
33+
34+
push @rules, "iptables -t $table -F OUTPUT";
35+
push @rules, "iptables -t $table -A OUTPUT -m state --state ESTABLISHED -j $pass";
36+
push @rules, "iptables -t $table -A OUTPUT -m owner --uid-owner $user -j $pass";
37+
push @rules, "iptables -t $table -A OUTPUT -p udp --dport $dns_match -j $dns_target";
38+
push @rules, "iptables -t $table -A OUTPUT -p tcp --dport $dns_match -j $dns_target";
39+
push @rules, "iptables -t $table -A OUTPUT -d $network -p tcp -j $trans_target";
40+
41+
foreach my $net (@local_nets) {
42+
push @rules, "iptables -t $table -A OUTPUT -d $net -j $pass";
43+
}
944

10-
sub new {
11-
my $stop = Nipe::Component::Engine::Stop -> new();
12-
my %device = Nipe::Component::Utils::Device -> new();
13-
my $dns_port = '9061';
14-
my $transfer_port = '9051';
15-
my @table = qw(nat filter);
16-
my $network = '10.66.0.0/255.255.0.0';
17-
my $network_ipv6 = 'fd00::/8';
18-
my $start_tor = 'systemctl start tor';
19-
20-
if ($device{distribution} eq 'void') {
21-
$start_tor = 'sv start tor > /dev/null';
45+
push @rules, "iptables -t $table -A OUTPUT -p tcp -j $trans_target";
2246
}
2347

24-
elsif (-e '/etc/init.d/tor') {
25-
$start_tor = '/etc/init.d/tor start > /dev/null';
48+
# Default-deny: reject anything not explicitly accepted above. This
49+
# covers udp and icmp, but also every other protocol (sctp, dccp, ...),
50+
# so a non-tcp application cannot leak around tor.
51+
push @rules, 'iptables -t filter -A OUTPUT -j REJECT';
52+
53+
# IPv6 is not torified (tor's TransPort is IPv4 only), so reject every
54+
# IPv6 packet except loopback. REJECT (not DROP) makes apps fail fast and
55+
# fall back to the torified IPv4 path instead of hanging.
56+
if ($ipv6) {
57+
push @rules, 'ip6tables -t nat -F OUTPUT';
58+
push @rules, 'ip6tables -t filter -F OUTPUT';
59+
push @rules, 'ip6tables -t filter -A OUTPUT -o lo -j ACCEPT';
60+
push @rules, 'ip6tables -t filter -A OUTPUT -j REJECT';
2661
}
2762

28-
system "tor -f .configs/$device{distribution}-torrc > /dev/null";
29-
system $start_tor;
30-
31-
foreach my $table (@table) {
32-
my $target = 'ACCEPT';
33-
34-
if ($table eq 'nat') {
35-
$target = 'RETURN';
36-
}
37-
38-
system "iptables -t $table -F OUTPUT";
39-
system "iptables -t $table -A OUTPUT -m state --state ESTABLISHED -j $target";
40-
system "iptables -t $table -A OUTPUT -m owner --uid $device{username} -j $target";
41-
42-
my $match_dns_port = $dns_port;
43-
44-
if ($table eq 'nat') {
45-
$target = "REDIRECT --to-ports $dns_port";
46-
$match_dns_port = '53';
47-
}
48-
49-
system "iptables -t $table -A OUTPUT -p udp --dport $match_dns_port -j $target";
50-
system "iptables -t $table -A OUTPUT -p tcp --dport $match_dns_port -j $target";
51-
52-
if ($table eq 'nat') {
53-
$target = "REDIRECT --to-ports $transfer_port";
54-
}
55-
56-
system "iptables -t $table -A OUTPUT -d $network -p tcp -j $target";
57-
58-
if ($table eq 'nat') {
59-
$target = 'RETURN';
60-
}
61-
62-
system "iptables -t $table -A OUTPUT -d 127.0.0.1/8 -j $target";
63-
system "iptables -t $table -A OUTPUT -d 192.168.0.0/16 -j $target";
64-
system "iptables -t $table -A OUTPUT -d 172.16.0.0/12 -j $target";
65-
system "iptables -t $table -A OUTPUT -d 10.0.0.0/8 -j $target";
66-
67-
if ($table eq 'nat') {
68-
$target = "REDIRECT --to-ports $transfer_port";
69-
}
63+
return @rules;
64+
}
7065

71-
system "iptables -t $table -A OUTPUT -p tcp -j $target";
66+
sub new {
67+
my %device = Nipe::Component::Utils::Device->new();
68+
my $torrc = "$FindBin::RealBin/.configs/$device{distribution}-torrc";
69+
my $run_dir = '/run/tor';
70+
my $state = '/run/nipe';
71+
72+
# Kill any tor instance nipe started previously so the ports are free.
73+
Nipe::Component::Engine::Stop->stop_tor();
74+
75+
# Preserve the user's existing firewall the first time we activate, so
76+
# `stop` can restore it exactly instead of leaving OUTPUT flushed.
77+
system "mkdir -p $state";
78+
if (! -e "$state/iptables.rules") {
79+
system "iptables-save > $state/iptables.rules";
80+
system "ip6tables-save > $state/ip6tables.rules 2>/dev/null";
7281
}
7382

74-
system 'iptables -t filter -A OUTPUT -p udp -j REJECT';
75-
system 'iptables -t filter -A OUTPUT -p icmp -j REJECT';
76-
77-
if (-d '/proc/sys/net/ipv6') {
78-
foreach my $table (@table) {
79-
my $target = 'ACCEPT';
80-
81-
if ($table eq 'nat') {
82-
$target = 'RETURN';
83-
}
84-
85-
system "ip6tables -t $table -F OUTPUT";
86-
system "ip6tables -t $table -A OUTPUT -m state --state ESTABLISHED -j $target";
87-
system "ip6tables -t $table -A OUTPUT -m owner --uid $device{username} -j $target";
88-
89-
my $match_dns_port = $dns_port;
90-
91-
if ($table eq 'nat') {
92-
$target = "REDIRECT --to-ports $dns_port";
93-
$match_dns_port = '53';
94-
}
95-
96-
system "ip6tables -t $table -A OUTPUT -p udp --dport $match_dns_port -j $target";
97-
system "ip6tables -t $table -A OUTPUT -p tcp --dport $match_dns_port -j $target";
98-
99-
if ($table eq 'nat') {
100-
$target = "REDIRECT --to-ports $transfer_port";
101-
}
102-
103-
system "ip6tables -t $table -A OUTPUT -d $network_ipv6 -p tcp -j $target";
104-
105-
if ($table eq 'nat') {
106-
$target = 'RETURN';
107-
}
108-
109-
system "ip6tables -t $table -A OUTPUT -d ::1/128 -j $target";
110-
system "ip6tables -t $table -A OUTPUT -d fc00::/7 -j $target";
111-
system "ip6tables -t $table -A OUTPUT -d fe80::/10 -j $target";
112-
113-
if ($table eq 'nat') {
114-
$target = "REDIRECT --to-ports $transfer_port";
115-
}
83+
# tor needs its runtime, data and log directories to exist and be owned by
84+
# the tor user (it drops privileges via the User directive in the torrc).
85+
# The control-socket directory must be private (0700), otherwise tor
86+
# refuses to create the socket because other users could connect to it.
87+
system "mkdir -p $run_dir /var/log/tor /var/lib/tor";
88+
system "chown $device{username}:$device{username} $run_dir /var/log/tor /var/lib/tor";
89+
system "chmod 700 $run_dir";
90+
91+
# Lock the network down before tor is up, so there is never a window
92+
# where traffic can leave without going through tor.
93+
my $ipv6 = (-d '/proc/sys/net/ipv6') ? 1 : 0;
94+
foreach my $rule (build_rules(username => $device{username}, ipv6 => $ipv6)) {
95+
system $rule;
96+
}
11697

117-
system "ip6tables -t $table -A OUTPUT -p tcp -j $target";
118-
}
98+
# Drop existing connection tracking so flows opened before nipe started
99+
# cannot keep bypassing tor through the ESTABLISHED rule. Best effort:
100+
# needs conntrack-tools, otherwise it is a no-op.
101+
system 'conntrack -F > /dev/null 2>&1';
119102

120-
system 'ip6tables -t filter -A OUTPUT -p udp -j REJECT';
121-
system 'ip6tables -t filter -A OUTPUT -p icmpv6 -j REJECT';
122-
}
103+
system "tor -f $torrc > /dev/null";
123104

124-
my $status = Nipe::Component::Utils::Status -> new();
105+
my $status = Nipe::Component::Utils::Status->new();
125106

126107
if ($status =~ /true/sm) {
127108
return 1;
@@ -131,4 +112,4 @@ package Nipe::Component::Engine::Start {
131112
}
132113
}
133114

134-
1;
115+
1;

lib/Nipe/Component/Engine/Stop.pm

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,51 @@
11
package Nipe::Component::Engine::Stop {
22
use strict;
33
use warnings;
4-
use Nipe::Component::Utils::Device;
54

6-
our $VERSION = '0.0.4';
5+
our $VERSION = '0.0.5';
76

8-
sub new {
9-
my %device = Nipe::Component::Utils::Device -> new();
10-
my @table = qw(nat filter);
11-
my $stop_tor = 'systemctl stop tor';
7+
# Stop the tor instance nipe launched. We start tor with `tor -f <torrc>`
8+
# (a standalone daemon), so it must be stopped the same way, via its pid
9+
# file, rather than through systemctl which would only touch the system unit.
10+
sub stop_tor {
11+
my $pidfile = '/run/tor/tor.pid';
1212

13-
if ($device{distribution} eq 'void') {
14-
$stop_tor = 'sv stop tor > /dev/null';
13+
if (-e $pidfile) {
14+
system "kill \$(cat $pidfile) 2>/dev/null";
1515
}
1616

17-
foreach my $table (@table) {
18-
system "iptables -t $table -F OUTPUT";
17+
system q{pkill -f '[.]configs/.*-torrc' 2>/dev/null};
1918

20-
if (-d '/proc/sys/net/ipv6') {
21-
system "ip6tables -t $table -F OUTPUT";
22-
}
23-
}
19+
return 1;
20+
}
2421

25-
if ( -e '/etc/init.d/tor' ) {
26-
$stop_tor = '/etc/init.d/tor stop > /dev/null';
22+
sub new {
23+
my $state = '/run/nipe';
24+
25+
__PACKAGE__->stop_tor();
26+
27+
# Always remove nipe's OUTPUT rules so connectivity is restored
28+
# deterministically. We cannot rely on iptables-restore alone: on
29+
# nft-backed systems the snapshot taken at start is empty when no rules
30+
# existed yet, and restoring an empty snapshot would leave nipe's rules
31+
# in place.
32+
foreach my $table (qw(nat filter)) {
33+
system "iptables -t $table -F OUTPUT";
34+
system "ip6tables -t $table -F OUTPUT 2>/dev/null";
2735
}
2836

29-
system $stop_tor;
37+
# Re-apply the user's original firewall only if we captured a non-empty
38+
# snapshot at start time.
39+
if (-s "$state/iptables.rules") {
40+
system "iptables-restore < $state/iptables.rules";
41+
}
42+
if (-s "$state/ip6tables.rules") {
43+
system "ip6tables-restore < $state/ip6tables.rules 2>/dev/null";
44+
}
45+
system "rm -f $state/iptables.rules $state/ip6tables.rules";
3046

3147
return 1;
3248
}
3349
}
3450

35-
1;
51+
1;

t/20-rules.t

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/perl
2+
use strict;
3+
use warnings;
4+
use Test::More;
5+
use lib 'lib';
6+
use Nipe::Component::Engine::Start;
7+
8+
our $VERSION = '0.001';
9+
10+
my @v4 = Nipe::Component::Engine::Start::build_rules(username => 'tor', ipv6 => 0);
11+
my $joined = join "\n", @v4;
12+
13+
# The tor user's own traffic is passed through, so tor can reach the network.
14+
ok(index($joined, '-t nat -A OUTPUT -m owner --uid-owner tor -j RETURN') >= 0, 'nat owner RETURN for tor user');
15+
ok(index($joined, '-t filter -A OUTPUT -m owner --uid-owner tor -j ACCEPT') >= 0, 'filter owner ACCEPT for tor user');
16+
17+
# DNS is forced into tor's DNSPort.
18+
ok(index($joined, '-t nat -A OUTPUT -p udp --dport 53 -j REDIRECT --to-ports 9061') >= 0, 'udp/53 redirected to DNSPort');
19+
ok(index($joined, '-t nat -A OUTPUT -p tcp --dport 53 -j REDIRECT --to-ports 9061') >= 0, 'tcp/53 redirected to DNSPort');
20+
21+
# Everything else (tcp) is forced into tor's TransPort.
22+
ok(index($joined, '-t nat -A OUTPUT -p tcp -j REDIRECT --to-ports 9051') >= 0, 'catch-all tcp redirected to TransPort');
23+
24+
# Leak guard: default-deny closes the filter chain, so udp, icmp and every
25+
# other protocol that was not explicitly accepted is rejected.
26+
ok(index($joined, '-t filter -A OUTPUT -j REJECT') >= 0, 'default-deny REJECT present on filter');
27+
is($v4[-1], 'iptables -t filter -A OUTPUT -j REJECT', 'default-deny is the final ipv4 rule');
28+
29+
# Private ranges bypass tor.
30+
ok(index($joined, '-t nat -A OUTPUT -d 192.168.0.0/16 -j RETURN') >= 0, 'rfc1918 192.168 bypass');
31+
ok(index($joined, '-t nat -A OUTPUT -d 10.0.0.0/8 -j RETURN') >= 0, 'rfc1918 10/8 bypass');
32+
33+
# No IPv6 rules unless explicitly enabled.
34+
ok(index($joined, 'ip6tables') < 0, 'no ip6tables emitted when ipv6 disabled');
35+
36+
# Critical ordering: the owner RETURN must come before the catch-all REDIRECT in
37+
# the nat table. If reversed, tor's own connections get redirected into tor and
38+
# it can never bootstrap. This is the regression that broke Arch.
39+
my ($owner_index) = grep { index($v4[$_], '-t nat -A OUTPUT -m owner --uid-owner tor -j RETURN') >= 0 } 0 .. $#v4;
40+
my ($catch_index) = grep { index($v4[$_], '-t nat -A OUTPUT -p tcp -j REDIRECT --to-ports 9051') >= 0 } 0 .. $#v4;
41+
ok(defined $owner_index, 'nat owner rule present');
42+
ok(defined $catch_index, 'nat catch-all rule present');
43+
cmp_ok($owner_index, '<', $catch_index, 'owner RETURN precedes catch-all REDIRECT');
44+
45+
# The owner uid is parameterized per distro.
46+
my @debian = Nipe::Component::Engine::Start::build_rules(username => 'debian-tor', ipv6 => 0);
47+
ok(index(join("\n", @debian), '--uid-owner debian-tor -j RETURN') >= 0, 'owner uid is parameterized');
48+
49+
# IPv6 path rejects everything except loopback.
50+
my @v6 = Nipe::Component::Engine::Start::build_rules(username => 'tor', ipv6 => 1);
51+
my $joined6 = join "\n", @v6;
52+
ok(index($joined6, 'ip6tables -t filter -A OUTPUT -o lo -j ACCEPT') >= 0, 'v6 loopback accepted');
53+
ok(index($joined6, 'ip6tables -t filter -A OUTPUT -j REJECT') >= 0, 'v6 everything else rejected');
54+
55+
done_testing();

0 commit comments

Comments
 (0)