内核没有默认的健康检查地址,url 为空时检查根本不会运行 (adapter/provider/healthcheck.go:196-199),所以默认值必须写全; 开关只翻 enable,手写的 url/interval 留着下次开启时还用。 改名和策略组 use: 的引用同步收在同一次变换里,链式副本由保存 链路的 syncGenerated 按新名字重建,缓存路径刻意不动——file 类型 的数据本体和副本认亲都靠它。
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
/**
|
||
* 订阅的读写。
|
||
*
|
||
* 配置文件里的 `proxy-providers` 段:一个名字对一份声明,内核定时从地址拉回一批线路。
|
||
* 这段里除了用户自己加的订阅,还有 Patchbay 为链式代理生成的副本(带 `patchbay:` 前缀)。
|
||
* 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责,
|
||
* 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。
|
||
*
|
||
* 改名必须走 renameSubscription:名字被策略组的 `use:` 和生成的副本引用,改名和
|
||
* 同步引用要在同一次变换里完成,散开做漏一处就是断引用。
|
||
*/
|
||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||
import { isGenerated, providerCachePath } from './inbound-groups.ts'
|
||
|
||
/** 新增订阅时的默认更新间隔,秒。一天一次。 */
|
||
export const DEFAULT_INTERVAL = 86400
|
||
|
||
/**
|
||
* 新增订阅或开启健康检查时写入的默认值。
|
||
*
|
||
* `url` 必须写:内核没有默认检查地址,为空时 interval 被强制归零、检查不会运行
|
||
* (内核源码 adapter/provider/healthcheck.go:196-199)。`interval` 与内核在
|
||
* enable 时的默认一致(adapter/provider/parser.go:66-68),写死一个更稳。
|
||
*/
|
||
export const DEFAULT_HEALTH_CHECK = {
|
||
enable: true,
|
||
url: 'https://www.gstatic.com/generate_204',
|
||
interval: 300,
|
||
}
|
||
|
||
/** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */
|
||
export interface Subscription {
|
||
name: string
|
||
/** http 定时从地址拉取;file 读本机文件,内核靠文件监视跟进变化 */
|
||
type: 'http' | 'file'
|
||
url: string
|
||
/** 线路文件的路径(http 类型是缓存,file 类型是数据本体) */
|
||
path: string | null
|
||
/**
|
||
* 更新间隔,秒。null 表示配置里没写——内核只在 interval > 0 时才起定时
|
||
* 拉取循环(内核源码 component/resource/fetcher.go:226-228),所以
|
||
* 「没写」就是「不自动更新」,界面上它是个开关而不是必填数字。
|
||
*/
|
||
interval: number | null
|
||
/** 健康检查。没写或 enable 不是 true 都算未开启 */
|
||
healthCheck: { url: string | null; interval: number | null } | null
|
||
}
|
||
|
||
function isPlainObject(value: unknown): value is ConfigObject {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||
}
|
||
|
||
function providersOf(data: ConfigObject): ConfigObject {
|
||
return isPlainObject(data['proxy-providers']) ? data['proxy-providers'] : {}
|
||
}
|
||
|
||
function groupsOf(data: ConfigObject): ConfigObject[] {
|
||
return (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []).filter(isPlainObject)
|
||
}
|
||
|
||
function stringList(value: unknown): string[] {
|
||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||
}
|
||
|
||
/** 所有用户订阅,按配置里的顺序。不含生成的副本。 */
|
||
export function listSubscriptions(data: ConfigObject): Subscription[] {
|
||
const result: Subscription[] = []
|
||
for (const [name, entry] of Object.entries(providersOf(data))) {
|
||
if (isGenerated(name) || !isPlainObject(entry)) continue
|
||
const hc = isPlainObject(entry['health-check']) ? entry['health-check'] : null
|
||
result.push({
|
||
name,
|
||
type: entry.type === 'file' ? 'file' : 'http',
|
||
path: typeof entry.path === 'string' ? entry.path : null,
|
||
url: typeof entry.url === 'string' ? entry.url : '',
|
||
interval: typeof entry.interval === 'number' ? entry.interval : null,
|
||
healthCheck:
|
||
hc !== null && hc.enable === true
|
||
? {
|
||
url: typeof hc.url === 'string' ? hc.url : null,
|
||
interval: typeof hc.interval === 'number' ? hc.interval : null,
|
||
}
|
||
: null,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* `proxy-providers` 里的所有名字,含生成的副本。
|
||
* 新增订阅挡重名要用全部键:它是映射,和用户看不见的副本撞名也会被静默丢掉。
|
||
*/
|
||
export function providerNames(data: ConfigObject): string[] {
|
||
return Object.keys(providersOf(data))
|
||
}
|
||
|
||
/**
|
||
* 新增一个订阅。
|
||
*
|
||
* `path` 自动给出确定的缓存文件路径,不让用户填:不写的话内核按 url 哈希自己生成一个,
|
||
* 那个路径 Patchbay 算不出来,链式代理的副本就没法靠「读同一个缓存文件」认来源
|
||
* (见 inbound-groups.ts 的 applySetup)。
|
||
*
|
||
* `interval` 为 null 时不写这个键,内核就不自动更新(见 Subscription.interval 的注)。
|
||
*/
|
||
export function addSubscription(
|
||
data: ConfigObject,
|
||
name: string,
|
||
url: string,
|
||
interval: number | null = DEFAULT_INTERVAL,
|
||
): ConfigObject {
|
||
const next = { ...data }
|
||
next['proxy-providers'] = {
|
||
...providersOf(data),
|
||
[name]: {
|
||
type: 'http',
|
||
url,
|
||
path: providerCachePath(name),
|
||
...(interval !== null ? { interval } : {}),
|
||
'health-check': { ...DEFAULT_HEALTH_CHECK },
|
||
},
|
||
}
|
||
return next
|
||
}
|
||
|
||
/**
|
||
* 新订阅的缓存路径是否和现有订阅的撞车。
|
||
*
|
||
* 名字查重挡不住这种:路径由名字净化而来,「订阅 A」和「订阅/A」是两个名字、
|
||
* 同一个 `providers/订阅-A.yaml`,两个订阅会互相覆盖对方的缓存文件。
|
||
* 返回撞上的那个订阅名,没撞返回 null。
|
||
*/
|
||
export function subscriptionPathConflict(
|
||
data: ConfigObject,
|
||
selfName: string,
|
||
path: string,
|
||
): string | null {
|
||
for (const [name, decl] of Object.entries(providersOf(data))) {
|
||
if (name === selfName) continue
|
||
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
|
||
if ((decl as ConfigObject).path === path) return name
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 新增一个来自本机文件的订阅。数据就在 path 指的文件里(内容由上传流程先写好),
|
||
* 内核靠文件监视跟进变化,没有地址也没有更新间隔。
|
||
*/
|
||
export function addFileSubscription(data: ConfigObject, name: string): ConfigObject {
|
||
const next = { ...data }
|
||
next['proxy-providers'] = {
|
||
...providersOf(data),
|
||
[name]: {
|
||
type: 'file',
|
||
path: providerCachePath(name),
|
||
'health-check': { ...DEFAULT_HEALTH_CHECK },
|
||
},
|
||
}
|
||
return next
|
||
}
|
||
|
||
/** 改订阅的地址和更新间隔。其余字段(缓存路径、健康检查、筛选)原样保留。 */
|
||
export function updateSubscription(
|
||
data: ConfigObject,
|
||
name: string,
|
||
url: string,
|
||
interval: number | null,
|
||
): ConfigObject {
|
||
const entry = providersOf(data)[name]
|
||
if (!isPlainObject(entry)) return data
|
||
const updated: ConfigObject = { ...entry, url }
|
||
if (interval === null) delete updated.interval
|
||
else updated.interval = interval
|
||
const next = { ...data }
|
||
next['proxy-providers'] = {
|
||
...providersOf(data),
|
||
[name]: updated,
|
||
}
|
||
return next
|
||
}
|
||
|
||
/**
|
||
* 开关一个订阅的健康检查。
|
||
*
|
||
* 关闭写 `enable: false` 而不是删掉整个键:用户手写的 url/interval 要留着,
|
||
* 下次开启时还用它们。开启时缺的字段用默认值补齐——url 不能缺,内核没有默认
|
||
* 检查地址(见 DEFAULT_HEALTH_CHECK 的注)。
|
||
*/
|
||
export function setSubscriptionHealthCheck(
|
||
data: ConfigObject,
|
||
name: string,
|
||
enable: boolean,
|
||
): ConfigObject {
|
||
const entry = providersOf(data)[name]
|
||
if (!isPlainObject(entry)) return data
|
||
const existing = isPlainObject(entry['health-check']) ? entry['health-check'] : null
|
||
// 本来就没写又要关闭,不必写一个 enable: false 的完整声明进去
|
||
if (!enable && existing === null) return data
|
||
const next = { ...data }
|
||
next['proxy-providers'] = {
|
||
...providersOf(data),
|
||
[name]: { ...entry, 'health-check': { ...DEFAULT_HEALTH_CHECK, ...existing, enable } },
|
||
}
|
||
return next
|
||
}
|
||
|
||
/**
|
||
* 改订阅的名字,并同步所有策略组 `use:` 里的引用。
|
||
*
|
||
* 缓存路径 `path` 保持不变:file 类型的数据本体就在旧路径上,链式代理的副本靠
|
||
* 「和原订阅同一个 path」认来源(见 inbound-groups.ts),路径一动这两件事都得另做迁移。
|
||
* 生成的副本这里不碰:写配置的链路每次都跑 syncGenerated/pruneGenerated,
|
||
* 会按新名字重建副本、清掉旧的。
|
||
*/
|
||
export function renameSubscription(
|
||
data: ConfigObject,
|
||
oldName: string,
|
||
newName: string,
|
||
): ConfigObject {
|
||
const providers = providersOf(data)
|
||
if (oldName === newName || !(oldName in providers) || newName in providers) return data
|
||
|
||
const next = { ...data }
|
||
// 逐项重建以保住条目在映射里的位置,diff 才不会把整段重排
|
||
next['proxy-providers'] = Object.fromEntries(
|
||
Object.entries(providers).map(([name, entry]) => [name === oldName ? newName : name, entry]),
|
||
)
|
||
|
||
if (Array.isArray(data['proxy-groups'])) {
|
||
next['proxy-groups'] = data['proxy-groups'].map((group) => {
|
||
if (!isPlainObject(group) || !Array.isArray(group.use)) return group
|
||
if (!group.use.includes(oldName)) return group
|
||
return { ...group, use: group.use.map((item) => (item === oldName ? newName : item)) }
|
||
})
|
||
}
|
||
|
||
return next
|
||
}
|
||
|
||
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
|
||
export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
|
||
const providers = { ...providersOf(data) }
|
||
if (!(name in providers)) return data
|
||
delete providers[name]
|
||
const next = { ...data }
|
||
if (Object.keys(providers).length === 0) delete next['proxy-providers']
|
||
else next['proxy-providers'] = providers
|
||
return next
|
||
}
|
||
|
||
/**
|
||
* 哪些策略组在引用这个订阅。删除前必须先查:被引用的订阅删了,那些组的 `use:` 就空了,
|
||
* 而空组会让指向它的入站无路可走。
|
||
*
|
||
* 引用有两种,第二种容易漏:
|
||
* 直接 组的 `use:` 里有这个订阅。链式代理的入口组就是这么引原订阅的
|
||
* 间接 开了链式代理的入站,它的专属组 `use:` 的是订阅副本;副本靠和原订阅
|
||
* 共用缓存文件(`path` 相同)认来源,名字不可靠解析(见 inbound-groups.ts)
|
||
*/
|
||
export function subscriptionReferences(data: ConfigObject, name: string): string[] {
|
||
const target = providersOf(data)[name]
|
||
if (!isPlainObject(target)) return []
|
||
|
||
const copies = new Set<string>()
|
||
if (typeof target.path === 'string') {
|
||
for (const [copyName, entry] of Object.entries(providersOf(data))) {
|
||
if (isGenerated(copyName) && isPlainObject(entry) && entry.path === target.path) {
|
||
copies.add(copyName)
|
||
}
|
||
}
|
||
}
|
||
|
||
return groupsOf(data)
|
||
.filter((group) => {
|
||
const use = stringList(group.use)
|
||
return use.includes(name) || use.some((u) => copies.has(u))
|
||
})
|
||
.map((group) => String(group.name))
|
||
}
|