Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5d351068 | ||
|
|
2363afba9b | ||
|
|
5b14158870 | ||
|
|
f29a3fc855 | ||
|
|
6c67390d52 | ||
|
|
5f5b5799e4 | ||
|
|
d61799d180 | ||
|
|
39f1841bc8 | ||
|
|
37643321fc | ||
|
|
05e78dc882 |
+5
-2
@@ -1,6 +1,9 @@
|
||||
IMAGE_NAME=mohomo-docker:local
|
||||
CONTAINER_NAME=mohomo-docker
|
||||
WEB_BIND=0.0.0.0
|
||||
SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo
|
||||
SSCLASH_PASSWORD=
|
||||
# 9091 is always host-loopback; expose it through a host HTTPS reverse proxy.
|
||||
WEB_PORT=9091
|
||||
PROXY_BIND=0.0.0.0
|
||||
# Public opt-in: use 0.0.0.0 only behind a trusted-network firewall/ACL.
|
||||
PROXY_BIND=127.0.0.1
|
||||
PROXY_PORT=7890
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref_type != 'tag' }}
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
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: Run container smoke test
|
||||
run: ./tests/container-smoke.sh
|
||||
|
||||
build:
|
||||
name: Build and publish
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max,ignore-error=true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
@@ -22,3 +22,4 @@
|
||||
- Do not commit SSClash or Mihomo binaries 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.
|
||||
|
||||
+31
-12
@@ -7,7 +7,8 @@ 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} \
|
||||
@@ -17,11 +18,12 @@ FROM alpine:${ALPINE_VERSION} AS release-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 \
|
||||
RUN set -eu; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64|arm64) ;; \
|
||||
*) echo "unsupported TARGETARCH=${TARGETARCH}; supported: amd64, arm64" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
@@ -35,11 +37,11 @@ RUN case "${TARGETARCH}" in \
|
||||
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}" ;; \
|
||||
RUN set -eu; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) asset="mihomo-linux-amd64-v1-${MIHOMO_VERSION}.gz"; mihomo_sha256="${MIHOMO_SHA256_AMD64}" ;; \
|
||||
arm64) asset="mihomo-linux-arm64-${MIHOMO_VERSION}.gz"; mihomo_sha256="${MIHOMO_SHA256_ARM64}" ;; \
|
||||
esac; \
|
||||
asset="mihomo-linux-${TARGETARCH}-${MIHOMO_VERSION}.gz"; \
|
||||
curl --fail --show-error --silent --location --retry 3 \
|
||||
--output mihomo.gz \
|
||||
"https://github.com/MetaCubeX/mihomo/releases/download/${MIHOMO_VERSION}/${asset}"; \
|
||||
@@ -47,26 +49,43 @@ RUN case "${TARGETARCH}" in \
|
||||
gzip -d mihomo.gz; \
|
||||
chmod 0755 mihomo
|
||||
|
||||
FROM alpine:${ALPINE_VERSION} AS acl4ssr-assets
|
||||
ARG ACL4SSR_REF=6e27259b8625e360699c014f98f978ee7408c644
|
||||
ARG ACL4SSR_SHA256=72229e2f0a38fc9776720a20dd4ecb44fdd0b0704bbf1f5141732562a237bff2
|
||||
RUN apk add --no-cache ca-certificates curl
|
||||
RUN set -eu; \
|
||||
curl --fail --show-error --silent --location --retry 3 \
|
||||
--output /tmp/acl4ssr.tar.gz \
|
||||
"https://github.com/ACL4SSR/ACL4SSR/archive/${ACL4SSR_REF}.tar.gz"; \
|
||||
printf '%s %s\n' "${ACL4SSR_SHA256}" /tmp/acl4ssr.tar.gz | sha256sum -c -; \
|
||||
mkdir -p /out/rules; \
|
||||
tar -xzf /tmp/acl4ssr.tar.gz -C /out/rules --strip-components=3 \
|
||||
"ACL4SSR-${ACL4SSR_REF}/Clash/Providers"; \
|
||||
tar -xOzf /tmp/acl4ssr.tar.gz "ACL4SSR-${ACL4SSR_REF}/LICENCE" \
|
||||
> /out/ACL4SSR-LICENSE
|
||||
|
||||
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
|
||||
&& mkdir -p /data /opt/clash /tmp/ssclash /usr/local/lib/ssclash /usr/local/share/ssclash \
|
||||
&& chown -R ssclash:ssclash /data /opt/clash /tmp/ssclash
|
||||
COPY --from=bootstrap-builder /out/bootstrap /usr/local/bin/bootstrap
|
||||
COPY --from=release-assets /assets/ssclash /usr/local/bin/ssclash
|
||||
COPY --from=release-assets /assets/mihomo /usr/local/lib/ssclash/clash
|
||||
COPY --from=acl4ssr-assets /out/rules /usr/local/share/ssclash/rules
|
||||
COPY --from=acl4ssr-assets /out/ACL4SSR-LICENSE /usr/local/share/licenses/ACL4SSR-LICENSE
|
||||
COPY config/config.yaml /usr/local/share/ssclash/config.yaml
|
||||
|
||||
ENV SSCLASH_ROOT=/opt/clash \
|
||||
SSCLASH_TMP=/tmp/ssclash \
|
||||
SSCLASH_PLATFORM=linux \
|
||||
SSCLASH_ADDR=:9091
|
||||
SSCLASH_ADDR=0.0.0.0:9091 \
|
||||
SAFE_PATHS=/usr/local/share/ssclash
|
||||
|
||||
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
|
||||
CMD curl --fail --silent --show-error http://127.0.0.1:9091/login >/dev/null
|
||||
ENTRYPOINT ["/usr/local/bin/bootstrap"]
|
||||
CMD ["serve"]
|
||||
|
||||
@@ -22,3 +22,5 @@ SOFTWARE.
|
||||
|
||||
This license covers only the original files in this repository. Downloaded
|
||||
SSClash-Go and Mihomo binaries remain subject to their respective licenses.
|
||||
ACL4SSR rule files are packaged from their pinned upstream revision and remain
|
||||
subject to ACL4SSR's CC BY-SA 4.0 license, included in the built image.
|
||||
|
||||
@@ -1,63 +1,73 @@
|
||||
# mohomo-docker
|
||||
|
||||
Docker packaging for the official SSClash-Go daemon and Mihomo core. It intentionally provides only:
|
||||
|
||||
- SSClash embedded Web UI on port `9091`;
|
||||
- Mihomo HTTP/SOCKS mixed proxy on port `7890`;
|
||||
- server mode, without TUN, transparent proxy, firewall, routing, or DNS interception.
|
||||
Minimal Mihomo service with the ACL4SSR `Online Full MultiMode` routing model. The host publishes the SSClash Web UI on loopback port `9091` and the mixed proxy on loopback port `7890` by default; Mihomo's controller remains private to the container.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# Generate a password, then set SUBSCRIPTION_URL and SSCLASH_PASSWORD in .env.
|
||||
openssl rand -base64 24
|
||||
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.
|
||||
|
||||
Configure clients with either of these endpoints:
|
||||
The subscription endpoint must return a Clash/Mihomo proxy-provider YAML document (`proxies:`). Use an HTTPS endpoint when its URL contains a credential. A fresh volume refuses to start without an `SSCLASH_PASSWORD` of at least 12 characters; bootstrap uses SSClash's own `setpass` command before the Web listener starts. On the Docker host, open `http://127.0.0.1:9091`, log in with that password, and press **Start**. SSClash then owns the Mihomo process and the Web UI Start/Stop/status controls stay authoritative. A valid existing authentication file is preserved, so later starts do not require or replace the password. Local proxy clients connect to either endpoint:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
The Compose boundary fixes plaintext `9091` to host loopback. To provide the required external Web access, configure a host HTTPS reverse proxy to `127.0.0.1:${WEB_PORT:-9091}`; for example, a host-native Caddy configuration is:
|
||||
|
||||
```caddyfile
|
||||
ssclash.example.com {
|
||||
reverse_proxy 127.0.0.1:9091
|
||||
}
|
||||
```
|
||||
|
||||
Replace the domain and ensure its DNS reaches the host; Caddy then obtains and serves the TLS certificate. Do not publish 9091 directly as public HTTP.
|
||||
|
||||
`WEB_PORT`, `PROXY_BIND`, and `PROXY_PORT` are optional deployment overrides. Port 7890 also defaults to `127.0.0.1`; setting `PROXY_BIND=0.0.0.0` is the explicit public opt-in. The packaged Mihomo proxy has no client authentication, so use that opt-in only when a host firewall or network ACL restricts clients to a trusted range. Prefer binding `PROXY_BIND` to a specific trusted host address.
|
||||
|
||||
## Update and secret handling
|
||||
|
||||
The bootstrap fetches the subscription once before startup and every hour thereafter. Each candidate is limited to 16 MiB and validated with the active Mihomo binary before an atomic replacement. When Mihomo is running, bootstrap hot-reloads it; when it is stopped, the next Web-managed Start reads the latest provider. A failed reload restores and reloads the previous provider; if that recovery cannot be confirmed, SSClash stops instead of leaving Mihomo in an uncertain state.
|
||||
|
||||
The subscription URL and bootstrap administrator password are removed from child-process environments and never printed. The password is persisted only as SSClash's PBKDF2 authentication file. Before opening the Web listener, bootstrap requires that file to be a readable, process-owned regular file with exact mode `0600` and SSClash v6.1.0's expected PBKDF2 format; a missing or abnormal file fails closed. The generated configuration contains only a local provider path. Subscription data lives under `/dev/shm/mohomo`; `/opt/clash/subscription.yaml` is only a managed link to that in-memory file, so neither the image nor the volume stores its URL, response, or node credentials. Container restarts intentionally fetch a fresh subscription instead of persisting credentials.
|
||||
|
||||
Do not commit `.env`; it is ignored by Git. Docker still exposes bootstrap environment variables to principals allowed to inspect the container, so restrict Docker daemon access.
|
||||
|
||||
## ACL4SSR rules
|
||||
|
||||
The image packages ACL4SSR provider files from pinned commit `6e27259b8625e360699c014f98f978ee7408c644`. The archive checksum is pinned in the Dockerfile. Runtime routing uses only those local files—there is no online rule converter or rule-provider download.
|
||||
|
||||
The generated groups and rule order mirror `ACL4SSR_Online_Full_MultiMode.ini`: automatic selection, fallback, load balancing, regional selectors, service/media splits, ad rejection, China direct routing, GFW routing, and final fallback.
|
||||
|
||||
## Persistent data
|
||||
|
||||
The named volume `ssclash-data` is mounted at `/opt/clash` and stores:
|
||||
`/opt/clash` stores only SSClash settings, the packaged Mihomo core, and the non-secret generated configuration. Bootstrap creates missing files, preserves existing regular non-empty files, enforces `OPERATING_MODE=server` and `PROXY_MODE=none`, and rejects corrupt or ambiguous persistent state. It atomically migrates only the exact legacy packaged `GEOIP,CN` configuration to version 1's local `ChinaIp` rule and records `.mohomo-docker-config-version`; a customized legacy `GEOIP,CN` configuration is preserved and startup fails with an explicit remediation message. On container replacement it repairs only SSClash's exact `rule-providers` and `proxy-providers` links into `SSCLASH_TMP`; unexpected links are rejected without deleting their targets.
|
||||
|
||||
- `config.yaml` and named configurations;
|
||||
- SSClash settings and administrator credentials;
|
||||
- subscription, rule-provider, and proxy-provider files;
|
||||
- the active Mihomo core and its runtime data.
|
||||
## Reproducible inputs
|
||||
|
||||
The bootstrap process creates missing files only. Existing Mihomo and configuration files are preserved. It explicitly enforces `OPERATING_MODE=server` and `PROXY_MODE=none`; the latter prevents SSClash from synchronizing gateway-only TProxy, redirect, or TUN listeners into `config.yaml`. Duplicate mode entries or empty runtime files cause startup to fail with a diagnostic message.
|
||||
The Dockerfile pins:
|
||||
|
||||
Resetting the volume deletes configuration and credentials. Inspect the exact Compose project and volume name before doing so.
|
||||
- SSClash-Go `v6.1.0`, verified with its release checksum file;
|
||||
- Mihomo `v1.19.30`, verified with repository-pinned SHA-256 values;
|
||||
- ACL4SSR rules by commit and archive SHA-256.
|
||||
|
||||
## Version updates
|
||||
The GitHub Actions workflow builds `linux/amd64`, runs tests first, publishes only to private GHCR, and attaches SBOM and provenance. Do not make an image containing SSClash-Go public without permission from its copyright holder.
|
||||
|
||||
Versions are pinned in the Dockerfile:
|
||||
|
||||
- SSClash-Go `v6.1.0`;
|
||||
- Mihomo `v1.19.30`.
|
||||
|
||||
SSClash is verified against the checksum file from its official release. Mihomo amd64 and arm64 archives are verified against pinned SHA-256 values. To update either component, update the version and checksums together, then run the complete test suite.
|
||||
|
||||
## Tests
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
./scripts/test.sh
|
||||
./tests/container-smoke.sh
|
||||
```
|
||||
|
||||
The unit suite enforces at least 65% statement coverage for bootstrap behavior. The container smoke test builds the image, validates the Mihomo configuration, starts the Web UI with all Linux capabilities dropped, authenticates to SSClash, starts Mihomo through the Web API, rejects gateway-listener/error regressions, and sends an HTTPS request through the mapped mixed proxy port.
|
||||
The unit suite checks strict fail-closed authentication-file validation, managed-config migration, provider-link recovery, atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test validates a legacy volume with networking disabled, verifies loopback-only Compose defaults and proxy-only public opt-in, proves 7890/9091 are unreachable through a non-loopback host address, checks fresh-volume authentication and credential isolation, and repeats health and login checks after recreating the container with the same volume.
|
||||
|
||||
## License boundary
|
||||
|
||||
This repository contains only original Docker packaging and bootstrap code. It downloads SSClash-Go and Mihomo from their official releases while building.
|
||||
|
||||
SSClash-Go uses a proprietary binary license that permits personal/internal use but prohibits redistributing its binary to third parties. Do not publish the resulting image without the copyright holder's permission. Mihomo is separately licensed under GPL-3.0.
|
||||
Original packaging code is MIT licensed. SSClash-Go, Mihomo, and packaged ACL4SSR rule files retain their upstream licenses; the image includes ACL4SSR's CC BY-SA 4.0 text.
|
||||
|
||||
+50
-8
@@ -1,50 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/mohomo-docker/internal/bootstrap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRoot = "/opt/clash"
|
||||
defaultSSClashTemp = "/tmp/ssclash"
|
||||
defaultCoreSource = "/usr/local/lib/ssclash/clash"
|
||||
defaultConfigSource = "/usr/local/share/ssclash/config.yaml"
|
||||
ssclashBinary = "/usr/local/bin/ssclash"
|
||||
defaultRuntimeDir = "/dev/shm/mohomo"
|
||||
defaultSecretPath = "/run/secrets/subscription"
|
||||
defaultDataDir = "/data"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.LUTC)
|
||||
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]")
|
||||
}
|
||||
root := envOrDefault("SSCLASH_ROOT", defaultRoot)
|
||||
log.Printf("bootstrap: preparing persistent runtime root=%s", root)
|
||||
|
||||
result, err := bootstrap.Prepare(bootstrap.Config{
|
||||
Root: root,
|
||||
SSClashTemp: envOrDefault("SSCLASH_TMP", defaultSSClashTemp),
|
||||
CoreSource: defaultCoreSource,
|
||||
ConfigSource: defaultConfigSource,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("bootstrap: runtime preparation failed: %v", err)
|
||||
}
|
||||
adminPasswordInitialized, err := bootstrap.EnsureAdminPassword(root, ssclashBinary, os.Getenv("SSCLASH_PASSWORD"))
|
||||
if err != nil {
|
||||
log.Fatalf("bootstrap: admin authentication setup failed: %v", err)
|
||||
}
|
||||
log.Printf(
|
||||
"bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_settings_changed=%t",
|
||||
"bootstrap: ready root=%s core_initialized=%t config_initialized=%t config_migrated=%t server_settings_changed=%t admin_password_initialized=%t",
|
||||
root,
|
||||
result.CoreInitialized,
|
||||
result.ConfigInitialized,
|
||||
result.ConfigMigrated,
|
||||
result.ServerSettingsChanged,
|
||||
adminPasswordInitialized,
|
||||
)
|
||||
|
||||
arguments := os.Args[1:]
|
||||
if len(arguments) == 0 {
|
||||
arguments = []string{"serve"}
|
||||
subscriptionURL := os.Getenv("SUBSCRIPTION_URL")
|
||||
if subscriptionURL == "" {
|
||||
log.Fatal("bootstrap: SUBSCRIPTION_URL is required")
|
||||
}
|
||||
argv := append([]string{filepath.Base(ssclashBinary)}, arguments...)
|
||||
log.Printf("bootstrap: exec path=%s command=%s", ssclashBinary, arguments[0])
|
||||
if err := syscall.Exec(ssclashBinary, argv, os.Environ()); err != nil {
|
||||
log.Fatalf("bootstrap: exec failed: %v", err)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
err = bootstrap.Run(ctx, bootstrap.RuntimeConfig{
|
||||
Root: root,
|
||||
CoreBinary: filepath.Join(root, "bin", "clash"),
|
||||
SSClashBinary: ssclashBinary,
|
||||
ConfigSource: filepath.Join(root, "config.yaml"),
|
||||
RuntimeDir: defaultRuntimeDir,
|
||||
SubscriptionURL: subscriptionURL,
|
||||
UpdateInterval: time.Hour,
|
||||
})
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Fatalf("bootstrap: service failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -6,10 +6,14 @@ services:
|
||||
container_name: ${CONTAINER_NAME:-mohomo-docker}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
environment:
|
||||
SUBSCRIPTION_URL: ${SUBSCRIPTION_URL:?set SUBSCRIPTION_URL in .env}
|
||||
SSCLASH_PASSWORD: ${SSCLASH_PASSWORD:-}
|
||||
ports:
|
||||
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-9091}:9091/tcp"
|
||||
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/tcp"
|
||||
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/udp"
|
||||
# Keep the plaintext Web UI behind a host-local HTTPS reverse proxy.
|
||||
- "127.0.0.1:${WEB_PORT:-9091}:9091/tcp"
|
||||
- "${PROXY_BIND:-127.0.0.1}:${PROXY_PORT:-7890}:7890/tcp"
|
||||
- "${PROXY_BIND:-127.0.0.1}:${PROXY_PORT:-7890}:7890/udp"
|
||||
volumes:
|
||||
- ssclash-data:/opt/clash
|
||||
cap_drop:
|
||||
|
||||
+197
-7
@@ -7,16 +7,206 @@ ipv6: false
|
||||
external-controller: 127.0.0.1:9090
|
||||
|
||||
profile:
|
||||
store-selected: true
|
||||
store-fake-ip: true
|
||||
store-selected: false
|
||||
store-fake-ip: false
|
||||
|
||||
proxies: []
|
||||
proxy-providers:
|
||||
subscription:
|
||||
type: file
|
||||
path: ./subscription.yaml
|
||||
health-check:
|
||||
enable: true
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
lazy: true
|
||||
|
||||
proxy-groups:
|
||||
- name: PROXY
|
||||
- name: 🚀 节点选择
|
||||
type: select
|
||||
proxies:
|
||||
- DIRECT
|
||||
proxies: [♻️ 自动选择, 🔯 故障转移, 🔮 负载均衡, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 🚀 手动切换
|
||||
type: select
|
||||
use: [subscription]
|
||||
- name: ♻️ 自动选择
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
tolerance: 50
|
||||
- name: 🔯 故障转移
|
||||
type: fallback
|
||||
use: [subscription]
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🔮 负载均衡
|
||||
type: load-balance
|
||||
use: [subscription]
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
strategy: consistent-hashing
|
||||
- name: 🇭🇰 香港节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(港|HK|Hong ?Kong)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🇨🇳 台湾节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(台|TW|Taiwan)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🇸🇬 狮城节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(新加坡|坡|狮城|SG|Singapore)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🇯🇵 日本节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(日本|东京|大阪|JP|Japan)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🇺🇲 美国节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(美|US|United ?States|Los ?Angeles|Seattle)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🇰🇷 韩国节点
|
||||
type: url-test
|
||||
use: [subscription]
|
||||
filter: "(?i)(韩|韓|KR|Korea|Seoul)"
|
||||
url: http://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
- name: 🎥 奈飞节点
|
||||
type: select
|
||||
use: [subscription]
|
||||
filter: "(?i)(NF|奈飞|解锁|Netflix|Media)"
|
||||
- name: 📲 电报消息
|
||||
type: select
|
||||
proxies: [🚀 节点选择, ♻️ 自动选择, 🇸🇬 狮城节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 💬 Ai平台
|
||||
type: select
|
||||
proxies: [🚀 节点选择, ♻️ 自动选择, 🇸🇬 狮城节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 📹 油管视频
|
||||
type: select
|
||||
proxies: [🚀 节点选择, ♻️ 自动选择, 🇸🇬 狮城节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 🎥 奈飞视频
|
||||
type: select
|
||||
proxies: [🎥 奈飞节点, 🚀 节点选择, ♻️ 自动选择, 🇸🇬 狮城节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 📺 巴哈姆特
|
||||
type: select
|
||||
proxies: [🇨🇳 台湾节点, 🚀 节点选择, 🚀 手动切换, DIRECT]
|
||||
- name: 📺 哔哩哔哩
|
||||
type: select
|
||||
proxies: [🎯 全球直连, 🇨🇳 台湾节点, 🇭🇰 香港节点]
|
||||
- name: 🌍 国外媒体
|
||||
type: select
|
||||
proxies: [🚀 节点选择, ♻️ 自动选择, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换, DIRECT]
|
||||
- name: 🌏 国内媒体
|
||||
type: select
|
||||
proxies: [DIRECT, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🚀 手动切换]
|
||||
- name: 📢 谷歌FCM
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: Ⓜ️ 微软Bing
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: Ⓜ️ 微软云盘
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: Ⓜ️ 微软服务
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: 🍎 苹果服务
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: 🎮 游戏平台
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, 🇺🇲 美国节点, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
- name: 🎶 网易音乐
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, ♻️ 自动选择]
|
||||
- name: 🎯 全球直连
|
||||
type: select
|
||||
proxies: [DIRECT, 🚀 节点选择, ♻️ 自动选择]
|
||||
- name: 🛑 广告拦截
|
||||
type: select
|
||||
proxies: [REJECT, DIRECT]
|
||||
- name: 🍃 应用净化
|
||||
type: select
|
||||
proxies: [REJECT, DIRECT]
|
||||
- name: 🐟 漏网之鱼
|
||||
type: select
|
||||
proxies: [🚀 节点选择, ♻️ 自动选择, DIRECT, 🇭🇰 香港节点, 🇨🇳 台湾节点, 🇸🇬 狮城节点, 🇯🇵 日本节点, 🇺🇲 美国节点, 🇰🇷 韩国节点, 🚀 手动切换]
|
||||
|
||||
rule-providers:
|
||||
LocalAreaNetwork: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/LocalAreaNetwork.yaml}
|
||||
UnBan: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/UnBan.yaml}
|
||||
BanAD: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/BanAD.yaml}
|
||||
BanProgramAD: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/BanProgramAD.yaml}
|
||||
GoogleFCM: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/GoogleFCM.yaml}
|
||||
GoogleCN: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/GoogleCN.yaml}
|
||||
SteamCN: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/SteamCN.yaml}
|
||||
Bing: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Bing.yaml}
|
||||
OneDrive: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/OneDrive.yaml}
|
||||
Microsoft: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Microsoft.yaml}
|
||||
Apple: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Apple.yaml}
|
||||
Telegram: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Telegram.yaml}
|
||||
AI: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/AI.yaml}
|
||||
OpenAi: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/OpenAi.yaml}
|
||||
NetEaseMusic: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/NetEaseMusic.yaml}
|
||||
Epic: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Epic.yaml}
|
||||
Origin: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Origin.yaml}
|
||||
Sony: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Sony.yaml}
|
||||
Steam: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Steam.yaml}
|
||||
Nintendo: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Nintendo.yaml}
|
||||
YouTube: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/YouTube.yaml}
|
||||
Netflix: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Netflix.yaml}
|
||||
Bahamut: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Bahamut.yaml}
|
||||
BilibiliHMT: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/BilibiliHMT.yaml}
|
||||
Bilibili: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Bilibili.yaml}
|
||||
ChinaMedia: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/ChinaMedia.yaml}
|
||||
ProxyMedia: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/ProxyMedia.yaml}
|
||||
ProxyGFWlist: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/ProxyGFWlist.yaml}
|
||||
ChinaDomain: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/ChinaDomain.yaml}
|
||||
ChinaCompanyIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaCompanyIp.yaml}
|
||||
ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}
|
||||
Download: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Download.yaml}
|
||||
|
||||
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=
|
||||
@@ -1,14 +1,36 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSubscriptionSize = 16 << 20
|
||||
minAdminPasswordLength = 12
|
||||
managedConfigVersion = "1"
|
||||
managedConfigVersionFile = ".mohomo-docker-config-version"
|
||||
managedChinaIPProvider = " ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}\n"
|
||||
managedChinaIPRule = " - RULE-SET,ChinaIp,🎯 全球直连"
|
||||
legacyChinaIPRule = " - GEOIP,CN,🎯 全球直连"
|
||||
defaultControllerURL = "http://127.0.0.1:9090"
|
||||
)
|
||||
|
||||
var errMihomoStateUncertain = errors.New("Mihomo subscription state could not be restored")
|
||||
|
||||
type enforcedSetting struct {
|
||||
key string
|
||||
value string
|
||||
@@ -24,14 +46,15 @@ var runtimeDirectories = []string{
|
||||
".ssclash",
|
||||
"configs",
|
||||
"local-rules",
|
||||
"rule-providers",
|
||||
"proxy-providers",
|
||||
"subscriptions",
|
||||
"ui",
|
||||
}
|
||||
|
||||
var managedProviderDirectories = []string{"rule-providers", "proxy-providers"}
|
||||
|
||||
type Config struct {
|
||||
Root string
|
||||
SSClashTemp string
|
||||
CoreSource string
|
||||
ConfigSource string
|
||||
}
|
||||
@@ -39,9 +62,21 @@ type Config struct {
|
||||
type Result struct {
|
||||
CoreInitialized bool
|
||||
ConfigInitialized bool
|
||||
ConfigMigrated bool
|
||||
ServerSettingsChanged bool
|
||||
}
|
||||
|
||||
type RuntimeConfig struct {
|
||||
Root string
|
||||
CoreBinary string
|
||||
SSClashBinary string
|
||||
ConfigSource string
|
||||
RuntimeDir string
|
||||
SubscriptionURL string
|
||||
ControllerURL string
|
||||
UpdateInterval time.Duration
|
||||
}
|
||||
|
||||
func Prepare(config Config) (Result, error) {
|
||||
var result Result
|
||||
root := filepath.Clean(config.Root)
|
||||
@@ -51,6 +86,10 @@ func Prepare(config Config) (Result, error) {
|
||||
if !filepath.IsAbs(root) {
|
||||
return result, fmt.Errorf("root must be absolute: %q", config.Root)
|
||||
}
|
||||
ssclashTemp := filepath.Clean(config.SSClashTemp)
|
||||
if !filepath.IsAbs(ssclashTemp) || ssclashTemp == string(filepath.Separator) {
|
||||
return result, fmt.Errorf("unsafe SSClash temporary directory %q", config.SSClashTemp)
|
||||
}
|
||||
if err := validateSource(config.CoreSource, "core source"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -63,15 +102,24 @@ func Prepare(config Config) (Result, error) {
|
||||
return result, fmt.Errorf("create runtime directory %s: %w", directory, err)
|
||||
}
|
||||
}
|
||||
for _, directory := range managedProviderDirectories {
|
||||
if err := reconcileManagedProviderDirectory(root, ssclashTemp, directory); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
result.CoreInitialized, err = copyIfAbsent(config.CoreSource, filepath.Join(root, "bin", "clash"), 0o755)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("initialize Mihomo core: %w", err)
|
||||
}
|
||||
result.ConfigInitialized, err = copyIfAbsent(config.ConfigSource, filepath.Join(root, "config.yaml"), 0o644)
|
||||
result.ConfigInitialized, result.ConfigMigrated, err = prepareManagedConfig(
|
||||
config.ConfigSource,
|
||||
filepath.Join(root, "config.yaml"),
|
||||
filepath.Join(root, managedConfigVersionFile),
|
||||
)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("initialize config: %w", err)
|
||||
return result, fmt.Errorf("prepare config: %w", err)
|
||||
}
|
||||
result.ServerSettingsChanged, err = enforceServerSettings(filepath.Join(root, ".ssclash", "settings"))
|
||||
if err != nil {
|
||||
@@ -81,6 +129,450 @@ func Prepare(config Config) (Result, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EnsureAdminPassword(root, binary, password string) (bool, error) {
|
||||
root = filepath.Clean(root)
|
||||
if root == "." || root == string(filepath.Separator) || !filepath.IsAbs(root) {
|
||||
return false, fmt.Errorf("unsafe root %q", root)
|
||||
}
|
||||
passwordPath := filepath.Join(root, ".ssclash", "password")
|
||||
configured, err := adminPasswordConfigured(passwordPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if configured {
|
||||
return false, nil
|
||||
}
|
||||
if password == "" {
|
||||
return false, errors.New("SSCLASH_PASSWORD is required to initialize a fresh volume")
|
||||
}
|
||||
if len(password) < minAdminPasswordLength {
|
||||
return false, fmt.Errorf("SSCLASH_PASSWORD must be at least %d characters", minAdminPasswordLength)
|
||||
}
|
||||
if err := validateSource(binary, "SSClash binary"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
command := exec.Command(binary, "setpass", password)
|
||||
command.Env = childEnvironment()
|
||||
command.Stdout = io.Discard
|
||||
command.Stderr = io.Discard
|
||||
if err := command.Run(); err != nil {
|
||||
return false, errors.New("SSClash password initialization failed")
|
||||
}
|
||||
configured, err = adminPasswordConfigured(passwordPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !configured {
|
||||
return false, errors.New("SSClash password initialization did not create an authentication file")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func adminPasswordConfigured(path string) (bool, error) {
|
||||
return adminPasswordConfiguredFor(path, uint32(os.Geteuid()), uint32(os.Getegid()))
|
||||
}
|
||||
|
||||
func adminPasswordConfiguredFor(path string, expectedUID, expectedGID uint32) (bool, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect SSClash authentication file: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return false, errors.New("SSClash authentication file must be a regular file")
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
return false, fmt.Errorf("SSClash authentication file permissions are %o; want 600", info.Mode().Perm())
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return false, errors.New("SSClash authentication file ownership could not be verified")
|
||||
}
|
||||
if stat.Uid != expectedUID || stat.Gid != expectedGID {
|
||||
return false, fmt.Errorf("SSClash authentication file owner is %d:%d; want %d:%d", stat.Uid, stat.Gid, expectedUID, expectedGID)
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read SSClash authentication file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
openedInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect opened SSClash authentication file: %w", err)
|
||||
}
|
||||
if !os.SameFile(info, openedInfo) {
|
||||
return false, errors.New("SSClash authentication file changed while being verified")
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(file, 257))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read SSClash authentication file: %w", err)
|
||||
}
|
||||
if len(content) > 256 {
|
||||
return false, errors.New("SSClash authentication file is too large")
|
||||
}
|
||||
if err := validateAdminPasswordHash(content); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func validateAdminPasswordHash(content []byte) error {
|
||||
text := string(content)
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
return errors.New("SSClash authentication file has an invalid password hash")
|
||||
}
|
||||
parts := strings.Split(strings.TrimSuffix(text, "\n"), "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2" || parts[1] != "120000" || len(parts[2]) != 32 || len(parts[3]) != 64 {
|
||||
return errors.New("SSClash authentication file has an invalid password hash")
|
||||
}
|
||||
if _, err := hex.DecodeString(parts[2]); err != nil {
|
||||
return errors.New("SSClash authentication file has an invalid password hash")
|
||||
}
|
||||
if _, err := hex.DecodeString(parts[3]); err != nil {
|
||||
return errors.New("SSClash authentication file has an invalid password hash")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reconcileManagedProviderDirectory(root, ssclashTemp, directory string) error {
|
||||
path := filepath.Join(root, directory)
|
||||
expectedTarget := filepath.Join(ssclashTemp, directory)
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return fmt.Errorf("create runtime directory %s: %w", directory, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect runtime directory %s: %w", directory, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return fmt.Errorf("runtime path %s is not a directory", directory)
|
||||
}
|
||||
target, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read runtime symlink %s: %w", directory, err)
|
||||
}
|
||||
if target != expectedTarget {
|
||||
return fmt.Errorf("runtime path %s has unexpected symlink target %q", directory, target)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("remove managed runtime symlink %s: %w", directory, err)
|
||||
}
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return fmt.Errorf("recreate runtime directory %s: %w", directory, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, config RuntimeConfig) error {
|
||||
if err := validateSubscriptionURL(config.SubscriptionURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.UpdateInterval <= 0 {
|
||||
return errors.New("subscription update interval must be positive")
|
||||
}
|
||||
root := filepath.Clean(config.Root)
|
||||
if !filepath.IsAbs(root) || root == string(filepath.Separator) {
|
||||
return fmt.Errorf("unsafe root %q", config.Root)
|
||||
}
|
||||
runtimeDir := filepath.Clean(config.RuntimeDir)
|
||||
if !filepath.IsAbs(runtimeDir) || runtimeDir == string(filepath.Separator) {
|
||||
return fmt.Errorf("unsafe runtime directory %q", config.RuntimeDir)
|
||||
}
|
||||
for path, label := range map[string]string{
|
||||
config.CoreBinary: "Mihomo core",
|
||||
config.SSClashBinary: "SSClash binary",
|
||||
config.ConfigSource: "config source",
|
||||
} {
|
||||
if err := validateSource(path, label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(runtimeDir, 0o700); err != nil {
|
||||
return fmt.Errorf("create in-memory runtime directory: %w", err)
|
||||
}
|
||||
|
||||
runtimeConfig := filepath.Join(runtimeDir, "config.yaml")
|
||||
if err := copyFile(config.ConfigSource, runtimeConfig, 0o600); err != nil {
|
||||
return fmt.Errorf("prepare in-memory config: %w", err)
|
||||
}
|
||||
activeSubscription := filepath.Join(runtimeDir, "subscription.yaml")
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
controllerURL := strings.TrimRight(config.ControllerURL, "/")
|
||||
if controllerURL == "" {
|
||||
controllerURL = defaultControllerURL
|
||||
}
|
||||
validate := func(candidate string) error {
|
||||
return validateSubscription(ctx, config, runtimeConfig, candidate)
|
||||
}
|
||||
reload := func(ctx context.Context) error {
|
||||
return reloadSubscription(ctx, client, controllerURL)
|
||||
}
|
||||
if err := updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate); err != nil {
|
||||
return fmt.Errorf("initial subscription update failed: %w", err)
|
||||
}
|
||||
if err := validateMihomoConfig(ctx, config.CoreBinary, runtimeDir, runtimeConfig); err != nil {
|
||||
return errors.New("generated Mihomo configuration failed validation")
|
||||
}
|
||||
if err := ensureSubscriptionLink(filepath.Join(root, "subscription.yaml"), activeSubscription); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serviceCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
ssclash := serviceCommand(serviceCtx, config.SSClashBinary, "serve")
|
||||
if err := ssclash.Start(); err != nil {
|
||||
return fmt.Errorf("start SSClash: %w", err)
|
||||
}
|
||||
log.Printf("bootstrap: SSClash started mode=server core_owner=ssclash subscription_update_interval=%s", config.UpdateInterval)
|
||||
|
||||
exit := make(chan error, 1)
|
||||
go func() { exit <- ssclash.Wait() }()
|
||||
ticker := time.NewTicker(config.UpdateInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
<-exit
|
||||
return ctx.Err()
|
||||
case err := <-exit:
|
||||
if err == nil {
|
||||
return errors.New("SSClash exited")
|
||||
}
|
||||
return fmt.Errorf("SSClash exited: %w", err)
|
||||
case <-ticker.C:
|
||||
running := mihomoRunning(ctx, client, controllerURL)
|
||||
var err error
|
||||
if running {
|
||||
err = updateAndReload(ctx, client, config, activeSubscription, validate, reload)
|
||||
} else {
|
||||
err = updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate)
|
||||
}
|
||||
if errors.Is(err, errMihomoStateUncertain) {
|
||||
log.Print("bootstrap: subscription rollback failed; stopping SSClash")
|
||||
cancel()
|
||||
<-exit
|
||||
return err
|
||||
} else if err != nil {
|
||||
log.Print("bootstrap: subscription update rejected; keeping previous valid configuration")
|
||||
continue
|
||||
}
|
||||
if running {
|
||||
log.Print("bootstrap: subscription updated and reloaded")
|
||||
} else {
|
||||
log.Print("bootstrap: subscription updated; Mihomo is stopped")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureSubscriptionLink(path, target string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err := os.Symlink(target, path); err != nil {
|
||||
return fmt.Errorf("create runtime subscription link: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect runtime subscription link: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return errors.New("runtime subscription path is not a managed symlink")
|
||||
}
|
||||
existingTarget, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read runtime subscription link: %w", err)
|
||||
}
|
||||
if existingTarget != target {
|
||||
return fmt.Errorf("runtime subscription link has unexpected target %q", existingTarget)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSubscriptionURL(raw string) error {
|
||||
parsed, err := url.ParseRequestURI(raw)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||
return errors.New("SUBSCRIPTION_URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return errors.New("SUBSCRIPTION_URL must not contain user information")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateSubscription(ctx context.Context, client *http.Client, endpoint, target string, validate func(string) error) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return errors.New("create subscription request")
|
||||
}
|
||||
request.Header.Set("Accept", "application/yaml, text/yaml, text/plain")
|
||||
request.Header.Set("User-Agent", "mihomo")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return errors.New("subscription request failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("subscription endpoint returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(response.Body, maxSubscriptionSize+1))
|
||||
if err != nil {
|
||||
return errors.New("read subscription response")
|
||||
}
|
||||
if len(content) == 0 || len(content) > maxSubscriptionSize {
|
||||
return errors.New("subscription response is empty or too large")
|
||||
}
|
||||
|
||||
candidate := filepath.Join(filepath.Dir(target), ".subscription-candidate.yaml")
|
||||
if err := atomicWrite(candidate, 0o600, func(output *os.File) error {
|
||||
_, err := output.Write(content)
|
||||
return err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write subscription candidate: %w", err)
|
||||
}
|
||||
defer os.Remove(candidate)
|
||||
if err := validate(candidate); err != nil {
|
||||
return errors.New("subscription candidate failed Mihomo validation")
|
||||
}
|
||||
if err := os.Rename(candidate, target); err != nil {
|
||||
return fmt.Errorf("activate subscription candidate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSubscription(ctx context.Context, config RuntimeConfig, runtimeConfig, candidate string) error {
|
||||
content, err := os.ReadFile(runtimeConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidateConfig := strings.Replace(string(content), "path: ./subscription.yaml", "path: ./"+filepath.Base(candidate), 1)
|
||||
if candidateConfig == string(content) {
|
||||
return errors.New("subscription provider path is missing from config")
|
||||
}
|
||||
path := filepath.Join(config.RuntimeDir, ".candidate-config.yaml")
|
||||
if err := atomicWrite(path, 0o600, func(output *os.File) error {
|
||||
_, err := output.WriteString(candidateConfig)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(path)
|
||||
return validateMihomoConfig(ctx, config.CoreBinary, config.RuntimeDir, path)
|
||||
}
|
||||
|
||||
func validateMihomoConfig(ctx context.Context, binary, runtimeDir, configPath string) error {
|
||||
command := exec.CommandContext(ctx, binary, "-t", "-d", runtimeDir, "-f", configPath)
|
||||
command.Env = childEnvironment()
|
||||
command.Stdout = io.Discard
|
||||
command.Stderr = io.Discard
|
||||
if err := command.Run(); err != nil {
|
||||
return errors.New("Mihomo validation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAndReload(ctx context.Context, client *http.Client, config RuntimeConfig, target string, validate func(string) error, reload func(context.Context) error) error {
|
||||
previous, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateSubscription(ctx, client, config.SubscriptionURL, target, validate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reload(ctx); err == nil {
|
||||
return nil
|
||||
}
|
||||
if rollbackErr := atomicWrite(target, 0o600, func(output *os.File) error {
|
||||
_, writeErr := output.Write(previous)
|
||||
return writeErr
|
||||
}); rollbackErr != nil {
|
||||
return fmt.Errorf("%w: restore previous subscription file: %v", errMihomoStateUncertain, rollbackErr)
|
||||
}
|
||||
if err := reload(ctx); err != nil {
|
||||
return fmt.Errorf("%w: reload previous subscription", errMihomoStateUncertain)
|
||||
}
|
||||
return errors.New("new subscription reload failed; previous subscription restored")
|
||||
}
|
||||
|
||||
func reloadSubscription(ctx context.Context, client *http.Client, controllerURL string) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPut, controllerURL+"/providers/proxies/subscription", nil)
|
||||
if err != nil {
|
||||
return errors.New("create Mihomo reload request")
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return errors.New("Mihomo reload request failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("Mihomo reload returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mihomoRunning(ctx context.Context, client *http.Client, controllerURL string) bool {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, controllerURL+"/version", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
return true
|
||||
}
|
||||
|
||||
func serviceCommand(ctx context.Context, binary string, arguments ...string) *exec.Cmd {
|
||||
command := exec.CommandContext(ctx, binary, arguments...)
|
||||
command.Env = childEnvironment()
|
||||
command.Stdout = os.Stdout
|
||||
command.Stderr = os.Stderr
|
||||
command.Cancel = func() error {
|
||||
return command.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
command.WaitDelay = 10 * time.Second
|
||||
return command
|
||||
}
|
||||
|
||||
func childEnvironment() []string {
|
||||
environment := os.Environ()
|
||||
result := environment[:0]
|
||||
for _, entry := range environment {
|
||||
if strings.HasPrefix(entry, "SUBSCRIPTION_URL=") || strings.HasPrefix(entry, "SSCLASH_PASSWORD=") {
|
||||
continue
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func copyFile(source, target string, mode os.FileMode) error {
|
||||
input, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer input.Close()
|
||||
return atomicWrite(target, mode, func(output *os.File) error {
|
||||
_, err := io.Copy(output, input)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func validateSource(path, label string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
@@ -125,6 +617,108 @@ func copyIfAbsent(source, target string, mode os.FileMode) (bool, error) {
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func prepareManagedConfig(source, target, versionPath string) (bool, bool, error) {
|
||||
if err := validateManagedConfigVersion(versionPath); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
current, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("read managed config source: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Lstat(target)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err := writeManagedConfig(target, current); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if err := writeManagedConfigVersion(versionPath); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return true, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("inspect config %q: %w", target, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return false, false, fmt.Errorf("config %q is not a regular file", target)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return false, false, fmt.Errorf("config %q is empty", target)
|
||||
}
|
||||
existing, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("read config %q: %w", target, err)
|
||||
}
|
||||
if bytes.Equal(existing, current) {
|
||||
return false, false, writeManagedConfigVersion(versionPath)
|
||||
}
|
||||
|
||||
legacy, legacyErr := legacyManagedConfig(current)
|
||||
if legacyErr == nil && bytes.Equal(existing, legacy) {
|
||||
if err := writeManagedConfig(target, current); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if err := writeManagedConfigVersion(versionPath); err != nil {
|
||||
return false, true, err
|
||||
}
|
||||
return false, true, nil
|
||||
}
|
||||
if bytes.Contains(existing, []byte("GEOIP,CN")) {
|
||||
return false, false, fmt.Errorf("custom config uses GEOIP,CN and was preserved; replace it with the packaged local ChinaIp rule before retrying")
|
||||
}
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
func legacyManagedConfig(current []byte) ([]byte, error) {
|
||||
text := string(current)
|
||||
if strings.Count(text, managedChinaIPProvider) != 1 || strings.Count(text, managedChinaIPRule) != 1 {
|
||||
return nil, errors.New("packaged config is missing the managed ChinaIp rule")
|
||||
}
|
||||
text = strings.Replace(text, managedChinaIPProvider, "", 1)
|
||||
text = strings.Replace(text, managedChinaIPRule, legacyChinaIPRule, 1)
|
||||
return []byte(text), nil
|
||||
}
|
||||
|
||||
func validateManagedConfigVersion(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect managed config version: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return errors.New("managed config version marker is not a regular file")
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read managed config version: %w", err)
|
||||
}
|
||||
if string(content) != managedConfigVersion+"\n" {
|
||||
return fmt.Errorf("unsupported managed config version %q", strings.TrimSpace(string(content)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeManagedConfig(path string, content []byte) error {
|
||||
return atomicWrite(path, 0o644, func(output *os.File) error {
|
||||
_, err := output.Write(content)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func writeManagedConfigVersion(path string) error {
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("inspect managed config version: %w", err)
|
||||
}
|
||||
return atomicWrite(path, 0o644, func(output *os.File) error {
|
||||
_, err := output.WriteString(managedConfigVersion + "\n")
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func enforceServerSettings(path string) (bool, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPrepareInitializesServerRuntime(t *testing.T) {
|
||||
@@ -17,6 +24,7 @@ func TestPrepareInitializesServerRuntime(t *testing.T) {
|
||||
|
||||
result, err := Prepare(Config{
|
||||
Root: root,
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: coreSource,
|
||||
ConfigSource: configSource,
|
||||
})
|
||||
@@ -68,6 +76,7 @@ func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) {
|
||||
|
||||
result, err := Prepare(Config{
|
||||
Root: root,
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: writeFixture(t, tempDir, "mihomo", "image-core"),
|
||||
ConfigSource: writeFixture(t, tempDir, "default.yaml", "image: config\n"),
|
||||
})
|
||||
@@ -83,6 +92,308 @@ func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) {
|
||||
assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "LOG_LEVEL=debug\nOPERATING_MODE=server\nPROXY_MODE=none\n")
|
||||
}
|
||||
|
||||
func TestPrepareMigratesExactLegacyManagedConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
root := filepath.Join(tempDir, "data")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := "rule-providers:\n" + managedChinaIPProvider + "rules:\n" + managedChinaIPRule + "\n"
|
||||
legacy := "rule-providers:\nrules:\n" + legacyChinaIPRule + "\n"
|
||||
writeFixture(t, root, "config.yaml", legacy)
|
||||
|
||||
result, err := Prepare(Config{
|
||||
Root: root,
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: writeFixture(t, tempDir, "mihomo", "core"),
|
||||
ConfigSource: writeFixture(t, tempDir, "current.yaml", current),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v", err)
|
||||
}
|
||||
if result.ConfigInitialized || !result.ConfigMigrated {
|
||||
t.Fatalf("Prepare() result = %+v, want migrated existing config", result)
|
||||
}
|
||||
assertFileContent(t, filepath.Join(root, "config.yaml"), current)
|
||||
assertFileContent(t, filepath.Join(root, managedConfigVersionFile), managedConfigVersion+"\n")
|
||||
}
|
||||
|
||||
func TestPrepareBackfillsVersionAfterMigrationMarkerFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
root := filepath.Join(tempDir, "data")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := "rule-providers:\n" + managedChinaIPProvider + "rules:\n" + managedChinaIPRule + "\n"
|
||||
legacy := "rule-providers:\nrules:\n" + legacyChinaIPRule + "\n"
|
||||
target := writeFixture(t, root, "config.yaml", legacy)
|
||||
configSource := writeFixture(t, tempDir, "current.yaml", current)
|
||||
|
||||
_, migrated, err := prepareManagedConfig(
|
||||
configSource,
|
||||
target,
|
||||
filepath.Join(tempDir, "missing", managedConfigVersionFile),
|
||||
)
|
||||
if err == nil || !migrated {
|
||||
t.Fatalf("prepareManagedConfig() = migrated %t, error %v; want migrated config and marker write error", migrated, err)
|
||||
}
|
||||
assertFileContent(t, target, current)
|
||||
|
||||
result, err := Prepare(Config{
|
||||
Root: root,
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: writeFixture(t, tempDir, "mihomo", "core"),
|
||||
ConfigSource: configSource,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() retry error = %v", err)
|
||||
}
|
||||
if result.ConfigInitialized || result.ConfigMigrated {
|
||||
t.Fatalf("Prepare() retry result = %+v, want marker-only recovery", result)
|
||||
}
|
||||
assertFileContent(t, target, current)
|
||||
assertFileContent(t, filepath.Join(root, managedConfigVersionFile), managedConfigVersion+"\n")
|
||||
}
|
||||
|
||||
func TestPreparePreservesAndRejectsCustomLegacyGeoIPConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
root := filepath.Join(tempDir, "data")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := "rule-providers:\n" + managedChinaIPProvider + "rules:\n" + managedChinaIPRule + "\n"
|
||||
custom := "rule-providers:\nrules:\n" + legacyChinaIPRule + "\n# user managed\n"
|
||||
target := writeFixture(t, root, "config.yaml", custom)
|
||||
|
||||
_, err := Prepare(Config{
|
||||
Root: root,
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: writeFixture(t, tempDir, "mihomo", "core"),
|
||||
ConfigSource: writeFixture(t, tempDir, "current.yaml", current),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "custom config uses GEOIP,CN") {
|
||||
t.Fatalf("Prepare() error = %v, want explicit custom config migration error", err)
|
||||
}
|
||||
assertFileContent(t, target, custom)
|
||||
if _, statErr := os.Stat(filepath.Join(root, managedConfigVersionFile)); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("managed config version marker unexpectedly created: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminPasswordFailsClosedOnFreshVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := EnsureAdminPassword(t.TempDir(), "unused", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "SSCLASH_PASSWORD") {
|
||||
t.Fatalf("EnsureAdminPassword() error = %v, want missing password error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminPasswordInitializesOnlyWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary := writeFixture(t, filepath.Join(root, "bin"), "ssclash", `#!/bin/sh
|
||||
set -eu
|
||||
[ "$1" = setpass ]
|
||||
[ "$2" = fresh-volume-password ]
|
||||
password="$(dirname "$0")/../.ssclash/password"
|
||||
printf 'pbkdf2$120000$0123456789abcdef0123456789abcdef$0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n' > "$password"
|
||||
chmod 0600 "$password"
|
||||
`)
|
||||
if err := os.Chmod(binary, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
initialized, err := EnsureAdminPassword(root, binary, "fresh-volume-password")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminPassword() error = %v", err)
|
||||
}
|
||||
if !initialized {
|
||||
t.Fatal("EnsureAdminPassword() initialized = false, want true")
|
||||
}
|
||||
assertFileContent(t, filepath.Join(root, ".ssclash", "password"), validAdminPasswordHash)
|
||||
|
||||
if err := os.Remove(binary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initialized, err = EnsureAdminPassword(root, binary, "replacement-password")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminPassword() existing password error = %v", err)
|
||||
}
|
||||
if initialized {
|
||||
t.Fatal("EnsureAdminPassword() replaced existing password")
|
||||
}
|
||||
assertFileContent(t, filepath.Join(root, ".ssclash", "password"), validAdminPasswordHash)
|
||||
}
|
||||
|
||||
func TestAdminPasswordConfiguredRejectsUnsafeFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
setup func(t *testing.T, path string)
|
||||
}{
|
||||
{name: "mode 000", setup: passwordFileSetup(validAdminPasswordHash, 0o000)},
|
||||
{name: "mode 0200", setup: passwordFileSetup(validAdminPasswordHash, 0o200)},
|
||||
{name: "mode 0400", setup: passwordFileSetup(validAdminPasswordHash, 0o400)},
|
||||
{name: "mode 0644", setup: passwordFileSetup(validAdminPasswordHash, 0o644)},
|
||||
{name: "empty", setup: passwordFileSetup("", 0o600)},
|
||||
{name: "invalid hash", setup: passwordFileSetup("pbkdf2$test\n", 0o600)},
|
||||
{name: "non-hex hash", setup: passwordFileSetup("pbkdf2$120000$zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz$0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n", 0o600)},
|
||||
{name: "directory", setup: func(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.Mkdir(path, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "symlink", setup: func(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
target := path + ".target"
|
||||
passwordFileSetup(validAdminPasswordHash, 0o600)(t, target)
|
||||
if err := os.Symlink(target, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "password")
|
||||
testCase.setup(t, path)
|
||||
if configured, err := adminPasswordConfigured(path); err == nil || configured {
|
||||
t.Fatalf("adminPasswordConfigured() = %t, %v; want false, error", configured, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordConfiguredRequiresOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "password")
|
||||
passwordFileSetup(validAdminPasswordHash, 0o600)(t, path)
|
||||
for _, owner := range []struct {
|
||||
name string
|
||||
uid uint32
|
||||
gid uint32
|
||||
}{
|
||||
{name: "UID", uid: uint32(os.Geteuid() + 1), gid: uint32(os.Getegid())},
|
||||
{name: "GID", uid: uint32(os.Geteuid()), gid: uint32(os.Getegid() + 1)},
|
||||
} {
|
||||
t.Run(owner.name, func(t *testing.T) {
|
||||
configured, err := adminPasswordConfiguredFor(path, owner.uid, owner.gid)
|
||||
if err == nil || configured {
|
||||
t.Fatalf("adminPasswordConfiguredFor() = %t, %v; want false, owner error", configured, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordConfiguredAcceptsSecureFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "password")
|
||||
passwordFileSetup(validAdminPasswordHash, 0o600)(t, path)
|
||||
configured, err := adminPasswordConfigured(path)
|
||||
if err != nil || !configured {
|
||||
t.Fatalf("adminPasswordConfigured() = %t, %v; want true, nil", configured, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRepairsManagedProviderSymlinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
root := filepath.Join(tempDir, "data")
|
||||
ssclashTemp := filepath.Join(tempDir, "tmp")
|
||||
config := Config{
|
||||
Root: root,
|
||||
SSClashTemp: ssclashTemp,
|
||||
CoreSource: writeFixture(t, tempDir, "mihomo", "core"),
|
||||
ConfigSource: writeFixture(t, tempDir, "config.yaml", "config"),
|
||||
}
|
||||
if _, err := Prepare(config); err != nil {
|
||||
t.Fatalf("first Prepare() error = %v", err)
|
||||
}
|
||||
for _, directory := range []string{"rule-providers", "proxy-providers"} {
|
||||
path := filepath.Join(root, directory)
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(ssclashTemp, directory), path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := Prepare(config); err != nil {
|
||||
t.Fatalf("second Prepare() error = %v", err)
|
||||
}
|
||||
for _, directory := range []string{"rule-providers", "proxy-providers"} {
|
||||
info, err := os.Lstat(filepath.Join(root, directory))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Errorf("%s mode = %s, want directory", directory, info.Mode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRejectsUnexpectedProviderSymlink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
root := filepath.Join(tempDir, "data")
|
||||
ssclashTemp := filepath.Join(tempDir, "tmp")
|
||||
config := Config{
|
||||
Root: root,
|
||||
SSClashTemp: ssclashTemp,
|
||||
CoreSource: writeFixture(t, tempDir, "mihomo", "core"),
|
||||
ConfigSource: writeFixture(t, tempDir, "config.yaml", "config"),
|
||||
}
|
||||
if _, err := Prepare(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "rule-providers")
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(tempDir, "unexpected"), path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := Prepare(config); err == nil || !strings.Contains(err.Error(), "unexpected symlink") {
|
||||
t.Fatalf("Prepare() error = %v, want unexpected symlink error", err)
|
||||
}
|
||||
target, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target != filepath.Join(tempDir, "unexpected") {
|
||||
t.Fatalf("unexpected symlink target = %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildEnvironmentRemovesCredentials(t *testing.T) {
|
||||
t.Setenv("SUBSCRIPTION_URL", "https://subscription.example.invalid/?token=secret")
|
||||
t.Setenv("SSCLASH_PASSWORD", "secret-password")
|
||||
|
||||
environment := strings.Join(childEnvironment(), "\n")
|
||||
for _, key := range []string{"SUBSCRIPTION_URL=", "SSCLASH_PASSWORD="} {
|
||||
if strings.Contains(environment, key) {
|
||||
t.Errorf("childEnvironment() retained %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -100,6 +411,7 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) {
|
||||
name: "filesystem root",
|
||||
config: Config{
|
||||
Root: "/",
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: coreSource,
|
||||
ConfigSource: configSource,
|
||||
},
|
||||
@@ -109,6 +421,7 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) {
|
||||
name: "missing core source",
|
||||
config: Config{
|
||||
Root: filepath.Join(tempDir, "missing-core"),
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: filepath.Join(tempDir, "does-not-exist"),
|
||||
ConfigSource: configSource,
|
||||
},
|
||||
@@ -118,6 +431,7 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) {
|
||||
name: "duplicate operating mode",
|
||||
config: Config{
|
||||
Root: filepath.Join(tempDir, "duplicate-mode"),
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: coreSource,
|
||||
ConfigSource: configSource,
|
||||
},
|
||||
@@ -134,6 +448,7 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) {
|
||||
name: "duplicate proxy mode",
|
||||
config: Config{
|
||||
Root: filepath.Join(tempDir, "duplicate-proxy-mode"),
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: coreSource,
|
||||
ConfigSource: configSource,
|
||||
},
|
||||
@@ -161,6 +476,241 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSubscriptionKeepsPreviousValidFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
response := "proxies:\n - name: valid\n"
|
||||
var responseLock sync.RWMutex
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
responseLock.RLock()
|
||||
defer responseLock.RUnlock()
|
||||
_, _ = writer.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
target := filepath.Join(t.TempDir(), "subscription.yaml")
|
||||
validate := func(path string) error {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(string(content), "invalid") {
|
||||
return errors.New("invalid provider")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := updateSubscription(context.Background(), server.Client(), server.URL, target, validate); err != nil {
|
||||
t.Fatalf("initial updateSubscription() error = %v", err)
|
||||
}
|
||||
responseLock.Lock()
|
||||
response = "invalid"
|
||||
responseLock.Unlock()
|
||||
if err := updateSubscription(context.Background(), server.Client(), server.URL, target, validate); err == nil {
|
||||
t.Fatal("updateSubscription() accepted invalid replacement")
|
||||
}
|
||||
assertFileContent(t, target, "proxies:\n - name: valid\n")
|
||||
}
|
||||
|
||||
func TestSubscriptionErrorsDoNotExposeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
secretURL := "https://subscription.example.invalid/feed?token=do-not-log"
|
||||
client := &http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New(request.URL.String())
|
||||
})}
|
||||
err := updateSubscription(context.Background(), client, secretURL, filepath.Join(t.TempDir(), "subscription.yaml"), func(string) error { return nil })
|
||||
if err == nil {
|
||||
t.Fatal("updateSubscription() error = nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), "do-not-log") || strings.Contains(err.Error(), secretURL) {
|
||||
t.Fatalf("updateSubscription() leaked subscription URL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAndReloadRestoresRuntimeAfterAmbiguousFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write([]byte("proxies:\n - name: updated\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
recover bool
|
||||
wantFatal bool
|
||||
}{
|
||||
{name: "rollback reload succeeds", recover: true},
|
||||
{name: "rollback reload fails", wantFatal: true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
target := writeFixture(t, t.TempDir(), "subscription.yaml", "proxies:\n - name: previous\n")
|
||||
var applied []string
|
||||
reload := func(context.Context) error {
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applied = append(applied, string(content))
|
||||
if len(applied) == 1 || !testCase.recover {
|
||||
return errors.New("connection lost after server applied provider")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := updateAndReload(context.Background(), server.Client(), RuntimeConfig{SubscriptionURL: server.URL}, target, func(string) error { return nil }, reload)
|
||||
if err == nil {
|
||||
t.Fatal("updateAndReload() error = nil")
|
||||
}
|
||||
if got := errors.Is(err, errMihomoStateUncertain); got != testCase.wantFatal {
|
||||
t.Fatalf("errors.Is(state uncertain) = %t, want %t: %v", got, testCase.wantFatal, err)
|
||||
}
|
||||
if len(applied) != 2 || !strings.Contains(applied[0], "updated") || !strings.Contains(applied[1], "previous") {
|
||||
t.Fatalf("reload sequence = %q, want updated then previous", applied)
|
||||
}
|
||||
assertFileContent(t, target, "proxies:\n - name: previous\n")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLeavesMihomoLifecycleToSSClash(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
root := filepath.Join(tempDir, "root")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
coreMarker := filepath.Join(tempDir, "core-started")
|
||||
ssclashMarker := filepath.Join(tempDir, "ssclash-started")
|
||||
t.Setenv("MIHOMO_TEST_MARKER", coreMarker)
|
||||
t.Setenv("SSCLASH_TEST_MARKER", ssclashMarker)
|
||||
core := writeFixture(t, tempDir, "fake-core", "#!/bin/sh\nif [ \"$1\" = -t ]; then exit 0; fi\ntouch \"$MIHOMO_TEST_MARKER\"\nexit 1\n")
|
||||
ssclash := writeFixture(t, tempDir, "fake-ssclash", "#!/bin/sh\n[ \"$1\" = serve ]\ntouch \"$SSCLASH_TEST_MARKER\"\nsleep 1\n")
|
||||
for _, binary := range []string{core, ssclash} {
|
||||
if err := os.Chmod(binary, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
config := writeFixture(t, root, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write([]byte("proxies:\n - name: initial\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runtimeDir := filepath.Join(tempDir, "runtime")
|
||||
err := Run(context.Background(), RuntimeConfig{
|
||||
Root: root,
|
||||
CoreBinary: core,
|
||||
SSClashBinary: ssclash,
|
||||
ConfigSource: config,
|
||||
RuntimeDir: runtimeDir,
|
||||
SubscriptionURL: server.URL,
|
||||
UpdateInterval: time.Hour,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "SSClash exited") {
|
||||
t.Fatalf("Run() error = %v, want SSClash exit", err)
|
||||
}
|
||||
if _, err := os.Stat(ssclashMarker); err != nil {
|
||||
t.Fatalf("SSClash was not started: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(coreMarker); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("bootstrap started Mihomo outside SSClash: %v", err)
|
||||
}
|
||||
linkTarget, err := os.Readlink(filepath.Join(root, "subscription.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read subscription link: %v", err)
|
||||
}
|
||||
if want := filepath.Join(runtimeDir, "subscription.yaml"); linkTarget != want {
|
||||
t.Fatalf("subscription link = %q, want %q", linkTarget, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsSSClashWhenControllerCannotConfirmRollback(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
root := filepath.Join(tempDir, "root")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core := writeFixture(t, tempDir, "fake-core", "#!/bin/sh\n[ \"$1\" = -t ]\n")
|
||||
ssclash := writeFixture(t, tempDir, "fake-ssclash", "#!/bin/sh\n[ \"$1\" = serve ]\nexec sleep 3600\n")
|
||||
for _, binary := range []string{core, ssclash} {
|
||||
if err := os.Chmod(binary, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
config := writeFixture(t, root, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n")
|
||||
|
||||
var subscriptionRequests atomic.Int32
|
||||
subscription := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
name := "updated"
|
||||
if subscriptionRequests.Add(1) == 1 {
|
||||
name = "initial"
|
||||
}
|
||||
_, _ = writer.Write([]byte("proxies:\n - name: " + name + "\n"))
|
||||
}))
|
||||
defer subscription.Close()
|
||||
var reloadRequests atomic.Int32
|
||||
controller := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch {
|
||||
case request.Method == http.MethodGet && request.URL.Path == "/version":
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
case request.Method == http.MethodPut && request.URL.Path == "/providers/proxies/subscription":
|
||||
reloadRequests.Add(1)
|
||||
http.Error(writer, "reload failed", http.StatusInternalServerError)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer controller.Close()
|
||||
|
||||
runResult := make(chan error, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
runResult <- Run(ctx, RuntimeConfig{
|
||||
Root: root,
|
||||
CoreBinary: core,
|
||||
SSClashBinary: ssclash,
|
||||
ConfigSource: config,
|
||||
RuntimeDir: filepath.Join(tempDir, "runtime"),
|
||||
SubscriptionURL: subscription.URL,
|
||||
ControllerURL: controller.URL,
|
||||
UpdateInterval: 20 * time.Millisecond,
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-runResult:
|
||||
if !errors.Is(err, errMihomoStateUncertain) {
|
||||
t.Fatalf("Run() error = %v, want uncertain Mihomo state", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run() did not stop SSClash after rollback reload failure")
|
||||
}
|
||||
if subscriptionRequests.Load() < 2 || reloadRequests.Load() != 2 {
|
||||
t.Fatalf("requests = subscription:%d reload:%d, want at least 2 and exactly 2", subscriptionRequests.Load(), reloadRequests.Load())
|
||||
}
|
||||
assertFileContent(t, filepath.Join(tempDir, "runtime", "subscription.yaml"), "proxies:\n - name: initial\n")
|
||||
}
|
||||
|
||||
func TestValidateSubscriptionURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, raw := range []string{"", "relative/path", "ftp://example.com/feed", "https://user@example.com/feed"} {
|
||||
if err := validateSubscriptionURL(raw); err == nil {
|
||||
t.Errorf("validateSubscriptionURL(%q) error = nil", raw)
|
||||
}
|
||||
}
|
||||
if err := validateSubscriptionURL("https://example.com/feed"); err != nil {
|
||||
t.Fatalf("validateSubscriptionURL() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (function roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return function(request)
|
||||
}
|
||||
|
||||
func writeFixture(t *testing.T, directory, name, content string) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
@@ -173,6 +723,20 @@ func writeFixture(t *testing.T, directory, name, content string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
const validAdminPasswordHash = "pbkdf2$120000$0123456789abcdef0123456789abcdef$0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n"
|
||||
|
||||
func passwordFileSetup(content string, mode os.FileMode) func(t *testing.T, path string) {
|
||||
return func(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContent(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const maxSecretSize = 4096
|
||||
|
||||
type CandidateConfig struct {
|
||||
SecretPath string
|
||||
DataDir string
|
||||
TemplatePath string
|
||||
MihomoBinary string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// PublishCandidate performs one Stage 1 update. Starting or reloading Mihomo is
|
||||
// deliberately left to the lifecycle stage.
|
||||
func PublishCandidate(ctx context.Context, config CandidateConfig) error {
|
||||
dataDir := filepath.Clean(config.DataDir)
|
||||
if !filepath.IsAbs(dataDir) || dataDir == string(filepath.Separator) {
|
||||
return fmt.Errorf("unsafe data directory %q", config.DataDir)
|
||||
}
|
||||
if err := ensureDirectory(dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
generations := filepath.Join(dataDir, "generations")
|
||||
if err := ensureDirectory(generations); err != nil {
|
||||
return 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 fmt.Errorf("create candidate generation: %w", err)
|
||||
}
|
||||
if err := os.Chmod(candidate, 0o700); err != nil {
|
||||
_ = os.RemoveAll(candidate)
|
||||
return fmt.Errorf("secure candidate generation: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(candidate)
|
||||
|
||||
configPath := filepath.Join(candidate, "config.yaml")
|
||||
if err := writePrivateFile(configPath, generated); err != nil {
|
||||
return fmt.Errorf("write candidate config: %w", err)
|
||||
}
|
||||
if err := writePrivateFile(filepath.Join(candidate, "subscription.yaml"), subscription); err != nil {
|
||||
return fmt.Errorf("write candidate subscription: %w", 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 fmt.Errorf("clear inactive generation: %w", err)
|
||||
}
|
||||
if err := os.Rename(candidate, slot); err != nil {
|
||||
return fmt.Errorf("publish candidate generation: %w", err)
|
||||
}
|
||||
if err := syncDirectory(generations); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replaceSymlink(filepath.Join(dataDir, "last-good"), next); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncDirectory(dataDir)
|
||||
}
|
||||
|
||||
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 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,225 @@
|
||||
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
|
||||
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: "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.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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -37,3 +39,62 @@ func TestSeededConfigExposesOnlyServerListeners(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeededConfigUsesLocalACL4SSRRulesAndMemorySubscription(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content, err := os.ReadFile("../../config/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("read seeded config: %v", err)
|
||||
}
|
||||
config := string(content)
|
||||
for _, required := range []string{
|
||||
"path: ./subscription.yaml",
|
||||
"RULE-SET,LocalAreaNetwork,🎯 全球直连",
|
||||
"RULE-SET,BanAD,🛑 广告拦截",
|
||||
"RULE-SET,ProxyGFWlist,🚀 节点选择",
|
||||
"MATCH,🐟 漏网之鱼",
|
||||
"GoogleCN: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/GoogleCN.yaml}",
|
||||
"Bing: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Bing.yaml}",
|
||||
"OneDrive: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/OneDrive.yaml}",
|
||||
"Microsoft: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Microsoft.yaml}",
|
||||
"Telegram: {type: file, behavior: classical, format: yaml, path: /usr/local/share/ssclash/rules/Ruleset/Telegram.yaml}",
|
||||
"ChinaCompanyIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaCompanyIp.yaml}",
|
||||
"ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}",
|
||||
"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)), "ba556936c447692164e6d7eabec13c1a83ace8014b723b4b20d6e3648ae49d54"; 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",
|
||||
} {
|
||||
if !strings.Contains(string(dockerfile), pin) {
|
||||
t.Errorf("Dockerfile is missing pinned asset %q", pin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -4,6 +4,8 @@ set -eu
|
||||
project_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$project_root"
|
||||
|
||||
./tests/workflow-contract.sh
|
||||
|
||||
unformatted=$(gofmt -l cmd internal)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "Go files require formatting:" >&2
|
||||
@@ -22,5 +24,5 @@ echo "unit test coverage: ${coverage}%"
|
||||
go test ./...
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
docker compose config --quiet
|
||||
SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo docker compose config --quiet
|
||||
fi
|
||||
|
||||
+302
-86
@@ -4,118 +4,334 @@ set -eu
|
||||
image=${1:-mohomo-docker:smoke}
|
||||
suffix="$$"
|
||||
container="mohomo-docker-smoke-${suffix}"
|
||||
volume="mohomo-docker-smoke-${suffix}"
|
||||
cookie=""
|
||||
login_html=""
|
||||
config_html=""
|
||||
unconfigured="mohomo-docker-unconfigured-${suffix}"
|
||||
legacy_container="mohomo-docker-legacy-${suffix}"
|
||||
provider="mohomo-provider-smoke-${suffix}"
|
||||
network="mohomo-network-smoke-${suffix}"
|
||||
legacy_network="mohomo-legacy-network-smoke-${suffix}"
|
||||
volume="mohomo-volume-smoke-${suffix}"
|
||||
legacy_volume="mohomo-legacy-volume-smoke-${suffix}"
|
||||
candidate_volume="mohomo-candidate-volume-smoke-${suffix}"
|
||||
candidate_secret_file=$(mktemp "${TMPDIR:-/tmp}/mohomo-candidate-secret.XXXXXX")
|
||||
secret="container-smoke-secret"
|
||||
admin_password="container-smoke-admin-password"
|
||||
|
||||
case "$container:$volume" in
|
||||
mohomo-docker-smoke-*':mohomo-docker-smoke-'*) ;;
|
||||
*) echo "refusing unsafe cleanup targets" >&2; exit 1 ;;
|
||||
case "$container:$unconfigured:$legacy_container:$provider:$network:$legacy_network:$volume:$legacy_volume:$candidate_volume" in
|
||||
mohomo-docker-smoke-*':mohomo-docker-unconfigured-'*':mohomo-docker-legacy-'*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-legacy-network-smoke-'*':mohomo-volume-smoke-'*':mohomo-legacy-volume-smoke-'*':mohomo-candidate-volume-smoke-'*) ;;
|
||||
*) 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" "$unconfigured" "$legacy_container" "$provider" >/dev/null 2>&1 || true
|
||||
docker volume rm "$volume" "$legacy_volume" "$candidate_volume" >/dev/null 2>&1 || true
|
||||
docker network rm "$network" "$legacy_network" >/dev/null 2>&1 || true
|
||||
rm -f "$candidate_secret_file"
|
||||
}
|
||||
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() {
|
||||
health_container=${1:-$container}
|
||||
attempt=0
|
||||
until [ "$(docker inspect --format '{{.State.Health.Status}}' "$health_container")" = healthy ]; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 30 ]; then
|
||||
docker logs "$health_container" >&2
|
||||
echo "container did not become healthy" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
docker volume create "$volume" >/dev/null
|
||||
wait_for_subscription() {
|
||||
expected=$1
|
||||
attempt=0
|
||||
until docker exec "$provider" wget -qO- http://127.0.0.1:8080/provider.yaml | grep -F "$expected" >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 10 ]; then
|
||||
echo "subscription fixture did not serve expected content" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
assert_published_ports() {
|
||||
published=$(docker port "$container")
|
||||
for port in 7890/tcp 7890/udp 9091/tcp; do
|
||||
printf '%s\n' "$published" | grep -F "$port ->" >/dev/null
|
||||
done
|
||||
if printf '%s\n' "$published" | grep -vE '^(7890/(tcp|udp)|9091/tcp)' >/dev/null; then
|
||||
echo "container published a port other than 7890 or 9091" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_web_login_and_start() {
|
||||
web_port=$1
|
||||
docker run --rm --network host \
|
||||
--env "WEB_PORT=${web_port}" \
|
||||
--env "ADMIN_PASSWORD=${admin_password}" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image" -c '
|
||||
set -eu
|
||||
cookie=$(mktemp)
|
||||
setup_redirect=$(curl --silent --show-error --output /dev/null \
|
||||
--write-out "%{http_code} %{redirect_url}" \
|
||||
"http://127.0.0.1:${WEB_PORT}/setup")
|
||||
if [ "$setup_redirect" != "303 http://127.0.0.1:${WEB_PORT}/login" ]; then
|
||||
echo "configured Web UI exposed setup: ${setup_redirect}" >&2
|
||||
exit 1
|
||||
fi
|
||||
login_html=$(curl --fail --silent --show-error --cookie-jar "$cookie" \
|
||||
"http://127.0.0.1:${WEB_PORT}/login")
|
||||
login_csrf=$(printf "%s" "$login_html" | sed -n "s/.*name=\"csrf\" value=\"\([^\"]*\)\".*/\1/p" | head -1)
|
||||
test -n "$login_csrf"
|
||||
curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
--cookie-jar "$cookie" \
|
||||
--request POST \
|
||||
--data-urlencode "csrf=${login_csrf}" \
|
||||
--data-urlencode "password=${ADMIN_PASSWORD}" \
|
||||
"http://127.0.0.1:${WEB_PORT}/login" >/dev/null
|
||||
config_html=$(curl --fail --silent --show-error --cookie "$cookie" \
|
||||
"http://127.0.0.1:${WEB_PORT}/config")
|
||||
api_csrf=$(printf "%s" "$config_html" | sed -n "s/.*name=\"csrf-token\" content=\"\([^\"]*\)\".*/\1/p" | head -1)
|
||||
test -n "$api_csrf"
|
||||
curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
--header "X-CSRF-Token: ${api_csrf}" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "{\"action\":\"start\"}" \
|
||||
"http://127.0.0.1:${WEB_PORT}/api/service" | grep -F "\"ok\":true" >/dev/null
|
||||
curl --fail --silent --show-error --cookie "$cookie" \
|
||||
"http://127.0.0.1:${WEB_PORT}/api/status" | grep -F "\"running\":true" >/dev/null
|
||||
'
|
||||
}
|
||||
|
||||
default_compose=$(SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo docker compose config)
|
||||
loopback_bindings=$(printf '%s\n' "$default_compose" | awk '$1 == "host_ip:" && $2 == "127.0.0.1" { count++ } END { print count + 0 }')
|
||||
if [ "$loopback_bindings" -ne 3 ]; then
|
||||
echo "Compose must bind 7890/tcp, 7890/udp, and 9091/tcp to host loopback by default" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
public_proxy_compose=$(SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo \
|
||||
PROXY_BIND=0.0.0.0 WEB_BIND=0.0.0.0 docker compose config)
|
||||
public_bindings=$(printf '%s\n' "$public_proxy_compose" | awk '$1 == "host_ip:" && $2 == "0.0.0.0" { count++ } END { print count + 0 }')
|
||||
loopback_bindings=$(printf '%s\n' "$public_proxy_compose" | awk '$1 == "host_ip:" && $2 == "127.0.0.1" { count++ } END { print count + 0 }')
|
||||
if [ "$public_bindings" -ne 2 ] || [ "$loopback_bindings" -ne 1 ]; then
|
||||
echo "public opt-in must affect only 7890; 9091 must remain on host loopback" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker build --tag "$image" .
|
||||
docker run --rm --network none --entrypoint /bin/sh "$image" -c '
|
||||
set -eu
|
||||
runtime=$(mktemp -d)
|
||||
cp /usr/local/share/ssclash/config.yaml "$runtime/config.yaml"
|
||||
printf "proxies:\n - name: smoke-node\n type: socks5\n server: 127.0.0.1\n port: 9\n" > "$runtime/subscription.yaml"
|
||||
/usr/local/lib/ssclash/clash -t -d "$runtime" -f "$runtime/config.yaml"
|
||||
' >/dev/null
|
||||
|
||||
docker network create "$network" >/dev/null
|
||||
docker network create --internal "$legacy_network" >/dev/null
|
||||
docker run --detach --rm \
|
||||
--name "$provider" \
|
||||
--network "$network" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image" -c 'while :; do
|
||||
if [ -f /tmp/invalid-subscription ]; then
|
||||
printf "HTTP/1.1 200 OK\r\nContent-Type: text/yaml\r\nConnection: close\r\n\r\nproxies: ["
|
||||
else
|
||||
printf "HTTP/1.1 200 OK\r\nContent-Type: text/yaml\r\nConnection: close\r\n\r\nproxies:\n - name: smoke-node\n type: socks5\n server: 127.0.0.1\n port: 9\n"
|
||||
fi | nc -l -p 8080
|
||||
done' >/dev/null
|
||||
docker network connect "$legacy_network" "$provider"
|
||||
wait_for_subscription 'name: smoke-node'
|
||||
|
||||
printf 'http://%s:8080/provider.yaml?token=%s\n' "$provider" "$secret" > "$candidate_secret_file"
|
||||
chmod 0444 "$candidate_secret_file"
|
||||
docker volume create "$candidate_volume" >/dev/null
|
||||
docker run --rm \
|
||||
--network "$network" \
|
||||
--volume "$candidate_volume:/data" \
|
||||
--mount "type=bind,source=${candidate_secret_file},target=/run/secrets/subscription,readonly" \
|
||||
"$image" candidate >/dev/null
|
||||
last_good=$(docker run --rm \
|
||||
--volume "$candidate_volume:/data" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image" -c 'test -w /data; test -L /data/last-good; grep -F "name: smoke-node" /data/last-good/subscription.yaml >/dev/null; readlink /data/last-good')
|
||||
docker exec "$provider" touch /tmp/invalid-subscription
|
||||
wait_for_subscription 'proxies: ['
|
||||
if failure=$(docker run --rm \
|
||||
--network "$network" \
|
||||
--volume "$candidate_volume:/data" \
|
||||
--mount "type=bind,source=${candidate_secret_file},target=/run/secrets/subscription,readonly" \
|
||||
"$image" candidate 2>&1); then
|
||||
echo "candidate accepted invalid YAML" >&2
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s\n' "$failure" | grep -F "$secret" >/dev/null; then
|
||||
echo "candidate failure leaked subscription secret" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker run --rm \
|
||||
--volume "$candidate_volume:/data" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image" -c "test \"\$(readlink /data/last-good)\" = '$last_good'; grep -F 'name: smoke-node' /data/last-good/subscription.yaml >/dev/null"
|
||||
docker exec "$provider" rm /tmp/invalid-subscription
|
||||
wait_for_subscription 'name: smoke-node'
|
||||
|
||||
docker volume create "$legacy_volume" >/dev/null
|
||||
docker run --rm \
|
||||
--volume "$legacy_volume:/opt/clash" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image" -c '
|
||||
set -eu
|
||||
grep -v -F " ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}" \
|
||||
/usr/local/share/ssclash/config.yaml \
|
||||
| sed "s/^ - RULE-SET,ChinaIp,/ - GEOIP,CN,/" \
|
||||
> /opt/clash/config.yaml
|
||||
grep -F " - GEOIP,CN,🎯 全球直连" /opt/clash/config.yaml >/dev/null
|
||||
'
|
||||
docker run --detach \
|
||||
--name "$container" \
|
||||
--name "$legacy_container" \
|
||||
--network "$legacy_network" \
|
||||
--env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml" \
|
||||
--env "SSCLASH_PASSWORD=${admin_password}" \
|
||||
--volume "$legacy_volume:/opt/clash" \
|
||||
"$image" >/dev/null
|
||||
wait_for_health "$legacy_container"
|
||||
docker logs "$legacy_container" 2>&1 | grep -F 'bootstrap: SSClash started' >/dev/null
|
||||
if docker logs "$legacy_container" 2>&1 | grep -F 'geoip.metadb' >/dev/null; then
|
||||
echo "legacy managed config attempted a GeoIP download" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker exec "$legacy_container" /bin/sh -c '
|
||||
set -eu
|
||||
cmp /opt/clash/config.yaml /usr/local/share/ssclash/config.yaml
|
||||
test "$(cat /opt/clash/.mohomo-docker-config-version)" = 1
|
||||
grep -F "name: smoke-node" /dev/shm/mohomo/subscription.yaml >/dev/null
|
||||
/usr/local/lib/ssclash/clash -t -d /dev/shm/mohomo -f /dev/shm/mohomo/config.yaml
|
||||
' >/dev/null
|
||||
docker container rm --force "$legacy_container" >/dev/null
|
||||
docker volume create "$volume" >/dev/null
|
||||
|
||||
docker run --detach \
|
||||
--name "$unconfigured" \
|
||||
--network "$network" \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges:true \
|
||||
--env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml" \
|
||||
--volume "$volume:/opt/clash" \
|
||||
--publish 127.0.0.1::9091/tcp \
|
||||
--publish 127.0.0.1::7890/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"
|
||||
|
||||
unconfigured_port=$(docker port "$unconfigured" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||
attempt=0
|
||||
until curl --fail --silent --show-error "http://127.0.0.1:${web_port}/" >/dev/null; do
|
||||
while [ "$(docker inspect --format '{{.State.Running}}' "$unconfigured")" = true ]; do
|
||||
if curl --fail --silent --show-error --max-time 1 \
|
||||
"http://127.0.0.1:${unconfigured_port}/setup" >/dev/null 2>&1; then
|
||||
echo "fresh volume exposed anonymous setup" >&2
|
||||
exit 1
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 30 ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "web UI did not become ready" >&2
|
||||
if [ "$attempt" -ge 10 ]; then
|
||||
echo "fresh volume did not fail closed without SSCLASH_PASSWORD" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$(docker inspect --format '{{.State.ExitCode}}' "$unconfigured")" -eq 0 ]; then
|
||||
echo "fresh volume exited successfully without SSCLASH_PASSWORD" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker logs "$unconfigured" 2>&1 | grep -F 'SSCLASH_PASSWORD is required' >/dev/null
|
||||
if docker logs "$unconfigured" 2>&1 | grep -F 'web UI listening' >/dev/null; then
|
||||
echo "fresh volume started the Web UI before authentication was configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker container rm "$unconfigured" >/dev/null
|
||||
|
||||
docker run --detach \
|
||||
--name "$container" \
|
||||
--network "$network" \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges:true \
|
||||
--env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml?token=${secret}" \
|
||||
--env "SSCLASH_PASSWORD=${admin_password}" \
|
||||
--volume "$volume:/opt/clash" \
|
||||
--publish 127.0.0.1::7890/tcp \
|
||||
--publish 127.0.0.1::7890/udp \
|
||||
--publish 127.0.0.1::9091/tcp \
|
||||
"$image" >/dev/null
|
||||
|
||||
wait_for_health
|
||||
assert_published_ports
|
||||
web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||
assert_web_login_and_start "$web_port"
|
||||
|
||||
host_gateway=$(docker network inspect "$network" --format '{{(index .IPAM.Config 0).Gateway}}')
|
||||
container_ip=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container")
|
||||
proxy_port=$(docker port "$container" 7890/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||
if curl --fail --silent --show-error --max-time 2 --noproxy "" \
|
||||
--proxy "http://${host_gateway}:${proxy_port}" \
|
||||
"http://${container_ip}:9090/version" >/dev/null 2>&1; then
|
||||
echo "default 7890 publish was reachable through a non-loopback host address" >&2
|
||||
exit 1
|
||||
fi
|
||||
if curl --fail --silent --show-error --max-time 2 \
|
||||
"http://${host_gateway}:${web_port}/login" >/dev/null 2>&1; then
|
||||
echo "default 9091 publish was reachable through a non-loopback host address" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker exec "$container" grep -Fx 'OPERATING_MODE=server' /opt/clash/.ssclash/settings >/dev/null
|
||||
docker exec "$container" grep -Fx 'PROXY_MODE=none' /opt/clash/.ssclash/settings >/dev/null
|
||||
docker exec "$container" grep -Fx 'mixed-port: 7890' /opt/clash/config.yaml >/dev/null
|
||||
docker exec "$container" test -s /dev/shm/mohomo/subscription.yaml
|
||||
docker exec "$container" /usr/local/lib/ssclash/clash \
|
||||
-t -d /dev/shm/mohomo -f /dev/shm/mohomo/config.yaml >/dev/null
|
||||
docker exec "$container" curl --fail --silent --show-error \
|
||||
http://127.0.0.1:9090/version >/dev/null
|
||||
|
||||
docker exec "$container" /usr/local/bin/ssclash setpass container-smoke-only >/dev/null
|
||||
cookie=$(mktemp)
|
||||
login_html=$(mktemp)
|
||||
config_html=$(mktemp)
|
||||
curl --fail --silent --show-error --cookie-jar "$cookie" \
|
||||
"http://127.0.0.1:${web_port}/login" > "$login_html"
|
||||
login_csrf=$(sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p' "$login_html" | head -1)
|
||||
test -n "$login_csrf"
|
||||
curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
--cookie-jar "$cookie" \
|
||||
--request POST \
|
||||
--data-urlencode "csrf=${login_csrf}" \
|
||||
--data-urlencode 'password=container-smoke-only' \
|
||||
"http://127.0.0.1:${web_port}/login" >/dev/null
|
||||
curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
"http://127.0.0.1:${web_port}/config" > "$config_html"
|
||||
api_csrf=$(sed -n 's/.*name="csrf-token" content="\([^"]*\)".*/\1/p' "$config_html" | head -1)
|
||||
test -n "$api_csrf"
|
||||
|
||||
start_response=$(curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
--header "X-CSRF-Token: ${api_csrf}" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"action":"start"}' \
|
||||
"http://127.0.0.1:${web_port}/api/service")
|
||||
printf '%s' "$start_response" | grep -F '"ok":true' >/dev/null
|
||||
|
||||
status_response=$(curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
--header "X-CSRF-Token: ${api_csrf}" \
|
||||
"http://127.0.0.1:${web_port}/api/status")
|
||||
printf '%s' "$status_response" | grep -F '"running":true' >/dev/null
|
||||
printf '%s' "$status_response" | grep -F '"operatingMode":"server"' >/dev/null
|
||||
|
||||
attempt=0
|
||||
until curl --fail --silent --show-error \
|
||||
--proxy "http://127.0.0.1:${proxy_port}" \
|
||||
--max-time 10 \
|
||||
https://example.com/ >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 20 ]; then
|
||||
docker logs "$container" >&2
|
||||
echo "mixed proxy did not become ready" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if docker exec "$container" grep -Eq '^(tproxy-port|redir-port|tun):' /opt/clash/config.yaml; then
|
||||
docker logs "$container" >&2
|
||||
echo "gateway listener leaked into server-only config" >&2
|
||||
if docker exec "$container" grep -R -F "$secret" /opt/clash /dev/shm/mohomo >/dev/null 2>&1; then
|
||||
echo "subscription URL credential was written to runtime files" >&2
|
||||
exit 1
|
||||
fi
|
||||
if docker logs "$container" 2>&1 | grep -Ei '\[(error|fatal)\]|operation not permitted' >/dev/null; then
|
||||
docker logs "$container" >&2
|
||||
echo "container emitted an error during Web-managed startup" >&2
|
||||
if docker logs "$container" 2>&1 | grep -F "$secret" >/dev/null; then
|
||||
echo "subscription URL credential was written to logs" >&2
|
||||
exit 1
|
||||
fi
|
||||
if docker exec "$container" grep -R -F "$admin_password" /opt/clash /dev/shm/mohomo >/dev/null 2>&1; then
|
||||
echo "administrator password was written to runtime files" >&2
|
||||
exit 1
|
||||
fi
|
||||
if docker logs "$container" 2>&1 | grep -F "$admin_password" >/dev/null; then
|
||||
echo "administrator password was written to logs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "container smoke test passed: web_port=${web_port} proxy_port=${proxy_port}"
|
||||
docker container rm --force "$container" >/dev/null
|
||||
docker run --rm \
|
||||
--volume "$volume:/opt/clash" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image" -c 'test -L /opt/clash/rule-providers && test ! -e /opt/clash/rule-providers && test "$(readlink /opt/clash/rule-providers)" = /tmp/ssclash/rule-providers'
|
||||
|
||||
docker run --detach \
|
||||
--name "$container" \
|
||||
--network "$network" \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges:true \
|
||||
--env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml?token=${secret}" \
|
||||
--volume "$volume:/opt/clash" \
|
||||
--publish 127.0.0.1::7890/tcp \
|
||||
--publish 127.0.0.1::7890/udp \
|
||||
--publish 127.0.0.1::9091/tcp \
|
||||
"$image" >/dev/null
|
||||
|
||||
wait_for_health
|
||||
assert_published_ports
|
||||
web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||
assert_web_login_and_start "$web_port"
|
||||
|
||||
echo "container smoke test passed: fresh candidate volume publishes and rolls back invalid YAML; legacy config migrates; fresh SSClash volume fails closed; authenticated 9091 survives same-volume rebuild; only 7890/9091 are published"
|
||||
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
workflow=.github/workflows/docker.yml
|
||||
test -f "$workflow" || {
|
||||
echo "missing GHCR workflow: $workflow" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
grep -F 'packages: write' "$workflow" >/dev/null
|
||||
# Match the GitHub expression literally.
|
||||
# shellcheck disable=SC2016
|
||||
grep -F 'ghcr.io/${{ github.repository }}' "$workflow" >/dev/null
|
||||
grep -F 'platforms: linux/amd64' "$workflow" >/dev/null
|
||||
grep -F 'needs: test' "$workflow" >/dev/null
|
||||
grep -F 'run: ./tests/container-smoke.sh' "$workflow" >/dev/null
|
||||
grep -F 'cache-to: type=gha,mode=max,ignore-error=true' "$workflow" >/dev/null
|
||||
# Match the GitHub expression literally.
|
||||
# shellcheck disable=SC2016
|
||||
grep -F 'push: ${{ github.event_name != '\''pull_request'\'' }}' "$workflow" >/dev/null
|
||||
|
||||
if grep -Ei 'arm64|setup-qemu' "$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")
|
||||
if [ "$uses_count" -eq 0 ] || [ "$uses_count" -ne "$pinned_count" ]; then
|
||||
echo "every GitHub Action must be pinned to a full commit SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user