diff --git a/.env.example b/.env.example index 1607977..c83b589 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index d60627c..3fb78e8 100644 --- a/README.md +++ b/README.md @@ -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://: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://:7890 -SOCKS5 proxy: socks5://: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 diff --git a/compose.yaml b/compose.yaml index ca4f3e2..7b72864 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh index bf4cbd9..4629333 100755 --- a/tests/container-smoke.sh +++ b/tests/container-smoke.sh @@ -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