Initialize Clash Web source and deployment documentation

This commit is contained in:
lhc committed 2026-10-05 17:32:00 +08:00
commit 8e0fa00a73
66 files changed
+8532

No files matched your search

+316
View File
@@ -0,0 +1,316 @@
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) }