In Getting Italy Back Online, Part Two, I created a multi-container application for my Italian Dictionary website. In the meantime, I added my personal website to the compose file and put them all onto a user-defined network and created the containers on my VPS.
Next, I wanted to create an onion service (formally known as a hidden service) for my personal website. I used to run onion services before, and after much contemplation and seeking answers in the wilderness, it’s time to bring sexy back.
Since my personal site is solely devoted to what interests me in tech, I felt that I didn’t want to mix in anything personal (although, I would never post anything inflammatory or irreverent). Perhaps I’ll change my mind, but for now that’s what I’m doing. Deal with it.
So, what’s involved in onionizing a website? That’s what this post is all about, friend.
Let’s dive in.
Pre-Onion
Before we take a look at the changes needed to onionize the site, let’s take a look at what changed in the config files to get benjamintoll.com online.
docker-compose.yml
version: "3.7"
services:
db:
image: mysql
restart: always
environment:
MYSQL_DATABASE_FILE: /run/secrets/db_name
MYSQL_USER_FILE: /run/secrets/db_user
MYSQL_PASSWORD_FILE: /run/secrets/db_password
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
networks: (1)
- italy_net
secrets:
- db_name
- db_user
- db_password
- db_root_password
volumes:
- ./sql:/docker-entrypoint-initdb.d
- italy_data:/var/lib/mysql
proxy:
build:
context: dockerfiles
dockerfile: Dockerfile.nginx
restart: always
depends_on:
- db
ports:
- 80:80
networks: (1)
- italy_net
volumes:
- ./italy:/var/www/html:ro
italy:
build:
context: dockerfiles
dockerfile: Dockerfile.php-fpm
restart: always
depends_on:
- proxy
networks: (1)
- italy_net
secrets:
- source: php_italy
target: /var/www/italy.php
volumes:
- ./italy:/var/www/html:ro
benjamintoll: (2)
image: nginx
restart: always
depends_on:
- proxy
networks: (1)
- italy_net
volumes:
- ./benjamintoll.com/public:/usr/share/nginx/html:ro
networks: (1)
italy_net:
name: italy_net
driver: bridge
secrets:
db_name:
file: secrets/italy/db_name.txt
db_user:
file: secrets/italy/db_user.txt
db_password:
file: secrets/italy/db_password.txt
db_root_password:
file: secrets/italy/db_root_password.txt
php_italy:
file: secrets/italy/php_italy.txt
volumes:
italy_data:
name: italy_data
Notes:
-
Added a user-defined networked called
italy_netand joined each service to it. Although there are many reasons for wanting this over using the defaultbridgenetwork, I’m using it here primarily for service discovery. Note that it is defined globally (i.e., the same indentation level asservices,secretsandvolumes) and referenced by its key, which in this case has the same name.The
namefield is optional, but if it is not set it is given a name by Docker: the directory name in whichdocker-composewas invoked is prepended to the value ofname(i.e.,projects_italy_data). I like to keep control of the names and not have something magically generated. -
Added the
benjamintollservice.
Next, let’s take a gander at the updated default.conf configuration:
server {
listen 80;
root /var/www/html;
index index.php index.html index.htm;
server_name kilgore-trout;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /var/www/html;
}
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
if (!-f $document_root$fastcgi_script_name) {
return 404;
}
# Mitigate https://httpoxy.org/ vulnerabilities.
fastcgi_param HTTP_PROXY "";
fastcgi_pass italy:9000;
fastcgi_index index.php;
include fastcgi_params;
# SCRIPT_FILENAME parameter is used for PHP FPM determining
# the script name. If it is not set in fastcgi_params file,
# i.e. /etc/nginx/fastcgi_params or in the parent contexts.
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
server { (1)
listen 80;
root /var/www/html;
index index.html index.htm;
server_name _; (2)
location / {
proxy_pass http://benjamintoll; (3)
}
}
Notes:
-
Added a new server block to support my personal site.
-
The underscore denotes
localhost. As seen below, I had added an entry to/etc/hostssokilgore-troutresolves to the loopback device.127.0.0.1 localhost 127.0.1.1 kilgore-trout -
Proxy the requests through to the
benjamintollservice (defined indocker-compose.yml).
Hopefully, that makes sense and is straightforward and familiar.
Neat!
Onion
After having done some research, I decided the best approach would be to have a new service running Tor that would be connected to both the italy_net network and a new tor_net network. The latter will be an isolated network and won’t have access to the public Internet by turning off IP masquerading. Since the Tor service belongs to both networks, it will act as a proxy for the isolated onion service. This isolated container with the onion service is where the onion service files are served.
I’ve created a Dockerfile that sets up Tor. This will include the configuration needed to get the onion service online as well as the GPG key used to verify the signature of the Tor Project’s packages.
Let’s check it out, ragazzi.
Dockerfile.tor
FROM ubuntu:focal
RUN apt-get update && \
apt-get install -y \
gnupg2 \
lsb-release \
wget
RUN wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --import && \
gpg --export A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89 | apt-key add - && \
echo "deb [arch=amd64] https://deb.torproject.org/torproject.org $(lsb_release -sc) main" > /etc/apt/sources.list.d/tor.list \
echo "deb-src [arch=amd64] https://deb.torproject.org/torproject.org $(lsb_release -sc) main" >> /etc/apt/sources.list.d/tor.list
RUN apt-get update && \
apt-get install -y \
apt-transport-https \
deb.torproject.org-keyring \
tor
RUN echo "HiddenServiceDir /var/lib/tor/benjamintoll.com" > /etc/tor/torrc
RUN echo "HiddenServicePort 80 onion:80" >> /etc/tor/torrc
ENTRYPOINT ["tor"]
The next step is to create the two new services and the private network in the Docker Compose file. Since much of the configuration is the same as before, only the new additions will be shown in full.
version: "3.7"
services:
db:
...
proxy:
...
italy:
...
benjamintoll:
...
tor: (1)
build:
context: dockerfiles
dockerfile: Dockerfile.tor
restart: always
networks: (2)
- italy_net
- tor_net
volumes: (3)
- onion_data:/var/lib/tor
onion: (4)
image: nginx
restart: always
depends_on:
- tor
networks: (5)
- tor_net
volumes: (6)
- ./benjamintoll.com/public:/usr/share/nginx/html:ro
networks:
italy_net:
name: italy_net
driver: bridge
tor_net: (7)
name: onion
driver: bridge
driver_opts:
com.docker.network.bridge.enable_ip_masquerade: "false" (8)
secrets:
...
volumes:
italy_data:
name: italy_data
onion_data: (9)
name: onion_data
Notes:
-
The new
torservice. -
As described, the
torservice belongs to bothitaly_netandtor_netDocker bridge networks. -
Binding the
onion_datanamed volume on the host to the location of the onion service’s hidden directory will give us access to the onion service’s hostname. This allows us to find the hidden service in the Tor network.Also, creating the bind mount will ensure that the keys (and thus the hostname) won’t change when the containers are brought up and down. That would be bad, as we’d lose the original hostname (the
.onionaddress), as it would change every time! -
The new
onionservice. -
Note that the
onionservice is only joined to the isolatedtor_netnetwork. -
I’m using the same location for the mount as I am for my personal site on the public Internet. I’ll later change this to another location with different content.
-
The new
tor_netDocker network. -
Set an option on
tor_netto turn off IP masquerading. -
The new
onion_datanamed volume.
Post-Onion
We’re all set to fire up our cluster. Cross your fingers and toes!
$ docker-compose up -d
Creating network "italy_net" with driver "bridge"
Creating network "onion" with driver "bridge"
Creating projects_db_1 ... done
Creating projects_tor_1 ... done
Creating projects_onion_1 ... done
Creating projects_proxy_1 ... done
Creating projects_italy_1 ... done
Creating projects_benjamintoll_1 ... done
Ok, looking good. Now, let’s take a look at the Tor config file, torrc, that was created in the tor service. We’ll first look up its container name and execute a remote command:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cff222b37a28 projects_tor "tor" 19 minutes ago Up 18 minutes projects_tor_1
6e9bd3232cd2 projects_italy "php-fpm" 19 minutes ago Up 18 minutes 9000/tcp projects_italy_1
870eeaefecf1 nginx "/docker-entrypoint.…" 19 minutes ago Up 18 minutes 80/tcp projects_benjamintoll_1
5c3639fe7138 projects_proxy "/docker-entrypoint.…" 19 minutes ago Up 18 minutes 0.0.0.0:80->80/tcp projects_proxy_1
46e86502b54f mysql "docker-entrypoint.s…" 19 minutes ago Up 18 minutes 3306/tcp, 33060/tcp projects_db_1
590b13d0cfd5 nginx "/docker-entrypoint.…" 19 minutes ago Up 18 minutes 80/tcp projects_onion_1
$
$ docker exec projects_tor_1 cat /etc/tor/torrc
HiddenServiceDir /var/lib/tor/benjamintoll.com
HiddenServicePort 80 onion:80
The HiddenServiceDir will contain the key pair and the hostname. This hidden service directory and its contents were created when the tor binary was executed as soon as the container was instanced from its image (see the ENTRYPOINT directive in the Dockerfile.tor Dockerfile above).
Since I created a named volume bind mount to the HiddenServiceDir location called onion_data, the hidden directory, and most importantly its hostname file, is accessible and I can find the hostname and plug it into a Tor browser, but this is a painful manual step.
But, how do I find the location of the onion_data mount on the Docker host? Here you go:
$ docker volume inspect onion_data
[
{
"CreatedAt": "2021-03-23T23:24:28-04:00",
"Driver": "local",
"Labels": {
"com.docker.compose.project": "projects",
"com.docker.compose.version": "1.28.5",
"com.docker.compose.volume": "onion_data"
},
"Mountpoint": "/var/lib/docker/volumes/onion_data/_data",
"Name": "onion_data",
"Options": null,
"Scope": "local"
}
]
The Mountpoint key value can be cherry-picked using a Go template:
$ docker volume inspect onion_data -f "{{.Mountpoint}}"
/var/lib/docker/volumes/onion_data/_data
Now I know how to retrieve the onion address. Like the previous command, this is again run on the host.
$ sudo cat $(docker volume inspect -f "{{.Mountpoint}}" onion_data)/benjamintoll.com/hostname
56jmsa3xrujuaxkiw54qpvgbpu5jvlrj7qgbl6y6cdf3x7barils55id.onion
Just plug that into a Tor browser, and you should see the copy of the disruptive benjamintoll.com website. Best of all, the hostname will remain the same across cluster restarts.
Weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
Generating a Custom Onion Address
You may be wondering how an onion address is generated. From the Tor v3 onion services protocol specification:
The onion address of a hidden service includes its identity public key, a version field and a basic checksum. All this information is then base32 encoded…
So, if the onion address is by definition random (since it is generated from the public key information), how can a company like ProPublica generate an address that includes keywords and is able to be remembered by humans (propub3r6espa33w.onion)?
Lots of processing power!
Essentially, keys are generated and hashed until the desired pattern is computed. There are tools to help with this, although I have not used them.
Conclusion
As I’ve demonstrated, onionizing an existing or new site is easy. It’s a great way to obtain anonymity for your server!
I highly recommend reading through the Tor onion service protocol in the official Tor documentation. I also did a presentation of the same material at the Leominster Code Meetup, which is now defunct. Tor has provided an enormous level of security and privacy for onion services that is impressive.