Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f385c8808 | ||
|
|
2c9ae4bbc4 | ||
|
|
dcad041c0f | ||
|
|
43e05501ee | ||
|
|
d7c883afc1 | ||
|
|
48f44a0004 | ||
|
|
5b14158870 | ||
|
|
f29a3fc855 | ||
|
|
6c67390d52 | ||
|
|
5f5b5799e4 | ||
|
|
d61799d180 | ||
|
|
39f1841bc8 | ||
|
|
37643321fc |
@@ -1,5 +1,6 @@
|
||||
.git
|
||||
.env
|
||||
subscription.url
|
||||
coverage.out
|
||||
tests
|
||||
README.md
|
||||
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
IMAGE_NAME=mohomo-docker:local
|
||||
CONTAINER_NAME=mohomo-docker
|
||||
WEB_BIND=0.0.0.0
|
||||
WEB_PORT=9091
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -82,6 +85,6 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max,ignore-error=true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
.env
|
||||
subscription.url
|
||||
coverage.out
|
||||
|
||||
@@ -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.
|
||||
|
||||
+55
-43
@@ -7,66 +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"; \
|
||||
--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 set -eu; \
|
||||
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"; \
|
||||
--output /tmp/acl4ssr.tar.gz "https://github.com/ACL4SSR/ACL4SSR/archive/${ACL4SSR_REF}.tar.gz"; \
|
||||
printf '%s %s\n' "${ACL4SSR_SHA256}" /tmp/acl4ssr.tar.gz | sha256sum -c -; \
|
||||
mkdir -p /out/rules; \
|
||||
tar -xzf /tmp/acl4ssr.tar.gz -C /out/rules --strip-components=3 \
|
||||
"ACL4SSR-${ACL4SSR_REF}/Clash/Providers"; \
|
||||
tar -xOzf /tmp/acl4ssr.tar.gz "ACL4SSR-${ACL4SSR_REF}/LICENCE" > /out/ACL4SSR-LICENSE
|
||||
|
||||
FROM alpine:${ALPINE_VERSION} 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 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 /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 config/config.yaml /usr/local/share/ssclash/config.yaml
|
||||
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 --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=:9091
|
||||
ENV SAFE_PATHS=/usr/local/share/mihomo:/data
|
||||
|
||||
USER ssclash
|
||||
VOLUME ["/opt/clash"]
|
||||
EXPOSE 9091/tcp 7890/tcp 7890/udp
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=4 \
|
||||
CMD curl --fail --silent --show-error http://127.0.0.1:9091/ >/dev/null
|
||||
USER mihomo
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 7890/tcp 9090/tcp
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=15s --retries=4 \
|
||||
CMD curl --fail --silent --show-error http://127.0.0.1:9090/version >/dev/null
|
||||
ENTRYPOINT ["/usr/local/bin/bootstrap"]
|
||||
CMD ["serve"]
|
||||
|
||||
@@ -21,4 +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.
|
||||
|
||||
@@ -16,4 +16,4 @@ down:
|
||||
docker compose down
|
||||
|
||||
logs:
|
||||
docker compose logs -f ssclash
|
||||
docker compose logs -f mihomo
|
||||
|
||||
@@ -1,76 +1,111 @@
|
||||
# mohomo-docker
|
||||
|
||||
Docker packaging for the official SSClash-Go daemon and Mihomo core. It intentionally provides only:
|
||||
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.
|
||||
|
||||
- SSClash embedded Web UI on port `9091`;
|
||||
- Mihomo HTTP/SOCKS mixed proxy on port `7890`;
|
||||
- server mode, without TUN, transparent proxy, firewall, routing, or DNS interception.
|
||||
|
||||
## Quick start
|
||||
## Start
|
||||
|
||||
```sh
|
||||
cp .env.example .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
|
||||
```
|
||||
|
||||
Open `http://<server>:9091`, create the administrator password, review `config.yaml`, and start the proxy from the Web UI. The seeded configuration exposes a direct-only `PROXY` group so port `7890` can be tested before adding a subscription.
|
||||
`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.
|
||||
|
||||
Configure clients with either of these endpoints:
|
||||
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
|
||||
```
|
||||
|
||||
The Web UI and proxy listen on all host interfaces by default. Change `WEB_BIND` or `PROXY_BIND` in `.env` to restrict them. Do not expose the Web UI to the Internet without HTTPS and an additional access-control layer. Configure Mihomo proxy authentication before exposing port `7890` outside a trusted network.
|
||||
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.
|
||||
|
||||
## Persistent data
|
||||
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 named volume `ssclash-data` is mounted at `/opt/clash` and stores:
|
||||
## Data and restart recovery
|
||||
|
||||
- `config.yaml` and named configurations;
|
||||
- SSClash settings and administrator credentials;
|
||||
- subscription, rule-provider, and proxy-provider files;
|
||||
- the active Mihomo core and its runtime data.
|
||||
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.
|
||||
|
||||
The bootstrap process creates missing files only. Existing Mihomo and configuration files are preserved. It explicitly enforces `OPERATING_MODE=server` and `PROXY_MODE=none`; the latter prevents SSClash from synchronizing gateway-only TProxy, redirect, or TUN listeners into `config.yaml`. Duplicate mode entries or empty runtime files cause startup to fail with a diagnostic message.
|
||||
`/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.
|
||||
|
||||
Resetting the volume deletes configuration and credentials. Inspect the exact Compose project and volume name before doing so.
|
||||
Normal recovery uses the existing volume:
|
||||
|
||||
## Version updates
|
||||
```sh
|
||||
docker compose restart mihomo
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Versions are pinned in the Dockerfile:
|
||||
## Lifecycle and updates
|
||||
|
||||
- SSClash-Go `v6.1.0`;
|
||||
- Mihomo `v1.19.30`.
|
||||
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.
|
||||
|
||||
SSClash is verified against the checksum file from its official release. Mihomo amd64 and arm64 archives are verified against pinned SHA-256 values. To update either component, update the version and checksums together, then run the complete test suite.
|
||||
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.
|
||||
|
||||
## GitHub Container Registry
|
||||
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 GitHub Actions workflow builds `linux/amd64` only. SSClash-Go and Mihomo are downloaded and checksum-verified during the Docker build, so the resulting container never downloads executable files at startup.
|
||||
Trigger the same update path immediately for tests or operations:
|
||||
|
||||
The workflow runs tests before building, publishes to `ghcr.io/<github-owner>/mohomo-docker`, attaches SBOM and provenance, and creates these tags:
|
||||
```sh
|
||||
docker compose kill --signal HUP mihomo
|
||||
```
|
||||
|
||||
- `latest` and `main` from the default branch;
|
||||
- the Git tag and major/minor tags from releases such as `v1.2.3`;
|
||||
- an immutable `sha-<commit>` tag;
|
||||
- pull-request tags for build validation only, without pushing.
|
||||
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.
|
||||
|
||||
Keep the GHCR package visibility **private**. The workflow deliberately does not attempt to change package visibility. Making an image containing SSClash-Go available to third parties conflicts with the upstream binary license unless the copyright holder grants permission.
|
||||
## Troubleshooting
|
||||
|
||||
## Tests
|
||||
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 enforces at least 65% statement coverage for bootstrap behavior. The container smoke test builds the image, validates the Mihomo configuration, starts the Web UI with all Linux capabilities dropped, authenticates to SSClash, starts Mihomo through the Web API, rejects gateway-listener/error regressions, and sends an HTTPS request through the mapped mixed proxy port.
|
||||
|
||||
## License boundary
|
||||
|
||||
This repository contains only original Docker packaging and bootstrap code. It downloads SSClash-Go and Mihomo from their official releases while building.
|
||||
|
||||
SSClash-Go uses a proprietary binary license that permits personal/internal use but prohibits redistributing its binary to third parties. Do not publish the resulting image without the copyright holder's permission. Mihomo is separately licensed under GPL-3.0.
|
||||
All test fixtures use only sanitized fake values.
|
||||
|
||||
+41
-37
@@ -1,56 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/mohomo-docker/internal/bootstrap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRoot = "/opt/clash"
|
||||
defaultCoreSource = "/usr/local/lib/ssclash/clash"
|
||||
defaultConfigSource = "/usr/local/share/ssclash/config.yaml"
|
||||
ssclashBinary = "/usr/local/bin/ssclash"
|
||||
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)
|
||||
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
|
||||
}
|
||||
if len(os.Args) != 1 {
|
||||
log.Fatal("bootstrap: usage: bootstrap [candidate]")
|
||||
}
|
||||
|
||||
result, err := bootstrap.Prepare(bootstrap.Config{
|
||||
Root: root,
|
||||
CoreSource: defaultCoreSource,
|
||||
ConfigSource: defaultConfigSource,
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
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 {
|
||||
log.Fatalf("bootstrap: runtime preparation failed: %v", err)
|
||||
}
|
||||
log.Printf(
|
||||
"bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_settings_changed=%t",
|
||||
root,
|
||||
result.CoreInitialized,
|
||||
result.ConfigInitialized,
|
||||
result.ServerSettingsChanged,
|
||||
)
|
||||
|
||||
arguments := os.Args[1:]
|
||||
if len(arguments) == 0 {
|
||||
arguments = []string{"serve"}
|
||||
}
|
||||
argv := append([]string{filepath.Base(ssclashBinary)}, arguments...)
|
||||
log.Printf("bootstrap: exec path=%s command=%s", ssclashBinary, arguments[0])
|
||||
if err := syscall.Exec(ssclashBinary, argv, os.Environ()); err != nil {
|
||||
log.Fatalf("bootstrap: exec failed: %v", err)
|
||||
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
-6
@@ -1,17 +1,23 @@
|
||||
services:
|
||||
ssclash:
|
||||
mihomo:
|
||||
build:
|
||||
context: .
|
||||
image: ${IMAGE_NAME:-mohomo-docker:local}
|
||||
container_name: ${CONTAINER_NAME:-mohomo-docker}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
read_only: true
|
||||
ports:
|
||||
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-9091}:9091/tcp"
|
||||
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/tcp"
|
||||
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/udp"
|
||||
- "${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:
|
||||
@@ -23,5 +29,9 @@ services:
|
||||
max-size: 10m
|
||||
max-file: "3"
|
||||
|
||||
secrets:
|
||||
subscription:
|
||||
file: ${SUBSCRIPTION_FILE:-./subscription.url}
|
||||
|
||||
volumes:
|
||||
ssclash-data:
|
||||
mihomo-data:
|
||||
|
||||
+199
-8
@@ -4,19 +4,210 @@ 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: true
|
||||
store-fake-ip: true
|
||||
store-selected: false
|
||||
store-fake-ip: false
|
||||
|
||||
proxies: []
|
||||
proxy-providers:
|
||||
subscription:
|
||||
type: file
|
||||
path: ./subscription.yaml
|
||||
health-check:
|
||||
enable: true
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
lazy: true
|
||||
|
||||
proxy-groups:
|
||||
- name: PROXY
|
||||
- name: 🚀 节点选择
|
||||
type: select
|
||||
proxies:
|
||||
- DIRECT
|
||||
proxies: [♻️ 自动选择, 🔯 故障转移, 🔮 负载均衡, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 🚀 手动切换
|
||||
type: select
|
||||
use: [subscription]
|
||||
- name: ♻️ 自动选择
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
tolerance: 50
|
||||
- name: 🔯 故障转移
|
||||
type: fallback
|
||||
use: [subscription]
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🔮 负载均衡
|
||||
type: load-balance
|
||||
use: [subscription]
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
strategy: consistent-hashing
|
||||
- name: 🇭🇰 香港节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(港|HK|Hong ?Kong)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🇨🇳 台湾节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(台|TW|Taiwan)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🇸🇬 狮城节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(新加坡|坡|狮城|SG|Singapore)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🇯🇵 日本节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(日本|东京|大阪|JP|Japan)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🇺🇲 美国节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(美|US|United ?States|Los ?Angeles|Seattle)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🇰🇷 韩国节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(韩|韓|KR|Korea|Seoul)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🎥 奈飞节点
|
||||
type: select
|
||||
use: [subscription]
|
||||
filter: "(?i)(NF|奈飞|解锁|Netflix|Media)"
|
||||
- name: 📲 电报消息
|
||||
type: select
|
||||
proxies: [🚀 节点选择, ♻️ 自动选择, 🇸🇬 狮城节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 💬 Ai平台
|
||||
type: select
|
||||
proxies: [🚀 节点选择, ♻️ 自动选择, 🇸🇬 狮城节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 📹 油管视频
|
||||
type: select
|
||||
proxies: [🚀 节点选择, ♻️ 自动选择, 🇸🇬 狮城节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 🎥 奈飞视频
|
||||
type: select
|
||||
proxies: [🎥 奈飞节点, 🚀 节点选择, ♻️ 自动选择, 🇸🇬 狮城节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 📺 巴哈姆特
|
||||
type: select
|
||||
proxies: [🇨🇳 台湾节点, 🚀 节点选择, 🚀 手动切换, DIRECT]
|
||||
- name: 📺 哔哩哔哩
|
||||
type: select
|
||||
proxies: [🎯 全球直连, 🇨🇳 台湾节点, 🇭🇰 香港节点]
|
||||
- name: 🌍 国外媒体
|
||||
type: select
|
||||
proxies: [🚀 节点选择, ♻️ 自动选择, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 🌏 国内媒体
|
||||
type: select
|
||||
proxies: [DIRECT, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🚀 手动切换]
|
||||
- name: 📢 谷歌FCM
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: Ⓜ️ 微软Bing
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: Ⓜ️ 微软云盘
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: Ⓜ️ 微软服务
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: 🍎 苹果服务
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: 🎮 游戏平台
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: 🎶 网易音乐
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, ♻️ 自动选择]
|
||||
- name: 🎯 全球直连
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, ♻️ 自动选择]
|
||||
- name: 🛑 广告拦截
|
||||
type: select
|
||||
proxies: [REJECT, DIRECT]
|
||||
- name: 🍃 应用净化
|
||||
type: select
|
||||
proxies: [REJECT, DIRECT]
|
||||
- name: 🐟 漏网之鱼
|
||||
type: select
|
||||
proxies: [🚀 节点选择, ♻️ 自动选择, DIRECT, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
|
||||
rule-providers:
|
||||
LocalAreaNetwork: {type: file, behavior: classical, format: yaml, path: /usr/local/share/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:
|
||||
- MATCH,PROXY
|
||||
- RULE-SET,LocalAreaNetwork,🎯 全球直连
|
||||
- RULE-SET,UnBan,🎯 全球直连
|
||||
- RULE-SET,BanAD,🛑 广告拦截
|
||||
- RULE-SET,BanProgramAD,🍃 应用净化
|
||||
- RULE-SET,GoogleFCM,📢 谷歌FCM
|
||||
- RULE-SET,GoogleCN,🎯 全球直连
|
||||
- RULE-SET,SteamCN,🎯 全球直连
|
||||
- RULE-SET,Bing,Ⓜ️ 微软Bing
|
||||
- RULE-SET,OneDrive,Ⓜ️ 微软云盘
|
||||
- RULE-SET,Microsoft,Ⓜ️ 微软服务
|
||||
- RULE-SET,Apple,🍎 苹果服务
|
||||
- RULE-SET,Telegram,📲 电报消息
|
||||
- RULE-SET,AI,💬 Ai平台
|
||||
- RULE-SET,OpenAi,💬 Ai平台
|
||||
- RULE-SET,NetEaseMusic,🎶 网易音乐
|
||||
- RULE-SET,Epic,🎮 游戏平台
|
||||
- RULE-SET,Origin,🎮 游戏平台
|
||||
- RULE-SET,Sony,🎮 游戏平台
|
||||
- RULE-SET,Steam,🎮 游戏平台
|
||||
- RULE-SET,Nintendo,🎮 游戏平台
|
||||
- RULE-SET,YouTube,📹 油管视频
|
||||
- RULE-SET,Netflix,🎥 奈飞视频
|
||||
- RULE-SET,Bahamut,📺 巴哈姆特
|
||||
- RULE-SET,BilibiliHMT,📺 哔哩哔哩
|
||||
- RULE-SET,Bilibili,📺 哔哩哔哩
|
||||
- RULE-SET,ChinaMedia,🌏 国内媒体
|
||||
- RULE-SET,ProxyMedia,🌍 国外媒体
|
||||
- RULE-SET,ProxyGFWlist,🚀 节点选择
|
||||
- RULE-SET,ChinaDomain,🎯 全球直连
|
||||
- RULE-SET,ChinaCompanyIp,🎯 全球直连
|
||||
- RULE-SET,Download,🎯 全球直连
|
||||
- RULE-SET,ChinaIp,🎯 全球直连
|
||||
- MATCH,🐟 漏网之鱼
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
module git.ipao.vip/rogee/mohomo-docker
|
||||
|
||||
go 1.24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -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=
|
||||
+18
-151
@@ -1,86 +1,17 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 validateSource(path, label string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
@@ -95,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) {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,10 +33,83 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeededConfigUsesLocalACL4SSRRulesAndMemorySubscription(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content, err := os.ReadFile("../../config/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("read seeded config: %v", err)
|
||||
}
|
||||
config := string(content)
|
||||
for _, required := range []string{
|
||||
"path: ./subscription.yaml",
|
||||
"RULE-SET,LocalAreaNetwork,🎯 全球直连",
|
||||
"RULE-SET,BanAD,🛑 广告拦截",
|
||||
"RULE-SET,ProxyGFWlist,🚀 节点选择",
|
||||
"MATCH,🐟 漏网之鱼",
|
||||
"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") || 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+160
-94
@@ -2,120 +2,186 @@
|
||||
set -eu
|
||||
|
||||
image=${1:-mohomo-docker:smoke}
|
||||
suffix="$$"
|
||||
container="mohomo-docker-smoke-${suffix}"
|
||||
volume="mohomo-docker-smoke-${suffix}"
|
||||
cookie=""
|
||||
login_html=""
|
||||
config_html=""
|
||||
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:$volume" in
|
||||
mohomo-docker-smoke-*':mohomo-docker-smoke-'*) ;;
|
||||
*) echo "refusing unsafe cleanup targets" >&2; exit 1 ;;
|
||||
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" >/dev/null 2>&1 || true
|
||||
docker volume rm "$volume" >/dev/null 2>&1 || true
|
||||
[ -z "$cookie" ] || rm -f "$cookie"
|
||||
[ -z "$login_html" ] || rm -f "$login_html"
|
||||
[ -z "$config_html" ] || rm -f "$config_html"
|
||||
docker container rm --force "$container" "$provider" >/dev/null 2>&1 || true
|
||||
docker volume rm "$volume" "$cold_volume" "$secret_volume" >/dev/null 2>&1 || true
|
||||
docker network rm "$network" >/dev/null 2>&1 || true
|
||||
rm -f "$secret_file"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
docker build --tag "$image" .
|
||||
docker run --rm --entrypoint /usr/local/lib/ssclash/clash "$image" \
|
||||
-t -d /usr/local/share/ssclash
|
||||
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
|
||||
}
|
||||
|
||||
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" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image" -c 'mkdir /tmp/web; printf "proxies:\n - name: first-node\n type: socks5\n server: 127.0.0.1\n port: 9\n" > /tmp/web/provider.yaml; printf "%s\n" "#!/bin/sh" "body=\$(cat /tmp/web/provider.yaml)" "length=\$(printf \"%s\" \"\$body\" | wc -c)" "printf \"HTTP/1.1 200 OK\\r\\nContent-Type: text/yaml\\r\\nContent-Length: %s\\r\\nConnection: close\\r\\n\\r\\n\" \"\$length\"" "printf \"%s\" \"\$body\"" > /tmp/handler; chmod +x /tmp/handler; exec nc -lk -p 8080 -e /tmp/handler' >/dev/null
|
||||
|
||||
attempt=0
|
||||
until docker exec "$provider" wget -qO- http://127.0.0.1:8080/provider.yaml | grep -F first-node >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -lt 20 ] || { echo "subscription fixture did not start" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
|
||||
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 \
|
||||
--volume "$volume:/opt/clash" \
|
||||
--publish 127.0.0.1::9091/tcp \
|
||||
--volume "$volume:/data" \
|
||||
--volume "$secret_volume:/run/secrets:ro" \
|
||||
--publish 127.0.0.1::7890/tcp \
|
||||
--publish 127.0.0.1::9090/tcp \
|
||||
"$image" >/dev/null
|
||||
|
||||
web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||
proxy_port=$(docker port "$container" 7890/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||
test -n "$web_port"
|
||||
test -n "$proxy_port"
|
||||
wait_for_health
|
||||
wait_for_last_good first-node
|
||||
published=$(docker port "$container")
|
||||
[ "$(printf '%s\n' "$published" | wc -l)" -eq 2 ]
|
||||
printf '%s\n' "$published" | grep -F '7890/tcp -> 127.0.0.1:' >/dev/null
|
||||
printf '%s\n' "$published" | grep -F '9090/tcp -> 127.0.0.1:' >/dev/null
|
||||
proxy_port=$(docker port "$container" 7890/tcp | awk -F: 'NR == 1 {print $NF}')
|
||||
controller_port=$(docker port "$container" 9090/tcp | awk -F: 'NR == 1 {print $NF}')
|
||||
docker run --rm --network host --entrypoint /bin/sh "$image" -c "nc -z 127.0.0.1 $proxy_port"
|
||||
host_curl "http://127.0.0.1:${controller_port}/version" >/dev/null
|
||||
host_curl "http://127.0.0.1:${controller_port}/ui/" | grep -Fi '<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
|
||||
|
||||
attempt=0
|
||||
until curl --fail --silent --show-error "http://127.0.0.1:${web_port}/" >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 30 ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "web UI did not become ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
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
|
||||
|
||||
docker exec "$container" grep -Fx 'OPERATING_MODE=server' /opt/clash/.ssclash/settings >/dev/null
|
||||
docker exec "$container" grep -Fx 'PROXY_MODE=none' /opt/clash/.ssclash/settings >/dev/null
|
||||
docker exec "$container" grep -Fx 'mixed-port: 7890' /opt/clash/config.yaml >/dev/null
|
||||
|
||||
docker exec "$container" /usr/local/bin/ssclash setpass container-smoke-only >/dev/null
|
||||
cookie=$(mktemp)
|
||||
login_html=$(mktemp)
|
||||
config_html=$(mktemp)
|
||||
curl --fail --silent --show-error --cookie-jar "$cookie" \
|
||||
"http://127.0.0.1:${web_port}/login" > "$login_html"
|
||||
login_csrf=$(sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p' "$login_html" | head -1)
|
||||
test -n "$login_csrf"
|
||||
curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
--cookie-jar "$cookie" \
|
||||
--request POST \
|
||||
--data-urlencode "csrf=${login_csrf}" \
|
||||
--data-urlencode 'password=container-smoke-only' \
|
||||
"http://127.0.0.1:${web_port}/login" >/dev/null
|
||||
curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
"http://127.0.0.1:${web_port}/config" > "$config_html"
|
||||
api_csrf=$(sed -n 's/.*name="csrf-token" content="\([^"]*\)".*/\1/p' "$config_html" | head -1)
|
||||
test -n "$api_csrf"
|
||||
|
||||
start_response=$(curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
--header "X-CSRF-Token: ${api_csrf}" \
|
||||
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 '{"action":"start"}' \
|
||||
"http://127.0.0.1:${web_port}/api/service")
|
||||
printf '%s' "$start_response" | grep -F '"ok":true' >/dev/null
|
||||
--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
|
||||
|
||||
status_response=$(curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
--header "X-CSRF-Token: ${api_csrf}" \
|
||||
"http://127.0.0.1:${web_port}/api/status")
|
||||
printf '%s' "$status_response" | grep -F '"running":true' >/dev/null
|
||||
printf '%s' "$status_response" | grep -F '"operatingMode":"server"' >/dev/null
|
||||
|
||||
attempt=0
|
||||
until curl --fail --silent --show-error \
|
||||
--proxy "http://127.0.0.1:${proxy_port}" \
|
||||
--max-time 10 \
|
||||
https://example.com/ >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 20 ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "mixed proxy did not become ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if docker exec "$container" grep -Eq '^(tproxy-port|redir-port|tun):' /opt/clash/config.yaml; then
|
||||
docker logs "$container" >&2
|
||||
echo "gateway listener leaked into server-only config" >&2
|
||||
exit 1
|
||||
fi
|
||||
if docker logs "$container" 2>&1 | grep -Ei '\[(error|fatal)\]|operation not permitted' >/dev/null; then
|
||||
docker logs "$container" >&2
|
||||
echo "container emitted an error during Web-managed startup" >&2
|
||||
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 "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: web_port=${web_port} proxy_port=${proxy_port}"
|
||||
echo "container smoke test passed: cold fail-closed, warm recovery, HUP update, rollback, 7890, 9090, ExternalUI, and proxy switching"
|
||||
|
||||
+64
-16
@@ -1,30 +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
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user