All new Registrations are manually reviewed and approved, so a short delay after registration may occur before your account becomes active.
Webtunnel and Snowflake Tor Bridge Setup - Ansible Playbooks Inside
Hello,
After posting in another thread, some people were curious about my Tor bridge setup. I currently run over 10 tor bridges on various idlers I have.
Around a year ago I wrote two ansible playbooks to help me deploy and manage those a bit easier and also make sure I did not make any manual mistake during configuration.
webtunnel
The playbook is basically the adaptation of steps from official wiki page with some minor improvements and also a script that helps getting the bridge line after Tor daemon starts successfully.
The webtunnel bridge works basically by tunneling Tor traffic via HTTPS web server. For the client to be able to connect, you need to provide the domain and secret url on which reverse proxy is listening. Ideally, the connection should look as if someone was just visiting your website - the traffic is encrypted with TLS so censors are not able to see what the client does on there. most of the time
The requirements for webtunnel:
a free 80 and 443 port or nginx already running on 443
This playbook assumes no web server is running. You could add a new vhost manually if you already have nginx configured and running. It did not work for me with Caddy for some reason (i didn't investigate further, and also no one seems to be running Caddy for webtunnel).a subdomain
A CNAME record you can delegate to this bridge.small VPS running Debian
~512MB RAM should be enough - just needs Tor daemon and nginx. It should be able to run on other distros as well, but the playbook would need some adaptation.
playbook.yml
- name: Install and configure Tor WebTunnel Bridge on Debian
hosts: tor_bridge
become: yes
tasks:
- name: Update and upgrade system packages
ansible.builtin.apt:
update_cache: yes
upgrade: dist
autoremove: yes
autoclean: yes
- name: Install required dependencies for Tor and WebTunnel
ansible.builtin.apt:
name:
- apt-transport-https
- gnupg2
- curl
- socat
- nginx
- cron
state: present
- name: Stop service nginx
ansible.builtin.systemd_service:
name: nginx
state: stopped
- name: Download and save Tor GPG key to custom keyring
ansible.builtin.shell:
cmd: wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --dearmor | tee /usr/share/keyrings/deb.torproject.org-keyring.gpg >/dev/null
creates: /usr/share/keyrings/deb.torproject.org-keyring.gpg
become: yes
tags: tor_repo[1][3]
- name: Set permissions for Tor GPG key
ansible.builtin.file:
path: /usr/share/keyrings/deb.torproject.org-keyring.gpg
mode: '0644'
owner: root
group: root
become: yes
tags: tor_repo[1][3]
- name: Add Tor Project repository
ansible.builtin.apt_repository:
repo: 'deb [signed-by=/usr/share/keyrings/deb.torproject.org-keyring.gpg] https://deb.torproject.org/torproject.org trixie main'
state: present
tags: tor_repo[3]
- name: Install Tor from Tor Project repository
ansible.builtin.apt:
name: tor
state: present
tags: tor_repo[3]
- name: Install Tor Keyring from Tor Project repository
ansible.builtin.apt:
name: deb.torproject.org-keyring
state: present
tags: tor_repo[3]
- name: Download WebTunnel binary
ansible.builtin.get_url:
url: "{{ webtunnel_url }}"
dest: /usr/local/bin/webtunnel
mode: '0755'
tags: webtunnel_download[2]
- name: Update Apparmor configuration for Tor to allow WebTunnel
ansible.builtin.lineinfile:
path: /etc/apparmor.d/system_tor
insertafter: ' \/var\/lib\/tor\/\*\* r,'
line: ' /usr/local/bin/webtunnel ix,'
state: present
notify: Reload Apparmor
- name: Installing acme
ansible.builtin.shell:
cmd: wget -O - https://get.acme.sh | sh -s email={{ letsencrypt_email }}
become: yes
- name: Setting up acme
ansible.builtin.shell:
cmd: /root/.acme.sh/acme.sh --issue --force --standalone --domain {{ domain }}
become: yes
- name: Configure Nginx for WebTunnel
ansible.builtin.copy:
dest: /etc/nginx/sites-available/default
content: |
server {
listen [::]:443 ssl http2;
listen 443 ssl http2;
server_name {{ domain }};
#ssl on;
# you can place a dummy website here, or just leave it to 404
root /var/www/new_site;
# certificates generated via acme.sh
ssl_certificate /root/.acme.sh/{{ domain }}_ecc/fullchain.cer;
ssl_certificate_key /root/.acme.sh/{{ domain }}_ecc/{{ domain }}.key;
ssl_session_timeout 15m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:MozSSL:50m;
#ssl_ecdh_curve secp521r1,prime256v1,secp384r1;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000" always;
# Reverse proxy to Tor daemon
location = /{{ random_string }} {
proxy_pass http://127.0.0.1:15000;
proxy_http_version 1.1;
### Set WebSocket headers ###
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
### Set Proxy headers ###
proxy_set_header Accept-Encoding "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Front-End-Https on;
proxy_redirect off;
access_log off;
error_log off;
}
}
owner: root
group: root
mode: '0644'
notify: Reload Nginx
- name: Create Tor configuration for WebTunnel bridge
ansible.builtin.blockinfile:
path: /etc/tor/torrc
block: |
BridgeRelay 1
ORPort 127.0.0.1:auto
AssumeReachable 1
ServerTransportPlugin webtunnel exec /usr/local/bin/webtunnel
ServerTransportListenAddr webtunnel 127.0.0.1:15000
ServerTransportOptions webtunnel url=https://{{ domain }}/{{ random_string }}
ExtORPort auto
ContactInfo <{{ contact_email }}>
Nickname {{ nickname }}
SocksPort 0
Log notice file /var/log/tor/notices.log
{% if make_bridge_private | default(false) %}
PublishServerDescriptor 0
BridgeDistribution none
{% else %}
PublishServerDescriptor 1
{% endif %}
state: present
notify: Restart Tor
- name: Ensure Tor service is enabled and started
ansible.builtin.systemd:
name: tor.service
enabled: yes
state: started
- name: Write get bridge line script
ansible.builtin.template:
src: get-bridge-line.sh.j2
dest: /opt/get-bridge-line.sh
owner: root
group: root
mode: '0755'
become: yes
handlers:
- name: Reload Apparmor
ansible.builtin.systemd:
name: apparmor
state: reloaded
- name: Reload Nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
- name: Stop Nginx
ansible.builtin.systemd:
name: nginx
state: stopped
- name: Restart Tor
ansible.builtin.systemd:
name: tor.service
state: restarted
get-bridge-line.sh.j2 - I adapted this script from the obfs4 docker image. It really helps piece together the bridge line to use.
#!/usr/bin/env bash
#
# This script extracts the pieces that we need to compile our bridge line.
# This will have to do until the following bug is fixed:
# <https://gitlab.torproject.org/tpo/core/tor/-/issues/29128>
TOR_LOG=/var/log/tor/notices.log
if [ ! -r "$TOR_LOG" ]
then
echo "Cannot read Tor's log file ${TOR_LOG}. This is a bug."
exit 1
fi
fingerprint=$(grep "Your Tor server's identity key *fingerprint is" "$TOR_LOG" | \
sed "s/.*\([0-9A-F]\{40\}\)'$/\1/" | \
tail -1)
imaginaryaddr=$(grep 'Registered server transport' "$TOR_LOG" | sed -E "s/.*?(\[[0-9a-f:]*\]:443)'$/\\1/gm" | \
tail -1)
WEBTUNNEL_URL=https://{{ domain }}/{{ random_string }}
echo "webtunnel ${imaginaryaddr} ${fingerprint} url=${WEBTUNNEL_URL}"
hosts.ini
[all:vars]
letsencrypt_email="[email protected]"
contact_email="youremail at example [dot] com" # email for contact, can be slightly obfuscated if you do not wish to receive spam (it's listed publicly)
webtunnel_url="https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/webtunnel/-/jobs/1316181/artifacts/raw/build/amd64-linux/server" # webtunnel server binary - needs changing to the most recent artifact from time to time
[tor_bridge]
subdomain.example.com domain=subdomain.example.com nickname=bridgename random_string=yoursecreturl # make_bridge_private=true
Replace:
letsencrypt_emailandcontact_emailwith your actual email you want to use for bridge in both places- your subdomain in
domain- clients will connect to it nickname- that's the name your bridge will be listed asrandom_string- secret URL for reverse proxy to Tor
There is one more parameter I implemented, make_bridge_private - if you don't want the bridge to be listed publicly (rather send someone who asks you directly for example), you can set it to true.
Lastly, there's somewhat of non-ideal solution - I'm just grabbing the most recent build from gitlab build artifacts instead of manual compilation. This saves on compile time but needs you to update the job id if you want to perform an update. I think I will change that in the future.
How to check it is working
After successfully executing the playbook, the easiest way is to get bridge line and test for yourself:
$ sudo /opt/get-bridge-line.sh
webtunnel [2001:db8:272b:3cc6:66df:4d41:6895:cbcf]:443 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA url=https://subdomain.example.com/yoursecreturl
Tor Browser should be able to bootstrap with this.
You can also check the Tor logs, every day it will print out the usage stats:
Mar 03 06:27:35.000 [notice] Heartbeat: Tor's uptime is 13 days 12:00 hours, with 102 circuits open. I've sent 119.61 GB and received 122.25 GB. I've received 44378 connections on IPv4 and 0 on IPv6. I've made 180822 connections with IPv4 and 0 with IPv6.
Lastly, you should be able to find your bridge on Tor metrics site, either looking by bridge name or the hash. It can take a few hours for it to appear there.
There you can also see the bridge distribution method assigned to your bridge. In the first few days/weeks it can be None (this means your bridge is not distributed yet), but later should change to something like Settings, Telegram, Email or HTTPS.
disclaimer: sometimes the Tor daemon may be attacked and as a consequence unable to bootstrap. It happened to me a couple times that Tor was consistently using 100% CPU. There are some malicious malicious/non-functional guard relays on the network which sometimes prevent daemon from bootstraping. Usually restart solves this (since the daemon chooses different guard from a set).
snowflake
Snowflake architecture is very different. Since it was initially designed to work as just a website one can open to help others (later chrome extension), it doesn't actually need the Tor daemon running in the background. Instead it makes connections over widely adopted WebRTC protocol and forwards them via websocket to backend Snowflake Server owned by The Tor Foundation (snowflake-01.torproject.net). Their server is responsible to actually connect users to Tor relays and gain access to the network.
There is also a concept of "Broker" - a server which helps Clients find Bridges (Proxies). It is set by default to snowflake-broker.torproject.net, but you may use other ones by providing -broker parameter as argument to snowflake-proxy. Beware that will require your clients to also change their broker server.
You can learn more about the technical concepts in the wiki or this page from the original author published back in 2017.
A snowflake bridge (proxy) is arguably a bit easier to set up.
You will need:
direct internet connection
No NAT - at least on some ports. From my experience, port forwarding does not work too well with snowflake. It's true snowflake can work even behind NATed environments however its helpfulness is pretty limited there, I was barely able to get anyone to connect to my proxy running behind NAT.small VPS running Debian (same as for webtunnel)
You do not need Tor daemon running, however Snowflake proxy itself is pretty memory heavy (can use 200-300MB by itself). I would recommend having at least 512MB RAM.
---
- name: Deploy Tor Snowflake Proxy
hosts: snowflake_bridges
become: true
vars:
snowflake_user: snowflake
snowflake_home: /var/lib/snowflake
snowflake_repo: https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake.git
snowflake_bin_dir: /usr/local/bin
port_range_width: 200
tasks:
- name: Install build dependencies
ansible.builtin.apt:
name:
- acl
- git
- golang
- build-essential
state: present
update_cache: yes
- name: Create snowflake system user
ansible.builtin.user:
name: "{{ snowflake_user }}"
system: yes
home: "{{ snowflake_home }}"
create_home: yes
shell: /usr/sbin/nologin
- name: Check if Snowflake service already exists
ansible.builtin.stat:
path: /etc/systemd/system/snowflake-proxy.service
register: service_file
- name: Extract existing port range (if service exists)
ansible.builtin.shell: |
grep -oP 'ephemeral-ports-range \K[0-9]+:[0-9]+' /etc/systemd/system/snowflake-proxy.service
register: existing_ports
when: service_file.stat.exists
changed_when: false
- name: Generate random port range (if fresh install)
ansible.builtin.set_fact:
generated_start: "{{ 36000 | random(start=32000) }}"
when: not service_file.stat.exists
- name: Set final port range variable
ansible.builtin.set_fact:
snowflake_port_range: >-
{{ existing_ports.stdout
if (service_file.stat.exists and existing_ports.stdout | length > 0)
else (generated_start | string + ':' + (generated_start | int + port_range_width) | string) }}
- name: Display active port range
ansible.builtin.debug:
msg: "Snowflake Proxy will run on UDP ports: {{ snowflake_port_range }}"
- name: Create build directory
ansible.builtin.file:
path: "{{ snowflake_home }}/src"
state: directory
owner: "{{ snowflake_user }}"
group: "{{ snowflake_user }}"
mode: '0755'
- name: Clone Snowflake repository
ansible.builtin.git:
repo: "{{ snowflake_repo }}"
dest: "{{ snowflake_home }}/src/snowflake"
version: main
depth: 1
force: yes
become_user: "{{ snowflake_user }}"
register: git_clone
- name: Build Snowflake Proxy binary
ansible.builtin.command:
cmd: go build -o {{ snowflake_home }}/snowflake-proxy
chdir: "{{ snowflake_home }}/src/snowflake/proxy"
environment:
GOCACHE: "{{ snowflake_home }}/.cache/go-build"
become_user: "{{ snowflake_user }}"
when: git_clone.changed
register: go_build
- name: Clean Go build cache
ansible.builtin.command:
cmd: go clean -cache
environment:
GOCACHE: "{{ snowflake_home }}/.cache/go-build"
become_user: "{{ snowflake_user }}"
when: go_build.changed
- name: Install binary to system path
ansible.builtin.copy:
src: "{{ snowflake_home }}/snowflake-proxy"
dest: "{{ snowflake_bin_dir }}/snowflake-proxy"
remote_src: yes
mode: '0755'
owner: root
group: root
notify: Restart Snowflake
- name: Create Systemd Service file
ansible.builtin.copy:
dest: /etc/systemd/system/snowflake-proxy.service
content: |
[Unit]
Description=Tor Snowflake Proxy
After=network.target
[Service]
Type=simple
User={{ snowflake_user }}
ExecStart={{ snowflake_bin_dir }}/snowflake-proxy -capacity {{ port_range_width//2 }} -ephemeral-ports-range {{ snowflake_port_range }}
Restart=on-failure
RestartSec=10
# Hardening
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
[Install]
WantedBy=multi-user.target
notify: Restart Snowflake
- name: Enable and start Snowflake service
ansible.builtin.systemd:
name: snowflake-proxy
state: started
enabled: yes
daemon_reload: yes
handlers:
- name: Restart Snowflake
ansible.builtin.systemd:
name: snowflake-proxy
state: restarted
daemon_reload: yes
You can specify how many ports to allocate to snowflake port_range_width: 200. Playbook is designed to randomly pick N consecutive ones but you may change that using the -ephemeral-ports-range parameter. The number of simultaneously connected clients is N/2 at maximum.
The playbook will clone and compile Snowflake proxy from the official repository. This may take a few minutes of 100% utilized CPU.
How to check it is working
Snowflake daemon will periodically print out the statistics in the system log:
$ sudo journalctl -xeu snowflake-proxy
In the last 1h0m0s, there were 17 completed successful connections. Traffic Relayed ↓ 367858 KB (102.18 KB/s), ↑ 29148 KB (8.10 KB/s).
In the last 1h0m0s, there were 17 completed successful connections. Traffic Relayed ↓ 415168 KB (115.32 KB/s), ↑ 23925 KB (6.65 KB/s).
In the last 1h0m0s, there were 13 completed successful connections. Traffic Relayed ↓ 429052 KB (119.18 KB/s), ↑ 17835 KB (4.95 KB/s).
In the last 1h0m0s, there were 7 completed successful connections. Traffic Relayed ↓ 958863 KB (266.35 KB/s), ↑ 52831 KB (14.68 KB/s).
In the last 1h0m0s, there were 18 completed successful connections. Traffic Relayed ↓ 282815 KB (78.56 KB/s), ↑ 23143 KB (6.43 KB/s).
In the last 1h0m0s, there were 11 completed successful connections. Traffic Relayed ↓ 118503 KB (32.92 KB/s), ↑ 20306 KB (5.64 KB/s).
In the last 1h0m0s, there were 13 completed successful connections. Traffic Relayed ↓ 146744 KB (40.76 KB/s), ↑ 12292 KB (3.41 KB/s).
In the last 1h0m0s, there were 16 completed successful connections. Traffic Relayed ↓ 132514 KB (36.81 KB/s), ↑ 15211 KB (4.23 KB/s).
In the last 1h0m0s, there were 14 completed successful connections. Traffic Relayed ↓ 249259 KB (69.24 KB/s), ↑ 39897 KB (11.08 KB/s).
How much help/traffic to expect
It depends. I will mention the exact reasons below, however some of my bridges never really gained traction, while others were using 100s of GBs per day. It's pretty hard to predict which one will perform well.
To better monitor how much my bridges actually do, I wrote a small eBPF program to monitor traffic on snowflake ports (or 443 in case of webtunnel). It measures how much traffic is coming from a given network on specific day and sends the data to a central server for visualization purposes.
Those are some stats from past 28 days:

as you may notice, not every Tor bridge user comes from a censored country
Additional notes
Last but not least - beware your bridges may get blocked and loose their clients over time. Some may never even get many clients in the first place. It usually works like this - firewalls can adapt and seeing a spike of traffic to particular website/IP address/IP range with no clear purpose, it will most likely get automatically dropped some time. It's an endless game of cat and mouse. Additionally remember that no amount of bridges will help when the entire country is cut off from the internet.
The distribution method is not perfect either, relays can be discovered and there's in fact a project actively doing that just to show how flawed the process currently is.
There are some good videos on how this currently works and how censors tried to block bridges in the past:
37C3 - Tor censorship attempts in Russia, Iran, Turkmenistan
Feds try Censoring Tor, Accidentally take down Microsoft Azure


Comments
Instructions unclear ive ended up in a local titty bar in romania, is this normal?
Send location
Love you!
Please send in text, do not send in co-ordination.
merci beaucoup monsieur olok
can I use this to watch gay porn
Thank you! Well written
now i only need time to read AND understand it
defintely going to try setup some snowflakes this weekend
Wow this is a lot of words
No.
No.
Ansible is great for setting up multiple servers at once. Also using the Tor config file does give you more control. I find the docker container setup is pretty easy to use.
omg so much work, off the grid is so much easier.
Are you sure? That's just the allocated virtual memory. The RSS (which is the amount of memory it actually uses) is typically under 50 MiB, whereas Tor often likes nearly 1 GiB of RSS.
Hmm, I'm pretty sure it always required around 200MB RAM by itself. At least that's what my experience was.
and another one:
What version of Snowflake are you using? The one in Debian stable is very outdated (2.5.1-1+b16), but the one in Debian testing is newer (2.10.1-1+b1).
That is similar to all the others I run, and the traffic I get is similar to what you get:
Compiled from source exactly as in the playbook.
That's interesting. I wonder why it's using so much RSS. Perhaps setting the custom ephemeral port range rather than letting it choose did something strange? It's by far the lightest daemon I run.
Btw, you can make the Tor daemon also use less memory if you switch it to use jemalloc2! I always do:
That reduces memory fragmentation and reduces RSS by replacing glibc's malloc (ptmalloc3) with jemalloc2. Perhaps that would also help with snowflake-proxy.