HH-635: migrate legacy managed configs
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -47,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. 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.
|
||||
`/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. It atomically migrates only the exact legacy packaged `GEOIP,CN` configuration to version 1's local `ChinaIp` rule and records `.mohomo-docker-config-version`; a customized legacy `GEOIP,CN` configuration is preserved and startup fails with an explicit remediation message. 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
|
||||
|
||||
@@ -66,7 +66,7 @@ The GitHub Actions workflow builds `linux/amd64`, runs tests first, publishes on
|
||||
./tests/container-smoke.sh
|
||||
```
|
||||
|
||||
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.
|
||||
The unit suite checks strict fail-closed authentication-file validation, managed-config migration, provider-link recovery, atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test validates a legacy volume with networking disabled, 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
|
||||
|
||||
|
||||
@@ -41,10 +41,11 @@ func main() {
|
||||
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 admin_password_initialized=%t",
|
||||
"bootstrap: ready root=%s core_initialized=%t config_initialized=%t config_migrated=%t server_settings_changed=%t admin_password_initialized=%t",
|
||||
root,
|
||||
result.CoreInitialized,
|
||||
result.ConfigInitialized,
|
||||
result.ConfigMigrated,
|
||||
result.ServerSettingsChanged,
|
||||
adminPasswordInitialized,
|
||||
)
|
||||
|
||||
+129
-10
@@ -1,6 +1,7 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -18,8 +19,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxSubscriptionSize = 16 << 20
|
||||
minAdminPasswordLength = 12
|
||||
maxSubscriptionSize = 16 << 20
|
||||
minAdminPasswordLength = 12
|
||||
managedConfigVersion = "1"
|
||||
managedConfigVersionFile = ".mohomo-docker-config-version"
|
||||
managedChinaIPProvider = " ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}\n"
|
||||
managedChinaIPRule = " - RULE-SET,ChinaIp,🎯 全球直连"
|
||||
legacyChinaIPRule = " - GEOIP,CN,🎯 全球直连"
|
||||
defaultControllerURL = "http://127.0.0.1:9090"
|
||||
)
|
||||
|
||||
var errMihomoStateUncertain = errors.New("Mihomo subscription state could not be restored")
|
||||
@@ -55,6 +62,7 @@ type Config struct {
|
||||
type Result struct {
|
||||
CoreInitialized bool
|
||||
ConfigInitialized bool
|
||||
ConfigMigrated bool
|
||||
ServerSettingsChanged bool
|
||||
}
|
||||
|
||||
@@ -65,6 +73,7 @@ type RuntimeConfig struct {
|
||||
ConfigSource string
|
||||
RuntimeDir string
|
||||
SubscriptionURL string
|
||||
ControllerURL string
|
||||
UpdateInterval time.Duration
|
||||
}
|
||||
|
||||
@@ -104,9 +113,13 @@ func Prepare(config Config) (Result, error) {
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("initialize Mihomo core: %w", err)
|
||||
}
|
||||
result.ConfigInitialized, err = copyIfAbsent(config.ConfigSource, filepath.Join(root, "config.yaml"), 0o644)
|
||||
result.ConfigInitialized, result.ConfigMigrated, err = prepareManagedConfig(
|
||||
config.ConfigSource,
|
||||
filepath.Join(root, "config.yaml"),
|
||||
filepath.Join(root, managedConfigVersionFile),
|
||||
)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("initialize config: %w", err)
|
||||
return result, fmt.Errorf("prepare config: %w", err)
|
||||
}
|
||||
result.ServerSettingsChanged, err = enforceServerSettings(filepath.Join(root, ".ssclash", "settings"))
|
||||
if err != nil {
|
||||
@@ -294,11 +307,15 @@ func Run(ctx context.Context, config RuntimeConfig) error {
|
||||
}
|
||||
activeSubscription := filepath.Join(runtimeDir, "subscription.yaml")
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
controllerURL := strings.TrimRight(config.ControllerURL, "/")
|
||||
if controllerURL == "" {
|
||||
controllerURL = defaultControllerURL
|
||||
}
|
||||
validate := func(candidate string) error {
|
||||
return validateSubscription(ctx, config, runtimeConfig, candidate)
|
||||
}
|
||||
reload := func(ctx context.Context) error {
|
||||
return reloadSubscription(ctx, client)
|
||||
return reloadSubscription(ctx, client, controllerURL)
|
||||
}
|
||||
if err := updateSubscription(ctx, client, config.SubscriptionURL, activeSubscription, validate); err != nil {
|
||||
return fmt.Errorf("initial subscription update failed: %w", err)
|
||||
@@ -335,7 +352,7 @@ func Run(ctx context.Context, config RuntimeConfig) error {
|
||||
}
|
||||
return fmt.Errorf("SSClash exited: %w", err)
|
||||
case <-ticker.C:
|
||||
running := mihomoRunning(ctx, client)
|
||||
running := mihomoRunning(ctx, client, controllerURL)
|
||||
var err error
|
||||
if running {
|
||||
err = updateAndReload(ctx, client, config, activeSubscription, validate, reload)
|
||||
@@ -489,8 +506,8 @@ func updateAndReload(ctx context.Context, client *http.Client, config RuntimeCon
|
||||
return errors.New("new subscription reload failed; previous subscription restored")
|
||||
}
|
||||
|
||||
func reloadSubscription(ctx context.Context, client *http.Client) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://127.0.0.1:9090/providers/proxies/subscription", nil)
|
||||
func reloadSubscription(ctx context.Context, client *http.Client, controllerURL string) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPut, controllerURL+"/providers/proxies/subscription", nil)
|
||||
if err != nil {
|
||||
return errors.New("create Mihomo reload request")
|
||||
}
|
||||
@@ -506,8 +523,8 @@ 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)
|
||||
func mihomoRunning(ctx context.Context, client *http.Client, controllerURL string) bool {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, controllerURL+"/version", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -600,6 +617,108 @@ func copyIfAbsent(source, target string, mode os.FileMode) (bool, error) {
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func prepareManagedConfig(source, target, versionPath string) (bool, bool, error) {
|
||||
if err := validateManagedConfigVersion(versionPath); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
current, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("read managed config source: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Lstat(target)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err := writeManagedConfig(target, current); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if err := writeManagedConfigVersion(versionPath); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return true, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("inspect config %q: %w", target, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return false, false, fmt.Errorf("config %q is not a regular file", target)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return false, false, fmt.Errorf("config %q is empty", target)
|
||||
}
|
||||
existing, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("read config %q: %w", target, err)
|
||||
}
|
||||
if bytes.Equal(existing, current) {
|
||||
return false, false, writeManagedConfigVersion(versionPath)
|
||||
}
|
||||
|
||||
legacy, legacyErr := legacyManagedConfig(current)
|
||||
if legacyErr == nil && bytes.Equal(existing, legacy) {
|
||||
if err := writeManagedConfig(target, current); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if err := writeManagedConfigVersion(versionPath); err != nil {
|
||||
return false, true, err
|
||||
}
|
||||
return false, true, nil
|
||||
}
|
||||
if bytes.Contains(existing, []byte("GEOIP,CN")) {
|
||||
return false, false, fmt.Errorf("custom config uses GEOIP,CN and was preserved; replace it with the packaged local ChinaIp rule before retrying")
|
||||
}
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
func legacyManagedConfig(current []byte) ([]byte, error) {
|
||||
text := string(current)
|
||||
if strings.Count(text, managedChinaIPProvider) != 1 || strings.Count(text, managedChinaIPRule) != 1 {
|
||||
return nil, errors.New("packaged config is missing the managed ChinaIp rule")
|
||||
}
|
||||
text = strings.Replace(text, managedChinaIPProvider, "", 1)
|
||||
text = strings.Replace(text, managedChinaIPRule, legacyChinaIPRule, 1)
|
||||
return []byte(text), nil
|
||||
}
|
||||
|
||||
func validateManagedConfigVersion(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect managed config version: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return errors.New("managed config version marker is not a regular file")
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read managed config version: %w", err)
|
||||
}
|
||||
if string(content) != managedConfigVersion+"\n" {
|
||||
return fmt.Errorf("unsupported managed config version %q", strings.TrimSpace(string(content)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeManagedConfig(path string, content []byte) error {
|
||||
return atomicWrite(path, 0o644, func(output *os.File) error {
|
||||
_, err := output.Write(content)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func writeManagedConfigVersion(path string) error {
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("inspect managed config version: %w", err)
|
||||
}
|
||||
return atomicWrite(path, 0o644, func(output *os.File) error {
|
||||
_, err := output.WriteString(managedConfigVersion + "\n")
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func enforceServerSettings(path string) (bool, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -91,6 +92,61 @@ func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) {
|
||||
assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "LOG_LEVEL=debug\nOPERATING_MODE=server\nPROXY_MODE=none\n")
|
||||
}
|
||||
|
||||
func TestPrepareMigratesExactLegacyManagedConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
root := filepath.Join(tempDir, "data")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := "rule-providers:\n" + managedChinaIPProvider + "rules:\n" + managedChinaIPRule + "\n"
|
||||
legacy := "rule-providers:\nrules:\n" + legacyChinaIPRule + "\n"
|
||||
writeFixture(t, root, "config.yaml", legacy)
|
||||
|
||||
result, err := Prepare(Config{
|
||||
Root: root,
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: writeFixture(t, tempDir, "mihomo", "core"),
|
||||
ConfigSource: writeFixture(t, tempDir, "current.yaml", current),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v", err)
|
||||
}
|
||||
if result.ConfigInitialized || !result.ConfigMigrated {
|
||||
t.Fatalf("Prepare() result = %+v, want migrated existing config", result)
|
||||
}
|
||||
assertFileContent(t, filepath.Join(root, "config.yaml"), current)
|
||||
assertFileContent(t, filepath.Join(root, managedConfigVersionFile), managedConfigVersion+"\n")
|
||||
}
|
||||
|
||||
func TestPreparePreservesAndRejectsCustomLegacyGeoIPConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
root := filepath.Join(tempDir, "data")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := "rule-providers:\n" + managedChinaIPProvider + "rules:\n" + managedChinaIPRule + "\n"
|
||||
custom := "rule-providers:\nrules:\n" + legacyChinaIPRule + "\n# user managed\n"
|
||||
target := writeFixture(t, root, "config.yaml", custom)
|
||||
|
||||
_, err := Prepare(Config{
|
||||
Root: root,
|
||||
SSClashTemp: filepath.Join(tempDir, "tmp"),
|
||||
CoreSource: writeFixture(t, tempDir, "mihomo", "core"),
|
||||
ConfigSource: writeFixture(t, tempDir, "current.yaml", current),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "custom config uses GEOIP,CN") {
|
||||
t.Fatalf("Prepare() error = %v, want explicit custom config migration error", err)
|
||||
}
|
||||
assertFileContent(t, target, custom)
|
||||
if _, statErr := os.Stat(filepath.Join(root, managedConfigVersionFile)); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("managed config version marker unexpectedly created: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminPasswordFailsClosedOnFreshVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -529,6 +585,74 @@ func TestRunLeavesMihomoLifecycleToSSClash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsSSClashWhenControllerCannotConfirmRollback(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
root := filepath.Join(tempDir, "root")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core := writeFixture(t, tempDir, "fake-core", "#!/bin/sh\n[ \"$1\" = -t ]\n")
|
||||
ssclash := writeFixture(t, tempDir, "fake-ssclash", "#!/bin/sh\n[ \"$1\" = serve ]\nexec sleep 3600\n")
|
||||
for _, binary := range []string{core, ssclash} {
|
||||
if err := os.Chmod(binary, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
config := writeFixture(t, root, "config.yaml", "proxy-providers:\n subscription:\n type: file\n path: ./subscription.yaml\n")
|
||||
|
||||
var subscriptionRequests atomic.Int32
|
||||
subscription := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
name := "updated"
|
||||
if subscriptionRequests.Add(1) == 1 {
|
||||
name = "initial"
|
||||
}
|
||||
_, _ = writer.Write([]byte("proxies:\n - name: " + name + "\n"))
|
||||
}))
|
||||
defer subscription.Close()
|
||||
var reloadRequests atomic.Int32
|
||||
controller := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch {
|
||||
case request.Method == http.MethodGet && request.URL.Path == "/version":
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
case request.Method == http.MethodPut && request.URL.Path == "/providers/proxies/subscription":
|
||||
reloadRequests.Add(1)
|
||||
http.Error(writer, "reload failed", http.StatusInternalServerError)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer controller.Close()
|
||||
|
||||
runResult := make(chan error, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
runResult <- Run(ctx, RuntimeConfig{
|
||||
Root: root,
|
||||
CoreBinary: core,
|
||||
SSClashBinary: ssclash,
|
||||
ConfigSource: config,
|
||||
RuntimeDir: filepath.Join(tempDir, "runtime"),
|
||||
SubscriptionURL: subscription.URL,
|
||||
ControllerURL: controller.URL,
|
||||
UpdateInterval: 20 * time.Millisecond,
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-runResult:
|
||||
if !errors.Is(err, errMihomoStateUncertain) {
|
||||
t.Fatalf("Run() error = %v, want uncertain Mihomo state", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run() did not stop SSClash after rollback reload failure")
|
||||
}
|
||||
if subscriptionRequests.Load() < 2 || reloadRequests.Load() != 2 {
|
||||
t.Fatalf("requests = subscription:%d reload:%d, want at least 2 and exactly 2", subscriptionRequests.Load(), reloadRequests.Load())
|
||||
}
|
||||
assertFileContent(t, filepath.Join(tempDir, "runtime", "subscription.yaml"), "proxies:\n - name: initial\n")
|
||||
}
|
||||
|
||||
func TestValidateSubscriptionURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -5,20 +5,22 @@ image=${1:-mohomo-docker:smoke}
|
||||
suffix="$$"
|
||||
container="mohomo-docker-smoke-${suffix}"
|
||||
unconfigured="mohomo-docker-unconfigured-${suffix}"
|
||||
legacy_container="mohomo-docker-legacy-${suffix}"
|
||||
provider="mohomo-provider-smoke-${suffix}"
|
||||
network="mohomo-network-smoke-${suffix}"
|
||||
volume="mohomo-volume-smoke-${suffix}"
|
||||
legacy_volume="mohomo-legacy-volume-smoke-${suffix}"
|
||||
secret="container-smoke-secret"
|
||||
admin_password="container-smoke-admin-password"
|
||||
|
||||
case "$container:$unconfigured:$provider:$network:$volume" in
|
||||
mohomo-docker-smoke-*':mohomo-docker-unconfigured-'*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-volume-smoke-'*) ;;
|
||||
case "$container:$unconfigured:$legacy_container:$provider:$network:$volume:$legacy_volume" in
|
||||
mohomo-docker-smoke-*':mohomo-docker-unconfigured-'*':mohomo-docker-legacy-'*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-volume-smoke-'*':mohomo-legacy-volume-smoke-'*) ;;
|
||||
*) echo "refusing unsafe cleanup targets" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
cleanup() {
|
||||
docker container rm --force "$container" "$unconfigured" "$provider" >/dev/null 2>&1 || true
|
||||
docker volume rm "$volume" >/dev/null 2>&1 || true
|
||||
docker container rm --force "$container" "$unconfigured" "$legacy_container" "$provider" >/dev/null 2>&1 || true
|
||||
docker volume rm "$volume" "$legacy_volume" >/dev/null 2>&1 || true
|
||||
docker network rm "$network" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
@@ -114,6 +116,46 @@ docker run --rm --network none --entrypoint /bin/sh "$image" -c '
|
||||
/usr/local/lib/ssclash/clash -t -d "$runtime" -f "$runtime/config.yaml"
|
||||
' >/dev/null
|
||||
|
||||
docker volume create "$legacy_volume" >/dev/null
|
||||
docker run --rm \
|
||||
--volume "$legacy_volume:/opt/clash" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image" -c '
|
||||
set -eu
|
||||
grep -v -F " ChinaIp: {type: file, behavior: ipcidr, format: yaml, path: /usr/local/share/ssclash/rules/ChinaIp.yaml}" \
|
||||
/usr/local/share/ssclash/config.yaml \
|
||||
| sed "s/^ - RULE-SET,ChinaIp,/ - GEOIP,CN,/" \
|
||||
> /opt/clash/config.yaml
|
||||
grep -F " - GEOIP,CN,🎯 全球直连" /opt/clash/config.yaml >/dev/null
|
||||
'
|
||||
if docker run \
|
||||
--name "$legacy_container" \
|
||||
--network none \
|
||||
--env "SUBSCRIPTION_URL=http://127.0.0.1:9/provider.yaml" \
|
||||
--env "SSCLASH_PASSWORD=${admin_password}" \
|
||||
--volume "$legacy_volume:/opt/clash" \
|
||||
"$image" >/dev/null 2>&1; then
|
||||
echo "legacy volume unexpectedly started without a subscription network" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker logs "$legacy_container" 2>&1 | grep -F 'initial subscription update failed: subscription request failed' >/dev/null
|
||||
if docker logs "$legacy_container" 2>&1 | grep -F 'geoip.metadb' >/dev/null; then
|
||||
echo "legacy managed config attempted a GeoIP download" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker run --rm --network none \
|
||||
--volume "$legacy_volume:/opt/clash" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image" -c '
|
||||
set -eu
|
||||
cmp /opt/clash/config.yaml /usr/local/share/ssclash/config.yaml
|
||||
test "$(cat /opt/clash/.mohomo-docker-config-version)" = 1
|
||||
runtime=$(mktemp -d)
|
||||
cp /opt/clash/config.yaml "$runtime/config.yaml"
|
||||
printf "proxies:\n - name: smoke-node\n type: socks5\n server: 127.0.0.1\n port: 9\n" > "$runtime/subscription.yaml"
|
||||
/usr/local/lib/ssclash/clash -t -d "$runtime" -f "$runtime/config.yaml"
|
||||
' >/dev/null
|
||||
|
||||
docker network create "$network" >/dev/null
|
||||
docker volume create "$volume" >/dev/null
|
||||
docker run --detach --rm \
|
||||
@@ -247,4 +289,4 @@ assert_published_ports
|
||||
web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||
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"
|
||||
echo "container smoke test passed: legacy config migrates offline; fresh volume fails closed; authenticated 9091 survives same-volume rebuild; only 7890/9091 are published"
|
||||
|
||||
Reference in New Issue
Block a user