diff --git a/Dockerfile b/Dockerfile index b3c3eec..e627443 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index a9263ca..6cac998 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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. diff --git a/backend/internal/app/container.go b/backend/internal/app/container.go index a94d9aa..3d17458 100644 --- a/backend/internal/app/container.go +++ b/backend/internal/app/container.go @@ -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() diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index dd2ab09..a56d61c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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)))) +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2500266..b5fe3ed 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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) + } +} diff --git a/backend/internal/converter/client.go b/backend/internal/converter/client.go index acbd252..26590b5 100644 --- a/backend/internal/converter/client.go +++ b/backend/internal/converter/client.go @@ -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) { diff --git a/backend/internal/docs/openapi.yaml b/backend/internal/docs/openapi.yaml index 84135a7..885921c 100644 --- a/backend/internal/docs/openapi.yaml +++ b/backend/internal/docs/openapi.yaml @@ -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] diff --git a/backend/internal/mihomo/connection_check.go b/backend/internal/mihomo/connection_check.go index 53573b0..44515de 100644 --- a/backend/internal/mihomo/connection_check.go +++ b/backend/internal/mihomo/connection_check.go @@ -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-") diff --git a/backend/internal/mihomo/connection_check_test.go b/backend/internal/mihomo/connection_check_test.go index 365589b..5139d0f 100644 --- a/backend/internal/mihomo/connection_check_test.go +++ b/backend/internal/mihomo/connection_check_test.go @@ -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 { diff --git a/backend/internal/mihomo/core_store.go b/backend/internal/mihomo/core_store.go new file mode 100644 index 0000000..798383e --- /dev/null +++ b/backend/internal/mihomo/core_store.go @@ -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")) +} diff --git a/backend/internal/mihomo/core_update.go b/backend/internal/mihomo/core_update.go new file mode 100644 index 0000000..5ee312e --- /dev/null +++ b/backend/internal/mihomo/core_update.go @@ -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 +} diff --git a/backend/internal/mihomo/core_update_test.go b/backend/internal/mihomo/core_update_test.go new file mode 100644 index 0000000..6dc06f2 --- /dev/null +++ b/backend/internal/mihomo/core_update_test.go @@ -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) +} diff --git a/backend/internal/mihomo/process.go b/backend/internal/mihomo/process.go index 1efd108..2ddb929 100644 --- a/backend/internal/mihomo/process.go +++ b/backend/internal/mihomo/process.go @@ -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 +} diff --git a/backend/internal/mihomo/releases.go b/backend/internal/mihomo/releases.go new file mode 100644 index 0000000..832b05c --- /dev/null +++ b/backend/internal/mihomo/releases.go @@ -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 +} diff --git a/backend/internal/mihomo/service.go b/backend/internal/mihomo/service.go index 3726cff..5e3e511 100644 --- a/backend/internal/mihomo/service.go +++ b/backend/internal/mihomo/service.go @@ -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() } diff --git a/backend/internal/router/mihomo_routes.go b/backend/internal/router/mihomo_routes.go index be83d93..b6c831a 100644 --- a/backend/internal/router/mihomo_routes.go +++ b/backend/internal/router/mihomo_routes.go @@ -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 { diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 9a41a5d..4c23102 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -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) + } + } +} diff --git a/backend/internal/user/api_export.go b/backend/internal/user/api_export.go index 1e4765b..0022e55 100644 --- a/backend/internal/user/api_export.go +++ b/backend/internal/user/api_export.go @@ -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) diff --git a/distribution/core-update.json b/distribution/core-update.json new file mode 100644 index 0000000..9c8fbc9 --- /dev/null +++ b/distribution/core-update.json @@ -0,0 +1 @@ +{"schema":1,"directory":"cores","selection":"selection.json","lock":"update.lock"} diff --git a/docker-compose.bridge.yml b/docker-compose.bridge.yml index 834d4a0..4ca566b 100644 --- a/docker-compose.bridge.yml +++ b/docker-compose.bridge.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 33acf76..3a5b6dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/core-updates.md b/docs/core-updates.md new file mode 100644 index 0000000..6c5d14b --- /dev/null +++ b/docs/core-updates.md @@ -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-`: 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-` 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. diff --git a/docs/installation.md b/docs/installation.md index 6820838..b2a873d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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. diff --git a/frontend/src/api/core.ts b/frontend/src/api/core.ts new file mode 100644 index 0000000..c8e7899 --- /dev/null +++ b/frontend/src/api/core.ts @@ -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('/mihomo/status', { signal })).data, + updateStatus: async (signal?: AbortSignal) => (await client.get('/mihomo/update', { signal })).data, + releases: async () => (await client.get('/mihomo/releases', { timeout: 25000 })).data, + install: async (version: string) => (await client.post('/mihomo/update', { version })).data, + rollback: async () => (await client.post('/mihomo/update/rollback')).data, + action: async (action: 'start' | 'stop' | 'restart') => client.post(`/mihomo/${action}`), +}; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 03bb2aa..4daed6c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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 suggested port is available in 10000-60000. Enter a port manually.","sectionProtocol":"Protocol options","transportLayer":"Transport","securityLayer":"Security","sectionSecurity":"Security","sectionTransport":"Transport","sectionTLS":"TLS certificate","sectionReality":"REALITY","sectionSSOption":"Trojan SS option","cipher":"Cipher","password":"Password","passwordOptional":"Password (optional)","uuid":"UUID","privateKey":"Private key","certificate":"Certificate","alpn":"ALPN","sni":"SNI","clientFingerprint":"Client fingerprint","allowInsecure":"Allow insecure","muxOption":"Mux","realityPrivateKey":"Reality private key","realityShortID":"Short ID","realityServerNames":"Server names","realityDest":"Dest","transport":"Transport","path":"Path","host":"Host","serviceName":"Service name","up":"Up","down":"Down","ignoreClientBandwidth":"Ignore client bandwidth","masquerade":"Masquerade","congestionControl":"Congestion control","clone":"Clone","saveTemplate":"Save as template","versions":"Versions","cloned":"Cloned","templateCreated":"Template created","instantiated":"Listener created from template","batchDone":"Batch operation completed","templates":"Listener Templates","templateName":"Template name","createdAt":"Created at","instantiate":"Instantiate","deleteTemplateConfirm":"Delete this template?","templateDeleted":"Template deleted","enableSelected":"Enable selected","disableSelected":"Disable selected","newPort":"New port","version":"Version","reason":"Reason","diff":"Diff","rollback":"Rollback","rollbackConfirm":"Rollback to this version?","rollbackDone":"Rollback completed","addUser":"Add user","aeadMethod":"AEAD method","allowInsecureHint":"Allow insecure (debug only)","alterId":"Alter ID","alterIdHint":"VMess alterId, prefer 0","authenticationTimeout":"Auth timeout","bandwidthHint":"Bandwidth limit, e.g. 100 mbps","bbrProfile":"BBR profile","clientAuthCert":"Client cert","clientAuthType":"Client auth type","congestionController":"Congestion controller","createWizard":"Create listener wizard","cwnd":"CWND","decryption":"Decryption","encryption":"Encryption","encryptionHint":"Client-only field for subscription export; not written to inbound YAML.","decryptionHint":"Server-side field written to listener decryption.","echKey":"ECH Key","enablePureDownlink":"Pure downlink","flow":"Flow","flowHint":"e.g. xtls-rprx-vision","grpcHint":"gRPC transport","grpcServiceName":"gRPC service name","grpcUserAgent":"gRPC User-Agent","grpcUserAgentHint":"gRPC client User-Agent string (panel-side metadata; emitted into client-side grpc-opts).","pingInterval":"Ping interval (ms)","pingIntervalHint":"gRPC keepalive ping interval (panel-side metadata; emitted into client-side grpc-opts).","maxConnections":"Max connections","maxConnectionsHint":"gRPC max concurrent connections (panel-side metadata).","minStreams":"Min streams","minStreamsHint":"gRPC min concurrent streams (panel-side metadata).","maxStreams":"Max streams","maxStreamsHint":"gRPC max concurrent streams (panel-side metadata).","handshakeTimeout":"Handshake timeout","jlsDest":"JLS dest","jlsHint":"JLS settings","jlsProxy":"JLS proxy","jlsSni":"JLS SNI","jlsSniHint":"JLS SNI hostname","jlsUpstreamAddr":"JLS upstream","jlsUsers":"JLS users","kcpTunConn":"KCP connections","kcpTunCrypt":"KCP crypt","kcpTunKey":"KCP key","kcpTunMode":"KCP mode","kcpTunNocomp":"KCP no compress","kcpTunRcvwnd":"KCP recv window","kcpTunSndwnd":"KCP send window","maxIdleTime":"Max idle time","maxUdpRelayPacketSize":"Max UDP relay packet","mekyaHint":"Mekya settings","mekyaKcpSection":"Mekya KCP","mekyaMaxSimultaneous":"Max simultaneous","mekyaMaxSimultaneousHint":"Max concurrent connections","mekyaMaxWriteDuration":"Max write duration","mekyaMaxWriteDurationHint":"Write timeout","mekyaMaxWriteSize":"Max write size","mekyaMaxWriteSizeHint":"Single write limit","mekyaPacketBuffer":"Packet buffer","mekyaPacketBufferHint":"Packet buffer size","mekyaPollingInterval":"Polling interval (ms)","mekyaH2PoolSize":"H2 pool size","mkcpCongestion":"mKCP congestion","mkcpDownlink":"mKCP downlink","mkcpHeader":"mKCP header","mkcpHint":"mKCP transport","mkcpReadBuffer":"mKCP read buffer","mkcpSeed":"mKCP seed","mkcpUplink":"mKCP uplink","mkcpWriteBuffer":"mKCP write buffer","muxBrutal":"Mux brutal","muxBrutalDown":"Brutal down","muxBrutalUp":"Brutal up","muxMaxConnections":"Mux max connections","muxMaxStreams":"Mux max streams","muxMinStreams":"Mux min streams","muxOnlyTcp":"Mux TCP only","muxPadding":"Mux padding","muxProtocol":"Mux protocol","muxStatistic":"Mux statistics","network":"Network","obfs":"Obfuscation","obfsMaxPacketSize":"Obfs max packet","obfsMinPacketSize":"Obfs min packet","obfsOptsHost":"Obfs host","obfsOptsMode":"Obfs mode","obfsPassword":"Obfs password","paddingMax":"Padding max","paddingMin":"Padding min","paddingScheme":"Padding scheme","psk":"PSK","quicVersions":"QUIC versions","rateLimit":"Rate limit","rateLimitHint":"e.g. 10m","realityExclusiveHint":"Reality and TLS cert are mutually exclusive","realityShortId":"Reality short ID","realmId":"Realm ID","realmProxy":"Realm proxy","realmServerUrl":"Realm server URL","realmSkipCert":"Skip cert verify","realmStun":"STUN","realmToken":"Realm token","resTlsDest":"ResTLS dest","resTlsHint":"ResTLS settings","resTlsMinRecordLen":"Min record length","resTlsPassword":"ResTLS password","resTlsProxy":"ResTLS proxy","resTlsScript":"ResTLS script","sectionJLS":"JLS","sectionJlsUpstream":"JLS upstream","sectionKcpTun":"KCP Tun","sectionMekya":"Mekya","sectionMkcp":"mKCP","sectionMux":"Mux","sectionRealm":"Realm","sectionResTLS":"Reality TLS","sectionShadowTLS":"ShadowTLS","sectionSimpleObfs":"simple-obfs","sectionXHTTP":"XHTTP","shadowTlsHandshakeDest":"Handshake dest","shadowTlsHandshakeProxy":"Handshake proxy","shadowTlsHint":"ShadowTLS settings","shadowTlsPassword":"ShadowTLS password","shadowTlsPasswordHint":"v2/v3 password","shadowTlsUsers":"ShadowTLS users","shadowTlsVersion":"ShadowTLS version","simpleObfsMode":"Simple Obfs mode","snellVersion":"Snell version","ssOptionMethod":"SS method","ssOptionPassword":"SS password","sudokuKey":"Sudoku key","customTable":"Custom table","customTables":"Custom tables","sudokuFallback":"Fallback","sectionHttpmask":"HTTP mask","httpmaskDisable":"Disable HTTP mask","httpmaskMode":"HTTP mask mode","httpmaskPathRoot":"HTTP path root","tableType":"Table type","tlsPairHint":"Certificate and private key must both be set","token":"Token","tokenHint":"Auth token","tokenPlaceholder":"Enter token","trafficPattern":"Traffic pattern","userHintMandatory":"At least one user required","wizardBasic":"Basics","wizardConfig":"Protocol","wizardConfigHint":"Fill protocol-specific fields (TLS / Reality / users, etc.)","wizardReview":"Review","wsPath":"WebSocket path","wsPathHint":"e.g. /ws","xhttpHint":"XHTTP transport","xhttpHost":"XHTTP host","xhttpMode":"XHTTP mode","xhttpPath":"XHTTP path","clientExportOnlyHint":"Client subscription only — not written to Mihomo inbound YAML.","transportExclusiveHint":"One transport only. mKCP/Mekya (VMess) require TCP.","mkcpExclusiveHint":"Requires TCP; exclusive with WS/gRPC/Mekya. Other transports cleared on save.","mekyaExclusiveHint":"Requires TCP; exclusive with WS/gRPC/mKCP. Other transports cleared on save.","zeroRtt":"0-RTT","obfsOptsHint":"Mode and host must be set together; leave both empty to disable","obfsOptsHostRequired":"Host is required when obfs is enabled","shadowquicDisableMtuDiscovery":"Disable MTU discovery","shadowquicMaxDatagramFrameSize":"Max datagram frame size","shadowquicRecvWindow":"Connection receive window","shadowquicRecvWindowConn":"Stream receive window","tcpUdp":"TCP / UDP","ipPort":"IP:Port","socketStatus":"Socket status"},"core":{"title":"Core","subtitle":"Mihomo core status and control.","status":"Status","running":"Running","stoppedStatus":"Stopped","version":"Version","pid":"PID","uptime":"Uptime","start":"Start","stop":"Stop","restart":"Restart","started":"Started","stopped":"Stopped","restarted":"Restarted","operationFailed":"Operation failed","unavailable":"Core unavailable"},"logs":{"title":"Logs","subtitle":"Recent panel and core logs.","empty":"No logs","level":"Level","timestamp":"Time","payload":"Payload","autoRefresh":"Auto refresh","clear":"Clear"},"config":{"title":"Config Engine","subtitle":"Visual Mihomo configuration","visual":"Visual Config","yaml":"YAML Config","proxies":"Proxy Configuration","yamlPreview":"YAML Preview","addProxy":"Add Proxy","editProxy":"Edit Proxy","deleteProxy":"Delete Proxy","deleteConfirm":"Delete this proxy?","generateSuccess":"Generated (not applied yet)","validateSuccess":"Configuration is valid","validateFailed":"Configuration validation failed","proxyName":"Proxy Name","proxyType":"Proxy Type","proxyServer":"Proxy Server","proxyPort":"Proxy Port","proxyPassword":"Proxy Password","proxyUUID":"Proxy UUID","generate":"Generate config","validate":"Validate config","validateOk":"Configuration is valid","validateFail":"Configuration validation failed","apply":"Apply","applySuccess":"Configuration applied","rollback":"Rollback","rollbackSuccess":"Rolled back to previous config"},"users":{"title":"User Management","subtitle":"Manage accounts, traffic limits, node bindings and subscriptions.","batch":"Batch","create":"Create User","edit":"Edit User","username":"Username","password":"Password","enabled":"Enabled","deleteConfirm":"Delete this user?","created":"Created","updated":"Updated","deleted":"Deleted","bind":"Bind","bindNodes":"Bind Nodes","bindHint":"Select inbound nodes for this user. Credentials are injected into listeners when generating Mihomo config.","selectNodes":"Select nodes","bindSuccess":"Node bindings updated","traffic":"Traffic","trafficLimitGB":"Traffic limit (GB)","trafficLimitHint":"0 or empty = unlimited","expire":"Expire at","expireHint":"Leave empty for no expiry","neverExpire":"Never","online":"Online","offline":"Offline","blocked":"Blocked","unlimited":"Unlimited","resetTraffic":"Reset traffic","resetTrafficConfirm":"Reset traffic counters for this user?","trafficReset":"Traffic counters reset","passwordKeep":"Leave blank to keep current password","hwidLimit":"HWID device limit","hwidLimitHint":"0 = unlimited. Happ clients send x-hwid when importing the subscription.","ipLimit":"IP limit","ipLimitHint":"0 = unlimited concurrent client IPs","remark":"Remark","shareTitle":"Subscription","rotateSub":"Rotate subscription token","subRotated":"Subscription rotated","deleteDepleted":"Delete depleted","batchEnable":"Enable","batchDisable":"Disable","batchResetTraffic":"Reset traffic","batchDelete":"Delete selected","batchDeleteConfirm":"Delete selected users?","batchNeedSelect":"Select users first","batchDone":"Batch done","deleteDepletedConfirm":"Delete all expired / over-quota users?","depletedDeleted":"Deleted depleted users","subMihomo":"Mihomo / Clash YAML","subV2ray":"V2Ray / Base64 (v2rayNG / Hiddify)","subSingbox":"Sing-box subscription","qrHint":"QR = default (Mihomo). Paste the V2Ray URL into classic clients.","bindRemoteHint":"Remote cluster nodes (sync mirrors on the Cluster page first)","selectRemoteNodes":"Select remote nodes","disabled":"Disabled","batchExtend":"Extend days","batchAddTraffic":"Add traffic","batchExtendPrompt":"Extend by how many days?","batchAddTrafficPrompt":"Add how many GiB to the limit?"},"traffic":{"title":"Traffic","subtitle":"Live connections and traffic usage.","upload":"Upload","download":"Download","uploadRate":"Upload rate","downloadRate":"Download rate","connections":"Connections","onlineUsers":"Online users","byUser":"Per-user traffic","listener":"Listener","network":"Network","rule":"Rule"},"cluster":{"title":"Cluster nodes","subtitle":"Register remote 3m-ui panels and health-check them (full remote control comes later)","create":"Add node","edit":"Edit node","name":"Name","baseUrl":"Panel URL","apiToken":"API Token","apiTokenHint":"Optional; used to authenticate against the remote panel","remark":"Remark","health":"Health check","healthDone":"Checked","healthAll":"Check all","healthAllDone":"Health check finished","dashboard":"Remote dashboard","users":"Remote users","restartCore":"Restart core","restarted":"Remote core restarted","startCore":"Start core","stopCore":"Stop core","created":"Created","updated":"Updated","deleted":"Deleted","nodesLoaded":"Nodes loaded","remoteCreate":"Remote create","remoteCreated":"Remote listener created","remoteDeleted":"Remote listener deleted","remoteNodes":"Remote nodes","syncNodes":"Sync nodes","syncDone":"Synced","pushNode":"Push node","pushNodePrompt":"Local node ID to push","pushNodeConfirm":"Push this node to remote? (created disabled)","pushNodeDone":"Pushed (disabled on remote)","colId":"ID","colName":"Name","colProtocol":"Protocol","colPort":"Port","operationOk":"OK"},"routing":{"title":"Routing & groups","subtitle":"Manage Mihomo proxy-groups and rules","groups":"Proxy groups","rules":"Rules","addGroup":"Add group","proxies":"Proxies","proxiesHint":"Comma-separated proxy / group names","rulesHint":"One Mihomo rule per line","rulesSaved":"Rules saved","groupSaved":"Group saved","groupDeleted":"Group deleted"},"settings":{"navPanel":"Panel / UI","navAccess":"Access profile","navTelegram":"Telegram","navSecurity":"Security & backup","navSubscription":"Subscription page","navSSL":"Certificates / SSL","navNetwork":"Proxy & Geo","navTraffic":"Traffic reset","navAbout":"About","panelServer":"Panel / NAT","panelPort":"Panel port","panelPortHint":"Map this port on your NAT gateway. Restart 3m-ui after changing.","panelListen":"Listen address","panelListenHint":"Empty = all interfaces (IPv4/IPv6). Use 127.0.0.1 behind a reverse proxy.","panelPublicURL":"Public panel URL","panelPublicURLHint":"Used in subscription links behind NAT, e.g. https://panel.example.com:8443","title":"Settings","subtitle":"Preferences & Account","language":"Language","theme":"Theme","light":"Light","dark":"Dark","system":"System","passwordTitle":"Account Security","passwordDescription":"Change your password regularly","changePassword":"Change Password","about":"About 3M-UI","version":"Version","backup":"Backup & restore","backupHint":"Download a zip with SQLite and Mihomo config. Restart the panel after restoring the database.","downloadBackup":"Download backup","restoreDb":"Restore database","backupDone":"Backup downloaded","restoreDone":"Database restored — restart the panel","apiDocs":"API documentation","apiDocsHint":"OpenAPI 3 specification for automation.","openOpenAPI":"Open openapi.yaml","alpnHint":"Comma-separated, e.g. h2,http/1.1","accessProfile":"Access Profile","accessProfileHint":"Public host / SNI / fingerprint for share links and client YAML","publicHost":"Public Host","publicPort":"Public Port","clientFingerprint":"Client Fingerprint","telegram":"Telegram alerts","botToken":"Bot Token","chatIds":"Chat IDs","telegramSaved":"Telegram settings saved","telegramTest":"Send test message","telegramTestOk":"Test message sent","chatIdsHint":"Comma-separated chat IDs","notifyLogin":"Notify on panel login","notifyBlock":"Notify on user block","notifyUnblock":"Notify on user unblock","notifyExpiry":"Notify on user expiry","notifyDailyDigest":"Send a daily traffic and user summary","templates":"TLS & reverse-proxy templates","proxyKind":"Proxy type","domain":"Domain","upstream":"Upstream","generateTemplate":"Generate template","templateGenerated":"Template generated","trafficReset":"Traffic reset","trafficResetDay":"Monthly reset day","certWizard":"Certificate wizard","email":"Email","webroot":"Webroot path","generateAcme":"Generate certbot command","acmeGenerated":"Command generated","acmeHint":"Run the command on the server, then put cert paths into Listener TLS settings","notifyTraffic":"Traffic threshold warning","trafficWarnPct":"Traffic warn %","expiryWarnHours":"Expiry warn (hours)","panelSSL":"Panel SSL (ACME)","panelSSLHint":"Enable HTTPS for the panel via Let's Encrypt (autocert) or a manual certificate. Restart the panel after saving.","sslSaved":"SSL settings saved — restart panel to apply","acmeCacheDir":"ACME cache dir","manualCert":"Manual cert file (optional)","manualKey":"Manual key file (optional)","subPage":"Subscription page template","subPageHint":"Custom HTML template directory (index.html or sub.html). Leave empty for the built-in page. Browsers hitting the subscription URL with Accept: text/html will see this page.","subscriptionFormatHint":"Subscription auto-format","subscriptionFormatHintDesc":"Format follows User-Agent (Clash/Mihomo YAML, v2ray Base64, sing-box JSON). Override with ?target=clash|v2ray|singbox|html. Traffic/expiry via Subscription-Userinfo headers.","subThemeDir":"Theme directory","subTitle":"Page title","subSupportUrl":"Support URL","downloadDefaultTpl":"Download default template","geofiles":"GeoIP / GeoSite","geofilesHint":"Download latest MetaCubeX GeoIP/GeoSite databases into the Mihomo data directory .","updateGeofiles":"Update geo files","geofilesDone":"Geo files updated","subAnnounce":"Subscription announce","subWebPage":"Profile web page URL","subUpdates":"Update interval (hours)","subEncrypt":"Base64-encode URI list","notifyCPU":"Notify on high CPU","cpuWarnPct":"CPU warn threshold (%)","tgSchedule":"Report schedule","tgScheduleHint":"Cron like \"0 9 * * *\" or @daily / @every 6h","tgLanguage":"Bot language","tgEvents":"Enabled events","tgExpiryDays":"Expiry warn (days)","tgTrafficGB":"Traffic warn (GB left)","tgAttachBackup":"Attach DB backup in report","tgProxy":"Proxy URL","tgApiServer":"Telegram API server","tgSetCommands":"Set bot commands","tgCommandsSet":"Bot commands registered","restoreBackup":"Restore backup","restoreRestart":"Restart to apply","restoreRestartHint":"Restart the 3m-ui service after restoring the database","security":"Security"},"share":{"title":"Share / Subscription","subtitle":"Unified view of subscription URLs and per-node share URIs for a user.","selectUser":"User","tabSub":"Subscription links","tabUri":"Node URIs","noSub":"No subscription URL","noNodes":"No nodes bound. Bind listeners under Users first.","uri":"URI","loadUri":"Load URI","subHint":"Paste into the client. Default (no target) is Mihomo YAML; target=v2ray is classic Base64 URI list.","subHtml":"Info page (HTML)"}} as const; +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 suggested port is available in 10000-60000. Enter a port manually.","sectionProtocol":"Protocol options","transportLayer":"Transport","securityLayer":"Security","sectionSecurity":"Security","sectionTransport":"Transport","sectionTLS":"TLS certificate","sectionReality":"REALITY","sectionSSOption":"Trojan SS option","cipher":"Cipher","password":"Password","passwordOptional":"Password (optional)","uuid":"UUID","privateKey":"Private key","certificate":"Certificate","alpn":"ALPN","sni":"SNI","clientFingerprint":"Client fingerprint","allowInsecure":"Allow insecure","muxOption":"Mux","realityPrivateKey":"Reality private key","realityShortID":"Short ID","realityServerNames":"Server names","realityDest":"Dest","transport":"Transport","path":"Path","host":"Host","serviceName":"Service name","up":"Up","down":"Down","ignoreClientBandwidth":"Ignore client bandwidth","masquerade":"Masquerade","congestionControl":"Congestion control","clone":"Clone","saveTemplate":"Save as template","versions":"Versions","cloned":"Cloned","templateCreated":"Template created","instantiated":"Listener created from template","batchDone":"Batch operation completed","templates":"Listener Templates","templateName":"Template name","createdAt":"Created at","instantiate":"Instantiate","deleteTemplateConfirm":"Delete this template?","templateDeleted":"Template deleted","enableSelected":"Enable selected","disableSelected":"Disable selected","newPort":"New port","version":"Version","reason":"Reason","diff":"Diff","rollback":"Rollback","rollbackConfirm":"Rollback to this version?","rollbackDone":"Rollback completed","addUser":"Add user","aeadMethod":"AEAD method","allowInsecureHint":"Allow insecure (debug only)","alterId":"Alter ID","alterIdHint":"VMess alterId, prefer 0","authenticationTimeout":"Auth timeout","bandwidthHint":"Bandwidth limit, e.g. 100 mbps","bbrProfile":"BBR profile","clientAuthCert":"Client cert","clientAuthType":"Client auth type","congestionController":"Congestion controller","createWizard":"Create listener wizard","cwnd":"CWND","decryption":"Decryption","encryption":"Encryption","encryptionHint":"Client-only field for subscription export; not written to inbound YAML.","decryptionHint":"Server-side field written to listener decryption.","echKey":"ECH Key","enablePureDownlink":"Pure downlink","flow":"Flow","flowHint":"e.g. xtls-rprx-vision","grpcHint":"gRPC transport","grpcServiceName":"gRPC service name","grpcUserAgent":"gRPC User-Agent","grpcUserAgentHint":"gRPC client User-Agent string (panel-side metadata; emitted into client-side grpc-opts).","pingInterval":"Ping interval (ms)","pingIntervalHint":"gRPC keepalive ping interval (panel-side metadata; emitted into client-side grpc-opts).","maxConnections":"Max connections","maxConnectionsHint":"gRPC max concurrent connections (panel-side metadata).","minStreams":"Min streams","minStreamsHint":"gRPC min concurrent streams (panel-side metadata).","maxStreams":"Max streams","maxStreamsHint":"gRPC max concurrent streams (panel-side metadata).","handshakeTimeout":"Handshake timeout","jlsDest":"JLS dest","jlsHint":"JLS settings","jlsProxy":"JLS proxy","jlsSni":"JLS SNI","jlsSniHint":"JLS SNI hostname","jlsUpstreamAddr":"JLS upstream","jlsUsers":"JLS users","kcpTunConn":"KCP connections","kcpTunCrypt":"KCP crypt","kcpTunKey":"KCP key","kcpTunMode":"KCP mode","kcpTunNocomp":"KCP no compress","kcpTunRcvwnd":"KCP recv window","kcpTunSndwnd":"KCP send window","maxIdleTime":"Max idle time","maxUdpRelayPacketSize":"Max UDP relay packet","mekyaHint":"Mekya settings","mekyaKcpSection":"Mekya KCP","mekyaMaxSimultaneous":"Max simultaneous","mekyaMaxSimultaneousHint":"Max concurrent connections","mekyaMaxWriteDuration":"Max write duration","mekyaMaxWriteDurationHint":"Write timeout","mekyaMaxWriteSize":"Max write size","mekyaMaxWriteSizeHint":"Single write limit","mekyaPacketBuffer":"Packet buffer","mekyaPacketBufferHint":"Packet buffer size","mekyaPollingInterval":"Polling interval (ms)","mekyaH2PoolSize":"H2 pool size","mkcpCongestion":"mKCP congestion","mkcpDownlink":"mKCP downlink","mkcpHeader":"mKCP header","mkcpHint":"mKCP transport","mkcpReadBuffer":"mKCP read buffer","mkcpSeed":"mKCP seed","mkcpUplink":"mKCP uplink","mkcpWriteBuffer":"mKCP write buffer","muxBrutal":"Mux brutal","muxBrutalDown":"Brutal down","muxBrutalUp":"Brutal up","muxMaxConnections":"Mux max connections","muxMaxStreams":"Mux max streams","muxMinStreams":"Mux min streams","muxOnlyTcp":"Mux TCP only","muxPadding":"Mux padding","muxProtocol":"Mux protocol","muxStatistic":"Mux statistics","network":"Network","obfs":"Obfuscation","obfsMaxPacketSize":"Obfs max packet","obfsMinPacketSize":"Obfs min packet","obfsOptsHost":"Obfs host","obfsOptsMode":"Obfs mode","obfsPassword":"Obfs password","paddingMax":"Padding max","paddingMin":"Padding min","paddingScheme":"Padding scheme","psk":"PSK","quicVersions":"QUIC versions","rateLimit":"Rate limit","rateLimitHint":"e.g. 10m","realityExclusiveHint":"Reality and TLS cert are mutually exclusive","realityShortId":"Reality short ID","realmId":"Realm ID","realmProxy":"Realm proxy","realmServerUrl":"Realm server URL","realmSkipCert":"Skip cert verify","realmStun":"STUN","realmToken":"Realm token","resTlsDest":"ResTLS dest","resTlsHint":"ResTLS settings","resTlsMinRecordLen":"Min record length","resTlsPassword":"ResTLS password","resTlsProxy":"ResTLS proxy","resTlsScript":"ResTLS script","sectionJLS":"JLS","sectionJlsUpstream":"JLS upstream","sectionKcpTun":"KCP Tun","sectionMekya":"Mekya","sectionMkcp":"mKCP","sectionMux":"Mux","sectionRealm":"Realm","sectionResTLS":"Reality TLS","sectionShadowTLS":"ShadowTLS","sectionSimpleObfs":"simple-obfs","sectionXHTTP":"XHTTP","shadowTlsHandshakeDest":"Handshake dest","shadowTlsHandshakeProxy":"Handshake proxy","shadowTlsHint":"ShadowTLS settings","shadowTlsPassword":"ShadowTLS password","shadowTlsPasswordHint":"v2/v3 password","shadowTlsUsers":"ShadowTLS users","shadowTlsVersion":"ShadowTLS version","simpleObfsMode":"Simple Obfs mode","snellVersion":"Snell version","ssOptionMethod":"SS method","ssOptionPassword":"SS password","sudokuKey":"Sudoku key","customTable":"Custom table","customTables":"Custom tables","sudokuFallback":"Fallback","sectionHttpmask":"HTTP mask","httpmaskDisable":"Disable HTTP mask","httpmaskMode":"HTTP mask mode","httpmaskPathRoot":"HTTP path root","tableType":"Table type","tlsPairHint":"Certificate and private key must both be set","token":"Token","tokenHint":"Auth token","tokenPlaceholder":"Enter token","trafficPattern":"Traffic pattern","userHintMandatory":"At least one user required","wizardBasic":"Basics","wizardConfig":"Protocol","wizardConfigHint":"Fill protocol-specific fields (TLS / Reality / users, etc.)","wizardReview":"Review","wsPath":"WebSocket path","wsPathHint":"e.g. /ws","xhttpHint":"XHTTP transport","xhttpHost":"XHTTP host","xhttpMode":"XHTTP mode","xhttpPath":"XHTTP path","clientExportOnlyHint":"Client subscription only — not written to Mihomo inbound YAML.","transportExclusiveHint":"One transport only. mKCP/Mekya (VMess) require TCP.","mkcpExclusiveHint":"Requires TCP; exclusive with WS/gRPC/Mekya. Other transports cleared on save.","mekyaExclusiveHint":"Requires TCP; exclusive with WS/gRPC/mKCP. Other transports cleared on save.","zeroRtt":"0-RTT","obfsOptsHint":"Mode and host must be set together; leave both empty to disable","obfsOptsHostRequired":"Host is required when obfs is enabled","shadowquicDisableMtuDiscovery":"Disable MTU discovery","shadowquicMaxDatagramFrameSize":"Max datagram frame size","shadowquicRecvWindow":"Connection receive window","shadowquicRecvWindowConn":"Stream receive window","tcpUdp":"TCP / UDP","ipPort":"IP:Port","socketStatus":"Socket status"},"core":{"title":"Core","subtitle":"Mihomo core status and control.","status":"Status","running":"Running","stoppedStatus":"Stopped","version":"Version","pid":"PID","uptime":"Uptime","start":"Start","stop":"Stop","restart":"Restart","started":"Started","stopped":"Stopped","restarted":"Restarted","operationFailed":"Operation failed","unavailable":"Core unavailable","updates":{"title":"Mihomo updates","description":"Select an official stable or Pre release. Downloads and configuration checks finish before switching the running core; a failed switch restores the previous core. Updates are manual only.","source":"Core source","managed":"Selected version (persistent)","bundled":"Bundled with the panel","previous":"Previous version","noPrevious":"No previous version","unavailable":"Core updates unavailable","check":"Check for updates","select":"Select version","latest":"Latest","current":"Current","install":"Install version","rollback":"Restore previous version","releaseNotes":"Release notes","noVersions":"No verifiable stable releases are available for this platform.","running":"Update in progress","failed":"Update failed","succeeded":"Update complete","restored":"The previous core and its running state have been restored.","confirm":"Switch Mihomo to","interruption":"Active proxy connections will briefly disconnect during the switch. Configuration and accounts are preserved.","staysStopped":"The core will remain stopped after installation. Configuration and accounts are preserved.","steps":{"prepare":"Prepare","download":"Download and verify","validate":"Check configuration","activate":"Switch and check"},"stage":{"interrupted":"Interrupted by a panel restart","preparing":"Preparing update","resolving":"Resolving official release","downloading":"Downloading core; the current core keeps running","verifying":"Verifying SHA-256 checksum","validating":"Checking binary and existing configuration","switching":"Switching core","starting":"Starting selected core","checking":"Checking process and listening sockets","recovering":"Restoring previous core","complete":"Completed"},"stable":"Stable","pre":"Pre release","preNotice":"Pre builds are rolling test releases and may have compatibility issues. Installation verifies the selected build; if the official build has changed, check for updates again."}},"logs":{"title":"Logs","subtitle":"Recent panel and core logs.","empty":"No logs","level":"Level","timestamp":"Time","payload":"Payload","autoRefresh":"Auto refresh","clear":"Clear"},"config":{"title":"Config Engine","subtitle":"Visual Mihomo configuration","visual":"Visual Config","yaml":"YAML Config","proxies":"Proxy Configuration","yamlPreview":"YAML Preview","addProxy":"Add Proxy","editProxy":"Edit Proxy","deleteProxy":"Delete Proxy","deleteConfirm":"Delete this proxy?","generateSuccess":"Generated (not applied yet)","validateSuccess":"Configuration is valid","validateFailed":"Configuration validation failed","proxyName":"Proxy Name","proxyType":"Proxy Type","proxyServer":"Proxy Server","proxyPort":"Proxy Port","proxyPassword":"Proxy Password","proxyUUID":"Proxy UUID","generate":"Generate config","validate":"Validate config","validateOk":"Configuration is valid","validateFail":"Configuration validation failed","apply":"Apply","applySuccess":"Configuration applied","rollback":"Rollback","rollbackSuccess":"Rolled back to previous config"},"users":{"title":"User Management","subtitle":"Manage accounts, traffic limits, node bindings and subscriptions.","batch":"Batch","create":"Create User","edit":"Edit User","username":"Username","password":"Password","enabled":"Enabled","deleteConfirm":"Delete this user?","created":"Created","updated":"Updated","deleted":"Deleted","bind":"Bind","bindNodes":"Bind Nodes","bindHint":"Select inbound nodes for this user. Credentials are injected into listeners when generating Mihomo config.","selectNodes":"Select nodes","bindSuccess":"Node bindings updated","traffic":"Traffic","trafficLimitGB":"Traffic limit (GB)","trafficLimitHint":"0 or empty = unlimited","expire":"Expire at","expireHint":"Leave empty for no expiry","neverExpire":"Never","online":"Online","offline":"Offline","blocked":"Blocked","unlimited":"Unlimited","resetTraffic":"Reset traffic","resetTrafficConfirm":"Reset traffic counters for this user?","trafficReset":"Traffic counters reset","passwordKeep":"Leave blank to keep current password","hwidLimit":"HWID device limit","hwidLimitHint":"0 = unlimited. Happ clients send x-hwid when importing the subscription.","ipLimit":"IP limit","ipLimitHint":"0 = unlimited concurrent client IPs","remark":"Remark","shareTitle":"Subscription","rotateSub":"Rotate subscription token","subRotated":"Subscription rotated","deleteDepleted":"Delete depleted","batchEnable":"Enable","batchDisable":"Disable","batchResetTraffic":"Reset traffic","batchDelete":"Delete selected","batchDeleteConfirm":"Delete selected users?","batchNeedSelect":"Select users first","batchDone":"Batch done","deleteDepletedConfirm":"Delete all expired / over-quota users?","depletedDeleted":"Deleted depleted users","subMihomo":"Mihomo / Clash YAML","subV2ray":"V2Ray / Base64 (v2rayNG / Hiddify)","subSingbox":"Sing-box subscription","qrHint":"QR = default (Mihomo). Paste the V2Ray URL into classic clients.","bindRemoteHint":"Remote cluster nodes (sync mirrors on the Cluster page first)","selectRemoteNodes":"Select remote nodes","disabled":"Disabled","batchExtend":"Extend days","batchAddTraffic":"Add traffic","batchExtendPrompt":"Extend by how many days?","batchAddTrafficPrompt":"Add how many GiB to the limit?"},"traffic":{"title":"Traffic","subtitle":"Live connections and traffic usage.","upload":"Upload","download":"Download","uploadRate":"Upload rate","downloadRate":"Download rate","connections":"Connections","onlineUsers":"Online users","byUser":"Per-user traffic","listener":"Listener","network":"Network","rule":"Rule"},"cluster":{"title":"Cluster nodes","subtitle":"Register remote 3m-ui panels and health-check them (full remote control comes later)","create":"Add node","edit":"Edit node","name":"Name","baseUrl":"Panel URL","apiToken":"API Token","apiTokenHint":"Optional; used to authenticate against the remote panel","remark":"Remark","health":"Health check","healthDone":"Checked","healthAll":"Check all","healthAllDone":"Health check finished","dashboard":"Remote dashboard","users":"Remote users","restartCore":"Restart core","restarted":"Remote core restarted","startCore":"Start core","stopCore":"Stop core","created":"Created","updated":"Updated","deleted":"Deleted","nodesLoaded":"Nodes loaded","remoteCreate":"Remote create","remoteCreated":"Remote listener created","remoteDeleted":"Remote listener deleted","remoteNodes":"Remote nodes","syncNodes":"Sync nodes","syncDone":"Synced","pushNode":"Push node","pushNodePrompt":"Local node ID to push","pushNodeConfirm":"Push this node to remote? (created disabled)","pushNodeDone":"Pushed (disabled on remote)","colId":"ID","colName":"Name","colProtocol":"Protocol","colPort":"Port","operationOk":"OK"},"routing":{"title":"Routing & groups","subtitle":"Manage Mihomo proxy-groups and rules","groups":"Proxy groups","rules":"Rules","addGroup":"Add group","proxies":"Proxies","proxiesHint":"Comma-separated proxy / group names","rulesHint":"One Mihomo rule per line","rulesSaved":"Rules saved","groupSaved":"Group saved","groupDeleted":"Group deleted"},"settings":{"navPanel":"Panel / UI","navAccess":"Access profile","navTelegram":"Telegram","navSecurity":"Security & backup","navSubscription":"Subscription page","navSSL":"Certificates / SSL","navNetwork":"Proxy & Geo","navTraffic":"Traffic reset","navAbout":"About","panelServer":"Panel / NAT","panelPort":"Panel port","panelPortHint":"Map this port on your NAT gateway. Restart 3m-ui after changing.","panelListen":"Listen address","panelListenHint":"Empty = all interfaces (IPv4/IPv6). Use 127.0.0.1 behind a reverse proxy.","panelPublicURL":"Public panel URL","panelPublicURLHint":"Used in subscription links behind NAT, e.g. https://panel.example.com:8443","title":"Settings","subtitle":"Preferences & Account","language":"Language","theme":"Theme","light":"Light","dark":"Dark","system":"System","passwordTitle":"Account Security","passwordDescription":"Change your password regularly","changePassword":"Change Password","about":"About 3M-UI","version":"Version","backup":"Backup & restore","backupHint":"Download a zip with SQLite and Mihomo config. Restart the panel after restoring the database.","downloadBackup":"Download backup","restoreDb":"Restore database","backupDone":"Backup downloaded","restoreDone":"Database restored — restart the panel","apiDocs":"API documentation","apiDocsHint":"OpenAPI 3 specification for automation.","openOpenAPI":"Open openapi.yaml","alpnHint":"Comma-separated, e.g. h2,http/1.1","accessProfile":"Access Profile","accessProfileHint":"Public host / SNI / fingerprint for share links and client YAML","publicHost":"Public Host","publicPort":"Public Port","clientFingerprint":"Client Fingerprint","telegram":"Telegram alerts","botToken":"Bot Token","chatIds":"Chat IDs","telegramSaved":"Telegram settings saved","telegramTest":"Send test message","telegramTestOk":"Test message sent","chatIdsHint":"Comma-separated chat IDs","notifyLogin":"Notify on panel login","notifyBlock":"Notify on user block","notifyUnblock":"Notify on user unblock","notifyExpiry":"Notify on user expiry","notifyDailyDigest":"Send a daily traffic and user summary","templates":"TLS & reverse-proxy templates","proxyKind":"Proxy type","domain":"Domain","upstream":"Upstream","generateTemplate":"Generate template","templateGenerated":"Template generated","trafficReset":"Traffic reset","trafficResetDay":"Monthly reset day","certWizard":"Certificate wizard","email":"Email","webroot":"Webroot path","generateAcme":"Generate certbot command","acmeGenerated":"Command generated","acmeHint":"Run the command on the server, then put cert paths into Listener TLS settings","notifyTraffic":"Traffic threshold warning","trafficWarnPct":"Traffic warn %","expiryWarnHours":"Expiry warn (hours)","panelSSL":"Panel SSL (ACME)","panelSSLHint":"Enable HTTPS for the panel via Let's Encrypt (autocert) or a manual certificate. Restart the panel after saving.","sslSaved":"SSL settings saved — restart panel to apply","acmeCacheDir":"ACME cache dir","manualCert":"Manual cert file (optional)","manualKey":"Manual key file (optional)","subPage":"Subscription page template","subPageHint":"Custom HTML template directory (index.html or sub.html). Leave empty for the built-in page. Browsers hitting the subscription URL with Accept: text/html will see this page.","subscriptionFormatHint":"Subscription auto-format","subscriptionFormatHintDesc":"Format follows User-Agent (Clash/Mihomo YAML, v2ray Base64, sing-box JSON). Override with ?target=clash|v2ray|singbox|html. Traffic/expiry via Subscription-Userinfo headers.","subThemeDir":"Theme directory","subTitle":"Page title","subSupportUrl":"Support URL","downloadDefaultTpl":"Download default template","geofiles":"GeoIP / GeoSite","geofilesHint":"Download latest MetaCubeX GeoIP/GeoSite databases into the Mihomo data directory .","updateGeofiles":"Update geo files","geofilesDone":"Geo files updated","subAnnounce":"Subscription announce","subWebPage":"Profile web page URL","subUpdates":"Update interval (hours)","subEncrypt":"Base64-encode URI list","notifyCPU":"Notify on high CPU","cpuWarnPct":"CPU warn threshold (%)","tgSchedule":"Report schedule","tgScheduleHint":"Cron like \"0 9 * * *\" or @daily / @every 6h","tgLanguage":"Bot language","tgEvents":"Enabled events","tgExpiryDays":"Expiry warn (days)","tgTrafficGB":"Traffic warn (GB left)","tgAttachBackup":"Attach DB backup in report","tgProxy":"Proxy URL","tgApiServer":"Telegram API server","tgSetCommands":"Set bot commands","tgCommandsSet":"Bot commands registered","restoreBackup":"Restore backup","restoreRestart":"Restart to apply","restoreRestartHint":"Restart the 3m-ui service after restoring the database","security":"Security"},"share":{"title":"Share / Subscription","subtitle":"Unified view of subscription URLs and per-node share URIs for a user.","selectUser":"User","tabSub":"Subscription links","tabUri":"Node URIs","noSub":"No subscription URL","noNodes":"No nodes bound. Bind listeners under Users first.","uri":"URI","loadUri":"Load URI","subHint":"Paste into the client. Default (no target) is Mihomo YAML; target=v2ray is classic Base64 URI list.","subHtml":"Info page (HTML)"}} as const; diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index e48f8b0..ed89058 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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 语法(如 200,204,401-429)","randomPort":"随机端口","noAvailablePort":"10000-60000 范围内没有可推荐的端口,请手动填写。","sectionProtocol":"协议选项","transportLayer":"传输层","securityLayer":"安全层","sectionSecurity":"安全层","sectionTransport":"传输层","sectionTLS":"TLS 证书","sectionReality":"REALITY","sectionSSOption":"Trojan SS 混淆","cipher":"加密方法","password":"密码","passwordOptional":"密码(可选)","uuid":"UUID","privateKey":"私钥","certificate":"证书","alpn":"ALPN","sni":"SNI","clientFingerprint":"客户端指纹","allowInsecure":"允许不安全","muxOption":"多路复用","realityPrivateKey":"Reality 私钥","realityShortID":"Short ID","realityServerNames":"Server Names","realityDest":"目标 (dest)","transport":"传输","path":"路径","host":"Host","serviceName":"Service Name","up":"上行","down":"下行","ignoreClientBandwidth":"忽略客户端带宽","masquerade":"伪装","congestionControl":"拥塞控制","clone":"克隆","saveTemplate":"保存为模板","versions":"配置版本","cloned":"克隆成功","templateCreated":"模板创建成功","instantiated":"已从模板创建节点","batchDone":"批量操作完成","templates":"节点模板","templateName":"模板名称","createdAt":"创建时间","instantiate":"实例化","deleteTemplateConfirm":"确定删除此模板?","templateDeleted":"模板已删除","enableSelected":"启用选中","disableSelected":"禁用选中","newPort":"新端口","version":"版本","reason":"原因","diff":"配置差异","rollback":"回滚","rollbackConfirm":"确定回滚到此版本?","rollbackDone":"回滚完成","addUser":"添加用户","aeadMethod":"AEAD 加密方式","allowInsecureHint":"允许不安全连接(仅调试)","alterId":"alterId","alterIdHint":"VMess 额外 ID,建议 0","authenticationTimeout":"认证超时","bandwidthHint":"带宽限制,如 100 mbps","bbrProfile":"BBR 配置","clientAuthCert":"客户端证书","clientAuthType":"客户端认证类型","congestionController":"拥塞控制","createWizard":"向导创建节点","cwnd":"拥塞窗口 (cwnd)","decryption":"解密","encryption":"加密","encryptionHint":"客户端字段,仅用于订阅导出,不写入入站 YAML。","decryptionHint":"服务端字段,写入入站 decryption。","echKey":"ECH Key","enablePureDownlink":"纯下行","flow":"流控","flowHint":"如 xtls-rprx-vision","grpcHint":"gRPC 传输","grpcServiceName":"gRPC Service Name","grpcUserAgent":"gRPC User-Agent","grpcUserAgentHint":"gRPC 客户端 User-Agent 字符串(面板侧元数据;写入客户端 grpc-opts)。","pingInterval":"Ping 间隔 (ms)","pingIntervalHint":"gRPC keepalive Ping 间隔(面板侧元数据;写入客户端 grpc-opts)。","maxConnections":"最大连接数","maxConnectionsHint":"gRPC 最大并发连接数(面板侧元数据)。","minStreams":"最小流数","minStreamsHint":"gRPC 最小并发流数(面板侧元数据)。","maxStreams":"最大流数","maxStreamsHint":"gRPC 最大并发流数(面板侧元数据)。","handshakeTimeout":"握手超时","jlsDest":"JLS 目标","jlsHint":"Joliz 相关配置","jlsProxy":"JLS 代理","jlsSni":"JLS SNI","jlsSniHint":"JLS SNI 主机名","jlsUpstreamAddr":"JLS 上游地址","jlsUsers":"JLS 用户","kcpTunConn":"KCP 连接数","kcpTunCrypt":"KCP 加密","kcpTunKey":"KCP 密钥","kcpTunMode":"KCP 模式","kcpTunNocomp":"KCP 禁用压缩","kcpTunRcvwnd":"KCP 接收窗口","kcpTunSndwnd":"KCP 发送窗口","maxIdleTime":"最大空闲时间","maxUdpRelayPacketSize":"最大 UDP 中继包","mekyaHint":"Mekya 相关配置","mekyaKcpSection":"Mekya KCP","mekyaMaxSimultaneous":"最大并发","mekyaMaxSimultaneousHint":"最大同时连接数","mekyaMaxWriteDuration":"最大写入时长","mekyaMaxWriteDurationHint":"写入超时","mekyaMaxWriteSize":"最大写入大小","mekyaMaxWriteSizeHint":"单次写入上限","mekyaPacketBuffer":"包缓冲","mekyaPacketBufferHint":"数据包缓冲大小","mekyaPollingInterval":"轮询间隔 (ms)","mekyaH2PoolSize":"H2 连接池大小","mkcpCongestion":"mKCP 拥塞控制","mkcpDownlink":"mKCP 下行","mkcpHeader":"mKCP 头类型","mkcpHint":"mKCP 传输","mkcpReadBuffer":"mKCP 读缓冲","mkcpSeed":"mKCP Seed","mkcpUplink":"mKCP 上行","mkcpWriteBuffer":"mKCP 写缓冲","muxBrutal":"Mux Brutal","muxBrutalDown":"Brutal 下行","muxBrutalUp":"Brutal 上行","muxMaxConnections":"Mux 最大连接","muxMaxStreams":"Mux 最大流","muxMinStreams":"Mux 最小流","muxOnlyTcp":"仅 TCP Mux","muxPadding":"Mux 填充","muxProtocol":"Mux 协议","muxStatistic":"Mux 统计","network":"传输网络","obfs":"混淆","obfsMaxPacketSize":"混淆最大包","obfsMinPacketSize":"混淆最小包","obfsOptsHost":"混淆 Host","obfsOptsMode":"混淆模式","obfsPassword":"混淆密码","paddingMax":"填充最大","paddingMin":"填充最小","paddingScheme":"填充方案","psk":"PSK","quicVersions":"QUIC 版本","rateLimit":"速率限制","rateLimitHint":"如 10m","realityExclusiveHint":"Reality 与 TLS 证书互斥","realityShortId":"Reality Short ID","realmId":"Realm ID","realmProxy":"Realm 代理","realmServerUrl":"Realm 服务地址","realmSkipCert":"跳过证书校验","realmStun":"STUN","realmToken":"Realm Token","resTlsDest":"ResTLS 目标","resTlsHint":"ResTLS 配置","resTlsMinRecordLen":"最小记录长度","resTlsPassword":"ResTLS 密码","resTlsProxy":"ResTLS 代理","resTlsScript":"ResTLS 脚本","sectionJLS":"JLS","sectionJlsUpstream":"JLS 上游","sectionKcpTun":"KCP Tun","sectionMekya":"Mekya","sectionMkcp":"mKCP","sectionMux":"Mux","sectionRealm":"Realm","sectionResTLS":"Reality TLS","sectionShadowTLS":"ShadowTLS","sectionSimpleObfs":"simple-obfs","sectionXHTTP":"XHTTP","shadowTlsHandshakeDest":"握手目标","shadowTlsHandshakeProxy":"握手代理","shadowTlsHint":"ShadowTLS 配置","shadowTlsPassword":"ShadowTLS 密码","shadowTlsPasswordHint":"版本 2/3 密码","shadowTlsUsers":"ShadowTLS 用户","shadowTlsVersion":"ShadowTLS 版本","simpleObfsMode":"Simple Obfs 模式","snellVersion":"Snell 版本","ssOptionMethod":"SS 加密方式","ssOptionPassword":"SS 密码","sudokuKey":"Sudoku Key","customTable":"自定义表","customTables":"自定义表列表","sudokuFallback":"回落","sectionHttpmask":"HTTP 伪装","httpmaskDisable":"禁用 HTTP 伪装","httpmaskMode":"HTTP 伪装模式","httpmaskPathRoot":"HTTP 路径前缀","tableType":"表类型","tlsPairHint":"证书与私钥需成对填写","token":"Token","tokenHint":"认证 Token","tokenPlaceholder":"输入 Token","trafficPattern":"流量模式","userHintMandatory":"至少一个用户","wizardBasic":"基础信息","wizardConfig":"协议配置","wizardConfigHint":"填写协议相关字段(TLS / Reality / 用户等)","wizardReview":"确认创建","wsPath":"WebSocket 路径","wsPathHint":"如 /ws","xhttpHint":"XHTTP 传输","xhttpHost":"XHTTP Host","xhttpMode":"XHTTP 模式","xhttpPath":"XHTTP 路径","clientExportOnlyHint":"仅用于客户端订阅导出,不会写入 Mihomo 入站配置。","transportExclusiveHint":"同时只能选一种传输。VMess 的 mKCP/Mekya 需配合 TCP。","mkcpExclusiveHint":"需使用 TCP;与 WS/gRPC/Mekya 互斥,保存时清除其它传输。","mekyaExclusiveHint":"需使用 TCP;与 WS/gRPC/mKCP 互斥,保存时清除其它传输。","zeroRtt":"0-RTT","obfsOptsHint":"mode 与 host 需同时填写;都留空则关闭","obfsOptsHostRequired":"启用 obfs 时必须填写 host","shadowquicDisableMtuDiscovery":"禁用 MTU 探测","shadowquicMaxDatagramFrameSize":"最大 datagram frame 大小","shadowquicRecvWindow":"连接接收窗口","shadowquicRecvWindowConn":"流接收窗口","tcpUdp":"TCP / UDP","ipPort":"IP:端口","socketStatus":"监听状态"},"core":{"title":"核心管理","subtitle":"Mihomo 核心状态与控制。","status":"核心状态","running":"运行中","stoppedStatus":"已停止","version":"版本","pid":"进程 ID","uptime":"运行时间","start":"启动","stop":"停止","restart":"重启","started":"已启动","stopped":"已停止","restarted":"已重启","operationFailed":"操作失败","unavailable":"核心不可用"},"logs":{"title":"运行日志","subtitle":"面板与核心最近日志。","empty":"暂无日志","level":"级别","timestamp":"时间","payload":"内容","autoRefresh":"自动刷新","clear":"清空"},"config":{"title":"配置引擎","subtitle":"可视化管理 Mihomo 配置","visual":"可视化配置","yaml":"YAML 配置","proxies":"代理配置","yamlPreview":"YAML 预览","addProxy":"添加代理","editProxy":"编辑代理","deleteProxy":"删除代理","deleteConfirm":"确定要删除此代理吗?","generateSuccess":"已生成(尚未应用)","validateSuccess":"配置验证通过","validateFailed":"配置验证失败","proxyName":"代理名称","proxyType":"代理类型","proxyServer":"代理服务器","proxyPort":"代理端口","proxyPassword":"代理密码","proxyUUID":"代理 UUID","generate":"生成配置","validate":"校验配置","validateOk":"配置校验通过","validateFail":"配置校验失败","apply":"应用","applySuccess":"已应用配置","rollback":"回滚","rollbackSuccess":"已回滚到上一份配置"},"users":{"title":"用户管理","subtitle":"管理账号、流量、节点绑定与订阅。","batch":"批量操作","create":"创建用户","edit":"编辑用户","username":"用户名","password":"密码","enabled":"启用状态","deleteConfirm":"确定要删除此用户吗?","created":"创建成功","updated":"更新成功","deleted":"删除成功","bind":"绑定节点","bindNodes":"绑定节点","bindHint":"选择该用户可使用的入站节点。凭证会在生成 Mihomo 配置时自动注入到对应 Listener。","selectNodes":"选择节点","bindSuccess":"节点绑定已更新","traffic":"流量","trafficLimitGB":"流量上限 (GB)","trafficLimitHint":"0 或留空表示不限","expire":"到期时间","expireHint":"留空表示永不过期","neverExpire":"永不过期","online":"在线","offline":"离线","blocked":"已封禁","unlimited":"不限","resetTraffic":"重置流量","resetTrafficConfirm":"确定重置该用户的流量计数?","trafficReset":"流量已重置","passwordKeep":"留空则不修改密码","hwidLimit":"HWID 设备上限","hwidLimitHint":"0 为不限制。兼容 Happ 等客户端在订阅导入时发送的 x-hwid 头。","ipLimit":"IP 限制","ipLimitHint":"0 表示不限制并发客户端 IP","remark":"备注","shareTitle":"订阅链接","rotateSub":"轮换订阅 Token","subRotated":"订阅已轮换","deleteDepleted":"清理耗尽用户","batchEnable":"批量启用","batchDisable":"批量禁用","batchResetTraffic":"批量重置流量","batchDelete":"删除所选","batchDeleteConfirm":"确定删除所选用户?","batchNeedSelect":"请先勾选用户","batchDone":"批量操作完成","deleteDepletedConfirm":"确定删除所有已到期或超额用户?","depletedDeleted":"已删除耗尽用户","subMihomo":"Mihomo 订阅","subV2ray":"V2Ray / Base64(v2rayNG / Hiddify)","subSingbox":"Sing-box 订阅","qrHint":"二维码默认对应 Mihomo;经典客户端请粘贴 V2Ray 链接","bindRemoteHint":"远程集群节点(需先在多机节点中同步镜像)","selectRemoteNodes":"选择远程节点","disabled":"已禁用","batchExtend":"批量延期","batchAddTraffic":"批量加流量","batchExtendPrompt":"延期多少天?","batchAddTrafficPrompt":"增加多少 GiB 流量额度?"},"traffic":{"title":"流量监控","subtitle":"实时连接与流量统计。","upload":"上传","download":"下载","uploadRate":"上传速率","downloadRate":"下载速率","connections":"连接","onlineUsers":"在线用户","byUser":"用户流量","listener":"节点","network":"网络","rule":"规则"},"cluster":{"title":"多机节点","subtitle":"登记远程 3m-ui 面板并做健康检查(完整远程管控后续迭代)","create":"添加节点","edit":"编辑节点","name":"名称","baseUrl":"面板地址","apiToken":"API Token","apiTokenHint":"可选;用于鉴权远程面板","remark":"备注","health":"健康检查","healthDone":"已探测","healthAll":"全部检查","healthAllDone":"全部健康检查完成","dashboard":"远程仪表盘","users":"远程用户","restartCore":"重启核心","restarted":"远程核心已重启","startCore":"启动核心","stopCore":"停止核心","created":"已添加","updated":"已更新","deleted":"已删除","nodesLoaded":"已加载节点","remoteCreate":"远程创建","remoteCreated":"远程节点已创建","remoteDeleted":"远程节点已删除","remoteNodes":"远程节点","syncNodes":"同步节点","syncDone":"同步完成","pushNode":"推送节点","pushNodePrompt":"要推送的本机节点 ID","pushNodeConfirm":"确认推送到远程?(远程将以禁用状态创建)","pushNodeDone":"已推送(远程默认禁用)","colId":"ID","colName":"名称","colProtocol":"协议","colPort":"端口","operationOk":"完成"},"routing":{"title":"路由与策略组","subtitle":"管理 Mihomo proxy-groups 与 rules","groups":"策略组","rules":"规则列表","addGroup":"添加策略组","proxies":"节点列表","proxiesHint":"逗号分隔的 proxy / group 名称","rulesHint":"每行一条 Mihomo rule","rulesSaved":"规则已保存","groupSaved":"策略组已保存","groupDeleted":"策略组已删除"},"settings":{"navPanel":"面板 / 外观","navAccess":"访问档案","navTelegram":"Telegram","navSecurity":"安全与备份","navSubscription":"订阅页","navSSL":"证书 / SSL","navNetwork":"反代与 Geo","navTraffic":"流量重置","navAbout":"关于","panelServer":"面板 / NAT","panelPort":"面板端口","panelPortHint":"NAT 环境请映射此端口到公网;修改后需重启 3m-ui 服务。","panelListen":"监听地址","panelListenHint":"留空监听所有网卡(含 IPv6)。仅反代可访问时填 127.0.0.1。","panelPublicURL":"面板公网 URL","panelPublicURLHint":"用于 NAT 后订阅链接,例如 https://panel.example.com:8443","title":"系统设置","subtitle":"偏好与账户","language":"界面语言","theme":"主题外观","light":"浅色","dark":"深色","system":"跟随系统","passwordTitle":"账户安全","passwordDescription":"定期修改密码可以提高账户安全性","changePassword":"前往修改密码","about":"关于 3M-UI","version":"版本","backup":"备份与恢复","backupHint":"下载包含 SQLite 与 Mihomo 配置的 zip。恢复数据库后需重启面板进程。","downloadBackup":"下载备份","restoreDb":"恢复数据库","backupDone":"备份已下载","restoreDone":"数据库已恢复,请重启面板","apiDocs":"API 文档","apiDocsHint":"用于自动化的 OpenAPI 3 规范。","openOpenAPI":"打开 openapi.yaml","alpnHint":"逗号分隔,如 h2,http/1.1","accessProfile":"访问配置","accessProfileHint":"用于导出订阅链接 / 客户端 YAML 的公网 Host、SNI、指纹","publicHost":"公网 Host","publicPort":"公网端口","clientFingerprint":"客户端指纹","telegram":"Telegram 通知","botToken":"Bot Token","chatIds":"Chat ID 列表","telegramSaved":"Telegram 设置已保存","telegramTest":"发送测试消息","telegramTestOk":"测试消息已发送","chatIdsHint":"多个 Chat ID 用逗号分隔","notifyLogin":"面板登录时通知","notifyBlock":"用户封禁时通知","notifyUnblock":"用户解封时通知","notifyExpiry":"用户过期时通知","notifyDailyDigest":"每天发送流量与用户摘要","templates":"证书与反代模板","proxyKind":"反代类型","domain":"域名","upstream":"上游地址","generateTemplate":"生成模板","templateGenerated":"模板已生成","trafficReset":"流量重置","trafficResetDay":"每月重置日","certWizard":"证书申请向导","email":"邮箱","webroot":"Webroot 路径","generateAcme":"生成 certbot 命令","acmeGenerated":"命令已生成","acmeHint":"在服务器上执行下列命令申请证书,再将路径填入 Listener TLS 配置","notifyTraffic":"流量阈值告警","trafficWarnPct":"流量告警阈值 (%)","expiryWarnHours":"到期提前提醒 (小时)","panelSSL":"面板 SSL(ACME)","panelSSLHint":"通过 Let’s Encrypt(autocert)或手动证书为面板启用 HTTPS。保存后需重启面板生效。","sslSaved":"SSL 设置已保存 — 请重启面板生效","acmeCacheDir":"ACME 缓存目录","manualCert":"手动证书文件(可选)","manualKey":"手动私钥文件(可选)","subPage":"订阅页模板","subPageHint":"自定义 HTML 模板目录(index.html 或 sub.html)。留空使用内置页面。浏览器以 Accept: text/html 访问订阅 URL 时将显示此页。","subscriptionFormatHint":"订阅格式自动识别","subscriptionFormatHintDesc":"根据客户端 User-Agent 自动选择 Clash/Mihomo YAML、v2ray Base64 或 sing-box JSON;也可用 ?target=clash|v2ray|singbox|html 强制。响应头含 Subscription-Userinfo(流量/到期)。","subThemeDir":"主题目录","subTitle":"页面标题","subSupportUrl":"支持链接","downloadDefaultTpl":"下载默认模板","geofiles":"Geo 文件","geofilesHint":"下载最新 MetaCubeX GeoIP/GeoSite 数据库到 Mihomo 数据目录。","updateGeofiles":"更新地理文件","geofilesDone":"地理文件已更新","subAnnounce":"订阅公告","subWebPage":"订阅网页 URL","subUpdates":"刷新间隔(小时)","subEncrypt":"Base64 编码节点列表","notifyCPU":"CPU 过高时通知","cpuWarnPct":"CPU 告警阈值 (%)","tgSchedule":"报告计划","tgScheduleHint":"cron 表达式如 \"0 9 * * *\" 或 @daily / @every 6h","tgLanguage":"机器人语言","tgEvents":"启用事件","tgExpiryDays":"到期提前提醒 (天)","tgTrafficGB":"流量剩余提醒 (GB)","tgAttachBackup":"报告中附带数据库备份","tgProxy":"代理地址","tgApiServer":"Telegram API 服务器","tgSetCommands":"注册命令菜单","tgCommandsSet":"命令菜单已注册","restoreBackup":"恢复备份","restoreRestart":"重启以应用","restoreRestartHint":"恢复数据库后请重启 3m-ui 服务","security":"安全"},"share":{"title":"分享 / 订阅","subtitle":"统一查看用户订阅链接与已绑定节点的分享 URI。","selectUser":"选择用户","tabSub":"订阅链接","tabUri":"节点 URI","noSub":"暂无订阅地址","noNodes":"该用户未绑定节点,请先在用户管理中绑定。","uri":"URI","loadUri":"加载 URI","subHint":"将链接粘贴到客户端。默认(无 target)为 Mihomo YAML;target=v2ray 为经典 Base64 URI 列表。","subHtml":"订阅信息页 (HTML)"}} as const; +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 语法(如 200,204,401-429)","randomPort":"随机端口","noAvailablePort":"10000-60000 范围内没有可推荐的端口,请手动填写。","sectionProtocol":"协议选项","transportLayer":"传输层","securityLayer":"安全层","sectionSecurity":"安全层","sectionTransport":"传输层","sectionTLS":"TLS 证书","sectionReality":"REALITY","sectionSSOption":"Trojan SS 混淆","cipher":"加密方法","password":"密码","passwordOptional":"密码(可选)","uuid":"UUID","privateKey":"私钥","certificate":"证书","alpn":"ALPN","sni":"SNI","clientFingerprint":"客户端指纹","allowInsecure":"允许不安全","muxOption":"多路复用","realityPrivateKey":"Reality 私钥","realityShortID":"Short ID","realityServerNames":"Server Names","realityDest":"目标 (dest)","transport":"传输","path":"路径","host":"Host","serviceName":"Service Name","up":"上行","down":"下行","ignoreClientBandwidth":"忽略客户端带宽","masquerade":"伪装","congestionControl":"拥塞控制","clone":"克隆","saveTemplate":"保存为模板","versions":"配置版本","cloned":"克隆成功","templateCreated":"模板创建成功","instantiated":"已从模板创建节点","batchDone":"批量操作完成","templates":"节点模板","templateName":"模板名称","createdAt":"创建时间","instantiate":"实例化","deleteTemplateConfirm":"确定删除此模板?","templateDeleted":"模板已删除","enableSelected":"启用选中","disableSelected":"禁用选中","newPort":"新端口","version":"版本","reason":"原因","diff":"配置差异","rollback":"回滚","rollbackConfirm":"确定回滚到此版本?","rollbackDone":"回滚完成","addUser":"添加用户","aeadMethod":"AEAD 加密方式","allowInsecureHint":"允许不安全连接(仅调试)","alterId":"alterId","alterIdHint":"VMess 额外 ID,建议 0","authenticationTimeout":"认证超时","bandwidthHint":"带宽限制,如 100 mbps","bbrProfile":"BBR 配置","clientAuthCert":"客户端证书","clientAuthType":"客户端认证类型","congestionController":"拥塞控制","createWizard":"向导创建节点","cwnd":"拥塞窗口 (cwnd)","decryption":"解密","encryption":"加密","encryptionHint":"客户端字段,仅用于订阅导出,不写入入站 YAML。","decryptionHint":"服务端字段,写入入站 decryption。","echKey":"ECH Key","enablePureDownlink":"纯下行","flow":"流控","flowHint":"如 xtls-rprx-vision","grpcHint":"gRPC 传输","grpcServiceName":"gRPC Service Name","grpcUserAgent":"gRPC User-Agent","grpcUserAgentHint":"gRPC 客户端 User-Agent 字符串(面板侧元数据;写入客户端 grpc-opts)。","pingInterval":"Ping 间隔 (ms)","pingIntervalHint":"gRPC keepalive Ping 间隔(面板侧元数据;写入客户端 grpc-opts)。","maxConnections":"最大连接数","maxConnectionsHint":"gRPC 最大并发连接数(面板侧元数据)。","minStreams":"最小流数","minStreamsHint":"gRPC 最小并发流数(面板侧元数据)。","maxStreams":"最大流数","maxStreamsHint":"gRPC 最大并发流数(面板侧元数据)。","handshakeTimeout":"握手超时","jlsDest":"JLS 目标","jlsHint":"Joliz 相关配置","jlsProxy":"JLS 代理","jlsSni":"JLS SNI","jlsSniHint":"JLS SNI 主机名","jlsUpstreamAddr":"JLS 上游地址","jlsUsers":"JLS 用户","kcpTunConn":"KCP 连接数","kcpTunCrypt":"KCP 加密","kcpTunKey":"KCP 密钥","kcpTunMode":"KCP 模式","kcpTunNocomp":"KCP 禁用压缩","kcpTunRcvwnd":"KCP 接收窗口","kcpTunSndwnd":"KCP 发送窗口","maxIdleTime":"最大空闲时间","maxUdpRelayPacketSize":"最大 UDP 中继包","mekyaHint":"Mekya 相关配置","mekyaKcpSection":"Mekya KCP","mekyaMaxSimultaneous":"最大并发","mekyaMaxSimultaneousHint":"最大同时连接数","mekyaMaxWriteDuration":"最大写入时长","mekyaMaxWriteDurationHint":"写入超时","mekyaMaxWriteSize":"最大写入大小","mekyaMaxWriteSizeHint":"单次写入上限","mekyaPacketBuffer":"包缓冲","mekyaPacketBufferHint":"数据包缓冲大小","mekyaPollingInterval":"轮询间隔 (ms)","mekyaH2PoolSize":"H2 连接池大小","mkcpCongestion":"mKCP 拥塞控制","mkcpDownlink":"mKCP 下行","mkcpHeader":"mKCP 头类型","mkcpHint":"mKCP 传输","mkcpReadBuffer":"mKCP 读缓冲","mkcpSeed":"mKCP Seed","mkcpUplink":"mKCP 上行","mkcpWriteBuffer":"mKCP 写缓冲","muxBrutal":"Mux Brutal","muxBrutalDown":"Brutal 下行","muxBrutalUp":"Brutal 上行","muxMaxConnections":"Mux 最大连接","muxMaxStreams":"Mux 最大流","muxMinStreams":"Mux 最小流","muxOnlyTcp":"仅 TCP Mux","muxPadding":"Mux 填充","muxProtocol":"Mux 协议","muxStatistic":"Mux 统计","network":"传输网络","obfs":"混淆","obfsMaxPacketSize":"混淆最大包","obfsMinPacketSize":"混淆最小包","obfsOptsHost":"混淆 Host","obfsOptsMode":"混淆模式","obfsPassword":"混淆密码","paddingMax":"填充最大","paddingMin":"填充最小","paddingScheme":"填充方案","psk":"PSK","quicVersions":"QUIC 版本","rateLimit":"速率限制","rateLimitHint":"如 10m","realityExclusiveHint":"Reality 与 TLS 证书互斥","realityShortId":"Reality Short ID","realmId":"Realm ID","realmProxy":"Realm 代理","realmServerUrl":"Realm 服务地址","realmSkipCert":"跳过证书校验","realmStun":"STUN","realmToken":"Realm Token","resTlsDest":"ResTLS 目标","resTlsHint":"ResTLS 配置","resTlsMinRecordLen":"最小记录长度","resTlsPassword":"ResTLS 密码","resTlsProxy":"ResTLS 代理","resTlsScript":"ResTLS 脚本","sectionJLS":"JLS","sectionJlsUpstream":"JLS 上游","sectionKcpTun":"KCP Tun","sectionMekya":"Mekya","sectionMkcp":"mKCP","sectionMux":"Mux","sectionRealm":"Realm","sectionResTLS":"Reality TLS","sectionShadowTLS":"ShadowTLS","sectionSimpleObfs":"simple-obfs","sectionXHTTP":"XHTTP","shadowTlsHandshakeDest":"握手目标","shadowTlsHandshakeProxy":"握手代理","shadowTlsHint":"ShadowTLS 配置","shadowTlsPassword":"ShadowTLS 密码","shadowTlsPasswordHint":"版本 2/3 密码","shadowTlsUsers":"ShadowTLS 用户","shadowTlsVersion":"ShadowTLS 版本","simpleObfsMode":"Simple Obfs 模式","snellVersion":"Snell 版本","ssOptionMethod":"SS 加密方式","ssOptionPassword":"SS 密码","sudokuKey":"Sudoku Key","customTable":"自定义表","customTables":"自定义表列表","sudokuFallback":"回落","sectionHttpmask":"HTTP 伪装","httpmaskDisable":"禁用 HTTP 伪装","httpmaskMode":"HTTP 伪装模式","httpmaskPathRoot":"HTTP 路径前缀","tableType":"表类型","tlsPairHint":"证书与私钥需成对填写","token":"Token","tokenHint":"认证 Token","tokenPlaceholder":"输入 Token","trafficPattern":"流量模式","userHintMandatory":"至少一个用户","wizardBasic":"基础信息","wizardConfig":"协议配置","wizardConfigHint":"填写协议相关字段(TLS / Reality / 用户等)","wizardReview":"确认创建","wsPath":"WebSocket 路径","wsPathHint":"如 /ws","xhttpHint":"XHTTP 传输","xhttpHost":"XHTTP Host","xhttpMode":"XHTTP 模式","xhttpPath":"XHTTP 路径","clientExportOnlyHint":"仅用于客户端订阅导出,不会写入 Mihomo 入站配置。","transportExclusiveHint":"同时只能选一种传输。VMess 的 mKCP/Mekya 需配合 TCP。","mkcpExclusiveHint":"需使用 TCP;与 WS/gRPC/Mekya 互斥,保存时清除其它传输。","mekyaExclusiveHint":"需使用 TCP;与 WS/gRPC/mKCP 互斥,保存时清除其它传输。","zeroRtt":"0-RTT","obfsOptsHint":"mode 与 host 需同时填写;都留空则关闭","obfsOptsHostRequired":"启用 obfs 时必须填写 host","shadowquicDisableMtuDiscovery":"禁用 MTU 探测","shadowquicMaxDatagramFrameSize":"最大 datagram frame 大小","shadowquicRecvWindow":"连接接收窗口","shadowquicRecvWindowConn":"流接收窗口","tcpUdp":"TCP / UDP","ipPort":"IP:端口","socketStatus":"监听状态"},"core":{"title":"核心管理","subtitle":"Mihomo 核心状态与控制。","status":"核心状态","running":"运行中","stoppedStatus":"已停止","version":"版本","pid":"进程 ID","uptime":"运行时间","start":"启动","stop":"停止","restart":"重启","started":"已启动","stopped":"已停止","restarted":"已重启","operationFailed":"操作失败","unavailable":"核心不可用","updates":{"title":"Mihomo 内核更新","description":"选择官方稳定版或 Pre 测试版。下载和配置检查完成后才切换正在运行的内核;切换失败会恢复旧内核。更新仅由你手动触发。","source":"内核来源","managed":"已选择版本(持久保存)","bundled":"面板随附版本","previous":"可回退版本","noPrevious":"暂无旧版本","unavailable":"内核更新不可用","check":"检查更新","select":"选择版本","latest":"最新","current":"当前","install":"安装所选版本","rollback":"恢复旧版本","releaseNotes":"版本说明","noVersions":"当前平台暂无可验证的官方稳定版本。","running":"正在更新","failed":"更新失败","succeeded":"更新完成","restored":"已恢复旧内核及其原先的运行状态。","confirm":"将 Mihomo 切换至","interruption":"切换时代理连接会短暂中断,配置和账号会保留。","staysStopped":"安装完成后内核仍保持停止,配置和账号会保留。","steps":{"prepare":"准备","download":"下载并校验","validate":"检查配置","activate":"切换并验证"},"stage":{"interrupted":"面板重启,更新中断","preparing":"准备更新","resolving":"查询官方版本","downloading":"下载内核,当前内核继续运行","verifying":"校验 SHA-256","validating":"检查内核和现有配置","switching":"切换内核","starting":"启动所选内核","checking":"检查进程和监听端口","recovering":"恢复旧内核","complete":"已完成"},"stable":"稳定版","pre":"Pre 测试版","preNotice":"Pre 是滚动更新的测试版本,可能存在兼容性问题。安装会核对所选构建号;若官方版本已更新,请重新检查更新。"}},"logs":{"title":"运行日志","subtitle":"面板与核心最近日志。","empty":"暂无日志","level":"级别","timestamp":"时间","payload":"内容","autoRefresh":"自动刷新","clear":"清空"},"config":{"title":"配置引擎","subtitle":"可视化管理 Mihomo 配置","visual":"可视化配置","yaml":"YAML 配置","proxies":"代理配置","yamlPreview":"YAML 预览","addProxy":"添加代理","editProxy":"编辑代理","deleteProxy":"删除代理","deleteConfirm":"确定要删除此代理吗?","generateSuccess":"已生成(尚未应用)","validateSuccess":"配置验证通过","validateFailed":"配置验证失败","proxyName":"代理名称","proxyType":"代理类型","proxyServer":"代理服务器","proxyPort":"代理端口","proxyPassword":"代理密码","proxyUUID":"代理 UUID","generate":"生成配置","validate":"校验配置","validateOk":"配置校验通过","validateFail":"配置校验失败","apply":"应用","applySuccess":"已应用配置","rollback":"回滚","rollbackSuccess":"已回滚到上一份配置"},"users":{"title":"用户管理","subtitle":"管理账号、流量、节点绑定与订阅。","batch":"批量操作","create":"创建用户","edit":"编辑用户","username":"用户名","password":"密码","enabled":"启用状态","deleteConfirm":"确定要删除此用户吗?","created":"创建成功","updated":"更新成功","deleted":"删除成功","bind":"绑定节点","bindNodes":"绑定节点","bindHint":"选择该用户可使用的入站节点。凭证会在生成 Mihomo 配置时自动注入到对应 Listener。","selectNodes":"选择节点","bindSuccess":"节点绑定已更新","traffic":"流量","trafficLimitGB":"流量上限 (GB)","trafficLimitHint":"0 或留空表示不限","expire":"到期时间","expireHint":"留空表示永不过期","neverExpire":"永不过期","online":"在线","offline":"离线","blocked":"已封禁","unlimited":"不限","resetTraffic":"重置流量","resetTrafficConfirm":"确定重置该用户的流量计数?","trafficReset":"流量已重置","passwordKeep":"留空则不修改密码","hwidLimit":"HWID 设备上限","hwidLimitHint":"0 为不限制。兼容 Happ 等客户端在订阅导入时发送的 x-hwid 头。","ipLimit":"IP 限制","ipLimitHint":"0 表示不限制并发客户端 IP","remark":"备注","shareTitle":"订阅链接","rotateSub":"轮换订阅 Token","subRotated":"订阅已轮换","deleteDepleted":"清理耗尽用户","batchEnable":"批量启用","batchDisable":"批量禁用","batchResetTraffic":"批量重置流量","batchDelete":"删除所选","batchDeleteConfirm":"确定删除所选用户?","batchNeedSelect":"请先勾选用户","batchDone":"批量操作完成","deleteDepletedConfirm":"确定删除所有已到期或超额用户?","depletedDeleted":"已删除耗尽用户","subMihomo":"Mihomo 订阅","subV2ray":"V2Ray / Base64(v2rayNG / Hiddify)","subSingbox":"Sing-box 订阅","qrHint":"二维码默认对应 Mihomo;经典客户端请粘贴 V2Ray 链接","bindRemoteHint":"远程集群节点(需先在多机节点中同步镜像)","selectRemoteNodes":"选择远程节点","disabled":"已禁用","batchExtend":"批量延期","batchAddTraffic":"批量加流量","batchExtendPrompt":"延期多少天?","batchAddTrafficPrompt":"增加多少 GiB 流量额度?"},"traffic":{"title":"流量监控","subtitle":"实时连接与流量统计。","upload":"上传","download":"下载","uploadRate":"上传速率","downloadRate":"下载速率","connections":"连接","onlineUsers":"在线用户","byUser":"用户流量","listener":"节点","network":"网络","rule":"规则"},"cluster":{"title":"多机节点","subtitle":"登记远程 3m-ui 面板并做健康检查(完整远程管控后续迭代)","create":"添加节点","edit":"编辑节点","name":"名称","baseUrl":"面板地址","apiToken":"API Token","apiTokenHint":"可选;用于鉴权远程面板","remark":"备注","health":"健康检查","healthDone":"已探测","healthAll":"全部检查","healthAllDone":"全部健康检查完成","dashboard":"远程仪表盘","users":"远程用户","restartCore":"重启核心","restarted":"远程核心已重启","startCore":"启动核心","stopCore":"停止核心","created":"已添加","updated":"已更新","deleted":"已删除","nodesLoaded":"已加载节点","remoteCreate":"远程创建","remoteCreated":"远程节点已创建","remoteDeleted":"远程节点已删除","remoteNodes":"远程节点","syncNodes":"同步节点","syncDone":"同步完成","pushNode":"推送节点","pushNodePrompt":"要推送的本机节点 ID","pushNodeConfirm":"确认推送到远程?(远程将以禁用状态创建)","pushNodeDone":"已推送(远程默认禁用)","colId":"ID","colName":"名称","colProtocol":"协议","colPort":"端口","operationOk":"完成"},"routing":{"title":"路由与策略组","subtitle":"管理 Mihomo proxy-groups 与 rules","groups":"策略组","rules":"规则列表","addGroup":"添加策略组","proxies":"节点列表","proxiesHint":"逗号分隔的 proxy / group 名称","rulesHint":"每行一条 Mihomo rule","rulesSaved":"规则已保存","groupSaved":"策略组已保存","groupDeleted":"策略组已删除"},"settings":{"navPanel":"面板 / 外观","navAccess":"访问档案","navTelegram":"Telegram","navSecurity":"安全与备份","navSubscription":"订阅页","navSSL":"证书 / SSL","navNetwork":"反代与 Geo","navTraffic":"流量重置","navAbout":"关于","panelServer":"面板 / NAT","panelPort":"面板端口","panelPortHint":"NAT 环境请映射此端口到公网;修改后需重启 3m-ui 服务。","panelListen":"监听地址","panelListenHint":"留空监听所有网卡(含 IPv6)。仅反代可访问时填 127.0.0.1。","panelPublicURL":"面板公网 URL","panelPublicURLHint":"用于 NAT 后订阅链接,例如 https://panel.example.com:8443","title":"系统设置","subtitle":"偏好与账户","language":"界面语言","theme":"主题外观","light":"浅色","dark":"深色","system":"跟随系统","passwordTitle":"账户安全","passwordDescription":"定期修改密码可以提高账户安全性","changePassword":"前往修改密码","about":"关于 3M-UI","version":"版本","backup":"备份与恢复","backupHint":"下载包含 SQLite 与 Mihomo 配置的 zip。恢复数据库后需重启面板进程。","downloadBackup":"下载备份","restoreDb":"恢复数据库","backupDone":"备份已下载","restoreDone":"数据库已恢复,请重启面板","apiDocs":"API 文档","apiDocsHint":"用于自动化的 OpenAPI 3 规范。","openOpenAPI":"打开 openapi.yaml","alpnHint":"逗号分隔,如 h2,http/1.1","accessProfile":"访问配置","accessProfileHint":"用于导出订阅链接 / 客户端 YAML 的公网 Host、SNI、指纹","publicHost":"公网 Host","publicPort":"公网端口","clientFingerprint":"客户端指纹","telegram":"Telegram 通知","botToken":"Bot Token","chatIds":"Chat ID 列表","telegramSaved":"Telegram 设置已保存","telegramTest":"发送测试消息","telegramTestOk":"测试消息已发送","chatIdsHint":"多个 Chat ID 用逗号分隔","notifyLogin":"面板登录时通知","notifyBlock":"用户封禁时通知","notifyUnblock":"用户解封时通知","notifyExpiry":"用户过期时通知","notifyDailyDigest":"每天发送流量与用户摘要","templates":"证书与反代模板","proxyKind":"反代类型","domain":"域名","upstream":"上游地址","generateTemplate":"生成模板","templateGenerated":"模板已生成","trafficReset":"流量重置","trafficResetDay":"每月重置日","certWizard":"证书申请向导","email":"邮箱","webroot":"Webroot 路径","generateAcme":"生成 certbot 命令","acmeGenerated":"命令已生成","acmeHint":"在服务器上执行下列命令申请证书,再将路径填入 Listener TLS 配置","notifyTraffic":"流量阈值告警","trafficWarnPct":"流量告警阈值 (%)","expiryWarnHours":"到期提前提醒 (小时)","panelSSL":"面板 SSL(ACME)","panelSSLHint":"通过 Let’s Encrypt(autocert)或手动证书为面板启用 HTTPS。保存后需重启面板生效。","sslSaved":"SSL 设置已保存 — 请重启面板生效","acmeCacheDir":"ACME 缓存目录","manualCert":"手动证书文件(可选)","manualKey":"手动私钥文件(可选)","subPage":"订阅页模板","subPageHint":"自定义 HTML 模板目录(index.html 或 sub.html)。留空使用内置页面。浏览器以 Accept: text/html 访问订阅 URL 时将显示此页。","subscriptionFormatHint":"订阅格式自动识别","subscriptionFormatHintDesc":"根据客户端 User-Agent 自动选择 Clash/Mihomo YAML、v2ray Base64 或 sing-box JSON;也可用 ?target=clash|v2ray|singbox|html 强制。响应头含 Subscription-Userinfo(流量/到期)。","subThemeDir":"主题目录","subTitle":"页面标题","subSupportUrl":"支持链接","downloadDefaultTpl":"下载默认模板","geofiles":"Geo 文件","geofilesHint":"下载最新 MetaCubeX GeoIP/GeoSite 数据库到 Mihomo 数据目录。","updateGeofiles":"更新地理文件","geofilesDone":"地理文件已更新","subAnnounce":"订阅公告","subWebPage":"订阅网页 URL","subUpdates":"刷新间隔(小时)","subEncrypt":"Base64 编码节点列表","notifyCPU":"CPU 过高时通知","cpuWarnPct":"CPU 告警阈值 (%)","tgSchedule":"报告计划","tgScheduleHint":"cron 表达式如 \"0 9 * * *\" 或 @daily / @every 6h","tgLanguage":"机器人语言","tgEvents":"启用事件","tgExpiryDays":"到期提前提醒 (天)","tgTrafficGB":"流量剩余提醒 (GB)","tgAttachBackup":"报告中附带数据库备份","tgProxy":"代理地址","tgApiServer":"Telegram API 服务器","tgSetCommands":"注册命令菜单","tgCommandsSet":"命令菜单已注册","restoreBackup":"恢复备份","restoreRestart":"重启以应用","restoreRestartHint":"恢复数据库后请重启 3m-ui 服务","security":"安全"},"share":{"title":"分享 / 订阅","subtitle":"统一查看用户订阅链接与已绑定节点的分享 URI。","selectUser":"选择用户","tabSub":"订阅链接","tabUri":"节点 URI","noSub":"暂无订阅地址","noNodes":"该用户未绑定节点,请先在用户管理中绑定。","uri":"URI","loadUri":"加载 URI","subHint":"将链接粘贴到客户端。默认(无 target)为 Mihomo YAML;target=v2ray 为经典 Base64 URI 列表。","subHtml":"订阅信息页 (HTML)"}} as const; diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index a31748a..c726819 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -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":"安全","sectionTransport":"傳輸","sectionTLS":"TLS","sectionReality":"REALITY","sectionSSOption":"Trojan SS option","cipher":"Cipher","password":"Password","passwordOptional":"Password (optional)","uuid":"UUID","privateKey":"Private key","certificate":"Certificate","alpn":"ALPN","sni":"SNI","clientFingerprint":"Client fingerprint","allowInsecure":"Allow insecure","muxOption":"Mux","realityPrivateKey":"Reality private key","realityShortID":"Short ID","realityServerNames":"Server names","realityDest":"Dest","transport":"Transport","path":"Path","host":"Host","serviceName":"Service name","up":"Up","down":"Down","ignoreClientBandwidth":"Ignore client bandwidth","masquerade":"Masquerade","congestionControl":"Congestion control","clone":"複製","saveTemplate":"Save as template","versions":"版本","cloned":"Cloned","templateCreated":"Template created","instantiated":"Listener created from template","batchDone":"Batch operation completed","templates":"模板","templateName":"Template name","createdAt":"Created at","instantiate":"Instantiate","deleteTemplateConfirm":"Delete this template?","templateDeleted":"Template deleted","enableSelected":"啟用所選","disableSelected":"停用所選","newPort":"New port","version":"Version","reason":"Reason","diff":"Diff","rollback":"Rollback","rollbackConfirm":"Rollback to this version?","rollbackDone":"Rollback completed","addUser":"Add user","aeadMethod":"AEAD method","allowInsecureHint":"Allow insecure (debug only)","alterId":"Alter ID","alterIdHint":"VMess alterId, prefer 0","authenticationTimeout":"Auth timeout","bandwidthHint":"Bandwidth limit, e.g. 100 mbps","bbrProfile":"BBR profile","clientAuthCert":"Client cert","clientAuthType":"Client auth type","congestionController":"Congestion controller","createWizard":"Create listener wizard","cwnd":"CWND","decryption":"Decryption","encryption":"Encryption","encryptionHint":"Client-only field for subscription export; not written to inbound YAML.","decryptionHint":"Server-side field written to listener decryption.","echKey":"ECH Key","enablePureDownlink":"Pure downlink","flow":"Flow","flowHint":"e.g. xtls-rprx-vision","grpcHint":"gRPC transport","grpcServiceName":"gRPC service name","grpcUserAgent":"gRPC User-Agent","grpcUserAgentHint":"gRPC client User-Agent string (panel-side metadata; emitted into client-side grpc-opts).","pingInterval":"Ping interval (ms)","pingIntervalHint":"gRPC keepalive ping interval (panel-side metadata; emitted into client-side grpc-opts).","maxConnections":"Max connections","maxConnectionsHint":"gRPC max concurrent connections (panel-side metadata).","minStreams":"Min streams","minStreamsHint":"gRPC min concurrent streams (panel-side metadata).","maxStreams":"Max streams","maxStreamsHint":"gRPC max concurrent streams (panel-side metadata).","handshakeTimeout":"Handshake timeout","jlsDest":"JLS dest","jlsHint":"JLS settings","jlsProxy":"JLS proxy","jlsSni":"JLS SNI","jlsSniHint":"JLS SNI hostname","jlsUpstreamAddr":"JLS upstream","jlsUsers":"JLS users","kcpTunConn":"KCP connections","kcpTunCrypt":"KCP crypt","kcpTunKey":"KCP key","kcpTunMode":"KCP mode","kcpTunNocomp":"KCP no compress","kcpTunRcvwnd":"KCP recv window","kcpTunSndwnd":"KCP send window","maxIdleTime":"Max idle time","maxUdpRelayPacketSize":"Max UDP relay packet","mekyaHint":"Mekya settings","mekyaKcpSection":"Mekya KCP","mekyaMaxSimultaneous":"Max simultaneous","mekyaMaxSimultaneousHint":"Max concurrent connections","mekyaMaxWriteDuration":"Max write duration","mekyaMaxWriteDurationHint":"Write timeout","mekyaMaxWriteSize":"Max write size","mekyaMaxWriteSizeHint":"Single write limit","mekyaPacketBuffer":"Packet buffer","mekyaPacketBufferHint":"Packet buffer size","mekyaPollingInterval":"Polling interval (ms)","mekyaH2PoolSize":"H2 pool size","mkcpCongestion":"mKCP congestion","mkcpDownlink":"mKCP downlink","mkcpHeader":"mKCP header","mkcpHint":"mKCP transport","mkcpReadBuffer":"mKCP read buffer","mkcpSeed":"mKCP seed","mkcpUplink":"mKCP uplink","mkcpWriteBuffer":"mKCP write buffer","muxBrutal":"Mux brutal","muxBrutalDown":"Brutal down","muxBrutalUp":"Brutal up","muxMaxConnections":"Mux max connections","muxMaxStreams":"Mux max streams","muxMinStreams":"Mux min streams","muxOnlyTcp":"Mux TCP only","muxPadding":"Mux padding","muxProtocol":"Mux protocol","muxStatistic":"Mux statistics","network":"Network","obfs":"Obfuscation","obfsMaxPacketSize":"Obfs max packet","obfsMinPacketSize":"Obfs min packet","obfsOptsHost":"Obfs host","obfsOptsMode":"Obfs mode","obfsPassword":"Obfs password","paddingMax":"Padding max","paddingMin":"Padding min","paddingScheme":"Padding scheme","psk":"PSK","quicVersions":"QUIC versions","rateLimit":"Rate limit","rateLimitHint":"e.g. 10m","realityExclusiveHint":"Reality and TLS cert are mutually exclusive","realityShortId":"Reality short ID","realmId":"Realm ID","realmProxy":"Realm proxy","realmServerUrl":"Realm server URL","realmSkipCert":"Skip cert verify","realmStun":"STUN","realmToken":"Realm token","resTlsDest":"ResTLS dest","resTlsHint":"ResTLS settings","resTlsMinRecordLen":"Min record length","resTlsPassword":"ResTLS password","resTlsProxy":"ResTLS proxy","resTlsScript":"ResTLS script","sectionJLS":"JLS","sectionJlsUpstream":"JLS upstream","sectionKcpTun":"KCP Tun","sectionMekya":"Mekya","sectionMkcp":"mKCP","sectionMux":"Mux","sectionRealm":"Realm","sectionResTLS":"Reality TLS","sectionShadowTLS":"ShadowTLS","sectionSimpleObfs":"simple-obfs","sectionXHTTP":"XHTTP","shadowTlsHandshakeDest":"Handshake dest","shadowTlsHandshakeProxy":"Handshake proxy","shadowTlsHint":"ShadowTLS settings","shadowTlsPassword":"ShadowTLS password","shadowTlsPasswordHint":"v2/v3 password","shadowTlsUsers":"ShadowTLS users","shadowTlsVersion":"ShadowTLS version","simpleObfsMode":"Simple Obfs mode","snellVersion":"Snell version","ssOptionMethod":"SS method","ssOptionPassword":"SS password","sudokuKey":"Sudoku key","customTable":"Custom table","customTables":"Custom tables","sudokuFallback":"Fallback","sectionHttpmask":"HTTP mask","httpmaskDisable":"Disable HTTP mask","httpmaskMode":"HTTP mask mode","httpmaskPathRoot":"HTTP path root","tableType":"Table type","tlsPairHint":"Certificate and private key must both be set","token":"Token","tokenHint":"Auth token","tokenPlaceholder":"Enter token","trafficPattern":"Traffic pattern","userHintMandatory":"At least one user required","wizardBasic":"Basics","wizardConfig":"Protocol","wizardConfigHint":"Fill protocol-specific fields (TLS / Reality / users, etc.)","wizardReview":"Review","wsPath":"WebSocket path","wsPathHint":"e.g. /ws","xhttpHint":"XHTTP transport","xhttpHost":"XHTTP host","xhttpMode":"XHTTP mode","xhttpPath":"XHTTP path","clientExportOnlyHint":"Client subscription only — not written to Mihomo inbound YAML.","transportExclusiveHint":"One transport only. mKCP/Mekya (VMess) require TCP.","mkcpExclusiveHint":"Requires TCP; exclusive with WS/gRPC/Mekya. Other transports cleared on save.","mekyaExclusiveHint":"Requires TCP; exclusive with WS/gRPC/mKCP. Other transports cleared on save.","zeroRtt":"0-RTT","obfsOptsHint":"Mode and host must be set together; leave both empty to disable","obfsOptsHostRequired":"Host is required when obfs is enabled","shadowquicDisableMtuDiscovery":"Disable MTU discovery","shadowquicMaxDatagramFrameSize":"Max datagram frame size","shadowquicRecvWindow":"Connection receive window","shadowquicRecvWindowConn":"Stream receive window","tcpUdp":"TCP / UDP","ipPort":"IP:埠","socketStatus":"監聽狀態","delete":"刪除","reload":"重載"},"core":{"title":"核心管理","subtitle":"Mihomo 進程控制","status":"狀態","running":"運行中","stoppedStatus":"Stopped","version":"版本","pid":"PID","uptime":"運行時間","start":"啟動","stop":"停止","restart":"重啟","started":"Started","stopped":"已停止","restarted":"Restarted","operationFailed":"操作失敗","unavailable":"無法取得狀態"},"logs":{"title":"運行日誌","subtitle":"面板與核心日誌","empty":"暫無日誌","level":"Level","timestamp":"Time","payload":"Payload","autoRefresh":"Auto refresh","clear":"Clear","refresh":"重新整理"},"config":{"title":"配置引擎","subtitle":"預覽與驗證 Mihomo 配置","visual":"Visual Config","yaml":"YAML Config","proxies":"Proxy Configuration","yamlPreview":"YAML Preview","addProxy":"Add Proxy","editProxy":"Edit Proxy","deleteProxy":"Delete Proxy","deleteConfirm":"Delete this proxy?","generateSuccess":"Generated (not applied yet)","validateSuccess":"Configuration is valid","validateFailed":"Configuration validation failed","proxyName":"Proxy Name","proxyType":"Proxy Type","proxyServer":"Proxy Server","proxyPort":"Proxy Port","proxyPassword":"Proxy Password","proxyUUID":"Proxy UUID","generate":"Generate config","validate":"驗證","validateOk":"Configuration is valid","validateFail":"Configuration validation failed","apply":"套用","applySuccess":"Configuration applied","rollback":"Rollback","rollbackSuccess":"Rolled back to previous config","preview":"預覽","refresh":"重新整理"},"users":{"title":"使用者管理","subtitle":"代理使用者與訂閱","batch":"Batch","create":"建立使用者","edit":"編輯","username":"Username","password":"Password","enabled":"Enabled","deleteConfirm":"Delete this user?","created":"Created","updated":"Updated","deleted":"Deleted","bind":"Bind","bindNodes":"綁定節點","bindHint":"Select inbound nodes for this user. Credentials are injected into listeners when generating Mihomo config.","selectNodes":"Select nodes","bindSuccess":"Node bindings updated","traffic":"流量","trafficLimitGB":"Traffic limit (GB)","trafficLimitHint":"0 or empty = unlimited","expire":"到期","expireHint":"Leave empty for no expiry","neverExpire":"Never","online":"Online","offline":"Offline","blocked":"Blocked","unlimited":"Unlimited","resetTraffic":"Reset traffic","resetTrafficConfirm":"Reset traffic counters for this user?","trafficReset":"Traffic counters reset","passwordKeep":"Leave blank to keep current password","hwidLimit":"HWID device limit","hwidLimitHint":"0 = unlimited. Happ clients send x-hwid when importing the subscription.","ipLimit":"IP limit","ipLimitHint":"0 = unlimited concurrent client IPs","remark":"Remark","shareTitle":"Subscription","rotateSub":"Rotate subscription token","subRotated":"Subscription rotated","deleteDepleted":"Delete depleted","batchEnable":"Enable","batchDisable":"Disable","batchResetTraffic":"Reset traffic","batchDelete":"Delete selected","batchDeleteConfirm":"Delete selected users?","batchNeedSelect":"Select users first","batchDone":"Batch done","deleteDepletedConfirm":"Delete all expired / over-quota users?","depletedDeleted":"Deleted depleted users","subMihomo":"Mihomo 訂閱","subV2ray":"V2Ray / Base64 (v2rayNG / Hiddify)","subSingbox":"Sing-box subscription","qrHint":"QR = default (Mihomo). Paste the V2Ray URL into classic clients.","bindRemoteHint":"Remote cluster nodes (sync mirrors on the Cluster page first)","selectRemoteNodes":"Select remote nodes","disabled":"Disabled","batchExtend":"Extend days","batchAddTraffic":"Add traffic","batchExtendPrompt":"Extend by how many days?","batchAddTrafficPrompt":"Add how many GiB to the limit?","delete":"刪除","enable":"啟用","disable":"停用","subLink":"訂閱連結"},"traffic":{"title":"流量監控","subtitle":"用量與配額","upload":"Upload","download":"Download","uploadRate":"Upload rate","downloadRate":"Download rate","connections":"Connections","onlineUsers":"Online users","byUser":"Per-user traffic","listener":"Listener","network":"Network","rule":"Rule","total":"總計","used":"已用","remaining":"剩餘","refresh":"重新整理"},"cluster":{"title":"多機節點","subtitle":"遠端面闆與合併訂閱","create":"登記遠端","edit":"編輯","name":"名稱","baseUrl":"面板地址","apiToken":"API Token","apiTokenHint":"Optional; used to authenticate against the remote panel","remark":"Remark","health":"健康檢查","healthDone":"Checked","healthAll":"Check all","healthAllDone":"Health check finished","dashboard":"Remote dashboard","users":"Remote users","restartCore":"重啟核心","restarted":"Remote core restarted","startCore":"啟動核心","stopCore":"停止核心","created":"Created","updated":"Updated","deleted":"Deleted","nodesLoaded":"Nodes loaded","remoteCreate":"Remote create","remoteCreated":"Remote listener created","remoteDeleted":"Remote listener deleted","remoteNodes":"遠端節點","syncNodes":"同步節點","syncDone":"Synced","pushNode":"Push node","pushNodePrompt":"Local node ID to push","pushNodeConfirm":"Push this node to remote? (created disabled)","pushNodeDone":"Pushed (disabled on remote)","colId":"ID","colName":"名稱","colProtocol":"協定","colPort":"埠","operationOk":"完成"},"routing":{"title":"路由規則","subtitle":"分流與規則集","groups":"Proxy groups","rules":"Rules","addGroup":"Add group","proxies":"Proxies","proxiesHint":"Comma-separated proxy / group names","rulesHint":"One Mihomo rule per line","rulesSaved":"Rules saved","groupSaved":"Group saved","groupDeleted":"Group deleted","save":"儲存","refresh":"重新整理"},"settings":{"navPanel":"Panel / UI","navAccess":"Access profile","navTelegram":"Telegram","navSecurity":"Security & backup","navSubscription":"Subscription page","navSSL":"Certificates / SSL","navNetwork":"Proxy & Geo","navTraffic":"Traffic reset","navAbout":"About","panelServer":"Panel / NAT","panelPort":"Panel port","panelPortHint":"Map this port on your NAT gateway. Restart 3m-ui after changing.","panelListen":"Listen address","panelListenHint":"Empty = all interfaces (IPv4/IPv6). Use 127.0.0.1 behind a reverse proxy.","panelPublicURL":"Public panel URL","panelPublicURLHint":"Used in subscription links behind NAT, e.g. https://panel.example.com:8443","title":"系統設定","subtitle":"面板與通知選項","language":"語言","theme":"主題","light":"Light","dark":"Dark","system":"System","passwordTitle":"Account Security","passwordDescription":"Change your password regularly","changePassword":"Change Password","about":"About 3M-UI","version":"Version","backup":"Backup & restore","backupHint":"Download a zip with SQLite and Mihomo config. Restart the panel after restoring the database.","downloadBackup":"Download backup","restoreDb":"Restore database","backupDone":"Backup downloaded","restoreDone":"Database restored — restart the panel","apiDocs":"API documentation","apiDocsHint":"OpenAPI 3 specification for automation.","openOpenAPI":"Open openapi.yaml","alpnHint":"Comma-separated, e.g. h2,http/1.1","accessProfile":"Access Profile","accessProfileHint":"Public host / SNI / fingerprint for share links and client YAML","publicHost":"Public Host","publicPort":"Public Port","clientFingerprint":"Client Fingerprint","telegram":"Telegram alerts","botToken":"Bot Token","chatIds":"Chat ID 列表","telegramSaved":"Telegram settings saved","telegramTest":"Send test message","telegramTestOk":"Test message sent","chatIdsHint":"Comma-separated chat IDs","notifyLogin":"Notify on panel login","notifyBlock":"Notify on user block","notifyUnblock":"Notify on user unblock","notifyExpiry":"Notify on user expiry","notifyDailyDigest":"Send a daily traffic and user summary","templates":"TLS & reverse-proxy templates","proxyKind":"Proxy type","domain":"Domain","upstream":"Upstream","generateTemplate":"Generate template","templateGenerated":"Template generated","trafficReset":"Traffic reset","trafficResetDay":"Monthly reset day","certWizard":"Certificate wizard","email":"Email","webroot":"Webroot path","generateAcme":"Generate certbot command","acmeGenerated":"Command generated","acmeHint":"Run the command on the server, then put cert paths into Listener TLS settings","notifyTraffic":"Traffic threshold warning","trafficWarnPct":"Traffic warn %","expiryWarnHours":"Expiry warn (hours)","panelSSL":"Panel SSL (ACME)","panelSSLHint":"Enable HTTPS for the panel via Let's Encrypt (autocert) or a manual certificate. Restart the panel after saving.","sslSaved":"SSL settings saved — restart panel to apply","acmeCacheDir":"ACME cache dir","manualCert":"Manual cert file (optional)","manualKey":"Manual key file (optional)","subPage":"Subscription page template","subPageHint":"Custom HTML template directory (index.html or sub.html). Leave empty for the built-in page. Browsers hitting the subscription URL with Accept: text/html will see this page.","subscriptionFormatHint":"Subscription auto-format","subscriptionFormatHintDesc":"Format follows User-Agent (Clash/Mihomo YAML, v2ray Base64, sing-box JSON). Override with ?target=clash|v2ray|singbox|html. Traffic/expiry via Subscription-Userinfo headers.","subThemeDir":"Theme directory","subTitle":"Page title","subSupportUrl":"Support URL","downloadDefaultTpl":"Download default template","geofiles":"Geo 檔案","geofilesHint":"Download latest MetaCubeX GeoIP/GeoSite databases into the Mihomo data directory .","updateGeofiles":"Update geo files","geofilesDone":"Geo files updated","subAnnounce":"Subscription announce","subWebPage":"Profile web page URL","subUpdates":"Update interval (hours)","subEncrypt":"Base64-encode URI list","notifyCPU":"Notify on high CPU","cpuWarnPct":"CPU warn threshold (%)","tgSchedule":"Report schedule","tgScheduleHint":"Cron like \"0 9 * * *\" or @daily / @every 6h","tgLanguage":"Bot language","tgEvents":"Enabled events","tgExpiryDays":"Expiry warn (days)","tgTrafficGB":"Traffic warn (GB left)","tgAttachBackup":"Attach DB backup in report","tgProxy":"Proxy URL","tgApiServer":"Telegram API server","tgSetCommands":"Set bot commands","tgCommandsSet":"Bot commands registered","restoreBackup":"Restore backup","restoreRestart":"Restart to apply","restoreRestartHint":"Restart the 3m-ui service after restoring the database","security":"安全","save":"儲存","publicUrl":"公網地址"},"share":{"title":"訂閱與分享","subtitle":"訂閱連結與節點 URI","selectUser":"選擇使用者","tabSub":"訂閱連結","tabUri":"節點 URI","noSub":"No subscription URL","noNodes":"No nodes bound. Bind listeners under Users first.","uri":"URI","loadUri":"Load URI","subHint":"Paste into the client. Default (no target) is Mihomo YAML; target=v2ray is classic Base64 URI list.","subHtml":"Info page (HTML)"}} as const; +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":"安全","sectionTransport":"傳輸","sectionTLS":"TLS","sectionReality":"REALITY","sectionSSOption":"Trojan SS option","cipher":"Cipher","password":"Password","passwordOptional":"Password (optional)","uuid":"UUID","privateKey":"Private key","certificate":"Certificate","alpn":"ALPN","sni":"SNI","clientFingerprint":"Client fingerprint","allowInsecure":"Allow insecure","muxOption":"Mux","realityPrivateKey":"Reality private key","realityShortID":"Short ID","realityServerNames":"Server names","realityDest":"Dest","transport":"Transport","path":"Path","host":"Host","serviceName":"Service name","up":"Up","down":"Down","ignoreClientBandwidth":"Ignore client bandwidth","masquerade":"Masquerade","congestionControl":"Congestion control","clone":"複製","saveTemplate":"Save as template","versions":"版本","cloned":"Cloned","templateCreated":"Template created","instantiated":"Listener created from template","batchDone":"Batch operation completed","templates":"模板","templateName":"Template name","createdAt":"Created at","instantiate":"Instantiate","deleteTemplateConfirm":"Delete this template?","templateDeleted":"Template deleted","enableSelected":"啟用所選","disableSelected":"停用所選","newPort":"New port","version":"Version","reason":"Reason","diff":"Diff","rollback":"Rollback","rollbackConfirm":"Rollback to this version?","rollbackDone":"Rollback completed","addUser":"Add user","aeadMethod":"AEAD method","allowInsecureHint":"Allow insecure (debug only)","alterId":"Alter ID","alterIdHint":"VMess alterId, prefer 0","authenticationTimeout":"Auth timeout","bandwidthHint":"Bandwidth limit, e.g. 100 mbps","bbrProfile":"BBR profile","clientAuthCert":"Client cert","clientAuthType":"Client auth type","congestionController":"Congestion controller","createWizard":"Create listener wizard","cwnd":"CWND","decryption":"Decryption","encryption":"Encryption","encryptionHint":"Client-only field for subscription export; not written to inbound YAML.","decryptionHint":"Server-side field written to listener decryption.","echKey":"ECH Key","enablePureDownlink":"Pure downlink","flow":"Flow","flowHint":"e.g. xtls-rprx-vision","grpcHint":"gRPC transport","grpcServiceName":"gRPC service name","grpcUserAgent":"gRPC User-Agent","grpcUserAgentHint":"gRPC client User-Agent string (panel-side metadata; emitted into client-side grpc-opts).","pingInterval":"Ping interval (ms)","pingIntervalHint":"gRPC keepalive ping interval (panel-side metadata; emitted into client-side grpc-opts).","maxConnections":"Max connections","maxConnectionsHint":"gRPC max concurrent connections (panel-side metadata).","minStreams":"Min streams","minStreamsHint":"gRPC min concurrent streams (panel-side metadata).","maxStreams":"Max streams","maxStreamsHint":"gRPC max concurrent streams (panel-side metadata).","handshakeTimeout":"Handshake timeout","jlsDest":"JLS dest","jlsHint":"JLS settings","jlsProxy":"JLS proxy","jlsSni":"JLS SNI","jlsSniHint":"JLS SNI hostname","jlsUpstreamAddr":"JLS upstream","jlsUsers":"JLS users","kcpTunConn":"KCP connections","kcpTunCrypt":"KCP crypt","kcpTunKey":"KCP key","kcpTunMode":"KCP mode","kcpTunNocomp":"KCP no compress","kcpTunRcvwnd":"KCP recv window","kcpTunSndwnd":"KCP send window","maxIdleTime":"Max idle time","maxUdpRelayPacketSize":"Max UDP relay packet","mekyaHint":"Mekya settings","mekyaKcpSection":"Mekya KCP","mekyaMaxSimultaneous":"Max simultaneous","mekyaMaxSimultaneousHint":"Max concurrent connections","mekyaMaxWriteDuration":"Max write duration","mekyaMaxWriteDurationHint":"Write timeout","mekyaMaxWriteSize":"Max write size","mekyaMaxWriteSizeHint":"Single write limit","mekyaPacketBuffer":"Packet buffer","mekyaPacketBufferHint":"Packet buffer size","mekyaPollingInterval":"Polling interval (ms)","mekyaH2PoolSize":"H2 pool size","mkcpCongestion":"mKCP congestion","mkcpDownlink":"mKCP downlink","mkcpHeader":"mKCP header","mkcpHint":"mKCP transport","mkcpReadBuffer":"mKCP read buffer","mkcpSeed":"mKCP seed","mkcpUplink":"mKCP uplink","mkcpWriteBuffer":"mKCP write buffer","muxBrutal":"Mux brutal","muxBrutalDown":"Brutal down","muxBrutalUp":"Brutal up","muxMaxConnections":"Mux max connections","muxMaxStreams":"Mux max streams","muxMinStreams":"Mux min streams","muxOnlyTcp":"Mux TCP only","muxPadding":"Mux padding","muxProtocol":"Mux protocol","muxStatistic":"Mux statistics","network":"Network","obfs":"Obfuscation","obfsMaxPacketSize":"Obfs max packet","obfsMinPacketSize":"Obfs min packet","obfsOptsHost":"Obfs host","obfsOptsMode":"Obfs mode","obfsPassword":"Obfs password","paddingMax":"Padding max","paddingMin":"Padding min","paddingScheme":"Padding scheme","psk":"PSK","quicVersions":"QUIC versions","rateLimit":"Rate limit","rateLimitHint":"e.g. 10m","realityExclusiveHint":"Reality and TLS cert are mutually exclusive","realityShortId":"Reality short ID","realmId":"Realm ID","realmProxy":"Realm proxy","realmServerUrl":"Realm server URL","realmSkipCert":"Skip cert verify","realmStun":"STUN","realmToken":"Realm token","resTlsDest":"ResTLS dest","resTlsHint":"ResTLS settings","resTlsMinRecordLen":"Min record length","resTlsPassword":"ResTLS password","resTlsProxy":"ResTLS proxy","resTlsScript":"ResTLS script","sectionJLS":"JLS","sectionJlsUpstream":"JLS upstream","sectionKcpTun":"KCP Tun","sectionMekya":"Mekya","sectionMkcp":"mKCP","sectionMux":"Mux","sectionRealm":"Realm","sectionResTLS":"Reality TLS","sectionShadowTLS":"ShadowTLS","sectionSimpleObfs":"simple-obfs","sectionXHTTP":"XHTTP","shadowTlsHandshakeDest":"Handshake dest","shadowTlsHandshakeProxy":"Handshake proxy","shadowTlsHint":"ShadowTLS settings","shadowTlsPassword":"ShadowTLS password","shadowTlsPasswordHint":"v2/v3 password","shadowTlsUsers":"ShadowTLS users","shadowTlsVersion":"ShadowTLS version","simpleObfsMode":"Simple Obfs mode","snellVersion":"Snell version","ssOptionMethod":"SS method","ssOptionPassword":"SS password","sudokuKey":"Sudoku key","customTable":"Custom table","customTables":"Custom tables","sudokuFallback":"Fallback","sectionHttpmask":"HTTP mask","httpmaskDisable":"Disable HTTP mask","httpmaskMode":"HTTP mask mode","httpmaskPathRoot":"HTTP path root","tableType":"Table type","tlsPairHint":"Certificate and private key must both be set","token":"Token","tokenHint":"Auth token","tokenPlaceholder":"Enter token","trafficPattern":"Traffic pattern","userHintMandatory":"At least one user required","wizardBasic":"Basics","wizardConfig":"Protocol","wizardConfigHint":"Fill protocol-specific fields (TLS / Reality / users, etc.)","wizardReview":"Review","wsPath":"WebSocket path","wsPathHint":"e.g. /ws","xhttpHint":"XHTTP transport","xhttpHost":"XHTTP host","xhttpMode":"XHTTP mode","xhttpPath":"XHTTP path","clientExportOnlyHint":"Client subscription only — not written to Mihomo inbound YAML.","transportExclusiveHint":"One transport only. mKCP/Mekya (VMess) require TCP.","mkcpExclusiveHint":"Requires TCP; exclusive with WS/gRPC/Mekya. Other transports cleared on save.","mekyaExclusiveHint":"Requires TCP; exclusive with WS/gRPC/mKCP. Other transports cleared on save.","zeroRtt":"0-RTT","obfsOptsHint":"Mode and host must be set together; leave both empty to disable","obfsOptsHostRequired":"Host is required when obfs is enabled","shadowquicDisableMtuDiscovery":"Disable MTU discovery","shadowquicMaxDatagramFrameSize":"Max datagram frame size","shadowquicRecvWindow":"Connection receive window","shadowquicRecvWindowConn":"Stream receive window","tcpUdp":"TCP / UDP","ipPort":"IP:埠","socketStatus":"監聽狀態","delete":"刪除","reload":"重載"},"core":{"title":"核心管理","subtitle":"Mihomo 進程控制","status":"狀態","running":"運行中","stoppedStatus":"Stopped","version":"版本","pid":"PID","uptime":"運行時間","start":"啟動","stop":"停止","restart":"重啟","started":"Started","stopped":"已停止","restarted":"Restarted","operationFailed":"操作失敗","unavailable":"無法取得狀態","updates":{"title":"Mihomo 核心更新","description":"選擇官方穩定版或 Pre 測試版。下載和設定檢查完成後才切換正在執行的核心;切換失敗會恢復舊核心。更新僅由你手動觸發。","source":"核心來源","managed":"已選版本(持續保存)","bundled":"面板隨附版本","previous":"可回復版本","noPrevious":"暫無舊版本","unavailable":"核心更新無法使用","check":"檢查更新","select":"選擇版本","latest":"最新","current":"目前","install":"安裝所選版本","rollback":"恢復舊版本","releaseNotes":"版本說明","noVersions":"目前平台暫無可驗證的官方穩定版本。","running":"正在更新","failed":"更新失敗","succeeded":"更新完成","restored":"已恢復舊核心及其原先的執行狀態。","confirm":"將 Mihomo 切換至","interruption":"切換時代理連線會短暫中斷,設定與帳號會保留。","staysStopped":"安裝完成後核心仍保持停止,設定與帳號會保留。","steps":{"prepare":"準備","download":"下載並校驗","validate":"檢查設定","activate":"切換並驗證"},"stage":{"interrupted":"面板重啟,更新中斷","preparing":"準備更新","resolving":"查詢官方版本","downloading":"下載核心,目前核心繼續執行","verifying":"校驗 SHA-256","validating":"檢查核心及現有設定","switching":"切換核心","starting":"啟動所選核心","checking":"檢查進程及監聽連接埠","recovering":"恢復舊核心","complete":"已完成"},"stable":"穩定版","pre":"Pre 測試版","preNotice":"Pre 是滾動更新的測試版本,可能存在相容性問題。安裝會核對所選建置編號;若官方版本已更新,請重新檢查更新。"}},"logs":{"title":"運行日誌","subtitle":"面板與核心日誌","empty":"暫無日誌","level":"Level","timestamp":"Time","payload":"Payload","autoRefresh":"Auto refresh","clear":"Clear","refresh":"重新整理"},"config":{"title":"配置引擎","subtitle":"預覽與驗證 Mihomo 配置","visual":"Visual Config","yaml":"YAML Config","proxies":"Proxy Configuration","yamlPreview":"YAML Preview","addProxy":"Add Proxy","editProxy":"Edit Proxy","deleteProxy":"Delete Proxy","deleteConfirm":"Delete this proxy?","generateSuccess":"Generated (not applied yet)","validateSuccess":"Configuration is valid","validateFailed":"Configuration validation failed","proxyName":"Proxy Name","proxyType":"Proxy Type","proxyServer":"Proxy Server","proxyPort":"Proxy Port","proxyPassword":"Proxy Password","proxyUUID":"Proxy UUID","generate":"Generate config","validate":"驗證","validateOk":"Configuration is valid","validateFail":"Configuration validation failed","apply":"套用","applySuccess":"Configuration applied","rollback":"Rollback","rollbackSuccess":"Rolled back to previous config","preview":"預覽","refresh":"重新整理"},"users":{"title":"使用者管理","subtitle":"代理使用者與訂閱","batch":"Batch","create":"建立使用者","edit":"編輯","username":"Username","password":"Password","enabled":"Enabled","deleteConfirm":"Delete this user?","created":"Created","updated":"Updated","deleted":"Deleted","bind":"Bind","bindNodes":"綁定節點","bindHint":"Select inbound nodes for this user. Credentials are injected into listeners when generating Mihomo config.","selectNodes":"Select nodes","bindSuccess":"Node bindings updated","traffic":"流量","trafficLimitGB":"Traffic limit (GB)","trafficLimitHint":"0 or empty = unlimited","expire":"到期","expireHint":"Leave empty for no expiry","neverExpire":"Never","online":"Online","offline":"Offline","blocked":"Blocked","unlimited":"Unlimited","resetTraffic":"Reset traffic","resetTrafficConfirm":"Reset traffic counters for this user?","trafficReset":"Traffic counters reset","passwordKeep":"Leave blank to keep current password","hwidLimit":"HWID device limit","hwidLimitHint":"0 = unlimited. Happ clients send x-hwid when importing the subscription.","ipLimit":"IP limit","ipLimitHint":"0 = unlimited concurrent client IPs","remark":"Remark","shareTitle":"Subscription","rotateSub":"Rotate subscription token","subRotated":"Subscription rotated","deleteDepleted":"Delete depleted","batchEnable":"Enable","batchDisable":"Disable","batchResetTraffic":"Reset traffic","batchDelete":"Delete selected","batchDeleteConfirm":"Delete selected users?","batchNeedSelect":"Select users first","batchDone":"Batch done","deleteDepletedConfirm":"Delete all expired / over-quota users?","depletedDeleted":"Deleted depleted users","subMihomo":"Mihomo 訂閱","subV2ray":"V2Ray / Base64 (v2rayNG / Hiddify)","subSingbox":"Sing-box subscription","qrHint":"QR = default (Mihomo). Paste the V2Ray URL into classic clients.","bindRemoteHint":"Remote cluster nodes (sync mirrors on the Cluster page first)","selectRemoteNodes":"Select remote nodes","disabled":"Disabled","batchExtend":"Extend days","batchAddTraffic":"Add traffic","batchExtendPrompt":"Extend by how many days?","batchAddTrafficPrompt":"Add how many GiB to the limit?","delete":"刪除","enable":"啟用","disable":"停用","subLink":"訂閱連結"},"traffic":{"title":"流量監控","subtitle":"用量與配額","upload":"Upload","download":"Download","uploadRate":"Upload rate","downloadRate":"Download rate","connections":"Connections","onlineUsers":"Online users","byUser":"Per-user traffic","listener":"Listener","network":"Network","rule":"Rule","total":"總計","used":"已用","remaining":"剩餘","refresh":"重新整理"},"cluster":{"title":"多機節點","subtitle":"遠端面闆與合併訂閱","create":"登記遠端","edit":"編輯","name":"名稱","baseUrl":"面板地址","apiToken":"API Token","apiTokenHint":"Optional; used to authenticate against the remote panel","remark":"Remark","health":"健康檢查","healthDone":"Checked","healthAll":"Check all","healthAllDone":"Health check finished","dashboard":"Remote dashboard","users":"Remote users","restartCore":"重啟核心","restarted":"Remote core restarted","startCore":"啟動核心","stopCore":"停止核心","created":"Created","updated":"Updated","deleted":"Deleted","nodesLoaded":"Nodes loaded","remoteCreate":"Remote create","remoteCreated":"Remote listener created","remoteDeleted":"Remote listener deleted","remoteNodes":"遠端節點","syncNodes":"同步節點","syncDone":"Synced","pushNode":"Push node","pushNodePrompt":"Local node ID to push","pushNodeConfirm":"Push this node to remote? (created disabled)","pushNodeDone":"Pushed (disabled on remote)","colId":"ID","colName":"名稱","colProtocol":"協定","colPort":"埠","operationOk":"完成"},"routing":{"title":"路由規則","subtitle":"分流與規則集","groups":"Proxy groups","rules":"Rules","addGroup":"Add group","proxies":"Proxies","proxiesHint":"Comma-separated proxy / group names","rulesHint":"One Mihomo rule per line","rulesSaved":"Rules saved","groupSaved":"Group saved","groupDeleted":"Group deleted","save":"儲存","refresh":"重新整理"},"settings":{"navPanel":"Panel / UI","navAccess":"Access profile","navTelegram":"Telegram","navSecurity":"Security & backup","navSubscription":"Subscription page","navSSL":"Certificates / SSL","navNetwork":"Proxy & Geo","navTraffic":"Traffic reset","navAbout":"About","panelServer":"Panel / NAT","panelPort":"Panel port","panelPortHint":"Map this port on your NAT gateway. Restart 3m-ui after changing.","panelListen":"Listen address","panelListenHint":"Empty = all interfaces (IPv4/IPv6). Use 127.0.0.1 behind a reverse proxy.","panelPublicURL":"Public panel URL","panelPublicURLHint":"Used in subscription links behind NAT, e.g. https://panel.example.com:8443","title":"系統設定","subtitle":"面板與通知選項","language":"語言","theme":"主題","light":"Light","dark":"Dark","system":"System","passwordTitle":"Account Security","passwordDescription":"Change your password regularly","changePassword":"Change Password","about":"About 3M-UI","version":"Version","backup":"Backup & restore","backupHint":"Download a zip with SQLite and Mihomo config. Restart the panel after restoring the database.","downloadBackup":"Download backup","restoreDb":"Restore database","backupDone":"Backup downloaded","restoreDone":"Database restored — restart the panel","apiDocs":"API documentation","apiDocsHint":"OpenAPI 3 specification for automation.","openOpenAPI":"Open openapi.yaml","alpnHint":"Comma-separated, e.g. h2,http/1.1","accessProfile":"Access Profile","accessProfileHint":"Public host / SNI / fingerprint for share links and client YAML","publicHost":"Public Host","publicPort":"Public Port","clientFingerprint":"Client Fingerprint","telegram":"Telegram alerts","botToken":"Bot Token","chatIds":"Chat ID 列表","telegramSaved":"Telegram settings saved","telegramTest":"Send test message","telegramTestOk":"Test message sent","chatIdsHint":"Comma-separated chat IDs","notifyLogin":"Notify on panel login","notifyBlock":"Notify on user block","notifyUnblock":"Notify on user unblock","notifyExpiry":"Notify on user expiry","notifyDailyDigest":"Send a daily traffic and user summary","templates":"TLS & reverse-proxy templates","proxyKind":"Proxy type","domain":"Domain","upstream":"Upstream","generateTemplate":"Generate template","templateGenerated":"Template generated","trafficReset":"Traffic reset","trafficResetDay":"Monthly reset day","certWizard":"Certificate wizard","email":"Email","webroot":"Webroot path","generateAcme":"Generate certbot command","acmeGenerated":"Command generated","acmeHint":"Run the command on the server, then put cert paths into Listener TLS settings","notifyTraffic":"Traffic threshold warning","trafficWarnPct":"Traffic warn %","expiryWarnHours":"Expiry warn (hours)","panelSSL":"Panel SSL (ACME)","panelSSLHint":"Enable HTTPS for the panel via Let's Encrypt (autocert) or a manual certificate. Restart the panel after saving.","sslSaved":"SSL settings saved — restart panel to apply","acmeCacheDir":"ACME cache dir","manualCert":"Manual cert file (optional)","manualKey":"Manual key file (optional)","subPage":"Subscription page template","subPageHint":"Custom HTML template directory (index.html or sub.html). Leave empty for the built-in page. Browsers hitting the subscription URL with Accept: text/html will see this page.","subscriptionFormatHint":"Subscription auto-format","subscriptionFormatHintDesc":"Format follows User-Agent (Clash/Mihomo YAML, v2ray Base64, sing-box JSON). Override with ?target=clash|v2ray|singbox|html. Traffic/expiry via Subscription-Userinfo headers.","subThemeDir":"Theme directory","subTitle":"Page title","subSupportUrl":"Support URL","downloadDefaultTpl":"Download default template","geofiles":"Geo 檔案","geofilesHint":"Download latest MetaCubeX GeoIP/GeoSite databases into the Mihomo data directory .","updateGeofiles":"Update geo files","geofilesDone":"Geo files updated","subAnnounce":"Subscription announce","subWebPage":"Profile web page URL","subUpdates":"Update interval (hours)","subEncrypt":"Base64-encode URI list","notifyCPU":"Notify on high CPU","cpuWarnPct":"CPU warn threshold (%)","tgSchedule":"Report schedule","tgScheduleHint":"Cron like \"0 9 * * *\" or @daily / @every 6h","tgLanguage":"Bot language","tgEvents":"Enabled events","tgExpiryDays":"Expiry warn (days)","tgTrafficGB":"Traffic warn (GB left)","tgAttachBackup":"Attach DB backup in report","tgProxy":"Proxy URL","tgApiServer":"Telegram API server","tgSetCommands":"Set bot commands","tgCommandsSet":"Bot commands registered","restoreBackup":"Restore backup","restoreRestart":"Restart to apply","restoreRestartHint":"Restart the 3m-ui service after restoring the database","security":"安全","save":"儲存","publicUrl":"公網地址"},"share":{"title":"訂閱與分享","subtitle":"訂閱連結與節點 URI","selectUser":"選擇使用者","tabSub":"訂閱連結","tabUri":"節點 URI","noSub":"No subscription URL","noNodes":"No nodes bound. Bind listeners under Users first.","uri":"URI","loadUri":"Load URI","subHint":"Paste into the client. Default (no target) is Mihomo YAML; target=v2ray is classic Base64 URI list.","subHtml":"Info page (HTML)"}} as const; diff --git a/frontend/src/pages/Core.tsx b/frontend/src/pages/Core.tsx index f1bcf14..12b5456 100644 --- a/frontend/src/pages/Core.tsx +++ b/frontend/src/pages/Core.tsx @@ -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(null); + const [status, setStatus] = useState(null); + const [update, setUpdate] = useState(null); + const [releases, setReleases] = useState([]); + const [selected, setSelected] = useState(); 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 (
+ {error && } {loading ? : ( - - - - - {status?.version || '-'} - {status?.pid || '-'} - {status?.uptime || '-'} - - - - - + + + + + + {status?.version || '-'} + {status?.pid || '-'} + {status?.uptime || '-'} + {t(update?.managed ? 'core.updates.managed' : 'core.updates.bundled')} + {update?.previous_version || t('core.updates.noPrevious')} + + + + + + - - + + + + {t('core.updates.description')} + {!update?.supported && } + + +