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

Compare commits

..

123 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
nite f5686561f9 modify base64 decode func in BuildSub uses std encoder 2025-07-28 12:12:15 +00:00
nite 83c24170a0 Update API documentation to reflect changes in configuration format for GET /convert/:config, including detailed descriptions of new parameters and structures for config, RuleProvider, and Rule. 2025-07-22 04:19:26 +00:00
nite 80d91efca4 Refactor subscription handling by removing SubConfig model, updating BuildSub function to use ConvertConfig, and enhancing Base64 decoding across parsers. Update routes and frontend to support new configuration format. 2025-07-22 04:09:00 +00:00
nite 83a728a415 Implement YAML unmarshalling for various proxy types and update SOCKS parser to support "socks5" prefix. 2025-07-15 20:14:37 +08:00
nite a178d06248 Merge pull request #67 from HaTiWinter/main
修复 GetRawConfHandler 中短链的构建问题 | Fix URL construction in GetRawConfHandler
2025-07-13 22:49:33 +08:00
HaTiWinter 99d36d93d8 fix URL construction in GetRawConfHandler 2025-07-13 15:57:46 +08:00
nite 0a9892503d u 2025-07-05 22:57:46 +08:00
nite ff81d03492 u 2025-07-05 22:54:02 +08:00
nite 0fa95888cb Fix URL construction in GetRawConfHandler to ensure proper HTTP scheme is used. 2025-07-01 02:22:46 +08:00
nite b44703fa0f Enhance Trojan, Vless, and Vmess parsers. 2025-07-01 02:06:33 +08:00
nite b256c5e809 Enhance Base64 validation in isLikelyBase64 function to include UTF-8 check and improve decoding logic. 2025-06-26 10:58:56 +08:00
nite 307c36aa8d Refactor Base64 validation in isLikelyBase64 function to remove unnecessary suffix check. 2025-06-26 09:51:39 +08:00
nite 06da2e91c2 Update index.html to set default option for Clash.Meta and improve user-agent label and placeholder text for clarity. 2025-06-26 09:04:53 +08:00
nite 33d37e631b Fix subscription user info header handling in SubHandler to only set header on successful fetch 2025-06-26 10:55:21 +10:00
nite c1012750ff add tests 2025-06-12 19:33:14 +10:00
nite 69deed91df Refactor Shadowsocks and Socks parsers to simplify username and password extraction 2025-06-12 15:48:59 +10:00
nite 6a780a5e27 Refactor Shadowsocks and Socks parsers to improve username and password handling 2025-06-12 15:41:56 +10:00
nite f88ae52a29 Unify parts of the model with MetaCubeX. 2025-06-12 14:19:00 +10:00
nite 0c8bbac2e6 Update README.md to modify contributor image link format for improved clarity. 2025-06-12 13:44:27 +10:00
nite c4554d9030 Remove .all-contributorsrc file and update README.md to display contributor statistics using an image link. 2025-06-12 13:43:17 +10:00
nite 8db2c46bf0 update regex to dynamically include all supported prefixes for improved proxy parsing. 2025-06-12 13:40:30 +10:00
nite 8b3ae45623 Merge pull request #64 from beck-8/fix/socks
fix reg socks
2025-06-12 13:35:47 +10:00
nite cdc8ef7e32 Update README.md to include contributor section 2025-06-12 13:30:46 +10:00
beck-8 62229fba97 fix reg socks 2025-06-12 10:41:59 +08:00
nite b80afd97f1 Refactor error handling 2025-06-12 11:33:13 +10:00
nite 2a042968d0 Update .gitignore to include .vscode directory and remove .vscode/launch.json file 2025-06-12 10:28:33 +10:00
nite cdf69ce65f Refactor proxy structure and parser implementations to streamline protocol handling; remove unused marshaler interfaces and improve YAML serialization for various proxy types. 2025-06-12 10:27:22 +10:00
nite 2d8508f390 Move HTTP, gRPC, Reality, and WebSocket options to proxy.go refactor VmessJson type to use 'any' for flexible types 2025-06-12 09:22:23 +10:00
nite 73616c98a3 fix Dockerfile 2025-06-12 03:14:08 +10:00
nite 44163d30e1 Update index.html to use local Bootstrap and Axios files instead of CDN links 2025-06-12 03:04:19 +10:00
nite a2e97aaa01 remove toml and ini config type 2025-06-12 02:55:50 +10:00
nite 4f3c2bb280 modify ProxyParser interface 2025-06-12 02:48:59 +10:00
nite 1b662de245 Update README.md 2025-06-12 02:28:07 +10:00
nite 4a9297f4a3 mod example config and compose.yaml 2025-06-12 02:24:18 +10:00
nite fcb1358846 Merge branch 'main' of https://github.com/bestnite/sub2clash 2025-06-12 02:21:21 +10:00
nite da9a17201b refactor 2025-06-12 02:17:31 +10:00
nite 5b7a94e65c Update docker.yml 2025-05-31 17:13:20 +10:00
nite b5fcbab1a5 Merge pull request #63 from pingchieh/main
添加Anytls协议
2025-05-28 14:43:29 +10:00
Chieh 0b8299f432 feat: 返回头中增加订阅信息 2025-05-26 11:18:33 +00:00
Chieh 84c5a6e5f4 fix: RuleProvider增加Format 2025-05-26 11:18:33 +00:00
Chieh 06887d91ac feat: 添加 Anytls 代理支持 2025-05-26 11:18:33 +00:00
nite db00433931 Merge pull request #59 from timerzz/main
fix 修复Get函数重试结束后,返回nil,nil的bug
2025-03-24 01:12:06 +11:00
timerzz 5b3a12f00d fix 修复Get函数重试结束后,返回nil,nil的bug 2025-03-23 21:27:05 +08:00
nite d4d7010d8f Merge pull request #56 from hausen1012/main
add:生成短链支持自定义id
2025-03-10 12:28:37 +11:00
hz 2331cd4d18 modify: 短链和密码修改为后端生成 2025-03-08 19:02:23 +08:00
hz 88d8653ab5 fix:修复ua标识等两个前端未正常回显问题 2025-03-07 17:41:42 +08:00
hz cc0b73d7a4 add:生成短链支持自定义id 2025-03-07 17:30:28 +08:00
nite b57e4cf49f fix ua form 2025-03-05 19:24:48 +11:00
Nite07 cc80e237d6 Merge pull request #48 from 96368a/main
 增加socks5协议支持
2024-11-13 10:31:41 +08:00
96368a fefb4b895a 增加socks5协议支持 2024-11-13 09:21:31 +08:00
nite 66f214ae10 try to fix ss parser 2024-11-06 18:43:48 +08:00
nite f7dc78aabc 🐛 2024-10-19 15:39:20 +08:00
nite 6bb2d16e4b 🐛 #46 2024-10-09 11:08:24 +08:00
nite 98ef93c7bb 🐛 #42 2024-10-08 10:05:13 +08:00
nite 6e09c44d17 🐛 #43 2024-10-08 10:01:09 +08:00
nite 42fd251eb5 #42 2024-10-07 16:34:56 +08:00
nite e504a6cca4 🎨 Refactor package for public import 2024-09-17 13:10:13 +08:00
nite 3cfa4bdf24 ️ Improve 2024-08-11 23:55:47 +08:00
nite dedbf2bc03 🐛 fix #37 2024-08-04 12:59:55 +08:00
Nite07 7158ad467a Merge pull request #35 from arusuki/main
🔧  customize HTTP user-agent for fetching subscription from API.
2024-08-04 12:53:06 +08:00
tao-lintian fd22cd1499 🔧 customize HTTP user-agent for fetching subscription from API. 2024-07-15 21:01:22 +08:00
nite 0946412ea7 🐛 #31 2024-05-21 15:28:09 +08:00
nite 98bca0a7ac 🐛 Cannot parse custom rules 2024-05-21 15:00:19 +08:00
nite 036907fba4 🐛 Parse short link 2024-05-09 12:58:33 +08:00
nite 3e720ec14d 🐛 Fix vless parser lost fingerprint 2024-05-06 02:22:44 +08:00
nite b73a02bdbf 🐛 Fix vless parser cannot correctly parse some reality/grpc fields 2024-05-05 22:46:01 +08:00
nite 25e47453cb 🔥 Remove test dir, verison length limit 2024-05-04 22:30:46 +08:00
nite 3330412243 🔧 Update docker workflow 2024-04-26 00:04:17 +08:00
nite 35deaa015f 🔥 Deprecate Woodpecker CI in favor of GitHub Actions 2024-04-25 22:23:33 +08:00
nite ddd297492c ⬆️ Upgrade dependencies 2024-04-24 13:05:10 +08:00
nite effd22c750 ♻️ Refactor logger
📝 Update README
2024-04-24 13:01:22 +08:00
nite 566965bb6a ♻️ Migrate from gorm/sqlite to boltdb 2024-04-24 12:51:37 +08:00
nite 3d3b4e0bea 🔧 Update workflows 2024-04-23 17:28:05 +08:00
nite b86aa2559a 🔧 Remove builder for arm 2024-04-23 15:03:21 +08:00
nite 4a2fa21a0a 🔧 Update docker-compose.yml 2024-04-23 14:54:10 +08:00
nite 3b8352a34f 🔧 update docker and goreleaser configrations 2024-04-23 14:49:31 +08:00
nite ac4ad3c8aa ♻️ Refactor code
🔥 Remove update detection
2024-04-23 14:47:53 +08:00
nite ebc91d8aad ♻️ Refactor parsers 2024-04-23 14:39:16 +08:00
nite 48dece2a51 🐛 Fix trojan parser missing fields 2024-04-23 13:36:33 +08:00
nite aa9e102a81 fix: hy2 解析缺少 name 字段 2024-04-21 00:23:29 +08:00
nite faaf5c366a mod 2024-04-18 00:15:45 +08:00
nite 4384e56cc6 mod 2024-04-17 23:08:26 +08:00
nite abbd7b8b19 mod 2024-04-17 22:55:04 +08:00
nite b687acb94c update 2024-04-17 21:52:03 +08:00
nite 7748407583 fix: goreleaser 2024-03-13 17:44:21 +08:00
Nite07 822793b085 Merge pull request #23 from nitezs/dev
Dev
2024-03-13 17:34:19 +08:00
nite 3d058066b9 update: 补全短链解析配置 2024-03-13 16:57:32 +08:00
nite 1c5e17a4ab feat: 解析短链 2024-03-13 16:28:40 +08:00
nite 867da56f45 fix: workflow 2024-03-13 13:47:53 +08:00
nite 14c3b97ed2 feat: 修改短链
update: dockerfile,workflow
2024-03-13 13:30:45 +08:00
nite 1d9de31f47 fix: vless ws 解析缺失 TLS 字段 2024-03-12 21:46:07 +08:00
Nite07 0681f599f1 Merge pull request #20 from QiChaiQiChai/dev
fix: hysteria 缺失参数 @QiChaiQiChai 
update: 策略组 icon 支持 @QiChaiQiChai
2024-03-11 12:40:29 +08:00
Qi Chai 68c64ad867 Update proxy_hysteria.go 2024-03-11 12:30:16 +08:00
Qi Chai 18b6a81896 修复hysterica节点配置中部分字段的识别 2024-03-11 12:26:42 +08:00
Qi Chai e5c3852f06 修复hysterica类型代理配置中部分字段的识别 2024-03-11 12:24:21 +08:00
Qi Chai ff73da3a4c Update proxy_group.go 增加对proxies group中icon字段的支持 2024-03-11 12:20:19 +08:00
nite 916670cf68 fix: vless 未解析 short-id 2024-03-10 13:56:22 +08:00
nite 94a320a682 feat: 增加不输出国家策略组功能 2024-03-10 13:26:35 +08:00
nite 65dccc3b51 fix: 合并完整配置模板 2024-03-10 11:34:52 +08:00
Nite07 c159f2b417 Merge pull request #18 from nitezs/dev
v0.0.8
2024-03-09 17:42:59 +08:00
nite fd7c460f95 feat: 增加 Hysteria 节点支持 2024-03-09 17:17:57 +08:00
nite 68269fd499 feat: 增加 Hysteria 节点支持 2024-03-09 17:01:52 +08:00
nite d8a81e44b6 fix: 修复重名节点没有正确重命名的问题 2024-03-09 15:58:16 +08:00
nite 70f5c63fe2 fix: 修复 vmess/vless 节点名称未URL解码的问题 2024-03-09 15:39:47 +08:00
nite 679102fa3b fix: 修复 vmes/vless 解析问题 2024-03-09 15:33:26 +08:00
Nite07 3309a0c208 Merge pull request #14 from nitezs/dev
Dev
2024-02-19 13:12:12 +08:00
125 changed files with 20074 additions and 13085 deletions
-8
View File
@@ -1,8 +0,0 @@
PORT=8011
META_TEMPLATE=meta_template.json
CLASH_TEMPLATE=clash_template.json
REQUEST_RETRY_TIMES=3
REQUEST_MAX_FILE_SIZE=1048576
CACHE_EXPIRE=300
LOG_LEVEL=info
BASE_PATH=/
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+39 -51
View File
@@ -1,69 +1,57 @@
name: Build and Push to GHCR
name: docker
on:
push:
branches:
- dev
tags:
- '*'
workflow_dispatch:
- "v*"
jobs:
build:
prepare-and-build:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
nite07/sub2clash
ghcr.io/bestnite/sub2clash
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=match,pattern=(alpha|beta|rc),group=1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set tag name
id: set_tag
run: |
if [[ $GITHUB_REF == refs/heads/* ]]; then
echo "::set-output name=tag::$(echo $GITHUB_REF | cut -d'/' -f3)"
else
echo "::set-output name=tag::${{ github.ref_name }}"
fi
- name: Check if triggered by tag
id: check_tag
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "::set-output name=triggered_by_tag::true"
else
echo "::set-output name=triggered_by_tag::false"
fi
- name: Build and push Docker image for dev branch
if: steps.check_tag.outputs.triggered_by_tag == 'false'
uses: docker/build-push-action@v2
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
build-args: |
dev=true
version=${{ github.sha }}
"version=${{ github.ref_name }}"
push: true
tags: ghcr.io/${{ github.repository }}:${{ steps.set_tag.outputs.tag }}
- name: Build and push Docker image for tags
if: steps.check_tag.outputs.triggered_by_tag == 'true'
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
build-args: |
dev=false
version=${{ steps.set_tag.outputs.tag }}
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ steps.set_tag.outputs.tag }}
ghcr.io/${{ github.repository }}:latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
file: Containerfile
-110
View File
@@ -1,110 +0,0 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
tags:
- '*'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Build
run: |
LDFLAGS="-s -w -X config.Version=${{ github.ref_name }}"
# Linux
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o output/sub2clash-linux-amd64 main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o output/sub2clash-linux-arm64 main.go
# Darwin
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o output/sub2clash-darwin-amd64 main.go
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o output/sub2clash-darwin-arm64 main.go
# Windows
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o output/sub2clash-windows-amd64.exe main.go
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags="$LDFLAGS" -o output/sub2clash-windows-arm64.exe main.go
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref_name }}
release_name: Release ${{ github.ref_name }}
draft: false
prerelease: false
- name: Upload Release Asset (Linux amd64)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./output/sub2clash-linux-amd64
asset_name: sub2clash-linux-amd64
asset_content_type: application/octet-stream
- name: Upload Release Asset (Linux arm64)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./output/sub2clash-linux-arm64
asset_name: sub2clash-linux-arm64
asset_content_type: application/octet-stream
- name: Upload Release Asset (Darwin amd64)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./output/sub2clash-darwin-amd64
asset_name: sub2clash-darwin-amd64
asset_content_type: application/octet-stream
- name: Upload Release Asset (Darwin arm64)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./output/sub2clash-darwin-arm64
asset_name: sub2clash-darwin-arm64
asset_content_type: application/octet-stream
- name: Upload Release Asset (Windows amd64)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./output/sub2clash-windows-amd64.exe
asset_name: sub2clash-windows-amd64.exe
asset_content_type: application/octet-stream
- name: Upload Release Asset (Windows arm64)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./output/sub2clash-windows-arm64.exe
asset_name: sub2clash-windows-arm64.exe
asset_content_type: application/octet-stream
+38
View File
@@ -0,0 +1,38 @@
name: release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Go
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
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -2
View File
@@ -1,7 +1,10 @@
.idea
dist
subs
test
logs
sub2clash.db
data
.env
config.yaml
config.yml
config.json
.codex
+36 -6
View File
@@ -1,6 +1,8 @@
#before:
# hooks:
# - go mod tidy
version: 2
project_name: sub2clash
before:
hooks:
- bash ./build-frontend.sh {{ .Version }}
builds:
- env:
- CGO_ENABLED=0
@@ -12,6 +14,34 @@ builds:
- amd64
- arm64
ldflags:
- -s -w -X sub2clash/config.Version={{ .Version }}
no_unique_dist_dir: true
binary: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
- -s -w -X github.com/bestnite/sub2clash/constant.Version={{ .Version }}
flags:
- -trimpath
archives:
- formats: ["tar.gz"]
format_overrides:
- formats: ["zip"]
goos: windows
wrap_in_directory: true
files:
- LICENSE
- README.md
- templates
release:
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
+6 -5
View File
@@ -1,13 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug",
"name": "debug",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/main.go",
"output": "${workspaceFolder}/dist/main.exe",
"buildFlags": "-ldflags '-X sub2clash/config.Dev=true -X sub2clash/config.Version=dev'"
"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"
}
}
]
}
-37
View File
@@ -1,37 +0,0 @@
# `/clash`, `/meta`
获取 Clash/Clash.Meta 配置链接
| Query 参数 | 类型 | 是否必须 | 默认值 | 说明 |
|--------------|--------|-------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| sub | string | sub/proxy 至少有一项存在 | - | 订阅链接,可以在链接结尾加上`#名称`,来给订阅中的节点加上统一前缀(可以输入多个,用 `,` 分隔) |
| proxy | string | sub/proxy 至少有一项存在 | - | 节点分享链接(可以输入多个,用 `,` 分隔) |
| refresh | bool | 否 | `false` | 强制刷新配置(默认缓存 5 分钟) |
| template | string | 否 | - | 外部模板链接或内部模板名称 |
| ruleProvider | string | 否 | - | 格式 `[Behavior,Url,Group,Prepend,Name],[Behavior,Url,Group,Prepend,Name]...`,其中 `Group` 是该规则集使用的策略组名,`Prepend` 为 bool 类型,如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部(会调整到 MATCH 规则之前) |
| rule | string | 否 | - | 格式 `[Rule,Prepend],[Rule,Prepend]...`,其中 `Prepend` 为 bool 类型,如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部(会调整到 MATCH 规则之前) |
| autoTest | bool | 否 | `false` | 国家策略组是否自动测速 |
| lazy | bool | 否 | `false` | 自动测速是否启用 lazy |
| sort | string | 否 | `nameasc` | 国家策略组排序策略,可选值 `nameasc``namedesc``sizeasc``sizedesc` |
| replace | string | 否 | - | 通过正则表达式重命名节点,格式 `[<ReplaceKey>,<ReplaceTo>],[<ReplaceKey>,<ReplaceTo>]...` |
| remove | string | 否 | - | 通过正则表达式删除节点 |
| nodeList | bool | 否 | `false` | 只输出节点 |
# `/short`
获取短链,Content-Type 为 `application/json`
具体参考使用可以参考 [api\templates\index.html](api/static/index.html)
| Body 参数 | 类型 | 是否必须 | 默认值 | 说明 |
|----------|--------|------|-----|------------------|
| url | string | 是 | - | 需要转换的 Query 参数部分 |
| password | string | 否 | - | 短链密码 |
# `/s/:hash`
短链跳转
`hash` 为动态路由参数,可以通过 `/short` 接口获取
| Query 参数 | 类型 | 是否必须 | 默认值 | 说明 |
|----------|--------|------|-----|------|
| password | string | 否 | - | 短链密码 |
+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"]
-28
View File
@@ -1,28 +0,0 @@
# 使用官方 Golang 镜像作为构建环境
FROM golang:1.21-alpine as builder
LABEL authors="nite07"
# 设置工作目录
WORKDIR /app
# 复制源代码到工作目录
COPY . .
RUN go mod download
# 获取参数
ARG version
ARG dev
# 使用 -ldflags 参数进行编译
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X sub2clash/config.Version=${version} -X sub2clash/config.Dev=${dev}" -o sub2clash main.go
FROM alpine:latest
# 设置工作目录
WORKDIR /app
# 从 builder 镜像中复制出编译好的二进制文件
COPY --from=builder /app/sub2clash /app/sub2clash
# 设置容器的默认启动命令
ENTRYPOINT ["/app/sub2clash"]
View File
+92 -21
View File
@@ -1,52 +1,114 @@
# sub2clash
将订阅链接转换为 Clash、Clash.Meta 配置
[预览](https://www.nite07.com/sub)
[预览](https://clash.nite07.com/)
## 特性
- 开箱即用的规则、策略组配置
- 自动根据节点名称按国家划分策略组
- 支持多订阅合并
- 支持添加自定义 Rule Provider、Rule
- 多订阅合并
- 自定义 Rule Provider、Rule
- 支持多种协议
- Shadowsocks
- ShadowsocksR
- Vmess
- Vless Clash.Meta
- Trojan
- Hysteria Clash.Meta
- Hysteria2 Clash.Meta
- Socks5
- Anytls Clash.Meta
## 使用
### 运行
### 部署
- [docker compose](./docker-compose.yml)
- 运行[二进制文件](https://github.com/nitezs/sub2clash/releases/latest)
- [docker compose](./compose.yml)
- 运行[二进制文件](https://github.com/bestnite/sub2clash/releases/latest)
### 配置
可以通过编辑 .env 文件来修改默认配置,docker 直接添加环境变量
支持多种配置方式,按优先级排序:
| 变量名 | 说明 | 默认值 |
|-----------------------|----------------------------------------|-----------------------|
| PORT | 端口 | `8011` |
| META_TEMPLATE | 默认 meta 模板文件名 | `template_meta.yaml` |
| CLASH_TEMPLATE | 默认 clash 模板文件名 | `template_clash.yaml` |
| REQUEST_RETRY_TIMES | Get 请求重试次数 | `3` |
| REQUEST_MAX_FILE_SIZE | Get 请求订阅文件最大大小(byte) | `1048576` |
| CACHE_EXPIRE | 订阅缓存时间(秒) | `300` |
| LOG_LEVEL | 日志等级,可选值 `debug`,`info`,`warn`,`error` | `info` |
| SHORT_LINK_LENGTH | 短链长度 | `6` |
1. **配置文件**:支持多种格式(YAML、JSON),按以下优先级搜索:
- `config.yaml` / `config.yml`
- `config.json`
- `sub2clash.yaml` / `sub2clash.yml`
- `sub2clash.json`
2. **环境变量**:使用 `SUB2CLASH_` 前缀,例如 `SUB2CLASH_ADDRESS=0.0.0.0:8011`
3. **默认值**:内置默认配置
| 配置项 | 环境变量 | 说明 | 默认值 |
| --------------------- | ------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| address | SUB2CLASH_ADDRESS | 服务监听地址 | `0.0.0.0:8011` |
| meta_template | SUB2CLASH_META_TEMPLATE | 默认 meta 模板 URL | `https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_meta.yaml` |
| clash_template | SUB2CLASH_CLASH_TEMPLATE | 默认 clash 模板 URL | `https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_clash.yaml` |
| request_retry_times | SUB2CLASH_REQUEST_RETRY_TIMES | 请求重试次数 | `3` |
| request_max_file_size | SUB2CLASH_REQUEST_MAX_FILE_SIZE | 请求文件最大大小(byte) | `1048576` |
| cache_expire | SUB2CLASH_CACHE_EXPIRE | 订阅缓存时间(秒) | `300` |
| log_level | SUB2CLASH_LOG_LEVEL | 日志等级:`debug`,`info`,`warn`,`error` | `info` |
| short_link_length | SUB2CLASH_SHORT_LINK_LENGTH | 短链长度 | `6` |
#### 配置文件示例
参考示例文件:
- [config.example.yaml](./config.example.yaml) - YAML 格式
- [config.example.json](./config.example.json) - JSON 格式
### API
[API 文档](./API_README.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` 规则将被添加到规则列表顶部,否则添加到规则列表底部 |
### 模板
可以通过变量自定义模板中的策略组代理节点
解释的不太清楚,可以参考下方默认模板
具体参考下方默认模板
- `<all>` 为添加所有节点
- `<countries>` 为添加所有国家策略组
@@ -57,6 +119,15 @@
- [Clash](./templates/template_clash.yaml)
- [Clash.Meta](./templates/template_meta.yaml)
## 已知问题
## 开发
[代理链接解析](./parser)还没有经过严格测试,可能会出现解析错误的情况,如果出现问题请提交 issue
### 添加新协议支持
添加新协议支持需要实现以下组件:
1.`parser` 目录下实现协议解析器,用于解析节点链接
2.`model/proxy` 目录下定义协议结构体
## 贡献者
[![](https://contrib.rocks/image?repo=bestnite/sub2clash)](https://github.com/bestnite/sub2clash/graphs/contributors)
-43
View File
@@ -1,43 +0,0 @@
package controller
import (
"net/http"
"sub2clash/config"
"sub2clash/model"
"sub2clash/validator"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
func SubmodHandler(c *gin.Context) {
// 从请求中获取参数
query, err := validator.ParseQuery(c)
if err != nil {
c.String(http.StatusBadRequest, err.Error())
return
}
sub, err := BuildSub(model.Clash, query, config.Default.ClashTemplate)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return
}
// 输出
if query.NodeListMode {
nodelist := model.NodeList{}
nodelist.Proxies = sub.Proxies
marshal, err := yaml.Marshal(nodelist)
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 {
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
return
}
c.String(http.StatusOK, string(marshal))
}
-286
View File
@@ -1,286 +0,0 @@
package controller
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sub2clash/logger"
"sub2clash/model"
"sub2clash/parser"
"sub2clash/utils"
"sub2clash/validator"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
func BuildSub(clashType model.ClashType, query validator.SubValidator, template string) (
*model.Subscription, error,
) {
// 定义变量
var temp = &model.Subscription{}
var sub = &model.Subscription{}
var err error
var templateBytes []byte
// 加载模板
if query.Template != "" {
template = query.Template
}
if strings.HasPrefix(template, "http") {
templateBytes, err = utils.LoadSubscription(template, query.Refresh)
if err != nil {
logger.Logger.Debug(
"load template failed", zap.String("template", template), zap.Error(err),
)
return nil, errors.New("加载模板失败: " + err.Error())
}
} else {
unescape, err := url.QueryUnescape(template)
if err != nil {
return nil, errors.New("加载模板失败: " + err.Error())
}
templateBytes, err = utils.LoadTemplate(unescape)
if err != nil {
logger.Logger.Debug(
"load template failed", zap.String("template", template), zap.Error(err),
)
return nil, errors.New("加载模板失败: " + err.Error())
}
}
// 解析模板
err = yaml.Unmarshal(templateBytes, &temp)
if err != nil {
logger.Logger.Debug("parse template failed", zap.Error(err))
return nil, errors.New("解析模板失败: " + err.Error())
}
var proxyList []model.Proxy
// 加载订阅
for i := range query.Subs {
data, err := utils.LoadSubscription(query.Subs[i], query.Refresh)
subName := ""
if strings.Contains(query.Subs[i], "#") {
subName = query.Subs[i][strings.LastIndex(query.Subs[i], "#")+1:]
}
if err != nil {
logger.Logger.Debug(
"load subscription failed", zap.String("url", query.Subs[i]), zap.Error(err),
)
return nil, errors.New("加载订阅失败: " + err.Error())
}
// 解析订阅
err = yaml.Unmarshal(data, &sub)
newProxies := make([]model.Proxy, 0)
if err != nil {
reg, _ := regexp.Compile("(ssr|ss|vmess|trojan|vless|hysteria)://")
if reg.Match(data) {
p := utils.ParseProxy(strings.Split(string(data), "\n")...)
newProxies = p
} else {
// 如果无法直接解析,尝试Base64解码
base64, err := parser.DecodeBase64(string(data))
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, errors.New("加载订阅失败: " + err.Error())
}
p := utils.ParseProxy(strings.Split(base64, "\n")...)
newProxies = p
}
} else {
newProxies = sub.Proxies
}
if subName != "" {
for i := range newProxies {
newProxies[i].SubName = subName
}
}
proxyList = append(proxyList, newProxies...)
}
// 添加自定义节点
if len(query.Proxies) != 0 {
proxyList = append(proxyList, utils.ParseProxy(query.Proxies...)...)
}
// 给节点添加订阅名称
for i := range proxyList {
if proxyList[i].SubName != "" {
proxyList[i].Name = strings.TrimSpace(proxyList[i].SubName) + " " + strings.TrimSpace(proxyList[i].Name)
}
}
// 去掉配置相同的节点
proxies := make(map[string]*model.Proxy)
newProxies := make([]model.Proxy, 0, len(proxyList))
for i := range proxyList {
key := proxyList[i].Server + ":" + strconv.Itoa(proxyList[i].Port) + ":" + proxyList[i].Type
if _, exist := proxies[key]; !exist {
proxies[key] = &proxyList[i]
newProxies = append(newProxies, proxyList[i])
}
}
proxyList = newProxies
// 删除节点
if strings.TrimSpace(query.Remove) != "" {
newProxyList := make([]model.Proxy, 0, len(proxyList))
for i := range proxyList {
removeReg, err := regexp.Compile(query.Remove)
if err != nil {
logger.Logger.Debug("remove regexp compile failed", zap.Error(err))
return nil, errors.New("remove 参数非法: " + err.Error())
}
// 删除匹配到的节点
if removeReg.MatchString(proxyList[i].Name) {
continue // 如果匹配到要删除的元素,跳过该元素,不添加到新切片中
}
newProxyList = append(newProxyList, proxyList[i]) // 将要保留的元素添加到新切片中
}
proxyList = newProxyList
}
// 重命名
if len(query.ReplaceKeys) != 0 {
// 创建重命名正则表达式
replaceRegs := make([]*regexp.Regexp, 0, len(query.ReplaceKeys))
for _, v := range query.ReplaceKeys {
replaceReg, err := regexp.Compile(v)
if err != nil {
logger.Logger.Debug("replace regexp compile failed", zap.Error(err))
return nil, errors.New("replace 参数非法: " + err.Error())
}
replaceRegs = append(replaceRegs, replaceReg)
}
for i := range proxyList {
// 重命名匹配到的节点
for j, v := range replaceRegs {
if err != nil {
logger.Logger.Debug("replace regexp compile failed", zap.Error(err))
return nil, errors.New("replaceName 参数非法: " + err.Error())
}
if v.MatchString(proxyList[i].Name) {
proxyList[i].Name = v.ReplaceAllString(
proxyList[i].Name, query.ReplaceTo[j],
)
}
}
}
}
// 重名检测
names := make(map[string]int)
for i := range proxyList {
if _, exist := names[proxyList[i].Name]; exist {
proxyList[i].Name = proxyList[i].Name + " " + strconv.Itoa(names[proxyList[i].Name])
}
names[proxyList[i].Name] = names[proxyList[i].Name] + 1
}
// trim
for i := range proxyList {
proxyList[i].Name = strings.TrimSpace(proxyList[i].Name)
}
// 将新增节点都添加到临时变量 t 中,防止策略组排序错乱
var t = &model.Subscription{}
utils.AddProxy(t, query.AutoTest, query.Lazy, clashType, proxyList...)
// 排序策略组
switch query.Sort {
case "sizeasc":
sort.Sort(model.ProxyGroupsSortBySize(t.ProxyGroups))
case "sizedesc":
sort.Sort(sort.Reverse(model.ProxyGroupsSortBySize(t.ProxyGroups)))
case "nameasc":
sort.Sort(model.ProxyGroupsSortByName(t.ProxyGroups))
case "namedesc":
sort.Sort(sort.Reverse(model.ProxyGroupsSortByName(t.ProxyGroups)))
default:
sort.Sort(model.ProxyGroupsSortByName(t.ProxyGroups))
}
// 合并新节点和模板
MergeSubAndTemplate(temp, t)
// 处理自定义规则
for _, v := range query.Rules {
if v.Prepend {
utils.PrependRules(temp, v.Rule)
} else {
utils.AppendRules(temp, v.Rule)
}
}
// 处理自定义 ruleProvider
for _, v := range query.RuleProviders {
hash := sha256.Sum224([]byte(v.Url))
name := hex.EncodeToString(hash[:])
provider := model.RuleProvider{
Type: "http",
Behavior: v.Behavior,
Url: v.Url,
Path: "./" + name + ".yaml",
Interval: 3600,
}
if v.Prepend {
utils.PrependRuleProvider(
temp, v.Name, v.Group, provider,
)
} else {
utils.AppenddRuleProvider(
temp, v.Name, v.Group, provider,
)
}
}
return temp, nil
}
func MergeSubAndTemplate(temp *model.Subscription, sub *model.Subscription) {
// 只合并节点、策略组
// 统计所有国家策略组名称
var countryGroupNames []string
for _, proxyGroup := range sub.ProxyGroups {
if proxyGroup.IsCountryGrop {
countryGroupNames = append(
countryGroupNames, proxyGroup.Name,
)
}
}
var proxyNames []string
for _, proxy := range sub.Proxies {
proxyNames = append(proxyNames, proxy.Name)
}
// 将订阅中的节点添加到模板中
temp.Proxies = append(temp.Proxies, sub.Proxies...)
// 将订阅中的策略组添加到模板中
for i := range temp.ProxyGroups {
if temp.ProxyGroups[i].IsCountryGrop {
continue
}
newProxies := make([]string, 0)
countryGroupMap := make(map[string]model.ProxyGroup)
for _, v := range sub.ProxyGroups {
if v.IsCountryGrop {
countryGroupMap[v.Name] = v
}
}
for j := range temp.ProxyGroups[i].Proxies {
reg := regexp.MustCompile("<(.*?)>")
if reg.Match([]byte(temp.ProxyGroups[i].Proxies[j])) {
key := reg.FindStringSubmatch(temp.ProxyGroups[i].Proxies[j])[1]
switch key {
case "all":
newProxies = append(newProxies, proxyNames...)
case "countries":
newProxies = append(newProxies, countryGroupNames...)
default:
if len(key) == 2 {
newProxies = append(
newProxies, countryGroupMap[utils.GetContryName(key)].Proxies...,
)
}
}
} else {
newProxies = append(newProxies, temp.ProxyGroups[i].Proxies[j])
}
}
temp.ProxyGroups[i].Proxies = newProxies
}
temp.ProxyGroups = append(temp.ProxyGroups, sub.ProxyGroups...)
}
-44
View File
@@ -1,44 +0,0 @@
package controller
import (
_ "embed"
"net/http"
"sub2clash/config"
"sub2clash/model"
"sub2clash/validator"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
func SubHandler(c *gin.Context) {
// 从请求中获取参数
query, err := validator.ParseQuery(c)
if err != nil {
c.String(http.StatusBadRequest, err.Error())
return
}
sub, err := BuildSub(model.ClashMeta, query, config.Default.MetaTemplate)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return
}
// 输出
if query.NodeListMode {
nodelist := model.NodeList{}
nodelist.Proxies = sub.Proxies
marshal, err := yaml.Marshal(nodelist)
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 {
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
return
}
c.String(http.StatusOK, string(marshal))
}
-109
View File
@@ -1,109 +0,0 @@
package controller
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"sub2clash/config"
"sub2clash/logger"
"sub2clash/model"
"sub2clash/utils"
"sub2clash/utils/database"
"sub2clash/validator"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
func ShortLinkGenHandler(c *gin.Context) {
// 从请求中获取参数
var params validator.ShortLinkGenValidator
if err := c.ShouldBind(&params); err != nil {
c.String(400, "参数错误: "+err.Error())
}
if strings.TrimSpace(params.Url) == "" {
c.String(400, "参数错误")
return
}
// 生成hash
hash := utils.RandomString(config.Default.ShortLinkLength)
var item model.ShortLink
result := database.FindShortLinkByUrl(params.Url, &item)
if result.Error == nil {
if item.Password != params.Password {
item.Password = params.Password
database.SaveShortLink(&item)
c.String(200, item.Hash+"?password="+params.Password)
} else {
c.String(200, item.Hash)
}
return
} else {
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.String(500, "数据库错误: "+result.Error.Error())
return
}
}
// 如果记录存在则重新生成hash,直到记录不存在
result = database.FindShortLinkByHash(hash, &item)
for result.Error == nil {
hash = utils.RandomString(config.Default.ShortLinkLength)
result = database.FindShortLinkByHash(hash, &item)
}
// 创建记录
database.FirstOrCreateShortLink(
&model.ShortLink{
Hash: hash,
Url: params.Url,
LastRequestTime: -1,
Password: params.Password,
},
)
// 返回短链接
if params.Password != "" {
hash += "?password=" + params.Password
}
c.String(200, hash)
}
func ShortLinkGetHandler(c *gin.Context) {
// 获取动态路由
hash := c.Param("hash")
password := c.Query("password")
if strings.TrimSpace(hash) == "" {
c.String(400, "参数错误")
return
}
// 查询数据库
var shortLink model.ShortLink
result := database.FindShortLinkByHash(hash, &shortLink)
// 重定向
if result.Error != nil {
c.String(404, "未找到短链接或密码错误")
return
}
if shortLink.Password != "" && shortLink.Password != password {
c.String(404, "未找到短链接或密码错误")
return
}
// 更新最后访问时间
shortLink.LastRequestTime = time.Now().Unix()
database.SaveShortLink(&shortLink)
get, err := utils.Get("http://localhost:" + strconv.Itoa(config.Default.Port) + "/" + shortLink.Url)
if err != nil {
logger.Logger.Debug("get short link data failed", zap.Error(err))
c.String(500, "请求错误: "+err.Error())
return
}
all, err := io.ReadAll(get.Body)
if err != nil {
logger.Logger.Debug("read short link data failed", zap.Error(err))
c.String(500, "读取错误: "+err.Error())
return
}
c.String(http.StatusOK, string(all))
}
-66
View File
@@ -1,66 +0,0 @@
package api
import (
"embed"
"html/template"
"log"
"net/http"
"sub2clash/api/controller"
"sub2clash/config"
"sub2clash/middleware"
"github.com/gin-gonic/gin"
)
//go:embed static
var staticFiles embed.FS
func SetRoute(r *gin.Engine) {
r.Use(middleware.ZapLogger())
// 使用内嵌的模板文件
tpl, err := template.ParseFS(staticFiles, "static/*")
if err != nil {
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 := config.Version
if len(config.Version) > 7 {
version = config.Version[:7]
}
c.HTML(
200, "index.html", gin.H{
"Version": version,
},
)
},
)
r.GET(
"/clash", func(c *gin.Context) {
controller.SubmodHandler(c)
},
)
r.GET(
"/meta", func(c *gin.Context) {
controller.SubHandler(c)
},
)
r.POST(
"/short", func(c *gin.Context) {
controller.ShortLinkGenHandler(c)
},
)
r.GET(
"/s/:hash", func(c *gin.Context) {
controller.ShortLinkGetHandler(c)
},
)
}
-285
View File
@@ -1,285 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>sub2clash</title>
<!-- Bootstrap CSS -->
<link
crossorigin="anonymous"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
rel="stylesheet"
/>
<!-- Bootstrap JS -->
<script
crossorigin="anonymous"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
></script>
<!-- Axios -->
<script src="https://cdn.jsdelivr.net/npm/axios@latest/dist/axios.min.js"></script>
<script src="./static/index.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;
}
</style>
</head>
<body class="bg-light">
<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/nitezs/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="clash">Clash</option>
<option value="meta">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>
<!-- 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>
<!-- 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"
readonly
type="text"
/>
<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">
<input
class="form-control"
id="apiShortLink"
readonly
type="text"
/>
<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>
<!-- footer-->
<footer>
<p class="text-center">
Powered by
<a
class="link-primary"
href="https://github.com/nitezs/sub2clash"
>sub2clash</a
>
</p>
<p class="text-center">Version {{.Version}}</p>
</footer>
</div>
</body>
</html>
-421
View File
@@ -1,421 +0,0 @@
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("template").value = "";
document.getElementById("sort").value = "nameasc";
document.getElementById("remove").value = "";
document.getElementById("apiLink").value = "";
document.getElementById("apiShortLink").value = "";
document.getElementById("password").value = "";
document.getElementById("nodeList").checked = false;
// 清除由 createRuleProvider, createReplace, 和 createRule 创建的所有额外输入组
clearInputGroup("ruleProviderGroup");
clearInputGroup("replaceGroup");
clearInputGroup("ruleGroup");
}
function generateURI() {
const queryParams = [];
// 获取 API Endpoint
const endpoint = document.getElementById("endpoint").value;
// 获取并组合订阅链接
let subLines = document
.getElementById("sub")
.value.split("\n")
.filter((line) => line.trim() !== "");
let noSub = false;
// 去除 subLines 中空元素
subLines = subLines.map((item) => {
if (item !== "") {
return item;
}
});
if (subLines.length > 0) {
queryParams.push(`sub=${encodeURIComponent(subLines.join(","))}`);
} else {
noSub = true;
}
// 获取并组合节点分享链接
let proxyLines = document
.getElementById("proxy")
.value.split("\n")
.filter((line) => line.trim() !== "");
let noProxy = false;
// 去除 proxyLines 中空元素
proxyLines = proxyLines.map((item) => {
if (item !== "") {
return item;
}
});
if (proxyLines.length > 0) {
queryParams.push(`proxy=${encodeURIComponent(proxyLines.join(","))}`);
} else {
noProxy = true;
}
if (noSub && noProxy) {
alert("订阅链接和节点分享链接不能同时为空!");
return "";
}
// 获取复选框的值
const refresh = document.getElementById("refresh").checked;
queryParams.push(`refresh=${refresh ? "true" : "false"}`);
const autoTest = document.getElementById("autoTest").checked;
queryParams.push(`autoTest=${autoTest ? "true" : "false"}`);
const lazy = document.getElementById("lazy").checked;
queryParams.push(`lazy=${lazy ? "true" : "false"}`);
const nodeList = document.getElementById("nodeList").checked;
queryParams.push(`nodeList=${nodeList ? "true" : "false"}`);
// 获取模板链接或名称(如果存在)
const template = document.getElementById("template").value;
if (template.trim() !== "") {
queryParams.push(`template=${encodeURIComponent(template)}`);
}
// 获取Rule Provider和规则
const ruleProviders = document.getElementsByName("ruleProvider");
const rules = document.getElementsByName("rule");
let providers = [];
for (let i = 0; i < ruleProviders.length / 5; i++) {
let baseIndex = i * 5;
let behavior = ruleProviders[baseIndex].value;
let url = ruleProviders[baseIndex + 1].value;
let group = ruleProviders[baseIndex + 2].value;
let prepend = ruleProviders[baseIndex + 3].value;
let name = ruleProviders[baseIndex + 4].value;
// 是否存在空值
if (
behavior.trim() === "" ||
url.trim() === "" ||
group.trim() === "" ||
prepend.trim() === "" ||
name.trim() === ""
) {
alert("Rule Provider 中存在空值,请检查后重试!");
return "";
}
providers.push(`[${behavior},${url},${group},${prepend},${name}]`);
}
queryParams.push(`ruleProvider=${encodeURIComponent(providers.join(","))}`);
let ruleList = [];
for (let i = 0; i < rules.length / 3; i++) {
if (rules[i * 3].value.trim() !== "") {
let rule = rules[i * 3].value;
let prepend = rules[i * 3 + 1].value;
let group = rules[i * 3 + 2].value;
// 是否存在空值
if (
rule.trim() === "" ||
prepend.trim() === "" ||
group.trim() === ""
) {
alert("Rule 中存在空值,请检查后重试!");
return "";
}
ruleList.push(`[${rule},${prepend},${group}]`);
}
}
queryParams.push(`rule=${encodeURIComponent(ruleList.join(","))}`);
// 获取排序策略
const sort = document.getElementById("sort").value;
queryParams.push(`sort=${sort}`);
// 获取删除节点的正则表达式
const remove = document.getElementById("remove").value;
if (remove.trim() !== "") {
queryParams.push(`remove=${encodeURIComponent(remove)}`);
}
// 获取替换节点名称的正则表达式
let replaceList = [];
const replaces = document.getElementsByName("replace");
for (let i = 0; i < replaces.length / 2; i++) {
let replaceStr = `<${replaces[i * 2].value}>`;
let replaceTo = `<${replaces[i * 2 + 1].value}>`;
if (replaceStr.trim() === "") {
alert("重命名设置中存在空值,请检查后重试!");
return "";
}
replaceList.push(`[${replaceStr},${replaceTo}]`);
}
queryParams.push(`replace=${encodeURIComponent(replaceList.join(","))}`);
return `${endpoint}?${queryParams.join("&")}`;
}
// 将输入框中的 URL 解析为参数
function parseInputURL() {
// 获取输入框中的 URL
const inputURL = document.getElementById("urlInput").value;
if (!inputURL) {
alert("请输入有效的链接!");
return;
}
let url;
try {
url = new URL(inputURL);
} catch (_) {
alert("无效的链接!");
return;
}
// 清除现有的输入框值
clearExistingValues();
// 获取查询参数
const params = new URLSearchParams(url.search);
// 分配值到对应的输入框
const pathSections = url.pathname.split("/");
const lastSection = pathSections[pathSections.length - 1];
const clientTypeSelect = document.getElementById("endpoint");
switch (lastSection.toLowerCase()) {
case "meta":
clientTypeSelect.value = "meta";
break;
case "clash":
default:
clientTypeSelect.value = "clash";
break;
}
if (params.has("sub")) {
document.getElementById("sub").value = decodeURIComponent(
params.get("sub")
)
.split(",")
.join("\n");
}
if (params.has("proxy")) {
document.getElementById("proxy").value = decodeURIComponent(
params.get("proxy")
)
.split(",")
.join("\n");
}
if (params.has("refresh")) {
document.getElementById("refresh").checked =
params.get("refresh") === "true";
}
if (params.has("autoTest")) {
document.getElementById("autoTest").checked =
params.get("autoTest") === "true";
}
if (params.has("lazy")) {
document.getElementById("lazy").checked = params.get("lazy") === "true";
}
if (params.has("template")) {
document.getElementById("template").value = decodeURIComponent(
params.get("template")
);
}
if (params.has("sort")) {
document.getElementById("sort").value = params.get("sort");
}
if (params.has("remove")) {
document.getElementById("remove").value = decodeURIComponent(
params.get("remove")
);
}
if (params.has("replace")) {
parseAndFillReplaceParams(decodeURIComponent(params.get("replace")));
}
if (params.has("ruleProvider")) {
parseAndFillRuleProviderParams(
decodeURIComponent(params.get("ruleProvider"))
);
}
if (params.has("rule")) {
parseAndFillRuleParams(decodeURIComponent(params.get("rule")));
}
if (params.has("nodeList")) {
document.getElementById("nodeList").checked =
params.get("nodeList") === "true";
}
}
function clearInputGroup(groupId) {
// 清空第二个之后的child
const group = document.getElementById(groupId);
while (group.children.length > 2) {
group.removeChild(group.lastChild);
}
}
function parseAndFillReplaceParams(replaceParams) {
const replaceGroup = document.getElementById("replaceGroup");
let matches;
const regex = /\[(<.*?>),(<.*?>)\]/g;
const str = decodeURIComponent(replaceParams);
while ((matches = regex.exec(str)) !== null) {
const div = createReplace();
const original = matches[1].slice(1, -1); // Remove < and >
const replacement = matches[2].slice(1, -1); // Remove < and >
div.children[0].value = original;
div.children[1].value = replacement;
replaceGroup.appendChild(div);
}
}
function parseAndFillRuleProviderParams(ruleProviderParams) {
const ruleProviderGroup = document.getElementById("ruleProviderGroup");
let matches;
const regex = /\[(.*?),(.*?),(.*?),(.*?),(.*?)\]/g;
const str = decodeURIComponent(ruleProviderParams);
while ((matches = regex.exec(str)) !== null) {
const div = createRuleProvider();
div.children[0].value = matches[1];
div.children[1].value = matches[2];
div.children[2].value = matches[3];
div.children[3].value = matches[4];
div.children[4].value = matches[5];
ruleProviderGroup.appendChild(div);
}
}
function parseAndFillRuleParams(ruleParams) {
const ruleGroup = document.getElementById("ruleGroup");
let matches;
const regex = /\[(.*?),(.*?),(.*?)\]/g;
const str = decodeURIComponent(ruleParams);
while ((matches = regex.exec(str)) !== null) {
const div = createRule();
div.children[0].value = matches[1];
div.children[1].value = matches[2];
div.children[2].value = matches[3];
ruleGroup.appendChild(div);
}
}
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">
<input type="text" class="form-control" name="rule" placeholder="Group">
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
`;
return div;
}
function addRuleProvider() {
const div = createRuleProvider();
document.getElementById("ruleProviderGroup").appendChild(div);
}
function addRule() {
const div = createRule();
document.getElementById("ruleGroup").appendChild(div);
}
function addReplace() {
const div = createReplace();
document.getElementById("replaceGroup").appendChild(div);
}
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}`;
}
function generateShortLink() {
const apiShortLink = document.getElementById("apiShortLink");
const password = document.getElementById("password");
let uri = generateURI();
if (uri === "") {
return;
}
axios
.post(
"./short",
{
url: uri,
password: password.value.trim(),
},
{
headers: {
"Content-Type": "application/json",
},
}
)
.then((response) => {
apiShortLink.value = `${window.location.origin}${window.location.pathname}s/${response.data}`;
})
.catch((error) => {
console.log(error);
alert("生成短链失败,请重试!");
});
}
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
export VITE_APP_VERSION=$1
cd server/frontend
npm install
npm run build
+77
View File
@@ -0,0 +1,77 @@
package database
import (
"context"
"errors"
"os"
"path/filepath"
"time"
"github.com/bestnite/sub2clash/common"
"github.com/bestnite/sub2clash/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Database struct {
db *gorm.DB
}
func ConnectDB() (*Database, error) {
path := filepath.Join("data", "sub2clash.db")
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
Logger: logger.Discard,
})
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 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
}
+192
View File
@@ -0,0 +1,192 @@
package common
import (
"errors"
"fmt"
)
// CommonError represents a structured error type for the common package
type CommonError struct {
Code ErrorCode
Message string
Cause error
}
// ErrorCode represents different types of errors
type ErrorCode string
const (
// Directory operation errors
ErrDirCreation ErrorCode = "DIRECTORY_CREATION_FAILED"
ErrDirAccess ErrorCode = "DIRECTORY_ACCESS_FAILED"
// File operation errors
ErrFileNotFound ErrorCode = "FILE_NOT_FOUND"
ErrFileRead ErrorCode = "FILE_READ_FAILED"
ErrFileWrite ErrorCode = "FILE_WRITE_FAILED"
ErrFileCreate ErrorCode = "FILE_CREATE_FAILED"
// Network operation errors
ErrNetworkRequest ErrorCode = "NETWORK_REQUEST_FAILED"
ErrNetworkResponse ErrorCode = "NETWORK_RESPONSE_FAILED"
// Template and configuration errors
ErrTemplateLoad ErrorCode = "TEMPLATE_LOAD_FAILED"
ErrTemplateParse ErrorCode = "TEMPLATE_PARSE_FAILED"
ErrConfigInvalid ErrorCode = "CONFIG_INVALID"
// Subscription errors
ErrSubscriptionLoad ErrorCode = "SUBSCRIPTION_LOAD_FAILED"
ErrSubscriptionParse ErrorCode = "SUBSCRIPTION_PARSE_FAILED"
// Regex errors
ErrRegexCompile ErrorCode = "REGEX_COMPILE_FAILED"
ErrRegexInvalid ErrorCode = "REGEX_INVALID"
// Database errors
ErrDatabaseConnect ErrorCode = "DATABASE_CONNECTION_FAILED"
ErrDatabaseQuery ErrorCode = "DATABASE_QUERY_FAILED"
ErrRecordNotFound ErrorCode = "RECORD_NOT_FOUND"
// Validation errors
ErrValidation ErrorCode = "VALIDATION_FAILED"
ErrInvalidInput ErrorCode = "INVALID_INPUT"
)
// Error returns the string representation of the error
func (e *CommonError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
// Unwrap returns the underlying error
func (e *CommonError) Unwrap() error {
return e.Cause
}
// Is allows error comparison
func (e *CommonError) Is(target error) bool {
if t, ok := target.(*CommonError); ok {
return e.Code == t.Code
}
return false
}
// NewError creates a new CommonError
func NewError(code ErrorCode, message string, cause error) *CommonError {
return &CommonError{
Code: code,
Message: message,
Cause: cause,
}
}
// NewSimpleError creates a new CommonError without a cause
func NewSimpleError(code ErrorCode, message string) *CommonError {
return &CommonError{
Code: code,
Message: message,
}
}
// Convenience constructors for common error types
// Directory errors
func NewDirCreationError(dirPath string, cause error) *CommonError {
return NewError(ErrDirCreation, fmt.Sprintf("failed to create directory: %s", dirPath), cause)
}
func NewDirAccessError(dirPath string, cause error) *CommonError {
return NewError(ErrDirAccess, fmt.Sprintf("failed to access directory: %s", dirPath), cause)
}
// File errors
func NewFileNotFoundError(filePath string) *CommonError {
return NewSimpleError(ErrFileNotFound, fmt.Sprintf("file not found: %s", filePath))
}
func NewFileReadError(filePath string, cause error) *CommonError {
return NewError(ErrFileRead, fmt.Sprintf("failed to read file: %s", filePath), cause)
}
func NewFileWriteError(filePath string, cause error) *CommonError {
return NewError(ErrFileWrite, fmt.Sprintf("failed to write file: %s", filePath), cause)
}
func NewFileCreateError(filePath string, cause error) *CommonError {
return NewError(ErrFileCreate, fmt.Sprintf("failed to create file: %s", filePath), cause)
}
// Network errors
func NewNetworkRequestError(url string, cause error) *CommonError {
return NewError(ErrNetworkRequest, fmt.Sprintf("network request failed for URL: %s", url), cause)
}
func NewNetworkResponseError(message string, cause error) *CommonError {
return NewError(ErrNetworkResponse, message, cause)
}
// Template errors
func NewTemplateLoadError(template string, cause error) *CommonError {
return NewError(ErrTemplateLoad, fmt.Sprintf("failed to load template: %s", template), cause)
}
func NewTemplateParseError(data []byte, cause error) *CommonError {
return NewError(ErrTemplateParse, fmt.Sprintf("failed to parse template: %s", data), cause)
}
// Subscription errors
func NewSubscriptionLoadError(url string, cause error) *CommonError {
return NewError(ErrSubscriptionLoad, fmt.Sprintf("failed to load subscription: %s", url), cause)
}
func NewSubscriptionParseError(data []byte, cause error) *CommonError {
return NewError(ErrSubscriptionParse, fmt.Sprintf("failed to parse subscription: %s", string(data)), cause)
}
// Regex errors
func NewRegexCompileError(pattern string, cause error) *CommonError {
return NewError(ErrRegexCompile, fmt.Sprintf("failed to compile regex pattern: %s", pattern), cause)
}
func NewRegexInvalidError(paramName string, cause error) *CommonError {
return NewError(ErrRegexInvalid, fmt.Sprintf("invalid regex in parameter: %s", paramName), cause)
}
// Database errors
func NewDatabaseConnectError(cause error) *CommonError {
return NewError(ErrDatabaseConnect, "failed to connect to database", cause)
}
func NewRecordNotFoundError(recordType string, id string) *CommonError {
return NewSimpleError(ErrRecordNotFound, fmt.Sprintf("%s not found: %s", recordType, id))
}
// Validation errors
func NewValidationError(field string, message string) *CommonError {
return NewSimpleError(ErrValidation, fmt.Sprintf("validation failed for %s: %s", field, message))
}
func NewInvalidInputError(paramName string, value string) *CommonError {
return NewSimpleError(ErrInvalidInput, fmt.Sprintf("invalid input for parameter %s: %s", paramName, value))
}
// IsErrorCode checks if an error has a specific error code
func IsErrorCode(err error, code ErrorCode) bool {
var commonErr *CommonError
if errors.As(err, &commonErr) {
return commonErr.Code == code
}
return false
}
// GetErrorCode extracts the error code from an error
func GetErrorCode(err error) (ErrorCode, bool) {
var commonErr *CommonError
if errors.As(err, &commonErr) {
return commonErr.Code, true
}
return "", false
}
+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]
}
+6 -7
View File
@@ -1,7 +1,6 @@
package utils
package common
import (
"errors"
"os"
)
@@ -18,13 +17,13 @@ func MKDir(dir string) error {
func MkEssentialDir() error {
if err := MKDir("subs"); err != nil {
return errors.New("create subs dir failed" + err.Error())
}
if err := MKDir("templates"); err != nil {
return errors.New("create templates dir failed" + err.Error())
return NewDirCreationError("subs", err)
}
if err := MKDir("logs"); err != nil {
return errors.New("create logs dir failed" + err.Error())
return NewDirCreationError("logs", err)
}
if err := MKDir("data"); err != nil {
return NewDirCreationError("data", err)
}
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package common
import (
"strings"
"github.com/bestnite/sub2clash/model"
"github.com/bestnite/sub2clash/model/proxy"
)
func GetContryName(countryKey string) string {
countryMaps := []map[string]string{
model.CountryFlag,
model.CountryChineseName,
model.CountryISO,
model.CountryEnglishName,
}
for i, countryMap := range countryMaps {
if i == 2 {
splitChars := []string{"-", "_", " "}
key := make([]string, 0)
for _, splitChar := range splitChars {
slic := strings.Split(countryKey, splitChar)
for _, v := range slic {
if len(v) == 2 {
key = append(key, v)
}
}
}
for _, v := range key {
if country, ok := countryMap[strings.ToUpper(v)]; ok {
return country
}
}
}
for k, v := range countryMap {
if strings.Contains(countryKey, k) {
return v
}
}
}
return "其他地区"
}
func AddProxy(
sub *generatedConfig, autotest bool,
lazy bool, clashType model.ClashType, proxies ...proxy.Proxy,
) {
proxyTypes := model.GetSupportProxyTypes(clashType)
for _, proxy := range proxies {
if !proxyTypes[proxy.Type] {
continue
}
sub.Proxy = append(sub.Proxy, proxy)
haveProxyGroup := false
countryName := GetContryName(proxy.Name)
for i := range sub.ProxyGroup {
group := &sub.ProxyGroup[i]
if group.Name == countryName {
group.Proxies = append(group.Proxies, proxy.Name)
group.Size++
haveProxyGroup = true
}
}
if !haveProxyGroup {
var newGroup generatedGroup
if !autotest {
newGroup = generatedGroup{
Name: countryName,
Type: "select",
Proxies: []string{proxy.Name},
IsCountry: true,
Size: 1,
}
} else {
newGroup = generatedGroup{
Name: countryName,
Type: "url-test",
Proxies: []string{proxy.Name},
IsCountry: true,
Url: "http://www.gstatic.com/generate_204",
Interval: 300,
Tolerance: 50,
Lazy: lazy,
Size: 1,
}
}
sub.ProxyGroup = append(sub.ProxyGroup, newGroup)
}
}
}
@@ -1,9 +1,9 @@
package utils
package common
import "math/rand"
func RandomString(length int) string {
// 生成随机字符串
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var result []byte
for i := 0; i < length; i++ {
+12
View File
@@ -0,0 +1,12 @@
package common
import (
"resty.dev/v3"
)
func Request(retryTimes int) *resty.Client {
client := resty.New()
client.
SetRetryCount(retryTimes)
return client
}
+48
View File
@@ -0,0 +1,48 @@
package common
import (
"fmt"
"strings"
)
func PrependRuleProvider(
sub *generatedConfig, providerName string, group string,
) {
PrependRules(
sub,
fmt.Sprintf("RULE-SET,%s,%s", providerName, group),
)
}
func AppenddRuleProvider(
sub *generatedConfig, providerName string, group string,
) {
AppendRules(sub, fmt.Sprintf("RULE-SET,%s,%s", providerName, group))
}
// PrependRules 用于在规则头部插入新规则。
// 这通常对应用户显式要求 prepend 的场景。
func PrependRules(sub *generatedConfig, rules ...string) {
if sub.Rule == nil {
sub.Rule = make([]string, 0)
}
sub.Rule = append(rules, sub.Rule...)
}
// AppendRules 在规则尾部追加,但如果尾部已有 MATCH,则保持 MATCH 仍然是最后一条。
func AppendRules(sub *generatedConfig, rules ...string) {
if sub.Rule == nil {
sub.Rule = make([]string, 0)
}
if len(sub.Rule) == 0 {
sub.Rule = append(sub.Rule, rules...)
return
}
matchRule := sub.Rule[len(sub.Rule)-1]
if strings.Contains(matchRule, "MATCH") {
sub.Rule = append(sub.Rule[:len(sub.Rule)-1], rules...)
sub.Rule = append(sub.Rule, matchRule)
return
}
sub.Rule = append(sub.Rule, rules...)
}
+682
View File
@@ -0,0 +1,682 @@
package common
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/bestnite/sub2clash/logger"
"github.com/bestnite/sub2clash/model"
P "github.com/bestnite/sub2clash/model/proxy"
"github.com/bestnite/sub2clash/parser"
"github.com/bestnite/sub2clash/utils"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
var subsDir = "subs"
var fileLock sync.RWMutex
func LoadSubscription(url string, refresh bool, userAgent string, cacheExpire int64, retryTimes int) ([]byte, error) {
if refresh {
return FetchSubscriptionFromAPI(url, userAgent, retryTimes)
}
hash := sha256.Sum224([]byte(url))
fileName := filepath.Join(subsDir, hex.EncodeToString(hash[:]))
stat, err := os.Stat(fileName)
if err != nil {
if !os.IsNotExist(err) {
return nil, err
}
return FetchSubscriptionFromAPI(url, userAgent, retryTimes)
}
lastGetTime := stat.ModTime().Unix()
if lastGetTime+cacheExpire > time.Now().Unix() {
file, err := os.Open(fileName)
if err != nil {
return nil, err
}
defer func(file *os.File) {
if file != nil {
_ = file.Close()
}
}(file)
fileLock.RLock()
defer fileLock.RUnlock()
subContent, err := io.ReadAll(file)
if err != nil {
return nil, err
}
return subContent, nil
}
return FetchSubscriptionFromAPI(url, userAgent, retryTimes)
}
func FetchSubscriptionFromAPI(url string, userAgent string, retryTimes int) ([]byte, error) {
hash := sha256.Sum224([]byte(url))
fileName := filepath.Join(subsDir, hex.EncodeToString(hash[:]))
client := Request(retryTimes)
defer client.Close()
resp, err := client.R().SetHeader("User-Agent", userAgent).Get(url)
if err != nil {
return nil, err
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
file, err := os.Create(fileName)
if err != nil {
return nil, err
}
defer func(file *os.File) {
if file != nil {
_ = file.Close()
}
}(file)
fileLock.Lock()
defer fileLock.Unlock()
_, err = file.Write(data)
if err != nil {
return nil, fmt.Errorf("failed to write to sub.yaml: %w", err)
}
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) (
*BuiltSub, error,
) {
templateDoc, templateBytes, err := loadTemplateDocument(query, template, cacheExpire, retryTimes)
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 templateBytes []byte
if query.Template != "" {
template = query.Template
}
if strings.HasPrefix(template, "http") {
templateBytes, err = LoadSubscription(template, query.Refresh, query.UserAgent, cacheExpire, retryTimes)
if err != nil {
logger.Logger.Debug(
"load template failed", zap.String("template", template), zap.Error(err),
)
return nil, nil, NewTemplateLoadError(template, err)
}
} else {
unescape, err := url.QueryUnescape(template)
if err != nil {
return nil, nil, NewTemplateLoadError(template, err)
}
templateBytes, err = LoadTemplate(unescape)
if err != nil {
logger.Logger.Debug(
"load template failed", zap.String("template", template), zap.Error(err),
)
return nil, nil, NewTemplateLoadError(unescape, err)
}
}
templateDoc, err := ParseYAMLDocument(templateBytes)
if err != nil {
logger.Logger.Debug("parse template yaml node failed", zap.Error(err))
return nil, templateBytes, NewTemplateParseError(templateBytes, err)
}
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 {
newProxies, err := loadSubscriptionProxies(query, query.Subs[i], cacheExpire, retryTimes)
if err != nil {
return nil, err
}
proxyList = append(proxyList, newProxies...)
}
if len(query.Proxies) != 0 {
p, err := parser.ParseProxies(parser.ParseConfig{UseUDP: query.UseUDP}, query.Proxies...)
if err != nil {
return nil, err
}
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 {
if proxyList[i].SubName != "" {
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)
newProxies := make([]P.Proxy, 0, len(proxyList))
for i := range proxyList {
yamlBytes, err := yaml.Marshal(proxyList[i])
if err != nil {
logger.Logger.Debug("marshal proxy failed", zap.Error(err))
return nil, fmt.Errorf("marshal proxy failed: %w", err)
}
key := string(yamlBytes)
if _, exist := proxies[key]; !exist {
proxies[key] = &proxyList[i]
newProxies = append(newProxies, proxyList[i])
}
}
return newProxies, nil
}
func removeProxiesByPattern(proxyList []P.Proxy, pattern string) ([]P.Proxy, error) {
if strings.TrimSpace(pattern) == "" {
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 {
if replaceReg.MatchString(proxyList[i].Name) {
proxyList[i].Name = replaceReg.ReplaceAllString(proxyList[i].Name, replacement)
}
}
}
return proxyList, nil
}
func ensureUniqueProxyNames(proxyList []P.Proxy) {
names := make(map[string]int)
for i := range proxyList {
if _, exist := names[proxyList[i].Name]; exist {
names[proxyList[i].Name] = names[proxyList[i].Name] + 1
proxyList[i].Name = proxyList[i].Name + " " + strconv.Itoa(names[proxyList[i].Name])
} else {
names[proxyList[i].Name] = 0
}
}
}
func trimProxyNames(proxyList []P.Proxy) {
for i := range proxyList {
proxyList[i].Name = strings.TrimSpace(proxyList[i].Name)
}
}
// buildGeneratedConfig 只生成“新增内容”,例如国家组和最终可输出的节点集合。
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 sortMode {
case "sizeasc":
sort.Sort(generatedGroupsSortBySize(generated.ProxyGroup))
case "sizedesc":
sort.Sort(sort.Reverse(generatedGroupsSortBySize(generated.ProxyGroup)))
case "nameasc":
sort.Sort(generatedGroupsSortByName(generated.ProxyGroup))
case "namedesc":
sort.Sort(sort.Reverse(generatedGroupsSortByName(generated.ProxyGroup)))
default:
sort.Sort(generatedGroupsSortByName(generated.ProxyGroup))
}
}
// applyRulePatches 只修改运行期 overlay 中的 rules 切片,不直接写 YAML。
func applyRulePatches(temp *generatedConfig, query model.ConvertConfig) {
for _, v := range query.Rules {
if v.Prepend {
PrependRules(temp, v.Rule)
} else {
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 {
hash := sha256.Sum224([]byte(v.Url))
name := hex.EncodeToString(hash[:])
patches[v.Name] = generatedRulePatch{
Type: "http",
Behavior: v.Behavior,
Url: v.Url,
Path: "./" + name + ".yaml",
Interval: 3600,
}
}
return patches
}
// extractTemplateOverlay 只从模板 YAML 树中提取本项目真正会参与计算的局部字段。
// 这让模板读取完全基于 yaml.Node,而不再依赖任何整份配置的 typed unmarshal。
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 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) {
client := Request(retryTimes)
defer client.Close()
resp, err := client.R().SetHeader("User-Agent", userAgent).Head(url)
if err != nil {
logger.Logger.Debug("创建 HEAD 请求失败", zap.Error(err))
return "", NewNetworkRequestError(url, err)
}
defer resp.Body.Close()
if userInfo := resp.Header().Get("subscription-userinfo"); userInfo != "" {
return userInfo, nil
}
logger.Logger.Debug("subscription-userinfo header not found in response")
return "", NewNetworkResponseError("subscription-userinfo header not found", nil)
}
// MergeSubAndTemplate 把“模板侧需要参与计算的最小叠加层”和“本项目生成结果”合并。
// 它只处理本项目关心的运行期结构,不负责最终 YAML 输出。
func MergeSubAndTemplate(temp *generatedConfig, sub *generatedConfig, igcg bool) {
var countryGroupNames []string
for _, proxyGroup := range sub.ProxyGroup {
if proxyGroup.IsCountry {
countryGroupNames = append(
countryGroupNames, proxyGroup.Name,
)
}
}
var proxyNames []string
for _, proxy := range sub.Proxy {
proxyNames = append(proxyNames, proxy.Name)
}
for i := range temp.ProxyGroup {
if temp.ProxyGroup[i].IsCountry {
continue
}
newProxies := make([]string, 0)
countryGroupMap := make(map[string]generatedGroup)
for _, v := range sub.ProxyGroup {
if v.IsCountry {
countryGroupMap[v.Name] = v
}
}
for j := range temp.ProxyGroup[i].Proxies {
reg := regexp.MustCompile("<(.*?)>")
if reg.Match([]byte(temp.ProxyGroup[i].Proxies[j])) {
key := reg.FindStringSubmatch(temp.ProxyGroup[i].Proxies[j])[1]
switch key {
case "all":
newProxies = append(newProxies, proxyNames...)
case "countries":
if !igcg {
newProxies = append(newProxies, countryGroupNames...)
}
default:
if !igcg {
if len(key) == 2 {
newProxies = append(
newProxies, countryGroupMap[GetContryName(key)].Proxies...,
)
}
}
}
} else {
newProxies = append(newProxies, temp.ProxyGroup[i].Proxies[j])
}
}
temp.ProxyGroup[i].Proxies = newProxies
}
if !igcg {
temp.ProxyGroup = append(temp.ProxyGroup, sub.ProxyGroup...)
}
}
+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)
}
}
+42
View File
@@ -0,0 +1,42 @@
package common
import (
"io"
"os"
"path/filepath"
"strings"
)
const templatesDir = "templates"
// 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 {
return nil, err
}
defer func(file *os.File) {
if file != nil {
_ = file.Close()
}
}(file)
result, err := io.ReadAll(file)
if err != nil {
return nil, err
}
return result, nil
}
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
}
+11
View File
@@ -0,0 +1,11 @@
name: sub2clash
services:
sub2clash:
restart: unless-stopped
image: nite07/sub2clash:latest
ports:
- "8011:8011"
volumes:
# - ./logs:/app/logs
# - ./templates:/app/templates
- ./data:/app/data
+10
View File
@@ -0,0 +1,10 @@
{
"address": "0.0.0.0:8011",
"meta_template": "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_meta.yaml",
"clash_template": "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_clash.yaml",
"request_retry_times": 3,
"request_max_file_size": 1048576,
"cache_expire": 300,
"log_level": "info",
"short_link_length": 6
}
+22
View File
@@ -0,0 +1,22 @@
# Sub2Clash 配置文件示例
# 复制此文件为 config.yaml 并根据需要修改配置
# 服务端口
address: "0.0.0.0:8011"
# 模板配置
meta_template: "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_meta.yaml"
clash_template: "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_clash.yaml"
# 请求配置
request_retry_times: 3
request_max_file_size: 1048576 # 1MB in bytes
# 缓存配置 (秒)
cache_expire: 300 # 5 minutes
# 日志级别 (debug, info, warn, error)
log_level: "info"
# 短链接长度
short_link_length: 6
+57 -72
View File
@@ -1,91 +1,76 @@
package config
import (
"errors"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
"github.com/spf13/viper"
)
type Config struct {
Port int
MetaTemplate string
ClashTemplate string
RequestRetryTimes int
RequestMaxFileSize int64
CacheExpire int64
LogLevel string
//BasePath string
ShortLinkLength int
Address string `mapstructure:"address"`
MetaTemplate string `mapstructure:"meta_template"`
ClashTemplate string `mapstructure:"clash_template"`
RequestRetryTimes int `mapstructure:"request_retry_times"`
RequestMaxFileSize int64 `mapstructure:"request_max_file_size"`
CacheExpire int64 `mapstructure:"cache_expire"`
LogLevel string `mapstructure:"log_level"`
ShortLinkLength int `mapstructure:"short_link_length"`
}
var Default *Config
var Version string
var GlobalConfig *Config
var Dev string
func LoadConfig() error {
Default = &Config{
MetaTemplate: "template_meta.yaml",
ClashTemplate: "template_clash.yaml",
RequestRetryTimes: 3,
RequestMaxFileSize: 1024 * 1024 * 1,
Port: 8011,
CacheExpire: 60 * 5,
LogLevel: "info",
//BasePath: "/",
ShortLinkLength: 6,
v := viper.New()
// 添加配置文件搜索路径
v.AddConfigPath(".")
v.AddConfigPath("./config")
v.AddConfigPath("/etc/sub2clash/")
// 设置默认值
setDefaults(v)
// 设置环境变量前缀和自动绑定
v.SetEnvPrefix("SUB2CLASH")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// 尝试按优先级加载不同格式的配置文件
configLoaded := false
configNames := []string{"config", "sub2clash"}
configExts := []string{"yaml", "yml", "json"}
for _, name := range configNames {
for _, ext := range configExts {
v.SetConfigName(name)
v.SetConfigType(ext)
if err := v.ReadInConfig(); err == nil {
configLoaded = true
break
}
_ = godotenv.Load()
if os.Getenv("PORT") != "" {
atoi, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
return errors.New("PORT invalid")
}
Default.Port = atoi
if configLoaded {
break
}
if os.Getenv("META_TEMPLATE") != "" {
Default.MetaTemplate = os.Getenv("META_TEMPLATE")
}
if os.Getenv("CLASH_TEMPLATE") != "" {
Default.ClashTemplate = os.Getenv("CLASH_TEMPLATE")
}
if os.Getenv("REQUEST_RETRY_TIMES") != "" {
atoi, err := strconv.Atoi(os.Getenv("REQUEST_RETRY_TIMES"))
if err != nil {
return errors.New("REQUEST_RETRY_TIMES invalid")
}
Default.RequestRetryTimes = atoi
}
if os.Getenv("REQUEST_MAX_FILE_SIZE") != "" {
atoi, err := strconv.Atoi(os.Getenv("REQUEST_MAX_FILE_SIZE"))
if err != nil {
return errors.New("REQUEST_MAX_FILE_SIZE invalid")
}
Default.RequestMaxFileSize = int64(atoi)
}
if os.Getenv("CACHE_EXPIRE") != "" {
atoi, err := strconv.Atoi(os.Getenv("CACHE_EXPIRE"))
if err != nil {
return errors.New("CACHE_EXPIRE invalid")
}
Default.CacheExpire = int64(atoi)
}
if os.Getenv("LOG_LEVEL") != "" {
Default.LogLevel = os.Getenv("LOG_LEVEL")
}
//if os.Getenv("BASE_PATH") != "" {
// Default.BasePath = os.Getenv("BASE_PATH")
// if Default.BasePath[len(Default.BasePath)-1] != '/' {
// Default.BasePath += "/"
// }
//}
if os.Getenv("SHORT_LINK_LENGTH") != "" {
atoi, err := strconv.Atoi(os.Getenv("SHORT_LINK_LENGTH"))
if err != nil {
return errors.New("SHORT_LINK_LENGTH invalid")
}
Default.ShortLinkLength = atoi
// 将配置解析到结构体
GlobalConfig = &Config{}
if err := v.Unmarshal(GlobalConfig); err != nil {
return err
}
return nil
}
func setDefaults(v *viper.Viper) {
v.SetDefault("address", "0.0.0.0:8011")
v.SetDefault("meta_template", "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_meta.yaml")
v.SetDefault("clash_template", "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_clash.yaml")
v.SetDefault("request_retry_times", 3)
v.SetDefault("request_max_file_size", 1024*1024*1)
v.SetDefault("cache_expire", 60*5)
v.SetDefault("log_level", "info")
v.SetDefault("short_link_length", 6)
}
+3
View File
@@ -0,0 +1,3 @@
package constant
var Version = "dev"
-21
View File
@@ -1,21 +0,0 @@
version: "3"
services:
sub2clash:
container_name: sub2clash
restart: unless-stopped
image: ghcr.io/nitezs/sub2clash:latest
ports:
- "8011:8011"
volumes:
- ./logs:/app/logs
- ./templates:/app/templates
- ./data:/app/data
# environment:
# - PORT=8011
# - META_TEMPLATE=template_meta.yaml
# - PROXY_TEMPLATE=template_clash.yaml
# - REQUEST_RETRY_TIMES=3
# - REQUEST_MAX_FILE_SIZE=1048576
# - CACHE_EXPIRE=300
# - LOG_LEVEL=info
+128 -25
View File
@@ -1,49 +1,152 @@
module sub2clash
module github.com/bestnite/sub2clash
go 1.21
go 1.25
require (
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.9.0
github.com/joho/godotenv v1.5.1
go.uber.org/zap v1.26.0
golang.org/x/text v0.13.0
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/metacubex/mihomo v1.19.10
github.com/spf13/viper v1.20.1
go.uber.org/zap v1.27.0
golang.org/x/text v0.30.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.25.4
gorm.io/gorm v1.31.0
resty.dev/v3 v3.0.0-beta.3
)
require (
github.com/bytedance/sonic v1.10.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect
github.com/3andne/restls-client-go v0.1.6 // indirect
github.com/RyuaNerin/go-krypto v1.3.0 // indirect
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/coreos/go-iptables v0.8.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/ebitengine/purego v0.8.3 // indirect
github.com/enfein/mieru/v3 v3.13.0 // indirect
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 // indirect
github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 // indirect
github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gaukas/godicttls v0.0.4 // 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-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.15.4 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gofrs/uuid/v5 v5.3.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.6.0 // 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/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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/metacubex/amneziawg-go v0.0.0-20240922133038-fdf3a4d5a4ab // indirect
github.com/metacubex/bart v0.20.5 // indirect
github.com/metacubex/bbolt v0.0.0-20240822011022-aed6d4850399 // indirect
github.com/metacubex/chacha v0.1.2 // indirect
github.com/metacubex/fswatch v0.1.1 // indirect
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 // indirect
github.com/metacubex/gvisor v0.0.0-20250324165734-5857f47bd43b // indirect
github.com/metacubex/nftables v0.0.0-20250503052935-30a69ab87793 // indirect
github.com/metacubex/quic-go v0.52.1-0.20250522021943-aef454b9e639 // indirect
github.com/metacubex/randv2 v0.2.0 // indirect
github.com/metacubex/sing v0.5.3 // indirect
github.com/metacubex/sing-mux v0.3.2 // indirect
github.com/metacubex/sing-quic v0.0.0-20250523120938-f1a248e5ec7f // indirect
github.com/metacubex/sing-shadowsocks v0.2.10 // indirect
github.com/metacubex/sing-shadowsocks2 v0.2.4 // indirect
github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 // indirect
github.com/metacubex/sing-tun v0.4.6-0.20250524142129-9d110c0af70c // indirect
github.com/metacubex/sing-vmess v0.2.2 // indirect
github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f // indirect
github.com/metacubex/smux v0.0.0-20250503055512-501391591dee // indirect
github.com/metacubex/tfo-go v0.0.0-20250516165257-e29c16ae41d4 // indirect
github.com/metacubex/utls v1.7.3 // indirect
github.com/metacubex/wireguard-go v0.0.0-20240922131502-c182e7471181 // indirect
github.com/miekg/dns v1.1.63 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/mroth/weightedrand/v2 v2.1.0 // indirect
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 // indirect
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
github.com/openacid/low v0.1.21 // indirect
github.com/oschwald/maxminddb-golang v1.12.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // 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/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.50.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.1 // indirect
github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b // indirect
github.com/sina-ghaderi/rabaead v0.0.0-20220730151906-ab6e06b96e8c // indirect
github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 // indirect
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec // indirect
go.uber.org/mock v0.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.5.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.1 // 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
+326 -59
View File
@@ -1,120 +1,385 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc=
github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/3andne/restls-client-go v0.1.6 h1:tRx/YilqW7iHpgmEL4E1D8dAsuB0tFF3uvncS+B6I08=
github.com/3andne/restls-client-go v0.1.6/go.mod h1:iEdTZNt9kzPIxjIGSMScUFSBrUH6bFRNg0BWlP4orEY=
github.com/RyuaNerin/go-krypto v1.3.0 h1:smavTzSMAx8iuVlGb4pEwl9MD2qicqMzuXR2QWp2/Pg=
github.com/RyuaNerin/go-krypto v1.3.0/go.mod h1:9R9TU936laAIqAmjcHo/LsaXYOZlymudOAxjaBf62UM=
github.com/RyuaNerin/testingutil v0.1.0 h1:IYT6JL57RV3U2ml3dLHZsVtPOP6yNK7WUVdzzlpNrss=
github.com/RyuaNerin/testingutil v0.1.0/go.mod h1:yTqj6Ta/ycHMPJHRyO12Mz3VrvTloWOsy23WOZH19AA=
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 h1:cDVUiFo+npB0ZASqnw4q90ylaVAbnYyx0JYqK4YcGok=
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
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/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
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/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc=
github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
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/enfein/mieru/v3 v3.13.0 h1:eGyxLGkb+lut9ebmx+BGwLJ5UMbEc/wGIYO0AXEKy98=
github.com/enfein/mieru/v3 v3.13.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g=
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391/go.mod h1:K2R7GhgxrlJzHw2qiPWsCZXf/kXEJN9PLnQK73Ll0po=
github.com/ericlagergren/saferand v0.0.0-20220206064634-960a4dd2bc5c h1:RUzBDdZ+e/HEe2Nh8lYsduiPAZygUfVXJn0Ncj5sHMg=
github.com/ericlagergren/saferand v0.0.0-20220206064634-960a4dd2bc5c/go.mod h1:ETASDWf/FmEb6Ysrtd1QhjNedUU/ZQxBCRLh60bQ/UI=
github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 h1:tlDMEdcPRQKBEz5nGDMvswiajqh7k8ogWRlhRwKy5mY=
github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1/go.mod h1:4RfsapbGx2j/vU5xC/5/9qB3kn9Awp1YDiEnN43QrJ4=
github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010 h1:fuGucgPk5dN6wzfnxl3D0D3rVLw4v2SbBT9jb4VnxzA=
github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010/go.mod h1:JtBcj7sBuTTRupn7c2bFspMDIObMJsVK8TeUvpShPok=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
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-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
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/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.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
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/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/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.15.4 h1:zMXza4EpOdooxPel5xDqXEdXG5r+WggpvnAKMsalBjs=
github.com/go-playground/validator/v10 v10.15.4/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=
github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/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/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
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/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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 h1:EnfXoSqDfSNJv0VBNqY/88RNnhSGYkrHaO0mmFGbVsc=
github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40/go.mod h1:vy1vK6wD6j7xX6O6hXe621WabdtNkou2h7uRtTfRMyg=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/metacubex/amneziawg-go v0.0.0-20240922133038-fdf3a4d5a4ab h1:Chbw+/31UC14YFNr78pESt5Vowlc62zziw05JCUqoL4=
github.com/metacubex/amneziawg-go v0.0.0-20240922133038-fdf3a4d5a4ab/go.mod h1:xVKK8jC5Sd3hfh7WjmCq+HorehIbrBijaUWmcuKjPcI=
github.com/metacubex/bart v0.20.5 h1:XkgLZ17QxfxkqKdGsojoM2Zu01mmHyyQSFzt2/calTM=
github.com/metacubex/bart v0.20.5/go.mod h1:DCcyfP4MC+Zy7sLK7XeGuMw+P5K9mIRsYOBgiE8icsI=
github.com/metacubex/bbolt v0.0.0-20240822011022-aed6d4850399 h1:oBowHVKZycNtAFbZ6avaCSZJYeme2Nrj+4RpV2cNJig=
github.com/metacubex/bbolt v0.0.0-20240822011022-aed6d4850399/go.mod h1:4xcieuIK+M4bGQmQYZVqEaIYqjS1ahO4kXG7EmDgEro=
github.com/metacubex/chacha v0.1.2 h1:QulCq3eVm3TO6+4nVIWJtmSe7BT2GMrgVHuAoqRQnlc=
github.com/metacubex/chacha v0.1.2/go.mod h1:Djn9bPZxLTXbJFSeyo0/qzEzQI+gUSSzttuzZM75GH8=
github.com/metacubex/fswatch v0.1.1 h1:jqU7C/v+g0qc2RUFgmAOPoVvfl2BXXUXEumn6oQuxhU=
github.com/metacubex/fswatch v0.1.1/go.mod h1:czrTT7Zlbz7vWft8RQu9Qqh+JoX+Nnb+UabuyN1YsgI=
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvOzK9ubNCCkQ+ldc4YSH/rILn53l/xGBFHHI=
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759/go.mod h1:UHOv2xu+RIgLwpXca7TLrXleEd4oR3sPatW6IF8wU88=
github.com/metacubex/gvisor v0.0.0-20250324165734-5857f47bd43b h1:RUh4OdVPz/jDrM9MQ2ySuqu2aeBqcA8rtfWUYLZ8RtI=
github.com/metacubex/gvisor v0.0.0-20250324165734-5857f47bd43b/go.mod h1:8LpS0IJW1VmWzUm3ylb0e2SK5QDm5lO/2qwWLZgRpBU=
github.com/metacubex/mihomo v1.19.10 h1:GXCOA1rJNfU5qYSvo+UBUFksh61M0tjPfvlZ1OsYtfs=
github.com/metacubex/mihomo v1.19.10/go.mod h1:ih7BKy1pfqSvPRqaCcuFFK4oNRIFyBotoHX0PbhF7SQ=
github.com/metacubex/nftables v0.0.0-20250503052935-30a69ab87793 h1:1Qpuy+sU3DmyX9HwI+CrBT/oLNJngvBorR2RbajJcqo=
github.com/metacubex/nftables v0.0.0-20250503052935-30a69ab87793/go.mod h1:RjRNb4G52yAgfR+Oe/kp9G4PJJ97Fnj89eY1BFO3YyA=
github.com/metacubex/quic-go v0.52.1-0.20250522021943-aef454b9e639 h1:L+1brQNzBhCCxWlicwfK1TlceemCRmrDE4HmcVHc29w=
github.com/metacubex/quic-go v0.52.1-0.20250522021943-aef454b9e639/go.mod h1:Kc6h++Q/zf3AxcUCevJhJwgrskJumv+pZdR8g/E/10k=
github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs=
github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY=
github.com/metacubex/sing v0.5.2/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
github.com/metacubex/sing v0.5.3 h1:QWdN16WFKMk06x4nzkc8SvZ7y2x+TLQrpkPoHs+WSVM=
github.com/metacubex/sing v0.5.3/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
github.com/metacubex/sing-mux v0.3.2 h1:nJv52pyRivHcaZJKk2JgxpaVvj1GAXG81scSa9N7ncw=
github.com/metacubex/sing-mux v0.3.2/go.mod h1:3rt1soewn0O6j89GCLmwAQFsq257u0jf2zQSPhTL3Bw=
github.com/metacubex/sing-quic v0.0.0-20250523120938-f1a248e5ec7f h1:mP3vIm+9hRFI0C0Vl3pE0NESF/L85FDbuB0tGgUii6I=
github.com/metacubex/sing-quic v0.0.0-20250523120938-f1a248e5ec7f/go.mod h1:JPTpf7fpnojsSuwRJExhSZSy63pVbp3VM39+zj+sAJM=
github.com/metacubex/sing-shadowsocks v0.2.10 h1:Pr7LDbjMANIQHl07zWgl1vDuhpsfDQUpZ8cX6DPabfg=
github.com/metacubex/sing-shadowsocks v0.2.10/go.mod h1:MtRM0ZZjR0kaDOzy9zWSt6/4/UlrnsNBq+1FNAF4vBk=
github.com/metacubex/sing-shadowsocks2 v0.2.4 h1:Ec0x3hHR7xkld5Z09IGh16wtUUpBb2HgqZ9DExd8Q7s=
github.com/metacubex/sing-shadowsocks2 v0.2.4/go.mod h1:WP8+S0kqtnSbX1vlIpo5i8Irm/ijZITEPBcZ26B5unY=
github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 h1:gXU+MYPm7Wme3/OAY2FFzVq9d9GxPHOqu5AQfg/ddhI=
github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E=
github.com/metacubex/sing-tun v0.4.6-0.20250524142129-9d110c0af70c h1:Y6jk7AH5BEg9Dsvczrf/KokYsvxeKSZZlCLHg+hC4ro=
github.com/metacubex/sing-tun v0.4.6-0.20250524142129-9d110c0af70c/go.mod h1:HDaHDL6onAX2ZGbAGUXKp++PohRdNb7Nzt6zxzhox+U=
github.com/metacubex/sing-vmess v0.2.2 h1:nG6GIKF1UOGmlzs+BIetdGHkFZ20YqFVIYp5Htqzp+4=
github.com/metacubex/sing-vmess v0.2.2/go.mod h1:CVDNcdSLVYFgTHQlubr88d8CdqupAUDqLjROos+H9xk=
github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f h1:Sr/DYKYofKHKc4GF3qkRGNuj6XA6c0eqPgEDN+VAsYU=
github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f/go.mod h1:jpAkVLPnCpGSfNyVmj6Cq4YbuZsFepm/Dc+9BAOcR80=
github.com/metacubex/smux v0.0.0-20250503055512-501391591dee h1:lp6hJ+4wCLZu113awp7P6odM2okB5s60HUyF0FMqKmo=
github.com/metacubex/smux v0.0.0-20250503055512-501391591dee/go.mod h1:4bPD8HWx9jPJ9aE4uadgyN7D1/Wz3KmPy+vale8sKLE=
github.com/metacubex/tfo-go v0.0.0-20250516165257-e29c16ae41d4 h1:j1VRTiC9JLR4nUbSikx9OGdu/3AgFDqgcLj4GoqyQkc=
github.com/metacubex/tfo-go v0.0.0-20250516165257-e29c16ae41d4/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw=
github.com/metacubex/utls v1.7.3 h1:yDcMEWojFh+t8rU9X0HPcZDPAoFze/rIIyssqivzj8A=
github.com/metacubex/utls v1.7.3/go.mod h1:oknYT0qTOwE4hjPmZOEpzVdefnW7bAdGLvZcqmk4TLU=
github.com/metacubex/wireguard-go v0.0.0-20240922131502-c182e7471181 h1:hJLQviGySBuaynlCwf/oYgIxbVbGRUIKZCxdya9YrbQ=
github.com/metacubex/wireguard-go v0.0.0-20240922131502-c182e7471181/go.mod h1:phewKljNYiTVT31Gcif8RiCKnTUOgVWFJjccqYM8s+Y=
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/mroth/weightedrand/v2 v2.1.0 h1:o1ascnB1CIVzsqlfArQQjeMy1U0NcIbBO5rfd5E/OeU=
github.com/mroth/weightedrand/v2 v2.1.0/go.mod h1:f2faGsfOGOwc1p94wzHKKZyTpcJUW7OJ/9U4yfiNAOU=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 h1:1102pQc2SEPp5+xrS26wEaeb26sZy6k9/ZXlZN+eXE4=
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7/go.mod h1:UqoUn6cHESlliMhOnKLWr+CBH+e3bazUPvFj1XZwAjs=
github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/openacid/errors v0.8.1/go.mod h1:GUQEJJOJE3W9skHm8E8Y4phdl2LLEN8iD7c5gcGgdx0=
github.com/openacid/low v0.1.21 h1:Tr2GNu4N/+rGRYdOsEHOE89cxUIaDViZbVmKz29uKGo=
github.com/openacid/low v0.1.21/go.mod h1:q+MsKI6Pz2xsCkzV4BLj7NR5M4EX0sGz5AqotpZDVh0=
github.com/openacid/must v0.1.3/go.mod h1:luPiXCuJlEo3UUFQngVQokV0MPGryeYvtCbQPs3U1+I=
github.com/openacid/testkeys v0.1.6/go.mod h1:MfA7cACzBpbiwekivj8StqX0WIRmqlMsci1c37CA3Do=
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
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/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/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/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b h1:rXHg9GrUEtWZhEkrykicdND3VPjlVbYiLdX9J7gimS8=
github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b/go.mod h1:X7qrxNQViEaAN9LNZOPl9PfvQtp3V3c7LTo0dvGi0fM=
github.com/sina-ghaderi/rabaead v0.0.0-20220730151906-ab6e06b96e8c h1:DjKMC30y6yjG3IxDaeAj3PCoRr+IsO+bzyT+Se2m2Hk=
github.com/sina-ghaderi/rabaead v0.0.0-20220730151906-ab6e06b96e8c/go.mod h1:NV/a66PhhWYVmUMaotlXJ8fIEFB98u+c8l/CQIEFLrU=
github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e h1:ur8uMsPIFG3i4Gi093BQITvwH9znsz2VUZmnmwHvpIo=
github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e/go.mod h1:+e5fBW3bpPyo+3uLo513gIUblc03egGjMM0+5GKbzK8=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 h1:UNrDfkQqiEYzdMlNsVvBYOAJWZjdktqFE9tQh5BT2+4=
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/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ=
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/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
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/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
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.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
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-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-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-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-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
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.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
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/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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw=
gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
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/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=
@@ -124,4 +389,6 @@ 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=
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=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+35 -53
View File
@@ -1,70 +1,52 @@
package logger
import (
"path/filepath"
"sync"
"time"
"os"
"strings"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
var (
Logger *zap.Logger
lock sync.Mutex
logLevel string
)
var Logger *zap.Logger
func InitLogger(level string) {
logLevel = level
buildLogger()
go rotateLogs()
func InitLogger(logLevel string) {
logger := zap.New(buildZapCore(getZapLogLevel(logLevel)))
Logger = logger
}
func buildLogger() {
lock.Lock()
defer lock.Unlock()
var level zapcore.Level
switch logLevel {
case "error":
level = zap.ErrorLevel
func buildZapCore(logLevel zapcore.Level) zapcore.Core {
fileWriter := zapcore.AddSync(&lumberjack.Logger{
Filename: "logs/app.log",
MaxSize: 500,
MaxBackups: 3,
MaxAge: 28,
Compress: true,
})
consoleWriter := zapcore.AddSync(os.Stdout)
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
fileCore := zapcore.NewCore(zapcore.NewJSONEncoder(encoderConfig), fileWriter, logLevel)
consoleCore := zapcore.NewCore(zapcore.NewConsoleEncoder(encoderConfig), consoleWriter, logLevel)
combinedCore := zapcore.NewTee(fileCore, consoleCore)
return combinedCore
}
func getZapLogLevel(logLevel string) zapcore.Level {
switch strings.ToLower(logLevel) {
case "debug":
level = zap.DebugLevel
return zap.DebugLevel
case "warn":
level = zap.WarnLevel
return zap.WarnLevel
case "error":
return zap.ErrorLevel
case "info":
level = zap.InfoLevel
return zap.InfoLevel
default:
level = zap.InfoLevel
}
zapConfig := zap.NewProductionConfig()
zapConfig.Encoding = "console"
zapConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
zapConfig.EncoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
zapConfig.OutputPaths = []string{"stdout", getLogFileName("info")}
zapConfig.ErrorOutputPaths = []string{"stderr", getLogFileName("error")}
zapConfig.Level = zap.NewAtomicLevelAt(level)
var err error
Logger, err = zapConfig.Build()
if err != nil {
panic("log failed" + err.Error())
}
}
// 根据日期获得日志文件
func getLogFileName(name string) string {
return filepath.Join("logs", time.Now().Format("2006-01-02")+"-"+name+".log")
}
func rotateLogs() {
for {
now := time.Now()
nextMidnight := time.Date(
now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location(),
).Add(24 * time.Hour)
durationUntilMidnight := nextMidnight.Sub(now)
time.Sleep(durationUntilMidnight)
buildLogger()
return zap.InfoLevel
}
}
+17 -48
View File
@@ -3,75 +3,44 @@ package main
import (
_ "embed"
"io"
"strconv"
"sub2clash/api"
"sub2clash/config"
"sub2clash/logger"
"sub2clash/utils"
"sub2clash/utils/database"
"github.com/bestnite/sub2clash/common"
"github.com/bestnite/sub2clash/config"
"github.com/bestnite/sub2clash/logger"
"github.com/bestnite/sub2clash/server"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
//go:embed templates/template_meta.yaml
var templateMeta string
//go:embed templates/template_clash.yaml
var templateClash string
func init() {
var err error
// 创建文件夹
err = utils.MkEssentialDir()
err = common.MkEssentialDir()
if err != nil {
logger.Logger.Panic("create essential dir failed", zap.Error(err))
}
// 加载配置
err = config.LoadConfig()
// 初始化日志
logger.InitLogger(config.Default.LogLevel)
logger.InitLogger(config.GlobalConfig.LogLevel)
if err != nil {
logger.Logger.Panic("load config failed", zap.Error(err))
}
// 检查更新
if config.Dev != "true" {
go func() {
update, newVersion, err := utils.CheckUpdate()
if err != nil {
logger.Logger.Warn("check update failed", zap.Error(err))
}
if update {
logger.Logger.Info("new version is available", zap.String("version", newVersion))
}
}()
} else {
logger.Logger.Info("running in dev mode")
}
// 写入默认模板
err = utils.WriteDefalutTemplate(templateMeta, templateClash)
if err != nil {
logger.Logger.Panic("write default template 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")
}
func main() {
// 设置运行模式
gin.SetMode(gin.ReleaseMode)
// 关闭 Gin 的日志输出
gin.DefaultWriter = io.Discard
// 创建路由
r := gin.Default()
// 设置路由
api.SetRoute(r)
logger.Logger.Info("server is running at http://localhost:" + strconv.Itoa(config.Default.Port))
err := r.Run(":" + strconv.Itoa(config.Default.Port))
server.SetRoute(r)
logger.Logger.Info("server is running at " + config.GlobalConfig.Address)
err := r.Run(config.GlobalConfig.Address)
if err != nil {
logger.Logger.Error("server running failed", zap.Error(err))
return
+15 -16
View File
@@ -1,5 +1,7 @@
package model
import "github.com/bestnite/sub2clash/parser"
type ClashType int
const (
@@ -8,23 +10,20 @@ const (
)
func GetSupportProxyTypes(clashType ClashType) map[string]bool {
if clashType == Clash {
return map[string]bool{
"ss": true,
"ssr": true,
"vmess": true,
"trojan": true,
supportProxyTypes := make(map[string]bool)
for _, parser := range parser.GetAllParsers() {
switch clashType {
case Clash:
if parser.SupportClash() {
supportProxyTypes[parser.GetType()] = true
}
case ClashMeta:
if parser.SupportMeta() {
supportProxyTypes[parser.GetType()] = true
}
}
if clashType == ClashMeta {
return map[string]bool{
"ss": true,
"ssr": true,
"vmess": true,
"trojan": true,
"vless": true,
"hysteria2": true,
}
}
return nil
return supportProxyTypes
}
+92
View File
@@ -0,0 +1,92 @@
package model
import (
"encoding/json"
"errors"
"net/url"
"strings"
"github.com/bestnite/sub2clash/utils"
"github.com/gin-gonic/gin"
)
type ConvertConfig struct {
ClashType ClashType `json:"clashType" binding:"required"`
Subs []string `json:"subscriptions" binding:""`
Proxies []string `json:"proxies" binding:""`
Refresh bool `json:"refresh" binding:""`
Template string `json:"template" binding:""`
RuleProviders []RuleProviderStruct `json:"ruleProviders" binding:""`
Rules []RuleStruct `json:"rules" binding:""`
AutoTest bool `json:"autoTest" binding:""`
Lazy bool `json:"lazy" binding:""`
Sort string `json:"sort" binding:""`
Remove string `json:"remove" binding:""`
Replace map[string]string `json:"replace" binding:""`
NodeListMode bool `json:"nodeList" binding:""`
IgnoreCountryGrooup bool `json:"ignoreCountryGroup" binding:""`
UserAgent string `json:"userAgent" binding:""`
UseUDP bool `json:"useUDP" binding:""`
}
type RuleProviderStruct struct {
Behavior string `json:"behavior" binding:""`
Url string `json:"url" binding:""`
Group string `json:"group" binding:""`
Prepend bool `json:"prepend" binding:""`
Name string `json:"name" binding:""`
}
type RuleStruct struct {
Rule string `json:"rule" binding:""`
Prepend bool `json:"prepend" binding:""`
}
func ParseConvertQuery(c *gin.Context) (ConvertConfig, error) {
config := c.Param("config")
queryBytes, err := utils.DecodeBase64(config, true)
if err != nil {
return ConvertConfig{}, errors.New("参数错误: " + err.Error())
}
var query ConvertConfig
err = json.Unmarshal([]byte(queryBytes), &query)
if err != nil {
return ConvertConfig{}, errors.New("参数错误: " + err.Error())
}
if len(query.Subs) == 0 && len(query.Proxies) == 0 {
return ConvertConfig{}, errors.New("参数错误: sub 和 proxy 不能同时为空")
}
if len(query.Subs) > 0 {
for i := range query.Subs {
if !strings.HasPrefix(query.Subs[i], "http") {
return ConvertConfig{}, errors.New("参数错误: sub 格式错误")
}
if _, err := url.ParseRequestURI(query.Subs[i]); err != nil {
return ConvertConfig{}, errors.New("参数错误: " + err.Error())
}
}
} else {
query.Subs = nil
}
if query.Template != "" {
if strings.HasPrefix(query.Template, "http") {
uri, err := url.ParseRequestURI(query.Template)
if err != nil {
return ConvertConfig{}, err
}
query.Template = uri.String()
}
}
if len(query.RuleProviders) > 0 {
names := make(map[string]bool)
for _, ruleProvider := range query.RuleProviders {
if _, ok := names[ruleProvider.Name]; ok {
return ConvertConfig{}, errors.New("参数错误: Rule-Provider 名称重复")
}
names[ruleProvider.Name] = true
}
} else {
query.RuleProviders = nil
}
return query, nil
}
-3
View File
@@ -1,8 +1,5 @@
package model
// https://zh.wikipedia.org/wiki/%E5%8C%BA%E5%9F%9F%E6%8C%87%E7%A4%BA%E7%AC%A6
// https://zh.wikipedia.org/zh-sg/ISO_3166-1%E4%BA%8C%E4%BD%8D%E5%AD%97%E6%AF%8D%E4%BB%A3%E7%A0%81
var CountryEnglishName = map[string]string{
"Andorra": "安道尔(AD)",
"United Arab Emirates": "阿联酋(AE)",
-12
View File
@@ -1,12 +0,0 @@
package model
type Tags []struct {
Name string `json:"name"`
ZipballUrl string `json:"zipball_url"`
TarballUrl string `json:"tarball_url"`
Commit struct {
Sha string `json:"sha"`
Url string `json:"url"`
}
NodeId string `json:"node_id"`
}
-71
View File
@@ -1,71 +0,0 @@
package model
type SmuxStruct struct {
Enabled bool `yaml:"enable"`
}
type Proxy struct {
Name string `yaml:"name,omitempty"`
Server string `yaml:"server,omitempty"`
Port int `yaml:"port,omitempty"`
Type string `yaml:"type,omitempty"`
Cipher string `yaml:"cipher,omitempty"`
Password string `yaml:"password,omitempty"`
UDP bool `yaml:"udp,omitempty"`
UUID string `yaml:"uuid,omitempty"`
Network string `yaml:"network,omitempty"`
Flow string `yaml:"flow,omitempty"`
TLS bool `yaml:"tls,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
Plugin string `yaml:"plugin,omitempty"`
PluginOpts map[string]any `yaml:"plugin-opts,omitempty"`
Smux SmuxStruct `yaml:"smux,omitempty"`
Sni string `yaml:"sni,omitempty"`
AllowInsecure bool `yaml:"allow-insecure,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Alpn []string `yaml:"alpn,omitempty"`
XUDP bool `yaml:"xudp,omitempty"`
Servername string `yaml:"servername,omitempty"`
WSOpts WSOptions `yaml:"ws-opts,omitempty"`
AlterID int `yaml:"alterId,omitempty"`
GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"`
RealityOpts RealityOptions `yaml:"reality-opts,omitempty"`
Protocol string `yaml:"protocol,omitempty"`
Obfs string `yaml:"obfs,omitempty"`
ObfsParam string `yaml:"obfs-param,omitempty"`
ProtocolParam string `yaml:"protocol-param,omitempty"`
Remarks []string `yaml:"remarks,omitempty"`
HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"`
HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"`
PacketAddr bool `yaml:"packet-addr,omitempty"`
PacketEncoding string `yaml:"packet-encoding,omitempty"`
GlobalPadding bool `yaml:"global-padding,omitempty"`
AuthenticatedLength bool `yaml:"authenticated-length,omitempty"`
UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"`
UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"`
SubName string `yaml:"-"`
Up string `yaml:"up,omitempty"`
Down string `yaml:"down,omitempty"`
CustomCA string `yaml:"ca,omitempty"`
CustomCAString string `yaml:"ca-str,omitempty"`
CWND int `yaml:"cwnd,omitempty"`
}
func (p Proxy) MarshalYAML() (interface{}, error) {
switch p.Type {
case "vmess":
return ProxyToVmess(p), nil
case "ss":
return ProxyToShadowSocks(p), nil
case "ssr":
return ProxyToShadowSocksR(p), nil
case "vless":
return ProxyToVless(p), nil
case "trojan":
return ProxyToTrojan(p), nil
case "hysteria2":
return ProxyToHysteria2(p), nil
}
return nil, nil
}
+18
View File
@@ -0,0 +1,18 @@
package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/anytls.go
type Anytls struct {
Server string `yaml:"server"`
Port IntOrString `yaml:"port"`
Password string `yaml:"password"`
ALPN []string `yaml:"alpn,omitempty"`
SNI string `yaml:"sni,omitempty"`
ECHOpts ECHOptions `yaml:"ech-opts,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
UDP bool `yaml:"udp,omitempty"`
IdleSessionCheckInterval int `yaml:"idle-session-check-interval,omitempty"`
IdleSessionTimeout int `yaml:"idle-session-timeout,omitempty"`
MinIdleSession int `yaml:"min-idle-session,omitempty"`
}
+29
View File
@@ -0,0 +1,29 @@
package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/hysteria.go
type Hysteria struct {
Server string `yaml:"server"`
Port IntOrString `yaml:"port,omitempty"`
Ports string `yaml:"ports,omitempty"`
Protocol string `yaml:"protocol,omitempty"`
ObfsProtocol string `yaml:"obfs-protocol,omitempty"` // compatible with Stash
Up string `yaml:"up"`
UpSpeed int `yaml:"up-speed,omitempty"` // compatible with Stash
Down string `yaml:"down"`
DownSpeed int `yaml:"down-speed,omitempty"` // compatible with Stash
Auth string `yaml:"auth,omitempty"`
AuthString string `yaml:"auth-str,omitempty"`
Obfs string `yaml:"obfs,omitempty"`
SNI string `yaml:"sni,omitempty"`
ECHOpts ECHOptions `yaml:"ech-opts,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
ALPN []string `yaml:"alpn,omitempty"`
CustomCA string `yaml:"ca,omitempty"`
CustomCAString string `yaml:"ca-str,omitempty"`
ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"`
ReceiveWindow int `yaml:"recv-window,omitempty"`
DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"`
FastOpen bool `yaml:"fast-open,omitempty"`
HopInterval int `yaml:"hop-interval,omitempty"`
}
+29
View File
@@ -0,0 +1,29 @@
package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/hysteria2.go
type Hysteria2 struct {
Server string `yaml:"server"`
Port IntOrString `yaml:"port,omitempty"`
Ports string `yaml:"ports,omitempty"`
HopInterval int `yaml:"hop-interval,omitempty"`
Up string `yaml:"up,omitempty"`
Down string `yaml:"down,omitempty"`
Password string `yaml:"password,omitempty"`
Obfs string `yaml:"obfs,omitempty"`
ObfsPassword string `yaml:"obfs-password,omitempty"`
SNI string `yaml:"sni,omitempty"`
ECHOpts ECHOptions `yaml:"ech-opts,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
ALPN []string `yaml:"alpn,omitempty"`
CustomCA string `yaml:"ca,omitempty"`
CustomCAString string `yaml:"ca-str,omitempty"`
CWND int `yaml:"cwnd,omitempty"`
UdpMTU int `yaml:"udp-mtu,omitempty"`
// quic-go special config
InitialStreamReceiveWindow uint64 `yaml:"initial-stream-receive-window,omitempty"`
MaxStreamReceiveWindow uint64 `yaml:"max-stream-receive-window,omitempty"`
InitialConnectionReceiveWindow uint64 `yaml:"initial-connection-receive-window,omitempty"`
MaxConnectionReceiveWindow uint64 `yaml:"max-connection-receive-window,omitempty"`
}
+325
View File
@@ -0,0 +1,325 @@
package proxy
import (
"fmt"
"strconv"
"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 {
Method string `yaml:"method,omitempty"`
Path []string `yaml:"path,omitempty"`
Headers map[string][]string `yaml:"headers,omitempty"`
}
type HTTP2Options struct {
Host []string `yaml:"host,omitempty"`
Path string `yaml:"path,omitempty"`
}
type GrpcOptions struct {
GrpcServiceName string `yaml:"grpc-service-name,omitempty"`
}
type RealityOptions struct {
PublicKey string `yaml:"public-key"`
ShortID string `yaml:"short-id,omitempty"`
}
type WSOptions struct {
Path string `yaml:"path,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
MaxEarlyData int `yaml:"max-early-data,omitempty"`
EarlyDataHeaderName string `yaml:"early-data-header-name,omitempty"`
}
type SmuxStruct struct {
Enabled bool `yaml:"enable"`
}
type WireGuardPeerOption struct {
Server string `yaml:"server"`
Port int `yaml:"port"`
PublicKey string `yaml:"public-key,omitempty"`
PreSharedKey string `yaml:"pre-shared-key,omitempty"`
Reserved []uint8 `yaml:"reserved,omitempty"`
AllowedIPs []string `yaml:"allowed-ips,omitempty"`
}
type ECHOptions struct {
Enable bool `yaml:"enable,omitempty" obfs:"enable,omitempty"`
Config string `yaml:"config,omitempty" obfs:"config,omitempty"`
}
type Proxy struct {
Type string
Name string
SubName string `yaml:"-"`
Anytls
Hysteria
Hysteria2
ShadowSocks
ShadowSocksR
Trojan
Vless
Vmess
Socks
Tuic
}
func (p Proxy) MarshalYAML() (any, error) {
switch p.Type {
case "anytls":
return struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Anytls `yaml:",inline"`
}{
Type: p.Type,
Name: p.Name,
Anytls: p.Anytls,
}, nil
case "hysteria":
return struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Hysteria `yaml:",inline"`
}{
Type: p.Type,
Name: p.Name,
Hysteria: p.Hysteria,
}, nil
case "hysteria2":
return struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Hysteria2 `yaml:",inline"`
}{
Type: p.Type,
Name: p.Name,
Hysteria2: p.Hysteria2,
}, nil
case "ss":
return struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
ShadowSocks `yaml:",inline"`
}{
Type: p.Type,
Name: p.Name,
ShadowSocks: p.ShadowSocks,
}, nil
case "ssr":
return struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
ShadowSocksR `yaml:",inline"`
}{
Type: p.Type,
Name: p.Name,
ShadowSocksR: p.ShadowSocksR,
}, nil
case "trojan":
return struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Trojan `yaml:",inline"`
}{
Type: p.Type,
Name: p.Name,
Trojan: p.Trojan,
}, nil
case "vless":
return struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Vless `yaml:",inline"`
}{
Type: p.Type,
Name: p.Name,
Vless: p.Vless,
}, nil
case "vmess":
return struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Vmess `yaml:",inline"`
}{
Type: p.Type,
Name: p.Name,
Vmess: p.Vmess,
}, nil
case "socks5":
return struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Socks `yaml:",inline"`
}{
Type: p.Type,
Name: p.Name,
Socks: p.Socks,
}, 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:
return nil, fmt.Errorf("unsupported proxy type: %s", p.Type)
}
}
func (p *Proxy) UnmarshalYAML(node *yaml.Node) error {
var temp struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
}
if err := node.Decode(&temp); err != nil {
return err
}
p.Type = temp.Type
p.Name = temp.Name
switch temp.Type {
case "anytls":
var data struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Anytls `yaml:",inline"`
}
if err := node.Decode(&data); err != nil {
return err
}
p.Anytls = data.Anytls
case "hysteria":
var data struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Hysteria `yaml:",inline"`
}
if err := node.Decode(&data); err != nil {
return err
}
p.Hysteria = data.Hysteria
case "hysteria2":
var data struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Hysteria2 `yaml:",inline"`
}
if err := node.Decode(&data); err != nil {
return err
}
p.Hysteria2 = data.Hysteria2
case "ss":
var data struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
ShadowSocks `yaml:",inline"`
}
if err := node.Decode(&data); err != nil {
return err
}
p.ShadowSocks = data.ShadowSocks
case "ssr":
var data struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
ShadowSocksR `yaml:",inline"`
}
if err := node.Decode(&data); err != nil {
return err
}
p.ShadowSocksR = data.ShadowSocksR
case "trojan":
var data struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Trojan `yaml:",inline"`
}
if err := node.Decode(&data); err != nil {
return err
}
p.Trojan = data.Trojan
case "vless":
var data struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Vless `yaml:",inline"`
}
if err := node.Decode(&data); err != nil {
return err
}
p.Vless = data.Vless
case "vmess":
var data struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Vmess `yaml:",inline"`
}
if err := node.Decode(&data); err != nil {
return err
}
p.Vmess = data.Vmess
case "socks5":
var data struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Socks `yaml:",inline"`
}
if err := node.Decode(&data); err != nil {
return err
}
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:
return fmt.Errorf("unsupported proxy type: %s", temp.Type)
}
return nil
}
+15
View File
@@ -0,0 +1,15 @@
package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/shadowsocks.go
type ShadowSocks struct {
Server string `yaml:"server"`
Port IntOrString `yaml:"port"`
Password string `yaml:"password"`
Cipher string `yaml:"cipher"`
UDP bool `yaml:"udp,omitempty"`
Plugin string `yaml:"plugin,omitempty"`
PluginOpts map[string]any `yaml:"plugin-opts,omitempty"`
UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"`
UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
}
+14
View File
@@ -0,0 +1,14 @@
package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/shadowsocksr.go
type ShadowSocksR struct {
Server string `yaml:"server"`
Port IntOrString `yaml:"port"`
Password string `yaml:"password"`
Cipher string `yaml:"cipher"`
Obfs string `yaml:"obfs"`
ObfsParam string `yaml:"obfs-param,omitempty"`
Protocol string `yaml:"protocol"`
ProtocolParam string `yaml:"protocol-param,omitempty"`
UDP bool `yaml:"udp,omitempty"`
}
+13
View File
@@ -0,0 +1,13 @@
package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/socks5.go
type Socks struct {
Server string `yaml:"server"`
Port IntOrString `yaml:"port"`
UserName string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
TLS bool `yaml:"tls,omitempty"`
UDP bool `yaml:"udp,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
}
@@ -1,10 +1,9 @@
package model
package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/trojan.go
type Trojan struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Server string `yaml:"server"`
Port int `yaml:"port"`
Port IntOrString `yaml:"port"`
Password string `yaml:"password"`
ALPN []string `yaml:"alpn,omitempty"`
SNI string `yaml:"sni,omitempty"`
@@ -12,28 +11,16 @@ type Trojan struct {
Fingerprint string `yaml:"fingerprint,omitempty"`
UDP bool `yaml:"udp,omitempty"`
Network string `yaml:"network,omitempty"`
ECHOpts ECHOptions `yaml:"ech-opts,omitempty"`
RealityOpts RealityOptions `yaml:"reality-opts,omitempty"`
GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"`
WSOpts WSOptions `yaml:"ws-opts,omitempty"`
SSOpts TrojanSSOption `yaml:"ss-opts,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
}
func ProxyToTrojan(p Proxy) Trojan {
return Trojan{
Type: "trojan",
Name: p.Name,
Server: p.Server,
Port: p.Port,
Password: p.Password,
ALPN: p.Alpn,
SNI: p.Sni,
SkipCertVerify: p.SkipCertVerify,
Fingerprint: p.Fingerprint,
UDP: p.UDP,
Network: p.Network,
RealityOpts: p.RealityOpts,
GrpcOpts: p.GrpcOpts,
WSOpts: p.WSOpts,
ClientFingerprint: p.ClientFingerprint,
}
type TrojanSSOption struct {
Enabled bool `yaml:"enabled,omitempty"`
Method string `yaml:"method,omitempty"`
Password string `yaml:"password,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"`
}
+4 -33
View File
@@ -1,10 +1,9 @@
package model
package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/vless.go
type Vless struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Server string `yaml:"server"`
Port int `yaml:"port"`
Port IntOrString `yaml:"port"`
UUID string `yaml:"uuid"`
Flow string `yaml:"flow,omitempty"`
TLS bool `yaml:"tls,omitempty"`
@@ -14,6 +13,7 @@ type Vless struct {
XUDP bool `yaml:"xudp,omitempty"`
PacketEncoding string `yaml:"packet-encoding,omitempty"`
Network string `yaml:"network,omitempty"`
ECHOpts ECHOptions `yaml:"ech-opts,omitempty"`
RealityOpts RealityOptions `yaml:"reality-opts,omitempty"`
HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"`
HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"`
@@ -26,32 +26,3 @@ type Vless struct {
ServerName string `yaml:"servername,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
}
func ProxyToVless(p Proxy) Vless {
return Vless{
Type: "vless",
Name: p.Name,
Server: p.Server,
Port: p.Port,
UUID: p.UUID,
Flow: p.Flow,
TLS: p.TLS,
ALPN: p.Alpn,
UDP: p.UDP,
PacketAddr: p.PacketAddr,
XUDP: p.XUDP,
PacketEncoding: p.PacketEncoding,
Network: p.Network,
RealityOpts: p.RealityOpts,
HTTPOpts: p.HTTPOpts,
HTTP2Opts: p.HTTP2Opts,
GrpcOpts: p.GrpcOpts,
WSOpts: p.WSOpts,
WSPath: p.WSOpts.Path,
WSHeaders: p.WSOpts.Headers,
SkipCertVerify: p.SkipCertVerify,
Fingerprint: p.Fingerprint,
ServerName: p.Servername,
ClientFingerprint: p.ClientFingerprint,
}
}
+29
View File
@@ -0,0 +1,29 @@
package proxy
// https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/vmess.go
type Vmess struct {
Server string `yaml:"server"`
Port IntOrString `yaml:"port"`
UUID string `yaml:"uuid"`
AlterID IntOrString `yaml:"alterId"`
Cipher string `yaml:"cipher"`
UDP bool `yaml:"udp,omitempty"`
Network string `yaml:"network,omitempty"`
TLS bool `yaml:"tls,omitempty"`
ALPN []string `yaml:"alpn,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
ServerName string `yaml:"servername,omitempty"`
ECHOpts ECHOptions `yaml:"ech-opts,omitempty"`
RealityOpts RealityOptions `yaml:"reality-opts,omitempty"`
HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"`
HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"`
GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"`
WSOpts WSOptions `yaml:"ws-opts,omitempty"`
PacketAddr bool `yaml:"packet-addr,omitempty"`
XUDP bool `yaml:"xudp,omitempty"`
PacketEncoding string `yaml:"packet-encoding,omitempty"`
GlobalPadding bool `yaml:"global-padding,omitempty"`
AuthenticatedLength bool `yaml:"authenticated-length,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
}
-140
View File
@@ -1,140 +0,0 @@
package model
import (
"golang.org/x/text/collate"
"golang.org/x/text/language"
)
type ProxyGroup struct {
Name string `yaml:"name,omitempty"`
Type string `yaml:"type,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"`
}
type SelectProxyGroup struct {
Name string `yaml:"name,omitempty"`
Type string `yaml:"type,omitempty"`
Proxies []string `yaml:"proxies,omitempty"`
DisableUDP bool `yaml:"disable-udp,omitempty"`
}
type UrlTestProxyGroup struct {
Name string `yaml:"name,omitempty"`
Type string `yaml:"type,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"`
DisableUDP bool `yaml:"disable-udp,omitempty"`
}
type LoadBalanceProxyGroup struct {
Name string `yaml:"name,omitempty"`
Type string `yaml:"type,omitempty"`
Proxies []string `yaml:"proxies,omitempty"`
DisableUDP bool `yaml:"disable-udp,omitempty"`
Url string `yaml:"url,omitempty"`
Interval int `yaml:"interval,omitempty"`
Lazy bool `yaml:"lazy"`
Strategy string `yaml:"strategy,omitempty"`
}
type RelayProxyGroup struct {
Name string `yaml:"name,omitempty"`
Type string `yaml:"type,omitempty"`
Proxies []string `yaml:"proxies,omitempty"`
}
func (p ProxyGroup) MarshalYAML() (interface{}, error) {
switch p.Type {
case "select":
return SelectProxyGroup{
Name: p.Name,
Type: p.Type,
Proxies: p.Proxies,
DisableUDP: p.DisableUDP,
}, nil
case "url-test", "fallback":
return UrlTestProxyGroup{
Name: p.Name,
Type: p.Type,
Proxies: p.Proxies,
Url: p.Url,
Interval: p.Interval,
Tolerance: p.Tolerance,
Lazy: p.Lazy,
DisableUDP: p.DisableUDP,
}, nil
case "load-balance":
return LoadBalanceProxyGroup{
Name: p.Name,
Type: p.Type,
Proxies: p.Proxies,
DisableUDP: p.DisableUDP,
Url: p.Url,
Interval: p.Interval,
Lazy: p.Lazy,
Strategy: p.Strategy,
}, nil
case "relay":
return RelayProxyGroup{
Name: p.Name,
Type: p.Type,
Proxies: p.Proxies,
}, nil
default:
return SelectProxyGroup{
Name: p.Name,
Type: p.Type,
Proxies: p.Proxies,
}, nil
}
}
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)
// 假设我们的请求语言是 "zh"(中文),则使用匹配器找到最佳匹配的语言
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]
}
-41
View File
@@ -1,41 +0,0 @@
package model
type Hysteria2 struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Server string `yaml:"server"`
Port int `yaml:"port"`
Up string `yaml:"up,omitempty"`
Down string `yaml:"down,omitempty"`
Password string `yaml:"password,omitempty"`
Obfs string `yaml:"obfs,omitempty"`
ObfsPassword string `yaml:"obfs-password,omitempty"`
SNI string `yaml:"sni,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
ALPN []string `yaml:"alpn,omitempty"`
CustomCA string `yaml:"ca,omitempty"`
CustomCAString string `yaml:"ca-str,omitempty"`
CWND int `yaml:"cwnd,omitempty"`
}
func ProxyToHysteria2(p Proxy) Hysteria2 {
return Hysteria2{
Type: "hysteria2",
Name: p.Name,
Server: p.Server,
Port: p.Port,
Up: p.Up,
Down: p.Down,
Password: p.Password,
Obfs: p.Obfs,
ObfsPassword: p.ObfsParam,
SNI: p.Sni,
SkipCertVerify: p.SkipCertVerify,
Fingerprint: p.Fingerprint,
ALPN: p.Alpn,
CustomCA: p.CustomCA,
CustomCAString: p.CustomCAString,
CWND: p.CWND,
}
}
-33
View File
@@ -1,33 +0,0 @@
package model
type ShadowSocks struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Server string `yaml:"server"`
Port int `yaml:"port"`
Password string `yaml:"password"`
Cipher string `yaml:"cipher"`
UDP bool `yaml:"udp,omitempty"`
Plugin string `yaml:"plugin,omitempty"`
PluginOpts map[string]any `yaml:"plugin-opts,omitempty"`
UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"`
UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
}
func ProxyToShadowSocks(p Proxy) ShadowSocks {
return ShadowSocks{
Type: "ss",
Name: p.Name,
Server: p.Server,
Port: p.Port,
Password: p.Password,
Cipher: p.Cipher,
UDP: p.UDP,
Plugin: p.Plugin,
PluginOpts: p.PluginOpts,
UDPOverTCP: p.UDPOverTCP,
UDPOverTCPVersion: p.UDPOverTCPVersion,
ClientFingerprint: p.ClientFingerprint,
}
}
-31
View File
@@ -1,31 +0,0 @@
package model
type ShadowSocksR struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Server string `yaml:"server"`
Port int `yaml:"port"`
Password string `yaml:"password"`
Cipher string `yaml:"cipher"`
Obfs string `yaml:"obfs"`
ObfsParam string `yaml:"obfs-param,omitempty"`
Protocol string `yaml:"protocol"`
ProtocolParam string `yaml:"protocol-param,omitempty"`
UDP bool `yaml:"udp,omitempty"`
}
func ProxyToShadowSocksR(p Proxy) ShadowSocksR {
return ShadowSocksR{
Type: "ssr",
Name: p.Name,
Server: p.Server,
Port: p.Port,
Password: p.Password,
Cipher: p.Cipher,
Obfs: p.Obfs,
ObfsParam: p.ObfsParam,
Protocol: p.Protocol,
ProtocolParam: p.ProtocolParam,
UDP: p.UDP,
}
}
-104
View File
@@ -1,104 +0,0 @@
package model
type HTTPOptions struct {
Method string `proxy:"method,omitempty"`
Path []string `proxy:"path,omitempty"`
Headers map[string][]string `proxy:"headers,omitempty"`
}
type HTTP2Options struct {
Host []string `proxy:"host,omitempty"`
Path string `proxy:"path,omitempty"`
}
type GrpcOptions struct {
GrpcServiceName string `proxy:"grpc-service-name,omitempty"`
}
type RealityOptions struct {
PublicKey string `yaml:"public-key"`
ShortID string `yaml:"short-id,omitempty"`
}
type WSOptions struct {
Path string `proxy:"path,omitempty"`
Headers map[string]string `proxy:"headers,omitempty"`
MaxEarlyData int `proxy:"max-early-data,omitempty"`
EarlyDataHeaderName string `proxy:"early-data-header-name,omitempty"`
}
type VmessJson struct {
V string `json:"v"`
Ps string `json:"ps"`
Add string `json:"add"`
Port interface{} `json:"port"`
Id string `json:"id"`
Aid interface{} `json:"aid"`
Scy string `json:"scy"`
Net string `json:"net"`
Type string `json:"type"`
Host string `json:"host"`
Path string `json:"path"`
Tls string `json:"tls"`
Sni string `json:"sni"`
Alpn string `json:"alpn"`
Fp string `json:"fp"`
}
type Vmess struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Server string `yaml:"server"`
Port int `yaml:"port"`
UUID string `yaml:"uuid"`
AlterID int `yaml:"alterId"`
Cipher string `yaml:"cipher"`
UDP bool `yaml:"udp,omitempty"`
Network string `yaml:"network,omitempty"`
TLS bool `yaml:"tls,omitempty"`
ALPN []string `yaml:"alpn,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
ServerName string `yaml:"servername,omitempty"`
RealityOpts RealityOptions `yaml:"reality-opts,omitempty"`
HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"`
HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"`
GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"`
WSOpts WSOptions `yaml:"ws-opts,omitempty"`
PacketAddr bool `yaml:"packet-addr,omitempty"`
XUDP bool `yaml:"xudp,omitempty"`
PacketEncoding string `yaml:"packet-encoding,omitempty"`
GlobalPadding bool `yaml:"global-padding,omitempty"`
AuthenticatedLength bool `yaml:"authenticated-length,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
}
func ProxyToVmess(p Proxy) Vmess {
return Vmess{
Type: "vmess",
Name: p.Name,
Server: p.Server,
Port: p.Port,
UUID: p.UUID,
AlterID: p.AlterID,
Cipher: p.Cipher,
UDP: p.UDP,
Network: p.Network,
TLS: p.TLS,
ALPN: p.Alpn,
SkipCertVerify: p.SkipCertVerify,
Fingerprint: p.Fingerprint,
ServerName: p.Servername,
RealityOpts: p.RealityOpts,
HTTPOpts: p.HTTPOpts,
HTTP2Opts: p.HTTP2Opts,
GrpcOpts: p.GrpcOpts,
WSOpts: p.WSOpts,
PacketAddr: p.PacketAddr,
XUDP: p.XUDP,
PacketEncoding: p.PacketEncoding,
GlobalPadding: p.GlobalPadding,
AuthenticatedLength: p.AuthenticatedLength,
ClientFingerprint: p.ClientFingerprint,
}
}
-13
View File
@@ -1,13 +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"`
}
type Payload struct {
Rules []string `yaml:"payload,omitempty"`
}
+2 -2
View File
@@ -1,8 +1,8 @@
package model
type ShortLink struct {
Hash string `gorm:"primary_key"`
Url string
ID string `gorm:"unique"`
Config ConvertConfig `gorm:"serializer:json"`
Password string
LastRequestTime int64
}
-18
View File
@@ -1,18 +0,0 @@
package model
type Subscription struct {
Port int `yaml:"port,omitempty"`
SocksPort int `yaml:"socks-port,omitempty"`
AllowLan bool `yaml:"allow-lan"`
Mode string `yaml:"mode,omitempty"`
LogLevel string `yaml:"logger-level,omitempty"`
ExternalController string `yaml:"external-controller,omitempty"`
Proxies []Proxy `yaml:"proxies,omitempty"`
ProxyGroups []ProxyGroup `yaml:"proxy-groups,omitempty"`
Rules []string `yaml:"rules,omitempty"`
RuleProviders map[string]RuleProvider `yaml:"rule-providers,omitempty,omitempty"`
}
type NodeList struct {
Proxies []Proxy `yaml:"proxies,omitempty"`
}
+83
View File
@@ -0,0 +1,83 @@
package parser
import (
"fmt"
"net/url"
"strings"
P "github.com/bestnite/sub2clash/model/proxy"
)
type AnytlsParser struct{}
func (p *AnytlsParser) SupportClash() bool {
return false
}
func (p *AnytlsParser) SupportMeta() bool {
return true
}
func (p *AnytlsParser) GetPrefixes() []string {
return []string{"anytls://"}
}
func (p *AnytlsParser) GetType() string {
return "anytls"
}
func (p *AnytlsParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
if !hasPrefix(proxy, p.GetPrefixes()) {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy)
}
link, err := url.Parse(proxy)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
username := link.User.Username()
password, exist := link.User.Password()
if !exist {
password = username
}
query := link.Query()
server := link.Hostname()
if server == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host")
}
portStr := link.Port()
if portStr == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port")
}
port, err := ParsePort(portStr)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error())
}
insecure, sni := query.Get("insecure"), query.Get("sni")
insecureBool := insecure == "1"
remarks := link.Fragment
if remarks == "" {
remarks = fmt.Sprintf("%s:%s", server, portStr)
}
remarks = strings.TrimSpace(remarks)
result := P.Proxy{
Type: p.GetType(),
Name: remarks,
Anytls: P.Anytls{
Server: server,
Port: P.IntOrString(port),
Password: password,
SNI: sni,
SkipCertVerify: insecureBool,
UDP: config.UseUDP,
},
}
return result, nil
}
func init() {
RegisterParser(&AnytlsParser{})
}
-18
View File
@@ -1,18 +0,0 @@
package parser
import (
"encoding/base64"
"strings"
)
func DecodeBase64(s string) (string, error) {
s = strings.TrimSpace(s)
if len(s)%4 != 0 {
s += strings.Repeat("=", 4-len(s)%4)
}
decodeStr, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return "", err
}
return string(decodeStr), nil
}
+82
View File
@@ -0,0 +1,82 @@
package parser
import (
"errors"
"strconv"
"strings"
"unicode/utf8"
P "github.com/bestnite/sub2clash/model/proxy"
"github.com/bestnite/sub2clash/utils"
)
func hasPrefix(proxy string, prefixes []string) bool {
hasPrefix := false
for _, prefix := range prefixes {
if strings.HasPrefix(proxy, prefix) {
hasPrefix = true
break
}
}
return hasPrefix
}
func ParsePort(portStr string) (int, error) {
port, err := strconv.Atoi(portStr)
if err != nil {
return 0, err
}
if port < 1 || port > 65535 {
return 0, errors.New("invaild port range")
}
return port, nil
}
// isLikelyBase64 不严格判断是否是合法的 Base64, 很多分享链接不符合 Base64 规范
func isLikelyBase64(s string) bool {
if strings.TrimSpace(s) == "" {
return false
}
if !strings.Contains(strings.TrimSuffix(s, "="), "=") {
s = strings.TrimSuffix(s, "=")
chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
for _, c := range s {
if !strings.ContainsRune(chars, c) {
return false
}
}
}
decoded, err := utils.DecodeBase64(s, true)
if err != nil {
return false
}
if !utf8.ValidString(decoded) {
return false
}
return true
}
type ParseConfig struct {
UseUDP bool
}
func ParseProxies(config ParseConfig, proxies ...string) ([]P.Proxy, error) {
var result []P.Proxy
for _, proxy := range proxies {
if proxy != "" {
var proxyItem P.Proxy
var err error
proxyItem, err = ParseProxyWithRegistry(config, proxy)
if err != nil {
return nil, err
}
result = append(result, proxyItem)
}
}
return result, nil
}
+15
View File
@@ -0,0 +1,15 @@
package parser
type ParseErrorType string
const (
ErrInvalidPrefix ParseErrorType = "invalid url prefix"
ErrInvalidStruct ParseErrorType = "invalid struct"
ErrInvalidPort ParseErrorType = "invalid port number"
ErrCannotParseParams ParseErrorType = "cannot parse query parameters"
ErrInvalidBase64 ParseErrorType = "invalid base64"
)
func (e ParseErrorType) Error() string {
return string(e)
}
+95
View File
@@ -0,0 +1,95 @@
package parser
import (
"fmt"
"net/url"
"strconv"
"strings"
P "github.com/bestnite/sub2clash/model/proxy"
)
type HysteriaParser struct{}
func (p *HysteriaParser) SupportClash() bool {
return false
}
func (p *HysteriaParser) SupportMeta() bool {
return true
}
func (p *HysteriaParser) GetPrefixes() []string {
return []string{"hysteria://"}
}
func (p *HysteriaParser) GetType() string {
return "hysteria"
}
func (p *HysteriaParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
if !hasPrefix(proxy, p.GetPrefixes()) {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy)
}
link, err := url.Parse(proxy)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
server := link.Hostname()
if server == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host")
}
portStr := link.Port()
if portStr == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port")
}
port, err := ParsePort(portStr)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error())
}
query := link.Query()
protocol, auth, auth_str, insecure, upmbps, downmbps, obfs, alpnStr := query.Get("protocol"), query.Get("auth"), query.Get("auth-str"), query.Get("insecure"), query.Get("upmbps"), query.Get("downmbps"), query.Get("obfs"), query.Get("alpn")
insecureBool, err := strconv.ParseBool(insecure)
if err != nil {
insecureBool = false
}
var alpn []string
alpnStr = strings.TrimSpace(alpnStr)
if alpnStr != "" {
alpn = strings.Split(alpnStr, ",")
}
remarks := link.Fragment
if remarks == "" {
remarks = fmt.Sprintf("%s:%s", server, portStr)
}
remarks = strings.TrimSpace(remarks)
result := P.Proxy{
Type: p.GetType(),
Name: remarks,
Hysteria: P.Hysteria{
Server: server,
Port: P.IntOrString(port),
Up: upmbps,
Down: downmbps,
Auth: auth,
AuthString: auth_str,
Obfs: obfs,
SkipCertVerify: insecureBool,
ALPN: alpn,
Protocol: protocol,
},
}
return result, nil
}
func init() {
RegisterParser(&HysteriaParser{})
}
+67 -34
View File
@@ -1,51 +1,84 @@
package parser
import (
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"sub2clash/model"
P "github.com/bestnite/sub2clash/model/proxy"
)
func ParseHysteria2(proxy string) (model.Proxy, error) {
// 判断是否以 hysteria2:// 开头
if !strings.HasPrefix(proxy, "hysteria2://") {
return model.Proxy{}, errors.New("invalid hysteria2 Url")
type Hysteria2Parser struct{}
func (p *Hysteria2Parser) SupportClash() bool {
return false
}
// 分割
parts := strings.SplitN(strings.TrimPrefix(proxy, "hysteria2://"), "@", 2)
if len(parts) != 2 {
return model.Proxy{}, errors.New("invalid hysteria2 Url")
func (p *Hysteria2Parser) SupportMeta() bool {
return true
}
// 分割
serverInfo := strings.SplitN(parts[1], "/?", 2)
serverAndPort := strings.SplitN(serverInfo[0], ":", 2)
if len(serverAndPort) == 1 {
serverAndPort = append(serverAndPort, "443")
} else if len(serverAndPort) != 2 {
return model.Proxy{}, errors.New("invalid hysteria2 Url")
func (p *Hysteria2Parser) GetPrefixes() []string {
return []string{"hysteria2://", "hy2://"}
}
params, err := url.ParseQuery(serverInfo[1])
func (p *Hysteria2Parser) GetType() string {
return "hysteria2"
}
func (p *Hysteria2Parser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
if !hasPrefix(proxy, p.GetPrefixes()) {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy)
}
link, err := url.Parse(proxy)
if err != nil {
return model.Proxy{}, errors.New("invalid hysteria2 Url")
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
// 获取端口
port, err := strconv.Atoi(serverAndPort[1])
username := link.User.Username()
password, exist := link.User.Password()
if !exist {
password = username
}
query := link.Query()
server := link.Hostname()
if server == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host")
}
portStr := link.Port()
if portStr == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port")
}
port, err := ParsePort(portStr)
if err != nil {
return model.Proxy{}, errors.New("invalid hysteria2 Url")
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error())
}
// 返回结果
result := model.Proxy{
Type: "hysteria2",
Name: params.Get("name"),
Server: serverAndPort[0],
Port: port,
Password: parts[0],
Obfs: params.Get("obfs"),
ObfsParam: params.Get("obfs-password"),
Sni: params.Get("sni"),
SkipCertVerify: params.Get("insecure") == "1",
obfs, obfsPassword, insecure, sni := query.Get("obfs"), query.Get("obfs-password"), query.Get("insecure"), query.Get("sni")
insecureBool := insecure == "1"
remarks := link.Fragment
if remarks == "" {
remarks = fmt.Sprintf("%s:%s", server, portStr)
}
remarks = strings.TrimSpace(remarks)
result := P.Proxy{
Type: p.GetType(),
Name: remarks,
Hysteria2: P.Hysteria2{
Server: server,
Port: P.IntOrString(port),
Password: password,
Obfs: obfs,
ObfsPassword: obfsPassword,
SNI: sni,
SkipCertVerify: insecureBool,
},
}
return result, nil
}
func init() {
RegisterParser(&Hysteria2Parser{})
}
+80
View File
@@ -0,0 +1,80 @@
package parser
import (
"fmt"
"strings"
"sync"
P "github.com/bestnite/sub2clash/model/proxy"
)
type ProxyParser interface {
Parse(config ParseConfig, proxy string) (P.Proxy, error)
GetPrefixes() []string
GetType() string
SupportClash() bool
SupportMeta() bool
}
type parserRegistry struct {
mu sync.RWMutex
parsers map[string]ProxyParser
}
var registry = &parserRegistry{
parsers: make(map[string]ProxyParser),
}
func RegisterParser(parser ProxyParser) {
registry.mu.Lock()
defer registry.mu.Unlock()
for _, prefix := range parser.GetPrefixes() {
registry.parsers[prefix] = parser
}
}
func GetParser(prefix string) (ProxyParser, bool) {
registry.mu.RLock()
defer registry.mu.RUnlock()
parser, exists := registry.parsers[prefix]
return parser, exists
}
func GetAllParsers() map[string]ProxyParser {
registry.mu.RLock()
defer registry.mu.RUnlock()
result := make(map[string]ProxyParser)
for k, v := range registry.parsers {
result[k] = v
}
return result
}
func GetAllPrefixes() []string {
registry.mu.RLock()
defer registry.mu.RUnlock()
prefixes := make([]string, 0, len(registry.parsers))
for prefix := range registry.parsers {
prefixes = append(prefixes, prefix)
}
return prefixes
}
func ParseProxyWithRegistry(config ParseConfig, proxy string) (P.Proxy, error) {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "empty proxy string")
}
for prefix, parser := range registry.parsers {
if strings.HasPrefix(proxy, prefix) {
return parser.Parse(config, proxy)
}
}
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, "unsupported protocol")
}
+121
View File
@@ -0,0 +1,121 @@
package parser
import (
"fmt"
"net/url"
"strings"
P "github.com/bestnite/sub2clash/model/proxy"
"github.com/bestnite/sub2clash/utils"
)
// ShadowsocksParser Shadowsocks协议解析器
type ShadowsocksParser struct{}
func (p *ShadowsocksParser) SupportClash() bool {
return true
}
func (p *ShadowsocksParser) SupportMeta() bool {
return true
}
// GetPrefixes 返回支持的协议前缀
func (p *ShadowsocksParser) GetPrefixes() []string {
return []string{"ss://"}
}
// GetType 返回协议类型
func (p *ShadowsocksParser) GetType() string {
return "ss"
}
// Parse 解析Shadowsocks代理
func (p *ShadowsocksParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
if !hasPrefix(proxy, p.GetPrefixes()) {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy)
}
if !strings.Contains(proxy, "@") {
s := strings.SplitN(proxy, "#", 2)
for _, prefix := range p.GetPrefixes() {
if strings.HasPrefix(s[0], prefix) {
s[0] = strings.TrimPrefix(s[0], prefix)
break
}
}
d, err := utils.DecodeBase64(s[0], true)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
if len(s) == 2 {
proxy = "ss://" + d + "#" + s[1]
} else {
proxy = "ss://" + d
}
}
link, err := url.Parse(proxy)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
server := link.Hostname()
if server == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host")
}
portStr := link.Port()
if portStr == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port")
}
port, err := ParsePort(portStr)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
method := link.User.Username()
password, hasPassword := link.User.Password()
if !hasPassword && isLikelyBase64(method) {
decodedStr, err := utils.DecodeBase64(method, true)
if err == nil {
methodAndPass := strings.SplitN(decodedStr, ":", 2)
if len(methodAndPass) == 2 {
method = methodAndPass[0]
password = methodAndPass[1]
} else {
method = decodedStr
}
}
}
if password != "" && isLikelyBase64(password) {
password, err = utils.DecodeBase64(password, true)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
}
remarks := link.Fragment
if remarks == "" {
remarks = fmt.Sprintf("%s:%s", server, portStr)
}
remarks = strings.TrimSpace(remarks)
result := P.Proxy{
Type: p.GetType(),
Name: remarks,
ShadowSocks: P.ShadowSocks{
Cipher: method,
Password: password,
Server: server,
Port: P.IntOrString(port),
UDP: config.UseUDP,
},
}
return result, nil
}
// 注册解析器
func init() {
RegisterParser(&ShadowsocksParser{})
}
+135
View File
@@ -0,0 +1,135 @@
package parser
import (
"fmt"
"net/url"
"strconv"
"strings"
P "github.com/bestnite/sub2clash/model/proxy"
"github.com/bestnite/sub2clash/utils"
)
type ShadowsocksRParser struct{}
func (p *ShadowsocksRParser) SupportClash() bool {
return true
}
func (p *ShadowsocksRParser) SupportMeta() bool {
return true
}
func (p *ShadowsocksRParser) GetPrefixes() []string {
return []string{"ssr://"}
}
func (p *ShadowsocksRParser) GetType() string {
return "ssr"
}
func (p *ShadowsocksRParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
if !hasPrefix(proxy, p.GetPrefixes()) {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy)
}
for _, prefix := range p.GetPrefixes() {
if strings.HasPrefix(proxy, prefix) {
proxy = strings.TrimPrefix(proxy, prefix)
break
}
}
proxy, err := utils.DecodeBase64(proxy, true)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidBase64, err.Error())
}
serverInfoAndParams := strings.SplitN(proxy, "/?", 2)
if len(serverInfoAndParams) != 2 {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, proxy)
}
parts := SplitNRight(serverInfoAndParams[0], ":", 6)
if len(parts) < 6 {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, proxy)
}
server := parts[0]
protocol := parts[2]
method := parts[3]
obfs := parts[4]
password, err := utils.DecodeBase64(parts[5], true)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
port, err := ParsePort(parts[1])
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error())
}
var obfsParam string
var protoParam string
var remarks string
if len(serverInfoAndParams) == 2 {
params, err := url.ParseQuery(serverInfoAndParams[1])
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrCannotParseParams, err.Error())
}
if params.Get("obfsparam") != "" {
obfsParam, err = utils.DecodeBase64(params.Get("obfsparam"), true)
}
if params.Get("protoparam") != "" {
protoParam, err = utils.DecodeBase64(params.Get("protoparam"), true)
}
if params.Get("remarks") != "" {
remarks, err = utils.DecodeBase64(params.Get("remarks"), true)
} else {
remarks = server + ":" + strconv.Itoa(port)
}
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
}
result := P.Proxy{
Type: p.GetType(),
Name: remarks,
ShadowSocksR: P.ShadowSocksR{
Server: server,
Port: P.IntOrString(port),
Protocol: protocol,
Cipher: method,
Obfs: obfs,
Password: password,
ObfsParam: obfsParam,
ProtocolParam: protoParam,
UDP: config.UseUDP,
},
}
return result, nil
}
func init() {
RegisterParser(&ShadowsocksRParser{})
}
func SplitNRight(s, sep string, n int) []string {
if n <= 0 {
return nil
}
if n == 1 {
return []string{s}
}
parts := strings.Split(s, sep)
if len(parts) <= n {
return parts
}
result := make([]string, n)
for i, j := len(parts)-1, 0; i >= 0; i, j = i-1, j+1 {
if j < n-1 {
result[n-j-1] = parts[len(parts)-j-1]
} else {
result[0] = strings.Join(parts[:i+1], sep)
break
}
}
return result
}
+93
View File
@@ -0,0 +1,93 @@
package parser
import (
"fmt"
"net/url"
"strings"
P "github.com/bestnite/sub2clash/model/proxy"
"github.com/bestnite/sub2clash/utils"
)
type SocksParser struct{}
func (p *SocksParser) SupportClash() bool {
return true
}
func (p *SocksParser) SupportMeta() bool {
return true
}
func (p *SocksParser) GetPrefixes() []string {
return []string{"socks://", "socks5://"}
}
func (p *SocksParser) GetType() string {
return "socks5"
}
func (p *SocksParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
if !hasPrefix(proxy, p.GetPrefixes()) {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy)
}
link, err := url.Parse(proxy)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
server := link.Hostname()
if server == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host")
}
portStr := link.Port()
if portStr == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port")
}
port, err := ParsePort(portStr)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error())
}
remarks := link.Fragment
if remarks == "" {
remarks = fmt.Sprintf("%s:%s", server, portStr)
}
remarks = strings.TrimSpace(remarks)
var username, password string
username = link.User.Username()
password, hasPassword := link.User.Password()
if !hasPassword && isLikelyBase64(username) {
decodedStr, err := utils.DecodeBase64(username, true)
if err == nil {
usernameAndPassword := strings.SplitN(decodedStr, ":", 2)
if len(usernameAndPassword) == 2 {
username = usernameAndPassword[0]
password = usernameAndPassword[1]
} else {
username = decodedStr
}
}
}
tls, udp := link.Query().Get("tls"), link.Query().Get("udp")
return P.Proxy{
Type: p.GetType(),
Name: remarks,
Socks: P.Socks{
Server: server,
Port: P.IntOrString(port),
UserName: username,
Password: password,
TLS: tls == "true",
UDP: udp == "true" || config.UseUDP,
},
}, nil
}
func init() {
RegisterParser(&SocksParser{})
}
-67
View File
@@ -1,67 +0,0 @@
package parser
import (
"errors"
"net/url"
"strconv"
"strings"
"sub2clash/model"
)
// ParseSS 解析 SSShadowsocksUrl
func ParseSS(proxy string) (model.Proxy, error) {
// 判断是否以 ss:// 开头
if !strings.HasPrefix(proxy, "ss://") {
return model.Proxy{}, errors.New("invalid ss Url")
}
// 分割
parts := strings.SplitN(strings.TrimPrefix(proxy, "ss://"), "@", 2)
if len(parts) != 2 {
return model.Proxy{}, errors.New("invalid ss Url")
}
if !strings.Contains(parts[0], ":") {
// 解码
decoded, err := DecodeBase64(parts[0])
if err != nil {
return model.Proxy{}, errors.New("invalid ss Url" + err.Error())
}
parts[0] = decoded
}
credentials := strings.SplitN(parts[0], ":", 2)
if len(credentials) != 2 {
return model.Proxy{}, errors.New("invalid ss Url")
}
// 分割
serverInfo := strings.SplitN(parts[1], "#", 2)
serverAndPort := strings.SplitN(serverInfo[0], ":", 2)
if len(serverAndPort) != 2 {
return model.Proxy{}, errors.New("invalid ss Url")
}
// 转换端口字符串为数字
port, err := strconv.Atoi(strings.TrimSpace(serverAndPort[1]))
if err != nil {
return model.Proxy{}, errors.New("invalid ss Url" + err.Error())
}
// 返回结果
result := model.Proxy{
Type: "ss",
Cipher: strings.TrimSpace(credentials[0]),
Password: strings.TrimSpace(credentials[1]),
Server: strings.TrimSpace(serverAndPort[0]),
Port: port,
UDP: true,
Name: serverAndPort[0],
}
// 如果有节点名称
if len(serverInfo) == 2 {
unescape, err := url.QueryUnescape(serverInfo[1])
if err != nil {
return model.Proxy{}, errors.New("invalid ss Url" + err.Error())
}
result.Name = strings.TrimSpace(unescape)
} else {
result.Name = strings.TrimSpace(serverAndPort[0])
}
return result, nil
}
-67
View File
@@ -1,67 +0,0 @@
package parser
import (
"fmt"
"net/url"
"strconv"
"strings"
"sub2clash/model"
)
func ParseShadowsocksR(proxy string) (model.Proxy, error) {
// 判断是否以 ssr:// 开头
if !strings.HasPrefix(proxy, "ssr://") {
return model.Proxy{}, fmt.Errorf("invalid ssr Url")
}
var err error
proxy = strings.TrimPrefix(proxy, "ssr://")
if !strings.Contains(proxy, ":") {
proxy, err = DecodeBase64(strings.TrimPrefix(proxy, "ssr://"))
if err != nil {
return model.Proxy{}, err
}
}
// 分割
detailsAndParams := strings.SplitN(proxy, "/?", 2)
parts := strings.Split(detailsAndParams[0], ":")
params, err := url.ParseQuery(detailsAndParams[1])
if err != nil {
return model.Proxy{}, err
}
// 处理端口
port, err := strconv.Atoi(parts[1])
if err != nil {
return model.Proxy{}, err
}
var obfsParam string
var protoParam string
var remarks string
if params.Get("obfsparam") != "" {
obfsParam, err = DecodeBase64(params.Get("obfsparam"))
}
if params.Get("protoparam") != "" {
protoParam, err = DecodeBase64(params.Get("protoparam"))
}
if params.Get("remarks") != "" {
remarks, err = DecodeBase64(params.Get("remarks"))
}
result := model.Proxy{
Name: remarks,
Type: "ssr",
Server: parts[0],
Port: port,
Protocol: parts[2],
Cipher: parts[3],
Obfs: parts[4],
Password: parts[5],
ObfsParam: obfsParam,
ProtocolParam: protoParam,
}
if result.Name == "" {
result.Name = result.Server
}
return result, nil
}
+104 -34
View File
@@ -3,51 +3,121 @@ package parser
import (
"fmt"
"net/url"
"strconv"
"strings"
"sub2clash/model"
P "github.com/bestnite/sub2clash/model/proxy"
)
func ParseTrojan(proxy string) (model.Proxy, error) {
// 判断是否以 trojan:// 开头
if !strings.HasPrefix(proxy, "trojan://") {
return model.Proxy{}, fmt.Errorf("invalid trojan Url")
type TrojanParser struct{}
func (p *TrojanParser) SupportClash() bool {
return true
}
// 分割
parts := strings.SplitN(strings.TrimPrefix(proxy, "trojan://"), "@", 2)
if len(parts) != 2 {
return model.Proxy{}, fmt.Errorf("invalid trojan Url")
func (p *TrojanParser) SupportMeta() bool {
return true
}
// 分割
serverInfo := strings.SplitN(parts[1], "#", 2)
serverAndPortAndParams := strings.SplitN(serverInfo[0], "?", 2)
serverAndPort := strings.SplitN(serverAndPortAndParams[0], ":", 2)
params, err := url.ParseQuery(serverAndPortAndParams[1])
func (p *TrojanParser) GetPrefixes() []string {
return []string{"trojan://"}
}
func (p *TrojanParser) GetType() string {
return "trojan"
}
func (p *TrojanParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
if !hasPrefix(proxy, p.GetPrefixes()) {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy)
}
link, err := url.Parse(proxy)
if err != nil {
return model.Proxy{}, err
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
if len(serverAndPort) != 2 {
return model.Proxy{}, fmt.Errorf("invalid trojan")
password := link.User.Username()
server := link.Hostname()
if server == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host")
}
// 处理端口
port, err := strconv.Atoi(strings.TrimSpace(serverAndPort[1]))
portStr := link.Port()
if portStr == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port")
}
port, err := ParsePort(portStr)
if err != nil {
return model.Proxy{}, err
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error())
}
// 返回结果
result := model.Proxy{
Type: "trojan",
Server: strings.TrimSpace(serverAndPort[0]),
Port: port,
UDP: true,
Password: strings.TrimSpace(parts[0]),
Sni: params.Get("sni"),
remarks := link.Fragment
if remarks == "" {
remarks = fmt.Sprintf("%s:%s", server, portStr)
}
// 如果有节点名称
if len(serverInfo) == 2 {
result.Name, _ = url.QueryUnescape(strings.TrimSpace(serverInfo[1]))
remarks = strings.TrimSpace(remarks)
query := link.Query()
network, security, alpnStr, sni, pbk, sid, fp, path, host, serviceName, udp, insecure := query.Get("type"), query.Get("security"), query.Get("alpn"), query.Get("sni"), query.Get("pbk"), query.Get("sid"), query.Get("fp"), query.Get("path"), query.Get("host"), query.Get("serviceName"), query.Get("udp"), query.Get("allowInsecure")
insecureBool := insecure == "1"
result := P.Trojan{
Server: server,
Port: P.IntOrString(port),
Password: password,
Network: network,
UDP: udp == "true" || config.UseUDP,
SkipCertVerify: insecureBool,
}
var alpn []string
if strings.Contains(alpnStr, ",") {
alpn = strings.Split(alpnStr, ",")
} else {
result.Name = serverAndPort[0]
alpn = nil
}
return result, nil
if len(alpn) > 0 {
result.ALPN = alpn
}
if fp != "" {
result.ClientFingerprint = fp
}
if sni != "" {
result.SNI = sni
}
if security == "reality" {
result.RealityOpts = P.RealityOptions{
PublicKey: pbk,
ShortID: sid,
}
}
if network == "ws" {
result.Network = "ws"
result.WSOpts = P.WSOptions{
Path: path,
Headers: map[string]string{
"Host": host,
},
}
}
if network == "grpc" {
result.GrpcOpts = P.GrpcOptions{
GrpcServiceName: serviceName,
}
}
return P.Proxy{
Type: p.GetType(),
Name: remarks,
Trojan: result,
}, nil
}
func init() {
RegisterParser(&TrojanParser{})
}
+124 -62
View File
@@ -3,79 +3,141 @@ package parser
import (
"fmt"
"net/url"
"strconv"
"strings"
"sub2clash/model"
P "github.com/bestnite/sub2clash/model/proxy"
)
func ParseVless(proxy string) (model.Proxy, error) {
// 判断是否以 vless:// 开头
if !strings.HasPrefix(proxy, "vless://") {
return model.Proxy{}, fmt.Errorf("invalid vless Url")
type VlessParser struct{}
func (p *VlessParser) SupportClash() bool {
return false
}
// 分割
parts := strings.SplitN(strings.TrimPrefix(proxy, "vless://"), "@", 2)
if len(parts) != 2 {
return model.Proxy{}, fmt.Errorf("invalid vless Url")
func (p *VlessParser) SupportMeta() bool {
return true
}
// 分割
serverInfo := strings.SplitN(parts[1], "#", 2)
serverAndPortAndParams := strings.SplitN(serverInfo[0], "?", 2)
serverAndPort := strings.SplitN(serverAndPortAndParams[0], ":", 2)
params, err := url.ParseQuery(serverAndPortAndParams[1])
func (p *VlessParser) GetPrefixes() []string {
return []string{"vless://"}
}
func (p *VlessParser) GetType() string {
return "vless"
}
func (p *VlessParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
if !hasPrefix(proxy, p.GetPrefixes()) {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy)
}
link, err := url.Parse(proxy)
if err != nil {
return model.Proxy{}, err
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
if len(serverAndPort) != 2 {
return model.Proxy{}, fmt.Errorf("invalid vless")
server := link.Hostname()
if server == "" {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host")
}
// 处理端口
port, err := strconv.Atoi(strings.TrimSpace(serverAndPort[1]))
portStr := link.Port()
port, err := ParsePort(portStr)
if err != nil {
return model.Proxy{}, err
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error())
}
// 返回结果
result := model.Proxy{
Type: "vless",
Server: strings.TrimSpace(serverAndPort[0]),
Port: port,
UUID: strings.TrimSpace(parts[0]),
UDP: true,
Sni: params.Get("sni"),
Network: params.Get("type"),
TLS: params.Get("security") == "reality",
Flow: params.Get("flow"),
ClientFingerprint: params.Get("fp"),
Servername: params.Get("sni"),
RealityOpts: model.RealityOptions{
PublicKey: params.Get("pbk"),
},
}
if params.Get("alpn") != "" {
result.Alpn = strings.Split(params.Get("alpn"), ",")
}
if params.Get("type") == "ws" {
result.WSOpts = model.WSOptions{
Path: params.Get("path"),
Headers: map[string]string{
"Host": params.Get("host"),
},
}
}
if params.Get("type") == "grpc" {
result.GrpcOpts = model.GrpcOptions{
GrpcServiceName: params.Get("serviceName"),
}
}
// 如果有节点名称
if len(serverInfo) == 2 {
if strings.Contains(serverInfo[1], "|") {
result.Name = strings.SplitN(serverInfo[1], "|", 2)[1]
query := link.Query()
uuid := link.User.Username()
flow, security, alpnStr, sni, insecure, fp, pbk, sid, path, host, serviceName, _type, udp := query.Get("flow"), query.Get("security"), query.Get("alpn"), query.Get("sni"), query.Get("allowInsecure"), query.Get("fp"), query.Get("pbk"), query.Get("sid"), query.Get("path"), query.Get("host"), query.Get("serviceName"), query.Get("type"), query.Get("udp")
insecureBool := insecure == "1"
var alpn []string
if strings.Contains(alpnStr, ",") {
alpn = strings.Split(alpnStr, ",")
} else {
result.Name = serverInfo[1]
alpn = nil
}
} else {
result.Name = serverAndPort[0]
remarks := link.Fragment
if remarks == "" {
remarks = fmt.Sprintf("%s:%s", server, portStr)
}
return result, nil
remarks = strings.TrimSpace(remarks)
result := P.Vless{
Server: server,
Port: P.IntOrString(port),
UUID: uuid,
Flow: flow,
UDP: udp == "true" || config.UseUDP,
SkipCertVerify: insecureBool,
}
if len(alpn) > 0 {
result.ALPN = alpn
}
if fp != "" {
result.ClientFingerprint = fp
}
if sni != "" {
result.ServerName = sni
}
if security == "tls" {
result.TLS = true
}
if security == "reality" {
result.TLS = true
result.RealityOpts = P.RealityOptions{
PublicKey: pbk,
ShortID: sid,
}
}
if _type == "ws" {
result.Network = "ws"
result.WSOpts = P.WSOptions{
Path: path,
}
if host != "" {
result.WSOpts.Headers = make(map[string]string)
result.WSOpts.Headers["Host"] = host
}
}
if _type == "grpc" {
result.Network = "grpc"
result.GrpcOpts = P.GrpcOptions{
GrpcServiceName: serviceName,
}
}
if _type == "http" {
result.HTTPOpts = P.HTTPOptions{}
result.HTTPOpts.Headers = map[string][]string{}
result.HTTPOpts.Path = strings.Split(path, ",")
hosts, err := url.QueryUnescape(host)
if err != nil {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrCannotParseParams, err.Error())
}
result.Network = "http"
if hosts != "" {
result.HTTPOpts.Headers["host"] = strings.Split(host, ",")
}
}
return P.Proxy{
Type: p.GetType(),
Name: remarks,
Vless: result,
}, nil
}
func init() {
RegisterParser(&VlessParser{})
}
+131 -41
View File
@@ -2,84 +2,174 @@ package parser
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"sub2clash/model"
P "github.com/bestnite/sub2clash/model/proxy"
"github.com/bestnite/sub2clash/utils"
)
func ParseVmess(proxy string) (model.Proxy, error) {
// 判断是否以 vmess:// 开头
if !strings.HasPrefix(proxy, "vmess://") {
return model.Proxy{}, errors.New("invalid vmess url")
type VmessJson struct {
V any `json:"v"`
Ps string `json:"ps"`
Add string `json:"add"`
Port any `json:"port"`
Id string `json:"id"`
Aid any `json:"aid"`
Scy string `json:"scy"`
Net string `json:"net"`
Type string `json:"type"`
Host string `json:"host"`
Path string `json:"path"`
Tls string `json:"tls"`
Sni string `json:"sni"`
Alpn string `json:"alpn"`
Fp string `json:"fp"`
}
// 解码
base64, err := DecodeBase64(strings.TrimPrefix(proxy, "vmess://"))
type VmessParser struct{}
func (p *VmessParser) SupportClash() bool {
return true
}
func (p *VmessParser) SupportMeta() bool {
return true
}
func (p *VmessParser) GetPrefixes() []string {
return []string{"vmess://"}
}
func (p *VmessParser) GetType() string {
return "vmess"
}
func (p *VmessParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) {
if !hasPrefix(proxy, p.GetPrefixes()) {
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy)
}
for _, prefix := range p.GetPrefixes() {
if strings.HasPrefix(proxy, prefix) {
proxy = strings.TrimPrefix(proxy, prefix)
break
}
}
base64, err := utils.DecodeBase64(proxy, true)
if err != nil {
return model.Proxy{}, errors.New("invalid vmess url" + err.Error())
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidBase64, err.Error())
}
// 解析
var vmess model.VmessJson
var vmess VmessJson
err = json.Unmarshal([]byte(base64), &vmess)
if err != nil {
return model.Proxy{}, errors.New("invalid vmess url" + err.Error())
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
// 解析端口
port := 0
var port int
switch vmess.Port.(type) {
case string:
port, err = strconv.Atoi(vmess.Port.(string))
port, err = ParsePort(vmess.Port.(string))
if err != nil {
return model.Proxy{}, errors.New("invalid vmess url" + err.Error())
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error())
}
case float64:
port = int(vmess.Port.(float64))
}
// 解析Aid
aid := 0
switch vmess.Aid.(type) {
case string:
aid, err = strconv.Atoi(vmess.Aid.(string))
if err != nil {
return model.Proxy{}, errors.New("invalid vmess url" + err.Error())
return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error())
}
case float64:
aid = int(vmess.Aid.(float64))
}
// 设置默认值
if vmess.Scy == "" {
vmess.Scy = "auto"
}
if vmess.Net == "ws" && vmess.Path == "" {
name, err := url.QueryUnescape(vmess.Ps)
if err != nil {
name = vmess.Ps
}
var alpn []string
if strings.Contains(vmess.Alpn, ",") {
alpn = strings.Split(vmess.Alpn, ",")
} else {
alpn = nil
}
result := P.Vmess{
Server: vmess.Add,
Port: P.IntOrString(port),
UUID: vmess.Id,
AlterID: P.IntOrString(aid),
Cipher: vmess.Scy,
UDP: config.UseUDP,
}
if len(alpn) > 0 {
result.ALPN = alpn
}
if vmess.Fp != "" {
result.ClientFingerprint = vmess.Fp
}
if vmess.Sni != "" {
result.ServerName = vmess.Sni
}
if vmess.Tls == "tls" {
result.TLS = true
}
if vmess.Net == "ws" {
if vmess.Path == "" {
vmess.Path = "/"
}
if vmess.Net == "ws" && vmess.Host == "" {
if vmess.Host == "" {
vmess.Host = vmess.Add
}
// 返回结果
result := model.Proxy{
Name: vmess.Ps,
Type: "vmess",
Server: vmess.Add,
Port: port,
UUID: vmess.Id,
AlterID: aid,
Cipher: vmess.Scy,
UDP: true,
TLS: vmess.Tls == "tls",
Fingerprint: vmess.Fp,
ClientFingerprint: "chrome",
SkipCertVerify: true,
Servername: vmess.Add,
Network: vmess.Net,
}
if vmess.Net == "ws" {
result.WSOpts = model.WSOptions{
result.Network = "ws"
result.WSOpts = P.WSOptions{
Path: vmess.Path,
Headers: map[string]string{
"Host": vmess.Host,
},
}
}
return result, nil
if vmess.Net == "grpc" {
result.GrpcOpts = P.GrpcOptions{
GrpcServiceName: vmess.Path,
}
result.Network = "grpc"
}
if vmess.Net == "h2" {
result.HTTP2Opts = P.HTTP2Options{
Host: strings.Split(vmess.Host, ","),
Path: vmess.Path,
}
result.Network = "h2"
}
return P.Proxy{
Type: p.GetType(),
Name: name,
Vmess: result,
}, nil
}
func init() {
RegisterParser(&VmessParser{})
}
+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()],
});
+58
View File
@@ -0,0 +1,58 @@
package handler
import (
_ "embed"
"net/http"
"github.com/bestnite/sub2clash/common"
"github.com/bestnite/sub2clash/config"
"github.com/bestnite/sub2clash/model"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
func ConvertHandler() func(c *gin.Context) {
return func(c *gin.Context) {
query, err := model.ParseConvertQuery(c)
if err != nil {
c.String(http.StatusBadRequest, err.Error())
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)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return
}
if len(query.Subs) == 1 {
userInfoHeader, err := common.FetchSubscriptionUserInfo(query.Subs[0], "clash", config.GlobalConfig.RequestRetryTimes)
if err == nil {
c.Header("subscription-userinfo", userInfoHeader)
}
}
if query.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 {
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
return
}
c.String(http.StatusOK, string(marshal))
}
}
+250
View File
@@ -0,0 +1,250 @@
package handler
import (
"encoding/json"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/bestnite/sub2clash/common"
"github.com/bestnite/sub2clash/common/database"
"github.com/bestnite/sub2clash/config"
"github.com/bestnite/sub2clash/model"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
type shortLinkGenRequset struct {
Config model.ConvertConfig `form:"config" binding:"required"`
Password string `form:"password"`
ID string `form:"id"`
}
type shortLinkUpdateRequest struct {
Config model.ConvertConfig `form:"config" binding:"required"`
Password string `form:"password" binding:"required"`
ID string `form:"id" binding:"required"`
}
var DB *database.Database
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) {
var params shortLinkGenRequset
if err := c.ShouldBind(&params); err != nil {
c.String(http.StatusBadRequest, "参数错误: "+err.Error())
return
}
var id string
var password string
var err error
if params.ID != "" {
// 检查自定义ID是否已存在
exists, err := DB.CheckShortLinkIDExists(params.ID)
if err != nil {
c.String(http.StatusInternalServerError, "数据库错误")
return
}
if exists {
c.String(http.StatusBadRequest, "短链已存在")
return
}
id = params.ID
password = params.Password
} else {
// 自动生成短链ID和密码
id, err = generateUniqueHash(config.GlobalConfig.ShortLinkLength)
if err != nil {
c.String(http.StatusInternalServerError, "生成短链失败")
return
}
if params.Password == "" {
password = common.RandomString(8) // 生成8位随机密码
} else {
password = params.Password
}
}
shortLink := model.ShortLink{
ID: id,
Config: params.Config,
Password: password,
}
if err := DB.CreateShortLink(&shortLink); err != nil {
c.String(http.StatusInternalServerError, "数据库错误")
return
}
// 返回生成的短链ID和密码
response := map[string]string{
"id": id,
"password": password,
}
c.JSON(http.StatusOK, response)
}
func generateUniqueHash(length int) (string, error) {
for {
hash := common.RandomString(length)
exists, err := DB.CheckShortLinkIDExists(hash)
if err != nil {
return "", err
}
if !exists {
return hash, nil
}
}
}
func UpdateLinkHandler(c *gin.Context) {
var params shortLinkUpdateRequest
if err := c.ShouldBindJSON(&params); err != nil {
c.String(http.StatusBadRequest, "参数错误: "+err.Error())
return
}
// 先获取原有的短链
existingLink, err := DB.FindShortLinkByID(params.ID)
if err != nil {
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
// 验证密码
if existingLink.Password != params.Password {
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
jsonData, err := json.Marshal(params.Config)
if err != nil {
c.String(http.StatusBadRequest, "配置格式错误")
return
}
if err := DB.UpdataShortLink(params.ID, "config", jsonData); err != nil {
c.String(http.StatusInternalServerError, "数据库错误")
return
}
c.String(http.StatusOK, "短链更新成功")
}
func GetRawConfHandler(c *gin.Context) {
id := c.Param("id")
password := c.Query("password")
if strings.TrimSpace(id) == "" {
c.String(http.StatusBadRequest, "参数错误")
return
}
shortLink, err := DB.FindShortLinkByID(id)
if err != nil {
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
if shortLink.Password != "" && shortLink.Password != password {
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
err = DB.UpdataShortLink(shortLink.ID, "last_request_time", time.Now().Unix())
if err != nil {
c.String(http.StatusInternalServerError, "数据库错误")
return
}
template := ""
switch shortLink.Config.ClashType {
case model.Clash:
template = config.GlobalConfig.ClashTemplate
case model.ClashMeta:
template = config.GlobalConfig.MetaTemplate
}
sub, err := common.BuildSub(shortLink.Config.ClashType, shortLink.Config, template, config.GlobalConfig.CacheExpire, config.GlobalConfig.RequestRetryTimes)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return
}
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 {
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
return
}
c.String(http.StatusOK, string(marshal))
}
func GetRawConfUriHandler(c *gin.Context) {
id := c.Param("id")
password := c.Query("password")
if strings.TrimSpace(id) == "" {
c.String(http.StatusBadRequest, "参数错误")
return
}
shortLink, err := DB.FindShortLinkByID(id)
if err != nil {
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
if shortLink.Password != "" && shortLink.Password != password {
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
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)
}
}
@@ -2,9 +2,10 @@ package middleware
import (
"strconv"
"sub2clash/logger"
"time"
"github.com/bestnite/sub2clash/logger"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)

Some files were not shown because too many files have changed in this diff Show More