Author SHA1 Message Date
rogeeandmultica-agent 9b5d351068 HH-682: fix candidate volume ownership
Docker image / Test (pull_request) Failing after 1m10s
Docker image / Build and publish (pull_request) Skipped
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 12:16:29 +08:00
rogeeandmultica-agent 2363afba9b HH-682: add validated candidate config pipeline
Docker image / Test (pull_request) Canceled after 0s
Docker image / Build and publish (pull_request) Canceled after 0s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-26 11:30:04 +08:00
8 changed files with 670 additions and 17 deletions
+4 -3
View File
@@ -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
+19
View File
@@ -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)
+2
View File
@@ -1,3 +1,5 @@
module git.ipao.vip/rogee/mohomo-docker
go 1.24
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -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=
+326
View File
@@ -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
}
+225
View File
@@ -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)
}
}
}
+59 -12
View File
@@ -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
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
sleep 1
done
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"