|
| 1 | +# Deploying a .onion Mirror of am-i.exposed |
| 2 | + |
| 3 | +## Why a .onion mirror is needed |
| 4 | + |
| 5 | +When users run their own mempool.space instance (Umbrel, Start9, or a custom node), |
| 6 | +the API is typically accessible via a `.onion` Tor hidden service address. However, |
| 7 | +the main site at `https://am-i.exposed` is served over HTTPS, and browsers enforce |
| 8 | +**mixed content blocking** - an HTTPS page cannot make `fetch()` requests to plain |
| 9 | +HTTP URLs (except `localhost`). |
| 10 | + |
| 11 | +This means a user on Tor Browser visiting `https://am-i.exposed` cannot connect to |
| 12 | +their mempool at `http://xyz.onion/api` - the browser silently blocks the request. |
| 13 | + |
| 14 | +A `.onion` mirror of our site solves this: the mirror is served over HTTP (Tor |
| 15 | +provides encryption and authentication at the network layer), so requests from |
| 16 | +`http://our-mirror.onion` to `http://user-mempool.onion/api` are HTTP-to-HTTP |
| 17 | +with no mixed content barrier. |
| 18 | + |
| 19 | +**Note:** CORS headers are still required on the user's mempool nginx config |
| 20 | +regardless of the connection method. See the in-app help section for the nginx |
| 21 | +snippet. |
| 22 | + |
| 23 | +## Why Cloudflare Onion Routing doesn't solve this |
| 24 | + |
| 25 | +Cloudflare offers an "Onion Routing" toggle (available on all plans) that gives |
| 26 | +Tor Browser users a `.onion` route to your site via `alt-svc` headers. However: |
| 27 | + |
| 28 | +- The visible URL stays `https://am-i.exposed` (not a `.onion` address) |
| 29 | +- The page is still served over HTTPS |
| 30 | +- Mixed content restrictions still apply to outgoing `fetch()` calls |
| 31 | +- `window.location.hostname` remains `am-i.exposed`, not `.onion` |
| 32 | + |
| 33 | +Cloudflare's feature is useful for avoiding exit-node exposure when Tor users |
| 34 | +access our site, but it does **not** enable HTTP-to-HTTP API calls to self-hosted |
| 35 | +`.onion` mempool instances. A true `.onion` hidden service is required for that. |
| 36 | + |
| 37 | +## VPS setup instructions |
| 38 | + |
| 39 | +### Prerequisites |
| 40 | + |
| 41 | +- A VPS (any small instance - the site is fully static, minimal resources needed) |
| 42 | +- Root/sudo access |
| 43 | +- The static export files (`pnpm build` produces the `out/` directory) |
| 44 | + |
| 45 | +### 1. Install Tor |
| 46 | + |
| 47 | +```bash |
| 48 | +# Debian/Ubuntu |
| 49 | +sudo apt update |
| 50 | +sudo apt install tor |
| 51 | + |
| 52 | +# Verify it's running |
| 53 | +sudo systemctl status tor |
| 54 | +``` |
| 55 | + |
| 56 | +### 2. Configure the Tor hidden service |
| 57 | + |
| 58 | +Edit `/etc/tor/torrc`: |
| 59 | + |
| 60 | +``` |
| 61 | +HiddenServiceDir /var/lib/tor/am-i-exposed/ |
| 62 | +HiddenServicePort 80 127.0.0.1:8080 |
| 63 | +``` |
| 64 | + |
| 65 | +Restart Tor: |
| 66 | + |
| 67 | +```bash |
| 68 | +sudo systemctl restart tor |
| 69 | +``` |
| 70 | + |
| 71 | +Get your `.onion` address: |
| 72 | + |
| 73 | +```bash |
| 74 | +sudo cat /var/lib/tor/am-i-exposed/hostname |
| 75 | +``` |
| 76 | + |
| 77 | +This outputs something like `abc123xyz456.onion` - this is your mirror address. |
| 78 | + |
| 79 | +### 3. Serve the static site |
| 80 | + |
| 81 | +Install a lightweight web server (nginx, caddy, or even Python's http.server for |
| 82 | +testing): |
| 83 | + |
| 84 | +**Option A: nginx** |
| 85 | + |
| 86 | +```bash |
| 87 | +sudo apt install nginx |
| 88 | +``` |
| 89 | + |
| 90 | +Create `/etc/nginx/sites-available/am-i-exposed`: |
| 91 | + |
| 92 | +```nginx |
| 93 | +server { |
| 94 | + listen 127.0.0.1:8080; |
| 95 | + server_name _; |
| 96 | +
|
| 97 | + root /var/www/am-i-exposed; |
| 98 | + index index.html; |
| 99 | +
|
| 100 | + # Next.js static export uses trailing slashes |
| 101 | + location / { |
| 102 | + try_files $uri $uri/ $uri/index.html =404; |
| 103 | + } |
| 104 | +
|
| 105 | + # Security headers |
| 106 | + add_header X-Content-Type-Options nosniff always; |
| 107 | + add_header X-Frame-Options DENY always; |
| 108 | + add_header Referrer-Policy no-referrer always; |
| 109 | +
|
| 110 | + # No need for HTTPS headers - Tor provides encryption |
| 111 | +} |
| 112 | +``` |
| 113 | + |
| 114 | +Enable and start: |
| 115 | + |
| 116 | +```bash |
| 117 | +sudo ln -s /etc/nginx/sites-available/am-i-exposed /etc/nginx/sites-enabled/ |
| 118 | +sudo nginx -t |
| 119 | +sudo systemctl reload nginx |
| 120 | +``` |
| 121 | + |
| 122 | +**Option B: Caddy (simpler config)** |
| 123 | + |
| 124 | +```bash |
| 125 | +sudo apt install caddy |
| 126 | +``` |
| 127 | + |
| 128 | +Create `/etc/caddy/Caddyfile`: |
| 129 | + |
| 130 | +``` |
| 131 | +:8080 { |
| 132 | + root * /var/www/am-i-exposed |
| 133 | + file_server |
| 134 | + try_files {path} {path}/ {path}/index.html |
| 135 | +} |
| 136 | +``` |
| 137 | + |
| 138 | +### 4. Deploy the static files |
| 139 | + |
| 140 | +From your development machine: |
| 141 | + |
| 142 | +```bash |
| 143 | +# Build the static export |
| 144 | +pnpm build |
| 145 | + |
| 146 | +# Copy to VPS |
| 147 | +rsync -avz out/ user@your-vps:/var/www/am-i-exposed/ |
| 148 | +``` |
| 149 | + |
| 150 | +Or set up a simple CI/CD pipeline that builds and deploys on push to main. |
| 151 | + |
| 152 | +### 5. Verify |
| 153 | + |
| 154 | +Open Tor Browser and navigate to `http://your-onion-address.onion`. The site |
| 155 | +should load identically to the HTTPS version. |
| 156 | + |
| 157 | +Test the privacy-optimal flow: |
| 158 | +1. Open Tor Browser |
| 159 | +2. Visit `http://your-onion-address.onion` |
| 160 | +3. Open API settings (gear icon) |
| 161 | +4. Enter your mempool's `.onion` API address (e.g., `http://mempool-onion.onion/api`) |
| 162 | +5. The health check should succeed (no mixed content barrier) |
| 163 | + |
| 164 | +### 6. Keep it updated |
| 165 | + |
| 166 | +Set up a cron job or deploy hook to sync the `out/` directory whenever the site |
| 167 | +is updated: |
| 168 | + |
| 169 | +```bash |
| 170 | +# Example cron job (runs every 6 hours) |
| 171 | +0 */6 * * * rsync -avz /path/to/out/ /var/www/am-i-exposed/ && sudo systemctl reload nginx |
| 172 | +``` |
| 173 | + |
| 174 | +Or use a GitHub Actions workflow that SSHs into the VPS after a successful build. |
| 175 | + |
| 176 | +## Architecture overview |
| 177 | + |
| 178 | +``` |
| 179 | +Tor Browser user |
| 180 | + | |
| 181 | + | (Tor circuit - encrypted, authenticated) |
| 182 | + v |
| 183 | +[.onion hidden service on VPS] |
| 184 | + | |
| 185 | + | (localhost:8080) |
| 186 | + v |
| 187 | +[nginx serving static files] |
| 188 | + | |
| 189 | + | HTTP fetch() from the page |
| 190 | + | (no mixed content - both HTTP) |
| 191 | + v |
| 192 | +[User's mempool .onion hidden service] |
| 193 | + | |
| 194 | + | (Tor circuit) |
| 195 | + v |
| 196 | +[User's Bitcoin node + mempool backend] |
| 197 | +``` |
| 198 | + |
| 199 | +## CORS reminder |
| 200 | + |
| 201 | +Even with the `.onion` mirror eliminating mixed content, the user's mempool |
| 202 | +instance still needs CORS headers because the browser origins differ |
| 203 | +(`http://our-mirror.onion` vs `http://user-mempool.onion`). |
| 204 | + |
| 205 | +Add to the mempool nginx config on the user's node: |
| 206 | + |
| 207 | +```nginx |
| 208 | +location /api/ { |
| 209 | + add_header 'Access-Control-Allow-Origin' '*' always; |
| 210 | + add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always; |
| 211 | + add_header 'Access-Control-Allow-Headers' 'Content-Type' always; |
| 212 | +
|
| 213 | + if ($request_method = 'OPTIONS') { |
| 214 | + return 204; |
| 215 | + } |
| 216 | +
|
| 217 | + # ... existing proxy/upstream config ... |
| 218 | +} |
| 219 | +``` |
0 commit comments