HH-620: secure default host bindings
Docker image / Test (pull_request) Successful in 39s
Docker image / Build and publish (pull_request) Successful in 3m2s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-24 17:44:55 +08:00
co-authored by multica-agent
parent 6a349f62c8
commit 925ce84339
4 changed files with 54 additions and 11 deletions
+3 -2
View File
@@ -2,7 +2,8 @@ IMAGE_NAME=mohomo-docker:local
CONTAINER_NAME=mohomo-docker
SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo
SSCLASH_PASSWORD=
WEB_BIND=0.0.0.0
# 9091 is always host-loopback; expose it through a host HTTPS reverse proxy.
WEB_PORT=9091
PROXY_BIND=0.0.0.0
# Public opt-in: use 0.0.0.0 only behind a trusted-network firewall/ACL.
PROXY_BIND=127.0.0.1
PROXY_PORT=7890
+16 -6
View File
@@ -1,6 +1,6 @@
# mohomo-docker
Minimal Mihomo service with the ACL4SSR `Online Full MultiMode` routing model. The host exposes the SSClash Web UI on port `9091` and the mixed proxy on port `7890`; Mihomo's controller remains private to the container.
Minimal Mihomo service with the ACL4SSR `Online Full MultiMode` routing model. The host publishes the SSClash Web UI on loopback port `9091` and the mixed proxy on loopback port `7890` by default; Mihomo's controller remains private to the container.
## Quick start
@@ -12,14 +12,24 @@ docker compose up -d --build
docker compose logs -f ssclash
```
The subscription endpoint must return a Clash/Mihomo proxy-provider YAML document (`proxies:`). Use an HTTPS endpoint when its URL contains a credential. A fresh volume refuses to start without an `SSCLASH_PASSWORD` of at least 12 characters; bootstrap uses SSClash's own `setpass` command before the Web listener starts. Open `http://<server>:9091` and log in with that password. A valid existing authentication file is preserved, so later starts do not require or replace the password. Proxy clients connect to either endpoint:
The subscription endpoint must return a Clash/Mihomo proxy-provider YAML document (`proxies:`). Use an HTTPS endpoint when its URL contains a credential. A fresh volume refuses to start without an `SSCLASH_PASSWORD` of at least 12 characters; bootstrap uses SSClash's own `setpass` command before the Web listener starts. On the Docker host, open `http://127.0.0.1:9091` and log in with that password. A valid existing authentication file is preserved, so later starts do not require or replace the password. Local proxy clients connect to either endpoint:
```text
HTTP proxy: http://<server>:7890
SOCKS5 proxy: socks5://<server>:7890
HTTP proxy: http://127.0.0.1:7890
SOCKS5 proxy: socks5://127.0.0.1:7890
```
`WEB_BIND`, `WEB_PORT`, `PROXY_BIND`, and `PROXY_PORT` are optional deployment overrides; both services bind all host interfaces by default. Authentication prevents anonymous first-run setup, but the Web UI still serves plain HTTP: place it behind HTTPS and additional access control before exposing it to the Internet. Configure Mihomo proxy authentication before publishing port `7890` outside a trusted network.
The Compose boundary fixes plaintext `9091` to host loopback. To provide the required external Web access, configure a host HTTPS reverse proxy to `127.0.0.1:${WEB_PORT:-9091}`; for example, a host-native Caddy configuration is:
```caddyfile
ssclash.example.com {
reverse_proxy 127.0.0.1:9091
}
```
Replace the domain and ensure its DNS reaches the host; Caddy then obtains and serves the TLS certificate. Do not publish 9091 directly as public HTTP.
`WEB_PORT`, `PROXY_BIND`, and `PROXY_PORT` are optional deployment overrides. Port 7890 also defaults to `127.0.0.1`; setting `PROXY_BIND=0.0.0.0` is the explicit public opt-in. The packaged Mihomo proxy has no client authentication, so use that opt-in only when a host firewall or network ACL restricts clients to a trusted range. Prefer binding `PROXY_BIND` to a specific trusted host address.
## Update and secret handling
@@ -56,7 +66,7 @@ The GitHub Actions workflow builds `linux/amd64`, runs tests first, publishes on
./tests/container-smoke.sh
```
The unit suite checks strict fail-closed authentication-file validation, provider-link recovery, atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test verifies that a fresh volume without an administrator password never starts the Web UI, logs in through published port `9091`, checks the exact `7890`/`9091` port set, confirms that plaintext credentials are neither persisted nor logged, and repeats health and login checks after recreating the container with the same volume.
The unit suite checks strict fail-closed authentication-file validation, provider-link recovery, atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test verifies loopback-only Compose defaults and proxy-only public opt-in, proves 7890/9091 are unreachable through a non-loopback host address, checks fresh-volume authentication and credential isolation, and repeats health and login checks after recreating the container with the same volume.
## License boundary
+4 -3
View File
@@ -10,9 +10,10 @@ services:
SUBSCRIPTION_URL: ${SUBSCRIPTION_URL:?set SUBSCRIPTION_URL in .env}
SSCLASH_PASSWORD: ${SSCLASH_PASSWORD:-}
ports:
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-9091}:9091/tcp"
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/tcp"
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/udp"
# Keep the plaintext Web UI behind a host-local HTTPS reverse proxy.
- "127.0.0.1:${WEB_PORT:-9091}:9091/tcp"
- "${PROXY_BIND:-127.0.0.1}:${PROXY_PORT:-7890}:7890/tcp"
- "${PROXY_BIND:-127.0.0.1}:${PROXY_PORT:-7890}:7890/udp"
volumes:
- ssclash-data:/opt/clash
cap_drop:
+31
View File
@@ -76,6 +76,22 @@ assert_web_login() {
"http://127.0.0.1:${web_port}/config" | grep -F 'csrf-token' >/dev/null
}
default_compose=$(SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo docker compose config)
loopback_bindings=$(printf '%s\n' "$default_compose" | awk '$1 == "host_ip:" && $2 == "127.0.0.1" { count++ } END { print count + 0 }')
if [ "$loopback_bindings" -ne 3 ]; then
echo "Compose must bind 7890/tcp, 7890/udp, and 9091/tcp to host loopback by default" >&2
exit 1
fi
public_proxy_compose=$(SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo \
PROXY_BIND=0.0.0.0 WEB_BIND=0.0.0.0 docker compose config)
public_bindings=$(printf '%s\n' "$public_proxy_compose" | awk '$1 == "host_ip:" && $2 == "0.0.0.0" { count++ } END { print count + 0 }')
loopback_bindings=$(printf '%s\n' "$public_proxy_compose" | awk '$1 == "host_ip:" && $2 == "127.0.0.1" { count++ } END { print count + 0 }')
if [ "$public_bindings" -ne 2 ] || [ "$loopback_bindings" -ne 1 ]; then
echo "public opt-in must affect only 7890; 9091 must remain on host loopback" >&2
exit 1
fi
docker build --tag "$image" .
docker run --rm --entrypoint /usr/local/lib/ssclash/clash "$image" \
-t -d /usr/local/share/ssclash -f /usr/local/share/ssclash/config.yaml >/dev/null 2>&1 && {
@@ -166,6 +182,21 @@ assert_published_ports
web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
assert_web_login "$web_port"
host_gateway=$(docker network inspect "$network" --format '{{(index .IPAM.Config 0).Gateway}}')
container_ip=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container")
proxy_port=$(docker port "$container" 7890/tcp | awk -F: 'NR == 1 { print $NF }')
if curl --fail --silent --show-error --max-time 2 --noproxy "" \
--proxy "http://${host_gateway}:${proxy_port}" \
"http://${container_ip}:9090/version" >/dev/null 2>&1; then
echo "default 7890 publish was reachable through a non-loopback host address" >&2
exit 1
fi
if curl --fail --silent --show-error --max-time 2 \
"http://${host_gateway}:${web_port}/login" >/dev/null 2>&1; then
echo "default 9091 publish was reachable through a non-loopback host address" >&2
exit 1
fi
docker exec "$container" grep -Fx 'OPERATING_MODE=server' /opt/clash/.ssclash/settings >/dev/null
docker exec "$container" grep -Fx 'PROXY_MODE=none' /opt/clash/.ssclash/settings >/dev/null
docker exec "$container" test -s /dev/shm/mohomo/subscription.yaml