feat: package SSClash server mode for Docker

This commit is contained in:
Rogee
2026-08-21 13:30:53 +08:00
commit 09ddf8f78d
17 changed files with 816 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.env
coverage.out
tests
README.md
AGENTS.md
+6
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
.env
coverage.out
+23
View File
@@ -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.
+72
View File
@@ -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"]
+24
View File
@@ -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.
+19
View File
@@ -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
+63
View File
@@ -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://<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:
```text
HTTP proxy: http://<server>:7890
SOCKS5 proxy: socks5://<server>: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.
+56
View File
@@ -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
}
+27
View File
@@ -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:
+22
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module git.ipao.vip/rogee/mohomo-docker
go 1.24
+191
View File
@@ -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
}
+169
View File
@@ -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)
}
}
@@ -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)
}
}
}
+26
View File
@@ -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
+68
View File
@@ -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}"