ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于TypeScript的网络安全检测服务端:端口扫描与指纹识别实践

基于TypeScript的网络安全检测服务端:端口扫描与指纹识别实践 简介面向信息安全课程设计场景基于TypeScript实现的网络安全检测系统服务端源码适合计算机相关专业学生、教师及企业开发者用于课程设计、毕业设计或项目初期演示。资源共63个文件以TypeScript源码为主辅以JSON配置、JavaScript脚本、XML与CSS样式等整体压缩包仅241KB轻量且结构清晰。服务端按模块化组织包含dao、service、router、filter、util等核心目录并提供了web、redis、security、database等配置文件便于理解网络安全检测的后端分层架构与权限过滤逻辑。已有587人学习下载代码经过运行验证可直接部署使用也可在此基础之上扩展检测规则或对接前端页面是学习TypeScript服务端开发与网络安全系统实现的实用参考。1. 用 TypeScript 写网络安全检测服务端先解决“能扫什么”和“该收敛什么”很多人一提起网络安全检测系统脑子里最先跳出来的是 Nmap 那一类命令行扫描器可一旦把它做成课程设计或内网巡检的交付物缺的往往不是“更快的扫描算法”而是一个能把目标展开、端口探活、指纹识别串起来并且通过 HTTP 接口对外暴露检测能力的服务端。这一层用 TypeScript 来做最大的收益是编译期就把端口范围、IP 网段、任务状态这些容易手误的数据结构钉死不用等运行时才暴露问题。本文要搭的服务端不是重造扫描轮子而是把检测能力服务化输入可以是单个 IP也可以是 CIDR 网段输出是结构化端口状态、HTTP 服务特征和任务进度。整个过程围绕四个工程问题展开并发和超时怎么控、细粒度结果怎么收、任务生命周期怎么管、边界条件怎么防止把扫描器变成事故源头。检测只讨论授权资产这一点在服务端参数里就要预设执行而不是靠命令行参数劝告。2. 服务端骨架与 TCP 端口探活net 模块的并发和超时怎么调2.1 项目初始化和 tsconfig 里值得多看一眼的开关先按一个最小可跑的服务端来初始化。终端执行下面这组命令在当前目录得到一个 Node.js TypeScript 项目mkdir security-scout cd security-scout npm init -y npm pkg set typemodule npm install express npm install -D typescript tsx types/node types/express npx tsc --init把package.json里的type设为module是为了让编译产物走 ESM 而不是在 CommonJS 和 ESM 之间来回搬移。依赖很少运行时只有 ExpressTypeScript 相关的包全部进 devDependencies端口探活用的是 Node 内置net不需要任何第三方扫描库。npx tsc --init生成的 tsconfig 太宽松我会直接在compilerOptions里覆盖这几个关键项{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, noUncheckedIndexedAccess: true, outDir: dist, rootDir: src, sourceMap: true } }这里有两个开关值得单独说明。moduleResolution: NodeNext配合type: module后TypeScript 源码里的相对导入必须带.js后缀例如import { probePort } from ./scanner/transport.js否则编译能过、运行时直接抛ERR_MODULE_NOT_FOUND——这是 ESM 项目最常见的首坑越早习惯越好。noUncheckedIndexedAccess会让arr[i]的类型带上| undefined扫描结果数组在遍历时避免隐式空值落入后续 JSON 序列化。目录结构按检测链路的职责拆成三块scanner放协议探测iprange放网段解析task放任务编排。入口文件只负责启动服务。项目里第一版先不接数据库任务和结果放在进程内存跑通后再决定要不要换 Redis 或 SQLite。2.2 用 net 模块实现带超时的 TCP 端口探活端口探活本质是一次 TCP 连接的建立尝试。“连接成功”视为端口开放“连接被拒、超时、域名解析失败”都归为不开放。Node 的net.Socket提供了全部事件不需要引入 raw socket 或者第三方包。// src/scanner/transport.ts import net from node:net; export interface PortProbe { port: number; open: boolean; latencyMs: number; } export function probePort( host: string, port: number, timeoutMs 2000, ): PromisePortProbe { return new Promise((resolve) { const started Date.now(); const socket new net.Socket(); let done false; const settle (open: boolean) { if (done) return; done true; socket.destroy(); resolve({ port, open, latencyMs: Date.now() - started }); }; socket.setTimeout(timeoutMs); socket.once(connect, () settle(true)); socket.once(timeout, () settle(false)); socket.once(error, () settle(false)); socket.connect(port, host); }); }参数说明timeoutMs是 socket 从建立到等待事件的超时上限建议与业务方约定一个值跨运营商的公网探测一般给 3000内网巡检给 1500 就够。settle函数用done标志位保证 Promise 只 resolve 一次避免 timeout 和 error 先后触发导致二次 resolve。socket.destroy()放在 resolve 之前执行确保文件描述符及时回收。实际使用中连接被拒的错误事件往往在几十毫秒内返回而超时则要等到timeoutMs结束。两者统一标记为open: false只保留latencyMs作为区分这个延迟值后续可以用来粗略判断目标是否做了丢包或限速。2.3 并发控制扫描几百个端口时不打爆本机连接数逐个端口串行探测太慢怎么并发又不把本机打挂是这里唯一值得讨论的工程问题。常见做法是维护一个共享游标由 N 个 worker 各自循环取下一个端口而不是为每个端口注册一段setTimeout。export async function scanPorts( host: string, ports: number[], concurrency 200, timeoutMs 2000, ): PromisePortProbe[] { const results: PortProbe[] []; let cursor 0; const worker async () { while (cursor ports.length) { const portNo ports[cursor]; if (portNo undefined) break; cursor 1; results.push(await probePort(host, portNo, timeoutMs)); } }; await Promise.all(Array.from({ length: concurrency }, () worker())); return results; }说明cursor的递增发生在同一个微任务队列里await probePort会把执行权让出去因此读取和递增游标不会出现真正意义上的数据竞争加上noUncheckedIndexedAccess后ports[cursor]被识别为可能为undefined所以代码里先判空再自增顺手规避了越界问题。Array.from生成concurrency个 worker每个 worker 不是一个拦截器只专注于一件事取端口、探测、记录结果。并发数不是越大越好。本机连接数受ulimit -n限制Linux 默认通常是 1024macOS 默认 256。并发开 500 时上游路由器和中间防火墙也可能先开始丢包。建议先跑一次ulimit -n看硬上限再按百分比预留。服务端把这张参数表直接暴露成任务配置的默认值参数默认值适用场景concurrency200单主机常用端口范围扫描timeoutMs2000内网链路抖动较小时单任务端口上限1024防止一次提交全端口 TOP 太多端口列表本身也要在服务端做一次白名单校验只允许1-65535之间且去重后的数组通过避免重复端口让游标空转。3. HTTP 指纹识别与任务 API从端口探测结果到资产画像3.1 指纹识别的请求构造与响应头解析TCP 端口开着只能说明有进程在监听不能说明它是什么。下一步是向端口发送一次最小化 HTTP 请求从响应头和服务端首页提取特征。这种识别方式属于软识别用合法的 GET 请求做探测比构造异常报文更稳健对目标设备也友好。// src/scanner/web.ts export interface HttpFingerprint { port: number; statusCode: number; serverHeader?: string; poweredBy?: string; pageTitle?: string; finalUrl?: string; } export async function fingerprintHttp( host: string, port: number, timeoutMs 3000, ): PromiseHttpFingerprint | null { try { const response await fetch(http://${host}:${port}/, { headers: { User-Agent: Mozilla/5.0 security-scout, Accept: text/html,*/*, }, redirect: follow, signal: AbortSignal.timeout(timeoutMs), }); const total Number(response.headers.get(content-length) ?? 0); if (total 200 * 1024) return null; const body await response.text(); const title body .match(/title[^]*([^]{1,120})\/title/i)?.[1] ?.trim(); return { port, statusCode: response.status, serverHeader: response.headers.get(server) ?? undefined, poweredBy: response.headers.get(x-powered-by) ?? undefined, pageTitle: title, finalUrl: response.url, }; } catch { return null; } }这段逻辑里有几个地方要说明。content-length超过 200KB 时直接丢弃避免把大文件服务器当成网站首页抓取节省内存。pageTitle的正则只截取title标签内前 120 个字符防止超长标题污染结果。重定向跟随用的是 fetch 默认行为但finalUrl会记录最终地址方便判断目标是否做了 302 跳转。返回null的情况包括连接被断、超时、返回非 HTTP 协议不区分具体原因在汇总阶段统一过滤掉即可。Node 18 之后内置的 fetch 对这类探测已经够用不需要引入 axios。如果目标端口是 HTTPS上述代码不能直接复用常见做法是保留一个 TLS 握手分支把http://换成https://并关闭证书校验但在重定向和超时逻辑上保持一致。3.2 任务模型与最小 API 接口集服务端的核心不是扫描器而是任务管理。一次检测提交进来后需要被记录、被执行、被查询最后还能被回收。内存里用一个Map存任务比数组的find快且语义清晰。任务接口定义如下// src/task/task.ts import type { PortProbe } from ../scanner/transport.js; import type { HttpFingerprint } from ../scanner/web.js; export type TaskStatus pending | running | done | failed; export interface ScanTask { id: string; host: string; ports: number[]; concurrency: number; status: TaskStatus; createdAt: number; startedAt?: number; results: ArrayPortProbe | HttpFingerprint; errorMessage?: string; } export const taskStore new Mapstring, ScanTask();API 端点的设计按 REST 风格收敛成三个方法路径入参出参POST/api/v1/tasks{ host, ports, concurrency }202 taskIdGET/api/v1/tasks/:id路径参数任务状态与结果DELETE/api/v1/tasks/:id路径参数204POST 返回202 Accepted而不是 200表示任务已受理并进入队列实际执行异步进行。GET 接口返回完整ScanTask对象检测方通过轮询status字段判断是否done。DELETE 的用途是显式回收内存课程设计阶段可以简单从 Map 中删除生产环境则要考虑删除后仍被外部引用的任务快照。// src/index.ts import express from express; import { createScanTask, getTask } from ./task/task.js; const app express(); app.use(express.json()); app.post(/api/v1/tasks, async (req, res) { try { const { host, ports, concurrency } req.body; const task await createScanTask(host, ports, concurrency); res.status(202).json({ taskId: task.id }); } catch (err) { res.status(400).json({ error: (err as Error).message }); } }); app.get(/api/v1/tasks/:id, (req, res) { const task getTask(req.params.id); if (!task) return res.status(404).json({ error: task not found }); res.json(task); }); app.listen(3000);express.json()只负责解析 JSON 请求体对非法请求体会直接抛出异常这里通过外层 try/catch 转成 400 响应。getTask内部先判断 Map 里是否存在 key不存在时返回undefined路由层据此回 404。路由里不要直接对 Map 做set把任务创建逻辑收口到createScanTask里方便统一做参数校验和状态变更。3.3 任务状态机从 pending 到 done 的推进路径任务生命周期不复杂但每一步状态更新都要有明确的触发点否则排查时会出现“任务永远停在 running”的假象。export async function createScanTask( host: string, ports: number[], concurrency: number, ): PromiseScanTask { if (!host || !/^[\w.-]$/.test(host)) { throw new Error(invalid host); } if (!Array.isArray(ports) || ports.length 0 || ports.length 1024) { throw new Error(ports must be an array of 1-1024 entries); } if (concurrency 1 || concurrency 512) { throw new Error(concurrency must be between 1 and 512); } const task: ScanTask { id: crypto.randomUUID(), host, ports: Array.from(new Set(ports)).sort((a, b) a - b), concurrency, status: pending, createdAt: Date.now(), results: [], }; taskStore.set(task.id, task); runTask(task).catch((err) { task.status failed; task.errorMessage (err as Error).message; }); return task; } async function runTask(task: ScanTask): Promisevoid { task.status running; task.startedAt Date.now(); const portResults await scanPorts( task.host, task.ports, task.concurrency, ); task.results.push(...portResults.filter((r) r.open)); task.status done; }这段代码把两个容易踩的坑提前处理掉了。一是端口列表去重并排序重复端口会导致扫描器做无效连接排序则让结果输出顺序可预期。二是runTask的 catch 放在外面scanPorts自身不会抛异常但未来如果加入 HTTP 指纹采集总链路里任何一步出错任务都会被标记为failed而不是永远停在 running。task.results在 done 之前持续累积外部 GET 接口轮询时能看到半成品这在长耗时任务里比一次性返回更友好。指纹识别和端口探测的结果结构不同放在同一个数组里需要用类型区分。目前的ArrayPortProbe | HttpFingerprint在消费端都要做类型收窄例如判断result是否具有statusCode字段这也是 TypeScript 里联合类型常见的用法在任务结果序列化时会逐渐体现出来。4. CIDR 展开与数据清洗flatMap、Set 去重背后的边界判断4.1 IPv4 网段展开的外墙逻辑上一章的任务 API 只接受单一 host实际检测请求常常拿到的是一段 C 段甚至更大。CIDR 展开要做的是把192.168.1.16/28这类输入转成具体的 IP 列表。这里第一步不是写循环而是先校验掩码和地址的合法性因为/8的输入有 1677 万个地址直接展开能把内存撑爆。// src/iprange/cidr.ts export function cidrToHosts(cidr: string): string[] { const [base, maskText] cidr.split(/); const mask Number(maskText); if (!Number.isInteger(mask) || mask 1 || mask 32) { throw new Error(invalid netmask: ${maskText}); } const octets base.split(.).map(Number); if ( octets.length ! 4 || octets.some((o) !Number.isInteger(o) || o 0 || o 255) ) { throw new Error(invalid ip: ${base}); } const start (((octets[0]! 24) 0) ((octets[1]! 16) 0) ((octets[2]! 8) 0) octets[3]!) 0; const count Math.pow(2, 32 - mask); const end start count; const hosts: string[] []; for (let v start; v end; v) { hosts.push( [ (v 24) 0xff, (v 16) 0xff, (v 8) 0xff, v 0xff, ].join(.), ); } return hosts; }掩码的下限定为 1不允许/0因为Math.pow(2, 32)超出 32 位整数范围理论上会变成 42 亿个地址的数组。位运算里对超过 32 位的数会截断所以每段位移后都要用 0转回无符号整数。循环结束条件v end用的是严格小于保证网络号本身也被算进去/30这样有 2 个可用地址的掩码展开结果包含全部 4 个地址不做“去掉网络号和广播地址”的运算/31这种特殊掩码在这里也不做特殊处理。4.2 多网段合并时的数组去重与类型推导实际任务提交时经常是多个来源拼接主站 IP、几条 CIDR、可能还有几个域名解析结果。合并去重是典型的 array 处理场景用flatMap配合Set可以一次搞定。type TargetEntry | { type: ip; value: string } | { type: cidr; value: string }; function expandTargets(entries: TargetEntry[], maxHostsPerCidr 1024): string[] { return Array.from( new Setstring( entries.flatMap((entry): string[] { if (entry.type cidr) { return expandCidr(entry.value, maxHostsPerCidr); } return [entry.value]; }), ), ); }这里有一个 TypeScript 特有的坑值得单独提醒。flatMap回调如果两个分支返回不同类型的数组例如一个返回string[]一个返回string推导结果会变成never[]或者(string | never)[]看上去编译能过实际Set里的元素类型已经不对。解决方式就是上面代码里的显式返回标注: string[]两个分支都写成数组形态。展开后的 IP 顺序依赖Set的插入顺序也就是输入条目顺序。这个特性在并发调度时有用它可以保证同一个网段的地址在任务队列里挨在一起减少路由表跳变。如果希望进一步控制检测节奏可以在expandTargets之后按Math.floor(hostIndex / 256)做分组每组一次线程池调度。expandCidr的maxHosts参数是硬边界。常见的服务端约定是网段展开后超过上限直接报错而不是截断前 N 个地址。因为截断会给人“我只扫了这段地址”的错觉实际上漏掉了后半段。建议按下面的表控制边界掩码范围展开地址数是否允许/24 及更小≤ 256直接通过/22 到 /25256 - 1024默认放行/16 到 /214096 - 65536需要显式增大 maxHosts/15 及以上 131072拒绝按批次拆开提交4.3 目标合法性过滤与扫描结果整体的超时兜底有了 IP 列表不等于可以直接开扫。服务端必须内置一层目标过滤。常见做法是维护一个可以被配置文件覆盖的“授权网段”列表任何输入目标必须落在授权段内否则拒绝任务。这里给一个便于课程设计演示的简化版本。// src/iprange/policy.ts const AUTHORIZED: string[] [10.0.0.0/8, 172.16.0.0/12]; export function assertAuthorized(host: string): void { const octets host.split(.).map(Number); if (octets.length ! 4) throw new Error(invalid ip: ${host}); const inRange AUTHORIZED.some((net) { const [base, mask] net.split(/); const maskInt Number(mask); const baseInt ipToInt(base); const hostInt ipToInt(host); const shift 32 - maskInt; return (baseInt shift) (hostInt shift); }); if (!inRange) throw new Error(host ${host} not in authorized range); }具体网段和策略应当按实际环境配置这里的重点是判断方式。注意ipToInt需要复用 CIDR 展开里的同类函数建议把位运算部分抽成公共方法避免在 policy 和 cidr 两个文件里各写一遍导致逻辑漂移。超时兜底是另一个容易被忽略的边界。端口扫描即使每个连接都设置了超时整批任务也可能因为队列积压拖很久。服务端应在任务启动时计算一个全局截止时间超出后停止分配新端口。改造scanPorts的 worker 循环const deadline Date.now() Math.max(ports.length * 100, 30_000); while (cursor ports.length) { if (Date.now() deadline) { break; } // 原有探测逻辑 }作用是控制单任务的运行时间上界。100 毫秒每个端口只是粗略估算实际可以根据历史任务的平均延迟动态调整但先给出一个固定倍数比没有要好。5. 用 node:test 做服务端自测兼顾导入路径和临时端口最后一层工作是把前面几个模块固定成可回归的测试。Node 18 以上内置了node:test和node:assert/strict不需要额外引入 Jest 这类框架。TypeScript 源码要直接跑测试我一般用tsx作为加载器而不是先编译再跑这样开发时每改一行都能立刻反馈。npm install -D tsx node --import tsx --test test/*.test.ts如果 shell 的 glob 没有展开部分 Windows 环境可以替换成node --import tsx --test test/node:test 会递归发现测试目录下的.test.ts文件。在这之前还有一道坎ESM 下相对导入必须带.js后缀即使文件本身是.ts。// test/cidr.test.ts import test from node:test; import assert from node:assert/strict; import { cidrToHosts } from ../src/iprange/cidr.js; test(expand 192.168.1.4/30 returns 4 hosts, () { const hosts cidrToHosts(192.168.1.4/30); assert.deepEqual(hosts, [ 192.168.1.4, 192.168.1.5, 192.168.1.6, 192.168.1.7, ]); }); test(expand with invalid mask should throw, () { assert.throws(() cidrToHosts(192.168.1.0/33)); });端口探测和指纹识别需要真实目标不能在测试里凭空 mock。做法是在测试进程里临时起一个 HTTP 服务监听0端口让系统自动分配空闲端口测试跑完再关闭。这一步的好处是测试不依赖某个固定端口不会和本机已有服务冲突。import { createServer, type AddressInfo } from node:http; import { probePort } from ../src/scanner/transport.js; import { fingerprintHttp } from ../src/scanner/web.js; test(probe an alive local http port, async () { const server createServer((req, res) { res.setHeader(Server, TestServer/1.0); res.setHeader(X-Powered-By, UnitExpress); res.end(htmlheadtitleunit test/title/head/html); }); await new Promisevoid((resolve) server.listen(0, resolve)); const port (server.address() as AddressInfo).port; const probe await probePort(127.0.0.1, port, 1000); assert.equal(probe.open, true); const fp await fingerprintHttp(127.0.0.1, port); assert.equal(fp?.serverHeader, TestServer/1.0); assert.equal(fp?.pageTitle, unit test); await new Promisevoid((resolve, reject) server.close((err) (err ? reject(err) : resolve())), ); });测试覆盖了三件事probePort对存活端口返回 openfingerprintHttp从响应头解析出 Server 和 titleHTTP 服务关闭后端口真正释放不影响下一次测试执行。其中server.listen(0)是 Node 提供的动态端口分配机制比硬编码 8080 可靠得多server.close的回调要等待所有活动连接结束注意别把断言写在 close 之前否则会出现端口还没释放就进入下一个用例的情况。最后把测试命令挂进 npm scriptspackage.json里加一行test: node --import tsx --test test/*.test.ts。跑一次npm test看到pass N fail 0这套 TypeScript 网络安全检测服务端的主链路就算闭环了。本文还有配套的精品资源点击获取
返回列表