theHarvester HarvestView 私有控制平面:用本地 SQLite 调度有限运行的架构解析(ADR-0006)
【免费下载链接】theHarvesterE-mails, subdomains and names Harvester - OSINT项目地址: https://gitcode.com/GitHub_Trending/th/theHarvester
导读
本文围绕 theHarvester 仓库中的架构决策记录 ADR-0006:Schedule finite runs through a private control plane,深入剖析 HarvestView 如何用一个与证据分离的本地 SQLite 控制平面来存储授权目标清单、递归策略、重叠策略、调度声明与派发预留,并通过既有持久队列与单一 worker 提交普通有限运行。读完本文,你将掌握调度 API 的完整用法、日历递归的时区语义、skip/queue 重叠策略的行为差异,以及控制平面与可移植证据为何必须分离的底层原理。
一、决策背景:为什么需要一个"私有控制平面"
在引入调度能力之前,theHarvester 已经通过 ADR-0003:Keep run records separate with one isolated worker 建立了一条稳定的运行管线:HTTP 应用拥有独立的持久化运行记录,提交即获得稳定 ID 并入队,一个本地 worker 一次认领一个运行,在隔离的子进程中执行有限的枚举核心。生命周期为queued -> running -> completed|failed、queued -> cancelled、running -> cancelling -> cancelled,证据状态(complete、partial、failed)与编排生命周期状态分开报告。
如果直接把这些"调度"数据塞进现有证据模型,会混淆三类性质完全不同的数据:
- 操作者意图:谁被授权执行、按什么频率执行;
- 编排状态:运行是否被认领、是否在取消、worker 租约是否有效;
- 证据质量:最终结果是否完整、部分还是失败。
ADR-0006 的答案是把它们拆开:控制平面(control plane)负责"何时、对谁、按什么策略发起运行",证据平面(data plane)只保存最终的可移植证据。这是一个从源码结构可以清楚看到的边界——控制平面文件theHarvester/lib/api/schedule_store.py与证据存储theHarvester/lib/api/run_store.py是两套独立的 SQLite 引擎、两张独立的数据表集合。
二、决策核心:控制平面存储什么,证据导出不存储什么
ADR-0006 原文明确了控制平面承载五类数据:
- 授权的目标清单(authorized target inventories);
- 递归策略(recurrence policy);
- 重叠策略(overlap policy);
- 声明(claims,即调度器对某次到点 occurrence 的认领租约);
- 派发预留(dispatch reservations,即每个目标对应的稳定 run ID 预留)。
而可移植的完成运行证据导出不包含调度、声明或派发预留。这一点在 Rest-API.md 的导出章节 中再次确认:"The export contains canonical completed evidence and screenshot metadata. It excludes queue state, cancellation state, worker leases, and legacy observations." 也就是说,把完成证据导出到别处做长期归档或交叉比对时,不会把未来的自动化策略、worker 租约这类"编排内幕"一起带出去——证据文件是干净、稳定、可复现的。
从实现看,这一分离体现在两个层面:
- 物理层面:
configured_schedule_database()默认把调度库放在运行库的同名 sibling 路径(例如runs.sqlite对应runs.schedules.sqlite),可用环境变量THEHARVESTER_SCHEDULE_DB覆盖; - 语义层面:调度库的
schedules表与schedule_dispatches表通过 SQLAlchemy 定义在独立的_SCHEDULE_METADATA上(见 schedule_store.py),与运行证据表互不引用(仅run_id作为字符串关联)。
三、复用而非再造:每次 occurrence 都是普通有限运行
ADR-0006 最核心的设计选择是:调度不引入第二条执行路径。每次到点的 occurrence,调度器做两件事:
- 预留稳定的 run ID:在派发前先把
schedule_dispatches表中每个目标的run_id状态置为reserved,此时运行记录尚未创建; - 通过既有队列提交普通有限运行:随后用该
run_id调用RunStore.create()创建运行记录(状态进入queued),并唤醒 worker 认领执行。
对应代码在ScheduleDispatcher._enqueue_targets()(schedule_service.py)中:reserve_dispatches一次性为所有目标生成{target: str(uuid4())}预留;随后对每个预留,RunRequest.model_validate({**template, 'target': target})把调度模板中的target替换为当前目标,再run_store.create(run_request, run_id=run_id)。全部创建完成后调用run_worker.wake_worker()唤醒执行 worker。
这样带来的直接收益正是 ADR 原文所述:生命周期、取消、归属、导出行为全部保持不变。调度的运行与手工提交的运行没有本质区别,取消一条调度产生的运行、查看其来源归属、导出其证据,走的都是同一条已验证的路径,不需要为调度单独维护一套"半吊子"生命周期。
3.1 派发状态机
每个派发记录(dispatch record)有自己的状态镜像,见 schedule_models.py 中的DispatchState:
reserved -> queued -> running -> cancelling -> completed | failed | cancelled调度器的_reconcile_pending()会读取对应运行的真实状态并同步到派发记录上,例如运行进入cancelling时派发记录也镜像为cancelling(测试test_dispatch_mirrors_cancelling_run_state验证了这一点)。这也意味着派发历史是可审计的:每个目标、每次 occurrence 到底有没有被真正创建为运行,在schedule_dispatches表中一目了然。
四、日历递归语义:保留本地墙钟时间,DST 与月末都有明确答案
ADR-0006 原文规定了两条容易出错的语义:
- 日历递归保留所选本地墙钟时间(Calendar recurrence preserves the selected local wall-clock time);
- 当所选日期在当月不存在时,月度调度使用该月最后一天(monthly schedules use the final day when their selected day does not exist)。
这两条语义在ScheduleTiming.next_after()中有精确实现(schedule_models.py):
if self.frequency == 'monthly': ... candidate_day = date(year, month, min(local_start.day, monthrange(year, month)[1]))即用min(所选日, 当月最大天数)把 1 月 31 日的月度调度在 2 月折叠为 2 月 28/29 日。测试test_monthly_schedule_uses_the_final_day_of_short_months给出了具体期望值:2027-01-31T09:00:00-05:00起算的月度调度,2 月落在2027-02-28T14:00:00+00:00,3 月落在2027-03-31T13:00:00+00:00(注意 3 月美东已进入夏令时,UTC 偏移变化但本地 09:00 不变)。
4.1 各频率的时区行为一览
| 频率 | 计时基准 | 关键行为 |
|---|---|---|
daily | 本地墙钟 | 跨 DST 保持本地时刻不变(如美东 09:00 全年不变) |
weekly | 本地墙钟 | 可指定多个 ISO 星期(周一=1…周日=7),未指定时默认采用 start_at 的星期 |
monthly | 本地墙钟 | 所选日不存在时折叠到当月最后一天 |
hourly | 经过的 UTC 小时数 | 按 UTC 整点推进,不受时区/夏令时影响 |
once | 固定时刻 | 仅执行一次,interval必须为 1 |
daily的 DST 处理包含一个细节:_wall_candidate()会在本地时间不存在(如春季 DST 跳变导致的 02:30 不存在)时用resolve_imaginary()向前归一化。测试test_daily_schedule_preserves_local_wall_clock_across_dst与test_recurrence_handles_intervals_dst_and_downtime分别覆盖了秋季回拨与春季跳变两种场景。
4.2 停机后的追赶策略:只补一次,不重放
ADR 原文没有直接写,但 Rest-API.md 与实现都明确:服务停机后恢复时,只派发一个逾期 occurrence,然后递归推进到下一个未来时间,绝不把错过的每个间隔全部重放。实现上这是通过next_future_after(occurrence, now)完成的——它取max(上次 occurrence, 当前时间)作为基准再计算下一个时刻,从而把多次错过的间隔折叠为一次。测试test_recurrence_handles_intervals_dst_and_downtime验证了 hourly 调度停机 15 小时后从2026-08-21T04:00继续而非重放。
五、重叠策略:skip 与 queue 的精确语义
ADR-0006 规定默认重叠策略为skip:当同一调度的前一批次仍处于活动状态(reserved、queued 或 running)时,跳过本次 occurrence。API 文档给出的另一个选项是queue:再提交一个有限批次排在后面。
实现位置在ScheduleDispatcher.dispatch_claimed():
active = await self._reconcile_pending(..., reserved_only=schedule.overlap_policy == 'queue', ...) if schedule.overlap_policy == 'skip' and active: next_run = schedule.timing.next_future_after(scheduled_for) await self.schedule_store.complete_claim(..., error='Occurrence skipped because a prior scheduled batch is still active') return注意reserved_only的差别:skip模式下把reserved/queued/running/cancelling全部视为"活动";queue模式只把尚未创建运行的reserved视为待办,因此已有运行在跑时仍会追加新批次。测试test_due_schedules_skip_or_queue_while_a_prior_batch_is_active验证了 skip 模式只产生 1 条派发记录并记录跳过错误,而 queue 模式产生 3 条派发记录且无错误。
六、私有 SQLite 控制平面的安全与存储细节
调度库是私有的,ADR-0006 用 "private local SQLite" 强调这一点,schedule_store.py 中有多道防线:
- 文件权限 0600:初始化完成后
self.database.chmod(0o600),测试test_schedule_database_rejects_symlinks_and_is_private断言权限位精确等于0o600; - 拒绝符号链接:初始化时与每次建立会话前都调用
reject_symlink(),防止调度库被替换为指向其他文件的链接(测试test_schedule_database_rechecks_symlink_before_each_connection验证了会话前复检); - WAL 模式:初始化时执行
PRAGMA journal_mode = WAL,失败即报错; - 外键强制:连接后检查
PRAGMA foreign_keys必须为 1; - 原子初始化:
BEGIN IMMEDIATE包裹建表,避免并发初始化竞争。
调度库的schedules表还带 CheckConstraint(enabled IN (0,1)、overlap_policy IN ('skip','queue')),schedule_dispatches表对(schedule_id, scheduled_for, target)有唯一约束,杜绝同一 occurrence 同一目标重复派发。
6.1 相关环境变量
| 环境变量 | 作用 | 默认值 |
|---|---|---|
THEHARVESTER_SCHEDULE_DB | 覆盖调度库文件路径 | 运行库同名 sibling(*.schedules.sqlite) |
THEHARVESTER_SCHEDULER | 设为disabled仅用于持久化预览或外部控制启动 | enabled |
THEHARVESTER_RUN_WORKER | 控制执行 worker 开关(调度派发依赖它) | enabled |
THEHARVESTER_API_KEY | API 认证密钥 | 无(必须设置) |
run-now端点会先检查worker_enabled()与worker_available(),worker 不可用时返回 503 且不创建任何运行(测试test_run_now_rejects_an_unavailable_worker_without_creating_runs)。
七、调度器运行机制:认领、租约与幂等恢复
调度器以 asyncio 任务运行在 HarvestView 进程内(start_scheduler()/_scheduler_loop(),见 schedule_service.py)。核心流程:
- 查询下一个到期时刻:
_wait_for_work()读取next_due_at()(enabled=1且next_run_at最小的记录),据此休眠至多 5 秒或等待唤醒事件(创建/修改/删除/暂停/恢复调度都会wake_scheduler()); - 认领到期 occurrence:
claim_due(owner_id, lease_seconds=60)用带claim_until的原子 UPDATE 抢占到期记录,同一 occurrence 只可能被一个调度器实例认领(测试test_two_scheduler_instances_cannot_claim_the_same_occurrence验证两个并发认领只有一个成功); - 处理过程中续租:处理大批量目标时每 100 个目标续租一次(
renew_claim),租约丢失则立刻抛错、失败关闭(测试test_claimed_occurrence_fails_closed_after_claim_loss、test_large_reservation_reconciliation_renews_the_schedule_claim); - 完成或推迟:成功派发后
complete_claim写入last_run_at并推进next_run_at;执行 worker 不可用或异常时defer_claim保留 occurrence 身份并推迟 60 秒重试(测试test_deferred_claim_keeps_occurrence_identity)。
7.1 幂等恢复
派发预留的一个重要特性是可恢复且幂等:如果调度器在创建运行过程中崩溃,已reserved的派发记录与已创建的运行都会保留;下次派发时_enqueue_targets会复用既有预留与 run ID,而不是重新生成(测试test_dispatch_recovery_reuses_reservations_and_run_ids验证了重复dispatch_now第二次返回空run_ids且目标全部进入skipped_targets)。reserve_dispatches使用sqlite_insert(...).on_conflict_do_nothing()保证预留的原子幂等。
八、实操:创建、查看与管理一个调度
HarvestView 的调度 API 以/api/v1/schedules为前缀,路由定义在 schedules.py,全部需要X-API-Key认证。
8.1 创建调度
以下示例来自 Rest-API.md 的 "Schedule finite runs" 章节,创建一个每周一 09:00(美东)运行的库存枚举调度:
curl -s http://127.0.0.1:5000/api/v1/schedules \ -X POST \ -H "X-API-Key: $THEHARVESTER_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "name": "Weekly external inventory", "targets": ["example.com", "example.org"], "run": {"target": "example.com", "sources": ["crtsh"], "limit": 500}, "timing": { "frequency": "weekly", "start_at": "2026-08-24T09:00:00-04:00", "timezone": "America/New_York", "interval": 1, "weekdays": [1] }, "enabled": true, "overlap_policy": "skip" }' \ | jq请求字段说明(与 schedule_models.py 的校验规则对应):
| 字段 | 约束与说明 |
|---|---|
name | 1–120 字符,空白折叠后不可为空 |
targets | 1–10000 个规范化目标,去重;会与run模板合并校验(每个目标都必须是合法的运行目标) |
run | 完整 RunRequest 模板,target会被逐个替换;sources可为空(纯动作调度) |
timing.frequency | once/hourly/daily/weekly/monthly |
timing.start_at | 必须带 UTC 偏移 |
timing.timezone | 合法 IANA 时区名 |
timing.interval | 1–365,频率单位数 |
timing.weekdays | 仅 weekly 可用,ISO 星期 1–7,去重 |
overlap_policy | skip(默认)或queue |
enabled | 是否立即可派发 |
响应中除了schedule_id、next_run_at外,还包含接下来 5 个派生 occurrence(upcoming_occurrences),HarvestView 前端会在每张调度卡片上展示。测试test_schedule_api_exposes_five_upcoming_monthly_occurrences验证了 1 月 31 日起算的月度调度返回 5 个正确日期,且 pause 后upcoming_occurrences变为空。
8.2 完整 API 一览
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/v1/schedules | 列出调度(limit 1–500,默认 100) |
| POST | /api/v1/schedules | 创建调度(201) |
| GET | /api/v1/schedules/health | 报告调度器与执行 worker 的 enabled/available 状态 |
| GET | /api/v1/schedules/{id} | 读取单个调度 |
| PUT | /api/v1/schedules/{id} | 整体替换调度(不删除已提交运行与派发历史) |
| DELETE | /api/v1/schedules/{id} | 删除调度(204;不取消已提交运行,不删除完成证据) |
| POST | /api/v1/schedules/{id}/pause | 暂停未来 occurrence,不取消已提交运行 |
| POST | /api/v1/schedules/{id}/resume | 恢复未来 occurrence |
| POST | /api/v1/schedules/{id}/run-now | 不改变递归时机,立即多派发一次(202;worker 不可用返回 503) |
| GET | /api/v1/schedules/{id}/dispatches | 列出每个目标的派发预留与生命周期镜像(limit 1–5000) |
健康检查示例响应(对应 schedules.py 的ScheduleHealthResponse):
{ "scheduler_enabled": true, "scheduler_available": true, "worker_enabled": true, "worker_available": true }8.3 行为要点
- 暂停/删除不会取消已提交运行,删除也不会删除完成证据——这是 ADR-0006 "preserves lifecycle and cancellation behavior" 的操作者友好体现;
- 替换调度走 PUT 路由:已完成的一次性调度被替换后,其
last_run_at保留,重新启用时按新模板计算next_run_at(测试test_replacing_a_completed_once_schedule_resets_occurrence_state); - P1/P2 活动的授权不因调度而放宽:每个 occurrence 只执行运行模板中显式存储的 provider/DNS/直接活动,P1、P2 仍要求操作者对每个列出的目标显式授权(见 Rest-API.md)。
九、网络边界与"单操作者"设计哲学
ADR-0006 末尾明确:不引入外部调度器、第二条执行路径或分布式队列,直到有可衡量的需求证明需要改变本地单操作者边界。与之呼应的是 ADR-0003 的 "A single worker matches the local single-operator product"——这是一个刻意保持的本地化、串行化、可审计的产品边界。
对操作者而言这意味着:
- 调度管理本身零网络活动(纯本地 SQLite);
- 到点只发生运行模板里显式声明的 provider/DNS/直接请求;
- 一次只执行一个运行(worker 串行),重叠由
skip/queue策略显式表达,不会因调度产生意外的并发网络洪峰; - 默认绑定
127.0.0.1:5000,需要远程访问时须自行叠加 TLS、访问控制、请求日志与限流(见 Rest-API.md 安全边界章节)。
十、测试与验证路径
调度功能在 tests/lib/test_schedules.py 中有超过三十个测试覆盖,是理解语义最直接的入口。值得重点阅读的几类:
- 递归语义:
test_recurrence_handles_intervals_dst_and_downtime(interval、DST、停机追赶)、test_daily_schedule_preserves_local_wall_clock_across_dst、test_monthly_schedule_uses_the_final_day_of_short_months; - 重叠策略:
test_due_schedules_skip_or_queue_while_a_prior_batch_is_active; - 幂等与恢复:
test_dispatch_recovery_reuses_reservations_and_run_ids、test_overlap_policy_recovers_current_occurrence_reservations、test_recovered_queued_occurrence_completes_without_a_false_error; - 并发与租约:
test_two_scheduler_instances_cannot_claim_the_same_occurrence、test_claimed_occurrence_fails_closed_after_claim_loss、test_claim_loss_during_enqueue_leaves_every_target_auditable; - 安全:
test_schedule_database_rejects_symlinks_and_is_private、test_every_schedule_route_requires_authentication; - 容量:
test_schedule_store_reserves_the_maximum_target_batch(10000 目标全量预留)。
十一、总结
ADR-0006 通过"私有本地 SQLite 控制平面 + 既有持久队列 + 单一 worker"的组合,为 HarvestView 增加了可落地的定时枚举能力,同时守住了三条底线:生命周期与取消行为不被破坏、自动化策略不进证据导出、不引入超出单操作者需求的分布式基础设施。对于需要"每周对一批授权域名做 crtsh 枚举并留档"这类场景,只需一个 POST 请求即可建立长期任务,并借助upcoming_occurrences、dispatches与health端点持续观察其状态。
【免费下载链接】theHarvesterE-mails, subdomains and names Harvester - OSINT项目地址: https://gitcode.com/GitHub_Trending/th/theHarvester
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考