From 37643321fc7d1a7729edafa6f18a8dbabcb784dd Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 24 Aug 2026 12:45:44 +0800 Subject: [PATCH] HH-594: generate ACL4SSR subscription config (#1) --- .env.example | 3 +- Dockerfile | 24 +- LICENSE | 2 + README.md | 70 +++--- cmd/bootstrap/main.go | 29 ++- compose.yaml | 3 +- config/config.yaml | 203 ++++++++++++++- internal/bootstrap/bootstrap.go | 274 +++++++++++++++++++++ internal/bootstrap/bootstrap_test.go | 159 ++++++++++++ internal/bootstrap/config_contract_test.go | 24 ++ scripts/test.sh | 2 +- tests/container-smoke.sh | 142 +++++------ 12 files changed, 792 insertions(+), 143 deletions(-) diff --git a/.env.example b/.env.example index 791f627..5223a04 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ IMAGE_NAME=mohomo-docker:local CONTAINER_NAME=mohomo-docker -WEB_BIND=0.0.0.0 -WEB_PORT=9091 +SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo PROXY_BIND=0.0.0.0 PROXY_PORT=7890 diff --git a/Dockerfile b/Dockerfile index ac9086b..e1999ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,20 @@ RUN case "${TARGETARCH}" in \ gzip -d mihomo.gz; \ chmod 0755 mihomo +FROM alpine:${ALPINE_VERSION} AS acl4ssr-assets +ARG ACL4SSR_REF=6e27259b8625e360699c014f98f978ee7408c644 +ARG ACL4SSR_SHA256=72229e2f0a38fc9776720a20dd4ecb44fdd0b0704bbf1f5141732562a237bff2 +RUN apk add --no-cache ca-certificates curl +RUN curl --fail --show-error --silent --location --retry 3 \ + --output /tmp/acl4ssr.tar.gz \ + "https://github.com/ACL4SSR/ACL4SSR/archive/${ACL4SSR_REF}.tar.gz"; \ + printf '%s %s\n' "${ACL4SSR_SHA256}" /tmp/acl4ssr.tar.gz | sha256sum -c -; \ + mkdir -p /out/rules; \ + tar -xzf /tmp/acl4ssr.tar.gz -C /out/rules --strip-components=3 \ + "ACL4SSR-${ACL4SSR_REF}/Clash/Providers"; \ + tar -xOzf /tmp/acl4ssr.tar.gz "ACL4SSR-${ACL4SSR_REF}/LICENCE" \ + > /out/ACL4SSR-LICENSE + FROM alpine:${ALPINE_VERSION} RUN apk add --no-cache ca-certificates curl gzip tzdata \ && addgroup -S ssclash \ @@ -56,17 +70,19 @@ RUN apk add --no-cache ca-certificates curl gzip tzdata \ COPY --from=bootstrap-builder /out/bootstrap /usr/local/bin/bootstrap COPY --from=release-assets /assets/ssclash /usr/local/bin/ssclash COPY --from=release-assets /assets/mihomo /usr/local/lib/ssclash/clash +COPY --from=acl4ssr-assets /out/rules /usr/local/share/ssclash/rules +COPY --from=acl4ssr-assets /out/ACL4SSR-LICENSE /usr/local/share/licenses/ACL4SSR-LICENSE COPY config/config.yaml /usr/local/share/ssclash/config.yaml ENV SSCLASH_ROOT=/opt/clash \ SSCLASH_TMP=/tmp/ssclash \ SSCLASH_PLATFORM=linux \ - SSCLASH_ADDR=:9091 + SSCLASH_ADDR=127.0.0.1:9091 \ + SAFE_PATHS=/usr/local/share/ssclash USER ssclash VOLUME ["/opt/clash"] -EXPOSE 9091/tcp 7890/tcp 7890/udp +EXPOSE 7890/tcp 7890/udp HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=4 \ - CMD curl --fail --silent --show-error http://127.0.0.1:9091/ >/dev/null + CMD curl --fail --silent --show-error http://127.0.0.1:9090/version >/dev/null ENTRYPOINT ["/usr/local/bin/bootstrap"] -CMD ["serve"] diff --git a/LICENSE b/LICENSE index 0b37e5f..9e26877 100644 --- a/LICENSE +++ b/LICENSE @@ -22,3 +22,5 @@ SOFTWARE. This license covers only the original files in this repository. Downloaded SSClash-Go and Mihomo binaries remain subject to their respective licenses. +ACL4SSR rule files are packaged from their pinned upstream revision and remain +subject to ACL4SSR's CC BY-SA 4.0 license, included in the built image. diff --git a/README.md b/README.md index 5d8dbee..af6c30b 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,62 @@ # mohomo-docker -Docker packaging for the official SSClash-Go daemon and Mihomo core. It intentionally provides only: - -- SSClash embedded Web UI on port `9091`; -- Mihomo HTTP/SOCKS mixed proxy on port `7890`; -- server mode, without TUN, transparent proxy, firewall, routing, or DNS interception. +Minimal Mihomo service with the ACL4SSR `Online Full MultiMode` routing model. The host exposes only mixed proxy port `7890`; SSClash and Mihomo's controller remain loopback-only inside the container. ## Quick start ```sh cp .env.example .env +# Replace only SUBSCRIPTION_URL in .env. docker compose up -d --build docker compose logs -f ssclash ``` -Open `http://:9091`, create the administrator password, review `config.yaml`, and start the proxy from the Web UI. The seeded configuration exposes a direct-only `PROXY` group so port `7890` can be tested before adding a subscription. - -Configure clients with either of these endpoints: +The subscription endpoint must return a Clash/Mihomo proxy-provider YAML document (`proxies:`). Use an HTTPS endpoint when its URL contains a credential. Clients connect to either endpoint: ```text HTTP proxy: http://:7890 SOCKS5 proxy: socks5://:7890 ``` -The Web UI and proxy listen on all host interfaces by default. Change `WEB_BIND` or `PROXY_BIND` in `.env` to restrict them. Do not expose the Web UI to the Internet without HTTPS and an additional access-control layer. Configure Mihomo proxy authentication before exposing port `7890` outside a trusted network. +`PROXY_BIND` and `PROXY_PORT` are optional deployment overrides. Configure Mihomo proxy authentication before publishing port `7890` outside a trusted network. + +## Update and secret handling + +The bootstrap fetches the subscription once before startup and every hour thereafter. Each candidate is limited to 16 MiB and validated with the packaged Mihomo binary before an atomic replacement and hot reload. A failed reload restores and reloads the previous provider; if that recovery cannot be confirmed, both services stop instead of running with uncertain state. + +The subscription URL is read from `SUBSCRIPTION_URL`, removed from child-process environments, and never printed. The generated configuration contains only a local provider path. Subscription data lives under `/dev/shm/mohomo`, so neither the image nor the `/opt/clash` volume stores its URL, response, or node credentials. Container restarts intentionally fetch a fresh subscription instead of persisting credentials. + +Do not commit `.env`; it is ignored by Git. Docker still exposes container environment variables to principals allowed to inspect the container, so restrict Docker daemon access. + +## ACL4SSR rules + +The image packages ACL4SSR provider files from pinned commit `6e27259b8625e360699c014f98f978ee7408c644`. The archive checksum is pinned in the Dockerfile. Runtime routing uses only those local filesโ€”there is no online rule converter or rule-provider download. + +The generated groups and rule order mirror `ACL4SSR_Online_Full_MultiMode.ini`: automatic selection, fallback, load balancing, regional selectors, service/media splits, ad rejection, China direct routing, GFW routing, and final fallback. ## Persistent data -The named volume `ssclash-data` is mounted at `/opt/clash` and stores: +`/opt/clash` stores only SSClash settings, the packaged Mihomo core, and the non-secret generated configuration. Bootstrap creates missing files, preserves existing regular non-empty files, enforces `OPERATING_MODE=server` and `PROXY_MODE=none`, and rejects corrupt or ambiguous persistent state. -- `config.yaml` and named configurations; -- SSClash settings and administrator credentials; -- subscription, rule-provider, and proxy-provider files; -- the active Mihomo core and its runtime data. +## Reproducible inputs -The bootstrap process creates missing files only. Existing Mihomo and configuration files are preserved. It explicitly enforces `OPERATING_MODE=server` and `PROXY_MODE=none`; the latter prevents SSClash from synchronizing gateway-only TProxy, redirect, or TUN listeners into `config.yaml`. Duplicate mode entries or empty runtime files cause startup to fail with a diagnostic message. +The Dockerfile pins: -Resetting the volume deletes configuration and credentials. Inspect the exact Compose project and volume name before doing so. +- SSClash-Go `v6.1.0`, verified with its release checksum file; +- Mihomo `v1.19.30`, verified with repository-pinned SHA-256 values; +- ACL4SSR rules by commit and archive SHA-256. -## Version updates +The GitHub Actions workflow builds `linux/amd64`, runs tests first, publishes only to private GHCR, and attaches SBOM and provenance. Do not make an image containing SSClash-Go public without permission from its copyright holder. -Versions are pinned in the Dockerfile: - -- SSClash-Go `v6.1.0`; -- Mihomo `v1.19.30`. - -SSClash is verified against the checksum file from its official release. Mihomo amd64 and arm64 archives are verified against pinned SHA-256 values. To update either component, update the version and checksums together, then run the complete test suite. - -## GitHub Container Registry - -The GitHub Actions workflow builds `linux/amd64` only. SSClash-Go and Mihomo are downloaded and checksum-verified during the Docker build, so the resulting container never downloads executable files at startup. - -The workflow runs tests before building, publishes to `ghcr.io//mohomo-docker`, attaches SBOM and provenance, and creates these tags: - -- `latest` and `main` from the default branch; -- the Git tag and major/minor tags from releases such as `v1.2.3`; -- an immutable `sha-` tag; -- pull-request tags for build validation only, without pushing. - -Keep the GHCR package visibility **private**. The workflow deliberately does not attempt to change package visibility. Making an image containing SSClash-Go available to third parties conflicts with the upstream binary license unless the copyright holder grants permission. - -## Tests +## Verification ```sh ./scripts/test.sh ./tests/container-smoke.sh ``` -The unit suite enforces at least 65% statement coverage for bootstrap behavior. The container smoke test builds the image, validates the Mihomo configuration, starts the Web UI with all Linux capabilities dropped, authenticates to SSClash, starts Mihomo through the Web API, rejects gateway-listener/error regressions, and sends an HTTPS request through the mapped mixed proxy port. +The unit suite checks atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test builds the image, validates the generated configuration, checks that only port `7890` is published, and verifies that the subscription credential is neither persisted nor logged. ## License boundary -This repository contains only original Docker packaging and bootstrap code. It downloads SSClash-Go and Mihomo from their official releases while building. - -SSClash-Go uses a proprietary binary license that permits personal/internal use but prohibits redistributing its binary to third parties. Do not publish the resulting image without the copyright holder's permission. Mihomo is separately licensed under GPL-3.0. +Original packaging code is MIT licensed. SSClash-Go, Mihomo, and packaged ACL4SSR rule files retain their upstream licenses; the image includes ACL4SSR's CC BY-SA 4.0 text. diff --git a/cmd/bootstrap/main.go b/cmd/bootstrap/main.go index ca6181b..ee63424 100644 --- a/cmd/bootstrap/main.go +++ b/cmd/bootstrap/main.go @@ -1,10 +1,13 @@ package main import ( + "context" + "errors" "log" "os" - "path/filepath" + "os/signal" "syscall" + "time" "git.ipao.vip/rogee/mohomo-docker/internal/bootstrap" ) @@ -14,6 +17,7 @@ const ( defaultCoreSource = "/usr/local/lib/ssclash/clash" defaultConfigSource = "/usr/local/share/ssclash/config.yaml" ssclashBinary = "/usr/local/bin/ssclash" + defaultRuntimeDir = "/dev/shm/mohomo" ) func main() { @@ -37,14 +41,23 @@ func main() { result.ServerSettingsChanged, ) - arguments := os.Args[1:] - if len(arguments) == 0 { - arguments = []string{"serve"} + subscriptionURL := os.Getenv("SUBSCRIPTION_URL") + if subscriptionURL == "" { + log.Fatal("bootstrap: SUBSCRIPTION_URL is required") } - argv := append([]string{filepath.Base(ssclashBinary)}, arguments...) - log.Printf("bootstrap: exec path=%s command=%s", ssclashBinary, arguments[0]) - if err := syscall.Exec(ssclashBinary, argv, os.Environ()); err != nil { - log.Fatalf("bootstrap: exec failed: %v", err) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + err = bootstrap.Run(ctx, bootstrap.RuntimeConfig{ + CoreBinary: defaultCoreSource, + SSClashBinary: ssclashBinary, + ConfigSource: defaultConfigSource, + RuntimeDir: defaultRuntimeDir, + SubscriptionURL: subscriptionURL, + UpdateInterval: time.Hour, + }) + if err != nil && !errors.Is(err, context.Canceled) { + log.Fatalf("bootstrap: service failed: %v", err) } } diff --git a/compose.yaml b/compose.yaml index e9d289f..78c8631 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,8 +6,9 @@ services: container_name: ${CONTAINER_NAME:-mohomo-docker} restart: unless-stopped init: true + environment: + SUBSCRIPTION_URL: ${SUBSCRIPTION_URL:?set SUBSCRIPTION_URL in .env} 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" volumes: diff --git a/config/config.yaml b/config/config.yaml index 342a772..2cb8543 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -7,16 +7,205 @@ ipv6: false external-controller: 127.0.0.1:9090 profile: - store-selected: true - store-fake-ip: true + store-selected: false + store-fake-ip: false -proxies: [] +proxy-providers: + subscription: + type: file + path: ./subscription.yaml + health-check: + enable: true + url: http://www.gstatic.com/generate_204 + interval: 300 + lazy: true proxy-groups: - - name: PROXY + - name: ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ type: select - proxies: - - DIRECT + proxies: [โ™ป๏ธ ่‡ชๅŠจ้€‰ๆ‹ฉ, ๐Ÿ”ฏ ๆ•…้šœ่ฝฌ็งป, ๐Ÿ”ฎ ่ดŸ่ฝฝๅ‡่กก, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข, DIRECT] + - name: ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข + type: select + use: [subscription] + - name: โ™ป๏ธ ่‡ชๅŠจ้€‰ๆ‹ฉ + type: url-test + use: [subscription] + url: http://www.gstatic.com/generate_204 + interval: 300 + tolerance: 50 + - name: ๐Ÿ”ฏ ๆ•…้šœ่ฝฌ็งป + type: fallback + use: [subscription] + url: http://www.gstatic.com/generate_204 + interval: 300 + - name: ๐Ÿ”ฎ ่ดŸ่ฝฝๅ‡่กก + type: load-balance + use: [subscription] + url: http://www.gstatic.com/generate_204 + interval: 300 + strategy: consistent-hashing + - name: ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น + type: url-test + use: [subscription] + filter: "(?i)(ๆธฏ|HK|Hong ?Kong)" + url: http://www.gstatic.com/generate_204 + interval: 300 + - name: ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น + type: url-test + use: [subscription] + filter: "(?i)(ๅฐ|TW|Taiwan)" + url: http://www.gstatic.com/generate_204 + interval: 300 + - name: ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น + type: url-test + use: [subscription] + filter: "(?i)(ๆ–ฐๅŠ ๅก|ๅก|็‹ฎๅŸŽ|SG|Singapore)" + url: http://www.gstatic.com/generate_204 + interval: 300 + - name: ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น + type: url-test + use: [subscription] + filter: "(?i)(ๆ—ฅๆœฌ|ไธœไบฌ|ๅคง้˜ช|JP|Japan)" + url: http://www.gstatic.com/generate_204 + interval: 300 + - name: ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น + type: url-test + use: [subscription] + filter: "(?i)(็พŽ|US|United ?States|Los ?Angeles|Seattle)" + url: http://www.gstatic.com/generate_204 + interval: 300 + - name: ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น + type: url-test + use: [subscription] + filter: "(?i)(้Ÿฉ|้Ÿ“|KR|Korea|Seoul)" + url: http://www.gstatic.com/generate_204 + interval: 300 + - name: ๐ŸŽฅ ๅฅˆ้ฃž่Š‚็‚น + type: select + use: [subscription] + filter: "(?i)(NF|ๅฅˆ้ฃž|่งฃ้”|Netflix|Media)" + - name: ๐Ÿ“ฒ ็”ตๆŠฅๆถˆๆฏ + type: select + proxies: [๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, โ™ป๏ธ ่‡ชๅŠจ้€‰ๆ‹ฉ, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข, DIRECT] + - name: ๐Ÿ’ฌ Aiๅนณๅฐ + type: select + proxies: [๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, โ™ป๏ธ ่‡ชๅŠจ้€‰ๆ‹ฉ, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข, DIRECT] + - name: ๐Ÿ“น ๆฒน็ฎก่ง†้ข‘ + type: select + proxies: [๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, โ™ป๏ธ ่‡ชๅŠจ้€‰ๆ‹ฉ, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข, DIRECT] + - name: ๐ŸŽฅ ๅฅˆ้ฃž่ง†้ข‘ + type: select + proxies: [๐ŸŽฅ ๅฅˆ้ฃž่Š‚็‚น, ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, โ™ป๏ธ ่‡ชๅŠจ้€‰ๆ‹ฉ, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข, DIRECT] + - name: ๐Ÿ“บ ๅทดๅ“ˆๅง†็‰น + type: select + proxies: [๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข, DIRECT] + - name: ๐Ÿ“บ ๅ“”ๅ“ฉๅ“”ๅ“ฉ + type: select + proxies: [๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น] + - name: ๐ŸŒ ๅ›ฝๅค–ๅช’ไฝ“ + type: select + proxies: [๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, โ™ป๏ธ ่‡ชๅŠจ้€‰ๆ‹ฉ, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข, DIRECT] + - name: ๐ŸŒ ๅ›ฝๅ†…ๅช’ไฝ“ + type: select + proxies: [DIRECT, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข] + - name: ๐Ÿ“ข ่ฐทๆญŒFCM + type: select + proxies: [DIRECT, ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข] + - name: โ“‚๏ธ ๅพฎ่ฝฏBing + type: select + proxies: [DIRECT, ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข] + - name: โ“‚๏ธ ๅพฎ่ฝฏไบ‘็›˜ + type: select + proxies: [DIRECT, ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข] + - name: โ“‚๏ธ ๅพฎ่ฝฏๆœๅŠก + type: select + proxies: [DIRECT, ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข] + - name: ๐ŸŽ ่‹นๆžœๆœๅŠก + type: select + proxies: [DIRECT, ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข] + - name: ๐ŸŽฎ ๆธธๆˆๅนณๅฐ + type: select + proxies: [DIRECT, ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข] + - name: ๐ŸŽถ ็ฝ‘ๆ˜“้Ÿณไน + type: select + proxies: [DIRECT, ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, โ™ป๏ธ ่‡ชๅŠจ้€‰ๆ‹ฉ] + - name: ๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž + type: select + proxies: [DIRECT, ๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, โ™ป๏ธ ่‡ชๅŠจ้€‰ๆ‹ฉ] + - name: ๐Ÿ›‘ ๅนฟๅ‘Šๆ‹ฆๆˆช + type: select + proxies: [REJECT, DIRECT] + - name: ๐Ÿƒ ๅบ”็”จๅ‡€ๅŒ– + type: select + proxies: [REJECT, DIRECT] + - name: ๐ŸŸ ๆผ็ฝ‘ไน‹้ฑผ + type: select + proxies: [๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ, โ™ป๏ธ ่‡ชๅŠจ้€‰ๆ‹ฉ, DIRECT, ๐Ÿ‡ญ๐Ÿ‡ฐ ้ฆ™ๆธฏ่Š‚็‚น, ๐Ÿ‡จ๐Ÿ‡ณ ๅฐๆนพ่Š‚็‚น, ๐Ÿ‡ธ๐Ÿ‡ฌ ็‹ฎๅŸŽ่Š‚็‚น, ๐Ÿ‡ฏ๐Ÿ‡ต ๆ—ฅๆœฌ่Š‚็‚น, ๐Ÿ‡บ๐Ÿ‡ฒ ็พŽๅ›ฝ่Š‚็‚น, ๐Ÿ‡ฐ๐Ÿ‡ท ้Ÿฉๅ›ฝ่Š‚็‚น, ๐Ÿš€ ๆ‰‹ๅŠจๅˆ‡ๆข] + +rule-providers: + LocalAreaNetwork: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/LocalAreaNetwork.yaml} + UnBan: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/UnBan.yaml} + BanAD: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/BanAD.yaml} + BanProgramAD: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/BanProgramAD.yaml} + GoogleFCM: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/GoogleFCM.yaml} + GoogleCN: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/GoogleCN.yaml} + SteamCN: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/SteamCN.yaml} + Bing: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Bing.yaml} + OneDrive: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/OneDrive.yaml} + Microsoft: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Microsoft.yaml} + Apple: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Apple.yaml} + Telegram: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Telegram.yaml} + AI: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/AI.yaml} + OpenAi: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/OpenAi.yaml} + NetEaseMusic: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/NetEaseMusic.yaml} + Epic: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Epic.yaml} + Origin: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Origin.yaml} + Sony: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Sony.yaml} + Steam: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Steam.yaml} + Nintendo: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Nintendo.yaml} + YouTube: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/YouTube.yaml} + Netflix: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Netflix.yaml} + Bahamut: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Bahamut.yaml} + BilibiliHMT: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/BilibiliHMT.yaml} + Bilibili: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Bilibili.yaml} + ChinaMedia: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/ChinaMedia.yaml} + ProxyMedia: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/ProxyMedia.yaml} + ProxyGFWlist: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/ProxyGFWlist.yaml} + ChinaDomain: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/ChinaDomain.yaml} + ChinaCompanyIp: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/ChinaCompanyIp.yaml} + Download: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Download.yaml} rules: - - MATCH,PROXY + - RULE-SET,LocalAreaNetwork,๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž + - RULE-SET,UnBan,๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž + - RULE-SET,BanAD,๐Ÿ›‘ ๅนฟๅ‘Šๆ‹ฆๆˆช + - RULE-SET,BanProgramAD,๐Ÿƒ ๅบ”็”จๅ‡€ๅŒ– + - RULE-SET,GoogleFCM,๐Ÿ“ข ่ฐทๆญŒFCM + - RULE-SET,GoogleCN,๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž + - RULE-SET,SteamCN,๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž + - RULE-SET,Bing,โ“‚๏ธ ๅพฎ่ฝฏBing + - RULE-SET,OneDrive,โ“‚๏ธ ๅพฎ่ฝฏไบ‘็›˜ + - RULE-SET,Microsoft,โ“‚๏ธ ๅพฎ่ฝฏๆœๅŠก + - RULE-SET,Apple,๐ŸŽ ่‹นๆžœๆœๅŠก + - RULE-SET,Telegram,๐Ÿ“ฒ ็”ตๆŠฅๆถˆๆฏ + - RULE-SET,AI,๐Ÿ’ฌ Aiๅนณๅฐ + - RULE-SET,OpenAi,๐Ÿ’ฌ Aiๅนณๅฐ + - RULE-SET,NetEaseMusic,๐ŸŽถ ็ฝ‘ๆ˜“้Ÿณไน + - RULE-SET,Epic,๐ŸŽฎ ๆธธๆˆๅนณๅฐ + - RULE-SET,Origin,๐ŸŽฎ ๆธธๆˆๅนณๅฐ + - RULE-SET,Sony,๐ŸŽฎ ๆธธๆˆๅนณๅฐ + - RULE-SET,Steam,๐ŸŽฎ ๆธธๆˆๅนณๅฐ + - RULE-SET,Nintendo,๐ŸŽฎ ๆธธๆˆๅนณๅฐ + - RULE-SET,YouTube,๐Ÿ“น ๆฒน็ฎก่ง†้ข‘ + - RULE-SET,Netflix,๐ŸŽฅ ๅฅˆ้ฃž่ง†้ข‘ + - RULE-SET,Bahamut,๐Ÿ“บ ๅทดๅ“ˆๅง†็‰น + - RULE-SET,BilibiliHMT,๐Ÿ“บ ๅ“”ๅ“ฉๅ“”ๅ“ฉ + - RULE-SET,Bilibili,๐Ÿ“บ ๅ“”ๅ“ฉๅ“”ๅ“ฉ + - RULE-SET,ChinaMedia,๐ŸŒ ๅ›ฝๅ†…ๅช’ไฝ“ + - RULE-SET,ProxyMedia,๐ŸŒ ๅ›ฝๅค–ๅช’ไฝ“ + - RULE-SET,ProxyGFWlist,๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ + - RULE-SET,ChinaDomain,๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž + - RULE-SET,ChinaCompanyIp,๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž + - RULE-SET,Download,๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž + - GEOIP,CN,๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž + - MATCH,๐ŸŸ ๆผ็ฝ‘ไน‹้ฑผ diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index f1a66d4..f6fdac3 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -1,14 +1,25 @@ package bootstrap import ( + "context" "errors" "fmt" "io" + "log" + "net/http" + "net/url" "os" + "os/exec" "path/filepath" "strings" + "syscall" + "time" ) +const maxSubscriptionSize = 16 << 20 + +var errMihomoStateUncertain = errors.New("Mihomo subscription state could not be restored") + type enforcedSetting struct { key string value string @@ -42,6 +53,15 @@ type Result struct { ServerSettingsChanged bool } +type RuntimeConfig struct { + CoreBinary string + SSClashBinary string + ConfigSource string + RuntimeDir string + SubscriptionURL string + UpdateInterval time.Duration +} + func Prepare(config Config) (Result, error) { var result Result root := filepath.Clean(config.Root) @@ -81,6 +101,260 @@ func Prepare(config Config) (Result, error) { return result, nil } +func Run(ctx context.Context, config RuntimeConfig) error { + if err := validateSubscriptionURL(config.SubscriptionURL); err != nil { + return err + } + if config.UpdateInterval <= 0 { + return errors.New("subscription update interval must be positive") + } + runtimeDir := filepath.Clean(config.RuntimeDir) + if !filepath.IsAbs(runtimeDir) || runtimeDir == string(filepath.Separator) { + return fmt.Errorf("unsafe runtime directory %q", config.RuntimeDir) + } + for path, label := range map[string]string{ + config.CoreBinary: "Mihomo core", + config.SSClashBinary: "SSClash binary", + config.ConfigSource: "config source", + } { + if err := validateSource(path, label); err != nil { + return err + } + } + if err := os.MkdirAll(runtimeDir, 0o700); err != nil { + return fmt.Errorf("create in-memory runtime directory: %w", err) + } + + runtimeConfig := filepath.Join(runtimeDir, "config.yaml") + if err := copyFile(config.ConfigSource, runtimeConfig, 0o600); err != nil { + return fmt.Errorf("prepare in-memory config: %w", err) + } + activeSubscription := filepath.Join(runtimeDir, "subscription.yaml") + client := &http.Client{Timeout: 30 * time.Second} + validate := func(candidate string) error { + return validateSubscription(ctx, config, runtimeConfig, candidate) + } + reload := func(ctx context.Context) error { + return reloadSubscription(ctx, client) + } + if err := updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate); err != nil { + return fmt.Errorf("initial subscription update failed") + } + if err := validateMihomoConfig(ctx, config.CoreBinary, runtimeDir, runtimeConfig); err != nil { + return errors.New("generated Mihomo configuration failed validation") + } + + serviceCtx, cancel := context.WithCancel(ctx) + defer cancel() + ssclash := serviceCommand(serviceCtx, config.SSClashBinary, "serve") + mihomo := serviceCommand(serviceCtx, config.CoreBinary, "-d", runtimeDir, "-f", runtimeConfig) + if err := ssclash.Start(); err != nil { + return fmt.Errorf("start SSClash: %w", err) + } + if err := mihomo.Start(); err != nil { + cancel() + _ = ssclash.Wait() + return fmt.Errorf("start Mihomo: %w", err) + } + log.Printf("bootstrap: services started mode=server subscription_update_interval=%s", config.UpdateInterval) + + type processResult struct { + name string + err error + } + exits := make(chan processResult, 2) + go func() { exits <- processResult{name: "SSClash", err: ssclash.Wait()} }() + go func() { exits <- processResult{name: "Mihomo", err: mihomo.Wait()} }() + ticker := time.NewTicker(config.UpdateInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + cancel() + <-exits + <-exits + return ctx.Err() + case result := <-exits: + cancel() + <-exits + if result.err == nil { + return fmt.Errorf("%s exited", result.name) + } + return fmt.Errorf("%s exited: %w", result.name, result.err) + case <-ticker.C: + if err := updateAndReload(ctx, client, config, activeSubscription, validate, reload); errors.Is(err, errMihomoStateUncertain) { + log.Print("bootstrap: subscription rollback failed; stopping services") + cancel() + <-exits + <-exits + return err + } else if err != nil { + log.Print("bootstrap: subscription update rejected; keeping previous valid configuration") + continue + } + log.Print("bootstrap: subscription updated and reloaded") + } + } +} + +func validateSubscriptionURL(raw string) error { + parsed, err := url.ParseRequestURI(raw) + if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return errors.New("SUBSCRIPTION_URL must be an absolute HTTP(S) URL") + } + if parsed.User != nil { + return errors.New("SUBSCRIPTION_URL must not contain user information") + } + return nil +} + +func updateSubscription(ctx context.Context, client *http.Client, endpoint, target string, validate func(string) error) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return errors.New("create subscription request") + } + request.Header.Set("Accept", "application/yaml, text/yaml, text/plain") + request.Header.Set("User-Agent", "mihomo") + response, err := client.Do(request) + if err != nil { + return errors.New("subscription request failed") + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("subscription endpoint returned HTTP %d", response.StatusCode) + } + content, err := io.ReadAll(io.LimitReader(response.Body, maxSubscriptionSize+1)) + if err != nil { + return errors.New("read subscription response") + } + if len(content) == 0 || len(content) > maxSubscriptionSize { + return errors.New("subscription response is empty or too large") + } + + candidate := filepath.Join(filepath.Dir(target), ".subscription-candidate.yaml") + if err := atomicWrite(candidate, 0o600, func(output *os.File) error { + _, err := output.Write(content) + return err + }); err != nil { + return fmt.Errorf("write subscription candidate: %w", err) + } + defer os.Remove(candidate) + if err := validate(candidate); err != nil { + return errors.New("subscription candidate failed Mihomo validation") + } + if err := os.Rename(candidate, target); err != nil { + return fmt.Errorf("activate subscription candidate: %w", err) + } + return nil +} + +func validateSubscription(ctx context.Context, config RuntimeConfig, runtimeConfig, candidate string) error { + content, err := os.ReadFile(runtimeConfig) + if err != nil { + return err + } + candidateConfig := strings.Replace(string(content), "path: ./subscription.yaml", "path: ./"+filepath.Base(candidate), 1) + if candidateConfig == string(content) { + return errors.New("subscription provider path is missing from config") + } + path := filepath.Join(config.RuntimeDir, ".candidate-config.yaml") + if err := atomicWrite(path, 0o600, func(output *os.File) error { + _, err := output.WriteString(candidateConfig) + return err + }); err != nil { + return err + } + defer os.Remove(path) + return validateMihomoConfig(ctx, config.CoreBinary, config.RuntimeDir, path) +} + +func validateMihomoConfig(ctx context.Context, binary, runtimeDir, configPath string) error { + command := exec.CommandContext(ctx, binary, "-t", "-d", runtimeDir, "-f", configPath) + command.Env = childEnvironment() + command.Stdout = io.Discard + command.Stderr = io.Discard + if err := command.Run(); err != nil { + return errors.New("Mihomo validation failed") + } + return nil +} + +func updateAndReload(ctx context.Context, client *http.Client, config RuntimeConfig, target string, validate func(string) error, reload func(context.Context) error) error { + previous, err := os.ReadFile(target) + if err != nil { + return err + } + if err := updateSubscription(ctx, client, config.SubscriptionURL, target, validate); err != nil { + return err + } + if err := reload(ctx); err == nil { + return nil + } + if rollbackErr := atomicWrite(target, 0o600, func(output *os.File) error { + _, writeErr := output.Write(previous) + return writeErr + }); rollbackErr != nil { + return fmt.Errorf("%w: restore previous subscription file: %v", errMihomoStateUncertain, rollbackErr) + } + if err := reload(ctx); err != nil { + return fmt.Errorf("%w: reload previous subscription", errMihomoStateUncertain) + } + return errors.New("new subscription reload failed; previous subscription restored") +} + +func reloadSubscription(ctx context.Context, client *http.Client) error { + request, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://127.0.0.1:9090/providers/proxies/subscription", nil) + if err != nil { + return errors.New("create Mihomo reload request") + } + response, err := client.Do(request) + if err != nil { + return errors.New("Mihomo reload request failed") + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("Mihomo reload returned HTTP %d", response.StatusCode) + } + return nil +} + +func serviceCommand(ctx context.Context, binary string, arguments ...string) *exec.Cmd { + command := exec.CommandContext(ctx, binary, arguments...) + command.Env = childEnvironment() + command.Stdout = os.Stdout + command.Stderr = os.Stderr + command.Cancel = func() error { + return command.Process.Signal(syscall.SIGTERM) + } + command.WaitDelay = 10 * time.Second + return command +} + +func childEnvironment() []string { + environment := os.Environ() + result := environment[:0] + for _, entry := range environment { + if !strings.HasPrefix(entry, "SUBSCRIPTION_URL=") { + result = append(result, entry) + } + } + return result +} + +func copyFile(source, target string, mode os.FileMode) error { + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + return atomicWrite(target, mode, func(output *os.File) error { + _, err := io.Copy(output, input) + return err + }) +} + func validateSource(path, label string) error { info, err := os.Stat(path) if err != nil { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index f0e97ca..0f1990b 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -1,10 +1,17 @@ package bootstrap import ( + "context" + "errors" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" + "sync" + "sync/atomic" "testing" + "time" ) func TestPrepareInitializesServerRuntime(t *testing.T) { @@ -161,6 +168,158 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) { } } +func TestUpdateSubscriptionKeepsPreviousValidFile(t *testing.T) { + t.Parallel() + + response := "proxies:\n - name: valid\n" + var responseLock sync.RWMutex + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + responseLock.RLock() + defer responseLock.RUnlock() + _, _ = writer.Write([]byte(response)) + })) + defer server.Close() + + target := filepath.Join(t.TempDir(), "subscription.yaml") + validate := func(path string) error { + content, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(content), "invalid") { + return errors.New("invalid provider") + } + return nil + } + if err := updateSubscription(context.Background(), server.Client(), server.URL, target, validate); err != nil { + t.Fatalf("initial updateSubscription() error = %v", err) + } + responseLock.Lock() + response = "invalid" + responseLock.Unlock() + if err := updateSubscription(context.Background(), server.Client(), server.URL, target, validate); err == nil { + t.Fatal("updateSubscription() accepted invalid replacement") + } + assertFileContent(t, target, "proxies:\n - name: valid\n") +} + +func TestSubscriptionErrorsDoNotExposeURL(t *testing.T) { + t.Parallel() + + secretURL := "https://subscription.example.invalid/feed?token=do-not-log" + client := &http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) { + return nil, errors.New(request.URL.String()) + })} + err := updateSubscription(context.Background(), client, secretURL, filepath.Join(t.TempDir(), "subscription.yaml"), func(string) error { return nil }) + if err == nil { + t.Fatal("updateSubscription() error = nil") + } + if strings.Contains(err.Error(), "do-not-log") || strings.Contains(err.Error(), secretURL) { + t.Fatalf("updateSubscription() leaked subscription URL: %v", err) + } +} + +func TestUpdateAndReloadRestoresRuntimeAfterAmbiguousFailure(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte("proxies:\n - name: updated\n")) + })) + defer server.Close() + + for _, testCase := range []struct { + name string + recover bool + wantFatal bool + }{ + {name: "rollback reload succeeds", recover: true}, + {name: "rollback reload fails", wantFatal: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + target := writeFixture(t, t.TempDir(), "subscription.yaml", "proxies:\n - name: previous\n") + var applied []string + reload := func(context.Context) error { + content, err := os.ReadFile(target) + if err != nil { + return err + } + applied = append(applied, string(content)) + if len(applied) == 1 || !testCase.recover { + return errors.New("connection lost after server applied provider") + } + return nil + } + + err := updateAndReload(context.Background(), server.Client(), RuntimeConfig{SubscriptionURL: server.URL}, target, func(string) error { return nil }, reload) + if err == nil { + t.Fatal("updateAndReload() error = nil") + } + if got := errors.Is(err, errMihomoStateUncertain); got != testCase.wantFatal { + t.Fatalf("errors.Is(state uncertain) = %t, want %t: %v", got, testCase.wantFatal, err) + } + if len(applied) != 2 || !strings.Contains(applied[0], "updated") || !strings.Contains(applied[1], "previous") { + t.Fatalf("reload sequence = %q, want updated then previous", applied) + } + assertFileContent(t, target, "proxies:\n - name: previous\n") + }) + } +} + +func TestRunStopsServicesWhenRollbackReloadFails(t *testing.T) { + tempDir := t.TempDir() + binary := writeFixture(t, tempDir, "fake-service", "#!/bin/sh\nif [ \"$1\" = -t ]; then exit 0; fi\nexec sleep 3600\n") + if err := os.Chmod(binary, 0o755); err != nil { + t.Fatal(err) + } + config := writeFixture(t, tempDir, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n") + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + name := "updated" + if requests.Add(1) == 1 { + name = "initial" + } + _, _ = writer.Write([]byte("proxies:\n - name: " + name + "\n")) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + err := Run(ctx, RuntimeConfig{ + CoreBinary: binary, + SSClashBinary: binary, + ConfigSource: config, + RuntimeDir: filepath.Join(tempDir, "runtime"), + SubscriptionURL: server.URL, + UpdateInterval: 20 * time.Millisecond, + }) + if !errors.Is(err, errMihomoStateUncertain) { + t.Fatalf("Run() error = %v, want uncertain Mihomo state", err) + } + if requests.Load() < 2 { + t.Fatalf("subscription requests = %d, want initial fetch and timed update", requests.Load()) + } + assertFileContent(t, filepath.Join(tempDir, "runtime", "subscription.yaml"), "proxies:\n - name: initial\n") +} + +func TestValidateSubscriptionURL(t *testing.T) { + t.Parallel() + + for _, raw := range []string{"", "relative/path", "ftp://example.com/feed", "https://user@example.com/feed"} { + if err := validateSubscriptionURL(raw); err == nil { + t.Errorf("validateSubscriptionURL(%q) error = nil", raw) + } + } + if err := validateSubscriptionURL("https://example.com/feed"); err != nil { + t.Fatalf("validateSubscriptionURL() error = %v", err) + } +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (function roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + func writeFixture(t *testing.T, directory, name, content string) string { t.Helper() if err := os.MkdirAll(directory, 0o755); err != nil { diff --git a/internal/bootstrap/config_contract_test.go b/internal/bootstrap/config_contract_test.go index 6ae612c..d5a42d5 100644 --- a/internal/bootstrap/config_contract_test.go +++ b/internal/bootstrap/config_contract_test.go @@ -37,3 +37,27 @@ func TestSeededConfigExposesOnlyServerListeners(t *testing.T) { } } } + +func TestSeededConfigUsesLocalACL4SSRRulesAndMemorySubscription(t *testing.T) { + t.Parallel() + + content, err := os.ReadFile("../../config/config.yaml") + if err != nil { + t.Fatalf("read seeded config: %v", err) + } + config := string(content) + for _, required := range []string{ + "path: ./subscription.yaml", + "RULE-SET,LocalAreaNetwork,๐ŸŽฏ ๅ…จ็ƒ็›ด่ฟž", + "RULE-SET,BanAD,๐Ÿ›‘ ๅนฟๅ‘Šๆ‹ฆๆˆช", + "RULE-SET,ProxyGFWlist,๐Ÿš€ ่Š‚็‚น้€‰ๆ‹ฉ", + "MATCH,๐ŸŸ ๆผ็ฝ‘ไน‹้ฑผ", + } { + if !strings.Contains(config, required) { + t.Errorf("seeded config is missing %q", required) + } + } + if strings.Contains(config, "raw.githubusercontent.com") || strings.Contains(config, "type: http") { + t.Error("seeded config depends on an online rule or subscription provider") + } +} diff --git a/scripts/test.sh b/scripts/test.sh index d82d032..343dd09 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -24,5 +24,5 @@ echo "unit test coverage: ${coverage}%" go test ./... if command -v docker >/dev/null 2>&1; then - docker compose config --quiet + SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo docker compose config --quiet fi diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh index 174ceba..64fbee0 100755 --- a/tests/container-smoke.sh +++ b/tests/container-smoke.sh @@ -4,118 +4,104 @@ set -eu image=${1:-mohomo-docker:smoke} suffix="$$" container="mohomo-docker-smoke-${suffix}" -volume="mohomo-docker-smoke-${suffix}" -cookie="" -login_html="" -config_html="" +provider="mohomo-provider-smoke-${suffix}" +network="mohomo-network-smoke-${suffix}" +volume="mohomo-volume-smoke-${suffix}" +provider_dir="" +secret="container-smoke-secret" -case "$container:$volume" in - mohomo-docker-smoke-*':mohomo-docker-smoke-'*) ;; - *) echo "refusing unsafe cleanup targets" >&2; exit 1 ;; +case "$container:$provider:$network:$volume" in +mohomo-docker-smoke-*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-volume-smoke-'*) ;; +*) echo "refusing unsafe cleanup targets" >&2; exit 1 ;; esac cleanup() { - docker container rm --force "$container" >/dev/null 2>&1 || true + docker container rm --force "$container" "$provider" >/dev/null 2>&1 || true docker volume rm "$volume" >/dev/null 2>&1 || true - [ -z "$cookie" ] || rm -f "$cookie" - [ -z "$login_html" ] || rm -f "$login_html" - [ -z "$config_html" ] || rm -f "$config_html" + docker network rm "$network" >/dev/null 2>&1 || true + [ -z "$provider_dir" ] || rm -rf "$provider_dir" } trap cleanup EXIT INT TERM docker build --tag "$image" . docker run --rm --entrypoint /usr/local/lib/ssclash/clash "$image" \ - -t -d /usr/local/share/ssclash + -t -d /usr/local/share/ssclash -f /usr/local/share/ssclash/config.yaml >/dev/null 2>&1 && { + echo "config validation unexpectedly passed without a subscription provider" >&2 + exit 1 +} +provider_dir=$(mktemp -d) +printf '%s\n' \ + 'proxies:' \ + ' - name: smoke-node' \ + ' type: socks5' \ + ' server: 127.0.0.1' \ + ' port: 9' \ + > "$provider_dir/provider.yaml" +chmod 0755 "$provider_dir" +chmod 0644 "$provider_dir/provider.yaml" + +docker network create "$network" >/dev/null docker volume create "$volume" >/dev/null +docker run --detach --rm \ + --name "$provider" \ + --network "$network" \ + --volume "$provider_dir:/srv:ro" \ + --entrypoint /bin/sh \ + "$image" -c 'while :; do { printf "HTTP/1.1 200 OK\r\nContent-Type: text/yaml\r\nConnection: close\r\n\r\n"; cat /srv/provider.yaml; } | nc -l -p 8080; done' >/dev/null +attempt=0 +until docker exec "$provider" wget -qO- http://127.0.0.1:8080/provider.yaml >/dev/null; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 10 ]; then + echo "subscription fixture did not become ready" >&2 + exit 1 + fi + sleep 1 +done docker run --detach \ --name "$container" \ + --network "$network" \ --cap-drop ALL \ --security-opt no-new-privileges:true \ + --env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml?token=${secret}" \ --volume "$volume:/opt/clash" \ - --publish 127.0.0.1::9091/tcp \ --publish 127.0.0.1::7890/tcp \ + --publish 127.0.0.1::7890/udp \ "$image" >/dev/null -web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }') -proxy_port=$(docker port "$container" 7890/tcp | awk -F: 'NR == 1 { print $NF }') -test -n "$web_port" -test -n "$proxy_port" - attempt=0 -until curl --fail --silent --show-error "http://127.0.0.1:${web_port}/" >/dev/null; do +until [ "$(docker inspect --format '{{.State.Health.Status}}' "$container")" = healthy ]; do attempt=$((attempt + 1)) if [ "$attempt" -ge 30 ]; then docker logs "$container" >&2 - echo "web UI did not become ready" >&2 + echo "container did not become healthy" >&2 exit 1 fi sleep 1 done +published=$(docker port "$container") +printf '%s\n' "$published" | grep -E '^7890/(tcp|udp)' >/dev/null +if printf '%s\n' "$published" | grep -vE '^7890/(tcp|udp)' >/dev/null; then + echo "container published a port other than 7890" >&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" grep -Fx 'mixed-port: 7890' /opt/clash/config.yaml >/dev/null +docker exec "$container" test -s /dev/shm/mohomo/subscription.yaml +docker exec "$container" /usr/local/lib/ssclash/clash \ + -t -d /dev/shm/mohomo -f /dev/shm/mohomo/config.yaml >/dev/null +docker exec "$container" curl --fail --silent --show-error \ + http://127.0.0.1:9090/version >/dev/null -docker exec "$container" /usr/local/bin/ssclash setpass container-smoke-only >/dev/null -cookie=$(mktemp) -login_html=$(mktemp) -config_html=$(mktemp) -curl --fail --silent --show-error --cookie-jar "$cookie" \ - "http://127.0.0.1:${web_port}/login" > "$login_html" -login_csrf=$(sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p' "$login_html" | head -1) -test -n "$login_csrf" -curl --fail --silent --show-error \ - --cookie "$cookie" \ - --cookie-jar "$cookie" \ - --request POST \ - --data-urlencode "csrf=${login_csrf}" \ - --data-urlencode 'password=container-smoke-only' \ - "http://127.0.0.1:${web_port}/login" >/dev/null -curl --fail --silent --show-error \ - --cookie "$cookie" \ - "http://127.0.0.1:${web_port}/config" > "$config_html" -api_csrf=$(sed -n 's/.*name="csrf-token" content="\([^"]*\)".*/\1/p' "$config_html" | head -1) -test -n "$api_csrf" - -start_response=$(curl --fail --silent --show-error \ - --cookie "$cookie" \ - --header "X-CSRF-Token: ${api_csrf}" \ - --header 'Content-Type: application/json' \ - --data '{"action":"start"}' \ - "http://127.0.0.1:${web_port}/api/service") -printf '%s' "$start_response" | grep -F '"ok":true' >/dev/null - -status_response=$(curl --fail --silent --show-error \ - --cookie "$cookie" \ - --header "X-CSRF-Token: ${api_csrf}" \ - "http://127.0.0.1:${web_port}/api/status") -printf '%s' "$status_response" | grep -F '"running":true' >/dev/null -printf '%s' "$status_response" | grep -F '"operatingMode":"server"' >/dev/null - -attempt=0 -until curl --fail --silent --show-error \ - --proxy "http://127.0.0.1:${proxy_port}" \ - --max-time 10 \ - https://example.com/ >/dev/null; do - attempt=$((attempt + 1)) - if [ "$attempt" -ge 20 ]; then - docker logs "$container" >&2 - echo "mixed proxy did not become ready" >&2 - exit 1 - fi - sleep 1 -done - -if docker exec "$container" grep -Eq '^(tproxy-port|redir-port|tun):' /opt/clash/config.yaml; then - docker logs "$container" >&2 - echo "gateway listener leaked into server-only config" >&2 +if docker exec "$container" grep -R -F "$secret" /opt/clash /dev/shm/mohomo >/dev/null 2>&1; then + echo "subscription URL credential was written to runtime files" >&2 exit 1 fi -if docker logs "$container" 2>&1 | grep -Ei '\[(error|fatal)\]|operation not permitted' >/dev/null; then - docker logs "$container" >&2 - echo "container emitted an error during Web-managed startup" >&2 +if docker logs "$container" 2>&1 | grep -F "$secret" >/dev/null; then + echo "subscription URL credential was written to logs" >&2 exit 1 fi -echo "container smoke test passed: web_port=${web_port} proxy_port=${proxy_port}" +echo "container smoke test passed: only port 7890 published; subscription credential not persisted or logged"