10 Commits

Author SHA1 Message Date
b5fcbab1a5 Merge pull request #63 from pingchieh/main
添加Anytls协议
2025-05-28 14:43:29 +10:00
0b8299f432 feat: 返回头中增加订阅信息 2025-05-26 11:18:33 +00:00
84c5a6e5f4 fix: RuleProvider增加Format 2025-05-26 11:18:33 +00:00
06887d91ac feat: 添加 Anytls 代理支持 2025-05-26 11:18:33 +00:00
db00433931 Merge pull request #59 from timerzz/main
fix 修复Get函数重试结束后,返回nil,nil的bug
2025-03-24 01:12:06 +11:00
5b3a12f00d fix 修复Get函数重试结束后,返回nil,nil的bug 2025-03-23 21:27:05 +08:00
d4d7010d8f Merge pull request #56 from hausen1012/main
add:生成短链支持自定义id
2025-03-10 12:28:37 +11:00
hz
2331cd4d18 modify: 短链和密码修改为后端生成 2025-03-08 19:02:23 +08:00
hz
88d8653ab5 fix:修复ua标识等两个前端未正常回显问题 2025-03-07 17:41:42 +08:00
hz
cc0b73d7a4 add:生成短链支持自定义id 2025-03-07 17:30:28 +08:00
15 changed files with 412 additions and 93 deletions

View File

@ -18,6 +18,7 @@
- Hysteria Clash.Meta - Hysteria Clash.Meta
- Hysteria2 Clash.Meta - Hysteria2 Clash.Meta
- Socks5 - Socks5
- Anytls Clash.Meta
## 使用 ## 使用

View File

@ -233,6 +233,21 @@ func BuildSub(clashType model.ClashType, query validator.SubValidator, template
return temp, nil return temp, nil
} }
func fetchSubscriptionUserInfo(url string, userAgent string) (string, error) {
resp, err := common.Head(url, common.WithUserAgent(userAgent))
if err != nil {
logger.Logger.Debug("创建 HEAD 请求失败", zap.Error(err))
return "", err
}
defer resp.Body.Close()
if userInfo := resp.Header.Get("subscription-userinfo"); userInfo != "" {
return userInfo, nil
}
logger.Logger.Debug("目标 URL 未返回 subscription-userinfo 头", zap.Error(err))
return "", err
}
func MergeSubAndTemplate(temp *model.Subscription, sub *model.Subscription, igcg bool) { func MergeSubAndTemplate(temp *model.Subscription, sub *model.Subscription, igcg bool) {
var countryGroupNames []string var countryGroupNames []string

View File

@ -25,6 +25,14 @@ func SubHandler(c *gin.Context) {
return return
} }
if len(query.Subs) == 1 {
userInfoHeader, err := fetchSubscriptionUserInfo(query.Subs[0], "clash")
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
}
c.Header("subscription-userinfo", userInfoHeader)
}
if query.NodeListMode { if query.NodeListMode {
nodelist := model.NodeList{} nodelist := model.NodeList{}
nodelist.Proxies = sub.Proxies nodelist.Proxies = sub.Proxies

View File

@ -32,16 +32,41 @@ func GenerateLinkHandler(c *gin.Context) {
return return
} }
hash, err := generateUniqueHash() var hash string
var password string
var err error
if params.CustomID != "" {
// 检查自定义ID是否已存在
exists, err := database.CheckShortLinkHashExists(params.CustomID)
if err != nil {
respondWithError(c, http.StatusInternalServerError, "数据库错误")
return
}
if exists {
respondWithError(c, http.StatusBadRequest, "短链已存在")
return
}
hash = params.CustomID
password = params.Password
} else {
// 自动生成短链ID和密码
hash, err = generateUniqueHash()
if err != nil { if err != nil {
respondWithError(c, http.StatusInternalServerError, "生成短链接失败") respondWithError(c, http.StatusInternalServerError, "生成短链接失败")
return return
} }
if params.Password == "" {
password = common.RandomString(8) // 生成8位随机密码
} else {
password = params.Password
}
}
shortLink := model.ShortLink{ shortLink := model.ShortLink{
Hash: hash, Hash: hash,
Url: params.Url, Url: params.Url,
Password: params.Password, Password: password,
} }
if err := database.SaveShortLink(&shortLink); err != nil { if err := database.SaveShortLink(&shortLink); err != nil {
@ -49,10 +74,12 @@ func GenerateLinkHandler(c *gin.Context) {
return return
} }
if params.Password != "" { // 返回生成的短链ID和密码
hash += "?password=" + params.Password response := map[string]string{
"hash": hash,
"password": password,
} }
c.String(http.StatusOK, hash) c.JSON(http.StatusOK, response)
} }
func generateUniqueHash() (string, error) { func generateUniqueHash() (string, error) {
@ -74,11 +101,27 @@ func UpdateLinkHandler(c *gin.Context) {
respondWithError(c, http.StatusBadRequest, "参数错误: "+err.Error()) respondWithError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return return
} }
// 先获取原有的短链接
existingLink, err := database.FindShortLinkByHash(params.Hash)
if err != nil {
respondWithError(c, http.StatusNotFound, "未找到短链接")
return
}
// 验证密码
if existingLink.Password != params.Password {
respondWithError(c, http.StatusUnauthorized, "密码错误")
return
}
// 更新URL但保持原密码不变
shortLink := model.ShortLink{ shortLink := model.ShortLink{
Hash: params.Hash, Hash: params.Hash,
Url: params.Url, Url: params.Url,
Password: params.Password, Password: existingLink.Password, // 保持原密码不变
} }
if err := database.SaveShortLink(&shortLink); err != nil { if err := database.SaveShortLink(&shortLink); err != nil {
respondWithError(c, http.StatusInternalServerError, "数据库错误") respondWithError(c, http.StatusInternalServerError, "数据库错误")
return return

View File

@ -79,7 +79,7 @@
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="user-agent">ua标识:</label> <label for="user-agent">ua标识:</label>
<textarea class="form-control" id="user-agent" name="user-agent" <textarea class="form-control" id="user-agent" name="user-agent"
placeholder="用于获取订阅的http请求中的user-agent标识(可选)" rows="1"></textarea> placeholder="用于获取订阅的http请求中的user-agent标识(可选)" rows="3"></textarea>
</div> </div>
<!-- Refresh --> <!-- Refresh -->
<div class="form-check mb-3"> <div class="form-check mb-3">
@ -147,24 +147,27 @@
<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" type="text" placeholder="链接" /> <input class="form-control bg-light" id="apiLink" type="text" placeholder="链接" readonly style="cursor: not-allowed;" />
<button class="btn btn-primary" onclick="copyToClipboard('apiLink',this)" type="button"> <button class="btn btn-primary" onclick="copyToClipboard('apiLink',this)" type="button">
复制链接 复制链接
</button> </button>
</div> </div>
<div class="input-group"> <div class="input-group mb-2">
<input class="form-control" id="apiShortLink" type="text" placeholder="链接" /> <input class="form-control" id="customId" type="text" placeholder="短链ID可选" />
<input class="form-control" id="password" type="text" placeholder="密码" /> <input class="form-control" id="password" type="text" placeholder="密码(可选)" />
<button class="btn btn-primary" onclick="generateShortLink()" type="button"> <button class="btn btn-primary" onclick="generateShortLink()" type="button">
生成短链 生成短链
</button> </button>
<button class="btn btn-primary" onclick="updateShortLink()" type="button">
更新短链
</button>
<button class="btn btn-primary" onclick="copyToClipboard('apiShortLink',this)" type="button"> <button class="btn btn-primary" onclick="copyToClipboard('apiShortLink',this)" type="button">
复制短链 复制短链
</button> </button>
</div> </div>
<div class="input-group">
<input class="form-control bg-light" id="apiShortLink" type="text" placeholder="短链接" readonly style="cursor: not-allowed;" />
<button class="btn btn-primary" onclick="updateShortLink()" type="button">
更新短链
</button>
</div>
</div> </div>
<!-- footer--> <!-- footer-->

View File

@ -1,3 +1,15 @@
function setInputReadOnly(input, readonly) {
if (readonly) {
input.readOnly = true;
input.classList.add('bg-light');
input.style.cursor = 'not-allowed';
} else {
input.readOnly = false;
input.classList.remove('bg-light');
input.style.cursor = 'auto';
}
}
function clearExistingValues() { function clearExistingValues() {
// 清除简单输入框和复选框的值 // 清除简单输入框和复选框的值
document.getElementById("endpoint").value = "clash"; document.getElementById("endpoint").value = "clash";
@ -12,7 +24,23 @@ function clearExistingValues() {
document.getElementById("remove").value = ""; document.getElementById("remove").value = "";
document.getElementById("apiLink").value = ""; document.getElementById("apiLink").value = "";
document.getElementById("apiShortLink").value = ""; document.getElementById("apiShortLink").value = "";
document.getElementById("password").value = "";
// 恢复短链ID和密码输入框状态
const customIdInput = document.getElementById("customId");
const passwordInput = document.getElementById("password");
const generateButton = document.querySelector('button[onclick="generateShortLink()"]');
customIdInput.value = "";
setInputReadOnly(customIdInput, false);
passwordInput.value = "";
setInputReadOnly(passwordInput, false);
// 恢复生成短链按钮状态
generateButton.disabled = false;
generateButton.classList.remove('btn-secondary');
generateButton.classList.add('btn-primary');
document.getElementById("nodeList").checked = false; document.getElementById("nodeList").checked = false;
// 清除由 createRuleProvider, createReplace, 和 createRule 创建的所有额外输入组 // 清除由 createRuleProvider, createReplace, 和 createRule 创建的所有额外输入组
@ -188,8 +216,32 @@ async function parseInputURL() {
try { try {
const response = await axios.get("./short?" + q.toString()); const response = await axios.get("./short?" + q.toString());
url = new URL(window.location.href + response.data); url = new URL(window.location.href + response.data);
document.querySelector("#apiShortLink").value = inputURL;
document.querySelector("#password").value = password; // 回显配置链接
const apiLinkInput = document.querySelector("#apiLink");
apiLinkInput.value = `${window.location.origin}${window.location.pathname}${response.data}`;
setInputReadOnly(apiLinkInput, true);
// 回显短链相关信息
const apiShortLinkInput = document.querySelector("#apiShortLink");
apiShortLinkInput.value = inputURL;
setInputReadOnly(apiShortLinkInput, true);
// 设置短链ID和密码并设置为只读
const customIdInput = document.querySelector("#customId");
const passwordInput = document.querySelector("#password");
const generateButton = document.querySelector('button[onclick="generateShortLink()"]');
customIdInput.value = hash;
setInputReadOnly(customIdInput, true);
passwordInput.value = password;
setInputReadOnly(passwordInput, true);
// 禁用生成短链按钮
generateButton.disabled = true;
generateButton.classList.add('btn-secondary');
generateButton.classList.remove('btn-primary');
} catch (error) { } catch (error) {
console.log(error); console.log(error);
alert("获取短链失败,请检查密码!"); alert("获取短链失败,请检查密码!");
@ -255,6 +307,17 @@ async function parseInputURL() {
); );
} }
if (params.has("userAgent")) {
document.getElementById("user-agent").value = decodeURIComponent(
params.get("userAgent")
);
}
if (params.has("ignoreCountryGroup")) {
document.getElementById("igcg").checked =
params.get("ignoreCountryGroup") === "true";
}
if (params.has("replace")) { if (params.has("replace")) {
parseAndFillReplaceParams(decodeURIComponent(params.get("replace"))); parseAndFillReplaceParams(decodeURIComponent(params.get("replace")));
} }
@ -428,21 +491,25 @@ function generateURL() {
return; return;
} }
apiLink.value = `${window.location.origin}${window.location.pathname}${uri}`; apiLink.value = `${window.location.origin}${window.location.pathname}${uri}`;
setInputReadOnly(apiLink, true);
} }
function generateShortLink() { function generateShortLink() {
const apiShortLink = document.getElementById("apiShortLink"); const apiShortLink = document.getElementById("apiShortLink");
const password = document.getElementById("password"); const password = document.getElementById("password");
const customId = document.getElementById("customId");
let uri = generateURI(); let uri = generateURI();
if (uri === "") { if (uri === "") {
return; return;
} }
axios axios
.post( .post(
"./short", "./short",
{ {
url: uri, url: uri,
password: password.value.trim(), password: password.value.trim(),
customId: customId.value.trim()
}, },
{ {
headers: { headers: {
@ -451,11 +518,20 @@ function generateShortLink() {
} }
) )
.then((response) => { .then((response) => {
apiShortLink.value = `${window.location.origin}${window.location.pathname}s/${response.data}`; // 设置返回的短链ID和密码
customId.value = response.data.hash;
password.value = response.data.password;
// 生成完整的短链接
const shortLink = `${window.location.origin}${window.location.pathname}s/${response.data.hash}?password=${response.data.password}`;
apiShortLink.value = shortLink;
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
if (error.response && error.response.data) {
alert(error.response.data);
} else {
alert("生成短链失败,请重试!"); alert("生成短链失败,请重试!");
}
}); });
} }
@ -468,7 +544,7 @@ function updateShortLink() {
hash = u.pathname.substring(u.pathname.lastIndexOf("/s/") + 3); hash = u.pathname.substring(u.pathname.lastIndexOf("/s/") + 3);
} }
if (password.value.trim() === "") { if (password.value.trim() === "") {
alert("请输入密码!"); alert("请输入密码进行验证");
return; return;
} }
let uri = generateURI(); let uri = generateURI();
@ -490,11 +566,17 @@ function updateShortLink() {
} }
) )
.then((response) => { .then((response) => {
alert("更新短链成功!"); alert(`短链 ${hash} 更新成功!`);
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
if (error.response && error.response.status === 401) {
alert("密码错误,请输入正确的原密码!");
} else if (error.response && error.response.data) {
alert(error.response.data); alert(error.response.data);
} else {
alert("更新短链失败,请重试!");
}
}); });
} }

View File

@ -2,6 +2,7 @@ package common
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"time" "time"
@ -28,10 +29,12 @@ func Get(url string, options ...GetOption) (resp *http.Response, err error) {
for _, option := range options { for _, option := range options {
option(&getConfig) option(&getConfig)
} }
var req *http.Request
var get *http.Response
for haveTried < retryTimes { for haveTried < retryTimes {
client := &http.Client{} client := &http.Client{}
//client.Timeout = time.Second * 10 //client.Timeout = time.Second * 10
req, err := http.NewRequest("GET", url, nil) req, err = http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
haveTried++ haveTried++
time.Sleep(retryDelay) time.Sleep(retryDelay)
@ -40,13 +43,12 @@ func Get(url string, options ...GetOption) (resp *http.Response, err error) {
if getConfig.userAgent != "" { if getConfig.userAgent != "" {
req.Header.Set("User-Agent", getConfig.userAgent) req.Header.Set("User-Agent", getConfig.userAgent)
} }
get, err := client.Do(req) get, err = client.Do(req)
if err != nil { if err != nil {
haveTried++ haveTried++
time.Sleep(retryDelay) time.Sleep(retryDelay)
continue continue
} else { } else {
if get != nil && get.ContentLength > config.Default.RequestMaxFileSize { if get != nil && get.ContentLength > config.Default.RequestMaxFileSize {
return nil, errors.New("文件过大") return nil, errors.New("文件过大")
} }
@ -54,5 +56,47 @@ func Get(url string, options ...GetOption) (resp *http.Response, err error) {
} }
} }
return nil, err return nil, fmt.Errorf("请求失败:%v", err)
}
func Head(url string, options ...GetOption) (resp *http.Response, err error) {
retryTimes := config.Default.RequestRetryTimes
haveTried := 0
retryDelay := time.Second
// 解析可选参数(如 User-Agent
getConfig := GetConfig{}
for _, option := range options {
option(&getConfig)
}
var req *http.Request
var headResp *http.Response
for haveTried < retryTimes {
client := &http.Client{}
req, err = http.NewRequest("HEAD", url, nil)
if err != nil {
haveTried++
time.Sleep(retryDelay)
continue
}
// 设置 User-Agent如果提供
if getConfig.userAgent != "" {
req.Header.Set("User-Agent", getConfig.userAgent)
}
headResp, err = client.Do(req)
if err != nil {
haveTried++
time.Sleep(retryDelay)
continue
}
// HEAD 请求不检查 ContentLength因为没有响应体
return headResp, nil
}
return nil, fmt.Errorf("HEAD 请求失败:%v", err)
} }

View File

@ -130,6 +130,9 @@ func ParseProxy(proxies ...string) []model.Proxy {
if strings.HasPrefix(proxy, constant.SocksPrefix) { if strings.HasPrefix(proxy, constant.SocksPrefix) {
proxyItem, err = parser.ParseSocks(proxy) proxyItem, err = parser.ParseSocks(proxy)
} }
if strings.HasPrefix(proxy, constant.AnytlsPrefix) {
proxyItem, err = parser.ParseAnytls(proxy)
}
if err == nil { if err == nil {
result = append(result, proxyItem) result = append(result, proxyItem)
} else { } else {

View File

@ -10,4 +10,5 @@ const (
VLESSPrefix string = "vless://" VLESSPrefix string = "vless://"
VMessPrefix string = "vmess://" VMessPrefix string = "vmess://"
SocksPrefix string = "socks" SocksPrefix string = "socks"
AnytlsPrefix string = "anytls://"
) )

View File

@ -27,6 +27,7 @@ func GetSupportProxyTypes(clashType ClashType) map[string]bool {
"hysteria": true, "hysteria": true,
"hysteria2": true, "hysteria2": true,
"socks5": true, "socks5": true,
"anytls": true,
} }
} }
return nil return nil

View File

@ -69,6 +69,9 @@ type Proxy struct {
Peers []WireGuardPeerOption `yaml:"peers,omitempty"` Peers []WireGuardPeerOption `yaml:"peers,omitempty"`
RemoteDnsResolve bool `yaml:"remote-dns-resolve,omitempty"` RemoteDnsResolve bool `yaml:"remote-dns-resolve,omitempty"`
Dns []string `yaml:"dns,omitempty"` Dns []string `yaml:"dns,omitempty"`
IdleSessionCheckInterval int `yaml:"idle-session-check-interval,omitempty"`
IdleSessionTimeout int `yaml:"idle-session-timeout,omitempty"`
MinIdleSession int `yaml:"min-idle-session,omitempty"`
} }
type WireGuardPeerOption struct { type WireGuardPeerOption struct {
@ -98,6 +101,8 @@ func (p Proxy) MarshalYAML() (interface{}, error) {
return ProxyToHysteria(p), nil return ProxyToHysteria(p), nil
case "hysteria2": case "hysteria2":
return ProxyToHysteria2(p), nil return ProxyToHysteria2(p), nil
case "anytls":
return ProxyToAnytls(p), nil
default: default:
return _Proxy(p), nil return _Proxy(p), nil
} }

37
model/proxy_anytls.go Normal file
View File

@ -0,0 +1,37 @@
package model
type Anytls struct {
Type string `yaml:"type"`
Name string `yaml:"name"`
Server string `yaml:"server"`
Port int `yaml:"port"`
Password string `yaml:"password,omitempty"`
Alpn []string `yaml:"alpn,omitempty"`
SNI string `yaml:"sni,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
UDP bool `yaml:"udp,omitempty"`
IdleSessionCheckInterval int `yaml:"idle-session-check-interval,omitempty"`
IdleSessionTimeout int `yaml:"idle-session-timeout,omitempty"`
MinIdleSession int `yaml:"min-idle-session,omitempty"`
}
func ProxyToAnytls(p Proxy) Anytls {
return Anytls{
Type: "anytls",
Name: p.Name,
Server: p.Server,
Port: p.Port,
Password: p.Password,
Alpn: p.Alpn,
SNI: p.Sni,
ClientFingerprint: p.ClientFingerprint,
SkipCertVerify: p.SkipCertVerify,
Fingerprint: p.Fingerprint,
UDP: p.UDP,
IdleSessionCheckInterval: p.IdleSessionCheckInterval,
IdleSessionTimeout: p.IdleSessionTimeout,
MinIdleSession: p.MinIdleSession,
}
}

View File

@ -6,6 +6,7 @@ type RuleProvider struct {
Url string `yaml:"url,omitempty"` Url string `yaml:"url,omitempty"`
Path string `yaml:"path,omitempty"` Path string `yaml:"path,omitempty"`
Interval int `yaml:"interval,omitempty"` Interval int `yaml:"interval,omitempty"`
Format string `yaml:"format,omitempty"`
} }
type Payload struct { type Payload struct {

74
parser/anytls.go Normal file
View File

@ -0,0 +1,74 @@
package parser
import (
"fmt"
"net/url"
"strings"
"github.com/nitezs/sub2clash/constant"
"github.com/nitezs/sub2clash/model"
)
func ParseAnytls(proxy string) (model.Proxy, error) {
if !strings.HasPrefix(proxy, constant.AnytlsPrefix) {
return model.Proxy{}, &ParseError{Type: ErrInvalidPrefix, Raw: proxy}
}
link, err := url.Parse(proxy)
if err != nil {
return model.Proxy{}, &ParseError{
Type: ErrInvalidStruct,
Message: "url parse error",
Raw: proxy,
}
}
username := link.User.Username()
password, exist := link.User.Password()
if !exist {
password = username
}
query := link.Query()
server := link.Hostname()
if server == "" {
return model.Proxy{}, &ParseError{
Type: ErrInvalidStruct,
Message: "missing server host",
Raw: proxy,
}
}
portStr := link.Port()
if portStr == "" {
return model.Proxy{}, &ParseError{
Type: ErrInvalidStruct,
Message: "missing server port",
Raw: proxy,
}
}
port, err := ParsePort(portStr)
if err != nil {
return model.Proxy{}, &ParseError{
Type: ErrInvalidPort,
Raw: portStr,
}
}
insecure, sni := query.Get("insecure"), query.Get("sni")
insecureBool := insecure == "1"
remarks := link.Fragment
if remarks == "" {
remarks = fmt.Sprintf("%s:%s", server, portStr)
}
remarks = strings.TrimSpace(remarks)
result := model.Proxy{
Type: "anytls",
Name: remarks,
Server: server,
Port: port,
Password: password,
Sni: sni,
SkipCertVerify: insecureBool,
}
return result, nil
}

View File

@ -3,6 +3,7 @@ package validator
type ShortLinkGenValidator struct { type ShortLinkGenValidator struct {
Url string `form:"url" binding:"required"` Url string `form:"url" binding:"required"`
Password string `form:"password"` Password string `form:"password"`
CustomID string `form:"customId"`
} }
type GetUrlValidator struct { type GetUrlValidator struct {