├── .gitattributes ├── constant └── version.go ├── .gitignore ├── server ├── frontend │ ├── vite.config.js │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── src │ │ ├── utils.ts │ │ ├── interface.ts │ │ ├── index.css │ │ └── components │ │ │ ├── rule-input.ts │ │ │ ├── rename-input.ts │ │ │ ├── rule-provider-input.ts │ │ │ └── short-link-input-group.ts │ └── tsconfig.json ├── middleware │ └── logger.go ├── route.go └── handler │ ├── convert.go │ └── short_link.go ├── model ├── short_link.go ├── rule_provider.go ├── proxy │ ├── socks.go │ ├── shadowsocksr.go │ ├── shadowsocks.go │ ├── anytls.go │ ├── trojan.go │ ├── hysteria2.go │ ├── vless.go │ ├── vmess.go │ ├── hysteria.go │ ├── tuic.go │ └── proxy.go ├── clash.go ├── group.go └── convert_config.go ├── common ├── request.go ├── random_string.go ├── mkdir.go ├── template.go ├── rule.go ├── database │ └── database.go ├── proxy.go ├── errors.go └── sub.go ├── .vscode ├── launch.json └── tasks.json ├── compose.yml ├── config.example.json ├── parser ├── errors.go ├── common.go ├── registry.go ├── anytls.go ├── hysteria2.go ├── socks.go ├── hysteria.go ├── trojan.go ├── shadowsocks.go ├── vless.go ├── shadowsocksr.go └── vmess.go ├── config.example.yaml ├── test ├── parser │ ├── utils.go │ ├── shadowsocksr_test.go │ ├── socks_test.go │ ├── trojan_test.go │ ├── shadowsocks_test.go │ ├── hysteria_test.go │ ├── hysteria2_test.go │ ├── anytls_test.go │ ├── vless_test.go │ └── vmess_test.go └── yaml_test.go ├── Containerfile ├── utils └── base64.go ├── .github └── workflows │ ├── release.yml │ └── docker.yml ├── .goreleaser.yaml ├── LICENSE ├── main.go ├── logger └── logger.go ├── config └── config.go ├── templates └── template_meta.yaml ├── README.md └── go.mod /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /constant/version.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | var Version = "dev" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist 3 | subs 4 | logs 5 | data 6 | .env 7 | config.yaml 8 | config.yml 9 | config.json -------------------------------------------------------------------------------- /server/frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | 4 | export default defineConfig({ 5 | plugins: [tailwindcss()], 6 | }); 7 | -------------------------------------------------------------------------------- /model/short_link.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type ShortLink struct { 4 | ID string `gorm:"unique"` 5 | Config ConvertConfig `gorm:"serializer:json"` 6 | Password string 7 | LastRequestTime int64 8 | } 9 | -------------------------------------------------------------------------------- /common/request.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "resty.dev/v3" 5 | ) 6 | 7 | func Request(retryTimes int) *resty.Client { 8 | client := resty.New() 9 | client. 10 | SetRetryCount(retryTimes) 11 | return client 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "debug", 6 | "type": "go", 7 | "request": "launch", 8 | "mode": "auto", 9 | "program": "${workspaceFolder}", 10 | "args": [], 11 | "preLaunchTask": "build frontend" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | name: sub2clash 2 | services: 3 | sub2clash: 4 | restart: unless-stopped 5 | image: nite07/sub2clash:latest 6 | ports: 7 | - "8011:8011" 8 | volumes: 9 | # - ./logs:/app/logs 10 | # - ./templates:/app/templates 11 | - ./data:/app/data 12 | -------------------------------------------------------------------------------- /common/random_string.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "math/rand" 4 | 5 | func RandomString(length int) string { 6 | 7 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 8 | var result []byte 9 | for i := 0; i < length; i++ { 10 | result = append(result, charset[rand.Intn(len(charset))]) 11 | } 12 | return string(result) 13 | } 14 | -------------------------------------------------------------------------------- /server/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /model/rule_provider.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type RuleProvider struct { 4 | Type string `yaml:"type,omitempty"` 5 | Behavior string `yaml:"behavior,omitempty"` 6 | Url string `yaml:"url,omitempty"` 7 | Path string `yaml:"path,omitempty"` 8 | Interval int `yaml:"interval,omitempty"` 9 | Format string `yaml:"format,omitempty"` 10 | } 11 | 12 | type Payload struct { 13 | Rules []string `yaml:"payload,omitempty"` 14 | } 15 | -------------------------------------------------------------------------------- /server/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | sub2clash 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "address": "0.0.0.0:8011", 3 | "meta_template": "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_meta.yaml", 4 | "clash_template": "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_clash.yaml", 5 | "request_retry_times": 3, 6 | "request_max_file_size": 1048576, 7 | "cache_expire": 300, 8 | "log_level": "info", 9 | "short_link_length": 6 10 | } -------------------------------------------------------------------------------- /parser/errors.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | type ParseErrorType string 4 | 5 | const ( 6 | ErrInvalidPrefix ParseErrorType = "invalid url prefix" 7 | ErrInvalidStruct ParseErrorType = "invalid struct" 8 | ErrInvalidPort ParseErrorType = "invalid port number" 9 | ErrCannotParseParams ParseErrorType = "cannot parse query parameters" 10 | ErrInvalidBase64 ParseErrorType = "invalid base64" 11 | ) 12 | 13 | func (e ParseErrorType) Error() string { 14 | return string(e) 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build frontend", 6 | "type": "shell", 7 | "command": "npm", 8 | "args": [ 9 | "run", 10 | "build" 11 | ], 12 | "group": { 13 | "kind": "build", 14 | "isDefault": true 15 | }, 16 | "options": { 17 | "cwd": "${workspaceFolder}/server/frontend" 18 | } 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /server/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sub2clash-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@tailwindcss/vite": "^4.1.14", 13 | "axios": "^1.12.2", 14 | "daisyui": "^5.3.7", 15 | "lit": "^3.3.1", 16 | "tailwindcss": "^4.1.14" 17 | }, 18 | "devDependencies": { 19 | "typescript": "~5.9.3", 20 | "vite": "^7.2.6" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/frontend/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function base64EncodeUnicode(str: string) { 2 | const bytes = new TextEncoder().encode(str); 3 | let binary = ""; 4 | bytes.forEach((b) => (binary += String.fromCharCode(b))); 5 | return btoa(binary); 6 | } 7 | 8 | export function base64decodeUnicode(str: string) { 9 | const binaryString = atob(str); 10 | const bytes = new Uint8Array(binaryString.length); 11 | for (let i = 0; i < binaryString.length; i++) { 12 | bytes[i] = binaryString.charCodeAt(i); 13 | } 14 | return new TextDecoder().decode(bytes); 15 | } 16 | -------------------------------------------------------------------------------- /model/proxy/socks.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/socks5.go 4 | type Socks struct { 5 | Server string `yaml:"server"` 6 | Port IntOrString `yaml:"port"` 7 | UserName string `yaml:"username,omitempty"` 8 | Password string `yaml:"password,omitempty"` 9 | TLS bool `yaml:"tls,omitempty"` 10 | UDP bool `yaml:"udp,omitempty"` 11 | SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` 12 | Fingerprint string `yaml:"fingerprint,omitempty"` 13 | } 14 | -------------------------------------------------------------------------------- /model/proxy/shadowsocksr.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/shadowsocksr.go 4 | type ShadowSocksR struct { 5 | Server string `yaml:"server"` 6 | Port IntOrString `yaml:"port"` 7 | Password string `yaml:"password"` 8 | Cipher string `yaml:"cipher"` 9 | Obfs string `yaml:"obfs"` 10 | ObfsParam string `yaml:"obfs-param,omitempty"` 11 | Protocol string `yaml:"protocol"` 12 | ProtocolParam string `yaml:"protocol-param,omitempty"` 13 | UDP bool `yaml:"udp,omitempty"` 14 | } 15 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | # Sub2Clash 配置文件示例 2 | # 复制此文件为 config.yaml 并根据需要修改配置 3 | 4 | # 服务端口 5 | address: "0.0.0.0:8011" 6 | 7 | # 模板配置 8 | meta_template: "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_meta.yaml" 9 | clash_template: "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_clash.yaml" 10 | 11 | # 请求配置 12 | request_retry_times: 3 13 | request_max_file_size: 1048576 # 1MB in bytes 14 | 15 | # 缓存配置 (秒) 16 | cache_expire: 300 # 5 minutes 17 | 18 | # 日志级别 (debug, info, warn, error) 19 | log_level: "info" 20 | 21 | # 短链接长度 22 | short_link_length: 6 23 | -------------------------------------------------------------------------------- /common/mkdir.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func MKDir(dir string) error { 8 | if _, err := os.Stat(dir); os.IsNotExist(err) { 9 | err := os.MkdirAll(dir, os.ModePerm) 10 | if err != nil { 11 | 12 | return err 13 | } 14 | } 15 | return nil 16 | } 17 | 18 | func MkEssentialDir() error { 19 | if err := MKDir("subs"); err != nil { 20 | return NewDirCreationError("subs", err) 21 | } 22 | if err := MKDir("logs"); err != nil { 23 | return NewDirCreationError("logs", err) 24 | } 25 | if err := MKDir("data"); err != nil { 26 | return NewDirCreationError("data", err) 27 | } 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /model/clash.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "github.com/bestnite/sub2clash/parser" 4 | 5 | type ClashType int 6 | 7 | const ( 8 | Clash ClashType = 1 + iota 9 | ClashMeta 10 | ) 11 | 12 | func GetSupportProxyTypes(clashType ClashType) map[string]bool { 13 | supportProxyTypes := make(map[string]bool) 14 | 15 | for _, parser := range parser.GetAllParsers() { 16 | switch clashType { 17 | case Clash: 18 | if parser.SupportClash() { 19 | supportProxyTypes[parser.GetType()] = true 20 | } 21 | case ClashMeta: 22 | if parser.SupportMeta() { 23 | supportProxyTypes[parser.GetType()] = true 24 | } 25 | } 26 | } 27 | 28 | return supportProxyTypes 29 | } 30 | -------------------------------------------------------------------------------- /test/parser/utils.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/bestnite/sub2clash/model/proxy" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | func validateResult(t *testing.T, expected proxy.Proxy, result proxy.Proxy) { 12 | t.Helper() 13 | 14 | if result.Type != expected.Type { 15 | t.Errorf("Type mismatch: expected %s, got %s", expected.Type, result.Type) 16 | } 17 | 18 | if !reflect.DeepEqual(expected, result) { 19 | expectedYaml, _ := yaml.Marshal(expected) 20 | resultYaml, _ := yaml.Marshal(result) 21 | 22 | t.Errorf("Structure mismatch: \nexpected:\n %s\ngot:\n %s", string(expectedYaml), string(resultYaml)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest AS frontend_builder 2 | WORKDIR /app/server/frontend 3 | COPY server/frontend/package*.json ./ 4 | RUN npm install 5 | COPY server/frontend . 6 | ARG version 7 | ENV VITE_APP_VERSION=${version} 8 | RUN npm run build 9 | 10 | FROM golang:1.25 AS builder 11 | WORKDIR /app 12 | COPY . . 13 | COPY --from=frontend_builder /app/server/frontend/dist /app/server/frontend/dist 14 | RUN go mod download 15 | ARG version 16 | RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X github.com/bestnite/sub2clash/constant.Version=${version}" -o sub2clash . 17 | 18 | FROM alpine:latest 19 | WORKDIR /app 20 | COPY --from=builder /app/sub2clash /app/sub2clash 21 | ENTRYPOINT ["/app/sub2clash"] 22 | -------------------------------------------------------------------------------- /utils/base64.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/base64" 5 | "strings" 6 | ) 7 | 8 | func DecodeBase64(s string, urlSafe bool) (string, error) { 9 | s = strings.TrimSpace(s) 10 | if len(s)%4 != 0 { 11 | s += strings.Repeat("=", 4-len(s)%4) 12 | } 13 | var decodeStr []byte 14 | var err error 15 | if urlSafe { 16 | decodeStr, err = base64.URLEncoding.DecodeString(s) 17 | } else { 18 | decodeStr, err = base64.StdEncoding.DecodeString(s) 19 | } 20 | if err != nil { 21 | return "", err 22 | } 23 | return string(decodeStr), nil 24 | } 25 | 26 | func EncodeBase64(s string, urlSafe bool) string { 27 | if urlSafe { 28 | return base64.URLEncoding.EncodeToString([]byte(s)) 29 | } 30 | return base64.StdEncoding.EncodeToString([]byte(s)) 31 | } 32 | -------------------------------------------------------------------------------- /model/proxy/shadowsocks.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/shadowsocks.go 4 | type ShadowSocks struct { 5 | Server string `yaml:"server"` 6 | Port IntOrString `yaml:"port"` 7 | Password string `yaml:"password"` 8 | Cipher string `yaml:"cipher"` 9 | UDP bool `yaml:"udp,omitempty"` 10 | Plugin string `yaml:"plugin,omitempty"` 11 | PluginOpts map[string]any `yaml:"plugin-opts,omitempty"` 12 | UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"` 13 | UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"` 14 | ClientFingerprint string `yaml:"client-fingerprint,omitempty"` 15 | } 16 | -------------------------------------------------------------------------------- /test/yaml_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bestnite/sub2clash/model/proxy" 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | type testStruct struct { 11 | A proxy.IntOrString `yaml:"a"` 12 | } 13 | 14 | func TestUnmarshal(t *testing.T) { 15 | yamlData1 := `a: 123` 16 | res := testStruct{} 17 | err := yaml.Unmarshal([]byte(yamlData1), &res) 18 | if err != nil { 19 | t.Errorf("failed to unmarshal yaml: %v", err) 20 | } 21 | if res.A != 123 { 22 | t.Errorf("expected 123, but got %v", res.A) 23 | } 24 | 25 | yamlData2 := `a: "123"` 26 | err = yaml.Unmarshal([]byte(yamlData2), &res) 27 | if err != nil { 28 | t.Errorf("failed to unmarshal yaml: %v", err) 29 | } 30 | if res.A != 123 { 31 | t.Errorf("expected 123, but got %v", res.A) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "experimentalDecorators": true, 5 | "useDefineForClassFields": false, 6 | "module": "ESNext", 7 | "lib": [ 8 | "ES2022", 9 | "DOM", 10 | "DOM.Iterable" 11 | ], 12 | "types": [ 13 | "vite/client" 14 | ], 15 | "skipLibCheck": true, 16 | "moduleResolution": "bundler", 17 | "allowImportingTsExtensions": true, 18 | "verbatimModuleSyntax": true, 19 | "moduleDetection": "force", 20 | "noEmit": true, 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "erasableSyntaxOnly": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "noUncheckedSideEffectImports": true 27 | }, 28 | "include": [ 29 | "src" 30 | ] 31 | } -------------------------------------------------------------------------------- /server/frontend/src/interface.ts: -------------------------------------------------------------------------------- 1 | export interface RuleProvider { 2 | behavior: string; 3 | url: string; 4 | group: string; 5 | prepend: boolean; 6 | name: string; 7 | } 8 | 9 | export interface Rule { 10 | rule: string; 11 | prepend: boolean; 12 | } 13 | 14 | export interface Rename { 15 | old: string; 16 | new: string; 17 | } 18 | 19 | export interface Config { 20 | clashType: number; 21 | subscriptions?: string[]; 22 | proxies?: string[]; 23 | userAgent?: string; 24 | refresh?: boolean; 25 | autoTest?: boolean; 26 | lazy?: boolean; 27 | nodeList?: boolean; 28 | ignoreCountryGroup?: boolean; 29 | useUDP?: boolean; 30 | template?: string; 31 | ruleProviders?: RuleProvider[]; 32 | rules?: Rule[]; 33 | sort?: string; 34 | remove?: string; 35 | replace?: { [key: string]: string }; 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v5 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | 21 | - name: Install UPX 22 | uses: crazy-max/ghaction-upx@v3 23 | with: 24 | install-only: true 25 | 26 | - name: setup node 27 | uses: actions/setup-node@v6 28 | with: 29 | node-version: "latest" 30 | 31 | - name: Run GoReleaser 32 | uses: goreleaser/goreleaser-action@v6 33 | with: 34 | distribution: goreleaser 35 | version: latest 36 | args: release --clean 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /server/middleware/logger.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/bestnite/sub2clash/logger" 8 | 9 | "github.com/gin-gonic/gin" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func ZapLogger() gin.HandlerFunc { 14 | return func(c *gin.Context) { 15 | startTime := time.Now() 16 | 17 | c.Next() 18 | 19 | endTime := time.Now() 20 | latencyTime := endTime.Sub(startTime).Milliseconds() 21 | reqMethod := c.Request.Method 22 | reqURI := c.Request.RequestURI 23 | statusCode := c.Writer.Status() 24 | clientIP := c.ClientIP() 25 | 26 | logger.Logger.Info( 27 | "Request", 28 | zap.Int("status", statusCode), 29 | zap.String("method", reqMethod), 30 | zap.String("uri", reqURI), 31 | zap.String("ip", clientIP), 32 | zap.String("latency", strconv.Itoa(int(latencyTime))+"ms"), 33 | ) 34 | 35 | if len(c.Errors) > 0 { 36 | for _, e := range c.Errors.Errors() { 37 | logger.Logger.Error(e) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /model/proxy/anytls.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/anytls.go 4 | type Anytls struct { 5 | Server string `yaml:"server"` 6 | Port IntOrString `yaml:"port"` 7 | Password string `yaml:"password"` 8 | ALPN []string `yaml:"alpn,omitempty"` 9 | SNI string `yaml:"sni,omitempty"` 10 | ECHOpts ECHOptions `yaml:"ech-opts,omitempty"` 11 | ClientFingerprint string `yaml:"client-fingerprint,omitempty"` 12 | SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` 13 | Fingerprint string `yaml:"fingerprint,omitempty"` 14 | UDP bool `yaml:"udp,omitempty"` 15 | IdleSessionCheckInterval int `yaml:"idle-session-check-interval,omitempty"` 16 | IdleSessionTimeout int `yaml:"idle-session-timeout,omitempty"` 17 | MinIdleSession int `yaml:"min-idle-session,omitempty"` 18 | } 19 | -------------------------------------------------------------------------------- /server/route.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "embed" 5 | "net/http" 6 | 7 | "github.com/bestnite/sub2clash/server/handler" 8 | "github.com/bestnite/sub2clash/server/middleware" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | //go:embed frontend/dist 14 | var staticFiles embed.FS 15 | 16 | func SetRoute(r *gin.Engine) { 17 | r.GET("/convert/:config", middleware.ZapLogger(), handler.ConvertHandler()) 18 | r.GET("/s/:id", middleware.ZapLogger(), handler.GetRawConfHandler) 19 | r.POST("/short", middleware.ZapLogger(), handler.GenerateLinkHandler) 20 | r.PUT("/short", middleware.ZapLogger(), handler.UpdateLinkHandler) 21 | r.GET("/short/:id", middleware.ZapLogger(), handler.GetRawConfUriHandler) 22 | r.DELETE("/short/:id", middleware.ZapLogger(), handler.DeleteShortLinkHandler) 23 | 24 | r.GET("/", func(c *gin.Context) { 25 | c.FileFromFS("frontend/dist/", http.FS(staticFiles)) 26 | }) 27 | r.GET( 28 | "/assets/*filepath", func(c *gin.Context) { 29 | c.FileFromFS("frontend/dist/assets/"+c.Param("filepath"), http.FS(staticFiles)) 30 | }, 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: sub2clash 3 | before: 4 | hooks: 5 | - bash ./build-frontend.sh {{ .Version }} 6 | builds: 7 | - env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - linux 11 | - windows 12 | - darwin 13 | goarch: 14 | - amd64 15 | - arm64 16 | ldflags: 17 | - -s -w -X github.com/bestnite/sub2clash/constant.Version={{ .Version }} 18 | flags: 19 | - -trimpath 20 | archives: 21 | - formats: ["tar.gz"] 22 | format_overrides: 23 | - formats: ["zip"] 24 | goos: windows 25 | wrap_in_directory: true 26 | files: 27 | - LICENSE 28 | - README.md 29 | - templates 30 | release: 31 | draft: true 32 | # upx: 33 | # - enabled: true 34 | # compress: best 35 | nfpms: 36 | - id: sub2clash 37 | homepage: https://github.com/bestnite/sub2clash 38 | maintainer: Nite 39 | license: "MIT" 40 | formats: 41 | - apk 42 | - deb 43 | - rpm 44 | - termux.deb 45 | - archlinux 46 | provides: 47 | - sub2clash 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nite07 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /common/template.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | const templatesDir = "templates" 11 | 12 | // LoadTemplate 只读取运行目录下的 templates 目录,防止其他文件内容泄漏 13 | func LoadTemplate(templateName string) ([]byte, error) { 14 | // 清理路径,防止目录遍历攻击 15 | cleanTemplateName := filepath.Clean(templateName) 16 | 17 | // 检查是否尝试访问父目录 18 | if strings.HasPrefix(cleanTemplateName, "..") || strings.Contains(cleanTemplateName, string(filepath.Separator)+".."+string(filepath.Separator)) { 19 | return nil, NewFileNotFoundError(templateName) // 拒绝包含父目录的路径 20 | } 21 | 22 | // 构建完整路径,确保只从 templates 目录读取 23 | fullPath := filepath.Join(templatesDir, cleanTemplateName) 24 | 25 | if _, err := os.Stat(fullPath); err == nil { 26 | file, err := os.Open(fullPath) 27 | if err != nil { 28 | return nil, err 29 | } 30 | defer func(file *os.File) { 31 | if file != nil { 32 | _ = file.Close() 33 | } 34 | }(file) 35 | result, err := io.ReadAll(file) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return result, nil 40 | } 41 | return nil, NewFileNotFoundError(templateName) 42 | } 43 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "io" 6 | 7 | "github.com/bestnite/sub2clash/common" 8 | "github.com/bestnite/sub2clash/config" 9 | "github.com/bestnite/sub2clash/logger" 10 | "github.com/bestnite/sub2clash/server" 11 | 12 | "github.com/gin-gonic/gin" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func init() { 17 | var err error 18 | 19 | err = common.MkEssentialDir() 20 | if err != nil { 21 | logger.Logger.Panic("create essential dir failed", zap.Error(err)) 22 | } 23 | 24 | err = config.LoadConfig() 25 | 26 | logger.InitLogger(config.GlobalConfig.LogLevel) 27 | if err != nil { 28 | logger.Logger.Panic("load config failed", zap.Error(err)) 29 | } 30 | 31 | logger.Logger.Info("database connect success") 32 | } 33 | 34 | func main() { 35 | gin.SetMode(gin.ReleaseMode) 36 | 37 | gin.DefaultWriter = io.Discard 38 | 39 | r := gin.Default() 40 | 41 | server.SetRoute(r) 42 | logger.Logger.Info("server is running at " + config.GlobalConfig.Address) 43 | err := r.Run(config.GlobalConfig.Address) 44 | if err != nil { 45 | logger.Logger.Error("server running failed", zap.Error(err)) 46 | return 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /model/proxy/trojan.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/trojan.go 4 | type Trojan struct { 5 | Server string `yaml:"server"` 6 | Port IntOrString `yaml:"port"` 7 | Password string `yaml:"password"` 8 | ALPN []string `yaml:"alpn,omitempty"` 9 | SNI string `yaml:"sni,omitempty"` 10 | SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` 11 | Fingerprint string `yaml:"fingerprint,omitempty"` 12 | UDP bool `yaml:"udp,omitempty"` 13 | Network string `yaml:"network,omitempty"` 14 | ECHOpts ECHOptions `yaml:"ech-opts,omitempty"` 15 | RealityOpts RealityOptions `yaml:"reality-opts,omitempty"` 16 | GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` 17 | WSOpts WSOptions `yaml:"ws-opts,omitempty"` 18 | SSOpts TrojanSSOption `yaml:"ss-opts,omitempty"` 19 | ClientFingerprint string `yaml:"client-fingerprint,omitempty"` 20 | } 21 | 22 | type TrojanSSOption struct { 23 | Enabled bool `yaml:"enabled,omitempty"` 24 | Method string `yaml:"method,omitempty"` 25 | Password string `yaml:"password,omitempty"` 26 | } 27 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | "gopkg.in/natefinch/lumberjack.v2" 10 | ) 11 | 12 | var Logger *zap.Logger 13 | 14 | func InitLogger(logLevel string) { 15 | logger := zap.New(buildZapCore(getZapLogLevel(logLevel))) 16 | Logger = logger 17 | } 18 | 19 | func buildZapCore(logLevel zapcore.Level) zapcore.Core { 20 | fileWriter := zapcore.AddSync(&lumberjack.Logger{ 21 | Filename: "logs/app.log", 22 | MaxSize: 500, 23 | MaxBackups: 3, 24 | MaxAge: 28, 25 | Compress: true, 26 | }) 27 | consoleWriter := zapcore.AddSync(os.Stdout) 28 | 29 | encoderConfig := zap.NewProductionEncoderConfig() 30 | encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder 31 | encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder 32 | 33 | fileCore := zapcore.NewCore(zapcore.NewJSONEncoder(encoderConfig), fileWriter, logLevel) 34 | consoleCore := zapcore.NewCore(zapcore.NewConsoleEncoder(encoderConfig), consoleWriter, logLevel) 35 | combinedCore := zapcore.NewTee(fileCore, consoleCore) 36 | return combinedCore 37 | } 38 | 39 | func getZapLogLevel(logLevel string) zapcore.Level { 40 | switch strings.ToLower(logLevel) { 41 | case "debug": 42 | return zap.DebugLevel 43 | case "warn": 44 | return zap.WarnLevel 45 | case "error": 46 | return zap.ErrorLevel 47 | case "info": 48 | return zap.InfoLevel 49 | default: 50 | return zap.InfoLevel 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /common/rule.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/bestnite/sub2clash/model" 8 | ) 9 | 10 | func PrependRuleProvider( 11 | sub *model.Subscription, providerName string, group string, provider model.RuleProvider, 12 | ) { 13 | if sub.RuleProvider == nil { 14 | sub.RuleProvider = make(map[string]model.RuleProvider) 15 | } 16 | sub.RuleProvider[providerName] = provider 17 | PrependRules( 18 | sub, 19 | fmt.Sprintf("RULE-SET,%s,%s", providerName, group), 20 | ) 21 | } 22 | 23 | func AppenddRuleProvider( 24 | sub *model.Subscription, providerName string, group string, provider model.RuleProvider, 25 | ) { 26 | if sub.RuleProvider == nil { 27 | sub.RuleProvider = make(map[string]model.RuleProvider) 28 | } 29 | sub.RuleProvider[providerName] = provider 30 | AppendRules(sub, fmt.Sprintf("RULE-SET,%s,%s", providerName, group)) 31 | } 32 | 33 | func PrependRules(sub *model.Subscription, rules ...string) { 34 | if sub.Rule == nil { 35 | sub.Rule = make([]string, 0) 36 | } 37 | sub.Rule = append(rules, sub.Rule...) 38 | } 39 | 40 | func AppendRules(sub *model.Subscription, rules ...string) { 41 | if sub.Rule == nil { 42 | sub.Rule = make([]string, 0) 43 | } 44 | matchRule := sub.Rule[len(sub.Rule)-1] 45 | if strings.Contains(matchRule, "MATCH") { 46 | sub.Rule = append(sub.Rule[:len(sub.Rule)-1], rules...) 47 | sub.Rule = append(sub.Rule, matchRule) 48 | return 49 | } 50 | sub.Rule = append(sub.Rule, rules...) 51 | } 52 | -------------------------------------------------------------------------------- /model/proxy/hysteria2.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/hysteria2.go 4 | type Hysteria2 struct { 5 | Server string `yaml:"server"` 6 | Port IntOrString `yaml:"port,omitempty"` 7 | Ports string `yaml:"ports,omitempty"` 8 | HopInterval int `yaml:"hop-interval,omitempty"` 9 | Up string `yaml:"up,omitempty"` 10 | Down string `yaml:"down,omitempty"` 11 | Password string `yaml:"password,omitempty"` 12 | Obfs string `yaml:"obfs,omitempty"` 13 | ObfsPassword string `yaml:"obfs-password,omitempty"` 14 | SNI string `yaml:"sni,omitempty"` 15 | ECHOpts ECHOptions `yaml:"ech-opts,omitempty"` 16 | SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` 17 | Fingerprint string `yaml:"fingerprint,omitempty"` 18 | ALPN []string `yaml:"alpn,omitempty"` 19 | CustomCA string `yaml:"ca,omitempty"` 20 | CustomCAString string `yaml:"ca-str,omitempty"` 21 | CWND int `yaml:"cwnd,omitempty"` 22 | UdpMTU int `yaml:"udp-mtu,omitempty"` 23 | 24 | // quic-go special config 25 | InitialStreamReceiveWindow uint64 `yaml:"initial-stream-receive-window,omitempty"` 26 | MaxStreamReceiveWindow uint64 `yaml:"max-stream-receive-window,omitempty"` 27 | InitialConnectionReceiveWindow uint64 `yaml:"initial-connection-receive-window,omitempty"` 28 | MaxConnectionReceiveWindow uint64 `yaml:"max-connection-receive-window,omitempty"` 29 | } 30 | -------------------------------------------------------------------------------- /model/proxy/vless.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/vless.go 4 | type Vless struct { 5 | Server string `yaml:"server"` 6 | Port IntOrString `yaml:"port"` 7 | UUID string `yaml:"uuid"` 8 | Flow string `yaml:"flow,omitempty"` 9 | TLS bool `yaml:"tls,omitempty"` 10 | ALPN []string `yaml:"alpn,omitempty"` 11 | UDP bool `yaml:"udp,omitempty"` 12 | PacketAddr bool `yaml:"packet-addr,omitempty"` 13 | XUDP bool `yaml:"xudp,omitempty"` 14 | PacketEncoding string `yaml:"packet-encoding,omitempty"` 15 | Network string `yaml:"network,omitempty"` 16 | ECHOpts ECHOptions `yaml:"ech-opts,omitempty"` 17 | RealityOpts RealityOptions `yaml:"reality-opts,omitempty"` 18 | HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"` 19 | HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"` 20 | GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` 21 | WSOpts WSOptions `yaml:"ws-opts,omitempty"` 22 | WSPath string `yaml:"ws-path,omitempty"` 23 | WSHeaders map[string]string `yaml:"ws-headers,omitempty"` 24 | SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` 25 | Fingerprint string `yaml:"fingerprint,omitempty"` 26 | ServerName string `yaml:"servername,omitempty"` 27 | ClientFingerprint string `yaml:"client-fingerprint,omitempty"` 28 | } 29 | -------------------------------------------------------------------------------- /model/proxy/vmess.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/vmess.go 4 | type Vmess struct { 5 | Server string `yaml:"server"` 6 | Port IntOrString `yaml:"port"` 7 | UUID string `yaml:"uuid"` 8 | AlterID IntOrString `yaml:"alterId"` 9 | Cipher string `yaml:"cipher"` 10 | UDP bool `yaml:"udp,omitempty"` 11 | Network string `yaml:"network,omitempty"` 12 | TLS bool `yaml:"tls,omitempty"` 13 | ALPN []string `yaml:"alpn,omitempty"` 14 | SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` 15 | Fingerprint string `yaml:"fingerprint,omitempty"` 16 | ServerName string `yaml:"servername,omitempty"` 17 | ECHOpts ECHOptions `yaml:"ech-opts,omitempty"` 18 | RealityOpts RealityOptions `yaml:"reality-opts,omitempty"` 19 | HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"` 20 | HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"` 21 | GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` 22 | WSOpts WSOptions `yaml:"ws-opts,omitempty"` 23 | PacketAddr bool `yaml:"packet-addr,omitempty"` 24 | XUDP bool `yaml:"xudp,omitempty"` 25 | PacketEncoding string `yaml:"packet-encoding,omitempty"` 26 | GlobalPadding bool `yaml:"global-padding,omitempty"` 27 | AuthenticatedLength bool `yaml:"authenticated-length,omitempty"` 28 | ClientFingerprint string `yaml:"client-fingerprint,omitempty"` 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: docker 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | prepare-and-build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@v4 14 | 15 | - name: Docker meta 16 | id: meta 17 | uses: docker/metadata-action@v5 18 | with: 19 | images: | 20 | nite07/sub2clash 21 | ghcr.io/bestnite/sub2clash 22 | tags: | 23 | type=semver,pattern={{version}} 24 | type=semver,pattern={{major}}.{{minor}} 25 | type=semver,pattern={{major}} 26 | type=match,pattern=(alpha|beta|rc),group=1 27 | 28 | - name: Set up QEMU 29 | uses: docker/setup-qemu-action@v3 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Login to Docker Hub 35 | uses: docker/login-action@v3 36 | with: 37 | username: ${{ secrets.DOCKERHUB_USERNAME }} 38 | password: ${{ secrets.DOCKERHUB_TOKEN }} 39 | 40 | - name: Login to GHCR 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ghcr.io 44 | username: ${{ github.repository_owner }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: Build and push 48 | uses: docker/build-push-action@v6 49 | with: 50 | build-args: | 51 | "version=${{ github.ref_name }}" 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | platforms: linux/amd64,linux/arm64 56 | file: Containerfile 57 | 58 | -------------------------------------------------------------------------------- /model/proxy/hysteria.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | // https://github.com/MetaCubeX/mihomo/blob/Meta/adapter/outbound/hysteria.go 4 | type Hysteria struct { 5 | Server string `yaml:"server"` 6 | Port IntOrString `yaml:"port,omitempty"` 7 | Ports string `yaml:"ports,omitempty"` 8 | Protocol string `yaml:"protocol,omitempty"` 9 | ObfsProtocol string `yaml:"obfs-protocol,omitempty"` // compatible with Stash 10 | Up string `yaml:"up"` 11 | UpSpeed int `yaml:"up-speed,omitempty"` // compatible with Stash 12 | Down string `yaml:"down"` 13 | DownSpeed int `yaml:"down-speed,omitempty"` // compatible with Stash 14 | Auth string `yaml:"auth,omitempty"` 15 | AuthString string `yaml:"auth-str,omitempty"` 16 | Obfs string `yaml:"obfs,omitempty"` 17 | SNI string `yaml:"sni,omitempty"` 18 | ECHOpts ECHOptions `yaml:"ech-opts,omitempty"` 19 | SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` 20 | Fingerprint string `yaml:"fingerprint,omitempty"` 21 | ALPN []string `yaml:"alpn,omitempty"` 22 | CustomCA string `yaml:"ca,omitempty"` 23 | CustomCAString string `yaml:"ca-str,omitempty"` 24 | ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"` 25 | ReceiveWindow int `yaml:"recv-window,omitempty"` 26 | DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"` 27 | FastOpen bool `yaml:"fast-open,omitempty"` 28 | HopInterval int `yaml:"hop-interval,omitempty"` 29 | } 30 | -------------------------------------------------------------------------------- /server/handler/convert.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | _ "embed" 5 | "net/http" 6 | 7 | "github.com/bestnite/sub2clash/common" 8 | "github.com/bestnite/sub2clash/config" 9 | "github.com/bestnite/sub2clash/model" 10 | M "github.com/bestnite/sub2clash/model" 11 | 12 | "github.com/gin-gonic/gin" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | func ConvertHandler() func(c *gin.Context) { 17 | return func(c *gin.Context) { 18 | query, err := M.ParseConvertQuery(c) 19 | if err != nil { 20 | c.String(http.StatusBadRequest, err.Error()) 21 | return 22 | } 23 | template := "" 24 | switch query.ClashType { 25 | case model.Clash: 26 | template = config.GlobalConfig.ClashTemplate 27 | case model.ClashMeta: 28 | template = config.GlobalConfig.MetaTemplate 29 | } 30 | sub, err := common.BuildSub(query.ClashType, query, template, config.GlobalConfig.CacheExpire, config.GlobalConfig.RequestRetryTimes) 31 | if err != nil { 32 | c.String(http.StatusInternalServerError, err.Error()) 33 | return 34 | } 35 | 36 | if len(query.Subs) == 1 { 37 | userInfoHeader, err := common.FetchSubscriptionUserInfo(query.Subs[0], "clash", config.GlobalConfig.RequestRetryTimes) 38 | if err == nil { 39 | c.Header("subscription-userinfo", userInfoHeader) 40 | } 41 | } 42 | 43 | if query.NodeListMode { 44 | nodelist := M.NodeList{} 45 | nodelist.Proxy = sub.Proxy 46 | marshal, err := yaml.Marshal(nodelist) 47 | if err != nil { 48 | c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error()) 49 | return 50 | } 51 | c.String(http.StatusOK, string(marshal)) 52 | return 53 | } 54 | marshal, err := yaml.Marshal(sub) 55 | if err != nil { 56 | c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error()) 57 | return 58 | } 59 | c.String(http.StatusOK, string(marshal)) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /parser/common.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | "unicode/utf8" 8 | 9 | P "github.com/bestnite/sub2clash/model/proxy" 10 | "github.com/bestnite/sub2clash/utils" 11 | ) 12 | 13 | func hasPrefix(proxy string, prefixes []string) bool { 14 | hasPrefix := false 15 | for _, prefix := range prefixes { 16 | if strings.HasPrefix(proxy, prefix) { 17 | hasPrefix = true 18 | break 19 | } 20 | } 21 | return hasPrefix 22 | } 23 | 24 | func ParsePort(portStr string) (int, error) { 25 | port, err := strconv.Atoi(portStr) 26 | 27 | if err != nil { 28 | return 0, err 29 | } 30 | if port < 1 || port > 65535 { 31 | return 0, errors.New("invaild port range") 32 | } 33 | return port, nil 34 | } 35 | 36 | // isLikelyBase64 不严格判断是否是合法的 Base64, 很多分享链接不符合 Base64 规范 37 | func isLikelyBase64(s string) bool { 38 | if strings.TrimSpace(s) == "" { 39 | return false 40 | } 41 | 42 | if !strings.Contains(strings.TrimSuffix(s, "="), "=") { 43 | s = strings.TrimSuffix(s, "=") 44 | chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" 45 | for _, c := range s { 46 | if !strings.ContainsRune(chars, c) { 47 | return false 48 | } 49 | } 50 | } 51 | 52 | decoded, err := utils.DecodeBase64(s, true) 53 | if err != nil { 54 | return false 55 | } 56 | if !utf8.ValidString(decoded) { 57 | return false 58 | } 59 | 60 | return true 61 | } 62 | 63 | type ParseConfig struct { 64 | UseUDP bool 65 | } 66 | 67 | func ParseProxies(config ParseConfig, proxies ...string) ([]P.Proxy, error) { 68 | var result []P.Proxy 69 | for _, proxy := range proxies { 70 | if proxy != "" { 71 | var proxyItem P.Proxy 72 | var err error 73 | 74 | proxyItem, err = ParseProxyWithRegistry(config, proxy) 75 | if err != nil { 76 | return nil, err 77 | } 78 | result = append(result, proxyItem) 79 | } 80 | } 81 | return result, nil 82 | } 83 | -------------------------------------------------------------------------------- /model/proxy/tuic.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | type Tuic struct { 4 | Server string `proxy:"server"` 5 | Port int `proxy:"port"` 6 | Token string `proxy:"token,omitempty"` 7 | UUID string `proxy:"uuid,omitempty"` 8 | Password string `proxy:"password,omitempty"` 9 | Ip string `proxy:"ip,omitempty"` 10 | HeartbeatInterval int `proxy:"heartbeat-interval,omitempty"` 11 | ALPN []string `proxy:"alpn,omitempty"` 12 | ReduceRtt bool `proxy:"reduce-rtt,omitempty"` 13 | RequestTimeout int `proxy:"request-timeout,omitempty"` 14 | UdpRelayMode string `proxy:"udp-relay-mode,omitempty"` 15 | CongestionController string `proxy:"congestion-controller,omitempty"` 16 | DisableSni bool `proxy:"disable-sni,omitempty"` 17 | MaxUdpRelayPacketSize int `proxy:"max-udp-relay-packet-size,omitempty"` 18 | 19 | FastOpen bool `proxy:"fast-open,omitempty"` 20 | MaxOpenStreams int `proxy:"max-open-streams,omitempty"` 21 | CWND int `proxy:"cwnd,omitempty"` 22 | SkipCertVerify bool `proxy:"skip-cert-verify,omitempty"` 23 | Fingerprint string `proxy:"fingerprint,omitempty"` 24 | Certificate string `proxy:"certificate,omitempty"` 25 | PrivateKey string `proxy:"private-key,omitempty"` 26 | ReceiveWindowConn int `proxy:"recv-window-conn,omitempty"` 27 | ReceiveWindow int `proxy:"recv-window,omitempty"` 28 | DisableMTUDiscovery bool `proxy:"disable-mtu-discovery,omitempty"` 29 | MaxDatagramFrameSize int `proxy:"max-datagram-frame-size,omitempty"` 30 | SNI string `proxy:"sni,omitempty"` 31 | ECHOpts ECHOptions `proxy:"ech-opts,omitempty"` 32 | 33 | UDPOverStream bool `proxy:"udp-over-stream,omitempty"` 34 | UDPOverStreamVersion int `proxy:"udp-over-stream-version,omitempty"` 35 | } 36 | -------------------------------------------------------------------------------- /parser/registry.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "sync" 7 | 8 | P "github.com/bestnite/sub2clash/model/proxy" 9 | ) 10 | 11 | type ProxyParser interface { 12 | Parse(config ParseConfig, proxy string) (P.Proxy, error) 13 | GetPrefixes() []string 14 | GetType() string 15 | SupportClash() bool 16 | SupportMeta() bool 17 | } 18 | 19 | type parserRegistry struct { 20 | mu sync.RWMutex 21 | parsers map[string]ProxyParser 22 | } 23 | 24 | var registry = &parserRegistry{ 25 | parsers: make(map[string]ProxyParser), 26 | } 27 | 28 | func RegisterParser(parser ProxyParser) { 29 | registry.mu.Lock() 30 | defer registry.mu.Unlock() 31 | 32 | for _, prefix := range parser.GetPrefixes() { 33 | registry.parsers[prefix] = parser 34 | } 35 | } 36 | 37 | func GetParser(prefix string) (ProxyParser, bool) { 38 | registry.mu.RLock() 39 | defer registry.mu.RUnlock() 40 | 41 | parser, exists := registry.parsers[prefix] 42 | return parser, exists 43 | } 44 | 45 | func GetAllParsers() map[string]ProxyParser { 46 | registry.mu.RLock() 47 | defer registry.mu.RUnlock() 48 | 49 | result := make(map[string]ProxyParser) 50 | for k, v := range registry.parsers { 51 | result[k] = v 52 | } 53 | return result 54 | } 55 | 56 | func GetAllPrefixes() []string { 57 | registry.mu.RLock() 58 | defer registry.mu.RUnlock() 59 | 60 | prefixes := make([]string, 0, len(registry.parsers)) 61 | for prefix := range registry.parsers { 62 | prefixes = append(prefixes, prefix) 63 | } 64 | return prefixes 65 | } 66 | 67 | func ParseProxyWithRegistry(config ParseConfig, proxy string) (P.Proxy, error) { 68 | proxy = strings.TrimSpace(proxy) 69 | if proxy == "" { 70 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "empty proxy string") 71 | } 72 | 73 | for prefix, parser := range registry.parsers { 74 | if strings.HasPrefix(proxy, prefix) { 75 | return parser.Parse(config, proxy) 76 | } 77 | } 78 | 79 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, "unsupported protocol") 80 | } 81 | -------------------------------------------------------------------------------- /parser/anytls.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | P "github.com/bestnite/sub2clash/model/proxy" 9 | ) 10 | 11 | type AnytlsParser struct{} 12 | 13 | func (p *AnytlsParser) SupportClash() bool { 14 | return false 15 | } 16 | 17 | func (p *AnytlsParser) SupportMeta() bool { 18 | return true 19 | } 20 | 21 | func (p *AnytlsParser) GetPrefixes() []string { 22 | return []string{"anytls://"} 23 | } 24 | 25 | func (p *AnytlsParser) GetType() string { 26 | return "anytls" 27 | } 28 | 29 | func (p *AnytlsParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) { 30 | if !hasPrefix(proxy, p.GetPrefixes()) { 31 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy) 32 | } 33 | 34 | link, err := url.Parse(proxy) 35 | if err != nil { 36 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 37 | } 38 | 39 | username := link.User.Username() 40 | password, exist := link.User.Password() 41 | if !exist { 42 | password = username 43 | } 44 | 45 | query := link.Query() 46 | server := link.Hostname() 47 | if server == "" { 48 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host") 49 | } 50 | portStr := link.Port() 51 | if portStr == "" { 52 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port") 53 | } 54 | port, err := ParsePort(portStr) 55 | if err != nil { 56 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error()) 57 | } 58 | insecure, sni := query.Get("insecure"), query.Get("sni") 59 | insecureBool := insecure == "1" 60 | remarks := link.Fragment 61 | if remarks == "" { 62 | remarks = fmt.Sprintf("%s:%s", server, portStr) 63 | } 64 | remarks = strings.TrimSpace(remarks) 65 | 66 | result := P.Proxy{ 67 | Type: p.GetType(), 68 | Name: remarks, 69 | Anytls: P.Anytls{ 70 | Server: server, 71 | Port: P.IntOrString(port), 72 | Password: password, 73 | SNI: sni, 74 | SkipCertVerify: insecureBool, 75 | UDP: config.UseUDP, 76 | }, 77 | } 78 | return result, nil 79 | } 80 | 81 | func init() { 82 | RegisterParser(&AnytlsParser{}) 83 | } 84 | -------------------------------------------------------------------------------- /common/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/bestnite/sub2clash/common" 11 | "github.com/bestnite/sub2clash/model" 12 | "github.com/glebarez/sqlite" 13 | "gorm.io/gorm" 14 | "gorm.io/gorm/logger" 15 | ) 16 | 17 | type Database struct { 18 | db *gorm.DB 19 | } 20 | 21 | func ConnectDB() (*Database, error) { 22 | path := filepath.Join("data", "sub2clash.db") 23 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 24 | return nil, err 25 | } 26 | db, err := gorm.Open(sqlite.Open(path), &gorm.Config{ 27 | Logger: logger.Discard, 28 | }) 29 | if err != nil { 30 | return nil, common.NewDatabaseConnectError(err) 31 | } 32 | 33 | if err = db.AutoMigrate(&model.ShortLink{}); err != nil { 34 | return nil, err 35 | } 36 | 37 | return &Database{ 38 | db: db, 39 | }, nil 40 | } 41 | 42 | func (d *Database) FindShortLinkByID(id string) (model.ShortLink, error) { 43 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 44 | defer cancel() 45 | return gorm.G[model.ShortLink](d.db).Where("id = ?", id).First(ctx) 46 | } 47 | 48 | func (d *Database) CreateShortLink(shortLink *model.ShortLink) error { 49 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 50 | defer cancel() 51 | return gorm.G[model.ShortLink](d.db).Create(ctx, shortLink) 52 | } 53 | 54 | func (d *Database) UpdataShortLink(id string, name string, value any) error { 55 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 56 | defer cancel() 57 | _, err := gorm.G[model.ShortLink](d.db).Where("id = ?", id).Update(ctx, name, value) 58 | return err 59 | } 60 | 61 | func (d *Database) CheckShortLinkIDExists(id string) (bool, error) { 62 | _, err := d.FindShortLinkByID(id) 63 | if err != nil { 64 | if errors.Is(err, gorm.ErrRecordNotFound) { 65 | return false, nil 66 | } 67 | return false, err 68 | } 69 | return true, nil 70 | } 71 | 72 | func (d *Database) DeleteShortLink(id string) error { 73 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 74 | defer cancel() 75 | _, err := gorm.G[model.ShortLink](d.db).Where("id = ?", id).Delete(ctx) 76 | return err 77 | } 78 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/spf13/viper" 7 | ) 8 | 9 | type Config struct { 10 | Address string `mapstructure:"address"` 11 | MetaTemplate string `mapstructure:"meta_template"` 12 | ClashTemplate string `mapstructure:"clash_template"` 13 | RequestRetryTimes int `mapstructure:"request_retry_times"` 14 | RequestMaxFileSize int64 `mapstructure:"request_max_file_size"` 15 | CacheExpire int64 `mapstructure:"cache_expire"` 16 | LogLevel string `mapstructure:"log_level"` 17 | ShortLinkLength int `mapstructure:"short_link_length"` 18 | } 19 | 20 | var GlobalConfig *Config 21 | var Dev string 22 | 23 | func LoadConfig() error { 24 | v := viper.New() 25 | 26 | // 添加配置文件搜索路径 27 | v.AddConfigPath(".") 28 | v.AddConfigPath("./config") 29 | v.AddConfigPath("/etc/sub2clash/") 30 | 31 | // 设置默认值 32 | setDefaults(v) 33 | 34 | // 设置环境变量前缀和自动绑定 35 | v.SetEnvPrefix("SUB2CLASH") 36 | v.AutomaticEnv() 37 | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 38 | 39 | // 尝试按优先级加载不同格式的配置文件 40 | configLoaded := false 41 | configNames := []string{"config", "sub2clash"} 42 | configExts := []string{"yaml", "yml", "json"} 43 | 44 | for _, name := range configNames { 45 | for _, ext := range configExts { 46 | v.SetConfigName(name) 47 | v.SetConfigType(ext) 48 | if err := v.ReadInConfig(); err == nil { 49 | configLoaded = true 50 | break 51 | } 52 | } 53 | if configLoaded { 54 | break 55 | } 56 | } 57 | 58 | // 将配置解析到结构体 59 | GlobalConfig = &Config{} 60 | if err := v.Unmarshal(GlobalConfig); err != nil { 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func setDefaults(v *viper.Viper) { 68 | v.SetDefault("address", "0.0.0.0:8011") 69 | v.SetDefault("meta_template", "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_meta.yaml") 70 | v.SetDefault("clash_template", "https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_clash.yaml") 71 | v.SetDefault("request_retry_times", 3) 72 | v.SetDefault("request_max_file_size", 1024*1024*1) 73 | v.SetDefault("cache_expire", 60*5) 74 | v.SetDefault("log_level", "info") 75 | v.SetDefault("short_link_length", 6) 76 | } 77 | -------------------------------------------------------------------------------- /parser/hysteria2.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | P "github.com/bestnite/sub2clash/model/proxy" 9 | ) 10 | 11 | type Hysteria2Parser struct{} 12 | 13 | func (p *Hysteria2Parser) SupportClash() bool { 14 | return false 15 | } 16 | 17 | func (p *Hysteria2Parser) SupportMeta() bool { 18 | return true 19 | } 20 | 21 | func (p *Hysteria2Parser) GetPrefixes() []string { 22 | return []string{"hysteria2://", "hy2://"} 23 | } 24 | 25 | func (p *Hysteria2Parser) GetType() string { 26 | return "hysteria2" 27 | } 28 | 29 | func (p *Hysteria2Parser) Parse(config ParseConfig, proxy string) (P.Proxy, error) { 30 | if !hasPrefix(proxy, p.GetPrefixes()) { 31 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy) 32 | } 33 | 34 | link, err := url.Parse(proxy) 35 | if err != nil { 36 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 37 | } 38 | 39 | username := link.User.Username() 40 | password, exist := link.User.Password() 41 | if !exist { 42 | password = username 43 | } 44 | 45 | query := link.Query() 46 | server := link.Hostname() 47 | if server == "" { 48 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host") 49 | } 50 | portStr := link.Port() 51 | if portStr == "" { 52 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port") 53 | } 54 | port, err := ParsePort(portStr) 55 | if err != nil { 56 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error()) 57 | } 58 | obfs, obfsPassword, insecure, sni := query.Get("obfs"), query.Get("obfs-password"), query.Get("insecure"), query.Get("sni") 59 | insecureBool := insecure == "1" 60 | remarks := link.Fragment 61 | if remarks == "" { 62 | remarks = fmt.Sprintf("%s:%s", server, portStr) 63 | } 64 | remarks = strings.TrimSpace(remarks) 65 | 66 | result := P.Proxy{ 67 | Type: p.GetType(), 68 | Name: remarks, 69 | Hysteria2: P.Hysteria2{ 70 | Server: server, 71 | Port: P.IntOrString(port), 72 | Password: password, 73 | Obfs: obfs, 74 | ObfsPassword: obfsPassword, 75 | SNI: sni, 76 | SkipCertVerify: insecureBool, 77 | }, 78 | } 79 | return result, nil 80 | } 81 | 82 | func init() { 83 | RegisterParser(&Hysteria2Parser{}) 84 | } 85 | -------------------------------------------------------------------------------- /parser/socks.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | P "github.com/bestnite/sub2clash/model/proxy" 9 | "github.com/bestnite/sub2clash/utils" 10 | ) 11 | 12 | type SocksParser struct{} 13 | 14 | func (p *SocksParser) SupportClash() bool { 15 | return true 16 | } 17 | func (p *SocksParser) SupportMeta() bool { 18 | return true 19 | } 20 | 21 | func (p *SocksParser) GetPrefixes() []string { 22 | return []string{"socks://", "socks5://"} 23 | } 24 | 25 | func (p *SocksParser) GetType() string { 26 | return "socks5" 27 | } 28 | 29 | func (p *SocksParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) { 30 | if !hasPrefix(proxy, p.GetPrefixes()) { 31 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy) 32 | } 33 | 34 | link, err := url.Parse(proxy) 35 | if err != nil { 36 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 37 | } 38 | server := link.Hostname() 39 | if server == "" { 40 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host") 41 | } 42 | portStr := link.Port() 43 | if portStr == "" { 44 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port") 45 | } 46 | port, err := ParsePort(portStr) 47 | if err != nil { 48 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error()) 49 | } 50 | 51 | remarks := link.Fragment 52 | if remarks == "" { 53 | remarks = fmt.Sprintf("%s:%s", server, portStr) 54 | } 55 | remarks = strings.TrimSpace(remarks) 56 | 57 | var username, password string 58 | 59 | username = link.User.Username() 60 | password, hasPassword := link.User.Password() 61 | 62 | if !hasPassword && isLikelyBase64(username) { 63 | decodedStr, err := utils.DecodeBase64(username, true) 64 | if err == nil { 65 | usernameAndPassword := strings.SplitN(decodedStr, ":", 2) 66 | if len(usernameAndPassword) == 2 { 67 | username = usernameAndPassword[0] 68 | password = usernameAndPassword[1] 69 | } else { 70 | username = decodedStr 71 | } 72 | } 73 | } 74 | 75 | tls, udp := link.Query().Get("tls"), link.Query().Get("udp") 76 | 77 | return P.Proxy{ 78 | Type: p.GetType(), 79 | Name: remarks, 80 | Socks: P.Socks{ 81 | Server: server, 82 | Port: P.IntOrString(port), 83 | UserName: username, 84 | Password: password, 85 | TLS: tls == "true", 86 | UDP: udp == "true", 87 | }, 88 | }, nil 89 | } 90 | 91 | func init() { 92 | RegisterParser(&SocksParser{}) 93 | } 94 | -------------------------------------------------------------------------------- /model/group.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "golang.org/x/text/collate" 5 | "golang.org/x/text/language" 6 | ) 7 | 8 | type ProxyGroup struct { 9 | Type string `yaml:"type,omitempty"` 10 | Name string `yaml:"name,omitempty"` 11 | Proxies []string `yaml:"proxies,omitempty"` 12 | IsCountryGrop bool `yaml:"-"` 13 | Url string `yaml:"url,omitempty"` 14 | Interval int `yaml:"interval,omitempty"` 15 | Tolerance int `yaml:"tolerance,omitempty"` 16 | Lazy bool `yaml:"lazy"` 17 | Size int `yaml:"-"` 18 | DisableUDP bool `yaml:"disable-udp,omitempty"` 19 | Strategy string `yaml:"strategy,omitempty"` 20 | Icon string `yaml:"icon,omitempty"` 21 | Timeout int `yaml:"timeout,omitempty"` 22 | Use []string `yaml:"use,omitempty"` 23 | InterfaceName string `yaml:"interface-name,omitempty"` 24 | RoutingMark int `yaml:"routing-mark,omitempty"` 25 | IncludeAll bool `yaml:"include-all,omitempty"` 26 | IncludeAllProxies bool `yaml:"include-all-proxies,omitempty"` 27 | IncludeAllProviders bool `yaml:"include-all-providers,omitempty"` 28 | Filter string `yaml:"filter,omitempty"` 29 | ExcludeFilter string `yaml:"exclude-filter,omitempty"` 30 | ExpectedStatus int `yaml:"expected-status,omitempty"` 31 | Hidden bool `yaml:"hidden,omitempty"` 32 | } 33 | 34 | type ProxyGroupsSortByName []ProxyGroup 35 | type ProxyGroupsSortBySize []ProxyGroup 36 | 37 | func (p ProxyGroupsSortByName) Len() int { 38 | return len(p) 39 | } 40 | func (p ProxyGroupsSortBySize) Len() int { 41 | return len(p) 42 | } 43 | 44 | func (p ProxyGroupsSortByName) Less(i, j int) bool { 45 | 46 | tags := []language.Tag{ 47 | language.English, 48 | language.Chinese, 49 | } 50 | matcher := language.NewMatcher(tags) 51 | 52 | bestMatch, _, _ := matcher.Match(language.Make("zh")) 53 | 54 | c := collate.New(bestMatch) 55 | return c.CompareString(p[i].Name, p[j].Name) < 0 56 | } 57 | 58 | func (p ProxyGroupsSortBySize) Less(i, j int) bool { 59 | if p[i].Size == p[j].Size { 60 | return p[i].Name < p[j].Name 61 | } 62 | return p[i].Size < p[j].Size 63 | } 64 | 65 | func (p ProxyGroupsSortByName) Swap(i, j int) { 66 | p[i], p[j] = p[j], p[i] 67 | } 68 | func (p ProxyGroupsSortBySize) Swap(i, j int) { 69 | p[i], p[j] = p[j], p[i] 70 | } 71 | -------------------------------------------------------------------------------- /common/proxy.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/bestnite/sub2clash/model" 7 | "github.com/bestnite/sub2clash/model/proxy" 8 | ) 9 | 10 | func GetContryName(countryKey string) string { 11 | 12 | countryMaps := []map[string]string{ 13 | model.CountryFlag, 14 | model.CountryChineseName, 15 | model.CountryISO, 16 | model.CountryEnglishName, 17 | } 18 | 19 | for i, countryMap := range countryMaps { 20 | if i == 2 { 21 | 22 | splitChars := []string{"-", "_", " "} 23 | key := make([]string, 0) 24 | for _, splitChar := range splitChars { 25 | slic := strings.Split(countryKey, splitChar) 26 | for _, v := range slic { 27 | if len(v) == 2 { 28 | key = append(key, v) 29 | } 30 | } 31 | } 32 | 33 | for _, v := range key { 34 | 35 | if country, ok := countryMap[strings.ToUpper(v)]; ok { 36 | return country 37 | } 38 | } 39 | } 40 | for k, v := range countryMap { 41 | if strings.Contains(countryKey, k) { 42 | return v 43 | } 44 | } 45 | } 46 | return "其他地区" 47 | } 48 | 49 | func AddProxy( 50 | sub *model.Subscription, autotest bool, 51 | lazy bool, clashType model.ClashType, proxies ...proxy.Proxy, 52 | ) { 53 | proxyTypes := model.GetSupportProxyTypes(clashType) 54 | 55 | for _, proxy := range proxies { 56 | if !proxyTypes[proxy.Type] { 57 | continue 58 | } 59 | sub.Proxy = append(sub.Proxy, proxy) 60 | haveProxyGroup := false 61 | countryName := GetContryName(proxy.Name) 62 | for i := range sub.ProxyGroup { 63 | group := &sub.ProxyGroup[i] 64 | if group.Name == countryName { 65 | group.Proxies = append(group.Proxies, proxy.Name) 66 | group.Size++ 67 | haveProxyGroup = true 68 | } 69 | } 70 | if !haveProxyGroup { 71 | var newGroup model.ProxyGroup 72 | if !autotest { 73 | newGroup = model.ProxyGroup{ 74 | Name: countryName, 75 | Type: "select", 76 | Proxies: []string{proxy.Name}, 77 | IsCountryGrop: true, 78 | Size: 1, 79 | } 80 | } else { 81 | newGroup = model.ProxyGroup{ 82 | Name: countryName, 83 | Type: "url-test", 84 | Proxies: []string{proxy.Name}, 85 | IsCountryGrop: true, 86 | Url: "http://www.gstatic.com/generate_204", 87 | Interval: 300, 88 | Tolerance: 50, 89 | Lazy: lazy, 90 | Size: 1, 91 | } 92 | } 93 | sub.ProxyGroup = append(sub.ProxyGroup, newGroup) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /parser/hysteria.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | "strings" 8 | 9 | P "github.com/bestnite/sub2clash/model/proxy" 10 | ) 11 | 12 | type HysteriaParser struct{} 13 | 14 | func (p *HysteriaParser) SupportClash() bool { 15 | return false 16 | } 17 | 18 | func (p *HysteriaParser) SupportMeta() bool { 19 | return true 20 | } 21 | 22 | func (p *HysteriaParser) GetPrefixes() []string { 23 | return []string{"hysteria://"} 24 | } 25 | 26 | func (p *HysteriaParser) GetType() string { 27 | return "hysteria" 28 | } 29 | 30 | func (p *HysteriaParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) { 31 | if !hasPrefix(proxy, p.GetPrefixes()) { 32 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy) 33 | } 34 | 35 | link, err := url.Parse(proxy) 36 | if err != nil { 37 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 38 | } 39 | server := link.Hostname() 40 | if server == "" { 41 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host") 42 | } 43 | 44 | portStr := link.Port() 45 | if portStr == "" { 46 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port") 47 | } 48 | 49 | port, err := ParsePort(portStr) 50 | if err != nil { 51 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error()) 52 | } 53 | 54 | query := link.Query() 55 | 56 | 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") 57 | insecureBool, err := strconv.ParseBool(insecure) 58 | if err != nil { 59 | insecureBool = false 60 | } 61 | 62 | var alpn []string 63 | alpnStr = strings.TrimSpace(alpnStr) 64 | if alpnStr != "" { 65 | alpn = strings.Split(alpnStr, ",") 66 | } 67 | 68 | remarks := link.Fragment 69 | if remarks == "" { 70 | remarks = fmt.Sprintf("%s:%s", server, portStr) 71 | } 72 | remarks = strings.TrimSpace(remarks) 73 | 74 | result := P.Proxy{ 75 | Type: p.GetType(), 76 | Name: remarks, 77 | Hysteria: P.Hysteria{ 78 | Server: server, 79 | Port: P.IntOrString(port), 80 | Up: upmbps, 81 | Down: downmbps, 82 | Auth: auth, 83 | AuthString: auth_str, 84 | Obfs: obfs, 85 | SkipCertVerify: insecureBool, 86 | ALPN: alpn, 87 | Protocol: protocol, 88 | }, 89 | } 90 | return result, nil 91 | } 92 | 93 | func init() { 94 | RegisterParser(&HysteriaParser{}) 95 | } 96 | -------------------------------------------------------------------------------- /server/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "daisyui"; 3 | 4 | @plugin "daisyui/theme" { 5 | name: "light"; 6 | default: false; 7 | prefersdark: false; 8 | color-scheme: "light"; 9 | --color-base-100: oklch(98% 0.001 106.423); 10 | --color-base-200: oklch(97% 0.001 106.424); 11 | --color-base-300: oklch(92% 0.003 48.717); 12 | --color-base-content: oklch(21% 0.006 56.043); 13 | --color-primary: oklch(54% 0.281 293.009); 14 | --color-primary-content: oklch(96% 0.016 293.756); 15 | --color-secondary: oklch(57% 0.245 27.325); 16 | --color-secondary-content: oklch(97% 0.013 17.38); 17 | --color-accent: oklch(59% 0.249 0.584); 18 | --color-accent-content: oklch(97% 0.014 343.198); 19 | --color-neutral: oklch(14% 0.004 49.25); 20 | --color-neutral-content: oklch(98% 0.001 106.423); 21 | --color-info: oklch(78% 0.154 211.53); 22 | --color-info-content: oklch(30% 0.056 229.695); 23 | --color-success: oklch(79% 0.209 151.711); 24 | --color-success-content: oklch(26% 0.065 152.934); 25 | --color-warning: oklch(82% 0.189 84.429); 26 | --color-warning-content: oklch(27% 0.077 45.635); 27 | --color-error: oklch(71% 0.194 13.428); 28 | --color-error-content: oklch(27% 0.105 12.094); 29 | --radius-selector: 1rem; 30 | --radius-field: 1rem; 31 | --radius-box: 1rem; 32 | --size-selector: 0.25rem; 33 | --size-field: 0.25rem; 34 | --border: 1.5px; 35 | --depth: 0; 36 | --noise: 0; 37 | } 38 | 39 | @plugin "daisyui/theme" { 40 | name: "dark"; 41 | default: true; 42 | prefersdark: true; 43 | color-scheme: "dark"; 44 | --color-base-100: oklch(21% 0.006 285.885); 45 | --color-base-200: oklch(21% 0.006 285.885); 46 | --color-base-300: oklch(27% 0.006 286.033); 47 | --color-base-content: oklch(96% 0.001 286.375); 48 | --color-primary: oklch(55% 0.288 302.321); 49 | --color-primary-content: oklch(97% 0.014 308.299); 50 | --color-secondary: oklch(44% 0.03 256.802); 51 | --color-secondary-content: oklch(98% 0.002 247.839); 52 | --color-accent: oklch(59% 0.249 0.584); 53 | --color-accent-content: oklch(97% 0.014 343.198); 54 | --color-neutral: oklch(37% 0.013 285.805); 55 | --color-neutral-content: oklch(98% 0 0); 56 | --color-info: oklch(54% 0.245 262.881); 57 | --color-info-content: oklch(97% 0.014 254.604); 58 | --color-success: oklch(64% 0.2 131.684); 59 | --color-success-content: oklch(98% 0.031 120.757); 60 | --color-warning: oklch(66% 0.179 58.318); 61 | --color-warning-content: oklch(98% 0.022 95.277); 62 | --color-error: oklch(58% 0.253 17.585); 63 | --color-error-content: oklch(96% 0.015 12.422); 64 | --radius-selector: 1rem; 65 | --radius-field: 1rem; 66 | --radius-box: 1rem; 67 | --size-selector: 0.25rem; 68 | --size-field: 0.25rem; 69 | --border: 1px; 70 | --depth: 0; 71 | --noise: 1; 72 | } 73 | -------------------------------------------------------------------------------- /templates/template_meta.yaml: -------------------------------------------------------------------------------- 1 | mixed-port: 7890 2 | allow-lan: true 3 | mode: Rule 4 | log-level: info 5 | proxies: 6 | proxy-groups: 7 | - name: 节点选择 8 | type: select 9 | proxies: 10 | - 11 | - 手动切换 12 | - DIRECT 13 | - name: 手动切换 14 | type: select 15 | proxies: 16 | - 17 | - name: 游戏平台(中国) 18 | type: select 19 | proxies: 20 | - 节点选择 21 | - 22 | - 手动切换 23 | - DIRECT 24 | - name: 游戏平台(全球) 25 | type: select 26 | proxies: 27 | - 节点选择 28 | - 29 | - 手动切换 30 | - DIRECT 31 | - name: 巴哈姆特 32 | type: select 33 | proxies: 34 | - 节点选择 35 | - 36 | - 手动切换 37 | - DIRECT 38 | - name: 哔哩哔哩 39 | type: select 40 | proxies: 41 | - 节点选择 42 | - 43 | - 手动切换 44 | - DIRECT 45 | - name: Telegram 46 | type: select 47 | proxies: 48 | - 节点选择 49 | - 50 | - 手动切换 51 | - DIRECT 52 | - name: OpenAI 53 | type: select 54 | proxies: 55 | - 节点选择 56 | - 57 | - 手动切换 58 | - DIRECT 59 | - name: Youtube 60 | type: select 61 | proxies: 62 | - 节点选择 63 | - 64 | - 手动切换 65 | - DIRECT 66 | - name: Microsoft 67 | type: select 68 | proxies: 69 | - 节点选择 70 | - 71 | - 手动切换 72 | - DIRECT 73 | - name: Onedrive 74 | type: select 75 | proxies: 76 | - 节点选择 77 | - 78 | - 手动切换 79 | - DIRECT 80 | - name: Apple 81 | type: select 82 | proxies: 83 | - 节点选择 84 | - 85 | - 手动切换 86 | - DIRECT 87 | - name: Netflix 88 | type: select 89 | proxies: 90 | - 节点选择 91 | - 92 | - 手动切换 93 | - DIRECT 94 | - name: 广告拦截 95 | type: select 96 | proxies: 97 | - REJECT 98 | - DIRECT 99 | - name: 漏网之鱼 100 | type: select 101 | proxies: 102 | - 节点选择 103 | - 104 | - 手动切换 105 | - DIRECT 106 | rules: 107 | - GEOSITE,private,DIRECT,no-resolve 108 | - GEOIP,private,DIRECT 109 | - GEOSITE,category-ads-all,广告拦截 110 | - GEOSITE,microsoft,Microsoft 111 | - GEOSITE,apple,Apple 112 | - GEOSITE,netflix,Netflix 113 | - GEOIP,netflix,Netflix 114 | - GEOSITE,onedrive,Onedrive 115 | - GEOSITE,youtube,Youtube 116 | - GEOSITE,telegram,Telegram 117 | - GEOIP,telegram,Telegram 118 | - GEOSITE,openai,OpenAI 119 | - GEOSITE,bilibili,哔哩哔哩 120 | - GEOSITE,bahamut,巴哈姆特 121 | - GEOSITE,category-games@cn,游戏平台(中国) 122 | - GEOSITE,category-games,游戏平台(全球) 123 | - GEOSITE,geolocation-!cn,节点选择 124 | - GEOSITE,CN,DIRECT 125 | - GEOIP,CN,DIRECT 126 | - MATCH,漏网之鱼 127 | -------------------------------------------------------------------------------- /server/frontend/src/components/rule-input.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, unsafeCSS } from "lit"; 2 | import { customElement, state } from "lit/decorators.js"; 3 | import type { Rule } from "../interface"; 4 | import globalStyles from "../index.css?inline"; 5 | 6 | @customElement("rule-input") 7 | export class RuleInput extends LitElement { 8 | static styles = [unsafeCSS(globalStyles)]; 9 | 10 | _rules: Array = []; 11 | @state() 12 | set rules(value: Array) { 13 | this.dispatchEvent( 14 | new CustomEvent("change", { 15 | detail: value, 16 | }) 17 | ); 18 | this._rules = value; 19 | } 20 | get rules() { 21 | return this._rules; 22 | } 23 | render() { 24 | return html` 25 |
26 | 42 |
43 | 44 |
45 | ${this.rules?.map((_, i) => this.RuleTemplate(i))} 46 |
`; 47 | } 48 | 49 | RuleTemplate(index: number) { 50 | return html`
51 | 61 |
62 | 74 |
75 | 84 |
`; 85 | } 86 | } 87 | 88 | declare global { 89 | interface HTMLElementTagNameMap { 90 | "rule-input": RuleInput; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /parser/trojan.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | P "github.com/bestnite/sub2clash/model/proxy" 9 | ) 10 | 11 | type TrojanParser struct{} 12 | 13 | func (p *TrojanParser) SupportClash() bool { 14 | return true 15 | } 16 | 17 | func (p *TrojanParser) SupportMeta() bool { 18 | return true 19 | } 20 | 21 | func (p *TrojanParser) GetPrefixes() []string { 22 | return []string{"trojan://"} 23 | } 24 | 25 | func (p *TrojanParser) GetType() string { 26 | return "trojan" 27 | } 28 | 29 | func (p *TrojanParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) { 30 | if !hasPrefix(proxy, p.GetPrefixes()) { 31 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy) 32 | } 33 | 34 | link, err := url.Parse(proxy) 35 | if err != nil { 36 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 37 | } 38 | 39 | password := link.User.Username() 40 | server := link.Hostname() 41 | if server == "" { 42 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host") 43 | } 44 | portStr := link.Port() 45 | if portStr == "" { 46 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port") 47 | } 48 | 49 | port, err := ParsePort(portStr) 50 | if err != nil { 51 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error()) 52 | } 53 | 54 | remarks := link.Fragment 55 | if remarks == "" { 56 | remarks = fmt.Sprintf("%s:%s", server, portStr) 57 | } 58 | remarks = strings.TrimSpace(remarks) 59 | 60 | query := link.Query() 61 | 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") 62 | 63 | insecureBool := insecure == "1" 64 | result := P.Trojan{ 65 | Server: server, 66 | Port: P.IntOrString(port), 67 | Password: password, 68 | Network: network, 69 | UDP: udp == "true", 70 | SkipCertVerify: insecureBool, 71 | } 72 | 73 | var alpn []string 74 | if strings.Contains(alpnStr, ",") { 75 | alpn = strings.Split(alpnStr, ",") 76 | } else { 77 | alpn = nil 78 | } 79 | if len(alpn) > 0 { 80 | result.ALPN = alpn 81 | } 82 | 83 | if fp != "" { 84 | result.ClientFingerprint = fp 85 | } 86 | 87 | if sni != "" { 88 | result.SNI = sni 89 | } 90 | 91 | if security == "reality" { 92 | result.RealityOpts = P.RealityOptions{ 93 | PublicKey: pbk, 94 | ShortID: sid, 95 | } 96 | } 97 | 98 | if network == "ws" { 99 | result.Network = "ws" 100 | result.WSOpts = P.WSOptions{ 101 | Path: path, 102 | Headers: map[string]string{ 103 | "Host": host, 104 | }, 105 | } 106 | } 107 | 108 | if network == "grpc" { 109 | result.GrpcOpts = P.GrpcOptions{ 110 | GrpcServiceName: serviceName, 111 | } 112 | } 113 | 114 | return P.Proxy{ 115 | Type: p.GetType(), 116 | Name: remarks, 117 | Trojan: result, 118 | }, nil 119 | } 120 | 121 | func init() { 122 | RegisterParser(&TrojanParser{}) 123 | } 124 | -------------------------------------------------------------------------------- /parser/shadowsocks.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | P "github.com/bestnite/sub2clash/model/proxy" 9 | "github.com/bestnite/sub2clash/utils" 10 | ) 11 | 12 | // ShadowsocksParser Shadowsocks协议解析器 13 | type ShadowsocksParser struct{} 14 | 15 | func (p *ShadowsocksParser) SupportClash() bool { 16 | return true 17 | } 18 | 19 | func (p *ShadowsocksParser) SupportMeta() bool { 20 | return true 21 | } 22 | 23 | // GetPrefixes 返回支持的协议前缀 24 | func (p *ShadowsocksParser) GetPrefixes() []string { 25 | return []string{"ss://"} 26 | } 27 | 28 | // GetType 返回协议类型 29 | func (p *ShadowsocksParser) GetType() string { 30 | return "ss" 31 | } 32 | 33 | // Parse 解析Shadowsocks代理 34 | func (p *ShadowsocksParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) { 35 | if !hasPrefix(proxy, p.GetPrefixes()) { 36 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy) 37 | } 38 | 39 | if !strings.Contains(proxy, "@") { 40 | s := strings.SplitN(proxy, "#", 2) 41 | for _, prefix := range p.GetPrefixes() { 42 | if strings.HasPrefix(s[0], prefix) { 43 | s[0] = strings.TrimPrefix(s[0], prefix) 44 | break 45 | } 46 | } 47 | d, err := utils.DecodeBase64(s[0], true) 48 | if err != nil { 49 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 50 | } 51 | if len(s) == 2 { 52 | proxy = "ss://" + d + "#" + s[1] 53 | } else { 54 | proxy = "ss://" + d 55 | } 56 | } 57 | link, err := url.Parse(proxy) 58 | if err != nil { 59 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 60 | } 61 | 62 | server := link.Hostname() 63 | if server == "" { 64 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host") 65 | } 66 | 67 | portStr := link.Port() 68 | if portStr == "" { 69 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server port") 70 | } 71 | port, err := ParsePort(portStr) 72 | if err != nil { 73 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 74 | } 75 | 76 | method := link.User.Username() 77 | password, hasPassword := link.User.Password() 78 | 79 | if !hasPassword && isLikelyBase64(method) { 80 | decodedStr, err := utils.DecodeBase64(method, true) 81 | if err == nil { 82 | methodAndPass := strings.SplitN(decodedStr, ":", 2) 83 | if len(methodAndPass) == 2 { 84 | method = methodAndPass[0] 85 | password = methodAndPass[1] 86 | } else { 87 | method = decodedStr 88 | } 89 | } 90 | } 91 | if password != "" && isLikelyBase64(password) { 92 | password, err = utils.DecodeBase64(password, true) 93 | if err != nil { 94 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 95 | } 96 | } 97 | 98 | remarks := link.Fragment 99 | if remarks == "" { 100 | remarks = fmt.Sprintf("%s:%s", server, portStr) 101 | } 102 | remarks = strings.TrimSpace(remarks) 103 | 104 | result := P.Proxy{ 105 | Type: p.GetType(), 106 | Name: remarks, 107 | ShadowSocks: P.ShadowSocks{ 108 | Cipher: method, 109 | Password: password, 110 | Server: server, 111 | Port: P.IntOrString(port), 112 | UDP: config.UseUDP, 113 | }, 114 | } 115 | return result, nil 116 | } 117 | 118 | // 注册解析器 119 | func init() { 120 | RegisterParser(&ShadowsocksParser{}) 121 | } 122 | -------------------------------------------------------------------------------- /server/frontend/src/components/rename-input.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, unsafeCSS } from "lit"; 2 | import { customElement, state } from "lit/decorators.js"; 3 | import globalStyles from "../index.css?inline"; 4 | import type { Rename } from "../interface"; 5 | 6 | @customElement("rename-input") 7 | export class RenameInput extends LitElement { 8 | static styles = [unsafeCSS(globalStyles)]; 9 | 10 | private _replaceArray: Array = []; 11 | 12 | @state() 13 | set replaceArray(value: Array) { 14 | this._replaceArray = value; 15 | let updatedReplaceMap: { [key: string]: string } = {}; 16 | value.forEach((e) => { 17 | updatedReplaceMap[e.old] = e.new; 18 | }); 19 | this.dispatchEvent( 20 | new CustomEvent("change", { 21 | detail: updatedReplaceMap, 22 | }) 23 | ); 24 | } 25 | 26 | get replaceArray(): Array { 27 | return this._replaceArray; 28 | } 29 | 30 | render() { 31 | return html` 32 |
33 | 46 |
47 | 48 |
49 | ${this.replaceArray.map((_, i) => this.RenameTemplate(i))} 50 |
`; 51 | } 52 | 53 | RenameTemplate(index: number) { 54 | const replaceItem = this.replaceArray[index]; 55 | return html`
56 | 69 | 82 | 93 |
`; 94 | } 95 | } 96 | 97 | declare global { 98 | interface HTMLElementTagNameMap { 99 | "rename-input": RenameInput; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /model/convert_config.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/bestnite/sub2clash/utils" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | type ConvertConfig struct { 14 | ClashType ClashType `json:"clashType" binding:"required"` 15 | Subs []string `json:"subscriptions" binding:""` 16 | Proxies []string `json:"proxies" binding:""` 17 | Refresh bool `json:"refresh" binding:""` 18 | Template string `json:"template" binding:""` 19 | RuleProviders []RuleProviderStruct `json:"ruleProviders" binding:""` 20 | Rules []RuleStruct `json:"rules" binding:""` 21 | AutoTest bool `json:"autoTest" binding:""` 22 | Lazy bool `json:"lazy" binding:""` 23 | Sort string `json:"sort" binding:""` 24 | Remove string `json:"remove" binding:""` 25 | Replace map[string]string `json:"replace" binding:""` 26 | NodeListMode bool `json:"nodeList" binding:""` 27 | IgnoreCountryGrooup bool `json:"ignoreCountryGroup" binding:""` 28 | UserAgent string `json:"userAgent" binding:""` 29 | UseUDP bool `json:"useUDP" binding:""` 30 | } 31 | 32 | type RuleProviderStruct struct { 33 | Behavior string `json:"behavior" binding:""` 34 | Url string `json:"url" binding:""` 35 | Group string `json:"group" binding:""` 36 | Prepend bool `json:"prepend" binding:""` 37 | Name string `json:"name" binding:""` 38 | } 39 | 40 | type RuleStruct struct { 41 | Rule string `json:"rule" binding:""` 42 | Prepend bool `json:"prepend" binding:""` 43 | } 44 | 45 | func ParseConvertQuery(c *gin.Context) (ConvertConfig, error) { 46 | config := c.Param("config") 47 | queryBytes, err := utils.DecodeBase64(config, true) 48 | if err != nil { 49 | return ConvertConfig{}, errors.New("参数错误: " + err.Error()) 50 | } 51 | var query ConvertConfig 52 | err = json.Unmarshal([]byte(queryBytes), &query) 53 | if err != nil { 54 | return ConvertConfig{}, errors.New("参数错误: " + err.Error()) 55 | } 56 | if len(query.Subs) == 0 && len(query.Proxies) == 0 { 57 | return ConvertConfig{}, errors.New("参数错误: sub 和 proxy 不能同时为空") 58 | } 59 | if len(query.Subs) > 0 { 60 | for i := range query.Subs { 61 | if !strings.HasPrefix(query.Subs[i], "http") { 62 | return ConvertConfig{}, errors.New("参数错误: sub 格式错误") 63 | } 64 | if _, err := url.ParseRequestURI(query.Subs[i]); err != nil { 65 | return ConvertConfig{}, errors.New("参数错误: " + err.Error()) 66 | } 67 | } 68 | } else { 69 | query.Subs = nil 70 | } 71 | if query.Template != "" { 72 | if strings.HasPrefix(query.Template, "http") { 73 | uri, err := url.ParseRequestURI(query.Template) 74 | if err != nil { 75 | return ConvertConfig{}, err 76 | } 77 | query.Template = uri.String() 78 | } 79 | } 80 | if len(query.RuleProviders) > 0 { 81 | names := make(map[string]bool) 82 | for _, ruleProvider := range query.RuleProviders { 83 | if _, ok := names[ruleProvider.Name]; ok { 84 | return ConvertConfig{}, errors.New("参数错误: Rule-Provider 名称重复") 85 | } 86 | names[ruleProvider.Name] = true 87 | } 88 | } else { 89 | query.RuleProviders = nil 90 | } 91 | return query, nil 92 | } 93 | -------------------------------------------------------------------------------- /test/parser/shadowsocksr_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bestnite/sub2clash/model/proxy" 7 | "github.com/bestnite/sub2clash/parser" 8 | ) 9 | 10 | func TestShadowsocksR_Basic_SimpleLink(t *testing.T) { 11 | p := &parser.ShadowsocksRParser{} 12 | input := "ssr://MTI3LjAuMC4xOjQ0MzpvcmlnaW46YWVzLTE5Mi1jZmI6cGxhaW46TVRJek1USXovP2dyb3VwPVpHVm1ZWFZzZEEmcmVtYXJrcz1TRUZJUVE" 13 | 14 | expected := proxy.Proxy{ 15 | Type: "ssr", 16 | Name: "HAHA", 17 | ShadowSocksR: proxy.ShadowSocksR{ 18 | Server: "127.0.0.1", 19 | Port: 443, 20 | Cipher: "aes-192-cfb", 21 | Password: "123123", 22 | ObfsParam: "", 23 | Obfs: "plain", 24 | Protocol: "origin", 25 | ProtocolParam: "", 26 | }, 27 | } 28 | 29 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 30 | if err != nil { 31 | t.Errorf("Unexpected error: %v", err) 32 | return 33 | } 34 | 35 | validateResult(t, expected, result) 36 | } 37 | 38 | func TestShadowsocksR_Basic_WithParams(t *testing.T) { 39 | p := &parser.ShadowsocksRParser{} 40 | input := "ssr://MTI3LjAuMC4xOjQ0MzpvcmlnaW46YWVzLTE5Mi1jZmI6dGxzMS4wX3Nlc3Npb25fYXV0aDpNVEl6TVRJei8/b2Jmc3BhcmFtPWIySm1jeTF3WVhKaGJXVjBaWEkmcHJvdG9wYXJhbT1jSEp2ZEc5allXd3RjR0Z5WVcxbGRHVnkmZ3JvdXA9WkdWbVlYVnNkQSZyZW1hcmtzPVNFRklRUQ" 41 | 42 | expected := proxy.Proxy{ 43 | Type: "ssr", 44 | Name: "HAHA", 45 | ShadowSocksR: proxy.ShadowSocksR{ 46 | Server: "127.0.0.1", 47 | Port: 443, 48 | Cipher: "aes-192-cfb", 49 | Password: "123123", 50 | ObfsParam: "obfs-parameter", 51 | Obfs: "tls1.0_session_auth", 52 | Protocol: "origin", 53 | ProtocolParam: "protocal-parameter", 54 | }, 55 | } 56 | 57 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 58 | if err != nil { 59 | t.Errorf("Unexpected error: %v", err) 60 | return 61 | } 62 | 63 | validateResult(t, expected, result) 64 | } 65 | 66 | func TestShadowsocksR_Basic_IPv6Address(t *testing.T) { 67 | p := &parser.ShadowsocksRParser{} 68 | input := "ssr://WzIwMDE6MGRiODo4NWEzOjAwMDA6MDAwMDo4YTJlOjAzNzA6NzMzNF06NDQzOm9yaWdpbjphZXMtMTkyLWNmYjpwbGFpbjpNVEl6TVRJei8/Z3JvdXA9WkdWbVlYVnNkQSZyZW1hcmtzPVNFRklRUQ" 69 | 70 | expected := proxy.Proxy{ 71 | Type: "ssr", 72 | Name: "HAHA", 73 | ShadowSocksR: proxy.ShadowSocksR{ 74 | Server: "[2001:0db8:85a3:0000:0000:8a2e:0370:7334]", 75 | Port: 443, 76 | Cipher: "aes-192-cfb", 77 | Password: "123123", 78 | ObfsParam: "", 79 | Obfs: "plain", 80 | Protocol: "origin", 81 | ProtocolParam: "", 82 | }, 83 | } 84 | 85 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 86 | if err != nil { 87 | t.Errorf("Unexpected error: %v", err) 88 | return 89 | } 90 | 91 | validateResult(t, expected, result) 92 | } 93 | 94 | func TestShadowsocksR_Error_InvalidBase64(t *testing.T) { 95 | p := &parser.ShadowsocksRParser{} 96 | input := "ssr://invalid_base64" 97 | 98 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 99 | if err == nil { 100 | t.Errorf("Expected error but got none") 101 | } 102 | } 103 | 104 | func TestShadowsocksR_Error_InvalidProtocol(t *testing.T) { 105 | p := &parser.ShadowsocksRParser{} 106 | input := "ss://example.com:8080" 107 | 108 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 109 | if err == nil { 110 | t.Errorf("Expected error but got none") 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /parser/vless.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | P "github.com/bestnite/sub2clash/model/proxy" 9 | ) 10 | 11 | type VlessParser struct{} 12 | 13 | func (p *VlessParser) SupportClash() bool { 14 | return false 15 | } 16 | 17 | func (p *VlessParser) SupportMeta() bool { 18 | return true 19 | } 20 | 21 | func (p *VlessParser) GetPrefixes() []string { 22 | return []string{"vless://"} 23 | } 24 | 25 | func (p *VlessParser) GetType() string { 26 | return "vless" 27 | } 28 | 29 | func (p *VlessParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) { 30 | if !hasPrefix(proxy, p.GetPrefixes()) { 31 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy) 32 | } 33 | 34 | link, err := url.Parse(proxy) 35 | if err != nil { 36 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 37 | } 38 | 39 | server := link.Hostname() 40 | if server == "" { 41 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, "missing server host") 42 | } 43 | portStr := link.Port() 44 | port, err := ParsePort(portStr) 45 | if err != nil { 46 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error()) 47 | } 48 | 49 | query := link.Query() 50 | uuid := link.User.Username() 51 | 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") 52 | 53 | insecureBool := insecure == "1" 54 | var alpn []string 55 | if strings.Contains(alpnStr, ",") { 56 | alpn = strings.Split(alpnStr, ",") 57 | } else { 58 | alpn = nil 59 | } 60 | 61 | remarks := link.Fragment 62 | if remarks == "" { 63 | remarks = fmt.Sprintf("%s:%s", server, portStr) 64 | } 65 | remarks = strings.TrimSpace(remarks) 66 | 67 | result := P.Vless{ 68 | Server: server, 69 | Port: P.IntOrString(port), 70 | UUID: uuid, 71 | Flow: flow, 72 | UDP: udp == "true", 73 | SkipCertVerify: insecureBool, 74 | } 75 | 76 | if len(alpn) > 0 { 77 | result.ALPN = alpn 78 | } 79 | 80 | if fp != "" { 81 | result.ClientFingerprint = fp 82 | } 83 | 84 | if sni != "" { 85 | result.ServerName = sni 86 | } 87 | 88 | if security == "tls" { 89 | result.TLS = true 90 | } 91 | 92 | if security == "reality" { 93 | result.TLS = true 94 | result.RealityOpts = P.RealityOptions{ 95 | PublicKey: pbk, 96 | ShortID: sid, 97 | } 98 | } 99 | 100 | if _type == "ws" { 101 | result.Network = "ws" 102 | result.WSOpts = P.WSOptions{ 103 | Path: path, 104 | } 105 | if host != "" { 106 | result.WSOpts.Headers = make(map[string]string) 107 | result.WSOpts.Headers["Host"] = host 108 | } 109 | } 110 | 111 | if _type == "grpc" { 112 | result.Network = "grpc" 113 | result.GrpcOpts = P.GrpcOptions{ 114 | GrpcServiceName: serviceName, 115 | } 116 | } 117 | 118 | if _type == "http" { 119 | result.HTTPOpts = P.HTTPOptions{} 120 | result.HTTPOpts.Headers = map[string][]string{} 121 | 122 | result.HTTPOpts.Path = strings.Split(path, ",") 123 | 124 | hosts, err := url.QueryUnescape(host) 125 | if err != nil { 126 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrCannotParseParams, err.Error()) 127 | } 128 | result.Network = "http" 129 | if hosts != "" { 130 | result.HTTPOpts.Headers["host"] = strings.Split(host, ",") 131 | } 132 | } 133 | 134 | return P.Proxy{ 135 | Type: p.GetType(), 136 | Name: remarks, 137 | Vless: result, 138 | }, nil 139 | } 140 | 141 | func init() { 142 | RegisterParser(&VlessParser{}) 143 | } 144 | -------------------------------------------------------------------------------- /parser/shadowsocksr.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | "strings" 8 | 9 | P "github.com/bestnite/sub2clash/model/proxy" 10 | "github.com/bestnite/sub2clash/utils" 11 | ) 12 | 13 | type ShadowsocksRParser struct{} 14 | 15 | func (p *ShadowsocksRParser) SupportClash() bool { 16 | return true 17 | } 18 | 19 | func (p *ShadowsocksRParser) SupportMeta() bool { 20 | return true 21 | } 22 | 23 | func (p *ShadowsocksRParser) GetPrefixes() []string { 24 | return []string{"ssr://"} 25 | } 26 | 27 | func (p *ShadowsocksRParser) GetType() string { 28 | return "ssr" 29 | } 30 | 31 | func (p *ShadowsocksRParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) { 32 | if !hasPrefix(proxy, p.GetPrefixes()) { 33 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy) 34 | } 35 | 36 | for _, prefix := range p.GetPrefixes() { 37 | if strings.HasPrefix(proxy, prefix) { 38 | proxy = strings.TrimPrefix(proxy, prefix) 39 | break 40 | } 41 | } 42 | 43 | proxy, err := utils.DecodeBase64(proxy, true) 44 | if err != nil { 45 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidBase64, err.Error()) 46 | } 47 | serverInfoAndParams := strings.SplitN(proxy, "/?", 2) 48 | if len(serverInfoAndParams) != 2 { 49 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, proxy) 50 | } 51 | parts := SplitNRight(serverInfoAndParams[0], ":", 6) 52 | if len(parts) < 6 { 53 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, proxy) 54 | } 55 | server := parts[0] 56 | protocol := parts[2] 57 | method := parts[3] 58 | obfs := parts[4] 59 | password, err := utils.DecodeBase64(parts[5], true) 60 | if err != nil { 61 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 62 | } 63 | port, err := ParsePort(parts[1]) 64 | if err != nil { 65 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error()) 66 | } 67 | 68 | var obfsParam string 69 | var protoParam string 70 | var remarks string 71 | if len(serverInfoAndParams) == 2 { 72 | params, err := url.ParseQuery(serverInfoAndParams[1]) 73 | if err != nil { 74 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrCannotParseParams, err.Error()) 75 | } 76 | if params.Get("obfsparam") != "" { 77 | obfsParam, err = utils.DecodeBase64(params.Get("obfsparam"), true) 78 | } 79 | if params.Get("protoparam") != "" { 80 | protoParam, err = utils.DecodeBase64(params.Get("protoparam"), true) 81 | } 82 | if params.Get("remarks") != "" { 83 | remarks, err = utils.DecodeBase64(params.Get("remarks"), true) 84 | } else { 85 | remarks = server + ":" + strconv.Itoa(port) 86 | } 87 | if err != nil { 88 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 89 | } 90 | } 91 | 92 | result := P.Proxy{ 93 | Type: p.GetType(), 94 | Name: remarks, 95 | ShadowSocksR: P.ShadowSocksR{ 96 | Server: server, 97 | Port: P.IntOrString(port), 98 | Protocol: protocol, 99 | Cipher: method, 100 | Obfs: obfs, 101 | Password: password, 102 | ObfsParam: obfsParam, 103 | ProtocolParam: protoParam, 104 | UDP: config.UseUDP, 105 | }, 106 | } 107 | return result, nil 108 | } 109 | 110 | func init() { 111 | RegisterParser(&ShadowsocksRParser{}) 112 | } 113 | 114 | func SplitNRight(s, sep string, n int) []string { 115 | if n <= 0 { 116 | return nil 117 | } 118 | if n == 1 { 119 | return []string{s} 120 | } 121 | parts := strings.Split(s, sep) 122 | if len(parts) <= n { 123 | return parts 124 | } 125 | result := make([]string, n) 126 | for i, j := len(parts)-1, 0; i >= 0; i, j = i-1, j+1 { 127 | if j < n-1 { 128 | result[n-j-1] = parts[len(parts)-j-1] 129 | } else { 130 | result[0] = strings.Join(parts[:i+1], sep) 131 | break 132 | } 133 | } 134 | return result 135 | } 136 | -------------------------------------------------------------------------------- /parser/vmess.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | 10 | P "github.com/bestnite/sub2clash/model/proxy" 11 | "github.com/bestnite/sub2clash/utils" 12 | ) 13 | 14 | type VmessJson struct { 15 | V any `json:"v"` 16 | Ps string `json:"ps"` 17 | Add string `json:"add"` 18 | Port any `json:"port"` 19 | Id string `json:"id"` 20 | Aid any `json:"aid"` 21 | Scy string `json:"scy"` 22 | Net string `json:"net"` 23 | Type string `json:"type"` 24 | Host string `json:"host"` 25 | Path string `json:"path"` 26 | Tls string `json:"tls"` 27 | Sni string `json:"sni"` 28 | Alpn string `json:"alpn"` 29 | Fp string `json:"fp"` 30 | } 31 | 32 | type VmessParser struct{} 33 | 34 | func (p *VmessParser) SupportClash() bool { 35 | return true 36 | } 37 | 38 | func (p *VmessParser) SupportMeta() bool { 39 | return true 40 | } 41 | 42 | func (p *VmessParser) GetPrefixes() []string { 43 | return []string{"vmess://"} 44 | } 45 | 46 | func (p *VmessParser) GetType() string { 47 | return "vmess" 48 | } 49 | 50 | func (p *VmessParser) Parse(config ParseConfig, proxy string) (P.Proxy, error) { 51 | if !hasPrefix(proxy, p.GetPrefixes()) { 52 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPrefix, proxy) 53 | } 54 | 55 | for _, prefix := range p.GetPrefixes() { 56 | if strings.HasPrefix(proxy, prefix) { 57 | proxy = strings.TrimPrefix(proxy, prefix) 58 | break 59 | } 60 | } 61 | base64, err := utils.DecodeBase64(proxy, true) 62 | if err != nil { 63 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidBase64, err.Error()) 64 | } 65 | 66 | var vmess VmessJson 67 | err = json.Unmarshal([]byte(base64), &vmess) 68 | if err != nil { 69 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 70 | } 71 | 72 | var port int 73 | switch vmess.Port.(type) { 74 | case string: 75 | port, err = ParsePort(vmess.Port.(string)) 76 | if err != nil { 77 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidPort, err.Error()) 78 | } 79 | case float64: 80 | port = int(vmess.Port.(float64)) 81 | } 82 | 83 | aid := 0 84 | switch vmess.Aid.(type) { 85 | case string: 86 | aid, err = strconv.Atoi(vmess.Aid.(string)) 87 | if err != nil { 88 | return P.Proxy{}, fmt.Errorf("%w: %s", ErrInvalidStruct, err.Error()) 89 | } 90 | case float64: 91 | aid = int(vmess.Aid.(float64)) 92 | } 93 | 94 | if vmess.Scy == "" { 95 | vmess.Scy = "auto" 96 | } 97 | 98 | name, err := url.QueryUnescape(vmess.Ps) 99 | if err != nil { 100 | name = vmess.Ps 101 | } 102 | 103 | var alpn []string 104 | if strings.Contains(vmess.Alpn, ",") { 105 | alpn = strings.Split(vmess.Alpn, ",") 106 | } else { 107 | alpn = nil 108 | } 109 | 110 | result := P.Vmess{ 111 | Server: vmess.Add, 112 | Port: P.IntOrString(port), 113 | UUID: vmess.Id, 114 | AlterID: P.IntOrString(aid), 115 | Cipher: vmess.Scy, 116 | UDP: config.UseUDP, 117 | } 118 | 119 | if len(alpn) > 0 { 120 | result.ALPN = alpn 121 | } 122 | 123 | if vmess.Fp != "" { 124 | result.ClientFingerprint = vmess.Fp 125 | } 126 | 127 | if vmess.Sni != "" { 128 | result.ServerName = vmess.Sni 129 | } 130 | 131 | if vmess.Tls == "tls" { 132 | result.TLS = true 133 | } 134 | 135 | if vmess.Net == "ws" { 136 | if vmess.Path == "" { 137 | vmess.Path = "/" 138 | } 139 | if vmess.Host == "" { 140 | vmess.Host = vmess.Add 141 | } 142 | result.Network = "ws" 143 | result.WSOpts = P.WSOptions{ 144 | Path: vmess.Path, 145 | Headers: map[string]string{ 146 | "Host": vmess.Host, 147 | }, 148 | } 149 | } 150 | 151 | if vmess.Net == "grpc" { 152 | result.GrpcOpts = P.GrpcOptions{ 153 | GrpcServiceName: vmess.Path, 154 | } 155 | result.Network = "grpc" 156 | } 157 | 158 | if vmess.Net == "h2" { 159 | result.HTTP2Opts = P.HTTP2Options{ 160 | Host: strings.Split(vmess.Host, ","), 161 | Path: vmess.Path, 162 | } 163 | result.Network = "h2" 164 | } 165 | 166 | return P.Proxy{ 167 | Type: p.GetType(), 168 | Name: name, 169 | Vmess: result, 170 | }, nil 171 | } 172 | 173 | func init() { 174 | RegisterParser(&VmessParser{}) 175 | } 176 | -------------------------------------------------------------------------------- /test/parser/socks_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bestnite/sub2clash/model/proxy" 7 | "github.com/bestnite/sub2clash/parser" 8 | ) 9 | 10 | func TestSocks_Basic_SimpleLink(t *testing.T) { 11 | p := &parser.SocksParser{} 12 | input := "socks://user:pass@127.0.0.1:1080#SOCKS%20Proxy" 13 | 14 | expected := proxy.Proxy{ 15 | Type: "socks5", 16 | Name: "SOCKS Proxy", 17 | Socks: proxy.Socks{ 18 | Server: "127.0.0.1", 19 | Port: 1080, 20 | UserName: "user", 21 | Password: "pass", 22 | }, 23 | } 24 | 25 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 26 | if err != nil { 27 | t.Errorf("Unexpected error: %v", err) 28 | return 29 | } 30 | 31 | validateResult(t, expected, result) 32 | } 33 | 34 | func TestSocks_Basic_NoAuth(t *testing.T) { 35 | p := &parser.SocksParser{} 36 | input := "socks://127.0.0.1:1080#SOCKS%20No%20Auth" 37 | 38 | expected := proxy.Proxy{ 39 | Type: "socks5", 40 | Name: "SOCKS No Auth", 41 | Socks: proxy.Socks{ 42 | Server: "127.0.0.1", 43 | Port: 1080, 44 | }, 45 | } 46 | 47 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 48 | if err != nil { 49 | t.Errorf("Unexpected error: %v", err) 50 | return 51 | } 52 | 53 | validateResult(t, expected, result) 54 | } 55 | 56 | func TestSocks_Basic_IPv6Address(t *testing.T) { 57 | p := &parser.SocksParser{} 58 | input := "socks://user:pass@[2001:db8::1]:1080#SOCKS%20IPv6" 59 | 60 | expected := proxy.Proxy{ 61 | Type: "socks5", 62 | Name: "SOCKS IPv6", 63 | Socks: proxy.Socks{ 64 | Server: "2001:db8::1", 65 | Port: 1080, 66 | UserName: "user", 67 | Password: "pass", 68 | }, 69 | } 70 | 71 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 72 | if err != nil { 73 | t.Errorf("Unexpected error: %v", err) 74 | return 75 | } 76 | 77 | validateResult(t, expected, result) 78 | } 79 | 80 | func TestSocks_Basic_WithTLS(t *testing.T) { 81 | p := &parser.SocksParser{} 82 | input := "socks://user:pass@127.0.0.1:1080?tls=true&sni=example.com#SOCKS%20TLS" 83 | 84 | expected := proxy.Proxy{ 85 | Type: "socks5", 86 | Name: "SOCKS TLS", 87 | Socks: proxy.Socks{ 88 | Server: "127.0.0.1", 89 | Port: 1080, 90 | UserName: "user", 91 | Password: "pass", 92 | TLS: true, 93 | }, 94 | } 95 | 96 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 97 | if err != nil { 98 | t.Errorf("Unexpected error: %v", err) 99 | return 100 | } 101 | 102 | validateResult(t, expected, result) 103 | } 104 | 105 | func TestSocks_Basic_WithUDP(t *testing.T) { 106 | p := &parser.SocksParser{} 107 | input := "socks://user:pass@127.0.0.1:1080?udp=true#SOCKS%20UDP" 108 | 109 | expected := proxy.Proxy{ 110 | Type: "socks5", 111 | Name: "SOCKS UDP", 112 | Socks: proxy.Socks{ 113 | Server: "127.0.0.1", 114 | Port: 1080, 115 | UserName: "user", 116 | Password: "pass", 117 | UDP: true, 118 | }, 119 | } 120 | 121 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 122 | if err != nil { 123 | t.Errorf("Unexpected error: %v", err) 124 | return 125 | } 126 | 127 | validateResult(t, expected, result) 128 | } 129 | 130 | func TestSocks_Error_MissingServer(t *testing.T) { 131 | p := &parser.SocksParser{} 132 | input := "socks://user:pass@:1080" 133 | 134 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 135 | if err == nil { 136 | t.Errorf("Expected error but got none") 137 | } 138 | } 139 | 140 | func TestSocks_Error_MissingPort(t *testing.T) { 141 | p := &parser.SocksParser{} 142 | input := "socks://user:pass@127.0.0.1" 143 | 144 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 145 | if err == nil { 146 | t.Errorf("Expected error but got none") 147 | } 148 | } 149 | 150 | func TestSocks_Error_InvalidPort(t *testing.T) { 151 | p := &parser.SocksParser{} 152 | input := "socks://user:pass@127.0.0.1:99999" 153 | 154 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 155 | if err == nil { 156 | t.Errorf("Expected error but got none") 157 | } 158 | } 159 | 160 | func TestSocks_Error_InvalidProtocol(t *testing.T) { 161 | p := &parser.SocksParser{} 162 | input := "ss://example.com:8080" 163 | 164 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 165 | if err == nil { 166 | t.Errorf("Expected error but got none") 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /test/parser/trojan_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bestnite/sub2clash/model/proxy" 7 | "github.com/bestnite/sub2clash/parser" 8 | ) 9 | 10 | func TestTrojan_Basic_SimpleLink(t *testing.T) { 11 | p := &parser.TrojanParser{} 12 | input := "trojan://password@127.0.0.1:443#Trojan%20Proxy" 13 | 14 | expected := proxy.Proxy{ 15 | Type: "trojan", 16 | Name: "Trojan Proxy", 17 | Trojan: proxy.Trojan{ 18 | Server: "127.0.0.1", 19 | Port: 443, 20 | Password: "password", 21 | }, 22 | } 23 | 24 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 25 | if err != nil { 26 | t.Errorf("Unexpected error: %v", err) 27 | return 28 | } 29 | 30 | validateResult(t, expected, result) 31 | } 32 | 33 | func TestTrojan_Basic_WithTLS(t *testing.T) { 34 | p := &parser.TrojanParser{} 35 | input := "trojan://password@127.0.0.1:443?security=tls&sni=example.com&alpn=h2,http/1.1#Trojan%20TLS" 36 | 37 | expected := proxy.Proxy{ 38 | Type: "trojan", 39 | Name: "Trojan TLS", 40 | Trojan: proxy.Trojan{ 41 | Server: "127.0.0.1", 42 | Port: 443, 43 | Password: "password", 44 | ALPN: []string{"h2", "http/1.1"}, 45 | SNI: "example.com", 46 | }, 47 | } 48 | 49 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 50 | if err != nil { 51 | t.Errorf("Unexpected error: %v", err) 52 | return 53 | } 54 | 55 | validateResult(t, expected, result) 56 | } 57 | 58 | func TestTrojan_Basic_WithReality(t *testing.T) { 59 | p := &parser.TrojanParser{} 60 | input := "trojan://password@127.0.0.1:443?security=reality&sni=example.com&pbk=publickey123&sid=shortid123&fp=chrome#Trojan%20Reality" 61 | 62 | expected := proxy.Proxy{ 63 | Type: "trojan", 64 | Name: "Trojan Reality", 65 | Trojan: proxy.Trojan{ 66 | Server: "127.0.0.1", 67 | Port: 443, 68 | Password: "password", 69 | SNI: "example.com", 70 | RealityOpts: proxy.RealityOptions{ 71 | PublicKey: "publickey123", 72 | ShortID: "shortid123", 73 | }, 74 | Fingerprint: "chrome", 75 | }, 76 | } 77 | 78 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 79 | if err != nil { 80 | t.Errorf("Unexpected error: %v", err) 81 | return 82 | } 83 | 84 | validateResult(t, expected, result) 85 | } 86 | 87 | func TestTrojan_Basic_WithWebSocket(t *testing.T) { 88 | p := &parser.TrojanParser{} 89 | input := "trojan://password@127.0.0.1:443?type=ws&path=/ws&host=example.com#Trojan%20WS" 90 | 91 | expected := proxy.Proxy{ 92 | Type: "trojan", 93 | Name: "Trojan WS", 94 | Trojan: proxy.Trojan{ 95 | Server: "127.0.0.1", 96 | Port: 443, 97 | Password: "password", 98 | Network: "ws", 99 | WSOpts: proxy.WSOptions{ 100 | Path: "/ws", 101 | Headers: map[string]string{ 102 | "Host": "example.com", 103 | }, 104 | }, 105 | }, 106 | } 107 | 108 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 109 | if err != nil { 110 | t.Errorf("Unexpected error: %v", err) 111 | return 112 | } 113 | 114 | validateResult(t, expected, result) 115 | } 116 | 117 | func TestTrojan_Basic_WithGrpc(t *testing.T) { 118 | p := &parser.TrojanParser{} 119 | input := "trojan://password@127.0.0.1:443?type=grpc&serviceName=grpc_service#Trojan%20gRPC" 120 | 121 | expected := proxy.Proxy{ 122 | Type: "trojan", 123 | Name: "Trojan gRPC", 124 | Trojan: proxy.Trojan{ 125 | Server: "127.0.0.1", 126 | Port: 443, 127 | Password: "password", 128 | Network: "grpc", 129 | GrpcOpts: proxy.GrpcOptions{ 130 | GrpcServiceName: "grpc_service", 131 | }, 132 | }, 133 | } 134 | 135 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 136 | if err != nil { 137 | t.Errorf("Unexpected error: %v", err) 138 | return 139 | } 140 | 141 | validateResult(t, expected, result) 142 | } 143 | 144 | func TestTrojan_Error_MissingServer(t *testing.T) { 145 | p := &parser.TrojanParser{} 146 | input := "trojan://password@:443" 147 | 148 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 149 | if err == nil { 150 | t.Errorf("Expected error but got none") 151 | } 152 | } 153 | 154 | func TestTrojan_Error_MissingPort(t *testing.T) { 155 | p := &parser.TrojanParser{} 156 | input := "trojan://password@127.0.0.1" 157 | 158 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 159 | if err == nil { 160 | t.Errorf("Expected error but got none") 161 | } 162 | } 163 | 164 | func TestTrojan_Error_InvalidPort(t *testing.T) { 165 | p := &parser.TrojanParser{} 166 | input := "trojan://password@127.0.0.1:99999" 167 | 168 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 169 | if err == nil { 170 | t.Errorf("Expected error but got none") 171 | } 172 | } 173 | 174 | func TestTrojan_Error_InvalidProtocol(t *testing.T) { 175 | p := &parser.TrojanParser{} 176 | input := "ss://example.com:8080" 177 | 178 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 179 | if err == nil { 180 | t.Errorf("Expected error but got none") 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /test/parser/shadowsocks_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/bestnite/sub2clash/model/proxy" 8 | "github.com/bestnite/sub2clash/parser" 9 | ) 10 | 11 | func TestShadowsocks_Basic_SimpleLink(t *testing.T) { 12 | p := &parser.ShadowsocksParser{} 13 | input := "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@127.0.0.1:8080" 14 | 15 | expected := proxy.Proxy{ 16 | Type: "ss", 17 | Name: "127.0.0.1:8080", 18 | ShadowSocks: proxy.ShadowSocks{ 19 | Server: "127.0.0.1", 20 | Port: 8080, 21 | Cipher: "aes-256-gcm", 22 | Password: "password", 23 | }, 24 | } 25 | 26 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 27 | if err != nil { 28 | t.Errorf("Unexpected error: %v", err) 29 | return 30 | } 31 | 32 | validateResult(t, expected, result) 33 | } 34 | 35 | func TestShadowsocks_Basic_IPv6Address(t *testing.T) { 36 | p := &parser.ShadowsocksParser{} 37 | input := "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@[2001:db8::1]:8080" 38 | 39 | expected := proxy.Proxy{ 40 | Type: "ss", 41 | Name: "2001:db8::1:8080", 42 | ShadowSocks: proxy.ShadowSocks{ 43 | Server: "2001:db8::1", 44 | Port: 8080, 45 | Cipher: "aes-256-gcm", 46 | Password: "password", 47 | }, 48 | } 49 | 50 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 51 | if err != nil { 52 | t.Errorf("Unexpected error: %v", err) 53 | return 54 | } 55 | 56 | validateResult(t, expected, result) 57 | } 58 | 59 | func TestShadowsocks_Basic_WithRemark(t *testing.T) { 60 | p := &parser.ShadowsocksParser{} 61 | input := "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@proxy.example.com:8080#My%20SS%20Proxy" 62 | 63 | expected := proxy.Proxy{ 64 | Type: "ss", 65 | Name: "My SS Proxy", 66 | ShadowSocks: proxy.ShadowSocks{ 67 | Server: "proxy.example.com", 68 | Port: 8080, 69 | Cipher: "aes-256-gcm", 70 | Password: "password", 71 | }, 72 | } 73 | 74 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 75 | if err != nil { 76 | t.Errorf("Unexpected error: %v", err) 77 | return 78 | } 79 | 80 | validateResult(t, expected, result) 81 | } 82 | 83 | func TestShadowsocks_Advanced_Base64FullEncoded(t *testing.T) { 84 | p := &parser.ShadowsocksParser{} 85 | input := "ss://YWVzLTI1Ni1nY206cGFzc3dvcmRAbG9jYWxob3N0OjgwODA=#Local%20SS" 86 | 87 | expected := proxy.Proxy{ 88 | Type: "ss", 89 | Name: "Local SS", 90 | ShadowSocks: proxy.ShadowSocks{ 91 | Server: "localhost", 92 | Port: 8080, 93 | Cipher: "aes-256-gcm", 94 | Password: "password", 95 | }, 96 | } 97 | 98 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 99 | if err != nil { 100 | t.Errorf("Unexpected error: %v", err) 101 | return 102 | } 103 | 104 | validateResult(t, expected, result) 105 | } 106 | 107 | func TestShadowsocks_Advanced_PlainUserPassword(t *testing.T) { 108 | p := &parser.ShadowsocksParser{} 109 | input := "ss://aes-256-gcm:password@192.168.1.1:8080" 110 | 111 | expected := proxy.Proxy{ 112 | Type: "ss", 113 | Name: "192.168.1.1:8080", 114 | ShadowSocks: proxy.ShadowSocks{ 115 | Server: "192.168.1.1", 116 | Port: 8080, 117 | Cipher: "aes-256-gcm", 118 | Password: "password", 119 | }, 120 | } 121 | 122 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 123 | if err != nil { 124 | t.Errorf("Unexpected error: %v", err) 125 | return 126 | } 127 | 128 | validateResult(t, expected, result) 129 | } 130 | 131 | func TestShadowsocks_Advanced_ChaCha20Cipher(t *testing.T) { 132 | p := &parser.ShadowsocksParser{} 133 | input := "ss://chacha20-poly1305:mypassword@server.com:443#ChaCha20" 134 | 135 | expected := proxy.Proxy{ 136 | Type: "ss", 137 | Name: "ChaCha20", 138 | ShadowSocks: proxy.ShadowSocks{ 139 | Server: "server.com", 140 | Port: 443, 141 | Cipher: "chacha20-poly1305", 142 | Password: "mypassword", 143 | }, 144 | } 145 | 146 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 147 | if err != nil { 148 | t.Errorf("Unexpected error: %v", err) 149 | return 150 | } 151 | 152 | validateResult(t, expected, result) 153 | } 154 | 155 | // 错误处理测试 156 | func TestShadowsocks_Error_MissingServer(t *testing.T) { 157 | p := &parser.ShadowsocksParser{} 158 | input := "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@:8080" 159 | 160 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 161 | if !errors.Is(err, parser.ErrInvalidStruct) { 162 | t.Errorf("Error is not expected: %v", err) 163 | } 164 | } 165 | 166 | func TestShadowsocks_Error_MissingPort(t *testing.T) { 167 | p := &parser.ShadowsocksParser{} 168 | input := "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@127.0.0.1" 169 | 170 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 171 | if !errors.Is(err, parser.ErrInvalidStruct) { 172 | t.Errorf("Error is not expected: %v", err) 173 | } 174 | } 175 | 176 | func TestShadowsocks_Error_InvalidProtocol(t *testing.T) { 177 | p := &parser.ShadowsocksParser{} 178 | input := "http://example.com:8080" 179 | 180 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 181 | if !errors.Is(err, parser.ErrInvalidPrefix) { 182 | t.Errorf("Error is not expected: %v", err) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /server/frontend/src/components/rule-provider-input.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, unsafeCSS } from "lit"; 2 | import { customElement, state } from "lit/decorators.js"; 3 | import type { RuleProvider } from "../interface"; 4 | import globalStyles from "../index.css?inline"; 5 | 6 | @customElement("rule-provider-input") 7 | export class RuleProviderInput extends LitElement { 8 | static styles = [unsafeCSS(globalStyles)]; 9 | 10 | _ruleProviders: Array = []; 11 | 12 | @state() 13 | set ruleProviders(value) { 14 | this.dispatchEvent( 15 | new CustomEvent("change", { 16 | detail: value, 17 | }) 18 | ); 19 | this._ruleProviders = value; 20 | } 21 | 22 | get ruleProviders() { 23 | return this._ruleProviders; 24 | } 25 | 26 | RuleProviderTemplate(index: number) { 27 | return html` 28 |
29 |
30 | 40 |
41 |
42 | 55 |
56 |
57 | 67 |
68 | 78 |
79 | 91 |
92 | 103 |
104 | `; 105 | } 106 | 107 | render() { 108 | return html` 109 |
110 | 131 |
132 | 133 |
134 | ${this.ruleProviders?.map((_, i) => this.RuleProviderTemplate(i))} 135 |
`; 136 | } 137 | } 138 | 139 | declare global { 140 | interface HTMLElementTagNameMap { 141 | "rule-provider-input": RuleProviderInput; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /test/parser/hysteria_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bestnite/sub2clash/model/proxy" 7 | "github.com/bestnite/sub2clash/parser" 8 | ) 9 | 10 | func TestHysteria_Basic_SimpleLink(t *testing.T) { 11 | p := &parser.HysteriaParser{} 12 | input := "hysteria://127.0.0.1:8080?protocol=udp&auth=password123&upmbps=100&downmbps=100#Hysteria%20Proxy" 13 | 14 | expected := proxy.Proxy{ 15 | Type: "hysteria", 16 | Name: "Hysteria Proxy", 17 | Hysteria: proxy.Hysteria{ 18 | Server: "127.0.0.1", 19 | Port: 8080, 20 | Protocol: "udp", 21 | Auth: "password123", 22 | Up: "100", 23 | Down: "100", 24 | SkipCertVerify: false, 25 | }, 26 | } 27 | 28 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 29 | if err != nil { 30 | t.Errorf("Unexpected error: %v", err) 31 | return 32 | } 33 | 34 | validateResult(t, expected, result) 35 | } 36 | 37 | func TestHysteria_Basic_WithAuthString(t *testing.T) { 38 | p := &parser.HysteriaParser{} 39 | input := "hysteria://proxy.example.com:443?protocol=wechat-video&auth-str=myauth&upmbps=50&downmbps=200&insecure=true#Hysteria%20Auth" 40 | 41 | expected := proxy.Proxy{ 42 | Type: "hysteria", 43 | Name: "Hysteria Auth", 44 | Hysteria: proxy.Hysteria{ 45 | Server: "proxy.example.com", 46 | Port: 443, 47 | Protocol: "wechat-video", 48 | AuthString: "myauth", 49 | Up: "50", 50 | Down: "200", 51 | SkipCertVerify: true, 52 | }, 53 | } 54 | 55 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 56 | if err != nil { 57 | t.Errorf("Unexpected error: %v", err) 58 | return 59 | } 60 | 61 | validateResult(t, expected, result) 62 | } 63 | 64 | func TestHysteria_Basic_WithObfs(t *testing.T) { 65 | p := &parser.HysteriaParser{} 66 | input := "hysteria://127.0.0.1:8080?auth=password123&upmbps=100&downmbps=100&obfs=xplus&alpn=h3#Hysteria%20Obfs" 67 | 68 | expected := proxy.Proxy{ 69 | Type: "hysteria", 70 | Name: "Hysteria Obfs", 71 | Hysteria: proxy.Hysteria{ 72 | Server: "127.0.0.1", 73 | Port: 8080, 74 | Auth: "password123", 75 | Up: "100", 76 | Down: "100", 77 | Obfs: "xplus", 78 | ALPN: []string{"h3"}, 79 | SkipCertVerify: false, 80 | }, 81 | } 82 | 83 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 84 | if err != nil { 85 | t.Errorf("Unexpected error: %v", err) 86 | return 87 | } 88 | 89 | validateResult(t, expected, result) 90 | } 91 | 92 | func TestHysteria_Basic_IPv6Address(t *testing.T) { 93 | p := &parser.HysteriaParser{} 94 | input := "hysteria://[2001:db8::1]:8080?auth=password123&upmbps=100&downmbps=100#Hysteria%20IPv6" 95 | 96 | expected := proxy.Proxy{ 97 | Type: "hysteria", 98 | Name: "Hysteria IPv6", 99 | Hysteria: proxy.Hysteria{ 100 | Server: "2001:db8::1", 101 | Port: 8080, 102 | Auth: "password123", 103 | Up: "100", 104 | Down: "100", 105 | SkipCertVerify: false, 106 | }, 107 | } 108 | 109 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 110 | if err != nil { 111 | t.Errorf("Unexpected error: %v", err) 112 | return 113 | } 114 | 115 | validateResult(t, expected, result) 116 | } 117 | 118 | func TestHysteria_Basic_MultiALPN(t *testing.T) { 119 | p := &parser.HysteriaParser{} 120 | input := "hysteria://proxy.example.com:443?auth=password123&upmbps=100&downmbps=100&alpn=h3,h2,http/1.1#Hysteria%20Multi%20ALPN" 121 | 122 | expected := proxy.Proxy{ 123 | Type: "hysteria", 124 | Name: "Hysteria Multi ALPN", 125 | Hysteria: proxy.Hysteria{ 126 | Server: "proxy.example.com", 127 | Port: 443, 128 | Auth: "password123", 129 | Up: "100", 130 | Down: "100", 131 | ALPN: []string{"h3", "h2", "http/1.1"}, 132 | SkipCertVerify: false, 133 | }, 134 | } 135 | 136 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 137 | if err != nil { 138 | t.Errorf("Unexpected error: %v", err) 139 | return 140 | } 141 | 142 | validateResult(t, expected, result) 143 | } 144 | 145 | func TestHysteria_Error_MissingServer(t *testing.T) { 146 | p := &parser.HysteriaParser{} 147 | input := "hysteria://:8080?auth=password123" 148 | 149 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 150 | if err == nil { 151 | t.Errorf("Expected error but got none") 152 | } 153 | } 154 | 155 | func TestHysteria_Error_MissingPort(t *testing.T) { 156 | p := &parser.HysteriaParser{} 157 | input := "hysteria://127.0.0.1?auth=password123" 158 | 159 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 160 | if err == nil { 161 | t.Errorf("Expected error but got none") 162 | } 163 | } 164 | 165 | func TestHysteria_Error_InvalidPort(t *testing.T) { 166 | p := &parser.HysteriaParser{} 167 | input := "hysteria://127.0.0.1:99999?auth=password123" 168 | 169 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 170 | if err == nil { 171 | t.Errorf("Expected error but got none") 172 | } 173 | } 174 | 175 | func TestHysteria_Error_InvalidProtocol(t *testing.T) { 176 | p := &parser.HysteriaParser{} 177 | input := "hysteria2://example.com:8080" 178 | 179 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 180 | if err == nil { 181 | t.Errorf("Expected error but got none") 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /server/frontend/src/components/short-link-input-group.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, unsafeCSS } from "lit"; 2 | import { customElement, property } from "lit/decorators.js"; 3 | import globalStyles from "../index.css?inline"; 4 | 5 | @customElement("short-link-input-group") 6 | export class ShortLinkInputGroup extends LitElement { 7 | static styles = unsafeCSS(globalStyles); 8 | 9 | @property() 10 | id: string = ""; 11 | 12 | @property({ type: Number }) 13 | _screenSizeLevel: number = 0; 14 | 15 | @property() 16 | passwd: string = ""; 17 | 18 | connectedCallback() { 19 | super.connectedCallback(); 20 | window.addEventListener("resize", this._checkScreenSize); 21 | this._checkScreenSize(); // Initial check 22 | } 23 | 24 | disconnectedCallback() { 25 | window.removeEventListener("resize", this._checkScreenSize); 26 | super.disconnectedCallback(); 27 | } 28 | 29 | _checkScreenSize = () => { 30 | const width = window.innerWidth; 31 | if (width < 365) { 32 | this._screenSizeLevel = 0; // sm 33 | } else if (width < 640) { 34 | this._screenSizeLevel = 1; // md 35 | } else { 36 | this._screenSizeLevel = 2; // other 37 | } 38 | }; 39 | 40 | async copyToClipboard(content: string, e: HTMLButtonElement) { 41 | try { 42 | await navigator.clipboard.writeText(content); 43 | let text = e.textContent; 44 | e.addEventListener("mouseout", function () { 45 | e.textContent = text; 46 | }); 47 | e.textContent = "复制成功"; 48 | } catch (err) { 49 | console.error("复制到剪贴板失败:", err); 50 | } 51 | } 52 | 53 | idInputTemplate() { 54 | return html``; 67 | } 68 | 69 | passwdInputTemplate() { 70 | return html``; 83 | } 84 | 85 | generateBtnTemplate(extraClass: string = "") { 86 | return html``; 96 | } 97 | 98 | updateBtnTemplate(extraClass: string = "") { 99 | return html``; 107 | } 108 | 109 | deleteBtnTemplate(extraClass: string = "") { 110 | return html``; 118 | } 119 | 120 | copyBtnTemplate(extraClass: string = "") { 121 | return html``; 132 | } 133 | 134 | render() { 135 | const sm = html`
136 |
137 | ${this.idInputTemplate()} ${this.passwdInputTemplate()} 138 |
139 |
140 | ${this.generateBtnTemplate("w-1/2")} ${this.updateBtnTemplate("w-1/2")} 141 |
142 |
143 | ${this.deleteBtnTemplate("w-1/2")} ${this.copyBtnTemplate("w-1/2")} 144 |
145 |
`; 146 | 147 | const md = html`
148 |
149 | ${this.idInputTemplate()} ${this.passwdInputTemplate()} 150 |
151 |
152 | ${this.generateBtnTemplate("w-1/4")} ${this.updateBtnTemplate("w-1/4")} 153 | ${this.deleteBtnTemplate("w-1/4")} ${this.copyBtnTemplate("w-1/4")} 154 |
155 |
`; 156 | 157 | const other = html`
158 |
159 | ${this.idInputTemplate()} ${this.passwdInputTemplate()} 160 | ${this.generateBtnTemplate()} ${this.updateBtnTemplate()} 161 | ${this.deleteBtnTemplate()} ${this.copyBtnTemplate()} 162 |
163 |
`; 164 | 165 | switch (this._screenSizeLevel) { 166 | case 0: 167 | return sm; 168 | case 1: 169 | return md; 170 | default: 171 | return other; 172 | } 173 | } 174 | } 175 | 176 | declare global { 177 | interface HTMLElementTagNameMap { 178 | "short-link-input-group": ShortLinkInputGroup; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /test/parser/hysteria2_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bestnite/sub2clash/model/proxy" 7 | "github.com/bestnite/sub2clash/parser" 8 | ) 9 | 10 | func TestHysteria2_Basic_SimpleLink(t *testing.T) { 11 | p := &parser.Hysteria2Parser{} 12 | input := "hysteria2://password123@127.0.0.1:8080#Hysteria2%20Proxy" 13 | 14 | expected := proxy.Proxy{ 15 | Type: "hysteria2", 16 | Name: "Hysteria2 Proxy", 17 | Hysteria2: proxy.Hysteria2{ 18 | Server: "127.0.0.1", 19 | Port: 8080, 20 | Password: "password123", 21 | SkipCertVerify: false, 22 | }, 23 | } 24 | 25 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 26 | if err != nil { 27 | t.Errorf("Unexpected error: %v", err) 28 | return 29 | } 30 | 31 | validateResult(t, expected, result) 32 | } 33 | 34 | func TestHysteria2_Basic_AltPrefix(t *testing.T) { 35 | p := &parser.Hysteria2Parser{} 36 | input := "hy2://password123@proxy.example.com:443?insecure=1&sni=proxy.example.com#Hysteria2%20Alt" 37 | 38 | expected := proxy.Proxy{ 39 | Type: "hysteria2", 40 | Name: "Hysteria2 Alt", 41 | Hysteria2: proxy.Hysteria2{ 42 | Server: "proxy.example.com", 43 | Port: 443, 44 | Password: "password123", 45 | SNI: "proxy.example.com", 46 | SkipCertVerify: true, 47 | }, 48 | } 49 | 50 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 51 | if err != nil { 52 | t.Errorf("Unexpected error: %v", err) 53 | return 54 | } 55 | 56 | validateResult(t, expected, result) 57 | } 58 | 59 | func TestHysteria2_Basic_WithObfs(t *testing.T) { 60 | p := &parser.Hysteria2Parser{} 61 | input := "hysteria2://password123@127.0.0.1:8080?obfs=salamander&obfs-password=obfs123#Hysteria2%20Obfs" 62 | 63 | expected := proxy.Proxy{ 64 | Type: "hysteria2", 65 | Name: "Hysteria2 Obfs", 66 | Hysteria2: proxy.Hysteria2{ 67 | Server: "127.0.0.1", 68 | Port: 8080, 69 | Password: "password123", 70 | Obfs: "salamander", 71 | ObfsPassword: "obfs123", 72 | SkipCertVerify: false, 73 | }, 74 | } 75 | 76 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 77 | if err != nil { 78 | t.Errorf("Unexpected error: %v", err) 79 | return 80 | } 81 | 82 | validateResult(t, expected, result) 83 | } 84 | 85 | func TestHysteria2_Basic_IPv6Address(t *testing.T) { 86 | p := &parser.Hysteria2Parser{} 87 | input := "hysteria2://password123@[2001:db8::1]:8080#Hysteria2%20IPv6" 88 | 89 | expected := proxy.Proxy{ 90 | Type: "hysteria2", 91 | Name: "Hysteria2 IPv6", 92 | Hysteria2: proxy.Hysteria2{ 93 | Server: "2001:db8::1", 94 | Port: 8080, 95 | Password: "password123", 96 | SkipCertVerify: false, 97 | }, 98 | } 99 | 100 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 101 | if err != nil { 102 | t.Errorf("Unexpected error: %v", err) 103 | return 104 | } 105 | 106 | validateResult(t, expected, result) 107 | } 108 | 109 | func TestHysteria2_Basic_FullConfig(t *testing.T) { 110 | p := &parser.Hysteria2Parser{} 111 | input := "hysteria2://password123@proxy.example.com:443?insecure=1&sni=proxy.example.com&obfs=salamander&obfs-password=obfs123#Hysteria2%20Full" 112 | 113 | expected := proxy.Proxy{ 114 | Type: "hysteria2", 115 | Name: "Hysteria2 Full", 116 | Hysteria2: proxy.Hysteria2{ 117 | Server: "proxy.example.com", 118 | Port: 443, 119 | Password: "password123", 120 | SNI: "proxy.example.com", 121 | Obfs: "salamander", 122 | ObfsPassword: "obfs123", 123 | SkipCertVerify: true, 124 | }, 125 | } 126 | 127 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 128 | if err != nil { 129 | t.Errorf("Unexpected error: %v", err) 130 | return 131 | } 132 | 133 | validateResult(t, expected, result) 134 | } 135 | 136 | func TestHysteria2_Basic_NoPassword(t *testing.T) { 137 | p := &parser.Hysteria2Parser{} 138 | input := "hysteria2://@127.0.0.1:8080#No%20Password" 139 | 140 | expected := proxy.Proxy{ 141 | Type: "hysteria2", 142 | Name: "No Password", 143 | Hysteria2: proxy.Hysteria2{ 144 | Server: "127.0.0.1", 145 | Port: 8080, 146 | Password: "", 147 | SkipCertVerify: false, 148 | }, 149 | } 150 | 151 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 152 | if err != nil { 153 | t.Errorf("Unexpected error: %v", err) 154 | return 155 | } 156 | 157 | validateResult(t, expected, result) 158 | } 159 | 160 | func TestHysteria2_Error_MissingServer(t *testing.T) { 161 | p := &parser.Hysteria2Parser{} 162 | input := "hysteria2://password123@:8080" 163 | 164 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 165 | if err == nil { 166 | t.Errorf("Expected error but got none") 167 | } 168 | } 169 | 170 | func TestHysteria2_Error_MissingPort(t *testing.T) { 171 | p := &parser.Hysteria2Parser{} 172 | input := "hysteria2://password123@127.0.0.1" 173 | 174 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 175 | if err == nil { 176 | t.Errorf("Expected error but got none") 177 | } 178 | } 179 | 180 | func TestHysteria2_Error_InvalidPort(t *testing.T) { 181 | p := &parser.Hysteria2Parser{} 182 | input := "hysteria2://password123@127.0.0.1:99999" 183 | 184 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 185 | if err == nil { 186 | t.Errorf("Expected error but got none") 187 | } 188 | } 189 | 190 | func TestHysteria2_Error_InvalidProtocol(t *testing.T) { 191 | p := &parser.Hysteria2Parser{} 192 | input := "hysteria://example.com:8080" 193 | 194 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 195 | if err == nil { 196 | t.Errorf("Expected error but got none") 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /test/parser/anytls_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bestnite/sub2clash/model/proxy" 7 | "github.com/bestnite/sub2clash/parser" 8 | ) 9 | 10 | func TestAnytls_Basic_SimpleLink(t *testing.T) { 11 | p := &parser.AnytlsParser{} 12 | input := "anytls://password123@127.0.0.1:8080#Anytls%20Proxy" 13 | 14 | expected := proxy.Proxy{ 15 | Type: "anytls", 16 | Name: "Anytls Proxy", 17 | Anytls: proxy.Anytls{ 18 | Server: "127.0.0.1", 19 | Port: 8080, 20 | Password: "password123", 21 | SkipCertVerify: false, 22 | }, 23 | } 24 | 25 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 26 | if err != nil { 27 | t.Errorf("Unexpected error: %v", err) 28 | return 29 | } 30 | 31 | validateResult(t, expected, result) 32 | } 33 | 34 | func TestAnytls_Basic_WithSNI(t *testing.T) { 35 | p := &parser.AnytlsParser{} 36 | input := "anytls://password123@proxy.example.com:443?sni=proxy.example.com#Anytls%20SNI" 37 | 38 | expected := proxy.Proxy{ 39 | Type: "anytls", 40 | Name: "Anytls SNI", 41 | Anytls: proxy.Anytls{ 42 | Server: "proxy.example.com", 43 | Port: 443, 44 | Password: "password123", 45 | SNI: "proxy.example.com", 46 | SkipCertVerify: false, 47 | }, 48 | } 49 | 50 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 51 | if err != nil { 52 | t.Errorf("Unexpected error: %v", err) 53 | return 54 | } 55 | 56 | validateResult(t, expected, result) 57 | } 58 | 59 | func TestAnytls_Basic_WithInsecure(t *testing.T) { 60 | p := &parser.AnytlsParser{} 61 | input := "anytls://password123@proxy.example.com:443?insecure=1&sni=proxy.example.com#Anytls%20Insecure" 62 | 63 | expected := proxy.Proxy{ 64 | Type: "anytls", 65 | Name: "Anytls Insecure", 66 | Anytls: proxy.Anytls{ 67 | Server: "proxy.example.com", 68 | Port: 443, 69 | Password: "password123", 70 | SNI: "proxy.example.com", 71 | SkipCertVerify: true, 72 | }, 73 | } 74 | 75 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 76 | if err != nil { 77 | t.Errorf("Unexpected error: %v", err) 78 | return 79 | } 80 | 81 | validateResult(t, expected, result) 82 | } 83 | 84 | func TestAnytls_Basic_IPv6Address(t *testing.T) { 85 | p := &parser.AnytlsParser{} 86 | input := "anytls://password123@[2001:db8::1]:8080#Anytls%20IPv6" 87 | 88 | expected := proxy.Proxy{ 89 | Type: "anytls", 90 | Name: "Anytls IPv6", 91 | Anytls: proxy.Anytls{ 92 | Server: "2001:db8::1", 93 | Port: 8080, 94 | Password: "password123", 95 | SkipCertVerify: false, 96 | }, 97 | } 98 | 99 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 100 | if err != nil { 101 | t.Errorf("Unexpected error: %v", err) 102 | return 103 | } 104 | 105 | validateResult(t, expected, result) 106 | } 107 | 108 | func TestAnytls_Basic_ComplexPassword(t *testing.T) { 109 | p := &parser.AnytlsParser{} 110 | input := "anytls://ComplexPassword!%40%23%24@proxy.example.com:8443?sni=example.com&insecure=1#Anytls%20Full" 111 | 112 | expected := proxy.Proxy{ 113 | Type: "anytls", 114 | Name: "Anytls Full", 115 | Anytls: proxy.Anytls{ 116 | Server: "proxy.example.com", 117 | Port: 8443, 118 | Password: "ComplexPassword!@#$", 119 | SNI: "example.com", 120 | SkipCertVerify: true, 121 | }, 122 | } 123 | 124 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 125 | if err != nil { 126 | t.Errorf("Unexpected error: %v", err) 127 | return 128 | } 129 | 130 | validateResult(t, expected, result) 131 | } 132 | 133 | func TestAnytls_Basic_NoPassword(t *testing.T) { 134 | p := &parser.AnytlsParser{} 135 | input := "anytls://@127.0.0.1:8080#No%20Password" 136 | 137 | expected := proxy.Proxy{ 138 | Type: "anytls", 139 | Name: "No Password", 140 | Anytls: proxy.Anytls{ 141 | Server: "127.0.0.1", 142 | Port: 8080, 143 | Password: "", 144 | SkipCertVerify: false, 145 | }, 146 | } 147 | 148 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 149 | if err != nil { 150 | t.Errorf("Unexpected error: %v", err) 151 | return 152 | } 153 | 154 | validateResult(t, expected, result) 155 | } 156 | 157 | func TestAnytls_Basic_UsernameOnly(t *testing.T) { 158 | p := &parser.AnytlsParser{} 159 | input := "anytls://username@127.0.0.1:8080#Username%20Only" 160 | 161 | expected := proxy.Proxy{ 162 | Type: "anytls", 163 | Name: "Username Only", 164 | Anytls: proxy.Anytls{ 165 | Server: "127.0.0.1", 166 | Port: 8080, 167 | Password: "username", 168 | SkipCertVerify: false, 169 | }, 170 | } 171 | 172 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 173 | if err != nil { 174 | t.Errorf("Unexpected error: %v", err) 175 | return 176 | } 177 | 178 | validateResult(t, expected, result) 179 | } 180 | 181 | func TestAnytls_Error_MissingServer(t *testing.T) { 182 | p := &parser.AnytlsParser{} 183 | input := "anytls://password123@:8080" 184 | 185 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 186 | if err == nil { 187 | t.Errorf("Expected error but got none") 188 | } 189 | } 190 | 191 | func TestAnytls_Error_MissingPort(t *testing.T) { 192 | p := &parser.AnytlsParser{} 193 | input := "anytls://password123@127.0.0.1" 194 | 195 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 196 | if err == nil { 197 | t.Errorf("Expected error but got none") 198 | } 199 | } 200 | 201 | func TestAnytls_Error_InvalidPort(t *testing.T) { 202 | p := &parser.AnytlsParser{} 203 | input := "anytls://password123@127.0.0.1:99999" 204 | 205 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 206 | if err == nil { 207 | t.Errorf("Expected error but got none") 208 | } 209 | } 210 | 211 | func TestAnytls_Error_InvalidProtocol(t *testing.T) { 212 | p := &parser.AnytlsParser{} 213 | input := "anyssl://example.com:8080" 214 | 215 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 216 | if err == nil { 217 | t.Errorf("Expected error but got none") 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /test/parser/vless_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bestnite/sub2clash/model/proxy" 7 | "github.com/bestnite/sub2clash/parser" 8 | ) 9 | 10 | func TestVless_Basic_SimpleLink(t *testing.T) { 11 | p := &parser.VlessParser{} 12 | input := "vless://b831b0c4-33b7-4873-9834-28d66d87d4ce@127.0.0.1:8080#VLESS%20Proxy" 13 | 14 | expected := proxy.Proxy{ 15 | Type: "vless", 16 | Name: "VLESS Proxy", 17 | Vless: proxy.Vless{ 18 | Server: "127.0.0.1", 19 | Port: 8080, 20 | UUID: "b831b0c4-33b7-4873-9834-28d66d87d4ce", 21 | }, 22 | } 23 | 24 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 25 | if err != nil { 26 | t.Errorf("Unexpected error: %v", err) 27 | return 28 | } 29 | 30 | validateResult(t, expected, result) 31 | } 32 | 33 | func TestVless_Basic_WithTLS(t *testing.T) { 34 | p := &parser.VlessParser{} 35 | input := "vless://b831b0c4-33b7-4873-9834-28d66d87d4ce@127.0.0.1:443?security=tls&sni=example.com&alpn=h2,http/1.1#VLESS%20TLS" 36 | 37 | expected := proxy.Proxy{ 38 | Type: "vless", 39 | Name: "VLESS TLS", 40 | Vless: proxy.Vless{ 41 | Server: "127.0.0.1", 42 | Port: 443, 43 | UUID: "b831b0c4-33b7-4873-9834-28d66d87d4ce", 44 | TLS: true, 45 | ALPN: []string{"h2", "http/1.1"}, 46 | ServerName: "example.com", 47 | }, 48 | } 49 | 50 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 51 | if err != nil { 52 | t.Errorf("Unexpected error: %v", err) 53 | return 54 | } 55 | 56 | validateResult(t, expected, result) 57 | } 58 | 59 | func TestVless_Basic_WithReality(t *testing.T) { 60 | p := &parser.VlessParser{} 61 | input := "vless://b831b0c4-33b7-4873-9834-28d66d87d4ce@127.0.0.1:443?security=reality&sni=example.com&pbk=publickey123&sid=shortid123&fp=chrome#VLESS%20Reality" 62 | 63 | expected := proxy.Proxy{ 64 | Type: "vless", 65 | Name: "VLESS Reality", 66 | Vless: proxy.Vless{ 67 | Server: "127.0.0.1", 68 | Port: 443, 69 | UUID: "b831b0c4-33b7-4873-9834-28d66d87d4ce", 70 | TLS: true, 71 | ServerName: "example.com", 72 | RealityOpts: proxy.RealityOptions{ 73 | PublicKey: "publickey123", 74 | ShortID: "shortid123", 75 | }, 76 | Fingerprint: "chrome", 77 | }, 78 | } 79 | 80 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 81 | if err != nil { 82 | t.Errorf("Unexpected error: %v", err) 83 | return 84 | } 85 | 86 | validateResult(t, expected, result) 87 | } 88 | 89 | func TestVless_Basic_WithWebSocket(t *testing.T) { 90 | p := &parser.VlessParser{} 91 | input := "vless://b831b0c4-33b7-4873-9834-28d66d87d4ce@127.0.0.1:443?type=ws&path=/ws&host=example.com#VLESS%20WS" 92 | 93 | expected := proxy.Proxy{ 94 | Type: "vless", 95 | Name: "VLESS WS", 96 | Vless: proxy.Vless{ 97 | Server: "127.0.0.1", 98 | Port: 443, 99 | UUID: "b831b0c4-33b7-4873-9834-28d66d87d4ce", 100 | Network: "ws", 101 | WSOpts: proxy.WSOptions{ 102 | Path: "/ws", 103 | Headers: map[string]string{ 104 | "Host": "example.com", 105 | }, 106 | }, 107 | }, 108 | } 109 | 110 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 111 | if err != nil { 112 | t.Errorf("Unexpected error: %v", err) 113 | return 114 | } 115 | 116 | validateResult(t, expected, result) 117 | } 118 | 119 | func TestVless_Basic_WithGrpc(t *testing.T) { 120 | p := &parser.VlessParser{} 121 | input := "vless://b831b0c4-33b7-4873-9834-28d66d87d4ce@127.0.0.1:443?type=grpc&serviceName=grpc_service#VLESS%20gRPC" 122 | 123 | expected := proxy.Proxy{ 124 | Type: "vless", 125 | Name: "VLESS gRPC", 126 | Vless: proxy.Vless{ 127 | Server: "127.0.0.1", 128 | Port: 443, 129 | UUID: "b831b0c4-33b7-4873-9834-28d66d87d4ce", 130 | Network: "grpc", 131 | GrpcOpts: proxy.GrpcOptions{ 132 | GrpcServiceName: "grpc_service", 133 | }, 134 | }, 135 | } 136 | 137 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 138 | if err != nil { 139 | t.Errorf("Unexpected error: %v", err) 140 | return 141 | } 142 | 143 | validateResult(t, expected, result) 144 | } 145 | 146 | func TestVless_Basic_WithHTTP(t *testing.T) { 147 | p := &parser.VlessParser{} 148 | input := "vless://b831b0c4-33b7-4873-9834-28d66d87d4ce@127.0.0.1:443?type=http&path=/path1,/path2&host=host1.com,host2.com#VLESS%20HTTP" 149 | 150 | expected := proxy.Proxy{ 151 | Type: "vless", 152 | Name: "VLESS HTTP", 153 | Vless: proxy.Vless{ 154 | Server: "127.0.0.1", 155 | Port: 443, 156 | UUID: "b831b0c4-33b7-4873-9834-28d66d87d4ce", 157 | Network: "http", 158 | HTTPOpts: proxy.HTTPOptions{ 159 | Path: []string{"/path1", "/path2"}, 160 | Headers: map[string][]string{ 161 | "host": {"host1.com", "host2.com"}, 162 | }, 163 | }, 164 | }, 165 | } 166 | 167 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 168 | if err != nil { 169 | t.Errorf("Unexpected error: %v", err) 170 | return 171 | } 172 | 173 | validateResult(t, expected, result) 174 | } 175 | 176 | func TestVless_Error_MissingServer(t *testing.T) { 177 | p := &parser.VlessParser{} 178 | input := "vless://b831b0c4-33b7-4873-9834-28d66d87d4ce@:8080" 179 | 180 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 181 | if err == nil { 182 | t.Errorf("Expected error but got none") 183 | } 184 | } 185 | 186 | func TestVless_Error_MissingPort(t *testing.T) { 187 | p := &parser.VlessParser{} 188 | input := "vless://b831b0c4-33b7-4873-9834-28d66d87d4ce@127.0.0.1" 189 | 190 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 191 | if err == nil { 192 | t.Errorf("Expected error but got none") 193 | } 194 | } 195 | 196 | func TestVless_Error_InvalidPort(t *testing.T) { 197 | p := &parser.VlessParser{} 198 | input := "vless://b831b0c4-33b7-4873-9834-28d66d87d4ce@127.0.0.1:99999" 199 | 200 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 201 | if err == nil { 202 | t.Errorf("Expected error but got none") 203 | } 204 | } 205 | 206 | func TestVless_Error_InvalidProtocol(t *testing.T) { 207 | p := &parser.VlessParser{} 208 | input := "ss://example.com:8080" 209 | 210 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 211 | if err == nil { 212 | t.Errorf("Expected error but got none") 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /common/errors.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // CommonError represents a structured error type for the common package 9 | type CommonError struct { 10 | Code ErrorCode 11 | Message string 12 | Cause error 13 | } 14 | 15 | // ErrorCode represents different types of errors 16 | type ErrorCode string 17 | 18 | const ( 19 | // Directory operation errors 20 | ErrDirCreation ErrorCode = "DIRECTORY_CREATION_FAILED" 21 | ErrDirAccess ErrorCode = "DIRECTORY_ACCESS_FAILED" 22 | 23 | // File operation errors 24 | ErrFileNotFound ErrorCode = "FILE_NOT_FOUND" 25 | ErrFileRead ErrorCode = "FILE_READ_FAILED" 26 | ErrFileWrite ErrorCode = "FILE_WRITE_FAILED" 27 | ErrFileCreate ErrorCode = "FILE_CREATE_FAILED" 28 | 29 | // Network operation errors 30 | ErrNetworkRequest ErrorCode = "NETWORK_REQUEST_FAILED" 31 | ErrNetworkResponse ErrorCode = "NETWORK_RESPONSE_FAILED" 32 | 33 | // Template and configuration errors 34 | ErrTemplateLoad ErrorCode = "TEMPLATE_LOAD_FAILED" 35 | ErrTemplateParse ErrorCode = "TEMPLATE_PARSE_FAILED" 36 | ErrConfigInvalid ErrorCode = "CONFIG_INVALID" 37 | 38 | // Subscription errors 39 | ErrSubscriptionLoad ErrorCode = "SUBSCRIPTION_LOAD_FAILED" 40 | ErrSubscriptionParse ErrorCode = "SUBSCRIPTION_PARSE_FAILED" 41 | 42 | // Regex errors 43 | ErrRegexCompile ErrorCode = "REGEX_COMPILE_FAILED" 44 | ErrRegexInvalid ErrorCode = "REGEX_INVALID" 45 | 46 | // Database errors 47 | ErrDatabaseConnect ErrorCode = "DATABASE_CONNECTION_FAILED" 48 | ErrDatabaseQuery ErrorCode = "DATABASE_QUERY_FAILED" 49 | ErrRecordNotFound ErrorCode = "RECORD_NOT_FOUND" 50 | 51 | // Validation errors 52 | ErrValidation ErrorCode = "VALIDATION_FAILED" 53 | ErrInvalidInput ErrorCode = "INVALID_INPUT" 54 | ) 55 | 56 | // Error returns the string representation of the error 57 | func (e *CommonError) Error() string { 58 | if e.Cause != nil { 59 | return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause) 60 | } 61 | return fmt.Sprintf("[%s] %s", e.Code, e.Message) 62 | } 63 | 64 | // Unwrap returns the underlying error 65 | func (e *CommonError) Unwrap() error { 66 | return e.Cause 67 | } 68 | 69 | // Is allows error comparison 70 | func (e *CommonError) Is(target error) bool { 71 | if t, ok := target.(*CommonError); ok { 72 | return e.Code == t.Code 73 | } 74 | return false 75 | } 76 | 77 | // NewError creates a new CommonError 78 | func NewError(code ErrorCode, message string, cause error) *CommonError { 79 | return &CommonError{ 80 | Code: code, 81 | Message: message, 82 | Cause: cause, 83 | } 84 | } 85 | 86 | // NewSimpleError creates a new CommonError without a cause 87 | func NewSimpleError(code ErrorCode, message string) *CommonError { 88 | return &CommonError{ 89 | Code: code, 90 | Message: message, 91 | } 92 | } 93 | 94 | // Convenience constructors for common error types 95 | 96 | // Directory errors 97 | func NewDirCreationError(dirPath string, cause error) *CommonError { 98 | return NewError(ErrDirCreation, fmt.Sprintf("failed to create directory: %s", dirPath), cause) 99 | } 100 | 101 | func NewDirAccessError(dirPath string, cause error) *CommonError { 102 | return NewError(ErrDirAccess, fmt.Sprintf("failed to access directory: %s", dirPath), cause) 103 | } 104 | 105 | // File errors 106 | func NewFileNotFoundError(filePath string) *CommonError { 107 | return NewSimpleError(ErrFileNotFound, fmt.Sprintf("file not found: %s", filePath)) 108 | } 109 | 110 | func NewFileReadError(filePath string, cause error) *CommonError { 111 | return NewError(ErrFileRead, fmt.Sprintf("failed to read file: %s", filePath), cause) 112 | } 113 | 114 | func NewFileWriteError(filePath string, cause error) *CommonError { 115 | return NewError(ErrFileWrite, fmt.Sprintf("failed to write file: %s", filePath), cause) 116 | } 117 | 118 | func NewFileCreateError(filePath string, cause error) *CommonError { 119 | return NewError(ErrFileCreate, fmt.Sprintf("failed to create file: %s", filePath), cause) 120 | } 121 | 122 | // Network errors 123 | func NewNetworkRequestError(url string, cause error) *CommonError { 124 | return NewError(ErrNetworkRequest, fmt.Sprintf("network request failed for URL: %s", url), cause) 125 | } 126 | 127 | func NewNetworkResponseError(message string, cause error) *CommonError { 128 | return NewError(ErrNetworkResponse, message, cause) 129 | } 130 | 131 | // Template errors 132 | func NewTemplateLoadError(template string, cause error) *CommonError { 133 | return NewError(ErrTemplateLoad, fmt.Sprintf("failed to load template: %s", template), cause) 134 | } 135 | 136 | func NewTemplateParseError(data []byte, cause error) *CommonError { 137 | return NewError(ErrTemplateParse, fmt.Sprintf("failed to parse template: %s", data), cause) 138 | } 139 | 140 | // Subscription errors 141 | func NewSubscriptionLoadError(url string, cause error) *CommonError { 142 | return NewError(ErrSubscriptionLoad, fmt.Sprintf("failed to load subscription: %s", url), cause) 143 | } 144 | 145 | func NewSubscriptionParseError(data []byte, cause error) *CommonError { 146 | return NewError(ErrSubscriptionParse, fmt.Sprintf("failed to parse subscription: %s", string(data)), cause) 147 | } 148 | 149 | // Regex errors 150 | func NewRegexCompileError(pattern string, cause error) *CommonError { 151 | return NewError(ErrRegexCompile, fmt.Sprintf("failed to compile regex pattern: %s", pattern), cause) 152 | } 153 | 154 | func NewRegexInvalidError(paramName string, cause error) *CommonError { 155 | return NewError(ErrRegexInvalid, fmt.Sprintf("invalid regex in parameter: %s", paramName), cause) 156 | } 157 | 158 | // Database errors 159 | func NewDatabaseConnectError(cause error) *CommonError { 160 | return NewError(ErrDatabaseConnect, "failed to connect to database", cause) 161 | } 162 | 163 | func NewRecordNotFoundError(recordType string, id string) *CommonError { 164 | return NewSimpleError(ErrRecordNotFound, fmt.Sprintf("%s not found: %s", recordType, id)) 165 | } 166 | 167 | // Validation errors 168 | func NewValidationError(field string, message string) *CommonError { 169 | return NewSimpleError(ErrValidation, fmt.Sprintf("validation failed for %s: %s", field, message)) 170 | } 171 | 172 | func NewInvalidInputError(paramName string, value string) *CommonError { 173 | return NewSimpleError(ErrInvalidInput, fmt.Sprintf("invalid input for parameter %s: %s", paramName, value)) 174 | } 175 | 176 | // IsErrorCode checks if an error has a specific error code 177 | func IsErrorCode(err error, code ErrorCode) bool { 178 | var commonErr *CommonError 179 | if errors.As(err, &commonErr) { 180 | return commonErr.Code == code 181 | } 182 | return false 183 | } 184 | 185 | // GetErrorCode extracts the error code from an error 186 | func GetErrorCode(err error) (ErrorCode, bool) { 187 | var commonErr *CommonError 188 | if errors.As(err, &commonErr) { 189 | return commonErr.Code, true 190 | } 191 | return "", false 192 | } 193 | -------------------------------------------------------------------------------- /test/parser/vmess_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/bestnite/sub2clash/model/proxy" 7 | "github.com/bestnite/sub2clash/parser" 8 | ) 9 | 10 | func TestVmess_Basic_SimpleLink(t *testing.T) { 11 | p := &parser.VmessParser{} 12 | input := "vmess://eyJhZGQiOiIxMjcuMC4wLjEiLCJhaWQiOiIwIiwiaWQiOiIxMjM0NTY3OC05MDEyLTM0NTYtNzg5MC0xMjM0NTY3ODkwMTIiLCJuZXQiOiJ3cyIsInBvcnQiOiI0NDMiLCJwcyI6IkhBSEEiLCJ0bHMiOiJ0bHMiLCJ0eXBlIjoibm9uZSIsInYiOiIyIn0=" 13 | 14 | expected := proxy.Proxy{ 15 | Type: "vmess", 16 | Name: "HAHA", 17 | Vmess: proxy.Vmess{ 18 | UUID: "12345678-9012-3456-7890-123456789012", 19 | AlterID: 0, 20 | Cipher: "auto", 21 | Server: "127.0.0.1", 22 | Port: 443, 23 | TLS: true, 24 | Network: "ws", 25 | WSOpts: proxy.WSOptions{ 26 | Path: "/", 27 | Headers: map[string]string{ 28 | "Host": "127.0.0.1", 29 | }, 30 | }, 31 | }, 32 | } 33 | 34 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 35 | if err != nil { 36 | t.Errorf("Unexpected error: %v", err) 37 | return 38 | } 39 | 40 | validateResult(t, expected, result) 41 | } 42 | 43 | func TestVmess_Basic_WithPath(t *testing.T) { 44 | p := &parser.VmessParser{} 45 | input := "vmess://eyJhZGQiOiIxMjcuMC4wLjEiLCJhaWQiOiIwIiwiaWQiOiIxMjM0NTY3OC05MDEyLTM0NTYtNzg5MC0xMjM0NTY3ODkwMTIiLCJuZXQiOiJ3cyIsInBhdGgiOiIvd3MiLCJwb3J0IjoiNDQzIiwicHMiOiJIQUNLIiwidGxzIjoidGxzIiwidHlwZSI6Im5vbmUiLCJ2IjoiMiJ9" 46 | 47 | expected := proxy.Proxy{ 48 | Type: "vmess", 49 | Name: "HACK", 50 | Vmess: proxy.Vmess{ 51 | UUID: "12345678-9012-3456-7890-123456789012", 52 | AlterID: 0, 53 | Cipher: "auto", 54 | Server: "127.0.0.1", 55 | Port: 443, 56 | TLS: true, 57 | Network: "ws", 58 | WSOpts: proxy.WSOptions{ 59 | Path: "/ws", 60 | Headers: map[string]string{ 61 | "Host": "127.0.0.1", 62 | }, 63 | }, 64 | }, 65 | } 66 | 67 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 68 | if err != nil { 69 | t.Errorf("Unexpected error: %v", err) 70 | return 71 | } 72 | 73 | validateResult(t, expected, result) 74 | } 75 | 76 | func TestVmess_Basic_WithHost(t *testing.T) { 77 | p := &parser.VmessParser{} 78 | input := "vmess://eyJhZGQiOiIxMjcuMC4wLjEiLCJhaWQiOiIwIiwiaG9zdCI6ImV4YW1wbGUuY29tIiwiaWQiOiIxMjM0NTY3OC05MDEyLTM0NTYtNzg5MC0xMjM0NTY3ODkwMTIiLCJuZXQiOiJ3cyIsInBvcnQiOiI0NDMiLCJwcyI6IkhBSEEiLCJ0bHMiOiJ0bHMiLCJ0eXBlIjoibm9uZSIsInYiOiIyIn0=" 79 | 80 | expected := proxy.Proxy{ 81 | Type: "vmess", 82 | Name: "HAHA", 83 | Vmess: proxy.Vmess{ 84 | UUID: "12345678-9012-3456-7890-123456789012", 85 | AlterID: 0, 86 | Cipher: "auto", 87 | Server: "127.0.0.1", 88 | Port: 443, 89 | TLS: true, 90 | Network: "ws", 91 | WSOpts: proxy.WSOptions{ 92 | Path: "/", 93 | Headers: map[string]string{ 94 | "Host": "example.com", 95 | }, 96 | }, 97 | }, 98 | } 99 | 100 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 101 | if err != nil { 102 | t.Errorf("Unexpected error: %v", err) 103 | return 104 | } 105 | 106 | validateResult(t, expected, result) 107 | } 108 | 109 | func TestVmess_Basic_WithSNI(t *testing.T) { 110 | p := &parser.VmessParser{} 111 | input := "vmess://eyJhZGQiOiIxMjcuMC4wLjEiLCJhaWQiOiIwIiwiaWQiOiIxMjM0NTY3OC05MDEyLTM0NTYtNzg5MC0xMjM0NTY3ODkwMTIiLCJuZXQiOiJ3cyIsInBvcnQiOiI0NDMiLCJwcyI6IkhBSEEiLCJzbmkiOiJleGFtcGxlLmNvbSIsInRscyI6InRscyIsInR5cGUiOiJub25lIiwidiI6IjIifQ==" 112 | 113 | expected := proxy.Proxy{ 114 | Type: "vmess", 115 | Name: "HAHA", 116 | Vmess: proxy.Vmess{ 117 | UUID: "12345678-9012-3456-7890-123456789012", 118 | AlterID: 0, 119 | Cipher: "auto", 120 | Server: "127.0.0.1", 121 | Port: 443, 122 | TLS: true, 123 | Network: "ws", 124 | ServerName: "example.com", 125 | WSOpts: proxy.WSOptions{ 126 | Path: "/", 127 | Headers: map[string]string{ 128 | "Host": "127.0.0.1", 129 | }, 130 | }, 131 | }, 132 | } 133 | 134 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 135 | if err != nil { 136 | t.Errorf("Unexpected error: %v", err) 137 | return 138 | } 139 | 140 | validateResult(t, expected, result) 141 | } 142 | 143 | func TestVmess_Basic_WithAlterID(t *testing.T) { 144 | p := &parser.VmessParser{} 145 | input := "vmess://eyJhZGQiOiIxMjcuMC4wLjEiLCJhaWQiOiIxIiwiaWQiOiIxMjM0NTY3OC05MDEyLTM0NTYtNzg5MC0xMjM0NTY3ODkwMTIiLCJuZXQiOiJ3cyIsInBvcnQiOiI0NDMiLCJwcyI6IkhBSEEiLCJ0bHMiOiJ0bHMiLCJ0eXBlIjoibm9uZSIsInYiOiIyIn0=" 146 | 147 | expected := proxy.Proxy{ 148 | Type: "vmess", 149 | Name: "HAHA", 150 | Vmess: proxy.Vmess{ 151 | UUID: "12345678-9012-3456-7890-123456789012", 152 | AlterID: 1, 153 | Cipher: "auto", 154 | Server: "127.0.0.1", 155 | Port: 443, 156 | TLS: true, 157 | Network: "ws", 158 | WSOpts: proxy.WSOptions{ 159 | Path: "/", 160 | Headers: map[string]string{ 161 | "Host": "127.0.0.1", 162 | }, 163 | }, 164 | }, 165 | } 166 | 167 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 168 | if err != nil { 169 | t.Errorf("Unexpected error: %v", err) 170 | return 171 | } 172 | 173 | validateResult(t, expected, result) 174 | } 175 | 176 | func TestVmess_Basic_GRPC(t *testing.T) { 177 | p := &parser.VmessParser{} 178 | input := "vmess://eyJhZGQiOiIxMjcuMC4wLjEiLCJhaWQiOiIwIiwiaWQiOiIxMjM0NTY3OC05MDEyLTM0NTYtNzg5MC0xMjM0NTY3ODkwMTIiLCJuZXQiOiJncnBjIiwicG9ydCI6IjQ0MyIsInBzIjoiSEFIQSIsInRscyI6InRscyIsInR5cGUiOiJub25lIiwidiI6IjIifQ==" 179 | 180 | expected := proxy.Proxy{ 181 | Type: "vmess", 182 | Name: "HAHA", 183 | Vmess: proxy.Vmess{ 184 | UUID: "12345678-9012-3456-7890-123456789012", 185 | AlterID: 0, 186 | Cipher: "auto", 187 | Server: "127.0.0.1", 188 | Port: 443, 189 | TLS: true, 190 | Network: "grpc", 191 | GrpcOpts: proxy.GrpcOptions{}, 192 | }, 193 | } 194 | 195 | result, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 196 | if err != nil { 197 | t.Errorf("Unexpected error: %v", err) 198 | return 199 | } 200 | 201 | validateResult(t, expected, result) 202 | } 203 | 204 | func TestVmess_Error_InvalidBase64(t *testing.T) { 205 | p := &parser.VmessParser{} 206 | input := "vmess://invalid_base64" 207 | 208 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 209 | if err == nil { 210 | t.Errorf("Expected error but got none") 211 | } 212 | } 213 | 214 | func TestVmess_Error_InvalidJSON(t *testing.T) { 215 | p := &parser.VmessParser{} 216 | input := "vmess://eyJpbnZhbGlkIjoianNvbn0=" 217 | 218 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 219 | if err == nil { 220 | t.Errorf("Expected error but got none") 221 | } 222 | } 223 | 224 | func TestVmess_Error_InvalidProtocol(t *testing.T) { 225 | p := &parser.VmessParser{} 226 | input := "ss://example.com:8080" 227 | 228 | _, err := p.Parse(parser.ParseConfig{UseUDP: false}, input) 229 | if err == nil { 230 | t.Errorf("Expected error but got none") 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /server/handler/short_link.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/bestnite/sub2clash/common" 12 | "github.com/bestnite/sub2clash/common/database" 13 | "github.com/bestnite/sub2clash/config" 14 | "github.com/bestnite/sub2clash/model" 15 | M "github.com/bestnite/sub2clash/model" 16 | "gopkg.in/yaml.v3" 17 | 18 | "github.com/gin-gonic/gin" 19 | ) 20 | 21 | type shortLinkGenRequset struct { 22 | Config model.ConvertConfig `form:"config" binding:"required"` 23 | Password string `form:"password"` 24 | ID string `form:"id"` 25 | } 26 | 27 | type shortLinkUpdateRequest struct { 28 | Config model.ConvertConfig `form:"config" binding:"required"` 29 | Password string `form:"password" binding:"required"` 30 | ID string `form:"id" binding:"required"` 31 | } 32 | 33 | var DB *database.Database 34 | 35 | func init() { 36 | var err error 37 | DB, err = database.ConnectDB() 38 | if err != nil { 39 | log.Printf("failed to connect to database: %v", err) 40 | os.Exit(1) 41 | } 42 | } 43 | 44 | func GenerateLinkHandler(c *gin.Context) { 45 | var params shortLinkGenRequset 46 | if err := c.ShouldBind(¶ms); err != nil { 47 | c.String(http.StatusBadRequest, "参数错误: "+err.Error()) 48 | return 49 | } 50 | 51 | var id string 52 | var password string 53 | var err error 54 | 55 | if params.ID != "" { 56 | // 检查自定义ID是否已存在 57 | exists, err := DB.CheckShortLinkIDExists(params.ID) 58 | if err != nil { 59 | c.String(http.StatusInternalServerError, "数据库错误") 60 | return 61 | } 62 | if exists { 63 | c.String(http.StatusBadRequest, "短链已存在") 64 | return 65 | } 66 | id = params.ID 67 | password = params.Password 68 | } else { 69 | // 自动生成短链ID和密码 70 | id, err = generateUniqueHash(config.GlobalConfig.ShortLinkLength) 71 | if err != nil { 72 | c.String(http.StatusInternalServerError, "生成短链失败") 73 | return 74 | } 75 | if params.Password == "" { 76 | password = common.RandomString(8) // 生成8位随机密码 77 | } else { 78 | password = params.Password 79 | } 80 | } 81 | 82 | shortLink := model.ShortLink{ 83 | ID: id, 84 | Config: params.Config, 85 | Password: password, 86 | } 87 | 88 | if err := DB.CreateShortLink(&shortLink); err != nil { 89 | c.String(http.StatusInternalServerError, "数据库错误") 90 | return 91 | } 92 | 93 | // 返回生成的短链ID和密码 94 | response := map[string]string{ 95 | "id": id, 96 | "password": password, 97 | } 98 | c.JSON(http.StatusOK, response) 99 | } 100 | 101 | func generateUniqueHash(length int) (string, error) { 102 | for { 103 | hash := common.RandomString(length) 104 | exists, err := DB.CheckShortLinkIDExists(hash) 105 | if err != nil { 106 | return "", err 107 | } 108 | if !exists { 109 | return hash, nil 110 | } 111 | } 112 | } 113 | 114 | func UpdateLinkHandler(c *gin.Context) { 115 | var params shortLinkUpdateRequest 116 | if err := c.ShouldBindJSON(¶ms); err != nil { 117 | c.String(http.StatusBadRequest, "参数错误: "+err.Error()) 118 | return 119 | } 120 | 121 | // 先获取原有的短链 122 | existingLink, err := DB.FindShortLinkByID(params.ID) 123 | if err != nil { 124 | c.String(http.StatusUnauthorized, "短链不存在或密码错误") 125 | return 126 | } 127 | 128 | // 验证密码 129 | if existingLink.Password != params.Password { 130 | c.String(http.StatusUnauthorized, "短链不存在或密码错误") 131 | return 132 | } 133 | 134 | jsonData, err := json.Marshal(params.Config) 135 | if err != nil { 136 | c.String(http.StatusBadRequest, "配置格式错误") 137 | return 138 | } 139 | if err := DB.UpdataShortLink(params.ID, "config", jsonData); err != nil { 140 | c.String(http.StatusInternalServerError, "数据库错误") 141 | return 142 | } 143 | 144 | c.String(http.StatusOK, "短链更新成功") 145 | } 146 | 147 | func GetRawConfHandler(c *gin.Context) { 148 | id := c.Param("id") 149 | password := c.Query("password") 150 | 151 | if strings.TrimSpace(id) == "" { 152 | c.String(http.StatusBadRequest, "参数错误") 153 | return 154 | } 155 | 156 | shortLink, err := DB.FindShortLinkByID(id) 157 | if err != nil { 158 | c.String(http.StatusUnauthorized, "短链不存在或密码错误") 159 | return 160 | } 161 | 162 | if shortLink.Password != "" && shortLink.Password != password { 163 | c.String(http.StatusUnauthorized, "短链不存在或密码错误") 164 | return 165 | } 166 | 167 | err = DB.UpdataShortLink(shortLink.ID, "last_request_time", time.Now().Unix()) 168 | if err != nil { 169 | c.String(http.StatusInternalServerError, "数据库错误") 170 | return 171 | } 172 | 173 | template := "" 174 | switch shortLink.Config.ClashType { 175 | case model.Clash: 176 | template = config.GlobalConfig.ClashTemplate 177 | case model.ClashMeta: 178 | template = config.GlobalConfig.MetaTemplate 179 | } 180 | sub, err := common.BuildSub(shortLink.Config.ClashType, shortLink.Config, template, config.GlobalConfig.CacheExpire, config.GlobalConfig.RequestRetryTimes) 181 | if err != nil { 182 | c.String(http.StatusInternalServerError, err.Error()) 183 | return 184 | } 185 | 186 | if len(shortLink.Config.Subs) == 1 { 187 | userInfoHeader, err := common.FetchSubscriptionUserInfo(shortLink.Config.Subs[0], "clash", config.GlobalConfig.RequestRetryTimes) 188 | if err == nil { 189 | c.Header("subscription-userinfo", userInfoHeader) 190 | } 191 | } 192 | 193 | if shortLink.Config.NodeListMode { 194 | nodelist := M.NodeList{} 195 | nodelist.Proxy = sub.Proxy 196 | marshal, err := yaml.Marshal(nodelist) 197 | if err != nil { 198 | c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error()) 199 | return 200 | } 201 | c.String(http.StatusOK, string(marshal)) 202 | return 203 | } 204 | marshal, err := yaml.Marshal(sub) 205 | if err != nil { 206 | c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error()) 207 | return 208 | } 209 | 210 | c.String(http.StatusOK, string(marshal)) 211 | } 212 | 213 | func GetRawConfUriHandler(c *gin.Context) { 214 | id := c.Param("id") 215 | password := c.Query("password") 216 | 217 | if strings.TrimSpace(id) == "" { 218 | c.String(http.StatusBadRequest, "参数错误") 219 | return 220 | } 221 | 222 | shortLink, err := DB.FindShortLinkByID(id) 223 | if err != nil { 224 | c.String(http.StatusUnauthorized, "短链不存在或密码错误") 225 | return 226 | } 227 | 228 | if shortLink.Password != "" && shortLink.Password != password { 229 | c.String(http.StatusUnauthorized, "短链不存在或密码错误") 230 | return 231 | } 232 | 233 | c.JSON(http.StatusOK, shortLink.Config) 234 | } 235 | 236 | func DeleteShortLinkHandler(c *gin.Context) { 237 | id := c.Param("id") 238 | password := c.Query("password") 239 | shortLink, err := DB.FindShortLinkByID(id) 240 | if err != nil { 241 | c.String(http.StatusBadRequest, "短链不存在或密码错误") 242 | return 243 | } 244 | if shortLink.Password != password { 245 | c.String(http.StatusUnauthorized, "短链不存在或密码错误") 246 | return 247 | } 248 | 249 | err = DB.DeleteShortLink(id) 250 | if err != nil { 251 | c.String(http.StatusInternalServerError, "删除失败", err) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sub2clash 2 | 3 | 将订阅链接转换为 Clash、Clash.Meta 配置 4 | [预览](https://clash.nite07.com/) 5 | 6 | ## 特性 7 | 8 | - 开箱即用的规则、策略组配置 9 | - 自动根据节点名称按国家划分策略组 10 | - 多订阅合并 11 | - 自定义 Rule Provider、Rule 12 | - 支持多种协议 13 | - Shadowsocks 14 | - ShadowsocksR 15 | - Vmess 16 | - Vless (Clash.Meta) 17 | - Trojan 18 | - Hysteria (Clash.Meta) 19 | - Hysteria2 (Clash.Meta) 20 | - Socks5 21 | - Anytls (Clash.Meta) 22 | 23 | ## 使用 24 | 25 | ### 部署 26 | 27 | - [docker compose](./compose.yml) 28 | - 运行[二进制文件](https://github.com/bestnite/sub2clash/releases/latest) 29 | 30 | ### 配置 31 | 32 | 支持多种配置方式,按优先级排序: 33 | 34 | 1. **配置文件**:支持多种格式(YAML、JSON),按以下优先级搜索: 35 | - `config.yaml` / `config.yml` 36 | - `config.json` 37 | - `sub2clash.yaml` / `sub2clash.yml` 38 | - `sub2clash.json` 39 | 2. **环境变量**:使用 `SUB2CLASH_` 前缀,例如 `SUB2CLASH_ADDRESS=0.0.0.0:8011` 40 | 3. **默认值**:内置默认配置 41 | 42 | | 配置项 | 环境变量 | 说明 | 默认值 | 43 | | --------------------- | ------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------- | 44 | | address | SUB2CLASH_ADDRESS | 服务监听地址 | `0.0.0.0:8011` | 45 | | meta_template | SUB2CLASH_META_TEMPLATE | 默认 meta 模板 URL | `https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_meta.yaml` | 46 | | clash_template | SUB2CLASH_CLASH_TEMPLATE | 默认 clash 模板 URL | `https://raw.githubusercontent.com/bestnite/sub2clash/refs/heads/main/templates/template_clash.yaml` | 47 | | request_retry_times | SUB2CLASH_REQUEST_RETRY_TIMES | 请求重试次数 | `3` | 48 | | request_max_file_size | SUB2CLASH_REQUEST_MAX_FILE_SIZE | 请求文件最大大小(byte) | `1048576` | 49 | | cache_expire | SUB2CLASH_CACHE_EXPIRE | 订阅缓存时间(秒) | `300` | 50 | | log_level | SUB2CLASH_LOG_LEVEL | 日志等级:`debug`,`info`,`warn`,`error` | `info` | 51 | | short_link_length | SUB2CLASH_SHORT_LINK_LENGTH | 短链长度 | `6` | 52 | 53 | #### 配置文件示例 54 | 55 | 参考示例文件: 56 | 57 | - [config.example.yaml](./config.example.yaml) - YAML 格式 58 | - [config.example.json](./config.example.json) - JSON 格式 59 | 60 | ### API 61 | 62 | #### `GET /convert/:config` 63 | 64 | 获取 Clash/Clash.Meta 配置链接 65 | 66 | | Path 参数 | 类型 | 说明 | 67 | | --------- | ------ | ---------------------------------------------- | 68 | | config | string | Base64 URL Safe 编码后的 JSON 字符串,格式如下 | 69 | 70 | ##### `config` JSON 结构 71 | 72 | | Query 参数 | 类型 | 是否必须 | 默认值 | 说明 | 73 | | ------------------ | ----------------- | ------------------------ | --------- | -------------------------------------------------------------------------------------------------------- | 74 | | clashType | int | 是 | 1 | 配置文件类型 (1: Clash, 2: Clash.Meta) | 75 | | subscriptions | []string | sub/proxy 至少有一项存在 | - | 订阅链接(v2ray 或 clash 格式),可以在链接结尾加上`#名称`,来给订阅中的节点加上统一前缀(可以输入多个) | 76 | | proxies | []string | sub/proxy 至少有一项存在 | - | 节点分享链接(可以输入多个) | 77 | | refresh | bool | 否 | `false` | 强制刷新配置(默认缓存 5 分钟) | 78 | | template | string | 否 | - | 外部模板链接或内部模板名称 | 79 | | ruleProviders | []RuleProvider | 否 | - | 规则 | 80 | | rules | []Rule | 否 | - | 规则 | 81 | | autoTest | bool | 否 | `false` | 国家策略组是否自动测速 | 82 | | lazy | bool | 否 | `false` | 自动测速是否启用 lazy | 83 | | sort | string | 否 | `nameasc` | 国家策略组排序策略,可选值 `nameasc`、`namedesc`、`sizeasc`、`sizedesc` | 84 | | replace | map[string]string | 否 | - | 通过正则表达式重命名节点 | 85 | | remove | string | 否 | - | 通过正则表达式删除节点 | 86 | | nodeList | bool | 否 | `false` | 只输出节点 | 87 | | ignoreCountryGroup | bool | 否 | `false` | 是否忽略国家分组 | 88 | | userAgent | string | 否 | - | 订阅 user-agent | 89 | | useUDP | bool | 否 | `false` | 是否使用 UDP | 90 | 91 | ###### `RuleProvider` 结构 92 | 93 | | 字段 | 类型 | 说明 | 94 | | -------- | ------ | ---------------------------------------------------------------- | 95 | | behavior | string | rule-set 的 behavior | 96 | | url | string | rule-set 的 url | 97 | | group | string | 该规则集使用的策略组名 | 98 | | prepend | bool | 如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部 | 99 | | name | string | 该 rule-provider 的名称,不能重复 | 100 | 101 | ###### `Rule` 结构 102 | 103 | | 字段 | 类型 | 说明 | 104 | | ------- | ------ | ---------------------------------------------------------------- | 105 | | rule | string | 规则 | 106 | | prepend | bool | 如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部 | 107 | 108 | ### 模板 109 | 110 | 可以通过变量自定义模板中的策略组代理节点 111 | 具体参考下方默认模板 112 | 113 | - `` 为添加所有节点 114 | - `` 为添加所有国家策略组 115 | - `<地区二位字母代码>` 为添加指定地区所有节点,例如 `` 将添加所有香港节点 116 | 117 | #### 默认模板 118 | 119 | - [Clash](./templates/template_clash.yaml) 120 | - [Clash.Meta](./templates/template_meta.yaml) 121 | 122 | ## 开发 123 | 124 | ### 添加新协议支持 125 | 126 | 添加新协议支持需要实现以下组件: 127 | 128 | 1. 在 `parser` 目录下实现协议解析器,用于解析节点链接 129 | 2. 在 `model/proxy` 目录下定义协议结构体 130 | 131 | ## 贡献者 132 | 133 | [![](https://contrib.rocks/image?repo=bestnite/sub2clash)](https://github.com/bestnite/sub2clash/graphs/contributors) 134 | -------------------------------------------------------------------------------- /model/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | type IntOrString int 11 | 12 | func (i *IntOrString) UnmarshalYAML(value *yaml.Node) error { 13 | intVal := 0 14 | err := yaml.Unmarshal([]byte(value.Value), &intVal) 15 | if err == nil { 16 | *i = IntOrString(intVal) 17 | } 18 | strVal := "" 19 | err = yaml.Unmarshal([]byte(value.Value), &strVal) 20 | if err == nil { 21 | _int, err := strconv.ParseInt(strVal, 10, 64) 22 | if err != nil { 23 | *i = IntOrString(_int) 24 | } 25 | return err 26 | } 27 | return nil 28 | } 29 | 30 | type HTTPOptions struct { 31 | Method string `yaml:"method,omitempty"` 32 | Path []string `yaml:"path,omitempty"` 33 | Headers map[string][]string `yaml:"headers,omitempty"` 34 | } 35 | 36 | type HTTP2Options struct { 37 | Host []string `yaml:"host,omitempty"` 38 | Path string `yaml:"path,omitempty"` 39 | } 40 | 41 | type GrpcOptions struct { 42 | GrpcServiceName string `yaml:"grpc-service-name,omitempty"` 43 | } 44 | 45 | type RealityOptions struct { 46 | PublicKey string `yaml:"public-key"` 47 | ShortID string `yaml:"short-id,omitempty"` 48 | } 49 | 50 | type WSOptions struct { 51 | Path string `yaml:"path,omitempty"` 52 | Headers map[string]string `yaml:"headers,omitempty"` 53 | MaxEarlyData int `yaml:"max-early-data,omitempty"` 54 | EarlyDataHeaderName string `yaml:"early-data-header-name,omitempty"` 55 | } 56 | 57 | type SmuxStruct struct { 58 | Enabled bool `yaml:"enable"` 59 | } 60 | 61 | type WireGuardPeerOption struct { 62 | Server string `yaml:"server"` 63 | Port int `yaml:"port"` 64 | PublicKey string `yaml:"public-key,omitempty"` 65 | PreSharedKey string `yaml:"pre-shared-key,omitempty"` 66 | Reserved []uint8 `yaml:"reserved,omitempty"` 67 | AllowedIPs []string `yaml:"allowed-ips,omitempty"` 68 | } 69 | 70 | type ECHOptions struct { 71 | Enable bool `yaml:"enable,omitempty" obfs:"enable,omitempty"` 72 | Config string `yaml:"config,omitempty" obfs:"config,omitempty"` 73 | } 74 | 75 | type Proxy struct { 76 | Type string 77 | Name string 78 | SubName string `yaml:"-"` 79 | Anytls 80 | Hysteria 81 | Hysteria2 82 | ShadowSocks 83 | ShadowSocksR 84 | Trojan 85 | Vless 86 | Vmess 87 | Socks 88 | Tuic 89 | } 90 | 91 | func (p Proxy) MarshalYAML() (any, error) { 92 | switch p.Type { 93 | case "anytls": 94 | return struct { 95 | Type string `yaml:"type"` 96 | Name string `yaml:"name"` 97 | Anytls `yaml:",inline"` 98 | }{ 99 | Type: p.Type, 100 | Name: p.Name, 101 | Anytls: p.Anytls, 102 | }, nil 103 | case "hysteria": 104 | return struct { 105 | Type string `yaml:"type"` 106 | Name string `yaml:"name"` 107 | Hysteria `yaml:",inline"` 108 | }{ 109 | Type: p.Type, 110 | Name: p.Name, 111 | Hysteria: p.Hysteria, 112 | }, nil 113 | case "hysteria2": 114 | return struct { 115 | Type string `yaml:"type"` 116 | Name string `yaml:"name"` 117 | Hysteria2 `yaml:",inline"` 118 | }{ 119 | Type: p.Type, 120 | Name: p.Name, 121 | Hysteria2: p.Hysteria2, 122 | }, nil 123 | case "ss": 124 | return struct { 125 | Type string `yaml:"type"` 126 | Name string `yaml:"name"` 127 | ShadowSocks `yaml:",inline"` 128 | }{ 129 | Type: p.Type, 130 | Name: p.Name, 131 | ShadowSocks: p.ShadowSocks, 132 | }, nil 133 | case "ssr": 134 | return struct { 135 | Type string `yaml:"type"` 136 | Name string `yaml:"name"` 137 | ShadowSocksR `yaml:",inline"` 138 | }{ 139 | Type: p.Type, 140 | Name: p.Name, 141 | ShadowSocksR: p.ShadowSocksR, 142 | }, nil 143 | case "trojan": 144 | return struct { 145 | Type string `yaml:"type"` 146 | Name string `yaml:"name"` 147 | Trojan `yaml:",inline"` 148 | }{ 149 | Type: p.Type, 150 | Name: p.Name, 151 | Trojan: p.Trojan, 152 | }, nil 153 | case "vless": 154 | return struct { 155 | Type string `yaml:"type"` 156 | Name string `yaml:"name"` 157 | Vless `yaml:",inline"` 158 | }{ 159 | Type: p.Type, 160 | Name: p.Name, 161 | Vless: p.Vless, 162 | }, nil 163 | case "vmess": 164 | return struct { 165 | Type string `yaml:"type"` 166 | Name string `yaml:"name"` 167 | Vmess `yaml:",inline"` 168 | }{ 169 | Type: p.Type, 170 | Name: p.Name, 171 | Vmess: p.Vmess, 172 | }, nil 173 | case "socks5": 174 | return struct { 175 | Type string `yaml:"type"` 176 | Name string `yaml:"name"` 177 | Socks `yaml:",inline"` 178 | }{ 179 | Type: p.Type, 180 | Name: p.Name, 181 | Socks: p.Socks, 182 | }, nil 183 | case "tuic": 184 | return struct { 185 | Type string `yaml:"type"` 186 | Name string `yaml:"name"` 187 | Tuic `yaml:",inline"` 188 | }{ 189 | Type: p.Type, 190 | Name: p.Name, 191 | Tuic: p.Tuic, 192 | }, nil 193 | default: 194 | return nil, fmt.Errorf("unsupported proxy type: %s", p.Type) 195 | } 196 | } 197 | 198 | func (p *Proxy) UnmarshalYAML(node *yaml.Node) error { 199 | var temp struct { 200 | Type string `yaml:"type"` 201 | Name string `yaml:"name"` 202 | } 203 | 204 | if err := node.Decode(&temp); err != nil { 205 | return err 206 | } 207 | 208 | p.Type = temp.Type 209 | p.Name = temp.Name 210 | 211 | switch temp.Type { 212 | case "anytls": 213 | var data struct { 214 | Type string `yaml:"type"` 215 | Name string `yaml:"name"` 216 | Anytls `yaml:",inline"` 217 | } 218 | if err := node.Decode(&data); err != nil { 219 | return err 220 | } 221 | p.Anytls = data.Anytls 222 | 223 | case "hysteria": 224 | var data struct { 225 | Type string `yaml:"type"` 226 | Name string `yaml:"name"` 227 | Hysteria `yaml:",inline"` 228 | } 229 | if err := node.Decode(&data); err != nil { 230 | return err 231 | } 232 | p.Hysteria = data.Hysteria 233 | 234 | case "hysteria2": 235 | var data struct { 236 | Type string `yaml:"type"` 237 | Name string `yaml:"name"` 238 | Hysteria2 `yaml:",inline"` 239 | } 240 | if err := node.Decode(&data); err != nil { 241 | return err 242 | } 243 | p.Hysteria2 = data.Hysteria2 244 | 245 | case "ss": 246 | var data struct { 247 | Type string `yaml:"type"` 248 | Name string `yaml:"name"` 249 | ShadowSocks `yaml:",inline"` 250 | } 251 | if err := node.Decode(&data); err != nil { 252 | return err 253 | } 254 | p.ShadowSocks = data.ShadowSocks 255 | 256 | case "ssr": 257 | var data struct { 258 | Type string `yaml:"type"` 259 | Name string `yaml:"name"` 260 | ShadowSocksR `yaml:",inline"` 261 | } 262 | if err := node.Decode(&data); err != nil { 263 | return err 264 | } 265 | p.ShadowSocksR = data.ShadowSocksR 266 | 267 | case "trojan": 268 | var data struct { 269 | Type string `yaml:"type"` 270 | Name string `yaml:"name"` 271 | Trojan `yaml:",inline"` 272 | } 273 | if err := node.Decode(&data); err != nil { 274 | return err 275 | } 276 | p.Trojan = data.Trojan 277 | 278 | case "vless": 279 | var data struct { 280 | Type string `yaml:"type"` 281 | Name string `yaml:"name"` 282 | Vless `yaml:",inline"` 283 | } 284 | if err := node.Decode(&data); err != nil { 285 | return err 286 | } 287 | p.Vless = data.Vless 288 | 289 | case "vmess": 290 | var data struct { 291 | Type string `yaml:"type"` 292 | Name string `yaml:"name"` 293 | Vmess `yaml:",inline"` 294 | } 295 | if err := node.Decode(&data); err != nil { 296 | return err 297 | } 298 | p.Vmess = data.Vmess 299 | 300 | case "socks5": 301 | var data struct { 302 | Type string `yaml:"type"` 303 | Name string `yaml:"name"` 304 | Socks `yaml:",inline"` 305 | } 306 | if err := node.Decode(&data); err != nil { 307 | return err 308 | } 309 | p.Socks = data.Socks 310 | case "tuic": 311 | var data struct { 312 | Type string `yaml:"type"` 313 | Name string `yaml:"name"` 314 | Tuic `yaml:",inline"` 315 | } 316 | if err := node.Decode(&data); err != nil { 317 | return err 318 | } 319 | p.Tuic = data.Tuic 320 | default: 321 | return fmt.Errorf("unsupported proxy type: %s", temp.Type) 322 | } 323 | 324 | return nil 325 | } 326 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bestnite/sub2clash 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.10.1 7 | github.com/glebarez/sqlite v1.11.0 8 | github.com/metacubex/mihomo v1.19.10 9 | github.com/spf13/viper v1.20.1 10 | go.uber.org/zap v1.27.0 11 | golang.org/x/text v0.30.0 12 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 13 | gopkg.in/yaml.v3 v3.0.1 14 | gorm.io/gorm v1.31.0 15 | resty.dev/v3 v3.0.0-beta.3 16 | ) 17 | 18 | require ( 19 | github.com/3andne/restls-client-go v0.1.6 // indirect 20 | github.com/RyuaNerin/go-krypto v1.3.0 // indirect 21 | github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect 22 | github.com/andybalholm/brotli v1.0.6 // indirect 23 | github.com/bahlo/generic-list-go v0.2.0 // indirect 24 | github.com/buger/jsonparser v1.1.1 // indirect 25 | github.com/bytedance/sonic v1.11.6 // indirect 26 | github.com/bytedance/sonic/loader v0.1.1 // indirect 27 | github.com/cloudflare/circl v1.3.7 // indirect 28 | github.com/cloudwego/base64x v0.1.4 // indirect 29 | github.com/cloudwego/iasm v0.2.0 // indirect 30 | github.com/coreos/go-iptables v0.8.0 // indirect 31 | github.com/dlclark/regexp2 v1.11.5 // indirect 32 | github.com/dustin/go-humanize v1.0.1 // indirect 33 | github.com/ebitengine/purego v0.8.3 // indirect 34 | github.com/enfein/mieru/v3 v3.13.0 // indirect 35 | github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect 36 | github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 // indirect 37 | github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 // indirect 38 | github.com/ericlagergren/subtle v0.0.0-20220507045147-890d697da010 // indirect 39 | github.com/fsnotify/fsnotify v1.9.0 // indirect 40 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 41 | github.com/gaukas/godicttls v0.0.4 // indirect 42 | github.com/gin-contrib/sse v0.1.0 // indirect 43 | github.com/glebarez/go-sqlite v1.21.2 // indirect 44 | github.com/go-ole/go-ole v1.3.0 // indirect 45 | github.com/go-playground/locales v0.14.1 // indirect 46 | github.com/go-playground/universal-translator v0.18.1 // indirect 47 | github.com/go-playground/validator/v10 v10.20.0 // indirect 48 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 49 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 50 | github.com/gobwas/httphead v0.1.0 // indirect 51 | github.com/gobwas/pool v0.2.1 // indirect 52 | github.com/gobwas/ws v1.4.0 // indirect 53 | github.com/goccy/go-json v0.10.2 // indirect 54 | github.com/gofrs/uuid/v5 v5.3.2 // indirect 55 | github.com/google/btree v1.1.3 // indirect 56 | github.com/google/go-cmp v0.6.0 // indirect 57 | github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 // indirect 58 | github.com/google/uuid v1.6.0 // indirect 59 | github.com/hashicorp/yamux v0.1.2 // indirect 60 | github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 // indirect 61 | github.com/jinzhu/inflection v1.0.0 // indirect 62 | github.com/jinzhu/now v1.1.5 // indirect 63 | github.com/josharian/native v1.1.0 // indirect 64 | github.com/json-iterator/go v1.1.12 // indirect 65 | github.com/klauspost/compress v1.17.9 // indirect 66 | github.com/klauspost/cpuid/v2 v2.2.9 // indirect 67 | github.com/leodido/go-urn v1.4.0 // indirect 68 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 69 | github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 // indirect 70 | github.com/mailru/easyjson v0.7.7 // indirect 71 | github.com/mattn/go-isatty v0.0.20 // indirect 72 | github.com/mdlayher/netlink v1.7.2 // indirect 73 | github.com/mdlayher/socket v0.4.1 // indirect 74 | github.com/metacubex/amneziawg-go v0.0.0-20240922133038-fdf3a4d5a4ab // indirect 75 | github.com/metacubex/bart v0.20.5 // indirect 76 | github.com/metacubex/bbolt v0.0.0-20240822011022-aed6d4850399 // indirect 77 | github.com/metacubex/chacha v0.1.2 // indirect 78 | github.com/metacubex/fswatch v0.1.1 // indirect 79 | github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 // indirect 80 | github.com/metacubex/gvisor v0.0.0-20250324165734-5857f47bd43b // indirect 81 | github.com/metacubex/nftables v0.0.0-20250503052935-30a69ab87793 // indirect 82 | github.com/metacubex/quic-go v0.52.1-0.20250522021943-aef454b9e639 // indirect 83 | github.com/metacubex/randv2 v0.2.0 // indirect 84 | github.com/metacubex/sing v0.5.3 // indirect 85 | github.com/metacubex/sing-mux v0.3.2 // indirect 86 | github.com/metacubex/sing-quic v0.0.0-20250523120938-f1a248e5ec7f // indirect 87 | github.com/metacubex/sing-shadowsocks v0.2.10 // indirect 88 | github.com/metacubex/sing-shadowsocks2 v0.2.4 // indirect 89 | github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 // indirect 90 | github.com/metacubex/sing-tun v0.4.6-0.20250524142129-9d110c0af70c // indirect 91 | github.com/metacubex/sing-vmess v0.2.2 // indirect 92 | github.com/metacubex/sing-wireguard v0.0.0-20250503063753-2dc62acc626f // indirect 93 | github.com/metacubex/smux v0.0.0-20250503055512-501391591dee // indirect 94 | github.com/metacubex/tfo-go v0.0.0-20250516165257-e29c16ae41d4 // indirect 95 | github.com/metacubex/utls v1.7.3 // indirect 96 | github.com/metacubex/wireguard-go v0.0.0-20240922131502-c182e7471181 // indirect 97 | github.com/miekg/dns v1.1.63 // indirect 98 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 99 | github.com/modern-go/reflect2 v1.0.2 // indirect 100 | github.com/mroth/weightedrand/v2 v2.1.0 // indirect 101 | github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 // indirect 102 | github.com/onsi/ginkgo/v2 v2.9.5 // indirect 103 | github.com/openacid/low v0.1.21 // indirect 104 | github.com/oschwald/maxminddb-golang v1.12.0 // indirect 105 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 106 | github.com/pierrec/lz4/v4 v4.1.14 // indirect 107 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 108 | github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect 109 | github.com/quic-go/qpack v0.4.0 // indirect 110 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 111 | github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect 112 | github.com/sagikazarmark/locafero v0.7.0 // indirect 113 | github.com/samber/lo v1.50.0 // indirect 114 | github.com/shirou/gopsutil/v4 v4.25.1 // indirect 115 | github.com/sina-ghaderi/poly1305 v0.0.0-20220724002748-c5926b03988b // indirect 116 | github.com/sina-ghaderi/rabaead v0.0.0-20220730151906-ab6e06b96e8c // indirect 117 | github.com/sina-ghaderi/rabbitio v0.0.0-20220730151941-9ce26f4f872e // indirect 118 | github.com/sirupsen/logrus v1.9.3 // indirect 119 | github.com/sourcegraph/conc v0.3.0 // indirect 120 | github.com/spf13/afero v1.12.0 // indirect 121 | github.com/spf13/cast v1.7.1 // indirect 122 | github.com/spf13/pflag v1.0.6 // indirect 123 | github.com/subosito/gotenv v1.6.0 // indirect 124 | github.com/tklauser/go-sysconf v0.3.12 // indirect 125 | github.com/tklauser/numcpus v0.6.1 // indirect 126 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 127 | github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect 128 | github.com/ugorji/go/codec v1.2.12 // indirect 129 | github.com/vishvananda/netns v0.0.4 // indirect 130 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 131 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 132 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 133 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 134 | gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 // indirect 135 | gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec // indirect 136 | go.uber.org/mock v0.4.0 // indirect 137 | go.uber.org/multierr v1.11.0 // indirect 138 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 139 | golang.org/x/arch v0.8.0 // indirect 140 | golang.org/x/crypto v0.42.0 // indirect 141 | golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect 142 | golang.org/x/mod v0.28.0 // indirect 143 | golang.org/x/net v0.44.0 // indirect 144 | golang.org/x/sync v0.17.0 // indirect 145 | golang.org/x/sys v0.36.0 // indirect 146 | golang.org/x/time v0.8.0 // indirect 147 | golang.org/x/tools v0.37.0 // indirect 148 | google.golang.org/protobuf v1.36.1 // indirect 149 | lukechampine.com/blake3 v1.3.0 // indirect 150 | modernc.org/libc v1.22.5 // indirect 151 | modernc.org/mathutil v1.5.0 // indirect 152 | modernc.org/memory v1.5.0 // indirect 153 | modernc.org/sqlite v1.23.1 // indirect 154 | ) 155 | -------------------------------------------------------------------------------- /common/sub.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "io" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "sort" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "github.com/bestnite/sub2clash/logger" 19 | "github.com/bestnite/sub2clash/model" 20 | P "github.com/bestnite/sub2clash/model/proxy" 21 | "github.com/bestnite/sub2clash/parser" 22 | "github.com/bestnite/sub2clash/utils" 23 | "go.uber.org/zap" 24 | "gopkg.in/yaml.v3" 25 | ) 26 | 27 | var subsDir = "subs" 28 | var fileLock sync.RWMutex 29 | 30 | func LoadSubscription(url string, refresh bool, userAgent string, cacheExpire int64, retryTimes int) ([]byte, error) { 31 | if refresh { 32 | return FetchSubscriptionFromAPI(url, userAgent, retryTimes) 33 | } 34 | hash := sha256.Sum224([]byte(url)) 35 | fileName := filepath.Join(subsDir, hex.EncodeToString(hash[:])) 36 | stat, err := os.Stat(fileName) 37 | if err != nil { 38 | if !os.IsNotExist(err) { 39 | return nil, err 40 | } 41 | return FetchSubscriptionFromAPI(url, userAgent, retryTimes) 42 | } 43 | lastGetTime := stat.ModTime().Unix() 44 | if lastGetTime+cacheExpire > time.Now().Unix() { 45 | file, err := os.Open(fileName) 46 | if err != nil { 47 | return nil, err 48 | } 49 | defer func(file *os.File) { 50 | if file != nil { 51 | _ = file.Close() 52 | } 53 | }(file) 54 | fileLock.RLock() 55 | defer fileLock.RUnlock() 56 | subContent, err := io.ReadAll(file) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return subContent, nil 61 | } 62 | return FetchSubscriptionFromAPI(url, userAgent, retryTimes) 63 | } 64 | 65 | func FetchSubscriptionFromAPI(url string, userAgent string, retryTimes int) ([]byte, error) { 66 | hash := sha256.Sum224([]byte(url)) 67 | fileName := filepath.Join(subsDir, hex.EncodeToString(hash[:])) 68 | client := Request(retryTimes) 69 | defer client.Close() 70 | resp, err := client.R().SetHeader("User-Agent", userAgent).Get(url) 71 | if err != nil { 72 | return nil, err 73 | } 74 | data, err := io.ReadAll(resp.Body) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to read response body: %w", err) 77 | } 78 | file, err := os.Create(fileName) 79 | if err != nil { 80 | return nil, err 81 | } 82 | defer func(file *os.File) { 83 | if file != nil { 84 | _ = file.Close() 85 | } 86 | }(file) 87 | fileLock.Lock() 88 | defer fileLock.Unlock() 89 | _, err = file.Write(data) 90 | if err != nil { 91 | return nil, fmt.Errorf("failed to write to sub.yaml: %w", err) 92 | } 93 | return data, nil 94 | } 95 | 96 | func BuildSub(clashType model.ClashType, query model.ConvertConfig, template string, cacheExpire int64, retryTimes int) ( 97 | *model.Subscription, error, 98 | ) { 99 | var temp = &model.Subscription{} 100 | var sub = &model.Subscription{} 101 | var err error 102 | var templateBytes []byte 103 | 104 | if query.Template != "" { 105 | template = query.Template 106 | } 107 | if strings.HasPrefix(template, "http") { 108 | templateBytes, err = LoadSubscription(template, query.Refresh, query.UserAgent, cacheExpire, retryTimes) 109 | if err != nil { 110 | logger.Logger.Debug( 111 | "load template failed", zap.String("template", template), zap.Error(err), 112 | ) 113 | return nil, NewTemplateLoadError(template, err) 114 | } 115 | } else { 116 | unescape, err := url.QueryUnescape(template) 117 | if err != nil { 118 | return nil, NewTemplateLoadError(template, err) 119 | } 120 | templateBytes, err = LoadTemplate(unescape) 121 | if err != nil { 122 | logger.Logger.Debug( 123 | "load template failed", zap.String("template", template), zap.Error(err), 124 | ) 125 | return nil, NewTemplateLoadError(unescape, err) 126 | } 127 | } 128 | 129 | err = yaml.Unmarshal(templateBytes, &temp) 130 | if err != nil { 131 | logger.Logger.Debug("parse template failed", zap.Error(err)) 132 | return nil, NewTemplateParseError(templateBytes, err) 133 | } 134 | var proxyList []P.Proxy 135 | 136 | for i := range query.Subs { 137 | data, err := LoadSubscription(query.Subs[i], query.Refresh, query.UserAgent, cacheExpire, retryTimes) 138 | if err != nil { 139 | logger.Logger.Debug( 140 | "load subscription failed", zap.String("url", query.Subs[i]), zap.Error(err), 141 | ) 142 | return nil, NewSubscriptionLoadError(query.Subs[i], err) 143 | } 144 | subName := "" 145 | if strings.Contains(query.Subs[i], "#") { 146 | subName = query.Subs[i][strings.LastIndex(query.Subs[i], "#")+1:] 147 | } 148 | 149 | err = yaml.Unmarshal(data, &sub) 150 | var newProxies []P.Proxy 151 | if err != nil { 152 | reg, err := regexp.Compile("(" + strings.Join(parser.GetAllPrefixes(), "|") + ")://") 153 | if err != nil { 154 | logger.Logger.Debug("compile regex failed", zap.Error(err)) 155 | return nil, NewRegexInvalidError("prefix", err) 156 | } 157 | if reg.Match(data) { 158 | p, err := parser.ParseProxies(parser.ParseConfig{UseUDP: query.UseUDP}, strings.Split(string(data), "\n")...) 159 | if err != nil { 160 | return nil, err 161 | } 162 | newProxies = p 163 | } else { 164 | base64, err := utils.DecodeBase64(string(data), false) 165 | if err != nil { 166 | logger.Logger.Debug( 167 | "parse subscription failed", zap.String("url", query.Subs[i]), 168 | zap.String("data", string(data)), 169 | zap.Error(err), 170 | ) 171 | return nil, NewSubscriptionParseError(data, err) 172 | } 173 | p, err := parser.ParseProxies(parser.ParseConfig{UseUDP: query.UseUDP}, strings.Split(base64, "\n")...) 174 | if err != nil { 175 | return nil, err 176 | } 177 | newProxies = p 178 | } 179 | } else { 180 | newProxies = sub.Proxy 181 | } 182 | if subName != "" { 183 | for i := range newProxies { 184 | newProxies[i].SubName = subName 185 | } 186 | } 187 | proxyList = append(proxyList, newProxies...) 188 | } 189 | 190 | if len(query.Proxies) != 0 { 191 | p, err := parser.ParseProxies(parser.ParseConfig{UseUDP: query.UseUDP}, query.Proxies...) 192 | if err != nil { 193 | return nil, err 194 | } 195 | proxyList = append(proxyList, p...) 196 | } 197 | 198 | for i := range proxyList { 199 | if proxyList[i].SubName != "" { 200 | proxyList[i].Name = strings.TrimSpace(proxyList[i].SubName) + " " + strings.TrimSpace(proxyList[i].Name) 201 | } 202 | } 203 | 204 | // 去重 205 | proxies := make(map[string]*P.Proxy) 206 | newProxies := make([]P.Proxy, 0, len(proxyList)) 207 | for i := range proxyList { 208 | yamlBytes, err := yaml.Marshal(proxyList[i]) 209 | if err != nil { 210 | logger.Logger.Debug("marshal proxy failed", zap.Error(err)) 211 | return nil, fmt.Errorf("marshal proxy failed: %w", err) 212 | } 213 | key := string(yamlBytes) 214 | if _, exist := proxies[key]; !exist { 215 | proxies[key] = &proxyList[i] 216 | newProxies = append(newProxies, proxyList[i]) 217 | } 218 | } 219 | proxyList = newProxies 220 | 221 | // 移除 222 | if strings.TrimSpace(query.Remove) != "" { 223 | newProxyList := make([]P.Proxy, 0, len(proxyList)) 224 | for i := range proxyList { 225 | removeReg, err := regexp.Compile(query.Remove) 226 | if err != nil { 227 | logger.Logger.Debug("remove regexp compile failed", zap.Error(err)) 228 | return nil, NewRegexInvalidError("remove", err) 229 | } 230 | 231 | if removeReg.MatchString(proxyList[i].Name) { 232 | continue 233 | } 234 | newProxyList = append(newProxyList, proxyList[i]) 235 | } 236 | proxyList = newProxyList 237 | } 238 | 239 | // 替换 240 | if len(query.Replace) != 0 { 241 | for k, v := range query.Replace { 242 | replaceReg, err := regexp.Compile(k) 243 | if err != nil { 244 | logger.Logger.Debug("replace regexp compile failed", zap.Error(err)) 245 | return nil, NewRegexInvalidError("replace", err) 246 | } 247 | for i := range proxyList { 248 | if replaceReg.MatchString(proxyList[i].Name) { 249 | proxyList[i].Name = replaceReg.ReplaceAllString( 250 | proxyList[i].Name, v, 251 | ) 252 | } 253 | } 254 | } 255 | } 256 | 257 | // 重命名有相同名称的节点 258 | names := make(map[string]int) 259 | for i := range proxyList { 260 | if _, exist := names[proxyList[i].Name]; exist { 261 | names[proxyList[i].Name] = names[proxyList[i].Name] + 1 262 | proxyList[i].Name = proxyList[i].Name + " " + strconv.Itoa(names[proxyList[i].Name]) 263 | } else { 264 | names[proxyList[i].Name] = 0 265 | } 266 | } 267 | 268 | for i := range proxyList { 269 | proxyList[i].Name = strings.TrimSpace(proxyList[i].Name) 270 | } 271 | 272 | var t = &model.Subscription{} 273 | AddProxy(t, query.AutoTest, query.Lazy, clashType, proxyList...) 274 | 275 | // 排序 276 | switch query.Sort { 277 | case "sizeasc": 278 | sort.Sort(model.ProxyGroupsSortBySize(t.ProxyGroup)) 279 | case "sizedesc": 280 | sort.Sort(sort.Reverse(model.ProxyGroupsSortBySize(t.ProxyGroup))) 281 | case "nameasc": 282 | sort.Sort(model.ProxyGroupsSortByName(t.ProxyGroup)) 283 | case "namedesc": 284 | sort.Sort(sort.Reverse(model.ProxyGroupsSortByName(t.ProxyGroup))) 285 | default: 286 | sort.Sort(model.ProxyGroupsSortByName(t.ProxyGroup)) 287 | } 288 | 289 | MergeSubAndTemplate(temp, t, query.IgnoreCountryGrooup) 290 | 291 | for _, v := range query.Rules { 292 | if v.Prepend { 293 | PrependRules(temp, v.Rule) 294 | } else { 295 | AppendRules(temp, v.Rule) 296 | } 297 | } 298 | 299 | for _, v := range query.RuleProviders { 300 | hash := sha256.Sum224([]byte(v.Url)) 301 | name := hex.EncodeToString(hash[:]) 302 | provider := model.RuleProvider{ 303 | Type: "http", 304 | Behavior: v.Behavior, 305 | Url: v.Url, 306 | Path: "./" + name + ".yaml", 307 | Interval: 3600, 308 | } 309 | if v.Prepend { 310 | PrependRuleProvider( 311 | temp, v.Name, v.Group, provider, 312 | ) 313 | } else { 314 | AppenddRuleProvider( 315 | temp, v.Name, v.Group, provider, 316 | ) 317 | } 318 | } 319 | return temp, nil 320 | } 321 | 322 | func FetchSubscriptionUserInfo(url string, userAgent string, retryTimes int) (string, error) { 323 | client := Request(retryTimes) 324 | defer client.Close() 325 | resp, err := client.R().SetHeader("User-Agent", userAgent).Head(url) 326 | if err != nil { 327 | logger.Logger.Debug("创建 HEAD 请求失败", zap.Error(err)) 328 | return "", NewNetworkRequestError(url, err) 329 | } 330 | defer resp.Body.Close() 331 | if userInfo := resp.Header().Get("subscription-userinfo"); userInfo != "" { 332 | return userInfo, nil 333 | } 334 | 335 | logger.Logger.Debug("subscription-userinfo header not found in response") 336 | return "", NewNetworkResponseError("subscription-userinfo header not found", nil) 337 | } 338 | 339 | func MergeSubAndTemplate(temp *model.Subscription, sub *model.Subscription, igcg bool) { 340 | var countryGroupNames []string 341 | for _, proxyGroup := range sub.ProxyGroup { 342 | if proxyGroup.IsCountryGrop { 343 | countryGroupNames = append( 344 | countryGroupNames, proxyGroup.Name, 345 | ) 346 | } 347 | } 348 | var proxyNames []string 349 | for _, proxy := range sub.Proxy { 350 | proxyNames = append(proxyNames, proxy.Name) 351 | } 352 | 353 | temp.Proxy = append(temp.Proxy, sub.Proxy...) 354 | 355 | for i := range temp.ProxyGroup { 356 | if temp.ProxyGroup[i].IsCountryGrop { 357 | continue 358 | } 359 | newProxies := make([]string, 0) 360 | countryGroupMap := make(map[string]model.ProxyGroup) 361 | for _, v := range sub.ProxyGroup { 362 | if v.IsCountryGrop { 363 | countryGroupMap[v.Name] = v 364 | } 365 | } 366 | for j := range temp.ProxyGroup[i].Proxies { 367 | reg := regexp.MustCompile("<(.*?)>") 368 | if reg.Match([]byte(temp.ProxyGroup[i].Proxies[j])) { 369 | key := reg.FindStringSubmatch(temp.ProxyGroup[i].Proxies[j])[1] 370 | switch key { 371 | case "all": 372 | newProxies = append(newProxies, proxyNames...) 373 | case "countries": 374 | if !igcg { 375 | newProxies = append(newProxies, countryGroupNames...) 376 | } 377 | default: 378 | if !igcg { 379 | if len(key) == 2 { 380 | newProxies = append( 381 | newProxies, countryGroupMap[GetContryName(key)].Proxies..., 382 | ) 383 | } 384 | } 385 | } 386 | } else { 387 | newProxies = append(newProxies, temp.ProxyGroup[i].Proxies[j]) 388 | } 389 | } 390 | temp.ProxyGroup[i].Proxies = newProxies 391 | } 392 | if !igcg { 393 | temp.ProxyGroup = append(temp.ProxyGroup, sub.ProxyGroup...) 394 | } 395 | } 396 | --------------------------------------------------------------------------------