Author SHA1 Message Date
rogeeandmultica-agent 925ce84339 HH-620: secure default host bindings
Docker image / Test (pull_request) Successful in 39s
Docker image / Build and publish (pull_request) Successful in 3m2s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 17:44:55 +08:00
rogeeandmultica-agent 6a349f62c8 HH-620: harden authentication and volume rebuild
Docker image / Test (pull_request) Successful in 38s
Docker image / Build and publish (pull_request) Successful in 2m15s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 17:18:29 +08:00
rogeeandmultica-agent ab0f9d3eec HH-620: require authentication before exposing setup
Docker image / Test (pull_request) Successful in 39s
Docker image / Build and publish (pull_request) Successful in 2m41s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 16:44:55 +08:00
rogeeandmultica-agent 8d6e7c0098 HH-620: expose SSClash Web UI on port 9091
Docker image / Test (pull_request) Successful in 36s
Docker image / Build and publish (pull_request) Successful in 2m19s
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 16:12:38 +08:00
8 changed files with 592 additions and 39 deletions
+5 -1
View File
@@ -1,5 +1,9 @@
IMAGE_NAME=mohomo-docker:local
CONTAINER_NAME=mohomo-docker
SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo
PROXY_BIND=0.0.0.0
SSCLASH_PASSWORD=
# 9091 is always host-loopback; expose it through a host HTTPS reverse proxy.
WEB_PORT=9091
# Public opt-in: use 0.0.0.0 only behind a trusted-network firewall/ACL.
PROXY_BIND=127.0.0.1
PROXY_PORT=7890
+2 -2
View File
@@ -77,12 +77,12 @@ COPY config/config.yaml /usr/local/share/ssclash/config.yaml
ENV SSCLASH_ROOT=/opt/clash \
SSCLASH_TMP=/tmp/ssclash \
SSCLASH_PLATFORM=linux \
SSCLASH_ADDR=127.0.0.1:9091 \
SSCLASH_ADDR=0.0.0.0:9091 \
SAFE_PATHS=/usr/local/share/ssclash
USER ssclash
VOLUME ["/opt/clash"]
EXPOSE 7890/tcp 7890/udp
EXPOSE 9091/tcp 7890/tcp 7890/udp
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=4 \
CMD curl --fail --silent --show-error http://127.0.0.1:9090/version >/dev/null
ENTRYPOINT ["/usr/local/bin/bootstrap"]
+21 -10
View File
@@ -1,32 +1,43 @@
# mohomo-docker
Minimal Mihomo service with the ACL4SSR `Online Full MultiMode` routing model. The host exposes only mixed proxy port `7890`; SSClash and Mihomo's controller remain loopback-only inside the container.
Minimal Mihomo service with the ACL4SSR `Online Full MultiMode` routing model. The host publishes the SSClash Web UI on loopback port `9091` and the mixed proxy on loopback port `7890` by default; Mihomo's controller remains private to the container.
## Quick start
```sh
cp .env.example .env
# Replace only SUBSCRIPTION_URL in .env.
# Generate a password, then set SUBSCRIPTION_URL and SSCLASH_PASSWORD in .env.
openssl rand -base64 24
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. 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. On the Docker host, open `http://127.0.0.1:9091` and log in with that password. A valid existing authentication file is preserved, so later starts do not require or replace the password. Local proxy clients connect to either endpoint:
```text
HTTP proxy: http://<server>:7890
SOCKS5 proxy: socks5://<server>:7890
HTTP proxy: http://127.0.0.1:7890
SOCKS5 proxy: socks5://127.0.0.1:7890
```
`PROXY_BIND` and `PROXY_PORT` are optional deployment overrides. Configure Mihomo proxy authentication before publishing port `7890` outside a trusted network.
The Compose boundary fixes plaintext `9091` to host loopback. To provide the required external Web access, configure a host HTTPS reverse proxy to `127.0.0.1:${WEB_PORT:-9091}`; for example, a host-native Caddy configuration is:
```caddyfile
ssclash.example.com {
reverse_proxy 127.0.0.1:9091
}
```
Replace the domain and ensure its DNS reaches the host; Caddy then obtains and serves the TLS certificate. Do not publish 9091 directly as public HTTP.
`WEB_PORT`, `PROXY_BIND`, and `PROXY_PORT` are optional deployment overrides. Port 7890 also defaults to `127.0.0.1`; setting `PROXY_BIND=0.0.0.0` is the explicit public opt-in. The packaged Mihomo proxy has no client authentication, so use that opt-in only when a host firewall or network ACL restricts clients to a trusted range. Prefer binding `PROXY_BIND` to a specific trusted host address.
## Update and secret handling
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 is read from `SUBSCRIPTION_URL`, removed from child-process environments, and never printed. 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 container environment variables to principals allowed to inspect the container, so restrict Docker daemon access.
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.
## ACL4SSR rules
@@ -36,7 +47,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
@@ -55,7 +66,7 @@ The GitHub Actions workflow builds `linux/amd64`, runs tests first, publishes on
./tests/container-smoke.sh
```
The unit suite checks atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test builds the image, validates the generated configuration, checks that only port `7890` is published, and verifies that the subscription credential is 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 loopback-only Compose defaults and proxy-only public opt-in, proves 7890/9091 are unreachable through a non-loopback host address, checks fresh-volume authentication and credential isolation, and repeats health and login checks after recreating the container with the same volume.
## License boundary
+8 -1
View File
@@ -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,18 +28,24 @@ func main() {
result, err := bootstrap.Prepare(bootstrap.Config{
Root: root,
SSClashTemp: envOrDefault("SSCLASH_TMP", defaultSSClashTemp),
CoreSource: defaultCoreSource,
ConfigSource: defaultConfigSource,
})
if err != nil {
log.Fatalf("bootstrap: runtime preparation failed: %v", err)
}
adminPasswordInitialized, err := bootstrap.EnsureAdminPassword(root, ssclashBinary, os.Getenv("SSCLASH_PASSWORD"))
if err != nil {
log.Fatalf("bootstrap: admin authentication setup failed: %v", err)
}
log.Printf(
"bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_settings_changed=%t",
"bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_settings_changed=%t admin_password_initialized=%t",
root,
result.CoreInitialized,
result.ConfigInitialized,
result.ServerSettingsChanged,
adminPasswordInitialized,
)
subscriptionURL := os.Getenv("SUBSCRIPTION_URL")
+5 -2
View File
@@ -8,9 +8,12 @@ services:
init: true
environment:
SUBSCRIPTION_URL: ${SUBSCRIPTION_URL:?set SUBSCRIPTION_URL in .env}
SSCLASH_PASSWORD: ${SSCLASH_PASSWORD:-}
ports:
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/tcp"
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/udp"
# Keep the plaintext Web UI behind a host-local HTTPS reverse proxy.
- "127.0.0.1:${WEB_PORT:-9091}:9091/tcp"
- "${PROXY_BIND:-127.0.0.1}:${PROXY_PORT:-7890}:7890/tcp"
- "${PROXY_BIND:-127.0.0.1}:${PROXY_PORT:-7890}:7890/udp"
volumes:
- ssclash-data:/opt/clash
cap_drop:
+164 -5
View File
@@ -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
}
+228
View File
@@ -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)
+159 -18
View File
@@ -4,25 +4,94 @@ set -eu
image=${1:-mohomo-docker:smoke}
suffix="$$"
container="mohomo-docker-smoke-${suffix}"
unconfigured="mohomo-docker-unconfigured-${suffix}"
provider="mohomo-provider-smoke-${suffix}"
network="mohomo-network-smoke-${suffix}"
volume="mohomo-volume-smoke-${suffix}"
provider_dir=""
cookie=""
secret="container-smoke-secret"
admin_password="container-smoke-admin-password"
case "$container:$provider:$network:$volume" in
mohomo-docker-smoke-*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-volume-smoke-'*) ;;
case "$container:$unconfigured:$provider:$network:$volume" in
mohomo-docker-smoke-*':mohomo-docker-unconfigured-'*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-volume-smoke-'*) ;;
*) echo "refusing unsafe cleanup targets" >&2; exit 1 ;;
esac
cleanup() {
docker container rm --force "$container" "$provider" >/dev/null 2>&1 || true
docker container rm --force "$container" "$unconfigured" "$provider" >/dev/null 2>&1 || true
docker volume rm "$volume" >/dev/null 2>&1 || true
docker network rm "$network" >/dev/null 2>&1 || true
[ -z "$provider_dir" ] || rm -rf "$provider_dir"
[ -z "$cookie" ] || rm -f "$cookie"
}
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
}
default_compose=$(SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo docker compose config)
loopback_bindings=$(printf '%s\n' "$default_compose" | awk '$1 == "host_ip:" && $2 == "127.0.0.1" { count++ } END { print count + 0 }')
if [ "$loopback_bindings" -ne 3 ]; then
echo "Compose must bind 7890/tcp, 7890/udp, and 9091/tcp to host loopback by default" >&2
exit 1
fi
public_proxy_compose=$(SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo \
PROXY_BIND=0.0.0.0 WEB_BIND=0.0.0.0 docker compose config)
public_bindings=$(printf '%s\n' "$public_proxy_compose" | awk '$1 == "host_ip:" && $2 == "0.0.0.0" { count++ } END { print count + 0 }')
loopback_bindings=$(printf '%s\n' "$public_proxy_compose" | awk '$1 == "host_ip:" && $2 == "127.0.0.1" { count++ } END { print count + 0 }')
if [ "$public_bindings" -ne 2 ] || [ "$loopback_bindings" -ne 1 ]; then
echo "public opt-in must affect only 7890; 9091 must remain on host loopback" >&2
exit 1
fi
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 && {
@@ -58,32 +127,73 @@ until docker exec "$provider" wget -qO- http://127.0.0.1:8080/provider.yaml >/de
fi
sleep 1
done
docker run --detach \
--name "$unconfigured" \
--network "$network" \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml" \
--volume "$volume:/opt/clash" \
--publish 127.0.0.1::9091/tcp \
"$image" >/dev/null
unconfigured_port=$(docker port "$unconfigured" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
attempt=0
while [ "$(docker inspect --format '{{.State.Running}}' "$unconfigured")" = true ]; do
if curl --fail --silent --show-error --max-time 1 \
"http://127.0.0.1:${unconfigured_port}/setup" >/dev/null 2>&1; then
echo "fresh volume exposed anonymous setup" >&2
exit 1
fi
attempt=$((attempt + 1))
if [ "$attempt" -ge 10 ]; then
echo "fresh volume did not fail closed without SSCLASH_PASSWORD" >&2
exit 1
fi
sleep 1
done
if [ "$(docker inspect --format '{{.State.ExitCode}}' "$unconfigured")" -eq 0 ]; then
echo "fresh volume exited successfully without SSCLASH_PASSWORD" >&2
exit 1
fi
docker logs "$unconfigured" 2>&1 | grep -F 'SSCLASH_PASSWORD is required' >/dev/null
if docker logs "$unconfigured" 2>&1 | grep -F 'web UI listening' >/dev/null; then
echo "fresh volume started the Web UI before authentication was configured" >&2
exit 1
fi
docker container rm "$unconfigured" >/dev/null
cookie=$(mktemp)
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}" \
--env "SSCLASH_PASSWORD=${admin_password}" \
--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
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
wait_for_health
assert_published_ports
web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
assert_web_login "$web_port"
published=$(docker port "$container")
printf '%s\n' "$published" | grep -E '^7890/(tcp|udp)' >/dev/null
if printf '%s\n' "$published" | grep -vE '^7890/(tcp|udp)' >/dev/null; then
echo "container published a port other than 7890" >&2
host_gateway=$(docker network inspect "$network" --format '{{(index .IPAM.Config 0).Gateway}}')
container_ip=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container")
proxy_port=$(docker port "$container" 7890/tcp | awk -F: 'NR == 1 { print $NF }')
if curl --fail --silent --show-error --max-time 2 --noproxy "" \
--proxy "http://${host_gateway}:${proxy_port}" \
"http://${container_ip}:9090/version" >/dev/null 2>&1; then
echo "default 7890 publish was reachable through a non-loopback host address" >&2
exit 1
fi
if curl --fail --silent --show-error --max-time 2 \
"http://${host_gateway}:${web_port}/login" >/dev/null 2>&1; then
echo "default 9091 publish was reachable through a non-loopback host address" >&2
exit 1
fi
@@ -103,5 +213,36 @@ if docker logs "$container" 2>&1 | grep -F "$secret" >/dev/null; then
echo "subscription URL credential was written to logs" >&2
exit 1
fi
if docker exec "$container" grep -R -F "$admin_password" /opt/clash /dev/shm/mohomo >/dev/null 2>&1; then
echo "administrator password was written to runtime files" >&2
exit 1
fi
if docker logs "$container" 2>&1 | grep -F "$admin_password" >/dev/null; then
echo "administrator password was written to logs" >&2
exit 1
fi
echo "container smoke test passed: only port 7890 published; subscription credential not persisted or logged"
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"