|
| 1 | +## Introduction |
| 2 | + |
| 3 | +In the previous article, I wrote about a temporary SSH tunneling technique to bypass CGNAT. This method is not suitable for exposing permanent services, at least not without `autossh` manager. Proper tools for this are [rapiz1/rathole](https://github.com/rapiz1/rathole) or [fatedier/frp](https://github.com/fatedier/frp). I chose Rathole since it's written in Rust and offers better performance and benchmarks. |
| 4 | + |
| 5 | +## Prerequisites |
| 6 | + |
| 7 | +- A VPS server with a public IP and Docker, ideally small, you can't use ports `80` and `443` for any other services aside from Rathole |
| 8 | +- A home server |
| 9 | +- A domain name |
| 10 | + |
| 11 | +## Architecture overview |
| 12 | + |
| 13 | +We will use Rathole for an encrypted tunnel between the VPS and the local network. We will also use Traefik since we want to host multiple websites on our home server, just like you would on any server. |
| 14 | + |
| 15 | +The main question is where to run Traefik: |
| 16 | + |
| 17 | +1. On the VPS |
| 18 | +2. On the home server |
| 19 | + |
| 20 | +I highly prefer option 2 because, that way, the entire configuration is stored on our home server. The home server is almost tunnel-agnostic, and you can reuse it on any tunneled or non-tunneled server. Otherwise, we would need to maintain state between the VPS and the home server, debug both together, etc. |
| 21 | + |
| 22 | +Another point is that, with option 2, we avoid the gap of unencrypted traffic on the VPS between Traefik (TLS) and Rathole (Noise Protocol). You can read more about the comparison of these two options in this article: https://blog.mni.li/posts/caddy-rathole-zero-knowledge/. |
| 23 | + |
| 24 | +The downside is that Rathole will exclusively occupy ports `80` and `443` on the VPS, preventing any other process from using them. We won't be able to run other web servers on that VPS, so it's best to use a small one dedicated to this purpose. |
| 25 | + |
| 26 | + |
| 27 | + |
| 28 | +## Rathole server |
| 29 | + |
| 30 | +We will run the Rathole server inside a Docker container on our VPS. Rathole uses the same binary for both the server and client, you just pass the right option (`--server` or `--client`) and the `.toml` configuration file. |
| 31 | + |
| 32 | +Here is the Rathole server configuration [rathole.server.toml](https://github.com/nemanjam/rathole-server/blob/5226ff53992abe930302098677a570151ebff927/rathole.server.toml): |
| 33 | + |
| 34 | +```toml |
| 35 | +# rathole.server.toml |
| 36 | + |
| 37 | +[server] |
| 38 | +bind_addr = "0.0.0.0:2333" |
| 39 | + |
| 40 | +[server.transport] |
| 41 | +type = "noise" |
| 42 | + |
| 43 | +[server.transport.noise] |
| 44 | +local_private_key = "private_key" |
| 45 | + |
| 46 | +[server.services.traefik-http] |
| 47 | +token = "secret_token_1" |
| 48 | +bind_addr = "0.0.0.0:5080" |
| 49 | + |
| 50 | +[server.services.traefik-https] |
| 51 | +token = "secret_token_1" |
| 52 | +bind_addr = "0.0.0.0:5443" |
| 53 | +``` |
| 54 | + |
| 55 | +Let's explain it: we choose port `2333` for the control channel and bind it to all interfaces inside the Docker container with the `0.0.0.0` IP. We choose the `noise` encryption protocol and specify a private key. The public key will be used on the Rathole client. The public and private key pair is generated with: |
| 56 | + |
| 57 | +```bash |
| 58 | +docker run -it --rm rapiz1/rathole --genkey |
| 59 | +``` |
| 60 | + |
| 61 | +Then we define two tunnels: one for HTTP and another for HTTPS. For the HTTP tunnel, we define the name `server.services.traefik-http`, set the value for `token`, and choose port `5080`, and again we bind it to all container interfaces with `0.0.0.0`. Similarly, for HTTPS, we set the name to `server.services.traefik-https`, provide a `token` value, and choose port `5443`. |
| 62 | + |
| 63 | +An important note is that, aside from a different name, a different token value is sufficient to create another tunnel (Rathole service). This practically means we can use a single Rathole server container to expose multiple home servers (Rathole clients) on the same ports `5080` and `5443`, which is pretty convenient. |
| 64 | + |
| 65 | +Token is just a random base64 string, we generate it by running this: |
| 66 | + |
| 67 | +```bash |
| 68 | +openssl rand -base64 32 |
| 69 | +``` |
| 70 | + |
| 71 | +After configuration file we define a Rathole server container with [docker-compose.yml](https://github.com/nemanjam/rathole-server/blob/5226ff53992abe930302098677a570151ebff927/docker-compose.yml): |
| 72 | + |
| 73 | +```yml |
| 74 | +# docker-compose.yml |
| 75 | + |
| 76 | +services: |
| 77 | + rathole: |
| 78 | + image: rapiz1/rathole:v0.5.0 |
| 79 | + container_name: rathole |
| 80 | + command: --server /config/rathole.server.toml |
| 81 | + restart: unless-stopped |
| 82 | + ports: |
| 83 | + # host:container |
| 84 | + - 2333:2333 |
| 85 | + - 80:5080 |
| 86 | + - 443:5443 |
| 87 | + volumes: |
| 88 | + - ./rathole.server.toml:/config/rathole.server.toml:ro |
| 89 | +``` |
| 90 | +
|
| 91 | +In the command, we set the `--server` option, pass the `.toml` configuration file, and mount it as a read-only bind-mount volume. |
| 92 | + |
| 93 | +The important part is the port mappings. Here, you can see that the Rathole server container will occupy ports `2333`, `80`, and `443` exclusively on the host VPS. This practically means we won't be able to run any other web servers on ports `80` and `443`. We will also need to open ports `80`, `443`, and `2333` in the VPS firewall. You don't need to open ports `5080` and `5443`, those are used only by Rathole internally. |
| 94 | + |
| 95 | +## Rathole client and connecting with Traefik |
| 96 | + |
| 97 | +We run the Rathole client and Traefik inside Docker containers on the home server. Configuring the Rathole client and connecting it to Traefik is a bit more complex and tricky. |
| 98 | + |
| 99 | +Here is the Rathole client configuration [core/rathole.client.toml.example](https://github.com/nemanjam/traefik-proxy/blob/e8fece09e31ec99ddd21559f343d0ddea9fb55bf/core/rathole.client.toml.example): |
| 100 | + |
| 101 | +```toml |
| 102 | +# core/rathole.client.toml.example |
| 103 | +
|
| 104 | +[client] |
| 105 | +remote_addr = "123.123.123.123:2333" |
| 106 | +
|
| 107 | +[client.transport] |
| 108 | +type = "noise" |
| 109 | +
|
| 110 | +[client.transport.noise] |
| 111 | +remote_public_key = "public_key" |
| 112 | +
|
| 113 | +# this is the important part |
| 114 | +# Rathole knows traffic comes from 5080 and 5443, control channel told him |
| 115 | +# DON'T do ANY mapping in docker-compose.yml |
| 116 | +# just pass traffic from Rathole on ports which Traefik expects (80 and 443) |
| 117 | +
|
| 118 | +[client.services.traefik-http] |
| 119 | +token = "secret_token_1" |
| 120 | +local_addr = "traefik:80" |
| 121 | +
|
| 122 | +[client.services.traefik-https] |
| 123 | +token = "secret_token_1" |
| 124 | +local_addr = "traefik:443" |
| 125 | +``` |
| 126 | + |
| 127 | +Let's go through it. First, we define the VPS server IP `remote_addr`, the control channel port `2333`, set the `noise` encryption protocol, and this time specify a public key `remote_public_key`. |
| 128 | + |
| 129 | +Now comes the important and tricky part: defining tunnels and services. We repeat the service name and token that we used in the Rathole server config. |
| 130 | + |
| 131 | +**And now the most important part:** `local_addr`, for this we target the Traefik hostname - service name from `core/docker-compose.local.yml` and the Traefik listening ports `80` and `443`. That's it. It might look simple and obvious, this is the correct setup. I must emphasize: don't fall into temptation of setting any additional port mappings in `core/docker-compose.local.yml`, functionality will break, all should be done in `core/rathole.client.toml`. |
| 132 | + |
| 133 | +Another note: You might wonder why ports `5080` and `5443` aren't repeated anywhere in the client config `core/rathole.client.toml`. The answer is "no need for it", we already specified port `2333` for the control channel, which will communicate all additional required information between the Rathole server and client. |
| 134 | + |
| 135 | +Now that we have configured the Rathole client, we need to define Rathole client and Traefik containers. |
| 136 | + |
| 137 | +Here is the Rathole client container and the important part of the Traefik container [core/docker-compose.local.yml](https://github.com/nemanjam/traefik-proxy/blob/e8fece09e31ec99ddd21559f343d0ddea9fb55bf/core/docker-compose.local.yml): |
| 138 | + |
| 139 | +```yml |
| 140 | +# core/docker-compose.local.yml |
| 141 | +
|
| 142 | +services: |
| 143 | + rathole: |
| 144 | + # 1. default official x86 image |
| 145 | + image: rapiz1/rathole:v0.5.0 |
| 146 | +
|
| 147 | + # 2. custom built ARM image (for Raspberry pi) |
| 148 | + # image: nemanjamitic/my-rathole-arm64:v1.0 |
| 149 | +
|
| 150 | + # 3. build for arm - AVOID, use prebuilt ARM image above |
| 151 | + # build: https://github.com/rapiz1/rathole.git#main |
| 152 | + # platform: linux/arm64 |
| 153 | +
|
| 154 | + container_name: rathole |
| 155 | + command: --client /config/rathole.client.toml |
| 156 | + restart: unless-stopped |
| 157 | + volumes: |
| 158 | + - ./rathole.client.toml:/config/rathole.client.toml:ro |
| 159 | + networks: |
| 160 | + - proxy |
| 161 | +
|
| 162 | + traefik: |
| 163 | + image: 'traefik:v2.9.8' |
| 164 | + container_name: traefik |
| 165 | + restart: unless-stopped |
| 166 | +
|
| 167 | + # for this to work both services must be defined in the same docker-compose.yml file |
| 168 | + depends_on: |
| 169 | + - rathole |
| 170 | +
|
| 171 | + # other config... |
| 172 | +
|
| 173 | + networks: |
| 174 | + - proxy |
| 175 | +
|
| 176 | + # leave this commented out, just for explanation |
| 177 | + # Rathole will pass Traffic through proxy network directly on 80 and 443 |
| 178 | + # defined in rathole.client.toml |
| 179 | + # ports: |
| 180 | + # - '80:80' |
| 181 | + # - '443:443' |
| 182 | +
|
| 183 | + # other config... |
| 184 | +``` |
| 185 | + |
| 186 | +Let's start with the Rathole service. Similarly to the server command, we run the Rathole binary, this time in client mode with `--client` and we pass the client config file `/config/rathole.client.toml` which we also bind mount as volume. An important part is that we set both the Rathole and Traefik containers on the same **external** network `proxy` so they can communicate with each other and with the host. |
| 187 | + |
| 188 | +Additional notes about the Rathole image: |
| 189 | + |
| 190 | +- Always make sure to use the same Rathole image version for both the server and client for compatibility. |
| 191 | +- `x86` - By default, Rathole provides only the `x86` image. If your home server uses that architecture, you are good to go. |
| 192 | +- `ARM` - If you have an ARM home server (e.g., Raspberry Pi), you will have to build the image yourself or use a prebuilt, unofficial one. **Avoid** building images on low-power ARM single-board computers, as it will take a long time and require a lot of RAM and CPU power. Instead, either pre-build one yourself and push it to Docker Hub, or you can reuse my `nemanjamitic/my-rathole-arm64:v1.0` image (which uses Rathole `v0.5.0`). |
| 193 | + |
| 194 | +Now, the Traefik container. It must be on the same `proxy` external network as Rathole. Another important part: It must **wait** for the Rathole container to boot up `depends_on: rathole`, because the traffic will come from the Rathole tunnel. **Do not** expose ports `80` and `443`, Rathole has already bound those Traefik container ports, as we defined in the Rathole client config `core/rathole.client.toml`. |
| 195 | + |
| 196 | +The rest of the Traefik container definition is left out here because it's the usual configuration, unrelated to the Rathole tunnel. Below is a quick reminder about the general Traefik configuration. |
| 197 | + |
| 198 | +**Traefik reminder** |
| 199 | + |
| 200 | +1. Provide the `.env` file with variables needed for Traefik: |
| 201 | + |
| 202 | +```bash |
| 203 | +cp .env.example .env |
| 204 | +``` |
| 205 | + |
| 206 | +```bash |
| 207 | +# .env" |
| 208 | +
|
| 209 | +SITE_HOSTNAME=homeserver.my-domain.com |
| 210 | +
|
| 211 | +# important: must put value in quotes "..." and escape $ with \$ |
| 212 | +TRAEFIK_AUTH= |
| 213 | +
|
| 214 | +# will receive expiration notifications |
| 215 | + |
| 216 | +``` |
| 217 | + |
| 218 | +2. On your home server host OS you must create an external Docker network: |
| 219 | + |
| 220 | +```bash |
| 221 | +docker network create proxy |
| 222 | +``` |
| 223 | + |
| 224 | +3. Create `acme.json` file with permission `600`: |
| 225 | + |
| 226 | +```bash |
| 227 | +touch ~/homelab/traefik-proxy/core/traefik-data/acme.json |
| 228 | +
|
| 229 | +sudo chmod 600 ~/homelab/traefik-proxy/core/traefik-data/acme.json |
| 230 | +``` |
| 231 | + |
| 232 | +4. Always start with the staging Acme server for testing and swap to production once satisfied: |
| 233 | + |
| 234 | +```yml |
| 235 | +# core/traefik-data/traefik.yml |
| 236 | +
|
| 237 | +certificatesResolvers: |
| 238 | + letsencrypt: |
| 239 | + acme: |
| 240 | + # always start with staging certificate |
| 241 | + caServer: 'https://acme-staging-v02.api.letsencrypt.org/directory' |
| 242 | + # caServer: 'https://acme-v02.api.letsencrypt.org/directory' |
| 243 | +``` |
| 244 | + |
| 245 | +5. To clear the temporary staging certificates, clear the contents of `acme.json` |
| 246 | + |
| 247 | +```bash |
| 248 | +truncate -s 0 acme.json |
| 249 | +``` |
| 250 | + |
| 251 | +That's it. Once done, we can run Rathole client and Traefik containers on our home server with: |
| 252 | + |
| 253 | +```bash |
| 254 | +docker compose -f docker-compose.local.yml up -d |
| 255 | +``` |
| 256 | + |
| 257 | + |
| 258 | + |
| 259 | +## Exposing multiple servers |
| 260 | + |
| 261 | +Fortunately, Rathole makes it trivial to run multiple tunnels using a single Rathole server. We don't need to open any additional ports or run multiple container instances. We just use a different tunnel name, e.g., `server.services.traefik-http`, and the value for the `token` for each tunnel/service. That's it. |
| 262 | + |
| 263 | +**Rathole server:** |
| 264 | + |
| 265 | +Rathole server configuration file example [rathole.server.toml](https://github.com/nemanjam/rathole-server/blob/5226ff53992abe930302098677a570151ebff927/rathole.server.toml): |
| 266 | + |
| 267 | +```toml |
| 268 | +[server] |
| 269 | +bind_addr = "0.0.0.0:2333" |
| 270 | +
|
| 271 | +[server.transport] |
| 272 | +type = "noise" |
| 273 | +
|
| 274 | +[server.transport.noise] |
| 275 | +local_private_key = "private_key" |
| 276 | +
|
| 277 | +# separated based on token, use the same ports |
| 278 | +
|
| 279 | +# home server 1 - local |
| 280 | +[server.services.traefik-http] |
| 281 | +token = "secret_token_1" |
| 282 | +bind_addr = "0.0.0.0:5080" |
| 283 | +
|
| 284 | +[server.services.traefik-https] |
| 285 | +token = "secret_token_1" |
| 286 | +bind_addr = "0.0.0.0:5443" |
| 287 | +
|
| 288 | +# home server 2 - pi |
| 289 | +[server.services.pi-traefik-http] |
| 290 | +token = "secret_token_2" |
| 291 | +bind_addr = "0.0.0.0:5080" |
| 292 | +
|
| 293 | +[server.services.pi-traefik-https] |
| 294 | +token = "secret_token_2" |
| 295 | +bind_addr = "0.0.0.0:5443" |
| 296 | +``` |
| 297 | + |
| 298 | +In the code above I use this Rathole server to connect a two Rathole client home servers `traefik-http` and `pi-traefik-http` for HTTP tunnels, and `traefik-https` and `pi-traefik-https` for HTTPS tunnels. |
| 299 | + |
| 300 | +**Rathole client:** |
| 301 | + |
| 302 | +On the each Rathole client you just specify which tunnels you are using. For example, on the "pi" home server you will use just its HTTP/HTTPS par and omit the other ones, [core/rathole.client.toml.example](https://github.com/nemanjam/traefik-proxy/blob/e8fece09e31ec99ddd21559f343d0ddea9fb55bf/core/rathole.client.toml.example): |
| 303 | + |
| 304 | +```toml |
| 305 | +# core/rathole.client.toml.example |
| 306 | +
|
| 307 | +[client] |
| 308 | +remote_addr = "123.123.123.123:2333" |
| 309 | +
|
| 310 | +[client.transport] |
| 311 | +type = "noise" |
| 312 | +
|
| 313 | +[client.transport.noise] |
| 314 | +remote_public_key = "public_key" |
| 315 | +
|
| 316 | +# pi |
| 317 | +[client.services.pi-traefik-http] |
| 318 | +token = "secret_token_2" |
| 319 | +local_addr = "traefik:80" |
| 320 | +
|
| 321 | +[client.services.pi-traefik-https] |
| 322 | +token = "secret_token_2" |
| 323 | +local_addr = "traefik:443" |
| 324 | +``` |
| 325 | + |
| 326 | +## Open the firewall on the VPS |
| 327 | + |
| 328 | +Like for any webserver, on the VPS you will need to open ports `80` and `443` to listen for HTTP/HTTPS traffic. Additionally you will need to open the port `2333` for the Rathole control channel - tunnel. |
| 329 | + |
| 330 | + |
| 331 | + |
| 332 | +## Completed code |
| 333 | + |
| 334 | +- **Rathole server:** https://github.com/nemanjam/rathole-server |
| 335 | +- **Rathole client and local Traefik:** https://github.com/nemanjam/traefik-proxy/tree/main/core |
| 336 | + |
| 337 | +## Conclusion |
| 338 | + |
| 339 | +Most consumer-grade internet connections are behind a CGNAT. This setup allows you to bypass CGNAT and host an unlimited number of websites from your home almost for free. You can use it for web servers in virtual machines, LXC containers, SBC computers, etc. - anywhere you can run Docker. |
| 340 | + |
| 341 | +It is simple, cheap, and you can set it up in 30 minutes. Like anything, it also has some downsides, one of them is the overhead latency caused by an additional network hop between the VPS and your home network, but it's a reasonable tradeoff. |
| 342 | + |
| 343 | +Did you make something similar yourself? Can you see room for improvement? Did you use a different method? You tried to run the code and need help with troubleshooting? Let me know in the comments. |
| 344 | + |
| 345 | + |
| 346 | + |
| 347 | +## References |
| 348 | + |
| 349 | +- Rathole repository https://github.com/rapiz1/rathole |
| 350 | +- Local or remote Traefik discussion https://github.com/rapiz1/rathole/issues/169 |
| 351 | +- Local and remote Traefik comparison, Tailscale benchmarks https://blog.mni.li/posts/caddy-rathole-zero-knowledge/ |
| 352 | +- Rathole Docker example configuration https://nitinja.in/tech/ |
| 353 | +- Rathole `.toml` environment variables discussion https://github.com/rapiz1/rathole/issues/218 |
| 354 | +- frp repository https://github.com/fatedier/frp |
0 commit comments