A Snowflake proxy on a first-generation Raspberry Pi
A single-core ARMv6 board relayed about 22 GiB of Tor traffic in its first week. The full setup, from firewall to sandbox, and what the proxy's own counters can and cannot tell you.
Scope: one proxy, home connection behind CGNAT, real Tor clients, 2026-10-20 to 2026-10-27
Method: The proxy's own 15-minute summary lines, shipped from the journal to a log collector and checked against the Pi's interface byte counters, plus systemd and nftables state read on the device.
Contents
The oldest computer in my lab is a Raspberry Pi 1 Model B from 2012: one ARMv6 core at 700 MHz, 512 MB of RAM, a 100 Mbit network port. Too slow for almost anything people use a Pi for today, so I wanted to know whether it could still do something useful. It turned out it could: for the last couple of weeks it has been helping people in censored countries reach Tor.
This entry is the whole setup, step by step, with every configuration file I actually run. If you have an old board, a NAS or any Linux box that is on all the time anyway, you can follow it as written.
What Snowflake is, in two paragraphs
Tor bridges are the usual way around censorship of Tor itself, but a bridge sits at a fixed address, and fixed addresses get found and blocked. Snowflake takes a different approach: thousands of volunteers run small, short-lived proxies, and a censored user’s Tor client is handed a different one each time. Blocking them one by one does not scale.
The traffic between the user and the proxy is WebRTC, the same protocol video calls use, so it looks like a call rather than like Tor. The proxy forwards it to a Snowflake bridge run by the Tor Project. A central broker introduces clients to proxies. As a proxy operator you never see what anyone is doing: you relay encrypted bytes, and you are not an exit node, so nobody’s browsing comes out of your connection.
What you need
| what I used | minimum, roughly | |
|---|---|---|
| board | Raspberry Pi 1 Model B Rev 2 | anything that runs Debian or Raspberry Pi OS |
| CPU / RAM | 1× ARMv6 @ 700 MHz, 512 MB (426 MB visible to Linux) | this is close to the floor |
| storage | 32 GB microSD in an adapter | 8 GB is plenty |
| network | 100 Mbit Ethernet, home connection | wired; Wi-Fi works but is less stable |
| OS | Raspberry Pi OS Lite, Trixie, armhf | any Debian 13 based system |
| package | snowflake-proxy 2.5.1-1 from the distribution | the distribution package is fine to start |
No port forwarding on the router. I will come back to why that matters.
Step 1: a minimal base system
Flash Raspberry Pi OS Lite, enable SSH with a key, and create one user without a password. If you use the Raspberry Pi Imager or cloud-init, the relevant part looks like this:
#cloud-config
hostname: snowflake
users:
- name: you
groups: [adm, sudo]
lock_passwd: true
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ssh-ed25519 AAAA... you@laptop
ssh_pwauth: false
disable_root: true
Then update everything once:
sudo apt update && sudo apt full-upgrade -y
On a Pi 1 this takes a while. Most of it is the boot image being rebuilt for three kernels (v6, v7 and v8), of which the Pi 1 only ever uses v6.
Turn IPv6 off, unless your network actually uses it. My firewall rules below are IPv4 only, and a protocol you do not filter is a protocol you do not control:
# /etc/sysctl.d/90-no-ipv6.conf
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
sudo sysctl --system
ip -6 addr # should print nothing
Step 2: the firewall comes before the proxy
The proxy’s whole job is to talk to strangers on the internet. It sits inside a home network. So before it runs, decide what it may reach: the internet, yes; the rest of your LAN, no, except for the few things the box genuinely needs (DNS, DHCP, and optionally a log collector).
nftables is already on Raspberry Pi OS. This is my /etc/nftables.conf, with my LAN addresses replaced by documentation addresses. Change the three define lines to match your network:
#!/usr/sbin/nft -f
# Snowflake proxy box: default drop in both directions.
# Internet: allowed out, and WebRTC in on a fixed UDP range.
# LAN: closed, except DNS and DHCP to the router, SSH from admin machines,
# and (optional) logs to one collector.
define ROUTER = 192.0.2.1 # your router: DNS and DHCP
define ADMIN = { 192.0.2.10 } # machines allowed to SSH in
define LOGHOST = 192.0.2.20 # optional: where logs are shipped
flush ruleset
table inet snowflake {
set nonpublic4 {
type ipv4_addr; flags interval
elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16,
224.0.0.0/4, 240.0.0.0/4 }
}
chain input {
type filter hook input priority filter; policy drop;
iif "lo" accept
ct state invalid drop
ct state established,related accept
meta nfproto ipv6 drop
ip saddr $ADMIN tcp dport 22 ct state new accept
# WebRTC from clients: only from public addresses, only on the proxy's range
ip saddr != @nonpublic4 udp dport 61000-61039 accept
udp sport 67 udp dport 68 accept
icmp type { echo-request, destination-unreachable, time-exceeded } accept
# LAN chatter (mDNS, SSDP, IGMP): drop quietly, it is not worth a log line
meta pkttype { broadcast, multicast } drop
limit rate 6/minute log prefix "sf-in-drop " level info
}
chain output {
type filter hook output priority filter; policy drop;
oif "lo" accept
ct state invalid drop
ct state established,related accept
meta nfproto ipv6 drop
ip daddr $ROUTER udp dport 53 accept
ip daddr $ROUTER tcp dport 53 accept
ip daddr { $ROUTER, 255.255.255.255 } udp dport 67 accept
ip daddr $LOGHOST tcp dport 8088 accept
# everything public is fine, everything private is not
ip daddr != @nonpublic4 accept
limit rate 6/minute log prefix "sf-out-drop " level info
}
chain forward {
type filter hook forward priority filter; policy drop;
}
}
Two things are worth spelling out. The output chain is what does the real work: if the proxy were ever compromised, the attacker’s next move would be your NAS or your laptop, and this chain says no. And the UDP range 61000-61039 is not arbitrary: it has to match the port range you give the proxy in step 4, and it sits outside Linux’s default ephemeral range (32768-60999), so nothing else on the box will grab those ports.
Applying a default-drop ruleset over SSH is how you lock yourself out. Arm an automatic undo first, apply, then prove you can still get in from a new session before disarming it:
sudo nft -c -f /etc/nftables.conf && echo "syntax OK" # check, change nothing
sudo systemd-run --on-active=5min --unit=nft-undo nft flush ruleset
sudo nft -f /etc/nftables.conf
# now, from a second terminal on your laptop:
ssh you@snowflake 'sudo systemctl stop nft-undo.timer && echo "undo disarmed"'
sudo systemctl enable nftables # load the rules on every boot
If the second SSH session does not get in, do nothing. In five minutes the timer wipes the rules and you are back where you started.
Step 3: install the proxy without letting it start
The Debian package enables and starts the service on install, with the package’s default flags. Those set no port range, so the proxy would pick random UDP ports that your firewall drops. So mask the unit first, install, and look before you let it run:
sudo systemctl mask snowflake-proxy.service
sudo apt install --no-install-recommends snowflake-proxy
dpkg-query -W -f='${Version} ${Architecture}\n' snowflake-proxy # mine: 2.5.1-1 armhf
snowflake-proxy -help 2>&1 | grep -E '^\s+-(capacity|ephemeral-ports-range|summary-interval)'
The last command matters. Flags change between versions, and the distribution package is older than upstream (2.5.1 in Debian 13, while the Tor Project’s own packages were on 2.15.1 when I wrote this). If any of the three flags is missing, stop and read that version’s help before going on.
Step 4: the flags
Everything you change goes into a drop-in, never into the packaged unit, so package updates do not overwrite it:
sudo systemctl edit snowflake-proxy.service
[Service]
ExecStart=
ExecStart=/usr/bin/snowflake-proxy -capacity 10 -ephemeral-ports-range 61000:61039 -summary-interval 15m
Nice=5
MemoryMax=192M
Restart=on-failure
RestartSec=30
What each one does, and why I picked these values:
-capacity 10: at most ten clients at once. One old core doing DTLS encryption in Go was my main worry, so I started low. I have not raised it, because I still cannot tell how close it comes: the proxy logs connections when they end, not how many are open at once (more on that in step 7).-ephemeral-ports-range 61000:61039: forty UDP ports, matching the firewall. That is twice what the common estimate of two sockets per client would need for ten clients. The estimate is an assumption, not something I measured, hence the headroom.-summary-interval 15m: every fifteen minutes the proxy writes one line to the journal with the number of connections and the traffic relayed. That line is all the monitoring this needs, and its regular arrival doubles as a heartbeat.Nice=5,MemoryMax=192M: keep the proxy polite to SSH and the rest of the system on a box with 426 MB.Restart=on-failure,RestartSec=30: bring the proxy back if the memory limit kills it. The packaged unit hasRestart=no.
Correction, 2 November 2026. The MemoryMax=192M above did nothing until 28 October. This Raspberry Pi kernel boots with cgroup_disable=memory, a parameter added before the kernel starts and absent from cmdline.txt, so the memory controller did not exist. systemd stored the limit and printed it back, the same trap as IPAddressDeny= in step 5 below. To make the limit real, append cgroup_enable=memory cgroup_memory=1 to the single line in /boot/firmware/cmdline.txt and reboot. cat /sys/fs/cgroup/cgroup.controllers should then list memory, and systemctl show -p MemoryCurrent snowflake-proxy should return a number instead of [not set]. An enforced limit can kill the proxy, which is why the drop-in now carries the two Restart lines; they were not in the version first published. Part two has the whole story.
What is not there on purpose: -unsafe-logging. Without it the proxy scrubs client addresses from its logs. The people on the other end are, by definition, people whose government does not want them on Tor. Their addresses should not exist anywhere on my disk.
Step 5: put the proxy in a sandbox
Out of the box, the packaged service ran the proxy as root. systemd-analyze security scored it 9.6 out of 10, “UNSAFE”. A process that parses traffic from strangers, running as root, can rewrite the very firewall from step 2. So I extended the same drop-in:
[Service]
# run as a throwaway user created on start, with no capabilities at all
DynamicUser=yes
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=yes
# the proxy needs no files, so it gets a read-only view of the system
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
# AF_NETLINK stays: the WebRTC library lists network interfaces through it
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_UNIX
UMask=0077
Then restart and measure, rather than assume:
sudo systemctl daemon-reload
sudo systemctl unmask snowflake-proxy.service
sudo systemctl enable --now snowflake-proxy.service
sleep 30
systemctl is-active snowflake-proxy
ps -o user=,rss= -C snowflake-proxy # a numeric UID, not root
systemd-analyze security snowflake-proxy.service | tail -1
When I ran this on my Pi, the score went from 9.6 to 1.3, and the proxy kept running as UID 63137 instead of root.
One trap I fell into. My first version of this drop-in also had IPAddressAllow= and IPAddressDeny= lines, a second, per-service copy of the LAN rule. systemctl show happily printed them back, so I counted them as working. They were not. The journal said:
snowflake-proxy.service: bpf-firewall: Preparation of BPF deny maps failed: Cannot allocate memory
systemd enforces those lines with BPF programs, could not allocate them on this kernel, and started the service anyway, without the filter. Reading a setting back proves only that you wrote it. If you use IPAddressDeny= anywhere, check that it is enforced:
journalctl -u snowflake-proxy -b --no-pager | grep -i bpf-firewall
Empty output is what you want. On a Pi 1 it was not, so on this box nftables is the only network containment, and the sandbox is what keeps the proxy from touching nftables.
Step 6: check that it works
Within a minute the journal should show the proxy starting and its NAT test:
journalctl -u snowflake-proxy -b --no-pager -o cat | head
The two lines to look for are Proxy starting and NAT type: .... Mine says restricted, which is what you get behind a home router with no port forwarding. After fifteen minutes the first summary line arrives:
journalctl -u snowflake-proxy --since -1h --no-pager -o cat | grep 'In the last'
2026/10/22 06:47:55 In the last 15m0s, there were 3 connections. Traffic Relayed ↑ 680 KB, ↓ 87 KB.
On 2.5.1 the up arrow is what the proxy sent towards clients and the down arrow is what came back from them, which is why the up number is usually the larger one. Upstream later switched the labels to the client’s point of view (merge request !130), so on a recent version the large number sits under the down arrow. The change landed upstream in February 2023, after the code Debian packaged as 2.5.1, and the line itself tells you which side of it you are on: 2.5.1 prints the up arrow first, newer versions print the down arrow first. Check that before you read a direction into these lines.
And to see clients connected right now:
ss -uapn | grep snowflake # UDP sockets in 61000-61039 = WebRTC sessions in progress
Step 7 (optional): keep the numbers
The summary lines are enough to answer “is it alive and is anyone using it”. I ship them from the journal to my log collector over TLS every five minutes, which is the LOGHOST exception in the firewall. Two things I learned doing that:
- The summary counts completed connections, not clients served at once. I first read “8 connections in a 15-minute window” as “8 of 10 slots in use”. It is not: a Tor session can stay open for a long time and only shows up in the window where it ends. The proxy does not log concurrency at all, so whether it ever reaches
-capacity 10is still an open question for me. - The traffic is booked the same way. On 2.5.1 a summary adds up the whole traffic of every connection that ended in those fifteen minutes, not what flowed during them. A session that moved several gigabytes over an evening lands in one line as if it had all happened in that quarter of an hour. The Snowflake developers have a change that makes a summary count only the traffic of its own interval (merge request !202); I have not checked which release, if any, ships it. My own summaries show the old behaviour: in the first eight days, 169 fifteen-minute windows had no completed connection, and every one of them booked zero bytes, which could not happen if the proxy counted traffic while long sessions were still open. On 2.5.1 the summaries are good for totals and useless for rates.
- Check how long your journal really keeps things. On this Pi, with a small SD card and default limits, the journal held about four hours of history when I checked. If your shipper is down for longer than that, the gap is permanent. Either ship more often or give journald more room in
/etc/systemd/journald.conf.
As a second safety net for privacy, my shipper also looks for public IP addresses in every line and masks any it finds, counting how many it masked. Over six days that counter stayed at zero: the proxy’s own scrubbing did its job.
The first week in numbers
I looked closely at the first 150 hours, from the second start on 20 October at 12:17 (after the hardening in step 5) to the summary of 26 October at 17:17. These are the proxy’s own numbers for that window:
| period | 150 hours, 600 of 600 fifteen-minute summaries, no gap over 20 minutes |
| restarts | none; one process ID for the whole period |
| relayed in total | 18.62 GiB (17.24 to clients, 1.39 from them) |
| typical full day | median 1.46 GiB, range 0.72 to 10.78 GiB |
| completed connections | 978, at most 8 in one fifteen-minute window |
| largest single summary | 9.13 GiB, 22 October at 06:32 UTC: sessions booked when they ended, not traffic of that quarter hour (see below) |
| system at rest | load 0.37, 47.6 °C, 292 MB of RAM available |
Day by day, this is what the daily summary search returns, from the first start to the evening of 27 October. “To clients” is what the proxy sent towards censored users, “from clients” what it carried the other way:
| day | connections | most in one 15 min | to clients, GiB | from clients, GiB | total, GiB |
|---|---|---|---|---|---|
| 20.10 (from midday) | 86 | 6 | 1.59 | 0.10 | 1.69 |
| 21.10 | 150 | 6 | 0.65 | 0.06 | 0.72 |
| 22.10 | 162 | 5 | 10.15 | 0.64 | 10.78 |
| 23.10 | 99 | 4 | 1.84 | 0.33 | 2.16 |
| 24.10 | 185 | 7 | 1.04 | 0.10 | 1.14 |
| 25.10 (25 hours, clock change) | 154 | 5 | 1.42 | 0.10 | 1.52 |
| 26.10 | 177 | 8 | 2.71 | 0.18 | 2.90 |
| 27.10 (to about 20:30) | 175 | 7 | 0.94 | 0.07 | 1.01 |
| total | 1,188 | 20.34 | 1.58 | 21.92 |
Two patterns stand out. Traffic is lopsided, about thirteen bytes towards clients for every one coming back, which is what you would expect from people mostly downloading through Tor. And the number of connections is steady from day to day, between 99 and 185, while the volume swings by more than ten times. That swing is a handful of long sessions: the ten largest summaries of the week hold almost three quarters of all the traffic. Because each session is booked on the day it ends, a day’s total says when heavy sessions finished, not when they ran.
I will run the same collection again once the proxy has been up for two weeks, and add the numbers, with what they change in the conclusions, to this entry as a dated update. The 150-hour window above stays as it is.
When it is busy
Adding up the same summaries by hour of day gives a daily rhythm. Every hour below covers seven or eight days, from 20 October at noon to the evening of 27 October, in UTC so that the clock change on 25 October does not shift anything. Each summary is counted in the hour its fifteen minutes fell in, which, as step 7 explains, is the hour its connections ended.
| hour (UTC) | completed connections, median | MiB booked, median |
|---|---|---|
| 00:00 | 3 | 2.2 |
| 01:00 | 4 | 4.6 |
| 02:00 | 5 | 39.7 |
| 03:00 | 6 | 16.6 |
| 04:00 | 7 | 18.8 |
| 05:00 | 8 | 41.7 |
| 06:00 | 10 | 37.4 |
| 07:00 | 8 | 16.2 |
| 08:00 | 6 | 4.0 |
| 09:00 | 8 | 7.9 |
| 10:00 | 7 | 17.5 |
| 11:00 | 7 | 25.2 |
| 12:00 | 11 | 16.3 |
| 13:00 | 7 | 52.8 |
| 14:00 | 7 | 8.1 |
| 15:00 | 5 | 7.9 |
| 16:00 | 9 | 19.9 |
| 17:00 | 9 | 22.7 |
| 18:00 | 8 | 7.5 |
| 19:00 | 6 | 20.3 |
| 20:00 | 4 | 3.9 |
| 21:00 | 5 | 18.2 |
| 22:00 | 2 | 0.0 |
| 23:00 | 4 | 0.4 |
Connections follow a clear daily curve. The quietest stretch is 22:00 to 01:00 UTC, with two to four completed connections in a typical hour, and from about 05:00 to 19:00 UTC the proxy sees five to eleven. Over a whole day the hourly medians add up to 156 connections, in line with the daily totals above.
Traffic is harder to read, because of how it is booked. In a typical hour the summaries book between nothing and about 50 MiB, but a single long session can drop gigabytes into whichever hour it happened to end in, so averages and shares of the total mostly show where those endings fell. I left them out. The median is the most honest column, and even it tells you when sessions ended, not when their bytes moved.
It is tempting to read a country into the quiet hours. The data cannot carry that. The proxy never records client addresses, the broker hands proxies to users wherever they are, and one proxy for one week is far too small a sample. All this shows is when people used this particular proxy, not who they were or where they lived.
If you collect the same summary lines in Splunk, this is the search behind the table. It assumes the connection count and the kilobytes in each direction are already extracted as fields; on my side that happens in props.conf, at search time:
index=snowflake sourcetype=snowflake:proxy sf_conns=* earliest="10/20/2026:12:00:00"
| dedup _time
| fillnull value=0 sf_up_kb sf_down_kb
| eval t=_time-450
| eval off=tonumber(substr(strftime(t,"%z"),1,3))
| eval hour_utc=(tonumber(strftime(t,"%H"))-off+24)%24, day_utc=strftime(t-off*3600,"%Y-%m-%d")
| stats sum(sf_conns) as conns sum(eval(sf_up_kb+sf_down_kb)) as kb by day_utc hour_utc
| stats count as days median(conns) as med_conns median(kb) as med_kb by hour_utc
| eval med_MiB=round(med_kb/1024,1)
| table hour_utc days med_conns med_MiB
| sort hour_utc
dedup _time drops duplicates from a shipper that delivers at least once, and the 450-second shift moves each summary from the end of its fifteen-minute window to the middle of it.
The three fields come from one search-time extraction in props.conf. The arrows are matched as \S+ on purpose: if the lines reach Splunk as JSON from the journal, the arrows may arrive escaped as \u2191 and \u2193, and a literal arrow in the regex would then match nothing. It reads the 2.5.1 order, up first; on a newer version swap the two field names.
[snowflake:proxy]
EXTRACT-summary = there were (?<sf_conns>\d+) connections\. Traffic Relayed \S+ (?<sf_up_kb>\d+) KB, \S+ (?<sf_down_kb>\d+) KB
What limits it
My expectation going in was that one 700 MHz core would be the bottleneck. A week of data has not answered that, and the reason is the most useful thing I learned from it.
My first test grouped hours by how many connections they completed and compared the traffic per connection. If the CPU were the limit, I reasoned, each connection would get less in the busiest hours. It did not; if anything it got more. But traffic per connection is the size of a session, not its speed. A Pi that slowed everyone down would make sessions last longer, not carry less, and the summary would book the same bytes at the end. The test could not see a CPU limit even if there was one, so I have dropped it.
The same mistake produced the number that looked best. This is the summary for 22 October at 06:32 UTC:
2026/10/22 06:32:55 In the last 15m0s, there were 5 connections. Traffic Relayed ↑ 9020695 KB, ↓ 556940 KB.
Taken literally, that is about 9 GiB in fifteen minutes, around 85 Mbit/s through a 100 Mbit port on one ARMv6 core. The quarter hour before it booked about 40 MiB and the one after it less than 1 MiB. It was not a burst. It was long sessions that happened to end in that window, booked whole at the end as described in step 7. An earlier draft of this entry called it the busiest hour and read a hardware ceiling into it. That was wrong, and it is gone.
To see what the Pi can actually push you need a rate, not a total: the interface or nftables byte counters sampled every few seconds while the proxy is busy. That is a measurement for a later entry.
What I can say is what sets the demand, and that is NAT. Behind a home router without port forwarding the proxy tests as restricted, and the broker hands restricted proxies only to clients whose own NAT is known to be unrestricted, because two restricted ends often cannot reach each other. Issue #40376 in the Snowflake tracker describes what follows: clients ask the broker for a proxy before their own NAT test has finished, clients of unknown NAT get unrestricted proxies, and restricted proxies end up almost unused. The operators quoted there saw about 200 connections an hour on unrestricted proxies and fewer than 10 on restricted ones. Mine completed about seven an hour over the week, which fits the restricted side of that picture. I have not checked whether the broker has changed since the issue was written.
Port forwarding was never an option here. My provider puts the line behind carrier-grade NAT: the router’s own WAN address sits in 100.64.0.0/10, the range reserved for it in RFC 6598, and the address the internet sees is shared with other customers. Nothing I set on the router can open a port from the outside. I would not have opened one anyway, because I do not open inbound ports into my home network for anything. If you have a public address and a box that can sit in a DMZ or on a network where opening a UDP range is fine, my guess, which I have not tested, is that you would relay much more with the same hardware.
What your internet provider sees
A proxy that helps people get around censorship sounds like something you would want to hide. It is not hidden, and it does not need to be. Here is everything that crosses your connection, in the order the proxy does it. All of it is visible to your provider and to anyone your provider shares data with. None of it shows what the people behind the proxy are doing.
Three names in your DNS log. Before it connects anywhere, the proxy looks up the broker (snowflake-broker.torproject.net), the relay (snowflake.torproject.net) and a STUN server (stun.l.google.com). These are the defaults of the Debian package, and I did not change them. If your lookups go to your provider’s resolver in plain DNS, which is the default on most home routers, those three names alone tell it that this machine runs a Snowflake proxy.
Steady HTTPS to the Tor Project. The proxy keeps asking the broker whether a client is waiting for it. Each request is a short HTTPS exchange with a Tor Project server. The content is encrypted, but the server name is sent in clear text at the start of every TLS connection, so the destination is no secret either. When the proxy starts, and again from time to time afterwards, it also runs its NAT test against a probe server run by the Tor Project.
A short UDP exchange with Google. The STUN query asks one of Google’s public servers which address the proxy has on the internet. Google learns your public address and the time of the question, and that is all it learns.
UDP to and from strangers. This is half of the traffic; the WebSocket to the relay, below, carries the other half. Each client reaches the proxy over WebRTC, the protocol behind browser video calls: encrypted UDP between your address and the client’s, on the fixed port range from step 4. Your provider sees each client’s address, how many bytes went each way, and when. The addresses come from all over the world, and many will be the shared address of a mobile network or another provider’s NAT rather than one person’s. This is the only place where anything about the users is visible on your side. The proxy does not write it down, but whoever watches your line can.
A secure WebSocket to the relay. Whatever a client sends is carried on to the Tor Project’s relay over TLS, and the answers come back the same way. To your provider this is one more encrypted connection to a Tor Project address.
The shape of it. Every byte the proxy relays crosses your line twice: in from one side, out to the other. So your upload and your download grow by about the same amount, roughly the relayed total each way. The thirteen-to-one ratio earlier in this entry is between what goes towards clients and what comes back from them, not between your upload and your download. I checked this on my line. From the Pi’s boot on 19 October to the afternoon of 28 October its network interface received 26.8 GiB and sent 26.9 GiB, while the proxy’s summaries for the same period add up to 22.6 GiB relayed. Both directions grew by the same amount, and both sit about a fifth above the relayed total. I read that fifth as protocol overhead on both legs, sessions that were still open and not yet booked, and the Pi’s own traffic, such as updates and log shipping; the interface counters cannot tell these apart. What they do show is that the proxy’s own numbers are not inflated. A typical day here was one or two gibibytes in each direction, with a few days far above that. On a connection with a data cap, or a slow uplink you share with the household, the upload is the number to keep an eye on. -capacity is the brake.
What nobody on the path can see. What the users do. Between a client and the proxy the traffic is encrypted, between the proxy and the relay it is encrypted again, and inside both sits Tor’s own encryption. You cannot see which sites anyone visits, and neither can your provider. Nothing leaves your connection towards those sites: a Snowflake proxy is not a Tor exit.
What it means in practice. Where Tor is not censored, a provider has nothing here to act on, and the most it learns is that you run a Snowflake proxy. Two things are still worth checking: whether your contract forbids running servers (the proxy accepts connections from outside, even without a forwarded port), and whether you want those three names in your provider’s DNS log at all. If you do not, send the Pi’s lookups to an encrypted resolver; the rest of the picture stays the same.
You can watch most of this yourself on the Pi. The first command shows the encrypted connections to the Tor Project, the second a few seconds of WebRTC traffic:
sudo ss -tnp | grep snowflake
sudo apt install -y tcpdump
sudo tcpdump -ni eth0 -c 20 'udp portrange 61000-61039'
The tcpdump output contains the addresses of real users. Look at it, then let it scroll away; do not save it or paste it anywhere.
What running one means for you
These are the questions I had before I started, and the answers as I understand them now.
Will other people’s traffic come out of my internet connection? No. A Snowflake proxy is not a Tor exit. It passes encrypted traffic between a censored user and a bridge run by the Tor Project, and the user’s traffic leaves the Tor network somewhere else entirely. Nobody’s browsing appears to come from your address.
What does my internet provider see? That you run a Snowflake proxy, and how much it carries, but not what anyone does through it. The section above goes through it step by step.
Have I had complaints? None. My provider has not sent a single abuse report or warning since the proxy started on 20 October.
Will my address end up on a blocklist? A proxy is not listed in the public directory of Tor relays, so the usual problem of relay operators, websites refusing their address, should not apply. I have not tested this beyond everyday use of the same connection.
What stays on my disk? Very little. Without -unsafe-logging the proxy writes no client addresses, only the fifteen-minute counts of connections and bytes, and my shipper checks every line for public addresses before anything leaves the Pi. What I keep is the table in this post: how much, and when sessions ended. Nothing says who.
What if the proxy itself gets attacked? It is a program that talks to strangers, so I assume it can be. That is why it runs without root, without capabilities and in a sandbox (step 5), behind a firewall that will not let it reach the rest of my network (step 2). If it were taken over, the attacker would hold a 2012 single-board computer that can talk to the internet and to nothing else in my home.
Is it legal? I am not a lawyer, and the answer depends on where you live. The Tor Project keeps guidance for people who run relays and proxies; read it for your country before you start.
If you want to run one
The useful conclusion is the opposite of the usual hardware advice. Behind a restrictive NAT you do not need a fast machine: a Pi 1 carried the demand such a proxy gets for a week without a restart. You need one that stays on, and if you can arrange it, one that sits behind less restrictive NAT, where I cannot promise a Pi 1 would keep up. Behind a restrictive NAT, an old board, a NAS or a mini PC that is running anyway is good enough.
If you do not want to run anything at all, the Snowflake browser extension does the same job while your browser is open.
The Pi has been running since 20 October and I have not touched it.
Sources
- Tor Project: Snowflake
- Tor Project: Standalone Snowflake proxy and the Debian package instructions
- Debian: snowflake-proxy package and its manual page (default broker, relay and STUN server)
- Tor Project: the NAT probe service the proxy uses to test its NAT type
- Snowflake tracker: issue #40376 (restricted proxies almost unused), merge request !130 (up and down labels in the summary) and merge request !202 (summaries that count only their own interval)
- systemd:
systemd.exec(sandboxing options) andsystemd.resource-control(IPAddressDeny=and its BPF requirement)