feat(mihomo): persistent core updates with rollback (#53)
This commit is contained in:
1 parent
437ad5eee8
commit
3ec69d55de
30 files changed
+2139
-106
No files matched your search
+3
-2
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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 = ©
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"schema":1,"directory":"cores","selection":"selection.json","lock":"update.lock"}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user