HH-635: let SSClash own Mihomo lifecycle
Docker image / Test (pull_request) Successful in 2m29s
Docker image / Build and publish (pull_request) Successful in 2m43s

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-08-25 10:06:23 +08:00
co-authored by multica-agent
parent 6c67390d52
commit 1c9c91f63e
6 changed files with 130 additions and 71 deletions
+1 -1
View File
@@ -86,5 +86,5 @@ USER ssclash
VOLUME ["/opt/clash"]
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
CMD curl --fail --silent --show-error http://127.0.0.1:9091/login >/dev/null
ENTRYPOINT ["/usr/local/bin/bootstrap"]
+3 -3
View File
@@ -12,7 +12,7 @@ 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. 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:
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`, log in with that password, and press **Start**. SSClash then owns the Mihomo process and the Web UI Start/Stop/status controls stay authoritative. 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://127.0.0.1:7890
@@ -33,9 +33,9 @@ Replace the domain and ensure its DNS reaches the host; Caddy then obtains and s
## 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 bootstrap fetches the subscription once before startup and every hour thereafter. Each candidate is limited to 16 MiB and validated with the active Mihomo binary before an atomic replacement. When Mihomo is running, bootstrap hot-reloads it; when it is stopped, the next Web-managed Start reads the latest provider. A failed reload restores and reloads the previous provider; if that recovery cannot be confirmed, SSClash stops instead of leaving Mihomo in an uncertain state.
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.
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`; `/opt/clash/subscription.yaml` is only a managed link to that in-memory file, so neither the image nor the 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 bootstrap environment variables to principals allowed to inspect the container, so restrict Docker daemon access.
+4 -2
View File
@@ -6,6 +6,7 @@ import (
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
@@ -56,9 +57,10 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
err = bootstrap.Run(ctx, bootstrap.RuntimeConfig{
CoreBinary: defaultCoreSource,
Root: root,
CoreBinary: filepath.Join(root, "bin", "clash"),
SSClashBinary: ssclashBinary,
ConfigSource: defaultConfigSource,
ConfigSource: filepath.Join(root, "config.yaml"),
RuntimeDir: defaultRuntimeDir,
SubscriptionURL: subscriptionURL,
UpdateInterval: time.Hour,
+69 -27
View File
@@ -59,6 +59,7 @@ type Result struct {
}
type RuntimeConfig struct {
Root string
CoreBinary string
SSClashBinary string
ConfigSource string
@@ -266,6 +267,10 @@ func Run(ctx context.Context, config RuntimeConfig) error {
if config.UpdateInterval <= 0 {
return errors.New("subscription update interval must be positive")
}
root := filepath.Clean(config.Root)
if !filepath.IsAbs(root) || root == string(filepath.Separator) {
return fmt.Errorf("unsafe root %q", config.Root)
}
runtimeDir := filepath.Clean(config.RuntimeDir)
if !filepath.IsAbs(runtimeDir) || runtimeDir == string(filepath.Separator) {
return fmt.Errorf("unsafe runtime directory %q", config.RuntimeDir)
@@ -301,28 +306,20 @@ func Run(ctx context.Context, config RuntimeConfig) error {
if err := validateMihomoConfig(ctx, config.CoreBinary, runtimeDir, runtimeConfig); err != nil {
return errors.New("generated Mihomo configuration failed validation")
}
if err := ensureSubscriptionLink(filepath.Join(root, "subscription.yaml"), activeSubscription); err != nil {
return err
}
serviceCtx, cancel := context.WithCancel(ctx)
defer cancel()
ssclash := serviceCommand(serviceCtx, config.SSClashBinary, "serve")
mihomo := serviceCommand(serviceCtx, config.CoreBinary, "-d", runtimeDir, "-f", runtimeConfig)
if err := ssclash.Start(); err != nil {
return fmt.Errorf("start SSClash: %w", err)
}
if err := mihomo.Start(); err != nil {
cancel()
_ = ssclash.Wait()
return fmt.Errorf("start Mihomo: %w", err)
}
log.Printf("bootstrap: services started mode=server subscription_update_interval=%s", config.UpdateInterval)
log.Printf("bootstrap: SSClash started mode=server core_owner=ssclash subscription_update_interval=%s", config.UpdateInterval)
type processResult struct {
name string
err error
}
exits := make(chan processResult, 2)
go func() { exits <- processResult{name: "SSClash", err: ssclash.Wait()} }()
go func() { exits <- processResult{name: "Mihomo", err: mihomo.Wait()} }()
exit := make(chan error, 1)
go func() { exit <- ssclash.Wait() }()
ticker := time.NewTicker(config.UpdateInterval)
defer ticker.Stop()
@@ -330,32 +327,63 @@ func Run(ctx context.Context, config RuntimeConfig) error {
select {
case <-ctx.Done():
cancel()
<-exits
<-exits
<-exit
return ctx.Err()
case result := <-exits:
cancel()
<-exits
if result.err == nil {
return fmt.Errorf("%s exited", result.name)
case err := <-exit:
if err == nil {
return errors.New("SSClash exited")
}
return fmt.Errorf("%s exited: %w", result.name, result.err)
return fmt.Errorf("SSClash exited: %w", err)
case <-ticker.C:
if err := updateAndReload(ctx, client, config, activeSubscription, validate, reload); errors.Is(err, errMihomoStateUncertain) {
log.Print("bootstrap: subscription rollback failed; stopping services")
running := mihomoRunning(ctx, client)
var err error
if running {
err = updateAndReload(ctx, client, config, activeSubscription, validate, reload)
} else {
err = updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate)
}
if errors.Is(err, errMihomoStateUncertain) {
log.Print("bootstrap: subscription rollback failed; stopping SSClash")
cancel()
<-exits
<-exits
<-exit
return err
} else if err != nil {
log.Print("bootstrap: subscription update rejected; keeping previous valid configuration")
continue
}
log.Print("bootstrap: subscription updated and reloaded")
if running {
log.Print("bootstrap: subscription updated and reloaded")
} else {
log.Print("bootstrap: subscription updated; Mihomo is stopped")
}
}
}
}
func ensureSubscriptionLink(path, target string) error {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
if err := os.Symlink(target, path); err != nil {
return fmt.Errorf("create runtime subscription link: %w", err)
}
return nil
}
if err != nil {
return fmt.Errorf("inspect runtime subscription link: %w", err)
}
if info.Mode()&os.ModeSymlink == 0 {
return errors.New("runtime subscription path is not a managed symlink")
}
existingTarget, err := os.Readlink(path)
if err != nil {
return fmt.Errorf("read runtime subscription link: %w", err)
}
if existingTarget != target {
return fmt.Errorf("runtime subscription link has unexpected target %q", existingTarget)
}
return nil
}
func validateSubscriptionURL(raw string) error {
parsed, err := url.ParseRequestURI(raw)
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
@@ -478,6 +506,20 @@ func reloadSubscription(ctx context.Context, client *http.Client) error {
return nil
}
func mihomoRunning(ctx context.Context, client *http.Client) bool {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:9090/version", nil)
if err != nil {
return false
}
response, err := client.Do(request)
if err != nil {
return false
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, response.Body)
return true
}
func serviceCommand(ctx context.Context, binary string, arguments ...string) *exec.Cmd {
command := exec.CommandContext(ctx, binary, arguments...)
command.Env = childEnvironment()
+39 -34
View File
@@ -9,7 +9,6 @@ import (
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
@@ -479,49 +478,55 @@ func TestUpdateAndReloadRestoresRuntimeAfterAmbiguousFailure(t *testing.T) {
}
}
func TestRunStopsServicesWhenRollbackReloadFails(t *testing.T) {
func TestRunLeavesMihomoLifecycleToSSClash(t *testing.T) {
tempDir := t.TempDir()
binary := writeFixture(t, tempDir, "fake-service", "#!/bin/sh\nif [ \"$1\" = -t ]; then exit 0; fi\nexec sleep 3600\n")
if err := os.Chmod(binary, 0o755); err != nil {
root := filepath.Join(tempDir, "root")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
config := writeFixture(t, tempDir, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n")
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
name := "updated"
if requests.Add(1) == 1 {
name = "initial"
coreMarker := filepath.Join(tempDir, "core-started")
ssclashMarker := filepath.Join(tempDir, "ssclash-started")
t.Setenv("MIHOMO_TEST_MARKER", coreMarker)
t.Setenv("SSCLASH_TEST_MARKER", ssclashMarker)
core := writeFixture(t, tempDir, "fake-core", "#!/bin/sh\nif [ \"$1\" = -t ]; then exit 0; fi\ntouch \"$MIHOMO_TEST_MARKER\"\nexit 1\n")
ssclash := writeFixture(t, tempDir, "fake-ssclash", "#!/bin/sh\n[ \"$1\" = serve ]\ntouch \"$SSCLASH_TEST_MARKER\"\nsleep 1\n")
for _, binary := range []string{core, ssclash} {
if err := os.Chmod(binary, 0o755); err != nil {
t.Fatal(err)
}
_, _ = writer.Write([]byte("proxies:\n - name: " + name + "\n"))
}
config := writeFixture(t, root, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n")
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte("proxies:\n - name: initial\n"))
}))
defer server.Close()
runResult := make(chan error, 1)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
runResult <- Run(ctx, RuntimeConfig{
CoreBinary: binary,
SSClashBinary: binary,
ConfigSource: config,
RuntimeDir: filepath.Join(tempDir, "runtime"),
SubscriptionURL: server.URL,
UpdateInterval: 20 * time.Millisecond,
})
}()
var err error
select {
case err = <-runResult:
case <-time.After(5 * time.Second):
t.Fatal("Run() did not reach rollback failure")
runtimeDir := filepath.Join(tempDir, "runtime")
err := Run(context.Background(), RuntimeConfig{
Root: root,
CoreBinary: core,
SSClashBinary: ssclash,
ConfigSource: config,
RuntimeDir: runtimeDir,
SubscriptionURL: server.URL,
UpdateInterval: time.Hour,
})
if err == nil || !strings.Contains(err.Error(), "SSClash exited") {
t.Fatalf("Run() error = %v, want SSClash exit", err)
}
if !errors.Is(err, errMihomoStateUncertain) {
t.Fatalf("Run() error = %v, want uncertain Mihomo state", err)
if _, err := os.Stat(ssclashMarker); err != nil {
t.Fatalf("SSClash was not started: %v", err)
}
if requests.Load() < 2 {
t.Fatalf("subscription requests = %d, want initial fetch and timed update", requests.Load())
if _, err := os.Stat(coreMarker); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("bootstrap started Mihomo outside SSClash: %v", err)
}
linkTarget, err := os.Readlink(filepath.Join(root, "subscription.yaml"))
if err != nil {
t.Fatalf("read subscription link: %v", err)
}
if want := filepath.Join(runtimeDir, "subscription.yaml"); linkTarget != want {
t.Fatalf("subscription link = %q, want %q", linkTarget, want)
}
assertFileContent(t, filepath.Join(tempDir, "runtime", "subscription.yaml"), "proxies:\n - name: initial\n")
}
func TestValidateSubscriptionURL(t *testing.T) {
+14 -4
View File
@@ -47,7 +47,7 @@ assert_published_ports() {
fi
}
assert_web_login() {
assert_web_login_and_start() {
web_port=$1
docker run --rm --network host \
--env "WEB_PORT=${web_port}" \
@@ -74,8 +74,18 @@ assert_web_login() {
--data-urlencode "csrf=${login_csrf}" \
--data-urlencode "password=${ADMIN_PASSWORD}" \
"http://127.0.0.1:${WEB_PORT}/login" >/dev/null
config_html=$(curl --fail --silent --show-error --cookie "$cookie" \
"http://127.0.0.1:${WEB_PORT}/config")
api_csrf=$(printf "%s" "$config_html" | sed -n "s/.*name=\"csrf-token\" content=\"\([^\"]*\)\".*/\1/p" | head -1)
test -n "$api_csrf"
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" | grep -F "\"ok\":true" >/dev/null
curl --fail --silent --show-error --cookie "$cookie" \
"http://127.0.0.1:${WEB_PORT}/config" | grep -F csrf-token >/dev/null
"http://127.0.0.1:${WEB_PORT}/api/status" | grep -F "\"running\":true" >/dev/null
'
}
@@ -172,7 +182,7 @@ docker run --detach \
wait_for_health
assert_published_ports
web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
assert_web_login "$web_port"
assert_web_login_and_start "$web_port"
host_gateway=$(docker network inspect "$network" --format '{{(index .IPAM.Config 0).Gateway}}')
container_ip=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container")
@@ -235,6 +245,6 @@ docker run --detach \
wait_for_health
assert_published_ports
web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
assert_web_login "$web_port"
assert_web_login_and_start "$web_port"
echo "container smoke test passed: fresh volume fails closed; authenticated 9091 survives same-volume rebuild; only 7890/9091 are published"