package app import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "log" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "sync" "time" ) type Options struct { DataDir string CoreDir string CoreURL string CoreBinary string Helper string SecureCookie bool Web http.Handler } type App struct { store *Store core *Core state State mu sync.RWMutex setupToken string secureCookie bool web http.Handler download *http.Client started time.Time lastError string authMu sync.Mutex failures map[string]loginLimit refreshMu sync.Mutex refreshing map[string]bool updatesMu sync.Mutex updateCache *coreRelease updateTime time.Time connectivitySlots chan struct{} } func randomID() string { b := make([]byte, 12) if _, err := rand.Read(b); err != nil { panic(err) } return hex.EncodeToString(b) } func randomToken() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic(err) } return hex.EncodeToString(b) } func New(options Options) (*App, error) { store, err := OpenStore(options.DataDir) if err != nil { return nil, err } fail := func(err error) (*App, error) { store.Close(); return nil, err } state, err := store.Load() if err != nil { return fail(err) } if options.CoreDir == "" { options.CoreDir = filepath.Join(options.DataDir, "mihomo") } options.CoreDir, err = filepath.Abs(options.CoreDir) if err != nil { return fail(err) } if err = os.MkdirAll(options.CoreDir, 0750); err != nil { return fail(err) } if options.CoreURL == "" { options.CoreURL = "http://127.0.0.1:9090" } u, err := url.Parse(options.CoreURL) if err != nil || u.Scheme != "http" || u.Host != "127.0.0.1:9090" || u.Path != "" || u.User != nil || u.RawQuery != "" { return fail(errors.New("内核控制地址必须是 http://127.0.0.1:9090")) } secretPath := filepath.Join(options.CoreDir, "controller.secret") secret, err := os.ReadFile(secretPath) if os.IsNotExist(err) { secret = []byte(randomToken()) err = atomicWrite(secretPath, secret) } if err != nil { return fail(err) } if options.CoreBinary == "" { options.CoreBinary, _ = exec.LookPath("mihomo") } core := &Core{URL: options.CoreURL, Secret: strings.TrimSpace(string(secret)), Binary: options.CoreBinary, Dir: options.CoreDir, Helper: options.Helper, HTTP: &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{Proxy: nil}}} token, err := store.Get("setup_token") if err != nil { return fail(err) } password, err := store.Get("password") if err != nil { return fail(err) } if password == "" && token == "" { token = randomToken() if err := store.Set("setup_token", token); err != nil { return fail(err) } } if password == "" { log.Printf("首次设置令牌(只在受信任的管理浏览器中使用): %s", token) } app := &App{store: store, core: core, state: state, setupToken: token, secureCookie: options.SecureCookie, web: options.Web, download: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return errors.New("重定向次数过多") } return validHTTPURL(req.URL.String()) }}, started: time.Now(), failures: map[string]loginLimit{}, refreshing: map[string]bool{}} app.connectivitySlots = make(chan struct{}, 3) return app, nil } func (a *App) Close() error { return a.store.Close() } func (a *App) Initialize(ctx context.Context) error { config, err := compileConfig(a.Snapshot(), a.core.Secret) if err != nil { return err } return a.core.Apply(ctx, config) } func (a *App) Snapshot() State { a.mu.RLock(); defer a.mu.RUnlock(); return cloneState(a.state) } func (a *App) change(ctx context.Context, update func(*State) error) error { a.mu.Lock() defer a.mu.Unlock() before := a.state candidate := cloneState(before) if err := update(&candidate); err != nil { return err } if err := validateState(candidate); err != nil { return err } config, err := compileConfig(candidate, a.core.Secret) if err != nil { return err } if err := a.core.Apply(ctx, config); err != nil { a.lastError = err.Error() return err } if err := a.core.Select(ctx, candidate); err != nil { old, _ := compileConfig(before, a.core.Secret) restoreCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if restoreErr := a.core.Apply(restoreCtx, old); restoreErr != nil { a.lastError = "节点切换失败且恢复失败,请重启内核" return errors.New(a.lastError) } _ = a.core.Select(restoreCtx, before) return err } if err := a.store.Save(candidate); err != nil { old, _ := compileConfig(before, a.core.Secret) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if restoreErr := a.core.Apply(ctx, old); restoreErr != nil { a.lastError = "数据库写入失败,内核配置恢复也失败,请重启服务" return errors.New(a.lastError) } _ = a.core.Select(ctx, before) return errors.New("数据库保存失败,已恢复原配置") } a.state = candidate a.lastError = "" return nil } func (a *App) Refresh(ctx context.Context, id string) error { a.refreshMu.Lock() if a.refreshing[id] { a.refreshMu.Unlock() return errors.New("此订阅正在更新") } a.refreshing[id] = true a.refreshMu.Unlock() defer func() { a.refreshMu.Lock(); delete(a.refreshing, id); a.refreshMu.Unlock() }() var sub Subscription found := false for _, s := range a.Snapshot().Subscriptions { if s.ID == id { sub = s found = true break } } if !found { return errors.New("订阅不存在") } nodes, usage, err := loadSubscription(ctx, sub, a.download) if err == nil { err = a.change(ctx, func(s *State) error { for i := range s.Subscriptions { if s.Subscriptions[i].ID == id { if s.Subscriptions[i].URL != sub.URL { return errors.New("订阅地址已改变,请重新更新") } s.Subscriptions[i].Nodes = nodes s.Subscriptions[i].Usage = usage s.Subscriptions[i].UpdatedAt = now() s.Subscriptions[i].AttemptedAt = now() s.Subscriptions[i].LastError = "" return nil } } return errors.New("订阅已被删除") }) } if err != nil { a.mu.Lock() candidate := cloneState(a.state) for i := range candidate.Subscriptions { if candidate.Subscriptions[i].ID == id && candidate.Subscriptions[i].URL == sub.URL { candidate.Subscriptions[i].AttemptedAt = now() candidate.Subscriptions[i].LastError = err.Error() } } if saveErr := a.store.Save(candidate); saveErr == nil { a.state = candidate } a.mu.Unlock() } return err } func (a *App) Run(ctx context.Context) { // Reconcile the persisted configuration on startup, without changing Linux host networking. if a.core.Binary != "" { config, _ := compileConfig(a.Snapshot(), a.core.Secret) if err := a.core.Apply(ctx, config); err != nil { a.mu.Lock() a.lastError = err.Error() a.mu.Unlock() log.Printf("启动配置未应用: %s", err) } _ = a.core.Select(ctx, a.Snapshot()) } ticker := time.NewTicker(time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: for _, sub := range a.Snapshot().Subscriptions { if !sub.Enabled { continue } last, _ := time.Parse(time.RFC3339, sub.AttemptedAt) if time.Since(last) >= time.Duration(sub.IntervalHours)*time.Hour { if err := a.Refresh(ctx, sub.ID); err != nil { log.Printf("订阅 %s 更新失败: %s", sub.ID, err) } } } _, _ = a.store.db.Exec("DELETE FROM sessions WHERE expires < ?", time.Now().Unix()) } } } func (a *App) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/v1/auth/status", a.authStatus) mux.HandleFunc("POST /api/v1/auth/setup", a.setup) mux.HandleFunc("POST /api/v1/auth/login", a.login) mux.Handle("POST /api/v1/auth/logout", a.protect(http.HandlerFunc(a.logout))) mux.Handle("PUT /api/v1/auth/password", a.protect(http.HandlerFunc(a.password))) mux.Handle("/api/v1/", a.protect(a.api())) if a.web != nil { mux.Handle("/", a.web) } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") if strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Cache-Control", "no-store") } if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" { if origin := r.Header.Get("Origin"); origin != "" { u, err := url.Parse(origin) if err != nil || u.Host != r.Host { writeError(w, 403, "不允许跨站请求") return } } if r.Header.Get("Sec-Fetch-Site") == "cross-site" { writeError(w, 403, "不允许跨站请求") return } } mux.ServeHTTP(w, r) }) } func (a *App) String() string { return fmt.Sprintf("Clash Web (Mihomo directory: %s)", a.core.Dir) }