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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user