diff --git a/server/geo.ts b/server/geo.ts index c81c84b..573e25b 100644 --- a/server/geo.ts +++ b/server/geo.ts @@ -4,12 +4,20 @@ * 内核不提供这个信息,但文件就躺在它的工作目录里,和 Patchbay 服务端 * 在同一台机器上,直接 stat 就是。 */ -import { readdir, stat } from 'node:fs/promises' +import { readdir, rename, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { DEFAULT_GEOX_URLS, GEO_FILES } from '../shared/geo.ts' +import { DEFAULT_GEOX_URLS, GEO_FILES, looksLikeGeodata, looksLikeMmdb } from '../shared/geo.ts' import type { GeoFileStatus } from '../shared/geo.ts' import type { Env } from './env.ts' +/** 配置里写了就用配置的,否则退回内核默认。 */ +function urlFor(geoxUrl: Record, configKey: string): string { + const configured = geoxUrl[configKey] + return typeof configured === 'string' && configured !== '' + ? configured + : (DEFAULT_GEOX_URLS[configKey] ?? '') +} + export async function geoStatus( env: Env, geoxUrl: Record, @@ -28,12 +36,52 @@ export async function geoStatus( updatedAt = info.mtime.toISOString() } } - const configured = geoxUrl[configKey] - const url = - typeof configured === 'string' && configured !== '' - ? configured - : (DEFAULT_GEOX_URLS[configKey] ?? '') - return { key, configKey, url, size, updatedAt } + return { key, configKey, url: urlFor(geoxUrl, configKey), size, updatedAt } }), ) } + +/** + * 下载单个 GEO 文件写进内核工作目录。 + * + * 内核的更新接口是全量的(POST /configs/geo,按 enable 标记更新它用到的类型), + * 单文件只能 Patchbay 自己下。代价是拿不到内核那套完整校验,只能做形状检查 + * (shared/geo.ts),也不触发热加载——内核用 mmap 缓存着旧数据,重启后才换新。 + */ +export async function syncGeoFile( + env: Env, + configKey: string, + geoxUrl: Record, +): Promise<{ ok: true } | { ok: false; message: string }> { + const spec = GEO_FILES.find((g) => g.configKey === configKey) + if (!spec) return { ok: false, message: `未知的 GEO 类型:${configKey}` } + const url = urlFor(geoxUrl, configKey) + + let bytes: Uint8Array + try { + const res = await fetch(url, { signal: AbortSignal.timeout(300_000), redirect: 'follow' }) + if (!res.ok) return { ok: false, message: `下载失败:HTTP ${String(res.status)}` } + bytes = new Uint8Array(await res.arrayBuffer()) + } catch (error) { + return { ok: false, message: `下载失败:${String(error)}` } + } + + const shapeOk = + configKey === 'mmdb' || configKey === 'asn' ? looksLikeMmdb(bytes) : looksLikeGeodata(bytes) + if (!shapeOk) { + return { ok: false, message: `下载到的内容不像 ${spec.key} 文件,已丢弃,本地文件没有动。` } + } + + // 沿用目录里现有的大小写写法,内核就是这样找文件的 + const entries = await readdir(env.mihomoHome).catch(() => [] as string[]) + const actual = entries.find((name) => name.toLowerCase() === spec.file.toLowerCase()) ?? spec.file + const finalPath = join(env.mihomoHome, actual) + const tempPath = `${finalPath}.patchbay-tmp` + try { + await writeFile(tempPath, bytes) + await rename(tempPath, finalPath) + } catch (error) { + return { ok: false, message: `写入失败:${String(error)}` } + } + return { ok: true } +} diff --git a/server/routes.ts b/server/routes.ts index 3bec538..77e77cb 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -22,7 +22,7 @@ import { restoreBackup, undoConfig, } from './config-store.ts' -import { geoStatus } from './geo.ts' +import { geoStatus, syncGeoFile } from './geo.ts' import { hostInfo, probeExit } from './host.ts' import { kernelVersion, @@ -306,6 +306,16 @@ function makeRoute(env: Env, token: string) { return json({ files: await geoStatus(env, geox) }) } + // 单个 GEO 文件的下载。服务端自己下(内核只有全量更新的口子),重启后生效 + if (path === '/api/geo/sync' && req.method === 'POST') { + const body = (await req.json()) as { key?: string } + if (typeof body.key !== 'string') return json({ message: '请求缺少 key 字段' }, 400) + const snapshot = await readConfig(env) + const geox = (snapshot.data['geox-url'] ?? {}) as Record + const result = await syncGeoFile(env, body.key, geox) + return result.ok ? json({ ok: true }) : json({ message: result.message }, 502) + } + // 更新 GEO 数据库。运行时动作;同步接口,透传层给了长超时 if (path === '/api/kernel/geo' && req.method === 'POST') { const upstream = await updateGeoDatabases(env) diff --git a/shared/geo.test.ts b/shared/geo.test.ts new file mode 100644 index 0000000..55cc7ca --- /dev/null +++ b/shared/geo.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test' +import { looksLikeGeodata, looksLikeMmdb } from './geo.ts' + +describe('GEO 文件的形状检查', () => { + test('末尾带 MaxMind 标记的通过,纯垃圾不通过', () => { + const marker = new Uint8Array([ + 0xab, + 0xcd, + 0xef, + ...Array.from('MaxMind.com', (c) => c.charCodeAt(0)), + ]) + const good = new Uint8Array(4096) + good.set(marker, 4000) + expect(looksLikeMmdb(good)).toBe(true) + expect(looksLikeMmdb(new Uint8Array(4096))).toBe(false) + }) + + test('标记只认末尾 128KB,太靠前算无效', () => { + const marker = new Uint8Array([ + 0xab, + 0xcd, + 0xef, + ...Array.from('MaxMind.com', (c) => c.charCodeAt(0)), + ]) + const bytes = new Uint8Array(300 * 1024) + bytes.set(marker, 100) + expect(looksLikeMmdb(bytes)).toBe(false) + }) + + test('geodata 认首字节 0x0a,HTML 错误页和小文件被挡下', () => { + const good = new Uint8Array(2048) + good[0] = 0x0a + expect(looksLikeGeodata(good)).toBe(true) + + const html = new Uint8Array(2048) + html[0] = '<'.charCodeAt(0) + expect(looksLikeGeodata(html)).toBe(false) + + const tiny = new Uint8Array(10) + tiny[0] = 0x0a + expect(looksLikeGeodata(tiny)).toBe(false) + }) +}) diff --git a/shared/geo.ts b/shared/geo.ts index e992bc1..2113da1 100644 --- a/shared/geo.ts +++ b/shared/geo.ts @@ -28,6 +28,33 @@ export const GEO_FILES = [ { key: 'GEOSITE', configKey: 'geosite', file: 'GeoSite.dat' }, ] as const +/** + * 下载内容的形状检查,挡住 HTML 错误页、截断的下载这类明显的坏文件。 + * Patchbay 自己下载单个 GEO 文件时用(内核的更新接口是全量的,单文件 + * 下载只能自己来,也就拿不到内核那套完整校验)。 + */ + +/** MaxMind 格式:元数据标记按规范出现在文件末尾 128KB 内。MMDB 和 ASN 都是这个格式。 */ +export function looksLikeMmdb(bytes: Uint8Array): boolean { + const marker = [0xab, 0xcd, 0xef, ...Array.from('MaxMind.com', (c) => c.charCodeAt(0))] + const from = Math.max(0, bytes.length - 128 * 1024) + outer: for (let i = from; i <= bytes.length - marker.length; i++) { + for (let j = 0; j < marker.length; j++) { + if (bytes[i + j] !== marker[j]) continue outer + } + return true + } + return false +} + +/** + * geoip.dat / geosite.dat:GeoIPList 和 GeoSiteList 都是「repeated 消息字段 1」, + * 首字节必然是 0x0a。弱校验,但足够挡住以 '<' 开头的错误页和空文件。 + */ +export function looksLikeGeodata(bytes: Uint8Array): boolean { + return bytes.length > 1024 && bytes[0] === 0x0a +} + /** 内核的默认下载地址(config.go:578-583)。配置里没写 geox-url 时就是这些。 */ export const DEFAULT_GEOX_URLS: Record = { mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb', diff --git a/web/App.vue b/web/App.vue index 83bcb45..e359ec1 100644 --- a/web/App.vue +++ b/web/App.vue @@ -271,22 +271,23 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) > + + +
+ -
- -
-
- - {{ kernel.online ? kernel.version : 'offline' }} -