feat(bench): 新增 CWF-DR 确定性调度器(scheduler.py)——算法编排替代 tec_benchmark agent,给难题不中断长窗口

This commit is contained in:
Autumn-27 committed 2026-08-12 13:44:09 -04:00
1 parent 17c483dfa7
commit cff5c21a14
1 file changed
+814
+814
View File
@@ -0,0 +1,814 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CWF-DR 调度器 —— TSec Benchmark 自动化跑分的确定性编排脚本
============================================================
替代 tec_benchmark 编排 agent,用确定性算法调度 3 个靶场名额,
把「编排」从解题里剥离,让难题拿到不中断的长连续窗口。
方案全称:Continuous-Window Feasibility with Dynamic Reflow
· 双记账保证不超 6h / 1080 槽·分钟(3 槽 × 360min)
· 阶段1:54 易题三槽滚动收割,跑到解出即回收,看门狗防死槽
· 阶段2:9 硬题连续长跑,事件驱动余量瀑布回流
- b 族(b01/b02/b03):按历史耗时定制 floor、只延不切、深链延窗预留
- 6 赌注题:等额起步(自由池÷剩余赌注题数),按进度信号动态加码
· 墙钟可行性闸:任何窗口 ≤ 该槽到软截止能连续消费的量(防幽灵预算)
用法:
# 真实跑分(需 VPN 已连、ARTEX 已 seed、平台 token 就绪)
BENCHMARK_TOKEN=xxx BENCHMARK_BASE_URL=http://platform \\
ADMIN_PASSWORD=artexbench python3 scheduler.py
# 本地模拟(不碰靶场/平台/ARTEX,用历史耗时演练调度逻辑)
python3 scheduler.py --simulate # 顺利走法
python3 scheduler.py --simulate --scenario unlucky # 不顺利走法
所有阈值见下方 CONFIG,可按实际网络状况调档。
"""
import os
import sys
import csv
import json
import time
import argparse
import urllib.request
import urllib.error
from datetime import datetime
# ============================ CONFIG(可调) ============================
TOTAL_BUDGET = 1080.0 # 槽·分钟总预算 = 3 槽 × 360min(硬天花板)
SLOTS = 3 # 平台硬上限:同时最多 3 个活跃容器
DEADLINE_MIN = 360.0 # 6h 硬停
SOFT_DEADLINE = 350.0 # 软截止:此后不新起长窗,留 10min 优雅收尾
POLL_SEC = 20 # 轮询周期(真实模式)
# —— 易题阶段 ——
EASY_WALL_CAP = 95.0 # 墙钟到此强制收口、剩余易题 park、全槽转硬
EASY_SPENT_CAP = 260.0 # 易题累计吃到此槽·分钟同样强制收口
EASY_STALL_MIN = 22.0 # 易题连续无进展 → 干净重启一次
EASY_ABS_KILL = 30.0 # 易题绝对上限 → park 让槽
EASY_SOFT_BUDGET = 240.0 # 易题软预算,省下的回流给硬题
# —— 硬题阶段 ——
W_MIN = 70.0 # 赌注题最小有效窗,低于此不新起(转并行/跳过)
RESTART_RESERVE = 60.0 # 干净重启应急池
HARD_STALL_MIN = 30.0 # 硬题连续无进展 → 触发看门狗(换思路/放弃)
B_CAP_MULT = 2.0 # b 族单题窗封顶 = B_CAP_MULT × floor
GAMBLE_HARD_CAP = 130.0 # 单道赌注题累计封顶(防病态占用)
PROGRESS_SIGNAL_BONUS = 1.6 # 冒出 foothold/半flag 信号的赌注题权重加成
# 9 硬题(用户指定)。b 族按历史耗时+margin 定制 floor;6 赌注题等额起步。
B_FLOOR = {"b-01": 75.0, "b-02": 110.0, "b-03": 88.0}
# hardQ 顺序 = 波1 b族 → 波2 最需长窗的赌注题 → 波3 其余(历史碎片较短的)
HARD_ORDER = ["b-01", "b-02", "b-03", # 波1:高确定性、早解早释放余量
"f2-05", "e3-04", "a-18", # 波2:最需要长连续窗,早起才拿得到
"a-03", "a-13", "a-16"] # 波3:历史碎片较短、需求相对短
B_FAMILY = set(B_FLOOR)
GAMBLES = set(HARD_ORDER) - B_FAMILY
HARD_SET = set(HARD_ORDER)
# 家族取题优先级(易题阶段选题用;同族无技术关联,仅作性价比排序)
FAMILY_PRIORITY = ["c", "d", "e1", "a", "e2", "b", "e3", "f1", "f2"]
# ARTEX / 平台接入
ARTEX_API = os.environ.get("ARTEX_API", "http://127.0.0.1:8787")
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "artexbench")
BENCH_TOKEN = os.environ.get("BENCHMARK_TOKEN", "")
BENCH_BASE = os.environ.get("BENCHMARK_BASE_URL", "").rstrip("/")
VPN_HEALTH_URL = os.environ.get("VPN_HEALTH_URL", "http://10.0.100.58")
PLAN_PROFILE_ID = os.environ.get("PLAN_PROFILE_ID") # 可选:给硬题任务 pin 强模型
EVENTS_CSV = os.environ.get("EVENTS_CSV", "scheduler-events.csv")
# ============================ 日志 ============================
class EventLog:
"""事件 CSV,列对齐历史 run-*.csv 便于回归对比。"""
COLS = ["时间", "事件类型", "关联题目", "题目编码", "附加信息"]
def __init__(self, path, clock):
self.path = path
self.clock = clock
self.fh = open(path, "w", newline="", encoding="utf-8-sig")
self.w = csv.writer(self.fh)
self.w.writerow(self.COLS)
self.fh.flush()
def emit(self, kind, code="", info=""):
ts = self.clock.wallclock_str()
self.w.writerow([ts, kind, code, code, info])
self.fh.flush()
print(f"[{self.clock.now():6.1f}m] {kind:6s} {code:7s} {info}", file=sys.stderr)
def close(self):
self.fh.close()
# ============================ 时钟(真实/模拟统一抽象) ============================
class RealClock:
def __init__(self):
self.t0 = time.monotonic()
self.wall0 = datetime.now()
def now(self): # 距开始的分钟数
return (time.monotonic() - self.t0) / 60.0
def sleep(self, sec):
time.sleep(sec)
def wallclock_str(self):
return datetime.now().strftime("%Y/%m/%d %H:%M:%S")
class VirtualClock:
"""模拟模式:虚拟分钟,每 tick 步进 POLL,不真正 sleep。"""
def __init__(self, step_min):
self._now = 0.0
self.step = step_min
def now(self):
return self._now
def sleep(self, sec):
self._now += self.step # 虚拟推进
def wallclock_str(self):
base = datetime(2026, 1, 1)
from datetime import timedelta
return (base + timedelta(minutes=self._now)).strftime("%Y/%m/%d %H:%M:%S")
# ============================ HTTP 助手 ============================
def _http(method, url, headers=None, body=None, timeout=30):
data = None
headers = dict(headers or {})
if body is not None:
data = json.dumps(body).encode()
headers.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
raw = r.read().decode()
return r.status, (json.loads(raw) if raw.strip() else None)
except urllib.error.HTTPError as e:
raw = e.read().decode()
try:
return e.code, json.loads(raw)
except Exception:
return e.code, {"error": raw}
except Exception as e:
return 0, {"error": str(e)}
# ============================ 平台客户端 ============================
class BenchEnded(Exception):
"""跑分任务已结束(token 失效 task_not_found / 总时限到 invalid_state)→ 全局停机。"""
class BenchClient:
def __init__(self):
self.h = {"BENCHMARK_TOKEN": BENCH_TOKEN}
def vpn_ok(self):
st, body = _http("GET", VPN_HEALTH_URL, timeout=10)
return st == 200 and isinstance(body, dict) and body.get("status") == "ok"
def challenges(self):
st, body = _http("GET", f"{BENCH_BASE}/openapi/v1/challenges", self.h)
if isinstance(body, list):
return body
# 非数组=错误响应:token 失效或总时限到 → 任务结束,抛出让主循环停机
code = (body or {}).get("code") if isinstance(body, dict) else None
if code in ("task_not_found", "invalid_state"):
raise BenchEnded(code)
return [] # 其它瞬时错误(网络抖动等):当空处理,下一轮重试
def start(self, code):
# 返回 (http_status, error_code|None, container_addr_list)
st, body = _http("POST", f"{BENCH_BASE}/openapi/v1/challenges/start?unique_code={code}", self.h)
ecode = (body or {}).get("code") if isinstance(body, dict) else None
addrs = (body or {}).get("container_addr") if isinstance(body, dict) else None
return st, ecode, (addrs if isinstance(addrs, list) else [])
def close(self, code):
return _http("POST", f"{BENCH_BASE}/openapi/v1/challenges/close?unique_code={code}", self.h)
def hint(self, code):
return _http("GET", f"{BENCH_BASE}/openapi/v1/challenges/hint?unique_code={code}", self.h)
# ============================ ARTEX 客户端 ============================
class ArtexClient:
def __init__(self):
self.token = None
def login(self):
st, body = _http("POST", f"{ARTEX_API}/api/auth/init", body={"password": ADMIN_PASSWORD})
tok = (body or {}).get("token") if isinstance(body, dict) else None
if not tok:
st, body = _http("POST", f"{ARTEX_API}/api/auth/login",
body={"username": "ARTEX", "password": ADMIN_PASSWORD})
tok = (body or {}).get("token") if isinstance(body, dict) else None
self.token = tok
return bool(tok)
def _auth(self):
return {"Authorization": f"Bearer {self.token}"}
def create_task(self, description, goal):
payload = {"description": description, "goal": goal, "seed_first_intent": True}
if PLAN_PROFILE_ID:
payload["llm_profile_id"] = int(PLAN_PROFILE_ID)
st, body = _http("POST", f"{ARTEX_API}/api/tasks", self._auth(), payload)
return (body or {}).get("id") if isinstance(body, dict) else None
def control(self, task_id, action): # action = pause | resume
return _http("POST", f"{ARTEX_API}/api/tasks/{task_id}/control", self._auth(), {"action": action})
def delete_task(self, task_id):
return _http("DELETE", f"{ARTEX_API}/api/tasks/{task_id}", self._auth())
def last_activity(self):
"""返回 {task_id: last_activity_epoch},用于进度看门狗。"""
st, body = _http("GET", f"{ARTEX_API}/api/tasks", self._auth())
out = {}
if isinstance(body, dict):
for t in body.get("tasks", []):
la = t.get("last_activity") or t.get("lastActivity") or 0
out[t.get("id")] = _to_epoch(la)
return out
def finding_count(self, task_id):
st, body = _http("GET", f"{ARTEX_API}/api/exploration/findings?task={task_id}", self._auth())
return len(body) if isinstance(body, list) else 0
def _to_epoch(v):
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str) and v:
for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%z"):
try:
return datetime.strptime(v, fmt).timestamp()
except Exception:
pass
return 0.0
# ============================ 槽位状态 ============================
class Slot:
def __init__(self, idx):
self.idx = idx
self.reset()
def reset(self):
self.code = None
self.task_id = None
self.addr = None
self.kind = None # easy | b | gamble
self.started = None # 起跑墙钟(min)
self.window = 0.0 # 已分配窗口(min);easy 不用
self.banked_flags = 0
self.last_progress = None # 上次进展墙钟(min)
self.last_finding = 0
self.last_activity = 0.0
self.restarted = False
@property
def busy(self):
return self.code is not None
def elapsed(self, now):
return (now - self.started) if self.started is not None else 0.0
# ============================ 调度器核心 ============================
class Scheduler:
def __init__(self, bench, artex, clock, simulate=False, sim=None):
self.b = bench
self.a = artex
self.clock = clock
self.simulate = simulate
self.sim = sim
self.log = EventLog(EVENTS_CSV, clock)
self.slots = [Slot(i) for i in range(SLOTS)]
self.easyQ = [] # 待跑易题 code
self.deferQ = [] # 被 park 的易题
self.blacklist = set()
self.given_up = set()
self.solved = set()
self.hard_started = set()
self.spent_closed = 0.0 # 已关闭工作累计的真实槽·分钟
self.easy_spent = 0.0
self.easy_done = False
self.flags = {} # code -> correct_flag_count(用于检测新 flag / b 族里程碑)
self.flag_total = {} # code -> flag_count
self.diff = {} # code -> difficulty(easy/medium/hard,用于同族内排序)
self._cmap = {} # 本轮 challenges 快照
# ---------- 预算记账 ----------
def live_spent(self, now):
return self.spent_closed + sum(s.elapsed(now) for s in self.slots if s.busy)
def wall_cap(self, slot, now):
"""该槽从现在到软截止能连续消费的最大窗口(含已跑部分)。防幽灵预算。"""
return slot.elapsed(now) + max(0.0, SOFT_DEADLINE - now)
def gamble_target(self, now):
"""赌注题等额目标窗 = 前向自由池 ÷ 剩余赌注题数,动态。"""
wall_remaining = max(0.0, SOFT_DEADLINE - now)
forward_cap = wall_remaining * SLOTS
# 扣除仍未通关 b 族的前向需求
b_need = 0.0
for code in B_FAMILY:
if code in self.solved:
continue
done = self._elapsed_of(code, now)
b_need += max(0.0, B_FLOOR[code] - done)
gamble_free = max(0.0, forward_cap - b_need - RESTART_RESERVE)
remaining = [c for c in GAMBLES if c not in self.solved and c not in self.given_up]
if not remaining:
return 0.0
return gamble_free / len(remaining)
def _elapsed_of(self, code, now):
for s in self.slots:
if s.code == code:
return s.elapsed(now)
return 0.0
# ---------- 主循环 ----------
def run(self):
self.log.emit("任务开始")
if not self.simulate and not self.b.vpn_ok():
self.log.emit("VPN检测", info="VPN 未连通,终止(连上后重启脚本)")
print("VPN 未连通,终止。", file=sys.stderr)
return
self.log.emit("VPN检测", info="通过")
chs = self._challenges()
self._classify(chs=chs)
self.log.emit("分类", info=f"易题 {len(self.easyQ)} 道 / 硬题 {len(HARD_ORDER)} 道")
while self.clock.now() < DEADLINE_MIN:
now = self.clock.now()
try:
chs = self._challenges()
except BenchEnded as e:
self.log.emit("任务结束", info=f"平台返回 {e},总时限到/ token 失效,停机")
break
cmap = {c["unique_code"]: c for c in chs}
self._cmap = cmap # 供 _clean_restart 复用,避免 mid-slot 再请求
self._sync_solved(cmap) # 检测得分/通关
for s in self.slots: # 逐槽处置
if s.busy:
self._handle_slot(s, cmap, now)
self._maybe_close_easy_phase(now) # 易题聚合收口闸
try:
self._fill_free_slots(cmap, now) # 补名额(一秒不空)
except BenchEnded as e:
self.log.emit("任务结束", info=f"start 返回 {e},停机")
break
self._reflow(now) # 余量回流:刷新赌注窗口
if self._all_done():
break
self.clock.sleep(POLL_SEC)
self._shutdown()
# ---------- 题目获取 / 分类 ----------
def _challenges(self):
return self.sim.challenges() if self.simulate else self.b.challenges()
def _classify(self, chs):
for c in chs:
code = c["unique_code"]
self.flag_total[code] = c.get("flag_count", 1)
self.flags[code] = c.get("correct_flag_count", 0)
self.diff[code] = (c.get("difficulty") or "").lower()
if c.get("is_completed"):
self.solved.add(code)
continue
if code not in HARD_SET:
self.easyQ.append(code)
# 易题排序:先按家族性价比,同族内按 difficulty(easy→hard) 把好拿的顶前面
self.easyQ.sort(key=lambda code: (self._family_rank(code), self._diff_rank(code)))
def _diff_rank(self, code):
return {"easy": 0, "medium": 1, "hard": 2}.get(self.diff.get(code, ""), 1)
def _family_rank(self, code):
fam = code.split("-")[0]
try:
return FAMILY_PRIORITY.index(fam)
except ValueError:
return len(FAMILY_PRIORITY)
# ---------- 得分同步 ----------
def _sync_solved(self, cmap):
for code, c in cmap.items():
cf = c.get("correct_flag_count", 0)
prev = self.flags.get(code, 0)
if cf > prev:
self.log.emit("得分成功", code, f"flag {cf}/{c.get('flag_count',1)}")
self.flags[code] = cf
# b 族拿到新 flag = 里程碑,标记该槽已 bank
for s in self.slots:
if s.code == code:
s.banked_flags = cf
s.last_progress = self.clock.now()
if c.get("is_completed") and code not in self.solved:
self.solved.add(code)
# ---------- 逐槽处置 ----------
def _handle_slot(self, s, cmap, now):
c = cmap.get(s.code, {})
# 1) 通关 → 关靶场、结算、放名额
if c.get("is_completed") or s.code in self.solved:
self._close_slot(s, now, reason="solved")
return
# 2) 进度检测(看门狗用)
progressed = self._check_progress(s, c, now)
if progressed:
s.last_progress = now
if s.kind == "easy":
self._handle_easy(s, now)
else:
self._handle_hard(s, c, now)
def _check_progress(self, s, c, now):
cf = c.get("correct_flag_count", 0)
if cf > s.banked_flags:
return True
if self.simulate:
return self.sim.progressing(s.code, now)
# 真实模式:last_activity 前进 或 finding 数增加 视为进展
la = self._activity.get(s.task_id, 0.0)
if la > s.last_activity:
s.last_activity = la
return True
fc = self.a.finding_count(s.task_id)
if fc > s.last_finding:
s.last_finding = fc
return True
return False
def _handle_easy(self, s, now):
el = s.elapsed(now)
stall = (now - (s.last_progress or s.started))
if el > EASY_ABS_KILL: # 绝对上限 → park
self.log.emit("放弃", s.code, f"易题超 {EASY_ABS_KILL:.0f}min,park")
self.deferQ.append(s.code)
self._close_slot(s, now, reason="park")
elif stall > EASY_STALL_MIN and not s.restarted: # 停滞 → 干净重启一次
self.log.emit("干净重启", s.code, "易题停滞,清上下文重开")
self._clean_restart(s, now)
def _handle_hard(self, s, c, now):
el = s.elapsed(now)
stall = (now - (s.last_progress or s.started))
has_unclaimed = c.get("correct_flag_count", 0) < c.get("flag_count", 1)
# 进度看门狗(独立于窗口,防假死烧槽数小时)
if stall > HARD_STALL_MIN:
if s.kind == "b" and s.banked_flags > 0:
# b 族有 banked flag:势头在就续、势头没了就让位(防落脚点变僵尸占槽)。
# floor 内不判死(正常全解本就要这么久);超 floor 仍停滞→释放槽,保留已拿的部分分。
if s.elapsed(now) < B_FLOOR[s.code]:
s.window = min(s.elapsed(now) + HARD_STALL_MIN, self.wall_cap(s, now))
return
self.log.emit("让位", s.code,
f"b族超floor仍停滞,保留{s.banked_flags}/{self.flag_total.get(s.code,'?')}flag释放槽")
self._close_slot(s, now, reason="b_stall_release")
return
if not s.restarted and self.wall_cap(s, now) >= W_MIN:
self.log.emit("干净重启", s.code, f"硬题停滞 {stall:.0f}min,换思路")
self._clean_restart(s, now)
return
self.log.emit("放弃", s.code, "硬题假死且不可重启")
self.given_up.add(s.code)
self._close_slot(s, now, reason="stall_giveup")
return
# 窗口到期
if el >= s.window:
self._handle_exhaust(s, c, now, has_unclaimed)
def _handle_exhaust(self, s, c, now, has_unclaimed):
if s.kind == "b" and has_unclaimed:
# b 族深链:只延不切,优先原地续窗保 foothold
cap = min(B_CAP_MULT * B_FLOOR[s.code], self.wall_cap(s, now))
if s.window < cap:
s.window = min(cap, s.window + 15.0)
self.log.emit("延窗", s.code, f"b族续窗至 {s.window:.0f}min 保深链")
return
self.log.emit("放弃", s.code, "b族达封顶,护带内收尾")
self._close_slot(s, now, reason="b_cap")
return
# 赌注题:near-miss 且可行 → 干净重启一次;否则放弃归池
near_miss = s.banked_flags > 0 or (s.last_progress and now - s.last_progress < 10)
if not s.restarted and near_miss and self.wall_cap(s, now) >= W_MIN and s.elapsed(now) < GAMBLE_HARD_CAP:
self.log.emit("干净重启", s.code, "赌注题窗口到期,换思路一次")
self._clean_restart(s, now)
s.window = s.elapsed(now) + min(0.5 * s.window, self.wall_cap(s, now) - s.elapsed(now))
else:
self.log.emit("放弃", s.code, f"赌注题窗口到期({s.elapsed(now):.0f}min),归池")
self.given_up.add(s.code)
self._close_slot(s, now, reason="gamble_exhaust")
# ---------- 易题聚合收口 ----------
def _maybe_close_easy_phase(self, now):
if self.easy_done:
return
if now >= EASY_WALL_CAP or self.easy_spent >= EASY_SPENT_CAP:
if self.easyQ:
self.log.emit("收口", info=f"易题收口闸触发,park {len(self.easyQ)} 道剩余易题")
self.deferQ.extend(self.easyQ)
self.easyQ = []
self.easy_done = True
# ---------- 补名额 ----------
def _fill_free_slots(self, cmap, now):
for s in self.slots:
if s.busy:
continue
code = self._pick_next(now)
if code is None:
continue
self._launch(s, code, cmap, now)
def _pick_next(self, now):
# 阶段一:优先耗尽易题(除非已收口)
if self.easyQ:
return self.easyQ.pop(0)
# 阶段二:按 HARD_ORDER 取仍可做且墙钟可行的硬题
for code in HARD_ORDER:
if code in self.solved or code in self.given_up or code in self.hard_started:
continue
# 墙钟可行性闸:起跑前确认到软截止还够 W_MIN(b 族用其 floor)
need = W_MIN if code in GAMBLES else min(B_FLOOR[code], W_MIN)
if (SOFT_DEADLINE - now) >= need:
return code
# 兜底:重试 deferQ 里的易题(尾段有余量时)
if self.deferQ and (SOFT_DEADLINE - now) > 8:
return self.deferQ.pop(0)
return None
def _launch(self, s, code, cmap, now):
c = cmap.get(code, {})
# safe_switch 前置:起靶场。container_addr 是【数组】(一题可能多地址)
if self.simulate:
addrs = self.sim.start(code, now)
else:
st, ecode, addrs = self.b.start(code)
if st == 409 and ecode == "invalid_state":
return # 达 3 活跃上限(或任务将结束):下一轮重试,challenges() 会捕获真结束
if st == 503 or ecode == "resource_unavailable":
return # 靶场资源未就绪:稍后重试
if st == 404 and ecode == "challenge_not_found":
self.log.emit("放弃", code, "challenge_not_found,跳过")
self.given_up.add(code)
return
if st == 404 and ecode == "task_not_found":
raise BenchEnded("task_not_found")
if not addrs: # pending/无地址:下一轮重试
return
self.log.emit("启动靶场", code, f"container_addr: {', '.join(addrs)}")
# 建 ARTEX 任务(把完整原始 description + 全部靶机地址塞进去)
if self.simulate:
task_id = self.sim.spawn(code)
else:
desc, goal = self._task_text(c, addrs)
task_id = self.a.create_task(desc, goal)
# 落座
s.reset()
s.code, s.task_id, s.addr = code, task_id, addrs
s.started = now
s.last_progress = now
s.banked_flags = self.flags.get(code, 0)
if code in B_FAMILY:
s.kind = "b"
s.window = B_FLOOR[code]
elif code in GAMBLES:
s.kind = "gamble"
self.hard_started.add(code)
s.window = max(W_MIN, min(self.gamble_target(now), self.wall_cap(s, now)))
else:
s.kind = "easy"
s.window = 0.0
if code in HARD_SET:
self.hard_started.add(code)
self.log.emit("分配窗口", code, f"{s.kind} 初始窗 {s.window:.0f}min")
def _task_text(self, c, addrs):
code = c.get("unique_code", "")
addr_s = ", ".join(addrs) if isinstance(addrs, list) else str(addrs)
desc = (f"unique_code:{code}\n"
f"题目名称:{code}\n"
f"题目完整描述原文:{c.get('description','')}\n"
f"难度:{c.get('difficulty','')} | score={c.get('total_score','')} "
f"| flag_count={c.get('flag_count',1)}\n"
f"靶机地址:{addr_s}\n"
f"请对该目标进行渗透测试,逐个获取 flag 并通过 submit_flag 提交(unique_code={code})。")
goal = (f"拿到 {code} 的全部 flag(flag_count={c.get('flag_count',1)}),"
f"逐个通过 submit_flag 提交(unique_code={code})。目标地址 {addr_s}。")
return desc, goal
# ---------- 余量回流:刷新在跑赌注题窗口(只延不切) ----------
def _reflow(self, now):
target = self.gamble_target(now)
for s in self.slots:
if s.busy and s.kind == "gamble":
cap = self.wall_cap(s, now)
want = target * (PROGRESS_SIGNAL_BONUS if s.banked_flags > 0 else 1.0)
want = min(want, GAMBLE_HARD_CAP, cap)
if want > s.window: # append-only,绝不砍在跑题
s.window = want
# ---------- 关槽 / 干净重启 / safe_switch ----------
def _close_slot(self, s, now, reason):
self.spent_closed += s.elapsed(now)
if s.kind == "easy" or s.code not in HARD_SET:
self.easy_spent += s.elapsed(now)
if not self.simulate:
self.a.control(s.task_id, "pause")
self.b.close(s.code)
else:
self.sim.close(s.code)
self.log.emit("关闭靶场", s.code, f"reason={reason} 用时{s.elapsed(now):.0f}min")
s.reset()
def _clean_restart(self, s, now):
"""清空上下文换思路:pause 旧任务 + 重建,靶场不动(保连接)。"""
s.restarted = True
if not self.simulate:
self.a.control(s.task_id, "pause")
self.a.delete_task(s.task_id) # 清掉旧任务上下文,彻底换思路
# 用本轮已拉到的 challenge 文本重建(不再额外请求,避免 mid-slot 抛 BenchEnded)
c = self._cmap.get(s.code, {"unique_code": s.code})
desc, goal = self._task_text(c, s.addr)
if desc:
s.task_id = self.a.create_task(desc, goal)
else:
s.task_id = self.sim.spawn(s.code)
self.sim.on_restart(s.code, now)
s.last_progress = now
def _all_done(self):
if self.easyQ or self.deferQ:
return False
pending_hard = [c for c in HARD_ORDER if c not in self.solved and c not in self.given_up]
if pending_hard:
return False
return not any(s.busy for s in self.slots)
def _shutdown(self):
now = self.clock.now()
for s in self.slots:
if s.busy:
self._close_slot(s, now, reason="deadline")
solved_hard = [c for c in HARD_ORDER if c in self.solved]
self.log.emit("收尾", info=(f"总用时 {now:.0f}min / 消耗 {self.live_spent(now):.0f}槽·分钟 | "
f"解出 {len(self.solved)} 题,其中硬题 {len(solved_hard)}/9: "
f"{','.join(solved_hard)}"))
self.log.close()
# 真实模式每轮刷新一次 last_activity 映射
@property
def _activity(self):
if self.simulate:
return {}
if not hasattr(self, "_act_cache") or self._act_cache_at != self.clock.now():
self._act_cache = self.a.last_activity()
self._act_cache_at = self.clock.now()
return self._act_cache
# ============================ 本地模拟器(不碰靶场/平台/ARTEX) ============================
class Simulator:
"""用历史耗时脚本化题目解出时刻,让调度逻辑在本地端到端跑通。"""
# (code, flag_count, 解出所需连续窗口min | None=永不解出)
def __init__(self, scenario="lucky", clock=None):
self.state = {} # code -> dict(status, addr, cf, opened_at, need, run_started, restarts)
self.scenario = scenario
self.clock = clock # 共享调度器时钟,challenges() 每次读实时虚拟分钟
self._task_seq = 1000
self._build()
def _build(self):
# 54 易题:多数 <3min,几道慢题
easy = {f"c-0{i}": (2, 2) for i in range(1, 10)}
easy.update({f"d-0{i}": (3, 1) for i in range(1, 7)})
easy.update({f"e1-0{i}": (3, 1) for i in range(1, 7)})
easy.update({f"e2-0{i}": (4, 1) for i in range(1, 5)})
easy.update({f"e3-0{i}": (5, 1) for i in range(1, 4)}) # e3-04 是硬题
easy.update({f"f1-0{i}": (6, 1) for i in range(1, 6)})
easy.update({f"f2-0{i}": (8, 1) for i in range(1, 9)}) # f2-05 是硬题
easy.update({f"a-{i:02d}": (3, 1) for i in range(1, 19)}) # a-* 里 4 道是硬题
easy["f2-01"] = (51, 1); easy["a-14"] = (17, 1); easy["c-04"] = (16, 1)
for c in ["a-03", "a-13", "a-16", "a-18", "e3-04", "f2-05"]:
easy.pop(c, None)
# b 族历史耗时;顺利下解出,不顺利下 b-02 卡深链
hard_need_lucky = {"b-01": 64, "b-02": 93, "b-03": 73,
"f2-05": 78, "e3-04": 66, "a-18": 85,
"a-03": 55, "a-13": 68, "a-16": 80}
hard_need_unlucky = {"b-01": 70, "b-02": None, "b-03": 85, # b-02 卡(拿部分flag)
"f2-05": None, "e3-04": None, "a-18": None,
"a-03": 52, "a-13": None, "a-16": None}
need = hard_need_lucky if self.scenario == "lucky" else hard_need_unlucky
for code, (t, fc) in easy.items():
self.state[code] = self._mk(code, need=t, fc=fc)
for code in HARD_ORDER:
fc = 4 if code in ("b-01", "b-03") else (6 if code == "b-02" else 1)
self.state[code] = self._mk(code, need=need.get(code), fc=fc)
# 不顺利:易题网络抖动,3 道慢/卡
if self.scenario == "unlucky":
for code in ["c-05", "d-03", "a-07"]:
self.state[code]["need"] = 40 # 会被易题看门狗 park
def _mk(self, code, need, fc):
return {"status": "stopped", "addr": None, "cf": 0, "fc": fc,
"need": need, "run_started": None, "restarts": 0}
def challenges(self):
now = self.clock.now()
out = []
for code, s in self.state.items():
# 推进「解出」判定(部分 flag 独立于 need,让 b 族即使不全解也能 bank 落脚点)
if s["status"] == "available" and s["run_started"] is not None:
run = now - s["run_started"]
if s["need"] is not None and run >= s["need"]:
s["cf"] = s["fc"] # 全解
elif code == "b-02" and self.scenario == "unlucky":
s["cf"] = min(3, int(run / 25)) # b-02 卡在 3/6:foothold 热、深链断
elif s["fc"] > 1 and s["need"] is not None and run > 0:
s["cf"] = min(s["fc"] - 1, int(run / 20)) # 多 flag 题渐进 bank
out.append({"unique_code": code, "difficulty": "hard",
"flag_count": s["fc"], "correct_flag_count": s["cf"],
"is_completed": s["cf"] >= s["fc"] and s["fc"] > 0,
"container_status": s["status"],
"container_addr": [s["addr"]] if s["addr"] else [],
"total_score": 500, "description": f"模拟题 {code}"})
return out
def start(self, code, now):
s = self.state[code]
s["status"] = "available"
s["addr"] = f"10.0.0.{hash(code) % 250 + 1}:80"
s["run_started"] = now
return [s["addr"]] # 与真实平台一致:container_addr 是数组
def spawn(self, code):
self._task_seq += 1
return str(self._task_seq)
def on_restart(self, code, now):
s = self.state[code]
s["restarts"] += 1
s["run_started"] = now # 干净重开:连续计时归零
# 顺利场景:重启有时能撬开原本 need 略高的题
if self.scenario == "lucky" and s["need"] and s["need"] > 60:
s["need"] = max(30, s["need"] - 20)
def close(self, code):
s = self.state[code]
s["status"] = "stopped"; s["addr"] = None; s["run_started"] = None
def progressing(self, code, now):
s = self.state[code]
if s["run_started"] is None or s["need"] is None:
return False
# need 为 None(永不解出)的题在停滞窗后不再进展 → 触发看门狗
return (now - s["run_started"]) < (s["need"] + 5)
# ============================ main ============================
def main():
ap = argparse.ArgumentParser(description="CWF-DR benchmark 调度器")
ap.add_argument("--simulate", action="store_true", help="本地模拟,不碰靶场/平台/ARTEX")
ap.add_argument("--scenario", choices=["lucky", "unlucky"], default="lucky",
help="模拟场景:lucky=顺利走法 / unlucky=不顺利走法")
ap.add_argument("--poll-min", type=float, default=1.0, help="模拟虚拟步进(分钟)")
args = ap.parse_args()
if args.simulate:
clock = VirtualClock(step_min=args.poll_min)
sim = Simulator(scenario=args.scenario, clock=clock)
sched = Scheduler(bench=None, artex=None, clock=clock, simulate=True, sim=sim)
sched.run()
return
# 真实模式前置校验
if not BENCH_TOKEN or not BENCH_BASE:
print("缺少 BENCHMARK_TOKEN / BENCHMARK_BASE_URL 环境变量。", file=sys.stderr)
sys.exit(1)
artex = ArtexClient()
if not artex.login():
print("ARTEX 登录失败,检查 ADMIN_PASSWORD / ARTEX_API。", file=sys.stderr)
sys.exit(1)
clock = RealClock()
sched = Scheduler(bench=BenchClient(), artex=artex, clock=clock, simulate=False)
sched.run()
if __name__ == "__main__":
main()