15 Commits

32 changed files with 10730 additions and 10362 deletions

13
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"configurations": [
{
"name": "Debug",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/main.go",
"output": "${workspaceFolder}/dist/main.exe",
"buildFlags": "-ldflags '-X sub2clash/config.Dev=true -X sub2clash/config.Version=dev'"
}
]
}

View File

@ -3,7 +3,7 @@
获取 Clash/Clash.Meta 配置链接 获取 Clash/Clash.Meta 配置链接
| Query 参数 | 类型 | 是否必须 | 默认值 | 说明 | | Query 参数 | 类型 | 是否必须 | 默认值 | 说明 |
|--------------|--------|-------------------|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |--------------|--------|-------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| sub | string | sub/proxy 至少有一项存在 | - | 订阅链接,可以在链接结尾加上`#名称`,来给订阅中的节点加上统一前缀(可以输入多个,用 `,` 分隔) | | sub | string | sub/proxy 至少有一项存在 | - | 订阅链接,可以在链接结尾加上`#名称`,来给订阅中的节点加上统一前缀(可以输入多个,用 `,` 分隔) |
| proxy | string | sub/proxy 至少有一项存在 | - | 节点分享链接(可以输入多个,用 `,` 分隔) | | proxy | string | sub/proxy 至少有一项存在 | - | 节点分享链接(可以输入多个,用 `,` 分隔) |
| refresh | bool | 否 | `false` | 强制刷新配置(默认缓存 5 分钟) | | refresh | bool | 否 | `false` | 强制刷新配置(默认缓存 5 分钟) |
@ -15,7 +15,7 @@
| sort | string | 否 | `nameasc` | 国家策略组排序策略,可选值 `nameasc``namedesc``sizeasc``sizedesc` | | sort | string | 否 | `nameasc` | 国家策略组排序策略,可选值 `nameasc``namedesc``sizeasc``sizedesc` |
| replace | string | 否 | - | 通过正则表达式重命名节点,格式 `[<ReplaceKey>,<ReplaceTo>],[<ReplaceKey>,<ReplaceTo>]...` | | replace | string | 否 | - | 通过正则表达式重命名节点,格式 `[<ReplaceKey>,<ReplaceTo>],[<ReplaceKey>,<ReplaceTo>]...` |
| remove | string | 否 | - | 通过正则表达式删除节点 | | remove | string | 否 | - | 通过正则表达式删除节点 |
| nodeList | bool | 否 | `false` | 只输出节点 | | | nodeList | bool | 否 | `false` | 只输出节点 |
# `/short` # `/short`

View File

@ -1,18 +1,22 @@
# sub2clash # sub2clash
将订阅链接转换为 Clash、Clash.Meta 配置 将订阅链接转换为 Clash、Clash.Meta 配置
[预览](https://www.nite07.com/sub)
## 特性 ## 特性
- 开箱即用的规则、策略组配置 - 开箱即用的规则、策略组配置
- 自动根据节点名称按国家划分策略组 - 自动根据节点名称按国家划分策略组
- 支持多订阅合并 - 支持多订阅合并
- 支持添加自定义 Rule Provider、Rule
- 支持多种协议 - 支持多种协议
- Shadowsocks - Shadowsocks
- ShadowsocksR - ShadowsocksR
- Vmess - Vmess
- Vless - Vless Clash.Meta
- Trojan - Trojan
- Hysteria Clash.Meta
- Hysteria2 Clash.Meta
## 使用 ## 使用
@ -26,10 +30,10 @@
可以通过编辑 .env 文件来修改默认配置docker 直接添加环境变量 可以通过编辑 .env 文件来修改默认配置docker 直接添加环境变量
| 变量名 | 说明 | 默认值 | | 变量名 | 说明 | 默认值 |
|-----------------------|-----------------------------------------------------------|-----------------------| |-----------------------|----------------------------------------|-----------------------|
| PORT | 端口 | `8011` | | PORT | 端口 | `8011` |
| META_TEMPLATE | meta 模板文件名 | `template_meta.yaml` | | META_TEMPLATE | 默认 meta 模板文件名 | `template_meta.yaml` |
| CLASH_TEMPLATE | clash 模板文件名 | `template_clash.yaml` | | CLASH_TEMPLATE | 默认 clash 模板文件名 | `template_clash.yaml` |
| REQUEST_RETRY_TIMES | Get 请求重试次数 | `3` | | REQUEST_RETRY_TIMES | Get 请求重试次数 | `3` |
| REQUEST_MAX_FILE_SIZE | Get 请求订阅文件最大大小byte | `1048576` | | REQUEST_MAX_FILE_SIZE | Get 请求订阅文件最大大小byte | `1048576` |
| CACHE_EXPIRE | 订阅缓存时间(秒) | `300` | | CACHE_EXPIRE | 订阅缓存时间(秒) | `300` |

View File

@ -1,12 +1,13 @@
package controller package controller
import ( import (
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
"net/http" "net/http"
"sub2clash/config" "sub2clash/config"
"sub2clash/model" "sub2clash/model"
"sub2clash/validator" "sub2clash/validator"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
) )
func SubmodHandler(c *gin.Context) { func SubmodHandler(c *gin.Context) {

View File

@ -4,8 +4,6 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
"net/url" "net/url"
"regexp" "regexp"
"sort" "sort"
@ -16,6 +14,9 @@ import (
"sub2clash/parser" "sub2clash/parser"
"sub2clash/utils" "sub2clash/utils"
"sub2clash/validator" "sub2clash/validator"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
) )
func BuildSub(clashType model.ClashType, query validator.SubValidator, template string) ( func BuildSub(clashType model.ClashType, query validator.SubValidator, template string) (
@ -30,9 +31,8 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
if query.Template != "" { if query.Template != "" {
template = query.Template template = query.Template
} }
_, err = url.ParseRequestURI(template) if strings.HasPrefix(template, "http") {
if err != nil { templateBytes, err = utils.LoadSubscription(template, query.Refresh)
templateBytes, err = utils.LoadTemplate(template)
if err != nil { if err != nil {
logger.Logger.Debug( logger.Logger.Debug(
"load template failed", zap.String("template", template), zap.Error(err), "load template failed", zap.String("template", template), zap.Error(err),
@ -40,7 +40,11 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
return nil, errors.New("加载模板失败: " + err.Error()) return nil, errors.New("加载模板失败: " + err.Error())
} }
} else { } else {
templateBytes, err = utils.LoadSubscription(template, query.Refresh) unescape, err := url.QueryUnescape(template)
if err != nil {
return nil, errors.New("加载模板失败: " + err.Error())
}
templateBytes, err = utils.LoadTemplate(unescape)
if err != nil { if err != nil {
logger.Logger.Debug( logger.Logger.Debug(
"load template failed", zap.String("template", template), zap.Error(err), "load template failed", zap.String("template", template), zap.Error(err),
@ -72,7 +76,7 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
err = yaml.Unmarshal(data, &sub) err = yaml.Unmarshal(data, &sub)
newProxies := make([]model.Proxy, 0) newProxies := make([]model.Proxy, 0)
if err != nil { if err != nil {
reg, _ := regexp.Compile("(ssr|ss|vmess|trojan|vless)://") reg, _ := regexp.Compile("(ssr|ss|vmess|trojan|vless|hysteria)://")
if reg.Match(data) { if reg.Match(data) {
p := utils.ParseProxy(strings.Split(string(data), "\n")...) p := utils.ParseProxy(strings.Split(string(data), "\n")...)
newProxies = p newProxies = p
@ -169,9 +173,11 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
names := make(map[string]int) names := make(map[string]int)
for i := range proxyList { for i := range proxyList {
if _, exist := names[proxyList[i].Name]; exist { if _, exist := names[proxyList[i].Name]; exist {
proxyList[i].Name = proxyList[i].Name + " " + strconv.Itoa(names[proxyList[i].Name])
}
names[proxyList[i].Name] = names[proxyList[i].Name] + 1 names[proxyList[i].Name] = names[proxyList[i].Name] + 1
proxyList[i].Name = proxyList[i].Name + " " + strconv.Itoa(names[proxyList[i].Name])
} else {
names[proxyList[i].Name] = 0
}
} }
// trim // trim
for i := range proxyList { for i := range proxyList {
@ -249,7 +255,7 @@ func MergeSubAndTemplate(temp *model.Subscription, sub *model.Subscription) {
if temp.ProxyGroups[i].IsCountryGrop { if temp.ProxyGroups[i].IsCountryGrop {
continue continue
} }
newProxies := make([]string, 0, len(temp.ProxyGroups[i].Proxies)) newProxies := make([]string, 0)
countryGroupMap := make(map[string]model.ProxyGroup) countryGroupMap := make(map[string]model.ProxyGroup)
for _, v := range sub.ProxyGroups { for _, v := range sub.ProxyGroups {
if v.IsCountryGrop { if v.IsCountryGrop {

View File

@ -2,12 +2,13 @@ package controller
import ( import (
_ "embed" _ "embed"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
"net/http" "net/http"
"sub2clash/config" "sub2clash/config"
"sub2clash/model" "sub2clash/model"
"sub2clash/validator" "sub2clash/validator"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
) )
func SubHandler(c *gin.Context) { func SubHandler(c *gin.Context) {

View File

@ -2,9 +2,6 @@ package controller
import ( import (
"errors" "errors"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@ -16,6 +13,10 @@ import (
"sub2clash/utils/database" "sub2clash/utils/database"
"sub2clash/validator" "sub2clash/validator"
"time" "time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
) )
func ShortLinkGenHandler(c *gin.Context) { func ShortLinkGenHandler(c *gin.Context) {

View File

@ -2,13 +2,14 @@ package api
import ( import (
"embed" "embed"
"github.com/gin-gonic/gin"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"sub2clash/api/controller" "sub2clash/api/controller"
"sub2clash/config" "sub2clash/config"
"sub2clash/middleware" "sub2clash/middleware"
"github.com/gin-gonic/gin"
) )
//go:embed static //go:embed static

View File

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
@ -46,6 +46,8 @@
target="_blank" target="_blank"
>使用文档</a >使用文档</a
></span ></span
><br><span class="text-muted fst-italic"
>注意:本程序非纯前端程序,输入的订阅将被后端缓存,请确保您信任当前站点</span
> >
</div> </div>
@ -117,7 +119,9 @@
name="refresh" name="refresh"
type="checkbox" type="checkbox"
/> />
<label class="form-check-label" for="refresh">强制重新获取订阅</label> <label class="form-check-label" for="refresh"
>强制重新获取订阅</label
>
</div> </div>
<!-- Node List --> <!-- Node List -->
<div class="form-check mb-3"> <div class="form-check mb-3">
@ -127,7 +131,9 @@
name="nodeList" name="nodeList"
type="checkbox" type="checkbox"
/> />
<label class="form-check-label" for="nodeList">输出为 Node List</label> <label class="form-check-label" for="nodeList"
>输出为 Node List</label
>
</div> </div>
<!-- Auto Test --> <!-- Auto Test -->
<div class="form-check mb-3"> <div class="form-check mb-3">
@ -143,7 +149,12 @@
</div> </div>
<!-- Lazy --> <!-- Lazy -->
<div class="form-check mb-3"> <div class="form-check mb-3">
<input class="form-check-input" id="lazy" name="lazy" type="checkbox" /> <input
class="form-check-input"
id="lazy"
name="lazy"
type="checkbox"
/>
<label class="form-check-label" for="lazy" <label class="form-check-label" for="lazy"
>自动测速启用 lazy 模式</label >自动测速启用 lazy 模式</label
> >
@ -207,8 +218,17 @@
<div class="form-group mb-5"> <div class="form-group mb-5">
<label for="apiLink">配置链接:</label> <label for="apiLink">配置链接:</label>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input class="form-control" id="apiLink" readonly type="text" /> <input
<button class="btn btn-primary" onclick="generateURL()" type="button"> class="form-control"
id="apiLink"
readonly
type="text"
/>
<button
class="btn btn-primary"
onclick="generateURL()"
type="button"
>
生成链接 生成链接
</button> </button>
<button <button
@ -220,7 +240,12 @@
</button> </button>
</div> </div>
<div class="input-group"> <div class="input-group">
<input class="form-control" id="apiShortLink" readonly type="text" /> <input
class="form-control"
id="apiShortLink"
readonly
type="text"
/>
<input <input
class="form-control" class="form-control"
id="password" id="password"
@ -247,7 +272,9 @@
<footer> <footer>
<p class="text-center"> <p class="text-center">
Powered by Powered by
<a class="link-primary" href="https://github.com/nitezs/sub2clash" <a
class="link-primary"
href="https://github.com/nitezs/sub2clash"
>sub2clash</a >sub2clash</a
> >
</p> </p>

View File

@ -114,7 +114,11 @@ function generateURI() {
let prepend = rules[i * 3 + 1].value; let prepend = rules[i * 3 + 1].value;
let group = rules[i * 3 + 2].value; let group = rules[i * 3 + 2].value;
// 是否存在空值 // 是否存在空值
if (rule.trim() === "" || prepend.trim() === "" || group.trim() === "") { if (
rule.trim() === "" ||
prepend.trim() === "" ||
group.trim() === ""
) {
alert("Rule 中存在空值,请检查后重试!"); alert("Rule 中存在空值,请检查后重试!");
return ""; return "";
} }
@ -189,14 +193,16 @@ function parseInputURL() {
} }
if (params.has("sub")) { if (params.has("sub")) {
document.getElementById("sub").value = decodeURIComponent(params.get("sub")) document.getElementById("sub").value = decodeURIComponent(
params.get("sub")
)
.split(",") .split(",")
.join("\n"); .join("\n");
} }
if (params.has("proxy")) { if (params.has("proxy")) {
document.getElementById("proxy").value = decodeURIComponent( document.getElementById("proxy").value = decodeURIComponent(
params.get("proxy"), params.get("proxy")
) )
.split(",") .split(",")
.join("\n"); .join("\n");
@ -218,7 +224,7 @@ function parseInputURL() {
if (params.has("template")) { if (params.has("template")) {
document.getElementById("template").value = decodeURIComponent( document.getElementById("template").value = decodeURIComponent(
params.get("template"), params.get("template")
); );
} }
@ -228,7 +234,7 @@ function parseInputURL() {
if (params.has("remove")) { if (params.has("remove")) {
document.getElementById("remove").value = decodeURIComponent( document.getElementById("remove").value = decodeURIComponent(
params.get("remove"), params.get("remove")
); );
} }
@ -238,7 +244,7 @@ function parseInputURL() {
if (params.has("ruleProvider")) { if (params.has("ruleProvider")) {
parseAndFillRuleProviderParams( parseAndFillRuleProviderParams(
decodeURIComponent(params.get("ruleProvider")), decodeURIComponent(params.get("ruleProvider"))
); );
} }
@ -403,7 +409,7 @@ function generateShortLink() {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}, }
) )
.then((response) => { .then((response) => {
apiShortLink.value = `${window.location.origin}${window.location.pathname}s/${response.data}`; apiShortLink.value = `${window.location.origin}${window.location.pathname}s/${response.data}`;

View File

@ -2,9 +2,10 @@ package config
import ( import (
"errors" "errors"
"github.com/joho/godotenv"
"os" "os"
"strconv" "strconv"
"github.com/joho/godotenv"
) )
type Config struct { type Config struct {

View File

@ -1,4 +1,4 @@
version: '3' version: "3"
services: services:
sub2clash: sub2clash:

View File

@ -1,11 +1,12 @@
package logger package logger
import ( import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
) )
var ( var (

View File

@ -2,8 +2,6 @@ package main
import ( import (
_ "embed" _ "embed"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"io" "io"
"strconv" "strconv"
"sub2clash/api" "sub2clash/api"
@ -11,6 +9,9 @@ import (
"sub2clash/logger" "sub2clash/logger"
"sub2clash/utils" "sub2clash/utils"
"sub2clash/utils/database" "sub2clash/utils/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
) )
//go:embed templates/template_meta.yaml //go:embed templates/template_meta.yaml

View File

@ -1,11 +1,12 @@
package middleware package middleware
import ( import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"strconv" "strconv"
"sub2clash/logger" "sub2clash/logger"
"time" "time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
) )
func ZapLogger() gin.HandlerFunc { func ZapLogger() gin.HandlerFunc {

View File

@ -23,6 +23,8 @@ func GetSupportProxyTypes(clashType ClashType) map[string]bool {
"vmess": true, "vmess": true,
"trojan": true, "trojan": true,
"vless": true, "vless": true,
"hysteria": true,
"hysteria2": true,
} }
} }
return nil return nil

View File

@ -45,6 +45,17 @@ type Proxy struct {
UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"` UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"`
UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"` UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"`
SubName string `yaml:"-"` SubName string `yaml:"-"`
Up string `yaml:"up,omitempty"`
Down string `yaml:"down,omitempty"`
CustomCA string `yaml:"ca,omitempty"`
CustomCAString string `yaml:"ca-str,omitempty"`
CWND int `yaml:"cwnd,omitempty"`
Auth string `yaml:"auth,omitempty"`
ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"`
ReceiveWindow int `yaml:"recv-window,omitempty"`
DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"`
FastOpen bool `yaml:"fast-open,omitempty"`
HopInterval int `yaml:"hop-interval,omitempty"`
} }
func (p Proxy) MarshalYAML() (interface{}, error) { func (p Proxy) MarshalYAML() (interface{}, error) {
@ -59,6 +70,10 @@ func (p Proxy) MarshalYAML() (interface{}, error) {
return ProxyToVless(p), nil return ProxyToVless(p), nil
case "trojan": case "trojan":
return ProxyToTrojan(p), nil return ProxyToTrojan(p), nil
case "hysteria":
return ProxyToHysteria(p), nil
case "hysteria2":
return ProxyToHysteria2(p), nil
} }
return nil, nil return nil, nil
} }

View File

@ -15,12 +15,15 @@ type ProxyGroup struct {
Tolerance int `yaml:"tolerance,omitempty"` Tolerance int `yaml:"tolerance,omitempty"`
Lazy bool `yaml:"lazy"` Lazy bool `yaml:"lazy"`
Size int `yaml:"-"` Size int `yaml:"-"`
DisableUDP bool `yaml:"disable-udp,omitempty"`
Strategy string `yaml:"strategy,omitempty"`
} }
type SelectProxyGroup struct { type SelectProxyGroup struct {
Name string `yaml:"name,omitempty"` Name string `yaml:"name,omitempty"`
Type string `yaml:"type,omitempty"` Type string `yaml:"type,omitempty"`
Proxies []string `yaml:"proxies,omitempty"` Proxies []string `yaml:"proxies,omitempty"`
DisableUDP bool `yaml:"disable-udp,omitempty"`
} }
type UrlTestProxyGroup struct { type UrlTestProxyGroup struct {
@ -31,6 +34,24 @@ type UrlTestProxyGroup struct {
Interval int `yaml:"interval,omitempty"` Interval int `yaml:"interval,omitempty"`
Tolerance int `yaml:"tolerance,omitempty"` Tolerance int `yaml:"tolerance,omitempty"`
Lazy bool `yaml:"lazy"` Lazy bool `yaml:"lazy"`
DisableUDP bool `yaml:"disable-udp,omitempty"`
}
type LoadBalanceProxyGroup struct {
Name string `yaml:"name,omitempty"`
Type string `yaml:"type,omitempty"`
Proxies []string `yaml:"proxies,omitempty"`
DisableUDP bool `yaml:"disable-udp,omitempty"`
Url string `yaml:"url,omitempty"`
Interval int `yaml:"interval,omitempty"`
Lazy bool `yaml:"lazy"`
Strategy string `yaml:"strategy,omitempty"`
}
type RelayProxyGroup struct {
Name string `yaml:"name,omitempty"`
Type string `yaml:"type,omitempty"`
Proxies []string `yaml:"proxies,omitempty"`
} }
func (p ProxyGroup) MarshalYAML() (interface{}, error) { func (p ProxyGroup) MarshalYAML() (interface{}, error) {
@ -40,8 +61,9 @@ func (p ProxyGroup) MarshalYAML() (interface{}, error) {
Name: p.Name, Name: p.Name,
Type: p.Type, Type: p.Type,
Proxies: p.Proxies, Proxies: p.Proxies,
DisableUDP: p.DisableUDP,
}, nil }, nil
case "url-test": case "url-test", "fallback":
return UrlTestProxyGroup{ return UrlTestProxyGroup{
Name: p.Name, Name: p.Name,
Type: p.Type, Type: p.Type,
@ -50,9 +72,32 @@ func (p ProxyGroup) MarshalYAML() (interface{}, error) {
Interval: p.Interval, Interval: p.Interval,
Tolerance: p.Tolerance, Tolerance: p.Tolerance,
Lazy: p.Lazy, Lazy: p.Lazy,
DisableUDP: p.DisableUDP,
}, nil
case "load-balance":
return LoadBalanceProxyGroup{
Name: p.Name,
Type: p.Type,
Proxies: p.Proxies,
DisableUDP: p.DisableUDP,
Url: p.Url,
Interval: p.Interval,
Lazy: p.Lazy,
Strategy: p.Strategy,
}, nil
case "relay":
return RelayProxyGroup{
Name: p.Name,
Type: p.Type,
Proxies: p.Proxies,
}, nil
default:
return SelectProxyGroup{
Name: p.Name,
Type: p.Type,
Proxies: p.Proxies,
}, nil }, nil
} }
return nil, nil
} }
type ProxyGroupsSortByName []ProxyGroup type ProxyGroupsSortByName []ProxyGroup

53
model/proxy_hysteria.go Normal file
View File

@ -0,0 +1,53 @@
package model
type Hysteria struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Server string `yaml:"server"`
Port int `yaml:"port,omitempty"`
Ports string `yaml:"ports,omitempty"`
Protocol string `yaml:"protocol,omitempty"`
ObfsProtocol string `yaml:"obfs-protocol,omitempty"` // compatible with Stash
Up string `yaml:"up"`
UpSpeed int `yaml:"up-speed,omitempty"` // compatible with Stash
Down string `yaml:"down"`
DownSpeed int `yaml:"down-speed,omitempty"` // compatible with Stash
Auth string `yaml:"auth,omitempty"`
AuthString string `yaml:"auth-str,omitempty"`
Obfs string `yaml:"obfs,omitempty"`
SNI string `yaml:"sni,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
ALPN []string `yaml:"alpn,omitempty"`
CustomCA string `yaml:"ca,omitempty"`
CustomCAString string `yaml:"ca-str,omitempty"`
ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"`
ReceiveWindow int `yaml:"recv-window,omitempty"`
DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"`
FastOpen bool `yaml:"fast-open,omitempty"`
HopInterval int `yaml:"hop-interval,omitempty"`
}
func ProxyToHysteria(p Proxy) Hysteria {
return Hysteria{
Type: "hysteria",
Name: p.Name,
Server: p.Server,
Port: p.Port,
Up: p.Up,
Down: p.Down,
Auth: p.Auth,
Obfs: p.Obfs,
SNI: p.Sni,
SkipCertVerify: p.SkipCertVerify,
Fingerprint: p.Fingerprint,
ALPN: p.Alpn,
CustomCA: p.CustomCA,
CustomCAString: p.CustomCAString,
ReceiveWindowConn: p.ReceiveWindowConn,
ReceiveWindow: p.ReceiveWindow,
DisableMTUDiscovery: p.DisableMTUDiscovery,
FastOpen: p.FastOpen,
HopInterval: p.HopInterval,
}
}

41
model/proxy_hysteria2.go Normal file
View File

@ -0,0 +1,41 @@
package model
type Hysteria2 struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Server string `yaml:"server"`
Port int `yaml:"port"`
Up string `yaml:"up,omitempty"`
Down string `yaml:"down,omitempty"`
Password string `yaml:"password,omitempty"`
Obfs string `yaml:"obfs,omitempty"`
ObfsPassword string `yaml:"obfs-password,omitempty"`
SNI string `yaml:"sni,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
ALPN []string `yaml:"alpn,omitempty"`
CustomCA string `yaml:"ca,omitempty"`
CustomCAString string `yaml:"ca-str,omitempty"`
CWND int `yaml:"cwnd,omitempty"`
}
func ProxyToHysteria2(p Proxy) Hysteria2 {
return Hysteria2{
Type: "hysteria2",
Name: p.Name,
Server: p.Server,
Port: p.Port,
Up: p.Up,
Down: p.Down,
Password: p.Password,
Obfs: p.Obfs,
ObfsPassword: p.ObfsParam,
SNI: p.Sni,
SkipCertVerify: p.SkipCertVerify,
Fingerprint: p.Fingerprint,
ALPN: p.Alpn,
CustomCA: p.CustomCA,
CustomCAString: p.CustomCAString,
CWND: p.CWND,
}
}

View File

@ -1,30 +1,30 @@
package model package model
type HTTPOptions struct { type HTTPOptions struct {
Method string `proxy:"method,omitempty"` Method string `yaml:"method,omitempty"`
Path []string `proxy:"path,omitempty"` Path []string `yaml:"path,omitempty"`
Headers map[string][]string `proxy:"headers,omitempty"` Headers map[string][]string `yaml:"headers,omitempty"`
} }
type HTTP2Options struct { type HTTP2Options struct {
Host []string `proxy:"host,omitempty"` Host []string `yaml:"host,omitempty"`
Path string `proxy:"path,omitempty"` Path string `yaml:"path,omitempty"`
} }
type GrpcOptions struct { type GrpcOptions struct {
GrpcServiceName string `proxy:"grpc-service-name,omitempty"` GrpcServiceName string `yaml:"grpc-service-name,omitempty"`
} }
type RealityOptions struct { type RealityOptions struct {
PublicKey string `proxy:"public-key"` PublicKey string `yaml:"public-key"`
ShortID string `proxy:"short-id"` ShortID string `yaml:"short-id,omitempty"`
} }
type WSOptions struct { type WSOptions struct {
Path string `proxy:"path,omitempty"` Path string `yaml:"path,omitempty"`
Headers map[string]string `proxy:"headers,omitempty"` Headers map[string]string `yaml:"headers,omitempty"`
MaxEarlyData int `proxy:"max-early-data,omitempty"` MaxEarlyData int `yaml:"max-early-data,omitempty"`
EarlyDataHeaderName string `proxy:"early-data-header-name,omitempty"` EarlyDataHeaderName string `yaml:"early-data-header-name,omitempty"`
} }
type VmessJson struct { type VmessJson struct {

79
parser/hysteria.go Normal file
View File

@ -0,0 +1,79 @@
package parser
import (
"errors"
"net/url"
"strconv"
"strings"
"sub2clash/model"
)
//hysteria://host:port?protocol=udp&auth=123456&peer=sni.domain&insecure=1&upmbps=100&downmbps=100&alpn=hysteria&obfs=xplus&obfsParam=123456#remarks
//
//- host: hostname or IP address of the server to connect to (required)
//- port: port of the server to connect to (required)
//- protocol: protocol to use ("udp", "wechat-video", "faketcp") (optional, default: "udp")
//- auth: authentication payload (string) (optional)
//- peer: SNI for TLS (optional)
//- insecure: ignore certificate errors (optional)
//- upmbps: upstream bandwidth in Mbps (required)
//- downmbps: downstream bandwidth in Mbps (required)
//- alpn: QUIC ALPN (optional)
//- obfs: Obfuscation mode (optional, empty or "xplus")
//- obfsParam: Obfuscation password (optional)
//- remarks: remarks (optional)
func ParseHysteria(proxy string) (model.Proxy, error) {
// 判断是否以 hysteria:// 开头
if !strings.HasPrefix(proxy, "hysteria://") {
return model.Proxy{}, errors.New("invalid hysteria Url")
}
// 分割
parts := strings.SplitN(strings.TrimPrefix(proxy, "hysteria://"), "?", 2)
serverInfo := strings.SplitN(parts[0], ":", 2)
if len(serverInfo) != 2 {
return model.Proxy{}, errors.New("invalid hysteria Url")
}
params, err := url.ParseQuery(parts[1])
if err != nil {
return model.Proxy{}, errors.New("invalid hysteria Url")
}
host := serverInfo[0]
port, err := strconv.Atoi(serverInfo[1])
if err != nil {
return model.Proxy{}, errors.New("invalid hysteria Url")
}
protocol := params.Get("protocol")
auth := params.Get("auth")
peer := params.Get("peer")
insecure := params.Get("insecure")
upmbps := params.Get("upmbps")
downmbps := params.Get("downmbps")
alpn := params.Get("alpn")
obfs := params.Get("obfs")
obfsParam := params.Get("obfsParam")
remarks := ""
if strings.Contains(parts[1], "#") {
r := strings.Split(parts[1], "#")
remarks = r[len(r)-1]
} else {
remarks = serverInfo[0] + ":" + serverInfo[1]
}
// 返回结果
result := model.Proxy{
Type: "hysteria",
Name: remarks,
Server: host,
Port: port,
Up: upmbps,
Down: downmbps,
Auth: auth,
Obfs: obfs,
Sni: peer,
SkipCertVerify: insecure == "1",
Alpn: strings.Split(alpn, ","),
ObfsParam: obfsParam,
Protocol: protocol,
}
return result, nil
}

50
parser/hysteria2.go Normal file
View File

@ -0,0 +1,50 @@
package parser
import (
"errors"
"net/url"
"strconv"
"strings"
"sub2clash/model"
)
// hysteria2://letmein@example.com/?insecure=1&obfs=salamander&obfs-password=gawrgura&pinSHA256=deadbeef&sni=real.example.com
func ParseHysteria2(proxy string) (model.Proxy, error) {
// 判断是否以 hysteria2:// 开头
if !strings.HasPrefix(proxy, "hysteria2://") && !strings.HasPrefix(proxy, "hy2://") {
return model.Proxy{}, errors.New("invalid hysteria2 Url")
}
// 分割
parts := strings.SplitN(strings.TrimPrefix(proxy, "hysteria2://"), "@", 2)
// 分割
serverInfo := strings.SplitN(parts[1], "/?", 2)
serverAndPort := strings.SplitN(serverInfo[0], ":", 2)
if len(serverAndPort) == 1 {
serverAndPort = append(serverAndPort, "443")
} else if len(serverAndPort) != 2 {
return model.Proxy{}, errors.New("invalid hysteria2 Url")
}
params, err := url.ParseQuery(serverInfo[1])
if err != nil {
return model.Proxy{}, errors.New("invalid hysteria2 Url")
}
// 获取端口
port, err := strconv.Atoi(serverAndPort[1])
if err != nil {
return model.Proxy{}, errors.New("invalid hysteria2 Url")
}
// 返回结果
result := model.Proxy{
Type: "hysteria2",
Name: params.Get("name"),
Server: serverAndPort[0],
Port: port,
Password: parts[0],
Obfs: params.Get("obfs"),
ObfsParam: params.Get("obfs-password"),
Sni: params.Get("sni"),
SkipCertVerify: params.Get("insecure") == "1",
}
return result, nil
}

View File

@ -43,9 +43,9 @@ func ParseVless(proxy string) (model.Proxy, error) {
UDP: true, UDP: true,
Sni: params.Get("sni"), Sni: params.Get("sni"),
Network: params.Get("type"), Network: params.Get("type"),
TLS: params.Get("security") == "tls", TLS: params.Get("security") == "reality",
Flow: params.Get("flow"), Flow: params.Get("flow"),
Fingerprint: params.Get("fp"), ClientFingerprint: params.Get("fp"),
Servername: params.Get("sni"), Servername: params.Get("sni"),
RealityOpts: model.RealityOptions{ RealityOpts: model.RealityOptions{
PublicKey: params.Get("pbk"), PublicKey: params.Get("pbk"),
@ -72,10 +72,16 @@ func ParseVless(proxy string) (model.Proxy, error) {
if strings.Contains(serverInfo[1], "|") { if strings.Contains(serverInfo[1], "|") {
result.Name = strings.SplitN(serverInfo[1], "|", 2)[1] result.Name = strings.SplitN(serverInfo[1], "|", 2)[1]
} else { } else {
result.Name = serverInfo[1] result.Name, err = url.QueryUnescape(serverInfo[1])
if err != nil {
return model.Proxy{}, err
}
} }
} else { } else {
result.Name = serverAndPort[0] result.Name, err = url.QueryUnescape(serverAndPort[0])
if err != nil {
return model.Proxy{}, err
}
} }
return result, nil return result, nil
} }

View File

@ -3,6 +3,7 @@ package parser
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/url"
"strconv" "strconv"
"strings" "strings"
"sub2clash/model" "sub2clash/model"
@ -56,9 +57,13 @@ func ParseVmess(proxy string) (model.Proxy, error) {
if vmess.Net == "ws" && vmess.Host == "" { if vmess.Net == "ws" && vmess.Host == "" {
vmess.Host = vmess.Add vmess.Host = vmess.Add
} }
name, err := url.QueryUnescape(vmess.Ps)
if err != nil {
name = vmess.Ps
}
// 返回结果 // 返回结果
result := model.Proxy{ result := model.Proxy{
Name: vmess.Ps, Name: name,
Type: "vmess", Type: "vmess",
Server: vmess.Add, Server: vmess.Add,
Port: port, Port: port,

View File

@ -1,13 +1,14 @@
package database package database
import ( import (
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"path/filepath" "path/filepath"
"sub2clash/logger" "sub2clash/logger"
"sub2clash/model" "sub2clash/model"
"sub2clash/utils" "sub2clash/utils"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
) )
var DB *gorm.DB var DB *gorm.DB

View File

@ -1,11 +1,12 @@
package utils package utils
import ( import (
"go.uber.org/zap"
"strings" "strings"
"sub2clash/logger" "sub2clash/logger"
"sub2clash/model" "sub2clash/model"
"sub2clash/parser" "sub2clash/parser"
"go.uber.org/zap"
) )
func GetContryName(countryKey string) string { func GetContryName(countryKey string) string {
@ -126,6 +127,12 @@ func ParseProxy(proxies ...string) []model.Proxy {
if strings.HasPrefix(proxy, "ssr://") { if strings.HasPrefix(proxy, "ssr://") {
proxyItem, err = parser.ParseShadowsocksR(proxy) proxyItem, err = parser.ParseShadowsocksR(proxy)
} }
if strings.HasPrefix(proxy, "hysteria2://") || strings.HasPrefix(proxy, "hy2://") {
proxyItem, err = parser.ParseHysteria2(proxy)
}
if strings.HasPrefix(proxy, "hysteria://") {
proxyItem, err = parser.ParseHysteria(proxy)
}
if err == nil { if err == nil {
result = append(result, proxyItem) result = append(result, proxyItem)
} else { } else {

View File

@ -4,11 +4,11 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"github.com/gin-gonic/gin"
"net/url" "net/url"
"os"
"regexp" "regexp"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
type SubValidator struct { type SubValidator struct {
@ -72,14 +72,14 @@ func ParseQuery(c *gin.Context) (SubValidator, error) {
query.Proxies = nil query.Proxies = nil
} }
if query.Template != "" { if query.Template != "" {
if strings.HasPrefix(query.Template, "http") {
uri, err := url.ParseRequestURI(query.Template) uri, err := url.ParseRequestURI(query.Template)
if err != nil { if err != nil {
if strings.Contains(query.Template, string(os.PathSeparator)) {
return SubValidator{}, err return SubValidator{}, err
} }
}
query.Template = uri.String() query.Template = uri.String()
} }
}
if query.RuleProvider != "" { if query.RuleProvider != "" {
reg := regexp.MustCompile(`\[(.*?)\]`) reg := regexp.MustCompile(`\[(.*?)\]`)
ruleProviders := reg.FindAllStringSubmatch(query.RuleProvider, -1) ruleProviders := reg.FindAllStringSubmatch(query.RuleProvider, -1)