commit 09ddf8f78d3f0d99af5c2044efce97f83046a393 Author: Rogee Date: Fri Aug 21 13:30:53 2026 +0800 feat: package SSClash server mode for Docker diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..567085a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.env +coverage.out +tests +README.md +AGENTS.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..791f627 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +IMAGE_NAME=mohomo-docker:local +CONTAINER_NAME=mohomo-docker +WEB_BIND=0.0.0.0 +WEB_PORT=9091 +PROXY_BIND=0.0.0.0 +PROXY_PORT=7890 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdcd106 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +coverage.out diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5ced327 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Project instructions + +## Product boundary + +- Package the official SSClash-Go release binary with an official Mihomo core. +- Run SSClash in `server` mode only: embedded Web UI plus Mihomo mixed proxy on port 7890. +- Do not add transparent gateway, TUN, firewall, policy-routing, or DNS-hijack behavior. +- Keep Mihomo's controller private to the container; never publish port 9090. + +## Engineering rules + +- Pin release versions and verify every downloaded artifact with SHA-256. +- Preserve user-managed files in `/opt/clash`; initialization may only create missing files. +- Fail explicitly on corrupt or ambiguous persistent state. +- Add tests before behavior changes and keep Go unit coverage at or above 65%. +- Run `./scripts/test.sh` and `./tests/container-smoke.sh` before publishing. +- Keep startup logs sufficient to identify initialization, selected mode, and executed command. + +## Licensing + +- Do not commit SSClash or Mihomo binaries to this repository. +- 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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac9086b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# syntax=docker/dockerfile:1.7 + +ARG GO_VERSION=1.24 +ARG ALPINE_VERSION=3.22 + +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS bootstrap-builder +ARG TARGETOS +ARG TARGETARCH +WORKDIR /src +COPY go.mod ./ +COPY cmd ./cmd +COPY internal ./internal +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags='-s -w' -o /out/bootstrap ./cmd/bootstrap + +FROM alpine:${ALPINE_VERSION} AS release-assets +ARG TARGETARCH +ARG SSCLASH_VERSION=v6.1.0 +ARG MIHOMO_VERSION=v1.19.30 +ARG MIHOMO_SHA256_AMD64=cf06ce2c7d1421bdbda14ee4a5b6046672dc35ebf8eecd8e77504ec3c0ed9a84 +ARG MIHOMO_SHA256_ARM64=58896873736d28628f66de3677c8654fa0f180662523148e136cff4f6e890069 +WORKDIR /assets +RUN apk add --no-cache ca-certificates curl gzip +RUN case "${TARGETARCH}" in \ + amd64|arm64) ;; \ + *) echo "unsupported TARGETARCH=${TARGETARCH}; supported: amd64, arm64" >&2; exit 1 ;; \ + esac; \ + curl --fail --show-error --silent --location --retry 3 \ + --output sha256sums.txt \ + "https://github.com/zerolabnet/SSClash-Go/releases/download/${SSCLASH_VERSION}/sha256sums.txt"; \ + curl --fail --show-error --silent --location --retry 3 \ + --output ssclash \ + "https://github.com/zerolabnet/SSClash-Go/releases/download/${SSCLASH_VERSION}/ssclash-linux-${TARGETARCH}"; \ + expected="$(awk -v asset="ssclash-linux-${TARGETARCH}" '$2 == asset { print $1 }' sha256sums.txt)"; \ + test -n "${expected}"; \ + printf '%s %s\n' "${expected}" ssclash | sha256sum -c -; \ + chmod 0755 ssclash +RUN case "${TARGETARCH}" in \ + amd64) mihomo_sha256="${MIHOMO_SHA256_AMD64}" ;; \ + arm64) mihomo_sha256="${MIHOMO_SHA256_ARM64}" ;; \ + esac; \ + asset="mihomo-linux-${TARGETARCH}-${MIHOMO_VERSION}.gz"; \ + curl --fail --show-error --silent --location --retry 3 \ + --output mihomo.gz \ + "https://github.com/MetaCubeX/mihomo/releases/download/${MIHOMO_VERSION}/${asset}"; \ + printf '%s %s\n' "${mihomo_sha256}" mihomo.gz | sha256sum -c -; \ + gzip -d mihomo.gz; \ + chmod 0755 mihomo + +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 +COPY --from=bootstrap-builder /out/bootstrap /usr/local/bin/bootstrap +COPY --from=release-assets /assets/ssclash /usr/local/bin/ssclash +COPY --from=release-assets /assets/mihomo /usr/local/lib/ssclash/clash +COPY config/config.yaml /usr/local/share/ssclash/config.yaml + +ENV SSCLASH_ROOT=/opt/clash \ + SSCLASH_TMP=/tmp/ssclash \ + SSCLASH_PLATFORM=linux \ + SSCLASH_ADDR=:9091 + +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 +ENTRYPOINT ["/usr/local/bin/bootstrap"] +CMD ["serve"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b37e5f --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +MIT License + +Copyright (c) 2026 rogee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This license covers only the original files in this repository. Downloaded +SSClash-Go and Mihomo binaries remain subject to their respective licenses. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..859721b --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: test build smoke up down logs + +test: + ./scripts/test.sh + +build: + docker compose build + +smoke: + ./tests/container-smoke.sh + +up: + docker compose up -d --build + +down: + docker compose down + +logs: + docker compose logs -f ssclash diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c024c6 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# 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. + +## Quick start + +```sh +cp .env.example .env +docker compose up -d --build +docker compose logs -f ssclash +``` + +Open `http://:9091`, create the administrator password, review `config.yaml`, and start the proxy from the Web UI. The seeded configuration exposes a direct-only `PROXY` group so port `7890` can be tested before adding a subscription. + +Configure clients with either of these endpoints: + +```text +HTTP proxy: http://:7890 +SOCKS5 proxy: socks5://:7890 +``` + +The Web UI and proxy listen on all host interfaces by default. Change `WEB_BIND` or `PROXY_BIND` in `.env` to restrict them. Do not expose the Web UI to the Internet without HTTPS and an additional access-control layer. Configure Mihomo proxy authentication before exposing port `7890` outside a trusted network. + +## Persistent data + +The named volume `ssclash-data` is mounted at `/opt/clash` and stores: + +- `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. + +The bootstrap process creates missing files only. Existing Mihomo and configuration files are preserved, while `OPERATING_MODE` is explicitly enforced as `server`. Duplicate mode entries or empty runtime files cause startup to fail with a diagnostic message. + +Resetting the volume deletes configuration and credentials. Inspect the exact Compose project and volume name before doing so. + +## Version updates + +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 + +```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, and sends an HTTPS request through the mapped mixed proxy port. + +## License boundary + +This repository contains only original Docker packaging and bootstrap code. It downloads SSClash-Go and Mihomo from their official releases while building. + +SSClash-Go uses a proprietary binary license that permits personal/internal use but prohibits redistributing its binary to third parties. Do not publish the resulting image without the copyright holder's permission. Mihomo is separately licensed under GPL-3.0. diff --git a/cmd/bootstrap/main.go b/cmd/bootstrap/main.go new file mode 100644 index 0000000..a5fe789 --- /dev/null +++ b/cmd/bootstrap/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "log" + "os" + "path/filepath" + "syscall" + + "git.ipao.vip/rogee/mohomo-docker/internal/bootstrap" +) + +const ( + defaultRoot = "/opt/clash" + defaultCoreSource = "/usr/local/lib/ssclash/clash" + defaultConfigSource = "/usr/local/share/ssclash/config.yaml" + ssclashBinary = "/usr/local/bin/ssclash" +) + +func main() { + log.SetFlags(log.Ldate | log.Ltime | log.LUTC) + root := envOrDefault("SSCLASH_ROOT", defaultRoot) + log.Printf("bootstrap: preparing persistent runtime root=%s", root) + + result, err := bootstrap.Prepare(bootstrap.Config{ + Root: root, + CoreSource: defaultCoreSource, + ConfigSource: defaultConfigSource, + }) + if err != nil { + log.Fatalf("bootstrap: runtime preparation failed: %v", err) + } + log.Printf( + "bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_mode_changed=%t", + root, + result.CoreInitialized, + result.ConfigInitialized, + result.ServerModeChanged, + ) + + arguments := os.Args[1:] + if len(arguments) == 0 { + arguments = []string{"serve"} + } + argv := append([]string{filepath.Base(ssclashBinary)}, arguments...) + log.Printf("bootstrap: exec path=%s command=%s", ssclashBinary, arguments[0]) + if err := syscall.Exec(ssclashBinary, argv, os.Environ()); err != nil { + log.Fatalf("bootstrap: exec failed: %v", err) + } +} + +func envOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e9d289f --- /dev/null +++ b/compose.yaml @@ -0,0 +1,27 @@ +services: + ssclash: + build: + context: . + image: ${IMAGE_NAME:-mohomo-docker:local} + container_name: ${CONTAINER_NAME:-mohomo-docker} + restart: unless-stopped + init: true + ports: + - "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-9091}:9091/tcp" + - "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/tcp" + - "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/udp" + volumes: + - ssclash-data:/opt/clash + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + stop_grace_period: 20s + logging: + driver: json-file + options: + max-size: 10m + max-file: "3" + +volumes: + ssclash-data: diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..342a772 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,22 @@ +mixed-port: 7890 +allow-lan: true +bind-address: "*" +mode: rule +log-level: info +ipv6: false +external-controller: 127.0.0.1:9090 + +profile: + store-selected: true + store-fake-ip: true + +proxies: [] + +proxy-groups: + - name: PROXY + type: select + proxies: + - DIRECT + +rules: + - MATCH,PROXY diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..198a8e1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.ipao.vip/rogee/mohomo-docker + +go 1.24 diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..db36132 --- /dev/null +++ b/internal/bootstrap/bootstrap.go @@ -0,0 +1,191 @@ +package bootstrap + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const operatingModeKey = "OPERATING_MODE=" + +var runtimeDirectories = []string{ + "bin", + ".ssclash", + "configs", + "local-rules", + "rule-providers", + "proxy-providers", + "subscriptions", + "ui", +} + +type Config struct { + Root string + CoreSource string + ConfigSource string +} + +type Result struct { + CoreInitialized bool + ConfigInitialized bool + ServerModeChanged bool +} + +func Prepare(config Config) (Result, error) { + var result Result + root := filepath.Clean(config.Root) + if root == "." || root == string(filepath.Separator) { + return result, fmt.Errorf("unsafe root %q", config.Root) + } + if !filepath.IsAbs(root) { + return result, fmt.Errorf("root must be absolute: %q", config.Root) + } + if err := validateSource(config.CoreSource, "core source"); err != nil { + return result, err + } + if err := validateSource(config.ConfigSource, "config source"); err != nil { + return result, err + } + + for _, directory := range runtimeDirectories { + if err := os.MkdirAll(filepath.Join(root, directory), 0o755); err != nil { + return result, fmt.Errorf("create runtime directory %s: %w", directory, err) + } + } + + var err error + result.CoreInitialized, err = copyIfAbsent(config.CoreSource, filepath.Join(root, "bin", "clash"), 0o755) + if err != nil { + return result, fmt.Errorf("initialize Mihomo core: %w", err) + } + result.ConfigInitialized, err = copyIfAbsent(config.ConfigSource, filepath.Join(root, "config.yaml"), 0o644) + if err != nil { + return result, fmt.Errorf("initialize config: %w", err) + } + result.ServerModeChanged, err = enforceServerMode(filepath.Join(root, ".ssclash", "settings")) + if err != nil { + return result, fmt.Errorf("enforce server mode: %w", err) + } + + return result, nil +} + +func validateSource(path, label string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("%s %q: %w", label, path, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s %q is not a regular file", label, path) + } + if info.Size() == 0 { + return fmt.Errorf("%s %q is empty", label, path) + } + return nil +} + +func copyIfAbsent(source, target string, mode os.FileMode) (bool, error) { + info, err := os.Stat(target) + if err == nil { + if !info.Mode().IsRegular() { + return false, fmt.Errorf("target %q is not a regular file", target) + } + if info.Size() == 0 { + return false, fmt.Errorf("target %q is empty", target) + } + return false, nil + } + if !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("inspect target %q: %w", target, err) + } + + input, err := os.Open(source) + if err != nil { + return false, fmt.Errorf("open source %q: %w", source, err) + } + defer input.Close() + + err = atomicWrite(target, mode, func(output *os.File) error { + if _, copyErr := io.Copy(output, input); copyErr != nil { + return fmt.Errorf("copy %q to %q: %w", source, target, copyErr) + } + return nil + }) + return err == nil, err +} + +func enforceServerMode(path string) (bool, error) { + content, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("read settings %q: %w", path, err) + } + + lines := make([]string, 0) + if len(content) > 0 { + lines = strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") + } + modeIndex := -1 + for index, line := range lines { + if strings.HasPrefix(line, operatingModeKey) { + if modeIndex >= 0 { + return false, fmt.Errorf("multiple OPERATING_MODE entries in %q", path) + } + modeIndex = index + } + } + changed := modeIndex < 0 || lines[modeIndex] != operatingModeKey+"server" + if modeIndex >= 0 { + lines[modeIndex] = operatingModeKey + "server" + } else { + lines = append(lines, operatingModeKey+"server") + } + + settings := strings.Join(lines, "\n") + "\n" + err = atomicWrite(path, 0o600, func(output *os.File) error { + if _, writeErr := output.WriteString(settings); writeErr != nil { + return fmt.Errorf("write settings %q: %w", path, writeErr) + } + return nil + }) + return changed, err +} + +func atomicWrite(path string, mode os.FileMode, write func(*os.File) error) (resultErr error) { + temp, err := os.CreateTemp(filepath.Dir(path), ".mohomo-docker-*") + if err != nil { + return fmt.Errorf("create temporary file for %q: %w", path, err) + } + tempPath := temp.Name() + closed := false + defer func() { + if !closed { + if closeErr := temp.Close(); resultErr == nil && closeErr != nil { + resultErr = fmt.Errorf("close temporary file for %q: %w", path, closeErr) + } + } + if resultErr != nil { + _ = os.Remove(tempPath) + } + }() + + if err := temp.Chmod(mode); err != nil { + return fmt.Errorf("chmod temporary file for %q: %w", path, err) + } + if err := write(temp); err != nil { + return err + } + if err := temp.Sync(); err != nil { + return fmt.Errorf("sync temporary file for %q: %w", path, err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close temporary file for %q: %w", path, err) + } + closed = true + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("replace %q atomically: %w", path, err) + } + return nil +} diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..014c060 --- /dev/null +++ b/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,169 @@ +package bootstrap + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrepareInitializesServerRuntime(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + root := filepath.Join(tempDir, "data") + coreSource := writeFixture(t, tempDir, "mihomo", "mihomo-binary") + configSource := writeFixture(t, tempDir, "config.yaml", "mixed-port: 7890\n") + + result, err := Prepare(Config{ + Root: root, + CoreSource: coreSource, + ConfigSource: configSource, + }) + if err != nil { + t.Fatalf("Prepare() error = %v", err) + } + if !result.CoreInitialized || !result.ConfigInitialized || !result.ServerModeChanged { + t.Errorf("Prepare() result = %+v, want all initialization flags", result) + } + + for _, directory := range []string{ + "bin", ".ssclash", "configs", "local-rules", "rule-providers", + "proxy-providers", "subscriptions", "ui", + } { + info, statErr := os.Stat(filepath.Join(root, directory)) + if statErr != nil { + t.Errorf("directory %q not created: %v", directory, statErr) + continue + } + if !info.IsDir() { + t.Errorf("path %q is not a directory", directory) + } + } + + assertFileContent(t, filepath.Join(root, "bin", "clash"), "mihomo-binary") + assertFileContent(t, filepath.Join(root, "config.yaml"), "mixed-port: 7890\n") + assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "OPERATING_MODE=server\n") + + coreInfo, err := os.Stat(filepath.Join(root, "bin", "clash")) + if err != nil { + t.Fatal(err) + } + if coreInfo.Mode().Perm() != 0o755 { + t.Errorf("core mode = %o, want 755", coreInfo.Mode().Perm()) + } +} + +func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + root := filepath.Join(tempDir, "data") + if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil { + t.Fatal(err) + } + writeFixture(t, filepath.Join(root, "bin"), "clash", "user-managed-core") + writeFixture(t, root, "config.yaml", "user: config\n") + writeFixture(t, filepath.Join(root, ".ssclash"), "settings", "LOG_LEVEL=debug\nOPERATING_MODE=gateway\n") + + result, err := Prepare(Config{ + Root: root, + CoreSource: writeFixture(t, tempDir, "mihomo", "image-core"), + ConfigSource: writeFixture(t, tempDir, "default.yaml", "image: config\n"), + }) + if err != nil { + t.Fatalf("Prepare() error = %v", err) + } + if result.CoreInitialized || result.ConfigInitialized || !result.ServerModeChanged { + t.Errorf("Prepare() result = %+v, want only server mode changed", result) + } + + assertFileContent(t, filepath.Join(root, "bin", "clash"), "user-managed-core") + assertFileContent(t, filepath.Join(root, "config.yaml"), "user: config\n") + assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "LOG_LEVEL=debug\nOPERATING_MODE=server\n") +} + +func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + coreSource := writeFixture(t, tempDir, "mihomo", "core") + configSource := writeFixture(t, tempDir, "config.yaml", "config") + + tests := []struct { + name string + config Config + setup func(t *testing.T, root string) + wantErr string + }{ + { + name: "filesystem root", + config: Config{ + Root: "/", + CoreSource: coreSource, + ConfigSource: configSource, + }, + wantErr: "unsafe root", + }, + { + name: "missing core source", + config: Config{ + Root: filepath.Join(tempDir, "missing-core"), + CoreSource: filepath.Join(tempDir, "does-not-exist"), + ConfigSource: configSource, + }, + wantErr: "core source", + }, + { + name: "duplicate operating mode", + config: Config{ + Root: filepath.Join(tempDir, "duplicate-mode"), + CoreSource: coreSource, + ConfigSource: configSource, + }, + setup: func(t *testing.T, root string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil { + t.Fatal(err) + } + writeFixture(t, filepath.Join(root, ".ssclash"), "settings", "OPERATING_MODE=gateway\nOPERATING_MODE=server\n") + }, + wantErr: "multiple OPERATING_MODE", + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + if testCase.setup != nil { + testCase.setup(t, testCase.config.Root) + } + _, err := Prepare(testCase.config) + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("Prepare() error = %v, want substring %q", err, testCase.wantErr) + } + }) + } +} + +func writeFixture(t *testing.T, directory, name, content string) string { + t.Helper() + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if string(content) != want { + t.Errorf("content of %s = %q, want %q", path, content, want) + } +} diff --git a/internal/bootstrap/config_contract_test.go b/internal/bootstrap/config_contract_test.go new file mode 100644 index 0000000..6ae612c --- /dev/null +++ b/internal/bootstrap/config_contract_test.go @@ -0,0 +1,39 @@ +package bootstrap + +import ( + "os" + "strings" + "testing" +) + +func TestSeededConfigExposesOnlyServerListeners(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{ + "mixed-port: 7890", + "allow-lan: true", + "bind-address: \"*\"", + "external-controller: 127.0.0.1:9090", + } { + if !strings.Contains(config, required) { + t.Errorf("seeded config is missing %q", required) + } + } + + for _, forbidden := range []string{ + "tun:", + "tproxy-port:", + "redir-port:", + "external-controller: 0.0.0.0", + } { + if strings.Contains(config, forbidden) { + t.Errorf("seeded config contains forbidden server-mode setting %q", forbidden) + } + } +} diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..3513ed9 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +project_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +cd "$project_root" + +unformatted=$(gofmt -l cmd internal) +if [ -n "$unformatted" ]; then + echo "Go files require formatting:" >&2 + echo "$unformatted" >&2 + exit 1 +fi + +go test -race -coverprofile=coverage.out ./internal/... +coverage=$(go tool cover -func=coverage.out | awk '/^total:/ { gsub(/%/, "", $3); print $3 }') +awk -v coverage="$coverage" 'BEGIN { if (coverage + 0 < 65) exit 1 }' || { + echo "unit test coverage ${coverage}% is below required 65%" >&2 + exit 1 +} +echo "unit test coverage: ${coverage}%" + +go test ./... + +if command -v docker >/dev/null 2>&1; then + docker compose config --quiet +fi diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh new file mode 100755 index 0000000..b2a3a60 --- /dev/null +++ b/tests/container-smoke.sh @@ -0,0 +1,68 @@ +#!/bin/sh +set -eu + +image=${1:-mohomo-docker:smoke} +suffix="$$" +container="mohomo-docker-smoke-${suffix}" +volume="mohomo-docker-smoke-${suffix}" + +case "$container:$volume" in + mohomo-docker-smoke-*':mohomo-docker-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 +} +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 + +docker volume create "$volume" >/dev/null +docker run --detach \ + --name "$container" \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + --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" + +attempt=0 +until curl --fail --silent --show-error "http://127.0.0.1:${web_port}/" >/dev/null; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ]; then + docker logs "$container" >&2 + echo "web UI did not become ready" >&2 + exit 1 + fi + sleep 1 +done + +docker exec "$container" grep -Fx 'OPERATING_MODE=server' /opt/clash/.ssclash/settings >/dev/null +docker exec "$container" grep -Fx 'mixed-port: 7890' /opt/clash/config.yaml >/dev/null +docker exec --detach "$container" /opt/clash/bin/clash -d /opt/clash + +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 + +echo "container smoke test passed: web_port=${web_port} proxy_port=${proxy_port}"