Initialize Clash Web source and deployment documentation
This commit is contained in:
commit
8e0fa00a73
66 files changed
+8532
No files matched your search
@@ -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) }
|
||||
Reference in new issue
Block a user