diff --git a/README.md b/README.md index c6e3623..d60627c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ docker compose up -d --build docker compose logs -f ssclash ``` -The subscription endpoint must return a Clash/Mihomo proxy-provider YAML document (`proxies:`). Use an HTTPS endpoint when its URL contains a credential. A fresh volume refuses to start without an `SSCLASH_PASSWORD` of at least 12 characters; bootstrap uses SSClash's own `setpass` command before the Web listener starts. Open `http://:9091` and log in with that password. Existing authentication files are preserved, so later starts do not require or replace the password. Proxy clients connect to either endpoint: +The subscription endpoint must return a Clash/Mihomo proxy-provider YAML document (`proxies:`). Use an HTTPS endpoint when its URL contains a credential. A fresh volume refuses to start without an `SSCLASH_PASSWORD` of at least 12 characters; bootstrap uses SSClash's own `setpass` command before the Web listener starts. Open `http://:9091` and log in with that password. A valid existing authentication file is preserved, so later starts do not require or replace the password. Proxy clients connect to either endpoint: ```text HTTP proxy: http://:7890 @@ -25,7 +25,7 @@ SOCKS5 proxy: socks5://:7890 The bootstrap fetches the subscription once before startup and every hour thereafter. Each candidate is limited to 16 MiB and validated with the packaged Mihomo binary before an atomic replacement and hot reload. A failed reload restores and reloads the previous provider; if that recovery cannot be confirmed, both services stop instead of running with uncertain state. -The subscription URL and bootstrap administrator password are removed from child-process environments and never printed. The password is persisted only as SSClash's PBKDF2 authentication file. The generated configuration contains only a local provider path. Subscription data lives under `/dev/shm/mohomo`, so neither the image nor the `/opt/clash` volume stores its URL, response, or node credentials. Container restarts intentionally fetch a fresh subscription instead of persisting credentials. +The subscription URL and bootstrap administrator password are removed from child-process environments and never printed. The password is persisted only as SSClash's PBKDF2 authentication file. Before opening the Web listener, bootstrap requires that file to be a readable, process-owned regular file with exact mode `0600` and SSClash v6.1.0's expected PBKDF2 format; a missing or abnormal file fails closed. The generated configuration contains only a local provider path. Subscription data lives under `/dev/shm/mohomo`, so neither the image nor the `/opt/clash` volume stores its URL, response, or node credentials. Container restarts intentionally fetch a fresh subscription instead of persisting credentials. Do not commit `.env`; it is ignored by Git. Docker still exposes bootstrap environment variables to principals allowed to inspect the container, so restrict Docker daemon access. @@ -37,7 +37,7 @@ The generated groups and rule order mirror `ACL4SSR_Online_Full_MultiMode.ini`: ## Persistent data -`/opt/clash` stores only SSClash settings, the packaged Mihomo core, and the non-secret generated configuration. Bootstrap creates missing files, preserves existing regular non-empty files, enforces `OPERATING_MODE=server` and `PROXY_MODE=none`, and rejects corrupt or ambiguous persistent state. +`/opt/clash` stores only SSClash settings, the packaged Mihomo core, and the non-secret generated configuration. Bootstrap creates missing files, preserves existing regular non-empty files, enforces `OPERATING_MODE=server` and `PROXY_MODE=none`, and rejects corrupt or ambiguous persistent state. On container replacement it repairs only SSClash's exact `rule-providers` and `proxy-providers` links into `SSCLASH_TMP`; unexpected links are rejected without deleting their targets. ## Reproducible inputs @@ -56,7 +56,7 @@ The GitHub Actions workflow builds `linux/amd64`, runs tests first, publishes on ./tests/container-smoke.sh ``` -The unit suite checks fail-closed authentication initialization, atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test verifies that a fresh volume without an administrator password never starts the Web UI, then logs in through published port `9091`, checks the exact `7890`/`9091` port set, and confirms that plaintext credentials are neither persisted nor logged. +The unit suite checks strict fail-closed authentication-file validation, provider-link recovery, atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test verifies that a fresh volume without an administrator password never starts the Web UI, logs in through published port `9091`, checks the exact `7890`/`9091` port set, confirms that plaintext credentials are neither persisted nor logged, and repeats health and login checks after recreating the container with the same volume. ## License boundary diff --git a/cmd/bootstrap/main.go b/cmd/bootstrap/main.go index 9a2eb88..fceff85 100644 --- a/cmd/bootstrap/main.go +++ b/cmd/bootstrap/main.go @@ -14,6 +14,7 @@ import ( const ( defaultRoot = "/opt/clash" + defaultSSClashTemp = "/tmp/ssclash" defaultCoreSource = "/usr/local/lib/ssclash/clash" defaultConfigSource = "/usr/local/share/ssclash/config.yaml" ssclashBinary = "/usr/local/bin/ssclash" @@ -27,6 +28,7 @@ func main() { result, err := bootstrap.Prepare(bootstrap.Config{ Root: root, + SSClashTemp: envOrDefault("SSCLASH_TMP", defaultSSClashTemp), CoreSource: defaultCoreSource, ConfigSource: defaultConfigSource, }) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 80b0fbc..7de6441 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -2,6 +2,7 @@ package bootstrap import ( "context" + "encoding/hex" "errors" "fmt" "io" @@ -38,14 +39,15 @@ var runtimeDirectories = []string{ ".ssclash", "configs", "local-rules", - "rule-providers", - "proxy-providers", "subscriptions", "ui", } +var managedProviderDirectories = []string{"rule-providers", "proxy-providers"} + type Config struct { Root string + SSClashTemp string CoreSource string ConfigSource string } @@ -74,6 +76,10 @@ func Prepare(config Config) (Result, error) { if !filepath.IsAbs(root) { return result, fmt.Errorf("root must be absolute: %q", config.Root) } + ssclashTemp := filepath.Clean(config.SSClashTemp) + if !filepath.IsAbs(ssclashTemp) || ssclashTemp == string(filepath.Separator) { + return result, fmt.Errorf("unsafe SSClash temporary directory %q", config.SSClashTemp) + } if err := validateSource(config.CoreSource, "core source"); err != nil { return result, err } @@ -86,6 +92,11 @@ func Prepare(config Config) (Result, error) { return result, fmt.Errorf("create runtime directory %s: %w", directory, err) } } + for _, directory := range managedProviderDirectories { + if err := reconcileManagedProviderDirectory(root, ssclashTemp, directory); err != nil { + return result, err + } + } var err error result.CoreInitialized, err = copyIfAbsent(config.CoreSource, filepath.Join(root, "bin", "clash"), 0o755) @@ -145,6 +156,10 @@ func EnsureAdminPassword(root, binary, password string) (bool, error) { } func adminPasswordConfigured(path string) (bool, error) { + return adminPasswordConfiguredFor(path, uint32(os.Geteuid()), uint32(os.Getegid())) +} + +func adminPasswordConfiguredFor(path string, expectedUID, expectedGID uint32) (bool, error) { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { return false, nil @@ -152,15 +167,98 @@ func adminPasswordConfigured(path string) (bool, error) { if err != nil { return false, fmt.Errorf("inspect SSClash authentication file: %w", err) } - if !info.Mode().IsRegular() || info.Size() == 0 { - return false, errors.New("SSClash authentication file must be a non-empty regular file") + if !info.Mode().IsRegular() { + return false, errors.New("SSClash authentication file must be a regular file") } - if info.Mode().Perm()&0o077 != 0 { + if info.Mode().Perm() != 0o600 { return false, fmt.Errorf("SSClash authentication file permissions are %o; want 600", info.Mode().Perm()) } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false, errors.New("SSClash authentication file ownership could not be verified") + } + if stat.Uid != expectedUID || stat.Gid != expectedGID { + return false, fmt.Errorf("SSClash authentication file owner is %d:%d; want %d:%d", stat.Uid, stat.Gid, expectedUID, expectedGID) + } + + file, err := os.Open(path) + if err != nil { + return false, fmt.Errorf("read SSClash authentication file: %w", err) + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil { + return false, fmt.Errorf("inspect opened SSClash authentication file: %w", err) + } + if !os.SameFile(info, openedInfo) { + return false, errors.New("SSClash authentication file changed while being verified") + } + content, err := io.ReadAll(io.LimitReader(file, 257)) + if err != nil { + return false, fmt.Errorf("read SSClash authentication file: %w", err) + } + if len(content) > 256 { + return false, errors.New("SSClash authentication file is too large") + } + if err := validateAdminPasswordHash(content); err != nil { + return false, err + } return true, nil } +func validateAdminPasswordHash(content []byte) error { + text := string(content) + if !strings.HasSuffix(text, "\n") { + return errors.New("SSClash authentication file has an invalid password hash") + } + parts := strings.Split(strings.TrimSuffix(text, "\n"), "$") + if len(parts) != 4 || parts[0] != "pbkdf2" || parts[1] != "120000" || len(parts[2]) != 32 || len(parts[3]) != 64 { + return errors.New("SSClash authentication file has an invalid password hash") + } + if _, err := hex.DecodeString(parts[2]); err != nil { + return errors.New("SSClash authentication file has an invalid password hash") + } + if _, err := hex.DecodeString(parts[3]); err != nil { + return errors.New("SSClash authentication file has an invalid password hash") + } + return nil +} + +func reconcileManagedProviderDirectory(root, ssclashTemp, directory string) error { + path := filepath.Join(root, directory) + expectedTarget := filepath.Join(ssclashTemp, directory) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(path, 0o755); err != nil { + return fmt.Errorf("create runtime directory %s: %w", directory, err) + } + return nil + } + if err != nil { + return fmt.Errorf("inspect runtime directory %s: %w", directory, err) + } + if info.IsDir() { + return nil + } + if info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("runtime path %s is not a directory", directory) + } + target, err := os.Readlink(path) + if err != nil { + return fmt.Errorf("read runtime symlink %s: %w", directory, err) + } + if target != expectedTarget { + return fmt.Errorf("runtime path %s has unexpected symlink target %q", directory, target) + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove managed runtime symlink %s: %w", directory, err) + } + if err := os.MkdirAll(path, 0o755); err != nil { + return fmt.Errorf("recreate runtime directory %s: %w", directory, err) + } + return nil +} + func Run(ctx context.Context, config RuntimeConfig) error { if err := validateSubscriptionURL(config.SubscriptionURL); err != nil { return err diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index ab5b30e..0a1dcb8 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -24,6 +24,7 @@ func TestPrepareInitializesServerRuntime(t *testing.T) { result, err := Prepare(Config{ Root: root, + SSClashTemp: filepath.Join(tempDir, "tmp"), CoreSource: coreSource, ConfigSource: configSource, }) @@ -75,6 +76,7 @@ func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) { result, err := Prepare(Config{ Root: root, + SSClashTemp: filepath.Join(tempDir, "tmp"), CoreSource: writeFixture(t, tempDir, "mihomo", "image-core"), ConfigSource: writeFixture(t, tempDir, "default.yaml", "image: config\n"), }) @@ -111,7 +113,7 @@ set -eu [ "$1" = setpass ] [ "$2" = fresh-volume-password ] password="$(dirname "$0")/../.ssclash/password" -printf 'pbkdf2$test\n' > "$password" +printf 'pbkdf2$120000$0123456789abcdef0123456789abcdef$0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n' > "$password" chmod 0600 "$password" `) if err := os.Chmod(binary, 0o755); err != nil { @@ -125,7 +127,7 @@ chmod 0600 "$password" if !initialized { t.Fatal("EnsureAdminPassword() initialized = false, want true") } - assertFileContent(t, filepath.Join(root, ".ssclash", "password"), "pbkdf2$test\n") + assertFileContent(t, filepath.Join(root, ".ssclash", "password"), validAdminPasswordHash) if err := os.Remove(binary); err != nil { t.Fatal(err) @@ -137,7 +139,153 @@ chmod 0600 "$password" if initialized { t.Fatal("EnsureAdminPassword() replaced existing password") } - assertFileContent(t, filepath.Join(root, ".ssclash", "password"), "pbkdf2$test\n") + assertFileContent(t, filepath.Join(root, ".ssclash", "password"), validAdminPasswordHash) +} + +func TestAdminPasswordConfiguredRejectsUnsafeFiles(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + setup func(t *testing.T, path string) + }{ + {name: "mode 000", setup: passwordFileSetup(validAdminPasswordHash, 0o000)}, + {name: "mode 0200", setup: passwordFileSetup(validAdminPasswordHash, 0o200)}, + {name: "mode 0400", setup: passwordFileSetup(validAdminPasswordHash, 0o400)}, + {name: "mode 0644", setup: passwordFileSetup(validAdminPasswordHash, 0o644)}, + {name: "empty", setup: passwordFileSetup("", 0o600)}, + {name: "invalid hash", setup: passwordFileSetup("pbkdf2$test\n", 0o600)}, + {name: "non-hex hash", setup: passwordFileSetup("pbkdf2$120000$zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz$0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n", 0o600)}, + {name: "directory", setup: func(t *testing.T, path string) { + t.Helper() + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + }}, + {name: "symlink", setup: func(t *testing.T, path string) { + t.Helper() + target := path + ".target" + passwordFileSetup(validAdminPasswordHash, 0o600)(t, target) + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(testCase.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "password") + testCase.setup(t, path) + if configured, err := adminPasswordConfigured(path); err == nil || configured { + t.Fatalf("adminPasswordConfigured() = %t, %v; want false, error", configured, err) + } + }) + } +} + +func TestAdminPasswordConfiguredRequiresOwner(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "password") + passwordFileSetup(validAdminPasswordHash, 0o600)(t, path) + for _, owner := range []struct { + name string + uid uint32 + gid uint32 + }{ + {name: "UID", uid: uint32(os.Geteuid() + 1), gid: uint32(os.Getegid())}, + {name: "GID", uid: uint32(os.Geteuid()), gid: uint32(os.Getegid() + 1)}, + } { + t.Run(owner.name, func(t *testing.T) { + configured, err := adminPasswordConfiguredFor(path, owner.uid, owner.gid) + if err == nil || configured { + t.Fatalf("adminPasswordConfiguredFor() = %t, %v; want false, owner error", configured, err) + } + }) + } +} + +func TestAdminPasswordConfiguredAcceptsSecureFile(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "password") + passwordFileSetup(validAdminPasswordHash, 0o600)(t, path) + configured, err := adminPasswordConfigured(path) + if err != nil || !configured { + t.Fatalf("adminPasswordConfigured() = %t, %v; want true, nil", configured, err) + } +} + +func TestPrepareRepairsManagedProviderSymlinks(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + root := filepath.Join(tempDir, "data") + ssclashTemp := filepath.Join(tempDir, "tmp") + config := Config{ + Root: root, + SSClashTemp: ssclashTemp, + CoreSource: writeFixture(t, tempDir, "mihomo", "core"), + ConfigSource: writeFixture(t, tempDir, "config.yaml", "config"), + } + if _, err := Prepare(config); err != nil { + t.Fatalf("first Prepare() error = %v", err) + } + for _, directory := range []string{"rule-providers", "proxy-providers"} { + path := filepath.Join(root, directory) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(ssclashTemp, directory), path); err != nil { + t.Fatal(err) + } + } + + if _, err := Prepare(config); err != nil { + t.Fatalf("second Prepare() error = %v", err) + } + for _, directory := range []string{"rule-providers", "proxy-providers"} { + info, err := os.Lstat(filepath.Join(root, directory)) + if err != nil { + t.Fatal(err) + } + if !info.IsDir() { + t.Errorf("%s mode = %s, want directory", directory, info.Mode()) + } + } +} + +func TestPrepareRejectsUnexpectedProviderSymlink(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + root := filepath.Join(tempDir, "data") + ssclashTemp := filepath.Join(tempDir, "tmp") + config := Config{ + Root: root, + SSClashTemp: ssclashTemp, + CoreSource: writeFixture(t, tempDir, "mihomo", "core"), + ConfigSource: writeFixture(t, tempDir, "config.yaml", "config"), + } + if _, err := Prepare(config); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "rule-providers") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(tempDir, "unexpected"), path); err != nil { + t.Fatal(err) + } + + if _, err := Prepare(config); err == nil || !strings.Contains(err.Error(), "unexpected symlink") { + t.Fatalf("Prepare() error = %v, want unexpected symlink error", err) + } + target, err := os.Readlink(path) + if err != nil { + t.Fatal(err) + } + if target != filepath.Join(tempDir, "unexpected") { + t.Fatalf("unexpected symlink target = %q", target) + } } func TestChildEnvironmentRemovesCredentials(t *testing.T) { @@ -169,6 +317,7 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) { name: "filesystem root", config: Config{ Root: "/", + SSClashTemp: filepath.Join(tempDir, "tmp"), CoreSource: coreSource, ConfigSource: configSource, }, @@ -178,6 +327,7 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) { name: "missing core source", config: Config{ Root: filepath.Join(tempDir, "missing-core"), + SSClashTemp: filepath.Join(tempDir, "tmp"), CoreSource: filepath.Join(tempDir, "does-not-exist"), ConfigSource: configSource, }, @@ -187,6 +337,7 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) { name: "duplicate operating mode", config: Config{ Root: filepath.Join(tempDir, "duplicate-mode"), + SSClashTemp: filepath.Join(tempDir, "tmp"), CoreSource: coreSource, ConfigSource: configSource, }, @@ -203,6 +354,7 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) { name: "duplicate proxy mode", config: Config{ Root: filepath.Join(tempDir, "duplicate-proxy-mode"), + SSClashTemp: filepath.Join(tempDir, "tmp"), CoreSource: coreSource, ConfigSource: configSource, }, @@ -403,6 +555,20 @@ func writeFixture(t *testing.T, directory, name, content string) string { return path } +const validAdminPasswordHash = "pbkdf2$120000$0123456789abcdef0123456789abcdef$0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n" + +func passwordFileSetup(content string, mode os.FileMode) func(t *testing.T, path string) { + return func(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + } +} + func assertFileContent(t *testing.T, path, want string) { t.Helper() content, err := os.ReadFile(path) diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh index e686d4a..bf4cbd9 100755 --- a/tests/container-smoke.sh +++ b/tests/container-smoke.sh @@ -27,6 +27,55 @@ cleanup() { } trap cleanup EXIT INT TERM +wait_for_health() { + attempt=0 + until [ "$(docker inspect --format '{{.State.Health.Status}}' "$container")" = healthy ]; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ]; then + docker logs "$container" >&2 + echo "container did not become healthy" >&2 + exit 1 + fi + sleep 1 + done +} + +assert_published_ports() { + published=$(docker port "$container") + for port in 7890/tcp 7890/udp 9091/tcp; do + printf '%s\n' "$published" | grep -F "$port ->" >/dev/null + done + if printf '%s\n' "$published" | grep -vE '^(7890/(tcp|udp)|9091/tcp)' >/dev/null; then + echo "container published a port other than 7890 or 9091" >&2 + exit 1 + fi +} + +assert_web_login() { + web_port=$1 + : > "$cookie" + setup_redirect=$(curl --silent --show-error --output /dev/null \ + --write-out '%{http_code} %{redirect_url}' \ + "http://127.0.0.1:${web_port}/setup") + if [ "$setup_redirect" != "303 http://127.0.0.1:${web_port}/login" ]; then + echo "configured Web UI exposed setup: ${setup_redirect}" >&2 + exit 1 + fi + login_html=$(curl --fail --silent --show-error --cookie-jar "$cookie" \ + "http://127.0.0.1:${web_port}/login") + login_csrf=$(printf '%s' "$login_html" | sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p' | head -1) + test -n "$login_csrf" + curl --fail --silent --show-error \ + --cookie "$cookie" \ + --cookie-jar "$cookie" \ + --request POST \ + --data-urlencode "csrf=${login_csrf}" \ + --data-urlencode "password=${admin_password}" \ + "http://127.0.0.1:${web_port}/login" >/dev/null + curl --fail --silent --show-error --cookie "$cookie" \ + "http://127.0.0.1:${web_port}/config" | grep -F 'csrf-token' >/dev/null +} + docker build --tag "$image" . docker run --rm --entrypoint /usr/local/lib/ssclash/clash "$image" \ -t -d /usr/local/share/ssclash -f /usr/local/share/ssclash/config.yaml >/dev/null 2>&1 && { @@ -97,6 +146,7 @@ if docker logs "$unconfigured" 2>&1 | grep -F 'web UI listening' >/dev/null; the exit 1 fi docker container rm "$unconfigured" >/dev/null +cookie=$(mktemp) docker run --detach \ --name "$container" \ @@ -111,47 +161,10 @@ docker run --detach \ --publish 127.0.0.1::9091/tcp \ "$image" >/dev/null -attempt=0 -until [ "$(docker inspect --format '{{.State.Health.Status}}' "$container")" = healthy ]; do - attempt=$((attempt + 1)) - if [ "$attempt" -ge 30 ]; then - docker logs "$container" >&2 - echo "container did not become healthy" >&2 - exit 1 - fi - sleep 1 -done - -published=$(docker port "$container") -for port in 7890/tcp 7890/udp 9091/tcp; do - printf '%s\n' "$published" | grep -F "$port ->" >/dev/null -done -if printf '%s\n' "$published" | grep -vE '^(7890/(tcp|udp)|9091/tcp)' >/dev/null; then - echo "container published a port other than 7890 or 9091" >&2 - exit 1 -fi +wait_for_health +assert_published_ports web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }') -setup_redirect=$(curl --silent --show-error --output /dev/null \ - --write-out '%{http_code} %{redirect_url}' \ - "http://127.0.0.1:${web_port}/setup") -if [ "$setup_redirect" != "303 http://127.0.0.1:${web_port}/login" ]; then - echo "configured Web UI exposed setup: ${setup_redirect}" >&2 - exit 1 -fi -cookie=$(mktemp) -login_html=$(curl --fail --silent --show-error --cookie-jar "$cookie" \ - "http://127.0.0.1:${web_port}/login") -login_csrf=$(printf '%s' "$login_html" | sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p' | head -1) -test -n "$login_csrf" -curl --fail --silent --show-error \ - --cookie "$cookie" \ - --cookie-jar "$cookie" \ - --request POST \ - --data-urlencode "csrf=${login_csrf}" \ - --data-urlencode "password=${admin_password}" \ - "http://127.0.0.1:${web_port}/login" >/dev/null -curl --fail --silent --show-error --cookie "$cookie" \ - "http://127.0.0.1:${web_port}/config" | grep -F 'csrf-token' >/dev/null +assert_web_login "$web_port" docker exec "$container" grep -Fx 'OPERATING_MODE=server' /opt/clash/.ssclash/settings >/dev/null docker exec "$container" grep -Fx 'PROXY_MODE=none' /opt/clash/.ssclash/settings >/dev/null @@ -178,4 +191,27 @@ if docker logs "$container" 2>&1 | grep -F "$admin_password" >/dev/null; then exit 1 fi -echo "container smoke test passed: fresh volume fails closed; authenticated 9091 and proxy credentials are protected" +docker container rm --force "$container" >/dev/null +docker run --rm \ + --volume "$volume:/opt/clash" \ + --entrypoint /bin/sh \ + "$image" -c 'test -L /opt/clash/rule-providers && test ! -e /opt/clash/rule-providers && test "$(readlink /opt/clash/rule-providers)" = /tmp/ssclash/rule-providers' + +docker run --detach \ + --name "$container" \ + --network "$network" \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + --env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml?token=${secret}" \ + --volume "$volume:/opt/clash" \ + --publish 127.0.0.1::7890/tcp \ + --publish 127.0.0.1::7890/udp \ + --publish 127.0.0.1::9091/tcp \ + "$image" >/dev/null + +wait_for_health +assert_published_ports +web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }') +assert_web_login "$web_port" + +echo "container smoke test passed: fresh volume fails closed; authenticated 9091 survives same-volume rebuild; only 7890/9091 are published"