HH-682: add Stage 1 candidate config pipeline #8
+4
-3
@@ -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} \
|
||||
@@ -67,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
module git.ipao.vip/rogee/mohomo-docker
|
||||
|
||||
go 1.24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,326 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const maxSecretSize = 4096
|
||||
|
||||
type CandidateConfig struct {
|
||||
SecretPath string
|
||||
DataDir string
|
||||
TemplatePath string
|
||||
MihomoBinary string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// PublishCandidate performs one Stage 1 update. Starting or reloading Mihomo is
|
||||
// deliberately left to the lifecycle stage.
|
||||
func PublishCandidate(ctx context.Context, config CandidateConfig) error {
|
||||
dataDir := filepath.Clean(config.DataDir)
|
||||
if !filepath.IsAbs(dataDir) || dataDir == string(filepath.Separator) {
|
||||
return fmt.Errorf("unsafe data directory %q", config.DataDir)
|
||||
}
|
||||
if err := ensureDirectory(dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
generations := filepath.Join(dataDir, "generations")
|
||||
if err := ensureDirectory(generations); err != nil {
|
||||
return err
|
||||
}
|
||||
for path, label := range map[string]string{
|
||||
config.TemplatePath: "Mihomo template",
|
||||
config.MihomoBinary: "Mihomo binary",
|
||||
} {
|
||||
if err := validateSource(path, label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
endpoint, err := readSubscriptionSecret(config.SecretPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := config.Client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
subscription, err := fetchSubscription(ctx, client, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subscription, err = normalizeSubscription(subscription)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template, err := os.ReadFile(config.TemplatePath)
|
||||
if err != nil {
|
||||
return errors.New("read Mihomo template")
|
||||
}
|
||||
generated, err := generateConfig(template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := currentGeneration(filepath.Join(dataDir, "last-good"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next := "generations/a"
|
||||
if current == next {
|
||||
next = "generations/b"
|
||||
}
|
||||
candidate, err := os.MkdirTemp(generations, ".candidate-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create candidate generation: %w", err)
|
||||
}
|
||||
if err := os.Chmod(candidate, 0o700); err != nil {
|
||||
_ = os.RemoveAll(candidate)
|
||||
return fmt.Errorf("secure candidate generation: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(candidate)
|
||||
|
||||
configPath := filepath.Join(candidate, "config.yaml")
|
||||
if err := writePrivateFile(configPath, generated); err != nil {
|
||||
return fmt.Errorf("write candidate config: %w", err)
|
||||
}
|
||||
if err := writePrivateFile(filepath.Join(candidate, "subscription.yaml"), subscription); err != nil {
|
||||
return fmt.Errorf("write candidate subscription: %w", err)
|
||||
}
|
||||
if err := validateMihomoConfig(ctx, config.MihomoBinary, candidate, configPath); err != nil {
|
||||
return errors.New("candidate configuration failed Mihomo validation")
|
||||
}
|
||||
|
||||
slot := filepath.Join(dataDir, filepath.FromSlash(next))
|
||||
if err := os.RemoveAll(slot); err != nil {
|
||||
return fmt.Errorf("clear inactive generation: %w", err)
|
||||
}
|
||||
if err := os.Rename(candidate, slot); err != nil {
|
||||
return fmt.Errorf("publish candidate generation: %w", err)
|
||||
}
|
||||
if err := syncDirectory(generations); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replaceSymlink(filepath.Join(dataDir, "last-good"), next); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncDirectory(dataDir)
|
||||
}
|
||||
|
||||
func ensureDirectory(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||||
return fmt.Errorf("create data directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect data directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("data path %q is not a directory", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSubscriptionSecret(path string) (string, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return "", errors.New("read subscription secret")
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() == 0 || info.Size() > maxSecretSize {
|
||||
return "", errors.New("subscription secret must be a non-empty regular file")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", errors.New("read subscription secret")
|
||||
}
|
||||
defer file.Close()
|
||||
opened, err := file.Stat()
|
||||
if err != nil || !os.SameFile(info, opened) {
|
||||
return "", errors.New("subscription secret changed while being read")
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(file, maxSecretSize+1))
|
||||
if err != nil || len(content) > maxSecretSize {
|
||||
return "", errors.New("read subscription secret")
|
||||
}
|
||||
raw := strings.TrimSpace(string(content))
|
||||
if strings.ContainsAny(raw, "\r\n") {
|
||||
return "", errors.New("subscription secret must contain one URL")
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(raw)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil {
|
||||
return "", errors.New("subscription secret must contain one absolute HTTP(S) URL")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func fetchSubscription(ctx context.Context, client *http.Client, endpoint string) ([]byte, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("create subscription request")
|
||||
}
|
||||
request.Header.Set("Accept", "application/yaml, text/yaml, text/plain")
|
||||
request.Header.Set("User-Agent", "mihomo")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, errors.New("subscription request failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("subscription endpoint returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(response.Body, maxSubscriptionSize+1))
|
||||
if err != nil {
|
||||
return nil, errors.New("read subscription response")
|
||||
}
|
||||
if len(content) == 0 || len(content) > maxSubscriptionSize {
|
||||
return nil, errors.New("subscription response is empty or too large")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func normalizeSubscription(content []byte) ([]byte, error) {
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
||||
var document yaml.Node
|
||||
if err := decoder.Decode(&document); err != nil || len(document.Content) != 1 {
|
||||
return nil, errors.New("subscription YAML is invalid")
|
||||
}
|
||||
var extra yaml.Node
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("subscription YAML must contain one document")
|
||||
}
|
||||
root := document.Content[0]
|
||||
if root.Kind != yaml.MappingNode {
|
||||
return nil, errors.New("subscription YAML must be a mapping")
|
||||
}
|
||||
var proxies *yaml.Node
|
||||
for index := 0; index < len(root.Content); index += 2 {
|
||||
if root.Content[index].Value != "proxies" {
|
||||
continue
|
||||
}
|
||||
if proxies != nil {
|
||||
return nil, errors.New("subscription YAML contains duplicate proxies fields")
|
||||
}
|
||||
proxies = root.Content[index+1]
|
||||
}
|
||||
if proxies == nil || proxies.Kind != yaml.SequenceNode || len(proxies.Content) == 0 {
|
||||
return nil, errors.New("subscription YAML must contain a non-empty proxies list")
|
||||
}
|
||||
for _, proxy := range proxies.Content {
|
||||
if proxy.Kind != yaml.MappingNode {
|
||||
return nil, errors.New("subscription YAML contains an invalid proxy")
|
||||
}
|
||||
}
|
||||
normalized := yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{
|
||||
Kind: yaml.MappingNode,
|
||||
Content: []*yaml.Node{
|
||||
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "proxies"},
|
||||
proxies,
|
||||
},
|
||||
}}}
|
||||
return yaml.Marshal(&normalized)
|
||||
}
|
||||
|
||||
func generateConfig(content []byte) ([]byte, error) {
|
||||
var document yaml.Node
|
||||
if err := yaml.Unmarshal(content, &document); err != nil || len(document.Content) != 1 {
|
||||
return nil, errors.New("Mihomo template is invalid")
|
||||
}
|
||||
root := document.Content[0]
|
||||
if root.Kind != yaml.MappingNode {
|
||||
return nil, errors.New("Mihomo template must be a mapping")
|
||||
}
|
||||
controller := mappingValue(root, "external-controller")
|
||||
if controller == nil || controller.Kind != yaml.ScalarNode {
|
||||
return nil, errors.New("Mihomo template is missing external-controller")
|
||||
}
|
||||
controller.Tag = "!!str"
|
||||
controller.Value = "0.0.0.0:9090"
|
||||
return yaml.Marshal(&document)
|
||||
}
|
||||
|
||||
func mappingValue(mapping *yaml.Node, key string) *yaml.Node {
|
||||
for index := 0; index+1 < len(mapping.Content); index += 2 {
|
||||
if mapping.Content[index].Value == key {
|
||||
return mapping.Content[index+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentGeneration(path string) (string, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect last-good generation: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return "", errors.New("last-good must be a managed symlink")
|
||||
}
|
||||
target, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read last-good generation: %w", err)
|
||||
}
|
||||
if target != "generations/a" && target != "generations/b" {
|
||||
return "", fmt.Errorf("last-good has unexpected target %q", target)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func writePrivateFile(path string, content []byte) error {
|
||||
return atomicWrite(path, 0o600, func(output *os.File) error {
|
||||
_, err := output.Write(content)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func replaceSymlink(path, target string) error {
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".last-good-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create last-good pointer: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
if err := temporary.Close(); err != nil {
|
||||
_ = os.Remove(temporaryPath)
|
||||
return fmt.Errorf("close last-good pointer: %w", err)
|
||||
}
|
||||
if err := os.Remove(temporaryPath); err != nil {
|
||||
return fmt.Errorf("prepare last-good pointer: %w", err)
|
||||
}
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := os.Symlink(target, temporaryPath); err != nil {
|
||||
return fmt.Errorf("create last-good pointer: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, path); err != nil {
|
||||
return fmt.Errorf("publish last-good pointer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDirectory(path string) error {
|
||||
directory, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open data directory for sync: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
if err := directory.Sync(); err != nil {
|
||||
return fmt.Errorf("sync data directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPublishCandidateGeneratesValidatedLastGood(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var lock sync.RWMutex
|
||||
response := fullSubscription("first-node")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
lock.RLock()
|
||||
defer lock.RUnlock()
|
||||
_, _ = writer.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := candidateFixture(t, server.URL+"?token=FAKE-SECRET")
|
||||
if err := PublishCandidate(context.Background(), config); err != nil {
|
||||
t.Fatalf("PublishCandidate() error = %v", err)
|
||||
}
|
||||
firstTarget := readLastGood(t, config.DataDir)
|
||||
if firstTarget != "generations/a" {
|
||||
t.Fatalf("last-good target = %q, want generations/a", firstTarget)
|
||||
}
|
||||
firstDir := filepath.Join(config.DataDir, filepath.FromSlash(firstTarget))
|
||||
assertContains(t, filepath.Join(firstDir, "config.yaml"), "external-controller: 0.0.0.0:9090")
|
||||
assertContains(t, filepath.Join(firstDir, "subscription.yaml"), "name: first-node")
|
||||
assertNotContains(t, filepath.Join(firstDir, "subscription.yaml"), "proxy-groups:")
|
||||
for _, name := range []string{"config.yaml", "subscription.yaml"} {
|
||||
info, err := os.Stat(filepath.Join(firstDir, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("%s mode = %o, want 600", name, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
lock.Lock()
|
||||
response = fullSubscription("second-node")
|
||||
lock.Unlock()
|
||||
if err := PublishCandidate(context.Background(), config); err != nil {
|
||||
t.Fatalf("second PublishCandidate() error = %v", err)
|
||||
}
|
||||
secondTarget := readLastGood(t, config.DataDir)
|
||||
if secondTarget != "generations/b" {
|
||||
t.Fatalf("last-good target = %q, want generations/b", secondTarget)
|
||||
}
|
||||
assertContains(t, filepath.Join(config.DataDir, filepath.FromSlash(secondTarget), "subscription.yaml"), "name: second-node")
|
||||
assertContains(t, filepath.Join(firstDir, "subscription.yaml"), "name: first-node")
|
||||
}
|
||||
|
||||
func TestPublishCandidateFailureMatrixKeepsLastGoodAndRedactsInput(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
response string
|
||||
status int
|
||||
secret string
|
||||
transport bool
|
||||
}{
|
||||
{name: "invalid secret URL", secret: "not-a-url-FAKE-SECRET"},
|
||||
{name: "request failure", transport: true},
|
||||
{name: "HTTP failure", status: http.StatusServiceUnavailable},
|
||||
{name: "empty response"},
|
||||
{name: "oversized response", response: strings.Repeat("x", maxSubscriptionSize+1)},
|
||||
{name: "invalid YAML", response: "proxies: ["},
|
||||
{name: "missing proxies", response: "proxy-groups: []\n"},
|
||||
{name: "Mihomo rejection", response: fullSubscription("reject-validation")},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var lock sync.RWMutex
|
||||
response := fullSubscription("last-good-node")
|
||||
status := http.StatusOK
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
lock.RLock()
|
||||
defer lock.RUnlock()
|
||||
writer.WriteHeader(status)
|
||||
_, _ = writer.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := candidateFixture(t, server.URL+"?token=FAKE-SECRET")
|
||||
if err := PublishCandidate(context.Background(), config); err != nil {
|
||||
t.Fatalf("initial PublishCandidate() error = %v", err)
|
||||
}
|
||||
wantTarget := readLastGood(t, config.DataDir)
|
||||
wantSubscription, err := os.ReadFile(filepath.Join(config.DataDir, filepath.FromSlash(wantTarget), "subscription.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lock.Lock()
|
||||
response = testCase.response
|
||||
if testCase.status != 0 {
|
||||
status = testCase.status
|
||||
}
|
||||
lock.Unlock()
|
||||
if testCase.secret != "" {
|
||||
if err := os.WriteFile(config.SecretPath, []byte(testCase.secret), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if testCase.transport {
|
||||
config.Client = &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("FAKE-SECRET transport detail")
|
||||
})}
|
||||
}
|
||||
|
||||
err = PublishCandidate(context.Background(), config)
|
||||
if err == nil {
|
||||
t.Fatal("PublishCandidate() error = nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), "FAKE-SECRET") || strings.Contains(err.Error(), "reject-validation") {
|
||||
t.Fatalf("PublishCandidate() leaked sensitive input: %v", err)
|
||||
}
|
||||
if got := readLastGood(t, config.DataDir); got != wantTarget {
|
||||
t.Fatalf("last-good target = %q, want unchanged %q", got, wantTarget)
|
||||
}
|
||||
gotSubscription, err := os.ReadFile(filepath.Join(config.DataDir, filepath.FromSlash(wantTarget), "subscription.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(gotSubscription) != string(wantSubscription) {
|
||||
t.Fatal("failed update changed last-good subscription")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishCandidateRejectsUnmanagedLastGood(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = writer.Write([]byte(fullSubscription("new-node")))
|
||||
}))
|
||||
defer server.Close()
|
||||
config := candidateFixture(t, server.URL)
|
||||
if err := os.MkdirAll(config.DataDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFixture(t, config.DataDir, "last-good", "operator-owned")
|
||||
|
||||
err := PublishCandidate(context.Background(), config)
|
||||
if err == nil || !strings.Contains(err.Error(), "managed symlink") {
|
||||
t.Fatalf("PublishCandidate() error = %v, want unmanaged last-good rejection", err)
|
||||
}
|
||||
assertFileContent(t, filepath.Join(config.DataDir, "last-good"), "operator-owned")
|
||||
}
|
||||
|
||||
func candidateFixture(t *testing.T, endpoint string) CandidateConfig {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
secret := writeFixture(t, tempDir, "subscription-secret", endpoint+"\n")
|
||||
mihomo := writeFixture(t, tempDir, "mihomo", `#!/bin/sh
|
||||
set -eu
|
||||
test "$1" = -t
|
||||
directory=
|
||||
config=
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-d) directory=$2; shift 2 ;;
|
||||
-f) config=$2; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
test -n "$directory" -a -n "$config"
|
||||
grep -F 'external-controller: 0.0.0.0:9090' "$config" >/dev/null
|
||||
grep -F 'proxies:' "$directory/subscription.yaml" >/dev/null
|
||||
! grep -F 'reject-validation' "$directory/subscription.yaml" >/dev/null
|
||||
`)
|
||||
if err := os.Chmod(mihomo, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return CandidateConfig{
|
||||
SecretPath: secret,
|
||||
DataDir: filepath.Join(tempDir, "data"),
|
||||
TemplatePath: filepath.Join("..", "..", "config", "config.yaml"),
|
||||
MihomoBinary: mihomo,
|
||||
}
|
||||
}
|
||||
|
||||
func fullSubscription(name string) string {
|
||||
return "mixed-port: 1234\nproxies:\n - name: " + name + "\n type: socks5\n server: 127.0.0.1\n port: 9\nproxy-groups: []\n"
|
||||
}
|
||||
|
||||
func readLastGood(t *testing.T, dataDir string) string {
|
||||
t.Helper()
|
||||
target, err := os.Readlink(filepath.Join(dataDir, "last-good"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), want) {
|
||||
t.Errorf("%s does not contain %q", path, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotContains(t *testing.T, path, unwanted string) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(content), unwanted) {
|
||||
t.Errorf("%s contains %q", path, unwanted)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
-14
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user