fix: disable gateway listeners in server mode

This commit is contained in:
Rogee
2026-08-21 13:36:33 +08:00
parent 09ddf8f78d
commit 6d23532f7f
6 changed files with 122 additions and 27 deletions
+1
View File
@@ -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.
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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:]
+42 -17
View File
@@ -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"
+21 -5
View File
@@ -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 {
+54 -1
View File
@@ -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}"