diff --git a/AGENTS.md b/AGENTS.md index 5ced327..334244f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ - Package the official SSClash-Go release binary with an official Mihomo core. - Run SSClash in `server` mode only: embedded Web UI plus Mihomo mixed proxy on port 7890. +- Keep both `OPERATING_MODE=server` and `PROXY_MODE=none`; without the latter, SSClash injects a gateway listener during Web-managed start. - Do not add transparent gateway, TUN, firewall, policy-routing, or DNS-hijack behavior. - Keep Mihomo's controller private to the container; never publish port 9090. diff --git a/README.md b/README.md index 7c024c6..dc6476d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The named volume `ssclash-data` is mounted at `/opt/clash` and stores: - subscription, rule-provider, and proxy-provider files; - the active Mihomo core and its runtime data. -The bootstrap process creates missing files only. Existing Mihomo and configuration files are preserved, while `OPERATING_MODE` is explicitly enforced as `server`. Duplicate mode entries or empty runtime files cause startup to fail with a diagnostic message. +The bootstrap process creates missing files only. Existing Mihomo and configuration files are preserved. It explicitly enforces `OPERATING_MODE=server` and `PROXY_MODE=none`; the latter prevents SSClash from synchronizing gateway-only TProxy, redirect, or TUN listeners into `config.yaml`. Duplicate mode entries or empty runtime files cause startup to fail with a diagnostic message. Resetting the volume deletes configuration and credentials. Inspect the exact Compose project and volume name before doing so. @@ -54,7 +54,7 @@ SSClash is verified against the checksum file from its official release. Mihomo ./tests/container-smoke.sh ``` -The unit suite enforces at least 65% statement coverage for bootstrap behavior. The container smoke test builds the image, validates the Mihomo configuration, starts the Web UI with all Linux capabilities dropped, and sends an HTTPS request through the mapped mixed proxy port. +The unit suite enforces at least 65% statement coverage for bootstrap behavior. The container smoke test builds the image, validates the Mihomo configuration, starts the Web UI with all Linux capabilities dropped, authenticates to SSClash, starts Mihomo through the Web API, rejects gateway-listener/error regressions, and sends an HTTPS request through the mapped mixed proxy port. ## License boundary diff --git a/cmd/bootstrap/main.go b/cmd/bootstrap/main.go index a5fe789..ca6181b 100644 --- a/cmd/bootstrap/main.go +++ b/cmd/bootstrap/main.go @@ -30,11 +30,11 @@ func main() { log.Fatalf("bootstrap: runtime preparation failed: %v", err) } log.Printf( - "bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_mode_changed=%t", + "bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_settings_changed=%t", root, result.CoreInitialized, result.ConfigInitialized, - result.ServerModeChanged, + result.ServerSettingsChanged, ) arguments := os.Args[1:] diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index db36132..f1a66d4 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -9,7 +9,15 @@ import ( "strings" ) -const operatingModeKey = "OPERATING_MODE=" +type enforcedSetting struct { + key string + value string +} + +var serverSettings = []enforcedSetting{ + {key: "OPERATING_MODE=", value: "server"}, + {key: "PROXY_MODE=", value: "none"}, +} var runtimeDirectories = []string{ "bin", @@ -29,9 +37,9 @@ type Config struct { } type Result struct { - CoreInitialized bool - ConfigInitialized bool - ServerModeChanged bool + CoreInitialized bool + ConfigInitialized bool + ServerSettingsChanged bool } func Prepare(config Config) (Result, error) { @@ -65,9 +73,9 @@ func Prepare(config Config) (Result, error) { if err != nil { return result, fmt.Errorf("initialize config: %w", err) } - result.ServerModeChanged, err = enforceServerMode(filepath.Join(root, ".ssclash", "settings")) + result.ServerSettingsChanged, err = enforceServerSettings(filepath.Join(root, ".ssclash", "settings")) if err != nil { - return result, fmt.Errorf("enforce server mode: %w", err) + return result, fmt.Errorf("enforce server settings: %w", err) } return result, nil @@ -117,7 +125,7 @@ func copyIfAbsent(source, target string, mode os.FileMode) (bool, error) { return err == nil, err } -func enforceServerMode(path string) (bool, error) { +func enforceServerSettings(path string) (bool, error) { content, err := os.ReadFile(path) if err != nil && !errors.Is(err, os.ErrNotExist) { return false, fmt.Errorf("read settings %q: %w", path, err) @@ -127,20 +135,37 @@ func enforceServerMode(path string) (bool, error) { if len(content) > 0 { lines = strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") } - modeIndex := -1 + indexes := make(map[string]int, len(serverSettings)) + for _, setting := range serverSettings { + indexes[setting.key] = -1 + } for index, line := range lines { - if strings.HasPrefix(line, operatingModeKey) { - if modeIndex >= 0 { - return false, fmt.Errorf("multiple OPERATING_MODE entries in %q", path) + for _, setting := range serverSettings { + if !strings.HasPrefix(line, setting.key) { + continue } - modeIndex = index + if indexes[setting.key] >= 0 { + return false, fmt.Errorf("multiple %s entries in %q", strings.TrimSuffix(setting.key, "="), path) + } + indexes[setting.key] = index } } - changed := modeIndex < 0 || lines[modeIndex] != operatingModeKey+"server" - if modeIndex >= 0 { - lines[modeIndex] = operatingModeKey + "server" - } else { - lines = append(lines, operatingModeKey+"server") + changed := false + for _, setting := range serverSettings { + expected := setting.key + setting.value + index := indexes[setting.key] + if index >= 0 { + if lines[index] != expected { + lines[index] = expected + changed = true + } + continue + } + lines = append(lines, expected) + changed = true + } + if !changed { + return false, nil } settings := strings.Join(lines, "\n") + "\n" diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 014c060..f0e97ca 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -23,7 +23,7 @@ func TestPrepareInitializesServerRuntime(t *testing.T) { if err != nil { t.Fatalf("Prepare() error = %v", err) } - if !result.CoreInitialized || !result.ConfigInitialized || !result.ServerModeChanged { + if !result.CoreInitialized || !result.ConfigInitialized || !result.ServerSettingsChanged { t.Errorf("Prepare() result = %+v, want all initialization flags", result) } @@ -43,7 +43,7 @@ func TestPrepareInitializesServerRuntime(t *testing.T) { assertFileContent(t, filepath.Join(root, "bin", "clash"), "mihomo-binary") assertFileContent(t, filepath.Join(root, "config.yaml"), "mixed-port: 7890\n") - assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "OPERATING_MODE=server\n") + assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "OPERATING_MODE=server\nPROXY_MODE=none\n") coreInfo, err := os.Stat(filepath.Join(root, "bin", "clash")) if err != nil { @@ -64,7 +64,7 @@ func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) { } writeFixture(t, filepath.Join(root, "bin"), "clash", "user-managed-core") writeFixture(t, root, "config.yaml", "user: config\n") - writeFixture(t, filepath.Join(root, ".ssclash"), "settings", "LOG_LEVEL=debug\nOPERATING_MODE=gateway\n") + writeFixture(t, filepath.Join(root, ".ssclash"), "settings", "LOG_LEVEL=debug\nOPERATING_MODE=gateway\nPROXY_MODE=tproxy\n") result, err := Prepare(Config{ Root: root, @@ -74,13 +74,13 @@ func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) { if err != nil { t.Fatalf("Prepare() error = %v", err) } - if result.CoreInitialized || result.ConfigInitialized || !result.ServerModeChanged { + if result.CoreInitialized || result.ConfigInitialized || !result.ServerSettingsChanged { t.Errorf("Prepare() result = %+v, want only server mode changed", result) } assertFileContent(t, filepath.Join(root, "bin", "clash"), "user-managed-core") assertFileContent(t, filepath.Join(root, "config.yaml"), "user: config\n") - assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "LOG_LEVEL=debug\nOPERATING_MODE=server\n") + assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "LOG_LEVEL=debug\nOPERATING_MODE=server\nPROXY_MODE=none\n") } func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) { @@ -130,6 +130,22 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) { }, wantErr: "multiple OPERATING_MODE", }, + { + name: "duplicate proxy mode", + config: Config{ + Root: filepath.Join(tempDir, "duplicate-proxy-mode"), + CoreSource: coreSource, + ConfigSource: configSource, + }, + setup: func(t *testing.T, root string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil { + t.Fatal(err) + } + writeFixture(t, filepath.Join(root, ".ssclash"), "settings", "PROXY_MODE=tproxy\nPROXY_MODE=none\n") + }, + wantErr: "multiple PROXY_MODE", + }, } for _, testCase := range tests { diff --git a/tests/container-smoke.sh b/tests/container-smoke.sh index b2a3a60..174ceba 100755 --- a/tests/container-smoke.sh +++ b/tests/container-smoke.sh @@ -5,6 +5,9 @@ image=${1:-mohomo-docker:smoke} suffix="$$" container="mohomo-docker-smoke-${suffix}" volume="mohomo-docker-smoke-${suffix}" +cookie="" +login_html="" +config_html="" case "$container:$volume" in mohomo-docker-smoke-*':mohomo-docker-smoke-'*) ;; @@ -14,6 +17,9 @@ esac cleanup() { docker container rm --force "$container" >/dev/null 2>&1 || true docker volume rm "$volume" >/dev/null 2>&1 || true + [ -z "$cookie" ] || rm -f "$cookie" + [ -z "$login_html" ] || rm -f "$login_html" + [ -z "$config_html" ] || rm -f "$config_html" } trap cleanup EXIT INT TERM @@ -48,8 +54,44 @@ until curl --fail --silent --show-error "http://127.0.0.1:${web_port}/" >/dev/nu done 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 docker exec "$container" grep -Fx 'mixed-port: 7890' /opt/clash/config.yaml >/dev/null -docker exec --detach "$container" /opt/clash/bin/clash -d /opt/clash + +docker exec "$container" /usr/local/bin/ssclash setpass container-smoke-only >/dev/null +cookie=$(mktemp) +login_html=$(mktemp) +config_html=$(mktemp) +curl --fail --silent --show-error --cookie-jar "$cookie" \ + "http://127.0.0.1:${web_port}/login" > "$login_html" +login_csrf=$(sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p' "$login_html" | 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=container-smoke-only' \ + "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" > "$config_html" +api_csrf=$(sed -n 's/.*name="csrf-token" content="\([^"]*\)".*/\1/p' "$config_html" | head -1) +test -n "$api_csrf" + +start_response=$(curl --fail --silent --show-error \ + --cookie "$cookie" \ + --header "X-CSRF-Token: ${api_csrf}" \ + --header 'Content-Type: application/json' \ + --data '{"action":"start"}' \ + "http://127.0.0.1:${web_port}/api/service") +printf '%s' "$start_response" | grep -F '"ok":true' >/dev/null + +status_response=$(curl --fail --silent --show-error \ + --cookie "$cookie" \ + --header "X-CSRF-Token: ${api_csrf}" \ + "http://127.0.0.1:${web_port}/api/status") +printf '%s' "$status_response" | grep -F '"running":true' >/dev/null +printf '%s' "$status_response" | grep -F '"operatingMode":"server"' >/dev/null attempt=0 until curl --fail --silent --show-error \ @@ -65,4 +107,15 @@ until curl --fail --silent --show-error \ sleep 1 done +if docker exec "$container" grep -Eq '^(tproxy-port|redir-port|tun):' /opt/clash/config.yaml; then + docker logs "$container" >&2 + echo "gateway listener leaked into server-only config" >&2 + exit 1 +fi +if docker logs "$container" 2>&1 | grep -Ei '\[(error|fatal)\]|operation not permitted' >/dev/null; then + docker logs "$container" >&2 + echo "container emitted an error during Web-managed startup" >&2 + exit 1 +fi + echo "container smoke test passed: web_port=${web_port} proxy_port=${proxy_port}"