diff --git a/.dockerignore b/.dockerignore index 567085a..f9ce846 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ .git .env +subscription.url coverage.out tests README.md diff --git a/.env.example b/.env.example index c83b589..328833a 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,7 @@ IMAGE_NAME=mohomo-docker:local CONTAINER_NAME=mohomo-docker -SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo -SSCLASH_PASSWORD= -# 9091 is always host-loopback; expose it through a host HTTPS reverse proxy. -WEB_PORT=9091 -# Public opt-in: use 0.0.0.0 only behind a trusted-network firewall/ACL. +SUBSCRIPTION_FILE=./subscription.url PROXY_BIND=127.0.0.1 PROXY_PORT=7890 +CONTROLLER_BIND=127.0.0.1 +CONTROLLER_PORT=9090 diff --git a/.gitignore b/.gitignore index fdcd106..c91d4f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env +subscription.url coverage.out diff --git a/AGENTS.md b/AGENTS.md index edaf7da..b45b73d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,24 +2,22 @@ ## Product boundary -- Package the official SSClash-Go release binary with an official Mihomo core. -- Run SSClash in `server` mode only: embedded Web UI plus Mihomo mixed proxy on port 7890. -- Keep both `OPERATING_MODE=server` and `PROXY_MODE=none`; without the latter, SSClash injects a gateway listener during Web-managed start. +- Package the official Mihomo core as a single-container service. +- Publish only mixed proxy port 7890 and controller/ExternalUI port 9090. +- Read the subscription URL only from `/run/secrets/subscription`. - Do not add transparent gateway, TUN, firewall, policy-routing, or DNS-hijack behavior. -- Keep Mihomo's controller private to the container; never publish port 9090. +- Use only the image-packaged ACL4SSR rules and ExternalUI assets at runtime. ## Engineering rules - Pin release versions and verify every downloaded artifact with SHA-256. -- Preserve user-managed files in `/opt/clash`; initialization may only create missing files. -- Fail explicitly on corrupt or ambiguous persistent state. +- Preserve `/data/last-good` across restarts and atomically alternate its two managed slots. +- Never log or commit subscription URLs, tokens, or node credentials. - Add tests before behavior changes and keep Go unit coverage at or above 65%. - Run `./scripts/test.sh` and `./tests/container-smoke.sh` before publishing. -- Keep startup logs sufficient to identify initialization, selected mode, and executed command. ## Licensing -- Do not commit SSClash or Mihomo binaries to this repository. +- Do not commit Mihomo or ExternalUI binaries/assets to this repository. - The Dockerfile may link to official release URLs and users build the image for their own deployment. -- Do not publish a prebuilt image containing SSClash without permission from its copyright holder. -- GitHub Actions may push the amd64 image to private GHCR for this deployment; do not make the package public without that permission. +- Packaged Mihomo, MetaCubeXD, and ACL4SSR files retain their upstream licenses. diff --git a/Dockerfile b/Dockerfile index 1b1397d..8b10b84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,40 +14,23 @@ COPY internal ./internal RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ go build -trimpath -ldflags='-s -w' -o /out/bootstrap ./cmd/bootstrap -FROM alpine:${ALPINE_VERSION} AS release-assets +FROM alpine:${ALPINE_VERSION} AS mihomo-assets ARG TARGETARCH -ARG SSCLASH_VERSION=v6.1.0 ARG MIHOMO_VERSION=v1.19.30 ARG MIHOMO_SHA256_AMD64=cbe553d0319a414bd3a372c5976a252155b2c4882b66bce88a4d6bba9571a553 ARG MIHOMO_SHA256_ARM64=58896873736d28628f66de3677c8654fa0f180662523148e136cff4f6e890069 -WORKDIR /assets RUN apk add --no-cache ca-certificates curl gzip RUN set -eu; \ case "${TARGETARCH}" in \ - amd64|arm64) ;; \ + amd64) asset="mihomo-linux-amd64-v1-${MIHOMO_VERSION}.gz"; checksum="${MIHOMO_SHA256_AMD64}" ;; \ + arm64) asset="mihomo-linux-arm64-${MIHOMO_VERSION}.gz"; checksum="${MIHOMO_SHA256_ARM64}" ;; \ *) echo "unsupported TARGETARCH=${TARGETARCH}; supported: amd64, arm64" >&2; exit 1 ;; \ esac; \ curl --fail --show-error --silent --location --retry 3 \ - --output sha256sums.txt \ - "https://github.com/zerolabnet/SSClash-Go/releases/download/${SSCLASH_VERSION}/sha256sums.txt"; \ - curl --fail --show-error --silent --location --retry 3 \ - --output ssclash \ - "https://github.com/zerolabnet/SSClash-Go/releases/download/${SSCLASH_VERSION}/ssclash-linux-${TARGETARCH}"; \ - expected="$(awk -v asset="ssclash-linux-${TARGETARCH}" '$2 == asset { print $1 }' sha256sums.txt)"; \ - test -n "${expected}"; \ - printf '%s %s\n' "${expected}" ssclash | sha256sum -c -; \ - chmod 0755 ssclash -RUN set -eu; \ - case "${TARGETARCH}" in \ - amd64) asset="mihomo-linux-amd64-v1-${MIHOMO_VERSION}.gz"; mihomo_sha256="${MIHOMO_SHA256_AMD64}" ;; \ - arm64) asset="mihomo-linux-arm64-${MIHOMO_VERSION}.gz"; mihomo_sha256="${MIHOMO_SHA256_ARM64}" ;; \ - esac; \ - curl --fail --show-error --silent --location --retry 3 \ - --output mihomo.gz \ - "https://github.com/MetaCubeX/mihomo/releases/download/${MIHOMO_VERSION}/${asset}"; \ - printf '%s %s\n' "${mihomo_sha256}" mihomo.gz | sha256sum -c -; \ - gzip -d mihomo.gz; \ - chmod 0755 mihomo + --output /mihomo.gz "https://github.com/MetaCubeX/mihomo/releases/download/${MIHOMO_VERSION}/${asset}"; \ + printf '%s %s\n' "${checksum}" /mihomo.gz | sha256sum -c -; \ + gzip -d /mihomo.gz; \ + chmod 0755 /mihomo FROM alpine:${ALPINE_VERSION} AS acl4ssr-assets ARG ACL4SSR_REF=6e27259b8625e360699c014f98f978ee7408c644 @@ -55,37 +38,47 @@ ARG ACL4SSR_SHA256=72229e2f0a38fc9776720a20dd4ecb44fdd0b0704bbf1f5141732562a237b RUN apk add --no-cache ca-certificates curl RUN set -eu; \ curl --fail --show-error --silent --location --retry 3 \ - --output /tmp/acl4ssr.tar.gz \ - "https://github.com/ACL4SSR/ACL4SSR/archive/${ACL4SSR_REF}.tar.gz"; \ + --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 + tar -xOzf /tmp/acl4ssr.tar.gz "ACL4SSR-${ACL4SSR_REF}/LICENCE" > /out/ACL4SSR-LICENSE + +FROM alpine:${ALPINE_VERSION} AS external-ui-assets +ARG EXTERNAL_UI_VERSION=v1.273.0 +ARG EXTERNAL_UI_SHA256=076e05d2e3dc6641a0ec281aa4b97a18193fbcc379d139762c32d90adb22793c +ARG EXTERNAL_UI_LICENSE_SHA256=cd0735ba06f26a0008bbca399890c7ca87fe129aacc302c2e33fb03e60a4e8c3 +RUN apk add --no-cache ca-certificates curl +RUN set -eu; \ + curl --fail --show-error --silent --location --retry 3 \ + --output /tmp/ui.tgz "https://github.com/MetaCubeX/metacubexd/releases/download/${EXTERNAL_UI_VERSION}/compressed-dist.tgz"; \ + printf '%s %s\n' "${EXTERNAL_UI_SHA256}" /tmp/ui.tgz | sha256sum -c -; \ + mkdir -p /out/ui; \ + tar -xzf /tmp/ui.tgz -C /out/ui; \ + curl --fail --show-error --silent --location --retry 3 \ + --output /out/METACUBEXD-LICENSE "https://raw.githubusercontent.com/MetaCubeX/metacubexd/${EXTERNAL_UI_VERSION}/LICENSE"; \ + printf '%s %s\n' "${EXTERNAL_UI_LICENSE_SHA256}" /out/METACUBEXD-LICENSE | sha256sum -c - FROM alpine:${ALPINE_VERSION} -RUN apk add --no-cache ca-certificates curl gzip tzdata \ - && addgroup -S ssclash \ - && adduser -S -G ssclash -h /opt/clash ssclash \ - && mkdir -p /data /opt/clash /tmp/ssclash /usr/local/lib/ssclash /usr/local/share/ssclash \ - && chown -R ssclash:ssclash /data /opt/clash /tmp/ssclash +RUN apk add --no-cache ca-certificates curl tzdata \ + && addgroup -S mihomo \ + && adduser -S -G mihomo -h /data mihomo \ + && mkdir -p /data /run/secrets /usr/local/share/mihomo \ + && chown mihomo:mihomo /data 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=mihomo-assets /mihomo /usr/local/bin/mihomo +COPY --from=acl4ssr-assets /out/rules /usr/local/share/mihomo/rules COPY --from=acl4ssr-assets /out/ACL4SSR-LICENSE /usr/local/share/licenses/ACL4SSR-LICENSE -COPY config/config.yaml /usr/local/share/ssclash/config.yaml +COPY --from=external-ui-assets /out/ui /usr/local/share/mihomo/ui +COPY --from=external-ui-assets /out/METACUBEXD-LICENSE /usr/local/share/licenses/METACUBEXD-LICENSE +COPY config/config.yaml /usr/local/share/mihomo/config.yaml -ENV SSCLASH_ROOT=/opt/clash \ - SSCLASH_TMP=/tmp/ssclash \ - SSCLASH_PLATFORM=linux \ - SSCLASH_ADDR=0.0.0.0:9091 \ - SAFE_PATHS=/usr/local/share/ssclash +ENV SAFE_PATHS=/usr/local/share/mihomo:/data -USER ssclash -VOLUME ["/opt/clash"] -EXPOSE 9091/tcp 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/login >/dev/null +USER mihomo +VOLUME ["/data"] +EXPOSE 7890/tcp 9090/tcp +HEALTHCHECK --interval=15s --timeout=5s --start-period=15s --retries=4 \ + CMD curl --fail --silent --show-error http://127.0.0.1:9090/version >/dev/null ENTRYPOINT ["/usr/local/bin/bootstrap"] diff --git a/LICENSE b/LICENSE index 9e26877..3624ee0 100644 --- a/LICENSE +++ b/LICENSE @@ -21,6 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. This license covers only the original files in this repository. Downloaded -SSClash-Go and Mihomo binaries remain subject to their respective licenses. +Mihomo binaries and MetaCubeXD assets remain subject to their respective +licenses. MetaCubeXD's license is included in the built image. 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 1c9bb15..032b700 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,56 @@ # mohomo-docker -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. +Single-container Mihomo service using the repository-packaged ACL4SSR `Online Full MultiMode` routing model. Runtime access is limited to mixed proxy port 7890 and Mihomo controller/ExternalUI port 9090. -## Quick start +## Start ```sh cp .env.example .env -# Generate a password, then set SUBSCRIPTION_URL and SSCLASH_PASSWORD in .env. -openssl rand -base64 24 +printf '%s\n' 'https://subscription.example.invalid/mihomo' > subscription.url +chmod 0600 subscription.url 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. On the Docker host, open `http://127.0.0.1:9091`, log in with that password, and press **Start**. SSClash then owns the Mihomo process and the Web UI Start/Stop/status controls stay authoritative. A valid existing authentication file is preserved, so later starts do not require or replace the password. Local proxy clients connect to either endpoint: +`subscription.url` must contain exactly one absolute HTTP(S) URL without URL userinfo. Compose mounts it read-only at `/run/secrets/subscription`; the URL is never passed in the environment or written to the image, volume, generated configuration, or logs. Keep this file out of Git. + +Open `http://127.0.0.1:9090/ui/` for the packaged MetaCubeXD interface. It uses Mihomo's controller and proxy-group APIs to inspect status and switch nodes. Proxy clients use: ```text HTTP proxy: http://127.0.0.1:7890 SOCKS5 proxy: socks5://127.0.0.1:7890 +Controller: http://127.0.0.1:9090 ``` -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: +Both ports bind to host loopback by default. `PROXY_BIND`, `PROXY_PORT`, `CONTROLLER_BIND`, and `CONTROLLER_PORT` are optional overrides. Expose port 9090 only to a trusted network or authenticated reverse proxy; this minimal deployment intentionally does not add a second authentication layer. -```caddyfile -ssclash.example.com { - reverse_proxy 127.0.0.1:9091 -} +## Lifecycle and updates + +On a fresh volume, bootstrap downloads, normalizes, generates, and validates a candidate with the packaged Mihomo binary before starting Mihomo. A failure exits nonzero without starting an empty configuration. + +`/data/last-good` atomically points to one of two generation slots. On restart, a valid cached slot starts first and bootstrap immediately attempts an update. Download, HTTP, YAML, generation, Mihomo validation, publication, or reload failures leave the previous slot active. Successful updates use Mihomo's native `PUT /configs` API without replacing the foreground process. + +Updates run hourly from container start. Trigger the same update path immediately for tests or operations: + +```sh +docker kill --signal HUP mohomo-docker ``` -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. +The `bootstrap candidate` subcommand remains available for an isolated one-shot candidate pipeline check; a running service should use `SIGHUP` so the result is hot-reloaded. -`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. +## Runtime assets -## Update and secret handling +The image pins and SHA-256 verifies Mihomo `v1.19.30`, MetaCubeXD `v1.273.0`, and ACL4SSR commit `6e27259b8625e360699c014f98f978ee7408c644`. Rules and UI files are local to the image; runtime does not call an online converter or rule provider. -The bootstrap fetches the subscription once before startup and every hour thereafter. Each candidate is limited to 16 MiB and validated with the active Mihomo binary before an atomic replacement. When Mihomo is running, bootstrap hot-reloads it; when it is stopped, the next Web-managed Start reads the latest provider. A failed reload restores and reloads the previous provider; if that recovery cannot be confirmed, SSClash stops instead of leaving Mihomo in an uncertain state. - -The subscription URL and bootstrap administrator password are removed from child-process environments and never printed. The password is persisted only as SSClash's PBKDF2 authentication file. Before opening the Web listener, bootstrap requires that file to be a readable, process-owned regular file with exact mode `0600` and SSClash v6.1.0's expected PBKDF2 format; a missing or abnormal file fails closed. The generated configuration contains only a local provider path. Subscription data lives under `/dev/shm/mohomo`; `/opt/clash/subscription.yaml` is only a managed link to that in-memory file, so neither the image nor the 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 bootstrap 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 - -`/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. It atomically migrates only the exact legacy packaged `GEOIP,CN` configuration to version 1's local `ChinaIp` rule and records `.mohomo-docker-config-version`; a customized legacy `GEOIP,CN` configuration is preserved and startup fails with an explicit remediation message. On container replacement it repairs only SSClash's exact `rule-providers` and `proxy-providers` links into `SSCLASH_TMP`; unexpected links are rejected without deleting their targets. - -## Reproducible inputs - -The Dockerfile pins: - -- 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. - -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. +The container runs as an unprivileged user with all capabilities dropped, a read-only root filesystem, and only `/data` writable. Do not publish a derivative image without respecting the upstream Mihomo, MetaCubeXD, and ACL4SSR licenses. ## Verification ```sh ./scripts/test.sh ./tests/container-smoke.sh +go vet ./... +go mod verify +git diff --check ``` -The unit suite checks strict fail-closed authentication-file validation, managed-config migration, provider-link recovery, atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test validates a legacy volume with networking disabled, 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 - -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. +Tests use only local fake subscription URLs and fake node data. diff --git a/cmd/bootstrap/main.go b/cmd/bootstrap/main.go index eaa9e81..45f8c06 100644 --- a/cmd/bootstrap/main.go +++ b/cmd/bootstrap/main.go @@ -6,7 +6,6 @@ import ( "log" "os" "os/signal" - "path/filepath" "syscall" "time" @@ -14,12 +13,8 @@ import ( ) const ( - defaultRoot = "/opt/clash" - defaultSSClashTemp = "/tmp/ssclash" - defaultCoreSource = "/usr/local/lib/ssclash/clash" - defaultConfigSource = "/usr/local/share/ssclash/config.yaml" - ssclashBinary = "/usr/local/bin/ssclash" - defaultRuntimeDir = "/dev/shm/mohomo" + defaultCoreSource = "/usr/local/bin/mihomo" + defaultConfigSource = "/usr/local/share/mihomo/config.yaml" defaultSecretPath = "/run/secrets/subscription" defaultDataDir = "/data" ) @@ -43,56 +38,23 @@ func main() { if len(os.Args) != 1 { log.Fatal("bootstrap: usage: bootstrap [candidate]") } - root := envOrDefault("SSCLASH_ROOT", defaultRoot) - log.Printf("bootstrap: preparing persistent runtime root=%s", root) - - result, err := bootstrap.Prepare(bootstrap.Config{ - Root: root, - SSClashTemp: envOrDefault("SSCLASH_TMP", defaultSSClashTemp), - CoreSource: defaultCoreSource, - ConfigSource: defaultConfigSource, - }) - if err != nil { - log.Fatalf("bootstrap: runtime preparation failed: %v", err) - } - adminPasswordInitialized, err := bootstrap.EnsureAdminPassword(root, ssclashBinary, os.Getenv("SSCLASH_PASSWORD")) - if err != nil { - log.Fatalf("bootstrap: admin authentication setup failed: %v", err) - } - log.Printf( - "bootstrap: ready root=%s core_initialized=%t config_initialized=%t config_migrated=%t server_settings_changed=%t admin_password_initialized=%t", - root, - result.CoreInitialized, - result.ConfigInitialized, - result.ConfigMigrated, - result.ServerSettingsChanged, - adminPasswordInitialized, - ) - - subscriptionURL := os.Getenv("SUBSCRIPTION_URL") - if subscriptionURL == "" { - log.Fatal("bootstrap: SUBSCRIPTION_URL is required") - } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - err = bootstrap.Run(ctx, bootstrap.RuntimeConfig{ - Root: root, - CoreBinary: filepath.Join(root, "bin", "clash"), - SSClashBinary: ssclashBinary, - ConfigSource: filepath.Join(root, "config.yaml"), - RuntimeDir: defaultRuntimeDir, - SubscriptionURL: subscriptionURL, - UpdateInterval: time.Hour, + trigger := make(chan os.Signal, 1) + signal.Notify(trigger, syscall.SIGHUP) + defer signal.Stop(trigger) + err := bootstrap.Run(ctx, bootstrap.LifecycleConfig{ + Candidate: bootstrap.CandidateConfig{ + SecretPath: defaultSecretPath, + DataDir: defaultDataDir, + TemplatePath: defaultConfigSource, + MihomoBinary: defaultCoreSource, + }, + UpdateInterval: time.Hour, + Trigger: trigger, }) if err != nil && !errors.Is(err, context.Canceled) { log.Fatalf("bootstrap: service failed: %v", err) } } - -func envOrDefault(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value - } - return fallback -} diff --git a/compose.yaml b/compose.yaml index 7b72864..79ef666 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,21 +1,23 @@ services: - ssclash: + mihomo: build: context: . image: ${IMAGE_NAME:-mohomo-docker:local} container_name: ${CONTAINER_NAME:-mohomo-docker} restart: unless-stopped init: true - environment: - SUBSCRIPTION_URL: ${SUBSCRIPTION_URL:?set SUBSCRIPTION_URL in .env} - SSCLASH_PASSWORD: ${SSCLASH_PASSWORD:-} + read_only: true ports: - # 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" + - "${CONTROLLER_BIND:-127.0.0.1}:${CONTROLLER_PORT:-9090}:9090/tcp" + secrets: + - source: subscription + target: subscription + mode: 0444 volumes: - - ssclash-data:/opt/clash + - mihomo-data:/data + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=16m cap_drop: - ALL security_opt: @@ -27,5 +29,9 @@ services: max-size: 10m max-file: "3" +secrets: + subscription: + file: ${SUBSCRIPTION_FILE:-./subscription.url} + volumes: - ssclash-data: + mihomo-data: diff --git a/config/config.yaml b/config/config.yaml index 29a23a9..fe57072 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -4,7 +4,8 @@ bind-address: "*" mode: rule log-level: info ipv6: false -external-controller: 127.0.0.1:9090 +external-controller: 0.0.0.0:9090 +external-ui: /usr/local/share/mihomo/ui profile: store-selected: false @@ -143,38 +144,38 @@ proxy-groups: 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/Ruleset/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/Ruleset/Bing.yaml} - OneDrive: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/OneDrive.yaml} - Microsoft: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/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/Ruleset/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: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaCompanyIp.yaml} - ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml} - Download: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Download.yaml} + LocalAreaNetwork: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/LocalAreaNetwork.yaml} + UnBan: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/UnBan.yaml} + BanAD: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/BanAD.yaml} + BanProgramAD: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/BanProgramAD.yaml} + GoogleFCM: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/GoogleFCM.yaml} + GoogleCN: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/GoogleCN.yaml} + SteamCN: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/SteamCN.yaml} + Bing: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Bing.yaml} + OneDrive: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/OneDrive.yaml} + Microsoft: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Microsoft.yaml} + Apple: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Apple.yaml} + Telegram: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Telegram.yaml} + AI: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/AI.yaml} + OpenAi: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/OpenAi.yaml} + NetEaseMusic: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/NetEaseMusic.yaml} + Epic: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Epic.yaml} + Origin: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Origin.yaml} + Sony: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Sony.yaml} + Steam: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Steam.yaml} + Nintendo: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Nintendo.yaml} + YouTube: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/YouTube.yaml} + Netflix: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Netflix.yaml} + Bahamut: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Bahamut.yaml} + BilibiliHMT: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/BilibiliHMT.yaml} + Bilibili: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Bilibili.yaml} + ChinaMedia: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/ChinaMedia.yaml} + ProxyMedia: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/ProxyMedia.yaml} + ProxyGFWlist: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/ProxyGFWlist.yaml} + ChinaDomain: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/ChinaDomain.yaml} + ChinaCompanyIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/mihomo/rules/ChinaCompanyIp.yaml} + ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/mihomo/rules/ChinaIp.yaml} + Download: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Download.yaml} rules: - RULE-SET,LocalAreaNetwork,๐ฏ ๅ จ็็ด่ฟ diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 9faf605..c5053e6 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -1,578 +1,17 @@ package bootstrap import ( - "bytes" "context" - "encoding/hex" "errors" "fmt" "io" - "log" - "net/http" - "net/url" "os" "os/exec" "path/filepath" - "strings" "syscall" "time" ) -const ( - maxSubscriptionSize = 16 << 20 - minAdminPasswordLength = 12 - managedConfigVersion = "1" - managedConfigVersionFile = ".mohomo-docker-config-version" - managedChinaIPProvider = " ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}\n" - managedChinaIPRule = " - RULE-SET,ChinaIp,๐ฏ ๅ จ็็ด่ฟ" - legacyChinaIPRule = " - GEOIP,CN,๐ฏ ๅ จ็็ด่ฟ" - defaultControllerURL = "http://127.0.0.1:9090" -) - -var errMihomoStateUncertain = errors.New("Mihomo subscription state could not be restored") - -type enforcedSetting struct { - key string - value string -} - -var serverSettings = []enforcedSetting{ - {key: "OPERATING_MODE=", value: "server"}, - {key: "PROXY_MODE=", value: "none"}, -} - -var runtimeDirectories = []string{ - "bin", - ".ssclash", - "configs", - "local-rules", - "subscriptions", - "ui", -} - -var managedProviderDirectories = []string{"rule-providers", "proxy-providers"} - -type Config struct { - Root string - SSClashTemp string - CoreSource string - ConfigSource string -} - -type Result struct { - CoreInitialized bool - ConfigInitialized bool - ConfigMigrated bool - ServerSettingsChanged bool -} - -type RuntimeConfig struct { - Root string - CoreBinary string - SSClashBinary string - ConfigSource string - RuntimeDir string - SubscriptionURL string - ControllerURL string - UpdateInterval time.Duration -} - -func Prepare(config Config) (Result, error) { - var result Result - root := filepath.Clean(config.Root) - if root == "." || root == string(filepath.Separator) { - return result, fmt.Errorf("unsafe root %q", config.Root) - } - if !filepath.IsAbs(root) { - return result, fmt.Errorf("root must be absolute: %q", config.Root) - } - ssclashTemp := filepath.Clean(config.SSClashTemp) - if !filepath.IsAbs(ssclashTemp) || ssclashTemp == string(filepath.Separator) { - return result, fmt.Errorf("unsafe SSClash temporary directory %q", config.SSClashTemp) - } - if err := validateSource(config.CoreSource, "core source"); err != nil { - return result, err - } - if err := validateSource(config.ConfigSource, "config source"); err != nil { - return result, err - } - - for _, directory := range runtimeDirectories { - if err := os.MkdirAll(filepath.Join(root, directory), 0o755); err != nil { - return result, fmt.Errorf("create runtime directory %s: %w", directory, err) - } - } - for _, directory := range managedProviderDirectories { - if err := reconcileManagedProviderDirectory(root, ssclashTemp, directory); err != nil { - return result, err - } - } - - var err error - result.CoreInitialized, err = copyIfAbsent(config.CoreSource, filepath.Join(root, "bin", "clash"), 0o755) - if err != nil { - return result, fmt.Errorf("initialize Mihomo core: %w", err) - } - result.ConfigInitialized, result.ConfigMigrated, err = prepareManagedConfig( - config.ConfigSource, - filepath.Join(root, "config.yaml"), - filepath.Join(root, managedConfigVersionFile), - ) - if err != nil { - return result, fmt.Errorf("prepare config: %w", err) - } - result.ServerSettingsChanged, err = enforceServerSettings(filepath.Join(root, ".ssclash", "settings")) - if err != nil { - return result, fmt.Errorf("enforce server settings: %w", err) - } - - return result, nil -} - -func EnsureAdminPassword(root, binary, password string) (bool, error) { - root = filepath.Clean(root) - if root == "." || root == string(filepath.Separator) || !filepath.IsAbs(root) { - return false, fmt.Errorf("unsafe root %q", root) - } - passwordPath := filepath.Join(root, ".ssclash", "password") - configured, err := adminPasswordConfigured(passwordPath) - if err != nil { - return false, err - } - if configured { - return false, nil - } - if password == "" { - return false, errors.New("SSCLASH_PASSWORD is required to initialize a fresh volume") - } - if len(password) < minAdminPasswordLength { - return false, fmt.Errorf("SSCLASH_PASSWORD must be at least %d characters", minAdminPasswordLength) - } - if err := validateSource(binary, "SSClash binary"); err != nil { - return false, err - } - - command := exec.Command(binary, "setpass", password) - command.Env = childEnvironment() - command.Stdout = io.Discard - command.Stderr = io.Discard - if err := command.Run(); err != nil { - return false, errors.New("SSClash password initialization failed") - } - configured, err = adminPasswordConfigured(passwordPath) - if err != nil { - return false, err - } - if !configured { - return false, errors.New("SSClash password initialization did not create an authentication file") - } - return true, nil -} - -func adminPasswordConfigured(path string) (bool, error) { - return adminPasswordConfiguredFor(path, uint32(os.Geteuid()), uint32(os.Getegid())) -} - -func adminPasswordConfiguredFor(path string, expectedUID, expectedGID uint32) (bool, error) { - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("inspect SSClash authentication file: %w", err) - } - if !info.Mode().IsRegular() { - return false, errors.New("SSClash authentication file must be a regular file") - } - if info.Mode().Perm() != 0o600 { - return false, fmt.Errorf("SSClash authentication file permissions are %o; want 600", info.Mode().Perm()) - } - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok { - return false, errors.New("SSClash authentication file ownership could not be verified") - } - if stat.Uid != expectedUID || stat.Gid != expectedGID { - return false, fmt.Errorf("SSClash authentication file owner is %d:%d; want %d:%d", stat.Uid, stat.Gid, expectedUID, expectedGID) - } - - file, err := os.Open(path) - if err != nil { - return false, fmt.Errorf("read SSClash authentication file: %w", err) - } - defer file.Close() - openedInfo, err := file.Stat() - if err != nil { - return false, fmt.Errorf("inspect opened SSClash authentication file: %w", err) - } - if !os.SameFile(info, openedInfo) { - return false, errors.New("SSClash authentication file changed while being verified") - } - content, err := io.ReadAll(io.LimitReader(file, 257)) - if err != nil { - return false, fmt.Errorf("read SSClash authentication file: %w", err) - } - if len(content) > 256 { - return false, errors.New("SSClash authentication file is too large") - } - if err := validateAdminPasswordHash(content); err != nil { - return false, err - } - return true, nil -} - -func validateAdminPasswordHash(content []byte) error { - text := string(content) - if !strings.HasSuffix(text, "\n") { - return errors.New("SSClash authentication file has an invalid password hash") - } - parts := strings.Split(strings.TrimSuffix(text, "\n"), "$") - if len(parts) != 4 || parts[0] != "pbkdf2" || parts[1] != "120000" || len(parts[2]) != 32 || len(parts[3]) != 64 { - return errors.New("SSClash authentication file has an invalid password hash") - } - if _, err := hex.DecodeString(parts[2]); err != nil { - return errors.New("SSClash authentication file has an invalid password hash") - } - if _, err := hex.DecodeString(parts[3]); err != nil { - return errors.New("SSClash authentication file has an invalid password hash") - } - return nil -} - -func reconcileManagedProviderDirectory(root, ssclashTemp, directory string) error { - path := filepath.Join(root, directory) - expectedTarget := filepath.Join(ssclashTemp, directory) - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - if err := os.MkdirAll(path, 0o755); err != nil { - return fmt.Errorf("create runtime directory %s: %w", directory, err) - } - return nil - } - if err != nil { - return fmt.Errorf("inspect runtime directory %s: %w", directory, err) - } - if info.IsDir() { - return nil - } - if info.Mode()&os.ModeSymlink == 0 { - return fmt.Errorf("runtime path %s is not a directory", directory) - } - target, err := os.Readlink(path) - if err != nil { - return fmt.Errorf("read runtime symlink %s: %w", directory, err) - } - if target != expectedTarget { - return fmt.Errorf("runtime path %s has unexpected symlink target %q", directory, target) - } - if err := os.Remove(path); err != nil { - return fmt.Errorf("remove managed runtime symlink %s: %w", directory, err) - } - if err := os.MkdirAll(path, 0o755); err != nil { - return fmt.Errorf("recreate runtime directory %s: %w", directory, err) - } - return 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") - } - root := filepath.Clean(config.Root) - if !filepath.IsAbs(root) || root == string(filepath.Separator) { - return fmt.Errorf("unsafe root %q", config.Root) - } - 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} - controllerURL := strings.TrimRight(config.ControllerURL, "/") - if controllerURL == "" { - controllerURL = defaultControllerURL - } - validate := func(candidate string) error { - return validateSubscription(ctx, config, runtimeConfig, candidate) - } - reload := func(ctx context.Context) error { - return reloadSubscription(ctx, client, controllerURL) - } - if err := updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate); err != nil { - return fmt.Errorf("initial subscription update failed: %w", err) - } - if err := validateMihomoConfig(ctx, config.CoreBinary, runtimeDir, runtimeConfig); err != nil { - return errors.New("generated Mihomo configuration failed validation") - } - if err := ensureSubscriptionLink(filepath.Join(root, "subscription.yaml"), activeSubscription); err != nil { - return err - } - - serviceCtx, cancel := context.WithCancel(ctx) - defer cancel() - ssclash := serviceCommand(serviceCtx, config.SSClashBinary, "serve") - if err := ssclash.Start(); err != nil { - return fmt.Errorf("start SSClash: %w", err) - } - log.Printf("bootstrap: SSClash started mode=server core_owner=ssclash subscription_update_interval=%s", config.UpdateInterval) - - exit := make(chan error, 1) - go func() { exit <- ssclash.Wait() }() - ticker := time.NewTicker(config.UpdateInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - cancel() - <-exit - return ctx.Err() - case err := <-exit: - if err == nil { - return errors.New("SSClash exited") - } - return fmt.Errorf("SSClash exited: %w", err) - case <-ticker.C: - running := mihomoRunning(ctx, client, controllerURL) - var err error - if running { - err = updateAndReload(ctx, client, config, activeSubscription, validate, reload) - } else { - err = updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate) - } - if errors.Is(err, errMihomoStateUncertain) { - log.Print("bootstrap: subscription rollback failed; stopping SSClash") - cancel() - <-exit - return err - } else if err != nil { - log.Print("bootstrap: subscription update rejected; keeping previous valid configuration") - continue - } - if running { - log.Print("bootstrap: subscription updated and reloaded") - } else { - log.Print("bootstrap: subscription updated; Mihomo is stopped") - } - } - } -} - -func ensureSubscriptionLink(path, target string) error { - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - if err := os.Symlink(target, path); err != nil { - return fmt.Errorf("create runtime subscription link: %w", err) - } - return nil - } - if err != nil { - return fmt.Errorf("inspect runtime subscription link: %w", err) - } - if info.Mode()&os.ModeSymlink == 0 { - return errors.New("runtime subscription path is not a managed symlink") - } - existingTarget, err := os.Readlink(path) - if err != nil { - return fmt.Errorf("read runtime subscription link: %w", err) - } - if existingTarget != target { - return fmt.Errorf("runtime subscription link has unexpected target %q", existingTarget) - } - return nil -} - -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, controllerURL string) error { - request, err := http.NewRequestWithContext(ctx, http.MethodPut, controllerURL+"/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 mihomoRunning(ctx context.Context, client *http.Client, controllerURL string) bool { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, controllerURL+"/version", nil) - if err != nil { - return false - } - response, err := client.Do(request) - if err != nil { - return false - } - defer response.Body.Close() - _, _ = io.Copy(io.Discard, response.Body) - return true -} - -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=") || strings.HasPrefix(entry, "SSCLASH_PASSWORD=") { - continue - } - 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 { @@ -587,189 +26,23 @@ func validateSource(path, label string) error { return nil } -func copyIfAbsent(source, target string, mode os.FileMode) (bool, error) { - info, err := os.Stat(target) - if err == nil { - if !info.Mode().IsRegular() { - return false, fmt.Errorf("target %q is not a regular file", target) - } - if info.Size() == 0 { - return false, fmt.Errorf("target %q is empty", target) - } - return false, nil - } - if !errors.Is(err, os.ErrNotExist) { - return false, fmt.Errorf("inspect target %q: %w", target, err) - } - - input, err := os.Open(source) - if err != nil { - return false, fmt.Errorf("open source %q: %w", source, err) - } - defer input.Close() - - err = atomicWrite(target, mode, func(output *os.File) error { - if _, copyErr := io.Copy(output, input); copyErr != nil { - return fmt.Errorf("copy %q to %q: %w", source, target, copyErr) - } - return nil - }) - return err == nil, err -} - -func prepareManagedConfig(source, target, versionPath string) (bool, bool, error) { - if err := validateManagedConfigVersion(versionPath); err != nil { - return false, false, err - } - current, err := os.ReadFile(source) - if err != nil { - return false, false, fmt.Errorf("read managed config source: %w", err) - } - - info, err := os.Lstat(target) - if errors.Is(err, os.ErrNotExist) { - if err := writeManagedConfig(target, current); err != nil { - return false, false, err - } - if err := writeManagedConfigVersion(versionPath); err != nil { - return false, false, err - } - return true, false, nil - } - if err != nil { - return false, false, fmt.Errorf("inspect config %q: %w", target, err) - } - if !info.Mode().IsRegular() { - return false, false, fmt.Errorf("config %q is not a regular file", target) - } - if info.Size() == 0 { - return false, false, fmt.Errorf("config %q is empty", target) - } - existing, err := os.ReadFile(target) - if err != nil { - return false, false, fmt.Errorf("read config %q: %w", target, err) - } - if bytes.Equal(existing, current) { - return false, false, writeManagedConfigVersion(versionPath) - } - - legacy, legacyErr := legacyManagedConfig(current) - if legacyErr == nil && bytes.Equal(existing, legacy) { - if err := writeManagedConfig(target, current); err != nil { - return false, false, err - } - if err := writeManagedConfigVersion(versionPath); err != nil { - return false, true, err - } - return false, true, nil - } - if bytes.Contains(existing, []byte("GEOIP,CN")) { - return false, false, fmt.Errorf("custom config uses GEOIP,CN and was preserved; replace it with the packaged local ChinaIp rule before retrying") - } - return false, false, nil -} - -func legacyManagedConfig(current []byte) ([]byte, error) { - text := string(current) - if strings.Count(text, managedChinaIPProvider) != 1 || strings.Count(text, managedChinaIPRule) != 1 { - return nil, errors.New("packaged config is missing the managed ChinaIp rule") - } - text = strings.Replace(text, managedChinaIPProvider, "", 1) - text = strings.Replace(text, managedChinaIPRule, legacyChinaIPRule, 1) - return []byte(text), nil -} - -func validateManagedConfigVersion(path string) error { - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - return fmt.Errorf("inspect managed config version: %w", err) - } - if !info.Mode().IsRegular() { - return errors.New("managed config version marker is not a regular file") - } - content, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read managed config version: %w", err) - } - if string(content) != managedConfigVersion+"\n" { - return fmt.Errorf("unsupported managed config version %q", strings.TrimSpace(string(content))) +func validateMihomoConfig(ctx context.Context, binary, runtimeDir, configPath string) error { + command := exec.CommandContext(ctx, binary, "-t", "-d", runtimeDir, "-f", configPath) + command.Stdout = io.Discard + command.Stderr = io.Discard + if err := command.Run(); err != nil { + return errors.New("Mihomo validation failed") } return nil } -func writeManagedConfig(path string, content []byte) error { - return atomicWrite(path, 0o644, func(output *os.File) error { - _, err := output.Write(content) - return err - }) -} - -func writeManagedConfigVersion(path string) error { - if _, err := os.Lstat(path); err == nil { - return nil - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("inspect managed config version: %w", err) - } - return atomicWrite(path, 0o644, func(output *os.File) error { - _, err := output.WriteString(managedConfigVersion + "\n") - return err - }) -} - -func enforceServerSettings(path string) (bool, error) { - content, err := os.ReadFile(path) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return false, fmt.Errorf("read settings %q: %w", path, err) - } - - lines := make([]string, 0) - if len(content) > 0 { - lines = strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") - } - indexes := make(map[string]int, len(serverSettings)) - for _, setting := range serverSettings { - indexes[setting.key] = -1 - } - for index, line := range lines { - for _, setting := range serverSettings { - if !strings.HasPrefix(line, setting.key) { - continue - } - if indexes[setting.key] >= 0 { - return false, fmt.Errorf("multiple %s entries in %q", strings.TrimSuffix(setting.key, "="), path) - } - indexes[setting.key] = index - } - } - changed := false - for _, setting := range serverSettings { - expected := setting.key + setting.value - index := indexes[setting.key] - if index >= 0 { - if lines[index] != expected { - lines[index] = expected - changed = true - } - continue - } - lines = append(lines, expected) - changed = true - } - if !changed { - return false, nil - } - - settings := strings.Join(lines, "\n") + "\n" - err = atomicWrite(path, 0o600, func(output *os.File) error { - if _, writeErr := output.WriteString(settings); writeErr != nil { - return fmt.Errorf("write settings %q: %w", path, writeErr) - } - return nil - }) - return changed, err +func serviceCommand(ctx context.Context, binary, runtimeDir, configPath string) *exec.Cmd { + command := exec.CommandContext(ctx, binary, "-d", runtimeDir, "-f", configPath) + 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 atomicWrite(path string, mode os.FileMode, write func(*os.File) error) (resultErr error) { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go deleted file mode 100644 index 59ee4c0..0000000 --- a/internal/bootstrap/bootstrap_test.go +++ /dev/null @@ -1,749 +0,0 @@ -package bootstrap - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "testing" - "time" -) - -func TestPrepareInitializesServerRuntime(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - root := filepath.Join(tempDir, "data") - coreSource := writeFixture(t, tempDir, "mihomo", "mihomo-binary") - configSource := writeFixture(t, tempDir, "config.yaml", "mixed-port: 7890\n") - - result, err := Prepare(Config{ - Root: root, - SSClashTemp: filepath.Join(tempDir, "tmp"), - CoreSource: coreSource, - ConfigSource: configSource, - }) - if err != nil { - t.Fatalf("Prepare() error = %v", err) - } - if !result.CoreInitialized || !result.ConfigInitialized || !result.ServerSettingsChanged { - t.Errorf("Prepare() result = %+v, want all initialization flags", result) - } - - for _, directory := range []string{ - "bin", ".ssclash", "configs", "local-rules", "rule-providers", - "proxy-providers", "subscriptions", "ui", - } { - info, statErr := os.Stat(filepath.Join(root, directory)) - if statErr != nil { - t.Errorf("directory %q not created: %v", directory, statErr) - continue - } - if !info.IsDir() { - t.Errorf("path %q is not a directory", directory) - } - } - - assertFileContent(t, filepath.Join(root, "bin", "clash"), "mihomo-binary") - assertFileContent(t, filepath.Join(root, "config.yaml"), "mixed-port: 7890\n") - assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "OPERATING_MODE=server\nPROXY_MODE=none\n") - - coreInfo, err := os.Stat(filepath.Join(root, "bin", "clash")) - if err != nil { - t.Fatal(err) - } - if coreInfo.Mode().Perm() != 0o755 { - t.Errorf("core mode = %o, want 755", coreInfo.Mode().Perm()) - } -} - -func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - root := filepath.Join(tempDir, "data") - if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil { - t.Fatal(err) - } - writeFixture(t, filepath.Join(root, "bin"), "clash", "user-managed-core") - writeFixture(t, root, "config.yaml", "user: config\n") - writeFixture(t, filepath.Join(root, ".ssclash"), "settings", "LOG_LEVEL=debug\nOPERATING_MODE=gateway\nPROXY_MODE=tproxy\n") - - result, err := Prepare(Config{ - Root: root, - SSClashTemp: filepath.Join(tempDir, "tmp"), - CoreSource: writeFixture(t, tempDir, "mihomo", "image-core"), - ConfigSource: writeFixture(t, tempDir, "default.yaml", "image: config\n"), - }) - if err != nil { - t.Fatalf("Prepare() error = %v", err) - } - if result.CoreInitialized || result.ConfigInitialized || !result.ServerSettingsChanged { - t.Errorf("Prepare() result = %+v, want only server mode changed", result) - } - - assertFileContent(t, filepath.Join(root, "bin", "clash"), "user-managed-core") - assertFileContent(t, filepath.Join(root, "config.yaml"), "user: config\n") - assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "LOG_LEVEL=debug\nOPERATING_MODE=server\nPROXY_MODE=none\n") -} - -func TestPrepareMigratesExactLegacyManagedConfig(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - root := filepath.Join(tempDir, "data") - if err := os.MkdirAll(root, 0o755); err != nil { - t.Fatal(err) - } - current := "rule-providers:\n" + managedChinaIPProvider + "rules:\n" + managedChinaIPRule + "\n" - legacy := "rule-providers:\nrules:\n" + legacyChinaIPRule + "\n" - writeFixture(t, root, "config.yaml", legacy) - - result, err := Prepare(Config{ - Root: root, - SSClashTemp: filepath.Join(tempDir, "tmp"), - CoreSource: writeFixture(t, tempDir, "mihomo", "core"), - ConfigSource: writeFixture(t, tempDir, "current.yaml", current), - }) - if err != nil { - t.Fatalf("Prepare() error = %v", err) - } - if result.ConfigInitialized || !result.ConfigMigrated { - t.Fatalf("Prepare() result = %+v, want migrated existing config", result) - } - assertFileContent(t, filepath.Join(root, "config.yaml"), current) - assertFileContent(t, filepath.Join(root, managedConfigVersionFile), managedConfigVersion+"\n") -} - -func TestPrepareBackfillsVersionAfterMigrationMarkerFailure(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - root := filepath.Join(tempDir, "data") - if err := os.MkdirAll(root, 0o755); err != nil { - t.Fatal(err) - } - current := "rule-providers:\n" + managedChinaIPProvider + "rules:\n" + managedChinaIPRule + "\n" - legacy := "rule-providers:\nrules:\n" + legacyChinaIPRule + "\n" - target := writeFixture(t, root, "config.yaml", legacy) - configSource := writeFixture(t, tempDir, "current.yaml", current) - - _, migrated, err := prepareManagedConfig( - configSource, - target, - filepath.Join(tempDir, "missing", managedConfigVersionFile), - ) - if err == nil || !migrated { - t.Fatalf("prepareManagedConfig() = migrated %t, error %v; want migrated config and marker write error", migrated, err) - } - assertFileContent(t, target, current) - - result, err := Prepare(Config{ - Root: root, - SSClashTemp: filepath.Join(tempDir, "tmp"), - CoreSource: writeFixture(t, tempDir, "mihomo", "core"), - ConfigSource: configSource, - }) - if err != nil { - t.Fatalf("Prepare() retry error = %v", err) - } - if result.ConfigInitialized || result.ConfigMigrated { - t.Fatalf("Prepare() retry result = %+v, want marker-only recovery", result) - } - assertFileContent(t, target, current) - assertFileContent(t, filepath.Join(root, managedConfigVersionFile), managedConfigVersion+"\n") -} - -func TestPreparePreservesAndRejectsCustomLegacyGeoIPConfig(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - root := filepath.Join(tempDir, "data") - if err := os.MkdirAll(root, 0o755); err != nil { - t.Fatal(err) - } - current := "rule-providers:\n" + managedChinaIPProvider + "rules:\n" + managedChinaIPRule + "\n" - custom := "rule-providers:\nrules:\n" + legacyChinaIPRule + "\n# user managed\n" - target := writeFixture(t, root, "config.yaml", custom) - - _, err := Prepare(Config{ - Root: root, - SSClashTemp: filepath.Join(tempDir, "tmp"), - CoreSource: writeFixture(t, tempDir, "mihomo", "core"), - ConfigSource: writeFixture(t, tempDir, "current.yaml", current), - }) - if err == nil || !strings.Contains(err.Error(), "custom config uses GEOIP,CN") { - t.Fatalf("Prepare() error = %v, want explicit custom config migration error", err) - } - assertFileContent(t, target, custom) - if _, statErr := os.Stat(filepath.Join(root, managedConfigVersionFile)); !errors.Is(statErr, os.ErrNotExist) { - t.Fatalf("managed config version marker unexpectedly created: %v", statErr) - } -} - -func TestEnsureAdminPasswordFailsClosedOnFreshVolume(t *testing.T) { - t.Parallel() - - _, err := EnsureAdminPassword(t.TempDir(), "unused", "") - if err == nil || !strings.Contains(err.Error(), "SSCLASH_PASSWORD") { - t.Fatalf("EnsureAdminPassword() error = %v, want missing password error", err) - } -} - -func TestEnsureAdminPasswordInitializesOnlyWhenMissing(t *testing.T) { - t.Parallel() - - root := filepath.Join(t.TempDir(), "data") - if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil { - t.Fatal(err) - } - binary := writeFixture(t, filepath.Join(root, "bin"), "ssclash", `#!/bin/sh -set -eu -[ "$1" = setpass ] -[ "$2" = fresh-volume-password ] -password="$(dirname "$0")/../.ssclash/password" -printf 'pbkdf2$120000$0123456789abcdef0123456789abcdef$0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n' > "$password" -chmod 0600 "$password" -`) - if err := os.Chmod(binary, 0o755); err != nil { - t.Fatal(err) - } - - initialized, err := EnsureAdminPassword(root, binary, "fresh-volume-password") - if err != nil { - t.Fatalf("EnsureAdminPassword() error = %v", err) - } - if !initialized { - t.Fatal("EnsureAdminPassword() initialized = false, want true") - } - assertFileContent(t, filepath.Join(root, ".ssclash", "password"), validAdminPasswordHash) - - if err := os.Remove(binary); err != nil { - t.Fatal(err) - } - initialized, err = EnsureAdminPassword(root, binary, "replacement-password") - if err != nil { - t.Fatalf("EnsureAdminPassword() existing password error = %v", err) - } - if initialized { - t.Fatal("EnsureAdminPassword() replaced existing password") - } - assertFileContent(t, filepath.Join(root, ".ssclash", "password"), validAdminPasswordHash) -} - -func TestAdminPasswordConfiguredRejectsUnsafeFiles(t *testing.T) { - t.Parallel() - - for _, testCase := range []struct { - name string - setup func(t *testing.T, path string) - }{ - {name: "mode 000", setup: passwordFileSetup(validAdminPasswordHash, 0o000)}, - {name: "mode 0200", setup: passwordFileSetup(validAdminPasswordHash, 0o200)}, - {name: "mode 0400", setup: passwordFileSetup(validAdminPasswordHash, 0o400)}, - {name: "mode 0644", setup: passwordFileSetup(validAdminPasswordHash, 0o644)}, - {name: "empty", setup: passwordFileSetup("", 0o600)}, - {name: "invalid hash", setup: passwordFileSetup("pbkdf2$test\n", 0o600)}, - {name: "non-hex hash", setup: passwordFileSetup("pbkdf2$120000$zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz$0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n", 0o600)}, - {name: "directory", setup: func(t *testing.T, path string) { - t.Helper() - if err := os.Mkdir(path, 0o700); err != nil { - t.Fatal(err) - } - }}, - {name: "symlink", setup: func(t *testing.T, path string) { - t.Helper() - target := path + ".target" - passwordFileSetup(validAdminPasswordHash, 0o600)(t, target) - if err := os.Symlink(target, path); err != nil { - t.Fatal(err) - } - }}, - } { - t.Run(testCase.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "password") - testCase.setup(t, path) - if configured, err := adminPasswordConfigured(path); err == nil || configured { - t.Fatalf("adminPasswordConfigured() = %t, %v; want false, error", configured, err) - } - }) - } -} - -func TestAdminPasswordConfiguredRequiresOwner(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "password") - passwordFileSetup(validAdminPasswordHash, 0o600)(t, path) - for _, owner := range []struct { - name string - uid uint32 - gid uint32 - }{ - {name: "UID", uid: uint32(os.Geteuid() + 1), gid: uint32(os.Getegid())}, - {name: "GID", uid: uint32(os.Geteuid()), gid: uint32(os.Getegid() + 1)}, - } { - t.Run(owner.name, func(t *testing.T) { - configured, err := adminPasswordConfiguredFor(path, owner.uid, owner.gid) - if err == nil || configured { - t.Fatalf("adminPasswordConfiguredFor() = %t, %v; want false, owner error", configured, err) - } - }) - } -} - -func TestAdminPasswordConfiguredAcceptsSecureFile(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "password") - passwordFileSetup(validAdminPasswordHash, 0o600)(t, path) - configured, err := adminPasswordConfigured(path) - if err != nil || !configured { - t.Fatalf("adminPasswordConfigured() = %t, %v; want true, nil", configured, err) - } -} - -func TestPrepareRepairsManagedProviderSymlinks(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - root := filepath.Join(tempDir, "data") - ssclashTemp := filepath.Join(tempDir, "tmp") - config := Config{ - Root: root, - SSClashTemp: ssclashTemp, - CoreSource: writeFixture(t, tempDir, "mihomo", "core"), - ConfigSource: writeFixture(t, tempDir, "config.yaml", "config"), - } - if _, err := Prepare(config); err != nil { - t.Fatalf("first Prepare() error = %v", err) - } - for _, directory := range []string{"rule-providers", "proxy-providers"} { - path := filepath.Join(root, directory) - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Symlink(filepath.Join(ssclashTemp, directory), path); err != nil { - t.Fatal(err) - } - } - - if _, err := Prepare(config); err != nil { - t.Fatalf("second Prepare() error = %v", err) - } - for _, directory := range []string{"rule-providers", "proxy-providers"} { - info, err := os.Lstat(filepath.Join(root, directory)) - if err != nil { - t.Fatal(err) - } - if !info.IsDir() { - t.Errorf("%s mode = %s, want directory", directory, info.Mode()) - } - } -} - -func TestPrepareRejectsUnexpectedProviderSymlink(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - root := filepath.Join(tempDir, "data") - ssclashTemp := filepath.Join(tempDir, "tmp") - config := Config{ - Root: root, - SSClashTemp: ssclashTemp, - CoreSource: writeFixture(t, tempDir, "mihomo", "core"), - ConfigSource: writeFixture(t, tempDir, "config.yaml", "config"), - } - if _, err := Prepare(config); err != nil { - t.Fatal(err) - } - path := filepath.Join(root, "rule-providers") - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Symlink(filepath.Join(tempDir, "unexpected"), path); err != nil { - t.Fatal(err) - } - - if _, err := Prepare(config); err == nil || !strings.Contains(err.Error(), "unexpected symlink") { - t.Fatalf("Prepare() error = %v, want unexpected symlink error", err) - } - target, err := os.Readlink(path) - if err != nil { - t.Fatal(err) - } - if target != filepath.Join(tempDir, "unexpected") { - t.Fatalf("unexpected symlink target = %q", target) - } -} - -func TestChildEnvironmentRemovesCredentials(t *testing.T) { - t.Setenv("SUBSCRIPTION_URL", "https://subscription.example.invalid/?token=secret") - t.Setenv("SSCLASH_PASSWORD", "secret-password") - - environment := strings.Join(childEnvironment(), "\n") - for _, key := range []string{"SUBSCRIPTION_URL=", "SSCLASH_PASSWORD="} { - if strings.Contains(environment, key) { - t.Errorf("childEnvironment() retained %s", key) - } - } -} - -func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - coreSource := writeFixture(t, tempDir, "mihomo", "core") - configSource := writeFixture(t, tempDir, "config.yaml", "config") - - tests := []struct { - name string - config Config - setup func(t *testing.T, root string) - wantErr string - }{ - { - name: "filesystem root", - config: Config{ - Root: "/", - SSClashTemp: filepath.Join(tempDir, "tmp"), - CoreSource: coreSource, - ConfigSource: configSource, - }, - wantErr: "unsafe root", - }, - { - name: "missing core source", - config: Config{ - Root: filepath.Join(tempDir, "missing-core"), - SSClashTemp: filepath.Join(tempDir, "tmp"), - CoreSource: filepath.Join(tempDir, "does-not-exist"), - ConfigSource: configSource, - }, - wantErr: "core source", - }, - { - name: "duplicate operating mode", - config: Config{ - Root: filepath.Join(tempDir, "duplicate-mode"), - SSClashTemp: filepath.Join(tempDir, "tmp"), - CoreSource: coreSource, - ConfigSource: configSource, - }, - setup: func(t *testing.T, root string) { - t.Helper() - if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil { - t.Fatal(err) - } - writeFixture(t, filepath.Join(root, ".ssclash"), "settings", "OPERATING_MODE=gateway\nOPERATING_MODE=server\n") - }, - wantErr: "multiple OPERATING_MODE", - }, - { - name: "duplicate proxy mode", - config: Config{ - Root: filepath.Join(tempDir, "duplicate-proxy-mode"), - SSClashTemp: filepath.Join(tempDir, "tmp"), - CoreSource: coreSource, - ConfigSource: configSource, - }, - setup: func(t *testing.T, root string) { - t.Helper() - if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil { - t.Fatal(err) - } - writeFixture(t, filepath.Join(root, ".ssclash"), "settings", "PROXY_MODE=tproxy\nPROXY_MODE=none\n") - }, - wantErr: "multiple PROXY_MODE", - }, - } - - for _, testCase := range tests { - t.Run(testCase.name, func(t *testing.T) { - if testCase.setup != nil { - testCase.setup(t, testCase.config.Root) - } - _, err := Prepare(testCase.config) - if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { - t.Fatalf("Prepare() error = %v, want substring %q", err, testCase.wantErr) - } - }) - } -} - -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 TestRunLeavesMihomoLifecycleToSSClash(t *testing.T) { - tempDir := t.TempDir() - root := filepath.Join(tempDir, "root") - if err := os.MkdirAll(root, 0o755); err != nil { - t.Fatal(err) - } - coreMarker := filepath.Join(tempDir, "core-started") - ssclashMarker := filepath.Join(tempDir, "ssclash-started") - t.Setenv("MIHOMO_TEST_MARKER", coreMarker) - t.Setenv("SSCLASH_TEST_MARKER", ssclashMarker) - core := writeFixture(t, tempDir, "fake-core", "#!/bin/sh\nif [ \"$1\" = -t ]; then exit 0; fi\ntouch \"$MIHOMO_TEST_MARKER\"\nexit 1\n") - ssclash := writeFixture(t, tempDir, "fake-ssclash", "#!/bin/sh\n[ \"$1\" = serve ]\ntouch \"$SSCLASH_TEST_MARKER\"\nsleep 1\n") - for _, binary := range []string{core, ssclash} { - if err := os.Chmod(binary, 0o755); err != nil { - t.Fatal(err) - } - } - config := writeFixture(t, root, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n") - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - _, _ = writer.Write([]byte("proxies:\n - name: initial\n")) - })) - defer server.Close() - - runtimeDir := filepath.Join(tempDir, "runtime") - err := Run(context.Background(), RuntimeConfig{ - Root: root, - CoreBinary: core, - SSClashBinary: ssclash, - ConfigSource: config, - RuntimeDir: runtimeDir, - SubscriptionURL: server.URL, - UpdateInterval: time.Hour, - }) - if err == nil || !strings.Contains(err.Error(), "SSClash exited") { - t.Fatalf("Run() error = %v, want SSClash exit", err) - } - if _, err := os.Stat(ssclashMarker); err != nil { - t.Fatalf("SSClash was not started: %v", err) - } - if _, err := os.Stat(coreMarker); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("bootstrap started Mihomo outside SSClash: %v", err) - } - linkTarget, err := os.Readlink(filepath.Join(root, "subscription.yaml")) - if err != nil { - t.Fatalf("read subscription link: %v", err) - } - if want := filepath.Join(runtimeDir, "subscription.yaml"); linkTarget != want { - t.Fatalf("subscription link = %q, want %q", linkTarget, want) - } -} - -func TestRunStopsSSClashWhenControllerCannotConfirmRollback(t *testing.T) { - tempDir := t.TempDir() - root := filepath.Join(tempDir, "root") - if err := os.MkdirAll(root, 0o755); err != nil { - t.Fatal(err) - } - core := writeFixture(t, tempDir, "fake-core", "#!/bin/sh\n[ \"$1\" = -t ]\n") - ssclash := writeFixture(t, tempDir, "fake-ssclash", "#!/bin/sh\n[ \"$1\" = serve ]\nexec sleep 3600\n") - for _, binary := range []string{core, ssclash} { - if err := os.Chmod(binary, 0o755); err != nil { - t.Fatal(err) - } - } - config := writeFixture(t, root, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n") - - var subscriptionRequests atomic.Int32 - subscription := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - name := "updated" - if subscriptionRequests.Add(1) == 1 { - name = "initial" - } - _, _ = writer.Write([]byte("proxies:\n - name: " + name + "\n")) - })) - defer subscription.Close() - var reloadRequests atomic.Int32 - controller := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - switch { - case request.Method == http.MethodGet && request.URL.Path == "/version": - writer.WriteHeader(http.StatusOK) - case request.Method == http.MethodPut && request.URL.Path == "/providers/proxies/subscription": - reloadRequests.Add(1) - http.Error(writer, "reload failed", http.StatusInternalServerError) - default: - http.NotFound(writer, request) - } - })) - defer controller.Close() - - runResult := make(chan error, 1) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { - runResult <- Run(ctx, RuntimeConfig{ - Root: root, - CoreBinary: core, - SSClashBinary: ssclash, - ConfigSource: config, - RuntimeDir: filepath.Join(tempDir, "runtime"), - SubscriptionURL: subscription.URL, - ControllerURL: controller.URL, - UpdateInterval: 20 * time.Millisecond, - }) - }() - - select { - case err := <-runResult: - if !errors.Is(err, errMihomoStateUncertain) { - t.Fatalf("Run() error = %v, want uncertain Mihomo state", err) - } - case <-time.After(5 * time.Second): - t.Fatal("Run() did not stop SSClash after rollback reload failure") - } - if subscriptionRequests.Load() < 2 || reloadRequests.Load() != 2 { - t.Fatalf("requests = subscription:%d reload:%d, want at least 2 and exactly 2", subscriptionRequests.Load(), reloadRequests.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 { - t.Fatal(err) - } - path := filepath.Join(directory, name) - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - return path -} - -const validAdminPasswordHash = "pbkdf2$120000$0123456789abcdef0123456789abcdef$0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n" - -func passwordFileSetup(content string, mode os.FileMode) func(t *testing.T, path string) { - return func(t *testing.T, path string) { - t.Helper() - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Chmod(path, mode); err != nil { - t.Fatal(err) - } - } -} - -func assertFileContent(t *testing.T, path, want string) { - t.Helper() - content, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read %s: %v", path, err) - } - if string(content) != want { - t.Errorf("content of %s = %q, want %q", path, content, want) - } -} diff --git a/internal/bootstrap/candidate.go b/internal/bootstrap/candidate.go index d37c1c8..66dc6ab 100644 --- a/internal/bootstrap/candidate.go +++ b/internal/bootstrap/candidate.go @@ -3,42 +3,62 @@ package bootstrap import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" + "log" "net/http" "net/url" "os" "path/filepath" "strings" + "syscall" "time" "gopkg.in/yaml.v3" ) -const maxSecretSize = 4096 +const ( + maxSecretSize = 4096 + maxSubscriptionSize = 16 << 20 + defaultControllerURL = "http://127.0.0.1:9090" +) + +var errPersistence = errors.New("candidate persistence failure") type CandidateConfig struct { - SecretPath string - DataDir string - TemplatePath string - MihomoBinary string - Client *http.Client + SecretPath string + DataDir string + TemplatePath string + MihomoBinary string + Client *http.Client + replaceLastGood func(string, string) error + directorySync func(string) error +} + +type LifecycleConfig struct { + Candidate CandidateConfig + ControllerURL string + UpdateInterval time.Duration + Trigger <-chan os.Signal + afterLastGoodValidated func() } // PublishCandidate performs one Stage 1 update. Starting or reloading Mihomo is // deliberately left to the lifecycle stage. func PublishCandidate(ctx context.Context, config CandidateConfig) error { - dataDir := filepath.Clean(config.DataDir) - if !filepath.IsAbs(dataDir) || dataDir == string(filepath.Separator) { - return fmt.Errorf("unsafe data directory %q", config.DataDir) - } - if err := ensureDirectory(dataDir); err != nil { - return err - } + return withDataDirLock(config.DataDir, func(dataDir string) error { + config.DataDir = dataDir + return publishCandidateLocked(ctx, config) + }) +} + +func publishCandidateLocked(ctx context.Context, config CandidateConfig) error { + dataDir := config.DataDir generations := filepath.Join(dataDir, "generations") if err := ensureDirectory(generations); err != nil { - return err + return persistenceError("prepare generations directory", err) } for path, label := range map[string]string{ config.TemplatePath: "Mihomo template", @@ -84,20 +104,20 @@ func PublishCandidate(ctx context.Context, config CandidateConfig) error { } candidate, err := os.MkdirTemp(generations, ".candidate-") if err != nil { - return fmt.Errorf("create candidate generation: %w", err) + return persistenceError("create candidate generation", err) } if err := os.Chmod(candidate, 0o700); err != nil { _ = os.RemoveAll(candidate) - return fmt.Errorf("secure candidate generation: %w", err) + return persistenceError("secure candidate generation", err) } defer os.RemoveAll(candidate) configPath := filepath.Join(candidate, "config.yaml") if err := writePrivateFile(configPath, generated); err != nil { - return fmt.Errorf("write candidate config: %w", err) + return persistenceError("write candidate config", err) } if err := writePrivateFile(filepath.Join(candidate, "subscription.yaml"), subscription); err != nil { - return fmt.Errorf("write candidate subscription: %w", err) + return persistenceError("write candidate subscription", err) } if err := validateMihomoConfig(ctx, config.MihomoBinary, candidate, configPath); err != nil { return errors.New("candidate configuration failed Mihomo validation") @@ -105,18 +125,292 @@ func PublishCandidate(ctx context.Context, config CandidateConfig) error { slot := filepath.Join(dataDir, filepath.FromSlash(next)) if err := os.RemoveAll(slot); err != nil { - return fmt.Errorf("clear inactive generation: %w", err) + return persistenceError("clear inactive generation", err) } if err := os.Rename(candidate, slot); err != nil { - return fmt.Errorf("publish candidate generation: %w", err) + return persistenceError("publish candidate generation", err) } - if err := syncDirectory(generations); err != nil { + if err := config.syncDirectory(generations); err != nil { + return persistenceError("sync generations directory", err) + } + if err := config.replacePointer(filepath.Join(dataDir, "last-good"), next); err != nil { + return persistenceError("publish last-good pointer", err) + } + if err := config.syncDirectory(dataDir); err != nil { + rollbackErr := restoreLastGood(config, dataDir, current) + return errors.Join(persistenceError("sync data directory", err), rollbackErr) + } + return nil +} + +// Run starts the last known-good configuration and owns Mihomo until ctx ends. +func Run(ctx context.Context, config LifecycleConfig) error { + if config.UpdateInterval <= 0 { + return errors.New("update interval must be positive") + } + controllerURL := strings.TrimRight(config.ControllerURL, "/") + if controllerURL == "" { + controllerURL = defaultControllerURL + } + if err := validateControllerURL(controllerURL); err != nil { return err } - if err := replaceSymlink(filepath.Join(dataDir, "last-good"), next); err != nil { + + client := config.Candidate.Client + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + var cancel context.CancelFunc + var exit chan error + var done chan struct{} + startupErr := withDataDirLock(config.Candidate.DataDir, func(dataDir string) error { + config.Candidate.DataDir = dataDir + generation, valid := validLastGoodLocked(ctx, config.Candidate) + warm := valid == nil + if !warm { + if err := publishCandidateLocked(ctx, config.Candidate); err != nil { + return fmt.Errorf("cold-start candidate failed: %w", err) + } + generation, valid = validLastGoodLocked(ctx, config.Candidate) + if valid != nil { + return fmt.Errorf("published candidate is invalid: %w", valid) + } + } + if warm && config.afterLastGoodValidated != nil { + config.afterLastGoodValidated() + } + + serviceCtx, serviceCancel := context.WithCancel(ctx) + cancel = serviceCancel + mihomo := serviceCommand(serviceCtx, config.Candidate.MihomoBinary, generation, filepath.Join(generation, "config.yaml")) + if err := mihomo.Start(); err != nil { + cancel() + return fmt.Errorf("start Mihomo: %w", err) + } + exit = make(chan error, 1) + done = make(chan struct{}) + go func() { + exit <- mihomo.Wait() + close(done) + }() + stop := func() { + cancel() + <-done + } + if err := waitForController(ctx, client, controllerURL, exit); err != nil { + stop() + return err + } + log.Printf("bootstrap: Mihomo started config=%s update_interval=%s", filepath.Base(generation), config.UpdateInterval) + if warm { + if err := updateAndReloadLocked(ctx, client, controllerURL, config.Candidate); err != nil { + stop() + return fmt.Errorf("fatal update stopped Mihomo: %w", err) + } + } + return nil + }) + if startupErr != nil { + if cancel != nil { + cancel() + if done != nil { + <-done + } + } + return startupErr + } + defer cancel() + failClosed := func(err error) error { + cancel() + <-done + return fmt.Errorf("fatal update stopped Mihomo: %w", err) + } + + ticker := time.NewTicker(config.UpdateInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + cancel() + <-done + return ctx.Err() + case err := <-exit: + if err == nil { + return errors.New("Mihomo exited") + } + return fmt.Errorf("Mihomo exited: %w", err) + case <-ticker.C: + if err := updateAndReload(ctx, client, controllerURL, config.Candidate); err != nil { + return failClosed(err) + } + case <-config.Trigger: + if err := updateAndReload(ctx, client, controllerURL, config.Candidate); err != nil { + return failClosed(err) + } + } + } +} + +func validLastGoodLocked(ctx context.Context, config CandidateConfig) (string, error) { + target, err := currentGeneration(filepath.Join(config.DataDir, "last-good")) + if err != nil { + return "", err + } + if target == "" { + return "", errors.New("last-good is missing") + } + directory := filepath.Join(config.DataDir, filepath.FromSlash(target)) + for path, label := range map[string]string{ + filepath.Join(directory, "config.yaml"): "last-good config", + filepath.Join(directory, "subscription.yaml"): "last-good subscription", + } { + if err := validateSource(path, label); err != nil { + return "", err + } + } + if err := validateMihomoConfig(ctx, config.MihomoBinary, directory, filepath.Join(directory, "config.yaml")); err != nil { + return "", errors.New("last-good failed Mihomo validation") + } + return directory, nil +} + +func updateAndReload(ctx context.Context, client *http.Client, controllerURL string, config CandidateConfig) error { + return withDataDirLock(config.DataDir, func(dataDir string) error { + config.DataDir = dataDir + return updateAndReloadLocked(ctx, client, controllerURL, config) + }) +} + +func updateAndReloadLocked(ctx context.Context, client *http.Client, controllerURL string, config CandidateConfig) error { + dataDir := config.DataDir + previous, err := currentGeneration(filepath.Join(dataDir, "last-good")) + if err != nil || previous == "" { + return errors.Join(errors.New("last-good is unavailable during update"), err) + } + if err := publishCandidateLocked(ctx, config); err != nil { + if errors.Is(err, errPersistence) { + return err + } + log.Printf("bootstrap: update rejected; keeping last-good: %v", err) + return nil + } + next, err := currentGeneration(filepath.Join(dataDir, "last-good")) + if err == nil { + err = reloadMihomo(ctx, client, controllerURL, filepath.Join(dataDir, filepath.FromSlash(next), "config.yaml")) + } + if err == nil { + log.Print("bootstrap: configuration updated and reloaded") + return nil + } + if rollbackErr := config.replacePointer(filepath.Join(dataDir, "last-good"), previous); rollbackErr != nil { + return fmt.Errorf("restore last-good pointer after reload failure: %w", rollbackErr) + } + if rollbackErr := config.syncDirectory(dataDir); rollbackErr != nil { + return fmt.Errorf("persist restored last-good pointer after reload failure: %w", rollbackErr) + } + if rollbackErr := reloadMihomo(ctx, client, controllerURL, filepath.Join(dataDir, filepath.FromSlash(previous), "config.yaml")); rollbackErr != nil { + return fmt.Errorf("reload restored last-good after reload failure: %w", rollbackErr) + } + log.Printf("bootstrap: reload rejected; restored and reloaded last-good: %v", err) + return nil +} + +func reloadMihomo(ctx context.Context, client *http.Client, controllerURL, configPath string) error { + body, err := json.Marshal(map[string]string{"path": configPath}) + if err != nil { + return errors.New("encode Mihomo reload request") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPut, controllerURL+"/configs?force=true", bytes.NewReader(body)) + if err != nil { + return errors.New("create Mihomo reload request") + } + request.Header.Set("Content-Type", "application/json") + 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 waitForController(ctx context.Context, client *http.Client, controllerURL string, exit <-chan error) error { + timeout := time.NewTimer(15 * time.Second) + defer timeout.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, controllerURL+"/version", nil) + if response, err := client.Do(request); err == nil { + _, _ = io.Copy(io.Discard, response.Body) + response.Body.Close() + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-exit: + if err == nil { + return errors.New("Mihomo exited before controller became ready") + } + return fmt.Errorf("Mihomo exited before controller became ready: %w", err) + case <-timeout.C: + return errors.New("Mihomo controller did not become ready") + case <-ticker.C: + } + } +} + +func validateControllerURL(raw string) error { + parsed, err := url.ParseRequestURI(raw) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { + return errors.New("controller URL must be an absolute HTTP(S) URL") + } + return nil +} + +func withDataDirLock(dataDir string, operation func(string) error) error { + dataDir = filepath.Clean(dataDir) + if !filepath.IsAbs(dataDir) || dataDir == string(filepath.Separator) { + return fmt.Errorf("unsafe data directory %q", dataDir) + } + if err := ensureDirectory(dataDir); err != nil { return err } - return syncDirectory(dataDir) + lock, err := os.OpenFile(filepath.Join(dataDir, ".bootstrap.lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return fmt.Errorf("open data directory lock: %w", err) + } + if err := lock.Chmod(0o600); err != nil { + _ = lock.Close() + return fmt.Errorf("secure data directory lock: %w", err) + } + for { + err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX) + if !errors.Is(err, syscall.EINTR) { + break + } + } + if err != nil { + _ = lock.Close() + return fmt.Errorf("lock data directory: %w", err) + } + + operationErr := operation(dataDir) + unlockErr := syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + closeErr := lock.Close() + if unlockErr != nil { + unlockErr = fmt.Errorf("unlock data directory: %w", unlockErr) + } + if closeErr != nil { + closeErr = fmt.Errorf("close data directory lock: %w", closeErr) + } + return errors.Join(operationErr, unlockErr, closeErr) } func ensureDirectory(path string) error { @@ -136,6 +430,44 @@ func ensureDirectory(path string) error { return nil } +func (config CandidateConfig) replacePointer(path, target string) error { + if config.replaceLastGood != nil { + return config.replaceLastGood(path, target) + } + return replaceSymlink(path, target) +} + +func (config CandidateConfig) syncDirectory(path string) error { + if config.directorySync != nil { + return config.directorySync(path) + } + return syncDirectory(path) +} + +func restoreLastGood(config CandidateConfig, dataDir, target string) error { + path := filepath.Join(dataDir, "last-good") + var err error + if target == "" { + err = os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + err = nil + } + } else { + err = config.replacePointer(path, target) + } + if err != nil { + return persistenceError("restore last-good pointer", err) + } + if err := config.syncDirectory(dataDir); err != nil { + return persistenceError("sync restored last-good pointer", err) + } + return nil +} + +func persistenceError(action string, err error) error { + return fmt.Errorf("%w: %s: %w", errPersistence, action, err) +} + func readSubscriptionSecret(path string) (string, error) { info, err := os.Lstat(path) if err != nil { diff --git a/internal/bootstrap/candidate_test.go b/internal/bootstrap/candidate_test.go index f54db60..d620f90 100644 --- a/internal/bootstrap/candidate_test.go +++ b/internal/bootstrap/candidate_test.go @@ -66,6 +66,7 @@ func TestPublishCandidateFailureMatrixKeepsLastGoodAndRedactsInput(t *testing.T) response string status int secret string + template string transport bool }{ {name: "invalid secret URL", secret: "not-a-url-FAKE-SECRET"}, @@ -75,6 +76,7 @@ func TestPublishCandidateFailureMatrixKeepsLastGoodAndRedactsInput(t *testing.T) {name: "oversized response", response: strings.Repeat("x", maxSubscriptionSize+1)}, {name: "invalid YAML", response: "proxies: ["}, {name: "missing proxies", response: "proxy-groups: []\n"}, + {name: "generation failure", response: fullSubscription("new-node"), template: "[]\n"}, {name: "Mihomo rejection", response: fullSubscription("reject-validation")}, } { t.Run(testCase.name, func(t *testing.T) { @@ -110,6 +112,9 @@ func TestPublishCandidateFailureMatrixKeepsLastGoodAndRedactsInput(t *testing.T) t.Fatal(err) } } + if testCase.template != "" { + config.TemplatePath = writeFixture(t, t.TempDir(), "config.yaml", testCase.template) + } if testCase.transport { config.Client = &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("FAKE-SECRET transport detail") @@ -223,3 +228,32 @@ func assertNotContains(t *testing.T, path, unwanted string) { t.Errorf("%s contains %q", path, unwanted) } } + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(content) != want { + t.Fatalf("%s = %q, want %q", path, content, want) + } +} + +func writeFixture(t *testing.T, directory, name, content string) string { + t.Helper() + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (function roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} diff --git a/internal/bootstrap/config_contract_test.go b/internal/bootstrap/config_contract_test.go index 4c02411..85c0755 100644 --- a/internal/bootstrap/config_contract_test.go +++ b/internal/bootstrap/config_contract_test.go @@ -21,7 +21,8 @@ func TestSeededConfigExposesOnlyServerListeners(t *testing.T) { "mixed-port: 7890", "allow-lan: true", "bind-address: \"*\"", - "external-controller: 127.0.0.1:9090", + "external-controller: 0.0.0.0:9090", + "external-ui: /usr/local/share/mihomo/ui", } { if !strings.Contains(config, required) { t.Errorf("seeded config is missing %q", required) @@ -32,7 +33,6 @@ func TestSeededConfigExposesOnlyServerListeners(t *testing.T) { "tun:", "tproxy-port:", "redir-port:", - "external-controller: 0.0.0.0", } { if strings.Contains(config, forbidden) { t.Errorf("seeded config contains forbidden server-mode setting %q", forbidden) @@ -54,13 +54,13 @@ func TestSeededConfigUsesLocalACL4SSRRulesAndMemorySubscription(t *testing.T) { "RULE-SET,BanAD,๐ ๅนฟๅๆฆๆช", "RULE-SET,ProxyGFWlist,๐ ่็น้ๆฉ", "MATCH,๐ ๆผ็ฝไน้ฑผ", - "GoogleCN: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/GoogleCN.yaml}", - "Bing: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Bing.yaml}", - "OneDrive: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/OneDrive.yaml}", - "Microsoft: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Microsoft.yaml}", - "Telegram: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Telegram.yaml}", - "ChinaCompanyIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaCompanyIp.yaml}", - "ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}", + "GoogleCN: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/GoogleCN.yaml}", + "Bing: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Bing.yaml}", + "OneDrive: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/OneDrive.yaml}", + "Microsoft: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Microsoft.yaml}", + "Telegram: {type: file, behavior: classical, format: yaml, path: /usr/local/share/mihomo/rules/Ruleset/Telegram.yaml}", + "ChinaCompanyIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/mihomo/rules/ChinaCompanyIp.yaml}", + "ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/mihomo/rules/ChinaIp.yaml}", "RULE-SET,ChinaIp,๐ฏ ๅ จ็็ด่ฟ", } { if !strings.Contains(config, required) { @@ -79,7 +79,7 @@ func TestMihomoTemplateAndRuntimeAssetsArePinned(t *testing.T) { if err != nil { t.Fatalf("read seeded config: %v", err) } - if got, want := fmt.Sprintf("%x", sha256.Sum256(template)), "ba556936c447692164e6d7eabec13c1a83ace8014b723b4b20d6e3648ae49d54"; got != want { + if got, want := fmt.Sprintf("%x", sha256.Sum256(template)), "705568e96a76961b1593ac59163b07bb1aba5de1f7532794f9ab86afded3e07a"; got != want { t.Fatalf("seeded config SHA-256 = %s, want pinned %s", got, want) } dockerfile, err := os.ReadFile("../../Dockerfile") @@ -92,9 +92,24 @@ func TestMihomoTemplateAndRuntimeAssetsArePinned(t *testing.T) { "MIHOMO_SHA256_ARM64=58896873736d28628f66de3677c8654fa0f180662523148e136cff4f6e890069", "ACL4SSR_REF=6e27259b8625e360699c014f98f978ee7408c644", "ACL4SSR_SHA256=72229e2f0a38fc9776720a20dd4ecb44fdd0b0704bbf1f5141732562a237bff2", + "EXTERNAL_UI_VERSION=v1.273.0", + "EXTERNAL_UI_SHA256=076e05d2e3dc6641a0ec281aa4b97a18193fbcc379d139762c32d90adb22793c", + "EXTERNAL_UI_LICENSE_SHA256=cd0735ba06f26a0008bbca399890c7ca87fe129aacc302c2e33fb03e60a4e8c3", } { if !strings.Contains(string(dockerfile), pin) { t.Errorf("Dockerfile is missing pinned asset %q", pin) } } } + +func TestDockerBuildContextExcludesSubscriptionSecret(t *testing.T) { + t.Parallel() + + content, err := os.ReadFile("../../.dockerignore") + if err != nil { + t.Fatalf("read .dockerignore: %v", err) + } + if !strings.Contains("\n"+string(content)+"\n", "\nsubscription.url\n") { + t.Fatal(".dockerignore does not exclude subscription.url") + } +} diff --git a/internal/bootstrap/lifecycle_test.go b/internal/bootstrap/lifecycle_test.go new file mode 100644 index 0000000..ed65021 --- /dev/null +++ b/internal/bootstrap/lifecycle_test.go @@ -0,0 +1,429 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" +) + +func TestRunColdStartAndSignalUpdate(t *testing.T) { + fixture := newLifecycleFixture(t) + trigger := make(chan os.Signal, 1) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- Run(ctx, fixture.lifecycle(trigger)) }() + + fixture.waitStarted(t) + waitFor(t, func() bool { return fixture.subscriptionRequests.Load() == 1 }) + assertContains(t, filepath.Join(fixture.config.DataDir, "last-good", "subscription.yaml"), "first-node") + + fixture.setSubscription("second-node", http.StatusOK) + trigger <- syscall.SIGHUP + waitFor(t, func() bool { return fixture.subscriptionRequests.Load() == 2 && fixture.reloadRequests.Load() == 1 }) + assertContains(t, filepath.Join(fixture.config.DataDir, "last-good", "subscription.yaml"), "second-node") + + cancel() + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context cancellation", err) + } +} + +func TestRunWarmCacheSurvivesImmediateUpdateFailureAndRestart(t *testing.T) { + fixture := newLifecycleFixture(t) + if err := PublishCandidate(context.Background(), fixture.config); err != nil { + t.Fatal(err) + } + fixture.setSubscription("ignored", http.StatusServiceUnavailable) + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- Run(ctx, fixture.lifecycle(nil)) }() + fixture.waitStarted(t) + waitFor(t, func() bool { return fixture.subscriptionRequests.Load() >= 2 }) + assertContains(t, filepath.Join(fixture.config.DataDir, "last-good", "subscription.yaml"), "first-node") + select { + case err := <-result: + t.Fatalf("Run() stopped after warm-cache update failure: %v", err) + default: + } + cancel() + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context cancellation", err) + } +} + +func TestRunColdFailureDoesNotStartMihomo(t *testing.T) { + fixture := newLifecycleFixture(t) + fixture.setSubscription("ignored", http.StatusBadGateway) + err := Run(context.Background(), fixture.lifecycle(nil)) + if err == nil || !strings.Contains(err.Error(), "cold-start candidate failed") { + t.Fatalf("Run() error = %v, want cold-start failure", err) + } + if _, statErr := os.Stat(fixture.startedPath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("Mihomo started without a valid cache: %v", statErr) + } +} + +func TestRunReloadFailureRestoresLastGood(t *testing.T) { + fixture := newLifecycleFixture(t) + if err := PublishCandidate(context.Background(), fixture.config); err != nil { + t.Fatal(err) + } + wantTarget := readLastGood(t, fixture.config.DataDir) + fixture.setSubscription("rejected-node", http.StatusOK) + fixture.reloadFailures.Store(1) + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- Run(ctx, fixture.lifecycle(nil)) }() + fixture.waitStarted(t) + waitFor(t, func() bool { return fixture.reloadRequests.Load() == 2 }) + if got := readLastGood(t, fixture.config.DataDir); got != wantTarget { + t.Fatalf("last-good = %q, want restored %q", got, wantTarget) + } + assertContains(t, filepath.Join(fixture.config.DataDir, "last-good", "subscription.yaml"), "first-node") + cancel() + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context cancellation", err) + } +} + +func TestRunAndCandidateSerializeDataDirUpdates(t *testing.T) { + fixture := newLifecycleFixture(t) + if err := PublishCandidate(context.Background(), fixture.config); err != nil { + t.Fatal(err) + } + fixture.setSubscription("second-node", http.StatusOK) + fixture.blockNextSubscription.Store(true) + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- Run(ctx, fixture.lifecycle(nil)) }() + fixture.waitStarted(t) + select { + case <-fixture.subscriptionEntered: + case <-time.After(5 * time.Second): + close(fixture.subscriptionRelease) + t.Fatal("Run did not enter the locked candidate update") + } + + candidateResult := make(chan error, 1) + go func() { candidateResult <- PublishCandidate(context.Background(), fixture.config) }() + select { + case err := <-candidateResult: + close(fixture.subscriptionRelease) + t.Fatalf("concurrent candidate bypassed the data lock: %v", err) + case <-time.After(100 * time.Millisecond): + } + close(fixture.subscriptionRelease) + if err := <-candidateResult; err != nil { + t.Fatalf("concurrent PublishCandidate() error = %v", err) + } + assertContains(t, filepath.Join(fixture.config.DataDir, "last-good", "subscription.yaml"), "second-node") + + cancel() + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context cancellation", err) + } +} + +func TestRunWarmStartKeepsValidatedGenerationLocked(t *testing.T) { + fixture := newLifecycleFixture(t) + if err := PublishCandidate(context.Background(), fixture.config); err != nil { + t.Fatal(err) + } + firstTarget := readLastGood(t, fixture.config.DataDir) + fixture.setSubscription("second-node", http.StatusOK) + validated := make(chan struct{}) + resume := make(chan struct{}) + lifecycle := fixture.lifecycle(nil) + lifecycle.afterLastGoodValidated = func() { + close(validated) + <-resume + } + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { result <- Run(ctx, lifecycle) }() + select { + case <-validated: + case <-time.After(5 * time.Second): + close(resume) + t.Fatal("Run did not pause after warm generation validation") + } + + candidateStarted := make(chan struct{}) + candidateResult := make(chan error, 1) + go func() { + close(candidateStarted) + candidateResult <- PublishCandidate(context.Background(), fixture.config) + }() + <-candidateStarted + select { + case err := <-candidateResult: + close(resume) + t.Fatalf("candidate bypassed the warm-start data lock: %v", err) + case <-time.After(100 * time.Millisecond): + } + if got := fixture.subscriptionRequests.Load(); got != 1 { + close(resume) + t.Fatalf("subscription requests = %d before warm start resumed, want 1", got) + } + if _, err := os.Stat(fixture.startedPath); !errors.Is(err, os.ErrNotExist) { + close(resume) + t.Fatalf("Mihomo started before warm validation resumed: %v", err) + } + close(resume) + fixture.waitStarted(t) + if err := <-candidateResult; err != nil { + t.Fatalf("concurrent PublishCandidate() error = %v", err) + } + + activeTarget := "generations/a" + if firstTarget == activeTarget { + activeTarget = "generations/b" + } + if _, err := os.Stat(filepath.Join(fixture.config.DataDir, filepath.FromSlash(activeTarget))); err != nil { + t.Fatalf("active generation %q was removed: %v", activeTarget, err) + } + if _, err := os.Stat(filepath.Join(fixture.config.DataDir, "last-good")); err != nil { + t.Fatalf("last-good is dangling: %v", err) + } + select { + case err := <-result: + t.Fatalf("Run() stopped after concurrent candidate: %v", err) + default: + } + + cancel() + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context cancellation", err) + } +} + +func TestRunRollbackPersistenceFailureStopsMihomo(t *testing.T) { + fixture := newLifecycleFixture(t) + if err := PublishCandidate(context.Background(), fixture.config); err != nil { + t.Fatal(err) + } + wantTarget := readLastGood(t, fixture.config.DataDir) + fixture.setSubscription("rejected-node", http.StatusOK) + fixture.reloadFailures.Store(1) + var syncCalls atomic.Int32 + fixture.config.directorySync = func(path string) error { + if syncCalls.Add(1) == 3 { + return errors.New("injected rollback sync failure") + } + return syncDirectory(path) + } + + result := make(chan error, 1) + go func() { result <- Run(context.Background(), fixture.lifecycle(nil)) }() + fixture.waitStarted(t) + err := waitResult(t, result) + if !strings.Contains(err.Error(), "persist restored last-good pointer") { + t.Fatalf("Run() error = %v, want rollback persistence failure", err) + } + if got := readLastGood(t, fixture.config.DataDir); got != wantTarget { + t.Fatalf("last-good = %q, want restored %q", got, wantTarget) + } + fixture.waitStopped(t) +} + +func TestRunSecondReloadFailureStopsMihomo(t *testing.T) { + fixture := newLifecycleFixture(t) + if err := PublishCandidate(context.Background(), fixture.config); err != nil { + t.Fatal(err) + } + wantTarget := readLastGood(t, fixture.config.DataDir) + fixture.setSubscription("rejected-node", http.StatusOK) + fixture.reloadFailures.Store(2) + + result := make(chan error, 1) + go func() { result <- Run(context.Background(), fixture.lifecycle(nil)) }() + fixture.waitStarted(t) + err := waitResult(t, result) + if !strings.Contains(err.Error(), "reload restored last-good") { + t.Fatalf("Run() error = %v, want second reload failure", err) + } + if got := readLastGood(t, fixture.config.DataDir); got != wantTarget { + t.Fatalf("last-good = %q, want restored %q", got, wantTarget) + } + fixture.waitStopped(t) +} + +type lifecycleFixture struct { + config CandidateConfig + controllerURL string + startedPath string + lock sync.RWMutex + response string + status int + subscriptionRequests atomic.Int32 + reloadRequests atomic.Int32 + reloadFailures atomic.Int32 + blockNextSubscription atomic.Bool + subscriptionEntered chan struct{} + subscriptionRelease chan struct{} +} + +func newLifecycleFixture(t *testing.T) *lifecycleFixture { + t.Helper() + fixture := &lifecycleFixture{ + response: fullSubscription("first-node"), + status: http.StatusOK, + subscriptionEntered: make(chan struct{}), + subscriptionRelease: make(chan struct{}), + } + subscription := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + fixture.subscriptionRequests.Add(1) + if fixture.blockNextSubscription.CompareAndSwap(true, false) { + close(fixture.subscriptionEntered) + <-fixture.subscriptionRelease + } + fixture.lock.RLock() + defer fixture.lock.RUnlock() + writer.WriteHeader(fixture.status) + _, _ = writer.Write([]byte(fixture.response)) + })) + t.Cleanup(subscription.Close) + controller := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && request.URL.Path == "/version": + writer.WriteHeader(http.StatusOK) + case request.Method == http.MethodPut && request.URL.Path == "/configs": + fixture.reloadRequests.Add(1) + if fixture.consumeReloadFailure() { + http.Error(writer, "rejected", http.StatusInternalServerError) + return + } + writer.WriteHeader(http.StatusNoContent) + default: + http.NotFound(writer, request) + } + })) + t.Cleanup(controller.Close) + + directory := t.TempDir() + fixture.startedPath = filepath.Join(directory, "started") + secret := writeFixture(t, directory, "subscription-secret", subscription.URL+"\n") + template := writeFixture(t, directory, "config.yaml", "mixed-port: 7890\nexternal-controller: 127.0.0.1:9090\nproxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n") + mihomo := writeFixture(t, directory, "mihomo", fmt.Sprintf(`#!/bin/sh +set -eu +if [ "${1:-}" = -t ]; then + directory= + config= + while [ "$#" -gt 0 ]; do + case "$1" in + -d) directory=$2; shift 2 ;; + -f) config=$2; shift 2 ;; + *) shift ;; + esac + done + test -s "$config" + test -s "$directory/subscription.yaml" + ! grep -F reject-validation "$directory/subscription.yaml" >/dev/null + exit 0 +fi +printf '%%s' $$ > %q +trap 'exit 0' TERM INT +while :; do sleep 1; done +`, fixture.startedPath)) + if err := os.Chmod(mihomo, 0o755); err != nil { + t.Fatal(err) + } + fixture.config = CandidateConfig{ + SecretPath: secret, + DataDir: filepath.Join(directory, "data"), + TemplatePath: template, + MihomoBinary: mihomo, + } + fixture.controllerURL = controller.URL + return fixture +} + +func (fixture *lifecycleFixture) lifecycle(trigger <-chan os.Signal) LifecycleConfig { + return LifecycleConfig{ + Candidate: fixture.config, + ControllerURL: fixture.controllerURL, + UpdateInterval: time.Hour, + Trigger: trigger, + } +} + +func (fixture *lifecycleFixture) setSubscription(name string, status int) { + fixture.lock.Lock() + defer fixture.lock.Unlock() + fixture.response = fullSubscription(name) + fixture.status = status +} + +func (fixture *lifecycleFixture) waitStarted(t *testing.T) { + t.Helper() + waitFor(t, func() bool { + _, err := os.Stat(fixture.startedPath) + return err == nil + }) +} + +func (fixture *lifecycleFixture) waitStopped(t *testing.T) { + t.Helper() + waitFor(t, func() bool { + pidBytes, err := os.ReadFile(fixture.startedPath) + if err != nil { + return false + } + var pid int + if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { + return false + } + return errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) + }) +} + +func (fixture *lifecycleFixture) consumeReloadFailure() bool { + for { + remaining := fixture.reloadFailures.Load() + if remaining == 0 { + return false + } + if fixture.reloadFailures.CompareAndSwap(remaining, remaining-1) { + return true + } + } +} + +func waitResult(t *testing.T, result <-chan error) error { + t.Helper() + select { + case err := <-result: + if err == nil { + t.Fatal("Run() error = nil") + } + return err + case <-time.After(5 * time.Second): + t.Fatal("Run did not fail closed") + return nil + } +} + +func waitFor(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !condition() { + if time.Now().After(deadline) { + t.Fatal("condition was not met") + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/scripts/test.sh b/scripts/test.sh index 343dd09..d82d032 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 - SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo docker compose config --quiet + docker compose config --quiet fi diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh index fed0994..a48f2c6 100755 --- a/tests/container-smoke.sh +++ b/tests/container-smoke.sh @@ -2,336 +2,186 @@ set -eu image=${1:-mohomo-docker:smoke} -suffix="$$" -container="mohomo-docker-smoke-${suffix}" -unconfigured="mohomo-docker-unconfigured-${suffix}" -legacy_container="mohomo-docker-legacy-${suffix}" -provider="mohomo-provider-smoke-${suffix}" -network="mohomo-network-smoke-${suffix}" -legacy_network="mohomo-legacy-network-smoke-${suffix}" -volume="mohomo-volume-smoke-${suffix}" -legacy_volume="mohomo-legacy-volume-smoke-${suffix}" -candidate_volume="mohomo-candidate-volume-smoke-${suffix}" -candidate_secret_file=$(mktemp "${TMPDIR:-/tmp}/mohomo-candidate-secret.XXXXXX") -secret="container-smoke-secret" -admin_password="container-smoke-admin-password" +suffix=$$ +container="mohomo-smoke-${suffix}" +provider="mohomo-provider-${suffix}" +network="mohomo-network-${suffix}" +volume="mohomo-data-${suffix}" +cold_volume="mohomo-cold-${suffix}" +secret_volume="mohomo-secret-${suffix}" +secret_file=$(mktemp "${TMPDIR:-/tmp}/mohomo-secret.XXXXXX") +secret="fake-container-token" -case "$container:$unconfigured:$legacy_container:$provider:$network:$legacy_network:$volume:$legacy_volume:$candidate_volume" in -mohomo-docker-smoke-*':mohomo-docker-unconfigured-'*':mohomo-docker-legacy-'*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-legacy-network-smoke-'*':mohomo-volume-smoke-'*':mohomo-legacy-volume-smoke-'*':mohomo-candidate-volume-smoke-'*) ;; +case "$container:$provider:$network:$volume:$cold_volume:$secret_volume" in +mohomo-smoke-*':mohomo-provider-'*':mohomo-network-'*':mohomo-data-'*':mohomo-cold-'*':mohomo-secret-'*) ;; *) echo "refusing unsafe cleanup targets" >&2; exit 1 ;; esac cleanup() { - docker container rm --force "$container" "$unconfigured" "$legacy_container" "$provider" >/dev/null 2>&1 || true - docker volume rm "$volume" "$legacy_volume" "$candidate_volume" >/dev/null 2>&1 || true - docker network rm "$network" "$legacy_network" >/dev/null 2>&1 || true - rm -f "$candidate_secret_file" + docker container rm --force "$container" "$provider" >/dev/null 2>&1 || true + docker volume rm "$volume" "$cold_volume" "$secret_volume" >/dev/null 2>&1 || true + docker network rm "$network" >/dev/null 2>&1 || true + rm -f "$secret_file" } trap cleanup EXIT INT TERM wait_for_health() { - health_container=${1:-$container} attempt=0 - until [ "$(docker inspect --format '{{.State.Health.Status}}' "$health_container")" = healthy ]; do + until [ "$(docker inspect --format '{{.State.Health.Status}}' "$container")" = healthy ]; do attempt=$((attempt + 1)) - if [ "$attempt" -ge 30 ]; then - docker logs "$health_container" >&2 - echo "container did not become healthy" >&2 + if [ "$attempt" -ge 60 ]; then + docker logs "$container" >&2 exit 1 fi sleep 1 done } -wait_for_subscription() { +wait_for_last_good() { expected=$1 attempt=0 - until docker exec "$provider" wget -qO- http://127.0.0.1:8080/provider.yaml | grep -F "$expected" >/dev/null; do + until docker exec "$container" grep -F "$expected" /data/last-good/subscription.yaml >/dev/null 2>&1; do attempt=$((attempt + 1)) - if [ "$attempt" -ge 10 ]; then - echo "subscription fixture did not serve expected content" >&2 + if [ "$attempt" -ge 30 ]; then + docker logs "$container" >&2 + echo "last-good did not contain $expected" >&2 exit 1 fi sleep 1 done } -assert_published_ports() { - published=$(docker port "$container") - for port in 7890/tcp 7890/udp 9091/tcp; do - printf '%s\n' "$published" | grep -F "$port ->" >/dev/null +wait_for_log() { + expected=$1 + attempt=0 + until docker logs "$container" 2>&1 | grep -F "$expected" >/dev/null; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 30 ] || { docker logs "$container" >&2; exit 1; } + sleep 1 done - if printf '%s\n' "$published" | grep -vE '^(7890/(tcp|udp)|9091/tcp)' >/dev/null; then - echo "container published a port other than 7890 or 9091" >&2 - exit 1 - fi } -assert_web_login_and_start() { - web_port=$1 - docker run --rm --network host \ - --env "WEB_PORT=${web_port}" \ - --env "ADMIN_PASSWORD=${admin_password}" \ - --entrypoint /bin/sh \ - "$image" -c ' - set -eu - cookie=$(mktemp) - setup_redirect=$(curl --silent --show-error --output /dev/null \ - --write-out "%{http_code} %{redirect_url}" \ - "http://127.0.0.1:${WEB_PORT}/setup") - if [ "$setup_redirect" != "303 http://127.0.0.1:${WEB_PORT}/login" ]; then - echo "configured Web UI exposed setup: ${setup_redirect}" >&2 - exit 1 - fi - login_html=$(curl --fail --silent --show-error --cookie-jar "$cookie" \ - "http://127.0.0.1:${WEB_PORT}/login") - login_csrf=$(printf "%s" "$login_html" | sed -n "s/.*name=\"csrf\" value=\"\([^\"]*\)\".*/\1/p" | 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=${ADMIN_PASSWORD}" \ - "http://127.0.0.1:${WEB_PORT}/login" >/dev/null - config_html=$(curl --fail --silent --show-error --cookie "$cookie" \ - "http://127.0.0.1:${WEB_PORT}/config") - api_csrf=$(printf "%s" "$config_html" | sed -n "s/.*name=\"csrf-token\" content=\"\([^\"]*\)\".*/\1/p" | head -1) - test -n "$api_csrf" - 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" | grep -F "\"ok\":true" >/dev/null - curl --fail --silent --show-error --cookie "$cookie" \ - "http://127.0.0.1:${WEB_PORT}/api/status" | grep -F "\"running\":true" >/dev/null - ' +host_curl() { + docker run --rm --network host --entrypoint curl "$image" --fail --silent --show-error "$@" } -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 +printf 'http://%s:8080/provider.yaml?token=%s\n' "$provider" "$secret" > "$secret_file" +chmod 0444 "$secret_file" -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 +compose=$(SUBSCRIPTION_FILE="$secret_file" docker compose config) +[ "$(printf '%s\n' "$compose" | grep -c 'target: 7890')" -eq 1 ] +[ "$(printf '%s\n' "$compose" | grep -c 'target: 9090')" -eq 1 ] +[ "$(printf '%s\n' "$compose" | grep -c 'host_ip: 127.0.0.1')" -eq 2 ] +printf '%s\n' "$compose" | grep -F 'read_only: true' >/dev/null +printf '%s\n' "$compose" | grep -F 'source: subscription' >/dev/null +[ "$(printf '%s\n' "$compose" | grep -c 'protocol: tcp')" -eq 2 ] +if [ "$(printf '%s\n' "$compose" | grep -c 'protocol:')" -ne 2 ] || printf '%s\n' "$compose" | grep -F 'target: 9091' >/dev/null; then + echo "Compose publishes a forbidden port or protocol" >&2 exit 1 fi docker build --tag "$image" . -docker run --rm --network none --entrypoint /bin/sh "$image" -c ' - set -eu - runtime=$(mktemp -d) - cp /usr/local/share/ssclash/config.yaml "$runtime/config.yaml" - printf "proxies:\n - name: smoke-node\n type: socks5\n server: 127.0.0.1\n port: 9\n" > "$runtime/subscription.yaml" - /usr/local/lib/ssclash/clash -t -d "$runtime" -f "$runtime/config.yaml" -' >/dev/null - docker network create "$network" >/dev/null -docker network create --internal "$legacy_network" >/dev/null +docker volume create "$volume" >/dev/null +docker volume create "$cold_volume" >/dev/null +docker volume create "$secret_volume" >/dev/null +docker run --rm \ + --user 0:0 \ + --env "PROVIDER=$provider" \ + --env "SECRET=$secret" \ + --volume "$secret_volume:/run/secrets" \ + --entrypoint /bin/sh \ + "$image" -c 'printf "http://%s:8080/provider.yaml?token=%s\n" "$PROVIDER" "$SECRET" > /run/secrets/subscription; chmod 0444 /run/secrets/subscription' + docker run --detach --rm \ --name "$provider" \ --network "$network" \ --entrypoint /bin/sh \ - "$image" -c 'while :; do - if [ -f /tmp/invalid-subscription ]; then - printf "HTTP/1.1 200 OK\r\nContent-Type: text/yaml\r\nConnection: close\r\n\r\nproxies: [" - else - printf "HTTP/1.1 200 OK\r\nContent-Type: text/yaml\r\nConnection: close\r\n\r\nproxies:\n - name: smoke-node\n type: socks5\n server: 127.0.0.1\n port: 9\n" - fi | nc -l -p 8080 - done' >/dev/null -docker network connect "$legacy_network" "$provider" -wait_for_subscription 'name: smoke-node' + "$image" -c 'mkdir /tmp/web; printf "proxies:\n - name: first-node\n type: socks5\n server: 127.0.0.1\n port: 9\n" > /tmp/web/provider.yaml; printf "%s\n" "#!/bin/sh" "body=\$(cat /tmp/web/provider.yaml)" "length=\$(printf \"%s\" \"\$body\" | wc -c)" "printf \"HTTP/1.1 200 OK\\r\\nContent-Type: text/yaml\\r\\nContent-Length: %s\\r\\nConnection: close\\r\\n\\r\\n\" \"\$length\"" "printf \"%s\" \"\$body\"" > /tmp/handler; chmod +x /tmp/handler; exec nc -lk -p 8080 -e /tmp/handler' >/dev/null + +attempt=0 +until docker exec "$provider" wget -qO- http://127.0.0.1:8080/provider.yaml | grep -F first-node >/dev/null; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 20 ] || { echo "subscription fixture did not start" >&2; exit 1; } + sleep 1 +done -printf 'http://%s:8080/provider.yaml?token=%s\n' "$provider" "$secret" > "$candidate_secret_file" -chmod 0444 "$candidate_secret_file" -docker volume create "$candidate_volume" >/dev/null -docker run --rm \ - --network "$network" \ - --volume "$candidate_volume:/data" \ - --mount "type=bind,source=${candidate_secret_file},target=/run/secrets/subscription,readonly" \ - "$image" candidate >/dev/null -last_good=$(docker run --rm \ - --volume "$candidate_volume:/data" \ - --entrypoint /bin/sh \ - "$image" -c 'test -w /data; test -L /data/last-good; grep -F "name: smoke-node" /data/last-good/subscription.yaml >/dev/null; readlink /data/last-good') -docker exec "$provider" touch /tmp/invalid-subscription -wait_for_subscription 'proxies: [' if failure=$(docker run --rm \ - --network "$network" \ - --volume "$candidate_volume:/data" \ - --mount "type=bind,source=${candidate_secret_file},target=/run/secrets/subscription,readonly" \ - "$image" candidate 2>&1); then - echo "candidate accepted invalid YAML" >&2 + --network none \ + --volume "$cold_volume:/data" \ + --volume "$secret_volume:/run/secrets:ro" \ + "$image" 2>&1); then + echo "cold start succeeded without a reachable subscription" >&2 exit 1 fi if printf '%s\n' "$failure" | grep -F "$secret" >/dev/null; then - echo "candidate failure leaked subscription secret" >&2 + echo "cold-start failure leaked the subscription secret" >&2 exit 1 fi -docker run --rm \ - --volume "$candidate_volume:/data" \ - --entrypoint /bin/sh \ - "$image" -c "test \"\$(readlink /data/last-good)\" = '$last_good'; grep -F 'name: smoke-node' /data/last-good/subscription.yaml >/dev/null" -docker exec "$provider" rm /tmp/invalid-subscription -wait_for_subscription 'name: smoke-node' - -docker volume create "$legacy_volume" >/dev/null -docker run --rm \ - --volume "$legacy_volume:/opt/clash" \ - --entrypoint /bin/sh \ - "$image" -c ' - set -eu - grep -v -F " ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}" \ - /usr/local/share/ssclash/config.yaml \ - | sed "s/^ - RULE-SET,ChinaIp,/ - GEOIP,CN,/" \ - > /opt/clash/config.yaml - grep -F " - GEOIP,CN,๐ฏ ๅ จ็็ด่ฟ" /opt/clash/config.yaml >/dev/null - ' -docker run --detach \ - --name "$legacy_container" \ - --network "$legacy_network" \ - --env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml" \ - --env "SSCLASH_PASSWORD=${admin_password}" \ - --volume "$legacy_volume:/opt/clash" \ - "$image" >/dev/null -wait_for_health "$legacy_container" -docker logs "$legacy_container" 2>&1 | grep -F 'bootstrap: SSClash started' >/dev/null -if docker logs "$legacy_container" 2>&1 | grep -F 'geoip.metadb' >/dev/null; then - echo "legacy managed config attempted a GeoIP download" >&2 - exit 1 -fi -docker exec "$legacy_container" /bin/sh -c ' - set -eu - cmp /opt/clash/config.yaml /usr/local/share/ssclash/config.yaml - test "$(cat /opt/clash/.mohomo-docker-config-version)" = 1 - grep -F "name: smoke-node" /dev/shm/mohomo/subscription.yaml >/dev/null - /usr/local/lib/ssclash/clash -t -d /dev/shm/mohomo -f /dev/shm/mohomo/config.yaml - ' >/dev/null -docker container rm --force "$legacy_container" >/dev/null -docker volume create "$volume" >/dev/null - -docker run --detach \ - --name "$unconfigured" \ - --network "$network" \ - --cap-drop ALL \ - --security-opt no-new-privileges:true \ - --env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml" \ - --volume "$volume:/opt/clash" \ - --publish 127.0.0.1::9091/tcp \ - "$image" >/dev/null -unconfigured_port=$(docker port "$unconfigured" 9091/tcp | awk -F: 'NR == 1 { print $NF }') -attempt=0 -while [ "$(docker inspect --format '{{.State.Running}}' "$unconfigured")" = true ]; do - if curl --fail --silent --show-error --max-time 1 \ - "http://127.0.0.1:${unconfigured_port}/setup" >/dev/null 2>&1; then - echo "fresh volume exposed anonymous setup" >&2 - exit 1 - fi - attempt=$((attempt + 1)) - if [ "$attempt" -ge 10 ]; then - echo "fresh volume did not fail closed without SSCLASH_PASSWORD" >&2 - exit 1 - fi - sleep 1 -done -if [ "$(docker inspect --format '{{.State.ExitCode}}' "$unconfigured")" -eq 0 ]; then - echo "fresh volume exited successfully without SSCLASH_PASSWORD" >&2 - exit 1 -fi -docker logs "$unconfigured" 2>&1 | grep -F 'SSCLASH_PASSWORD is required' >/dev/null -if docker logs "$unconfigured" 2>&1 | grep -F 'web UI listening' >/dev/null; then - echo "fresh volume started the Web UI before authentication was configured" >&2 - exit 1 -fi -docker container rm "$unconfigured" >/dev/null +docker run --rm --volume "$cold_volume:/data" --entrypoint /bin/sh "$image" -c 'test ! -e /data/last-good' docker run --detach \ --name "$container" \ --network "$network" \ + --read-only \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m \ --cap-drop ALL \ --security-opt no-new-privileges:true \ - --env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml?token=${secret}" \ - --env "SSCLASH_PASSWORD=${admin_password}" \ - --volume "$volume:/opt/clash" \ + --volume "$volume:/data" \ + --volume "$secret_volume:/run/secrets:ro" \ --publish 127.0.0.1::7890/tcp \ - --publish 127.0.0.1::7890/udp \ - --publish 127.0.0.1::9091/tcp \ + --publish 127.0.0.1::9090/tcp \ "$image" >/dev/null wait_for_health -assert_published_ports -web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }') -assert_web_login_and_start "$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 +wait_for_last_good first-node +published=$(docker port "$container") +[ "$(printf '%s\n' "$published" | wc -l)" -eq 2 ] +printf '%s\n' "$published" | grep -F '7890/tcp -> 127.0.0.1:' >/dev/null +printf '%s\n' "$published" | grep -F '9090/tcp -> 127.0.0.1:' >/dev/null +proxy_port=$(docker port "$container" 7890/tcp | awk -F: 'NR == 1 {print $NF}') +controller_port=$(docker port "$container" 9090/tcp | awk -F: 'NR == 1 {print $NF}') +docker run --rm --network host --entrypoint /bin/sh "$image" -c "nc -z 127.0.0.1 $proxy_port" +host_curl "http://127.0.0.1:${controller_port}/version" >/dev/null +host_curl "http://127.0.0.1:${controller_port}/ui/" | grep -Fi '