1
0
mirror of https://github.com/bestnite/sub2clash.git synced 2026-06-18 03:58:16 +00:00

Compare commits

...

17 Commits

Author SHA1 Message Date
nite 7301c3d57d fix: use direct model import in convert handler 2026-04-26 16:43:13 +10:00
nite 9d23b11751 refactor: preserve template yaml structure 2026-04-25 23:22:36 +10:00
nite 2d863752b1 fix: use udp config 2026-01-03 22:43:31 +08:00
nite 9725a05c35 mod: model 2025-12-08 02:48:01 +08:00
nite 12de56d275 add: tuic protocol 2025-12-04 17:08:21 +08:00
nite f16779b441 fix: update short link 2025-12-02 23:16:10 +08:00
nite 516657f849 refactor(frontend): Extract short link UI into dedicated component 2025-10-20 16:45:42 +11:00
nite 800c5ff7f1 workflow 2025-10-19 04:16:31 +11:00
nite 892fa7ce41 workflow 2025-10-19 03:33:06 +11:00
nite 007093ac48 workflow 2025-10-19 03:28:27 +11:00
nite ed479b7efa workflow: pass version arg to builder 2025-10-19 03:23:43 +11:00
nite dac4760289 workflow 2025-10-19 03:17:08 +11:00
nite 86b74f30e7 Refactor(frontend): Refactor frontend using Lit
Refactor(database): use gorm+sqlite instead of bbolt
Feat: Add delete short link functionality
Fix: Load correct configuration template during meta config conversion
2025-10-19 03:13:10 +11:00
nite 1e8a79c2d2 #69 2025-10-17 18:13:49 +11:00
nite be656cca57 workflow 2025-10-15 17:33:15 +11:00
nite 23a85f573b refactor(template): Enhance template loading security and error messages 2025-10-15 16:40:07 +11:00
nite fce75baed4 docs: modify README 2025-10-15 15:56:23 +11:00
70 changed files with 5467 additions and 1562 deletions
+4 -2
View File
@@ -45,11 +45,13 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
build-args: | build-args: |
"version=${{ github.ref_name }}" "version=${{ github.ref_name }}"
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
file: Containerfile
+13 -3
View File
@@ -13,13 +13,23 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v5
- name: Install UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- name: setup node
uses: actions/setup-node@v6
with:
node-version: "latest"
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5 uses: goreleaser/goreleaser-action@v6
with: with:
distribution: goreleaser distribution: goreleaser
version: latest version: latest
+2 -3
View File
@@ -1,11 +1,10 @@
.idea .idea
.vscode
dist dist
subs subs
logs logs
data data
.env .env
.vscode/settings.json
config.yaml config.yaml
config.yml config.yml
config.json config.json
.codex
+24 -9
View File
@@ -1,27 +1,26 @@
version: 2
project_name: sub2clash project_name: sub2clash
before:
hooks:
- bash ./build-frontend.sh {{ .Version }}
builds: builds:
- env: - env:
- CGO_ENABLED=0 - CGO_ENABLED=0
goos: goos:
- windows
- linux - linux
- windows
- darwin - darwin
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
- arm
- "386"
goarm:
- "6"
- "7"
ldflags: ldflags:
- -s -w -X sub2clash/constant.Version={{ .Version }} - -s -w -X github.com/bestnite/sub2clash/constant.Version={{ .Version }}
flags: flags:
- -trimpath - -trimpath
archives: archives:
- format: tar.gz - formats: ["tar.gz"]
format_overrides: format_overrides:
- format: zip - formats: ["zip"]
goos: windows goos: windows
wrap_in_directory: true wrap_in_directory: true
files: files:
@@ -30,3 +29,19 @@ archives:
- templates - templates
release: release:
draft: true draft: true
# upx:
# - enabled: true
# compress: best
nfpms:
- id: sub2clash
homepage: https://github.com/bestnite/sub2clash
maintainer: Nite <admin@nite07.com>
license: "MIT"
formats:
- apk
- deb
- rpm
- termux.deb
- archlinux
provides:
- sub2clash
+14
View File
@@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "debug",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"args": [],
"preLaunchTask": "build frontend"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build frontend",
"type": "shell",
"command": "npm",
"args": [
"run",
"build"
],
"group": {
"kind": "build",
"isDefault": true
},
"options": {
"cwd": "${workspaceFolder}/server/frontend"
}
}
]
}
-74
View File
@@ -1,74 +0,0 @@
# `GET /convert/:config`
获取 Clash/Clash.Meta 配置链接
| Path 参数 | 类型 | 说明 |
| --------- | ------ | ---------------------------------------------- |
| config | string | Base64 URL Safe 编码后的 JSON 字符串,格式如下 |
## `config` JSON 结构
| Query 参数 | 类型 | 是否必须 | 默认值 | 说明 |
| ------------------ | ----------------- | ------------------------ | --------- | --------------------------------------------------------------------------------- |
| clashType | int | 是 | 1 | 配置文件类型 (1: Clash, 2: Clash.Meta) |
| subscriptions | []string | sub/proxy 至少有一项存在 | - | 订阅链接,可以在链接结尾加上`#名称`,来给订阅中的节点加上统一前缀(可以输入多个) |
| proxies | []string | sub/proxy 至少有一项存在 | - | 节点分享链接(可以输入多个) |
| refresh | bool | 否 | `false` | 强制刷新配置(默认缓存 5 分钟) |
| template | string | 否 | - | 外部模板链接或内部模板名称 |
| ruleProviders | []RuleProvider | 否 | - | 规则 |
| rules | []Rule | 否 | - | 规则 |
| autoTest | bool | 否 | `false` | 国家策略组是否自动测速 |
| lazy | bool | 否 | `false` | 自动测速是否启用 lazy |
| sort | string | 否 | `nameasc` | 国家策略组排序策略,可选值 `nameasc``namedesc``sizeasc``sizedesc` |
| replace | map[string]string | 否 | - | 通过正则表达式重命名节点 |
| remove | string | 否 | - | 通过正则表达式删除节点 |
| nodeList | bool | 否 | `false` | 只输出节点 |
| ignoreCountryGroup | bool | 否 | `false` | 是否忽略国家分组 |
| userAgent | string | 否 | - | 订阅 user-agent |
| useUDP | bool | 否 | `false` | 是否使用 UDP |
### `RuleProvider` 结构
| 字段 | 类型 | 说明 |
| -------- | ------ | ---------------------------------------------------------------- |
| behavior | string | rule-set 的 behavior |
| url | string | rule-set 的 url |
| group | string | 该规则集使用的策略组名 |
| prepend | bool | 如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部 |
| name | string | 该 rule-provider 的名称,不能重复 |
### `Rule` 结构
| 字段 | 类型 | 说明 |
| ------- | ------ | ---------------------------------------------------------------- |
| rule | string | 规则 |
| prepend | bool | 如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部 |
# `POST /short`
获取短链,Content-Type 为 `application/json`
具体参考使用可以参考 [api\templates\index.html](api/static/index.html)
| Body 参数 | 类型 | 是否必须 | 默认值 | 说明 |
| --------- | ------ | -------- | ------ | ------------------------- |
| url | string | 是 | - | 需要转换的 Query 参数部分 |
| password | string | 否 | - | 短链密码 |
# `GET /s/:hash`
短链跳转
`hash` 为动态路由参数,可以通过 `/short` 接口获取
| Query 参数 | 类型 | 是否必须 | 默认值 | 说明 |
| ---------- | ------ | -------- | ------ | -------- |
| password | string | 否 | - | 短链密码 |
# `PUT /short`
更新短链,Content-Type 为 `application/json`
| Body 参数 | 类型 | 是否必须 | 默认值 | 说明 |
| --------- | ------ | -------- | ------ | ------------------------- |
| url | string | 是 | - | 需要转换的 Query 参数部分 |
| password | string | 否 | - | 短链密码 |
| hash | string | 是 | - | 短链 hash |
+21
View File
@@ -0,0 +1,21 @@
FROM node:latest AS frontend_builder
WORKDIR /app/server/frontend
COPY server/frontend/package*.json ./
RUN npm install
COPY server/frontend .
ARG version
ENV VITE_APP_VERSION=${version}
RUN npm run build
FROM golang:1.25 AS builder
WORKDIR /app
COPY . .
COPY --from=frontend_builder /app/server/frontend/dist /app/server/frontend/dist
RUN go mod download
ARG version
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X github.com/bestnite/sub2clash/constant.Version=${version}" -o sub2clash .
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/sub2clash /app/sub2clash
ENTRYPOINT ["/app/sub2clash"]
-12
View File
@@ -1,12 +0,0 @@
FROM golang:1.21-alpine as builder
LABEL authors="nite07"
WORKDIR /app
COPY . .
RUN go mod download
ARG version
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X github.com/bestnite/sub2clash/constant.Version=${version}" -o sub2clash .
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/sub2clash /app/sub2clash
ENTRYPOINT ["/app/sub2clash"]
+45 -1
View File
@@ -59,7 +59,51 @@
### API ### API
[API 文档](./API.md) #### `GET /convert/:config`
获取 Clash/Clash.Meta 配置链接
| Path 参数 | 类型 | 说明 |
| --------- | ------ | ---------------------------------------------- |
| config | string | Base64 URL Safe 编码后的 JSON 字符串,格式如下 |
##### `config` JSON 结构
| Query 参数 | 类型 | 是否必须 | 默认值 | 说明 |
| ------------------ | ----------------- | ------------------------ | --------- | -------------------------------------------------------------------------------------------------------- |
| clashType | int | 是 | 1 | 配置文件类型 (1: Clash, 2: Clash.Meta) |
| subscriptions | []string | sub/proxy 至少有一项存在 | - | 订阅链接(v2ray 或 clash 格式),可以在链接结尾加上`#名称`,来给订阅中的节点加上统一前缀(可以输入多个) |
| proxies | []string | sub/proxy 至少有一项存在 | - | 节点分享链接(可以输入多个) |
| refresh | bool | 否 | `false` | 强制刷新配置(默认缓存 5 分钟) |
| template | string | 否 | - | 外部模板链接或内部模板名称 |
| ruleProviders | []RuleProvider | 否 | - | 规则 |
| rules | []Rule | 否 | - | 规则 |
| autoTest | bool | 否 | `false` | 国家策略组是否自动测速 |
| lazy | bool | 否 | `false` | 自动测速是否启用 lazy |
| sort | string | 否 | `nameasc` | 国家策略组排序策略,可选值 `nameasc``namedesc``sizeasc``sizedesc` |
| replace | map[string]string | 否 | - | 通过正则表达式重命名节点 |
| remove | string | 否 | - | 通过正则表达式删除节点 |
| nodeList | bool | 否 | `false` | 只输出节点 |
| ignoreCountryGroup | bool | 否 | `false` | 是否忽略国家分组 |
| userAgent | string | 否 | - | 订阅 user-agent |
| useUDP | bool | 否 | `false` | 是否使用 UDP |
###### `RuleProvider` 结构
| 字段 | 类型 | 说明 |
| -------- | ------ | ---------------------------------------------------------------- |
| behavior | string | rule-set 的 behavior |
| url | string | rule-set 的 url |
| group | string | 该规则集使用的策略组名 |
| prepend | bool | 如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部 |
| name | string | 该 rule-provider 的名称,不能重复 |
###### `Rule` 结构
| 字段 | 类型 | 说明 |
| ------- | ------ | ---------------------------------------------------------------- |
| rule | string | 规则 |
| prepend | bool | 如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部 |
### 模板 ### 模板
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
export VITE_APP_VERSION=$1
cd server/frontend
npm install
npm run build
+59 -54
View File
@@ -1,72 +1,77 @@
package database package database
import ( import (
"encoding/json" "context"
"errors"
"os"
"path/filepath" "path/filepath"
"time"
"github.com/bestnite/sub2clash/common" "github.com/bestnite/sub2clash/common"
"github.com/bestnite/sub2clash/model" "github.com/bestnite/sub2clash/model"
"github.com/glebarez/sqlite"
"go.etcd.io/bbolt" "gorm.io/gorm"
"gorm.io/gorm/logger"
) )
var DB *bbolt.DB type Database struct {
db *gorm.DB
func ConnectDB() error {
path := filepath.Join("data", "sub2clash.db")
db, err := bbolt.Open(path, 0600, nil)
if err != nil {
return common.NewDatabaseConnectError(err)
}
DB = db
return db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte("ShortLinks"))
if err != nil {
return common.NewDatabaseConnectError(err)
}
return nil
})
} }
func FindShortLinkByHash(hash string) (*model.ShortLink, error) { func ConnectDB() (*Database, error) {
var shortLink model.ShortLink path := filepath.Join("data", "sub2clash.db")
err := DB.View(func(tx *bbolt.Tx) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
b := tx.Bucket([]byte("ShortLinks"))
v := b.Get([]byte(hash))
if v == nil {
return common.NewRecordNotFoundError("ShortLink", hash)
}
return json.Unmarshal(v, &shortLink)
})
if err != nil {
return nil, err return nil, err
} }
return &shortLink, nil db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
} Logger: logger.Discard,
func SaveShortLink(shortLink *model.ShortLink) error {
return DB.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("ShortLinks"))
encoded, err := json.Marshal(shortLink)
if err != nil {
return err
}
return b.Put([]byte(shortLink.Hash), encoded)
})
}
func CheckShortLinkHashExists(hash string) (bool, error) {
exists := false
err := DB.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("ShortLinks"))
v := b.Get([]byte(hash))
exists = v != nil
return nil
}) })
if err != nil { if err != nil {
return nil, common.NewDatabaseConnectError(err)
}
if err = db.AutoMigrate(&model.ShortLink{}); err != nil {
return nil, err
}
return &Database{
db: db,
}, nil
}
func (d *Database) FindShortLinkByID(id string) (model.ShortLink, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return gorm.G[model.ShortLink](d.db).Where("id = ?", id).First(ctx)
}
func (d *Database) CreateShortLink(shortLink *model.ShortLink) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return gorm.G[model.ShortLink](d.db).Create(ctx, shortLink)
}
func (d *Database) UpdataShortLink(id string, name string, value any) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := gorm.G[model.ShortLink](d.db).Where("id = ?", id).Update(ctx, name, value)
return err
}
func (d *Database) CheckShortLinkIDExists(id string) (bool, error) {
_, err := d.FindShortLinkByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err return false, err
} }
return exists, nil return true, nil
}
func (d *Database) DeleteShortLink(id string) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := gorm.G[model.ShortLink](d.db).Where("id = ?", id).Delete(ctx)
return err
} }
+4 -4
View File
@@ -133,8 +133,8 @@ func NewTemplateLoadError(template string, cause error) *CommonError {
return NewError(ErrTemplateLoad, fmt.Sprintf("failed to load template: %s", template), cause) return NewError(ErrTemplateLoad, fmt.Sprintf("failed to load template: %s", template), cause)
} }
func NewTemplateParseError(cause error) *CommonError { func NewTemplateParseError(data []byte, cause error) *CommonError {
return NewError(ErrTemplateParse, "failed to parse template", cause) return NewError(ErrTemplateParse, fmt.Sprintf("failed to parse template: %s", data), cause)
} }
// Subscription errors // Subscription errors
@@ -142,8 +142,8 @@ func NewSubscriptionLoadError(url string, cause error) *CommonError {
return NewError(ErrSubscriptionLoad, fmt.Sprintf("failed to load subscription: %s", url), cause) return NewError(ErrSubscriptionLoad, fmt.Sprintf("failed to load subscription: %s", url), cause)
} }
func NewSubscriptionParseError(cause error) *CommonError { func NewSubscriptionParseError(data []byte, cause error) *CommonError {
return NewError(ErrSubscriptionParse, "failed to parse subscription", cause) return NewError(ErrSubscriptionParse, fmt.Sprintf("failed to parse subscription: %s", string(data)), cause)
} }
// Regex errors // Regex errors
+93
View File
@@ -0,0 +1,93 @@
package common
import (
P "github.com/bestnite/sub2clash/model/proxy"
"golang.org/x/text/collate"
"golang.org/x/text/language"
)
// proxyListDoc 只用于解析 YAML 订阅中的 proxies 字段。
// 方案 A/B 下我们不再关心订阅 YAML 里的其他 mihomo 配置项。
type proxyListDoc struct {
Proxy []P.Proxy `yaml:"proxies,omitempty"`
}
// generatedConfig 是运行期的最小叠加模型:
// 只保留本项目真正会读取、生成或修改的字段。
//
// 这里承载的是“本项目的业务叠加层”,而不是 mihomo 的完整配置模型:
// - Proxy: 解析出的节点,用于过滤、去重、分组等中间处理
// - ProxyGroup: 模板中需要参与占位符展开的组,以及本项目生成的国家组
// - Rule: 模板规则 + 用户追加规则,用于保持 MATCH 规则前插入的语义
type generatedConfig struct {
Proxy []P.Proxy `yaml:"proxies,omitempty"`
ProxyGroup []generatedGroup `yaml:"proxy-groups,omitempty"`
Rule []string `yaml:"rules,omitempty"`
}
// generatedGroup 表示本项目生成出来的代理组最小模型,
// 它不再镜像 mihomo 的完整 proxy-group 配置结构。
//
// 这里只保留“当前逻辑真正需要读写的字段”:
// - Name / Proxies:用于模板占位符展开与 patch
// - Type / Url / Interval / Tolerance / Lazy:用于输出自动测速国家组
// - Size / IsCountry:仅作为运行期辅助信息,不参与 YAML 输出
type generatedGroup struct {
Type string `yaml:"type,omitempty"`
Name string `yaml:"name,omitempty"`
Proxies []string `yaml:"proxies,omitempty"`
Url string `yaml:"url,omitempty"`
Interval int `yaml:"interval,omitempty"`
Tolerance int `yaml:"tolerance,omitempty"`
Lazy bool `yaml:"lazy"`
Size int `yaml:"-"`
IsCountry bool `yaml:"-"`
}
// generatedRulePatch 表示本项目追加/覆盖的 rule-provider 最小模型。
// 它仅用于把用户请求转换成对 templateDoc 的字段级 patch。
type generatedRulePatch struct {
Type string `yaml:"type,omitempty"`
Behavior string `yaml:"behavior,omitempty"`
Url string `yaml:"url,omitempty"`
Path string `yaml:"path,omitempty"`
Interval int `yaml:"interval,omitempty"`
Format string `yaml:"format,omitempty"`
}
type generatedGroupsSortByName []generatedGroup
type generatedGroupsSortBySize []generatedGroup
func (p generatedGroupsSortByName) Len() int {
return len(p)
}
func (p generatedGroupsSortBySize) Len() int {
return len(p)
}
func (p generatedGroupsSortByName) Less(i, j int) bool {
tags := []language.Tag{
language.English,
language.Chinese,
}
matcher := language.NewMatcher(tags)
bestMatch, _, _ := matcher.Match(language.Make("zh"))
c := collate.New(bestMatch)
return c.CompareString(p[i].Name, p[j].Name) < 0
}
func (p generatedGroupsSortBySize) Less(i, j int) bool {
if p[i].Size == p[j].Size {
return p[i].Name < p[j].Name
}
return p[i].Size < p[j].Size
}
func (p generatedGroupsSortByName) Swap(i, j int) {
p[i], p[j] = p[j], p[i]
}
func (p generatedGroupsSortBySize) Swap(i, j int) {
p[i], p[j] = p[j], p[i]
}
+18 -18
View File
@@ -47,7 +47,7 @@ func GetContryName(countryKey string) string {
} }
func AddProxy( func AddProxy(
sub *model.Subscription, autotest bool, sub *generatedConfig, autotest bool,
lazy bool, clashType model.ClashType, proxies ...proxy.Proxy, lazy bool, clashType model.ClashType, proxies ...proxy.Proxy,
) { ) {
proxyTypes := model.GetSupportProxyTypes(clashType) proxyTypes := model.GetSupportProxyTypes(clashType)
@@ -68,26 +68,26 @@ func AddProxy(
} }
} }
if !haveProxyGroup { if !haveProxyGroup {
var newGroup model.ProxyGroup var newGroup generatedGroup
if !autotest { if !autotest {
newGroup = model.ProxyGroup{ newGroup = generatedGroup{
Name: countryName, Name: countryName,
Type: "select", Type: "select",
Proxies: []string{proxy.Name}, Proxies: []string{proxy.Name},
IsCountryGrop: true, IsCountry: true,
Size: 1, Size: 1,
} }
} else { } else {
newGroup = model.ProxyGroup{ newGroup = generatedGroup{
Name: countryName, Name: countryName,
Type: "url-test", Type: "url-test",
Proxies: []string{proxy.Name}, Proxies: []string{proxy.Name},
IsCountryGrop: true, IsCountry: true,
Url: "http://www.gstatic.com/generate_204", Url: "http://www.gstatic.com/generate_204",
Interval: 300, Interval: 300,
Tolerance: 50, Tolerance: 50,
Lazy: lazy, Lazy: lazy,
Size: 1, Size: 1,
} }
} }
sub.ProxyGroup = append(sub.ProxyGroup, newGroup) sub.ProxyGroup = append(sub.ProxyGroup, newGroup)
+11 -14
View File
@@ -3,17 +3,11 @@ package common
import ( import (
"fmt" "fmt"
"strings" "strings"
"github.com/bestnite/sub2clash/model"
) )
func PrependRuleProvider( func PrependRuleProvider(
sub *model.Subscription, providerName string, group string, provider model.RuleProvider, sub *generatedConfig, providerName string, group string,
) { ) {
if sub.RuleProvider == nil {
sub.RuleProvider = make(map[string]model.RuleProvider)
}
sub.RuleProvider[providerName] = provider
PrependRules( PrependRules(
sub, sub,
fmt.Sprintf("RULE-SET,%s,%s", providerName, group), fmt.Sprintf("RULE-SET,%s,%s", providerName, group),
@@ -21,26 +15,29 @@ func PrependRuleProvider(
} }
func AppenddRuleProvider( func AppenddRuleProvider(
sub *model.Subscription, providerName string, group string, provider model.RuleProvider, sub *generatedConfig, providerName string, group string,
) { ) {
if sub.RuleProvider == nil {
sub.RuleProvider = make(map[string]model.RuleProvider)
}
sub.RuleProvider[providerName] = provider
AppendRules(sub, fmt.Sprintf("RULE-SET,%s,%s", providerName, group)) AppendRules(sub, fmt.Sprintf("RULE-SET,%s,%s", providerName, group))
} }
func PrependRules(sub *model.Subscription, rules ...string) { // PrependRules 用于在规则头部插入新规则。
// 这通常对应用户显式要求 prepend 的场景。
func PrependRules(sub *generatedConfig, rules ...string) {
if sub.Rule == nil { if sub.Rule == nil {
sub.Rule = make([]string, 0) sub.Rule = make([]string, 0)
} }
sub.Rule = append(rules, sub.Rule...) sub.Rule = append(rules, sub.Rule...)
} }
func AppendRules(sub *model.Subscription, rules ...string) { // AppendRules 在规则尾部追加,但如果尾部已有 MATCH,则保持 MATCH 仍然是最后一条。
func AppendRules(sub *generatedConfig, rules ...string) {
if sub.Rule == nil { if sub.Rule == nil {
sub.Rule = make([]string, 0) sub.Rule = make([]string, 0)
} }
if len(sub.Rule) == 0 {
sub.Rule = append(sub.Rule, rules...)
return
}
matchRule := sub.Rule[len(sub.Rule)-1] matchRule := sub.Rule[len(sub.Rule)-1]
if strings.Contains(matchRule, "MATCH") { if strings.Contains(matchRule, "MATCH") {
sub.Rule = append(sub.Rule[:len(sub.Rule)-1], rules...) sub.Rule = append(sub.Rule[:len(sub.Rule)-1], rules...)
+407 -120
View File
@@ -93,11 +93,85 @@ func FetchSubscriptionFromAPI(url string, userAgent string, retryTimes int) ([]b
return data, nil return data, nil
} }
// BuildSub 是当前配置转换链路的核心入口。
//
// 当前设计分为三层:
// 1. templateDoc:模板 YAML 的完整语法树,也是最终输出真源
// 2. generatedConfig:本项目运行期最小叠加层,只保存参与业务计算的字段
// 3. proxy.Proxy:节点解析后的 typed 模型,用于过滤、去重、重命名和输出
//
// 这个函数的目标不是“重建一整份 mihomo 配置”,而是:
// - 保留模板中绝大部分原始字段
// - 只对 proxies / proxy-groups / rules / rule-providers 做定点 patch
func BuildSub(clashType model.ClashType, query model.ConvertConfig, template string, cacheExpire int64, retryTimes int) ( func BuildSub(clashType model.ClashType, query model.ConvertConfig, template string, cacheExpire int64, retryTimes int) (
*model.Subscription, error, *BuiltSub, error,
) { ) {
var temp = &model.Subscription{} templateDoc, templateBytes, err := loadTemplateDocument(query, template, cacheExpire, retryTimes)
var sub = &model.Subscription{} if err != nil {
return nil, err
}
temp, err := extractTemplateOverlay(templateDoc)
if err != nil {
logger.Logger.Debug("extract template overlay failed", zap.Error(err))
return nil, NewTemplateParseError(templateBytes, err)
}
proxyList, err := collectQueryProxies(query, cacheExpire, retryTimes)
if err != nil {
return nil, err
}
proxyList, err = normalizeProxyList(query, proxyList)
if err != nil {
return nil, err
}
// t 仅承载“由节点生成出来的新内容”,例如国家组。
// 模板里原有的组、规则等则保存在 temp 中。
generated, err := buildGeneratedConfig(clashType, query, proxyList)
if err != nil {
return nil, err
}
MergeSubAndTemplate(temp, generated, query.IgnoreCountryGrooup)
applyRulePatches(temp, query)
addedRuleProviders := buildRuleProviderPatches(query)
if err := mergeTemplateProxies(templateDoc, generated.Proxy); err != nil {
return nil, NewError(ErrConfigInvalid, "failed to update template path: proxies", err)
}
if temp.ProxyGroup == nil {
temp.ProxyGroup = make([]generatedGroup, 0)
}
if err := mergeTemplateProxyGroups(templateDoc, temp.ProxyGroup); err != nil {
return nil, NewError(ErrConfigInvalid, "failed to update template path: proxy-groups", err)
}
rulesChanged := len(query.Rules) != 0 || len(query.RuleProviders) != 0
if rulesChanged {
if temp.Rule == nil {
temp.Rule = make([]string, 0)
}
if err := SetYAMLPath(templateDoc, "rules", temp.Rule); err != nil {
return nil, NewError(ErrConfigInvalid, "failed to update template path: rules", err)
}
}
if len(query.RuleProviders) != 0 {
if err := mergeTemplateRuleProviders(templateDoc, addedRuleProviders); err != nil {
return nil, NewError(ErrConfigInvalid, "failed to update template path: rule-providers", err)
}
}
return &BuiltSub{root: templateDoc}, nil
}
// loadTemplateDocument 负责统一加载模板来源,并返回:
// 1. 解析后的 YAML 语法树
// 2. 原始模板字节,用于错误报告
func loadTemplateDocument(query model.ConvertConfig, template string, cacheExpire int64, retryTimes int) (*yaml.Node, []byte, error) {
var err error var err error
var templateBytes []byte var templateBytes []byte
@@ -110,79 +184,38 @@ func BuildSub(clashType model.ClashType, query model.ConvertConfig, template str
logger.Logger.Debug( logger.Logger.Debug(
"load template failed", zap.String("template", template), zap.Error(err), "load template failed", zap.String("template", template), zap.Error(err),
) )
return nil, NewTemplateLoadError(template, err) return nil, nil, NewTemplateLoadError(template, err)
} }
} else { } else {
unescape, err := url.QueryUnescape(template) unescape, err := url.QueryUnescape(template)
if err != nil { if err != nil {
return nil, NewTemplateLoadError(template, err) return nil, nil, NewTemplateLoadError(template, err)
} }
templateBytes, err = LoadTemplate(unescape) templateBytes, err = LoadTemplate(unescape)
if err != nil { if err != nil {
logger.Logger.Debug( logger.Logger.Debug(
"load template failed", zap.String("template", template), zap.Error(err), "load template failed", zap.String("template", template), zap.Error(err),
) )
return nil, NewTemplateLoadError(unescape, err) return nil, nil, NewTemplateLoadError(unescape, err)
} }
} }
err = yaml.Unmarshal(templateBytes, &temp) templateDoc, err := ParseYAMLDocument(templateBytes)
if err != nil { if err != nil {
logger.Logger.Debug("parse template failed", zap.Error(err)) logger.Logger.Debug("parse template yaml node failed", zap.Error(err))
return nil, NewTemplateParseError(err) return nil, templateBytes, NewTemplateParseError(templateBytes, err)
} }
var proxyList []P.Proxy
return templateDoc, templateBytes, nil
}
// collectQueryProxies 汇总来自订阅链接和直接传入代理链接的所有节点。
func collectQueryProxies(query model.ConvertConfig, cacheExpire int64, retryTimes int) ([]P.Proxy, error) {
proxyList := make([]P.Proxy, 0)
for i := range query.Subs { for i := range query.Subs {
data, err := LoadSubscription(query.Subs[i], query.Refresh, query.UserAgent, cacheExpire, retryTimes) newProxies, err := loadSubscriptionProxies(query, query.Subs[i], cacheExpire, retryTimes)
if err != nil { if err != nil {
logger.Logger.Debug( return nil, err
"load subscription failed", zap.String("url", query.Subs[i]), zap.Error(err),
)
return nil, NewSubscriptionLoadError(query.Subs[i], err)
}
subName := ""
if strings.Contains(query.Subs[i], "#") {
subName = query.Subs[i][strings.LastIndex(query.Subs[i], "#")+1:]
}
err = yaml.Unmarshal(data, &sub)
var newProxies []P.Proxy
if err != nil {
reg, err := regexp.Compile("(" + strings.Join(parser.GetAllPrefixes(), "|") + ")://")
if err != nil {
logger.Logger.Debug("compile regex failed", zap.Error(err))
return nil, NewRegexInvalidError("prefix", err)
}
if reg.Match(data) {
p, err := parser.ParseProxies(parser.ParseConfig{UseUDP: query.UseUDP}, strings.Split(string(data), "\n")...)
if err != nil {
return nil, err
}
newProxies = p
} else {
base64, err := utils.DecodeBase64(string(data), false)
if err != nil {
logger.Logger.Debug(
"parse subscription failed", zap.String("url", query.Subs[i]),
zap.String("data", string(data)),
zap.Error(err),
)
return nil, NewSubscriptionParseError(err)
}
p, err := parser.ParseProxies(parser.ParseConfig{UseUDP: query.UseUDP}, strings.Split(base64, "\n")...)
if err != nil {
return nil, err
}
newProxies = p
}
} else {
newProxies = sub.Proxy
}
if subName != "" {
for i := range newProxies {
newProxies[i].SubName = subName
}
} }
proxyList = append(proxyList, newProxies...) proxyList = append(proxyList, newProxies...)
} }
@@ -195,13 +228,103 @@ func BuildSub(clashType model.ClashType, query model.ConvertConfig, template str
proxyList = append(proxyList, p...) proxyList = append(proxyList, p...)
} }
return proxyList, nil
}
// loadSubscriptionProxies 负责加载单条订阅并应用订阅名作为节点前缀。
func loadSubscriptionProxies(query model.ConvertConfig, subscriptionURL string, cacheExpire int64, retryTimes int) ([]P.Proxy, error) {
data, err := LoadSubscription(subscriptionURL, query.Refresh, query.UserAgent, cacheExpire, retryTimes)
if err != nil {
logger.Logger.Debug(
"load subscription failed", zap.String("url", subscriptionURL), zap.Error(err),
)
return nil, NewSubscriptionLoadError(subscriptionURL, err)
}
subName := ""
if strings.Contains(subscriptionURL, "#") {
subName = subscriptionURL[strings.LastIndex(subscriptionURL, "#")+1:]
}
newProxies, err := parseSubscriptionProxies(data, query.UseUDP, subscriptionURL)
if err != nil {
return nil, err
}
if subName != "" {
for i := range newProxies {
newProxies[i].SubName = subName
}
}
return newProxies, nil
}
// parseSubscriptionProxies 按“Clash YAML -> URI 列表 -> Base64 文本”的顺序容错解析节点。
func parseSubscriptionProxies(data []byte, useUDP bool, subscriptionURL string) ([]P.Proxy, error) {
sub := &proxyListDoc{}
if err := yaml.Unmarshal(data, sub); err == nil {
return sub.Proxy, nil
}
reg, err := regexp.Compile("(" + strings.Join(parser.GetAllPrefixes(), "|") + ")://")
if err != nil {
logger.Logger.Debug("compile regex failed", zap.Error(err))
return nil, NewRegexInvalidError("prefix", err)
}
if reg.Match(data) {
return parser.ParseProxies(parser.ParseConfig{UseUDP: useUDP}, strings.Split(string(data), "\n")...)
}
base64, err := utils.DecodeBase64(string(data), false)
if err != nil {
logger.Logger.Debug(
"parse subscription failed", zap.String("url", subscriptionURL),
zap.String("data", string(data)),
zap.Error(err),
)
return nil, NewSubscriptionParseError(data, err)
}
return parser.ParseProxies(parser.ParseConfig{UseUDP: useUDP}, strings.Split(base64, "\n")...)
}
// normalizeProxyList 汇总所有节点标准化步骤,确保后续分组和 patch 使用的是稳定结果。
func normalizeProxyList(query model.ConvertConfig, proxyList []P.Proxy) ([]P.Proxy, error) {
applySubscriptionPrefixes(proxyList)
var err error
proxyList, err = dedupeProxies(proxyList)
if err != nil {
return nil, err
}
proxyList, err = removeProxiesByPattern(proxyList, query.Remove)
if err != nil {
return nil, err
}
proxyList, err = replaceProxyNames(proxyList, query.Replace)
if err != nil {
return nil, err
}
ensureUniqueProxyNames(proxyList)
trimProxyNames(proxyList)
return proxyList, nil
}
func applySubscriptionPrefixes(proxyList []P.Proxy) {
for i := range proxyList { for i := range proxyList {
if proxyList[i].SubName != "" { if proxyList[i].SubName != "" {
proxyList[i].Name = strings.TrimSpace(proxyList[i].SubName) + " " + strings.TrimSpace(proxyList[i].Name) proxyList[i].Name = strings.TrimSpace(proxyList[i].SubName) + " " + strings.TrimSpace(proxyList[i].Name)
} }
} }
}
// 去重 // dedupeProxies 通过 YAML 序列化结果判定两个节点是否完全相同。
func dedupeProxies(proxyList []P.Proxy) ([]P.Proxy, error) {
proxies := make(map[string]*P.Proxy) proxies := make(map[string]*P.Proxy)
newProxies := make([]P.Proxy, 0, len(proxyList)) newProxies := make([]P.Proxy, 0, len(proxyList))
for i := range proxyList { for i := range proxyList {
@@ -216,45 +339,52 @@ func BuildSub(clashType model.ClashType, query model.ConvertConfig, template str
newProxies = append(newProxies, proxyList[i]) newProxies = append(newProxies, proxyList[i])
} }
} }
proxyList = newProxies return newProxies, nil
}
// 移除 func removeProxiesByPattern(proxyList []P.Proxy, pattern string) ([]P.Proxy, error) {
if strings.TrimSpace(query.Remove) != "" { if strings.TrimSpace(pattern) == "" {
newProxyList := make([]P.Proxy, 0, len(proxyList)) return proxyList, nil
}
removeReg, err := regexp.Compile(pattern)
if err != nil {
logger.Logger.Debug("remove regexp compile failed", zap.Error(err))
return nil, NewRegexInvalidError("remove", err)
}
newProxyList := make([]P.Proxy, 0, len(proxyList))
for i := range proxyList {
if removeReg.MatchString(proxyList[i].Name) {
continue
}
newProxyList = append(newProxyList, proxyList[i])
}
return newProxyList, nil
}
func replaceProxyNames(proxyList []P.Proxy, replacements map[string]string) ([]P.Proxy, error) {
if len(replacements) == 0 {
return proxyList, nil
}
for pattern, replacement := range replacements {
replaceReg, err := regexp.Compile(pattern)
if err != nil {
logger.Logger.Debug("replace regexp compile failed", zap.Error(err))
return nil, NewRegexInvalidError("replace", err)
}
for i := range proxyList { for i := range proxyList {
removeReg, err := regexp.Compile(query.Remove) if replaceReg.MatchString(proxyList[i].Name) {
if err != nil { proxyList[i].Name = replaceReg.ReplaceAllString(proxyList[i].Name, replacement)
logger.Logger.Debug("remove regexp compile failed", zap.Error(err))
return nil, NewRegexInvalidError("remove", err)
}
if removeReg.MatchString(proxyList[i].Name) {
continue
}
newProxyList = append(newProxyList, proxyList[i])
}
proxyList = newProxyList
}
// 替换
if len(query.Replace) != 0 {
for k, v := range query.Replace {
replaceReg, err := regexp.Compile(k)
if err != nil {
logger.Logger.Debug("replace regexp compile failed", zap.Error(err))
return nil, NewRegexInvalidError("replace", err)
}
for i := range proxyList {
if replaceReg.MatchString(proxyList[i].Name) {
proxyList[i].Name = replaceReg.ReplaceAllString(
proxyList[i].Name, v,
)
}
} }
} }
} }
// 重命名有相同名称的节点 return proxyList, nil
}
func ensureUniqueProxyNames(proxyList []P.Proxy) {
names := make(map[string]int) names := make(map[string]int)
for i := range proxyList { for i := range proxyList {
if _, exist := names[proxyList[i].Name]; exist { if _, exist := names[proxyList[i].Name]; exist {
@@ -264,30 +394,39 @@ func BuildSub(clashType model.ClashType, query model.ConvertConfig, template str
names[proxyList[i].Name] = 0 names[proxyList[i].Name] = 0
} }
} }
}
func trimProxyNames(proxyList []P.Proxy) {
for i := range proxyList { for i := range proxyList {
proxyList[i].Name = strings.TrimSpace(proxyList[i].Name) proxyList[i].Name = strings.TrimSpace(proxyList[i].Name)
} }
}
var t = &model.Subscription{} // buildGeneratedConfig 只生成“新增内容”,例如国家组和最终可输出的节点集合。
AddProxy(t, query.AutoTest, query.Lazy, clashType, proxyList...) func buildGeneratedConfig(clashType model.ClashType, query model.ConvertConfig, proxyList []P.Proxy) (*generatedConfig, error) {
generated := &generatedConfig{}
AddProxy(generated, query.AutoTest, query.Lazy, clashType, proxyList...)
sortGeneratedGroups(generated, query.Sort)
return generated, nil
}
// 排序 func sortGeneratedGroups(generated *generatedConfig, sortMode string) {
switch query.Sort { switch sortMode {
case "sizeasc": case "sizeasc":
sort.Sort(model.ProxyGroupsSortBySize(t.ProxyGroup)) sort.Sort(generatedGroupsSortBySize(generated.ProxyGroup))
case "sizedesc": case "sizedesc":
sort.Sort(sort.Reverse(model.ProxyGroupsSortBySize(t.ProxyGroup))) sort.Sort(sort.Reverse(generatedGroupsSortBySize(generated.ProxyGroup)))
case "nameasc": case "nameasc":
sort.Sort(model.ProxyGroupsSortByName(t.ProxyGroup)) sort.Sort(generatedGroupsSortByName(generated.ProxyGroup))
case "namedesc": case "namedesc":
sort.Sort(sort.Reverse(model.ProxyGroupsSortByName(t.ProxyGroup))) sort.Sort(sort.Reverse(generatedGroupsSortByName(generated.ProxyGroup)))
default: default:
sort.Sort(model.ProxyGroupsSortByName(t.ProxyGroup)) sort.Sort(generatedGroupsSortByName(generated.ProxyGroup))
} }
}
MergeSubAndTemplate(temp, t, query.IgnoreCountryGrooup) // applyRulePatches 只修改运行期 overlay 中的 rules 切片,不直接写 YAML。
func applyRulePatches(temp *generatedConfig, query model.ConvertConfig) {
for _, v := range query.Rules { for _, v := range query.Rules {
if v.Prepend { if v.Prepend {
PrependRules(temp, v.Rule) PrependRules(temp, v.Rule)
@@ -295,28 +434,176 @@ func BuildSub(clashType model.ClashType, query model.ConvertConfig, template str
AppendRules(temp, v.Rule) AppendRules(temp, v.Rule)
} }
} }
for _, v := range query.RuleProviders {
if v.Prepend {
PrependRuleProvider(temp, v.Name, v.Group)
} else {
AppenddRuleProvider(temp, v.Name, v.Group)
}
}
}
// buildRuleProviderPatches 把 API 请求中的 rule-provider 参数转换成 YAML patch payload。
func buildRuleProviderPatches(query model.ConvertConfig) map[string]generatedRulePatch {
if len(query.RuleProviders) == 0 {
return nil
}
patches := make(map[string]generatedRulePatch, len(query.RuleProviders))
for _, v := range query.RuleProviders { for _, v := range query.RuleProviders {
hash := sha256.Sum224([]byte(v.Url)) hash := sha256.Sum224([]byte(v.Url))
name := hex.EncodeToString(hash[:]) name := hex.EncodeToString(hash[:])
provider := model.RuleProvider{ patches[v.Name] = generatedRulePatch{
Type: "http", Type: "http",
Behavior: v.Behavior, Behavior: v.Behavior,
Url: v.Url, Url: v.Url,
Path: "./" + name + ".yaml", Path: "./" + name + ".yaml",
Interval: 3600, Interval: 3600,
} }
if v.Prepend { }
PrependRuleProvider( return patches
temp, v.Name, v.Group, provider, }
)
} else { // extractTemplateOverlay 只从模板 YAML 树中提取本项目真正会参与计算的局部字段。
AppenddRuleProvider( // 这让模板读取完全基于 yaml.Node,而不再依赖任何整份配置的 typed unmarshal。
temp, v.Name, v.Group, provider, func extractTemplateOverlay(templateDoc *yaml.Node) (*generatedConfig, error) {
) overlay := &generatedConfig{}
if err := decodeOptionalYAMLPath(templateDoc, "proxy-groups", &overlay.ProxyGroup); err != nil {
return nil, err
}
if err := decodeOptionalYAMLPath(templateDoc, "rules", &overlay.Rule); err != nil {
return nil, err
}
return overlay, nil
}
// decodeOptionalYAMLPath 在路径存在且非 null 时才执行 Decode
// 路径不存在时保持目标值为零值。
func decodeOptionalYAMLPath(doc *yaml.Node, path string, target any) error {
node, err := GetYAMLPath(doc, path)
if err != nil {
return err
}
if node == nil || isNullYAMLNode(node) {
return nil
}
if err := node.Decode(target); err != nil {
return fmt.Errorf("decode template path %q failed: %w", path, err)
}
return nil
}
// mergeTemplateProxies 只负责把本项目生成出的代理追加到模板现有 proxies 后面。
// 模板中已有代理节点原样保留,不做 struct round-trip。
func mergeTemplateProxies(templateDoc *yaml.Node, generated []P.Proxy) error {
if len(generated) == 0 && !HasYAMLPath(templateDoc, "proxies") {
return nil
}
proxiesNode, err := EnsureYAMLSequencePath(templateDoc, "proxies")
if err != nil {
return err
}
for _, proxy := range generated {
if err := AppendYAMLSequenceValue(proxiesNode, proxy); err != nil {
return err
} }
} }
return temp, nil
return nil
}
// mergeTemplateProxyGroups 负责两类更新:
// 1. 对模板中同名组,仅覆盖 proxies 字段,保留其他字段
// 2. 追加本项目新生成的国家组
func mergeTemplateProxyGroups(templateDoc *yaml.Node, groups []generatedGroup) error {
if len(groups) == 0 && !HasYAMLPath(templateDoc, "proxy-groups") {
return nil
}
groupNodes, err := EnsureYAMLSequencePath(templateDoc, "proxy-groups")
if err != nil {
return err
}
for _, group := range groups {
if group.IsCountry {
if existing := FindYAMLSequenceMappingByStringField(groupNodes, "name", group.Name); existing != nil {
continue
}
if err := AppendYAMLSequenceValue(groupNodes, group); err != nil {
return err
}
continue
}
existing := FindYAMLSequenceMappingByStringField(groupNodes, "name", group.Name)
if existing == nil {
if err := AppendYAMLSequenceValue(groupNodes, group); err != nil {
return err
}
continue
}
if findMappingValue(existing, "proxies") == nil {
continue
}
if err := SetYAMLMappingField(existing, "proxies", group.Proxies); err != nil {
return err
}
}
return nil
}
// mergeTemplateRuleProviders 以字段级 patch 的方式更新/插入 rule-provider
// 以避免覆盖模板中已有 provider 的未知字段。
func mergeTemplateRuleProviders(templateDoc *yaml.Node, providers map[string]generatedRulePatch) error {
if len(providers) == 0 && !HasYAMLPath(templateDoc, "rule-providers") {
return nil
}
providerNodes, err := EnsureYAMLMappingPath(templateDoc, "rule-providers")
if err != nil {
return err
}
for name, provider := range providers {
existing := findMappingValue(providerNodes, name)
if existing != nil && existing.Kind == yaml.MappingNode {
if err := SetYAMLMappingField(existing, "type", provider.Type); err != nil {
return err
}
if err := SetYAMLMappingField(existing, "behavior", provider.Behavior); err != nil {
return err
}
if err := SetYAMLMappingField(existing, "url", provider.Url); err != nil {
return err
}
if err := SetYAMLMappingField(existing, "path", provider.Path); err != nil {
return err
}
if err := SetYAMLMappingField(existing, "interval", provider.Interval); err != nil {
return err
}
if provider.Format != "" {
if err := SetYAMLMappingField(existing, "format", provider.Format); err != nil {
return err
}
}
continue
}
if err := SetYAMLMappingField(providerNodes, name, provider); err != nil {
return err
}
}
return nil
} }
func FetchSubscriptionUserInfo(url string, userAgent string, retryTimes int) (string, error) { func FetchSubscriptionUserInfo(url string, userAgent string, retryTimes int) (string, error) {
@@ -336,10 +623,12 @@ func FetchSubscriptionUserInfo(url string, userAgent string, retryTimes int) (st
return "", NewNetworkResponseError("subscription-userinfo header not found", nil) return "", NewNetworkResponseError("subscription-userinfo header not found", nil)
} }
func MergeSubAndTemplate(temp *model.Subscription, sub *model.Subscription, igcg bool) { // MergeSubAndTemplate 把“模板侧需要参与计算的最小叠加层”和“本项目生成结果”合并。
// 它只处理本项目关心的运行期结构,不负责最终 YAML 输出。
func MergeSubAndTemplate(temp *generatedConfig, sub *generatedConfig, igcg bool) {
var countryGroupNames []string var countryGroupNames []string
for _, proxyGroup := range sub.ProxyGroup { for _, proxyGroup := range sub.ProxyGroup {
if proxyGroup.IsCountryGrop { if proxyGroup.IsCountry {
countryGroupNames = append( countryGroupNames = append(
countryGroupNames, proxyGroup.Name, countryGroupNames, proxyGroup.Name,
) )
@@ -350,16 +639,14 @@ func MergeSubAndTemplate(temp *model.Subscription, sub *model.Subscription, igcg
proxyNames = append(proxyNames, proxy.Name) proxyNames = append(proxyNames, proxy.Name)
} }
temp.Proxy = append(temp.Proxy, sub.Proxy...)
for i := range temp.ProxyGroup { for i := range temp.ProxyGroup {
if temp.ProxyGroup[i].IsCountryGrop { if temp.ProxyGroup[i].IsCountry {
continue continue
} }
newProxies := make([]string, 0) newProxies := make([]string, 0)
countryGroupMap := make(map[string]model.ProxyGroup) countryGroupMap := make(map[string]generatedGroup)
for _, v := range sub.ProxyGroup { for _, v := range sub.ProxyGroup {
if v.IsCountryGrop { if v.IsCountry {
countryGroupMap[v.Name] = v countryGroupMap[v.Name] = v
} }
} }
+478
View File
@@ -0,0 +1,478 @@
package common
import (
"os"
"path/filepath"
"testing"
"github.com/bestnite/sub2clash/model"
"gopkg.in/yaml.v3"
)
func withRepoRoot(t *testing.T) {
t.Helper()
originalWD, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
repoRoot := filepath.Dir(originalWD)
if err := os.Chdir(repoRoot); err != nil {
t.Fatalf("change working directory: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(originalWD)
})
}
func TestBuildSubPreservesUnmodeledTemplateSections(t *testing.T) {
withRepoRoot(t)
templateName := "test_scheme_a_template.yaml"
templatePath := filepath.Join(templatesDir, templateName)
templateContent := `mixed-port: 7890
dns:
enable: true
future-field: true
new-section:
enabled: true
proxies:
proxy-groups:
- name: 节点选择
type: select
proxies:
- <countries>
- DIRECT
rules:
- MATCH,节点选择
`
if err := os.WriteFile(templatePath, []byte(templateContent), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
t.Cleanup(func() {
_ = os.Remove(templatePath)
})
result, err := BuildSub(model.Clash, model.ConvertConfig{
ClashType: model.Clash,
Proxies: []string{
"ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@127.0.0.1:8080#Test Node",
},
}, templateName, 0, 0)
if err != nil {
t.Fatalf("build subscription: %v", err)
}
output, err := yaml.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
var doc map[string]any
if err := yaml.Unmarshal(output, &doc); err != nil {
t.Fatalf("unmarshal output: %v", err)
}
dns, ok := doc["dns"].(map[string]any)
if !ok {
t.Fatalf("dns section missing: %s", output)
}
if dns["future-field"] != true {
t.Fatalf("dns future-field not preserved: %#v", dns)
}
newSection, ok := doc["new-section"].(map[string]any)
if !ok {
t.Fatalf("new-section missing: %s", output)
}
if newSection["enabled"] != true {
t.Fatalf("new-section not preserved: %#v", newSection)
}
proxies, ok := doc["proxies"].([]any)
if !ok || len(proxies) != 1 {
t.Fatalf("expected generated proxies in output: %#v", doc["proxies"])
}
rules, ok := doc["rules"].([]any)
if !ok || len(rules) != 1 || rules[0] != "MATCH,节点选择" {
t.Fatalf("rules should stay untouched without rule patches: %#v", doc["rules"])
}
}
func TestBuildSubPreservesTemplateProxyAndGroupFields(t *testing.T) {
withRepoRoot(t)
templateName := "test_scheme_a_group_template.yaml"
templatePath := filepath.Join(templatesDir, templateName)
templateContent := `proxies:
- name: Template Proxy
type: ss
server: 1.1.1.1
port: 443
cipher: aes-256-gcm
password: password
future-proxy-field: keep
proxy-groups:
- name: 节点选择
type: select
future-group-field: keep
proxies:
- <countries>
- DIRECT
rules:
- MATCH,节点选择
`
if err := os.WriteFile(templatePath, []byte(templateContent), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
t.Cleanup(func() {
_ = os.Remove(templatePath)
})
result, err := BuildSub(model.Clash, model.ConvertConfig{
ClashType: model.Clash,
Proxies: []string{
"ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@127.0.0.1:8080#Test Node",
},
}, templateName, 0, 0)
if err != nil {
t.Fatalf("build subscription: %v", err)
}
output, err := yaml.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
var doc map[string]any
if err := yaml.Unmarshal(output, &doc); err != nil {
t.Fatalf("unmarshal output: %v", err)
}
proxies, ok := doc["proxies"].([]any)
if !ok || len(proxies) != 2 {
t.Fatalf("expected two proxies in output: %#v", doc["proxies"])
}
firstProxy, ok := proxies[0].(map[string]any)
if !ok {
t.Fatalf("template proxy should remain a mapping: %#v", proxies[0])
}
if firstProxy["future-proxy-field"] != "keep" {
t.Fatalf("template proxy field not preserved: %#v", firstProxy)
}
groups, ok := doc["proxy-groups"].([]any)
if !ok || len(groups) == 0 {
t.Fatalf("expected proxy groups in output: %#v", doc["proxy-groups"])
}
firstGroup, ok := groups[0].(map[string]any)
if !ok {
t.Fatalf("template group should remain a mapping: %#v", groups[0])
}
if firstGroup["future-group-field"] != "keep" {
t.Fatalf("template proxy-group field not preserved: %#v", firstGroup)
}
groupProxies, ok := firstGroup["proxies"].([]any)
if !ok || len(groupProxies) == 0 {
t.Fatalf("template proxy-group proxies missing: %#v", firstGroup["proxies"])
}
for _, value := range groupProxies {
if value == "<countries>" {
t.Fatalf("placeholder should be resolved in template proxy-group: %#v", groupProxies)
}
}
}
func TestBuildSubAddsRulesForRuleProviderWhenTemplateHasNoRules(t *testing.T) {
withRepoRoot(t)
templateName := "test_scheme_a_rule_provider_template.yaml"
templatePath := filepath.Join(templatesDir, templateName)
templateContent := `proxy-groups:
- name: 节点选择
type: select
proxies:
- DIRECT
`
if err := os.WriteFile(templatePath, []byte(templateContent), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
t.Cleanup(func() {
_ = os.Remove(templatePath)
})
result, err := BuildSub(model.Clash, model.ConvertConfig{
ClashType: model.Clash,
Proxies: []string{
"ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@127.0.0.1:8080#Test Node",
},
RuleProviders: []model.RuleProviderStruct{{
Name: "test-provider",
Group: "节点选择",
Behavior: "domain",
Url: "https://example.com/rules.yaml",
}},
}, templateName, 0, 0)
if err != nil {
t.Fatalf("build subscription: %v", err)
}
output, err := yaml.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
var doc map[string]any
if err := yaml.Unmarshal(output, &doc); err != nil {
t.Fatalf("unmarshal output: %v", err)
}
ruleProviders, ok := doc["rule-providers"].(map[string]any)
if !ok {
t.Fatalf("rule-providers missing: %#v", doc["rule-providers"])
}
if _, ok := ruleProviders["test-provider"]; !ok {
t.Fatalf("test-provider missing: %#v", ruleProviders)
}
rules, ok := doc["rules"].([]any)
if !ok || len(rules) != 1 || rules[0] != "RULE-SET,test-provider,节点选择" {
t.Fatalf("expected generated rule for provider: %#v", doc["rules"])
}
}
func TestBuildSubDoesNotInjectProxiesFieldIntoUseBasedGroup(t *testing.T) {
withRepoRoot(t)
templateName := "test_scheme_a_use_group_template.yaml"
templatePath := filepath.Join(templatesDir, templateName)
templateContent := `proxy-groups:
- name: 节点选择
type: select
use:
- provider-a
rules:
- MATCH,节点选择
`
if err := os.WriteFile(templatePath, []byte(templateContent), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
t.Cleanup(func() {
_ = os.Remove(templatePath)
})
result, err := BuildSub(model.Clash, model.ConvertConfig{
ClashType: model.Clash,
Proxies: []string{
"ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@127.0.0.1:8080#Test Node",
},
}, templateName, 0, 0)
if err != nil {
t.Fatalf("build subscription: %v", err)
}
output, err := yaml.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
var doc map[string]any
if err := yaml.Unmarshal(output, &doc); err != nil {
t.Fatalf("unmarshal output: %v", err)
}
groups := doc["proxy-groups"].([]any)
firstGroup := groups[0].(map[string]any)
if _, exists := firstGroup["proxies"]; exists {
t.Fatalf("use-based group should not gain proxies field: %#v", firstGroup)
}
if _, exists := firstGroup["use"]; !exists {
t.Fatalf("use-based group should preserve use field: %#v", firstGroup)
}
}
func TestBuildSubPreservesUnknownFieldsOnExistingRuleProvider(t *testing.T) {
withRepoRoot(t)
templateName := "test_scheme_a_existing_provider_template.yaml"
templatePath := filepath.Join(templatesDir, templateName)
templateContent := `proxy-groups:
- name: 节点选择
type: select
proxies:
- DIRECT
rule-providers:
test-provider:
type: http
behavior: classical
url: https://old.example.com/rules.yaml
path: ./old.yaml
interval: 10
future-provider-field: keep
`
if err := os.WriteFile(templatePath, []byte(templateContent), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
t.Cleanup(func() {
_ = os.Remove(templatePath)
})
result, err := BuildSub(model.Clash, model.ConvertConfig{
ClashType: model.Clash,
Proxies: []string{
"ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@127.0.0.1:8080#Test Node",
},
RuleProviders: []model.RuleProviderStruct{{
Name: "test-provider",
Group: "节点选择",
Behavior: "domain",
Url: "https://example.com/rules.yaml",
}},
}, templateName, 0, 0)
if err != nil {
t.Fatalf("build subscription: %v", err)
}
output, err := yaml.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
var doc map[string]any
if err := yaml.Unmarshal(output, &doc); err != nil {
t.Fatalf("unmarshal output: %v", err)
}
ruleProviders := doc["rule-providers"].(map[string]any)
provider := ruleProviders["test-provider"].(map[string]any)
if provider["future-provider-field"] != "keep" {
t.Fatalf("existing provider field not preserved: %#v", provider)
}
if provider["behavior"] != "domain" {
t.Fatalf("provider behavior not updated: %#v", provider)
}
if provider["url"] != "https://example.com/rules.yaml" {
t.Fatalf("provider url not updated: %#v", provider)
}
}
func TestBuildSubSkipsDuplicateCountryGroupNames(t *testing.T) {
withRepoRoot(t)
templateName := "test_scheme_a_country_group_template.yaml"
templatePath := filepath.Join(templatesDir, templateName)
templateContent := `proxy-groups:
- name: 其他地区
type: select
proxies:
- DIRECT
rules:
- MATCH,其他地区
`
if err := os.WriteFile(templatePath, []byte(templateContent), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
t.Cleanup(func() {
_ = os.Remove(templatePath)
})
result, err := BuildSub(model.Clash, model.ConvertConfig{
ClashType: model.Clash,
Proxies: []string{
"ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@127.0.0.1:8080#UnknownCountryNode",
},
}, templateName, 0, 0)
if err != nil {
t.Fatalf("build subscription: %v", err)
}
output, err := yaml.Marshal(result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
var doc map[string]any
if err := yaml.Unmarshal(output, &doc); err != nil {
t.Fatalf("unmarshal output: %v", err)
}
groups := doc["proxy-groups"].([]any)
count := 0
for _, item := range groups {
group := item.(map[string]any)
if group["name"] == "其他地区" {
count++
}
}
if count != 1 {
t.Fatalf("expected duplicate country group names to be skipped, got %d entries: %#v", count, groups)
}
}
func TestBuiltSubMarshalNodeListYAMLUsesFinalYAMLTree(t *testing.T) {
withRepoRoot(t)
templateName := "test_scheme_a_nodelist_template.yaml"
templatePath := filepath.Join(templatesDir, templateName)
templateContent := `proxies:
- name: Template Proxy
type: ss
server: 1.1.1.1
port: 443
cipher: aes-256-gcm
password: password
future-proxy-field: keep
proxy-groups:
- name: 节点选择
type: select
proxies:
- DIRECT
`
if err := os.WriteFile(templatePath, []byte(templateContent), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
t.Cleanup(func() {
_ = os.Remove(templatePath)
})
result, err := BuildSub(model.Clash, model.ConvertConfig{
ClashType: model.Clash,
Proxies: []string{
"ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@127.0.0.1:8080#Generated Node",
},
}, templateName, 0, 0)
if err != nil {
t.Fatalf("build subscription: %v", err)
}
output, err := result.MarshalNodeListYAML()
if err != nil {
t.Fatalf("marshal node list: %v", err)
}
var doc map[string]any
if err := yaml.Unmarshal(output, &doc); err != nil {
t.Fatalf("unmarshal output: %v", err)
}
proxies, ok := doc["proxies"].([]any)
if !ok || len(proxies) != 2 {
t.Fatalf("expected node list to include template and generated proxies: %#v", doc["proxies"])
}
firstProxy, ok := proxies[0].(map[string]any)
if !ok {
t.Fatalf("template proxy should remain a mapping: %#v", proxies[0])
}
if firstProxy["future-proxy-field"] != "keep" {
t.Fatalf("node list should be built from final yaml tree: %#v", firstProxy)
}
}
+20 -4
View File
@@ -3,11 +3,27 @@ package common
import ( import (
"io" "io"
"os" "os"
"path/filepath"
"strings"
) )
func LoadTemplate(templatePath string) ([]byte, error) { const templatesDir = "templates"
if _, err := os.Stat(templatePath); err == nil {
file, err := os.Open(templatePath) // LoadTemplate 只读取运行目录下的 templates 目录,防止其他文件内容泄漏
func LoadTemplate(templateName string) ([]byte, error) {
// 清理路径,防止目录遍历攻击
cleanTemplateName := filepath.Clean(templateName)
// 检查是否尝试访问父目录
if strings.HasPrefix(cleanTemplateName, "..") || strings.Contains(cleanTemplateName, string(filepath.Separator)+".."+string(filepath.Separator)) {
return nil, NewFileNotFoundError(templateName) // 拒绝包含父目录的路径
}
// 构建完整路径,确保只从 templates 目录读取
fullPath := filepath.Join(templatesDir, cleanTemplateName)
if _, err := os.Stat(fullPath); err == nil {
file, err := os.Open(fullPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -22,5 +38,5 @@ func LoadTemplate(templatePath string) ([]byte, error) {
} }
return result, nil return result, nil
} }
return nil, NewFileNotFoundError(templatePath) return nil, NewFileNotFoundError(templateName)
} }
+383
View File
@@ -0,0 +1,383 @@
package common
import (
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
// BuiltSub 保存最终输出所需的完整 YAML 树。
//
// 这里刻意不再保存整份 typed 配置副本:
// - root 是整个转换流程的最终产物
// - 所有常规输出都直接从 root 序列化
// - nodeList 模式也从 root 中提取 proxies,而不是依赖额外状态
type BuiltSub struct {
root *yaml.Node
}
// MarshalYAML 让 BuiltSub 在输出时直接复用 patch 后的 YAML 树,
// 从而避免再次经过 struct round-trip 丢失未知字段。
func (b *BuiltSub) MarshalYAML() (any, error) {
if b == nil || b.root == nil {
return nil, nil
}
if b.root.Kind == yaml.DocumentNode {
if len(b.root.Content) == 0 {
return nil, nil
}
return b.root.Content[0], nil
}
return b.root, nil
}
// MarshalNodeListYAML 从最终 YAML 树中提取 proxies 节点,构造 nodeList 模式输出。
// 这样 nodeList 也直接复用最终 root,而不是依赖额外的 typed struct 副本。
func (b *BuiltSub) MarshalNodeListYAML() ([]byte, error) {
if b == nil || b.root == nil {
return yaml.Marshal(&yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"})
}
proxiesNode, err := GetYAMLPath(b.root, "proxies")
if err != nil {
return nil, err
}
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
if proxiesNode != nil && !isNullYAMLNode(proxiesNode) {
setMappingValue(root, "proxies", cloneYAMLNode(proxiesNode))
}
return yaml.Marshal(root)
}
// ParseYAMLDocument 把原始 YAML 解析成 DocumentNode
// 并确保根内容最终是一个可写入的 mapping 节点。
func ParseYAMLDocument(data []byte) (*yaml.Node, error) {
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, err
}
if _, err := rootMappingNode(&doc); err != nil {
return nil, err
}
return &doc, nil
}
// HasYAMLPath 判断某个点路径是否存在。
// 这里仅关心“是否找到节点”,不关心节点具体类型。
func HasYAMLPath(doc *yaml.Node, path string) bool {
current, err := GetYAMLPath(doc, path)
return err == nil && current != nil
}
// GetYAMLPath 按 a.b.c 这种点路径向下查找节点。
// 当前实现只支持 mapping 之间的逐层下钻,不处理数组索引路径。
func GetYAMLPath(doc *yaml.Node, path string) (*yaml.Node, error) {
segments := splitYAMLPath(path)
if len(segments) == 0 {
return nil, fmt.Errorf("yaml path is empty")
}
current, err := rootMappingNode(doc)
if err != nil {
return nil, err
}
for _, segment := range segments {
next := findMappingValue(current, segment)
if next == nil {
return nil, nil
}
current = next
}
return current, nil
}
// SetYAMLPath 按点路径写入一个值;不存在的中间层会自动补成 mapping。
// 例如 a.b.c=1 会在缺失时依次创建 a 和 b 两层对象节点。
func SetYAMLPath(doc *yaml.Node, path string, value any) error {
segments := splitYAMLPath(path)
if len(segments) == 0 {
return fmt.Errorf("yaml path is empty")
}
current, err := rootMappingNode(doc)
if err != nil {
return err
}
for idx, segment := range segments[:len(segments)-1] {
next := findMappingValue(current, segment)
if next == nil {
next = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
setMappingValue(current, segment, next)
}
if next.Kind != yaml.MappingNode {
return fmt.Errorf("yaml path %q segment %q is not a mapping", path, strings.Join(segments[:idx+1], "."))
}
current = next
}
encoded, err := encodeYAMLNode(value)
if err != nil {
return err
}
setMappingValue(current, segments[len(segments)-1], encoded)
return nil
}
// EnsureYAMLSequencePath 确保某个路径最终是 sequence(YAML 数组)节点。
// 不存在时会自动创建,已存在但类型不匹配时返回错误。
func EnsureYAMLSequencePath(doc *yaml.Node, path string) (*yaml.Node, error) {
return ensureYAMLPathKind(doc, path, yaml.SequenceNode, "!!seq")
}
// EnsureYAMLMappingPath 确保某个路径最终是 mapping(YAML 对象)节点。
func EnsureYAMLMappingPath(doc *yaml.Node, path string) (*yaml.Node, error) {
return ensureYAMLPathKind(doc, path, yaml.MappingNode, "!!map")
}
// SetYAMLMappingField 在一个 mapping 节点里设置单个字段。
// 它等价于“在当前对象上写 key: value”。
func SetYAMLMappingField(node *yaml.Node, key string, value any) error {
if node == nil || node.Kind != yaml.MappingNode {
return fmt.Errorf("yaml node is not a mapping")
}
encoded, err := encodeYAMLNode(value)
if err != nil {
return err
}
setMappingValue(node, key, encoded)
return nil
}
// AppendYAMLSequenceValue 向 sequence 节点末尾追加一个元素。
func AppendYAMLSequenceValue(node *yaml.Node, value any) error {
if node == nil || node.Kind != yaml.SequenceNode {
return fmt.Errorf("yaml node is not a sequence")
}
encoded, err := encodeYAMLNode(value)
if err != nil {
return err
}
node.Content = append(node.Content, encoded)
return nil
}
// FindYAMLSequenceMappingByStringField 在 YAML 数组中查找一个对象元素,
// 要求该对象存在指定字段且字段值等于目标字符串。
//
// 例如在 proxy-groups 里按 name 查找:
// - name: 节点选择
// type: select
func FindYAMLSequenceMappingByStringField(node *yaml.Node, field string, value string) *yaml.Node {
if node == nil || node.Kind != yaml.SequenceNode {
return nil
}
for _, item := range node.Content {
if item == nil || item.Kind != yaml.MappingNode {
continue
}
fieldNode := findMappingValue(item, field)
if fieldNode == nil || fieldNode.Kind != yaml.ScalarNode {
continue
}
if fieldNode.Value == value {
return item
}
}
return nil
}
// splitYAMLPath 把 a.b.c 这种点路径拆成 [a b c]。
// 空片段会被忽略,避免出现连续点号时产生无意义路径段。
func splitYAMLPath(path string) []string {
parts := strings.Split(path, ".")
segments := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
segments = append(segments, part)
}
return segments
}
// ensureYAMLPathKind 是 EnsureYAMLSequencePath / EnsureYAMLMappingPath 的底层实现。
// 它会:
// 1. 逐层确保中间节点存在且都是 mapping
// 2. 确保最后一个节点存在,且类型符合预期
func ensureYAMLPathKind(doc *yaml.Node, path string, kind yaml.Kind, tag string) (*yaml.Node, error) {
segments := splitYAMLPath(path)
if len(segments) == 0 {
return nil, fmt.Errorf("yaml path is empty")
}
current, err := rootMappingNode(doc)
if err != nil {
return nil, err
}
// 跳过最后一个元素在后面处理
for idx, segment := range segments[:len(segments)-1] {
next := findMappingValue(current, segment)
if next == nil || isNullYAMLNode(next) {
next = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
setMappingValue(current, segment, next)
}
if next.Kind != yaml.MappingNode {
return nil, fmt.Errorf("yaml path %q segment %q is not a mapping", path, strings.Join(segments[:idx+1], "."))
}
current = next
}
lastSegment := segments[len(segments)-1]
node := findMappingValue(current, lastSegment)
if node == nil || isNullYAMLNode(node) {
node = &yaml.Node{Kind: kind, Tag: tag}
setMappingValue(current, lastSegment, node)
}
if node.Kind != kind {
return nil, fmt.Errorf("yaml path %q is not a %s", path, yamlKindName(kind))
}
return node, nil
}
// rootMappingNode 统一把“文档根”整理成一个可操作的 mapping 节点。
//
// yaml.v3 通常把整份 YAML 包在 DocumentNode 下,真正的内容位于 Content[0]。
// 当前项目的 patch 逻辑都假定最外层是 key-value 结构,因此这里会:
// 1. 处理空文档
// 2. 取出 DocumentNode 的实际根内容
// 3. 确保该根内容是 mapping
func rootMappingNode(doc *yaml.Node) (*yaml.Node, error) {
if doc == nil {
return nil, fmt.Errorf("yaml document is nil")
}
root := doc
if doc.Kind == 0 {
doc.Kind = yaml.DocumentNode
}
if doc.Kind == yaml.DocumentNode {
if len(doc.Content) == 0 {
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"})
}
root = doc.Content[0]
}
if root.Kind == 0 {
root.Kind = yaml.MappingNode
root.Tag = "!!map"
}
if root.Kind != yaml.MappingNode {
return nil, fmt.Errorf("yaml root must be a mapping node")
}
return root, nil
}
// isNullYAMLNode 判断一个节点是否为空/未初始化/null。
// 这让我们在“路径不存在”和“路径存在但值为 null”时都能按缺失处理。
func isNullYAMLNode(node *yaml.Node) bool {
if node == nil {
return true
}
if node.Kind == 0 {
return true
}
return node.Kind == yaml.ScalarNode && node.Tag == "!!null"
}
// yamlKindName 仅用于生成更可读的错误信息。
func yamlKindName(kind yaml.Kind) string {
switch kind {
case yaml.MappingNode:
return "mapping"
case yaml.SequenceNode:
return "sequence"
case yaml.ScalarNode:
return "scalar"
case yaml.DocumentNode:
return "document"
default:
return "node"
}
}
// findMappingValue 在 mapping 节点中按 key 查找对应的 value 节点。
//
// 需要注意:yaml.v3 的 MappingNode.Content 不是 map,而是交替存储:
// [key1, value1, key2, value2, ...]
// 所以这里每次 idx += 2,依次跳过一个完整的 key-value 对。
func findMappingValue(node *yaml.Node, key string) *yaml.Node {
if node == nil || node.Kind != yaml.MappingNode {
return nil
}
for idx := 0; idx+1 < len(node.Content); idx += 2 {
if node.Content[idx].Value == key {
return node.Content[idx+1]
}
}
return nil
}
// setMappingValue 在 mapping 节点中设置 key 对应的 value。
// 如果 key 已存在,就原位替换;否则在末尾追加一组新的 key-value。
func setMappingValue(node *yaml.Node, key string, value *yaml.Node) {
for idx := 0; idx+1 < len(node.Content); idx += 2 {
if node.Content[idx].Value == key {
node.Content[idx+1] = value
return
}
}
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
value,
)
}
// encodeYAMLNode 把普通 Go 值编码成 *yaml.Node,方便统一塞回 YAML 树。
// 如果 Encode 产生的是 DocumentNode,这里会自动取出它的实际内容节点。
func encodeYAMLNode(value any) (*yaml.Node, error) {
var node yaml.Node
if err := node.Encode(value); err != nil {
return nil, err
}
if node.Kind == yaml.DocumentNode {
if len(node.Content) == 0 {
return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}, nil
}
return node.Content[0], nil
}
return &node, nil
}
// cloneYAMLNode 深拷贝一个节点树,避免把同一个子树同时挂到多个输出根下。
func cloneYAMLNode(node *yaml.Node) *yaml.Node {
if node == nil {
return nil
}
clone := *node
if len(node.Content) != 0 {
clone.Content = make([]*yaml.Node, len(node.Content))
for i := range node.Content {
clone.Content[i] = cloneYAMLNode(node.Content[i])
}
}
return &clone
}
+21 -12
View File
@@ -1,18 +1,17 @@
module github.com/bestnite/sub2clash module github.com/bestnite/sub2clash
go 1.21.0 go 1.25
toolchain go1.24.3
require ( require (
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/metacubex/mihomo v1.19.10 github.com/metacubex/mihomo v1.19.10
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
go.etcd.io/bbolt v1.3.9
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/text v0.22.0 golang.org/x/text v0.30.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.31.0
resty.dev/v3 v3.0.0-beta.3 resty.dev/v3 v3.0.0-beta.3
) )
@@ -30,6 +29,7 @@ require (
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/coreos/go-iptables v0.8.0 // indirect github.com/coreos/go-iptables v0.8.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.3 // indirect github.com/ebitengine/purego v0.8.3 // indirect
github.com/enfein/mieru/v3 v3.13.0 // indirect github.com/enfein/mieru/v3 v3.13.0 // indirect
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect
@@ -40,6 +40,7 @@ require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gaukas/godicttls v0.0.4 // indirect github.com/gaukas/godicttls v0.0.4 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -53,9 +54,12 @@ require (
github.com/gofrs/uuid/v5 v5.3.2 // indirect github.com/gofrs/uuid/v5 v5.3.2 // indirect
github.com/google/btree v1.1.3 // indirect github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-cmp v0.6.0 // indirect
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect github.com/hashicorp/yamux v0.1.2 // indirect
github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 // indirect github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/native v1.1.0 // indirect github.com/josharian/native v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.9 // indirect github.com/klauspost/compress v1.17.9 // indirect
@@ -103,6 +107,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/quic-go/qpack v0.4.0 // indirect github.com/quic-go/qpack v0.4.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.50.0 // indirect github.com/samber/lo v1.50.0 // indirect
@@ -132,14 +137,18 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.33.0 // indirect golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect
golang.org/x/mod v0.20.0 // indirect golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.35.0 // indirect golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.36.0 // indirect
golang.org/x/time v0.8.0 // indirect golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.24.0 // indirect golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect google.golang.org/protobuf v1.36.1 // indirect
lukechampine.com/blake3 v1.3.0 // indirect lukechampine.com/blake3 v1.3.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
) )
+43 -25
View File
@@ -19,9 +19,6 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@@ -35,6 +32,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc= github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc=
github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/enfein/mieru/v3 v3.13.0 h1:eGyxLGkb+lut9ebmx+BGwLJ5UMbEc/wGIYO0AXEKy98= github.com/enfein/mieru/v3 v3.13.0 h1:eGyxLGkb+lut9ebmx+BGwLJ5UMbEc/wGIYO0AXEKy98=
@@ -61,6 +60,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
@@ -101,15 +104,20 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/tink/go v1.6.1 h1:t7JHqO8Ath2w2ig5vjwQYJzhGEZymedQc90lQXUBa4I= github.com/google/tink/go v1.6.1 h1:t7JHqO8Ath2w2ig5vjwQYJzhGEZymedQc90lQXUBa4I=
github.com/google/tink/go v1.6.1/go.mod h1:IGW53kTgag+st5yPhKKwJ6u2l+SSp5/v9XF7spovjlY= github.com/google/tink/go v1.6.1/go.mod h1:IGW53kTgag+st5yPhKKwJ6u2l+SSp5/v9XF7spovjlY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 h1:q3OEI9RaN/wwcx+qgGo6ZaoJkCiDYe/gjDLfq7lQQF4= github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 h1:q3OEI9RaN/wwcx+qgGo6ZaoJkCiDYe/gjDLfq7lQQF4=
github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905/go.mod h1:VvGYjkZoJyKqlmT1yzakUs4mfKMNB0XdODP0+rdml6k= github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905/go.mod h1:VvGYjkZoJyKqlmT1yzakUs4mfKMNB0XdODP0+rdml6k=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
@@ -226,6 +234,9 @@ github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
@@ -297,8 +308,6 @@ gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 h1:UNrDfkQqiE
gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7/go.mod h1:E+rxHvJG9H6PUdzq9NRG6csuLN3XUx98BfGOVWNYnXs= gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7/go.mod h1:E+rxHvJG9H6PUdzq9NRG6csuLN3XUx98BfGOVWNYnXs=
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec h1:FpfFs4EhNehiVfzQttTuxanPIT43FtkkCFypIod8LHo= gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec h1:FpfFs4EhNehiVfzQttTuxanPIT43FtkkCFypIod8LHo=
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ= gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ=
go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
@@ -314,27 +323,26 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e h1:I88y4caeGeuDQxgdoFPUq097j7kNfw6uvuiNxUBfcBk= golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e h1:I88y4caeGeuDQxgdoFPUq097j7kNfw6uvuiNxUBfcBk=
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -344,18 +352,18 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
@@ -368,8 +376,18 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E= resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E=
resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4= resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4=
-5
View File
@@ -5,7 +5,6 @@ import (
"io" "io"
"github.com/bestnite/sub2clash/common" "github.com/bestnite/sub2clash/common"
"github.com/bestnite/sub2clash/common/database"
"github.com/bestnite/sub2clash/config" "github.com/bestnite/sub2clash/config"
"github.com/bestnite/sub2clash/logger" "github.com/bestnite/sub2clash/logger"
"github.com/bestnite/sub2clash/server" "github.com/bestnite/sub2clash/server"
@@ -29,10 +28,6 @@ func init() {
logger.Logger.Panic("load config failed", zap.Error(err)) logger.Logger.Panic("load config failed", zap.Error(err))
} }
err = database.ConnectDB()
if err != nil {
logger.Logger.Panic("database connect failed", zap.Error(err))
}
logger.Logger.Info("database connect success") logger.Logger.Info("database connect success")
} }
-70
View File
@@ -1,70 +0,0 @@
package model
import (
"golang.org/x/text/collate"
"golang.org/x/text/language"
)
type ProxyGroup struct {
Type string `yaml:"type,omitempty"`
Name string `yaml:"name,omitempty"`
Proxies []string `yaml:"proxies,omitempty"`
IsCountryGrop bool `yaml:"-"`
Url string `yaml:"url,omitempty"`
Interval int `yaml:"interval,omitempty"`
Tolerance int `yaml:"tolerance,omitempty"`
Lazy bool `yaml:"lazy"`
Size int `yaml:"-"`
DisableUDP bool `yaml:"disable-udp,omitempty"`
Strategy string `yaml:"strategy,omitempty"`
Icon string `yaml:"icon,omitempty"`
Timeout int `yaml:"timeout,omitempty"`
Use []string `yaml:"use,omitempty"`
InterfaceName string `yaml:"interface-name,omitempty"`
RoutingMark int `yaml:"routing-mark,omitempty"`
IncludeAll bool `yaml:"include-all,omitempty"`
IncludeAllProxies bool `yaml:"include-all-proxies,omitempty"`
IncludeAllProviders bool `yaml:"include-all-providers,omitempty"`
Filter string `yaml:"filter,omitempty"`
ExcludeFilter string `yaml:"exclude-filter,omitempty"`
ExpectedStatus int `yaml:"expected-status,omitempty"`
Hidden bool `yaml:"hidden,omitempty"`
}
type ProxyGroupsSortByName []ProxyGroup
type ProxyGroupsSortBySize []ProxyGroup
func (p ProxyGroupsSortByName) Len() int {
return len(p)
}
func (p ProxyGroupsSortBySize) Len() int {
return len(p)
}
func (p ProxyGroupsSortByName) Less(i, j int) bool {
tags := []language.Tag{
language.English,
language.Chinese,
}
matcher := language.NewMatcher(tags)
bestMatch, _, _ := matcher.Match(language.Make("zh"))
c := collate.New(bestMatch)
return c.CompareString(p[i].Name, p[j].Name) < 0
}
func (p ProxyGroupsSortBySize) Less(i, j int) bool {
if p[i].Size == p[j].Size {
return p[i].Name < p[j].Name
}
return p[i].Size < p[j].Size
}
func (p ProxyGroupsSortByName) Swap(i, j int) {
p[i], p[j] = p[j], p[i]
}
func (p ProxyGroupsSortBySize) Swap(i, j int) {
p[i], p[j] = p[j], p[i]
}
+13 -13
View File
@@ -2,17 +2,17 @@ package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/anytls.go // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/anytls.go
type Anytls struct { type Anytls struct {
Server string `yaml:"server"` Server string `yaml:"server"`
Port int `yaml:"port"` Port IntOrString `yaml:"port"`
Password string `yaml:"password"` Password string `yaml:"password"`
ALPN []string `yaml:"alpn,omitempty"` ALPN []string `yaml:"alpn,omitempty"`
SNI string `yaml:"sni,omitempty"` SNI string `yaml:"sni,omitempty"`
ECHOpts ECHOptions `yaml:"ech-opts,omitempty"` ECHOpts ECHOptions `yaml:"ech-opts,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"` ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"` Fingerprint string `yaml:"fingerprint,omitempty"`
UDP bool `yaml:"udp,omitempty"` UDP bool `yaml:"udp,omitempty"`
IdleSessionCheckInterval int `yaml:"idle-session-check-interval,omitempty"` IdleSessionCheckInterval int `yaml:"idle-session-check-interval,omitempty"`
IdleSessionTimeout int `yaml:"idle-session-timeout,omitempty"` IdleSessionTimeout int `yaml:"idle-session-timeout,omitempty"`
MinIdleSession int `yaml:"min-idle-session,omitempty"` MinIdleSession int `yaml:"min-idle-session,omitempty"`
} }
+24 -24
View File
@@ -2,28 +2,28 @@ package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/hysteria.go // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/hysteria.go
type Hysteria struct { type Hysteria struct {
Server string `yaml:"server"` Server string `yaml:"server"`
Port int `yaml:"port,omitempty"` Port IntOrString `yaml:"port,omitempty"`
Ports string `yaml:"ports,omitempty"` Ports string `yaml:"ports,omitempty"`
Protocol string `yaml:"protocol,omitempty"` Protocol string `yaml:"protocol,omitempty"`
ObfsProtocol string `yaml:"obfs-protocol,omitempty"` // compatible with Stash ObfsProtocol string `yaml:"obfs-protocol,omitempty"` // compatible with Stash
Up string `yaml:"up"` Up string `yaml:"up"`
UpSpeed int `yaml:"up-speed,omitempty"` // compatible with Stash UpSpeed int `yaml:"up-speed,omitempty"` // compatible with Stash
Down string `yaml:"down"` Down string `yaml:"down"`
DownSpeed int `yaml:"down-speed,omitempty"` // compatible with Stash DownSpeed int `yaml:"down-speed,omitempty"` // compatible with Stash
Auth string `yaml:"auth,omitempty"` Auth string `yaml:"auth,omitempty"`
AuthString string `yaml:"auth-str,omitempty"` AuthString string `yaml:"auth-str,omitempty"`
Obfs string `yaml:"obfs,omitempty"` Obfs string `yaml:"obfs,omitempty"`
SNI string `yaml:"sni,omitempty"` SNI string `yaml:"sni,omitempty"`
ECHOpts ECHOptions `yaml:"ech-opts,omitempty"` ECHOpts ECHOptions `yaml:"ech-opts,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"` Fingerprint string `yaml:"fingerprint,omitempty"`
ALPN []string `yaml:"alpn,omitempty"` ALPN []string `yaml:"alpn,omitempty"`
CustomCA string `yaml:"ca,omitempty"` CustomCA string `yaml:"ca,omitempty"`
CustomCAString string `yaml:"ca-str,omitempty"` CustomCAString string `yaml:"ca-str,omitempty"`
ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"` ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"`
ReceiveWindow int `yaml:"recv-window,omitempty"` ReceiveWindow int `yaml:"recv-window,omitempty"`
DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"` DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"`
FastOpen bool `yaml:"fast-open,omitempty"` FastOpen bool `yaml:"fast-open,omitempty"`
HopInterval int `yaml:"hop-interval,omitempty"` HopInterval int `yaml:"hop-interval,omitempty"`
} }
+18 -18
View File
@@ -2,24 +2,24 @@ package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/hysteria2.go // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/hysteria2.go
type Hysteria2 struct { type Hysteria2 struct {
Server string `yaml:"server"` Server string `yaml:"server"`
Port int `yaml:"port,omitempty"` Port IntOrString `yaml:"port,omitempty"`
Ports string `yaml:"ports,omitempty"` Ports string `yaml:"ports,omitempty"`
HopInterval int `yaml:"hop-interval,omitempty"` HopInterval int `yaml:"hop-interval,omitempty"`
Up string `yaml:"up,omitempty"` Up string `yaml:"up,omitempty"`
Down string `yaml:"down,omitempty"` Down string `yaml:"down,omitempty"`
Password string `yaml:"password,omitempty"` Password string `yaml:"password,omitempty"`
Obfs string `yaml:"obfs,omitempty"` Obfs string `yaml:"obfs,omitempty"`
ObfsPassword string `yaml:"obfs-password,omitempty"` ObfsPassword string `yaml:"obfs-password,omitempty"`
SNI string `yaml:"sni,omitempty"` SNI string `yaml:"sni,omitempty"`
ECHOpts ECHOptions `yaml:"ech-opts,omitempty"` ECHOpts ECHOptions `yaml:"ech-opts,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"` Fingerprint string `yaml:"fingerprint,omitempty"`
ALPN []string `yaml:"alpn,omitempty"` ALPN []string `yaml:"alpn,omitempty"`
CustomCA string `yaml:"ca,omitempty"` CustomCA string `yaml:"ca,omitempty"`
CustomCAString string `yaml:"ca-str,omitempty"` CustomCAString string `yaml:"ca-str,omitempty"`
CWND int `yaml:"cwnd,omitempty"` CWND int `yaml:"cwnd,omitempty"`
UdpMTU int `yaml:"udp-mtu,omitempty"` UdpMTU int `yaml:"udp-mtu,omitempty"`
// quic-go special config // quic-go special config
InitialStreamReceiveWindow uint64 `yaml:"initial-stream-receive-window,omitempty"` InitialStreamReceiveWindow uint64 `yaml:"initial-stream-receive-window,omitempty"`
+42 -1
View File
@@ -2,10 +2,31 @@ package proxy
import ( import (
"fmt" "fmt"
"strconv"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type IntOrString int
func (i *IntOrString) UnmarshalYAML(value *yaml.Node) error {
intVal := 0
err := yaml.Unmarshal([]byte(value.Value), &intVal)
if err == nil {
*i = IntOrString(intVal)
}
strVal := ""
err = yaml.Unmarshal([]byte(value.Value), &strVal)
if err == nil {
_int, err := strconv.ParseInt(strVal, 10, 64)
if err != nil {
*i = IntOrString(_int)
}
return err
}
return nil
}
type HTTPOptions struct { type HTTPOptions struct {
Method string `yaml:"method,omitempty"` Method string `yaml:"method,omitempty"`
Path []string `yaml:"path,omitempty"` Path []string `yaml:"path,omitempty"`
@@ -64,6 +85,7 @@ type Proxy struct {
Vless Vless
Vmess Vmess
Socks Socks
Tuic
} }
func (p Proxy) MarshalYAML() (any, error) { func (p Proxy) MarshalYAML() (any, error) {
@@ -158,6 +180,16 @@ func (p Proxy) MarshalYAML() (any, error) {
Name: p.Name, Name: p.Name,
Socks: p.Socks, Socks: p.Socks,
}, nil }, nil
case "tuic":
return struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Tuic `yaml:",inline"`
}{
Type: p.Type,
Name: p.Name,
Tuic: p.Tuic,
}, nil
default: default:
return nil, fmt.Errorf("unsupported proxy type: %s", p.Type) return nil, fmt.Errorf("unsupported proxy type: %s", p.Type)
} }
@@ -275,7 +307,16 @@ func (p *Proxy) UnmarshalYAML(node *yaml.Node) error {
return err return err
} }
p.Socks = data.Socks p.Socks = data.Socks
case "tuic":
var data struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Tuic `yaml:",inline"`
}
if err := node.Decode(&data); err != nil {
return err
}
p.Tuic = data.Tuic
default: default:
return fmt.Errorf("unsupported proxy type: %s", temp.Type) return fmt.Errorf("unsupported proxy type: %s", temp.Type)
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/shadowsocks.go // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/shadowsocks.go
type ShadowSocks struct { type ShadowSocks struct {
Server string `yaml:"server"` Server string `yaml:"server"`
Port int `yaml:"port"` Port IntOrString `yaml:"port"`
Password string `yaml:"password"` Password string `yaml:"password"`
Cipher string `yaml:"cipher"` Cipher string `yaml:"cipher"`
UDP bool `yaml:"udp,omitempty"` UDP bool `yaml:"udp,omitempty"`
+9 -9
View File
@@ -2,13 +2,13 @@ package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/shadowsocksr.go // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/shadowsocksr.go
type ShadowSocksR struct { type ShadowSocksR struct {
Server string `yaml:"server"` Server string `yaml:"server"`
Port int `yaml:"port"` Port IntOrString `yaml:"port"`
Password string `yaml:"password"` Password string `yaml:"password"`
Cipher string `yaml:"cipher"` Cipher string `yaml:"cipher"`
Obfs string `yaml:"obfs"` Obfs string `yaml:"obfs"`
ObfsParam string `yaml:"obfs-param,omitempty"` ObfsParam string `yaml:"obfs-param,omitempty"`
Protocol string `yaml:"protocol"` Protocol string `yaml:"protocol"`
ProtocolParam string `yaml:"protocol-param,omitempty"` ProtocolParam string `yaml:"protocol-param,omitempty"`
UDP bool `yaml:"udp,omitempty"` UDP bool `yaml:"udp,omitempty"`
} }
+8 -8
View File
@@ -2,12 +2,12 @@ package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/socks5.go // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/socks5.go
type Socks struct { type Socks struct {
Server string `yaml:"server"` Server string `yaml:"server"`
Port int `yaml:"port"` Port IntOrString `yaml:"port"`
UserName string `yaml:"username,omitempty"` UserName string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"` Password string `yaml:"password,omitempty"`
TLS bool `yaml:"tls,omitempty"` TLS bool `yaml:"tls,omitempty"`
UDP bool `yaml:"udp,omitempty"` UDP bool `yaml:"udp,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"` Fingerprint string `yaml:"fingerprint,omitempty"`
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/trojan.go // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/trojan.go
type Trojan struct { type Trojan struct {
Server string `yaml:"server"` Server string `yaml:"server"`
Port int `yaml:"port"` Port IntOrString `yaml:"port"`
Password string `yaml:"password"` Password string `yaml:"password"`
ALPN []string `yaml:"alpn,omitempty"` ALPN []string `yaml:"alpn,omitempty"`
SNI string `yaml:"sni,omitempty"` SNI string `yaml:"sni,omitempty"`
+35
View File
@@ -0,0 +1,35 @@
package proxy
type Tuic struct {
Server string `proxy:"server"`
Port int `proxy:"port"`
Token string `proxy:"token,omitempty"`
UUID string `proxy:"uuid,omitempty"`
Password string `proxy:"password,omitempty"`
Ip string `proxy:"ip,omitempty"`
HeartbeatInterval int `proxy:"heartbeat-interval,omitempty"`
ALPN []string `proxy:"alpn,omitempty"`
ReduceRtt bool `proxy:"reduce-rtt,omitempty"`
RequestTimeout int `proxy:"request-timeout,omitempty"`
UdpRelayMode string `proxy:"udp-relay-mode,omitempty"`
CongestionController string `proxy:"congestion-controller,omitempty"`
DisableSni bool `proxy:"disable-sni,omitempty"`
MaxUdpRelayPacketSize int `proxy:"max-udp-relay-packet-size,omitempty"`
FastOpen bool `proxy:"fast-open,omitempty"`
MaxOpenStreams int `proxy:"max-open-streams,omitempty"`
CWND int `proxy:"cwnd,omitempty"`
SkipCertVerify bool `proxy:"skip-cert-verify,omitempty"`
Fingerprint string `proxy:"fingerprint,omitempty"`
Certificate string `proxy:"certificate,omitempty"`
PrivateKey string `proxy:"private-key,omitempty"`
ReceiveWindowConn int `proxy:"recv-window-conn,omitempty"`
ReceiveWindow int `proxy:"recv-window,omitempty"`
DisableMTUDiscovery bool `proxy:"disable-mtu-discovery,omitempty"`
MaxDatagramFrameSize int `proxy:"max-datagram-frame-size,omitempty"`
SNI string `proxy:"sni,omitempty"`
ECHOpts ECHOptions `proxy:"ech-opts,omitempty"`
UDPOverStream bool `proxy:"udp-over-stream,omitempty"`
UDPOverStreamVersion int `proxy:"udp-over-stream-version,omitempty"`
}
+1 -1
View File
@@ -3,7 +3,7 @@ package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/vless.go // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/vless.go
type Vless struct { type Vless struct {
Server string `yaml:"server"` Server string `yaml:"server"`
Port int `yaml:"port"` Port IntOrString `yaml:"port"`
UUID string `yaml:"uuid"` UUID string `yaml:"uuid"`
Flow string `yaml:"flow,omitempty"` Flow string `yaml:"flow,omitempty"`
TLS bool `yaml:"tls,omitempty"` TLS bool `yaml:"tls,omitempty"`
+2 -2
View File
@@ -3,9 +3,9 @@ package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/vmess.go // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/vmess.go
type Vmess struct { type Vmess struct {
Server string `yaml:"server"` Server string `yaml:"server"`
Port int `yaml:"port"` Port IntOrString `yaml:"port"`
UUID string `yaml:"uuid"` UUID string `yaml:"uuid"`
AlterID int `yaml:"alterId"` AlterID IntOrString `yaml:"alterId"`
Cipher string `yaml:"cipher"` Cipher string `yaml:"cipher"`
UDP bool `yaml:"udp,omitempty"` UDP bool `yaml:"udp,omitempty"`
Network string `yaml:"network,omitempty"` Network string `yaml:"network,omitempty"`
-14
View File
@@ -1,14 +0,0 @@
package model
type RuleProvider struct {
Type string `yaml:"type,omitempty"`
Behavior string `yaml:"behavior,omitempty"`
Url string `yaml:"url,omitempty"`
Path string `yaml:"path,omitempty"`
Interval int `yaml:"interval,omitempty"`
Format string `yaml:"format,omitempty"`
}
type Payload struct {
Rules []string `yaml:"payload,omitempty"`
}
+2 -2
View File
@@ -1,8 +1,8 @@
package model package model
type ShortLink struct { type ShortLink struct {
Hash string ID string `gorm:"unique"`
Url string Config ConvertConfig `gorm:"serializer:json"`
Password string Password string
LastRequestTime int64 LastRequestTime int64
} }
-83
View File
@@ -1,83 +0,0 @@
package model
import (
"net/netip"
"github.com/bestnite/sub2clash/model/proxy"
C "github.com/metacubex/mihomo/config"
LC "github.com/metacubex/mihomo/listener/config"
)
type NodeList struct {
Proxy []proxy.Proxy `yaml:"proxies,omitempty" json:"proxies"`
}
// https://github.com/MetaCubeX/mihomo/blob/Meta/config/config.go RawConfig
type Subscription struct {
Port int `yaml:"port,omitempty" json:"port"`
SocksPort int `yaml:"socks-port,omitempty" json:"socks-port"`
RedirPort int `yaml:"redir-port,omitempty" json:"redir-port"`
TProxyPort int `yaml:"tproxy-port,omitempty" json:"tproxy-port"`
MixedPort int `yaml:"mixed-port,omitempty" json:"mixed-port"`
ShadowSocksConfig string `yaml:"ss-config,omitempty" json:"ss-config"`
VmessConfig string `yaml:"vmess-config,omitempty" json:"vmess-config"`
InboundTfo bool `yaml:"inbound-tfo,omitempty" json:"inbound-tfo"`
InboundMPTCP bool `yaml:"inbound-mptcp,omitempty" json:"inbound-mptcp"`
Authentication []string `yaml:"authentication,omitempty" json:"authentication"`
SkipAuthPrefixes []netip.Prefix `yaml:"skip-auth-prefixes,omitempty" json:"skip-auth-prefixes"`
LanAllowedIPs []netip.Prefix `yaml:"lan-allowed-ips,omitempty" json:"lan-allowed-ips"`
LanDisAllowedIPs []netip.Prefix `yaml:"lan-disallowed-ips,omitempty" json:"lan-disallowed-ips"`
AllowLan bool `yaml:"allow-lan,omitempty" json:"allow-lan"`
BindAddress string `yaml:"bind-address,omitempty" json:"bind-address"`
Mode string `yaml:"mode,omitempty" json:"mode"`
UnifiedDelay bool `yaml:"unified-delay,omitempty" json:"unified-delay"`
LogLevel string `yaml:"log-level,omitempty" json:"log-level"`
IPv6 bool `yaml:"ipv6,omitempty" json:"ipv6"`
ExternalController string `yaml:"external-controller,omitempty" json:"external-controller"`
ExternalControllerPipe string `yaml:"external-controller-pipe,omitempty" json:"external-controller-pipe"`
ExternalControllerUnix string `yaml:"external-controller-unix,omitempty" json:"external-controller-unix"`
ExternalControllerTLS string `yaml:"external-controller-tls,omitempty" json:"external-controller-tls"`
ExternalControllerCors C.RawCors `yaml:"external-controller-cors,omitempty" json:"external-controller-cors"`
ExternalUI string `yaml:"external-ui,omitempty" json:"external-ui"`
ExternalUIURL string `yaml:"external-ui-url,omitempty" json:"external-ui-url"`
ExternalUIName string `yaml:"external-ui-name,omitempty" json:"external-ui-name"`
ExternalDohServer string `yaml:"external-doh-server,omitempty" json:"external-doh-server"`
Secret string `yaml:"secret,omitempty" json:"secret"`
Interface string `yaml:"interface-name,omitempty" json:"interface-name"`
RoutingMark int `yaml:"routing-mark,omitempty" json:"routing-mark"`
Tunnels []LC.Tunnel `yaml:"tunnels,omitempty" json:"tunnels"`
GeoAutoUpdate bool `yaml:"geo-auto-update,omitempty" json:"geo-auto-update"`
GeoUpdateInterval int `yaml:"geo-update-interval,omitempty" json:"geo-update-interval"`
GeodataMode bool `yaml:"geodata-mode,omitempty" json:"geodata-mode"`
GeodataLoader string `yaml:"geodata-loader,omitempty" json:"geodata-loader"`
GeositeMatcher string `yaml:"geosite-matcher,omitempty" json:"geosite-matcher"`
TCPConcurrent bool `yaml:"tcp-concurrent,omitempty" json:"tcp-concurrent"`
FindProcessMode string `yaml:"find-process-mode,omitempty" json:"find-process-mode"`
GlobalClientFingerprint string `yaml:"global-client-fingerprint,omitempty" json:"global-client-fingerprint"`
GlobalUA string `yaml:"global-ua,omitempty" json:"global-ua"`
ETagSupport bool `yaml:"etag-support,omitempty" json:"etag-support"`
KeepAliveIdle int `yaml:"keep-alive-idle,omitempty" json:"keep-alive-idle"`
KeepAliveInterval int `yaml:"keep-alive-interval,omitempty" json:"keep-alive-interval"`
DisableKeepAlive bool `yaml:"disable-keep-alive,omitempty" json:"disable-keep-alive"`
ProxyProvider map[string]map[string]any `yaml:"proxy-providers,omitempty" json:"proxy-providers"`
RuleProvider map[string]RuleProvider `yaml:"rule-providers,omitempty" json:"rule-providers"`
Proxy []proxy.Proxy `yaml:"proxies,omitempty" json:"proxies"`
ProxyGroup []ProxyGroup `yaml:"proxy-groups,omitempty" json:"proxy-groups"`
Rule []string `yaml:"rules,omitempty" json:"rule"`
SubRules map[string][]string `yaml:"sub-rules,omitempty" json:"sub-rules"`
Listeners []map[string]any `yaml:"listeners,omitempty" json:"listeners"`
Hosts map[string]any `yaml:"hosts,omitempty" json:"hosts"`
DNS C.RawDNS `yaml:"dns,omitempty" json:"dns"`
NTP C.RawNTP `yaml:"ntp,omitempty" json:"ntp"`
Tun C.RawTun `yaml:"tun,omitempty" json:"tun"`
TuicServer C.RawTuicServer `yaml:"tuic-server,omitempty" json:"tuic-server"`
IPTables C.RawIPTables `yaml:"iptables,omitempty" json:"iptables"`
Experimental C.RawExperimental `yaml:"experimental,omitempty" json:"experimental"`
Profile C.RawProfile `yaml:"profile,omitempty" json:"profile"`
GeoXUrl C.RawGeoXUrl `yaml:"geox-url,omitempty" json:"geox-url"`
Sniffer C.RawSniffer `yaml:"sniffer,omitempty" json:"sniffer"`
TLS C.RawTLS `yaml:"tls,omitempty" json:"tls"`
ClashForAndroid C.RawClashForAndroid `yaml:"clash-for-android,omitempty" json:"clash-for-android"`
}
+1 -1
View File
@@ -68,7 +68,7 @@ func (p *AnytlsParser) Parse(config ParseConfig, proxy string) (P.Proxy, error)
Name: remarks, Name: remarks,
Anytls: P.Anytls{ Anytls: P.Anytls{
Server: server, Server: server,
Port: port, Port: P.IntOrString(port),
Password: password, Password: password,
SNI: sni, SNI: sni,
SkipCertVerify: insecureBool, SkipCertVerify: insecureBool,
+1 -1
View File
@@ -76,7 +76,7 @@ func (p *HysteriaParser) Parse(config ParseConfig, proxy string) (P.Proxy, error
Name: remarks, Name: remarks,
Hysteria: P.Hysteria{ Hysteria: P.Hysteria{
Server: server, Server: server,
Port: port, Port: P.IntOrString(port),
Up: upmbps, Up: upmbps,
Down: downmbps, Down: downmbps,
Auth: auth, Auth: auth,
+1 -1
View File
@@ -68,7 +68,7 @@ func (p *Hysteria2Parser) Parse(config ParseConfig, proxy string) (P.Proxy, erro
Name: remarks, Name: remarks,
Hysteria2: P.Hysteria2{ Hysteria2: P.Hysteria2{
Server: server, Server: server,
Port: port, Port: P.IntOrString(port),
Password: password, Password: password,
Obfs: obfs, Obfs: obfs,
ObfsPassword: obfsPassword, ObfsPassword: obfsPassword,
+1 -1
View File
@@ -108,7 +108,7 @@ func (p *ShadowsocksParser) Parse(config ParseConfig, proxy string) (P.Proxy, er
Cipher: method, Cipher: method,
Password: password, Password: password,
Server: server, Server: server,
Port: port, Port: P.IntOrString(port),
UDP: config.UseUDP, UDP: config.UseUDP,
}, },
} }
+1 -1
View File
@@ -94,7 +94,7 @@ func (p *ShadowsocksRParser) Parse(config ParseConfig, proxy string) (P.Proxy, e
Name: remarks, Name: remarks,
ShadowSocksR: P.ShadowSocksR{ ShadowSocksR: P.ShadowSocksR{
Server: server, Server: server,
Port: port, Port: P.IntOrString(port),
Protocol: protocol, Protocol: protocol,
Cipher: method, Cipher: method,
Obfs: obfs, Obfs: obfs,
+2 -2
View File
@@ -79,11 +79,11 @@ func (p *SocksParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
Name: remarks, Name: remarks,
Socks: P.Socks{ Socks: P.Socks{
Server: server, Server: server,
Port: port, Port: P.IntOrString(port),
UserName: username, UserName: username,
Password: password, Password: password,
TLS: tls == "true", TLS: tls == "true",
UDP: udp == "true", UDP: udp == "true" || config.UseUDP,
}, },
}, nil }, nil
} }
+2 -2
View File
@@ -63,10 +63,10 @@ func (p *TrojanParser) Parse(config ParseConfig, proxy string) (P.Proxy, error)
insecureBool := insecure == "1" insecureBool := insecure == "1"
result := P.Trojan{ result := P.Trojan{
Server: server, Server: server,
Port: port, Port: P.IntOrString(port),
Password: password, Password: password,
Network: network, Network: network,
UDP: udp == "true", UDP: udp == "true" || config.UseUDP,
SkipCertVerify: insecureBool, SkipCertVerify: insecureBool,
} }
+2 -2
View File
@@ -66,10 +66,10 @@ func (p *VlessParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
result := P.Vless{ result := P.Vless{
Server: server, Server: server,
Port: port, Port: P.IntOrString(port),
UUID: uuid, UUID: uuid,
Flow: flow, Flow: flow,
UDP: udp == "true", UDP: udp == "true" || config.UseUDP,
SkipCertVerify: insecureBool, SkipCertVerify: insecureBool,
} }
+2 -2
View File
@@ -109,9 +109,9 @@ func (p *VmessParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
result := P.Vmess{ result := P.Vmess{
Server: vmess.Add, Server: vmess.Add,
Port: port, Port: P.IntOrString(port),
UUID: vmess.Id, UUID: vmess.Id,
AlterID: aid, AlterID: P.IntOrString(aid),
Cipher: vmess.Scy, Cipher: vmess.Scy,
UDP: config.UseUDP, UDP: config.UseUDP,
} }
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sub2clash</title>
<link rel="stylesheet" href="./src/index.css" />
<script type="module" src="/src/app.ts"></script>
</head>
<body>
<sub2clash-app>
</sub2clash-app>
</body>
</html>
+2028
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "sub2clash-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.14",
"axios": "^1.12.2",
"daisyui": "^5.3.7",
"lit": "^3.3.1",
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^7.2.6"
}
}
+680
View File
@@ -0,0 +1,680 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import globalStyles from "./index.css?inline";
import { type Config, type Rule, type RuleProvider } from "./interface.js";
import axios, { AxiosError } from "axios";
import { base64EncodeUnicode, base64decodeUnicode } from "./utils.js";
import "./components/rule-provider-input.js";
import "./components/rule-input.js";
import "./components/rename-input.js";
import "./components/short-link-input-group.js";
@customElement("sub2clash-app")
export class Sub2clashApp extends LitElement {
static styles = [unsafeCSS(globalStyles)];
private _config: Config = {
clashType: 2,
subscriptions: [],
proxies: [],
refresh: false,
autoTest: false,
lazy: false,
ignoreCountryGroup: false,
useUDP: false,
template: "",
sort: "nameasc",
remove: "",
nodeList: false,
ruleProviders: [],
replace: undefined,
rules: [],
};
@state()
set config(value: Config) {
console.log(JSON.stringify(value));
if (
(value.subscriptions == null || value.subscriptions.length == 0) &&
(value.proxies == null || value.proxies.length == 0)
) {
this.configUrl = "";
return;
}
const oldValue = this._config;
this.configUrl = `${
window.location.origin
}${window.location.pathname.replace(
/\/$/,
""
)}/convert/${base64EncodeUnicode(JSON.stringify(value))
.replace(/\+/g, "-")
.replace(/\//g, "_")}`;
this._config = value;
this.requestUpdate("config", oldValue);
}
get config(): Config {
return this._config;
}
@state({
hasChanged(value: boolean) {
localStorage.setItem("theme", value ? "dark" : "light");
document
.querySelector("html")
?.setAttribute("data-theme", value ? "dark" : "light");
return true;
},
})
darkTheme: boolean = this.initTheme();
initTheme(): boolean {
const savedTheme = localStorage.getItem("theme");
if (savedTheme != null) {
return savedTheme === "dark" ? true : false;
}
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
return prefersDark;
}
@state()
reverseUrl: string = "";
@state()
dialogMessage: string = "";
@state()
dialogTitle: string = "";
@query("dialog#my_modal")
dialog!: HTMLDialogElement;
showDialog(title: string, message: string): void {
if (title.trim() === "") {
title = "警告";
}
this.dialogTitle = title;
this.dialogMessage = message;
this.dialog.showModal();
}
@state()
configUrl: string = "";
@state()
shortLinkID: string = "";
@state()
shortLinkPasswd: string = "";
async copyToClipboard(content: string, e: HTMLButtonElement) {
try {
await navigator.clipboard.writeText(content);
let text = e.textContent;
e.addEventListener("mouseout", function () {
e.textContent = text;
});
e.textContent = "复制成功";
} catch (err) {
console.error("复制到剪贴板失败:", err);
}
}
generateShortLink() {
if (this.configUrl === "") {
this.showDialog("", "还未填写配置");
return;
}
axios
.post(
"./short",
{
config: this.config,
password: this.shortLinkPasswd,
id: this.shortLinkID,
},
{
headers: {
"Content-Type": "application/json",
},
}
)
.then((response) => {
// 设置返回的短链ID和密码
this.shortLinkID = response.data.id;
this.shortLinkPasswd = response.data.password;
this.showDialog("成功", "生成短链成功");
})
.catch((error) => {
if (error.response && error.response.data) {
this.showDialog("", "生成短链失败:" + error.response.data);
} else {
this.showDialog("", "生成短链失败");
}
});
}
updateShortLink() {
if (this.shortLinkID.trim() === "") {
this.showDialog("", "请输入ID");
return;
}
if (this.shortLinkPasswd.trim() === "") {
this.showDialog("", "请输入密码");
return;
}
if (this.configUrl === "") {
this.showDialog("", "还未填写配置");
return;
}
axios
.put(
"./short",
{
id: this.shortLinkID,
config: this.config,
password: this.shortLinkPasswd,
},
{
headers: {
"Content-Type": "application/json",
},
}
)
.then(() => {
this.showDialog("成功", "更新短链成功");
})
.catch((error) => {
if (error.response && error.response.status === 401) {
this.showDialog("", "短链不存在或密码错误");
} else if (error.response && error.response.data) {
this.showDialog("", "更新短链失败:" + error.response.data);
} else {
this.showDialog("", "更新短链失败");
}
});
}
deleteShortLink() {
if (this.shortLinkID.trim() === "") {
this.showDialog("", "请输入ID");
return;
}
if (this.shortLinkPasswd.trim() === "") {
this.showDialog("", "请输入密码");
return;
}
const params = new URLSearchParams();
params.append("password", this.shortLinkPasswd);
axios
.delete(`./short/${this.shortLinkID}?${params.toString()}`, {
headers: {
"Content-Type": "application/json",
},
})
.then(() => {
this.showDialog("成功", "删除短链成功");
})
.catch((error) => {
if (error.response && error.response.status === 401) {
this.showDialog("", "短链不存在或密码错误");
} else if (error.response && error.response.data) {
this.showDialog("", "删除短链失败:" + error.response.data);
} else {
this.showDialog("", "删除短链失败");
}
});
}
getRawConfigFromShortLink() {
const s = this.reverseUrl.split("/s/");
if (s.length != 2) {
this.showDialog("", "解析失败");
return;
}
axios
.get(`./short/${s[1]}`)
.then((resp) => {
this.config = resp.data;
const parsedUrl = new URL(this.reverseUrl);
this.shortLinkID = parsedUrl.pathname.split("/").filter(Boolean).pop() ?? "";
this.shortLinkPasswd = parsedUrl.searchParams.get("password") ?? "";
})
.catch((err: AxiosError) => {
if (err.response && err.response.status == 401) {
this.showDialog("", "短链不存在或密码错误");
} else if (err.response && err.response.data) {
this.showDialog("", "获取配置失败:" + err.response.data);
} else {
this.showDialog("", "获取配置失败");
}
});
}
parseConfig() {
if (this.reverseUrl.trim() === "") {
this.showDialog("", "无法解析,链接为空");
}
if (this.reverseUrl.indexOf("/s/") != -1) {
this.getRawConfigFromShortLink();
return;
}
let url = new URL(this.reverseUrl);
const pathSections = url.pathname.split("/");
if (pathSections.length < 2) {
this.showDialog("", "无法解析,链接格式错误");
}
if (pathSections[pathSections.length - 2] == "convert") {
let base64Data = pathSections[pathSections.length - 1];
base64Data = base64Data.replace(/-/g, "+").replace(/_/g, "/");
try {
const configData = base64decodeUnicode(base64Data);
this.config = JSON.parse(configData) as Config;
} catch (e: any) {
this.showDialog("", "无法解析 Base64,配置格式错误");
return;
}
} else {
this.showDialog("", "无法解析,链接格式错误");
}
}
render() {
return html`
<dialog id="my_modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">${this.dialogTitle}</h3>
<p class="py-4">${this.dialogMessage}</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">关闭</button>
</form>
</div>
</div>
</dialog>
<div class="max-w-4xl mx-auto p-4 flex flex-col items-center">
<form class="w-full max-w-2xl bg-base-100">
<fieldset class="fieldset mb-6">
<div class="flex flex-row justify-between items-center my-6">
<legend
class="fieldset-legend text-2xl font-semibold inline-block m-0 p-0">
sub2clash
</legend>
<label class="swap swap-rotate h-7 w-7">
<!-- this hidden checkbox controls the state -->
<input
type="checkbox"
class="theme-controller"
.checked="${!this.darkTheme}"
@change="${() => (this.darkTheme = !this.darkTheme)}" />
<!-- sun icon -->
<svg
class="swap-off h-7 w-7 fill-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
</svg>
<!-- moon icon -->
<svg
class="swap-on h-7 w-7 fill-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
</label>
</div>
<!-- Input URL -->
<div class="form-control mb-5">
<label class="label mb-1 pl-1">解析链接</label>
<div class="join w-full">
<input
class="input input-bordered w-full join-item"
type="text"
@change="${(e: Event) => {
this.reverseUrl = (e.target as HTMLInputElement).value;
}}"
placeholder="通过生成的链接重新填写下方设置" />
<button
class="btn btn-primary join-item"
@click="${this.parseConfig}"
type="button">
解析
</button>
</div>
</div>
<!-- API Endpoint -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="endpoint">客户端类型</label>
<select
class="select select-bordered w-full"
name="endpoint"
.value="${this.config.clashType == 1 ? "1" : "2"}"
@change="${(e: Event) => {
this.config = {
...this.config,
clashType: Number((e.target as HTMLInputElement).value),
};
}}">
<option value="1">Clash</option>
<option value="2" selected>Clash.Meta</option>
</select>
</div>
<!-- Template -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="template">模板链接</label>
<input
class="input input-bordered w-full"
name="template"
placeholder="输入模板链接"
type="text"
.value="${this.config.template ?? ""}"
@change="${(e: Event) => {
this.config = {
...this.config,
template: (e.target as HTMLInputElement).value,
};
}}" />
</div>
<!-- Subscription Link -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="sub">订阅链接</label>
<div>
<textarea
class="textarea textarea-bordered h-24 w-full"
name="sub"
placeholder="每行输入一个订阅链接"
.value="${this.config.subscriptions
? this.config.subscriptions.join("\n")
: ""}"
@change="${(e: Event) => {
this.config = {
...this.config,
subscriptions: (e.target as HTMLInputElement).value
.split("\n")
.filter((e) => e.trim() !== ""),
};
}}"></textarea>
</div>
</div>
<!-- Proxy Link -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="proxy">节点分享链接</label>
<div>
<textarea
class="textarea textarea-bordered h-24 w-full"
name="proxy"
placeholder="每行输入一个节点分享链接"
.value="${this.config.proxies
? this.config.proxies.join("\n")
: ""}"
@change="${(e: Event) => {
this.config = {
...this.config,
proxies: (e.target as HTMLInputElement).value
.split("\n")
.filter((e) => e.trim() !== ""),
};
}}"></textarea>
</div>
</div>
<!-- User Agent -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="user-agent">UA 标识</label>
<div>
<textarea
class="textarea textarea-bordered h-20 w-full"
name="user-agent"
placeholder="用于获取订阅的 http 请求中的 User-Agent 标识"
.value="${this.config.userAgent ?? ""}"
@change="${(e: Event) => {
this.config = {
...this.config,
userAgent: (e.target as HTMLInputElement).value,
};
}}"></textarea>
</div>
</div>
<!-- Sort -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="sort">
国家策略组排序规则
</label>
<select
class="select select-bordered w-full"
name="sort"
.value="${this.config.sort ?? "nameasc"}"
@change="${(e: Event) => {
this.config = {
...this.config,
sort: (e.target as HTMLInputElement).value,
};
}}">
<option value="nameasc">名称(升序)</option>
<option value="namedesc">名称(降序)</option>
<option value="sizeasc">节点数量(升序)</option>
<option value="sizedesc">节点数量(降序)</option>
</select>
</div>
<!-- Remove -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="remove">
<span class="label-text">排除节点</span>
</label>
<input
class="input input-bordered w-full"
type="text"
name="remove"
placeholder="正则表达式"
.value="${this.config.remove ?? ""}"
@change="${(e: Event) => {
this.config = {
...this.config,
remove: (e.target as HTMLInputElement).value,
};
}}" />
</div>
<!-- Checkboxes -->
<div class="form-control mb-3">
<label class="label cursor-pointer">
<input
type="checkbox"
name="refresh"
class="checkbox"
.checked="${this.config.refresh ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
refresh: (e.target as HTMLInputElement).checked,
};
}}" />
强制重新获取订阅
</label>
</div>
<div class="form-control mb-3">
<label class="label cursor-pointer">
<input
type="checkbox"
name="nodeList"
class="checkbox"
.checked="${this.config.nodeList ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
nodeList: (e.target as HTMLInputElement).checked,
};
}}" />
输出为 Node List
</label>
</div>
<div class="form-control mb-3">
<label class="label cursor-pointer">
<input
type="checkbox"
name="autoTest"
class="checkbox"
.checked="${this.config.autoTest ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
autoTest: (e.target as HTMLInputElement).checked,
};
}}" />
国家策略组自动测速
</label>
</div>
<div class="form-control mb-3">
<label class="label cursor-pointer">
<input
type="checkbox"
name="lazy"
class="checkbox"
.checked="${this.config.lazy ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
lazy: (e.target as HTMLInputElement).checked,
};
}}" />
自动测速启用 lazy 模式
</label>
</div>
<div class="form-control mb-3">
<label class="label cursor-pointer">
<input
type="checkbox"
name="igcg"
class="checkbox"
.checked="${this.config.ignoreCountryGroup ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
ignoreCountryGroup: (e.target as HTMLInputElement)
.checked,
};
}}" />
不输出国家策略组
</label>
</div>
<div class="form-control mb-5">
<label class="label cursor-pointer">
<input
type="checkbox"
name="useUDP"
class="checkbox"
.checked="${this.config.useUDP ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
useUDP: (e.target as HTMLInputElement).checked,
};
}}" />
使用 UDP
</label>
</div>
<rule-provider-input
@change="${(e: CustomEvent<Array<RuleProvider>>) => {
this.config = {
...this.config,
ruleProviders: e.detail,
};
}}"></rule-provider-input>
<rule-input
@change="${(e: CustomEvent<Array<Rule>>) => {
this.config = {
...this.config,
rules: e.detail,
};
}}"></rule-input>
<rename-input
@change="${(e: CustomEvent<{ [key: string]: string }>) => {
this.config = {
...this.config,
replace: e.detail,
};
}}"></rename-input>
</fieldset>
<fieldset class="fieldset mb-8">
<legend class="fieldset-legend text-2xl font-semibold mb-4">
输出配置
</legend>
<!-- Display the API Link -->
<div class="form-control mb-5">
<div class="join w-full mb-2">
<input
class="input input-bordered w-full join-item cursor-not-allowed"
type="text"
placeholder="链接"
.value="${this.configUrl}"
readonly />
<button
class="btn btn-primary join-item"
@click="${(e: Event) => {
this.copyToClipboard(
this.configUrl,
e.target as HTMLButtonElement
);
}}"
type="button">
复制链接
</button>
</div>
</div>
<short-link-input-group
.id="${this.shortLinkID}"
.passwd="${this.shortLinkPasswd}"
@id-change="${(e: Event) => {
this.shortLinkID = (e.target as HTMLInputElement).value;
}}"
@passwd-change="${(e: Event) => {
this.shortLinkPasswd = (e.target as HTMLInputElement).value;
}}"
@generate-btn-click="${this.generateShortLink}"
@update-btn-click="${this.updateShortLink}"
@delete-btn-click="${this.deleteShortLink}">
</short-link-input-group>
</fieldset>
</form>
</div>
<footer class="footer footer-horizontal footer-center mb-8">
<aside>
<p>
Powered by
<a class="link" href="https://github.com/bestnite/sub2clash"
>sub2clash</a
>
</p>
<p>Version: ${import.meta.env.VITE_APP_VERSION ?? "dev"}</p>
</aside>
</footer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"sub2clash-app": Sub2clashApp;
}
}
@@ -0,0 +1,101 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, state } from "lit/decorators.js";
import globalStyles from "../index.css?inline";
import type { Rename } from "../interface";
@customElement("rename-input")
export class RenameInput extends LitElement {
static styles = [unsafeCSS(globalStyles)];
private _replaceArray: Array<Rename> = [];
@state()
set replaceArray(value: Array<Rename>) {
this._replaceArray = value;
let updatedReplaceMap: { [key: string]: string } = {};
value.forEach((e) => {
updatedReplaceMap[e.old] = e.new;
});
this.dispatchEvent(
new CustomEvent("change", {
detail: updatedReplaceMap,
})
);
}
get replaceArray(): Array<Rename> {
return this._replaceArray;
}
render() {
return html`<!-- Rename -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1">
<span class="label-text">节点名称替换</span>
<button
class="btn btn-primary btn-xs"
type="button"
@click="${() => {
let updatedReplaceArray = [...this.replaceArray];
updatedReplaceArray.push({ old: "", new: "" });
this.replaceArray = updatedReplaceArray;
}}">
+
</button>
</label>
</div>
<div class="mb-3">
${this.replaceArray.map((_, i) => this.RenameTemplate(i))}
</div>`;
}
RenameTemplate(index: number) {
const replaceItem = this.replaceArray[index];
return html`<div class="join mb-1">
<input
class="input join-item"
placeholder="旧名称 (正则表达式)"
.value="${replaceItem?.old ?? ""}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedReplaceArray = [...this.replaceArray];
updatedReplaceArray[index] = {
...updatedReplaceArray[index],
old: target.value,
};
this.replaceArray = updatedReplaceArray;
}}" />
<input
class="input join-item"
placeholder="新名称"
.value="${replaceItem?.new ?? ""}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedReplaceArray = [...this.replaceArray];
updatedReplaceArray[index] = {
...updatedReplaceArray[index],
new: target.value,
};
this.replaceArray = updatedReplaceArray;
}}" />
<button
class="btn join-item bg-error"
type="button"
@click="${() => {
let updatedReplaceArray = this.replaceArray.filter(
(_, i) => i !== index
);
this.replaceArray = updatedReplaceArray;
}}">
删除
</button>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"rename-input": RenameInput;
}
}
@@ -0,0 +1,92 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { Rule } from "../interface";
import globalStyles from "../index.css?inline";
@customElement("rule-input")
export class RuleInput extends LitElement {
static styles = [unsafeCSS(globalStyles)];
_rules: Array<Rule> = [];
@state()
set rules(value: Array<Rule>) {
this.dispatchEvent(
new CustomEvent("change", {
detail: value,
})
);
this._rules = value;
}
get rules() {
return this._rules;
}
render() {
return html`<!-- Rule -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1">
<span class="label-text">规则</span>
<button
class="btn btn-primary btn-xs"
type="button"
@click="${() => {
let updatedRules = this.rules ? [...this.rules] : [];
updatedRules?.push({
rule: "",
prepend: false,
});
this.rules = updatedRules;
}}">
+
</button>
</label>
</div>
<div class="mb-3">
${this.rules?.map((_, i) => this.RuleTemplate(i))}
</div>`;
}
RuleTemplate(index: number) {
return html`<div class="join mb-1">
<input
class="input join-item"
placeholder="规则"
.value="${this.rules![index].rule}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRules = this.rules;
updatedRules![index].rule = target.value;
this.rules = updatedRules;
}}" />
<div class="tooltip" data-tip="是否置于规则列表最前">
<select
class="select join-item w-fit"
.value="${String(this.rules![index].prepend)}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRules = this.rules;
updatedRules![index].prepend = Boolean(target.value);
this.rules = updatedRules;
}}">
<option value="true">是</option>
<option value="false" selected>否</option>
</select>
</div>
<button
class="btn join-item bg-error"
type="button"
@click="${() => {
let updatedRules = this.rules?.filter((_, i) => i !== index);
this.rules = updatedRules;
}}">
删除
</button>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"rule-input": RuleInput;
}
}
@@ -0,0 +1,143 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { RuleProvider } from "../interface";
import globalStyles from "../index.css?inline";
@customElement("rule-provider-input")
export class RuleProviderInput extends LitElement {
static styles = [unsafeCSS(globalStyles)];
_ruleProviders: Array<RuleProvider> = [];
@state()
set ruleProviders(value) {
this.dispatchEvent(
new CustomEvent("change", {
detail: value,
})
);
this._ruleProviders = value;
}
get ruleProviders() {
return this._ruleProviders;
}
RuleProviderTemplate(index: number) {
return html`
<div class="join mb-1">
<div class="tooltip" data-tip="不能重复">
<input
class="input join-item"
placeholder="名称"
.value="${this.ruleProviders![index].name}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRuleProviders = this.ruleProviders;
updatedRuleProviders![index].name = target.value;
this.ruleProviders = updatedRuleProviders;
}}" />
</div>
<div class="tooltip" data-tip="类型">
<select
class="select join-item w-fit"
.value="${this.ruleProviders![index].behavior}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRuleProviders = this.ruleProviders;
updatedRuleProviders![index].behavior = target.value;
this.ruleProviders = updatedRuleProviders;
}}">
<option value="classical" selected>classical</option>
<option value="domain">domain</option>
<option value="ipcidr">ipcidr</option>
</select>
</div>
<div>
<input
class="input join-item"
placeholder="Url"
.value="${this.ruleProviders![index].url}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRuleProviders = this.ruleProviders;
updatedRuleProviders![index].url = target.value;
this.ruleProviders = updatedRuleProviders;
}}" />
</div>
<input
class="input join-item"
placeholder="出站策略组"
.value="${this.ruleProviders![index].group}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRuleProviders = this.ruleProviders;
updatedRuleProviders![index].group = target.value;
this.ruleProviders = updatedRuleProviders;
}}" />
<div class="tooltip" data-tip="是否置于规则列表最前">
<select
class="select join-item w-fit"
.value="${String(this.ruleProviders![index].prepend)}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRuleProviders = this.ruleProviders;
updatedRuleProviders![index].prepend = Boolean(target.value);
this.ruleProviders = updatedRuleProviders;
}}">
<option value="true">是</option>
<option value="false" selected>否</option>
</select>
</div>
<button
class="btn join-item bg-error"
type="button"
@click="${() => {
let updatedRuleProviders = this.ruleProviders?.filter(
(_, i) => i !== index
);
this.ruleProviders = updatedRuleProviders;
}}">
删除
</button>
</div>
`;
}
render() {
return html` <!-- Rule Provider -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1">
<span class="label-text">Rule Provider</span>
<button
class="btn btn-primary btn-xs"
type="button"
@click="${() => {
let updatedRuleProviders = this.ruleProviders
? [...this.ruleProviders]
: [];
updatedRuleProviders.push({
behavior: "classical",
url: "",
name: "",
prepend: false,
group: "",
});
this.ruleProviders = updatedRuleProviders;
}}">
+
</button>
</label>
</div>
<div class="mb-3">
${this.ruleProviders?.map((_, i) => this.RuleProviderTemplate(i))}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"rule-provider-input": RuleProviderInput;
}
}
@@ -0,0 +1,180 @@
import { html, LitElement, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import globalStyles from "../index.css?inline";
@customElement("short-link-input-group")
export class ShortLinkInputGroup extends LitElement {
static styles = unsafeCSS(globalStyles);
@property()
id: string = "";
@property({ type: Number })
_screenSizeLevel: number = 0;
@property()
passwd: string = "";
connectedCallback() {
super.connectedCallback();
window.addEventListener("resize", this._checkScreenSize);
this._checkScreenSize(); // Initial check
}
disconnectedCallback() {
window.removeEventListener("resize", this._checkScreenSize);
super.disconnectedCallback();
}
_checkScreenSize = () => {
const width = window.innerWidth;
if (width < 365) {
this._screenSizeLevel = 0; // sm
} else if (width < 640) {
this._screenSizeLevel = 1; // md
} else {
this._screenSizeLevel = 2; // other
}
};
async copyToClipboard(content: string, e: HTMLButtonElement) {
try {
await navigator.clipboard.writeText(content);
let text = e.textContent;
e.addEventListener("mouseout", function () {
e.textContent = text;
});
e.textContent = "复制成功";
} catch (err) {
console.error("复制到剪贴板失败:", err);
}
}
idInputTemplate() {
return html`<input
class="input input-bordered w-1/2 join-item"
type="text"
placeholder="ID(可选)"
.value="${this.id}"
@change="${(e: Event) => {
this.id = (e.target as HTMLInputElement).value;
this.dispatchEvent(
new CustomEvent("id-change", {
detail: this.id,
})
);
}}" />`;
}
passwdInputTemplate() {
return html`<input
class="input input-bordered w-1/2 join-item"
type="text"
placeholder="密码"
.value="${this.passwd}"
@change="${(e: Event) => {
this.passwd = (e.target as HTMLInputElement).value;
this.dispatchEvent(
new CustomEvent("passwd-change", {
detail: this.passwd,
})
);
}}" />`;
}
generateBtnTemplate(extraClass: string = "") {
return html`<button
class="btn btn-primary join-item ${extraClass}"
type="button"
@click="${(e: Event) => {
this.dispatchEvent(
new CustomEvent("generate-btn-click", { detail: e })
);
}}">
生成短链
</button>`;
}
updateBtnTemplate(extraClass: string = "") {
return html`<button
class="btn btn-primary join-item ${extraClass}"
@click="${(e: Event) => {
this.dispatchEvent(new CustomEvent("update-btn-click", { detail: e }));
}}"
type="button">
更新短链
</button>`;
}
deleteBtnTemplate(extraClass: string = "") {
return html`<button
class="btn btn-primary join-item ${extraClass}"
@click="${(e: Event) => {
this.dispatchEvent(new CustomEvent("delete-btn-click", { detail: e }));
}}"
type="button">
删除短链
</button>`;
}
copyBtnTemplate(extraClass: string = "") {
return html`<button
class="btn btn-primary join-item ${extraClass}"
type="button"
@click="${(e: Event) => {
this.copyToClipboard(
`${window.location.origin}${window.location.pathname}s/${this.id}?password=${this.passwd}`,
e.target as HTMLButtonElement
);
}}">
复制短链
</button>`;
}
render() {
const sm = html`<div class="form-control mb-2">
<div class="join w-full mb-1">
${this.idInputTemplate()} ${this.passwdInputTemplate()}
</div>
<div class="join w-full mb-1">
${this.generateBtnTemplate("w-1/2")} ${this.updateBtnTemplate("w-1/2")}
</div>
<div class="join w-full">
${this.deleteBtnTemplate("w-1/2")} ${this.copyBtnTemplate("w-1/2")}
</div>
</div>`;
const md = html`<div class="form-control mb-2">
<div class="join w-full mb-1">
${this.idInputTemplate()} ${this.passwdInputTemplate()}
</div>
<div class="join w-full">
${this.generateBtnTemplate("w-1/4")} ${this.updateBtnTemplate("w-1/4")}
${this.deleteBtnTemplate("w-1/4")} ${this.copyBtnTemplate("w-1/4")}
</div>
</div>`;
const other = html`<div class="form-control mb-2">
<div class="join w-full">
${this.idInputTemplate()} ${this.passwdInputTemplate()}
${this.generateBtnTemplate()} ${this.updateBtnTemplate()}
${this.deleteBtnTemplate()} ${this.copyBtnTemplate()}
</div>
</div>`;
switch (this._screenSizeLevel) {
case 0:
return sm;
case 1:
return md;
default:
return other;
}
}
}
declare global {
interface HTMLElementTagNameMap {
"short-link-input-group": ShortLinkInputGroup;
}
}
+72
View File
@@ -0,0 +1,72 @@
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "light";
default: false;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0.001 106.423);
--color-base-200: oklch(97% 0.001 106.424);
--color-base-300: oklch(92% 0.003 48.717);
--color-base-content: oklch(21% 0.006 56.043);
--color-primary: oklch(54% 0.281 293.009);
--color-primary-content: oklch(96% 0.016 293.756);
--color-secondary: oklch(57% 0.245 27.325);
--color-secondary-content: oklch(97% 0.013 17.38);
--color-accent: oklch(59% 0.249 0.584);
--color-accent-content: oklch(97% 0.014 343.198);
--color-neutral: oklch(14% 0.004 49.25);
--color-neutral-content: oklch(98% 0.001 106.423);
--color-info: oklch(78% 0.154 211.53);
--color-info-content: oklch(30% 0.056 229.695);
--color-success: oklch(79% 0.209 151.711);
--color-success-content: oklch(26% 0.065 152.934);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(27% 0.077 45.635);
--color-error: oklch(71% 0.194 13.428);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 1rem;
--radius-field: 1rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1.5px;
--depth: 0;
--noise: 0;
}
@plugin "daisyui/theme" {
name: "dark";
default: true;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(21% 0.006 285.885);
--color-base-200: oklch(21% 0.006 285.885);
--color-base-300: oklch(27% 0.006 286.033);
--color-base-content: oklch(96% 0.001 286.375);
--color-primary: oklch(55% 0.288 302.321);
--color-primary-content: oklch(97% 0.014 308.299);
--color-secondary: oklch(44% 0.03 256.802);
--color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(59% 0.249 0.584);
--color-accent-content: oklch(97% 0.014 343.198);
--color-neutral: oklch(37% 0.013 285.805);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(54% 0.245 262.881);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(64% 0.2 131.684);
--color-success-content: oklch(98% 0.031 120.757);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 1rem;
--radius-field: 1rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 1;
}
+36
View File
@@ -0,0 +1,36 @@
export interface RuleProvider {
behavior: string;
url: string;
group: string;
prepend: boolean;
name: string;
}
export interface Rule {
rule: string;
prepend: boolean;
}
export interface Rename {
old: string;
new: string;
}
export interface Config {
clashType: number;
subscriptions?: string[];
proxies?: string[];
userAgent?: string;
refresh?: boolean;
autoTest?: boolean;
lazy?: boolean;
nodeList?: boolean;
ignoreCountryGroup?: boolean;
useUDP?: boolean;
template?: string;
ruleProviders?: RuleProvider[];
rules?: Rule[];
sort?: string;
remove?: string;
replace?: { [key: string]: string };
}
+15
View File
@@ -0,0 +1,15 @@
export function base64EncodeUnicode(str: string) {
const bytes = new TextEncoder().encode(str);
let binary = "";
bytes.forEach((b) => (binary += String.fromCharCode(b)));
return btoa(binary);
}
export function base64decodeUnicode(str: string) {
const binaryString = atob(str);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"vite/client"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
});
+11 -6
View File
@@ -6,19 +6,26 @@ import (
"github.com/bestnite/sub2clash/common" "github.com/bestnite/sub2clash/common"
"github.com/bestnite/sub2clash/config" "github.com/bestnite/sub2clash/config"
M "github.com/bestnite/sub2clash/model" "github.com/bestnite/sub2clash/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func ConvertHandler(template string) func(c *gin.Context) { func ConvertHandler() func(c *gin.Context) {
return func(c *gin.Context) { return func(c *gin.Context) {
query, err := M.ParseConvertQuery(c) query, err := model.ParseConvertQuery(c)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, err.Error()) c.String(http.StatusBadRequest, err.Error())
return return
} }
template := ""
switch query.ClashType {
case model.Clash:
template = config.GlobalConfig.ClashTemplate
case model.ClashMeta:
template = config.GlobalConfig.MetaTemplate
}
sub, err := common.BuildSub(query.ClashType, query, template, config.GlobalConfig.CacheExpire, config.GlobalConfig.RequestRetryTimes) sub, err := common.BuildSub(query.ClashType, query, template, config.GlobalConfig.CacheExpire, config.GlobalConfig.RequestRetryTimes)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, err.Error()) c.String(http.StatusInternalServerError, err.Error())
@@ -33,9 +40,7 @@ func ConvertHandler(template string) func(c *gin.Context) {
} }
if query.NodeListMode { if query.NodeListMode {
nodelist := M.NodeList{} marshal, err := sub.MarshalNodeListYAML()
nodelist.Proxy = sub.Proxy
marshal, err := yaml.Marshal(nodelist)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error()) c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
return return
+105 -75
View File
@@ -1,9 +1,10 @@
package handler package handler
import ( import (
"fmt" "encoding/json"
"io" "log"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@@ -13,58 +14,61 @@ import (
"github.com/bestnite/sub2clash/model" "github.com/bestnite/sub2clash/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
) )
type shortLinkGenRequset struct { type shortLinkGenRequset struct {
Url string `form:"url" binding:"required"` Config model.ConvertConfig `form:"config" binding:"required"`
Password string `form:"password"` Password string `form:"password"`
CustomID string `form:"customId"` ID string `form:"id"`
} }
type shortLinkUpdateRequest struct { type shortLinkUpdateRequest struct {
Hash string `form:"hash" binding:"required"` Config model.ConvertConfig `form:"config" binding:"required"`
Url string `form:"url" binding:"required"` Password string `form:"password" binding:"required"`
Password string `form:"password" binding:"required"` ID string `form:"id" binding:"required"`
} }
func respondWithError(c *gin.Context, code int, message string) { var DB *database.Database
c.String(code, message)
c.Abort() func init() {
var err error
DB, err = database.ConnectDB()
if err != nil {
log.Printf("failed to connect to database: %v", err)
os.Exit(1)
}
} }
func GenerateLinkHandler(c *gin.Context) { func GenerateLinkHandler(c *gin.Context) {
var params shortLinkGenRequset var params shortLinkGenRequset
if err := c.ShouldBind(&params); err != nil { if err := c.ShouldBind(&params); err != nil {
respondWithError(c, http.StatusBadRequest, "参数错误: "+err.Error()) c.String(http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if strings.TrimSpace(params.Url) == "" {
respondWithError(c, http.StatusBadRequest, "URL 不能为空")
return return
} }
var hash string var id string
var password string var password string
var err error var err error
if params.CustomID != "" { if params.ID != "" {
// 检查自定义ID是否已存在 // 检查自定义ID是否已存在
exists, err := database.CheckShortLinkHashExists(params.CustomID) exists, err := DB.CheckShortLinkIDExists(params.ID)
if err != nil { if err != nil {
respondWithError(c, http.StatusInternalServerError, "数据库错误") c.String(http.StatusInternalServerError, "数据库错误")
return return
} }
if exists { if exists {
respondWithError(c, http.StatusBadRequest, "短链已存在") c.String(http.StatusBadRequest, "短链已存在")
return return
} }
hash = params.CustomID id = params.ID
password = params.Password password = params.Password
} else { } else {
// 自动生成短链ID和密码 // 自动生成短链ID和密码
hash, err = generateUniqueHash(config.GlobalConfig.ShortLinkLength) id, err = generateUniqueHash(config.GlobalConfig.ShortLinkLength)
if err != nil { if err != nil {
respondWithError(c, http.StatusInternalServerError, "生成短链失败") c.String(http.StatusInternalServerError, "生成短链失败")
return return
} }
if params.Password == "" { if params.Password == "" {
@@ -75,19 +79,19 @@ func GenerateLinkHandler(c *gin.Context) {
} }
shortLink := model.ShortLink{ shortLink := model.ShortLink{
Hash: hash, ID: id,
Url: params.Url, Config: params.Config,
Password: password, Password: password,
} }
if err := database.SaveShortLink(&shortLink); err != nil { if err := DB.CreateShortLink(&shortLink); err != nil {
respondWithError(c, http.StatusInternalServerError, "数据库错误") c.String(http.StatusInternalServerError, "数据库错误")
return return
} }
// 返回生成的短链ID和密码 // 返回生成的短链ID和密码
response := map[string]string{ response := map[string]string{
"hash": hash, "id": id,
"password": password, "password": password,
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
@@ -96,7 +100,7 @@ func GenerateLinkHandler(c *gin.Context) {
func generateUniqueHash(length int) (string, error) { func generateUniqueHash(length int) (string, error) {
for { for {
hash := common.RandomString(length) hash := common.RandomString(length)
exists, err := database.CheckShortLinkHashExists(hash) exists, err := DB.CheckShortLinkIDExists(hash)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -109,112 +113,138 @@ func generateUniqueHash(length int) (string, error) {
func UpdateLinkHandler(c *gin.Context) { func UpdateLinkHandler(c *gin.Context) {
var params shortLinkUpdateRequest var params shortLinkUpdateRequest
if err := c.ShouldBindJSON(&params); err != nil { if err := c.ShouldBindJSON(&params); err != nil {
respondWithError(c, http.StatusBadRequest, "参数错误: "+err.Error()) c.String(http.StatusBadRequest, "参数错误: "+err.Error())
return return
} }
// 先获取原有的短链 // 先获取原有的短链
existingLink, err := database.FindShortLinkByHash(params.Hash) existingLink, err := DB.FindShortLinkByID(params.ID)
if err != nil { if err != nil {
respondWithError(c, http.StatusNotFound, "未找到短链接") c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return return
} }
// 验证密码 // 验证密码
if existingLink.Password != params.Password { if existingLink.Password != params.Password {
respondWithError(c, http.StatusUnauthorized, "密码错误") c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return return
} }
// 更新URL,但保持原密码不变 jsonData, err := json.Marshal(params.Config)
shortLink := model.ShortLink{ if err != nil {
Hash: params.Hash, c.String(http.StatusBadRequest, "配置格式错误")
Url: params.Url, return
Password: existingLink.Password, // 保持原密码不变
} }
if err := DB.UpdataShortLink(params.ID, "config", jsonData); err != nil {
if err := database.SaveShortLink(&shortLink); err != nil { c.String(http.StatusInternalServerError, "数据库错误")
respondWithError(c, http.StatusInternalServerError, "数据库错误")
return return
} }
c.String(http.StatusOK, "短链更新成功") c.String(http.StatusOK, "短链更新成功")
} }
func GetRawConfHandler(c *gin.Context) { func GetRawConfHandler(c *gin.Context) {
hash := c.Param("hash") id := c.Param("id")
password := c.Query("password") password := c.Query("password")
if strings.TrimSpace(hash) == "" { if strings.TrimSpace(id) == "" {
c.String(http.StatusBadRequest, "参数错误") c.String(http.StatusBadRequest, "参数错误")
return return
} }
shortLink, err := database.FindShortLinkByHash(hash) shortLink, err := DB.FindShortLinkByID(id)
if err != nil { if err != nil {
c.String(http.StatusNotFound, "未找到短链接或密码错误") c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return return
} }
if shortLink.Password != "" && shortLink.Password != password { if shortLink.Password != "" && shortLink.Password != password {
c.String(http.StatusNotFound, "未找到短链接或密码错误") c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return return
} }
shortLink.LastRequestTime = time.Now().Unix() err = DB.UpdataShortLink(shortLink.ID, "last_request_time", time.Now().Unix())
err = database.SaveShortLink(shortLink)
if err != nil { if err != nil {
respondWithError(c, http.StatusInternalServerError, "数据库错误") c.String(http.StatusInternalServerError, "数据库错误")
return return
} }
scheme := "http" template := ""
if c.Request.TLS != nil { switch shortLink.Config.ClashType {
scheme = "https" case model.Clash:
template = config.GlobalConfig.ClashTemplate
case model.ClashMeta:
template = config.GlobalConfig.MetaTemplate
} }
host := c.Request.Host sub, err := common.BuildSub(shortLink.Config.ClashType, shortLink.Config, template, config.GlobalConfig.CacheExpire, config.GlobalConfig.RequestRetryTimes)
targetPath := strings.TrimPrefix(shortLink.Url, "/")
requestURL := fmt.Sprintf("%s://%s/%s", scheme, host, targetPath)
client := &http.Client{
Timeout: 30 * time.Second, // 30秒超时
}
response, err := client.Get(requestURL)
if err != nil { if err != nil {
respondWithError(c, http.StatusInternalServerError, "请求错误: "+err.Error()) c.String(http.StatusInternalServerError, err.Error())
return return
} }
defer response.Body.Close()
all, err := io.ReadAll(response.Body) if len(shortLink.Config.Subs) == 1 {
userInfoHeader, err := common.FetchSubscriptionUserInfo(shortLink.Config.Subs[0], "clash", config.GlobalConfig.RequestRetryTimes)
if err == nil {
c.Header("subscription-userinfo", userInfoHeader)
}
}
if shortLink.Config.NodeListMode {
marshal, err := sub.MarshalNodeListYAML()
if err != nil {
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
return
}
c.String(http.StatusOK, string(marshal))
return
}
marshal, err := yaml.Marshal(sub)
if err != nil { if err != nil {
respondWithError(c, http.StatusInternalServerError, "读取错误: "+err.Error()) c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
return return
} }
c.String(http.StatusOK, string(all)) c.String(http.StatusOK, string(marshal))
} }
func GetRawConfUriHandler(c *gin.Context) { func GetRawConfUriHandler(c *gin.Context) {
hash := c.Query("hash") id := c.Param("id")
password := c.Query("password") password := c.Query("password")
if strings.TrimSpace(hash) == "" { if strings.TrimSpace(id) == "" {
c.String(http.StatusBadRequest, "参数错误") c.String(http.StatusBadRequest, "参数错误")
return return
} }
shortLink, err := database.FindShortLinkByHash(hash) shortLink, err := DB.FindShortLinkByID(id)
if err != nil { if err != nil {
c.String(http.StatusNotFound, "未找到短链接或密码错误") c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return return
} }
if shortLink.Password != "" && shortLink.Password != password { if shortLink.Password != "" && shortLink.Password != password {
c.String(http.StatusNotFound, "未找到短链接或密码错误") c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return return
} }
c.String(http.StatusOK, shortLink.Url) c.JSON(http.StatusOK, shortLink.Config)
}
func DeleteShortLinkHandler(c *gin.Context) {
id := c.Param("id")
password := c.Query("password")
shortLink, err := DB.FindShortLinkByID(id)
if err != nil {
c.String(http.StatusBadRequest, "短链不存在或密码错误")
return
}
if shortLink.Password != password {
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
err = DB.DeleteShortLink(id)
if err != nil {
c.String(http.StatusInternalServerError, "删除失败", err)
}
} }
+14 -29
View File
@@ -2,46 +2,31 @@ package server
import ( import (
"embed" "embed"
"html/template"
"log"
"net/http" "net/http"
"github.com/bestnite/sub2clash/config"
"github.com/bestnite/sub2clash/constant"
"github.com/bestnite/sub2clash/server/handler" "github.com/bestnite/sub2clash/server/handler"
"github.com/bestnite/sub2clash/server/middleware" "github.com/bestnite/sub2clash/server/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
//go:embed static //go:embed frontend/dist
var staticFiles embed.FS var staticFiles embed.FS
func SetRoute(r *gin.Engine) { func SetRoute(r *gin.Engine) {
tpl, err := template.ParseFS(staticFiles, "static/*") r.GET("/convert/:config", middleware.ZapLogger(), handler.ConvertHandler())
if err != nil { r.GET("/s/:id", middleware.ZapLogger(), handler.GetRawConfHandler)
log.Fatalf("Error parsing templates: %v", err)
}
r.SetHTMLTemplate(tpl)
r.GET(
"/static/*filepath", func(c *gin.Context) {
c.FileFromFS("static/"+c.Param("filepath"), http.FS(staticFiles))
},
)
r.GET(
"/", func(c *gin.Context) {
version := constant.Version
c.HTML(
200, "index.html", gin.H{
"Version": version,
},
)
},
)
r.GET("/convert/:config", middleware.ZapLogger(), handler.ConvertHandler(config.GlobalConfig.ClashTemplate))
r.GET("/s/:hash", middleware.ZapLogger(), handler.GetRawConfHandler)
r.POST("/short", middleware.ZapLogger(), handler.GenerateLinkHandler) r.POST("/short", middleware.ZapLogger(), handler.GenerateLinkHandler)
r.PUT("/short", middleware.ZapLogger(), handler.UpdateLinkHandler) r.PUT("/short", middleware.ZapLogger(), handler.UpdateLinkHandler)
r.GET("/short", middleware.ZapLogger(), handler.GetRawConfUriHandler) r.GET("/short/:id", middleware.ZapLogger(), handler.GetRawConfUriHandler)
r.DELETE("/short/:id", middleware.ZapLogger(), handler.DeleteShortLinkHandler)
r.GET("/", func(c *gin.Context) {
c.FileFromFS("frontend/dist/", http.FS(staticFiles))
})
r.GET(
"/assets/*filepath", func(c *gin.Context) {
c.FileFromFS("frontend/dist/assets/"+c.Param("filepath"), http.FS(staticFiles))
},
)
} }
-3
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-219
View File
@@ -1,219 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="light">
<head>
<meta charset="UTF-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>sub2clash</title>
<!-- Bootstrap CSS -->
<link href="./static/bootstrap.min.css" rel="stylesheet" />
<!-- Bootstrap JS -->
<script src="./static/bootstrap.bundle.min.js"></script>
<!-- Axios -->
<script src="./static/axios.min.js"></script>
<style>
.container {
max-width: 800px;
}
.btn-xs {
padding: 2px 2px;
/* 调整内边距以减小按钮大小 */
font-size: 10px;
/* 设置字体大小 */
line-height: 1.2;
/* 调整行高 */
border-radius: 3px;
/* 可选的边框半径调整 */
height: 25px;
width: 25px;
}
/* 主题切换按钮样式 */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
</style>
</head>
<body>
<!-- 主题切换按钮 -->
<button class="theme-toggle btn btn-outline-secondary" onclick="toggleTheme()" title="切换深色/浅色模式">
<span id="theme-icon">🌙</span>
</button>
<div class="container mt-5">
<div class="mb-4">
<h2>sub2clash</h2>
<span class="text-muted fst-italic">通用订阅链接转 Clash(Meta) 配置工具
<a href="https://github.com/bestnite/sub2clash#clash-meta" target="_blank">使用文档</a></span><br /><span
class="text-muted fst-italic">注意:本程序非纯前端程序,输入的订阅将被后端缓存,请确保您信任当前站点</span>
</div>
<!-- Input URL -->
<div class="form-group mb-5">
<label for="apiLink">解析链接:</label>
<div class="input-group mb-2">
<input class="form-control" id="urlInput" type="text" placeholder="通过生成的链接重新填写下方设置" />
<button class="btn btn-primary" onclick="parseInputURL()" type="button">
解析
</button>
</div>
</div>
<!-- API Endpoint -->
<div class="form-group mb-3">
<label for="endpoint">客户端类型:</label>
<select class="form-control" id="endpoint" name="endpoint">
<option value="1">Clash</option>
<option value="2" selected>Clash.Meta</option>
</select>
</div>
<!-- Template -->
<div class="form-group mb-3">
<label for="template">模板链接或名称:</label>
<input class="form-control" id="template" name="template" placeholder="输入外部模板链接或内部模板名称(可选)" type="text" />
</div>
<!-- Subscription Link -->
<div class="form-group mb-3">
<label for="sub">订阅链接:</label>
<textarea class="form-control" id="sub" name="sub" placeholder="每行输入一个订阅链接" rows="5"></textarea>
</div>
<!-- Proxy Link -->
<div class="form-group mb-3">
<label for="proxy">节点分享链接:</label>
<textarea class="form-control" id="proxy" name="proxy" placeholder="每行输入一个节点分享链接" rows="5"></textarea>
</div>
<!-- User Agent -->
<div class="form-group mb-3">
<label for="user-agent">UA 标识:</label>
<textarea class="form-control" id="user-agent" name="user-agent"
placeholder="用于获取订阅的 http 请求中的 User-Agent 标识(可选)" rows="3"></textarea>
</div>
<!-- Refresh -->
<div class="form-check mb-3">
<input class="form-check-input" id="refresh" name="refresh" type="checkbox" />
<label class="form-check-label" for="refresh">强制重新获取订阅</label>
</div>
<!-- Node List -->
<div class="form-check mb-3">
<input class="form-check-input" id="nodeList" name="nodeList" type="checkbox" />
<label class="form-check-label" for="nodeList">输出为 Node List</label>
</div>
<!-- Auto Test -->
<div class="form-check mb-3">
<input class="form-check-input" id="autoTest" name="autoTest" type="checkbox" />
<label class="form-check-label" for="autoTest">国家策略组自动测速</label>
</div>
<!-- Lazy -->
<div class="form-check mb-3">
<input class="form-check-input" id="lazy" name="lazy" type="checkbox" />
<label class="form-check-label" for="lazy">自动测速启用 lazy 模式</label>
</div>
<!-- IgnoreCountryGroup -->
<div class="form-check mb-3">
<input class="form-check-input" id="igcg" name="igcg" type="checkbox" />
<label class="form-check-label" for="igcg">不输出国家策略组</label>
</div>
<!-- Use UDP -->
<div class="form-check mb-3">
<input class="form-check-input" id="useUDP" name="useUDP" type="checkbox" />
<label class="form-check-label" for="useUDP">使用 UDP</label>
</div>
<!-- Rule Provider -->
<div class="form-group mb-3" id="ruleProviderGroup">
<label>Rule Provider:</label>
<button class="btn btn-primary mb-1 btn-xs" onclick="addRuleProvider()" type="button">
+
</button>
</div>
<!-- Rule -->
<div class="form-group mb-3" id="ruleGroup">
<label>规则:</label>
<button class="btn btn-primary mb-1 btn-xs" onclick="addRule()" type="button">
+
</button>
</div>
<!-- Sort -->
<div class="form-group mb-3">
<label for="sort">国家策略组排序规则:</label>
<select class="form-control" id="sort" name="sort">
<option value="nameasc">名称(升序)</option>
<option value="namedesc">名称(降序)</option>
<option value="sizeasc">节点数量(升序)</option>
<option value="sizedesc">节点数量(降序)</option>
</select>
</div>
<!-- Remove -->
<div class="form-group mb-3">
<label for="remove">排除节点:</label>
<input class="form-control" type="text" name="remove" id="remove" placeholder="正则表达式" />
</div>
<!-- Rename -->
<div class="form-group mb-3" id="replaceGroup">
<label>节点名称替换:</label>
<button class="btn btn-primary mb-1 btn-xs" onclick="addReplace()" type="button">
+
</button>
</div>
<!-- Display the API Link -->
<div class="form-group mb-5">
<label for="apiLink">配置链接:</label>
<div class="input-group mb-2">
<input class="form-control" id="apiLink" type="text" placeholder="链接" readonly
style="cursor: not-allowed;" />
<button class="btn btn-primary" onclick="generateURL()" type="button">生成配置</button>
<button class="btn btn-primary" onclick="copyToClipboard('apiLink',this)" type="button">
复制链接
</button>
</div>
<div class="input-group mb-2">
<input class="form-control" id="customId" type="text" placeholder="短链ID(可选)" />
<input class="form-control" id="password" type="text" placeholder="密码(可选)" />
<button class="btn btn-primary" onclick="generateShortLink()" type="button">
生成短链
</button>
<button class="btn btn-primary" onclick="copyToClipboard('apiShortLink',this)" type="button">
复制短链
</button>
</div>
<div class="input-group">
<input class="form-control" id="apiShortLink" type="text" placeholder="短链接" readonly
style="cursor: not-allowed;" />
<button class="btn btn-primary" onclick="updateShortLink()" type="button">
更新短链
</button>
</div>
</div>
<!-- footer-->
<footer>
<p class="text-center">
Powered by
<a class="link-primary" href="https://github.com/bestnite/sub2clash">sub2clash</a>
</p>
<p class="text-center">Version {{.Version}}</p>
</footer>
</div>
</body>
<script src="./static/index.js"></script>
</html>
-597
View File
@@ -1,597 +0,0 @@
function setInputReadOnly(input, readonly) {
if (readonly) {
input.readOnly = true;
input.style.cursor = 'not-allowed';
} else {
input.readOnly = false;
input.style.cursor = 'auto';
}
}
function clearExistingValues() {
// 清除简单输入框和复选框的值
document.getElementById("endpoint").value = "clash";
document.getElementById("sub").value = "";
document.getElementById("proxy").value = "";
document.getElementById("refresh").checked = false;
document.getElementById("autoTest").checked = false;
document.getElementById("lazy").checked = false;
document.getElementById("igcg").checked = false;
document.getElementById("useUDP").checked = false;
document.getElementById("template").value = "";
document.getElementById("sort").value = "nameasc";
document.getElementById("remove").value = "";
document.getElementById("apiLink").value = "";
document.getElementById("apiShortLink").value = "";
// 恢复短链ID和密码输入框状态
const customIdInput = document.getElementById("customId");
const passwordInput = document.getElementById("password");
const generateButton = document.querySelector('button[onclick="generateShortLink()"]');
customIdInput.value = "";
setInputReadOnly(customIdInput, false);
passwordInput.value = "";
setInputReadOnly(passwordInput, false);
// 恢复生成短链按钮状态
generateButton.disabled = false;
generateButton.classList.remove('btn-secondary');
generateButton.classList.add('btn-primary');
document.getElementById("nodeList").checked = false;
// 清除由 createRuleProvider, createReplace, 和 createRule 创建的所有额外输入组
clearInputGroup("ruleProviderGroup");
clearInputGroup("replaceGroup");
clearInputGroup("ruleGroup");
}
function generateURI() {
const config = {};
config.clashType = parseInt(document.getElementById("endpoint").value);
let subLines = document
.getElementById("sub")
.value.split("\n")
.filter((line) => line.trim() !== "");
if (subLines.length > 0) {
config.subscriptions = subLines;
}
let proxyLines = document
.getElementById("proxy")
.value.split("\n")
.filter((line) => line.trim() !== "");
if (proxyLines.length > 0) {
config.proxies = proxyLines;
}
if (
(config.subscriptions === undefined || config.subscriptions.length === 0) &&
(config.proxies === undefined || config.proxies.length === 0)
) {
return "";
}
config.userAgent = document.getElementById("user-agent").value;
config.refresh = document.getElementById("refresh").checked;
config.autoTest = document.getElementById("autoTest").checked;
config.lazy = document.getElementById("lazy").checked;
config.nodeList = document.getElementById("nodeList").checked;
config.ignoreCountryGroup = document.getElementById("igcg").checked;
config.useUDP = document.getElementById("useUDP").checked;
const template = document.getElementById("template").value;
if (template.trim() !== "") {
config.template = template;
}
const ruleProvidersElements = document.getElementsByName("ruleProvider");
if (ruleProvidersElements.length > 0) {
const ruleProviders = [];
for (let i = 0; i < ruleProvidersElements.length / 5; i++) {
let baseIndex = i * 5;
let behavior = ruleProvidersElements[baseIndex].value;
let url = ruleProvidersElements[baseIndex + 1].value;
let group = ruleProvidersElements[baseIndex + 2].value;
let prepend = ruleProvidersElements[baseIndex + 3].value;
let name = ruleProvidersElements[baseIndex + 4].value;
if (
behavior.trim() === "" ||
url.trim() === "" ||
group.trim() === "" ||
prepend.trim() === "" ||
name.trim() === ""
) {
return "";
}
ruleProviders.push({
behavior: behavior,
url: url,
group: group,
prepend: prepend.toLowerCase() === "true",
name: name,
});
}
if (ruleProviders.length > 0) {
config.ruleProviders = ruleProviders;
}
}
const rulesElements = document.getElementsByName("rule");
if (rulesElements.length > 0) {
const rules = [];
for (let i = 0; i < rulesElements.length / 2; i++) {
if (rulesElements[i * 2].value.trim() !== "") {
let rule = rulesElements[i * 2].value;
let prepend = rulesElements[i * 2 + 1].value;
if (rule.trim() === "" || prepend.trim() === "") {
return "";
}
rules.push({
rule: rule,
prepend: prepend.toLowerCase() === "true",
});
}
}
if (rules.length > 0) {
config.rules = rules;
}
}
config.sort = document.getElementById("sort").value;
const remove = document.getElementById("remove").value;
if (remove.trim() !== "") {
config.remove = remove;
}
const replacesElements = document.getElementsByName("replace");
if (replacesElements.length > 0) {
const replace = {};
for (let i = 0; i < replacesElements.length / 2; i++) {
let replaceStr = replacesElements[i * 2].value;
let replaceTo = replacesElements[i * 2 + 1].value;
if (replaceStr.trim() === "") {
return "";
}
replace[replaceStr] = replaceTo;
}
if (Object.keys(replace).length > 0) {
config.replace = replace;
}
}
const jsonString = JSON.stringify(config);
// 解决 btoa 中文报错,使用 TextEncoder 进行 UTF-8 编码再 base64
function base64EncodeUnicode(str) {
const bytes = new TextEncoder().encode(str);
let binary = '';
bytes.forEach((b) => binary += String.fromCharCode(b));
return btoa(binary);
}
const encoded = base64EncodeUnicode(jsonString);
const urlSafeBase64 = encoded
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return `convert/${urlSafeBase64}`;
}
// 将输入框中的 URL 解析为参数
async function parseInputURL() {
// 获取输入框中的 URL
const inputURL = document.getElementById("urlInput").value;
// 清除现有的输入框值
clearExistingValues();
if (!inputURL) {
alert("请输入有效的链接!");
return;
}
let url;
try {
url = new URL(inputURL);
} catch (_) {
alert("无效的链接!");
return;
}
if (url.pathname.includes("/s/")) {
let hash = url.pathname.substring(url.pathname.lastIndexOf("/s/") + 3);
let q = new URLSearchParams();
let password = url.searchParams.get("password");
if (password === null) {
alert("仅可解析加密短链");
return;
}
q.append("hash", hash);
q.append("password", password);
try {
const response = await axios.get("./short?" + q.toString());
url = new URL(response.data, window.location.href);
// 回显配置链接
const apiLinkInput = document.querySelector("#apiLink");
apiLinkInput.value = url.href;
setInputReadOnly(apiLinkInput, true);
// 回显短链相关信息
const apiShortLinkInput = document.querySelector("#apiShortLink");
apiShortLinkInput.value = inputURL;
setInputReadOnly(apiShortLinkInput, true);
// 设置短链ID和密码,并设置为只读
const customIdInput = document.querySelector("#customId");
const passwordInput = document.querySelector("#password");
const generateButton = document.querySelector('button[onclick="generateShortLink()"]');
customIdInput.value = hash;
setInputReadOnly(customIdInput, true);
passwordInput.value = password;
setInputReadOnly(passwordInput, true);
// 禁用生成短链按钮
generateButton.disabled = true;
generateButton.classList.add('btn-secondary');
generateButton.classList.remove('btn-primary');
} catch (error) {
console.log(error);
alert("获取短链失败,请检查密码!");
}
}
const pathSections = url.pathname.split("/");
const convertIndex = pathSections.findIndex((s) => s === "convert");
if (convertIndex === -1 || convertIndex + 1 >= pathSections.length) {
alert("无效的配置链接,请确认链接为新版格式。");
return;
}
const base64Config = pathSections[convertIndex + 1];
let config;
try {
const regularBase64 = base64Config.replace(/-/g, "+").replace(/_/g, "/");
const decodedStr = atob(regularBase64);
config = JSON.parse(decodeURIComponent(escape(decodedStr)));
} catch (e) {
alert("解析配置失败!");
console.error(e);
return;
}
document.getElementById("endpoint").value = config.clashType || "1";
if (config.subscriptions) {
document.getElementById("sub").value = config.subscriptions.join("\n");
}
if (config.proxies) {
document.getElementById("proxy").value = config.proxies.join("\n");
}
if (config.refresh) {
document.getElementById("refresh").checked = config.refresh;
}
if (config.autoTest) {
document.getElementById("autoTest").checked = config.autoTest;
}
if (config.lazy) {
document.getElementById("lazy").checked = config.lazy;
}
if (config.template) {
document.getElementById("template").value = config.template;
}
if (config.sort) {
document.getElementById("sort").value = config.sort;
}
if (config.remove) {
document.getElementById("remove").value = config.remove;
}
if (config.userAgent) {
document.getElementById("user-agent").value = config.userAgent;
}
if (config.ignoreCountryGroup) {
document.getElementById("igcg").checked = config.ignoreCountryGroup;
}
if (config.replace) {
const replaceGroup = document.getElementById("replaceGroup");
for (const original in config.replace) {
const div = createReplace();
div.children[0].value = original;
div.children[1].value = config.replace[original];
replaceGroup.appendChild(div);
}
}
if (config.ruleProviders) {
const ruleProviderGroup = document.getElementById("ruleProviderGroup");
for (const p of config.ruleProviders) {
const div = createRuleProvider();
div.children[0].value = p.behavior;
div.children[1].value = p.url;
div.children[2].value = p.group;
div.children[3].value = p.prepend;
div.children[4].value = p.name;
ruleProviderGroup.appendChild(div);
}
}
if (config.rules) {
const ruleGroup = document.getElementById("ruleGroup");
for (const r of config.rules) {
const div = createRule();
div.children[0].value = r.rule;
div.children[1].value = r.prepend;
ruleGroup.appendChild(div);
}
}
if (config.nodeList) {
document.getElementById("nodeList").checked = config.nodeList;
}
if (config.useUDP) {
document.getElementById("useUDP").checked = config.useUDP;
}
}
function clearInputGroup(groupId) {
// 清空第二个之后的child
const group = document.getElementById(groupId);
while (group.children.length > 2) {
group.removeChild(group.lastChild);
}
}
async function copyToClipboard(elem, e) {
const apiLinkInput = document.querySelector(`#${elem}`).value;
try {
await navigator.clipboard.writeText(apiLinkInput);
let text = e.textContent;
e.addEventListener("mouseout", function () {
e.textContent = text;
});
e.textContent = "复制成功";
} catch (err) {
console.error("复制到剪贴板失败:", err);
}
}
function createRuleProvider() {
const div = document.createElement("div");
div.classList.add("input-group", "mb-2");
div.innerHTML = `
<input type="text" class="form-control" name="ruleProvider" placeholder="Behavior">
<input type="text" class="form-control" name="ruleProvider" placeholder="Url">
<input type="text" class="form-control" name="ruleProvider" placeholder="Group">
<input type="text" class="form-control" name="ruleProvider" placeholder="Prepend">
<input type="text" class="form-control" name="ruleProvider" placeholder="Name">
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
`;
return div;
}
function createReplace() {
const div = document.createElement("div");
div.classList.add("input-group", "mb-2");
div.innerHTML = `
<input type="text" class="form-control" name="replace" placeholder="原字符串(正则表达式)">
<input type="text" class="form-control" name="replace" placeholder="替换为(可为空)">
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
`;
return div;
}
function createRule() {
const div = document.createElement("div");
div.classList.add("input-group", "mb-2");
div.innerHTML = `
<input type="text" class="form-control" name="rule" placeholder="Rule">
<input type="text" class="form-control" name="rule" placeholder="Prepend">
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
`;
return div;
}
function listenInput() {
let selectElements = document.querySelectorAll("select");
let inputElements = document.querySelectorAll("input");
let textAreaElements = document.querySelectorAll("textarea");
inputElements.forEach(function (element) {
element.addEventListener("input", function () {
generateURL();
});
});
textAreaElements.forEach(function (element) {
element.addEventListener("input", function () {
generateURL();
});
});
selectElements.forEach(function (element) {
element.addEventListener("change", function () {
generateURL();
});
});
}
function addRuleProvider() {
const div = createRuleProvider();
document.getElementById("ruleProviderGroup").appendChild(div);
listenInput();
}
function addRule() {
const div = createRule();
document.getElementById("ruleGroup").appendChild(div);
listenInput();
}
function addReplace() {
const div = createReplace();
document.getElementById("replaceGroup").appendChild(div);
listenInput();
}
function removeElement(button) {
button.parentElement.remove();
}
function generateURL() {
const apiLink = document.getElementById("apiLink");
let uri = generateURI();
if (uri === "") {
return;
}
apiLink.value = `${window.location.origin}${window.location.pathname}${uri}`;
setInputReadOnly(apiLink, true);
}
function generateShortLink() {
const apiShortLink = document.getElementById("apiShortLink");
const password = document.getElementById("password");
const customId = document.getElementById("customId");
let uri = generateURI();
if (uri === "") {
return;
}
axios
.post(
"./short",
{
url: uri,
password: password.value.trim(),
customId: customId.value.trim()
},
{
headers: {
"Content-Type": "application/json",
},
}
)
.then((response) => {
// 设置返回的短链ID和密码
customId.value = response.data.hash;
password.value = response.data.password;
// 生成完整的短链接
const shortLink = `${window.location.origin}${window.location.pathname}s/${response.data.hash}?password=${response.data.password}`;
apiShortLink.value = shortLink;
})
.catch((error) => {
console.log(error);
if (error.response && error.response.data) {
alert(error.response.data);
} else {
alert("生成短链失败,请重试!");
}
});
}
function updateShortLink() {
const password = document.getElementById("password");
const apiShortLink = document.getElementById("apiShortLink");
let hash = apiShortLink.value;
if (hash.startsWith("http")) {
let u = new URL(hash);
hash = u.pathname.substring(u.pathname.lastIndexOf("/s/") + 3);
}
if (password.value.trim() === "") {
alert("请输入原密码进行验证!");
return;
}
let uri = generateURI();
if (uri === "") {
return;
}
axios
.put(
"./short",
{
hash: hash,
url: uri,
password: password.value.trim(),
},
{
headers: {
"Content-Type": "application/json",
},
}
)
.then((response) => {
alert(`短链 ${hash} 更新成功!`);
})
.catch((error) => {
console.log(error);
if (error.response && error.response.status === 401) {
alert("密码错误,请输入正确的原密码!");
} else if (error.response && error.response.data) {
alert(error.response.data);
} else {
alert("更新短链失败,请重试!");
}
});
}
// 主题切换功能
function initTheme() {
const html = document.querySelector('html');
const themeIcon = document.getElementById('theme-icon');
let theme;
// 从localStorage获取用户偏好的主题
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
// 如果用户之前设置过主题,使用保存的主题
theme = savedTheme;
} else {
// 如果没有设置过,检测系统主题偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
theme = prefersDark ? 'dark' : 'light';
}
// 设置主题
html.setAttribute('data-bs-theme', theme);
// 更新图标
if (theme === 'dark') {
themeIcon.textContent = '☀️';
} else {
themeIcon.textContent = '🌙';
}
}
function toggleTheme() {
const html = document.querySelector('html');
const currentTheme = html.getAttribute('data-bs-theme');
// 切换主题
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-bs-theme', newTheme);
// 更新图标
if (newTheme === 'dark') {
themeIcon.textContent = '☀️';
} else {
themeIcon.textContent = '🌙';
}
// 保存用户偏好到localStorage
localStorage.setItem('theme', newTheme);
}
listenInput();
initTheme();
+33
View File
@@ -0,0 +1,33 @@
package test
import (
"testing"
"github.com/bestnite/sub2clash/model/proxy"
"gopkg.in/yaml.v3"
)
type testStruct struct {
A proxy.IntOrString `yaml:"a"`
}
func TestUnmarshal(t *testing.T) {
yamlData1 := `a: 123`
res := testStruct{}
err := yaml.Unmarshal([]byte(yamlData1), &res)
if err != nil {
t.Errorf("failed to unmarshal yaml: %v", err)
}
if res.A != 123 {
t.Errorf("expected 123, but got %v", res.A)
}
yamlData2 := `a: "123"`
err = yaml.Unmarshal([]byte(yamlData2), &res)
if err != nil {
t.Errorf("failed to unmarshal yaml: %v", err)
}
if res.A != 123 {
t.Errorf("expected 123, but got %v", res.A)
}
}