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,215 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const maxDownload = 8 << 20
|
||||
|
||||
type subscriptionDocument struct {
|
||||
Proxies []map[string]any `yaml:"proxies"`
|
||||
Providers map[string]struct {
|
||||
Type string `yaml:"type"`
|
||||
URL string `yaml:"url"`
|
||||
Payload []map[string]any `yaml:"payload"`
|
||||
Filter string `yaml:"filter"`
|
||||
Exclude string `yaml:"exclude-filter"`
|
||||
Override map[string]any `yaml:"override"`
|
||||
Header map[string][]string `yaml:"header"`
|
||||
} `yaml:"proxy-providers"`
|
||||
}
|
||||
|
||||
func fetchSubscription(ctx context.Context, client *http.Client, raw string, headers map[string][]string) ([]byte, string, error) {
|
||||
if err := validHTTPURL(raw); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", raw, nil)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("无效的订阅地址")
|
||||
}
|
||||
req.Header.Set("User-Agent", "clash.meta/ClashWeb")
|
||||
for k, values := range headers {
|
||||
for _, v := range values {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", subscriptionDownloadError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, "", fmt.Errorf("订阅下载失败(HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, maxDownload+1))
|
||||
if err != nil {
|
||||
return nil, "", errors.New("订阅读取失败")
|
||||
}
|
||||
if len(b) > maxDownload {
|
||||
return nil, "", errors.New("订阅文件超过 8 MiB 限制")
|
||||
}
|
||||
return b, resp.Header.Get("Subscription-Userinfo"), nil
|
||||
}
|
||||
|
||||
func subscriptionDownloadError(err error) error {
|
||||
// Do not return url.Error.String(): it includes the private subscription URL.
|
||||
var authority x509.UnknownAuthorityError
|
||||
var hostname x509.HostnameError
|
||||
var certificate x509.CertificateInvalidError
|
||||
var dns *net.DNSError
|
||||
var network net.Error
|
||||
switch {
|
||||
case errors.As(err, &authority):
|
||||
return errors.New("订阅 TLS 证书不受信任,请检查 VM 的 CA 证书包;若站点使用自签名证书,需修复其信任配置")
|
||||
case errors.As(err, &hostname):
|
||||
return errors.New("订阅 TLS 证书与域名不匹配,请核对订阅地址")
|
||||
case errors.As(err, &certificate):
|
||||
return errors.New("订阅 TLS 证书校验失败,请检查 VM 时间和站点证书有效期")
|
||||
case errors.As(err, &dns):
|
||||
return errors.New("订阅域名解析失败,请检查 VM 的系统 DNS 配置")
|
||||
case errors.Is(err, context.Canceled):
|
||||
return errors.New("订阅下载已取消")
|
||||
case errors.Is(err, context.DeadlineExceeded), errors.As(err, &network) && network.Timeout():
|
||||
return errors.New("订阅下载超时,请检查 VM 到订阅服务器的网络或下载代理")
|
||||
case errors.Is(err, syscall.ECONNREFUSED):
|
||||
return errors.New("订阅连接被拒绝,请检查服务器或下载代理端口")
|
||||
default:
|
||||
return errors.New("订阅连接失败,请检查 VM 的网络、TLS 和下载代理配置")
|
||||
}
|
||||
}
|
||||
|
||||
func parseDocument(data []byte) (subscriptionDocument, error) {
|
||||
var doc subscriptionDocument
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return doc, errors.New("订阅不是有效的 Clash/Mihomo YAML;不支持 Base64 订阅")
|
||||
}
|
||||
if len(doc.Proxies) == 0 && len(doc.Providers) == 0 {
|
||||
return doc, errors.New("订阅中未发现 proxies 或 proxy-providers")
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func loadSubscription(ctx context.Context, sub Subscription, client *http.Client) ([]map[string]any, string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
b, usage, err := fetchSubscription(ctx, client, sub.URL, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
doc, err := parseDocument(b)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
type batch struct {
|
||||
source string
|
||||
nodes []map[string]any
|
||||
}
|
||||
batches := []batch{{"inline", doc.Proxies}}
|
||||
keys := []string{}
|
||||
for key := range doc.Providers {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) > 20 {
|
||||
return nil, "", errors.New("单个订阅最多允许 20 个节点提供者")
|
||||
}
|
||||
for _, key := range keys {
|
||||
provider := doc.Providers[key]
|
||||
nodes := provider.Payload
|
||||
if provider.Type == "http" {
|
||||
data, _, err := fetchSubscription(ctx, client, provider.URL, provider.Header)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("节点提供者下载失败: %w", err)
|
||||
}
|
||||
var p subscriptionDocument
|
||||
if err := yaml.Unmarshal(data, &p); err != nil {
|
||||
return nil, "", errors.New("节点提供者返回了无效 YAML")
|
||||
}
|
||||
nodes = p.Proxies
|
||||
} else if provider.Type != "inline" {
|
||||
return nil, "", errors.New("仅支持 HTTP 和 inline 节点提供者,不读取订阅指定的本地文件")
|
||||
}
|
||||
var filter, exclude *regexp.Regexp
|
||||
if provider.Filter != "" {
|
||||
filter, err = regexp.Compile(provider.Filter)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("节点提供者筛选表达式无效")
|
||||
}
|
||||
}
|
||||
if provider.Exclude != "" {
|
||||
exclude, err = regexp.Compile(provider.Exclude)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("节点提供者排除表达式无效")
|
||||
}
|
||||
}
|
||||
filtered := []map[string]any{}
|
||||
for _, node := range nodes {
|
||||
name, _ := node["name"].(string)
|
||||
if filter != nil && !filter.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
if exclude != nil && exclude.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
for k, v := range provider.Override {
|
||||
switch k {
|
||||
case "additional-prefix", "additional-suffix":
|
||||
default:
|
||||
node[k] = v
|
||||
}
|
||||
}
|
||||
prefix, _ := provider.Override["additional-prefix"].(string)
|
||||
suffix, _ := provider.Override["additional-suffix"].(string)
|
||||
node["name"] = prefix + name + suffix
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
batches = append(batches, batch{key, filtered})
|
||||
}
|
||||
result := []map[string]any{}
|
||||
for _, batch := range batches {
|
||||
renames := map[string]string{}
|
||||
for _, node := range batch.nodes {
|
||||
name, ok := node["name"].(string)
|
||||
kind, _ := node["type"].(string)
|
||||
if !ok || strings.TrimSpace(name) == "" || kind == "" || len(name) > 256 {
|
||||
return nil, "", errors.New("节点缺少有效名称或协议类型")
|
||||
}
|
||||
if _, exists := renames[name]; exists {
|
||||
return nil, "", errors.New("同一个节点提供者中存在同名节点")
|
||||
}
|
||||
renames[name] = sub.ID + "/" + batch.source + "/" + name
|
||||
}
|
||||
for _, node := range batch.nodes {
|
||||
if dialer, ok := node["dialer-proxy"].(string); ok && dialer != "" {
|
||||
if renamed, ok := renames[dialer]; ok {
|
||||
node["dialer-proxy"] = renamed
|
||||
} else {
|
||||
return nil, "", errors.New("节点链式代理引用了无法导入的策略组或其他节点提供者")
|
||||
}
|
||||
}
|
||||
name := node["name"].(string)
|
||||
node["name"] = renames[name]
|
||||
result = append(result, node)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, "", errors.New("订阅中没有可用的节点")
|
||||
}
|
||||
if len(result) > 5000 {
|
||||
return nil, "", errors.New("单个订阅最多允许 5000 个节点")
|
||||
}
|
||||
return result, usage, nil
|
||||
}
|
||||
Reference in new issue
Block a user