Compare commits

..

48 Commits

Author SHA1 Message Date
xream
815552d470 feat: 找不到资源时不通知, 仅保留日志 2024-11-28 15:44:55 +08:00
xream
9d90369594 feat: Trojan URI 支持省略端口号 2024-11-28 13:15:22 +08:00
xream
6aece471aa feat: Stash 使用 includeUnsupportedProxy 参数开启 Shadowsocks 2022 2024-11-27 15:20:20 +08:00
xream
99396773f6 ci: 去除 GitLab Sync 2024-11-26 15:01:04 +08:00
xream
e229408a2d feat: 默认缓存阈值 1024KB 2024-11-24 12:31:18 +08:00
xream
514414587b feat: 默认超时 8000ms 2024-11-24 12:13:52 +08:00
egerndaddy
d4c419745e Update Egern.yaml 2024-11-22 23:59:59 +08:00
xream
fe3da254f4 feat: 支持 Egern 输出 2024-11-21 13:16:04 +08:00
xream
7d8132d7cd feat: 默认输出格式改为 V2Ray; accept 为 application/json 时, 输出 JSON; 响应增加 X-Powered-By Sub-Store 2024-11-19 23:06:45 +08:00
xream
bc1247efaf feat: 手动设置的订阅流量信息会附加到订阅自己的流量信息之前 2024-11-17 23:40:03 +08:00
xream
dea937df66 feat: 默认查询流量信息的 User-AgentQuantumult%20X/1.0.30 (iPhone14,2; iOS 15.6) 改为 clash; 流量信息缓存逻辑调整 2024-11-17 02:10:38 +08:00
xream
cfb5a8e082 feat: 支持解析订阅中的 profile-web-page-url 字段 2024-11-17 01:02:28 +08:00
xream
4790bf47d1 feat: Surge 密码解析支持首尾成对的单引号双引号, 输出时增加双引号 2024-11-16 21:50:52 +08:00
xream
56fd495fb6 feat: 支持更多的 subscription-userinfo 2024-11-12 22:20:46 +08:00
xream
f4639d9a34 feat: 支持更多的 subscription-userinfo 2024-11-12 22:06:22 +08:00
xream
cc58a5541e feat: 订阅刷新按钮逻辑调整为无缓存刷新订阅和流量 2024-11-10 01:22:48 +08:00
xream
772f431887 feat: 模块版文件中增加 token 路由 2024-11-08 18:10:39 +08:00
xream
2b60c515cd feat: 支持管理 token 2024-11-04 13:59:57 +08:00
xream
c8c22c3901 fix: 修复 VMess URI SNI 2024-11-01 20:27:23 +08:00
xream
d8f9466b84 feat(wip): 支持自定义 share token 2024-10-31 23:33:34 +08:00
xream
d12ccad382 feat: MMDB 加入 $utils.ipasn 2024-10-31 01:39:13 +08:00
xream
b4358663cc feat(wip): 支持 JWT 2024-10-31 00:23:45 +08:00
xream
aba6264988 feat(wip): 支持 JWT 2024-10-30 23:08:01 +08:00
xream
2320ab3838 feat(wip): 支持 JWT 2024-10-30 22:51:31 +08:00
xream
542957d34a feat(wip): 支持 JWT 2024-10-30 22:27:39 +08:00
xream
07e50175f9 feat: cipher 应为小写 2024-10-30 16:07:27 +08:00
xream
e09d66060d feat: 远程订阅支持 insecure 不验证服务器证书 2024-10-30 14:33:34 +08:00
xream
b048ecdfff fix: 修复 surge mac 未开启 mihomo 时, 对于不支持的节点未报错, 导致出现 proxy 为 undefined 的问题 2024-10-29 18:31:02 +08:00
xream
aac72fb9a3 feat: Surge 支持 udp-port, 修复 udp-relay 参数解析 2024-10-27 19:00:42 +08:00
xream
baec193e5c feat: 支持 VLESS mKcp 2024-10-23 17:38:59 +08:00
xream
8fe818f826 fix: 处理乱填的订阅流量信息解析报错 2024-10-19 19:12:25 +08:00
xream
72286984ec fix: 修复 YAML 处理 undefined 的问题 2024-10-18 12:38:58 +08:00
xream
27e693c308 feat: ⚠️ BREAKING CHANG 仅手动指定 target 为 SurgeMac 时, 启用 mihomo 来支援 Surge 本身不支持的协议 2024-10-17 20:26:07 +08:00
xream
6cf8080cd3 fix: 修复 VMess VLESS servername 2024-10-17 14:01:44 +08:00
xream
839fcacf63 fix: 修复传输层和 SNI 的问题(有问题麻烦即时反馈 谢谢) 2024-10-16 21:31:41 +08:00
xream
a2e45bcb10 commit
feat: Surge 支持 Shadowsocks 2022(为了兼容 必须使用 `includeUnsupportedProxy` 参数或开启 `包含官方/商店版不支持的协议` 开关)
2024-10-15 17:00:13 +08:00
xream
ea0eb91691 doc: README 2024-10-12 14:51:58 +08:00
xream
1f0ddf2d28 fix: 修复组合订阅预览 2024-10-12 10:46:39 +08:00
xream
a660c6ff90 feat: 组合订阅支持通过单条订阅的标签进行关联 2024-10-11 20:57:45 +08:00
xream
71d9adbc07 chore: bump release version 2024-10-11 20:00:09 +08:00
pillarcoin
97bec9183a fix: clash 配置中 VLESS 节点的 short-id 值被错误解析 2024-10-11 19:10:02 +08:00
xream
ef85b6d0e9 feat: 文件支持设置代理/策略, 链接支持传入 proxy 参数指定代理/策略; 修复代理/策略优先级 2024-10-07 22:05:07 +08:00
xream
8ffb060cb4 feat: 组合订阅支持设置代理/策略, 链接支持传入 proxy 参数指定代理/策略 2024-10-07 20:56:33 +08:00
xream
6d43961e96 feat: Node.js 支持使用环境变量 SUB_STORE_BACKEND_DEFAULT_PROXY 设置默认代理; ProxyUtils 增加 download 方法 2024-10-07 18:43:29 +08:00
xream
f3200aea8c feat: 流量和同步配置也使用默认代理/策略 2024-10-07 18:34:39 +08:00
xream
e2346d16a2 feat: 新增全局代理/策略设置, 前端 > 2.14.265 2024-10-07 18:05:06 +08:00
xream
dc320eaa6c feat(file): 新增启用下载(文件名为显示名称), 前端 > 2.14.264 2024-10-07 17:26:15 +08:00
xream
02031019f7 doc: demo.js 2024-09-22 06:02:58 +08:00
38 changed files with 8360 additions and 7609 deletions

View File

@@ -76,8 +76,8 @@ jobs:
git commit -m "release: ${{ steps.tag.outputs.release_tag }}"
git remote add origin "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}"
git push -f -u origin release
- name: Sync to GitLab
env:
GITLAB_PIPELINE_TOKEN: ${{ secrets.GITLAB_PIPELINE_TOKEN }}
run: |
curl -X POST --fail -F token=$GITLAB_PIPELINE_TOKEN -F ref=master https://gitlab.com/api/v4/projects/48891296/trigger/pipeline
# - name: Sync to GitLab
# env:
# GITLAB_PIPELINE_TOKEN: ${{ secrets.GITLAB_PIPELINE_TOKEN }}
# run: |
# curl -X POST --fail -F token=$GITLAB_PIPELINE_TOKEN -F ref=master https://gitlab.com/api/v4/projects/48891296/trigger/pipeline

View File

@@ -7,7 +7,7 @@
</div>
<p align="center" color="#6a737d">
Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.
Advanced Subscription Manager for QX, Loon, Surge, Stash, Egern and Shadowrocket.
</p>
[![Build](https://github.com/sub-store-org/Sub-Store/actions/workflows/main.yml/badge.svg)](https://github.com/sub-store-org/Sub-Store/actions/workflows/main.yml) ![GitHub](https://img.shields.io/github/license/sub-store-org/Sub-Store) ![GitHub issues](https://img.shields.io/github/issues/sub-store-org/Sub-Store) ![GitHub closed pull requests](https://img.shields.io/github/issues-pr-closed-raw/Peng-Ym/Sub-Store) ![Lines of code](https://img.shields.io/tokei/lines/github/sub-store-org/Sub-Store) ![Size](https://img.shields.io/github/languages/code-size/sub-store-org/Sub-Store)
@@ -26,6 +26,8 @@ Core functionalities:
### Supported Input Formats
> ⚠️ Do not use `Shadowrocket` to export URI and then import it as input. It is not a standard URI.
- [x] URI(SS, SSR, VMess, VLESS, Trojan, Hysteria, Hysteria 2, TUIC v5, WireGuard)
- [x] Clash Proxies YAML
- [x] Clash Proxy JSON(single line)
@@ -33,7 +35,6 @@ Core functionalities:
- [x] Loon (SS, SSR, VMess, Trojan, HTTP, SOCKS5, SOCKS5-TLS, WireGuard, VLESS, Hysteria 2)
- [x] Surge (SS, VMess, Trojan, HTTP, SOCKS5, SOCKS5-TLS, TUIC, Snell, Hysteria 2, SSH(Password authentication only), External Proxy Program(only for macOS), WireGuard(Surge to Surge))
- [x] Surfboard (SS, VMess, Trojan, HTTP, SOCKS5, SOCKS5-TLS, WireGuard(Surfboard to Surfboard))
- [x] Shadowrocket (SS, SSR, VMess, Trojan, HTTP, SOCKS5, Snell, VLESS, WireGuard, Hysteria, Hysteria 2, TUIC)
- [x] Clash.Meta (SS, SSR, VMess, Trojan, HTTP, SOCKS5, Snell, VLESS, WireGuard, Hysteria, Hysteria 2, TUIC)
- [x] Stash (SS, SSR, VMess, Trojan, HTTP, SOCKS5, Snell, VLESS, WireGuard, Hysteria, TUIC, Juicity, SSH)
- [x] Clash (SS, SSR, VMess, Trojan, HTTP, SOCKS5, Snell, VLESS, WireGuard)
@@ -48,6 +49,7 @@ Core functionalities:
- [x] Surge
- [x] SurgeMac(Use mihomo to support protocols that are not supported by Surge itself)
- [x] Loon
- [x] Egern
- [x] Shadowrocket
- [x] QX
- [x] sing-box

View File

@@ -1,6 +1,6 @@
{
"name": "sub-store",
"version": "2.14.385",
"version": "2.14.430",
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and ShadowRocket.",
"main": "src/main.js",
"scripts": {
@@ -30,11 +30,10 @@
"js-base64": "^3.7.2",
"jsrsasign": "^11.1.0",
"lodash": "^4.17.21",
"ms": "^2.1.3",
"nanoid": "^3.3.3",
"request": "^2.88.2",
"requests": "^0.3.0",
"semver": "^7.3.7",
"static-js-yaml": "^1.0.0",
"uuid": "^8.3.2"
"static-js-yaml": "^1.0.0"
},
"devDependencies": {
"@babel/core": "^7.18.0",

14661
backend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ export const FILES_KEY = 'files';
export const MODULES_KEY = 'modules';
export const ARTIFACTS_KEY = 'artifacts';
export const RULES_KEY = 'rules';
export const TOKENS_KEY = 'tokens';
export const GIST_BACKUP_KEY = 'Auto Generated Sub-Store Backup';
export const GIST_BACKUP_FILE_NAME = 'Sub-Store';
export const ARTIFACT_REPOSITORY_KEY = 'Sub-Store Artifacts Repository';

View File

@@ -292,6 +292,7 @@ export const ProxyUtils = {
getISO,
MMDB,
Gist,
download,
};
function tryParse(parser, line) {
@@ -326,6 +327,9 @@ function formatTransportPath(path) {
}
function lastParse(proxy) {
if (typeof proxy.cipher === 'string') {
proxy.cipher = proxy.cipher.toLowerCase();
}
if (typeof proxy.password === 'number') {
proxy.password = numberToString(proxy.password);
}
@@ -407,20 +411,7 @@ function lastParse(proxy) {
proxy['h2-opts'].path = path[0];
}
}
if (proxy.tls && !proxy.sni) {
if (proxy.network) {
let transportHost = proxy[`${proxy.network}-opts`]?.headers?.Host;
transportHost = Array.isArray(transportHost)
? transportHost[0]
: transportHost;
if (transportHost) {
proxy.sni = transportHost;
}
}
if (!proxy.sni && !isIP(proxy.server)) {
proxy.sni = proxy.server;
}
}
// 非 tls, 有 ws/http 传输层, 使用域名的节点, 将设置传输层 Host 防止之后域名解析后丢失域名(不覆盖现有的 Host)
if (
!proxy.tls &&
@@ -447,6 +438,20 @@ function lastParse(proxy) {
proxy[`${proxy.network}-opts`].path = [transportPath];
}
}
if (proxy.tls && !proxy.sni) {
if (!isIP(proxy.server)) {
proxy.sni = proxy.server;
}
if (!proxy.sni && proxy.network) {
let transportHost = proxy[`${proxy.network}-opts`]?.headers?.Host;
transportHost = Array.isArray(transportHost)
? transportHost[0]
: transportHost;
if (transportHost) {
proxy.sni = transportHost;
}
}
}
// if (['hysteria', 'hysteria2', 'tuic'].includes(proxy.type)) {
if (proxy.ports) {
proxy.ports = String(proxy.ports).replace(/\//g, ',');

View File

@@ -316,7 +316,7 @@ function URI_VMess() {
);
}
// https://github.com/2dust/v2rayN/wiki/%E5%88%86%E4%BA%AB%E9%93%BE%E6%8E%A5%E6%A0%BC%E5%BC%8F%E8%AF%B4%E6%98%8E(ver-2)
if (proxy.tls && proxy.sni) {
if (proxy.tls && params.sni && params.sni !== '') {
proxy.sni = params.sni;
}
let httpupgrade = false;
@@ -340,6 +340,10 @@ function URI_VMess() {
} else if (params.net === 'h2' || proxy.network === 'h2') {
proxy.network = 'h2';
}
// 暂不支持 tcp + host + path
// else if (params.net === 'tcp' || proxy.network === 'tcp') {
// proxy.network = 'tcp';
// }
if (proxy.network) {
let transportHost = params.host ?? params.obfsParam;
try {
@@ -391,12 +395,6 @@ function URI_VMess() {
} else {
delete proxy.network;
}
// https://github.com/MetaCubeX/Clash.Meta/blob/Alpha/docs/config.yaml#L413
// sni 优先级应高于 host
if (proxy.tls && !proxy.sni && transportHost) {
proxy.sni = transportHost;
}
}
return proxy;
}
@@ -537,14 +535,13 @@ function URI_VLESS() {
if (Object.keys(opts).length > 0) {
proxy[`${proxy.network}-opts`] = opts;
}
}
if (proxy.tls && !proxy.sni) {
if (proxy.network === 'ws') {
proxy.sni = proxy['ws-opts']?.headers?.Host;
} else if (proxy.network === 'http') {
let httpHost = proxy['http-opts']?.headers?.Host;
proxy.sni = Array.isArray(httpHost) ? httpHost[0] : httpHost;
if (proxy.network === 'kcp') {
// mKCP 种子。省略时不使用种子,但不可以为空字符串。建议 mKCP 用户使用 seed。
if (params.seed) {
proxy.seed = params.seed;
}
// mKCP 的伪装头部类型。当前可选值有 none / srtp / utp / wechat-video / dtls / wireguard。省略时默认值为 none即不使用伪装头部但不可以为空字符串。
proxy.headerType = params.headerType || 'none';
}
}
@@ -845,6 +842,11 @@ function URI_Trojan() {
};
const parse = (line) => {
const matched = /^(trojan:\/\/.*?@.*?)(:(\d+))?\/?(\?.*?)?$/.exec(line);
const port = matched?.[2];
if (!port) {
line = line.replace(matched[1], `${matched[1]}:443`);
}
let [newLine, name] = line.split(/#(.+)/, 2);
const parser = getTrojanURIParser();
const proxy = parser.parse(newLine);
@@ -898,18 +900,7 @@ function Clash_All() {
if (['vmess', 'vless'].includes(proxy.type)) {
proxy.sni = proxy.servername;
delete proxy.servername;
if (proxy.tls && !proxy.sni) {
if (proxy.network === 'ws') {
proxy.sni = proxy['ws-opts']?.headers?.Host;
} else if (proxy.network === 'http') {
let httpHost = proxy['http-opts']?.headers?.Host;
proxy.sni = Array.isArray(httpHost)
? httpHost[0]
: httpHost;
}
}
}
if (proxy['server-cert-fingerprint']) {
proxy['tls-fingerprint'] = proxy['server-cert-fingerprint'];
}

View File

@@ -41,7 +41,7 @@ start = (shadowsocks/vmess/trojan/https/http/snell/socks5/socks5_tls/tuic/tuic_v
return proxy;
}
shadowsocks = tag equals "ss" address (method/passwordk/obfs/obfs_host/obfs_uri/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/fast_open/udp_relay/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
shadowsocks = tag equals "ss" address (method/passwordk/obfs/obfs_host/obfs_uri/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/fast_open/udp_relay/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/udp_port/others)* {
proxy.type = "ss";
// handle obfs
if (obfs.type == "http" || obfs.type === "tls") {
@@ -193,14 +193,14 @@ snell_psk = comma "psk" equals match:[^,]+ { proxy.psk = match.join(""); }
snell_version = comma "version" equals match:$[0-9]+ { proxy.version = parseInt(match.trim()); }
usernamek = comma "username" equals match:[^,]+ { proxy.username = match.join(""); }
passwordk = comma "password" equals match:[^,]+ { proxy.password = match.join(""); }
passwordk = comma "password" equals match:[^,]+ { proxy.password = match.join("").replace(/^"(.*?)"$/, '$1').replace(/^'(.*?)'$/, '$1'); }
vmess_uuid = comma "username" equals match:[^,]+ { proxy.uuid = match.join(""); }
vmess_aead = comma "vmess-aead" equals flag:bool { proxy.aead = flag; }
method = comma "encrypt-method" equals cipher:cipher {
proxy.cipher = cipher;
}
cipher = ("aes-128-cfb"/"aes-128-ctr"/"aes-128-gcm"/"aes-192-cfb"/"aes-192-ctr"/"aes-192-gcm"/"aes-256-cfb"/"aes-256-ctr"/"aes-256-gcm"/"bf-cfb"/"camellia-128-cfb"/"camellia-192-cfb"/"camellia-256-cfb"/"cast5-cfb"/"chacha20-ietf-poly1305"/"chacha20-ietf"/"chacha20-poly1305"/"chacha20"/"des-cfb"/"idea-cfb"/"none"/"rc2-cfb"/"rc4-md5"/"rc4"/"salsa20"/"seed-cfb"/"xchacha20-ietf-poly1305");
cipher = ("aes-128-cfb"/"aes-128-ctr"/"aes-128-gcm"/"aes-192-cfb"/"aes-192-ctr"/"aes-192-gcm"/"aes-256-cfb"/"aes-256-ctr"/"aes-256-gcm"/"bf-cfb"/"camellia-128-cfb"/"camellia-192-cfb"/"camellia-256-cfb"/"cast5-cfb"/"chacha20-ietf-poly1305"/"chacha20-ietf"/"chacha20-poly1305"/"chacha20"/"des-cfb"/"idea-cfb"/"none"/"rc2-cfb"/"rc4-md5"/"rc4"/"salsa20"/"seed-cfb"/"xchacha20-ietf-poly1305"/"2022-blake3-aes-128-gcm"/"2022-blake3-aes-256-gcm");
ws = comma "ws" equals flag:bool { obfs.type = "ws"; }
ws_headers = comma "ws-headers" equals headers:$[^,]+ {
@@ -219,7 +219,7 @@ obfs_host = comma "obfs-host" equals host:domain { obfs.host = host; };
obfs_uri = comma "obfs-uri" equals path:uri { obfs.path = path }
uri = $[^,]+
udp_relay = comma "udp" equals flag:bool { proxy.udp = flag; }
udp_relay = comma "udp-relay" equals flag:bool { proxy.udp = flag; }
fast_open = comma "fast-open" equals flag:bool { proxy.tfo = flag; }
reuse = comma "reuse" equals flag:bool { proxy.reuse = flag; }
ecn = comma "ecn" equals flag:bool { proxy.ecn = flag; }
@@ -240,6 +240,7 @@ idle_timeout = comma "idle-timeout" equals match:$[0-9]+ { proxy["idle-timeout"]
private_key = comma "private-key" equals match:[^,]+ { proxy["keystore-private-key"] = match.join("").replace(/^"(.*)"$/, '$1'); }
server_fingerprint = comma "server-fingerprint" equals match:[^,]+ { proxy["server-fingerprint"] = match.join("").replace(/^"(.*)"$/, '$1'); }
block_quic = comma "block-quic" equals match:[^,]+ { proxy["block-quic"] = match.join(""); }
udp_port = comma "udp-port" equals match:$[0-9]+ { proxy["udp-port"] = parseInt(match.trim()); }
shadow_tls_version = comma "shadow-tls-version" equals match:$[0-9]+ { proxy["shadow-tls-version"] = parseInt(match.trim()); }
shadow_tls_sni = comma "shadow-tls-sni" equals match:[^,]+ { proxy["shadow-tls-sni"] = match.join(""); }
shadow_tls_password = comma "shadow-tls-password" equals match:[^,]+ { proxy["shadow-tls-password"] = match.join(""); }

View File

@@ -39,7 +39,7 @@ start = (shadowsocks/vmess/trojan/https/http/snell/socks5/socks5_tls/tuic/tuic_v
return proxy;
}
shadowsocks = tag equals "ss" address (method/passwordk/obfs/obfs_host/obfs_uri/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/fast_open/udp_relay/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
shadowsocks = tag equals "ss" address (method/passwordk/obfs/obfs_host/obfs_uri/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/fast_open/udp_relay/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/udp_port/others)* {
proxy.type = "ss";
// handle obfs
if (obfs.type == "http" || obfs.type === "tls") {
@@ -191,14 +191,14 @@ snell_psk = comma "psk" equals match:[^,]+ { proxy.psk = match.join(""); }
snell_version = comma "version" equals match:$[0-9]+ { proxy.version = parseInt(match.trim()); }
usernamek = comma "username" equals match:[^,]+ { proxy.username = match.join(""); }
passwordk = comma "password" equals match:[^,]+ { proxy.password = match.join(""); }
passwordk = comma "password" equals match:[^,]+ { proxy.password = match.join("").replace(/^"(.*?)"$/, '$1').replace(/^'(.*?)'$/, '$1'); }
vmess_uuid = comma "username" equals match:[^,]+ { proxy.uuid = match.join(""); }
vmess_aead = comma "vmess-aead" equals flag:bool { proxy.aead = flag; }
method = comma "encrypt-method" equals cipher:cipher {
proxy.cipher = cipher;
}
cipher = ("aes-128-cfb"/"aes-128-ctr"/"aes-128-gcm"/"aes-192-cfb"/"aes-192-ctr"/"aes-192-gcm"/"aes-256-cfb"/"aes-256-ctr"/"aes-256-gcm"/"bf-cfb"/"camellia-128-cfb"/"camellia-192-cfb"/"camellia-256-cfb"/"cast5-cfb"/"chacha20-ietf-poly1305"/"chacha20-ietf"/"chacha20-poly1305"/"chacha20"/"des-cfb"/"idea-cfb"/"none"/"rc2-cfb"/"rc4-md5"/"rc4"/"salsa20"/"seed-cfb"/"xchacha20-ietf-poly1305");
cipher = ("aes-128-cfb"/"aes-128-ctr"/"aes-128-gcm"/"aes-192-cfb"/"aes-192-ctr"/"aes-192-gcm"/"aes-256-cfb"/"aes-256-ctr"/"aes-256-gcm"/"bf-cfb"/"camellia-128-cfb"/"camellia-192-cfb"/"camellia-256-cfb"/"cast5-cfb"/"chacha20-ietf-poly1305"/"chacha20-ietf"/"chacha20-poly1305"/"chacha20"/"des-cfb"/"idea-cfb"/"none"/"rc2-cfb"/"rc4-md5"/"rc4"/"salsa20"/"seed-cfb"/"xchacha20-ietf-poly1305"/"2022-blake3-aes-128-gcm"/"2022-blake3-aes-256-gcm");
ws = comma "ws" equals flag:bool { obfs.type = "ws"; }
ws_headers = comma "ws-headers" equals headers:$[^,]+ {
@@ -217,7 +217,7 @@ obfs_host = comma "obfs-host" equals host:domain { obfs.host = host; };
obfs_uri = comma "obfs-uri" equals path:uri { obfs.path = path }
uri = $[^,]+
udp_relay = comma "udp" equals flag:bool { proxy.udp = flag; }
udp_relay = comma "udp-relay" equals flag:bool { proxy.udp = flag; }
fast_open = comma "fast-open" equals flag:bool { proxy.tfo = flag; }
reuse = comma "reuse" equals flag:bool { proxy.reuse = flag; }
ecn = comma "ecn" equals flag:bool { proxy.ecn = flag; }
@@ -238,6 +238,7 @@ idle_timeout = comma "idle-timeout" equals match:$[0-9]+ { proxy["idle-timeout"]
private_key = comma "private-key" equals match:[^,]+ { proxy["keystore-private-key"] = match.join("").replace(/^"(.*)"$/, '$1'); }
server_fingerprint = comma "server-fingerprint" equals match:[^,]+ { proxy["server-fingerprint"] = match.join("").replace(/^"(.*)"$/, '$1'); }
block_quic = comma "block-quic" equals match:[^,]+ { proxy["block-quic"] = match.join(""); }
udp_port = comma "udp-port" equals match:$[0-9]+ { proxy["udp-port"] = parseInt(match.trim()); }
shadow_tls_version = comma "shadow-tls-version" equals match:$[0-9]+ { proxy["shadow-tls-version"] = parseInt(match.trim()); }
shadow_tls_sni = comma "shadow-tls-sni" equals match:[^,]+ { proxy["shadow-tls-sni"] = match.join(""); }
shadow_tls_password = comma "shadow-tls-password" equals match:[^,]+ { proxy["shadow-tls-password"] = match.join(""); }

View File

@@ -50,10 +50,32 @@ function Clash() {
};
const parse = function (raw) {
// Clash YAML format
// 防止 VLESS节点 reality-opts 选项中的 short-id 被解析成 Infinity
// 匹配 short-id 冒号后面的值(包含空格和引号)
const afterReplace = raw.replace(
/short-id:([ ]*[^,\n}]*)/g,
(matched, value) => {
const afterTrim = value.trim();
// 为空
if (!afterTrim || afterTrim === '') {
return 'short-id: ""'
}
// 是否被引号包裹
if (/^(['"]).*\1$/.test(afterTrim)) {
return `short-id: ${afterTrim}`;
} else {
return `short-id: "${afterTrim}"`
}
}
);
const {
proxies,
'global-client-fingerprint': globalClientFingerprint,
} = safeLoad(raw);
} = safeLoad(afterReplace);
return proxies
.map((p) => {
// https://github.com/MetaCubeX/mihomo/blob/Alpha/docs/config.yaml#L73C1-L73C26

View File

@@ -561,7 +561,7 @@ function ResolveDomainOperator({
throw new Error(`域名解析服务提供方 ${provider} 不支持 ${_type}`);
}
const { defaultTimeout } = $.read(SETTINGS_KEY);
const requestTimeout = timeout || defaultTimeout;
const requestTimeout = timeout || defaultTimeout || 8000;
let type = ['IPv6', 'IP4P'].includes(_type) ? 'IPv6' : 'IPv4';
const resolver = DOMAIN_RESOLVERS[provider];

View File

@@ -0,0 +1,298 @@
export default function Egern_Producer() {
const type = 'ALL';
const produce = (proxies, type, opts = {}) => {
// https://egernapp.com/zh-CN/docs/configuration/proxies
const list = proxies
.filter((proxy) => {
if (opts['include-unsupported-proxy']) return true;
if (
![
'http',
'socks5',
'ss',
'trojan',
'hysteria2',
'vless',
'vmess',
].includes(proxy.type) ||
(proxy.type === 'ss' &&
((proxy.plugin === 'obfs' &&
!['http', 'tls'].includes(
proxy['plugin-opts']?.mode,
)) ||
![
'chacha20-ietf-poly1305',
'chacha20-poly1305',
'aes-256-gcm',
'aes-128-gcm',
'none',
'tbale',
'rc4',
'rc4-md5',
'aes-128-cfb',
'aes-192-cfb',
'aes-256-cfb',
'aes-128-ctr',
'aes-192-ctr',
'aes-256-ctr',
'bf-cfb',
'camellia-128-cfb',
'camellia-192-cfb',
'camellia-256-cfb',
'cast5-cfb',
'des-cfb',
'idea-cfb',
'rc2-cfb',
'seed-cfb',
'salsa20',
'chacha20',
'chacha20-ietf',
].includes(proxy.cipher))) ||
(proxy.type === 'vmess' &&
(![
'auto',
'aes-128-gcm',
'chacha20-poly1305',
'none',
'zero',
].includes(proxy.cipher) ||
(!['http', 'ws', 'tcp'].includes(proxy.network) &&
proxy.network))) ||
(proxy.type === 'trojan' &&
!['http', 'ws', 'tcp'].includes(proxy.network) &&
proxy.network) ||
(proxy.type === 'vless' &&
(typeof proxy.flow !== 'undefined' ||
proxy['reality-opts'] ||
(!['http', 'ws', 'tcp'].includes(proxy.network) &&
proxy.network)))
) {
return false;
}
return true;
})
.map((proxy) => {
if (proxy.type === 'http') {
proxy = {
type: 'http',
name: proxy.name,
server: proxy.server,
port: proxy.port,
username: proxy.username,
password: proxy.password,
tfo: proxy.tfo || proxy['fast-open'],
next_hop: proxy.next_hop,
};
} else if (proxy.type === 'socks5') {
proxy = {
type: 'socks5',
name: proxy.name,
server: proxy.server,
port: proxy.port,
username: proxy.username,
password: proxy.password,
tfo: proxy.tfo || proxy['fast-open'],
udp_relay:
proxy.udp || proxy.udp_relay || proxy.udp_relay,
next_hop: proxy.next_hop,
};
} else if (proxy.type === 'ss') {
proxy = {
type: 'shadowsocks',
name: proxy.name,
method:
proxy.cipher === 'chacha20-ietf-poly1305'
? 'chacha20-poly1305'
: proxy.cipher,
server: proxy.server,
port: proxy.port,
password: proxy.password,
tfo: proxy.tfo || proxy['fast-open'],
udp_relay:
proxy.udp || proxy.udp_relay || proxy.udp_relay,
next_hop: proxy.next_hop,
};
if (proxy.plugin === 'obfs') {
proxy.obfs = proxy['plugin-opts'].mode;
proxy.obfs_host = proxy['plugin-opts'].host;
proxy.obfs_uri = proxy['plugin-opts'].path;
}
} else if (proxy.type === 'hysteria2') {
proxy = {
type: 'hysteria2',
name: proxy.name,
server: proxy.server,
port: proxy.port,
auth: proxy.password,
tfo: proxy.tfo || proxy['fast-open'],
udp_relay:
proxy.udp || proxy.udp_relay || proxy.udp_relay,
next_hop: proxy.next_hop,
sni: proxy.sni,
skip_tls_verify: proxy['skip-cert-verify'],
};
if (proxy['obfs-password'] && proxy.obfs == 'salamander') {
proxy.obfs = 'salamander';
proxy.obfs_password = proxy['obfs-password'];
}
} else if (proxy.type === 'trojan') {
if (proxy.network === 'ws') {
proxy.websocket = {
path: proxy['ws-opts']?.path,
host: proxy['ws-opts']?.headers?.Host,
};
}
proxy = {
type: 'trojan',
name: proxy.name,
server: proxy.server,
port: proxy.port,
password: proxy.password,
tfo: proxy.tfo || proxy['fast-open'],
udp_relay:
proxy.udp || proxy.udp_relay || proxy.udp_relay,
next_hop: proxy.next_hop,
sni: proxy.sni,
skip_tls_verify: proxy['skip-cert-verify'],
websocket: proxy.websocket,
};
} else if (proxy.type === 'vmess') {
if (proxy.network === 'ws') {
proxy.transport = {
[proxy.tls ? 'wss' : 'ws']: {
path: proxy['ws-opts']?.path,
headers: {
Host: proxy['ws-opts']?.headers?.Host,
},
sni: proxy.tls ? proxy.sni : undefined,
skip_tls_verify: proxy.tls
? proxy['skip-cert-verify']
: undefined,
},
};
} else if (proxy.network === 'http') {
proxy.transport = {
http: {
method: proxy['http-opts']?.method,
path: proxy['http-opts']?.path,
headers: {
Host: Array.isArray(
proxy['http-opts']?.headers?.Host,
)
? proxy['http-opts']?.headers?.Host[0]
: proxy['http-opts']?.headers?.Host,
},
skip_tls_verify: proxy['skip-cert-verify'],
},
};
} else if (proxy.network === 'tcp' || !proxy.network) {
proxy.transport = {
[proxy.tls ? 'tls' : 'tcp']: {
sni: proxy.tls ? proxy.sni : undefined,
skip_tls_verify: proxy.tls
? proxy['skip-cert-verify']
: undefined,
},
};
}
proxy = {
type: 'vmess',
name: proxy.name,
server: proxy.server,
port: proxy.port,
user_id: proxy.uuid,
security: proxy.cipher,
tfo: proxy.tfo || proxy['fast-open'],
legacy: proxy.legacy,
udp_relay:
proxy.udp || proxy.udp_relay || proxy.udp_relay,
next_hop: proxy.next_hop,
transport: proxy.transport,
// sni: proxy.sni,
// skip_tls_verify: proxy['skip-cert-verify'],
};
} else if (proxy.type === 'vless') {
if (proxy.network === 'ws') {
proxy.transport = {
[proxy.tls ? 'wss' : 'ws']: {
path: proxy['ws-opts']?.path,
headers: {
Host: proxy['ws-opts']?.headers?.Host,
},
sni: proxy.tls ? proxy.sni : undefined,
skip_tls_verify: proxy.tls
? proxy['skip-cert-verify']
: undefined,
},
};
} else if (proxy.network === 'http') {
proxy.transport = {
http: {
method: proxy['http-opts']?.method,
path: proxy['http-opts']?.path,
headers: {
Host: Array.isArray(
proxy['http-opts']?.headers?.Host,
)
? proxy['http-opts']?.headers?.Host[0]
: proxy['http-opts']?.headers?.Host,
},
skip_tls_verify: proxy['skip-cert-verify'],
},
};
} else if (proxy.network === 'tcp' || !proxy.network) {
proxy.transport = {
[proxy.tls ? 'tls' : 'tcp']: {
sni: proxy.tls ? proxy.sni : undefined,
skip_tls_verify: proxy.tls
? proxy['skip-cert-verify']
: undefined,
},
};
}
proxy = {
type: 'vless',
name: proxy.name,
server: proxy.server,
port: proxy.port,
user_id: proxy.uuid,
security: proxy.cipher,
tfo: proxy.tfo || proxy['fast-open'],
legacy: proxy.legacy,
udp_relay:
proxy.udp || proxy.udp_relay || proxy.udp_relay,
next_hop: proxy.next_hop,
transport: proxy.transport,
// sni: proxy.sni,
// skip_tls_verify: proxy['skip-cert-verify'],
};
}
delete proxy.subName;
delete proxy.collectionName;
delete proxy.id;
delete proxy.resolved;
delete proxy['no-resolve'];
if (type !== 'internal') {
for (const key in proxy) {
if (proxy[key] == null || /^_/i.test(key)) {
delete proxy[key];
}
}
}
return {
[proxy.type]: {
...proxy,
type: undefined,
},
};
});
return type === 'internal'
? list
: 'proxies:\n' +
list
.map((proxy) => ' - ' + JSON.stringify(proxy) + '\n')
.join('');
};
return { type, produce };
}

View File

@@ -10,6 +10,7 @@ import QX_Producer from './qx';
import Shadowrocket_Producer from './shadowrocket';
import Surfboard_Producer from './surfboard';
import singbox_Producer from './sing-box';
import Egern_Producer from './egern';
function JSON_Producer() {
const type = 'ALL';
@@ -34,4 +35,5 @@ export default {
ShadowRocket: Shadowrocket_Producer(),
Surfboard: Surfboard_Producer(),
'sing-box': singbox_Producer(),
Egern: Egern_Producer(),
};

View File

@@ -6,7 +6,6 @@ export default function Stash_Producer() {
// https://stash.wiki/proxy-protocols/proxy-types#shadowsocks
const list = proxies
.filter((proxy) => {
if (opts['include-unsupported-proxy']) return true;
if (
![
'ss',
@@ -40,6 +39,12 @@ export default function Stash_Producer() {
'xchacha20',
'chacha20-ietf-poly1305',
'xchacha20-ietf-poly1305',
...(opts['include-unsupported-proxy']
? [
'2022-blake3-aes-128-gcm',
'2022-blake3-aes-256-gcm',
]
: []),
].includes(proxy.cipher)) ||
(proxy.type === 'snell' && String(proxy.version) === '4') ||
(proxy.type === 'vless' && proxy['reality-opts'])

View File

@@ -20,7 +20,7 @@ export default function Surge_Producer() {
}
switch (proxy.type) {
case 'ss':
return shadowsocks(proxy);
return shadowsocks(proxy, opts['include-unsupported-proxy']);
case 'trojan':
return trojan(proxy);
case 'vmess':
@@ -51,7 +51,7 @@ export default function Surge_Producer() {
return { produce };
}
function shadowsocks(proxy) {
function shadowsocks(proxy, includeUnsupportedProxy) {
const result = new Result(proxy);
result.append(`${proxy.name}=${proxy.type},${proxy.server},${proxy.port}`);
if (!proxy.cipher) {
@@ -85,12 +85,15 @@ function shadowsocks(proxy) {
'chacha20',
'chacha20-ietf',
'none',
...(includeUnsupportedProxy
? ['2022-blake3-aes-128-gcm', '2022-blake3-aes-256-gcm']
: []),
].includes(proxy.cipher)
) {
throw new Error(`cipher ${proxy.cipher} is not supported`);
}
result.append(`,encrypt-method=${proxy.cipher}`);
result.appendIfPresent(`,password=${proxy.password}`, 'password');
result.appendIfPresent(`,password="${proxy.password}"`, 'password');
const ip_version = ipVersions[proxy['ip-version']] || proxy['ip-version'];
result.appendIfPresent(`,ip-version=${ip_version}`, 'ip-version');
@@ -122,6 +125,8 @@ function shadowsocks(proxy) {
// udp
result.appendIfPresent(`,udp-relay=${proxy.udp}`, 'udp');
// udp-port
result.appendIfPresent(`,udp-port=${proxy['udp-port']}`, 'udp-port');
// test-url
result.appendIfPresent(`,test-url=${proxy['test-url']}`, 'test-url');
@@ -188,7 +193,7 @@ function shadowsocks(proxy) {
function trojan(proxy) {
const result = new Result(proxy);
result.append(`${proxy.name}=${proxy.type},${proxy.server},${proxy.port}`);
result.appendIfPresent(`,password=${proxy.password}`, 'password');
result.appendIfPresent(`,password="${proxy.password}"`, 'password');
const ip_version = ipVersions[proxy['ip-version']] || proxy['ip-version'];
result.appendIfPresent(`,ip-version=${ip_version}`, 'ip-version');
@@ -361,7 +366,7 @@ function ssh(proxy) {
result.append(`${proxy.name}=ssh,${proxy.server},${proxy.port}`);
result.appendIfPresent(`,${proxy.username}`, 'username');
// 所有的类似的字段都有双引号的问题 暂不处理
result.appendIfPresent(`,${proxy.password}`, 'password');
result.appendIfPresent(`,"${proxy.password}"`, 'password');
// https://manual.nssurge.com/policy/ssh.html
// 需配合 Keystore
@@ -426,7 +431,7 @@ function http(proxy) {
const type = proxy.tls ? 'https' : 'http';
result.append(`${proxy.name}=${type},${proxy.server},${proxy.port}`);
result.appendIfPresent(`,${proxy.username}`, 'username');
result.appendIfPresent(`,${proxy.password}`, 'password');
result.appendIfPresent(`,"${proxy.password}"`, 'password');
const ip_version = ipVersions[proxy['ip-version']] || proxy['ip-version'];
result.appendIfPresent(`,ip-version=${ip_version}`, 'ip-version');
@@ -504,7 +509,7 @@ function socks5(proxy) {
const type = proxy.tls ? 'socks5-tls' : 'socks5';
result.append(`${proxy.name}=${type},${proxy.server},${proxy.port}`);
result.appendIfPresent(`,${proxy.username}`, 'username');
result.appendIfPresent(`,${proxy.password}`, 'password');
result.appendIfPresent(`,"${proxy.password}"`, 'password');
const ip_version = ipVersions[proxy['ip-version']] || proxy['ip-version'];
result.appendIfPresent(`,ip-version=${ip_version}`, 'ip-version');
@@ -670,7 +675,7 @@ function tuic(proxy) {
result.append(`${proxy.name}=${type},${proxy.server},${proxy.port}`);
result.appendIfPresent(`,uuid=${proxy.uuid}`, 'uuid');
result.appendIfPresent(`,password=${proxy.password}`, 'password');
result.appendIfPresent(`,password="${proxy.password}"`, 'password');
result.appendIfPresent(`,token=${proxy.token}`, 'token');
result.appendIfPresent(
@@ -945,7 +950,7 @@ function hysteria2(proxy) {
const result = new Result(proxy);
result.append(`${proxy.name}=hysteria2,${proxy.server},${proxy.port}`);
result.appendIfPresent(`,password=${proxy.password}`, 'password');
result.appendIfPresent(`,password="${proxy.password}"`, 'password');
if (isPresent(proxy, 'ports')) {
result.append(`,port-hopping="${proxy.ports.replace(/,/g, ';')}"`);

View File

@@ -20,10 +20,16 @@ export default function SurgeMac_Producer() {
try {
return surge_Producer.produce(proxy, type, opts);
} catch (e) {
$.log(
`${proxy.name} is not supported on ${targetPlatform}, try to use Mihomo(SurgeMac - External Proxy Program) instead`,
);
return mihomo(proxy, type, opts);
if (opts.useMihomoExternal) {
$.log(
`${proxy.name} is not supported on ${targetPlatform}, try to use Mihomo(SurgeMac - External Proxy Program) instead`,
);
return mihomo(proxy, type, opts);
} else {
throw new Error(
`Surge for macOS 可手动指定链接参数 target=SurgeMac 或在 同步配置 中指定 SurgeMac 来启用 mihomo 支援 Surge 本身不支持的协议`,
);
}
}
}
}

View File

@@ -224,6 +224,18 @@ export default function URI_Producer() {
vlessTransportServiceName,
)}`;
}
if (proxy.network === 'kcp') {
if (proxy.seed) {
vlessTransport += `&seed=${encodeURIComponent(
proxy.seed,
)}`;
}
if (proxy.headerType) {
vlessTransport += `&headerType=${encodeURIComponent(
proxy.headerType,
)}`;
}
}
result = `vless://${proxy.uuid}@${proxy.server}:${
proxy.port

View File

@@ -130,6 +130,15 @@ async function doSync() {
try {
if (artifact.sync && artifact.source) {
$.info(`正在同步云配置:${artifact.name}...`);
const useMihomoExternal =
artifact.platform === 'SurgeMac';
if (useMihomoExternal) {
$.info(
`手动指定了 target 为 SurgeMac, 将使用 Mihomo External`,
);
}
const output = await produceArtifact({
type: artifact.type,
name: artifact.source,
@@ -137,6 +146,7 @@ async function doSync() {
produceOpts: {
'include-unsupported-proxy':
artifact.includeUnsupportedProxy,
useMihomoExternal,
},
});

View File

@@ -21,6 +21,7 @@ import registerSettingRoutes from '@/restful/settings';
import registerMiscRoutes from '@/restful/miscs';
import registerSortRoutes from '@/restful/sort';
import registerFileRoutes from '@/restful/file';
import registerTokenRoutes from '@/restful/token';
import registerModuleRoutes from '@/restful/module';
migrate();
@@ -32,6 +33,7 @@ function serve() {
// register routes
registerCollectionRoutes($app);
registerSubscriptionRoutes($app);
registerTokenRoutes($app);
registerFileRoutes($app);
registerModuleRoutes($app);
registerArtifactRoutes($app);

View File

@@ -13,6 +13,9 @@ import { getISO } from '@/utils/geo';
import env from '@/utils/env';
export default function register($app) {
$app.get('/share/col/:name', downloadCollection);
$app.get('/share/sub/:name', downloadSubscription);
$app.get('/download/collection/:name', downloadCollection);
$app.get('/download/:name', downloadSubscription);
$app.get(
@@ -52,6 +55,8 @@ async function downloadSubscription(req, res) {
name = decodeURIComponent(name);
nezhaIndex = decodeURIComponent(nezhaIndex);
const useMihomoExternal = req.query.target === 'SurgeMac';
const platform =
req.query.target || getPlatformFromHeaders(req.headers) || 'JSON';
@@ -69,6 +74,8 @@ async function downloadSubscription(req, res) {
produceType,
includeUnsupportedProxy,
resultFormat,
proxy,
noCache,
} = req.query;
let $options = {};
if (req.query.$options) {
@@ -92,6 +99,10 @@ async function downloadSubscription(req, res) {
url = decodeURIComponent(url);
$.info(`指定远程订阅 URL: ${url}`);
}
if (proxy) {
proxy = decodeURIComponent(proxy);
$.info(`指定远程订阅使用代理/策略 proxy: ${proxy}`);
}
if (ua) {
ua = decodeURIComponent(ua);
$.info(`指定远程订阅 User-Agent: ${ua}`);
@@ -117,6 +128,14 @@ async function downloadSubscription(req, res) {
$.info(`包含不支持的节点: ${includeUnsupportedProxy}`);
}
if (useMihomoExternal) {
$.info(`手动指定了 target 为 SurgeMac, 将使用 Mihomo External`);
}
if (noCache) {
$.info(`指定不使用缓存: ${noCache}`);
}
const allSubs = $.read(SUBS_KEY);
const sub = findByName(allSubs, name);
if (sub) {
@@ -133,10 +152,13 @@ async function downloadSubscription(req, res) {
produceType,
produceOpts: {
'include-unsupported-proxy': includeUnsupportedProxy,
useMihomoExternal,
},
$options,
proxy,
noCache,
});
let flowInfo;
if (
sub.source !== 'local' ||
['localFirst', 'remoteFirst'].includes(sub.mergeSources)
@@ -171,11 +193,11 @@ async function downloadSubscription(req, res) {
}
if (!$arguments.noFlow) {
// forward flow headers
const flowInfo = await getFlowHeaders(
url,
flowInfo = await getFlowHeaders(
$arguments?.insecure ? `${url}#insecure` : url,
$arguments.flowUserAgent,
undefined,
sub.proxy,
proxy || sub.proxy,
$arguments.flowUrl,
);
if (flowInfo) {
@@ -191,7 +213,10 @@ async function downloadSubscription(req, res) {
}
}
if (sub.subUserinfo) {
res.set('subscription-userinfo', sub.subUserinfo);
res.set(
'subscription-userinfo',
[sub.subUserinfo, flowInfo].filter((i) => i).join('; '),
);
}
if (platform === 'JSON') {
@@ -230,7 +255,7 @@ async function downloadSubscription(req, res) {
);
}
} else {
$.notify(`🌍 Sub-Store 下载订阅失败`, `❌ 未找到订阅:${name}`);
$.error(`🌍 Sub-Store 下载订阅失败`, `❌ 未找到订阅:${name}`);
failed(
res,
new ResourceNotFoundError(
@@ -247,6 +272,8 @@ async function downloadCollection(req, res) {
name = decodeURIComponent(name);
nezhaIndex = decodeURIComponent(nezhaIndex);
const useMihomoExternal = req.query.target === 'SurgeMac';
const platform =
req.query.target || getPlatformFromHeaders(req.headers) || 'JSON';
@@ -264,6 +291,8 @@ async function downloadCollection(req, res) {
produceType,
includeUnsupportedProxy,
resultFormat,
proxy,
noCache,
} = req.query;
let $options = {};
@@ -285,6 +314,11 @@ async function downloadCollection(req, res) {
$.info(`传入 $options: ${JSON.stringify($options)}`);
}
if (proxy) {
proxy = decodeURIComponent(proxy);
$.info(`指定远程订阅使用代理/策略 proxy: ${proxy}`);
}
if (ignoreFailedRemoteSub != null && ignoreFailedRemoteSub !== '') {
ignoreFailedRemoteSub = decodeURIComponent(ignoreFailedRemoteSub);
$.info(`指定忽略失败的远程订阅: ${ignoreFailedRemoteSub}`);
@@ -298,6 +332,12 @@ async function downloadCollection(req, res) {
includeUnsupportedProxy = decodeURIComponent(includeUnsupportedProxy);
$.info(`包含不支持的节点: ${includeUnsupportedProxy}`);
}
if (useMihomoExternal) {
$.info(`手动指定了 target 为 SurgeMac, 将使用 Mihomo External`);
}
if (noCache) {
$.info(`指定不使用缓存: ${noCache}`);
}
if (collection) {
try {
@@ -309,8 +349,11 @@ async function downloadCollection(req, res) {
produceType,
produceOpts: {
'include-unsupported-proxy': includeUnsupportedProxy,
useMihomoExternal,
},
$options,
proxy,
noCache,
});
// forward flow header from the first subscription in this collection
@@ -318,6 +361,7 @@ async function downloadCollection(req, res) {
const subnames = collection.subscriptions;
if (subnames.length > 0) {
const sub = findByName(allSubs, subnames[0]);
let flowInfo;
if (
sub.source !== 'local' ||
['localFirst', 'remoteFirst'].includes(sub.mergeSources)
@@ -351,11 +395,11 @@ async function downloadCollection(req, res) {
}
}
if (!$arguments.noFlow) {
const flowInfo = await getFlowHeaders(
url,
flowInfo = await getFlowHeaders(
$arguments?.insecure ? `${url}#insecure` : url,
$arguments.flowUserAgent,
undefined,
sub.proxy,
proxy || sub.proxy || collection.proxy,
$arguments.flowUrl,
);
if (flowInfo) {
@@ -371,7 +415,10 @@ async function downloadCollection(req, res) {
}
}
if (sub.subUserinfo) {
res.set('subscription-userinfo', sub.subUserinfo);
res.set(
'subscription-userinfo',
[sub.subUserinfo, flowInfo].filter((i) => i).join('; '),
);
}
}
@@ -410,7 +457,7 @@ async function downloadCollection(req, res) {
);
}
} else {
$.notify(
$.error(
`🌍 Sub-Store 下载组合订阅失败`,
`❌ 未找到组合订阅:${name}`,
);

View File

@@ -13,6 +13,8 @@ import { produceArtifact } from '@/restful/sync';
export default function register($app) {
if (!$.read(FILES_KEY)) $.write([], FILES_KEY);
$app.get('/share/file/:name', getFile);
$app.route('/api/file/:name')
.get(getFile)
.patch(updateFile)
@@ -59,6 +61,7 @@ async function getFile(req, res) {
content,
mergeSources,
ignoreFailedRemoteFile,
proxy,
} = req.query;
let $options = {};
if (req.query.$options) {
@@ -82,6 +85,10 @@ async function getFile(req, res) {
url = decodeURIComponent(url);
$.info(`指定远程文件 URL: ${url}`);
}
if (proxy) {
proxy = decodeURIComponent(proxy);
$.info(`指定远程订阅使用代理/策略 proxy: ${proxy}`);
}
if (ua) {
ua = decodeURIComponent(ua);
$.info(`指定远程文件 User-Agent: ${ua}`);
@@ -120,6 +127,7 @@ async function getFile(req, res) {
mergeSources,
ignoreFailedRemoteFile,
$options,
proxy,
});
try {
@@ -129,6 +137,8 @@ async function getFile(req, res) {
const flowInfo = await getFlowHeaders(
subInfoUrl,
subInfoUserAgent || file.subInfoUserAgent,
undefined,
proxy || file.proxy,
);
if (flowInfo) {
res.set('subscription-userinfo', flowInfo);
@@ -141,7 +151,14 @@ async function getFile(req, res) {
)}`,
);
}
if (file.download) {
res.set(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(
file.displayName || file.name,
)}`,
);
}
res.set('Content-Type', 'text/plain; charset=utf-8').send(
output ?? '',
);
@@ -162,7 +179,7 @@ async function getFile(req, res) {
);
}
} else {
$.notify(`🌍 Sub-Store 下载文件失败`, `❌ 未找到文件:${name}`);
$.error(`🌍 Sub-Store 下载文件失败`, `❌ 未找到文件:${name}`);
failed(
res,
new ResourceNotFoundError(

View File

@@ -4,11 +4,13 @@ import migrate from '@/utils/migration';
import download from '@/utils/download';
import { syncArtifacts } from '@/restful/sync';
import { gistBackupAction } from '@/restful/miscs';
import { TOKENS_KEY } from '@/constants';
import registerSubscriptionRoutes from './subscriptions';
import registerCollectionRoutes from './collections';
import registerArtifactRoutes from './artifacts';
import registerFileRoutes from './file';
import registerTokenRoutes from './token';
import registerModuleRoutes from './module';
import registerSyncRoutes from './sync';
import registerDownloadRoutes from './download';
@@ -36,6 +38,7 @@ export default function serve() {
registerSettingRoutes($app);
registerArtifactRoutes($app);
registerFileRoutes($app);
registerTokenRoutes($app);
registerModuleRoutes($app);
registerSyncRoutes($app);
registerNodeInfoRoutes($app);
@@ -143,7 +146,7 @@ export default function serve() {
try {
fs.accessSync(path.join(fe_abs_path, 'index.html'));
} catch (e) {
throw new Error(
$.error(
`[FRONTEND] index.html file not found in ${fe_abs_path}`,
);
}
@@ -158,6 +161,7 @@ export default function serve() {
const staticFileMiddleware = express_.static(fe_path);
let be_share_rewrite = '/share/:type/:name';
let be_api_rewrite = '';
let be_download_rewrite = '';
let be_api = '/api/';
@@ -174,15 +178,39 @@ export default function serve() {
be_download_rewrite = `${
fe_be_path === '/' ? '' : fe_be_path
}${be_download}`;
app.use(
be_share_rewrite,
createProxyMiddleware({
target: `http://127.0.0.1:${port}`,
changeOrigin: true,
pathRewrite: (path, req) => {
if (req.method.toLowerCase() !== 'get')
throw new Error('Method not allowed');
const tokens = $.read(TOKENS_KEY) || [];
const token = tokens.find(
(t) =>
t.token === req.query.token &&
t.type === req.params.type &&
t.name === req.params.name &&
(t.exp == null || t.exp > Date.now()),
);
if (!token) throw new Error('Forbbiden');
return path;
},
}),
);
app.use(
be_api_rewrite,
createProxyMiddleware({
target: `http://127.0.0.1:${port}`,
changeOrigin: true,
pathRewrite: (path) => {
return path.startsWith(be_api_rewrite)
const newPath = path.startsWith(be_api_rewrite)
? path.replace(be_api_rewrite, be_api)
: path;
return newPath.includes('?')
? `${newPath}&share=true`
: `${newPath}?share=true`;
},
}),
);
@@ -220,6 +248,9 @@ export default function serve() {
$.info(
`[FRONTEND -> BACKEND] ${fe_address}:${fe_port}${be_download_rewrite} -> http://127.0.0.1:${port}${be_download}`,
);
$.info(
`[SHARE BACKEND] ${fe_address}:${fe_port}${be_share_rewrite}`,
);
}
});
}

View File

@@ -65,6 +65,9 @@ export default function register($app) {
}
function getEnv(req, res) {
if (req.query.share) {
env.feature.share = true;
}
success(res, env);
}

View File

@@ -177,7 +177,20 @@ async function compareCollection(req, res) {
try {
const allSubs = $.read(SUBS_KEY);
const collection = req.body;
const subnames = collection.subscriptions;
const subnames = [...collection.subscriptions];
let subscriptionTags = collection.subscriptionTags;
if (Array.isArray(subscriptionTags) && subscriptionTags.length > 0) {
allSubs.forEach((sub) => {
if (
Array.isArray(sub.tag) &&
sub.tag.length > 0 &&
!subnames.includes(sub.name) &&
sub.tag.some((tag) => subscriptionTags.includes(tag))
) {
subnames.push(sub.name);
}
});
}
const results = {};
const errors = {};
await Promise.all(

View File

@@ -57,9 +57,25 @@ async function getFlowInfo(req, res) {
!['localFirst', 'remoteFirst'].includes(sub.mergeSources)
) {
if (sub.subUserinfo) {
success(res, {
...parseFlowHeaders(sub.subUserinfo),
});
try {
success(res, {
...parseFlowHeaders(sub.subUserinfo),
});
} catch (e) {
$.error(
`Failed to parse flow info for local subscription ${name}: ${
e.message ?? e
}`,
);
failed(
res,
new RequestInvalidError(
'NO_FLOW_INFO',
'N/A',
`Failed to parse flow info`,
),
);
}
} else {
failed(
res,
@@ -109,42 +125,53 @@ async function getFlowInfo(req, res) {
);
return;
}
if (sub.subUserinfo) {
success(res, {
...parseFlowHeaders(sub.subUserinfo),
remainingDays: getRmainingDays({
resetDay: $arguments.resetDay,
startDate: $arguments.startDate,
cycleDays: $arguments.cycleDays,
}),
});
} else {
const flowHeaders = await getFlowHeaders(
url,
$arguments.flowUserAgent,
undefined,
sub.proxy,
$arguments.flowUrl,
const flowHeaders = await getFlowHeaders(
$arguments?.insecure ? `${url}#insecure` : url,
$arguments.flowUserAgent,
undefined,
sub.proxy,
$arguments.flowUrl,
);
if (!flowHeaders && !sub.subUserinfo) {
failed(
res,
new InternalServerError(
'NO_FLOW_INFO',
'No flow info',
`Failed to fetch flow headers`,
),
);
if (!flowHeaders) {
failed(
res,
new InternalServerError(
'NO_FLOW_INFO',
'No flow info',
`Failed to fetch flow headers`,
),
);
return;
}
success(res, {
...parseFlowHeaders(flowHeaders),
remainingDays: getRmainingDays({
resetDay: $arguments.resetDay,
startDate: $arguments.startDate,
cycleDays: $arguments.cycleDays,
}),
return;
}
try {
const remainingDays = getRmainingDays({
resetDay: $arguments.resetDay,
startDate: $arguments.startDate,
cycleDays: $arguments.cycleDays,
});
const result = {
...parseFlowHeaders(
[sub.subUserinfo, flowHeaders].filter((i) => i).join('; '),
),
};
if (remainingDays != null) {
result.remainingDays = remainingDays;
}
success(res, result);
} catch (e) {
$.error(
`Failed to parse flow info for local subscription ${name}: ${
e.message ?? e
}`,
);
failed(
res,
new RequestInvalidError(
'NO_FLOW_INFO',
'N/A',
`Failed to parse flow info`,
),
);
}
} catch (err) {
failed(

View File

@@ -38,6 +38,8 @@ async function produceArtifact({
subscription,
awaitCustomCache,
$options,
proxy,
noCache,
}) {
platform = platform || 'JSON';
@@ -68,9 +70,10 @@ async function produceArtifact({
url,
ua || sub.ua,
undefined,
sub.proxy,
proxy || sub.proxy,
undefined,
awaitCustomCache,
noCache,
);
} catch (err) {
errors[url] = err;
@@ -115,9 +118,10 @@ async function produceArtifact({
url,
ua || sub.ua,
undefined,
sub.proxy,
proxy || sub.proxy,
undefined,
awaitCustomCache,
noCache,
);
} catch (err) {
errors[url] = err;
@@ -189,7 +193,20 @@ async function produceArtifact({
const allCols = $.read(COLLECTIONS_KEY);
const collection = findByName(allCols, name);
if (!collection) throw new Error(`找不到组合订阅 ${name}`);
const subnames = collection.subscriptions;
const subnames = [...collection.subscriptions];
let subscriptionTags = collection.subscriptionTags;
if (Array.isArray(subscriptionTags) && subscriptionTags.length > 0) {
allSubs.forEach((sub) => {
if (
Array.isArray(sub.tag) &&
sub.tag.length > 0 &&
!subnames.includes(sub.name) &&
sub.tag.some((tag) => subscriptionTags.includes(tag))
) {
subnames.push(sub.name);
}
});
}
const results = {};
const errors = {};
let processed = 0;
@@ -220,7 +237,12 @@ async function produceArtifact({
url,
sub.ua,
undefined,
sub.proxy,
proxy ||
sub.proxy ||
collection.proxy,
undefined,
undefined,
noCache,
);
} catch (err) {
errors[url] = err;
@@ -344,7 +366,6 @@ async function produceArtifact({
}
exist[proxy.name] = true;
}
console.log(proxies);
return ProxyUtils.produce(proxies, platform, produceType, produceOpts);
} else if (type === 'rule') {
const allRules = $.read(RULES_KEY);
@@ -390,7 +411,15 @@ async function produceArtifact({
.filter((i) => i.length)
.map(async (url) => {
try {
return await download(url, ua || file.ua);
return await download(
url,
ua || file.ua,
undefined,
file.proxy || proxy,
undefined,
undefined,
noCache,
);
} catch (err) {
errors[url] = err;
$.error(
@@ -433,7 +462,15 @@ async function produceArtifact({
.filter((i) => i.length)
.map(async (url) => {
try {
return await download(url, ua || file.ua);
return await download(
url,
ua || file.ua,
undefined,
file.proxy || proxy,
undefined,
undefined,
noCache,
);
} catch (err) {
errors[url] = err;
$.error(
@@ -534,6 +571,16 @@ async function syncArtifacts() {
try {
if (artifact.sync && artifact.source) {
$.info(`正在同步云配置:${artifact.name}...`);
const useMihomoExternal =
artifact.platform === 'SurgeMac';
if (useMihomoExternal) {
$.info(
`手动指定了 target 为 SurgeMac, 将使用 Mihomo External`,
);
}
const output = await produceArtifact({
type: artifact.type,
name: artifact.source,
@@ -541,6 +588,7 @@ async function syncArtifacts() {
produceOpts: {
'include-unsupported-proxy':
artifact.includeUnsupportedProxy,
useMihomoExternal,
},
});
@@ -656,12 +704,18 @@ async function syncArtifact(req, res) {
}
try {
const useMihomoExternal = artifact.platform === 'SurgeMac';
if (useMihomoExternal) {
$.info(`手动指定了 target 为 SurgeMac, 将使用 Mihomo External`);
}
const output = await produceArtifact({
type: artifact.type,
name: artifact.source,
platform: artifact.platform,
produceOpts: {
'include-unsupported-proxy': artifact.includeUnsupportedProxy,
useMihomoExternal,
},
});

View File

@@ -0,0 +1,181 @@
import { deleteByName } from '@/utils/database';
import { ENV } from '@/vendor/open-api';
import { TOKENS_KEY, SUBS_KEY, FILES_KEY, COLLECTIONS_KEY } from '@/constants';
import { failed, success } from '@/restful/response';
import $ from '@/core/app';
import { RequestInvalidError, InternalServerError } from '@/restful/errors';
export default function register($app) {
if (!$.read(TOKENS_KEY)) $.write([], TOKENS_KEY);
$app.post('/api/token', signToken);
$app.route('/api/token/:token').delete(deleteToken);
$app.route('/api/tokens').get(getAllTokens);
}
function deleteToken(req, res) {
let { token } = req.params;
token = decodeURIComponent(token);
$.info(`正在删除:${token}`);
let allTokens = $.read(TOKENS_KEY);
deleteByName(allTokens, token, 'token');
$.write(allTokens, TOKENS_KEY);
success(res);
}
function getAllTokens(req, res) {
const { type, name } = req.query;
const allTokens = $.read(TOKENS_KEY) || [];
success(
res,
type || name
? allTokens.filter(
(item) =>
(type ? item.type === type : true) &&
(name ? item.name === name : true),
)
: allTokens,
);
}
async function signToken(req, res) {
if (!ENV().isNode) {
return failed(
res,
new RequestInvalidError(
'INVALID_ENV',
`This endpoint is only available in Node.js environment`,
),
);
}
try {
const { payload, options } = req.body;
const ms = eval(`require("ms")`);
let token = payload?.token;
if (token != null) {
if (typeof token !== 'string' || token.length < 1) {
return failed(
res,
new RequestInvalidError(
'INVALID_CUSTOM_TOKEN',
`Invalid custom token: ${token}`,
),
);
}
const tokens = $.read(TOKENS_KEY) || [];
if (tokens.find((t) => t.token === token)) {
return failed(
res,
new RequestInvalidError(
'DUPLICATE_TOKEN',
`Token ${token} already exists`,
),
);
}
}
const type = payload?.type;
const name = payload?.name;
if (!type || !name)
return failed(
res,
new RequestInvalidError(
'INVALID_PAYLOAD',
`payload type and name are required`,
),
);
if (type === 'col') {
const collections = $.read(COLLECTIONS_KEY) || [];
const collection = collections.find((c) => c.name === name);
if (!collection)
return failed(
res,
new RequestInvalidError(
'INVALID_COLLECTION',
`collection ${name} not found`,
),
);
} else if (type === 'file') {
const files = $.read(FILES_KEY) || [];
const file = files.find((f) => f.name === name);
if (!file)
return failed(
res,
new RequestInvalidError(
'INVALID_FILE',
`file ${name} not found`,
),
);
} else if (type === 'sub') {
const subs = $.read(SUBS_KEY) || [];
const sub = subs.find((s) => s.name === name);
if (!sub)
return failed(
res,
new RequestInvalidError(
'INVALID_SUB',
`sub ${name} not found`,
),
);
} else {
return failed(
res,
new RequestInvalidError(
'INVALID_TYPE',
`type ${name} not supported`,
),
);
}
let expiresIn = options?.expiresIn;
if (options?.expiresIn != null) {
expiresIn = ms(options.expiresIn);
if (expiresIn == null || isNaN(expiresIn) || expiresIn <= 0) {
return failed(
res,
new RequestInvalidError(
'INVALID_EXPIRES_IN',
`Invalid expiresIn option: ${options.expiresIn}`,
),
);
}
}
// const secret = eval('process.env.SUB_STORE_FRONTEND_BACKEND_PATH');
const nanoid = eval(`require("nanoid")`);
const tokens = $.read(TOKENS_KEY) || [];
// const now = Date.now();
// for (const key in tokens) {
// const token = tokens[key];
// if (token.exp != null || token.exp < now) {
// delete tokens[key];
// }
// }
if (!token) {
do {
token = nanoid.customAlphabet(nanoid.urlAlphabet)();
} while (tokens.find((t) => t.token === token));
}
tokens.push({
...payload,
token,
createdAt: Date.now(),
expiresIn: expiresIn > 0 ? options?.expiresIn : undefined,
exp: expiresIn > 0 ? Date.now() + expiresIn : undefined,
});
$.write(tokens, TOKENS_KEY);
return success(res, {
token,
// secret,
});
} catch (e) {
return failed(
res,
new InternalServerError(
'TOKEN_SIGN_FAILED',
`Failed to sign token`,
`Reason: ${e.message ?? e}`,
),
);
}
}

View File

@@ -1,17 +1,17 @@
export function findByName(list, name) {
return list.find((item) => item.name === name);
export function findByName(list, name, field = 'name') {
return list.find((item) => item[field] === name);
}
export function findIndexByName(list, name) {
return list.findIndex((item) => item.name === name);
export function findIndexByName(list, name, field = 'name') {
return list.findIndex((item) => item[field] === name);
}
export function deleteByName(list, name) {
const idx = findIndexByName(list, name);
export function deleteByName(list, name, field = 'name') {
const idx = findIndexByName(list, name, field);
list.splice(idx, 1);
}
export function updateByName(list, name, newItem) {
const idx = findIndexByName(list, name);
export function updateByName(list, name, newItem, field = 'name') {
const idx = findIndexByName(list, name, field);
list[idx] = newItem;
}

View File

@@ -18,9 +18,10 @@ export default async function download(
rawUrl = '',
ua,
timeout,
proxy,
customProxy,
skipCustomCache,
awaitCustomCache,
noCache,
) {
let $arguments = {};
let url = rawUrl.replace(/#noFlow$/, '');
@@ -43,10 +44,19 @@ export default async function download(
}
}
const { isNode, isStash, isLoon, isShadowRocket, isQX } = ENV();
const { defaultUserAgent, defaultTimeout, cacheThreshold } =
$.read(SETTINGS_KEY);
const {
defaultProxy,
defaultUserAgent,
defaultTimeout,
cacheThreshold: defaultCacheThreshold,
} = $.read(SETTINGS_KEY);
const cacheThreshold = defaultCacheThreshold || 1024;
let proxy = customProxy || defaultProxy;
if ($.env.isNode) {
proxy = proxy || eval('process.env.SUB_STORE_BACKEND_DEFAULT_PROXY');
}
const userAgent = ua || defaultUserAgent || 'clash.meta';
const requestTimeout = timeout || defaultTimeout;
const requestTimeout = timeout || defaultTimeout || 8000;
const id = hex_md5(userAgent + url);
if ($arguments?.cacheKey === true) {
@@ -61,7 +71,7 @@ export default async function download(
if (customCacheKey && !skipCustomCache) {
const customCached = $.read(customCacheKey);
const cached = resourceCache.get(id);
if (!$arguments?.noCache && cached) {
if (!noCache && !$arguments?.noCache && cached) {
$.info(
`乐观缓存: URL ${url}\n存在有效的常规缓存\n使用常规缓存以避免重复请求`,
);
@@ -145,16 +155,21 @@ export default async function download(
// try to find in app cache
const cached = resourceCache.get(id);
if (!$arguments?.noCache && cached) {
$.info(`使用缓存: ${url}`);
if (!noCache && !$arguments?.noCache && cached) {
$.info(`使用缓存: ${url}, ${userAgent}`);
result = cached;
if (customCacheKey) {
$.info(`URL ${url}\n写入自定义缓存 ${$arguments?.cacheKey}`);
$.write(cached, customCacheKey);
}
} else {
const insecure = $arguments?.insecure
? isNode
? { strictSSL: false }
: { insecure: true }
: undefined;
$.info(
`Downloading...\nUser-Agent: ${userAgent}\nTimeout: ${requestTimeout}\nProxy: ${proxy}\nURL: ${url}`,
`Downloading...\nUser-Agent: ${userAgent}\nTimeout: ${requestTimeout}\nProxy: ${proxy}\nInsecure: ${!!insecure}\nURL: ${url}`,
);
try {
const { body, headers } = await http.get({
@@ -163,12 +178,13 @@ export default async function download(
...(isLoon && proxy ? { node: proxy } : {}),
...(isQX && proxy ? { opts: { policy: proxy } } : {}),
...(proxy ? getPolicyDescriptor(proxy) : {}),
...(insecure ? insecure : {}),
});
if (headers) {
const flowInfo = getFlowField(headers);
if (flowInfo) {
headersResourceCache.set(url, flowInfo);
headersResourceCache.set(id, flowInfo);
}
}
if (body.replace(/\s/g, '').length === 0)

View File

@@ -24,6 +24,7 @@ if (isLanceX) backend = 'LanceX';
if (isGUIforCores) backend = 'GUI.for.Cores';
let meta = {};
let feature = {};
try {
if (typeof $environment !== 'undefined') {
@@ -63,5 +64,6 @@ try {
export default {
backend,
version: substoreVersion,
feature,
meta,
};

View File

@@ -1,16 +1,32 @@
import { SETTINGS_KEY } from '@/constants';
import { HTTP, ENV } from '@/vendor/open-api';
import { hex_md5 } from '@/vendor/md5';
import { getPolicyDescriptor } from '@/utils';
import $ from '@/core/app';
import headersResourceCache from '@/utils/headers-resource-cache';
export function getFlowField(headers) {
const subkey = Object.keys(headers).filter((k) =>
/SUBSCRIPTION-USERINFO/i.test(k),
)[0];
return headers[subkey];
const keys = Object.keys(headers);
let sub = '';
let webPage = '';
for (let k of keys) {
const lower = k.toLowerCase();
if (lower === 'subscription-userinfo') {
sub = headers[k];
} else if (lower === 'profile-web-page-url') {
webPage = headers[k];
}
}
return `${sub || ''}${webPage ? `;app_url=${webPage}` : ''}`;
}
export async function getFlowHeaders(rawUrl, ua, timeout, proxy, flowUrl) {
export async function getFlowHeaders(
rawUrl,
ua,
timeout,
customProxy,
flowUrl,
) {
let url = flowUrl || rawUrl || '';
let $arguments = {};
const rawArgs = url.split('#');
@@ -35,24 +51,32 @@ export async function getFlowHeaders(rawUrl, ua, timeout, proxy, flowUrl) {
return;
}
const { isStash, isLoon, isShadowRocket, isQX } = ENV();
const cached = headersResourceCache.get(url);
const insecure = $arguments?.insecure
? $.env.isNode
? { strictSSL: false }
: { insecure: true }
: undefined;
const { defaultProxy, defaultFlowUserAgent, defaultTimeout } =
$.read(SETTINGS_KEY);
let proxy = customProxy || defaultProxy;
if ($.env.isNode) {
proxy = proxy || eval('process.env.SUB_STORE_BACKEND_DEFAULT_PROXY');
}
const userAgent = ua || defaultFlowUserAgent || 'clash';
const requestTimeout = timeout || defaultTimeout || 8000;
const id = hex_md5(userAgent + url);
const cached = headersResourceCache.get(id);
let flowInfo;
if (!$arguments?.noCache && cached) {
// $.info(`使用缓存的流量信息: ${url}`);
$.info(`使用缓存的流量信息: ${url}, ${userAgent}`);
flowInfo = cached;
} else {
const { defaultFlowUserAgent, defaultTimeout } = $.read(SETTINGS_KEY);
const userAgent =
ua ||
defaultFlowUserAgent ||
'Quantumult%20X/1.0.30 (iPhone14,2; iOS 15.6)';
const requestTimeout = timeout || defaultTimeout;
const http = HTTP();
if (flowUrl) {
$.info(
`使用 GET 方法从响应体获取流量信息: ${flowUrl}, User-Agent: ${
userAgent || ''
}`,
}, Insecure: ${!!insecure}, Proxy: ${proxy}`,
);
const { body } = await http.get({
url: flowUrl,
@@ -60,6 +84,11 @@ export async function getFlowHeaders(rawUrl, ua, timeout, proxy, flowUrl) {
'User-Agent': userAgent,
},
timeout: requestTimeout,
...(proxy ? { proxy } : {}),
...(isLoon && proxy ? { node: proxy } : {}),
...(isQX && proxy ? { opts: { policy: proxy } } : {}),
...(proxy ? getPolicyDescriptor(proxy) : {}),
...(insecure ? insecure : {}),
});
flowInfo = body;
} else {
@@ -67,7 +96,7 @@ export async function getFlowHeaders(rawUrl, ua, timeout, proxy, flowUrl) {
$.info(
`使用 HEAD 方法从响应头获取流量信息: ${url}, User-Agent: ${
userAgent || ''
}`,
}, Insecure: ${!!insecure}, Proxy: ${proxy}`,
);
const { headers } = await http.head({
url: url
@@ -91,20 +120,23 @@ export async function getFlowHeaders(rawUrl, ua, timeout, proxy, flowUrl) {
...(isLoon && proxy ? { node: proxy } : {}),
...(isQX && proxy ? { opts: { policy: proxy } } : {}),
...(proxy ? getPolicyDescriptor(proxy) : {}),
...(insecure ? insecure : {}),
});
flowInfo = getFlowField(headers);
} catch (e) {
$.error(
`使用 HEAD 方法从响应头获取流量信息失败: ${url}, User-Agent: ${
userAgent || ''
}: ${e.message ?? e}`,
}, Insecure: ${!!insecure}, Proxy: ${proxy}: ${
e.message ?? e
}`,
);
}
if (!flowInfo) {
$.info(
`使用 GET 方法获取流量信息: ${url}, User-Agent: ${
userAgent || ''
}`,
}, Insecure: ${!!insecure}, Proxy: ${proxy}`,
);
const { headers } = await http.get({
url: url
@@ -113,14 +145,28 @@ export async function getFlowHeaders(rawUrl, ua, timeout, proxy, flowUrl) {
.filter((i) => i.length)[0],
headers: {
'User-Agent': userAgent,
...(isStash && proxy
? {
'X-Stash-Selected-Proxy':
encodeURIComponent(proxy),
}
: {}),
...(isShadowRocket && proxy
? { 'X-Surge-Policy': proxy }
: {}),
},
timeout: requestTimeout,
...(proxy ? { proxy } : {}),
...(isLoon && proxy ? { node: proxy } : {}),
...(isQX && proxy ? { opts: { policy: proxy } } : {}),
...(proxy ? getPolicyDescriptor(proxy) : {}),
...(insecure ? insecure : {}),
});
flowInfo = getFlowField(headers);
}
}
if (flowInfo) {
headersResourceCache.set(url, flowInfo);
headersResourceCache.set(id, flowInfo);
}
}
@@ -151,8 +197,29 @@ export function parseFlowHeaders(flowHeaders) {
? Number(expireMatch[1] + expireMatch[2])
: undefined;
return { expires, total, usage: { upload, download } };
const remainingDaysMatch = flowHeaders.match(/reset_day=([0-9]+)/);
const remainingDays = remainingDaysMatch
? Number(remainingDaysMatch[1])
: undefined;
const appUrlMatch = flowHeaders.match(/app_url=(.*?)\s*?(;|$)/);
const appUrl = appUrlMatch ? decodeURIComponent(appUrlMatch[1]) : undefined;
const planNameMatch = flowHeaders.match(/plan_name=(.*?)\s*?(;|$)/);
const planName = planNameMatch
? decodeURIComponent(planNameMatch[1])
: undefined;
return {
expires,
total,
usage: { upload, download },
remainingDays,
appUrl,
planName,
};
}
export function flowTransfer(flow, unit = 'B') {
const unitList = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
let unitIndex = unitList.indexOf(unit);

View File

@@ -474,4 +474,7 @@ export class MMDB {
ipaso(ip) {
return this.asnReader?.asn(ip)?.autonomousSystemOrganization;
}
ipasn(ip) {
return this.asnReader?.asn(ip)?.autonomousSystemNumber;
}
}

View File

@@ -1,10 +1,21 @@
import { HTTP } from '@/vendor/open-api';
import { HTTP, ENV } from '@/vendor/open-api';
import { getPolicyDescriptor } from '@/utils';
import $ from '@/core/app';
import { SETTINGS_KEY } from '@/constants';
/**
* Gist backup
*/
export default class Gist {
constructor({ token, key, syncPlatform }) {
const { isStash, isLoon, isShadowRocket, isQX } = ENV();
const { defaultProxy, defaultTimeout: timeout } = $.read(SETTINGS_KEY);
let proxy = defaultProxy;
if ($.env.isNode) {
proxy =
proxy || eval('process.env.SUB_STORE_BACKEND_DEFAULT_PROXY');
}
if (syncPlatform === 'gitlab') {
this.headers = {
'PRIVATE-TOKEN': `${token}`,
@@ -13,7 +24,25 @@ export default class Gist {
};
this.http = HTTP({
baseURL: 'https://gitlab.com/api/v4',
headers: { ...this.headers },
headers: {
...this.headers,
...(isStash && proxy
? {
'X-Stash-Selected-Proxy':
encodeURIComponent(proxy),
}
: {}),
...(isShadowRocket && proxy
? { 'X-Surge-Policy': proxy }
: {}),
},
...(proxy ? { proxy } : {}),
...(isLoon && proxy ? { node: proxy } : {}),
...(isQX && proxy ? { opts: { policy: proxy } } : {}),
...(proxy ? getPolicyDescriptor(proxy) : {}),
timeout: timeout || 8000,
events: {
onResponse: (resp) => {
if (/^[45]/.test(String(resp.statusCode))) {
@@ -35,7 +64,25 @@ export default class Gist {
};
this.http = HTTP({
baseURL: 'https://api.github.com',
headers: { ...this.headers },
headers: {
...this.headers,
...(isStash && proxy
? {
'X-Stash-Selected-Proxy':
encodeURIComponent(proxy),
}
: {}),
...(isShadowRocket && proxy
? { 'X-Surge-Policy': proxy }
: {}),
},
...(proxy ? { proxy } : {}),
...(isLoon && proxy ? { node: proxy } : {}),
...(isQX && proxy ? { opts: { policy: proxy } } : {}),
...(proxy ? getPolicyDescriptor(proxy) : {}),
timeout: timeout || 8000,
events: {
onResponse: (resp) => {
if (/^[45]/.test(String(resp.statusCode))) {

View File

@@ -2,18 +2,24 @@ export function getUserAgentFromHeaders(headers) {
const keys = Object.keys(headers);
let UA = '';
let ua = '';
let accept = '';
for (let k of keys) {
if (/USER-AGENT/i.test(k)) {
const lower = k.toLowerCase();
if (lower === 'user-agent') {
UA = headers[k];
ua = UA.toLowerCase();
break;
} else if (lower === 'accept') {
accept = headers[k];
}
}
return { UA, ua };
return { UA, ua, accept };
}
export function getPlatformFromUserAgent({ ua, UA }) {
export function getPlatformFromUserAgent({ ua, UA, accept }) {
if (UA.indexOf('Quantumult%20X') !== -1) {
return 'QX';
} else if (ua.indexOf('egern') !== -1) {
return 'Egern';
} else if (UA.indexOf('Surfboard') !== -1) {
return 'Surfboard';
} else if (UA.indexOf('Surge Mac') !== -1) {
@@ -39,11 +45,14 @@ export function getPlatformFromUserAgent({ ua, UA }) {
return 'V2Ray';
} else if (ua.indexOf('sing-box') !== -1) {
return 'sing-box';
} else {
} else if (accept.indexOf('application/json') === 0) {
return 'JSON';
} else {
return 'V2Ray';
}
}
export function getPlatformFromHeaders(headers) {
const { UA, ua } = getUserAgentFromHeaders(headers);
return getPlatformFromUserAgent({ ua, UA });
const { UA, ua, accept } = getUserAgentFromHeaders(headers);
return getPlatformFromUserAgent({ ua, UA, accept });
}

View File

@@ -17,16 +17,16 @@ function retry(fn, content, ...args) {
}
export function safeLoad(content, ...args) {
return retry(YAML.safeLoad, content, ...args);
return retry(YAML.safeLoad, JSON.parse(JSON.stringify(content)), ...args);
}
export function load(content, ...args) {
return retry(YAML.load, content, ...args);
return retry(YAML.load, JSON.parse(JSON.stringify(content)), ...args);
}
export function safeDump(...args) {
return YAML.safeDump(...args);
export function safeDump(content, ...args) {
return YAML.safeDump(JSON.parse(JSON.stringify(content)), ...args);
}
export function dump(...args) {
return YAML.dump(...args);
export function dump(content, ...args) {
return YAML.dump(JSON.parse(JSON.stringify(content)), ...args);
}
export default {

View File

@@ -9,6 +9,7 @@ export default function express({ substore: $, port, host }) {
'Access-Control-Allow-Methods': 'POST,GET,OPTIONS,PATCH,PUT,DELETE',
'Access-Control-Allow-Headers':
'Origin, X-Requested-With, Content-Type, Accept',
'X-Powered-By': 'Sub-Store',
};
// node support

View File

@@ -1,10 +1,10 @@
name: Sub-Store
description: "支持 Surge 正式版的参数设置功能. 测落地功能 ability: http-client-policy, 同步配置的定时 cronexp: 55 23 * * *"
description: '支持 Surge 正式版的参数设置功能. 测落地功能 ability: http-client-policy, 同步配置的定时 cronexp: 55 23 * * *'
compat_arguments:
ability: http-client-policy
cronexp: 55 23 * * *
sync: '"Sub-Store Sync"'
timeout: "120"
timeout: '120'
engine: auto
produce: '"# Sub-Store Produce"'
produce_cronexp: 50 */6 * * *
@@ -12,26 +12,27 @@ compat_arguments:
produce_col: '"col1,col2"'
compat_arguments_desc: '\n1⃣ ability\n\n默认已开启测落地能力\n需要配合脚本操作\n如 https://raw.githubusercontent.com/Keywos/rule/main/cname.js\n填写任意其他值关闭\n\n2⃣ cronexp\n\n同步配置定时任务\n默认为每天 23 点 55 分\n\n定时任务指定时将订阅/文件上传到私有 Gist. 在前端, 叫做 ''同步'' 或 ''同步配置''\n\n3⃣ sync\n\n自定义定时任务名\n便于在脚本编辑器中选择\n若设为 # 可取消定时任务\n\n4⃣ timeout\n\n脚本超时, 单位为秒\n\n5⃣ engine\n\n默认为自动使用 webview 引擎, 可设为指定 jsc, 但 jsc 容易爆内存\n\n6⃣ produce\n\n自定义处理订阅的定时任务名\n一般用于定时处理耗时较长的订阅, 以更新缓存\n这样 Surge 中拉取的时候就能用到缓存, 不至于总是超时\n若设为 # 可取消此定时任务\n默认不开启\n\n7⃣ produce_cronexp\n\n配置处理订阅的定时任务\n\n默认为每 6 小时\n\n9⃣ produce_sub\n\n自定义需定时处理的单条订阅名\n多个用 , 连接\n\n🔟 produce_col\n\n自定义需定时处理的组合订阅名\n多个用 , 连接\n\n⚠ 注意: 是 名称(name) 不是 显示名称(displayName)\n如果名称需要编码, 请编码后再用 , 连接\n顺序: 并发执行单条订阅, 然后并发执行组合订阅'
scriptings:
- http_request:
name: Sub-Store Core
match: ^https?:\/\/sub\.store\/((download)|api\/(preview|sync|(utils\/node-info)))
script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-1.min.js
body_required: true
- http_request:
name: Sub-Store Simple
match: ^https?:\/\/sub\.store
script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-0.min.js
body_required: true
- schedule:
name: "{{{sync}}}"
cron: "{{{cronexp}}}"
script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js
- schedule:
name: "{{{produce}}}"
cron: "{{{produce_cronexp}}}"
script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js
arguments:
_compat.$argument: '"sub={{{produce_sub}}}&col={{{produce_col}}}"'
- http_request:
name: Sub-Store Core
match: ^https?:\/\/sub\.store\/((download)|api\/(preview|sync|(utils\/node-info)))
script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-1.min.js
body_required: true
- http_request:
name: Sub-Store Simple
match: ^https?:\/\/sub\.store
script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/sub-store-0.min.js
body_required: true
- schedule:
name: '{{{sync}}}'
cron: '{{{cronexp}}}'
script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js
- schedule:
name: '{{{produce}}}'
cron: '{{{produce_cronexp}}}'
script_url: https://raw.githubusercontent.com/sub-store-org/Sub-Store/release/cron-sync-artifacts.min.js
arguments:
_compat.$argument: '"sub={{{produce_sub}}}&col={{{produce_col}}}"'
mitm:
hostnames:
includes:
- sub.store

View File

@@ -18,6 +18,9 @@ function operator(proxies = [], targetPlatform, context) {
// 10. `sni` 在某些协议里会自动与 `servername` 转换
// 11. 读取节点的 ca-str 和 _ca (后端文件路径) 字段, 自动计算 fingerprint (参考 https://t.me/zhetengsha/1512)
// 12. 以 Surge 为例, 最新的参数一般我都会跟进, 以 Surge 文档为例, 一些常用的: TUIC/Hysteria 2 的 `ecn`, Snell 的 `reuse` 连接复用, QUIC 策略 block-quic`, Hysteria 2 下载带宽 `down`
// 13. `test-url` 为测延迟链接, `test-timeout` 为测延迟超时
// 14. `ports` 为端口跳跃, `hop-interval` 变换端口号的时间间隔
// 15. `ip-version` 设置节点使用 IP 版本可选dualipv4ipv6ipv4-preferipv6-prefer. 会进行内部转换, 若无法匹配则使用原始值
// require 为 Node.js 的 require, 在 Node.js 运行环境下 可以用来引入模块
@@ -63,6 +66,8 @@ function operator(proxies = [], targetPlatform, context) {
// removeFlag, // 移除 emoji 旗帜
// getISO, // 获取 ISO 3166-1 alpha-2 代码
// Gist, // Gist 类
// download, // 内部的下载方法, 见 backend/src/utils/download.js
// MMDB, // Node.js 环境 可用于模拟 Surge/Loon 的 $utils.ipasn, $utils.ipaso, $utils.geoip. 具体见 https://t.me/zhetengsha/1269
// }
// 如果只是为了快速修改或者筛选 可以参考 脚本操作支持节点快捷脚本 https://t.me/zhetengsha/970 和 脚本筛选支持节点快捷脚本 https://t.me/zhetengsha/1009