220 lines
6.7 KiB
Go
220 lines
6.7 KiB
Go
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, ©)
|
||
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
|
||
}
|