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

+355
View File
@@ -0,0 +1,355 @@
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"gopkg.in/yaml.v3"
)
func newTestApp(t *testing.T) *App {
t.Helper()
a, err := New(Options{DataDir: t.TempDir(), CoreBinary: "/bin/true"})
if err != nil {
t.Fatal(err)
}
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/version" {
jsonResponse(w, 200, map[string]string{"version": "test-core"})
return
}
w.WriteHeader(204)
}))
a.core.URL = mock.URL
t.Cleanup(func() { mock.Close(); a.Close() })
return a
}
func request(t *testing.T, a *App, method, path string, body any, cookie *http.Cookie, csrf string) *httptest.ResponseRecorder {
t.Helper()
var b []byte
if body != nil {
b, _ = json.Marshal(body)
}
r := httptest.NewRequest(method, path, bytes.NewReader(b))
r.Header.Set("Content-Type", "application/json")
if cookie != nil {
r.AddCookie(cookie)
}
if csrf != "" {
r.Header.Set("X-CSRF-Token", csrf)
}
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
return w
}
func loginTest(t *testing.T, a *App) (*http.Cookie, string) {
t.Helper()
w := request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: a.setupToken, Password: "test-password-long"}, nil, "")
if w.Code != 200 {
t.Fatalf("setup %d: %s", w.Code, w.Body)
}
var response struct {
CSRF string `json:"csrf"`
}
json.Unmarshal(w.Body.Bytes(), &response)
return w.Result().Cookies()[0], response.CSRF
}
func TestAuthCSRFAndSessionRevocation(t *testing.T) {
a := newTestApp(t)
if w := request(t, a, "GET", "/api/v1/settings", nil, nil, ""); w.Code != 401 {
t.Fatal(w.Code)
}
if w := request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: "wrong", Password: "test-password-long"}, nil, ""); w.Code != 403 {
t.Fatal(w.Code)
}
cookie, csrf := loginTest(t, a)
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
t.Fatal("session cookie flags missing")
}
if w := request(t, a, "POST", "/api/v1/subscriptions", map[string]any{}, cookie, ""); w.Code != 403 {
t.Fatal("missing CSRF allowed")
}
r := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{"password":"test-password-long"}`))
r.Header.Set("Origin", "https://attacker.example")
w := httptest.NewRecorder()
a.Handler().ServeHTTP(w, r)
if w.Code != 403 {
t.Fatal("cross-site login allowed")
}
w = request(t, a, "PUT", "/api/v1/auth/password", credentials{CurrentPassword: "test-password-long", Password: "new-password-long"}, cookie, csrf)
if w.Code != 200 {
t.Fatal(w.Code, w.Body)
}
if w = request(t, a, "GET", "/api/v1/settings", nil, cookie, ""); w.Code != 401 {
t.Fatal("old session still valid")
}
if w = request(t, a, "POST", "/api/v1/auth/setup", credentials{Token: a.setupToken, Password: "test-password-long"}, nil, ""); w.Code != 409 {
t.Fatal("setup can run twice")
}
}
func TestLoginRateLimit(t *testing.T) {
a := newTestApp(t)
loginTest(t, a)
for i := 0; i < 5; i++ {
if w := request(t, a, "POST", "/api/v1/auth/login", credentials{Password: "wrong"}, nil, ""); w.Code != 401 {
t.Fatal(w.Code)
}
}
if w := request(t, a, "POST", "/api/v1/auth/login", credentials{Password: "test-password-long"}, nil, ""); w.Code != 429 {
t.Fatal("not rate limited", w.Code)
}
}
func TestDecodeRejectsTrailingJSON(t *testing.T) {
for _, payload := range []string{`{} {}`, `{"unknown":1}`, `{broken`, strings.Repeat("x", 17<<20)} {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/", strings.NewReader(payload))
var target credentials
if decode(w, r, &target) == nil {
t.Fatal("invalid payload accepted")
}
}
}
func TestSubscriptionImportNamespaceAndProvider(t *testing.T) {
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer provider-token" {
t.Error("missing provider header")
}
io.WriteString(w, "proxies:\n- name: shared\n type: http\n server: 127.0.0.1\n port: 8088\n")
}))
defer provider.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "proxies:\n- name: shared\n type: http\n server: localhost\n port: 8081\nproxy-providers:\n remote:\n type: http\n url: "+provider.URL+"\n header:\n Authorization: [Bearer provider-token]\nexternal-controller: 0.0.0.0:9999\nrules: [MATCH,DIRECT]\n")
}))
defer server.Close()
sub := Subscription{ID: randomID(), URL: server.URL}
nodes, _, err := loadSubscription(context.Background(), sub, server.Client())
if err != nil {
t.Fatal(err)
}
if len(nodes) != 2 || nodes[0]["name"] == nodes[1]["name"] {
t.Fatal("nodes not namespaced", nodes)
}
for _, node := range nodes {
if !strings.HasPrefix(node["name"].(string), sub.ID+"/") {
t.Fatal("unstable namespace")
}
}
sub.ID = randomID()
other, _, err := loadSubscription(context.Background(), sub, server.Client())
if err != nil || other[0]["name"] == nodes[0]["name"] {
t.Fatal("cross-subscription conflict", err)
}
}
func TestSubscriptionRejectsInvalidSources(t *testing.T) {
for _, body := range []string{"YmFzZTY0", "proxy-providers:\n local:\n type: file\n path: /etc/shadow\n", "proxies:\n- name: a\n type: http\n dialer-proxy: unknown-group\n", "proxies:\n- name: same\n type: http\n- name: same\n type: http\n"} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, body) }))
_, _, err := loadSubscription(context.Background(), Subscription{ID: randomID(), URL: server.URL}, server.Client())
server.Close()
if err == nil {
t.Fatalf("accepted %s", body)
}
}
}
func TestRefreshFailureKeepsNodesAndConfig(t *testing.T) {
a := newTestApp(t)
var fail atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if fail.Load() {
http.Error(w, "unavailable", 503)
return
}
io.WriteString(w, "proxies:\n- name: node-A\n type: http\n server: localhost\n port: 8088\n")
}))
defer server.Close()
id := randomID()
a.state.Subscriptions = []Subscription{{ID: id, Name: "subscription", URL: server.URL, Enabled: true, IntervalHours: 6}}
if err := a.Refresh(context.Background(), id); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(filepath.Join(a.core.Dir, "config.yaml"))
fail.Store(true)
if err := a.Refresh(context.Background(), id); err == nil {
t.Fatal("expected failure")
}
after, _ := os.ReadFile(filepath.Join(a.core.Dir, "config.yaml"))
state := a.Snapshot()
if !bytes.Equal(before, after) || len(state.Subscriptions[0].Nodes) != 1 || state.Subscriptions[0].LastError == "" {
t.Fatal("last good snapshot lost")
}
loaded, err := a.store.Load()
if err != nil || len(loaded.Subscriptions[0].Nodes) != 1 {
t.Fatal("snapshot not persisted", err)
}
}
func TestCoreReloadRollbackAndValidation(t *testing.T) {
var reloads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/version" {
jsonResponse(w, 200, map[string]string{"version": "test"})
return
}
if reloads.Add(1) == 1 {
http.Error(w, "failed", 500)
return
}
w.WriteHeader(204)
}))
defer server.Close()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
os.WriteFile(path, []byte("old"), 0600)
core := Core{Dir: dir, Binary: "/bin/true", URL: server.URL, HTTP: server.Client()}
if err := core.Apply(context.Background(), []byte("new")); err == nil {
t.Fatal("reload failure not surfaced")
}
actual, _ := os.ReadFile(path)
if string(actual) != "old" || reloads.Load() != 2 {
t.Fatal("rollback failed", string(actual), reloads.Load())
}
core.Binary = "/bin/false"
if err := core.Apply(context.Background(), []byte("invalid")); err == nil {
t.Fatal("validation failure not surfaced")
}
actual, _ = os.ReadFile(path)
if string(actual) != "old" {
t.Fatal("bad configuration overwrote old")
}
}
func TestStateNotCommittedWhenCoreFails(t *testing.T) {
a := newTestApp(t)
a.core.Binary = "/bin/false"
before := a.Snapshot()
err := a.change(context.Background(), func(s *State) error { s.Settings.ProxyEnabled = false; return nil })
if err == nil || a.Snapshot().Settings.ProxyEnabled != before.Settings.ProxyEnabled {
t.Fatal("failed change committed")
}
}
func TestControllerAuthFailureDoesNotStageConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
os.WriteFile(path, []byte("previous"), 0600)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unauthorized", 401) }))
defer server.Close()
core := Core{Dir: dir, Binary: "/bin/true", URL: server.URL, HTTP: server.Client()}
if core.Apply(context.Background(), []byte("candidate")) == nil {
t.Fatal("controller auth failure was treated as stopped")
}
data, _ := os.ReadFile(path)
if string(data) != "previous" {
t.Fatal("controller auth error replaced config")
}
}
func TestCompileManualRemovalDoesNotChooseOtherNode(t *testing.T) {
s := defaultState()
s.Settings.SelectedNode = "removed"
s.Subscriptions = []Subscription{{Enabled: true, Nodes: []map[string]any{{"name": "other", "type": "http", "server": "localhost", "port": 8088}}}}
data, err := compileConfig(s, "secret")
if err != nil {
t.Fatal(err)
}
var parsed map[string]any
if err := yaml.Unmarshal(data, &parsed); err != nil {
t.Fatal(err)
}
group := parsed["proxy-groups"].([]any)[0].(map[string]any)
first := group["proxies"].([]any)[0]
if first != "REJECT" {
t.Fatal("manual node silently changed", first)
}
if parsed["external-controller"] != "127.0.0.1:9090" || parsed["profile"].(map[string]any)["store-selected"] != false {
t.Fatal("controller or selection persistence invalid")
}
s.Settings.SelectionMode = "auto"
data, _ = compileConfig(s, "secret")
yaml.Unmarshal(data, &parsed)
if parsed["proxy-groups"].([]any)[0].(map[string]any)["proxies"].([]any)[0] != AutoGroup {
t.Fatal("auto mode not selected")
}
}
func TestSettingsValidation(t *testing.T) {
valid := defaultState().Settings
if err := validateSettings(valid); err != nil {
t.Fatal(err)
}
invalid := []Settings{}
s := valid
s.Gateway = "192.168.1.1"
s.LANIP = s.Gateway
invalid = append(invalid, s)
s = valid
s.TunEnabled = true
invalid = append(invalid, s)
s = valid
s.MixedPort = 9090
invalid = append(invalid, s)
s = valid
s.Interface = "eth0; touch /tmp/x"
invalid = append(invalid, s)
s = valid
s.LANCIDR = "0.0.0.0/0"
invalid = append(invalid, s)
for _, s := range invalid {
if validateSettings(s) == nil {
t.Fatal("invalid settings accepted", s)
}
}
}
func TestStoreRestartRecovery(t *testing.T) {
dir := t.TempDir()
store, err := OpenStore(dir)
if err != nil {
t.Fatal(err)
}
s := defaultState()
s.Settings.SelectedNode = "persisted-node"
s.Subscriptions = []Subscription{{ID: randomID(), Name: "saved", URL: "https://example.com/sub", IntervalHours: 6, Enabled: true, Nodes: []map[string]any{{"name": "persisted-node", "type": "http"}}}}
if err := store.Save(s); err != nil {
t.Fatal(err)
}
store.Close()
store, err = OpenStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
loaded, err := store.Load()
if err != nil || loaded.Settings.SelectedNode != "persisted-node" || len(loaded.Subscriptions) != 1 {
t.Fatal("restart lost state", err)
}
info, _ := os.Stat(filepath.Join(dir, "clashweb.sqlite"))
if info.Mode().Perm() != 0600 {
t.Fatal("database is not private")
}
}
func TestAPINodeListDoesNotExposeCredentials(t *testing.T) {
a := newTestApp(t)
cookie, _ := loginTest(t, a)
a.state.Subscriptions = []Subscription{{ID: randomID(), Name: "private", Enabled: true, Nodes: []map[string]any{{"name": "id/inline/node", "type": "ss", "password": "do-not-expose", "server": "private.example"}}}}
w := request(t, a, "GET", "/api/v1/nodes", nil, cookie, "")
if w.Code != 200 || strings.Contains(w.Body.String(), "do-not-expose") || strings.Contains(w.Body.String(), "private.example") {
t.Fatal("credentials leaked", w.Body)
}
}