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

+215
View File
@@ -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
}