GravityAgent(三):用 Rust 构建 IPv6 扫描执行引擎
IPv6 网络测绘 GravityAgent Rust 扫描器
用 Rust 构建 IPv6 扫描执行引擎
上一篇讲完算法适配层,这一篇我们下探到第一层——由 Rust 编写的 gravity-scanner。它是整个平台的基础设施,负责批次生命周期和扫描数据落库。
为什么扫描引擎要用 Rust
扫描引擎的核心诉求是高并发原始包收发。它需要:
- 直接构造并发送 TCP SYN / ICMP / UDP 原始包;
- 在极短时间内向海量地址发包;
- 同时接收并验证响应;
- 把结果高效写回数据库。
Rust 在这里的优势很直接:零成本抽象、无 GC、内存安全,配合 Tokio 异步运行时和 pnet 原始套接字库,可以在保证安全的前提下把发包性能拉满。
双角色架构
gravity-scanner 有两个子命令:
| 角色 | 说明 |
|---|---|
| Master 节点 | 管理批次任务、调度分发、结果汇聚、提供 REST API 和 Web UI |
| Agent 节点 | 从 Master 拉取任务、执行扫描、提交结果,可独立部署也可以作为内置进程随 Master 自动启动 |
启动 Master 时,默认会自动拉起一个内置 Agent,连接本地 Master:
// main.rs:Master 启动后自动拉起内置 Agent
if !no_agent {
let agent_server = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
engine::run_agent(agent_server, concurrency, source_ip).await;
});
}
如果只想跑 HTTP 服务,可以加 --no-agent:
# 基本启动(内置 Agent 自动随 Master 启动)
sudo ./target/release/gravity-scanner master \
--bind 0.0.0.0:3000 \
--db-url postgres://gravity:gravity@127.0.0.1:5432/gravity_scanner
# 仅运行 Master HTTP 服务(不启动内置 Agent)
sudo ./target/release/gravity-scanner master --no-agent
# 手动指定出口 IP(避免多网卡自动检测错误)
sudo ./target/release/gravity-scanner master --source-ip 203.0.113.1
双层存储设计
这是整个扫描系统最重要的架构决策:元数据与结果分离。
| 数据库 | 用途 | 位置 |
|---|---|---|
| PostgreSQL 主库 | 批次元数据、任务调度、result_spool 缓冲 | postgres://gravity:gravity@127.0.0.1:5432/gravity_scanner |
| 批次 SQLite 库 | 每批次的扫描结果明细(独立文件,便于归档) | data/batch_<id>.db |
在代码里,Database 结构同时持有 PostgreSQL 连接池和按批次缓存的 SQLite 连接池:
pub struct Database {
master_pool: PgPool,
batch_pools: DashMap<String, SqlitePool>, // 按 batch_id 缓存
}
为什么要这么设计?
- PostgreSQL 负责高并发调度。任务租约、结果暂存这些高频写入操作放在 PostgreSQL,天然支持并发和事务。
- SQLite 负责结果归档。每个批次一个文件,扫描完可以直接打包、迁移、删除,不会让主库无限膨胀。
- 结果导出简单。导出某个批次的结果,只要读对应的 SQLite 文件即可。
任务租约模型
分布式扫描的核心问题是:如何把海量目标分发给多个 Agent,并保证不丢不重。
gravity-scanner 采用租约(lease)模型:
- Agent 通过长轮询
GET /api/agent/task拉取任务; - 租约有效期
LEASE_SECONDS = 600(10 分钟); - 结果积压时施加背压:
pending + leased result_spool >= 8192时 sleep 200ms 重试; - 优先租约
preferred_batches(Agent 上次服务的批次),减少跨批次抖动。
一次租约如何填充任务
lease_from_batch 会在同一个 lease_id 下依次尝试五种来源,直到填满 max_targets:
① 租单 IP 任务(离散目标)
② 续租过期 IPv4 子区间(start_ip 前移,插入新子区间行)
③ 续租过期 IPv6 区间
④ 新取 IPv4 区间(推进 next_ip 游标)
⑤ 新取 IPv6 区间
这个顺序的设计意图是:优先复用已分配但未完成的任务,避免重复扫描;再推进新地址空间。
IPv6 游标机制
IPv6 地址以 32 字符十六进制字符串存储起止地址(完整 128 位),任务拉取时用游标分批推进:
任务范围:[start_hex, end_hex]
游标初始 = start_hex
每次拉取返回游标所指向的地址段(最多 agent_pull_max_targets 个)
游标推进到下一位置,直到到达 end_hex 或哨兵值 "zzzz...zzzz"
用十六进制字符串做游标,避免了 128 位整数在数据库和语言间的精度问题。
无状态 Cookie 探测
为了避免维护大量连接状态,引擎采用无状态 Cookie 验证:
tcp_cookie = hash(COOKIE_SALT ‖ nonce ‖ ip) & 0xFFFF_FFFF
发送时:
TCP SYN 的 Sequence Number = tcp_cookie
收到 SYN-ACK 时验证:
received_ack_number == tcp_cookie.wrapping_add(1) ✓
ICMP 的 identifier/sequence 字段、UDP 的源端口均从同一 cookie 派生,确保无状态验证的一致性。
这意味着引擎不需要为每个探测保存一个”待响应表”,只需要在收到包时重新计算 cookie 并比对,内存占用与在途探测数无关。
多种探测协议
Agent 对每个目标 IP 并发发送多种探测包,任意一种有响应即判定存活:
| 探测类型 | 说明 | 存活判定 |
|---|---|---|
| TCP SYN | 向端口 22/80/443 发送 SYN 包 | 收到 SYN-ACK,且 ACK 序号满足 cookie 验证 |
| ICMP Echo | 发送 ICMPv4/ICMPv6 Echo Request | 收到 Echo Reply,identifier/sequence 匹配 |
| UDP/53 | 发送 DNS 查询包 | 收到 ICMP Port Unreachable(反向证明主机存活) |
| UDP/443 | 发送 UDP 探测包至 443 端口 | 收到 ICMP Port Unreachable |
| SSH Banner | TCP 连接后读取 SSH Banner(可选 fallback) | 读到以 SSH- 开头的 Banner |
存活原因有优先级,save_results_batch 按 IP 聚合时会择优记录:
ssh_banner (4) > tcp_syn (3) > icmp (2) > udp (1) > 其他 (0)
择优规则是:latency 最小 → 同 latency 比 reason 优先级 → 再同比端口小。端口级观测全部保留在 service_observations 表,供结果导出使用。
多线程并发包发送
┌──────────────────────────────┐
│ run_task_loop │
│ (拉取任务 → 切分 chunk) │
└──────────────┬───────────────┘
│ 每 chunk 最多 8192 个目标
┌──────────────▼───────────────┐
│ burst sender 线程池 │
│ (默认 = CPU 核心数,最多 32) │
│ IPv4 TCP/UDP/ICMP sender │
│ IPv6 TCP/UDP/ICMP sender │
└──────────────────────────────┘
- 每个 sender 线程对应独立的原始传输通道(
pnet::transport_channel,16 MB 缓冲区); - 发送速率由
GRAVITY_BURST_SENDER_THREADS控制; - 发包后等待”结果稳定”(
result_settle_idle_ms内无新响应)再提交,避免丢失末尾响应。
自适应端口优先级
当启用 GRAVITY_ENABLE_APP_CONNECT_FALLBACK 时,引擎会基于历史响应率对每个组织(ASN)动态调整探测端口顺序:
fn score(&self, port: u16) -> f64 {
let (hits, attempts) = ...; // 来自 OrgProbeStats
(hits + 1.0) / (attempts + 3.0) // Laplace 平滑
}
用 Laplace 平滑的好处是:即使某个端口还没有历史数据,也不会被”饿死”,而是以中性分数参与排序。随着数据积累,响应率高的端口会自然排到前面。
result_spool:异步缓冲写入
这是扫描系统里另一个关键设计。如果 Agent 每提交一条结果就直接写 SQLite,会造成严重的锁竞争(SQLite 是单写者模型)。
所以系统引入了双阶段写入:
Agent 提交结果
│
▼
result_spool 表(PostgreSQL,高并发友好)
│ 每 500ms 轮询一次
▼
Result Worker(后台 Tokio 任务,N 个并行)
│ 批量租约 → ACK 任务 → 批量写入 SQLite → 释放租约
▼
batch_<id>.db / scan_results 表
│
▼
reconcile_alive_targets(更新 batches 表 alive_targets 计数)
关键参数:
| 变量 | 默认值 | 说明 |
|---|---|---|
GRAVITY_RESULT_QUEUE_SIZE | 16,384 | 结果通知队列容量 |
GRAVITY_RESULT_SPOOL_LEASE_BATCH | 128 | 每次租约处理的条数 |
GRAVITY_RESULT_SPOOL_POLL_MS | 500 ms | Worker 轮询间隔 |
GRAVITY_RESULT_SPOOL_LEASE_SECONDS | 300 s | 租约超时(用于故障重试) |
失败回滚时,spool 行会被置回 pending 并记录 last_error,带指数退避以抗 SQLite 锁。
HTTP API 设计
Master 暴露的 REST API 非常精简,核心路由只有 10 个:
.route("/", get(index_page))
.route("/api/batch", post(upload_batch).get(list_batches))
.route("/api/batch/:id/export", get(export_batch_results))
.route("/api/batch/:id/export/stream", get(stream_batch_results))
.route("/api/batch/:id/txt", get(download_batch_txt))
.route("/api/agent/task", get(pull_task))
.route("/api/agent/result", post(submit_result))
.route("/api/agent/heartbeat", post(heartbeat))
.route("/api/stats", get(get_stats))
.route("/api/stop", post(stop_all_tasks))
其中 /api/batch/:id/txt 特别有用——它直接导出批次的存活地址文本,正好是 Agent 做下一轮种子挖掘的输入格式。
认证与权限
所有需要认证的接口采用 HTTP Basic Auth,支持三类凭据:
| 类型 | 说明 |
|---|---|
| 管理员 | 启动时通过 --username / --password 指定 |
| 内置实时账号 | realtime / realtime(固定,供内置 Agent 使用) |
| 普通用户 | 通过 --user alice:pass1 在启动时批量注册 |
管理员与普通用户的区别:
| 权限 | Admin | Regular |
|---|---|---|
| 批次提交上传大小上限 | 10 GB | 500 MB |
| 任务优先级 | 100 | 1 |
| 查看所有批次 | ✅ | ❌(只能看自己提交的) |
目标地址上传格式
POST /api/batch 接受混合格式,每行一条:
1.2.3.4 # 单 IPv4 地址
2001:db8::1 # 单 IPv6 地址
10.0.0.1-10.0.0.255 # IPv4 范围
2001:db8::1-2001:db8::ffff # IPv6 范围
192.168.0.0/24 # IPv4 CIDR
2001:db8::/48 # IPv6 CIDR
# 注释行(以 # 开头)
支持的表单参数:
| 参数 | 说明 | 默认值 |
|---|---|---|
batch_name | 批次名称 | 自动生成 |
ports | 自定义扫描端口(逗号或换行分隔,支持范围,最多 64 个) | 空(仅默认端口) |
icmp | 是否启用 ICMP 探测 | false |
parallel_probes | 是否并发发送所有探测类型 | false |
端口解析由 models.rs 中的 parse_scan_ports_spec 完成,支持去重、乱序区间和上限校验。
数据模型
批次(Batch)
pub struct Batch {
pub id: String, // UUID
pub name: String,
pub status: BatchStatus, // Running | Completed | Stopped | Failed
pub total_targets: i64,
pub scanned_targets: i64,
pub alive_targets: i64,
pub created_at: NaiveDateTime,
pub submitted_by: String,
pub priority: i64,
pub scan_ports: Option<String>, // CSV 格式端口列表
pub scan_icmp: bool,
pub scan_parallel_probes: bool,
pub pending_tasks: i64,
pub leased_tasks: i64,
}
扫描结果(ScanResult)
pub struct ScanResult {
pub id: i64,
pub batch_id: String,
pub agent_id: String,
pub ip: String,
pub port: u16,
pub latency_ms: i64,
pub alive_reason: String, // "tcp_syn" | "icmp" | "udp" | "ssh_banner"
pub org: String, // 组织归属(启用 org_lookup 时填充)
pub created_at: NaiveDateTime,
}
PostgreSQL 主库核心表
-- 批次元数据
batches (id, name, status, total_targets, scanned_targets, alive_targets,
created_at, submitted_by, priority, scan_ports, scan_icmp, scan_parallel_probes)
-- 任务调度(IPv4 范围分段)
scan_tasks (...)
-- IPv4/IPv6 区间游标
scan_task_ranges (next_ip ...)
scan_task_ipv6_ranges (next_ip ...)
-- 区间租约
scan_range_leases (...)
scan_ipv6_range_leases (...)
-- 结果写入缓冲
result_spool (id, batch_id, agent_id, payload_json, lease_owner, lease_until, status)
Agent 心跳
Agent 会定期上报运行状态:
pub struct AgentHeartbeat {
pub agent_id: String,
pub hostname: String,
pub public_ip: String,
pub os: String,
pub cpu_usage: f32,
pub ram_usage: f32,
pub pps: u64, // 当前每秒发包数
pub last_seen: i64, // Unix 时间戳
}
Master 通过 GET /api/stats 汇总在线 Agent 数量、批次统计和结果队列水位,方便运维监控。
小结
gravity-scanner 的设计可以总结为几个关键词:
| 设计点 | 解决的问题 |
|---|---|
| 双角色(Master/Agent) | 水平扩展扫描能力 |
| 双层存储(PG + SQLite) | 调度与归档分离 |
| 租约模型 | 分布式任务不丢不重 |
| 无状态 Cookie | 无需维护连接状态 |
| 多协议探测 | 提高存活判定率 |
| result_spool | 避免 SQLite 写锁竞争 |
| 自适应端口 | 按组织优化探测顺序 |
| IPv6 十六进制游标 | 规避 128 位精度问题 |
到这里,底层的”怎么扫”就讲清楚了。下一篇,我们回到 Python 侧,看看 os_identify 子系统是如何用原始套接字和 nmap 指纹库做操作系统识别的。
订阅本站
通过 RSS 或邮箱,第一时间收到新文章。