post: reframe as onion-only hosting exploration, add service-offering + vanity mining section (en + zh)
Deploy / build (push) Successful in 1m2s

This commit is contained in:
2026-09-09 09:17:45 +08:00
parent b3f45bfc0d
commit 3503bb22b8
2 changed files with 102 additions and 124 deletions
@@ -1,54 +1,42 @@
---
title: "Hardening a Tor Onion Service: What Actually Matters"
description: "An audit of a Dockerized onion service, the obfs4 misconception, and the five silent failures that turned out to be the real risk — with fixes and the tests that prove them."
description: "What I learned hosting a service that exists only on Tor: the hardening that holds, the parts that silently break, and whether it's worth offering to clients."
pubDate: 2026-09-09
category: devops
tags: [tor, docker, security, self-hosting, networking]
---
Someone recently told me to add obfs4 to my Dockerized onion service, "for the server side." It's a well-intentioned suggestion and a common mistake. This post is about what I found when I actually audited the stack instead: which of my hardenings had silently rotted, which ones were wrong about how Docker works, and what ended up mattering.
I wanted a small file server that existed only on Tor. Nothing googleable, nothing port-forwarded, no public DNS entry. Just an address I could hand to people I trust, and everyone else gets to pretend it doesn't exist.
**TL;DR:** obfs4 does nothing for an onion service's server. The real risks were: an application container with full outbound internet access, a disabled host firewall, a config file that had never been reloaded, and a tor version behind a security release. All fixable with boring tooling.
While I was researching how to do this right, a lot of what I read online praised obfs4, and for a while I assumed it was a server-side thing I should probably add. It isn't. obfs4 is a client-side transport: it disguises a censored user's connection into the Tor network. A server hosting an onion service doesn't touch it.
## The obfs4 misconception
So if obfs4 isn't the thing that keeps an onion-only host safe, what is? I went through this properly when I built mine, and again months later when I went back to check on it. Some of the setup held up. Some of it had quietly broken. And a couple of things I believed about Docker turned out to be wrong in ways I could measure.
obfs4 is a *pluggable transport* — it disguises **client→Tor** traffic so that censored-network users (DPI, blocking regimes) can reach the Tor network at all. It runs on bridges, which are entry relays with disguised traffic.
## What actually protects the origin
An onion service's server has no use for it. The server connects to the Tor network as a client: it builds circuits to guards and registers itself with introduction points. That traffic uses standard Tor link protocol, and there is no "server-side obfs4" mode. Adding an obfs4 bridge container to your stack does exactly zero for your origin IP. (It's also impossible behind CGNAT anyway — a bridge needs a publicly reachable port.)
Three things, and only the last one requires any work:
What actually protects an onion service origin:
1. **The protocol.** Visitors never connect to your server directly. Your tor process dials out, registers the service with introduction points, and rendezvous happens inside the network. Nobody who visits gets your IP from the visit itself.
2. **Vanguards-lite.** Built into Tor since 0.4.7, this makes guard-discovery attacks (an attacker forcing circuits until they can observe your guard relay) far less practical. You get it by simply running a current Tor.
3. **Not leaking the origin through other channels.** The realistic way an onion host gets exposed is not traffic analysis. It's your own machine leaking: a clearnet service gets compromised, and the attacker just reads your onion keys off the disk. Or the same content appears on both your normal site and your onion, and someone lines them up.
1. **The protocol itself.** Visitors never learn your IP — they don't connect to you; your tor instance connects out and rendezvous happens inside the network.
2. **Vanguards-lite.** Built into Tor ≥ 0.4.7, this defends against guard-discovery attacks where an attacker forces circuits until they observe your guard. You get it by simply *running a current Tor*.
3. **Not leaking the origin through every other channel.** This is where most home setups actually fail — and it has nothing to do with Tor configuration.
## Things that quietly broke
## The audit
These are the parts where re-checking my own server paid for itself.
The stack: a file browser web app served exclusively through a Tor hidden service, both in Docker. tor → app over a private network, no published ports on either container. That part was already textbook.
**The app container had full internet access.** I ran a one-liner inside the container against a public IP echo service, and my home IP came back. There had been an iptables-based block for this, but the rules were gone. Host firewalls get flushed silently by interface changes, container manager restarts, platform updates. A block that nothing re-applies and nothing alarms on is not a block, it's a superstition. This convinced me to stop filtering the app's egress and remove it entirely instead (below).
Layer by layer, here's what I found:
**The host firewall was off.** INPUT and FORWARD policy ACCEPT, and a few dozen ports listening on all interfaces from the other services on the same machine. If any one of those gets owned, the onion keys on that disk belong to the attacker. People spend hours on Tor-specific hardening and skip this.
### ✅ What was already right
**The running tor didn't match its config file.** The torrc on disk said `SocksPort 0`. The process, up for days, was still listening on 127.0.0.1:9050. I had edited the file and never restarted the container. Impact here was small, since the listener was container-local. The lesson generalizes: the config file you wrote is a wish, what the process is actually doing is the truth.
- **No published ports.** The app and tor were reachable only inside their Docker network.
- **Tor hardening basics.** `cap_drop: ALL`, `no-new-privileges`, non-root user, `SocksPort 0`, no ORPort/exit config.
- **The right app image.** The original file browser project is unmaintained; the stack used its actively-maintained fork ("FileBrowser Quantum"), which is also designed to run as a non-root user.
**Tor was a security release behind.** Older than I'd want on a box holding sensitive keys, and my logs carried the warn-spam signatures from a relay-descriptor parsing bug the newer releases fixed. It stopped after the upgrade.
### ❌ What was quietly broken
## The fixes that stayed fixed
**1. The app container had full internet egress.** I ran a one-liner inside the container against a public IP echo service and got my home IP back. There had *been* an iptables-based block task, but the rules were gone — the host firewall had flushed them at some point (interface changes, container-manager restarts, platform updates all do this silently). The lesson: **container isolation built on host iptables that nothing re-applies and nothing alarms on is not isolation.**
### Zero egress, by construction
**2. The host firewall itself was off.** INPUT/FORWARD policy ACCEPT everywhere, a few dozen ports listening on 0.0.0.0 across the rest of the machine's services. For an onion service, this is the realistic deanonymization path: not exotic traffic analysis, but ordinary compromise of a clearnet-facing service — after which the attacker just reads your onion keys off the disk. Guard the keys' home before you worry about guard discovery.
**3. The running tor didn't match its config file.** The on-disk torrc said `SocksPort 0`; the process had been up for days and was still listening on 127.0.0.1:9050. Somebody (me) had edited the file and never restarted the container. Low impact here — container-local, nothing else could reach it — but it's a good reminder that *the config file it's running is not the config file on disk*.
**4. Tor was one security release behind.** Not dramatic on its own, but the newer releases carried fixes around parsing of malformed relay descriptors — my logs had the exact warn-spam signatures, and it disappeared after the upgrade. I treat security releases as mandatory for a box that hosts sensitive keys.
## The fixes
### Zero egress by design, not by script
The core change: put both containers on a Docker network with `internal: true`, and give only the tor container a second NIC for reaching the Tor network.
Both containers go on a Docker network with `internal: true`, and only the tor container gets a second NIC for reaching the Tor network.
```yaml
networks:
@@ -59,22 +47,22 @@ networks:
external: true # ordinary bridge with internet
```
The app now has **no route anywhere** — not to the internet, not to the LAN, not even to the host. `internal: true` removes the gateway entirely, so there's no iptables to flush, no boot task to forget, no silent decay. If the app is compromised, the attacker gets a socket to tor and nothing else. This is enforced by Docker's own networking, which is also why it survives reboots and daemon restarts.
The app has no route to anything now. Not the internet, not the LAN, not even the host, because an internal network has no gateway at all. Nothing to flush, no boot task to remember, no way for it to silently decay. If the app gets compromised, the attacker gains a socket pointing at tor and nothing else. This is the one change I'd call non-negotiable for any app I run this way.
### Non-root, and why `NET_BIND_SERVICE` didn't save me
### Non-root, and the capability surprise
The image's default user is non-root, and I wanted to keep binding port 80. Standard advice: `cap_add: NET_BIND_SERVICE`. It didn't work. The container crash-looped with:
The image runs as non-root by default, and I wanted the app to keep listening on port 80. The standard advice is `cap_add: NET_BIND_SERVICE`. It didn't work. The container crash-looped with:
```
[FATAL] Server error: listen tcp 0.0.0.0:80: bind: permission denied
```
The reason is worth knowing: **Docker grants capabilities to a non-root container only in the *bounding set*, not the effective set.** I verified it with a probe container `grep Cap /proc/self/status` showed `CapBnd` containing bit 10 (NET_BIND_SERVICE) while `CapEff` was 0. A non-root process executing a binary with no file capabilities gets an empty effective set, and the kernel checks the *effective* set on bind. So instead of fighting it: run on an unprivileged port (8080) internally and map the hidden service to it.
The reason surprised me enough that I measured it with a probe container: `grep Cap /proc/self/status` showed `CapBnd` with bit 10 (NET_BIND_SERVICE) set, and `CapEff` at zero. Docker grants capabilities to a non-root container only in the bounding set, not the effective set, and bind() checks the effective set. A non-root process running a binary without file capabilities gets an empty effective set, full stop. So the answer is boring: run on an unprivileged port inside.
```yaml
app:
user: "1000:1000"
cap_drop: [ALL] # hands empty; NET_BIND_SERVICE not needed on 8080
cap_drop: [ALL]
security_opt: [no-new-privileges]
tor:
user: "100:101"
@@ -82,70 +70,71 @@ The reason is worth knowing: **Docker grants capabilities to a non-root containe
security_opt: [no-new-privileges]
```
Hidden service side, one line change:
One line on the tor side remaps the visit:
```
HiddenServicePort 80 app:8080
```
Visitors still land on port 80 of the onion address; only the internal port moved.
People still land on port 80 of the onion address. Only the internal port moved.
### Healthchecks that check the right thing
The tor image's built-in healthcheck probes the SOCKS port. I'd just turned SOCKS off so healthy became permanently "unhealthy." Override it with the thing you actually care about: is the process alive?
The tor image ships with a healthcheck that probes the SOCKS port. I had just turned SOCKS off, so healthy became unhealthy forever. Override it with the question you actually mean: is the process alive?
```yaml
healthcheck:
test: ["CMD", "pgrep", "-x", "tor"]
```
For nginx the trap was subtler: I first used `wget --spider` against the root path. When the site returned 404 (I hadn't uploaded content yet), wget exits non-zero and the container was marked unhealthy — the check was testing the *content*, not the *service*. `nc -z 127.0.0.1 80` tests the port and nothing else.
The nginx trap was subtler. My first version used `wget --spider` against the root path. The site returned 404 (I hadn't uploaded content yet), wget exited non-zero, and the container got marked unhealthy. The check was testing the content, not the service. `nc -z 127.0.0.1 80` tests the port and nothing else.
Also added `depends_on` (tor waits for the app): tor resolves its `HiddenServicePort` target at startup, and if the app container isn't up yet, tor dies with "Unparseable address in hidden service port configuration" and crash-loops. I'd watched exactly that happen in the old logs — four failed starts, nobody noticed, because nothing was watching.
One more thing: tor resolves its `HiddenServicePort` target at startup. If the app container isn't up yet, tor dies with "Unparseable address in hidden service port configuration" and crash-loops. I'd watched four failed starts in the old logs, unnoticed, because nothing was watching. `depends_on` in the compose file fixed the ordering.
### The ownership landmine
After re-keying the service I copied the key material back through a file-share mount. New containers immediately crash-looped:
After generating new keys I copied the key material back through a file-share mount. The next containers crash-looped:
```
[warn] Could not open "/var/lib/tor/.../hs_ed25519_secret_key": Permission denied
```
Files written through the file share were owned by the share user, not by uid 100 that tor runs as. Fix is one command run anywhere, including a throwaway container with the volume attached:
Files written through the share are owned by the share user, not by the uid tor runs as. The fix is one command, runnable anywhere, including a throwaway container with the volume attached:
```bash
docker run --rm -v /path/libTor:/var/lib/tor alpine \
sh -c "chown -R 100:101 /var/lib/tor && chmod -R 700 /var/lib/tor"
```
Rule of thumb going forward: **any key file that passed through a file share gets `chown`ed before the next container start.**
### Verify, don't believe
### Version hygiene
Repulled `osminogin/tor-simple:latest` → tor 0.4.9.11, the current security track. Combined with SocksPort 0 now actually *applied*, the warn-spam stopped and the listener was gone.
## Verify, don't believe
Every fix above ends with a test I can run myself:
Every fix above ends with a test I can run myself. The egress one is my favorite, because the two probes together are convincing: the same wget that fails inside the app's network succeeds from tor's egress network.
```bash
# inside the app: with internal:true, even DNS should fail
wget -T 6 -qO- http://ipv4.icanhazip.com # → "wget: bad address", exit 1
# same probe, on tor's egress network (control group)
wget -T 6 -qO- http://ipv4.icanhazip.com # → <home IP>, exit 0
# same probe, on tor's egress network, as a control
wget -T 6 -qO- http://ipv4.icanhazip.com # → <your IP>, exit 0
```
If you can't exec into a container, attach a throwaway probe container to the *same network* — it tests the network's properties, which is the thing you hardened. Final state: all containers healthy, hidden service up with the same onion address (keys on a persistent volume), zero published ports, and an app that literally cannot resolve `ipv4.icanhazip.com`.
If you can't exec into a container, attach a throwaway probe container to the same network. It tests the network's properties, which is the thing you actually hardened.
## What I'd do next time
## Would I offer this as a service?
1. **Audit running state, not config files.** The config file is a wish; `docker inspect`, in-container `netstat`, and live egress probes are the truth.
2. **Prefer mechanisms that can't silently unwind.** `internal: true` beats an iptables boot task, always.
3. **Healthchecks are tiny observability debt payments.** The day they catch something real (mine caught a crash loop within minutes) they've paid for themselves.
4. **The boring host firewall matters more than exotic Tor hardening.** If the rest of the machine is 0.0.0.0-open, the onion's anonymity dies from a pickaxe attack, not a correlation attack.
5. **Skip the plugs, keep the transport.** Run a current Tor (vanguards-lite included), disable what you don't need, isolate egress, and you're ahead of most onion deployments — no obfs4 required.
I keep thinking about this, because the marginal cost is close to zero: the tor containers and their isolation are already running.
---
Most of my hosting clients want the opposite of an onion service. They want to be found on Google. Selling someone a website that only opens in Tor Browser means selling them secrecy they probably don't need, and it means supporting their visitors through installing Tor Browser.
*No IPs, addresses, or infrastructure specifics were harmed in the writing of this post.*
But there is a real sliver of a market. Lawyers exchanging drafts, auditors, people delivering digital goods, anyone sharing an archive that should never show up in a search index. For those clients the pitch writes itself: no port forwards, no domain, no logs on some platform you don't control, just an address you physically hand to the people who should have it.
There's one trick that sells better than I expected, and it ties to a question everyone asks: can you choose how the address starts? v3 onion addresses are random, but only because the keys are. You can mine them: generate keypairs until the base32 address begins with the prefix you want. Every character costs a factor of 32 in work. Community mining tools on a modern GPU check addresses in the low millions per second, which makes an 8-character prefix a day-or-a-few-days job, 9 characters a patient weeks-long one, and 10 characters a serious multi-GPU commitment. A prefix that starts with the client's brand turns an unmemorable 56-character string into something they can verify is really yours, and in a niche where trust is the entire product, that's real value. I'd mine 8 happily, 9 for a paying client, and quote 10 with a straight face only if they're renting the GPUs.
So: as a bolt-on for a handful of specific clients, yes. As a product line, no. The market is too thin to build a funnel on, and the support burden doesn't shrink with volume. Privacy consulting with an onion attached, fine. Onion hosting as a web hosting tier, someone else's problem.
## What stuck
- **Run the tests, not the config file.** In-container probes and health states are the truth; the yaml is the intention.
- **Prefer mechanisms that can't silently unwind.** `internal: true` beats an iptables boot task every time.
- **Healthchecks are cheap. They caught a crash loop in minutes** where previously nothing watched for days.
- **The boring host firewall matters more than exotic Tor hardening.** Nobody de-anonymizes you with traffic analysis if they can just walk in through an open port.
@@ -1,80 +1,68 @@
---
title: "加固 Tor 洋葱服务:真正重要的是什么"
description: "一次对 Docker 化洋葱服务的审计、obfs4 的常见误解,以及五个被静默失效的加固项才是真正的风险——附修复方法和能证明它们的测试。"
description: "我只想托管一个仅存在于 Tor 上的服务。这篇记录我从头到尾学到的东西:哪些加固扛得住、哪些会悄悄坏掉、以及值不值得把它当成服务卖给客户。"
pubDate: 2026-09-09
category: devops
tags: [tor, docker, security, self-hosting, networking]
---
有人建议我给 Docker 化的洋葱服务"服务器端"加上 obfs4。这是个出于好意的建议,也是个常见误解。这篇文章讲的是我实际审计这套栈之后发现的东西:哪些加固已经悄悄失效、哪些加固误解了 Docker 的机制、以及最后真正起作用的是什么
我想要一个小文件服务器,只存在于 Tor 上。它不会被搜索引擎收录,没有端口转发,没有公开 DNS 记录。就是一个我可以交到信任的人手里的地址,而其他人可以当作它不存在
**太长不看:** obfs4 对洋葱服务的服务器端毫无作用。真正的风险是:应用容器有完整的外网出口、主机防火墙整个没开、一份从未被重新加载的配置文件、以及落后一个安全版本的 Tor。全都是用最普通的工具就能修好的事
研究怎么把它做好时,我在网上读到了很多夸 obfs4 的文章,一度以为那是服务器端该加的东西。它不是。obfs4 是客户端侧的传输层:它伪装的是被封锁网络里的用户接入 Tor 的流量。托管洋葱服务的服务器用不着它
## obfs4 的误解
那么,真正保护一个仅限洋葱访问的主机的,到底是什么?我认真走了一遍全程,几个月后又回头检查了自己的部署。有一部分配置经受住了考验,有一部分已经悄悄坏掉,还有几件我原本对 Docker 的认知,被实测证明是错的。
obfs4 是一种 *pluggable transport*——它伪装的是**客户端→Tor** 的流量,让被封锁网络里的用户(DPI、审查环境)能接入 Tor 网络。它跑在 bridge 上,bridge 是伪装了流量的入口中继。
## 真正保护源站的是三件事
洋葱服务的服务器端用不上它。服务器是以"客户端"身份接入 Tor 网络的:它自己向 guard 建 circuit、向 introduction point 注册。这段流量走的是标准 Tor link 协议,不存在"服务器端 obfs4"这种模式。往你的栈里加一个 obfs4 bridge 容器,对你隐藏源站 IP 的帮助严格为零。(而且如果你在 CGNAT 后面,它客观上也不可行——bridge 需要一个公网可达端口。)
只有最后一件需要花力气:
真正保护洋葱服务源站的是:
1. **协议本身。** 访客从不直连你的服务器。你的 tor 进程主动拨出、向 introduction point 注册,会合发生在 Tor 网络内部。单凭一次访问,访客拿不到你的 IP。
2. **Vanguards-lite。** Tor 0.4.7 起内置。它让 guard-discovery 攻击(攻击者反复制造 circuit 直到观察到你的 guard 中继)变得很难实施。你只要跑一个当前版本的 Tor 就白送这层防护。
3. **别让源站信息从其它渠道泄漏。** 洋葱主机暴露的现实路径不是流量分析。是你自己的机器在漏:某台 clearnet 服务被攻破,攻击者直接从硬盘读走你的洋葱密钥;或者同样的内容同时出现在你的普通网站和洋葱站上,被人对上号。
1. **协议本身。** 访客永远拿不到你的 IP——他们并不连接你;是你的 tor 实例主动外连,会合发生在 Tor 网络内部。
2. **Vanguards-lite。** Tor ≥ 0.4.7 内置,防御"guard discovery"攻击(攻击者反复制造 circuit 直到观察到你的 guard)。你只要*跑一个当前版本的 Tor* 就白送。
3. **别让源站信息从其它渠道泄漏。** 大多数自托管部署栽在这条上——而且它跟 Tor 配置毫无关系。
## 那些悄悄坏掉的东西
## 审计
重检自己服务器时,是这些地方让我觉得花的时间值了。
栈的构成:一个文件浏览 Web 应用,只通过 Tor 隐藏服务对外,两者都在 Docker 里。tor → 应用走私有网络,两个容器都没有发布端口。这部分本来就是教科书做法
**应用容器有完整的互联网访问。** 我在容器里跑了一行访问公网 IP 回显服务的命令,拿回了我的家庭 IP。之前*确实*有过一条基于 iptables 的阻断,但规则已经没了。主机防火墙会被接口变动、容器管理器重启、平台升级静默清空。一条没有任何机制重放、没有任何告警的阻断,不是阻断,是迷信。这件事让我下定决心:与其过滤应用的出网流量,不如干脆把它的出网彻底移除(见下文)
逐层来看我的发现:
**主机防火墙是关的。** INPUT 和 FORWARD 策略全是 ACCEPT,同一台机器上其它服务在全部接口上监听了好几十个端口。其中任何一个被打穿,这块盘上的洋葱密钥就归攻击者了。人们愿意花几个小时做 Tor 专属加固,却跳过这一步。
### ✅ 本来就做对的
**运行中的 tor 和配置文件对不上。** 磁盘上的 torrc 写着 `SocksPort 0`;已经跑了几天进程,还在监听 127.0.0.1:9050。我改了文件,忘了重启容器。这里的实际影响很小,因为监听只在容器内部可达。但教训可以推广:你写的配置文件是愿望,进程实际在做什么才是真相。
- **零发布端口。** 应用和 tor 只在 Docker 网络内部可达
- **Tor 基础加固。** `cap_drop: ALL``no-new-privileges`、非 root 运行、`SocksPort 0`、没有 ORPort/exit 配置。
- **选对了应用镜像。** 原版文件浏览器项目已停止维护;这套栈用的是仍在活跃维护的 fork("FileBrowser Quantum"),而且它本身就是按非 root 运行设计的。
**Tor 落后了一个安全版本。** 对一台存放敏感密钥的机器来说太旧了,而且我的日志里带着新版已修复的中继描述符解析 bug 的刷屏签名。升级后刷屏停了
### ❌ 悄悄坏掉的
## 修好之后不再坏的修复
**1. 应用容器有完整外网出口。** 我在容器里跑了一行命令访问公网 IP 回显服务,拿回了我的家庭 IP。之前*确实*有一个基于 iptables 的阻断任务,但规则没了——主机防火墙在某个时刻把链清空了(网络接口变动、容器管理器重启、平台更新都会这样静默地做)。教训:**建立在主机 iptables 上的容器隔离,如果没有任何机制重放、没有任何告警,就不算隔离。**
### 零出网,靠构造而不是靠过滤
**2. 主机防火墙是关的。** INPUT/FORWARD 策略全是 ACCEPT,整台机器几十个端口在 0.0.0.0 上监听。对洋葱服务来说,这才是现实的去匿名化路径:不是花哨的流量关联分析,而是某台 clearnet 服务被普通攻破——之后攻击者直接从硬盘上读走你的洋葱密钥。**先守好密钥的安放处,再担心 guard discovery。**
**3. 运行中的 tor 和它的配置文件不一致。** 磁盘上的 torrc 写着 `SocksPort 0`;但进程已经跑了几天,还在监听 127.0.0.1:9050。某人(我)改了文件却没重启容器。这里的影响很小——只有容器内部能摸到——但它提醒了一件事:*它正在跑的配置,不等于磁盘上那份配置。*
**4. Tor 落后一个安全版本。** 单看不严重,但新版带着针对畸形中继描述符解析的修复——我的日志里正好全是那类 warn 刷屏签名,升级后就消失了。对一台存放敏感密钥的机器,安全版本我视为必升。
## 修复
### 零出网靠设计,不靠脚本
核心改动:把两个容器放进 `internal: true` 的 Docker 网络,只给 tor 容器第二块网卡去访问 Tor 网络。
两个容器放进 `internal: true` 的 Docker 网络,只有 tor 容器多一张网卡去访问 Tor 网络。
```yaml
networks:
service_net:
driver: bridge
internal: true # 没有网关没有 MASQUERADE没有出路
internal: true # 没有网关,没有 MASQUERADE,没有出路
egress:
external: true # 普通的带外网 bridge
```
应用从此**哪里都去不了**——到不了公网、到不了局域网、连主机都到不了。`internal: true` 直接去掉网关,所以没有 iptables 可被清空、没有开机任务可被遗忘、没有静默衰退。就算应用被攻破,攻击者拿到的最多是一个通向 tor 的 socket,仅此而已。它由 Docker 自己的网络机制强制,这也是它能扛过重启和 daemon 重启的原因
应用现在就哪里都去不了:公网不行,局域网不行,连主机都不行——internal 网络根本没有网关。没有可被清空的 iptables,没有可被遗忘的开机任务,也没有可以静默衰退的余地。就算应用被攻破,攻击者拿到的只是一根指向 tor 的 socket,仅此而已。这是我认为唯一不可妥协的一条改动
### 非 root 化,以及为什么 `NET_BIND_SERVICE` 救不了我
### 非 root,和 capability 的意外
镜像默认就是非 root 用户,而我想继续绑 80 端口。标准建议:`cap_add: NET_BIND_SERVICE`。没用。容器崩溃循环,报:
镜像默认就是非 root 用户,而我想让应用继续监听 80 端口。标准建议`cap_add: NET_BIND_SERVICE`。没用。容器崩溃循环,报:
```
[FATAL] Server error: listen tcp 0.0.0.0:80: bind: permission denied
```
这个原因值得记住:**Docker 给非 root 容器的 capability 只放进 *bounding set*,不会放进 effective set。** 我用探针容器验证过——`grep Cap /proc/self/status` 显示 `CapBnd` 包含 bit 10(NET_BIND_SERVICE),而 `CapEff` 0。非 root 进程执行一个没有 file capability 的二进制,得到的是空的 effective set,而内核在 bind 时检查的是 *effective* set。所以别跟它搏斗:内部改用非特权端口(8080),把隐藏服务映射过去
这个原因让我意外到用探针容器实测了一把:`grep Cap /proc/self/status` 显示 `CapBnd` 里有 bit 10(NET_BIND_SERVICE),而 `CapEff`零。Docker 给非 root 容器的 capability 只进 bounding set,不进 effective set,而 bind() 检查的是 effective set。一个非 root 进程执行没有 file capability 的二进制,拿到的 effective set 就是空的,没有例外。所以答案很无聊:内部改用非特权端口
```yaml
app:
user: "1000:1000"
cap_drop: [ALL] # 空手运行;8080 上不需要 NET_BIND_SERVICE
cap_drop: [ALL]
security_opt: [no-new-privileges]
tor:
user: "100:101"
@@ -82,70 +70,71 @@ networks:
security_opt: [no-new-privileges]
```
隐藏服务侧只需改一行:
tor 侧一行把访问重新映射:
```
HiddenServicePort 80 app:8080
```
访客仍然从洋葱地址的 80 端口进来;变的只是内部端口。
访客仍然从洋葱地址的 80 端口进来变的只是内部端口。
### 检查对的东西的 healthcheck
tor 镜像自带的 healthcheck 探的是 SOCKS 端口。我刚把 SOCKS 关了——于是"healthy"永久变成了"unhealthy"。用你真正关心的东西覆盖它:进程还活着吗?
tor 镜像自带一个探 SOCKS 端口的 healthcheck。我刚把 SOCKS 关了,于是 healthy 永远变成了 unhealthy。用你真正想问的问题覆盖它:进程还活着吗?
```yaml
healthcheck:
test: ["CMD", "pgrep", "-x", "tor"]
```
nginx 那边的坑更隐蔽:我先用了 `wget --spider` 探根路径。站点返回 404(当时内容还没传),wget 以非零退出,容器被标记为不健康——这个检查测的是*内容*,不是*服务*`nc -z 127.0.0.1 80` 只测端口,别的什么都不测。
nginx 的坑更隐蔽。我的第一版用了 `wget --spider` 探根路径。站点返回 404(当时内容还没传),wget 以非零退出,容器被标不健康这个检查测的是内容,不是服务。`nc -z 127.0.0.1 80` 只测端口,别的什么都不测。
另外补了 `depends_on`(tor 等应用先起):tor 在启动时解析 `HiddenServicePort` 的目标地址,如果应用容器还没起来,tor 会报 "Unparseable address in hidden service port configuration" 然后崩溃循环。我在旧日志里就见过这一幕——连续四次启动失败,没人注意到,因为没有任何东西在看着。
还有一件:tor 在启动时解析 `HiddenServicePort` 的目标地址如果应用容器还没起来,tor 会报 "Unparseable address in hidden service port configuration" 然后崩溃循环。我在旧日志里看过它连续四次启动失败,没人发现,因为当时没有任何东西在看着。compose 里的 `depends_on` 修好了启动顺序。
### 属主带来的地雷
### 属主地雷
换完服务密钥后,我通过文件共享挂载把密钥材料拷了回去。容器立刻崩溃循环:
换完密钥后,我通过文件共享挂载把密钥材料拷了回去。之后的容器立刻崩溃循环:
```
[warn] Could not open "/var/lib/tor/.../hs_ed25519_secret_key": Permission denied
```
文件共享写入的文件,属主是共享用户,不是 tor 运行的 uid 100。修复只一条命令——在哪里跑都行,包括挂上卷的一次性容器:
经共享写入的文件,属主是共享用户,不是 tor 运行的 uid。修复只一条命令,在哪里都能跑,包括挂上卷的一次性容器:
```bash
docker run --rm -v /path/libTor:/var/lib/tor alpine \
sh -c "chown -R 100:101 /var/lib/tor && chmod -R 700 /var/lib/tor"
```
此后给自己立的规矩:**任何经过文件共享的密钥文件,在下一次容器启动前必须先 `chown`。**
### 验证,而不是相信
### 版本卫生
重拉 `osminogin/tor-simple:latest` → tor 0.4.9.11,当前安全线。配合 `SocksPort 0` 这次真正生效,日志刷屏停了,监听也没了。
## 验证,而不是相信
上面每个修复都有一个我自己能跑的测试收尾:
上面每个修复都有一个我自己能跑的测试收尾。我最喜欢出网这条,因为两个探针放在一起最有说服力:同一个 wget,在应用的网络里失败,在 tor 的出网网络里成功。
```bash
# 在应用里:internal:true 之下,连 DNS 都应该失败
wget -T 6 -qO- http://ipv4.icanhazip.com # → "wget: bad address", exit 1
# 同一个探针,放 tor 的出网网络上(对照组)
wget -T 6 -qO- http://ipv4.icanhazip.com # → <家庭 IP>, exit 0
# 同一个探针,放 tor 的出网网络上,作为对照组
wget -T 6 -qO- http://ipv4.icanhazip.com # → <你的 IP>, exit 0
```
如果没法 exec 进容器,就挂一个一次性探针容器到*同一个网络*——它测的是网络的属性,而网络正是你加固的对象。最终状态:所有容器 healthy、隐藏服务在线且洋葱地址不变(密钥在持久卷上)、零发布端口、以及一个字面意义上连 `ipv4.icanhazip.com` 都解析不了的应用。
如果没法 exec 进容器,就挂一个一次性探针容器到同一个网络它测的是网络的属性,而正是你加固的对象。
## 下次我会怎么做
## 值得把它做成服务吗?
1. **审计运行态,而不是配置文件。** 配置文件是愿望;`docker inspect`、容器内 `netstat`、实际出网探针才是真相
2. **优先选择无法静默解体的机制。** `internal: true` 永远胜过 iptables 开机任务。
3. **healthcheck 是廉价的观测性分期付款。** 它抓到真问题的那天(我的在几分钟内就抓到了崩溃循环),就回本了。
4. **朴素的主机防火墙比花哨的 Tor 加固重要。** 如果整台机器 0.0.0.0 开放,洋葱服务的匿名性死于撬棍攻击,而不是关联分析。
5. **跳过插件,保持传输层干净。** 跑当前版 Tor(vanguards-lite 已内含)、关掉不需要的东西、隔离出网——你已经领先大多数洋葱部署了,不需要 obfs4。
我一直在想这件事,因为边际成本几乎为零:tor 容器和它的隔离措施本来就在跑
---
我大多数托管客户想要的和洋葱服务正好相反。他们想被 Google 搜到。卖一个只能在 Tor Browser 里打开的网站,等于卖给他们大概率用不上的隐秘性,还要负责教他们的访客安装 Tor Browser。
*本文写作过程中,没有任何 IP、地址或基础设施细节受到伤害。*
但确实有一小块真实市场。交换草稿的律师、审计师、交付数字商品的人,任何想分享一份永远不该出现在搜索引擎索引里的档案的人。对这类客户,销售话术自己就写好了:没有端口转发,没有域名,没有你控制不了的平台上留日志,只有一个你亲手交给该拿到的人的地址。
有个卖点比我预想的好使,它连着一个人人都会问的问题:地址开头能自己选吗?v3 洋葱地址是随机的,但那只是因为密钥是随机的。地址可以挖:不断生成密钥对,直到 base32 地址以你想要的前缀开头。每多一个字符,工作量乘以 32。社区挖矿工具在现代 GPU 上每秒能检查几百万个地址,这意味着 8 字符前缀是"一天到几天"的活,9 字符需要耐心地挖上几周,10 字符则是严肃的多 GPU 投入。一个以客户品牌开头的前缀,能把一行难记的 56 字符地址变成他们可以确认"这真的是你"的东西——在一个信任就是全部产品的细分市场里,这是实打实的价值。8 字符我乐意挖,9 字符为了付费客户可以,10 字符只有在对方自己租 GPU 的情况下我才会一脸平静地报价。
所以:作为给少数特定客户的附加服务,值得;作为一条产品线,不值。市场太薄,撑不起渠道,而且支持成本不会随规模摊薄。"带洋葱地址的隐私咨询",可以;"洋葱托管"作为一个网站套餐,那是别人的生意。
## 最后留下的
- **跑测试,别读配置文件。** 容器内探针和健康状态是真相,yaml 只是意图。
- **优先选择无法静默解体的机制。** `internal: true` 永远胜过 iptables 开机任务。
- **healthcheck 很便宜。** 它几分钟就抓到了一个崩溃循环,而之前没人看着,坏了好几天。
- **朴素的主机防火墙比花哨的 Tor 加固重要。** 如果攻击者能直接从一个开着的端口走进来,就不会有人费劲用流量分析去匿名化你。