HH-620: expose SSClash Web UI on port 9091 (#3)
This commit was merged in pull request #3.
This commit is contained in:
@@ -2,6 +2,7 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -16,7 +17,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxSubscriptionSize = 16 << 20
|
||||
const (
|
||||
maxSubscriptionSize = 16 << 20
|
||||
minAdminPasswordLength = 12
|
||||
)
|
||||
|
||||
var errMihomoStateUncertain = errors.New("Mihomo subscription state could not be restored")
|
||||
|
||||
@@ -35,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
|
||||
}
|
||||
@@ -71,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
|
||||
}
|
||||
@@ -83,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)
|
||||
@@ -101,6 +115,150 @@ func Prepare(config Config) (Result, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EnsureAdminPassword(root, binary, password string) (bool, error) {
|
||||
root = filepath.Clean(root)
|
||||
if root == "." || root == string(filepath.Separator) || !filepath.IsAbs(root) {
|
||||
return false, fmt.Errorf("unsafe root %q", root)
|
||||
}
|
||||
passwordPath := filepath.Join(root, ".ssclash", "password")
|
||||
configured, err := adminPasswordConfigured(passwordPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if configured {
|
||||
return false, nil
|
||||
}
|
||||
if password == "" {
|
||||
return false, errors.New("SSCLASH_PASSWORD is required to initialize a fresh volume")
|
||||
}
|
||||
if len(password) < minAdminPasswordLength {
|
||||
return false, fmt.Errorf("SSCLASH_PASSWORD must be at least %d characters", minAdminPasswordLength)
|
||||
}
|
||||
if err := validateSource(binary, "SSClash binary"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
command := exec.Command(binary, "setpass", password)
|
||||
command.Env = childEnvironment()
|
||||
command.Stdout = io.Discard
|
||||
command.Stderr = io.Discard
|
||||
if err := command.Run(); err != nil {
|
||||
return false, errors.New("SSClash password initialization failed")
|
||||
}
|
||||
configured, err = adminPasswordConfigured(passwordPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !configured {
|
||||
return false, errors.New("SSClash password initialization did not create an authentication file")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect SSClash authentication file: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return false, errors.New("SSClash authentication file must be a regular file")
|
||||
}
|
||||
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
|
||||
@@ -336,9 +494,10 @@ func childEnvironment() []string {
|
||||
environment := os.Environ()
|
||||
result := environment[:0]
|
||||
for _, entry := range environment {
|
||||
if !strings.HasPrefix(entry, "SUBSCRIPTION_URL=") {
|
||||
result = append(result, entry)
|
||||
if strings.HasPrefix(entry, "SUBSCRIPTION_URL=") || strings.HasPrefix(entry, "SSCLASH_PASSWORD=") {
|
||||
continue
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
@@ -90,6 +92,214 @@ func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) {
|
||||
assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "LOG_LEVEL=debug\nOPERATING_MODE=server\nPROXY_MODE=none\n")
|
||||
}
|
||||
|
||||
func TestEnsureAdminPasswordFailsClosedOnFreshVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := EnsureAdminPassword(t.TempDir(), "unused", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "SSCLASH_PASSWORD") {
|
||||
t.Fatalf("EnsureAdminPassword() error = %v, want missing password error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminPasswordInitializesOnlyWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary := writeFixture(t, filepath.Join(root, "bin"), "ssclash", `#!/bin/sh
|
||||
set -eu
|
||||
[ "$1" = setpass ]
|
||||
[ "$2" = fresh-volume-password ]
|
||||
password="$(dirname "$0")/../.ssclash/password"
|
||||
printf 'pbkdf2$120000$0123456789abcdef0123456789abcdef$0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n' > "$password"
|
||||
chmod 0600 "$password"
|
||||
`)
|
||||
if err := os.Chmod(binary, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
initialized, err := EnsureAdminPassword(root, binary, "fresh-volume-password")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminPassword() error = %v", err)
|
||||
}
|
||||
if !initialized {
|
||||
t.Fatal("EnsureAdminPassword() initialized = false, want true")
|
||||
}
|
||||
assertFileContent(t, filepath.Join(root, ".ssclash", "password"), validAdminPasswordHash)
|
||||
|
||||
if err := os.Remove(binary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initialized, err = EnsureAdminPassword(root, binary, "replacement-password")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminPassword() existing password error = %v", err)
|
||||
}
|
||||
if initialized {
|
||||
t.Fatal("EnsureAdminPassword() replaced existing password")
|
||||
}
|
||||
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) {
|
||||
t.Setenv("SUBSCRIPTION_URL", "https://subscription.example.invalid/?token=secret")
|
||||
t.Setenv("SSCLASH_PASSWORD", "secret-password")
|
||||
|
||||
environment := strings.Join(childEnvironment(), "\n")
|
||||
for _, key := range []string{"SUBSCRIPTION_URL=", "SSCLASH_PASSWORD="} {
|
||||
if strings.Contains(environment, key) {
|
||||
t.Errorf("childEnvironment() retained %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -107,6 +317,7 @@ func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) {
|
||||
name: "filesystem root",
|
||||
config: Config{
|
||||
Root: "/",
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: coreSource,
|
||||
ConfigSource: configSource,
|
||||
},
|
||||
@@ -116,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,
|
||||
},
|
||||
@@ -125,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,
|
||||
},
|
||||
@@ -141,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,
|
||||
},
|
||||
@@ -341,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)
|
||||
|
||||
Reference in New Issue
Block a user