From 2363afba9ba1e23eeef1a12bd99ea152a4157811 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 26 Aug 2026 11:30:04 +0800 Subject: [PATCH 1/2] HH-682: add validated candidate config pipeline Co-authored-by: multica-agent --- Dockerfile | 3 +- cmd/bootstrap/main.go | 19 ++ go.mod | 2 + go.sum | 4 + internal/bootstrap/candidate.go | 326 +++++++++++++++++++++ internal/bootstrap/candidate_test.go | 225 ++++++++++++++ internal/bootstrap/config_contract_test.go | 29 ++ 7 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 go.sum create mode 100644 internal/bootstrap/candidate.go create mode 100644 internal/bootstrap/candidate_test.go diff --git a/Dockerfile b/Dockerfile index b986cad..6bed36a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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} \ diff --git a/cmd/bootstrap/main.go b/cmd/bootstrap/main.go index c88c178..eaa9e81 100644 --- a/cmd/bootstrap/main.go +++ b/cmd/bootstrap/main.go @@ -20,10 +20,29 @@ const ( 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) diff --git a/go.mod b/go.mod index 198a8e1..cb58c28 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module git.ipao.vip/rogee/mohomo-docker go 1.24 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/bootstrap/candidate.go b/internal/bootstrap/candidate.go new file mode 100644 index 0000000..d37c1c8 --- /dev/null +++ b/internal/bootstrap/candidate.go @@ -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 +} diff --git a/internal/bootstrap/candidate_test.go b/internal/bootstrap/candidate_test.go new file mode 100644 index 0000000..f54db60 --- /dev/null +++ b/internal/bootstrap/candidate_test.go @@ -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) + } +} diff --git a/internal/bootstrap/config_contract_test.go b/internal/bootstrap/config_contract_test.go index 93480a9..4c02411 100644 --- a/internal/bootstrap/config_contract_test.go +++ b/internal/bootstrap/config_contract_test.go @@ -1,6 +1,8 @@ package bootstrap import ( + "crypto/sha256" + "fmt" "os" "strings" "testing" @@ -69,3 +71,30 @@ func TestSeededConfigUsesLocalACL4SSRRulesAndMemorySubscription(t *testing.T) { 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) + } + } +} -- 2.54.0 From 9b5d351068f007f114cbaa611ecb0119fedf1b33 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 26 Aug 2026 12:16:29 +0800 Subject: [PATCH 2/2] HH-682: fix candidate volume ownership Co-authored-by: multica-agent --- Dockerfile | 4 +-- tests/container-smoke.sh | 75 ++++++++++++++++++++++++++++++++-------- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6bed36a..1b1397d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,8 +68,8 @@ 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 diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh index d1e8302..fed0994 100755 --- a/tests/container-smoke.sh +++ b/tests/container-smoke.sh @@ -11,18 +11,21 @@ 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:$unconfigured:$legacy_container:$provider:$network:$legacy_network:$volume:$legacy_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-'*) ;; +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" "$unconfigured" "$legacy_container" "$provider" >/dev/null 2>&1 || true - docker volume rm "$volume" "$legacy_volume" >/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 @@ -40,6 +43,19 @@ wait_for_health() { done } +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 @@ -124,17 +140,48 @@ docker run --detach --rm \ --name "$provider" \ --network "$network" \ --entrypoint /bin/sh \ - "$image" -c 'while :; do 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" | nc -l -p 8080; done' >/dev/null + "$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" -attempt=0 -until docker exec "$provider" wget -qO- http://127.0.0.1:8080/provider.yaml | grep -F 'name: smoke-node' >/dev/null; do - attempt=$((attempt + 1)) - if [ "$attempt" -ge 10 ]; then - echo "subscription fixture did not become ready" >&2 - exit 1 - fi - sleep 1 -done +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 \ @@ -287,4 +334,4 @@ 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: legacy config migrates and validates a synthetic subscription offline; fresh volume fails closed; authenticated 9091 survives same-volume rebuild; only 7890/9091 are published" +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" -- 2.54.0