Author SHA1 Message Date
rogee 9f385c8808 HH-723: publish Gitea container package (#13)
Docker image (Gitea) / Test and build (push) Successful in 1m29s
2026-08-27 11:30:43 +08:00
rogee 2c9ae4bbc4 HH-723: split Gitea and GitHub workflows (#12)
Docker image (Gitea) / Test and build (push) Successful in 1m18s
2026-08-27 10:06:31 +08:00
rogee dcad041c0f HH-704: clarify update timing and HUP command (#11)
Docker image / Test (push) Successful in 1m27s
Docker image / Build and publish (push) Failing after 27s
2026-08-26 19:02:30 +08:00
rogee 43e05501ee HH-701: document operations and local acceptance (#10)
Docker image / Test (push) Successful in 1m20s
Docker image / Build and publish (push) Failing after 24s
2026-08-26 18:29:12 +08:00
rogee d7c883afc1 HH-690: single-container Mihomo lifecycle and ExternalUI (#9)
Docker image / Test (push) Successful in 2m12s
Docker image / Build and publish (push) Failing after 37s
2026-08-26 17:29:31 +08:00
rogee 48f44a0004 HH-682: add Stage 1 candidate config pipeline (#8)
Docker image / Test (push) Failing after 47s
Docker image / Build and publish (push) Skipped
2026-08-26 12:43:56 +08:00
rogee 5b14158870 HH-649: strengthen legacy migration tests (#7)
Docker image / Test (push) Successful in 1m25s
Docker image / Build and publish (push) Failing after 48s
2026-08-25 11:22:21 +08:00
rogee f29a3fc855 HH-635: let SSClash own Mihomo lifecycle (#6)
Docker image / Test (push) Successful in 1m12s
Docker image / Build and publish (push) Failing after 23s
2026-08-25 10:42:08 +08:00
rogee 6c67390d52 HH-636: run container smoke test in CI (#5)
Docker image / Test (push) Successful in 2m52s
Docker image / Build and publish (push) Failing after 23s
2026-08-25 00:40:20 +08:00
rogee 5f5b5799e4 HH-635: avoid runtime GeoIP download (#4)
Docker image / Test (push) Successful in 39s
Docker image / Build and publish (push) Failing after 34s
2026-08-24 23:30:26 +08:00
rogee d61799d180 HH-620: expose SSClash Web UI on port 9091 (#3)
Docker image / Test (push) Successful in 40s
Docker image / Build and publish (push) Failing after 23s
2026-08-24 17:57:11 +08:00
24 changed files with 1920 additions and 1038 deletions
+1
View File
@@ -1,5 +1,6 @@
.git
.env
subscription.url
coverage.out
tests
README.md
+4 -2
View File
@@ -1,5 +1,7 @@
IMAGE_NAME=mohomo-docker:local
CONTAINER_NAME=mohomo-docker
SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo
PROXY_BIND=0.0.0.0
SUBSCRIPTION_FILE=./subscription.url
PROXY_BIND=127.0.0.1
PROXY_PORT=7890
CONTROLLER_BIND=127.0.0.1
CONTROLLER_PORT=9090
+64
View File
@@ -0,0 +1,64 @@
name: Docker image (Gitea)
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
test:
name: Test and build
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false
- name: Run tests
run: ./scripts/test.sh
- name: Build and run container smoke test
run: ./tests/container-smoke.sh
- name: Log in to Gitea Container Registry
if: gitea.event_name != 'pull_request'
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: printf '%s' "$REGISTRY_TOKEN" | docker login git.ipao.vip --username "${{ gitea.actor }}" --password-stdin
- name: Publish tested image
if: gitea.event_name != 'pull_request'
env:
IMAGE_NAME: git.ipao.vip/rogee/mohomo-docker
SHA_TAG: sha-${{ gitea.sha }}
run: |
docker tag mohomo-docker:smoke "$IMAGE_NAME:latest"
docker tag mohomo-docker:smoke "$IMAGE_NAME:$SHA_TAG"
docker push "$IMAGE_NAME:latest"
docker push "$IMAGE_NAME:$SHA_TAG"
- name: Link package to repository
if: gitea.event_name != 'pull_request'
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
package_api=https://git.ipao.vip/api/v1/packages/rogee/container/mohomo-docker
linked_repo="$(curl --fail --silent --show-error --header "Authorization: token $REGISTRY_TOKEN" "$package_api/-/latest" | jq -r '.repository.full_name // empty')"
if [ "$linked_repo" != rogee/mohomo-docker ]; then
curl --fail --silent --show-error --request POST \
--header "Authorization: token $REGISTRY_TOKEN" \
"$package_api/-/link/mohomo-docker"
fi
test "$(curl --fail --silent --show-error --header "Authorization: token $REGISTRY_TOKEN" "$package_api/-/latest" | jq -r '.repository.full_name // empty')" = rogee/mohomo-docker
+4 -1
View File
@@ -1,4 +1,4 @@
name: Docker image
name: Docker image (GitHub)
on:
push:
@@ -38,6 +38,9 @@ jobs:
- name: Run tests
run: ./scripts/test.sh
- name: Run container smoke test
run: ./tests/container-smoke.sh
build:
name: Build and publish
needs: test
+1
View File
@@ -1,2 +1,3 @@
.env
subscription.url
coverage.out
+8 -10
View File
@@ -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.
+46 -50
View File
@@ -7,82 +7,78 @@ FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS bootstrap-builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod ./
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
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=cf06ce2c7d1421bdbda14ee4a5b6046672dc35ebf8eecd8e77504ec3c0ed9a84
ARG MIHOMO_SHA256_AMD64=cbe553d0319a414bd3a372c5976a252155b2c4882b66bce88a4d6bba9571a553
ARG MIHOMO_SHA256_ARM64=58896873736d28628f66de3677c8654fa0f180662523148e136cff4f6e890069
WORKDIR /assets
RUN apk add --no-cache ca-certificates curl gzip
RUN case "${TARGETARCH}" in \
amd64|arm64) ;; \
RUN set -eu; \
case "${TARGETARCH}" in \
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 case "${TARGETARCH}" in \
amd64) mihomo_sha256="${MIHOMO_SHA256_AMD64}" ;; \
arm64) mihomo_sha256="${MIHOMO_SHA256_ARM64}" ;; \
esac; \
asset="mihomo-linux-${TARGETARCH}-${MIHOMO_VERSION}.gz"; \
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
ARG ACL4SSR_SHA256=72229e2f0a38fc9776720a20dd4ecb44fdd0b0704bbf1f5141732562a237bff2
RUN apk add --no-cache ca-certificates curl
RUN curl --fail --show-error --silent --location --retry 3 \
--output /tmp/acl4ssr.tar.gz \
"https://github.com/ACL4SSR/ACL4SSR/archive/${ACL4SSR_REF}.tar.gz"; \
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"; \
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 /opt/clash /tmp/ssclash /usr/local/lib/ssclash /usr/local/share/ssclash \
&& chown -R ssclash:ssclash /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=127.0.0.1:9091 \
SAFE_PATHS=/usr/local/share/ssclash
ENV SAFE_PATHS=/usr/local/share/mihomo:/data
USER ssclash
VOLUME ["/opt/clash"]
EXPOSE 7890/tcp 7890/udp
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=4 \
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"]
+2 -1
View File
@@ -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.
+1 -1
View File
@@ -16,4 +16,4 @@ down:
docker compose down
logs:
docker compose logs -f ssclash
docker compose logs -f mihomo
+78 -29
View File
@@ -1,62 +1,111 @@
# mohomo-docker
Minimal Mihomo service with the ACL4SSR `Online Full MultiMode` routing model. The host exposes only mixed proxy port `7890`; SSClash and Mihomo's controller remain loopback-only inside the container.
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
# Replace only SUBSCRIPTION_URL in .env.
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. 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.
The host file should remain mode `0600`; Compose exposes it inside the container as a read-only secret. Do not put the URL in `.env`, command-line arguments, Compose YAML, or support logs. The checked-in `.gitignore` and `.dockerignore` exclude the default secret filename, but operators remain responsible for protecting custom secret paths.
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://<server>:7890
SOCKS5 proxy: socks5://<server>:7890
HTTP proxy: http://127.0.0.1:7890
SOCKS5 proxy: socks5://127.0.0.1:7890
Controller: http://127.0.0.1:9090
```
`PROXY_BIND` and `PROXY_PORT` are optional deployment overrides. Configure Mihomo proxy authentication before publishing port `7890` outside a trusted network.
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.
## Update and secret handling
In MetaCubeXD, open the `🚀 节点选择` group and choose a node or policy. The choice applies to the current Mihomo process only; `profile.store-selected` is disabled, so a restart returns to the configured default.
The bootstrap fetches the subscription once before startup and every hour thereafter. Each candidate is limited to 16 MiB and validated with the packaged Mihomo binary before an atomic replacement and hot reload. A failed reload restores and reloads the previous provider; if that recovery cannot be confirmed, both services stop instead of running with uncertain state.
## Data and restart recovery
The subscription URL is read from `SUBSCRIPTION_URL`, removed from child-process environments, and never printed. The generated configuration contains only a local provider path. Subscription data lives under `/dev/shm/mohomo`, so neither the image nor the `/opt/clash` volume stores its URL, response, or node credentials. Container restarts intentionally fetch a fresh subscription instead of persisting credentials.
Compose mounts the `mihomo-data` named volume at `/data`. It contains generated configuration and normalized subscription data, including node credentials, with private container-side permissions. Treat the volume as sensitive: do not copy it into images, source control, unencrypted backups, or support bundles.
Do not commit `.env`; it is ignored by Git. Docker still exposes container environment variables to principals allowed to inspect the container, so restrict Docker daemon access.
`/data/last-good` is a managed relative symlink to `/data/generations/a` or `/data/generations/b`. Keep the same Compose project and named volume across upgrades and restarts. Do not use `docker compose down --volumes` unless intentionally deleting the cached configuration; removing the volume makes the next start a cold start that requires the subscription endpoint to be reachable.
## ACL4SSR rules
Normal recovery uses the existing volume:
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.
```sh
docker compose restart mihomo
docker compose ps
```
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.
## Lifecycle and updates
## Persistent data
On a fresh volume, bootstrap downloads, normalizes, generates, and validates a candidate with the packaged Mihomo binary before starting Mihomo. This is the cold-start update; a failure exits nonzero without starting an empty configuration.
`/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.
On restart, bootstrap validates and starts the cached `last-good` slot, waits for the controller, and then immediately attempts an update. After startup processing finishes—including the cold-start update on a fresh volume or the immediate update on a restart—the hourly timer starts. The first scheduled update therefore runs one hour after that processing completes. Each candidate is written to the inactive generation, validated, and atomically selected before Mihomo reloads it through the native `PUT /configs` API.
## Reproducible inputs
Download, HTTP, YAML, generation, or Mihomo validation failures reject the candidate and keep the running `last-good`. A reload failure restores the prior pointer and reloads the prior configuration. A storage failure, or failure to persist/reload that rollback, stops Mihomo instead of claiming an unsafe recovery; Compose's restart policy then retries startup from whatever valid `last-good` remains.
The Dockerfile pins:
Trigger the same update path immediately for tests or operations:
- 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.
```sh
docker compose kill --signal HUP mihomo
```
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 `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.
## Verification
## Troubleshooting
Start with service state and bounded logs:
```sh
docker compose ps
docker compose logs --tail=200 mihomo
curl --fail http://127.0.0.1:9090/version
docker compose exec mihomo readlink /data/last-good
docker compose exec mihomo /usr/local/bin/mihomo -t -d /data/last-good -f /data/last-good/config.yaml
```
Do not print `/run/secrets/subscription` or `/data/last-good/subscription.yaml` while collecting diagnostics.
- `cold-start candidate failed`: there is no valid cache and the secret, endpoint, response, or generated configuration was rejected. Confirm the secret file contains one reachable absolute HTTP(S) URL and that the response is a single Mihomo/Clash YAML document with a non-empty `proxies` list.
- `update rejected; keeping last-good`: the service remains available on the old configuration. Fix the subscription response or connectivity, then send `SIGHUP` to retry.
- `reload rejected; restored and reloaded last-good`: the new candidate did not load, and bootstrap restored the previous configuration. Inspect the preceding error without exposing the subscription.
- `fatal update stopped Mihomo`: persistence or rollback could not be guaranteed. Check free space, ownership, and write access for the `/data` volume before relying on automatic restart.
- Controller works but the UI does not: use the trailing-slash URL `/ui/` and confirm the 9090 mapping with `docker compose port mihomo 9090`. A remote browser cannot use the default loopback binding; change `CONTROLLER_BIND` only after adding an appropriate network boundary.
## Runtime assets
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 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.
## CI image publishing
GitHub Actions publishes to `ghcr.io/<github.repository>`. Gitea Actions publishes the same tested image to `git.ipao.vip/rogee/mohomo-docker` with `latest` and `sha-<full-commit>` tags. The workflows are independent and do not share registry credentials or provider contexts.
Before enabling Gitea publishing, add a repository Actions secret named `REGISTRY_TOKEN`. It must be a Gitea token whose owner can push packages for `rogee` and link the resulting container package to `rogee/mohomo-docker`. Keep the token out of files and logs, and rotate it in Gitea without changing the workflow.
Gitea publishes only after tests and the container smoke test pass on a `main` push or manual workflow dispatch. Pull requests run those validations but skip secret use, registry login, image push, and package linking. A failed link check leaves the pushed image intact; fix the token permissions and rerun the workflow to retry the idempotent link step.
## Local acceptance
```sh
./tests/container-smoke.sh
```
This single command builds the image and uses an isolated local provider with sanitized fake nodes and a fake query token. It covers cold-start failure, warm recovery, immediate and `SIGHUP` updates, invalid-candidate retention, restart recovery, the 7890/9090 boundary, ExternalUI loading, proxy-group reads, and one node switch. It never uses a real subscription or an online rule conversion service. The image build may still need network access to download the pinned official artifacts whose SHA-256 values are verified.
For the complete developer check set, run:
```sh
./scripts/test.sh
./tests/container-smoke.sh
go vet ./...
go mod verify
git diff --check
```
The unit suite checks atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test builds the image, validates the generated configuration, checks that only port `7890` is published, and verifies that the subscription credential is neither persisted nor logged.
## License boundary
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.
All test fixtures use only sanitized fake values.
+31 -40
View File
@@ -13,57 +13,48 @@ import (
)
const (
defaultRoot = "/opt/clash"
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"
)
func main() {
log.SetFlags(log.Ldate | log.Ltime | log.LUTC)
root := envOrDefault("SSCLASH_ROOT", defaultRoot)
log.Printf("bootstrap: preparing persistent runtime root=%s", root)
result, err := bootstrap.Prepare(bootstrap.Config{
Root: root,
CoreSource: defaultCoreSource,
ConfigSource: defaultConfigSource,
})
if err != nil {
log.Fatalf("bootstrap: runtime preparation failed: %v", err)
if len(os.Args) == 2 && os.Args[1] == "candidate" {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := bootstrap.PublishCandidate(ctx, bootstrap.CandidateConfig{
SecretPath: defaultSecretPath,
DataDir: defaultDataDir,
TemplatePath: defaultConfigSource,
MihomoBinary: defaultCoreSource,
}); err != nil {
log.Fatalf("bootstrap: candidate update failed: %v", err)
}
log.Print("bootstrap: candidate configuration published")
return
}
log.Printf(
"bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_settings_changed=%t",
root,
result.CoreInitialized,
result.ConfigInitialized,
result.ServerSettingsChanged,
)
subscriptionURL := os.Getenv("SUBSCRIPTION_URL")
if subscriptionURL == "" {
log.Fatal("bootstrap: SUBSCRIPTION_URL is required")
if len(os.Args) != 1 {
log.Fatal("bootstrap: usage: bootstrap [candidate]")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
err = bootstrap.Run(ctx, bootstrap.RuntimeConfig{
CoreBinary: defaultCoreSource,
SSClashBinary: ssclashBinary,
ConfigSource: defaultConfigSource,
RuntimeDir: defaultRuntimeDir,
SubscriptionURL: subscriptionURL,
UpdateInterval: time.Hour,
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
}
+16 -7
View File
@@ -1,18 +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}
read_only: true
ports:
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/tcp"
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/udp"
- "${PROXY_BIND:-127.0.0.1}:${PROXY_PORT:-7890}:7890/tcp"
- "${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:
@@ -24,5 +29,9 @@ services:
max-size: 10m
max-file: "3"
secrets:
subscription:
file: ${SUBSCRIPTION_FILE:-./subscription.url}
volumes:
ssclash-data:
mihomo-data:
+35 -33
View File
@@ -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,37 +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}
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,🎯 全球直连
@@ -207,5 +209,5 @@ rules:
- RULE-SET,ChinaDomain,🎯 全球直连
- RULE-SET,ChinaCompanyIp,🎯 全球直连
- RULE-SET,Download,🎯 全球直连
- GEOIP,CN,🎯 全球直连
- RULE-SET,ChinaIp,🎯 全球直连
- MATCH,🐟 漏网之鱼
+2
View File
@@ -1,3 +1,5 @@
module git.ipao.vip/rogee/mohomo-docker
go 1.24
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+14 -421
View File
@@ -5,356 +5,13 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
const maxSubscriptionSize = 16 << 20
var errMihomoStateUncertain = errors.New("Mihomo subscription state could not be restored")
type enforcedSetting struct {
key string
value string
}
var serverSettings = []enforcedSetting{
{key: "OPERATING_MODE=", value: "server"},
{key: "PROXY_MODE=", value: "none"},
}
var runtimeDirectories = []string{
"bin",
".ssclash",
"configs",
"local-rules",
"rule-providers",
"proxy-providers",
"subscriptions",
"ui",
}
type Config struct {
Root string
CoreSource string
ConfigSource string
}
type Result struct {
CoreInitialized bool
ConfigInitialized bool
ServerSettingsChanged bool
}
type RuntimeConfig struct {
CoreBinary string
SSClashBinary string
ConfigSource string
RuntimeDir string
SubscriptionURL string
UpdateInterval time.Duration
}
func Prepare(config Config) (Result, error) {
var result Result
root := filepath.Clean(config.Root)
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)
}
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)
}
}
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, err = copyIfAbsent(config.ConfigSource, filepath.Join(root, "config.yaml"), 0o644)
if err != nil {
return result, fmt.Errorf("initialize 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 Run(ctx context.Context, config RuntimeConfig) error {
if err := validateSubscriptionURL(config.SubscriptionURL); err != nil {
return err
}
if config.UpdateInterval <= 0 {
return errors.New("subscription update interval must be positive")
}
runtimeDir := filepath.Clean(config.RuntimeDir)
if !filepath.IsAbs(runtimeDir) || runtimeDir == string(filepath.Separator) {
return fmt.Errorf("unsafe runtime directory %q", config.RuntimeDir)
}
for path, label := range map[string]string{
config.CoreBinary: "Mihomo core",
config.SSClashBinary: "SSClash binary",
config.ConfigSource: "config source",
} {
if err := validateSource(path, label); err != nil {
return err
}
}
if err := os.MkdirAll(runtimeDir, 0o700); err != nil {
return fmt.Errorf("create in-memory runtime directory: %w", err)
}
runtimeConfig := filepath.Join(runtimeDir, "config.yaml")
if err := copyFile(config.ConfigSource, runtimeConfig, 0o600); err != nil {
return fmt.Errorf("prepare in-memory config: %w", err)
}
activeSubscription := filepath.Join(runtimeDir, "subscription.yaml")
client := &http.Client{Timeout: 30 * time.Second}
validate := func(candidate string) error {
return validateSubscription(ctx, config, runtimeConfig, candidate)
}
reload := func(ctx context.Context) error {
return reloadSubscription(ctx, client)
}
if err := updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate); err != nil {
return fmt.Errorf("initial subscription update failed")
}
if err := validateMihomoConfig(ctx, config.CoreBinary, runtimeDir, runtimeConfig); err != nil {
return errors.New("generated Mihomo configuration failed validation")
}
serviceCtx, cancel := context.WithCancel(ctx)
defer cancel()
ssclash := serviceCommand(serviceCtx, config.SSClashBinary, "serve")
mihomo := serviceCommand(serviceCtx, config.CoreBinary, "-d", runtimeDir, "-f", runtimeConfig)
if err := ssclash.Start(); err != nil {
return fmt.Errorf("start SSClash: %w", err)
}
if err := mihomo.Start(); err != nil {
cancel()
_ = ssclash.Wait()
return fmt.Errorf("start Mihomo: %w", err)
}
log.Printf("bootstrap: services started mode=server subscription_update_interval=%s", config.UpdateInterval)
type processResult struct {
name string
err error
}
exits := make(chan processResult, 2)
go func() { exits <- processResult{name: "SSClash", err: ssclash.Wait()} }()
go func() { exits <- processResult{name: "Mihomo", err: mihomo.Wait()} }()
ticker := time.NewTicker(config.UpdateInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
cancel()
<-exits
<-exits
return ctx.Err()
case result := <-exits:
cancel()
<-exits
if result.err == nil {
return fmt.Errorf("%s exited", result.name)
}
return fmt.Errorf("%s exited: %w", result.name, result.err)
case <-ticker.C:
if err := updateAndReload(ctx, client, config, activeSubscription, validate, reload); errors.Is(err, errMihomoStateUncertain) {
log.Print("bootstrap: subscription rollback failed; stopping services")
cancel()
<-exits
<-exits
return err
} else if err != nil {
log.Print("bootstrap: subscription update rejected; keeping previous valid configuration")
continue
}
log.Print("bootstrap: subscription updated and reloaded")
}
}
}
func validateSubscriptionURL(raw string) error {
parsed, err := url.ParseRequestURI(raw)
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return errors.New("SUBSCRIPTION_URL must be an absolute HTTP(S) URL")
}
if parsed.User != nil {
return errors.New("SUBSCRIPTION_URL must not contain user information")
}
return nil
}
func updateSubscription(ctx context.Context, client *http.Client, endpoint, target string, validate func(string) error) error {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return errors.New("create subscription request")
}
request.Header.Set("Accept", "application/yaml, text/yaml, text/plain")
request.Header.Set("User-Agent", "mihomo")
response, err := client.Do(request)
if err != nil {
return errors.New("subscription request failed")
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("subscription endpoint returned HTTP %d", response.StatusCode)
}
content, err := io.ReadAll(io.LimitReader(response.Body, maxSubscriptionSize+1))
if err != nil {
return errors.New("read subscription response")
}
if len(content) == 0 || len(content) > maxSubscriptionSize {
return errors.New("subscription response is empty or too large")
}
candidate := filepath.Join(filepath.Dir(target), ".subscription-candidate.yaml")
if err := atomicWrite(candidate, 0o600, func(output *os.File) error {
_, err := output.Write(content)
return err
}); err != nil {
return fmt.Errorf("write subscription candidate: %w", err)
}
defer os.Remove(candidate)
if err := validate(candidate); err != nil {
return errors.New("subscription candidate failed Mihomo validation")
}
if err := os.Rename(candidate, target); err != nil {
return fmt.Errorf("activate subscription candidate: %w", err)
}
return nil
}
func validateSubscription(ctx context.Context, config RuntimeConfig, runtimeConfig, candidate string) error {
content, err := os.ReadFile(runtimeConfig)
if err != nil {
return err
}
candidateConfig := strings.Replace(string(content), "path: ./subscription.yaml", "path: ./"+filepath.Base(candidate), 1)
if candidateConfig == string(content) {
return errors.New("subscription provider path is missing from config")
}
path := filepath.Join(config.RuntimeDir, ".candidate-config.yaml")
if err := atomicWrite(path, 0o600, func(output *os.File) error {
_, err := output.WriteString(candidateConfig)
return err
}); err != nil {
return err
}
defer os.Remove(path)
return validateMihomoConfig(ctx, config.CoreBinary, config.RuntimeDir, path)
}
func validateMihomoConfig(ctx context.Context, binary, runtimeDir, configPath string) error {
command := exec.CommandContext(ctx, binary, "-t", "-d", runtimeDir, "-f", configPath)
command.Env = childEnvironment()
command.Stdout = io.Discard
command.Stderr = io.Discard
if err := command.Run(); err != nil {
return errors.New("Mihomo validation failed")
}
return nil
}
func updateAndReload(ctx context.Context, client *http.Client, config RuntimeConfig, target string, validate func(string) error, reload func(context.Context) error) error {
previous, err := os.ReadFile(target)
if err != nil {
return err
}
if err := updateSubscription(ctx, client, config.SubscriptionURL, target, validate); err != nil {
return err
}
if err := reload(ctx); err == nil {
return nil
}
if rollbackErr := atomicWrite(target, 0o600, func(output *os.File) error {
_, writeErr := output.Write(previous)
return writeErr
}); rollbackErr != nil {
return fmt.Errorf("%w: restore previous subscription file: %v", errMihomoStateUncertain, rollbackErr)
}
if err := reload(ctx); err != nil {
return fmt.Errorf("%w: reload previous subscription", errMihomoStateUncertain)
}
return errors.New("new subscription reload failed; previous subscription restored")
}
func reloadSubscription(ctx context.Context, client *http.Client) error {
request, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://127.0.0.1:9090/providers/proxies/subscription", nil)
if err != nil {
return errors.New("create Mihomo reload request")
}
response, err := client.Do(request)
if err != nil {
return errors.New("Mihomo reload request failed")
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("Mihomo reload returned HTTP %d", response.StatusCode)
}
return nil
}
func serviceCommand(ctx context.Context, binary string, arguments ...string) *exec.Cmd {
command := exec.CommandContext(ctx, binary, arguments...)
command.Env = childEnvironment()
command.Stdout = os.Stdout
command.Stderr = os.Stderr
command.Cancel = func() error {
return command.Process.Signal(syscall.SIGTERM)
}
command.WaitDelay = 10 * time.Second
return command
}
func childEnvironment() []string {
environment := os.Environ()
result := environment[:0]
for _, entry := range environment {
if !strings.HasPrefix(entry, "SUBSCRIPTION_URL=") {
result = append(result, entry)
}
}
return result
}
func copyFile(source, target string, mode os.FileMode) error {
input, err := os.Open(source)
if err != nil {
return err
}
defer input.Close()
return atomicWrite(target, mode, func(output *os.File) error {
_, err := io.Copy(output, input)
return err
})
}
func validateSource(path, label string) error {
info, err := os.Stat(path)
if err != nil {
@@ -369,87 +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
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")
}
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
return nil
}
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) {
-353
View File
@@ -1,353 +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,
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,
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 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: "/",
CoreSource: coreSource,
ConfigSource: configSource,
},
wantErr: "unsafe root",
},
{
name: "missing core source",
config: Config{
Root: filepath.Join(tempDir, "missing-core"),
CoreSource: filepath.Join(tempDir, "does-not-exist"),
ConfigSource: configSource,
},
wantErr: "core source",
},
{
name: "duplicate operating mode",
config: Config{
Root: filepath.Join(tempDir, "duplicate-mode"),
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"),
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 TestRunStopsServicesWhenRollbackReloadFails(t *testing.T) {
tempDir := t.TempDir()
binary := writeFixture(t, tempDir, "fake-service", "#!/bin/sh\nif [ \"$1\" = -t ]; then exit 0; fi\nexec sleep 3600\n")
if err := os.Chmod(binary, 0o755); err != nil {
t.Fatal(err)
}
config := writeFixture(t, tempDir, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n")
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
name := "updated"
if requests.Add(1) == 1 {
name = "initial"
}
_, _ = writer.Write([]byte("proxies:\n - name: " + name + "\n"))
}))
defer server.Close()
runResult := make(chan error, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
runResult <- Run(ctx, RuntimeConfig{
CoreBinary: binary,
SSClashBinary: binary,
ConfigSource: config,
RuntimeDir: filepath.Join(tempDir, "runtime"),
SubscriptionURL: server.URL,
UpdateInterval: 20 * time.Millisecond,
})
}()
var err error
select {
case err = <-runResult:
case <-time.After(5 * time.Second):
t.Fatal("Run() did not reach rollback failure")
}
if !errors.Is(err, errMihomoStateUncertain) {
t.Fatalf("Run() error = %v, want uncertain Mihomo state", err)
}
if requests.Load() < 2 {
t.Fatalf("subscription requests = %d, want initial fetch and timed update", requests.Load())
}
assertFileContent(t, filepath.Join(tempDir, "runtime", "subscription.yaml"), "proxies:\n - name: initial\n")
}
func TestValidateSubscriptionURL(t *testing.T) {
t.Parallel()
for _, raw := range []string{"", "relative/path", "ftp://example.com/feed", "https://user@example.com/feed"} {
if err := validateSubscriptionURL(raw); err == nil {
t.Errorf("validateSubscriptionURL(%q) error = nil", raw)
}
}
if err := validateSubscriptionURL("https://example.com/feed"); err != nil {
t.Fatalf("validateSubscriptionURL() error = %v", err)
}
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (function roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return function(request)
}
func writeFixture(t *testing.T, directory, name, content string) string {
t.Helper()
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(directory, name)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
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)
}
}
+658
View File
@@ -0,0 +1,658 @@
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
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
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 {
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 persistenceError("prepare generations directory", err)
}
for path, label := range map[string]string{
config.TemplatePath: "Mihomo template",
config.MihomoBinary: "Mihomo binary",
} {
if err := validateSource(path, label); err != nil {
return err
}
}
endpoint, err := readSubscriptionSecret(config.SecretPath)
if err != nil {
return err
}
client := config.Client
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
subscription, err := fetchSubscription(ctx, client, endpoint)
if err != nil {
return err
}
subscription, err = normalizeSubscription(subscription)
if err != nil {
return err
}
template, err := os.ReadFile(config.TemplatePath)
if err != nil {
return errors.New("read Mihomo template")
}
generated, err := generateConfig(template)
if err != nil {
return err
}
current, err := currentGeneration(filepath.Join(dataDir, "last-good"))
if err != nil {
return err
}
next := "generations/a"
if current == next {
next = "generations/b"
}
candidate, err := os.MkdirTemp(generations, ".candidate-")
if err != nil {
return persistenceError("create candidate generation", err)
}
if err := os.Chmod(candidate, 0o700); err != nil {
_ = os.RemoveAll(candidate)
return persistenceError("secure candidate generation", err)
}
defer os.RemoveAll(candidate)
configPath := filepath.Join(candidate, "config.yaml")
if err := writePrivateFile(configPath, generated); err != nil {
return persistenceError("write candidate config", err)
}
if err := writePrivateFile(filepath.Join(candidate, "subscription.yaml"), subscription); err != nil {
return persistenceError("write candidate subscription", err)
}
if err := validateMihomoConfig(ctx, config.MihomoBinary, candidate, configPath); err != nil {
return errors.New("candidate configuration failed Mihomo validation")
}
slot := filepath.Join(dataDir, filepath.FromSlash(next))
if err := os.RemoveAll(slot); err != nil {
return persistenceError("clear inactive generation", err)
}
if err := os.Rename(candidate, slot); err != nil {
return persistenceError("publish candidate generation", err)
}
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
}
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
}
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 {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(path, 0o700); err != nil {
return fmt.Errorf("create data directory: %w", err)
}
return nil
}
if err != nil {
return fmt.Errorf("inspect data directory: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("data path %q is not a directory", path)
}
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 {
return "", errors.New("read subscription secret")
}
if !info.Mode().IsRegular() || info.Size() == 0 || info.Size() > maxSecretSize {
return "", errors.New("subscription secret must be a non-empty regular file")
}
file, err := os.Open(path)
if err != nil {
return "", errors.New("read subscription secret")
}
defer file.Close()
opened, err := file.Stat()
if err != nil || !os.SameFile(info, opened) {
return "", errors.New("subscription secret changed while being read")
}
content, err := io.ReadAll(io.LimitReader(file, maxSecretSize+1))
if err != nil || len(content) > maxSecretSize {
return "", errors.New("read subscription secret")
}
raw := strings.TrimSpace(string(content))
if strings.ContainsAny(raw, "\r\n") {
return "", errors.New("subscription secret must contain one URL")
}
parsed, err := url.ParseRequestURI(raw)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil {
return "", errors.New("subscription secret must contain one absolute HTTP(S) URL")
}
return raw, nil
}
func fetchSubscription(ctx context.Context, client *http.Client, endpoint string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, 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 nil, errors.New("subscription request failed")
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("subscription endpoint returned HTTP %d", response.StatusCode)
}
content, err := io.ReadAll(io.LimitReader(response.Body, maxSubscriptionSize+1))
if err != nil {
return nil, errors.New("read subscription response")
}
if len(content) == 0 || len(content) > maxSubscriptionSize {
return nil, errors.New("subscription response is empty or too large")
}
return content, nil
}
func normalizeSubscription(content []byte) ([]byte, error) {
decoder := yaml.NewDecoder(bytes.NewReader(content))
var document yaml.Node
if err := decoder.Decode(&document); err != nil || len(document.Content) != 1 {
return nil, errors.New("subscription YAML is invalid")
}
var extra yaml.Node
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return nil, errors.New("subscription YAML must contain one document")
}
root := document.Content[0]
if root.Kind != yaml.MappingNode {
return nil, errors.New("subscription YAML must be a mapping")
}
var proxies *yaml.Node
for index := 0; index < len(root.Content); index += 2 {
if root.Content[index].Value != "proxies" {
continue
}
if proxies != nil {
return nil, errors.New("subscription YAML contains duplicate proxies fields")
}
proxies = root.Content[index+1]
}
if proxies == nil || proxies.Kind != yaml.SequenceNode || len(proxies.Content) == 0 {
return nil, errors.New("subscription YAML must contain a non-empty proxies list")
}
for _, proxy := range proxies.Content {
if proxy.Kind != yaml.MappingNode {
return nil, errors.New("subscription YAML contains an invalid proxy")
}
}
normalized := yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "proxies"},
proxies,
},
}}}
return yaml.Marshal(&normalized)
}
func generateConfig(content []byte) ([]byte, error) {
var document yaml.Node
if err := yaml.Unmarshal(content, &document); err != nil || len(document.Content) != 1 {
return nil, errors.New("Mihomo template is invalid")
}
root := document.Content[0]
if root.Kind != yaml.MappingNode {
return nil, errors.New("Mihomo template must be a mapping")
}
controller := mappingValue(root, "external-controller")
if controller == nil || controller.Kind != yaml.ScalarNode {
return nil, errors.New("Mihomo template is missing external-controller")
}
controller.Tag = "!!str"
controller.Value = "0.0.0.0:9090"
return yaml.Marshal(&document)
}
func mappingValue(mapping *yaml.Node, key string) *yaml.Node {
for index := 0; index+1 < len(mapping.Content); index += 2 {
if mapping.Content[index].Value == key {
return mapping.Content[index+1]
}
}
return nil
}
func currentGeneration(path string) (string, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("inspect last-good generation: %w", err)
}
if info.Mode()&os.ModeSymlink == 0 {
return "", errors.New("last-good must be a managed symlink")
}
target, err := os.Readlink(path)
if err != nil {
return "", fmt.Errorf("read last-good generation: %w", err)
}
if target != "generations/a" && target != "generations/b" {
return "", fmt.Errorf("last-good has unexpected target %q", target)
}
return target, nil
}
func writePrivateFile(path string, content []byte) error {
return atomicWrite(path, 0o600, func(output *os.File) error {
_, err := output.Write(content)
return err
})
}
func replaceSymlink(path, target string) error {
temporary, err := os.CreateTemp(filepath.Dir(path), ".last-good-")
if err != nil {
return fmt.Errorf("create last-good pointer: %w", err)
}
temporaryPath := temporary.Name()
if err := temporary.Close(); err != nil {
_ = os.Remove(temporaryPath)
return fmt.Errorf("close last-good pointer: %w", err)
}
if err := os.Remove(temporaryPath); err != nil {
return fmt.Errorf("prepare last-good pointer: %w", err)
}
defer os.Remove(temporaryPath)
if err := os.Symlink(target, temporaryPath); err != nil {
return fmt.Errorf("create last-good pointer: %w", err)
}
if err := os.Rename(temporaryPath, path); err != nil {
return fmt.Errorf("publish last-good pointer: %w", err)
}
return nil
}
func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return fmt.Errorf("open data directory for sync: %w", err)
}
defer directory.Close()
if err := directory.Sync(); err != nil {
return fmt.Errorf("sync data directory: %w", err)
}
return nil
}
+259
View File
@@ -0,0 +1,259 @@
package bootstrap
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func TestPublishCandidateGeneratesValidatedLastGood(t *testing.T) {
t.Parallel()
var lock sync.RWMutex
response := fullSubscription("first-node")
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
lock.RLock()
defer lock.RUnlock()
_, _ = writer.Write([]byte(response))
}))
defer server.Close()
config := candidateFixture(t, server.URL+"?token=FAKE-SECRET")
if err := PublishCandidate(context.Background(), config); err != nil {
t.Fatalf("PublishCandidate() error = %v", err)
}
firstTarget := readLastGood(t, config.DataDir)
if firstTarget != "generations/a" {
t.Fatalf("last-good target = %q, want generations/a", firstTarget)
}
firstDir := filepath.Join(config.DataDir, filepath.FromSlash(firstTarget))
assertContains(t, filepath.Join(firstDir, "config.yaml"), "external-controller: 0.0.0.0:9090")
assertContains(t, filepath.Join(firstDir, "subscription.yaml"), "name: first-node")
assertNotContains(t, filepath.Join(firstDir, "subscription.yaml"), "proxy-groups:")
for _, name := range []string{"config.yaml", "subscription.yaml"} {
info, err := os.Stat(filepath.Join(firstDir, name))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("%s mode = %o, want 600", name, info.Mode().Perm())
}
}
lock.Lock()
response = fullSubscription("second-node")
lock.Unlock()
if err := PublishCandidate(context.Background(), config); err != nil {
t.Fatalf("second PublishCandidate() error = %v", err)
}
secondTarget := readLastGood(t, config.DataDir)
if secondTarget != "generations/b" {
t.Fatalf("last-good target = %q, want generations/b", secondTarget)
}
assertContains(t, filepath.Join(config.DataDir, filepath.FromSlash(secondTarget), "subscription.yaml"), "name: second-node")
assertContains(t, filepath.Join(firstDir, "subscription.yaml"), "name: first-node")
}
func TestPublishCandidateFailureMatrixKeepsLastGoodAndRedactsInput(t *testing.T) {
for _, testCase := range []struct {
name string
response string
status int
secret string
template string
transport bool
}{
{name: "invalid secret URL", secret: "not-a-url-FAKE-SECRET"},
{name: "request failure", transport: true},
{name: "HTTP failure", status: http.StatusServiceUnavailable},
{name: "empty response"},
{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) {
var lock sync.RWMutex
response := fullSubscription("last-good-node")
status := http.StatusOK
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
lock.RLock()
defer lock.RUnlock()
writer.WriteHeader(status)
_, _ = writer.Write([]byte(response))
}))
defer server.Close()
config := candidateFixture(t, server.URL+"?token=FAKE-SECRET")
if err := PublishCandidate(context.Background(), config); err != nil {
t.Fatalf("initial PublishCandidate() error = %v", err)
}
wantTarget := readLastGood(t, config.DataDir)
wantSubscription, err := os.ReadFile(filepath.Join(config.DataDir, filepath.FromSlash(wantTarget), "subscription.yaml"))
if err != nil {
t.Fatal(err)
}
lock.Lock()
response = testCase.response
if testCase.status != 0 {
status = testCase.status
}
lock.Unlock()
if testCase.secret != "" {
if err := os.WriteFile(config.SecretPath, []byte(testCase.secret), 0o600); err != nil {
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")
})}
}
err = PublishCandidate(context.Background(), config)
if err == nil {
t.Fatal("PublishCandidate() error = nil")
}
if strings.Contains(err.Error(), "FAKE-SECRET") || strings.Contains(err.Error(), "reject-validation") {
t.Fatalf("PublishCandidate() leaked sensitive input: %v", err)
}
if got := readLastGood(t, config.DataDir); got != wantTarget {
t.Fatalf("last-good target = %q, want unchanged %q", got, wantTarget)
}
gotSubscription, err := os.ReadFile(filepath.Join(config.DataDir, filepath.FromSlash(wantTarget), "subscription.yaml"))
if err != nil {
t.Fatal(err)
}
if string(gotSubscription) != string(wantSubscription) {
t.Fatal("failed update changed last-good subscription")
}
})
}
}
func TestPublishCandidateRejectsUnmanagedLastGood(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte(fullSubscription("new-node")))
}))
defer server.Close()
config := candidateFixture(t, server.URL)
if err := os.MkdirAll(config.DataDir, 0o700); err != nil {
t.Fatal(err)
}
writeFixture(t, config.DataDir, "last-good", "operator-owned")
err := PublishCandidate(context.Background(), config)
if err == nil || !strings.Contains(err.Error(), "managed symlink") {
t.Fatalf("PublishCandidate() error = %v, want unmanaged last-good rejection", err)
}
assertFileContent(t, filepath.Join(config.DataDir, "last-good"), "operator-owned")
}
func candidateFixture(t *testing.T, endpoint string) CandidateConfig {
t.Helper()
tempDir := t.TempDir()
secret := writeFixture(t, tempDir, "subscription-secret", endpoint+"\n")
mihomo := writeFixture(t, tempDir, "mihomo", `#!/bin/sh
set -eu
test "$1" = -t
directory=
config=
while [ "$#" -gt 0 ]; do
case "$1" in
-d) directory=$2; shift 2 ;;
-f) config=$2; shift 2 ;;
*) shift ;;
esac
done
test -n "$directory" -a -n "$config"
grep -F 'external-controller: 0.0.0.0:9090' "$config" >/dev/null
grep -F 'proxies:' "$directory/subscription.yaml" >/dev/null
! grep -F 'reject-validation' "$directory/subscription.yaml" >/dev/null
`)
if err := os.Chmod(mihomo, 0o755); err != nil {
t.Fatal(err)
}
return CandidateConfig{
SecretPath: secret,
DataDir: filepath.Join(tempDir, "data"),
TemplatePath: filepath.Join("..", "..", "config", "config.yaml"),
MihomoBinary: mihomo,
}
}
func fullSubscription(name string) string {
return "mixed-port: 1234\nproxies:\n - name: " + name + "\n type: socks5\n server: 127.0.0.1\n port: 9\nproxy-groups: []\n"
}
func readLastGood(t *testing.T, dataDir string) string {
t.Helper()
target, err := os.Readlink(filepath.Join(dataDir, "last-good"))
if err != nil {
t.Fatal(err)
}
return target
}
func assertContains(t *testing.T, path, want string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), want) {
t.Errorf("%s does not contain %q", path, want)
}
}
func assertNotContains(t *testing.T, path, unwanted string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(content), unwanted) {
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)
}
+55 -9
View File
@@ -1,6 +1,8 @@
package bootstrap
import (
"crypto/sha256"
"fmt"
"os"
"strings"
"testing"
@@ -19,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)
@@ -30,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)
@@ -52,18 +54,62 @@ 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}",
"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) {
t.Errorf("seeded config is missing %q", required)
}
}
if strings.Contains(config, "raw.githubusercontent.com") || strings.Contains(config, "type: http") {
if strings.Contains(config, "raw.githubusercontent.com") || strings.Contains(config, "type: http") || strings.Contains(config, "GEOIP,CN,") {
t.Error("seeded config depends on an online rule or subscription provider")
}
}
func TestMihomoTemplateAndRuntimeAssetsArePinned(t *testing.T) {
t.Parallel()
template, err := os.ReadFile("../../config/config.yaml")
if err != nil {
t.Fatalf("read seeded config: %v", err)
}
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")
if err != nil {
t.Fatalf("read Dockerfile: %v", err)
}
for _, pin := range []string{
"MIHOMO_VERSION=v1.19.30",
"MIHOMO_SHA256_AMD64=cbe553d0319a414bd3a372c5976a252155b2c4882b66bce88a4d6bba9571a553",
"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")
}
}
+429
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+143 -63
View File
@@ -2,106 +2,186 @@
set -eu
image=${1:-mohomo-docker:smoke}
suffix="$$"
container="mohomo-docker-smoke-${suffix}"
provider="mohomo-provider-smoke-${suffix}"
network="mohomo-network-smoke-${suffix}"
volume="mohomo-volume-smoke-${suffix}"
provider_dir=""
secret="container-smoke-secret"
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:$provider:$network:$volume" in
mohomo-docker-smoke-*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-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" "$provider" >/dev/null 2>&1 || true
docker volume rm "$volume" >/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
[ -z "$provider_dir" ] || rm -rf "$provider_dir"
rm -f "$secret_file"
}
trap cleanup EXIT INT TERM
docker build --tag "$image" .
docker run --rm --entrypoint /usr/local/lib/ssclash/clash "$image" \
-t -d /usr/local/share/ssclash -f /usr/local/share/ssclash/config.yaml >/dev/null 2>&1 && {
echo "config validation unexpectedly passed without a subscription provider" >&2
exit 1
wait_for_health() {
attempt=0
until [ "$(docker inspect --format '{{.State.Health.Status}}' "$container")" = healthy ]; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 60 ]; then
docker logs "$container" >&2
exit 1
fi
sleep 1
done
}
provider_dir=$(mktemp -d)
printf '%s\n' \
'proxies:' \
' - name: smoke-node' \
' type: socks5' \
' server: 127.0.0.1' \
' port: 9' \
> "$provider_dir/provider.yaml"
chmod 0755 "$provider_dir"
chmod 0644 "$provider_dir/provider.yaml"
wait_for_last_good() {
expected=$1
attempt=0
until docker exec "$container" grep -F "$expected" /data/last-good/subscription.yaml >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
docker logs "$container" >&2
echo "last-good did not contain $expected" >&2
exit 1
fi
sleep 1
done
}
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
}
host_curl() {
docker run --rm --network host --entrypoint curl "$image" --fail --silent --show-error "$@"
}
printf 'http://%s:8080/provider.yaml?token=%s\n' "$provider" "$secret" > "$secret_file"
chmod 0444 "$secret_file"
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 network create "$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" \
--volume "$provider_dir:/srv:ro" \
--entrypoint /bin/sh \
"$image" -c 'while :; do { printf "HTTP/1.1 200 OK\r\nContent-Type: text/yaml\r\nConnection: close\r\n\r\n"; cat /srv/provider.yaml; } | nc -l -p 8080; done' >/dev/null
"$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 >/dev/null; do
until docker exec "$provider" wget -qO- http://127.0.0.1:8080/provider.yaml | grep -F first-node >/dev/null; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 10 ]; then
echo "subscription fixture did not become ready" >&2
exit 1
fi
[ "$attempt" -lt 20 ] || { echo "subscription fixture did not start" >&2; exit 1; }
sleep 1
done
if failure=$(docker run --rm \
--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 "cold-start failure leaked the subscription secret" >&2
exit 1
fi
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}" \
--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::9090/tcp \
"$image" >/dev/null
attempt=0
until [ "$(docker inspect --format '{{.State.Health.Status}}' "$container")" = healthy ]; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
docker logs "$container" >&2
echo "container did not become healthy" >&2
exit 1
fi
sleep 1
done
wait_for_health
wait_for_last_good first-node
published=$(docker port "$container")
printf '%s\n' "$published" | grep -E '^7890/(tcp|udp)' >/dev/null
if printf '%s\n' "$published" | grep -vE '^7890/(tcp|udp)' >/dev/null; then
echo "container published a port other than 7890" >&2
[ "$(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 '<title>' >/dev/null
docker exec "$container" ps | grep -F '/usr/local/bin/mihomo' >/dev/null
if docker exec "$container" touch /read-only-root >/dev/null 2>&1; then
echo "container root filesystem is writable" >&2
exit 1
fi
docker logs "$container" 2>&1 | grep -F 'update_interval=1h0m0s' >/dev/null
docker exec "$container" grep -Fx 'OPERATING_MODE=server' /opt/clash/.ssclash/settings >/dev/null
docker exec "$container" grep -Fx 'PROXY_MODE=none' /opt/clash/.ssclash/settings >/dev/null
docker exec "$container" test -s /dev/shm/mohomo/subscription.yaml
docker exec "$container" /usr/local/lib/ssclash/clash \
-t -d /dev/shm/mohomo -f /dev/shm/mohomo/config.yaml >/dev/null
docker exec "$container" curl --fail --silent --show-error \
http://127.0.0.1:9090/version >/dev/null
first_target=$(docker exec "$container" readlink /data/last-good)
docker exec "$provider" /bin/sh -c 'printf "proxies: [" > /tmp/web/provider.yaml'
docker kill --signal HUP "$container" >/dev/null
sleep 2
[ "$(docker exec "$container" readlink /data/last-good)" = "$first_target" ]
wait_for_last_good first-node
if docker exec "$container" grep -R -F "$secret" /opt/clash /dev/shm/mohomo >/dev/null 2>&1; then
echo "subscription URL credential was written to runtime files" >&2
exit 1
fi
docker exec "$provider" /bin/sh -c 'printf "proxies:\n - name: second-node\n type: socks5\n server: 127.0.0.1\n port: 9\n" > /tmp/web/provider.yaml'
docker kill --signal HUP "$container" >/dev/null
wait_for_last_good second-node
wait_for_log 'configuration updated and reloaded'
host_curl "http://127.0.0.1:${controller_port}/providers/proxies/subscription" >/dev/null
selection_group='%F0%9F%9A%80%20%E8%8A%82%E7%82%B9%E9%80%89%E6%8B%A9'
group=$(host_curl "http://127.0.0.1:${controller_port}/proxies/${selection_group}")
printf '%s\n' "$group" | grep -F '"DIRECT"' >/dev/null
host_curl \
--request PUT \
--header 'Content-Type: application/json' \
--data '{"name":"DIRECT"}' \
"http://127.0.0.1:${controller_port}/proxies/${selection_group}" >/dev/null
host_curl "http://127.0.0.1:${controller_port}/proxies/${selection_group}" \
| grep -F '"now":"DIRECT"' >/dev/null
docker exec "$provider" /bin/sh -c 'printf "proxies: [" > /tmp/web/provider.yaml'
docker restart "$container" >/dev/null
wait_for_health
wait_for_last_good second-node
if docker logs "$container" 2>&1 | grep -F "$secret" >/dev/null; then
echo "subscription URL credential was written to logs" >&2
echo "runtime logs leaked the subscription secret" >&2
exit 1
fi
docker exec "$container" /usr/local/bin/mihomo -t -d /data/last-good -f /data/last-good/config.yaml >/dev/null
echo "container smoke test passed: only port 7890 published; subscription credential not persisted or logged"
echo "container smoke test passed: cold fail-closed, warm recovery, HUP update, rollback, 7890, 9090, ExternalUI, and proxy switching"
+64 -17
View File
@@ -1,31 +1,78 @@
#!/bin/sh
set -eu
workflow=.github/workflows/docker.yml
test -f "$workflow" || {
echo "missing GHCR workflow: $workflow" >&2
github_workflow=.github/workflows/docker.yml
gitea_workflow=.gitea/workflows/docker.yml
test -f "$github_workflow" || {
echo "missing GHCR workflow: $github_workflow" >&2
exit 1
}
test -f "$gitea_workflow" || {
echo "missing Gitea workflow: $gitea_workflow" >&2
exit 1
}
grep -F 'packages: write' "$workflow" >/dev/null
# Match the GitHub expression literally.
# shellcheck disable=SC2016
grep -F 'ghcr.io/${{ github.repository }}' "$workflow" >/dev/null
grep -F 'platforms: linux/amd64' "$workflow" >/dev/null
grep -F 'needs: test' "$workflow" >/dev/null
grep -F 'cache-to: type=gha,mode=max,ignore-error=true' "$workflow" >/dev/null
# Match the GitHub expression literally.
# shellcheck disable=SC2016
grep -F 'push: ${{ github.event_name != '\''pull_request'\'' }}' "$workflow" >/dev/null
for workflow in "$github_workflow" "$gitea_workflow"; do
grep -F ' push:' "$workflow" >/dev/null
grep -F ' pull_request:' "$workflow" >/dev/null
grep -F ' workflow_dispatch:' "$workflow" >/dev/null
done
if grep -Ei 'arm64|setup-qemu' "$workflow" >/dev/null; then
gitea_trigger_count=$(awk '/^on:/ { in_on = 1; next } in_on && /^[^ ]/ { exit } in_on && /^ [a-z_]+:/ { count++ } END { print count + 0 }' "$gitea_workflow")
if [ "$gitea_trigger_count" -ne 3 ] || [ "$(grep -Fc ' - main' "$gitea_workflow")" -ne 2 ]; then
echo "Gitea workflow must only run for main pushes, main pull requests, and manual dispatches" >&2
exit 1
fi
grep -F 'packages: write' "$github_workflow" >/dev/null
# Match the GitHub expression literally.
# shellcheck disable=SC2016
grep -F 'ghcr.io/${{ github.repository }}' "$github_workflow" >/dev/null
grep -F 'platforms: linux/amd64' "$github_workflow" >/dev/null
grep -F 'needs: test' "$github_workflow" >/dev/null
grep -F 'run: ./tests/container-smoke.sh' "$github_workflow" >/dev/null
grep -F 'cache-to: type=gha,mode=max,ignore-error=true' "$github_workflow" >/dev/null
# Match the GitHub expression literally.
# shellcheck disable=SC2016
grep -F 'push: ${{ github.event_name != '\''pull_request'\'' }}' "$github_workflow" >/dev/null
grep -F 'run: ./scripts/test.sh' "$gitea_workflow" >/dev/null
grep -F 'run: ./tests/container-smoke.sh' "$gitea_workflow" >/dev/null
grep -F 'git.ipao.vip/rogee/mohomo-docker' "$gitea_workflow" >/dev/null
# Match Gitea expressions literally.
# shellcheck disable=SC2016
grep -F '${{ secrets.REGISTRY_TOKEN }}' "$gitea_workflow" >/dev/null
# shellcheck disable=SC2016
grep -F 'sha-${{ gitea.sha }}' "$gitea_workflow" >/dev/null
grep -F 'docker login git.ipao.vip' "$gitea_workflow" >/dev/null
grep -F 'docker push "$IMAGE_NAME:latest"' "$gitea_workflow" >/dev/null
grep -F 'api/v1/packages/rogee/container/mohomo-docker' "$gitea_workflow" >/dev/null
grep -F '/link/mohomo-docker' "$gitea_workflow" >/dev/null
publish_condition="if: gitea.event_name != 'pull_request'"
if [ "$(grep -Fc "$publish_condition" "$gitea_workflow")" -ne 3 ]; then
echo "every Gitea publishing step must be disabled for pull requests" >&2
exit 1
fi
if grep -Ei 'ghcr\.io|github\.|GITHUB_TOKEN|packages: write|docker/(login|build-push)-action' "$gitea_workflow" >/dev/null; then
echo "Gitea workflow must not depend on GitHub publishing" >&2
exit 1
fi
if grep -Ei 'gitea\.|REGISTRY_TOKEN|git\.ipao\.vip' "$github_workflow" >/dev/null; then
echo "GitHub workflow must not depend on Gitea publishing" >&2
exit 1
fi
if grep -Ei 'arm64|setup-qemu' "$github_workflow" >/dev/null; then
echo "workflow must build linux/amd64 only and must not configure QEMU" >&2
exit 1
fi
uses_count=$(grep -Ec '^[[:space:]]+uses:' "$workflow")
pinned_count=$(grep -Ec '^[[:space:]]+uses: [^ ]+@[0-9a-f]{40}([[:space:]]|$)' "$workflow")
uses_count=$(grep -Ehc '^[[:space:]]+uses:' "$github_workflow" "$gitea_workflow" | awk '{ total += $1 } END { print total }')
pinned_count=$(grep -Ehc '^[[:space:]]+uses: [^ ]+@[0-9a-f]{40}([[:space:]]|$)' "$github_workflow" "$gitea_workflow" | awk '{ total += $1 } END { print total }')
if [ "$uses_count" -eq 0 ] || [ "$uses_count" -ne "$pinned_count" ]; then
echo "every GitHub Action must be pinned to a full commit SHA" >&2
echo "every action must be pinned to a full commit SHA" >&2
exit 1
fi