简介:面向信息安全课程设计场景,基于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 type=module npm install express npm install -D typescript tsx @types/node @types/express npx tsc --init把package.json里的type设为module,是为了让编译产物走 ESM 而不是在 CommonJS 和 ESM 之间来回搬移。依赖很少:运行时只有 Express,TypeScript 相关的包全部进 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, ): Promise<PortProbe> { 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, ): Promise<PortProbe[]> { 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 默认通常是 1024,macOS 默认 256。并发开 500 时,上游路由器和中间防火墙也可能先开始丢包。建议先跑一次ulimit -n看硬上限,再按百分比预留。服务端把这张参数表直接暴露成任务配置的默认值:
| 参数 | 默认值 | 适用场景 |
|---|---|---|
| concurrency | 200 | 单主机常用端口范围扫描 |
| timeoutMs | 2000 | 内网链路抖动较小时 |
| 单任务端口上限 | 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, ): Promise<HttpFingerprint | 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: Array<PortProbe | HttpFingerprint>; errorMessage?: string; } export const taskStore = new Map<string, ScanTask>();API 端点的设计按 REST 风格收敛成三个:
| 方法 | 路径 | 入参 | 出参 |
|---|---|---|---|
| POST | /api/v1/tasks | { host, ports, concurrency } | 202 + taskId |
| GET | /api/v1/tasks/:id | 路径参数 | 任务状态与结果 |
| DELETE | /api/v1/tasks/:id | 路径参数 | 204 |
POST 返回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, ): Promise<ScanTask> { 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): Promise<void> { 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 接口轮询时能看到半成品,这在长耗时任务里比一次性返回更友好。
指纹识别和端口探测的结果结构不同,放在同一个数组里需要用类型区分。目前的Array<PortProbe | 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 Set<string>( 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 到 /25 | 256 - 1024 | 默认放行 |
| /16 到 /21 | 4096 - 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('<html><head><title>unit test</title></head></html>'); }); await new Promise<void>((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 Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())), ); });测试覆盖了三件事:probePort对存活端口返回 open;fingerprintHttp从响应头解析出 Server 和 title;HTTP 服务关闭后端口真正释放,不影响下一次测试执行。其中server.listen(0)是 Node 提供的动态端口分配机制,比硬编码 8080 可靠得多;server.close的回调要等待所有活动连接结束,注意别把断言写在 close 之前,否则会出现端口还没释放就进入下一个用例的情况。
最后把测试命令挂进 npm scripts,package.json里加一行"test": "node --import tsx --test test/*.test.ts"。跑一次npm test,看到pass N fail 0,这套 TypeScript 网络安全检测服务端的主链路就算闭环了。
本文还有配套的精品资源,点击获取