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) + } + } +}