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

+219
View File
@@ -0,0 +1,219 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/netip"
"net/url"
"regexp"
"strings"
"time"
)
const ProxyGroup = "CW-PROXY"
const AutoGroup = "CW-AUTO"
type Subscription struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Enabled bool `json:"enabled"`
IntervalHours int `json:"intervalHours"`
UpdatedAt string `json:"updatedAt"`
AttemptedAt string `json:"attemptedAt"`
LastError string `json:"lastError"`
Usage string `json:"usage,omitempty"`
Nodes []map[string]any `json:"nodes"`
}
type Rule struct {
ID string `json:"id"`
Type string `json:"type"`
Value string `json:"value"`
Action string `json:"action"`
Enabled bool `json:"enabled"`
}
type Settings struct {
ProxyEnabled bool `json:"proxyEnabled"`
TunEnabled bool `json:"tunEnabled"`
SelectionMode string `json:"selectionMode"`
SelectedNode string `json:"selectedNode"`
LANIP string `json:"lanIP"`
LANCIDR string `json:"lanCIDR"`
Gateway string `json:"gateway"`
Interface string `json:"interface"`
MixedPort int `json:"mixedPort"`
DNSPort int `json:"dnsPort"`
TestURL string `json:"testURL"`
TestTimeoutMS int `json:"testTimeoutMS"`
}
type State struct {
Subscriptions []Subscription `json:"subscriptions"`
Rules []Rule `json:"rules"`
Settings Settings `json:"settings"`
}
type Backup struct {
Version int `json:"version"`
ExportedAt string `json:"exportedAt"`
State State `json:"state"`
}
func defaultState() State {
return State{Subscriptions: []Subscription{}, Rules: []Rule{}, Settings: Settings{
ProxyEnabled: true, SelectionMode: "manual", MixedPort: 7890, DNSPort: 1053,
TestURL: "https://www.gstatic.com/generate_204", TestTimeoutMS: 5000,
}}
}
func cloneState(s State) State {
b, _ := json.Marshal(s)
var copy State
_ = json.Unmarshal(b, &copy)
return copy
}
func validHTTPURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" {
return errors.New("请输入有效的 HTTP 或 HTTPS 地址(不支持 userinfo 或 URL fragment)")
}
return nil
}
func validateSubscription(s Subscription) error {
if strings.TrimSpace(s.Name) == "" || len(s.Name) > 160 {
return errors.New("订阅名称不能为空,且不能超过 160 字节")
}
if s.IntervalHours < 1 || s.IntervalHours > 720 {
return errors.New("更新间隔应为 1–720 小时")
}
return validHTTPURL(s.URL)
}
var interfacePattern = regexp.MustCompile(`^[a-zA-Z0-9_.:-]{1,15}$`)
var domainPattern = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$`)
func validateSettings(s Settings) error {
if s.SelectionMode != "manual" && s.SelectionMode != "auto" {
return errors.New("无效的节点选择模式")
}
if s.MixedPort < 1024 || s.MixedPort > 65535 || s.DNSPort < 1 || s.DNSPort > 65535 || s.MixedPort == s.DNSPort || s.MixedPort == 9090 || s.DNSPort == 9090 || s.MixedPort == 8080 || s.DNSPort == 8080 {
return errors.New("代理端口应为 1024–65535,DNS 端口为 1–65535,且不能与管理端口或控制端口冲突")
}
if s.TestTimeoutMS < 1000 || s.TestTimeoutMS > 15000 {
return errors.New("测速超时应为 1000–15000 毫秒")
}
if err := validHTTPURL(s.TestURL); err != nil {
return err
}
if s.Interface != "" && !interfacePattern.MatchString(s.Interface) {
return errors.New("无效的网卡名称")
}
for label, raw := range map[string]string{"VM 地址": s.LANIP, "上游网关": s.Gateway} {
if raw != "" {
ip, err := netip.ParseAddr(raw)
if err != nil || !ip.Is4() || !ip.IsPrivate() {
return fmt.Errorf("%s应为内网 IPv4 地址", label)
}
}
}
if s.LANCIDR != "" {
prefix, err := netip.ParsePrefix(s.LANCIDR)
if err != nil || !prefix.Addr().Is4() || !prefix.Addr().IsPrivate() || prefix.Bits() < 8 || prefix.Bits() > 30 {
return errors.New("请输入有效的内网 IPv4 网段,例如 192.168.1.0/24")
}
if s.LANIP != "" {
ip, _ := netip.ParseAddr(s.LANIP)
if !prefix.Contains(ip) {
return errors.New("VM 地址不在内网网段内")
}
}
if s.Gateway != "" {
ip, _ := netip.ParseAddr(s.Gateway)
if !prefix.Contains(ip) {
return errors.New("上游网关不在内网网段内")
}
}
}
if s.LANIP != "" && s.LANIP == s.Gateway {
return errors.New("VM 的上游网关必须指向原路由器,不能指向自己")
}
if s.TunEnabled && (s.LANIP == "" || s.LANCIDR == "" || s.Gateway == "" || s.Interface == "" || s.DNSPort != 53) {
return errors.New("透明代理需要填写 VM 地址、网段、上游网关、网卡,并把 DNS 端口设为 53;先完成 Linux 网络部署步骤")
}
return nil
}
func validateRule(r Rule) error {
if r.Action != "DIRECT" && r.Action != "PROXY" {
return errors.New("规则动作应为直连或代理")
}
if r.Type == "IP-CIDR" {
p, err := netip.ParsePrefix(r.Value)
if err != nil || !p.Addr().Is4() {
return errors.New("请输入有效的 IPv4 网段")
}
} else if r.Type == "DOMAIN" || r.Type == "DOMAIN-SUFFIX" {
if len(r.Value) > 253 || !domainPattern.MatchString(r.Value) || strings.Contains(r.Value, "..") {
return errors.New("请输入域名,不要包含协议、端口或路径")
}
} else {
return errors.New("不支持此规则类型")
}
return nil
}
func validateState(s State) error {
if len(s.Subscriptions) > 50 || len(s.Rules) > 1000 {
return errors.New("最多允许 50 个订阅、1000 条规则")
}
if err := validateSettings(s.Settings); err != nil {
return err
}
ids := map[string]bool{}
for _, sub := range s.Subscriptions {
if !validID(sub.ID) || ids[sub.ID] {
return errors.New("订阅 ID 无效或重复")
}
ids[sub.ID] = true
if err := validateSubscription(sub); err != nil {
return err
}
if len(sub.Nodes) > 5000 {
return errors.New("单个订阅节点过多")
}
}
ids = map[string]bool{}
for _, rule := range s.Rules {
if !validID(rule.ID) || ids[rule.ID] {
return errors.New("规则 ID 无效或重复")
}
ids[rule.ID] = true
if err := validateRule(rule); err != nil {
return err
}
}
return nil
}
func validID(s string) bool { return regexp.MustCompile(`^[a-f0-9]{24}$`).MatchString(s) }
func now() string { return time.Now().UTC().Format(time.RFC3339) }
func interfaceExists(name string, ip string) bool {
i, err := net.InterfaceByName(name)
if err != nil {
return false
}
addresses, _ := i.Addrs()
for _, a := range addresses {
if strings.Split(a.String(), "/")[0] == ip {
return true
}
}
return false
}