feat: package SSClash server mode for Docker

This commit is contained in:
Rogee
2026-08-21 13:30:53 +08:00
commit 09ddf8f78d
17 changed files with 816 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
package bootstrap
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const operatingModeKey = "OPERATING_MODE="
var runtimeDirectories = []string{
"bin",
".ssclash",
"configs",
"local-rules",
"rule-providers",
"proxy-providers",
"subscriptions",
"ui",
}
type Config struct {
Root string
CoreSource string
ConfigSource string
}
type Result struct {
CoreInitialized bool
ConfigInitialized bool
ServerModeChanged bool
}
func Prepare(config Config) (Result, error) {
var result Result
root := filepath.Clean(config.Root)
if root == "." || root == string(filepath.Separator) {
return result, fmt.Errorf("unsafe root %q", config.Root)
}
if !filepath.IsAbs(root) {
return result, fmt.Errorf("root must be absolute: %q", config.Root)
}
if err := validateSource(config.CoreSource, "core source"); err != nil {
return result, err
}
if err := validateSource(config.ConfigSource, "config source"); err != nil {
return result, err
}
for _, directory := range runtimeDirectories {
if err := os.MkdirAll(filepath.Join(root, directory), 0o755); err != nil {
return result, fmt.Errorf("create runtime directory %s: %w", directory, err)
}
}
var err error
result.CoreInitialized, err = copyIfAbsent(config.CoreSource, filepath.Join(root, "bin", "clash"), 0o755)
if err != nil {
return result, fmt.Errorf("initialize Mihomo core: %w", err)
}
result.ConfigInitialized, err = copyIfAbsent(config.ConfigSource, filepath.Join(root, "config.yaml"), 0o644)
if err != nil {
return result, fmt.Errorf("initialize config: %w", err)
}
result.ServerModeChanged, err = enforceServerMode(filepath.Join(root, ".ssclash", "settings"))
if err != nil {
return result, fmt.Errorf("enforce server mode: %w", err)
}
return result, nil
}
func validateSource(path, label string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s %q: %w", label, path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s %q is not a regular file", label, path)
}
if info.Size() == 0 {
return fmt.Errorf("%s %q is empty", label, path)
}
return nil
}
func copyIfAbsent(source, target string, mode os.FileMode) (bool, error) {
info, err := os.Stat(target)
if err == nil {
if !info.Mode().IsRegular() {
return false, fmt.Errorf("target %q is not a regular file", target)
}
if info.Size() == 0 {
return false, fmt.Errorf("target %q is empty", target)
}
return false, nil
}
if !errors.Is(err, os.ErrNotExist) {
return false, fmt.Errorf("inspect target %q: %w", target, err)
}
input, err := os.Open(source)
if err != nil {
return false, fmt.Errorf("open source %q: %w", source, err)
}
defer input.Close()
err = atomicWrite(target, mode, func(output *os.File) error {
if _, copyErr := io.Copy(output, input); copyErr != nil {
return fmt.Errorf("copy %q to %q: %w", source, target, copyErr)
}
return nil
})
return err == nil, err
}
func enforceServerMode(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)
}
lines := make([]string, 0)
if len(content) > 0 {
lines = strings.Split(strings.TrimSuffix(string(content), "\n"), "\n")
}
modeIndex := -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)
}
modeIndex = index
}
}
changed := modeIndex < 0 || lines[modeIndex] != operatingModeKey+"server"
if modeIndex >= 0 {
lines[modeIndex] = operatingModeKey + "server"
} else {
lines = append(lines, operatingModeKey+"server")
}
settings := strings.Join(lines, "\n") + "\n"
err = atomicWrite(path, 0o600, func(output *os.File) error {
if _, writeErr := output.WriteString(settings); writeErr != nil {
return fmt.Errorf("write settings %q: %w", path, writeErr)
}
return nil
})
return changed, err
}
func atomicWrite(path string, mode os.FileMode, write func(*os.File) error) (resultErr error) {
temp, err := os.CreateTemp(filepath.Dir(path), ".mohomo-docker-*")
if err != nil {
return fmt.Errorf("create temporary file for %q: %w", path, err)
}
tempPath := temp.Name()
closed := false
defer func() {
if !closed {
if closeErr := temp.Close(); resultErr == nil && closeErr != nil {
resultErr = fmt.Errorf("close temporary file for %q: %w", path, closeErr)
}
}
if resultErr != nil {
_ = os.Remove(tempPath)
}
}()
if err := temp.Chmod(mode); err != nil {
return fmt.Errorf("chmod temporary file for %q: %w", path, err)
}
if err := write(temp); err != nil {
return err
}
if err := temp.Sync(); err != nil {
return fmt.Errorf("sync temporary file for %q: %w", path, err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("close temporary file for %q: %w", path, err)
}
closed = true
if err := os.Rename(tempPath, path); err != nil {
return fmt.Errorf("replace %q atomically: %w", path, err)
}
return nil
}
+169
View File
@@ -0,0 +1,169 @@
package bootstrap
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestPrepareInitializesServerRuntime(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
root := filepath.Join(tempDir, "data")
coreSource := writeFixture(t, tempDir, "mihomo", "mihomo-binary")
configSource := writeFixture(t, tempDir, "config.yaml", "mixed-port: 7890\n")
result, err := Prepare(Config{
Root: root,
CoreSource: coreSource,
ConfigSource: configSource,
})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
if !result.CoreInitialized || !result.ConfigInitialized || !result.ServerModeChanged {
t.Errorf("Prepare() result = %+v, want all initialization flags", result)
}
for _, directory := range []string{
"bin", ".ssclash", "configs", "local-rules", "rule-providers",
"proxy-providers", "subscriptions", "ui",
} {
info, statErr := os.Stat(filepath.Join(root, directory))
if statErr != nil {
t.Errorf("directory %q not created: %v", directory, statErr)
continue
}
if !info.IsDir() {
t.Errorf("path %q is not a directory", directory)
}
}
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")
coreInfo, err := os.Stat(filepath.Join(root, "bin", "clash"))
if err != nil {
t.Fatal(err)
}
if coreInfo.Mode().Perm() != 0o755 {
t.Errorf("core mode = %o, want 755", coreInfo.Mode().Perm())
}
}
func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
root := filepath.Join(tempDir, "data")
if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil {
t.Fatal(err)
}
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")
result, err := Prepare(Config{
Root: root,
CoreSource: writeFixture(t, tempDir, "mihomo", "image-core"),
ConfigSource: writeFixture(t, tempDir, "default.yaml", "image: config\n"),
})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
if result.CoreInitialized || result.ConfigInitialized || !result.ServerModeChanged {
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")
}
func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
coreSource := writeFixture(t, tempDir, "mihomo", "core")
configSource := writeFixture(t, tempDir, "config.yaml", "config")
tests := []struct {
name string
config Config
setup func(t *testing.T, root string)
wantErr string
}{
{
name: "filesystem root",
config: Config{
Root: "/",
CoreSource: coreSource,
ConfigSource: configSource,
},
wantErr: "unsafe root",
},
{
name: "missing core source",
config: Config{
Root: filepath.Join(tempDir, "missing-core"),
CoreSource: filepath.Join(tempDir, "does-not-exist"),
ConfigSource: configSource,
},
wantErr: "core source",
},
{
name: "duplicate operating mode",
config: Config{
Root: filepath.Join(tempDir, "duplicate-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", "OPERATING_MODE=gateway\nOPERATING_MODE=server\n")
},
wantErr: "multiple OPERATING_MODE",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
if testCase.setup != nil {
testCase.setup(t, testCase.config.Root)
}
_, err := Prepare(testCase.config)
if err == nil || !strings.Contains(err.Error(), testCase.wantErr) {
t.Fatalf("Prepare() error = %v, want substring %q", err, testCase.wantErr)
}
})
}
}
func writeFixture(t *testing.T, directory, name, content string) string {
t.Helper()
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(directory, name)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func assertFileContent(t *testing.T, path, want string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if string(content) != want {
t.Errorf("content of %s = %q, want %q", path, content, want)
}
}
@@ -0,0 +1,39 @@
package bootstrap
import (
"os"
"strings"
"testing"
)
func TestSeededConfigExposesOnlyServerListeners(t *testing.T) {
t.Parallel()
content, err := os.ReadFile("../../config/config.yaml")
if err != nil {
t.Fatalf("read seeded config: %v", err)
}
config := string(content)
for _, required := range []string{
"mixed-port: 7890",
"allow-lan: true",
"bind-address: \"*\"",
"external-controller: 127.0.0.1:9090",
} {
if !strings.Contains(config, required) {
t.Errorf("seeded config is missing %q", required)
}
}
for _, forbidden := range []string{
"tun:",
"tproxy-port:",
"redir-port:",
"external-controller: 0.0.0.0",
} {
if strings.Contains(config, forbidden) {
t.Errorf("seeded config contains forbidden server-mode setting %q", forbidden)
}
}
}