theHarvester HarvestView 私有控制平面:用本地 SQLite 调度有限运行的架构解析(ADR-0006)
2026/9/14 0:14:32 网站建设 项目流程

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|failedqueued -> cancelledrunning -> cancelling -> cancelled,证据状态(completepartialfailed)与编排生命周期状态分开报告。

如果直接把这些"调度"数据塞进现有证据模型,会混淆三类性质完全不同的数据:

  • 操作者意图:谁被授权执行、按什么频率执行;
  • 编排状态:运行是否被认领、是否在取消、worker 租约是否有效;
  • 证据质量:最终结果是否完整、部分还是失败。

ADR-0006 的答案是把它们拆开:控制平面(control plane)负责"何时、对谁、按什么策略发起运行",证据平面(data plane)只保存最终的可移植证据。这是一个从源码结构可以清楚看到的边界——控制平面文件theHarvester/lib/api/schedule_store.py与证据存储theHarvester/lib/api/run_store.py是两套独立的 SQLite 引擎、两张独立的数据表集合。

二、决策核心:控制平面存储什么,证据导出不存储什么

ADR-0006 原文明确了控制平面承载五类数据:

  1. 授权的目标清单(authorized target inventories);
  2. 递归策略(recurrence policy);
  3. 重叠策略(overlap policy);
  4. 声明(claims,即调度器对某次到点 occurrence 的认领租约);
  5. 派发预留(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,调度器做两件事:

  1. 预留稳定的 run ID:在派发前先把schedule_dispatches表中每个目标的run_id状态置为reserved,此时运行记录尚未创建;
  2. 通过既有队列提交普通有限运行:随后用该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_dsttest_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_KEYAPI 认证密钥无(必须设置)

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)。核心流程:

  1. 查询下一个到期时刻_wait_for_work()读取next_due_at()enabled=1next_run_at最小的记录),据此休眠至多 5 秒或等待唤醒事件(创建/修改/删除/暂停/恢复调度都会wake_scheduler());
  2. 认领到期 occurrenceclaim_due(owner_id, lease_seconds=60)用带claim_until的原子 UPDATE 抢占到期记录,同一 occurrence 只可能被一个调度器实例认领(测试test_two_scheduler_instances_cannot_claim_the_same_occurrence验证两个并发认领只有一个成功);
  3. 处理过程中续租:处理大批量目标时每 100 个目标续租一次(renew_claim),租约丢失则立刻抛错、失败关闭(测试test_claimed_occurrence_fails_closed_after_claim_losstest_large_reservation_reconciliation_renews_the_schedule_claim);
  4. 完成或推迟:成功派发后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 的校验规则对应):

字段约束与说明
name1–120 字符,空白折叠后不可为空
targets1–10000 个规范化目标,去重;会与run模板合并校验(每个目标都必须是合法的运行目标)
run完整 RunRequest 模板,target会被逐个替换;sources可为空(纯动作调度)
timing.frequencyonce/hourly/daily/weekly/monthly
timing.start_at必须带 UTC 偏移
timing.timezone合法 IANA 时区名
timing.interval1–365,频率单位数
timing.weekdays仅 weekly 可用,ISO 星期 1–7,去重
overlap_policyskip(默认)或queue
enabled是否立即可派发

响应中除了schedule_idnext_run_at外,还包含接下来 5 个派生 occurrenceupcoming_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_dsttest_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_idstest_overlap_policy_recovers_current_occurrence_reservationstest_recovered_queued_occurrence_completes_without_a_false_error
  • 并发与租约test_two_scheduler_instances_cannot_claim_the_same_occurrencetest_claimed_occurrence_fails_closed_after_claim_losstest_claim_loss_during_enqueue_leaves_every_target_auditable
  • 安全test_schedule_database_rejects_symlinks_and_is_privatetest_every_schedule_route_requires_authentication
  • 容量test_schedule_store_reserves_the_maximum_target_batch(10000 目标全量预留)。

十一、总结

ADR-0006 通过"私有本地 SQLite 控制平面 + 既有持久队列 + 单一 worker"的组合,为 HarvestView 增加了可落地的定时枚举能力,同时守住了三条底线:生命周期与取消行为不被破坏、自动化策略不进证据导出、不引入超出单操作者需求的分布式基础设施。对于需要"每周对一批授权域名做 crtsh 枚举并留档"这类场景,只需一个 POST 请求即可建立长期任务,并借助upcoming_occurrencesdispatcheshealth端点持续观察其状态。

【免费下载链接】theHarvesterE-mails, subdomains and names Harvester - OSINT项目地址: https://gitcode.com/GitHub_Trending/th/theHarvester

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询