Files
ARTEX/bench/scheduler.py
T

815 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()