feat(mihomo): persistent core updates with rollback (#53)

This commit is contained in:
freephilx authored and GitHub committed 2026-09-11 22:44:52 +08:00
1 parent 437ad5eee8
commit 3ec69d55de
30 files changed
+2139 -106

No files matched your search

+3 -2
View File
@@ -50,8 +50,9 @@ COPY distribution/MIHOMO_LICENSE /usr/local/share/licenses/mihomo/LICENSE
COPY LICENSE /usr/local/share/licenses/3m-ui/LICENSE
RUN printf 'Mihomo source and build scripts: https://github.com/MetaCubeX/mihomo/tree/%s\n' \
"$(cat /usr/local/lib/3m-ui/MIHOMO_VERSION)" > /usr/local/share/licenses/mihomo/SOURCE
# Immutable image files remain root-owned. Upgrade the reviewed panel/core pair
# by replacing the image; only configuration, state and logs are writable.
# Image files remain root-owned. The bundled core is the initial fallback;
# administrator-selected versions persist under /var/lib/3m-ui/mihomo/cores.
# Panel image updates preserve that selection through the existing data volume.
ENV THREE_M_UI_CONFIG=/etc/3m-ui/config.yaml \
THREE_M_UI_CONTAINER=1
VOLUME ["/etc/3m-ui", "/var/lib/3m-ui", "/var/log/3m-ui"]
+1 -1
View File
@@ -82,7 +82,7 @@ func Run(frontendFS fs.FS) error {
return fmt.Errorf("initialize Mihomo service: service is nil")
}
if generatedConfig != "" {
if _, statErr := os.Stat(cfg.Mihomo.Binary); statErr == nil {
if _, statErr := os.Stat(container.Mihomo.BinaryPath()); statErr == nil {
// Soft-fail: Mihomo problems must not prevent the management panel
// from starting. Operators can inspect logs and fix listeners/config
// from the UI, then start/restart the core explicitly.
+1 -1
View File
@@ -43,7 +43,7 @@ func NewContainer(db *gorm.DB, cfg *config.Config) *Container {
userSvc := user.NewService(db)
systemSvc := system.NewService()
userSvc.SetCredentialsChangedHandler(func() error {
return nodeSvc.RegenerateConfig()
return mihomoSvc.SyncCredentials(nodeSvc.RegenerateConfig)
})
trafficSvc := traffic.NewService()
+33
View File
@@ -3,6 +3,8 @@ package config
import (
"fmt"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -274,3 +276,34 @@ type SecurityConfig struct {
CredentialKey string `yaml:"credential_key"`
CORSOrigins []string `yaml:"cors_origins"`
}
// GetSubscriptionURL builds a public subscription link without service dependencies.
func GetSubscriptionURL(cfg *Config, req *http.Request, token string, target string) string {
var base string
if envURL := os.Getenv("PUBLIC_URL"); envURL != "" {
base = strings.TrimSpace(envURL)
} else if cfg != nil && cfg.Server.PublicURL != "" {
base = strings.TrimSpace(cfg.Server.PublicURL)
} else if req != nil && req.Host != "" {
scheme := "http"
if req.TLS != nil {
scheme = "https"
} else if proto := req.Header.Get("X-Forwarded-Proto"); strings.EqualFold(proto, "https") {
scheme = "https"
}
// req.Host already includes [ipv6]:port when applicable.
base = fmt.Sprintf("%s://%s", scheme, req.Host)
} else {
base = "http://127.0.0.1:8080"
}
base = strings.TrimSuffix(base, "/")
pathToken := url.PathEscape(token)
subBase := SubscriptionBasePath(cfg)
// When SubPort is set and PublicURL does not already include a non-panel port,
// leave host as PublicURL; operators should set PublicURL to the subscription
// entrypoint they want clients to use (recommended).
if target == "" {
return fmt.Sprintf("%s%s/%s", base, subBase, pathToken)
}
return fmt.Sprintf("%s%s/%s?target=%s", base, subBase, pathToken, url.QueryEscape(strings.ToLower(strings.TrimSpace(target))))
}
+22 -1
View File
@@ -1,6 +1,9 @@
package config
import "testing"
import (
"net/http/httptest"
"testing"
)
func TestIsMihomoListenerProtocol(t *testing.T) {
supported := []string{
@@ -72,3 +75,21 @@ func TestControllerOverrideRemainsLocal(t *testing.T) {
}
}
}
func TestGetSubscriptionURLUsesSharedConfiguration(t *testing.T) {
t.Setenv("PUBLIC_URL", "")
cfg := &Config{Server: ServerConfig{PublicURL: "https://panel.example", SubPath: "/custom/sub"}}
req := httptest.NewRequest("GET", "http://internal.example/", nil)
if got := GetSubscriptionURL(cfg, req, "token/a", " Clash "); got != "https://panel.example/custom/sub/token%2Fa?target=clash" {
t.Fatal(got)
}
t.Setenv("PUBLIC_URL", "https://public.example/")
if got := GetSubscriptionURL(cfg, req, "abc", ""); got != "https://public.example/custom/sub/abc" {
t.Fatal(got)
}
t.Setenv("PUBLIC_URL", "")
req.Header.Set("X-Forwarded-Proto", "https")
if got := GetSubscriptionURL(nil, req, "abc", ""); got != "https://internal.example/api/v1/client/sub/abc" {
t.Fatal(got)
}
}
+1 -28
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -60,33 +59,7 @@ func cleanURLHost(raw string) string {
}
func GetSubscriptionURL(cfg *config.Config, req *http.Request, token string, target string) string {
var base string
if envURL := os.Getenv("PUBLIC_URL"); envURL != "" {
base = strings.TrimSpace(envURL)
} else if cfg != nil && cfg.Server.PublicURL != "" {
base = strings.TrimSpace(cfg.Server.PublicURL)
} else if req != nil && req.Host != "" {
scheme := "http"
if req.TLS != nil {
scheme = "https"
} else if proto := req.Header.Get("X-Forwarded-Proto"); strings.EqualFold(proto, "https") {
scheme = "https"
}
// req.Host already includes [ipv6]:port when applicable.
base = fmt.Sprintf("%s://%s", scheme, req.Host)
} else {
base = "http://127.0.0.1:8080"
}
base = strings.TrimSuffix(base, "/")
pathToken := url.PathEscape(token)
subBase := config.SubscriptionBasePath(cfg)
// When SubPort is set and PublicURL does not already include a non-panel port,
// leave host as PublicURL; operators should set PublicURL to the subscription
// entrypoint they want clients to use (recommended).
if target == "" {
return fmt.Sprintf("%s%s/%s", base, subBase, pathToken)
}
return fmt.Sprintf("%s%s/%s?target=%s", base, subBase, pathToken, url.QueryEscape(strings.ToLower(strings.TrimSpace(target))))
return config.GetSubscriptionURL(cfg, req, token, target)
}
func GenerateRawConfig(db *gorm.DB, token models.AccessToken, req *http.Request) ([]byte, error) {
+49
View File
@@ -706,6 +706,55 @@ paths:
"200":
description: Connection list
/mihomo/releases:
get:
tags: [mihomo]
summary: List verifiable official stable and Pre Mihomo releases for this platform
responses:
"200":
description: Release list with version, published_at, url, asset, sha256, size, and prerelease
"502":
description: Official release metadata is unavailable
/mihomo/update:
get:
tags: [mihomo]
summary: Get core update capability, saved previous version, and latest job
responses:
"200":
description: supported, disabled_reason, busy, managed, previous_version, and job
post:
tags: [mihomo]
summary: Start an asynchronous official core update (administrator only)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [version]
properties:
version:
type: string
pattern: '^(v[0-9]+\.[0-9]+\.[0-9]+|alpha-[a-f0-9]{7,40})$'
responses:
"202":
description: Job with id, operation, version, status, stage, and started_at; poll GET /mihomo/update
"400":
description: Invalid version or unavailable persistent storage/platform
"409":
description: Another core update or deployment is in progress
/mihomo/update/rollback:
post:
tags: [mihomo]
summary: Restore the saved previous core asynchronously (administrator only)
responses:
"202":
description: Accepted rollback job; failures include error and rolled_back
"400":
description: No previous core is available or this platform is unsupported
"409":
description: Another update or deployment is in progress
/mihomo/status:
get:
tags: [mihomo]
+3 -3
View File
@@ -204,7 +204,7 @@ func (s *Service) CheckConnection(ctx context.Context, l models.Listener, export
}
check.Address = address
check.Steps = append(check.Steps, ConnectionStep{"client_config", "passed", "client_config_ready"})
runClientCheck(ctx, binary, proxy, &check, []string{"https://www.gstatic.com/generate_204", "https://cp.cloudflare.com/generate_204"})
runClientCheck(ctx, binary, proxy, &check, []string{"https://www.gstatic.com/generate_204", "https://cp.cloudflare.com/generate_204"}, s.pm.allowedBinary)
return finish(check.Reason)
}
@@ -256,7 +256,7 @@ func exportedClientProxy(clientYAML string) (map[string]any, string, error) {
return proxy, net.JoinHostPort(address, strconv.Itoa(p)), nil
}
func runClientCheck(ctx context.Context, binary string, proxy map[string]any, check *ConnectionCheck, targets []string) {
func runClientCheck(ctx context.Context, binary string, proxy map[string]any, check *ConnectionCheck, targets []string, allowed func(string) bool) {
check.State, check.Reason = "unknown", "client_start_failed"
ready := false
defer func() {
@@ -264,7 +264,7 @@ func runClientCheck(ctx context.Context, binary string, proxy map[string]any, ch
check.Steps = append(check.Steps, ConnectionStep{"client_start", "unknown", "client_start_failed"})
}
}()
if runtime.GOOS == "windows" || !isAllowedBinaryPath(binary) {
if runtime.GOOS == "windows" || !allowed(binary) {
return
}
dir, err := os.MkdirTemp("", "3m-node-check-")
@@ -328,7 +328,7 @@ func TestRealMihomoLocalConnectionCheck(t *testing.T) {
if err != nil {
t.Fatal(err)
}
runClientCheck(ctx, binary, prepared, &check, []string{target.URL, target.URL + "/backup"})
runClientCheck(ctx, binary, prepared, &check, []string{target.URL, target.URL + "/backup"}, isAllowedBinaryPath)
cancel()
want := "unavailable"
if good {
@@ -373,7 +373,7 @@ func TestRealMihomoLocalConnectionCheck(t *testing.T) {
count := requests.Load()
check := ConnectionCheck{}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
runClientCheck(ctx, binary, prepared, &check, []string{target.URL, target.URL + "/backup"})
runClientCheck(ctx, binary, prepared, &check, []string{target.URL, target.URL + "/backup"}, isAllowedBinaryPath)
cancel()
if check.State != "unavailable" || requests.Load() != count || !pm.IsRunning() {
t.Fatalf("unreachable exported endpoint %s fell back to local listener: %+v", addr, check)
@@ -399,7 +399,7 @@ func TestRealMihomoLocalConnectionCheck(t *testing.T) {
done := make(chan struct{})
go func() {
check := ConnectionCheck{}
runClientCheck(ctx, binary, proxy, &check, []string{slow.URL})
runClientCheck(ctx, binary, proxy, &check, []string{slow.URL}, isAllowedBinaryPath)
close(done)
}()
select {
+196
View File
@@ -0,0 +1,196 @@
package mihomo
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type coreArtifact struct {
Version string `json:"version"`
SHA256 string `json:"sha256"`
}
type coreSelection struct {
Schema int `json:"schema"`
Active *coreArtifact `json:"active"`
Previous *coreArtifact `json:"previous,omitempty"`
}
func (u *coreUpdater) artifactPath(a *coreArtifact) string {
return filepath.Join(u.dir, "mihomo-"+a.SHA256)
}
func (u *coreUpdater) verifyArtifact(a *coreArtifact) error {
if a == nil || !sha256Pattern.MatchString(a.SHA256) || a.Version == "" {
return fmt.Errorf("invalid saved core version")
}
path := u.artifactPath(a)
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Mode()&0111 == 0 || info.Size() > maxBinaryBytes {
return fmt.Errorf("saved core is not a regular executable")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
if hex.EncodeToString(h.Sum(nil)) != a.SHA256 {
return fmt.Errorf("saved core checksum mismatch")
}
return nil
}
func (u *coreUpdater) prepareDirectory() error {
if err := os.MkdirAll(u.dir, 0700); err != nil {
return fmt.Errorf("create persistent core directory: %w", err)
}
real, err := filepath.EvalSymlinks(u.dir)
if err != nil || real != u.dir {
return fmt.Errorf("persistent core directory must not contain symbolic links")
}
return nil
}
// publishBinary installs an immutable, content-addressed file. It never replaces
// the bundled binary, nor a file that a running core may still be executing.
func (u *coreUpdater) publishBinary(reader io.Reader, version string) (*coreArtifact, error) {
f, err := os.CreateTemp(u.dir, ".core-*")
if err != nil {
return nil, err
}
defer os.Remove(f.Name())
defer f.Close()
h := sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(reader, maxBinaryBytes+1))
if err != nil {
return nil, err
}
if n == 0 || n > maxBinaryBytes {
return nil, fmt.Errorf("core binary exceeds size limit or is empty")
}
if err := f.Chmod(0700); err != nil {
return nil, err
}
if err := f.Sync(); err != nil {
return nil, err
}
if err := f.Close(); err != nil {
return nil, err
}
a := &coreArtifact{version, hex.EncodeToString(h.Sum(nil))}
if _, err := os.Lstat(u.artifactPath(a)); err == nil {
return a, u.verifyArtifact(a)
} else if !os.IsNotExist(err) {
return nil, err
}
if err := os.Rename(f.Name(), u.artifactPath(a)); err != nil {
return nil, err
}
return a, nil
}
func (u *coreUpdater) loadSelection() error {
raw, err := os.ReadFile(filepath.Join(u.dir, "selection.json"))
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if err := u.prepareDirectory(); err != nil {
return err
}
var state coreSelection
if err := json.Unmarshal(raw, &state); err != nil {
return err
}
if state.Schema != 1 {
return fmt.Errorf("unsupported core selection schema")
}
if err := u.verifyArtifact(state.Active); err != nil {
return err
}
// A damaged rollback copy must not prevent a valid active core from starting.
u.selection = state
return nil
}
func (u *coreUpdater) saveSelection(state coreSelection) error {
raw, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
f, err := os.CreateTemp(u.dir, ".selection-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
defer f.Close()
if _, err := f.Write(raw); err != nil {
return err
}
if err := f.Sync(); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
// Until this atomic commit, a panel restart selects the previous known-good
// binary. Candidates are never persisted before successful runtime checks.
if err := os.Rename(f.Name(), filepath.Join(u.dir, "selection.json")); err != nil {
return err
}
if dir, err := os.Open(u.dir); err == nil {
_ = dir.Sync()
_ = dir.Close()
}
return nil
}
func (u *coreUpdater) pruneBinaries(activePath string) {
keep := map[string]bool{}
if filepath.Dir(activePath) == u.dir {
keep[filepath.Base(activePath)] = true
}
for _, a := range []*coreArtifact{u.selection.Active, u.selection.Previous} {
if a != nil {
keep["mihomo-"+a.SHA256] = true
}
}
entries, _ := os.ReadDir(u.dir)
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, "mihomo-") && len(name) == len("mihomo-")+64 && sha256Pattern.MatchString(name[len("mihomo-"):]) && !keep[name] {
_ = os.Remove(filepath.Join(u.dir, name))
}
}
}
// saveJob persists one bounded job record so a browser or panel restart can
// explain an interrupted update. It contains no config or credentials.
func (u *coreUpdater) saveJob() error {
raw, err := json.Marshal(u.job)
if err != nil {
return err
}
f, err := os.CreateTemp(u.dir, ".job-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
defer f.Close()
if _, err = f.Write(raw); err != nil {
return err
}
if err = f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), filepath.Join(u.dir, "job.json"))
}
+479
View File
@@ -0,0 +1,479 @@
package mihomo
import (
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"syscall"
"time"
)
var ErrCoreUpdateBusy = fmt.Errorf("a core update is in progress; wait for it to finish")
type CoreUpdateJob struct {
ID string `json:"id"`
Operation string `json:"operation"`
Version string `json:"version"`
Status string `json:"status"`
Stage string `json:"stage"`
Error string `json:"error,omitempty"`
RolledBack bool `json:"rolled_back"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
type CoreUpdateStatus struct {
Supported bool `json:"supported"`
DisabledReason string `json:"disabled_reason,omitempty"`
Busy bool `json:"busy"`
Managed bool `json:"managed"`
PreviousVersion string `json:"previous_version,omitempty"`
Job *CoreUpdateJob `json:"job,omitempty"`
}
type coreUpdater struct {
lock *os.File
mu sync.Mutex
dir string
selection coreSelection
job *CoreUpdateJob
initErr error
client *http.Client
releaseMu sync.Mutex
releases []CoreRelease
releasesAt time.Time
// Regenerate from the latest database after the job, never replay old YAML.
pendingCredentials func() error
}
// SyncCredentials preserves a committed user/binding change when a core update
// temporarily rejects ApplyConfig. Other configuration edits still fail fast.
func (s *Service) SyncCredentials(regenerate func() error) error {
if s.updater == nil {
return regenerate()
}
u := s.updater
for {
u.mu.Lock()
if u.job != nil && u.job.Status == "running" {
u.pendingCredentials = regenerate
u.mu.Unlock()
return ErrCoreUpdateBusy
}
u.mu.Unlock()
if err := regenerate(); !errors.Is(err, ErrCoreUpdateBusy) {
return err
}
// An update began during regeneration. Queue under the same mutex used
// to finish it, or retry if it has already finished, so no wakeup is lost.
}
}
func (s *Service) initCoreUpdater(configPath string) {
if configPath == "" {
return
}
dir, err := filepath.Abs(filepath.Join(filepath.Dir(configPath), "cores"))
u := &coreUpdater{dir: dir, client: coreHTTPClient(), initErr: err}
if err == nil {
u.initErr = u.loadSelection()
}
if u.initErr == nil {
if raw, err := os.ReadFile(filepath.Join(dir, "job.json")); err == nil && len(raw) < 65536 {
var job CoreUpdateJob
if json.Unmarshal(raw, &job) == nil && job.ID != "" {
if job.Status == "running" {
now := time.Now().UTC()
job.Status = "failed"
job.Stage = "interrupted"
job.Error = "Panel restarted during an update; check the selected core version before retrying"
job.FinishedAt = &now
}
u.job = &job
}
}
}
s.updater = u
s.pm.managedDir = dir
if u.initErr == nil && u.selection.Active != nil {
s.pm.binaryPath = u.artifactPath(u.selection.Active)
}
}
func (s *Service) CoreUpdateStatus() CoreUpdateStatus {
result := CoreUpdateStatus{}
if s == nil || s.updater == nil {
result.DisabledReason = "core updates are unavailable"
return result
}
u := s.updater
u.mu.Lock()
defer u.mu.Unlock()
_, err := corePlatform()
if u.initErr != nil {
err = fmt.Errorf("saved core selection is unavailable: %w", u.initErr)
}
if err != nil {
result.DisabledReason = err.Error()
} else {
result.Supported = true
}
result.Managed = u.selection.Active != nil
if u.selection.Previous != nil {
result.PreviousVersion = u.selection.Previous.Version
}
if u.job != nil {
copy := *u.job
result.Job = &copy
result.Busy = copy.Status == "running"
}
return result
}
func (s *Service) coreMutationAllowed() error {
if s.updater == nil {
return nil
}
u := s.updater
u.mu.Lock()
defer u.mu.Unlock()
if u.initErr != nil {
return fmt.Errorf("saved core selection is unavailable: %w", u.initErr)
}
if u.job != nil && u.job.Status == "running" {
return ErrCoreUpdateBusy
}
return nil
}
// BeginCoreUpdate returns immediately; the job lives independently of the HTTP
// request, so navigating away cannot interrupt the switch or its recovery.
func (s *Service) BeginCoreUpdate(version string, rollback bool) (*CoreUpdateJob, error) {
if s == nil || s.updater == nil {
return nil, fmt.Errorf("core updates are unavailable")
}
if _, err := corePlatform(); err != nil {
return nil, err
}
if !rollback && !stableVersion.MatchString(version) && !alphaVersion.MatchString(version) {
return nil, fmt.Errorf("select an official stable or pre version")
}
u := s.updater
u.mu.Lock()
defer u.mu.Unlock()
if u.initErr != nil {
return nil, fmt.Errorf("saved core selection is unavailable: %w", u.initErr)
}
if u.job != nil && u.job.Status == "running" {
return nil, ErrCoreUpdateBusy
}
operation := "install"
if rollback {
if u.selection.Previous == nil {
return nil, fmt.Errorf("no previous core version is available")
}
operation = "rollback"
version = u.selection.Previous.Version
}
if err := u.prepareDirectory(); err != nil {
return nil, err
}
lockPath := filepath.Join(u.dir, "update.lock")
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600)
if err != nil {
return nil, fmt.Errorf("open core update lock: %w", err)
}
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
lock.Close()
return nil, ErrCoreUpdateBusy
}
accepted := false
defer func() {
if !accepted {
_ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
lock.Close()
}
}()
// Check write permissions before returning an accepted job.
f, err := os.CreateTemp(u.dir, ".writable-*")
if err != nil {
return nil, fmt.Errorf("persistent core directory is not writable: %w", err)
}
f.Close()
os.Remove(f.Name())
now := time.Now().UTC()
u.job = &CoreUpdateJob{ID: fmt.Sprintf("%d", now.UnixNano()), Operation: operation, Version: version, Status: "running", Stage: "preparing", StartedAt: now}
if err := u.saveJob(); err != nil {
u.job = nil
return nil, err
}
u.lock = lock
accepted = true
job := *u.job
go s.runCoreUpdate(version, rollback)
return &job, nil
}
func (u *coreUpdater) stage(stage string) {
u.mu.Lock()
defer u.mu.Unlock()
u.job.Stage = stage
_ = u.saveJob()
}
func (s *Service) runCoreUpdate(version string, rollback bool) {
rolledBack, err := s.performCoreUpdate(version, rollback)
s.finishCoreUpdate(rolledBack, err)
}
func (s *Service) finishCoreUpdate(rolledBack bool, err error) {
u := s.updater
u.mu.Lock()
now := time.Now().UTC()
u.job.FinishedAt = &now
u.job.RolledBack = rolledBack
if err != nil {
u.job.Status = "failed"
u.job.Error = err.Error()
} else {
u.job.Status = "succeeded"
u.job.Stage = "complete"
}
_ = u.saveJob()
// Retain selected/previous binaries and the current process path even when
// recovery failed. Invalid candidates must not accumulate across retries.
u.pruneBinaries(s.pm.BinaryPath())
if u.lock != nil {
_ = syscall.Flock(int(u.lock.Fd()), syscall.LOCK_UN)
_ = u.lock.Close()
u.lock = nil
}
pending := u.pendingCredentials
u.pendingCredentials = nil
u.mu.Unlock()
// The callback takes the listener/config locks and may queue itself for a
// newer update. Never invoke it while holding the updater mutex.
if pending != nil {
if err := s.SyncCredentials(pending); err != nil && !errors.Is(err, ErrCoreUpdateBusy) {
log.Printf("Mihomo credential synchronization after core update failed: %v", err)
}
}
}
func (s *Service) downloadCore(version string) (*coreArtifact, error) {
u := s.updater
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
platform, err := corePlatform()
if err != nil {
return nil, err
}
u.stage("resolving")
tag := version
if alphaVersion.MatchString(version) {
tag = alphaReleaseTag
} else if !stableVersion.MatchString(version) {
return nil, fmt.Errorf("select an official stable or pre version")
}
var metadata githubRelease
if err := u.readReleaseJSON(ctx, releaseAPI+"/tags/"+tag, &metadata); err != nil {
return nil, err
}
release, ok := officialRelease(metadata, platform)
if !ok || release.Version != version {
return nil, fmt.Errorf("selected release is no longer available or has no verifiable artifact for this platform; check for updates again")
}
u.stage("downloading")
resp, err := u.get(ctx, releaseDownload+tag+"/"+release.Asset)
if err != nil {
return nil, err
}
defer resp.Body.Close()
f, err := os.CreateTemp(u.dir, ".download-*")
if err != nil {
return nil, err
}
defer os.Remove(f.Name())
defer f.Close()
h := sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(resp.Body, maxArchiveBytes+1))
if err != nil {
return nil, err
}
if n != release.Size || n > maxArchiveBytes {
return nil, fmt.Errorf("release archive size mismatch")
}
u.stage("verifying")
if hex.EncodeToString(h.Sum(nil)) != release.SHA256 {
return nil, fmt.Errorf("release archive SHA-256 mismatch")
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return nil, err
}
gz, err := gzip.NewReader(f)
if err != nil {
return nil, err
}
defer gz.Close()
return u.publishBinary(gz, version)
}
func (s *Service) performCoreUpdate(version string, rollback bool) (bool, error) {
u := s.updater
var candidate *coreArtifact
var err error
if rollback {
u.mu.Lock()
candidate = u.selection.Previous
u.mu.Unlock()
if err = u.verifyArtifact(candidate); err != nil {
return false, fmt.Errorf("previous core unavailable: %w", err)
}
} else {
candidate, err = s.downloadCore(version)
if err != nil {
return false, err
}
}
// Serialize config writes and process lifecycle only after download. The
// public mutation methods reject changes during the job instead of queuing
// a stale operation that unexpectedly runs once an update finishes.
s.applyMu.Lock()
defer s.applyMu.Unlock()
u.stage("validating")
check := NewProcessManager(u.artifactPath(candidate), s.pm.configPath)
check.managedDir = u.dir
info, err := check.GetVersion()
if err != nil {
return false, fmt.Errorf("candidate is not a runnable Mihomo core: %w", err)
}
if info.Version != version {
return false, fmt.Errorf("candidate version does not match selected release")
}
if err := check.ValidateConfig(); err != nil {
return false, err
}
content, err := s.cm.ReadConfig()
if err != nil {
return false, err
}
wasRunning := s.pm.IsRunning()
if wasRunning && !rollback {
if err := s.checkUpdateReadiness(content); err != nil {
return false, fmt.Errorf("current core is not healthy; resolve its listener errors before updating: %w", err)
}
}
oldPath := s.pm.BinaryPath()
oldVersion, err := s.pm.GetVersion()
if err != nil {
return false, fmt.Errorf("read current core version: %w", err)
}
oldFile, err := os.Open(oldPath)
if err != nil {
return false, err
}
previous, err := u.publishBinary(oldFile, oldVersion.Version)
oldFile.Close()
if err != nil {
return false, fmt.Errorf("back up current core: %w", err)
}
u.mu.Lock()
oldState := u.selection
u.mu.Unlock()
// Snapshot the initial bundled core as well, so rollback still works after a
// future panel image changes its bundled version.
if oldState.Active == nil {
oldState = coreSelection{Schema: 1, Active: previous}
if err := u.saveSelection(oldState); err != nil {
return false, err
}
u.mu.Lock()
u.selection = oldState
u.mu.Unlock()
}
if candidate.SHA256 == previous.SHA256 {
return false, nil
}
u.stage("switching")
if err := s.pm.quiesce(); err != nil {
return false, err
}
s.pm.setBinaryPath(u.artifactPath(candidate))
recoverOld := func(cause error) (bool, error) {
u.stage("recovering")
if err := s.pm.quiesce(); err != nil {
return false, fmt.Errorf("%v; could not stop candidate: %w", cause, err)
}
s.pm.setBinaryPath(u.artifactPath(previous))
if wasRunning {
if err := s.pm.Start(); err != nil {
return false, fmt.Errorf("%v; previous core restart failed: %w", cause, err)
}
if err := s.checkUpdateReadiness(content); err != nil {
return false, fmt.Errorf("%v; previous core recovery check failed: %w", cause, err)
}
}
return true, fmt.Errorf("%v; restored previous core", cause)
}
if wasRunning {
u.stage("starting")
if err := s.pm.Start(); err != nil {
return recoverOld(err)
}
u.stage("checking")
if err := s.checkUpdateReadiness(content); err != nil {
return recoverOld(err)
}
}
next := coreSelection{Schema: 1, Active: candidate, Previous: previous}
if err := u.saveSelection(next); err != nil {
return recoverOld(fmt.Errorf("save core selection: %w", err))
}
u.mu.Lock()
u.selection = next
u.mu.Unlock()
return false, nil
}
// Require actual inspection for updates. Ordinary ApplyConfig intentionally
// treats unavailable socket inspection as unknown; an updater must not commit
// a new binary based on that absence of evidence.
func (s *Service) checkUpdateReadiness(content string) error {
listeners, err := runtimeListeners(content)
if err != nil {
return err
}
expected := make([][]RuntimeEndpoint, len(listeners))
for i, l := range listeners {
expected[i], _ = l.endpoints()
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var inspectErr error
inspect := func(ctx context.Context, pid int) ([]runtimeConnection, error) {
rows, err := ownedConnections(ctx, pid)
inspectErr = err
return rows, err
}
if err := waitForListenerSockets(ctx, listeners, expected, s.runtimePID, inspect); err != nil {
return err
}
if inspectErr != nil {
return fmt.Errorf("could not verify core listening sockets: %w", inspectErr)
}
// Catch immediate exits even when there are no configured listeners.
timer := time.NewTimer(time.Second)
defer timer.Stop()
<-timer.C
if !s.pm.IsRunning() {
return fmt.Errorf("Mihomo stopped during readiness verification")
}
if err := waitForListenerSockets(ctx, listeners, expected, s.runtimePID, inspect); err != nil {
return err
}
if inspectErr != nil {
return fmt.Errorf("could not verify core listening sockets: %w", inspectErr)
}
return nil
}
+753
View File
@@ -0,0 +1,753 @@
package mihomo
import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
"github.com/kazeyukiro/3m-ui/backend/internal/config"
"github.com/kazeyukiro/3m-ui/backend/internal/database"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"github.com/kazeyukiro/3m-ui/backend/internal/security"
"github.com/kazeyukiro/3m-ui/backend/internal/user"
"gopkg.in/yaml.v3"
)
type coreRoundTrip func(*http.Request) (*http.Response, error)
func (f coreRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func fixtureCore(t *testing.T, body string) (*Service, *config.Config) {
t.Helper()
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(AllowBinaryPathPrefixForTesting(dir))
binary := filepath.Join(dir, "bundled")
if err := os.WriteFile(binary, []byte(fakeCore("v1.0.0", body, false)), 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte("listeners: []\n"), 0600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{Mihomo: config.MihomoConfig{Binary: binary, Config: path}}
s := NewService(cfg)
if err := s.updater.prepareDirectory(); err != nil {
t.Fatal(err)
}
return s, cfg
}
func fakeCore(version, body string, invalid bool) string {
validation := "exit 0"
if invalid {
validation = "echo invalid-config >&2; exit 1"
}
return fmt.Sprintf("#!/bin/sh\ncase \"$1\" in\n-v) echo 'Mihomo %s'; exit 0;;\n-t) %s;;\nesac\n%s\n", version, validation, body)
}
func publishFixture(t *testing.T, s *Service, version, body string, invalid bool) *coreArtifact {
t.Helper()
a, err := s.updater.publishBinary(strings.NewReader(fakeCore(version, body, invalid)), version)
if err != nil {
t.Fatal(err)
}
return a
}
func pendingRollback(s *Service, a *coreArtifact) {
s.updater.selection.Previous = a
s.updater.job = &CoreUpdateJob{Status: "running", Stage: "preparing"}
}
func TestCoreReleaseFilter(t *testing.T) {
base := githubRelease{Tag: "v1.19.30"}
if err := json.Unmarshal([]byte(`{"assets":[{"name":"mihomo-linux-arm64-v1.19.30.gz","digest":"sha256:`+strings.Repeat("a", 64)+`","size":100}]}`), &base); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
change func(*githubRelease)
ok bool
}{
{"stable", func(*githubRelease) {}, true},
{"prerelease", func(r *githubRelease) { r.Prerelease = true }, false},
{"draft", func(r *githubRelease) { r.Draft = true }, false},
{"path", func(r *githubRelease) { r.Tag = "../../etc" }, false},
{"missing digest", func(r *githubRelease) { r.Assets[0].Digest = "" }, false},
{"wrong architecture", func(r *githubRelease) { r.Assets[0].Name = "mihomo-linux-amd64-v1-v1.19.30.gz" }, false},
{"oversized", func(r *githubRelease) { r.Assets[0].Size = maxArchiveBytes + 1 }, false},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
raw, _ := json.Marshal(base)
var r githubRelease
_ = json.Unmarshal(raw, &r)
tt.change(&r)
_, ok := officialRelease(r, "linux-arm64")
if ok != tt.ok {
t.Fatalf("got %v", ok)
}
})
}
}
func TestCoreSelectionIntegrity(t *testing.T) {
s, cfg := fixtureCore(t, "exec sleep 60")
active := publishFixture(t, s, "v2.0.0", "exec sleep 60", false)
if err := s.updater.saveSelection(coreSelection{Schema: 1, Active: active}); err != nil {
t.Fatal(err)
}
reloaded := NewService(cfg)
if reloaded.BinaryPath() != s.updater.artifactPath(active) {
t.Fatal("selected binary lost on restart")
}
// Updating the image's bundled core must not reset a persisted selection.
if err := os.WriteFile(cfg.Mihomo.Binary, []byte(fakeCore("v3.0.0", "exec sleep 60", false)), 0700); err != nil {
t.Fatal(err)
}
if got := NewService(cfg).BinaryPath(); got != reloaded.BinaryPath() {
t.Fatal("image reset selected core")
}
if err := os.WriteFile(reloaded.BinaryPath(), []byte("corrupted"), 0700); err != nil {
t.Fatal(err)
}
broken := NewService(cfg)
if broken.updater.initErr == nil || broken.StartMihomo() == nil {
t.Fatal("corrupt selected core must fail closed")
}
}
func TestCoreSelectionRejectsSymlinkAndTraversal(t *testing.T) {
s, _ := fixtureCore(t, "exit 0")
if err := s.updater.verifyArtifact(&coreArtifact{Version: "v1.0.0", SHA256: "../../outside"}); err == nil {
t.Fatal("accepted traversal")
}
a := publishFixture(t, s, "v2.0.0", "exit 0", false)
path := s.updater.artifactPath(a)
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.Symlink(s.pm.BinaryPath(), path); err != nil {
t.Fatal(err)
}
if err := s.updater.verifyArtifact(a); err == nil {
t.Fatal("accepted symlink")
}
}
func TestStoppedCoreRollbackAndRestartPersistence(t *testing.T) {
s, cfg := fixtureCore(t, "exec sleep 60")
candidate := publishFixture(t, s, "v2.0.0", "exec sleep 60", false)
pendingRollback(s, candidate)
if restored, err := s.performCoreUpdate("v2.0.0", true); err != nil || restored {
t.Fatalf("%v %v", restored, err)
}
if s.pm.IsRunning() {
t.Fatal("stopped core was started")
}
if s.updater.selection.Previous.Version != "v1.0.0" {
t.Fatal("old core not backed up")
}
if NewService(cfg).BinaryPath() != s.pm.BinaryPath() {
t.Fatal("successful selection not persisted")
}
pendingRollback(s, s.updater.selection.Previous)
if _, err := s.performCoreUpdate("v1.0.0", true); err != nil {
t.Fatal(err)
}
info, err := s.pm.GetVersion()
if err != nil || info.Version != "v1.0.0" {
t.Fatalf("%v %v", info, err)
}
}
func TestInvalidCandidateLeavesCurrentSelectionUntouched(t *testing.T) {
for _, kind := range []string{"invalid config", "wrong version"} {
t.Run(kind, func(t *testing.T) {
s, _ := fixtureCore(t, "exec sleep 60")
candidate := publishFixture(t, s, "v2.0.0", "exit 1", kind == "invalid config")
pendingRollback(s, candidate)
before := s.pm.BinaryPath()
version := "v2.0.0"
if kind == "wrong version" {
version = "v9.0.0"
}
if _, err := s.performCoreUpdate(version, true); err == nil {
t.Fatal("invalid candidate accepted")
}
if s.pm.BinaryPath() != before {
t.Fatal("changed old core before validation")
}
if _, err := os.Stat(filepath.Join(s.updater.dir, "selection.json")); !os.IsNotExist(err) {
t.Fatal("persisted invalid candidate")
}
})
}
}
func TestCoreUpdateRejectsConcurrentMutations(t *testing.T) {
s, _ := fixtureCore(t, "exit 0")
s.updater.job = &CoreUpdateJob{Status: "running"}
for name, fn := range map[string]func() error{"start": s.StartMihomo, "stop": s.StopMihomo, "restart": s.RestartMihomo, "save": func() error { return s.SaveConfig("listeners: []") }, "apply": func() error { return s.ApplyConfig("listeners: []") }} {
if err := fn(); err != ErrCoreUpdateBusy {
t.Fatalf("%s: %v", name, err)
}
}
if runtime.GOOS == "linux" {
if _, err := s.BeginCoreUpdate("v2.0.0", false); err != ErrCoreUpdateBusy {
t.Fatalf("duplicate: %v", err)
}
}
}
func TestCoreSwitchRecoversStartupFailure(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("socket ownership inspection needs Linux")
}
for _, body := range []string{"exit 1", "sleep 0.7; exit 1"} {
t.Run(body, func(t *testing.T) {
s, cfg := fixtureCore(t, "exec sleep 60")
if err := s.pm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.pm.quiesce() })
candidate := publishFixture(t, s, "v2.0.0", body, false)
pendingRollback(s, candidate)
restored, err := s.performCoreUpdate("v2.0.0", true)
if err == nil || !restored || !s.pm.IsRunning() {
t.Fatalf("restored=%v running=%v err=%v", restored, s.pm.IsRunning(), err)
}
selected := NewService(cfg)
info, err := selected.pm.GetVersion()
if err != nil || info.Version != "v1.0.0" {
t.Fatalf("candidate persisted after failure: %v %v", info, err)
}
_ = s.pm.quiesce()
time.Sleep(2200 * time.Millisecond)
if s.pm.IsRunning() {
t.Fatal("crashed candidate's delayed restart revived the core")
}
})
}
}
func TestCoreDownloadChecksumAndLimits(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("official release downloads are Linux only")
}
for _, kind := range []string{"valid", "checksum", "size", "metadata status", "gzip", "version tag"} {
t.Run(kind, func(t *testing.T) {
s, _ := fixtureCore(t, "exec sleep 60")
s.updater.job = &CoreUpdateJob{Status: "running"}
var compressed bytes.Buffer
gz := gzip.NewWriter(&compressed)
_, _ = gz.Write([]byte(fakeCore("v2.0.0", "exec sleep 60", false)))
_ = gz.Close()
archive := compressed.Bytes()
if kind == "gzip" {
archive = []byte("not gzip")
}
sum := sha256.Sum256(archive)
digest := hex.EncodeToString(sum[:])
if kind == "checksum" {
digest = strings.Repeat("0", 64)
}
size := len(archive)
if kind == "size" {
size++
}
platform, _ := corePlatform()
tag := "v2.0.0"
if kind == "version tag" {
tag = "v9.0.0"
}
metadata := fmt.Sprintf(`{"tag_name":%q,"assets":[{"name":%q,"digest":%q,"size":%d}]}`, tag, "mihomo-"+platform+"-"+tag+".gz", "sha256:"+digest, size)
s.updater.client = &http.Client{Transport: coreRoundTrip(func(r *http.Request) (*http.Response, error) {
body := archive
code := 200
if r.URL.Host == "api.github.com" {
body = []byte(metadata)
if kind == "metadata status" {
code = 403
}
}
return &http.Response{StatusCode: code, Body: io.NopCloser(bytes.NewReader(body))}, nil
})}
a, err := s.downloadCore("v2.0.0")
if (err == nil) != (kind == "valid") {
t.Fatalf("artifact=%v error=%v", a, err)
}
if err == nil {
if err := s.updater.verifyArtifact(a); err != nil {
t.Fatal(err)
}
}
files, _ := filepath.Glob(filepath.Join(s.updater.dir, ".download-*"))
if len(files) != 0 {
t.Fatal("download not cleaned up")
}
})
}
}
func TestCoreRedirectAllowlist(t *testing.T) {
client := coreHTTPClient()
for _, url := range []string{"http://github.com/file", "https://evil.example/file", "https://github.com@evil.example/file", "https://127.0.0.1/file"} {
req, _ := http.NewRequestWithContext(context.Background(), "GET", url, nil)
if err := client.CheckRedirect(req, nil); err == nil {
t.Fatalf("accepted %s", url)
}
}
req, _ := http.NewRequest("GET", "https://release-assets.githubusercontent.com/file", nil)
if err := client.CheckRedirect(req, nil); err != nil {
t.Fatal(err)
}
}
func TestCoreUpdateHonorsDeploymentLock(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("Linux updater")
}
s, _ := fixtureCore(t, "exit 0")
f, err := os.OpenFile(filepath.Join(s.updater.dir, "update.lock"), os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
t.Fatal(err)
}
if _, err := s.BeginCoreUpdate("v2.0.0", false); err != ErrCoreUpdateBusy {
t.Fatalf("deployment lock ignored: %v", err)
}
if s.CoreUpdateStatus().Busy {
t.Fatal("rejected job remained active")
}
}
func TestInterruptedCoreJobSurvivesRestart(t *testing.T) {
s, cfg := fixtureCore(t, "exit 0")
s.updater.job = &CoreUpdateJob{ID: "interrupted-job", Version: "v2.0.0", Status: "running", Stage: "switching"}
if err := s.updater.saveJob(); err != nil {
t.Fatal(err)
}
reloaded := NewService(cfg)
status := reloaded.CoreUpdateStatus()
if status.Job == nil || status.Job.Status != "failed" || status.Job.Stage != "interrupted" || status.Busy {
t.Fatalf("interrupted job lost: %+v", status)
}
if reloaded.pm.BinaryPath() != cfg.Mihomo.Binary {
t.Fatal("unfinished candidate selected")
}
}
func TestCoreReadinessRejectsMissingSocket(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("Linux socket ownership")
}
s, _ := fixtureCore(t, "exec sleep 60")
if err := s.pm.Start(); err != nil {
t.Fatal(err)
}
defer s.pm.quiesce()
content := "listeners:\n- name: absent\n type: vless\n listen: 127.0.0.1\n port: '65123'\n"
if err := s.checkUpdateReadiness(content); err == nil || !strings.Contains(err.Error(), "absent") {
t.Fatalf("missing listener accepted: %v", err)
}
}
func TestCoreMetadataAllowsRealisticReleaseListsAndBoundsInput(t *testing.T) {
for _, n := range []int{5 << 20, maxReleaseMetadataBytes + 1} {
u := &coreUpdater{client: &http.Client{Transport: coreRoundTrip(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(strings.Repeat(" ", n) + "[]"))}, nil
})}}
var out []githubRelease
err := u.readReleaseJSON(context.Background(), releaseAPI, &out)
if (err != nil) != (n > maxReleaseMetadataBytes) {
t.Fatalf("size %d: %v", n, err)
}
}
}
func TestCoreUpdateReconcilesCommittedCredentials(t *testing.T) {
for _, failed := range []bool{false, true} {
t.Run(fmt.Sprintf("failed=%v", failed), func(t *testing.T) {
s, _ := fixtureCore(t, "exec sleep 60")
db, err := database.InitDB(filepath.Join(t.TempDir(), "credentials.db"))
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
security.InitCredentialKey("core-update-test")
users := user.NewService(db)
listener := models.Listener{Name: "fixture", Protocol: "shadowsocks", Port: "18388", Enabled: true}
if err := db.Create(&listener).Error; err != nil {
t.Fatal(err)
}
var ids []uint
for _, name := range []string{"deleted", "rotated", "unbound"} {
u, err := users.Create(user.CreateInput{Username: name, Password: "old-password"})
if err != nil {
t.Fatal(err)
}
if err := users.BindListeners(u.ID, []uint{listener.ID}); err != nil {
t.Fatal(err)
}
ids = append(ids, u.ID)
}
regenerations := 0
regenerate := func() error {
regenerations++
credentials, err := users.ActiveCredentialsByListener()
if err != nil {
return err
}
content, err := yaml.Marshal(map[string]any{"credentials": credentials})
if err != nil {
return err
}
return s.SaveConfig(string(content))
}
users.SetCredentialsChangedHandler(func() error { return s.SyncCredentials(regenerate) })
if err := regenerate(); err != nil {
t.Fatal(err)
}
s.updater.job = &CoreUpdateJob{Status: "running"}
if err := users.Delete(ids[0]); !errors.Is(err, ErrCoreUpdateBusy) {
t.Fatalf("delete: %v", err)
}
if _, err := users.Update(ids[1], user.UpdateInput{Password: "new-password"}); !errors.Is(err, ErrCoreUpdateBusy) {
t.Fatalf("rotate: %v", err)
}
if err := users.BindListeners(ids[2], nil); !errors.Is(err, ErrCoreUpdateBusy) {
t.Fatalf("unbind: %v", err)
}
var updateErr error
if failed {
updateErr = fmt.Errorf("download failed")
}
s.finishCoreUpdate(false, updateErr)
got, err := s.cm.ReadConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "new-password") || strings.Contains(got, "old-password") || strings.Contains(got, "deleted") || strings.Contains(got, "unbound") {
t.Fatalf("committed credential changes not reconciled: %s", got)
}
if regenerations != 2 {
t.Fatalf("expected one coalesced regeneration after the update, got %d", regenerations)
}
})
}
}
func TestCredentialSyncHandlesUpdateFinishingDuringRegeneration(t *testing.T) {
s, _ := fixtureCore(t, "exit 0")
calls := 0
err := s.SyncCredentials(func() error {
calls++
if calls == 1 {
s.updater.job = &CoreUpdateJob{Status: "running"}
s.finishCoreUpdate(false, nil)
return ErrCoreUpdateBusy
}
return nil
})
if err != nil || calls != 2 {
t.Fatalf("lost retry: calls=%d err=%v", calls, err)
}
}
func TestCredentialSyncRequeuesForNextUpdate(t *testing.T) {
s, _ := fixtureCore(t, "exit 0")
s.updater.job = &CoreUpdateJob{Status: "running"}
calls := 0
regenerate := func() error {
calls++
if calls == 1 {
s.updater.job = &CoreUpdateJob{Status: "running"}
return ErrCoreUpdateBusy
}
return nil
}
if err := s.SyncCredentials(regenerate); !errors.Is(err, ErrCoreUpdateBusy) {
t.Fatal(err)
}
s.finishCoreUpdate(false, nil)
if calls != 1 || s.updater.pendingCredentials == nil {
t.Fatal("lost deferred synchronization for next update")
}
s.finishCoreUpdate(false, fmt.Errorf("second update failed"))
if calls != 2 || s.updater.pendingCredentials != nil {
t.Fatal("did not drain deferred synchronization")
}
}
func TestCoreUpdateListenerHelper(t *testing.T) {
port := os.Getenv("THREE_M_UI_TEST_LISTENER")
if port == "" {
return
}
listener, err := net.Listen("tcp", "127.0.0.1:"+port)
if err != nil {
t.Fatal(err)
}
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
t.Fatal(err)
}
conn.Close()
}
}
func TestManualCoreRollbackRecoversMissingCurrentListener(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("Linux socket ownership inspection")
}
reserve, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := reserve.Addr().(*net.TCPAddr).Port
reserve.Close()
s, cfg := fixtureCore(t, "exec sleep 60")
content := fmt.Sprintf("listeners:\n- name: recovery\n type: vless\n listen: 127.0.0.1\n port: '%d'\n", port)
if err := os.WriteFile(cfg.Mihomo.Config, []byte(content), 0600); err != nil {
t.Fatal(err)
}
if err := s.pm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.pm.quiesce() })
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
body := fmt.Sprintf("exec env THREE_M_UI_TEST_LISTENER=%d %s -test.run=^TestCoreUpdateListenerHelper$", port, "'"+strings.ReplaceAll(executable, "'", "'\\''")+"'")
candidate := publishFixture(t, s, "v2.0.0", body, false)
pendingRollback(s, candidate)
restored, err := s.performCoreUpdate("v2.0.0", true)
if err != nil || restored {
t.Fatalf("rollback from unhealthy core: restored=%v err=%v", restored, err)
}
if s.pm.BinaryPath() != s.updater.artifactPath(candidate) || !s.pm.IsRunning() {
t.Fatal("previous version not activated")
}
if err := s.checkUpdateReadiness(content); err != nil {
t.Fatal(err)
}
}
func TestOfficialAlphaRelease(t *testing.T) {
for _, platform := range []string{"linux-arm64", "linux-amd64-v1"} {
for _, kind := range []string{"valid", "draft", "other tag", "not pre", "missing digest", "alternate Go", "wrong platform", "ambiguous"} {
t.Run(platform+"/"+kind, func(t *testing.T) {
var r githubRelease
raw := fmt.Sprintf(`{"tag_name":"Prerelease-Alpha","prerelease":true,"assets":[{"name":"mihomo-%s-alpha-dca26db.gz","digest":"sha256:%s","size":100}]}`, platform, strings.Repeat("a", 64))
if err := json.Unmarshal([]byte(raw), &r); err != nil {
t.Fatal(err)
}
switch kind {
case "draft":
r.Draft = true
case "other tag":
r.Tag = "Prerelease-Beta"
case "not pre":
r.Prerelease = false
case "missing digest":
r.Assets[0].Digest = ""
case "alternate Go":
r.Assets[0].Name = "mihomo-" + platform + "-go123-alpha-dca26db.gz"
case "wrong platform":
r.Assets[0].Name = "mihomo-windows-arm64-alpha-dca26db.gz"
case "ambiguous":
r.Assets = append(r.Assets, r.Assets[0])
r.Assets[1].Name = "mihomo-" + platform + "-alpha-123abcd.gz"
}
got, ok := officialRelease(r, platform)
if ok != (kind == "valid") {
t.Fatalf("release=%+v ok=%v", got, ok)
}
if ok && (got.Version != "alpha-dca26db" || !got.Prerelease || !strings.HasSuffix(got.URL, "/Prerelease-Alpha")) {
t.Fatalf("%+v", got)
}
})
}
}
}
func TestAlphaInstallPersistenceAndRollingRelease(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("Linux updater")
}
for _, kind := range []string{"valid", "replaced build", "wrong executable", "checksum"} {
t.Run(kind, func(t *testing.T) {
s, cfg := fixtureCore(t, "exec sleep 60")
s.updater.job = &CoreUpdateJob{Status: "running"}
version := "alpha-dca26db"
executableVersion := version
if kind == "wrong executable" {
executableVersion = "alpha-123abcd"
}
var archive bytes.Buffer
gz := gzip.NewWriter(&archive)
_, _ = gz.Write([]byte(fakeCore(executableVersion, "exec sleep 60", false)))
_ = gz.Close()
sum := sha256.Sum256(archive.Bytes())
digest := hex.EncodeToString(sum[:])
if kind == "checksum" {
digest = strings.Repeat("0", 64)
}
assetVersion := version
if kind == "replaced build" {
assetVersion = "alpha-123abcd"
}
platform, _ := corePlatform()
asset := "mihomo-" + platform + "-" + assetVersion + ".gz"
metadata := fmt.Sprintf(`{"tag_name":"Prerelease-Alpha","prerelease":true,"assets":[{"name":%q,"digest":%q,"size":%d}]}`, asset, "sha256:"+digest, archive.Len())
downloads := 0
s.updater.client = &http.Client{Transport: coreRoundTrip(func(r *http.Request) (*http.Response, error) {
body := archive.Bytes()
switch r.URL.String() {
case releaseAPI + "/tags/Prerelease-Alpha":
body = []byte(metadata)
case releaseDownload + "Prerelease-Alpha/" + asset:
downloads++
default:
t.Fatalf("unexpected URL %s", r.URL)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(body))}, nil
})}
before := s.pm.BinaryPath()
_, err := s.performCoreUpdate(version, false)
if kind != "valid" {
if err == nil || s.pm.BinaryPath() != before {
t.Fatalf("unsafe switch: %v", err)
}
if kind == "replaced build" && downloads != 0 {
t.Fatal("downloaded a different build")
}
return
}
if err != nil {
t.Fatal(err)
}
if s.pm.IsRunning() {
t.Fatal("started a stopped core")
}
reloaded := NewService(cfg)
info, err := reloaded.pm.GetVersion()
if err != nil || info.Version != version || reloaded.updater.selection.Active.Version != version {
t.Fatalf("lost concrete pre build: %+v %v", info, err)
}
for _, target := range []string{"v1.0.0", version} {
pendingRollback(s, s.updater.selection.Previous)
if _, err := s.performCoreUpdate(target, true); err != nil {
t.Fatal(err)
}
info, err := s.pm.GetVersion()
if err != nil || info.Version != target {
t.Fatalf("rollback: %+v %v", info, err)
}
}
if downloads != 1 {
t.Fatal("rollback used the network")
}
})
}
}
func TestAlphaReleaseListRefresh(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("Linux updater")
}
s, _ := fixtureCore(t, "exit 0")
platform, _ := corePlatform()
version := "alpha-dca26db"
requests := 0
s.updater.client = &http.Client{Transport: coreRoundTrip(func(r *http.Request) (*http.Response, error) {
requests++
if r.URL.String() != releaseAPI+"?per_page=30" {
t.Fatalf("unexpected URL: %s", r.URL)
}
body := fmt.Sprintf(`[{"tag_name":"Prerelease-Alpha","prerelease":true,"assets":[{"name":"mihomo-%s-%s.gz","digest":"sha256:%s","size":100}]},{"tag_name":"v1.19.30","assets":[{"name":"mihomo-%s-v1.19.30.gz","digest":"sha256:%s","size":100}]}]`, platform, version, strings.Repeat("a", 64), platform, strings.Repeat("b", 64))
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
})}
for i := 0; i < 3; i++ {
if i == 2 {
version = "alpha-123abcd"
s.updater.releasesAt = time.Now().Add(-2 * time.Minute)
}
releases, err := s.CoreReleases(context.Background())
if err != nil || len(releases) != 2 || releases[0].Prerelease || releases[1].Version != version {
t.Fatalf("list=%+v err=%v", releases, err)
}
}
if requests != 2 {
t.Fatalf("requests=%d", requests)
}
}
func TestRealAlphaCoreSwitchAndRollback(t *testing.T) {
binary := os.Getenv("MIHOMO_TEST_PRE_BINARY")
if runtime.GOOS != "linux" || binary == "" {
t.Skip("set MIHOMO_TEST_PRE_BINARY for isolated real pre tests")
}
s, cfg := fixtureCore(t, "exec sleep 60")
stable := os.Getenv("MIHOMO_TEST_BINARY")
if stable == "" {
t.Fatal("MIHOMO_TEST_BINARY is required")
}
data, err := os.ReadFile(stable)
if err != nil {
t.Fatal(err)
}
if err = os.WriteFile(cfg.Mihomo.Binary, data, 0700); err != nil {
t.Fatal(err)
}
stableInfo, err := s.pm.GetVersion()
if err != nil {
t.Fatal(err)
}
if err = s.pm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.pm.quiesce() })
data, err = os.ReadFile(binary)
if err != nil {
t.Fatal(err)
}
candidate, err := s.updater.publishBinary(bytes.NewReader(data), "pre-probe")
if err != nil {
t.Fatal(err)
}
probe := NewProcessManager(s.updater.artifactPath(candidate), cfg.Mihomo.Config)
probe.managedDir = s.updater.dir
info, err := probe.GetVersion()
if err != nil || !alphaVersion.MatchString(info.Version) {
t.Fatalf("pre version=%+v err=%v", info, err)
}
candidate.Version = info.Version
for _, target := range []string{info.Version, stableInfo.Version, info.Version} {
pendingRollback(s, candidate)
if _, err = s.performCoreUpdate(target, true); err != nil {
t.Fatal(err)
}
if !s.pm.IsRunning() {
t.Fatal("running state lost")
}
reloaded := NewService(cfg)
got, err := reloaded.pm.GetVersion()
if err != nil || got.Version != target {
t.Fatalf("persisted version=%+v err=%v", got, err)
}
candidate = s.updater.selection.Previous
}
t.Logf("real stable %s ↔ pre %s switch, persistence and rollback passed", stableInfo.Version, info.Version)
}
+59 -13
View File
@@ -2,6 +2,7 @@ package mihomo
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
@@ -20,6 +21,7 @@ type ProcessManager struct {
pid int
startTime time.Time
binaryPath string
managedDir string
configPath string
done chan struct{}
logs []string
@@ -113,24 +115,44 @@ func isAllowedBinaryPath(path string) bool {
return false
}
func (pm *ProcessManager) BinaryPath() string {
pm.mu.Lock()
defer pm.mu.Unlock()
return pm.binaryPath
}
func (pm *ProcessManager) setBinaryPath(path string) {
pm.mu.Lock()
defer pm.mu.Unlock()
pm.binaryPath = path
}
func (pm *ProcessManager) allowedBinary(path string) bool {
if isAllowedBinaryPath(path) {
return true
}
name := filepath.Base(path)
return pm.managedDir != "" && filepath.Dir(path) == pm.managedDir && strings.HasPrefix(name, "mihomo-") && sha256Pattern.MatchString(strings.TrimPrefix(name, "mihomo-"))
}
func (pm *ProcessManager) GetVersion() (*VersionInfo, error) {
info, err := os.Stat(pm.binaryPath)
binaryPath := pm.BinaryPath()
info, err := os.Stat(binaryPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("mihomo binary not found: %s", pm.binaryPath)
return nil, fmt.Errorf("mihomo binary not found: %s", binaryPath)
}
return nil, fmt.Errorf("failed to stat mihomo binary: %w", err)
}
if info.IsDir() {
return nil, fmt.Errorf("mihomo binary path is a directory: %s", pm.binaryPath)
return nil, fmt.Errorf("mihomo binary path is a directory: %s", binaryPath)
}
if !isAllowedBinaryPath(pm.binaryPath) {
return nil, fmt.Errorf("mihomo binary path is not in allowed list: %s", pm.binaryPath)
if !pm.allowedBinary(binaryPath) {
return nil, fmt.Errorf("mihomo binary path is not in allowed list: %s", binaryPath)
}
cmd := exec.Command(pm.binaryPath, "-v")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binaryPath, "-v")
var out bytes.Buffer
cmd.Stdout = &out
@@ -146,9 +168,9 @@ func (pm *ProcessManager) GetVersion() (*VersionInfo, error) {
version := "unknown"
// Mihomo output has changed over time (for example, it may contain
// both the product name and the version). Prefer the first token that
// looks like a semantic version instead of assuming it is fields[1].
// looks like a release version (including alpha builds), not fields[1].
for _, part := range parts {
if strings.HasPrefix(part, "v") && len(part) > 1 && strings.ContainsAny(part[1:], "0123456789") {
if alphaVersion.MatchString(part) || (strings.HasPrefix(part, "v") && len(part) > 1 && strings.ContainsAny(part[1:], "0123456789")) {
version = part
break
}
@@ -180,7 +202,7 @@ func (pm *ProcessManager) ValidateConfig() error {
return fmt.Errorf("mihomo binary is not executable: %s", binaryPath)
}
if !isAllowedBinaryPath(binaryPath) {
if !pm.allowedBinary(binaryPath) {
return fmt.Errorf("mihomo binary path is not in allowed list: %s", binaryPath)
}
@@ -193,7 +215,9 @@ func (pm *ProcessManager) ValidateConfig() error {
return fmt.Errorf("mihomo config is empty: %s", configPath)
}
cmd := exec.Command(
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx,
binaryPath,
"-t",
"-d",
@@ -232,7 +256,7 @@ func (pm *ProcessManager) findExistingProcesses() []int {
return nil
}
binary, err := filepath.EvalSymlinks(pm.binaryPath)
binary, err := filepath.EvalSymlinks(pm.BinaryPath())
if err != nil {
return nil
}
@@ -412,7 +436,7 @@ func (pm *ProcessManager) start() error {
)
}
if !isAllowedBinaryPath(binaryPath) {
if !pm.allowedBinary(binaryPath) {
return fmt.Errorf(
"mihomo binary path is not in allowed list: %s",
binaryPath,
@@ -582,6 +606,15 @@ func (pm *ProcessManager) waitProcess(
time.Sleep(2 * time.Second)
// A stopped/replaced process must not revive after an updater rolls back.
pm.lifecycleMu.Lock()
defer pm.lifecycleMu.Unlock()
pm.mu.Lock()
wanted := pm.desired && pm.cmd == cmd
pm.mu.Unlock()
if !wanted {
return
}
if pm.IsRunning() {
return
}
@@ -601,7 +634,7 @@ func (pm *ProcessManager) waitProcess(
return
}
if err := pm.Start(); err != nil {
if err := pm.start(); err != nil {
pm.mu.Lock()
pm.appendLogLocked(
@@ -870,3 +903,16 @@ func formatDuration(d time.Duration) string {
return fmt.Sprintf("%ds", s)
}
// quiesce is an idempotent stop for a version switch, including crashed cores.
func (pm *ProcessManager) quiesce() error {
pm.lifecycleMu.Lock()
defer pm.lifecycleMu.Unlock()
if pm.IsRunning() {
return pm.stop()
}
pm.mu.Lock()
pm.desired = false
pm.mu.Unlock()
return nil
}
+167
View File
@@ -0,0 +1,167 @@
package mihomo
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"runtime"
"sort"
"strings"
"time"
)
const releaseAPI = "https://api.github.com/repos/MetaCubeX/mihomo/releases"
const releaseDownload = "https://github.com/MetaCubeX/mihomo/releases/download/"
const maxArchiveBytes int64 = 64 << 20
const maxBinaryBytes int64 = 128 << 20
// Thirty releases include hundreds of platform assets each.
const maxReleaseMetadataBytes = 16 << 20
var stableVersion = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
var alphaVersion = regexp.MustCompile(`^alpha-[a-f0-9]{7,40}$`)
var sha256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
const alphaReleaseTag = "Prerelease-Alpha"
// CoreRelease is deliberately limited to official, verifiable artifacts.
// The API accepts a version, never a URL or an executable path.
type CoreRelease struct {
Version string `json:"version"`
PublishedAt string `json:"published_at"`
URL string `json:"url"`
Asset string `json:"asset"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
Prerelease bool `json:"prerelease"`
}
type githubRelease struct {
Tag string `json:"tag_name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
PublishedAt string `json:"published_at"`
Assets []struct {
Name string `json:"name"`
Digest string `json:"digest"`
Size int64 `json:"size"`
} `json:"assets"`
}
func corePlatform() (string, error) {
if runtime.GOOS != "linux" {
return "", fmt.Errorf("core updates support Linux amd64 and arm64")
}
switch runtime.GOARCH {
case "amd64":
return "linux-amd64-v1", nil
case "arm64":
return "linux-arm64", nil
default:
return "", fmt.Errorf("core updates support Linux amd64 and arm64")
}
}
func officialRelease(r githubRelease, platform string) (CoreRelease, bool) {
alpha := r.Tag == alphaReleaseTag && r.Prerelease
if r.Draft || (!alpha && (r.Prerelease || !stableVersion.MatchString(r.Tag))) {
return CoreRelease{}, false
}
prefix := "mihomo-" + platform + "-"
var result CoreRelease
for _, a := range r.Assets {
if !strings.HasPrefix(a.Name, prefix) || !strings.HasSuffix(a.Name, ".gz") {
continue
}
version := strings.TrimSuffix(strings.TrimPrefix(a.Name, prefix), ".gz")
if (alpha && !alphaVersion.MatchString(version)) || (!alpha && version != r.Tag) {
continue
}
// Fail closed while a rolling release contains ambiguous builds.
if result.Version != "" {
return CoreRelease{}, false
}
sum := strings.TrimPrefix(a.Digest, "sha256:")
if !strings.HasPrefix(a.Digest, "sha256:") || !sha256Pattern.MatchString(sum) || a.Size <= 0 || a.Size > maxArchiveBytes {
return CoreRelease{}, false
}
result = CoreRelease{Version: version, PublishedAt: r.PublishedAt, URL: "https://github.com/MetaCubeX/mihomo/releases/tag/" + r.Tag, Asset: a.Name, SHA256: sum, Size: a.Size, Prerelease: alpha}
}
return result, result.Version != ""
}
func coreHTTPClient() *http.Client {
return &http.Client{Timeout: 3 * time.Minute, CheckRedirect: func(req *http.Request, via []*http.Request) error {
host := req.URL.Hostname()
if len(via) >= 5 || req.URL.Scheme != "https" || req.URL.User != nil || (host != "github.com" && host != "api.github.com" && host != "release-assets.githubusercontent.com" && host != "objects.githubusercontent.com") {
return fmt.Errorf("unexpected redirect from the official release service")
}
return nil
}}
}
func (u *coreUpdater) get(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "3m-ui-core-updater")
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := u.client.Do(req)
if err != nil {
return nil, fmt.Errorf("official release request failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("official release service returned HTTP %d", resp.StatusCode)
}
return resp, nil
}
func (u *coreUpdater) readReleaseJSON(ctx context.Context, url string, out any) error {
resp, err := u.get(ctx, url)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxReleaseMetadataBytes+1))
if err != nil {
return err
}
if len(raw) > maxReleaseMetadataBytes {
return fmt.Errorf("release metadata exceeds size limit")
}
return json.Unmarshal(raw, out)
}
func (s *Service) CoreReleases(ctx context.Context) ([]CoreRelease, error) {
if s == nil {
return nil, fmt.Errorf("core updates are unavailable")
}
u := s.updater
if u == nil {
return nil, fmt.Errorf("core updates are unavailable")
}
platform, err := corePlatform()
if err != nil {
return nil, err
}
u.releaseMu.Lock()
defer u.releaseMu.Unlock()
if time.Since(u.releasesAt) < time.Minute && u.releases != nil {
return append([]CoreRelease{}, u.releases...), nil
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
var releases []githubRelease
if err := u.readReleaseJSON(ctx, releaseAPI+"?per_page=30", &releases); err != nil {
return nil, err
}
result := []CoreRelease{}
for _, r := range releases {
if release, ok := officialRelease(r, platform); ok {
result = append(result, release)
}
}
// Keep the default choice stable even though GitHub lists rolling alpha first.
sort.SliceStable(result, func(i, j int) bool { return !result[i].Prerelease && result[j].Prerelease })
u.releases, u.releasesAt = result, time.Now()
return append([]CoreRelease{}, result...), nil
}
+42 -3
View File
@@ -12,8 +12,9 @@ import (
)
type Service struct {
pm *ProcessManager
cm *ConfigManager
updater *coreUpdater
pm *ProcessManager
cm *ConfigManager
// applyMu serializes configuration replacement, backup rotation, validation,
// and process restart. Multiple API routes can call ApplyConfig concurrently.
applyMu sync.Mutex
@@ -26,7 +27,9 @@ func NewService(cfg *config.Config) *Service {
if cfg == nil {
return &Service{}
}
return &Service{pm: NewProcessManager(cfg.Mihomo.Binary, cfg.Mihomo.Config), cm: NewConfigManager(cfg.Mihomo.Config)}
svc := &Service{pm: NewProcessManager(cfg.Mihomo.Binary, cfg.Mihomo.Config), cm: NewConfigManager(cfg.Mihomo.Config)}
svc.initCoreUpdater(cfg.Mihomo.Config)
return svc
}
// SetCrashHandler forwards a crash-notification callback to the underlying
@@ -43,8 +46,14 @@ func (s *Service) StartMihomo() error {
if s == nil || s.pm == nil {
return fmt.Errorf("mihomo service not initialized")
}
if err := s.coreMutationAllowed(); err != nil {
return err
}
s.applyMu.Lock()
defer s.applyMu.Unlock()
if err := s.coreMutationAllowed(); err != nil {
return err
}
if err := s.pm.ValidateConfig(); err != nil {
return err
@@ -56,8 +65,14 @@ func (s *Service) StopMihomo() error {
if s == nil || s.pm == nil {
return fmt.Errorf("mihomo service not initialized")
}
if err := s.coreMutationAllowed(); err != nil {
return err
}
s.applyMu.Lock()
defer s.applyMu.Unlock()
if err := s.coreMutationAllowed(); err != nil {
return err
}
return s.pm.Stop()
}
@@ -66,8 +81,14 @@ func (s *Service) RestartMihomo() error {
if s == nil || s.pm == nil {
return fmt.Errorf("mihomo service not initialized")
}
if err := s.coreMutationAllowed(); err != nil {
return err
}
s.applyMu.Lock()
defer s.applyMu.Unlock()
if err := s.coreMutationAllowed(); err != nil {
return err
}
return s.pm.Restart()
}
@@ -82,6 +103,15 @@ func (s *Service) SaveConfig(content string) error {
if s == nil || s.cm == nil {
return fmt.Errorf("mihomo service not initialized")
}
if err := s.coreMutationAllowed(); err != nil {
return err
}
s.applyMu.Lock()
defer s.applyMu.Unlock()
if err := s.coreMutationAllowed(); err != nil {
return err
}
old, readErr := s.cm.ReadConfig()
hadOld := readErr == nil
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) && !os.IsNotExist(readErr) {
@@ -111,8 +141,14 @@ func (s *Service) ApplyConfig(content string) error {
if s == nil || s.cm == nil {
return fmt.Errorf("mihomo service not initialized")
}
if err := s.coreMutationAllowed(); err != nil {
return err
}
s.applyMu.Lock()
defer s.applyMu.Unlock()
if err := s.coreMutationAllowed(); err != nil {
return err
}
old, readErr := s.cm.ReadConfig()
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) && !os.IsNotExist(readErr) {
return fmt.Errorf("read current Mihomo config: %w", readErr)
@@ -249,3 +285,6 @@ func inferLogLevel(payload string) string {
}
return "info"
}
// BinaryPath reports the selected persistent core or the bundled fallback.
func (s *Service) BinaryPath() string { return s.pm.BinaryPath() }
+33
View File
@@ -1,13 +1,46 @@
package router
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/kazeyukiro/3m-ui/backend/internal/mihomo"
)
func registerMihomoRoutes(api *gin.RouterGroup, d Deps) {
group := api.Group("/mihomo")
group.GET("/releases", func(c *gin.Context) {
releases, err := d.mihomoService().CoreReleases(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, releases)
})
group.GET("/update", func(c *gin.Context) { c.JSON(http.StatusOK, d.mihomoService().CoreUpdateStatus()) })
begin := func(c *gin.Context, rollback bool) {
var input struct {
Version string `json:"version"`
}
if !rollback && c.ShouldBindJSON(&input) != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "a version is required"})
return
}
job, err := d.mihomoService().BeginCoreUpdate(input.Version, rollback)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, mihomo.ErrCoreUpdateBusy) {
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, job)
}
group.POST("/update", func(c *gin.Context) { begin(c, false) })
group.POST("/update/rollback", func(c *gin.Context) { begin(c, true) })
group.GET("/status", func(c *gin.Context) {
status, err := d.mihomoService().GetStatus()
if err != nil {
+18
View File
@@ -141,3 +141,21 @@ func TestCORSConfiguredOrigin(t *testing.T) {
t.Fatalf("expected no allow-origin for disallowed origin, got %q", got)
}
}
// Core update routes use the same administrator-only middleware as lifecycle
// controls. Rejected callers must not reach an updater or external releases API.
func TestCoreUpdateRoutesRequireAuthentication(t *testing.T) {
cfg := &config.Config{}
r := router.SetupRouter(cfg)
for _, item := range []struct{ method, path string }{
{"GET", "/api/v1/mihomo/releases"}, {"GET", "/api/v1/mihomo/update"},
{"POST", "/api/v1/mihomo/update"}, {"POST", "/api/v1/mihomo/update/rollback"},
} {
w := httptest.NewRecorder()
req := httptest.NewRequest(item.method, item.path, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("%s %s: %d", item.method, item.path, w.Code)
}
}
}
+4 -5
View File
@@ -6,7 +6,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/kazeyukiro/3m-ui/backend/internal/config"
"github.com/kazeyukiro/3m-ui/backend/internal/converter"
)
// ExportLinks returns subscription URLs for all (or filtered) proxy users.
@@ -33,10 +32,10 @@ func (h *Handler) ExportLinks(c *gin.Context) {
"enabled": u.Enabled,
}
if tok := strings.TrimSpace(u.SubToken); tok != "" {
item["subscription"] = converter.GetSubscriptionURL(cfg, c.Request, tok, "")
item["subscription_clash"] = converter.GetSubscriptionURL(cfg, c.Request, tok, "clash")
item["subscription_v2ray"] = converter.GetSubscriptionURL(cfg, c.Request, tok, "v2ray")
item["subscription_singbox"] = converter.GetSubscriptionURL(cfg, c.Request, tok, "singbox")
item["subscription"] = config.GetSubscriptionURL(cfg, c.Request, tok, "")
item["subscription_clash"] = config.GetSubscriptionURL(cfg, c.Request, tok, "clash")
item["subscription_v2ray"] = config.GetSubscriptionURL(cfg, c.Request, tok, "v2ray")
item["subscription_singbox"] = config.GetSubscriptionURL(cfg, c.Request, tok, "singbox")
item["sub_token"] = tok
}
out = append(out, item)
+1
View File
@@ -0,0 +1 @@
{"schema":1,"directory":"cores","selection":"selection.json","lock":"update.lock"}
+1
View File
@@ -16,6 +16,7 @@ services:
TZ: ${TZ:-UTC}
volumes:
- 3m-ui-config:/etc/3m-ui
# Includes selected/previous Mihomo cores; preserve across image updates.
- 3m-ui-data:/var/lib/3m-ui
- 3m-ui-logs:/var/log/3m-ui
cap_drop:
+1
View File
@@ -14,6 +14,7 @@ services:
TZ: ${TZ:-UTC}
volumes:
- 3m-ui-config:/etc/3m-ui
# Includes selected/previous Mihomo cores; preserve across image updates.
- 3m-ui-data:/var/lib/3m-ui
- 3m-ui-logs:/var/log/3m-ui
cap_drop:
+98
View File
@@ -0,0 +1,98 @@
# Updating the Mihomo core
Administrators can use **Core → Check for updates** to select an official stable
or Pre Mihomo release, or **Restore previous version** to return to the saved binary.
This updates Mihomo independently of the panel. Updates are manual; checking
for releases never installs one. The most recent 30 GitHub releases are queried,
with drafts and artifacts without a SHA-256 digest excluded.
Linux amd64 (baseline v1) and arm64 are supported.
The updater downloads only from `MetaCubeX/mihomo` on GitHub, verifies the release
asset's GitHub-provided SHA-256 digest, checks its executable version, and runs
its configuration test against the current configuration. These steps happen
before stopping the existing core. Downloads are limited to 64 MiB compressed
and 128 MiB decompressed, with request and validation timeouts.
For a running core, the updater verifies the existing listeners, stops the old
process, starts the candidate, and verifies the new process and known local
listening sockets. A failed switch restores the prior executable and running
state. If the core was stopped, installation keeps it stopped; only executable
and configuration validation are performed. Public connectivity, firewall rules,
and protocol handshakes are not tested by an update. Unsupported listener
transports are not covered by local socket inspection. Older releases may reject
newer configuration fields; configuration validation then rejects the update
before the running core is stopped.
## Persistent selection and recovery
The bundled `mihomo.binary` remains an immutable fallback. Managed binaries are
stored in `cores/` beside `mihomo.config`, normally
`/var/lib/3m-ui/mihomo/cores/`. The directory holds:
- `mihomo-<sha256>`: executable files for the active and previous versions.
- `selection.json`: schema 1, with active and previous version/checksum records.
- `job.json`: the most recent update result or interrupted job.
- `update.lock`: an advisory exclusive lock for updates and deployment tooling.
The initial bundled core is copied into this directory before the first switch,
so the previous version survives later panel/image upgrades too. The selected
version is recorded atomically only after successful validation and runtime
checks. Restarting during an unfinished switch therefore selects the previously
committed version. A corrupted or missing selected binary is reported rather
than silently resetting to the image's bundled version. Restore the directory
from backup to recover damaged selection metadata.
The normal Docker data volume already includes this directory. No Docker socket,
root permissions, or writable image filesystem are needed. Preserve that volume
when recreating containers. Native installations must keep this directory
writable and executable by the panel service; the supplied systemd unit already
permits writes in the data directory. A `noexec` data mount prevents core updates.
Do not share a data directory between multiple panel instances.
Panel upgrades must support selection schema 1 and preserve the selected binary.
`distribution/core-update.json` declares the storage contract to deployment tools.
Before replacing a panel, a deployment tool can hold `flock(LOCK_EX)` on
`cores/update.lock` through its backup, switch, and runtime checks. The in-panel
updater returns a conflict while this lock is held. Back up the entire core
configuration directory, including both binaries and selection metadata. Verify
the active core version and local listener states after deployment, separately
from the panel HTTP health check.
## API
All endpoints require the existing administrator authentication.
| Method | Endpoint | Result |
| --- | --- | --- |
| GET | `/api/v1/mihomo/releases` | Recent stable and Pre releases with usable platform artifacts and release notes |
| GET | `/api/v1/mihomo/update` | Capability, persistence/rollback state, and latest job |
| POST | `/api/v1/mihomo/update` | JSON `{"version":"v1.19.30"}`; returns HTTP 202 with the accepted job |
| POST | `/api/v1/mihomo/update/rollback` | Returns HTTP 202 with a job restoring the previous version |
POST accepts a version only, never an arbitrary download URL or executable path.
A concurrent update or deployment lock returns HTTP 409. Poll GET `/mihomo/update`
until the job is `succeeded` or `failed`; closing the page does not cancel it.
Failed jobs include their failure stage, error, and `rolled_back` result.
Start/stop/restart and configuration mutations are rejected during an update,
so queued actions cannot unexpectedly apply after the switch completes.
User and listener-binding changes that already committed to the database are
reconciled after either update success or failure by generating fresh configuration
from the latest database. Their API response can still report the temporary busy
error. Manual rollback does not require the current core to be healthy; it validates
the saved target and checks its listeners after starting it.
## Official Pre builds
The dropdown labels the official `Prerelease-Alpha` channel as **Pre** and shows
the concrete `alpha-<commit>` build. Stable remains the default; Pre requires an
explicit selection. Other prerelease channels and alternate Go builds are excluded.
Release lists are cached for one minute. Installation resolves the rolling tag
again and rejects a changed build instead of silently installing a different one.
The archive checksum and executable version must match the resolved metadata.
The selected build and content hash are persisted, so restart and rollback retain
that exact executable even after the upstream Pre release changes.
POST accepts the displayed build, for example `{"version":"alpha-dca26db"}`,
not the rolling tag. If the build has been replaced, check for updates again.
Pre versions may reject existing configuration; the usual validation and recovery
checks also apply to switches between stable and Pre.
+7
View File
@@ -176,3 +176,10 @@ docker compose exec 3m-ui /usr/local/bin/3m-ui reset-admin
## Subscription path and port
Optional `server.sub_path` (e.g. `/sub`) and `server.sub_port` in `/etc/3m-ui/config.yaml`, or env `THREE_M_UI_SUB_PATH` / `THREE_M_UI_SUB_PORT`. Legacy `/api/v1/client/sub/:token` remains. Set `public_url` to the client-facing base URL.
## Independent core updates
The Core page supports manual updates and rollback of official stable and Pre Mihomo
versions on Linux amd64/arm64. Selected versions persist in the existing data
volume across panel upgrades. See [core updates](core-updates.md) for validation,
recovery, and deployment integration details.
+14
View File
@@ -0,0 +1,14 @@
import client from './client';
export type CoreStatus = { running: boolean; version: string; pid: number; uptime: string };
export type CoreRelease = { version: string; published_at: string; url: string; asset: string; sha256: string; size: number; prerelease: boolean };
export type CoreUpdateJob = { id: string; operation: 'install' | 'rollback'; version: string; status: 'running' | 'succeeded' | 'failed'; stage: string; error?: string; rolled_back: boolean };
export type CoreUpdateStatus = { supported: boolean; disabled_reason?: string; busy: boolean; managed: boolean; previous_version?: string; job?: CoreUpdateJob };
export const coreAPI = {
status: async (signal?: AbortSignal) => (await client.get<CoreStatus>('/mihomo/status', { signal })).data,
updateStatus: async (signal?: AbortSignal) => (await client.get<CoreUpdateStatus>('/mihomo/update', { signal })).data,
releases: async () => (await client.get<CoreRelease[]>('/mihomo/releases', { timeout: 25000 })).data,
install: async (version: string) => (await client.post<CoreUpdateJob>('/mihomo/update', { version })).data,
rollback: async () => (await client.post<CoreUpdateJob>('/mihomo/update/rollback')).data,
action: async (action: 'start' | 'stop' | 'restart') => client.post(`/mihomo/${action}`),
};
+1 -1
View File
@@ -1 +1 @@
export default {"realityScan":{"required":"Choose a REALITY target or enter one manually.","placeholder":"Target hostname:443","hint":"Automatically checked from this node. You can enter a target manually.","scan":"Check targets","change":"Try another","scanning":"Checking available targets…","passed":"Handshake checks passed","noTargets":"No suitable target found. Retry or enter a target manually.","failed":"Target check failed. Retry or enter a target manually.","scope":"Only reviewed candidates are checked for handshake compatibility, redirects and known CDN signals; this does not guarantee protection from fallback traffic abuse."},"app":{"name":"3M-UI","title":"Mihomo Core Management Console"},"nav":{"dashboard":"Dashboard","listeners":"Nodes","users":"Users","traffic":"Traffic","cluster":"Cluster","routing":"Routing","core":"Core","logs":"Logs","config":"Config","share":"Share","settings":"Settings","logout":"Logout"},"common":{"advanced":"Advanced","prev":"Previous","next":"Next","saved":"Saved","copied":"Copied","copyFailed":"Copy failed — select and copy manually","save":"Save","cancel":"Cancel","delete":"Delete","edit":"Edit","create":"Create","confirm":"Confirm","close":"Close","copy":"Copy","download":"Download","refresh":"Refresh","loading":"Loading…","empty":"No data","search":"Search","actions":"Actions","status":"Status","enabled":"Enabled","disabled":"Disabled","name":"Name","type":"Type","port":"Port","protocol":"Protocol","address":"Address","password":"Password","username":"Username","submit":"Submit","success":"Success","error":"Error","confirmDelete":"Are you sure to delete?","back":"Back","generate":"Generate","validate":"Validate","generated":"Generated","ok":"OK","yes":"Yes","no":"No","more":"More","reset":"Reset","id":"ID","network":"Network","bound":"Bound","missing":"Missing"},"login":{"title":"3M-UI","subtitle":"Mihomo Core Management Console","username":"Username","password":"Password","button":"Login","welcomeBack":"Welcome back","failed":"Login failed"},"password":{"requiredHint":"You must set a new password before using the panel.","title":"Change Password","current":"Current Password","new":"New Password","confirm":"Confirm New Password","button":"Change Password","success":"Password updated","failed":"Failed to change password","mismatch":"Passwords do not match"},"dashboard":{"title":"Dashboard","subtitle":"System & Core Overview","status":"Core Status","running":"Running","stoppedStatus":"Stopped","version":"Version","pid":"PID","uptime":"Uptime","listeners":"Node Stats","total":"Total","enabled":"Enabled","disabled":"Disabled","system":"System Resources","cpu":"CPU","memory":"Memory","disk":"Disk","network":"Network","upload":"Upload","download":"Download","traffic":"Traffic Stats","uploadRate":"Upload rate","downloadRate":"Download rate","totalUpload":"Total upload","totalDownload":"Total download","onlineUsers":"Online users","activeConnections":"Active connections","start":"Start","stop":"Stop","restart":"Restart","started":"Core started","stopped":"Core stopped","restarted":"Core restarted","operationFailed":"Operation failed","unavailable":"Status unavailable","users":"Users","totalUsers":"Total users","enabledUsers":"Enabled users"},"listeners":{"subtitle":"Create and manage Mihomo inbound listeners.","quickSetup":"Quick setup","quickSetupHint":"One-click inbound (VLESS+REALITY by default). Override port, SNI, keys below if needed.","preset":"Preset","portAuto":"Leave empty to auto-pick a port","hy2QuickHint":"Skeleton only — add cert/key in node edit before production.","quickHints":"Generated","bind":"Bind address","publicHost":"Public host","title":"Nodes","create":"Create Node","edit":"Edit Node","name":"Name","protocol":"Protocol","port":"Port","bindAddress":"Bind Address","status":"Status","actions":"Actions","config":"Protocol Config","udp":"UDP","tls":"TLS","deleteConfirm":"Delete this node?","created":"Created","updated":"Updated","deleted":"Deleted","reloaded":"Reloaded","copyURI":"Copy subscription URI","urisTitle":"Node subscription links","invalidJSON":"Invalid JSON","selectProtocolFirst":"Select a protocol first","transportXhttpHint":"XHTTP is available for VLESS only.","transportNoXhttpHint":"XHTTP is not available for this protocol (VLESS only).","alwaysTlsHint":"This protocol requires TLS. Leave certificate empty to auto-generate a panel self-signed pair on save.","tlsPairAutoHint":"Certificate + private-key required. Leave both empty → panel self-signed on save.","sectionTlsMirror":"TLS mirror","tlsMirrorHint":"Advanced. Requires dest and primary-key; mutually exclusive with certificate TLS.","tlsMirrorDest":"Mirror dest","tlsMirrorPrimaryKey":"Primary key","tlsMirrorProxy":"Proxy","usersHint":"Create credentials under Users and bind them to this node. Only official Listener fields are configured here.","portHint":"Single port (e.g. 443) or official ports syntax (e.g. 200,204,401-429)","randomPort":"Random port","noAvailablePort":"No suLine truncated
export default {"realityScan":{"required":"Choose a REALITY target or enter one manually.","placeholder":"Target hostname:443","hint":"Automatically checked from this node. You can enter a target manually.","scan":"Check targets","change":"Try another","scanning":"Checking available targets…","passed":"Handshake checks passed","noTargets":"No suitable target found. Retry or enter a target manually.","failed":"Target check failed. Retry or enter a target manually.","scope":"Only reviewed candidates are checked for handshake compatibility, redirects and known CDN signals; this does not guarantee protection from fallback traffic abuse."},"app":{"name":"3M-UI","title":"Mihomo Core Management Console"},"nav":{"dashboard":"Dashboard","listeners":"Nodes","users":"Users","traffic":"Traffic","cluster":"Cluster","routing":"Routing","core":"Core","logs":"Logs","config":"Config","share":"Share","settings":"Settings","logout":"Logout"},"common":{"advanced":"Advanced","prev":"Previous","next":"Next","saved":"Saved","copied":"Copied","copyFailed":"Copy failed — select and copy manually","save":"Save","cancel":"Cancel","delete":"Delete","edit":"Edit","create":"Create","confirm":"Confirm","close":"Close","copy":"Copy","download":"Download","refresh":"Refresh","loading":"Loading…","empty":"No data","search":"Search","actions":"Actions","status":"Status","enabled":"Enabled","disabled":"Disabled","name":"Name","type":"Type","port":"Port","protocol":"Protocol","address":"Address","password":"Password","username":"Username","submit":"Submit","success":"Success","error":"Error","confirmDelete":"Are you sure to delete?","back":"Back","generate":"Generate","validate":"Validate","generated":"Generated","ok":"OK","yes":"Yes","no":"No","more":"More","reset":"Reset","id":"ID","network":"Network","bound":"Bound","missing":"Missing"},"login":{"title":"3M-UI","subtitle":"Mihomo Core Management Console","username":"Username","password":"Password","button":"Login","welcomeBack":"Welcome back","failed":"Login failed"},"password":{"requiredHint":"You must set a new password before using the panel.","title":"Change Password","current":"Current Password","new":"New Password","confirm":"Confirm New Password","button":"Change Password","success":"Password updated","failed":"Failed to change password","mismatch":"Passwords do not match"},"dashboard":{"title":"Dashboard","subtitle":"System & Core Overview","status":"Core Status","running":"Running","stoppedStatus":"Stopped","version":"Version","pid":"PID","uptime":"Uptime","listeners":"Node Stats","total":"Total","enabled":"Enabled","disabled":"Disabled","system":"System Resources","cpu":"CPU","memory":"Memory","disk":"Disk","network":"Network","upload":"Upload","download":"Download","traffic":"Traffic Stats","uploadRate":"Upload rate","downloadRate":"Download rate","totalUpload":"Total upload","totalDownload":"Total download","onlineUsers":"Online users","activeConnections":"Active connections","start":"Start","stop":"Stop","restart":"Restart","started":"Core started","stopped":"Core stopped","restarted":"Core restarted","operationFailed":"Operation failed","unavailable":"Status unavailable","users":"Users","totalUsers":"Total users","enabledUsers":"Enabled users"},"listeners":{"subtitle":"Create and manage Mihomo inbound listeners.","quickSetup":"Quick setup","quickSetupHint":"One-click inbound (VLESS+REALITY by default). Override port, SNI, keys below if needed.","preset":"Preset","portAuto":"Leave empty to auto-pick a port","hy2QuickHint":"Skeleton only — add cert/key in node edit before production.","quickHints":"Generated","bind":"Bind address","publicHost":"Public host","title":"Nodes","create":"Create Node","edit":"Edit Node","name":"Name","protocol":"Protocol","port":"Port","bindAddress":"Bind Address","status":"Status","actions":"Actions","config":"Protocol Config","udp":"UDP","tls":"TLS","deleteConfirm":"Delete this node?","created":"Created","updated":"Updated","deleted":"Deleted","reloaded":"Reloaded","copyURI":"Copy subscription URI","urisTitle":"Node subscription links","invalidJSON":"Invalid JSON","selectProtocolFirst":"Select a protocol first","transportXhttpHint":"XHTTP is available for VLESS only.","transportNoXhttpHint":"XHTTP is not available for this protocol (VLESS only).","alwaysTlsHint":"This protocol requires TLS. Leave certificate empty to auto-generate a panel self-signed pair on save.","tlsPairAutoHint":"Certificate + private-key required. Leave both empty → panel self-signed on save.","sectionTlsMirror":"TLS mirror","tlsMirrorHint":"Advanced. Requires dest and primary-key; mutually exclusive with certificate TLS.","tlsMirrorDest":"Mirror dest","tlsMirrorPrimaryKey":"Primary key","tlsMirrorProxy":"Proxy","usersHint":"Create credentials under Users and bind them to this node. Only official Listener fields are configured here.","portHint":"Single port (e.g. 443) or official ports syntax (e.g. 200,204,401-429)","randomPort":"Random port","noAvailablePort":"No suLine truncated
+1 -1
View File
@@ -1 +1 @@
export default {"realityScan":{"required":"请选择 Reality 目标或手动填写。","placeholder":"目标域名:443","hint":"从当前节点检测并自动选择,也可手动填写目标。","scan":"重新检测","change":"换一个","scanning":"正在检测可用目标……","passed":"握手检测通过","noTargets":"未找到合适目标,请重新检测或手动填写。","failed":"目标检测失败,请重试或手动填写。","scope":"仅从已审核候选中检测握手、跳转和已知 CDN 特征,不保证排除回落流量滥用。"},"app":{"name":"3M-UI","title":"Mihomo 核心管理控制台"},"nav":{"dashboard":"仪表盘","listeners":"节点管理","users":"用户管理","traffic":"流量监控","cluster":"多机节点","routing":"路由规则","core":"核心管理","logs":"运行日志","config":"配置引擎","share":"分享/订阅","settings":"系统设置","logout":"退出登录"},"common":{"advanced":"高级选项","prev":"上一步","next":"下一步","saved":"已保存","copied":"已复制","copyFailed":"复制失败,请手动选中复制","save":"保存","cancel":"取消","delete":"删除","edit":"编辑","create":"创建","confirm":"确认","close":"关闭","copy":"复制","download":"下载","refresh":"刷新","loading":"加载中…","empty":"暂无数据","search":"搜索","actions":"操作","status":"状态","enabled":"启用","disabled":"禁用","name":"名称","type":"类型","port":"端口","protocol":"协议","address":"地址","password":"密码","username":"用户名","submit":"提交","success":"操作成功","error":"操作失败","confirmDelete":"确定要删除吗?","back":"返回","generate":"生成","validate":"验证","generated":"已生成","ok":"确定","yes":"是","no":"否","more":"更多","reset":"重置","id":"ID","network":"网络","bound":"已绑定","missing":"缺失"},"login":{"title":"3M-UI","subtitle":"Mihomo 核心管理控制台","username":"用户名","password":"密码","button":"登录","welcomeBack":"欢迎回来","failed":"登录失败"},"password":{"requiredHint":"首次登录或管理员要求改密时,请先设置新密码后再使用面板。","title":"修改密码","current":"当前密码","new":"新密码","confirm":"确认新密码","button":"修改密码","success":"密码已更新","failed":"修改密码失败","mismatch":"两次输入的密码不一致"},"dashboard":{"title":"仪表盘","subtitle":"系统与核心状态概览","status":"核心状态","running":"运行中","stoppedStatus":"已停止","version":"版本","pid":"进程 ID","uptime":"运行时间","listeners":"节点统计","total":"总数","enabled":"已启用","disabled":"已禁用","system":"系统资源","cpu":"CPU","memory":"内存","disk":"磁盘","network":"网络","upload":"上传","download":"下载","traffic":"流量统计","uploadRate":"上传速率","downloadRate":"下载速率","totalUpload":"总上传","totalDownload":"总下载","onlineUsers":"在线用户","activeConnections":"活跃连接","start":"启动","stop":"停止","restart":"重启","started":"核心已启动","stopped":"核心已停止","restarted":"核心已重启","operationFailed":"操作失败","unavailable":"无法获取状态","users":"用户","totalUsers":"用户总数","enabledUsers":"启用用户"},"listeners":{"subtitle":"创建与管理 Mihomo 入站节点。","quickSetup":"一键搭建","quickSetupHint":"一键创建入站(默认 VLESS+REALITY)。可在下方覆盖端口、SNI、密钥等。","preset":"预设方案","portAuto":"留空自动分配端口","hy2QuickHint":"仅生成骨架,生产前请在节点编辑中补充证书/私钥","quickHints":"生成结果","bind":"绑定地址","publicHost":"公网地址","title":"节点管理","create":"创建节点","edit":"编辑节点","name":"名称","protocol":"协议","port":"端口","bindAddress":"绑定地址","status":"状态","actions":"操作","config":"协议配置","udp":"UDP","tls":"TLS","deleteConfirm":"确定要删除此节点吗?","created":"创建成功","updated":"更新成功","deleted":"删除成功","reloaded":"重载成功","copyURI":"复制订阅链接","urisTitle":"节点订阅链接","invalidJSON":"无效的 JSON","selectProtocolFirst":"请先选择协议","transportXhttpHint":"XHTTP 仅 VLESS 可用。","transportNoXhttpHint":"本协议不支持 XHTTP(仅 VLESS)。","alwaysTlsHint":"该协议必须使用 TLS。证书/私钥可留空,保存时由面板自动生成自签证书。","tlsPairAutoHint":"需要证书与私钥。两者都留空时,保存后由面板生成自签证书。","sectionTlsMirror":"TLS 镜像","tlsMirrorHint":"高级选项。需填写 dest 与 primary-key;与普通证书 TLS 互斥。","tlsMirrorDest":"镜像目标","tlsMirrorPrimaryKey":"Primary Key","tlsMirrorProxy":"代理","usersHint":"用户凭证请在「用户管理」中创建并绑定到本节点;此处仅配置官方 Listener 协议字段。","portHint":"支持单端口(如 443)或官方 ports 语法(如Line truncated
export default {"realityScan":{"required":"请选择 Reality 目标或手动填写。","placeholder":"目标域名:443","hint":"从当前节点检测并自动选择,也可手动填写目标。","scan":"重新检测","change":"换一个","scanning":"正在检测可用目标……","passed":"握手检测通过","noTargets":"未找到合适目标,请重新检测或手动填写。","failed":"目标检测失败,请重试或手动填写。","scope":"仅从已审核候选中检测握手、跳转和已知 CDN 特征,不保证排除回落流量滥用。"},"app":{"name":"3M-UI","title":"Mihomo 核心管理控制台"},"nav":{"dashboard":"仪表盘","listeners":"节点管理","users":"用户管理","traffic":"流量监控","cluster":"多机节点","routing":"路由规则","core":"核心管理","logs":"运行日志","config":"配置引擎","share":"分享/订阅","settings":"系统设置","logout":"退出登录"},"common":{"advanced":"高级选项","prev":"上一步","next":"下一步","saved":"已保存","copied":"已复制","copyFailed":"复制失败,请手动选中复制","save":"保存","cancel":"取消","delete":"删除","edit":"编辑","create":"创建","confirm":"确认","close":"关闭","copy":"复制","download":"下载","refresh":"刷新","loading":"加载中…","empty":"暂无数据","search":"搜索","actions":"操作","status":"状态","enabled":"启用","disabled":"禁用","name":"名称","type":"类型","port":"端口","protocol":"协议","address":"地址","password":"密码","username":"用户名","submit":"提交","success":"操作成功","error":"操作失败","confirmDelete":"确定要删除吗?","back":"返回","generate":"生成","validate":"验证","generated":"已生成","ok":"确定","yes":"是","no":"否","more":"更多","reset":"重置","id":"ID","network":"网络","bound":"已绑定","missing":"缺失"},"login":{"title":"3M-UI","subtitle":"Mihomo 核心管理控制台","username":"用户名","password":"密码","button":"登录","welcomeBack":"欢迎回来","failed":"登录失败"},"password":{"requiredHint":"首次登录或管理员要求改密时,请先设置新密码后再使用面板。","title":"修改密码","current":"当前密码","new":"新密码","confirm":"确认新密码","button":"修改密码","success":"密码已更新","failed":"修改密码失败","mismatch":"两次输入的密码不一致"},"dashboard":{"title":"仪表盘","subtitle":"系统与核心状态概览","status":"核心状态","running":"运行中","stoppedStatus":"已停止","version":"版本","pid":"进程 ID","uptime":"运行时间","listeners":"节点统计","total":"总数","enabled":"已启用","disabled":"已禁用","system":"系统资源","cpu":"CPU","memory":"内存","disk":"磁盘","network":"网络","upload":"上传","download":"下载","traffic":"流量统计","uploadRate":"上传速率","downloadRate":"下载速率","totalUpload":"总上传","totalDownload":"总下载","onlineUsers":"在线用户","activeConnections":"活跃连接","start":"启动","stop":"停止","restart":"重启","started":"核心已启动","stopped":"核心已停止","restarted":"核心已重启","operationFailed":"操作失败","unavailable":"无法获取状态","users":"用户","totalUsers":"用户总数","enabledUsers":"启用用户"},"listeners":{"subtitle":"创建与管理 Mihomo 入站节点。","quickSetup":"一键搭建","quickSetupHint":"一键创建入站(默认 VLESS+REALITY)。可在下方覆盖端口、SNI、密钥等。","preset":"预设方案","portAuto":"留空自动分配端口","hy2QuickHint":"仅生成骨架,生产前请在节点编辑中补充证书/私钥","quickHints":"生成结果","bind":"绑定地址","publicHost":"公网地址","title":"节点管理","create":"创建节点","edit":"编辑节点","name":"名称","protocol":"协议","port":"端口","bindAddress":"绑定地址","status":"状态","actions":"操作","config":"协议配置","udp":"UDP","tls":"TLS","deleteConfirm":"确定要删除此节点吗?","created":"创建成功","updated":"更新成功","deleted":"删除成功","reloaded":"重载成功","copyURI":"复制订阅链接","urisTitle":"节点订阅链接","invalidJSON":"无效的 JSON","selectProtocolFirst":"请先选择协议","transportXhttpHint":"XHTTP 仅 VLESS 可用。","transportNoXhttpHint":"本协议不支持 XHTTP(仅 VLESS)。","alwaysTlsHint":"该协议必须使用 TLS。证书/私钥可留空,保存时由面板自动生成自签证书。","tlsPairAutoHint":"需要证书与私钥。两者都留空时,保存后由面板生成自签证书。","sectionTlsMirror":"TLS 镜像","tlsMirrorHint":"高级选项。需填写 dest 与 primary-key;与普通证书 TLS 互斥。","tlsMirrorDest":"镜像目标","tlsMirrorPrimaryKey":"Primary Key","tlsMirrorProxy":"代理","usersHint":"用户凭证请在「用户管理」中创建并绑定到本节点;此处仅配置官方 Listener 协议字段。","portHint":"支持单端口(如 443)或官方 ports 语法(如Line truncated
+1 -1
View File
@@ -1 +1 @@
export default {"realityScan":{"required":"請選擇 Reality 目標或手動填寫。","placeholder":"例如 www.example.com:443","hint":"建議選用掃描通過的目標;也可手動輸入 dest。","scan":"掃描目標","change":"更換","scanning":"掃描中…","passed":"已通過","noTargets":"暫無可用目標","failed":"掃描失敗","scope":"僅檢測公開可達的 REALITY 候選目標。"},"app":{"name":"3m-ui","title":"3m-ui 管理面板"},"nav":{"dashboard":"儀表板","listeners":"節點管理","users":"使用者管理","traffic":"流量監控","cluster":"多機節點","routing":"路由規則","core":"核心管理","logs":"運行日誌","config":"配置引擎","share":"訂閱與分享","settings":"系統設定","logout":"登出"},"common":{"advanced":"進階","prev":"上一頁","next":"下一頁","saved":"已儲存","copied":"已複製","copyFailed":"複製失敗","save":"儲存","cancel":"取消","delete":"刪除","edit":"編輯","create":"建立","confirm":"確認","close":"關閉","copy":"複製","download":"下載","refresh":"重新整理","loading":"載入中…","empty":"暫無資料","search":"搜尋","actions":"操作","status":"狀態","enabled":"啟用","disabled":"停用","name":"名稱","type":"類型","port":"埠","protocol":"協定","address":"地址","password":"密碼","username":"使用者名稱","submit":"提交","success":"成功","error":"失敗","confirmDelete":"確認刪除?","back":"返回","generate":"產生","validate":"驗證","generated":"已產生","ok":"確定","yes":"是","no":"否","more":"更多","reset":"重設","id":"ID","network":"網路","bound":"已綁定","missing":"缺失"},"login":{"title":"登入","subtitle":"Mihomo 管理面板","username":"使用者名稱","password":"密碼","button":"登入","welcomeBack":"歡迎回來","failed":"登入失敗"},"password":{"requiredHint":"使用面板前請先設定新密碼。","title":"修改密碼","current":"目前密碼","new":"新密碼","confirm":"確認新密碼","button":"更新密碼","success":"密碼已更新","failed":"修改失敗","mismatch":"兩次輸入不一致"},"dashboard":{"title":"儀表板","subtitle":"系統與節點概覽","status":"Core Status","running":"運行中","stoppedStatus":"Stopped","version":"Version","pid":"PID","uptime":"運行時間","listeners":"Node Stats","total":"Total","enabled":"Enabled","disabled":"Disabled","system":"System Resources","cpu":"CPU","memory":"記憶體","disk":"磁碟","network":"Network","upload":"Upload","download":"Download","traffic":"流量","uploadRate":"Upload rate","downloadRate":"Download rate","totalUpload":"Total upload","totalDownload":"Total download","onlineUsers":"Online users","activeConnections":"Active connections","start":"Start","stop":"Stop","restart":"Restart","started":"Core started","stopped":"已停止","restarted":"Core restarted","operationFailed":"Operation failed","unavailable":"Status unavailable","users":"使用者","totalUsers":"Total users","enabledUsers":"Enabled users","nodes":"節點","online":"線上","core":"核心","refresh":"重新整理"},"listeners":{"subtitle":"入站 Listener","quickSetup":"Quick setup","quickSetupHint":"One-click inbound (VLESS+REALITY by default). Override port, SNI, keys below if needed.","preset":"Preset","portAuto":"Leave empty to auto-pick a port","hy2QuickHint":"Skeleton only — add cert/key in node edit before production.","quickHints":"Generated","bind":"Bind address","publicHost":"Public host","title":"節點管理","create":"建立節點","edit":"編輯","name":"Name","protocol":"Protocol","port":"Port","bindAddress":"Bind Address","status":"狀態","actions":"操作","config":"Protocol Config","udp":"UDP","tls":"TLS","deleteConfirm":"Delete this node?","created":"Created","updated":"Updated","deleted":"Deleted","reloaded":"Reloaded","copyURI":"Copy subscription URI","urisTitle":"Node subscription links","invalidJSON":"Invalid JSON","selectProtocolFirst":"Select a protocol first","transportXhttpHint":"XHTTP is available for VLESS only.","transportNoXhttpHint":"XHTTP is not available for this protocol (VLESS only).","alwaysTlsHint":"This protocol requires TLS. Leave certificate empty to auto-generate a panel self-signed pair on save.","tlsPairAutoHint":"Certificate + private-key required. Leave both empty → panel self-signed on save.","sectionTlsMirror":"TLS mirror","tlsMirrorHint":"Advanced. Requires dest and primary-key; mutually exclusive with certificate TLS.","tlsMirrorDest":"Mirror dest","tlsMirrorPrimaryKey":"Primary key","tlsMirrorProxy":"Proxy","usersHint":"Create credentials under Users and bind them to this node. Only official Listener fields are configured here.","portHint":"Single port (e.g. 443) or official ports syntax (e.g. 200,204,401-429)","randomPort":"隨機埠","noAvailablePort":"No suggested port is available in 10000-60000. Enter a port manually.","sectionProtocol":"協定","transportLayer":"Transport","securityLayer":"Security","sectionSecurity":"安全",Line truncated
export default {"realityScan":{"required":"請選擇 Reality 目標或手動填寫。","placeholder":"例如 www.example.com:443","hint":"建議選用掃描通過的目標;也可手動輸入 dest。","scan":"掃描目標","change":"更換","scanning":"掃描中…","passed":"已通過","noTargets":"暫無可用目標","failed":"掃描失敗","scope":"僅檢測公開可達的 REALITY 候選目標。"},"app":{"name":"3m-ui","title":"3m-ui 管理面板"},"nav":{"dashboard":"儀表板","listeners":"節點管理","users":"使用者管理","traffic":"流量監控","cluster":"多機節點","routing":"路由規則","core":"核心管理","logs":"運行日誌","config":"配置引擎","share":"訂閱與分享","settings":"系統設定","logout":"登出"},"common":{"advanced":"進階","prev":"上一頁","next":"下一頁","saved":"已儲存","copied":"已複製","copyFailed":"複製失敗","save":"儲存","cancel":"取消","delete":"刪除","edit":"編輯","create":"建立","confirm":"確認","close":"關閉","copy":"複製","download":"下載","refresh":"重新整理","loading":"載入中…","empty":"暫無資料","search":"搜尋","actions":"操作","status":"狀態","enabled":"啟用","disabled":"停用","name":"名稱","type":"類型","port":"埠","protocol":"協定","address":"地址","password":"密碼","username":"使用者名稱","submit":"提交","success":"成功","error":"失敗","confirmDelete":"確認刪除?","back":"返回","generate":"產生","validate":"驗證","generated":"已產生","ok":"確定","yes":"是","no":"否","more":"更多","reset":"重設","id":"ID","network":"網路","bound":"已綁定","missing":"缺失"},"login":{"title":"登入","subtitle":"Mihomo 管理面板","username":"使用者名稱","password":"密碼","button":"登入","welcomeBack":"歡迎回來","failed":"登入失敗"},"password":{"requiredHint":"使用面板前請先設定新密碼。","title":"修改密碼","current":"目前密碼","new":"新密碼","confirm":"確認新密碼","button":"更新密碼","success":"密碼已更新","failed":"修改失敗","mismatch":"兩次輸入不一致"},"dashboard":{"title":"儀表板","subtitle":"系統與節點概覽","status":"Core Status","running":"運行中","stoppedStatus":"Stopped","version":"Version","pid":"PID","uptime":"運行時間","listeners":"Node Stats","total":"Total","enabled":"Enabled","disabled":"Disabled","system":"System Resources","cpu":"CPU","memory":"記憶體","disk":"磁碟","network":"Network","upload":"Upload","download":"Download","traffic":"流量","uploadRate":"Upload rate","downloadRate":"Download rate","totalUpload":"Total upload","totalDownload":"Total download","onlineUsers":"Online users","activeConnections":"Active connections","start":"Start","stop":"Stop","restart":"Restart","started":"Core started","stopped":"已停止","restarted":"Core restarted","operationFailed":"Operation failed","unavailable":"Status unavailable","users":"使用者","totalUsers":"Total users","enabledUsers":"Enabled users","nodes":"節點","online":"線上","core":"核心","refresh":"重新整理"},"listeners":{"subtitle":"入站 Listener","quickSetup":"Quick setup","quickSetupHint":"One-click inbound (VLESS+REALITY by default). Override port, SNI, keys below if needed.","preset":"Preset","portAuto":"Leave empty to auto-pick a port","hy2QuickHint":"Skeleton only — add cert/key in node edit before production.","quickHints":"Generated","bind":"Bind address","publicHost":"Public host","title":"節點管理","create":"建立節點","edit":"編輯","name":"Name","protocol":"Protocol","port":"Port","bindAddress":"Bind Address","status":"狀態","actions":"操作","config":"Protocol Config","udp":"UDP","tls":"TLS","deleteConfirm":"Delete this node?","created":"Created","updated":"Updated","deleted":"Deleted","reloaded":"Reloaded","copyURI":"Copy subscription URI","urisTitle":"Node subscription links","invalidJSON":"Invalid JSON","selectProtocolFirst":"Select a protocol first","transportXhttpHint":"XHTTP is available for VLESS only.","transportNoXhttpHint":"XHTTP is not available for this protocol (VLESS only).","alwaysTlsHint":"This protocol requires TLS. Leave certificate empty to auto-generate a panel self-signed pair on save.","tlsPairAutoHint":"Certificate + private-key required. Leave both empty → panel self-signed on save.","sectionTlsMirror":"TLS mirror","tlsMirrorHint":"Advanced. Requires dest and primary-key; mutually exclusive with certificate TLS.","tlsMirrorDest":"Mirror dest","tlsMirrorPrimaryKey":"Primary key","tlsMirrorProxy":"Proxy","usersHint":"Create credentials under Users and bind them to this node. Only official Listener fields are configured here.","portHint":"Single port (e.g. 443) or official ports syntax (e.g. 200,204,401-429)","randomPort":"隨機埠","noAvailablePort":"No suggested port is available in 10000-60000. Enter a port manually.","sectionProtocol":"協定","transportLayer":"Transport","securityLayer":"Security","sectionSecurity":"安全",Line truncated
+110 -41
View File
@@ -1,64 +1,133 @@
import { useState, useEffect } from 'react';
import { Alert, Button, Card, Descriptions, Space, Spin, message } from 'antd';
import { PlayCircleOutlined, StopOutlined, RedoOutlined } from '@ant-design/icons';
import client, { isCanceledError } from '../api/client';
import { useState, useEffect, useRef } from 'react';
import { Alert, Button, Card, Descriptions, Modal, Select, Space, Spin, Steps, Typography, message } from 'antd';
import { PlayCircleOutlined, StopOutlined, RedoOutlined, DownloadOutlined, RollbackOutlined } from '@ant-design/icons';
import { coreAPI, type CoreStatus, type CoreRelease, type CoreUpdateStatus } from '../api/core';
import { isCanceledError } from '../api/client';
import { useI18n } from '../i18n';
import useIsMobile from '../hooks/useIsMobile';
import PageHeader from '../components/PageHeader';
import { startVisiblePolling } from '../utils/visiblePolling';
type Status = { running: boolean; version: string; pid: number; uptime: string };
const stages = ['preparing', 'resolving', 'downloading', 'verifying', 'validating', 'switching', 'starting', 'checking'];
export default function Core() {
const { t } = useI18n();
const isMobile = useIsMobile();
const [status, setStatus] = useState<Status | null>(null);
const [status, setStatus] = useState<CoreStatus | null>(null);
const [update, setUpdate] = useState<CoreUpdateStatus | null>(null);
const [releases, setReleases] = useState<CoreRelease[]>([]);
const [selected, setSelected] = useState<string>();
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [requesting, setRequesting] = useState(false);
const [checking, setChecking] = useState(false);
const [error, setError] = useState('');
const [confirmation, setConfirmation] = useState<'install' | 'rollback' | null>(null);
const mounted = useRef(true);
const busy = requesting || !!update?.busy;
const job = update?.job;
const load = async (showError = true) => {
try {
const { data } = await client.get('/mihomo/status');
setStatus(data);
return true;
} catch (e: any) {
if (showError && !isCanceledError(e)) message.error(e.message || t('core.unavailable'));
return false;
} finally { setLoading(false); }
useEffect(() => {
mounted.current = true;
let delay = 5000;
const stopPolling = startVisiblePolling(async signal => {
delay = 5000;
try {
const [nextStatus, nextUpdate] = await Promise.all([coreAPI.status(signal), coreAPI.updateStatus(signal)]);
if (signal.aborted) return;
setStatus(nextStatus); setUpdate(nextUpdate); setError('');
delay = nextUpdate.busy ? 1000 : 5000;
} catch (e) {
if (!signal.aborted && !isCanceledError(e)) setError((e as Error).message);
} finally {
if (!signal.aborted) setLoading(false);
}
}, () => delay);
return () => { mounted.current = false; stopPolling(); };
}, []);
const action = async (path: 'start' | 'stop' | 'restart', successKey: 'started' | 'stopped' | 'restarted') => {
setRequesting(true);
try { await coreAPI.action(path); setStatus(await coreAPI.status()); message.success(t(`core.${successKey}`)); }
catch (e) { message.error((e as Error).message || t('core.operationFailed')); }
finally { if (mounted.current) setRequesting(false); }
};
useEffect(() => { load(); const id = window.setInterval(load, 5000); return () => window.clearInterval(id); }, []);
const action = async (path: string, successKey: 'started' | 'stopped' | 'restarted') => {
setBusy(true);
const checkReleases = async () => {
setChecking(true);
try {
await client.post(`/mihomo/${path}`);
message.success(t(`core.${successKey}`));
if (!(await load(false))) message.warning(t('core.unavailable'));
}
catch (e: any) { message.error(e.message || t('core.operationFailed')); }
finally { setBusy(false); }
const list = await coreAPI.releases();
if (mounted.current) { setReleases(list); setSelected(list.find(item => !item.prerelease)?.version); }
if (list.length === 0) message.info(t('core.updates.noVersions'));
} catch (e) { message.error((e as Error).message); }
finally { if (mounted.current) setChecking(false); }
};
const begin = async () => {
if (!confirmation || (confirmation === 'install' && !selected)) return;
setRequesting(true);
try {
const nextJob = confirmation === 'rollback' ? await coreAPI.rollback() : await coreAPI.install(selected!);
if (mounted.current) {
setUpdate(previous => previous ? { ...previous, busy: true, job: nextJob } : previous);
setConfirmation(null);
}
} catch (e) { message.error((e as Error).message); }
finally { if (mounted.current) setRequesting(false); }
};
const release = releases.find(item => item.version === selected);
const latestStable = releases.find(item => !item.prerelease)?.version;
const progress = !job ? 0 : ['switching', 'starting', 'checking', 'recovering', 'complete'].includes(job.stage) ? 3 : job.stage === 'validating' ? 2 : ['downloading', 'verifying'].includes(job.stage) ? 1 : 0;
return (
<div>
<PageHeader title={t('core.title')} subtitle={t('core.subtitle')} />
{error && <Alert type="error" showIcon title={t('core.unavailable')} description={error} style={{ marginBottom: 16 }} />}
{loading ? <Spin /> : (
<Card>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Alert message={status?.running ? t('core.running') : t('core.stopped')} type={status?.running ? 'success' : 'warning'} showIcon />
<Descriptions bordered column={1}>
<Descriptions.Item label={t('core.version')}>{status?.version || '-'}</Descriptions.Item>
<Descriptions.Item label={t('core.pid')}>{status?.pid || '-'}</Descriptions.Item>
<Descriptions.Item label={t('core.uptime')}>{status?.uptime || '-'}</Descriptions.Item>
</Descriptions>
<Space>
<Button icon={<PlayCircleOutlined />} loading={busy} onClick={() => action('start', 'started')}>{t('core.start')}</Button>
<Button icon={<StopOutlined />} danger loading={busy} onClick={() => action('stop', 'stopped')}>{t('core.stop')}</Button>
<Button icon={<RedoOutlined />} loading={busy} onClick={() => action('restart', 'restarted')}>{t('core.restart')}</Button>
<Space orientation="vertical" style={{ width: '100%' }} size="large">
<Card>
<Space orientation="vertical" style={{ width: '100%' }} size="large">
<Alert title={status?.running ? t('core.running') : t('core.stopped')} type={status?.running ? 'success' : 'warning'} showIcon />
<Descriptions bordered column={1}>
<Descriptions.Item label={t('core.version')}>{status?.version || '-'}</Descriptions.Item>
<Descriptions.Item label={t('core.pid')}>{status?.pid || '-'}</Descriptions.Item>
<Descriptions.Item label={t('core.uptime')}>{status?.uptime || '-'}</Descriptions.Item>
<Descriptions.Item label={t('core.updates.source')}>{t(update?.managed ? 'core.updates.managed' : 'core.updates.bundled')}</Descriptions.Item>
<Descriptions.Item label={t('core.updates.previous')}>{update?.previous_version || t('core.updates.noPrevious')}</Descriptions.Item>
</Descriptions>
<Space wrap>
<Button icon={<PlayCircleOutlined />} disabled={busy || !!error} onClick={() => action('start', 'started')}>{t('core.start')}</Button>
<Button icon={<StopOutlined />} danger disabled={busy || !!error} onClick={() => action('stop', 'stopped')}>{t('core.stop')}</Button>
<Button icon={<RedoOutlined />} disabled={busy || !!error} onClick={() => action('restart', 'restarted')}>{t('core.restart')}</Button>
</Space>
</Space>
</Space>
</Card>
</Card>
<Card title={t('core.updates.title')}>
<Space orientation="vertical" size="large" style={{ width: '100%' }}>
<Typography.Paragraph style={{ margin: 0 }}>{t('core.updates.description')}</Typography.Paragraph>
{!update?.supported && <Alert type="warning" showIcon title={t('core.updates.unavailable')} description={update?.disabled_reason} />}
<Space wrap style={{ width: '100%' }}>
<Button onClick={checkReleases} loading={checking} disabled={busy || !update?.supported || !!error}>{t('core.updates.check')}</Button>
<Select aria-label={t('core.updates.select')} placeholder={t('core.updates.select')} value={selected} onChange={setSelected} style={{ width: isMobile ? 260 : 320, maxWidth: '100%' }} disabled={busy || !releases.length} options={[true, false].map(pre => ({ label: t(pre ? 'core.updates.pre' : 'core.updates.stable'), options: releases.filter(item => item.prerelease === pre).map(item => ({ value: item.version, label: `${item.version}${item.prerelease ? ` · ${t('core.updates.pre')}` : ''}${item.version === latestStable ? ` · ${t('core.updates.latest')}` : ''}${item.version === status?.version ? ` · ${t('core.updates.current')}` : ''}` })) })).filter(group => group.options.length > 0)} />
<Button type="primary" icon={<DownloadOutlined />} disabled={busy || !update?.supported || !selected || selected === status?.version || !!error} onClick={() => setConfirmation('install')}>{t('core.updates.install')}</Button>
<Button icon={<RollbackOutlined />} disabled={busy || !update?.supported || !update?.previous_version || !!error} onClick={() => setConfirmation('rollback')}>{t('core.updates.rollback')}</Button>
</Space>
{release && <Typography.Link href={release.url} target="_blank" rel="noopener noreferrer">{t('core.updates.releaseNotes')} · {release.version}</Typography.Link>}
{release?.prerelease && <Alert type="warning" showIcon title={t('core.updates.preNotice')} />}
{job && <>
<Alert type={job.status === 'failed' ? 'error' : job.status === 'succeeded' ? 'success' : 'info'} showIcon title={`${t(`core.updates.${job.status}`)} · ${job.version}`} description={<>
<div>{t(`core.updates.stage.${stages.includes(job.stage) || ['recovering', 'complete', 'interrupted'].includes(job.stage) ? job.stage : 'preparing'}`)}</div>
{job.error && <div style={{ overflowWrap: 'anywhere' }}>{job.error}</div>}
{job.rolled_back && <div>{t('core.updates.restored')}</div>}
</>} />
<Steps size="small" orientation={isMobile ? 'vertical' : 'horizontal'} current={job.status === 'succeeded' ? 4 : progress} status={job.status === 'failed' ? 'error' : 'process'} items={['prepare', 'download', 'validate', 'activate'].map(step => ({ title: t(`core.updates.steps.${step}`) }))} />
</>}
</Space>
</Card>
</Space>
)}
<Modal open={confirmation !== null} title={t(confirmation === 'rollback' ? 'core.updates.rollback' : 'core.updates.install')} okText={t('common.confirm')} cancelText={t('common.cancel')} confirmLoading={requesting} onOk={begin} onCancel={() => { if (!requesting) setConfirmation(null); }} closable={!requesting} maskClosable={!requesting} cancelButtonProps={{ disabled: requesting }}>
<p>{t('core.updates.confirm')} <strong>{confirmation === 'rollback' ? update?.previous_version : selected}</strong></p>
{confirmation === 'install' && release?.prerelease && <p>{t('core.updates.preNotice')}</p>}
<p>{t(status?.running ? 'core.updates.interruption' : 'core.updates.staysStopped')}</p>
</Modal>
</div>
);
}
@@ -54,3 +54,38 @@ test('visible polling pauses, cancels and resumes without overlapping requests',
t.mock.timers.tick(60000);
assert.equal(signals.length, 4, 'unmount removes listener and timer');
});
test('effect replacement cannot revive the aborted polling loop and uses the latest delay', async t => {
const original = globalThis.document;
const visibility = new EventTarget();
visibility.hidden = false;
globalThis.document = visibility;
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let delay = 1000;
const poll = signal => new Promise(resolve => { calls.push({ signal, resolve }); });
const oldStop = startVisiblePolling(poll, () => delay);
oldStop();
const stop = startVisiblePolling(poll, () => delay);
t.after(() => { stop(); globalThis.document = original; });
assert.equal(calls.length, 2);
assert.equal(calls[0].signal.aborted, true);
calls[0].resolve();
calls[1].resolve();
await Promise.resolve();
t.mock.timers.tick(1000);
assert.equal(calls.length, 3, 'only the replacement effect polls again');
assert.equal(calls[2].signal.aborted, false);
delay = 5000;
calls[2].resolve();
await Promise.resolve();
t.mock.timers.tick(4999);
assert.equal(calls.length, 3, 'idle status switches to the slower interval');
t.mock.timers.tick(1);
assert.equal(calls.length, 4);
stop();
calls[3].resolve();
await Promise.resolve();
t.mock.timers.tick(60000);
assert.equal(calls.length, 4, 'settling after cleanup never schedules more work');
});
+2 -2
View File
@@ -1,6 +1,6 @@
// Poll only while visible, with at most one request in flight. A hidden or
// unmounted page aborts its request; consumers must ignore aborted results.
export function startVisiblePolling(poll: (signal: AbortSignal) => Promise<void>, interval = 10000) {
export function startVisiblePolling(poll: (signal: AbortSignal) => Promise<void>, interval: number | (() => number) = 10000) {
let stopped = false;
let running = false;
let resumePending = false;
@@ -19,7 +19,7 @@ export function startVisiblePolling(poll: (signal: AbortSignal) => Promise<void>
} finally {
running = false;
if (!stopped && !document.hidden) {
timer = setTimeout(() => { void run(); }, resumePending ? 0 : interval);
timer = setTimeout(() => { void run(); }, resumePending ? 0 : typeof interval === 'function' ? interval() : interval);
}
}
};