- 拆分:tools/split.js 重切新版(1347 模块 / 126 代码模块),主文件 main.7cfa4058.js,语法验证通过 - 修复 split.js 内自动刷新拦截的 \\n 转义:它在模板字符串里,必须写 \\\\n,否则生成的主文件字符串断行、语法报错 - 符号映射:map_symbols.js 对齐旧→新(qyr→ryz、w187→c18o、z4t→jtn、vgt→rkt、Bmt→Ezt、Qjt→Fjt、txt→Gjt、k15b→s15n、azi_1→vzr_1…) - 移植 7 处针对性修改:炮塔摆头不回弹(vSt.ryz)、摆头不过冲(case 0)、精准跟手(case 1/2)、悬浮底盘 raw 两级(Ezt.n36 + Zmt 实际速度)、电磁炮蓄力时长(jtn.c18o) - 重新打包 unpacked.pack.json(127 文件 / 版本 96833eb2b0cf8909)
372 lines
14 KiB
JavaScript
372 lines
14 KiB
JavaScript
// probe_cdn.js - 探测 3dtank.com / tankionline.com 的边缘 CDN 与 source map 暴露情况
|
|
// 用法:
|
|
// node probe_cdn.js --mode dns # 子域枚举(DNS 解析)
|
|
// node probe_cdn.js --mode sourcemap --js <url> # 拉取 js 尾部找 sourceMappingURL,并试候选 map 路径
|
|
// node probe_cdn.js --mode tail --js <url> # 只取 js 末尾 N 字节(找 sourceMappingURL / 版本注释)
|
|
// node probe_cdn.js --mode paths --base <url> # 试一批常见路径(目录列表/备份/配置)
|
|
// node probe_cdn.js --mode cors --base <url> # 试探来源校验与 CORS
|
|
// node probe_cdn.js --mode all --js <url> --base <url>
|
|
// 说明:
|
|
// 只用 GET/HEAD/OPTIONS,不做任何写操作(避免对线上产生影响)。
|
|
// 默认忽略证书错误(站点多为老配置),并把结果落盘到 names/cdn_probe.json。
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const https = require('https');
|
|
const http = require('http');
|
|
const dns = require('dns');
|
|
const { URL } = require('url');
|
|
|
|
const PROJECT = path.resolve(__dirname, '..');
|
|
const Argv = process.argv.slice(2);
|
|
const Arg = (Name, Def) => {
|
|
const I = Argv.indexOf('--' + Name);
|
|
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
|
|
};
|
|
|
|
const MODE = Arg('mode', 'all');
|
|
const JS_URL = Arg('js', 'https://3dtank.com/play/static/js/main.6469b966.js');
|
|
const BASE = Arg('base', 'https://3dtank.com');
|
|
const TIMEOUT = Number(Arg('timeout', '12000'));
|
|
const CONCURRENCY = Number(Arg('concurrency', '8'));
|
|
const OUT = Arg('out', path.join(PROJECT, 'names', 'cdn_probe.json'));
|
|
const CORS_PATH = Arg('path', '/');
|
|
|
|
const UA =
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0';
|
|
|
|
const SITE_HEADERS = {
|
|
accept: '*/*',
|
|
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
origin: 'https://3dtank.com',
|
|
referer: 'https://3dtank.com/',
|
|
'user-agent': UA,
|
|
};
|
|
|
|
// 候选子域(两个站点都试)
|
|
const HOSTS = {
|
|
'3dtank.com': [
|
|
'3dtank.com', 'www', 'res', 'static', 'cdn', 'img', 'images', 'assets', 's',
|
|
'c1', 'c2', 'c3', 'c4', 'game', 'play', 'patch', 'update', 'updater', 'dl',
|
|
'download', 'files', 'media', 'mirror', 'client', 'launcher', 'api', 'open-api',
|
|
'mgr', 'ops', 'forum', 'union', 'ratings', 'test', 'dev', 'beta', 'new', 'old',
|
|
'bbs', 'sso', 'pay', 'stat', 'log', 'oss',
|
|
],
|
|
'tankionline.com': [
|
|
'tankionline.com', 'www', 'res', 'static', 'sstatic', 'cdn', 'img', 'images',
|
|
'assets', 's', 'c1', 'c2', 'c3', 'game', 'play', 'patch', 'update', 'updater',
|
|
'dl', 'download', 'files', 'media', 'mirror', 'client', 'launcher', 'api',
|
|
'm', 'test', 'dev', 'beta', 'new', 'old', 'forum', 'en', 'ru', 'de',
|
|
],
|
|
};
|
|
|
|
// 各域通用的子域候选(--domains 追加新域时复用)
|
|
const SUBS_DEFAULT = [
|
|
'www', 'res', 'static', 'cdn', 'img', 'images', 'assets', 's', 'play', 'game',
|
|
'c1', 'c2', 'c3', 'dl', 'download', 'files', 'patch', 'update', 'api', 'm',
|
|
];
|
|
|
|
// 常见可疑路径(开放目录/备份/配置泄漏)
|
|
const PATHS = [
|
|
'/', '/index.html', '/resources/', '/resources/index.json', '/play/', '/play/static/',
|
|
'/play/static/js/', '/play/static/js/index.json', '/play/static/js/resources.json',
|
|
'/play/static/js/main.js.map', '/static/', '/assets/', '/files/', '/dl/', '/patch/',
|
|
'/config.xml', '/s/status.js', '/robots.txt', '/sitemap.xml', '/.git/HEAD',
|
|
'/crossdomain.xml', '/version.txt', '/manifest.json', '/favicon.ico',
|
|
];
|
|
|
|
// 单次请求;Follow 为真时自动跟 3xx
|
|
function Request(Url, Opt) {
|
|
const O = Opt || {};
|
|
return new Promise((Resolve) => {
|
|
let U;
|
|
try {
|
|
U = new URL(Url);
|
|
} catch (E) {
|
|
return Resolve({ Error: 'bad url' });
|
|
}
|
|
const Lib = U.protocol === 'http:' ? http : https;
|
|
const Headers = Object.assign({}, O.Headers || {});
|
|
const Req = Lib.request(
|
|
{
|
|
protocol: U.protocol,
|
|
hostname: U.hostname,
|
|
port: U.port || (U.protocol === 'http:' ? 80 : 443),
|
|
path: U.pathname + U.search,
|
|
method: O.Method || 'GET',
|
|
headers: Headers,
|
|
rejectUnauthorized: false,
|
|
servername: U.hostname,
|
|
timeout: TIMEOUT,
|
|
},
|
|
(Res) => {
|
|
const Chunks = [];
|
|
let Total = 0;
|
|
let Finished = false;
|
|
const Cap = O.Cap || 0; // >0 时只读前 Cap 字节
|
|
const Done = () => {
|
|
if (Finished) return;
|
|
Finished = true;
|
|
const Body = Buffer.concat(Chunks);
|
|
if (O.Follow && Res.statusCode >= 300 && Res.statusCode < 400 && Res.headers.location) {
|
|
const Next = new URL(Res.headers.location, Url).href;
|
|
return Resolve(Request(Next, Object.assign({}, O, { Follow: (O.Follow || 1) - 1 })));
|
|
}
|
|
Resolve({
|
|
Url,
|
|
Status: Res.statusCode,
|
|
Bytes: Total,
|
|
Type: String(Res.headers['content-type'] || ''),
|
|
Server: String(Res.headers.server || ''),
|
|
Location: String(Res.headers.location || ''),
|
|
Acao: String(Res.headers['access-control-allow-origin'] || ''),
|
|
Allow: String(Res.headers.allow || ''),
|
|
Headers: Res.headers,
|
|
Body,
|
|
});
|
|
};
|
|
Res.on('data', (C) => {
|
|
Total += C.length;
|
|
if (!Cap || Total <= Cap) Chunks.push(C);
|
|
if (Cap && Total > Cap) {
|
|
Res.destroy();
|
|
Done();
|
|
}
|
|
});
|
|
Res.on('end', Done);
|
|
}
|
|
);
|
|
Req.on('timeout', () => {
|
|
Req.destroy();
|
|
Resolve({ Url, Error: 'timeout' });
|
|
});
|
|
Req.on('error', (E) => Resolve({ Url, Error: E.code || E.message }));
|
|
if (O.Body) Req.write(O.Body);
|
|
Req.end();
|
|
});
|
|
}
|
|
|
|
async function Pool(Items, Worker, N) {
|
|
let Next = 0;
|
|
const Out = new Array(Items.length);
|
|
await Promise.all(
|
|
new Array(Math.min(N, Items.length || 1)).fill(0).map(async () => {
|
|
while (true) {
|
|
const I = Next++;
|
|
if (I >= Items.length) return;
|
|
Out[I] = await Worker(Items[I], I);
|
|
}
|
|
})
|
|
);
|
|
return Out;
|
|
}
|
|
|
|
function Brief(R) {
|
|
if (R.Error) return 'ERR ' + R.Error;
|
|
return (
|
|
String(R.Status).padEnd(4) +
|
|
String(R.Bytes).padStart(9) +
|
|
' ' +
|
|
(R.Type || '-').split(';')[0].padEnd(24) +
|
|
(R.Server ? 'srv=' + R.Server + ' ' : '') +
|
|
(R.Acao ? 'ACAO=' + R.Acao + ' ' : '') +
|
|
(R.Location ? '-> ' + R.Location : '')
|
|
);
|
|
}
|
|
|
|
// ---- DNS 子域枚举 ----
|
|
async function ModeDns() {
|
|
const Result = {};
|
|
const Extra = Arg('domains', '');
|
|
if (Extra) for (const D of Extra.split(',').map((S) => S.trim()).filter(Boolean)) HOSTS[D] = HOSTS[D] || SUBS_DEFAULT;
|
|
const SubsOverride = Arg('subs', '');
|
|
if (SubsOverride) {
|
|
const S = SubsOverride.split(',').map((X) => X.trim()).filter(Boolean);
|
|
for (const D of Object.keys(HOSTS)) HOSTS[D] = S;
|
|
}
|
|
for (const [Domain, Subs] of Object.entries(HOSTS)) {
|
|
console.log('== DNS ' + Domain + ' ==');
|
|
const Found = [];
|
|
const Rows = await Pool(
|
|
Subs,
|
|
async (S) => {
|
|
const Fqdn = S.includes('.') ? S : S + '.' + Domain;
|
|
const A = await new Promise((R) => dns.resolve4(Fqdn, (E, Addr) => R(E ? null : Addr)));
|
|
const C = await new Promise((R) => dns.resolveCname(Fqdn, (E, Addr) => R(E ? null : Addr)));
|
|
if (!A && !C) return null;
|
|
const Row = { Host: Fqdn, A: A || [], Cname: C || [] };
|
|
Found.push(Row);
|
|
console.log(' ' + Fqdn.padEnd(34) + (A ? 'A=' + A.join(',') : '') + (C ? ' CNAME=' + C.join(',') : ''));
|
|
return Row;
|
|
},
|
|
CONCURRENCY
|
|
);
|
|
Result[Domain] = Rows.filter(Boolean);
|
|
}
|
|
return Result;
|
|
}
|
|
|
|
// ---- source map ----
|
|
async function ModeSourcemap() {
|
|
console.log('== sourcemap 探测: ' + JS_URL + ' ==');
|
|
const Result = { Js: JS_URL, Tail: '', MappingUrl: '', Candidates: [] };
|
|
const Main = await Request(JS_URL, { Headers: SITE_HEADERS, Follow: 3, Cap: 2 * 1024 * 1024 });
|
|
console.log(' js 本体: ' + Brief(Main));
|
|
if (Main.Body) {
|
|
const Text = Main.Body.toString('latin1');
|
|
const M = Text.match(/sourceMappingURL=([^\s'"*]+)/);
|
|
if (M) {
|
|
Result.MappingUrl = M[1];
|
|
console.log(' 发现 sourceMappingURL: ' + M[1]);
|
|
} else {
|
|
const Tail = Text.slice(-300).replace(/[^\x20-\x7e]/g, '.');
|
|
Result.Tail = Tail;
|
|
console.log(' 正文尾部(未见 sourceMappingURL): ' + Tail.slice(-160));
|
|
}
|
|
}
|
|
const Base = JS_URL.replace(/\.js($|\?)/, '');
|
|
const Cands = [
|
|
JS_URL + '.map',
|
|
Base + '.js.map',
|
|
Base + '.map',
|
|
Base.replace(/\/[^/]+$/, '/') + 'main.js.map',
|
|
Base + '.js.map?',
|
|
];
|
|
if (Result.MappingUrl) Cands.unshift(new URL(Result.MappingUrl, JS_URL).href);
|
|
const Rows = await Pool(
|
|
[...new Set(Cands)],
|
|
async (U) => {
|
|
const R = await Request(U, { Headers: SITE_HEADERS, Follow: 3 });
|
|
console.log(' ' + Brief(R) + ' ' + U);
|
|
return { Url: U, Status: R.Status, Bytes: R.Bytes, Type: R.Type, Error: R.Error };
|
|
},
|
|
CONCURRENCY
|
|
);
|
|
Result.Candidates = Rows;
|
|
return Result;
|
|
}
|
|
|
|
// ---- 尾部字节(sourceMappingURL / 版本注释常在这里)----
|
|
async function ModeTail() {
|
|
const N = Number(Arg('bytes', '2048'));
|
|
console.log('== 尾部 ' + N + ' 字节: ' + JS_URL + ' ==');
|
|
const R = await Request(JS_URL, {
|
|
Headers: Object.assign({}, SITE_HEADERS, { range: 'bytes=-' + N }),
|
|
Follow: 2,
|
|
});
|
|
console.log(' ' + Brief(R));
|
|
const Text = (R.Body || Buffer.alloc(0)).toString('latin1');
|
|
const Tail = Text.slice(-N);
|
|
const Result = { Js: JS_URL, Status: R.Status, Bytes: R.Bytes, Range: R.Headers && R.Headers['content-range'], Tail: Tail };
|
|
const M = Tail.match(/sourceMappingURL=([^\s'"*]+)/);
|
|
if (M) {
|
|
Result.MappingUrl = M[1];
|
|
console.log(' >>> sourceMappingURL = ' + M[1]);
|
|
}
|
|
console.log(' --- 尾部原文 ---');
|
|
console.log(Tail.replace(/[\x00-\x08\x0b-\x1f]/g, '.'));
|
|
return Result;
|
|
}
|
|
|
|
// ---- 常见路径 ----
|
|
async function ModePaths() {
|
|
console.log('== 路径探测: ' + BASE + ' ==');
|
|
const Rows = await Pool(
|
|
PATHS,
|
|
async (P) => {
|
|
const R = await Request(BASE + P, { Headers: SITE_HEADERS, Follow: 2, Cap: 4096 });
|
|
console.log(' ' + Brief(R).padEnd(80) + ' ' + P);
|
|
return { Path: P, Status: R.Status, Bytes: R.Bytes, Type: R.Type, Server: R.Server, Acao: R.Acao };
|
|
},
|
|
CONCURRENCY
|
|
);
|
|
return Rows;
|
|
}
|
|
|
|
// ---- 任意 URL 列表 ----
|
|
async function ModeList() {
|
|
const Inline = Arg('urls', '');
|
|
const File = Arg('file', '');
|
|
let List = Inline ? Inline.split(',').map((S) => S.trim()).filter(Boolean) : [];
|
|
if (File) {
|
|
List = List.concat(
|
|
fs.readFileSync(path.resolve(File), 'utf8').split(/\r?\n/).map((S) => S.trim()).filter((S) => S && !S.startsWith('#'))
|
|
);
|
|
}
|
|
const Cap = Number(Arg('cap', '4096'));
|
|
const HostOverride = Arg('host', ''); // 指定 Host(配 IP 直连做 CDN 回源探测)
|
|
const ExtraHeaders = Arg('headers', ''); // 额外请求头,形如 "k:v|k:v"
|
|
let HeadersBase = HostOverride ? Object.assign({}, SITE_HEADERS, { host: HostOverride }) : SITE_HEADERS;
|
|
if (ExtraHeaders) {
|
|
HeadersBase = Object.assign({}, HeadersBase);
|
|
for (const Pair of ExtraHeaders.split('|')) {
|
|
const C = Pair.indexOf(':');
|
|
if (C > 0) HeadersBase[Pair.slice(0, C).trim().toLowerCase()] = Pair.slice(C + 1).trim();
|
|
}
|
|
}
|
|
console.log('== 列表探测 ' + List.length + ' 个 URL ==' + (HostOverride ? ' Host 覆盖: ' + HostOverride : ''));
|
|
const Rows = await Pool(
|
|
List,
|
|
async (U) => {
|
|
const R = await Request(U, { Headers: HeadersBase, Follow: 3, Cap });
|
|
const Preview = R.Body ? R.Body.slice(0, Cap).toString('latin1').replace(/\s+/g, ' ').slice(0, 200) : '';
|
|
console.log(' ' + Brief(R).padEnd(76) + ' ' + U);
|
|
if (Preview && (!R.Type || !/image|font|octet/.test(R.Type))) console.log(' | ' + Preview);
|
|
return {
|
|
Url: U,
|
|
Status: R.Status,
|
|
Bytes: R.Bytes,
|
|
Type: R.Type,
|
|
Server: R.Server,
|
|
Acao: R.Acao,
|
|
Range: R.Headers && R.Headers['content-range'],
|
|
Preview,
|
|
Error: R.Error,
|
|
};
|
|
},
|
|
CONCURRENCY
|
|
);
|
|
return Rows;
|
|
}
|
|
|
|
// ---- CORS / 来源校验 / 写方法 ----
|
|
async function ModeCors() {
|
|
console.log('== CORS/来源校验/方法探测: ' + BASE + ' ==');
|
|
const Tests = [
|
|
{ Name: '纯 GET 无任何头', Headers: { 'user-agent': UA }, Method: 'GET' },
|
|
{ Name: '外来 Origin', Headers: { 'user-agent': UA, origin: 'https://evil.example' }, Method: 'GET' },
|
|
{ Name: '外来 Referer', Headers: { 'user-agent': UA, referer: 'https://evil.example/' }, Method: 'GET' },
|
|
{ Name: '空 Referer', Headers: { 'user-agent': UA, referer: '' }, Method: 'GET' },
|
|
{ Name: '站点头(基准)', Headers: SITE_HEADERS, Method: 'GET' },
|
|
{ Name: 'null Origin', Headers: { 'user-agent': UA, origin: 'null' }, Method: 'GET' },
|
|
{ Name: 'OPTIONS 预检', Headers: SITE_HEADERS, Method: 'OPTIONS' },
|
|
];
|
|
const Rows = [];
|
|
for (const T of Tests) {
|
|
const R = await Request(BASE + CORS_PATH, { Headers: T.Headers, Method: T.Method, Follow: 2, Cap: 2048 });
|
|
const Row = {
|
|
Name: T.Name,
|
|
Status: R.Status,
|
|
Bytes: R.Bytes,
|
|
Acao: R.Acao,
|
|
Allow: R.Allow,
|
|
Server: R.Server,
|
|
Error: R.Error,
|
|
};
|
|
Rows.push(Row);
|
|
console.log(' ' + T.Name.padEnd(20) + Brief(R).padEnd(70) + (R.Allow ? 'Allow=' + R.Allow : ''));
|
|
}
|
|
return Rows;
|
|
}
|
|
|
|
(async () => {
|
|
const Out = {};
|
|
if (MODE === 'dns' || MODE === 'all') Out.Dns = await ModeDns();
|
|
if (MODE === 'sourcemap' || MODE === 'all') Out.Sourcemap = await ModeSourcemap();
|
|
if (MODE === 'tail') Out.Tail = await ModeTail();
|
|
if (MODE === 'list') Out.List = await ModeList();
|
|
if (MODE === 'paths' || MODE === 'all') Out.Paths = await ModePaths();
|
|
if (MODE === 'cors' || MODE === 'all') Out.Cors = await ModeCors();
|
|
fs.writeFileSync(OUT, JSON.stringify(Out, (K, V) => (K === 'Body' ? undefined : V), 2), 'utf8');
|
|
console.log('结果落盘: ' + OUT);
|
|
})();
|