aes1

"""``ip xfrm`` 执行管理器.

所有操作回到 framework 的统一通道 ``exec_cmd(target, cmd) -> (rc, list)``:

    xfrm = XfrmManager(env)
    rc, out = xfrm.state_add("dut", sa)          # ip xfrm state add ...
    states = xfrm.state_list("dut")              # 解析成结构化 dict
    xfrm.flush("dut")                            # 清理现场(state + policy)
"""


import copy
import re
import time as _time
from datetime import datetime

from ..common.log import get_logger
from .models import LimitConfig, OffloadMode

logger = get_logger("ipsec.xfrm")


class XfrmManager:
    """在指定端执行 xfrm state / policy 的管理操作."""

    def __init__(self, exc, default_timeout=30.0):
        self.exc = exc
        self.default_timeout = default_timeout

    # ------------------------------------------------------------ 基础执行
    def run(self, target, cmd, timeout=None):
        return self.exc.exec_cmd(target, cmd, timeout=timeout or self.default_timeout)

    # ------------------------------------------------------------ state
    def state_add(self, target, sa, check=True):
        cmd = sa.add_cmd()
        logger.debug("[%s] %s", target, cmd)
        res = self.run(target, cmd)
        if check and not res.ok:
            logger.error("[%s] SA 下发失败(rc=%s): %s\n%s", target, res.rc, cmd, res.text)
        return res

    def state_update(self, target, sa):
        return self.run(target, sa.update_cmd())

    def state_del(self, target, sa):
        return self.run(target, sa.del_cmd())

    def state_flush(self, target):
        """清空该端所有 state(会连同 policy 一起失效,注意影响业务)."""
        return self.run(target, "ip xfrm state flush")

    def state_list(self, target):
        """解析 ``ip xfrm state`` 输出为结构化列表.

        返回每项含 src / dst / spi / proto / mode / reqid / auth-trunc / enc / offload 等。
        """
        rc, out = self.run(target, "ip xfrm state")
        if rc != 0:
            return []
        return parse_xfrm_state(out)

    def state_get(self, target, src, dst, spi):
        for st in self.state_list(target):
            if st.get("src") == src and st.get("dst") == dst:
                # spi 可能写作 0x1000 / 0x00001000 / 4096 / 0x00001000(4096),统一按数值比对
                if st.get("_spi_int") is not None and norm_spi(spi) is not None:
                    if st.get("_spi_int") == norm_spi(spi):
                        return st
                elif st.get("spi") == spi:
                    return st
        return None

    def state_stats(self, target):
        """``ip -s xfrm state``:带字节/包计数,用于统计卸载是否真的在转发."""
        rc, out = self.run(target, "ip -s xfrm state")
        if rc != 0:
            return []
        return parse_xfrm_state(out, with_stats=True)

    def has_offload_flag(self, target, src, dst, spi):
        """判断某条 SA 是否真的处于硬件卸载(kernel 会回显 offload dev X dir Y)."""
        st = self.state_get(target, src, dst, spi)
        if not st:
            return False
        return bool(st.get("offload")) and st["offload"] != "no"

    # -------------------------------------------------- 生命周期限制 limit
    def lifetime(self, target, src, dst, spi):
        """读取某条 SA 的限制配置与当前用量.

        返回 ``None`` 表示 SA 不存在——**hard 限制到限后内核会直接删掉 state**,
        所以 "查不到" 正是 hard limit 触发的直接证据。

        结构::

            {
              "config":  {"bytes": {"soft": int|None, "hard": int|None},
                          "packets": {...}, "seconds": {...}},
              "current": {"bytes": int, "packets": int},
              "add_time": "2026-09-26 23:20:00", "use_time": "...",
              "elapsed": 12.5,        # 距 add 时间的秒数(依赖两端时钟接近)
              "state": {...},         # 完整解析结果
            }
        """
        st = self.state_get(target, src, dst, spi)
        if not st:
            return None
        cfg = st.get("lft_cfg") or {}
        cur = st.get("lft_cur") or {}
        add_raw = st.get("add_time") or ""
        return {
            "config": cfg,
            "current": cur,
            "add_time": add_raw,
            "use_time": st.get("use_time") or "",
            "elapsed": seconds_since(add_raw),
            "state": st,
        }

    def limit_usage(self, target, src, dst, spi):
        """按维度给出 hard 限制的达成情况.

        每个维度返回 ``{"current", "hard", "soft", "ratio", "reached"}``;
        SA 已消失时返回 ``{"gone": {"gone": True}}``。
        """
        info = self.lifetime(target, src, dst, spi)
        if info is None:
            return {"gone": {"gone": True, "reached": True, "current": None, "hard": None}}

        usage = {}
        cur_bytes = (info["current"] or {}).get("bytes")
        cur_pkts = (info["current"] or {}).get("packets")
        for dim, current in (("bytes", cur_bytes), ("packets", cur_pkts), ("seconds", info.get("elapsed"))):
            cfg_dim = (info["config"] or {}).get(dim) or {}
            hard = cfg_dim.get("hard")
            soft = cfg_dim.get("soft")
            ratio = (current / hard) if (hard and current is not None) else 0.0
            usage[dim] = {
                "current": current,
                "soft": soft,
                "hard": hard,
                "ratio": round(ratio, 4),
                "reached": bool(hard is not None and current is not None and current >= hard),
            }
        return usage

    def verify_limit(self, target, src, dst,
                     spi, limit):
        """校验内核里**实际生效**的 limit 与期望值一致(下发是否被真正接受).

        注意 ``ip xfrm state`` 只回显 bytes / packets / seconds 三类,
        ``time-use-soft|hard`` 无法回读校验,遇到时只提示不参与比对。
        """
        expect = limit.as_dict() if isinstance(limit, LimitConfig) else dict(limit or {})
        info = self.lifetime(target, src, dst, spi)
        if info is None:
            return False, [f"SA 不存在: {src}->{dst} spi={spi}"]

        dim_of = {
            "byte-soft": ("bytes", "soft"), "byte-hard": ("bytes", "hard"),
            "packet-soft": ("packets", "soft"), "packet-hard": ("packets", "hard"),
            "time-soft": ("seconds", "soft"), "time-hard": ("seconds", "hard"),
        }
        problems = []
        for keyword, value in expect.items():
            mapping = dim_of.get(keyword)
            if mapping is None:
                problems.append(f"{keyword} 无法通过 ip xfrm state 回读校验(iproute2 不打印)")
                continue
            dim, slot = mapping
            actual = ((info["config"] or {}).get(dim) or {}).get(slot)
            if actual is None:
                actual_parsed = None
            else:
                actual_parsed = int(actual)
            if actual_parsed != value:
                problems.append(
                    f"{keyword} 期望 {value},内核实际 {actual_parsed}"
                )
        for problem in limit.problems() if isinstance(limit, LimitConfig) else []:
            problems.append(f"配置自检: {problem}")
        return (not problems), problems

    def wait_limit_expire(self, target, src, dst, spi,
                          kind="bytes", timeout=60.0,
                          poll=1.0):
        """轮询等待某个维度的 hard 限制到限.

        :param kind: ``bytes`` / ``packets`` / ``seconds``
        :return: ``{"ok", "reason", "elapsed", "usage", "gone"}``

        hard 到限后内核会立刻删掉 state,因此 **state 消失也算达成**。
        """
        start = _time.time()
        dead_line = start + timeout
        last_usage = {}
        while True:
            usage = self.limit_usage(target, src, dst, spi)
            last_usage = usage
            elapsed = _time.time() - start
            if usage.get("gone", {}).get("gone"):
                return {"ok": True, "reason": f"SA 已到期被内核删除({kind} hard 到限)",
                        "elapsed": round(elapsed, 2), "usage": usage, "gone": True}
            dim = usage.get(kind) or {}
            if dim.get("reached"):
                return {"ok": True,
                        "reason": f"{kind} 已达 hard 限制 {dim.get('hard')}(当前 {dim.get('current')})",
                        "elapsed": round(elapsed, 2), "usage": usage, "gone": False}
            if _time.time() >= dead_line:
                return {"ok": False,
                        "reason": f"等待 {timeout}s 后 {kind} 仍未到 hard 限制"
                                  f"(当前 {dim.get('current')} / hard {dim.get('hard')})",
                        "elapsed": round(elapsed, 2), "usage": usage, "gone": False}
            _time.sleep(poll)

    def wait_state_gone(self, target, src, dst, spi,
                        timeout=60.0, poll=1.0):
        """轮询等待 SA 消失(hard limit 到期后内核删除),返回 ``(是否消失, 耗时秒)``."""
        start = _time.time()
        while _time.time() - start < timeout:
            if self.state_get(target, src, dst, spi) is None:
                return True, round(_time.time() - start, 2)
            _time.sleep(poll)
        return False, round(_time.time() - start, 2)

    def update_limit(self, target, sa,
                     limit):
        """给**已存在**的 SA 改 limit(``ip xfrm state update``),不修改传入的 sa 对象."""
        tmp = copy.copy(sa)
        tmp.set_limit(limit)
        cmd = tmp.update_cmd()
        logger.debug("[%s] %s", target, cmd)
        res = self.run(target, cmd)
        if not res.ok:
            logger.error("[%s] 更新 limit 失败(rc=%s): %s", target, res.rc, res.text)
        return res.ok

    # ------------------------------------------------------------ policy
    def policy_add(self, target, policy, check=True):
        cmd = policy.add_cmd()
        logger.debug("[%s] %s", target, cmd)
        res = self.run(target, cmd)
        if check and not res.ok:
            logger.error("[%s] policy 下发失败(rc=%s): %s\n%s", target, res.rc, cmd, res.text)
        return res

    def policy_del(self, target, policy):
        return self.run(target, policy.del_cmd())

    def policy_flush(self, target):
        return self.run(target, "ip xfrm policy flush")

    def policy_list(self, target):
        rc, out = self.run(target, "ip xfrm policy")
        return out if rc == 0 else []

    # ------------------------------------------------------------ 批量 / 清理
    def apply_sas(self, target, sas):
        """批量下发多条 SA,返回 {sa.spi: 是否成功}(多条SA可共用同一对地址)."""
        result = {}
        for sa in sas:
            result[sa.spi] = self.state_add(target, sa).ok
        return result

    def apply_policies(self, target, policies):
        return [self.run(target, p.add_cmd()).ok for p in policies]

    def flush(self, target, both=True):
        """清理现场:默认同时清 policy 与 state."""
        res = []
        res.append(self.policy_flush(target))
        if both:
            res.append(self.state_flush(target))
        return res

    # ------------------------------------------------------------ 前置检查
    def check_capability(self, target, dev, mode=OffloadMode.PACKET):
        """检查网卡/驱动是否声明了对应卸载能力.

        依次看:ethtool -k 的 esp-hw-offload / esp-hw-inline,
        以及 ``ls /sys/class/net/<dev>/device/...`` 的密文能力目录。
        """
        if mode == OffloadMode.SOFTWARE:
            return True
        rc, out = self.run(target, f"ethtool -k {dev}", timeout=15)
        if rc != 0:
            logger.warning("[%s] ethtool -k %s 失败,无法确认卸载能力", target, dev)
            return False
        blob = "\n".join(out)
        want = "esp-hw-offload" if mode == OffloadMode.PACKET else "esp-hw-inline"
        hit = want in blob
        logger.debug("[%s] %s 的 %s=%s", target, dev, want, hit)
        return hit

    def xfrm_state_summary(self, target):
        """人类可读的 SA 概览,便于日志/报告."""
        states = self.state_list(target)
        if not states:
            return f"[{target}] 无 xfrm state"
        lines = [f"[{target}] xfrm state 共 {len(states)} 条:"]
        for st in states:
            lines.append(
                "    spi={spi:<10} {src:>16} -> {dst:<16} {mode:<9} offload={offload} {algo}".format(
                    spi=st.get("spi", "-"),
                    src=st.get("src", "-"),
                    dst=st.get("dst", "-"),
                    mode=st.get("mode", "-"),
                    offload=st.get("offload", "-"),
                    algo=st.get("algo", ""),
                )
            )
        return "\n".join(lines)


# =====================================================================
# 输出解析
# =====================================================================
# ``ip xfrm state`` 用**制表符**缩进,且字段行的内容格式不统一,因此按“内容关键字”
# 而不是缩进层次来判断——按缩进判断在真实输出(缩进=1 个 tab)下会全军覆没。

# 承载标量 key=value 对的字段
SCALAR_KEYS = (
    "proto", "spi", "reqid", "mode", "seq", "flag",
    "replay-window", "replay-seq", "replay-oseq", "replay-seq-hi", "replay-oseq-hi",
    "if_id", "mark", "output-mark", "tfcpad",
)
# 整行剩余内容作为值的深字段
DEEP_KEYS = (
    "aead", "auth-trunc", "auth", "enc", "comp",
    "offload", "sel", "encap", "coa", "ctx", "extra-flag",
)

_LIMIT_ROW_RE = re.compile(
    r"limit:\s*soft\s*\((\S+)\)\((\w+)\)\s*,\s*hard\s*\((\S+)\)\((\w+)\)"
)
_CUR_ROW_RE = re.compile(r"^(\d+)\(bytes\)\s*,\s*(\d+)\(packets\)")
_TIME_ROW_RE = re.compile(r"^add\s+(.+?)\s+use\s+(.+?)\s*$")
_SPI_PAREN_RE = re.compile(r"\(\d+\)$")


def norm_spi(value):
    """把 ``0x1000`` / ``0x00001000(4096)`` / ``4096`` 统一成 int."""
    if value is None:
        return None
    text = _SPI_PAREN_RE.sub("", str(value).strip())
    try:
        return int(text, 16) if text.lower().startswith("0x") else int(text, 10)
    except ValueError:
        return None


def seconds_since(stamp):
    """把 iproute2 打印的 ``2026-09-26 23:20:00`` 换算成"距今多少秒".

    依赖被测机与本控机时钟接近;无法解析时返回 None(不参与判定)。
    """
    if not stamp or stamp.strip() in ("-", ""):
        return None
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"):
        try:
            then = datetime.strptime(stamp.strip(), fmt)
        except ValueError:
            continue
        return round((datetime.now() - then).total_seconds(), 3)
    return None


def _limit_value(raw):
    """``(INF)`` -> None(无限);否则取数值."""
    if raw in ("INF", "-", "", "0xffffffff"):
        return None
    try:
        return int(raw, 0)
    except ValueError:
        return None


def parse_xfrm_state(lines, with_stats=False):
    r"""把 ``ip xfrm state`` 的输出解析为 dict 列表.

    兼容真实内核输出(制表符缩进、spi/reqid 带十进制括号、lifetime 三段 limit)::

        src 192.168.10.10 dst 192.168.10.11
                proto esp spi 0x00001000(4096) reqid 0(0) mode transport
                replay-window 32 seq 0x00000000 flag af-unspec (0x00100000)
                aead rfc4106(gcm(aes)) 0x... 128
                offload packet dev ens2f0 dir out
                lifetime config:
                  limit: soft (INF)(bytes), hard (1048576)(bytes)
                  limit: soft (INF)(packets), hard (1000)(packets)
                  limit: soft (INF)(seconds), hard (3600)(seconds)
                  add 2026-09-26 23:20:00 use -
                lifetime current:
                  65536(bytes), 64(packets)
                  add 2026-09-26 23:20:00 use 2026-09-26 23:20:01
                stats:
                  replay-window 0 replay 0 failed 0

    产出字段除 ``src/dst/spi/proto/mode/reqid/offload/enc/auth/aead/sel`` 外还有:

    * ``lft_cfg`` : ``{"bytes": {"soft","hard"}, "packets": {...}, "seconds": {...}}``
    * ``lft_cur`` : ``{"bytes": int, "packets": int}``
    * ``add_time`` / ``use_time`` :字符串时间戳
    """
    states = []
    cur = {}
    section = ""

    for raw in lines:
        line = raw.rstrip()
        if not line.strip():
            continue

        # ---- 新的一条 state(顶层,无缩进)
        m = re.match(r"^src (\S+) dst (\S+)\s*(.*)$", line)
        if m and not line[:1].isspace():
            if cur:
                states.append(cur)
            cur = {"src": m.group(1), "dst": m.group(2), "tail": m.group(3).strip()}
            section = ""
            continue
        if not cur:
            continue

        text = line.strip()

        # ---- 小节头
        if text.startswith("lifetime config"):
            section = "cfg"
            cur.setdefault("lft_cfg", {})
            continue
        if text.startswith("lifetime current"):
            section = "cur"
            cur.setdefault("lft_cur", {})
            continue
        if text.startswith("stats"):
            section = "stats"
            cur.setdefault("stats", {})
            continue

        # ---- lifetime 小节
        if section == "cfg":
            mm = _LIMIT_ROW_RE.search(text)
            if mm:
                soft_raw, dim_s, hard_raw, dim_h = mm.groups()
                cur["lft_cfg"][(dim_s or dim_h).lower()] = {
                    "soft": _limit_value(soft_raw),
                    "hard": _limit_value(hard_raw),
                }
                continue
            mm = _TIME_ROW_RE.match(text)
            if mm:
                cur["lft_cfg"]["add_expires"] = mm.group(1).strip()
                cur["lft_cfg"]["use_expires"] = mm.group(2).strip()
                continue

        if section == "cur":
            mm = _CUR_ROW_RE.match(text)
            if mm:
                cur["lft_cur"]["bytes"] = int(mm.group(1))
                cur["lft_cur"]["packets"] = int(mm.group(2))
                continue
            mm = _TIME_ROW_RE.match(text)
            if mm:
                cur["add_time"] = mm.group(1).strip()
                cur["use_time"] = mm.group(2).strip()
                continue

        if section == "stats":
            for key, value in _iter_pairs(text):
                if re.match(r"^\d+$", value):
                    value = int(value)
                cur["stats"][key] = value
            continue

        # ---- 深字段:整行剩余内容作为值
        hit = False
        for key in DEEP_KEYS:
            prefix = key + " "
            if not text.startswith(prefix):
                continue
            rest = text[len(prefix):].strip()
            if key == "offload":
                cur["offload"] = rest
                mm = re.search(r"dir (\w+)", rest)
                if mm:
                    cur["offload_dir"] = mm.group(1)
                mm = re.match(r"^(?:device\s+)?(crypto|packet)\s+dev\s+(\S+)", rest) or \
                     re.match(r"^(?:device\s+)?(\S+)", rest)
                if mm:
                    cur["offload_dev"] = mm.group(2) if mm.lastindex == 2 else mm.group(1)
                mm = re.match(r"(crypto|packet)\s", rest)
                if mm:
                    cur["offload_type"] = mm.group(1)
            elif key == "sel":
                cur["sel"] = rest
            else:
                role = ("aead" if key == "aead" else
                        "comp" if key == "comp" else
                        "auth" if "auth" in key else "enc")
                algo = re.match(r"'([^']+)'\s+(0x\S+)", rest)
                if algo:
                    cur[role] = algo.group(1)
                    cur[role + "_key"] = algo.group(2)
                else:
                    tokens = rest.split()
                    if tokens:
                        cur[role] = tokens[0]
                    if len(tokens) > 1:
                        cur[role + "_key"] = tokens[1]
            hit = True
            break
        if hit:
            continue

        # ---- 标量 key value 行
        for key, value in _iter_pairs(text):
            cur.setdefault(key, _SPI_PAREN_RE.sub("", value))

    if cur:
        states.append(cur)

    for st in states:
        st["algo"] = st.get("enc") or st.get("auth") or st.get("aead") or ""
        st["_spi_int"] = norm_spi(st.get("spi"))
        if with_stats:
            st.setdefault("stats", {})
    return states


def _iter_pairs(text):
    """从 ``a X b Y`` 这样的行里取出已知 SCALAR_KEYS 的 key/value 对."""
    tokens = text.split()
    pairs = []
    i = 0
    while i < len(tokens):
        key = tokens[i]
        if key in SCALAR_KEYS and i + 1 < len(tokens):
            pairs.append((key, tokens[i + 1]))
            i += 2
            continue
        i += 1
    return pairs
#########################################################
"""打流与校验工具:ping / iperf3 / iperf2 / 抗重放攻击模拟.

所有流量都经过 xfrm,因此能否 ping 通、能否跑出带宽,就是 IPsec 卸载是否
真正工作的第一道判据。

iperf 部分统一走 ``nic_tf.ipsec.iperf``:**后台进程 + 远端输出文件**,
成功判据是「服务端文件出现 listening + 客户端文件出现 connected + 带宽不跌 0」,
详见 ``iperf.py`` 模块头部的说明。
"""


import re

from ..common.log import get_logger
from .iperf import DEFAULT_RUN_DIR, IperfConfig, IperfResult, IperfRole, IperfRunner, IperfVersion, has_bandwidth_drop

logger = get_logger("ipsec.traffic")


class Traffic:
    """在已建立 IPsec 的两端之间打流."""

    def __init__(
        self,
        exc,
        default_timeout=300.0,
        iperf_run_dir=DEFAULT_RUN_DIR,
        iperf_poll=1.0,
    ):
        self.exc = exc
        self.default_timeout = default_timeout
        self.iperf_run_dir = iperf_run_dir
        self.iperf_poll = iperf_poll
        self._iperf = None

    # ------------------------------------------------------------ ping
    def ping(
        self,
        src,
        dst_ip,
        src_ip=None,
        count=5,
        size=None,
        interval=0.2,
    ):
        """从某端 ping 目标业务 IP(走 IPsec 后应仍能通)."""
        opts = [f"-c {count}", f"-i {interval}", "-W 2"]
        if size:
            opts.append(f"-s {size}")
        if src_ip:
            opts.append(f"-I {src_ip}")
        cmd = f"ping {' '.join(opts)} {dst_ip} 2>&1"
        return self.exc.exec_cmd(src, cmd, timeout=count * 3 + 20)

    def ping_ok(self, src, dst_ip, **kwargs):
        res = self.ping(src, dst_ip, **kwargs)
        return res.ok and "0% packet loss" in res.text

    def ping_report(self, src, dst_ip, **kwargs):
        """返回 ping 的统计信息(收包数、丢包率、平均时延)."""
        res = self.ping(src, dst_ip, **kwargs)
        info = {"target": src, "dst": dst_ip, "ok": res.ok, "text": res.text}
        m = re.search(r"(\d+) packets transmitted, (\d+) received", res.text)
        if m:
            info["tx"], info["rx"] = int(m.group(1)), int(m.group(2))
        m = re.search(r"([\d.]+)% packet loss", res.text)
        if m:
            info["loss"] = float(m.group(1))
        m = re.search(r"rtt min/avg/max/mdev = [\d.]+/([\d.]+)/", res.text)
        if m:
            info["avg_rtt"] = float(m.group(1))
        return info

    # ------------------------------------------------------------ iperf 后台打流
    @property
    def iperf(self):
        """后台 iperf 运行器(iperf3 / iperf2 通用)."""
        if self._iperf is None:
            self._iperf = IperfRunner(
                self.exc, run_dir=self.iperf_run_dir, poll_interval=self.iperf_poll
            )
        return self._iperf

    def iperf_stream(
        self,
        server_target,
        client_target,
        server_ip,
        version=IperfVersion.IPERF3,
        client_bind=None,
        server_bind=None,
        **kwargs,
    ):
        """后台打一次流,返回含两端判定的 :class:`IperfSession`.

        ``session.ok`` 为真的前提是三件事同时成立:服务端文件出了 listening、
        客户端文件出了 connected、两端带宽采样都没有跌 0。
        """
        return self.iperf.run(
            server_target,
            client_target,
            version=version,
            server_ip=server_ip,
            server_bind=server_bind,
            client_bind=client_bind,
            **kwargs,
        )

    def iperf3(self, src, server_ip, **kwargs):
        """从 src 端向“另一端”打 iperf3 流(服务端自动跑在 src 的对端机器上).

        说明:旧版这里返回前台执行的 CmdResult;现改为后台 + 输出文件的方式,
        返回 :class:`IperfSession`(含两端判定),取带宽用
        ``session.throughput_mbps``,取失败原因用 ``session.problems``。
        """
        return self.iperf_stream(src, self._opposite(src), server_ip, **kwargs)

    def iperf2(self, src, server_ip, **kwargs):
        """同上,但使用 iperf2(``iperf`` 二进制、默认端口 5001)."""
        kwargs.setdefault("version", IperfVersion.IPERF2)
        return self.iperf3(src, server_ip, **kwargs)

    def iperf_between(self, src_port, dst_port, **kwargs):
        """按 Port 打流:自动取两端的 target 与业务 IP(最常用).

        服务端跑在 ``dst_port`` 所在的那台机器上,客户端从 ``src_port`` 出发,
        双向 bind 都绑定到各自的业务 IP(走 IPsec 的那条链路)。
        """
        kwargs.setdefault("server_bind", dst_port.ip)
        kwargs.setdefault("client_bind", src_port.ip)
        return self.iperf_stream(
            dst_port.target.value, src_port.target.value, dst_port.ip, **kwargs
        )

    def throughput(self, src, server_ip, **kwargs):
        """跑一次 iperf 并返回吞吐(Mbps);失败返回 0."""
        return self.iperf3(src, server_ip, **kwargs).throughput_mbps

    @staticmethod
    def parse_iperf3(lines):
        """从 iperf 输出文本里提取吞吐(Mbps).

        兼容旧接口;内部复用后台打流同一套解析器(优先 receiver 汇总行)。
        """
        result = IperfResult.from_output(
            IperfVersion.IPERF3, IperfRole.CLIENT, lines,
            require_samples=False, check_zero=False,
        )
        return result.throughput_mbps

    def bandwidth_drop(self, result, zero_threshold=0.0):
        """判断某份 iperf 结果是否存在带宽跌 0:返回 ``(是否跌0, 描述列表)``."""
        return has_bandwidth_drop(result, zero_threshold)

    @staticmethod
    def _opposite(target):
        from ..core.target import Target

        return Target.parse(target).opposite.value

    # ------------------------------------------------------------ iperf server 生命周期
    def iperf3_server_start(self, target, bind_ip=None,
                            port=5201, version=IperfVersion.IPERF3,
                            wait=True, **kwargs):
        """后台起服务端并等待 listening 出现在输出文件里.

        注意:iperf3 的 ``-D`` 会把输出丢弃,所以这里不用 ``-D``,而是由 shell
        后台 + 文件重定向来承接输出,这样 listening 才能被验证到。
        """
        cfg = IperfConfig.server(version=version, bind_ip=bind_ip, port=port, **kwargs)
        cfg.run_dir = self.iperf_run_dir
        pid, ready = self.iperf.start_server(target, cfg, wait=wait)
        return bool(pid and (ready or not wait))

    def iperf3_server_stop(self, target):
        """兜底清理:杀掉该端所有 iperf server 进程."""
        self.exc.exec_cmd(
            target, "pkill -9 -f 'iperf3 -s' 2>/dev/null; pkill -9 -f 'iperf -s' 2>/dev/null; true",
            timeout=15,
        )

    # ------------------------------------------------------------ 抗重放/异常
    def push_seq(self, target, sa_key, oseq):
        """把某条 SA 的发送序号推到指定值,用于构造越界/超窗口场景.

        ``ip xfrm state update ... replay-oseq N``
        """
        cmd = (
            f"ip xfrm state update src {sa_key['src']} dst {sa_key['dst']} "
            f"proto esp spi {sa_key['spi']} replay-oseq {oseq}"
        )
        return self.exc.exec_cmd(target, cmd, timeout=20).ok

    def replay_window_stats(self, target, src, dst, spi):
        """读取某条 SA 的抗重放计数(seq/oseq/bitmap)。"""
        cmd = f"ip -s xfrm state | grep -A4 'src {src} dst {dst}' || true"
        res = self.exc.exec_cmd(target, cmd, timeout=20)
        info = {"found": False}
        for line in res.out:
            if "seq 0x" in line:
                info["found"] = True
                m = re.search(r"seq 0x([0-9a-f]+)", line)
                if m:
                    info["seq"] = int(m.group(1), 16)
                m = re.search(r"oseq 0x([0-9a-f]+)", line)
                if m:
                    info["oseq"] = int(m.group(1), 16)
                m = re.search(r"bitmap 0x([0-9a-f]+)", line)
                if m:
                    info["bitmap"] = m.group(1)
        return info

    def xfrm_error_stats(self, target):
        """读取 /proc/net/xfrm_stat 里的错误计数,用于判断是否被抗重放丢包."""
        cmd = "cat /proc/net/xfrm_stat 2>/dev/null || true"
        res = self.exc.exec_cmd(target, cmd, timeout=15)
        stats = {}
        for line in res.out:
            m = re.match(r"^(\w+)\s+(\d+)", line)
            if m:
                stats[m.group(1)] = m.group(2)
        return stats
###########################################################
"""卸载矩阵编排 —— 两端可用不同卸载方式、同一 IP 多 SA、一个端口多 IP.

典型互相组合::

    Scenario.pf_to_peer_pf(env, algos=[...], matrix=[("packet","packet"), ("crypto","software")])
    Scenario.vf_to_pf(env, ...)          # 同机 VF 与 PF 之间
    Scenario.vf_to_vf_same_pf(env, ...)  # 同一个 PF 下的两个 VF 之间

并且支持:
  * ``multi_sa_per_ip``:同一对 IP 上配多条 SA(不同 spi/reqid),用于验证多 SA 选择
  * ``multi_ip``:一个端口配多个业务 IP,每个 IP 各自一套 SA/Policy
"""


from itertools import product

from ..common.log import get_logger
from .models import DIR_IN, DIR_OUT, IPsecPolicy, IPsecSA, MODE_TRANSPORT, MODE_TUNNEL, Bundle, LimitConfig, OffloadMode, ReplayConfig, Selector
from .xfrm import XfrmManager

logger = get_logger("ipsec.scenario")


class LinkSpec:
    """一条 IPsec 链路的描述(两端端口 + 算法 + 卸载方式 + 可选 NAT-T/重放配置)."""

    def __init__(
        self,
        local,
        remote,
        suite=None,
        local_offload=OffloadMode.PACKET,
        remote_offload=OffloadMode.PACKET,
        spi_base=0x1000,
        mode=MODE_TRANSPORT,
        encap_local=None,
        encap_remote=None,
        replay=None,
        sa_count=1,
        local_ips=None,
        remote_ips=None,
        name="",
        limit=None,
        limit_local=None,
        limit_remote=None,
        **sa_kwargs,
    ):
        self.local = local
        self.remote = remote
        self.suite = suite
        self.local_offload = OffloadMode.parse(local_offload)
        self.remote_offload = OffloadMode.parse(remote_offload)
        self.spi_base = spi_base
        self.mode = mode
        self.encap_local = encap_local
        self.encap_remote = encap_remote
        self.replay = replay or ReplayConfig()
        self.sa_count = sa_count          # 同一对 IP 上的 SA 条数
        self.local_ips = list(local_ips or [])
        self.remote_ips = list(remote_ips or [])
        self.name = name or f"{local.alias}<->{remote.alias}"
        self.sa_kwargs = sa_kwargs
        # 生命周期限制:两端可配不同值(limit_local/remote 优先于统一的 limit)
        self.limit_local = LimitConfig.coerce(limit_local or limit)
        self.limit_remote = LimitConfig.coerce(limit_remote or limit)

    @property
    def is_cross_host(self):
        return self.local.target != self.remote.target

    def describe(self):
        return (
            f"{self.name}: {self.local_offload}/{self.remote_offload} "
            f"sa_count={self.sa_count} natt={bool(self.encap_local)}"
        )


class Scenario:
    """IPsec 场景编排器:把 LinkSpec 变成两端实际下发的 SA/Policy."""

    def __init__(self, exc):
        self.exc = exc
        self.xfrm = XfrmManager(exc)

    # ============================================================ 构建 SA
    def build_bundles(self, spec):
        """根据 LinkSpec 生成 Bundle 列表.

        每个 (local_ip, remote_ip, sa_index) 组合生成一个 Bundle。
        """
        bundles = []
        local_ips = spec.local_ips or ([spec.local.ip] if spec.local.ip else [])
        remote_ips = spec.remote_ips or ([spec.remote.ip] if spec.remote.ip else [])
        if not local_ips or not remote_ips:
            raise ValueError(f"{spec.name}: 两端必须配置业务 IP")

        spi = spec.spi_base
        for lip in local_ips:
            for rip in remote_ips:
                for idx in range(spec.sa_count):
                    out_sa = IPsecSA(
                        src=lip, dst=rip, spi=spi, mode=spec.mode,
                        suite=spec.suite, offload=spec.local_offload,
                        dev=spec.local.offload_dev(), direction=DIR_OUT,
                        selector=_selector(lip, rip, spec),
                        replay=spec.replay, encap=spec.encap_local,
                        limit=spec.limit_local,
                        **spec.sa_kwargs,
                    )
                    in_sa = IPsecSA(
                        src=rip, dst=lip, spi=spi, mode=spec.mode,
                        suite=spec.suite, offload=spec.remote_offload,
                        dev=spec.remote.offload_dev(), direction=DIR_IN,
                        selector=_selector(rip, lip, spec),
                        replay=spec.replay, encap=spec.encap_remote,
                        limit=spec.limit_remote,
                        **spec.sa_kwargs,
                    )
                    # 同一两委:key 必须一致 → 用本地 SA 的密钥覆盖到对端
                    in_sa.keys = dict(out_sa.keys)

                    policies = [
                        IPsecPolicy(src=lip, dst=rip, direction=DIR_OUT,
                                    tmpl_src=lip, tmpl_dst=rip, mode=spec.mode, reqid=spi),
                        IPsecPolicy(src=rip, dst=lip, direction=DIR_IN,
                                    tmpl_src=rip, tmpl_dst=lip, mode=spec.mode, reqid=spi),
                    ]
                    bundles.append(
                        Bundle(
                            out_sa, in_sa, policies,
                            name=f"{spec.name}#{idx}({lip}->{rip})",
                            local_target=spec.local.target.value,
                            remote_target=spec.remote.target.value,
                        )
                    )
                    spi += 1
        return bundles

    # ============================================================ 下发 / 清理
    def apply(self, bundles, auto_ip=True):
        """把 Bundle 下发到各自所属的 target.

        跨主机场景:local_out 下到 local.target,remote_in 下到 remote.target;
        同机场景(VF↔PF / VF↔VF):两条都下到同一台,只是 netdev 不同。
        """
        result = {}
        for bundle in bundles:
            lo, ri = bundle.local_out, bundle.remote_in
            ok_out = self.xfrm.state_add(bundle.local_target, lo).ok
            ok_in = self.xfrm.state_add(bundle.remote_target, ri).ok
            ok_pol = True
            for pol in bundle.policies:
                # dir in 的 policy 属于接收端
                target = bundle.remote_target if pol.direction == DIR_IN else bundle.local_target
                ok_pol = self.xfrm.policy_add(target, pol, check=True).ok and ok_pol
            result[bundle.name] = bool(ok_out and ok_in and ok_pol)
            logger.info("  %s -> %s", bundle.describe(), "OK" if result[bundle.name] else "FAIL")
        return result

    def teardown(self, targets=("dut", "peer")):
        """清理两端的 xfrm 现场."""
        for target in targets:
            self.xfrm.flush(target)

    # ============================================================ 拓扑模板
    def pf_to_peer_pf(
        self,
        suite=None,
        local_offload=OffloadMode.PACKET,
        remote_offload=OffloadMode.PACKET,
        **kwargs,
    ):
        """PF ↔ 对端 PF(最经典的卸载互操作场景)."""
        dut_pf, peer_pf = self.exc.ports.pairs()[0]
        spec = LinkSpec(dut_pf, peer_pf, suite, local_offload, remote_offload,
                        name="pf<->peer-pf", **kwargs)
        return self.build_bundles(spec)

    def vf_to_pf(
        self,
        suite=None,
        vf_index=0,
        pf_port="nic0",
        target="dut",
        local_offload=OffloadMode.PACKET,
        remote_offload=OffloadMode.PACKET,
        **kwargs,
    ):
        """同机 VF ↔ PF:验证同一块网卡内部的 SA 卸载。"""
        pf = self.exc.ports.get(target, pf_port)
        vf = pf.vf(vf_index)
        if not vf.ip:
            vf.ip = _next_ip(pf.ip, vf_index + 1)
            vf.set_ip()
        spec = LinkSpec(vf, pf, suite, local_offload, remote_offload,
                        name=f"vf{vf_index}<->pf", **kwargs)
        return self.build_bundles(spec)

    def vf_to_vf_same_pf(
        self,
        suite=None,
        pf_port="nic0",
        target="dut",
        vf_pair=(0, 1),
        local_offload=OffloadMode.PACKET,
        remote_offload=OffloadMode.PACKET,
        **kwargs,
    ):
        """同一 PF 下的两个 VF 互通(常用于 switchdev representor 场景)。"""
        pf = self.exc.ports.get(target, pf_port)
        vf0, vf1 = pf.vf(vf_pair[0]), pf.vf(vf_pair[1])
        for vf, off in ((vf0, 1), (vf1, 2)):
            if not vf.ip:
                vf.ip = _next_ip(pf.ip, off)
                vf.up()
                vf.set_ip()
        spec = LinkSpec(vf0, vf1, suite, local_offload, remote_offload,
                        name=f"vf{vf_pair[0]}<->vf{vf_pair[1]}", **kwargs)
        return self.build_bundles(spec)

    # ============================================================ 矩阵
    def offload_matrix(self, modes=(OffloadMode.SOFTWARE,
                                                     OffloadMode.CRYPTO,
                                                     OffloadMode.PACKET)):
        """生成所有两端卸载方式组合,含非对称组合(如 全卸载 vs 纯软)。"""
        modes = list(modes)
        return list(product(modes, modes))


# =====================================================================
# 辅助
# =====================================================================
def _selector(src, dst, spec):
    """tunnel 模式需要显式 sel;transport 模式内核可自动推导."""
    if spec.mode == MODE_TUNNEL:
        return Selector(src=src, dst=dst)
    return None


def _next_ip(base_ip, offset):
    """在同一网段上生成一个新 IP(给 VF 用),如 192.168.10.10 + 1 -> 192.168.10.11."""
    if not base_ip:
        return ""
    try:
        octets = base_ip.split(".")
        octets[3] = str(int(octets[3]) + offset)
        return ".".join(octets)
    except (IndexError, ValueError):
        return ""


def _port_of(exc, ifname, _unused):  # pragma: no cover
    """按 netdev 名反查 Port 对象(未来扩展用)."""
    for target in ("dut", "peer"):
        port = exc.ports.get(target, ifname, raise_on_missing=False)
        if port:
            return port
    return None
##############################################################
"""SA / Policy 数据模型与 ``ip xfrm`` 命令生成.

三个抽象层级::

    IPsecSA      一条 xfrm state(含 offload 模式、重放窗口、NAT-T 封装)
    IPsecPolicy  一条 xfrm policy(用 tmpl 关联到 SA 的 reqid)
    Bundle       一对双向 SA + 两条 Policy,且允许**两端使用不同卸载方式**

命令示例(全卸载 transport,含生命周期限制)::

    ip xfrm state add src 192.168.10.10 dst 192.168.10.11 proto esp spi 0x1000 \\
        reqid 0x1000 mode transport replay-window 64 \\
        aead 'rfc4106(gcm(aes))' 0x<key+salt> 128 \\
        limit byte-hard 1073741824 limit packet-hard 1000000 limit time-hard 3600 \\
        offload packet dev ens2f0 dir out \\
        sel src 192.168.10.10 dst 192.168.10.11

注意 ``limit`` 每个限制项都要单独带一个关键字(LIMIT-LIST 语法),详见
:class:`LimitConfig`。
"""


import re
import secrets

from .crypto import suite_aead
from ..common.log import get_logger

logger = get_logger("ipsec.models")

DIR_IN = "in"
DIR_OUT = "out"
DIR_FWD = "fwd"

MODE_TRANSPORT = "transport"
MODE_TUNNEL = "tunnel"
MODE_BEET = "beet"
MODE_ROUTEOPTIMIZATION = "ro"


class OffloadMode:
    """卸载方式.

    三种语义:
      * ``SOFTWARE`` 纯软:不加任何 offload 参数,协议栈软件加解密
      * ``CRYPTO``   半卸载(crypto/inline offload):仅加解密交给硬件,
                     封装/查表仍走内核,命令 ``offload crypto dev X dir Y``
      * ``PACKET``   全卸载(packet offload):整包处理卸载到网卡,
                     命令 ``offload packet dev X dir Y``
    """

    SOFTWARE = "software"
    CRYPTO = "crypto"
    PACKET = "packet"
    CRYPTO_LEGACY = "crypto_legacy"  # 老接口写法:offload dev X dir Y

    ALL = (SOFTWARE, CRYPTO, PACKET, CRYPTO_LEGACY)

    _CLI = {
        CRYPTO: "offload crypto dev {dev} dir {dir}",
        PACKET: "offload packet dev {dev} dir {dir}",
        CRYPTO_LEGACY: "offload dev {dev} dir {dir}",
    }

    @classmethod
    def cli(cls, mode, dev, direction):
        """生成 offload 命令行片段;SOFTWARE 返回空串."""
        if mode == cls.SOFTWARE:
            return ""
        if mode not in cls._CLI:
            raise ValueError(f"未知卸载模式: {mode},可选 {cls.ALL}")
        return cls._CLI[mode].format(dev=dev, dir=direction)

    @classmethod
    def is_offloaded(cls, mode):
        return mode != cls.SOFTWARE

    @classmethod
    def parse(cls, value):
        if isinstance(value, str):
            key = value.strip().lower()
            aliases = {
                "none": cls.SOFTWARE, "sw": cls.SOFTWARE, "soft": cls.SOFTWARE,
                "half": cls.CRYPTO, "inline": cls.CRYPTO,
                "full": cls.PACKET, "sw/hw-invalid": cls.CRYPTO,
            }
            key = aliases.get(key, key)
            if key not in cls.ALL:
                raise ValueError(f"未知卸载模式: {value},可选 {cls.ALL}")
            return key
        return getattr(value, "value", str(value))


class ReplayConfig:
    """抗重放配置.

    :param window:  ``replay-window N``,滑动窗口大小(0 表示关闭抗重放)
    :param oseq:    ``replay-oseq N``,把**发送**序号抬到指定值,
                    用来构造“序号越界/超窗口”的 hard limit 场景
    :param iseq:    ``replay-iseq N``,期望收到的起始序号
    """

    def __init__(self, window=32, oseq=None, iseq=None):
        self.window = window
        self.oseq = oseq
        self.iseq = iseq

    def cli(self):
        parts = []
        if self.window is not None:
            parts.append(f"replay-window {self.window}")
        if self.oseq is not None:
            parts.append(f"replay-oseq {self.oseq}")
        if self.iseq is not None:
            parts.append(f"replay-iseq {self.iseq}")
        return " ".join(parts)

    def __repr__(self):  # pragma: no cover
        return f"<ReplayConfig window={self.window} oseq={self.oseq}>"


class EncapConfig:
    """NAT-T 封装配置(UDP 4500).

    ``encap espinudp <sport> <dport> <oa>``;oa 一般为 0.0.0.0。
    """

    def __init__(self, enabled=True, sport=4500, dport=4500,
                 oa="0.0.0.0"):
        self.enabled = enabled
        self.sport = sport
        self.dport = dport
        self.oa = oa

    def cli(self):
        if not self.enabled:
            return ""
        return f"encap espinudp {self.sport} {self.dport} {self.oa}"

    @classmethod
    def natt(cls, sport=4500, dport=4500):
        return cls(True, sport, dport)

    def __repr__(self):  # pragma: no cover
        return f"<EncapConfig nat-t={self.enabled} {self.sport}->{self.dport}>"


# =====================================================================
# limit 取值解析:把 "1G" / "512MB" / "90s" / "1m30s" / "10k" 归一为整数
# =====================================================================
_SIZE_UNITS = {
    "": 1, "b": 1, "byte": 1, "bytes": 1,
    "k": 1024, "kb": 1000, "kib": 1024,
    "m": 1024 ** 2, "mb": 1000 ** 2, "mib": 1024 ** 2,
    "g": 1024 ** 3, "gb": 1000 ** 3, "gib": 1024 ** 3,
    "t": 1024 ** 4, "tb": 1000 ** 4, "tib": 1024 ** 4,
}

_TIME_UNITS = {
    "": 1, "s": 1, "sec": 1, "secs": 1, "second": 1, "seconds": 1,
    "m": 60, "min": 60, "mins": 60, "minute": 60, "minutes": 60,
    "h": 3600, "hour": 3600, "hours": 3600,
    "d": 86400, "day": 86400, "days": 86400,
}

_COUNT_UNITS = {"": 1, "k": 1000, "m": 1000 ** 2, "g": 1000 ** 3}

_NUM_RE = re.compile(r"^(\d+(?:\.\d+)?)([a-z]*)$")


def parse_size(value):
    """解析字节数:``1073741824`` / ``1G`` / ``512MB`` / ``2GiB`` -> int."""
    return _parse_number(value, _SIZE_UNITS, "字节数", "1024 / 1M / 512MB / 2GiB")


def parse_count(value):
    """解析包数:``1000000`` / ``10k`` / ``1m`` -> int."""
    return _parse_number(value, _COUNT_UNITS, "包数", "1000 / 10k / 1m")


def parse_seconds(value):
    """解析秒数:``60`` / ``90s`` / ``1m30s`` / ``2h`` -> int.

    组合写法(``1m30s``)会逐段累加,因此也能表达任意组合。
    """
    if value is None:
        return None
    if isinstance(value, (int, float)):
        return int(value)
    text = str(value).strip().lower().replace(" ", "")
    if text.isdigit():
        return int(text)
    total = 0.0
    for num, unit in re.findall(r"(\d+(?:\.\d+)?)([a-z]*)", text):
        if unit not in _TIME_UNITS:
            raise ValueError(
                f"未知时间单位: {unit!r}(可选 {sorted(set(_TIME_UNITS))},示例 90 / 90s / 1m30s / 2h)"
            )
        total += float(num) * _TIME_UNITS[unit]
    return int(total)


def _parse_number(value, units, what, example):
    if value is None:
        return None
    if isinstance(value, int):
        return value
    if isinstance(value, float):
        return int(value)
    text = str(value).strip().lower().replace(" ", "")
    m = _NUM_RE.match(text)
    if not m:
        raise ValueError(f"无法解析{what}: {value!r}(示例:{example})")
    unit = m.group(2)
    if unit not in units:
        raise ValueError(f"未知{what}单位: {unit!r}(示例:{example})")
    num = float(m.group(1)) * units[unit]
    result = int(num)
    if abs(num - result) > 1e-6:
        raise ValueError(f"{what}必须是整数: {value!r} -> {num}")
    return result


class LimitConfig:
    """生命周期软/硬限制(``ip xfrm ... limit ...``).

    内核支持的限制项(iproute2 man page 的 LIMIT):

    ============================  ==================================
    参数                          下发关键字
    ============================  ==================================
    ``byte_soft/byte_hard``       byte-soft / byte-hard   (字节数)
    ``packet_soft/packet_hard``   packet-soft / packet-hard(包数)
    ``time_soft/time_hard``       time-soft / time-hard   (秒,自 SA 添加起算)
    ``time_use_soft/_use_hard``   time-use-soft / time-use-hard(秒,自**首次使用**起算)
    ============================  ==================================

    语法要点:**每个限制项都要各带一个 ``limit`` 关键字**::

        ... limit byte-hard 1073741824 limit packet-hard 1000000 limit time-hard 3600

    写成 ``limit byte-hard X packet-hard Y`` 会被 iproute2 当成多余参数报错。
    这一点对应 man page 的 ``LIMIT-LIST := [ LIMIT-LIST ] limit LIMIT``。

    取值支持人类可读写法:``"1G"`` / ``"512MB"`` / ``"10k"``(包数)/ ``"90s"`` /
    ``"1m30s"`` / ``"2h"``,最终一律按整数下发,避免依赖 iproute2 的版本差异。
    """

    FIELDS = (
        "byte_soft", "byte_hard",
        "packet_soft", "packet_hard",
        "time_soft", "time_hard",
        "time_use_soft", "time_use_hard",
    )

    KEYWORDS = {
        "byte_soft": "byte-soft",
        "byte_hard": "byte-hard",
        "packet_soft": "packet-soft",
        "packet_hard": "packet-hard",
        "time_soft": "time-soft",
        "time_hard": "time-hard",
        "time_use_soft": "time-use-soft",
        "time_use_hard": "time-use-hard",
    }

    _PARSERS = {
        "byte_soft": parse_size,
        "byte_hard": parse_size,
        "packet_soft": parse_count,
        "packet_hard": parse_count,
        "time_soft": parse_seconds,
        "time_hard": parse_seconds,
        "time_use_soft": parse_seconds,
        "time_use_hard": parse_seconds,
    }

    def __init__(
            self,
            byte_soft=None,
            byte_hard=None,
            packet_soft=None,
            packet_hard=None,
            time_soft=None,
            time_hard=None,
            time_use_soft=None,
            time_use_hard=None,
    ):
        values = locals()
        for field in self.FIELDS:
            setattr(self, field, self._PARSERS[field](values[field]))

    # ------------------------------------------------------------ 工厂
    @classmethod
    def hard(
            cls,
            byte=None,
            packets=None,
            seconds=None,
            use_seconds=None,
    ):
        """只配 hard 限制(字节 / 包数 / 时间,最常用)."""
        return cls(
            byte_hard=byte, packet_hard=packets,
            time_hard=seconds, time_use_hard=use_seconds,
        )

    @classmethod
    def from_dict(cls, data):
        """从 dict 构造,key 支持 ``byte-hard`` 与 ``byte_hard`` 两种写法."""
        kwargs = {}
        for key, value in data.items():
            field = str(key).strip().replace("-", "_")
            if field not in cls.KEYWORDS:
                logger.warning("忽略未知 limit 字段: %s(可选 %s)", key, sorted(cls.KEYWORDS))
                continue
            kwargs[field] = value
        return cls(**kwargs)

    @classmethod
    def coerce(cls, value):
        """把 None / LimitConfig / dict 统一成 LimitConfig(供 SA 参数容错)."""
        if value is None or isinstance(value, cls):
            return value
        if isinstance(value, dict):
            return cls.from_dict(value)
        raise TypeError(f"limit 只接受 LimitConfig 或 dict,收到 {type(value).__name__}")

    # ------------------------------------------------------------ 输出
    def cli(self):
        parts = []
        for field in self.FIELDS:
            value = getattr(self, field)
            if value is None:
                continue
            parts.append(f"limit {self.KEYWORDS[field]} {value}")
        return " ".join(parts)

    def is_empty(self):
        return not self.cli()

    def items(self):
        return [(self.KEYWORDS[f], getattr(self, f)) for f in self.FIELDS
                if getattr(self, f) is not None]

    def as_dict(self):
        return dict(self.items())

    def describe(self):
        if self.is_empty():
            return "无 limit"
        return " ".join(f"{kw}={_fmt_limit_value(kw, val)}" for kw, val in self.items())

    def problems(self):
        """配置合法性自检:soft 必须小于 hard,且不能为负."""
        bad = []
        for soft_f, hard_f in (("byte_soft", "byte_hard"), ("packet_soft", "packet_hard"),
                               ("time_soft", "time_hard"), ("time_use_soft", "time_use_hard")):
            soft, hard = getattr(self, soft_f), getattr(self, hard_f)
            for field, value in ((soft_f, soft), (hard_f, hard)):
                if value is not None and value < 0:
                    bad.append(f"{self.KEYWORDS[field]} 不能为负: {value}")
            if soft is not None and hard is not None and soft > hard:
                bad.append(
                    f"{self.KEYWORDS[soft_f]}({soft}) 不应大于 "
                    f"{self.KEYWORDS[hard_f]}({hard})"
                )
        return bad

    def __repr__(self):  # pragma: no cover
        return f"<LimitConfig {self.describe()}>"


def _fmt_limit_value(keyword, value):
    """把限制值格式化成人类可读串(用于日志/报告)."""
    if keyword.startswith("byte-"):
        # 从大到小挑第一个“值 >= 1 个单位”的量级,除不尽时保留两位小数
        for unit, scale in (("TB", 1024 ** 4), ("GB", 1024 ** 3),
                            ("MB", 1024 ** 2), ("KB", 1024)):
            if value and value >= scale:
                human = f"{value / scale:.2f}".rstrip("0").rstrip(".")
                return f"{human}{unit}"
        return f"{value}B"
    if keyword.startswith("packet-"):
        return f"{value}pkt"
    return f"{value}s"


class Selector:
    """流量选择器 ``sel src A dst B [proto X] [dport/sport]``."""

    def __init__(self, src="", dst="", proto="",
                 sport="", dport=""):
        self.src = src
        self.dst = dst
        self.proto = proto
        self.sport = sport
        self.dport = dport

    def cli(self, prefix="sel"):
        parts = [prefix]
        if self.src:
            parts.append(f"src {self.src}")
        if self.dst:
            parts.append(f"dst {self.dst}")
        if self.proto:
            parts.append(f"proto {self.proto}")
        if self.sport:
            parts.append(f"sport {self.sport}")
        if self.dport:
            parts.append(f"dport {self.dport}")
        return " ".join(parts)

    def __repr__(self):  # pragma: no cover
        return f"<Selector {self.src} -> {self.dst}>"


class IPsecSA:
    """一条 IPsec 安全关联(xfrm state).

    :param src / dst: 外层地址(传输模式即通信两端)
    :param spi:       SPI,int 或 '0x..' 字符串均可
    :param reqid:     policy 与 state 关联的 reqid(缺省取 spi)
    :param suite:     算法组合(AEAD 或 cipher+auth)
    :param keys:      显式给定密钥;缺省自动生成
    :param offload:   卸载方式(见 OffloadMode)
    :param dev:       offload 绑定的 netdev
    :param direction: in / out / fwd
    :param selector:  sel 选择器(tunnel 模式必填)
    :param limit:     生命周期限制,见 LimitConfig(byte-hard / packet-hard / time-hard ...)
    """

    def __init__(
            self,
            src,
            dst,
            spi=0x1000,
            reqid=None,
            mode=MODE_TRANSPORT,
            suite=None,
            keys=None,
            offload=OffloadMode.SOFTWARE,
            dev="",
            direction=DIR_OUT,
            selector=None,
            replay=None,
            encap=None,
            proto="esp",
            mark=None,
            output_mark=None,
            key_seed=None,
            limit=None,
    ):
        self.src = src
        self.dst = dst
        self.spi = spi if isinstance(spi, str) else hex(spi)
        self.reqid = self._as_hex(reqid if reqid is not None else spi)
        self.mode = mode
        self.suite = suite or suite_aead()
        self.offload = OffloadMode.parse(offload)
        self.dev = dev
        self.direction = direction
        self.selector = selector
        self.replay = replay or ReplayConfig()
        self.encap = encap
        self.proto = proto
        self.mark = mark
        self.output_mark = output_mark

        # 生命周期限制(byte-hard / packet-hard / time-hard ...)
        self.limit = LimitConfig.coerce(limit)

        # 密钥:AEAD 用 "aead",组合模式用 "cipher"/"auth"
        self.keys = dict(keys or {})
        for algo, slot in self._key_slots:
            if slot not in self.keys:
                self.keys[slot] = algo.gen_key(deterministic=key_seed or f"{self.src}-{self.dst}-{self.spi}-{slot}")

    # ------------------------------------------------------------ 属性
    @property
    def _key_slots(self):
        if self.suite.aead:
            return [(self.suite.aead, "aead")]
        slots = []
        if self.suite.cipher:
            slots.append((self.suite.cipher, "cipher"))
        if self.suite.auth:
            slots.append((self.suite.auth, "auth"))
        return slots

    @staticmethod
    def _as_hex(value):
        return value if isinstance(value, str) else hex(value)

    @property
    def key_hex(self):
        """返回用于下发的密钥(不带 0x 前缀的原始 hex 也返回,调用方自行加 0x)."""
        return self.keys

    # ------------------------------------------------------------ 命令
    def crypto_cli(self):
        """生成加密相关命令片段."""
        suite = self.suite
        if suite.aead:
            key = f"0x{self.keys['aead']}"
            return f"aead '{suite.aead.name}' {key} {suite.aead.icv_bits}"
        parts = []
        if suite.cipher:
            parts.append(f"enc '{suite.cipher.name}' 0x{self.keys['cipher']}")
        if suite.auth:
            parts.append(f"auth '{suite.auth.name}' 0x{self.keys['auth']}")
        return " ".join(parts)

    def limit_cli(self):
        """生成 ``limit byte-hard N limit packet-hard N ...`` 片段(无配置时为空串)."""
        return self.limit.cli() if self.limit else ""

    def set_limit(self, limit):
        """给已有 SA 挂/改 limit(返回自身,便于链式调用)."""
        self.limit = LimitConfig.coerce(limit)
        return self

    def state_cmd(self, action="add"):
        """生成完整的 ``ip xfrm state <action> ...`` 命令."""
        if self.offload != OffloadMode.SOFTWARE and not self.dev:
            raise ValueError(f"卸载模式 {self.offload} 必须指定 dev")

        parts = ["ip xfrm state", action, f"src {self.src} dst {self.dst}",
                            f"proto {self.proto} spi {self.spi}", f"reqid {self.reqid}", f"mode {self.mode}",
                            self.crypto_cli(), self.replay.cli(), self.limit_cli()]
        # man page 顺序:[sel] [LIMIT-LIST] [encap] ... [offload]
        if self.encap:
            encap_cli = self.encap.cli()
            if encap_cli:
                parts.append(encap_cli)
        offload_cli = OffloadMode.cli(self.offload, self.dev, self.direction)
        if offload_cli:
            parts.append(offload_cli)
        if self.mark:
            parts.append(f"mark {self.mark}")
        if self.output_mark:
            parts.append(f"output-mark {self.output_mark}")
        if self.selector:
            parts.append(self.selector.cli())
        return " ".join(p for p in parts if p)

    def add_cmd(self):
        return self.state_cmd("add")

    def update_cmd(self):
        return self.state_cmd("update")

    def del_cmd(self):
        return (
            f"ip xfrm state del src {self.src} dst {self.dst} "
            f"proto {self.proto} spi {self.spi}"
        )

    def key_material(self):
        """导出密钥/算法信息,供 tshark 解密使用."""
        return {
            "src": self.src,
            "dst": self.dst,
            "spi": self.spi,
            "proto": self.proto,
            "mode": self.mode,
            "keys": self.keys,
            "suite": self.suite,
            "encap": self.encap,
        }

    def __repr__(self):  # pragma: no cover
        return (
            f"<IPsecSA {self.src}->{self.dst} spi={self.spi} {self.suite.describe()} "
            f"offload={self.offload}>"
        )


class IPsecPolicy:
    """一条 xfrm policy.

    ``ip xfrm policy add <sel-src/dst> dir X tmpl src A dst B proto esp mode M reqid R``
    """

    def __init__(
            self,
            src,
            dst,
            direction=DIR_OUT,
            tmpl_src="",
            tmpl_dst="",
            proto="esp",
            mode=MODE_TRANSPORT,
            reqid=0x1000,
            priority=None,
            mark=None,
            action="allow",
            limit=None,
    ):
        self.src = src
        self.dst = dst
        self.direction = direction
        self.tmpl_src = tmpl_src
        self.tmpl_dst = tmpl_dst
        self.proto = proto
        self.mode = mode
        self.reqid = reqid if isinstance(reqid, str) else hex(reqid)
        self.priority = priority
        self.mark = mark
        self.action = action
        self.limit = LimitConfig.coerce(limit)

    def policy_cmd(self, action="add"):
        parts = [
            "ip xfrm policy", action,
            f"src {self.src} dst {self.dst}",
            f"dir {self.direction}",
        ]
        if self.priority is not None:
            parts.append(f"priority {self.priority}")
        if self.mark:
            parts.append(f"mark {self.mark}")
        parts.append(f"action {self.action}")
        if self.limit:
            parts.append(self.limit.cli())  # man page: [LIMIT-LIST] [TMPL-LIST]
        parts.append(
            f"tmpl src {self.tmpl_src} dst {self.tmpl_dst} "
            f"proto {self.proto} reqid {self.reqid} mode {self.mode}"
        )
        return " ".join(parts)

    def add_cmd(self):
        return self.policy_cmd("add")

    def del_cmd(self):
        return (
            f"ip xfrm policy del src {self.src} dst {self.dst} dir {self.direction}"
        )

    def __repr__(self):  # pragma: no cover
        return f"<IPsecPolicy {self.src}->{self.dst} dir={self.direction} reqid={self.reqid}>"


class Bundle:
    """一对双向 SA + 双向 Policy.

    支持**本端和对端使用不同的卸载方式**,这是 offload 互操作测试的关键::

        Bundle(local=SA(..., offload=PACKET), remote=SA(..., offload=CRYPTO))
    """

    def __init__(
            self,
            local_out,
            remote_in,
            policies=None,
            name="",
            local_target="dut",
            remote_target="peer",
    ):
        self.local_out = local_out  # 本端发往远端(dir out)
        self.remote_in = remote_in  # 远端接收(dir in)
        self.policies = list(policies or [])
        self.name = name or f"{local_out.src}-{local_out.spi}"
        # 两端可以位于同一台机器(VF<->PF),也可以是两台机器(PF<->对端PF)
        self.local_target = local_target
        self.remote_target = remote_target

    @property
    def cross_host(self):
        return self.local_target != self.remote_target

    @property
    def local_offload(self):
        return self.local_out.offload

    @property
    def remote_offload(self):
        return self.remote_in.offload

    def describe(self):
        return (
            f"{self.name}: {self.local_offload}(local) vs {self.remote_offload}(remote) "
            f"| {self.local_out.suite.describe()}"
        )

    def __repr__(self):  # pragma: no cover
        return f"<Bundle {self.describe()}>"


def gen_spi(base=0x1000, step=1, index=0):
    """按序号生成互不冲突的 SPI."""
    return base + index * step


def random_spi():
    """随机 SPI(避开 0~255 保留区间)."""
    return secrets.randbelow(0x0FFFFFFF) + 0x1000
###############################################################
"""iperf 后台打流工具:iperf3 与 iperf2 统一封装.

为什么要后台 + 输出文件
------------------------
前台跑 iperf(``exec_cmd("iperf3 -c ... -t 600")``)有三个问题:

1. SSH 会话被长时间占满,期间无法在这一端执行其它命令(比如同时查 xfrm 计数);
2. 命令只能靠 SSH 侧超时来终止,超时后拿不到有效结果;
3. 结果全在 stdout 里,事后无法复查,失败现场的详细信息会丢。

因此本模块统一采用 **后台进程 + 远端输出文件** 的方式:两端都 nohup 起来,输出重定向到
``{run_dir}/{tag}-{role}.log``,pid 写进同名 ``.pid``,之后再用 ``cat`` 读文件做判断。

成功判据也不再看返回值(后台拿不到退出码),而是三件套:

===========================  ==========================================================
服务端文件出现 ``listening``   证明 server 真的进入监听态(而不是起来就退)
客户端文件出现 ``connected``   证明 client 真的和服务端握手成功
带宽采样没有跌 0             逐条解析 ``-i`` 输出的间隔行,任何一条为 0 都算异常
===========================  ==========================================================

版本差异全部收敛在这里:

=====================  ==========================  ==============================
项目                    iperf3                      iperf2
=====================  ==========================  ==============================
可执行文件              ``iperf3``                   ``iperf``
默认端口                5201                        5001
服务端 UDP             自动识别                      必须显式 ``-u``
双向打流               ``--bidir``                   ``-d``
握手行                 ``connected to X port N``    ``connected with X port N``
=====================  ==========================  ==============================
"""


import re
import time
from enum import Enum

from ..common.log import get_logger

logger = get_logger("ipsec.iperf")

DEFAULT_RUN_DIR = "/tmp/nic_tf_iperf"
DEFAULT_PORTS = {"iperf3": 5201, "iperf2": 5001}
BINARIES = {"iperf3": "iperf3", "iperf2": "iperf"}

# ---------------------------------------------------------------- 输出特征正则
# [  4]   0.00-1.00   sec   112 MBytes   941 Mbits/sec    0   1.41 MBytes
# [SUM]   0.00-1.00   sec   448 MBytes  3.76 Gbits/sec    0
#    0.00-1.00   sec   112 MBytes   941 Mbits/sec        (daemon 日志里没有 [id] 前缀)
RE_INTERVAL = re.compile(
    r"^(?:\[\s*(?P<sid>\w+)\s*\]\s*)?"
    r"(?P<start>\d+(?:\.\d+)?)\s*-\s*(?P<end>\d+(?:\.\d+)?)\s*sec\s+"
    r"(?P<transfer>\d+(?:\.\d+)?)\s*(?P<tunit>[KMG]?)Bytes\s+"
    r"(?P<value>\d+(?:\.\d+)?)\s*(?P<bunit>[KMG]?)bits/sec"
)
RE_LISTENING = re.compile(r"listening", re.IGNORECASE)
RE_CONNECTED = re.compile(r"connected\s+(?:to|with)\s+(?P<peer>[0-9a-fA-F:.]+)", re.IGNORECASE)
RE_ERROR_LINE = re.compile(
    r"iperf3?:\s*error|bind\s+failed|address already in use|no route to host|"
    r"connection refused|unable to connect|the server is busy|read failed",
    re.IGNORECASE,
)
RE_SENDER_ROW = re.compile(r"sender\s*$", re.IGNORECASE)
RE_RECEIVER_ROW = re.compile(r"receiver\s*$", re.IGNORECASE)

_BIT_UNITS = {"": 1e-6, "K": 1e-3, "M": 1.0, "G": 1e3}      # 统一折算成 Mbps
_BYTE_UNITS = {"": 1e-6, "K": 1e-3, "M": 1.0, "G": 1e3}     # 统一折算成 MBytes


class IperfVersion(str, Enum):
    IPERF3 = "iperf3"
    IPERF2 = "iperf2"

    @classmethod
    def parse(cls, value):
        if isinstance(value, cls):
            return value
        text = str(value).strip().lower()
        if text in ("iperf3", "3", "v3"):
            return cls.IPERF3
        if text in ("iperf2", "iperf", "2", "2.0", "v2"):
            return cls.IPERF2
        raise ValueError(f"不支持的 iperf 版本: {value!r}(可选 iperf3 / iperf2)")


class IperfRole(str, Enum):
    SERVER = "server"
    CLIENT = "client"

    @classmethod
    def parse(cls, value):
        if isinstance(value, cls):
            return value
        text = str(value).strip().lower()
        if text in ("server", "s", "服务端"):
            return cls.SERVER
        if text in ("client", "c", "客户端"):
            return cls.CLIENT
        raise ValueError(f"不支持的 iperf 角色: {value!r}(可选 server / client)")


def to_mbps(value, unit):
    """把 ``941 Mbits/sec`` 这类读数折算成 Mbps."""
    return float(value) * _BIT_UNITS.get((unit or "").upper(), 1e-6)


def to_mbytes(value, unit):
    """把 ``112 MBytes`` 这类读数折算成 MBytes."""
    return float(value) * _BYTE_UNITS.get((unit or "").upper(), 1e-6)


def _fmt_num(value):
    """``1.0`` -> ``1``:iperf2 对 ``-i 1.0`` 这类带小数的写法比较挑剔."""
    try:
        number = float(value)
    except (TypeError, ValueError):
        return str(value)
    if number == int(number):
        return str(int(number))
    return str(value)


class BandwidthSample:
    """一条带宽采样,对应 iperf 输出里的一个 ``-i`` 间隔行."""

    def __init__(
        self,
        stream="",
        start=0.0,
        end=0.0,
        mbps=0.0,
        transfer_mbytes=0.0,
        raw="",
    ):
        self.stream = stream
        self.start = start
        self.end = end
        self.mbps = mbps
        self.transfer_mbytes = transfer_mbytes
        self.raw = raw

    @property
    def seconds(self):
        return max(0.0, self.end - self.start)

    @property
    def interval(self):
        return f"{self.start:.2f}-{self.end:.2f}"

    def as_dict(self):
        return {
            "stream": self.stream,
            "start": self.start,
            "end": self.end,
            "mbps": round(self.mbps, 3),
            "transfer_mbytes": round(self.transfer_mbytes, 3),
        }

    def __repr__(self):  # pragma: no cover
        return f"<Sample {self.stream or '-'} {self.interval}s {self.mbps:.2f} Mbps>"


class IperfConfig:
    """一次 iperf 运行的参数(版本 / 角色 / 传输选项 / 输出文件).

    两种角色共用同一份参数结构,只有 ``role`` 决定生成 server 还是 client 命令。
    """

    def __init__(
        self,
        role,
        version=IperfVersion.IPERF3,
        bind_ip=None,
        server_ip=None,
        port=None,
        udp=False,
        bandwidth=None,
        time_sec=10,
        parallel=1,
        interval=1.0,
        window=None,
        length=None,
        reverse=False,
        bidirectional=False,
        tos=None,
        affinity=None,
        report_format=None,
        extra="",
        one_off=True,
        run_dir=DEFAULT_RUN_DIR,
        tag="run",
        binary=None,
    ):
        _args = {k: v for k, v in locals().items() if k != "self"}
        for _key, _value in _args.items():
            setattr(self, _key, _value)
        self.role = IperfRole.parse(role)
        self.version = IperfVersion.parse(version)

    # ------------------------------------------------------------ 构造糖(role)
    @classmethod
    def server(cls, version=IperfVersion.IPERF3, **kwargs):
        return cls(IperfRole.SERVER, version=version, **kwargs)

    @classmethod
    def client(cls, server_ip, version=IperfVersion.IPERF3, **kwargs):
        return cls(IperfRole.CLIENT, version=version, server_ip=server_ip, **kwargs)

    # ------------------------------------------------------------ 派生属性
    @property
    def resolved_port(self):
        return int(self.port or DEFAULT_PORTS[self.version.value])

    @property
    def binary_name(self):
        return self.binary or BINARIES[self.version.value]

    def run_id(self):
        return f"{self.tag}-{self.role.value}"

    def out_path(self):
        return f"{self.run_dir}/{self.run_id()}.log"

    def pid_path(self):
        return self.out_path() + ".pid"

    # ------------------------------------------------------------ 命令生成
    def _shared_opts(self):
        opts = ["-p", str(self.resolved_port), "-i", _fmt_num(self.interval)]
        if self.report_format:
            opts += ["-f", str(self.report_format)]
        if self.affinity:
            opts += ["-A", str(self.affinity)]
        if self.window:
            opts += ["-w", str(self.window)]
        if self.tos:
            # iperf2 用 -S 指定 TOS;iperf3 3.x 早期也没有 -S,统一用 --tos 更安全
            opts += ["-S", str(self.tos)] if self.version is IperfVersion.IPERF2 else ["--tos", str(self.tos)]
        return opts

    def server_opts(self):
        opts = ["-s"] + self._shared_opts()
        if self.version is IperfVersion.IPERF2 and self.udp:
            # iperf2 服务端必须显式指定 UDP,iperf3 服务端会自动识别
            opts += ["-u"]
        if self.bind_ip:
            opts += ["-B", str(self.bind_ip)]
        if self.version is IperfVersion.IPERF3 and self.one_off:
            # 跑完一次就退出,避免残留 server 占着端口影响下一个用例
            opts += ["-1"]
        return opts

    def client_opts(self):
        if not self.server_ip:
            raise ValueError("client 角色必须给出 server_ip")
        opts = ["-c", str(self.server_ip)] + self._shared_opts()
        opts += ["-t", str(self.time_sec)]
        if self.parallel > 1:
            opts += ["-P", str(self.parallel)]
        if self.udp or self.bandwidth:
            opts += ["-u"]
        if self.bandwidth:
            opts += ["-b", str(self.bandwidth)]
        if self.bind_ip:
            opts += ["-B", str(self.bind_ip)]
        if self.length:
            opts += ["-l", str(self.length)]
        if self.reverse:
            opts += ["-R"]
        if self.bidirectional:
            opts += ["-d"] if self.version is IperfVersion.IPERF2 else ["--bidir"]
        return opts

    def opts(self):
        return self.server_opts() if self.role is IperfRole.SERVER else self.client_opts()

    def cmd(self):
        """生成 iperf 命令行(不含重定向,由 Runner 负责包后台). """
        parts = [self.binary_name] + [str(o) for o in self.opts()]
        if self.extra:
            parts.append(str(self.extra))
        return " ".join(parts)

    def describe(self):
        kind = "UDP" if (self.udp or self.bandwidth) else "TCP"
        return (
            f"{self.version.value} {self.role.value} {kind} "
            f"port={self.resolved_port} t={self.time_sec}s P={self.parallel} -> {self.out_path()}"
        )

    def __repr__(self):  # pragma: no cover
        return f"<IperfConfig {self.describe()}>"


class IperfResult:
    """一次 iperf 运行的结果(基于后台输出文件分析而来).

    :ivar listening: 服务端是否输出了 ``listening``(仅 server 角色)
    :ivar connected: 客户端是否输出了 ``connected``(仅 client 角色)
    :ivar samples: 聚合带宽采样(存在 ``[SUM]`` 行时取 SUM,否则取全部单流行)
    :ivar streams: 每个 stream id 的采样明细
    :ivar zeros: 带宽跌到 0(或低于阈值)的采样,非空即视为异常
    :ivar checks / problems: 成功判据逐项取值与失败原因
    """

    def __init__(
        self,
        version=IperfVersion.IPERF3,
        role=IperfRole.CLIENT,
        target="",
        out_file="",
        lines=None,
    ):
        self.version = IperfVersion.parse(version)
        self.role = IperfRole.parse(role)
        self.target = target
        self.out_file = out_file
        self.lines = list(lines or [])

        self.pid = ""
        self.listening = False
        self.connected = False
        self.peers = []
        self.finished = False
        self.elapsed = 0.0

        self.samples = []
        self.streams = {}
        self.zeros = []
        self.lows = []
        self.sender_mbps = 0.0
        self.receiver_mbps = 0.0
        self.throughput_mbps = 0.0
        self.has_summary = False
        self.min_mbps = 0.0
        self.max_mbps = 0.0
        self.avg_mbps = 0.0

        self.error_lines = []
        self.checks = {}
        self.problems = []

    # ------------------------------------------------------------ 语义属性
    @property
    def ok(self):
        return bool(self.checks) and all(self.checks.values())

    def __bool__(self):
        return self.ok

    @property
    def connected_count(self):
        """client 输出里 ``connected`` 出现的次数(多流会多次握手). """
        return sum(1 for ln in self.lines if RE_CONNECTED.search(ln))

    @property
    def listening_line(self):
        for ln in self.lines:
            if RE_LISTENING.search(ln):
                return ln.strip()
        return ""

    @property
    def connected_line(self):
        for ln in self.lines:
            if RE_CONNECTED.search(ln):
                return ln.strip()
        return ""

    def as_dict(self):
        return {
            "version": self.version.value,
            "role": self.role.value,
            "target": self.target,
            "out_file": self.out_file,
            "ok": self.ok,
            "finished": self.finished,
            "listening": self.listening,
            "connected": self.connected,
            "peers": self.peers,
            "sample_count": len(self.samples),
            "zero_count": len(self.zeros),
            "low_count": len(self.lows),
            "throughput_mbps": round(self.throughput_mbps, 3),
            "min_mbps": round(self.min_mbps, 3),
            "max_mbps": round(self.max_mbps, 3),
            "avg_mbps": round(self.avg_mbps, 3),
            "sender_mbps": round(self.sender_mbps, 3),
            "receiver_mbps": round(self.receiver_mbps, 3),
            "checks": dict(self.checks),
            "problems": list(self.problems),
        }

    def summary(self):
        base = (
            f"{self.version.value}/{self.role.value}@{self.target} "
            f"samples={len(self.samples)} thr={self.throughput_mbps:.2f}Mbps "
            f"min/max/avg={self.min_mbps:.2f}/{self.max_mbps:.2f}/{self.avg_mbps:.2f}"
        )
        if self.role is IperfRole.SERVER:
            base += f" listening={self.listening}"
        else:
            base += f" connected={self.connected}"
        base += f" zeros={len(self.zeros)} -> {'OK' if self.ok else 'FAIL: ' + ';'.join(self.problems)}"
        return base

    def zero_report(self):
        """仅列出跌 0 / 偏低的采样,空则返回一行提示."""
        if not self.zeros and not self.lows:
            return "无带宽跌 0 现象"
        items = [f"[{s.stream or '-'}] {s.interval}s -> {s.mbps:.2f} Mbps" for s in self.zeros]
        lines = [f"跌 0 采样 {len(self.zeros)} 条: " + "; ".join(items)] if self.zeros else []
        if self.lows:
            low = [f"[{s.stream or '-'}] {s.interval}s -> {s.mbps:.2f} Mbps" for s in self.lows]
            lines.append(f"偏低采样 {len(self.lows)} 条: " + "; ".join(low))
        return "\n".join(lines)

    def __repr__(self):  # pragma: no cover
        return f"<IperfResult {self.summary()}>"

    # ------------------------------------------------------------ 分析入口
    @classmethod
    def from_output(
        cls,
        version,
        role,
        lines,
        target="",
        out_file="",
        require_listening=True,
        require_connected=True,
        expect_peer=None,
        zero_threshold=0.0,
        check_zero=True,
        low_ratio=None,
        require_samples=True,
        require_summary=False,
        finished=True,
        elapsed=0.0,
        pid="",
    ):
        """从 iperf 输出文件的内容里分析出结构化结果并完成成功判据检查."""
        res = cls(version, role, target=target, out_file=out_file, lines=lines)
        res.pid = pid
        res.finished = finished
        res.elapsed = elapsed

        # --- 1) 握手/监听标记 ---
        for ln in lines:
            if RE_LISTENING.search(ln):
                res.listening = True
            m = RE_CONNECTED.search(ln)
            if m:
                res.connected = True
                if m.group("peer") not in res.peers:
                    res.peers.append(m.group("peer"))
            if RE_ERROR_LINE.search(ln):
                res.error_lines.append(ln.strip())

        # --- 2) 带宽采样 ---
        sender_rows = []
        receiver_rows = []
        for ln in lines:
            m = RE_INTERVAL.search(ln)
            if not m:
                continue
            sid = (m.group("sid") or "").strip()
            start, end = float(m.group("start")), float(m.group("end"))
            mbps = to_mbps(float(m.group("value")), m.group("bunit"))
            transfer = to_mbytes(float(m.group("transfer")), m.group("tunit"))
            sample = BandwidthSample(sid, start, end, mbps, transfer, ln.strip())

            if RE_RECEIVER_ROW.search(ln):
                res.has_summary = True
                receiver_rows.append((sid, mbps))
                continue
            if RE_SENDER_ROW.search(ln):
                res.has_summary = True
                sender_rows.append((sid, mbps))
                continue
            res.streams.setdefault(sid, []).append(sample)

        res.samples = _drop_total_row(_pick_aggregate(res.streams))

        # --- 3) 汇总统计 ---
        if res.samples:
            values = [s.mbps for s in res.samples]
            res.min_mbps, res.max_mbps = min(values), max(values)
            res.avg_mbps = sum(values) / len(values)

        def _pick(rows):
            if not rows:
                return 0.0
            for sid, val in rows:
                if sid.upper() == "SUM":
                    return val
            return float(rows[0][1])

        res.sender_mbps = _pick(sender_rows)
        res.receiver_mbps = _pick(receiver_rows)
        res.throughput_mbps = res.receiver_mbps or res.sender_mbps or res.max_mbps

        # --- 4) 跌 0 / 偏低判定 ---
        res.zeros = [s for s in res.samples if s.mbps <= zero_threshold]
        if low_ratio and res.max_mbps > 0:
            floor = res.max_mbps * low_ratio
            res.lows = [s for s in res.samples if s.mbps > zero_threshold and s.mbps < floor]

        # --- 5) 成功判据 ---
        checks = {}
        problems = []
        if res.role is IperfRole.SERVER and require_listening:
            checks["listening"] = res.listening
            if not res.listening:
                problems.append("服务端输出里没有 listening,服务可能没起来或起来即退出")
        if res.role is IperfRole.CLIENT and require_connected:
            checks["connected"] = res.connected
            if not res.connected:
                problems.append("客户端输出里没有 connected,未与服务端握手成功")
        if expect_peer:
            hit = expect_peer in res.peers
            checks["peer_match"] = hit
            if not hit:
                problems.append(f"握手对端不匹配: 期望 {expect_peer},实际 {res.peers or '无'}")
        if require_samples:
            checks["samples"] = bool(res.samples)
            if not res.samples:
                problems.append("没有解析到任何带宽采样行(是否忘了加 -i?)")
        if require_summary:
            checks["summary"] = res.has_summary
            if not res.has_summary:
                problems.append("没有 sender/receiver 汇总行,打流可能未正常收尾")
        if check_zero:
            checks["no_zero"] = not res.zeros
            if res.zeros:
                problems.append(
                    f"带宽出现跌 0: {len(res.zeros)}/{len(res.samples)} 个采样为 0"
                )
        if low_ratio is not None:
            checks["no_low"] = not res.lows
            if res.lows:
                problems.append(f"带宽低于峰值 {low_ratio:.0%} 的采样有 {len(res.lows)} 个")
        checks["no_error"] = not res.error_lines
        if res.error_lines:
            problems.append("iperf 自身报错: " + res.error_lines[0])

        res.checks = checks
        res.problems = problems
        return res


def _pick_aggregate(streams):
    """多看聚合还是看单流:存在 ``[SUM]`` 行时以 SUM 为准,否则原样返回全部行."""
    if not streams:
        return []
    if "SUM" in streams:
        return list(streams["SUM"])
    merged = []
    for samples in streams.values():
        merged.extend(samples)
    merged.sort(key=lambda s: (s.start, s.end))
    return merged


def _drop_total_row(samples):
    """剔掉 iperf2 的“整段汇总行”.

    iperf2 没有 sender/receiver 标记,收尾时会再打一条 ``0.0-10.0`` 的整段汇总,
    它长得和间隔行一模一样。留着会把 avg/min 稀释/拉平,所以只要有足够多的采样就丢掉它。
    """
    if len(samples) < 3:
        return samples
    first = min(s.start for s in samples)
    last = max(s.end for s in samples)
    kept = [s for s in samples if not (s.start == first and s.end == last)]
    return kept or samples


class IperfSession:
    """一次完整打流:server + client 的两端结果."""

    def __init__(
        self,
        server=None,
        client=None,
        server_cfg=None,
        client_cfg=None,
    ):
        self.server = server
        self.client = client
        self.server_cfg = server_cfg
        self.client_cfg = client_cfg

    @property
    def ok(self):
        """两端都通过才算成功(缺任何一端都 False).

        注意:这里必须用 ``is not None``,因为 IperfResult 的布尔值代表“是否成功”,
        失败的 result 会被 ``if res:`` 当成空值跳过。
        """
        if self.server is None or self.client is None:
            return False
        return bool(self.server.ok and self.client.ok)

    @property
    def problems(self):
        problems = []
        for role, res in (("server", self.server), ("client", self.client)):
            if res is None:
                problems.append(f"{role} 端结果缺失")
            else:
                problems += [f"[{role}] {p}" for p in res.problems]
        return problems

    @property
    def throughput_mbps(self):
        if self.client is not None and self.client.throughput_mbps:
            return self.client.throughput_mbps
        return self.server.throughput_mbps if self.server is not None else 0.0

    def as_dict(self):
        return {
            "ok": self.ok,
            "throughput_mbps": round(self.throughput_mbps, 3),
            "problems": self.problems,
            "server": self.server.as_dict() if self.server is not None else None,
            "client": self.client.as_dict() if self.client is not None else None,
        }

    def summary(self):
        lines = []
        for role, res in (("server", self.server), ("client", self.client)):
            if res is None:
                lines.append(f"  {role:6s}: <无结果>")
                continue
            lines.append(f"  {role:6s}: {res.summary()}")
            lines.append(f"          {res.zero_report()}")
        lines.append(f"  ===> {'PASS' if self.ok else 'FAIL'} "
                     f"thr={self.throughput_mbps:.2f} Mbps "
                     f"{'' if self.ok else self.problems}")
        return "\n".join(lines)

    def __repr__(self):  # pragma: no cover
        return f"<IperfSession ok={self.ok} thr={self.throughput_mbps:.2f}Mbps>"


class IperfRunner:
    """以“后台进程 + 输出文件”的方式驱动 iperf3 / iperf2,并做结果判定."""

    def __init__(
        self,
        exc,
        run_dir=DEFAULT_RUN_DIR,
        poll_interval=1.0,
        ready_timeout=10.0,
        finish_grace=15.0,
    ):
        self.exc = exc
        self.run_dir = run_dir
        self.poll_interval = poll_interval
        self.ready_timeout = ready_timeout
        self.finish_grace = finish_grace

    # ------------------------------------------------------------ 底层命令
    def _exec(self, target, cmd, timeout=None):
        """走底层 host 执行,避免轮询读文件时刷屏."""
        host = self.exc.host(target)
        return host.exec_cmd(cmd, timeout=timeout or 30.0)

    def _cfg(self, cfg):
        """让 config 继承 runner 的 run_dir(未显式指定时才覆盖)."""
        if cfg.run_dir == DEFAULT_RUN_DIR and self.run_dir != DEFAULT_RUN_DIR:
            cfg.run_dir = self.run_dir
        return cfg

    @staticmethod
    def _quote(body):
        """安全地塞进 ``sh -c '...'`` 单引号里."""
        return body.replace("'", "'\"'\"'")

    def launch_cmd(self, cfg):
        """生成“后台启动”的完整 shell 命令.

        要点:

        * ``mkdir + rm`` 保证每次运行日志文件干净;
        * ``stdbuf -oL -eL`` 解决重定向到文件后 glibc 变全缓冲、看不到实时输出的问题
          (没有 stdbuf 时自动退化,仍能跑,只是 polling 首帧会晚一点);
        * ``echo $$ > pid; exec ...`` 让 pid 文件里记录的正是 iperf 自身的 PID;
        * 子进程与 shell 会话脱离,SSH 断开也不影响打流。
        """
        cfg = self._cfg(cfg)
        body = self._quote(cfg.cmd())
        pid, out = cfg.pid_path(), cfg.out_path()
        return (
            f"mkdir -p {cfg.run_dir} && rm -f {out} {pid} && "
            f"LB=''; command -v stdbuf >/dev/null 2>&1 && LB='stdbuf -oL -eL'; export LB; "
            f"( nohup sh -c 'echo $$ > {pid}; exec $LB {body}' </dev/null >{out} 2>&1 & ); "
            f"sleep 0.5; cat {pid} 2>/dev/null || echo launch-failed"
        )

    def launch(self, target, cfg):
        """后台启动 iperf,返回 pid(失败返回空串)."""
        cfg = self._cfg(cfg)
        logger.info("[%s] 后台启动 iperf: %s", target, cfg.describe())
        rc, out = self._exec(target, self.launch_cmd(cfg), timeout=20)
        if rc != 0 and not out:
            logger.error("[%s] iperf 启动失败: %s", target, " | ".join(out))
            return ""
        pid = (out[0].strip() if out else "")
        if not pid.isdigit():
            logger.warning("[%s] 未取到 iperf pid: %s", target, " | ".join(out))
            return ""
        logger.debug("[%s] iperf pid=%s log=%s", target, pid, cfg.out_path())
        return pid

    def read_output(self, target, cfg):
        """读取远端输出文件当前内容."""
        cfg = self._cfg(cfg)
        rc, out = self._exec(target, f"cat {cfg.out_path()} 2>/dev/null || true", timeout=20)
        return out if rc >= 0 else []

    def running(self, target, cfg):
        """进程是否还在跑(用 pid 文件 + kill -0 判断)."""
        cfg = self._cfg(cfg)
        cmd = (
            f"P=$(cat {cfg.pid_path()} 2>/dev/null || true); "
            f'[ -n "$P" ] && kill -0 "$P" 2>/dev/null && echo RUNNING || echo STOPPED'
        )
        rc, out = self._exec(target, cmd, timeout=15)
        return bool(out) and out[-1].strip().endswith("RUNNING") and rc == 0

    def wait_ready(self, target, cfg, timeout=None):
        """等该角色应有的“就绪标记”出现:server 等 listening,client 等 connected."""
        cfg = self._cfg(cfg)
        pattern = RE_LISTENING if cfg.role is IperfRole.SERVER else RE_CONNECTED
        what = "listening" if cfg.role is IperfRole.SERVER else "connected"
        deadline = time.monotonic() + (timeout or self.ready_timeout)
        while time.monotonic() < deadline:
            lines = self.read_output(target, cfg)
            if any(pattern.search(ln) for ln in lines):
                logger.debug("[%s] iperf %s 已出现 %s", target, cfg.role.value, what)
                return True
            if not self.running(target, cfg) and lines:
                # 进程已经不在了却还没标记 —— 起不来,直接判失败
                logger.warning("[%s] iperf %s 已退出且未出现 %s", target, cfg.role.value, what)
                return False
            time.sleep(self.poll_interval)
        logger.warning("[%s] 等待 %s 超时(%ss)", target, what, timeout or self.ready_timeout)
        return False

    def wait_finished(self, target, cfg, timeout=None):
        """等进程自然结束;超时返回 False(不主动杀进程)."""
        cfg = self._cfg(cfg)
        limit = timeout if timeout is not None else cfg.time_sec + self.finish_grace
        deadline = time.monotonic() + limit
        while time.monotonic() < deadline:
            if not self.running(target, cfg):
                return True
            time.sleep(self.poll_interval)
        logger.warning("[%s] iperf %s 未在 %ss 内结束", target, cfg.role.value, limit)
        return False

    def stop(self, target, cfg):
        """停止后台 iperf:先按 pid 杀,再用命令行兜底."""
        cfg = self._cfg(cfg)
        cmd = (
            f"P=$(cat {cfg.pid_path()} 2>/dev/null || true); "
            f'[ -n "$P" ] && kill -9 "$P" 2>/dev/null; '
            f"pkill -9 -f '{self._quote(cfg.binary_name)} -s -p {cfg.resolved_port}' 2>/dev/null || true; "
            f"echo stopped"
        )
        rc, _ = self._exec(target, cmd, timeout=15)
        return rc == 0

    def collect(
        self,
        target,
        cfg,
        pid="",
        finished=True,
        elapsed=0.0,
        **opts,
    ):
        """读取输出文件并做完整判定."""
        cfg = self._cfg(cfg)
        lines = self.read_output(target, cfg)
        result = IperfResult.from_output(
            cfg.version,
            cfg.role,
            lines,
            target=target,
            out_file=cfg.out_path(),
            finished=finished,
            elapsed=elapsed,
            pid=pid,
            **opts,
        )
        logger.info("[%s] %s", target, result.summary())
        return result

    # ------------------------------------------------------------ 高层编排
    def start_server(
        self,
        target,
        cfg=None,
        wait=True,
        timeout=None,
        **kwargs,
    ):
        """启动 server 并等待 listening 出现,返回 ``(pid, ready)``."""
        if cfg is None:
            cfg = IperfConfig.server(**kwargs)
        cfg = self._cfg(cfg)
        pid = self.launch(target, cfg)
        ready = self.wait_ready(target, cfg, timeout) if (pid and wait) else False
        return pid, ready

    def run_client(
        self,
        target,
        cfg,
        wait=True,
        timeout=None,
        **collect_opts,
    ):
        """启动 client,等到进程结束后分析输出文件.

        成功判据默认包含:客户端出现 ``connected``、带宽采样无跌 0。
        """
        cfg = self._cfg(cfg)
        started = time.monotonic()
        pid = self.launch(target, cfg)
        if not pid:
            return IperfResult.from_output(
                cfg.version, cfg.role, [], target=target,
                out_file=cfg.out_path(), finished=False,
            )
        self.wait_ready(target, cfg, timeout=self.ready_timeout)
        finished = self.wait_finished(target, cfg, timeout) if wait else False
        return self.collect(
            target, cfg, pid=pid, finished=finished,
            elapsed=time.monotonic() - started,
            **collect_opts,
        )

    def run(
        self,
        server_target,
        client_target,
        version=IperfVersion.IPERF3,
        server_ip=None,
        server_bind=None,
        client_bind=None,
        port=None,
        udp=False,
        bandwidth=None,
        time_sec=10,
        parallel=1,
        interval=1.0,
        tag="run",
        auto_stop_server=True,
        extra_client="",
        **collect_opts,
    ):
        """一次完整打流:起 server → 等 listening → 起 client → 等 connected → 取文件判定.

        :param server_ip: 客户端要连的地址;省略时用服务端的业务 IP(由 Port 决定,见 Traffic)
        :param auto_stop_server: 结束后是否杀掉服务端(iperf2 服务端不会自退,务必留 True)
        """
        # server_ip 既是服务端自己的监听地址,也是客户端要连的地址
        srv_cfg = IperfConfig(
            IperfRole.SERVER, version=version, bind_ip=server_bind or server_ip,
            port=port, udp=udp, time_sec=time_sec, parallel=parallel, interval=interval, tag=tag,
        )
        cli_cfg = IperfConfig(
            IperfRole.CLIENT, version=version, server_ip=server_ip, bind_ip=client_bind,
            port=port, udp=udp, bandwidth=bandwidth, time_sec=time_sec,
            parallel=parallel, interval=interval, tag=tag, extra=extra_client,
        )
        srv_cfg.run_dir = cli_cfg.run_dir = self.run_dir

        logger.info("[%s] server cmd: %s", server_target, srv_cfg.cmd())
        logger.info("[%s] client cmd: %s", client_target, cli_cfg.cmd())

        pid, ready = self.start_server(server_target, srv_cfg)
        if not pid:
            # 起不来也要给出可读结果,而不是抛异常中断用例
            empty = IperfResult.from_output(
                version, IperfRole.SERVER, [], target=server_target,
                out_file=srv_cfg.out_path(), require_samples=False,
            )
            return IperfSession(server=empty, client=None, server_cfg=srv_cfg, client_cfg=cli_cfg)
        if not ready:
            logger.error("[%s] 服务端未进入 listening,放弃本次打流", server_target)

        client = self.run_client(client_target, cli_cfg, **collect_opts)
        server = self.collect(
            server_target, srv_cfg, pid=pid, finished=True, **collect_opts
        )
        if auto_stop_server:
            self.stop(server_target, srv_cfg)
        return IperfSession(server=server, client=client, server_cfg=srv_cfg, client_cfg=cli_cfg)


def has_bandwidth_drop(result, zero_threshold=0.0):
    """独立小工具:判断某份 iperf 输出是否存在带宽跌 0.

    :return: ``(是否跌0, 描述列表)``;跌 0 为 True。
    """
    zeros = [s for s in result.samples if s.mbps <= zero_threshold]
    if not zeros:
        return False, []
    desc = [
        f"[{s.stream or '-'}] {s.interval}s -> {s.mbps:.2f} Mbps"
        for s in zeros
    ]
    return True, desc
############################################################################
"""IPsec 算法库 —— xfrm 算法名、密钥规则与 tshark 解密名称映射.

覆盖三类算法角色::

    aead   : 认证加密一体(rfc4106(gcm(aes))、rfc4309(ccm(sm4))、chacha20-poly1305 ...)
    cipher : 仅加密(cbc(aes)、cbc(sm4)、ctr(aes)、NULL ...)
    auth   : 仅认证(hmac(sha256)、hmac(sm3)、cmac(aes)、xcbc(aes) ...)

要点:
  * ``name`` 是内核 xfrm 侧的算法字符串,直接写进 ``ip xfrm state``;
  * AEAD 的密钥 = 密钥本体 + 4 字节 nonce salt(RFC4106 要求),本模块自动拼接;
  * ``tshark_enc`` / ``tshark_auth`` 是 Wireshark esp_sa UAT 里的名字,
    国密/部分新算法 Wireshark 不支持,映射为 None 并在解密时给出提示。
"""


import secrets

AEAD_SALT_BYTES = 4  # rfc4106/ccm 等 AEAD 算法附带的 nonce salt 长度

ROLE_AEAD = "aead"
ROLE_CIPHER = "cipher"
ROLE_AUTH = "auth"


class CryptoAlgo:
    """一个密码算法.

    :param name: 内核 xfrm 名称,如 ``rfc4106(gcm(aes))``
    :param role: aead / cipher / auth
    :param key_bits: 算法密钥位数(AEAD 的场景指“密钥本体”,不含 salt)
    :param icv_bits: ICV(完整性校验值)截断长度,opt(仅 AEAD/auth 用)
    :param min_key_bits / max_key_bits: 允许的密钥长度范围
    :param tshark_enc: Wireshark esp_sa 中的加密名(不支持则 None)
    :param tshark_auth: Wireshark esp_sa 中的认证名
    """

    def __init__(
        self,
        name,
        role,
        key_bits=0,
        icv_bits=0,
        min_key_bits=0,
        max_key_bits=0,
        tshark_enc=None,
        tshark_auth=None,
    ):
        self.name = name
        self.role = role
        self.key_bits = key_bits
        self.icv_bits = icv_bits
        self.min_key_bits = min_key_bits or key_bits
        self.max_key_bits = max_key_bits or key_bits
        self.tshark_enc = tshark_enc
        self.tshark_auth = tshark_auth

    # ------------------------------------------------------------ 长度规则
    @property
    def is_aead(self):
        return self.role == ROLE_AEAD

    @property
    def key_bytes(self):
        """实际下发到内核的十六进制密钥字节数(AEAD 含 salt)."""
        body = self.key_bits // 8
        return body + AEAD_SALT_BYTES if self.is_aead else body

    def normalize_key_bits(self, key_bits=None):
        """校验并归一密钥长度,超出允许范围就取边界值."""
        want = key_bits or self.key_bits
        if want < self.min_key_bits or want > self.max_key_bits:
            raise ValueError(
                f"{self.name} 不支持 {want} bit 密钥,允许范围 "
                f"[{self.min_key_bits}, {self.max_key_bits}]"
            )
        return want

    def with_key_bits(self, key_bits=None):
        """返回使用指定密钥长度的同名算法副本."""
        bits = self.normalize_key_bits(key_bits)
        return CryptoAlgo(
            name=self.name,
            role=self.role,
            key_bits=bits,
            icv_bits=self.icv_bits,
            min_key_bits=self.min_key_bits,
            max_key_bits=self.max_key_bits,
            tshark_enc=self.tshark_enc,
            tshark_auth=self.tshark_auth,
        )

    # ------------------------------------------------------------ 密钥
    def gen_key(self, key_bits=None, deterministic=None):
        """生成十六进制密钥(不带 0x 前缀).

        :param deterministic: 给定该字符串时按其内容确定性派生(便于复现),否则随机
        """
        bits = self.normalize_key_bits(key_bits)
        total = self.key_bytes_for_bits(bits)
        if deterministic is not None:
            # 用 SHA256 做 key-stretch,保证同样入参得到同样密钥
            seed = f"{self.name}|{bits}|{deterministic}".encode()
            return self._derive(seed, total)
        return secrets.token_hex(total)

    def key_bytes_for_bits(self, key_bits=None):
        bits = self.normalize_key_bits(key_bits)
        body = bits // 8
        return body + AEAD_SALT_BYTES if self.is_aead else body

    @staticmethod
    def _derive(seed, total):
        """确定性派生:把 seed 反复哈希展开到所需长度(无需第三方库)."""
        import hashlib

        out = b""
        counter = 0
        while len(out) < total:
            out += hashlib.sha256(seed + bytes([counter])).digest()
            counter += 1
        return out[:total].hex()

    def __repr__(self):  # pragma: no cover
        return f"<CryptoAlgo {self.role} {self.name}@{self.key_bits}>"


# =====================================================================
# 算法注册表
# =====================================================================
_AEAD_TABLE = {
    # AEAD(内核 xfrm 名 -> 算法)均为 密钥本体 + 4 字节 salt
    # 注意:tshark 的 esp_sa UAT 里算法名是**枚举值**,写错会被直接拒绝
    # (Invalid -o flag ... invalid value),实测 3.6.3 接受 "AES-GCM [RFC4106]",
    # 而不接受裸 "AES-GCM"。CCM / ChaCha20 在 3.6.3 无对应枚举,保留名字交给
    # capture.TsharkProbe 运行时探测(4.x 若支持即可自动用上)。
    "rfc4106_gcm_aes": CryptoAlgo(
        "rfc4106(gcm(aes))", ROLE_AEAD, 128, 128, 128, 256, tshark_enc="AES-GCM [RFC4106]",
    ),
    "rfc4543_gcm_aes": CryptoAlgo(
        "rfc4543(gcm(aes))", ROLE_AEAD, 128, 128, 128, 256, tshark_enc="AES-GCM [RFC4106]",
    ),
    "rfc4309_ccm_aes": CryptoAlgo(
        "rfc4309(ccm(aes))", ROLE_AEAD, 128, 128, 128, 256, tshark_enc="AES-CCM [RFC4309]",
    ),
    "rfc7539_chacha20_poly1305": CryptoAlgo(
        "rfc7539esp(chacha20,poly1305)", ROLE_AEAD, 256, 128, 256, 256,
        tshark_enc="ChaCha20-Poly1305 [RFC7539]",
    ),
    # 国密 SM4 —— Wireshark esp_sa 无对应名字,解密需自行扩展
    "gcm_sm4": CryptoAlgo("rfc4106(gcm(sm4))", ROLE_AEAD, 128, 128, 128, 128),
    "ccm_sm4": CryptoAlgo("rfc4309(ccm(sm4))", ROLE_AEAD, 128, 128, 128, 128),
}

_CIPHER_TABLE = {
    "cbc_aes": CryptoAlgo("cbc(aes)", ROLE_CIPHER, 128, 0, 128, 256, tshark_enc="AES-CBC [RFC3602]"),
    "ctr_aes": CryptoAlgo(
        "ctr(aes)", ROLE_CIPHER, 128, 0, 128, 256, tshark_enc="AES-CTR [RFC3686]",
    ),
    "rfc3686_ctr_aes": CryptoAlgo(
        "rfc3686(ctr(aes))", ROLE_CIPHER, 160, 0, 160, 288, tshark_enc="AES-CTR [RFC3686]",
    ),
    "cbc_des3_ede": CryptoAlgo(
        "cbc(des3_ede)", ROLE_CIPHER, 192, 0, 192, 192, tshark_enc="TripleDES-CBC [RFC2451]",
    ),
    "cbc_sm4": CryptoAlgo("cbc(sm4)", ROLE_CIPHER, 128, 0, 128, 128),
    "ctr_sm4": CryptoAlgo("ctr(sm4)", ROLE_CIPHER, 128, 0, 128, 128),
    "null": CryptoAlgo("ecb(cipher_null)", ROLE_CIPHER, 0, 0, 0, 0, tshark_enc="NULL"),
}

_AUTH_TABLE = {
    "hmac_md5": CryptoAlgo(
        "hmac(md5)", ROLE_AUTH, 128, 96, 128, 128, tshark_auth="HMAC-MD5-96 [RFC2403]",
    ),
    "hmac_sha1": CryptoAlgo(
        "hmac(sha1)", ROLE_AUTH, 160, 96, 160, 160, tshark_auth="HMAC-SHA-1-96 [RFC2404]",
    ),
    # tshark 的 SHA-2 系列名字带截断长度:SHA-256-128 / SHA-384-192 / SHA-512-256,
    # 写 "HMAC-SHA-256 [RFC4868]"(不带 -128)会被拒绝
    "hmac_sha256": CryptoAlgo(
        "hmac(sha256)", ROLE_AUTH, 256, 128, 128, 256, tshark_auth="HMAC-SHA-256-128 [RFC4868]",
    ),
    "hmac_sha384": CryptoAlgo(
        "hmac(sha384)", ROLE_AUTH, 384, 192, 384, 384, tshark_auth="HMAC-SHA-384-192 [RFC4868]",
    ),
    "hmac_sha512": CryptoAlgo(
        "hmac(sha512)", ROLE_AUTH, 512, 256, 512, 512, tshark_auth="HMAC-SHA-512-256 [RFC4868]",
    ),
    # 国密 SM3
    "hmac_sm3": CryptoAlgo("hmac(sm3)", ROLE_AUTH, 256, 128, 128, 256),
    "cmac_aes": CryptoAlgo("cmac(aes)", ROLE_AUTH, 128, 96, 128, 128),
    "xcbc_aes": CryptoAlgo(
        "xcbc(aes)", ROLE_AUTH, 128, 96, 128, 128, tshark_auth="AES-XCBC-MAC-96 [RFC3566]",
    ),
    "null_auth": CryptoAlgo("digest_null", ROLE_AUTH, 0, 0, 0, 0, tshark_auth="NULL"),
}

ALL_TABLES = {
    ROLE_AEAD: _AEAD_TABLE,
    ROLE_CIPHER: _CIPHER_TABLE,
    ROLE_AUTH: _AUTH_TABLE,
}


# ---------------------------------------------------------------- 查询接口
def aead(name, key_bits=None):
    return _get(ROLE_AEAD, name).with_key_bits(key_bits)


def cipher(name, key_bits=None):
    return _get(ROLE_CIPHER, name).with_key_bits(key_bits)


def auth(name, key_bits=None):
    return _get(ROLE_AUTH, name).with_key_bits(key_bits)


def _get(role, name):
    table = ALL_TABLES[role]
    key = name.lower()
    if key not in table:
        raise KeyError(f"未知{role}算法: {name},可选: {sorted(table)}")
    return table[key]


def list_algos(role=None):
    """列出可用算法别名(role 为空则返回全部)."""
    if role:
        return sorted(ALL_TABLES[role])
    return sorted(set(_AEAD_TABLE) | set(_CIPHER_TABLE) | set(_AUTH_TABLE))


def resolve(spec):
    """按别名或内核名取算法,如 ``gcm`` / ``aes-gcm`` / ``rfc4106(gcm(aes))``.

    同时也接受直接写内核名的形式,便于 yaml 里自由书写。
    """
    key = spec.strip().lower()
    for role, table in ALL_TABLES.items():
        if key in table:
            return table[key]
        for alias, algo in table.items():
            if algo.name.lower() == key:
                return algo
    raise KeyError(f"无法识别的算法: {spec},可用 aead={list_algos(ROLE_AEAD)} 等")


# 常用组合,便于测试用例直接引用
class Suite:
    """一组配套的算法组合(AEAD 或用 cipher+auth 组合)."""

    def __init__(self, aead=None,
                 cipher_algo=None,
                 auth_algo=None):
        if aead and (cipher_algo or auth_algo):
            raise ValueError("AEAD 与 cipher+auth 不能同时使用")
        if not aead and not cipher_algo:
            raise ValueError("至少需要 aead 或 cipher")
        self.aead = aead
        self.cipher = cipher_algo
        self.auth = auth_algo

    @property
    def is_aead(self):
        return self.aead is not None

    @property
    def icv_bits(self):
        return self.aead.icv_bits if self.aead else (self.auth.icv_bits if self.auth else 0)

    def describe(self):
        if self.aead:
            return f"{self.aead.name}/{self.aead.key_bits}"
        parts = [self.cipher.name]
        if self.auth:
            parts.append(self.auth.name)
        return "+".join(parts)

    def __repr__(self):  # pragma: no cover
        return f"<Suite {self.describe()}>"


def suite_aead(name="rfc4106_gcm_aes", key_bits=None):
    return Suite(aead=aead(name, key_bits))


def suite_sm4_gcm(key_bits=None):
    """国密 SM4-GCM."""
    return Suite(aead=aead("gcm_sm4", key_bits))


def suite_cbc_hmac(
    cipher_name="cbc_aes",
    auth_name="hmac_sha256",
    cipher_bits=None,
    auth_bits=None,
):
    return Suite(cipher_algo=cipher(cipher_name, cipher_bits), auth_algo=auth(auth_name, auth_bits))


def suite_sm4_cbc_sm3():
    """国密组合:SM4-CBC + HMAC-SM3."""
    return Suite(cipher_algo=cipher("cbc_sm4"), auth_algo=auth("hmac_sm3"))
################################################################################
"""抓包与 ESP 解密验证(tshark).

两个动作:

1. 在远端抓包(tcpdump),把 pcap 拉回本地;
2. 用 tshark 注入 SA 密钥解密,判断线上到底看到密文还是明文。

Wireshark 通过 UAT 表 ``esp_sa`` 接收密钥,命令行写法::

    tshark -r cap.pcap \\
        -o esp.enable_encryption_decode:TRUE \\
        -o 'uat:esp_sa:"IPv4","10.0.0.1","10.0.0.2","0x1000","AES-GCM [RFC4106]","0x..","NULL",""'

版本差异(实测,见 TsharkProbe):

* ``esp_sa`` 记录固定 8 个字段:protocol / src / dst / spi / enc / enc_key / auth / auth_key,
  少一个会报 ``expecting field authentication_key_string``;
* 算法名是**枚举值**,写错会被拒(``invalid value: AES-GCM``)。3.6.3 接受
  ``AES-GCM [RFC4106]``,不接受裸 ``AES-GCM``;SHA-2 认证必须带截断长度
  (``HMAC-SHA-256-128 [RFC4868]``);
* 不存在 ``esp.enable`` / ``esp.udp_encapsulation`` 这两个 preference,下发会直接
  ``unknown preference`` 报错退出。NAT-T 要用 Decode As:`-d udp.port==4500,udpencap`;
* 认证字段不能留空字符串,AEAD 场景填 ``NULL``。

因此本模块不做版本硬编码,而是在运行时探测 tshark 的版本、可用 preference 与算法名支持,
只下发当前版本认得的东西。
"""


import os
import re
import subprocess

from ..common.log import get_logger

logger = get_logger("ipsec.capture")

# 认证字段不能为空字符串(会被 UAT 拒绝),无认证时填 NULL
AUTH_NAME_FALLBACK = "NULL"

# 探测用的哑 pcap 路径:只为让 tshark 解析完 -o 参数,不真正读文件
_PROBE_PCAP = "__nic_tf_probe_nonexistent__.pcap"

# Windows 上装完 Wireshark 默认不进 PATH,这里按常见位置兜底查找
_TSHARK_CANDIDATES = (
    "tshark",
    r"C:\Program Files\Wireshark\tshark.exe",
    r"C:\Program Files (x86)\Wireshark\tshark.exe",
    "/usr/bin/tshark",
    "/usr/sbin/tshark",
    "/usr/local/bin/tshark",
)


def find_tshark(exe=None):
    """确定 tshark 可执行文件:显式指定 > 环境变量 TSHARK > PATH > 常见安装路径."""
    if exe:
        return exe
    env = os.environ.get("TSHARK")
    if env:
        return env
    for cand in _TSHARK_CANDIDATES:
        try:
            proc = subprocess.run([cand, "--version"], capture_output=True, text=True, timeout=30)
        except (OSError, subprocess.SubprocessError):
            continue
        if proc.returncode == 0 or "TShark" in (proc.stdout or ""):
            if cand != "tshark":
                logger.debug("tshark 不在 PATH,改用: %s", cand)
            return cand
    return "tshark"


class TsharkDecodeError(RuntimeError):
    """tshark 解密相关错误."""


class TsharkProbe:
    """探测本机 tshark 的版本、可用 preference 与算法名支持情况(结果缓存).

    不同 Wireshark 版本差异很大,硬编码选项名和算法名会在换机器时直接失败,
    所以这里一律用 ``tshark -G currentprefs`` 与试跑 UAT 的方式去问 tshark 本人。
    """

    def __init__(self, exe=None):
        self.exe = find_tshark(exe)
        self._version = None
        self._prefs = None
        self._algo = {}

    # ------------------------------------------------------------ 基本信息
    def available(self):
        """tshark 是否存在且可执行."""
        try:
            proc = subprocess.run([self.exe, "--version"], capture_output=True, text=True, timeout=30)
        except (OSError, subprocess.SubprocessError):
            return False
        return proc.returncode == 0 or "TShark" in (proc.stdout or "")

    def version(self):
        """返回 ``(major, minor, micro)``;探测不到返回 None."""
        if self._version is not None:
            return self._version or None
        self._version = False
        try:
            proc = subprocess.run([self.exe, "--version"], capture_output=True, text=True, timeout=30)
        except (OSError, subprocess.SubprocessError) as exc:
            logger.warning("tshark 不可用: %s", exc)
            return None
        m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", proc.stdout or "")
        if m:
            self._version = (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
        return self._version or None

    def version_str(self):
        ver = self.version()
        return ".".join(str(x) for x in ver) if ver else "unknown"

    # ------------------------------------------------------------ preference
    def prefs(self):
        """当前版本支持的所有 preference 名(来自 ``tshark -G currentprefs``)."""
        if self._prefs is not None:
            return self._prefs
        names = set()
        try:
            proc = subprocess.run([self.exe, "-G", "currentprefs"],
                                  capture_output=True, text=True, timeout=60)
            for line in (proc.stdout or "").splitlines():
                if ":" in line:
                    names.add(line.split(":", 1)[0].strip())
        except (OSError, subprocess.SubprocessError) as exc:
            logger.warning("读取 tshark preference 失败: %s", exc)
        self._prefs = names
        return names

    def has_pref(self, name):
        return name in self.prefs()

    def first_pref(self, *candidates):
        """返回候选列表中第一个当前版本存在的 preference 名,都不存在返回 None."""
        for name in candidates:
            if self.has_pref(name):
                return name
        return None

    # ------------------------------------------------------------ 算法名
    def algo_supported(self, enc_name, auth_name="NULL"):
        """试探某个算法名是否被当前 tshark 的 esp_sa UAT 接受(结果缓存)."""
        key = (enc_name, auth_name)
        if key in self._algo:
            return self._algo[key]
        uat = 'uat:esp_sa:"IPv4","0.0.0.0","0.0.0.0","0x0","%s","0x00","%s",""' % (enc_name, auth_name)
        try:
            proc = subprocess.run([self.exe, "-o", uat, "-r", _PROBE_PCAP],
                                  capture_output=True, text=True, timeout=30)
        except (OSError, subprocess.SubprocessError):
            # tshark 不可用:探测不了就不要拦着,照原样生成命令,执行时再报错
            logger.warning("tshark 不可用,跳过算法名探测: %s/%s", enc_name, auth_name)
            self._algo[key] = True
            return True
        err = (proc.stderr or "") + (proc.stdout or "")
        # 只关心 UAT 本身的错误;文件不存在属正常
        ok = "invalid value" not in err and "unknown preference" not in err
        self._algo[key] = ok
        return ok


class SAEntry:
    """一条 Wireshark esp_sa UAT 记录(固定 8 字段)."""

    def __init__(
        self,
        src,
        dst,
        spi,
        enc_name,
        enc_key,
        auth_name="NULL",
        auth_key="",
        protocol="IPv4",
    ):
        self.protocol = protocol or "IPv4"
        self.src = src
        self.dst = dst
        self.spi = spi if str(spi).startswith("0x") else hex(int(str(spi), 0))
        self.enc_name = enc_name or "NULL"
        self.enc_key = enc_key if str(enc_key).startswith("0x") else f"0x{enc_key}"
        # UAT 的枚举字段不接受空字符串,无认证时必须写 NULL
        self.auth_name = auth_name or AUTH_NAME_FALLBACK
        self.auth_key = ""
        if auth_key:
            self.auth_key = auth_key if str(auth_key).startswith("0x") else f"0x{auth_key}"

    def csv_fields(self):
        return ",".join(
            f'"{v}"'
            for v in (
                self.protocol, self.src, self.dst, self.spi,
                self.enc_name, self.enc_key,
                self.auth_name, self.auth_key,
            )
        )

    def __repr__(self):  # pragma: no cover
        return f"<SAEntry {self.src}->{self.dst} spi={self.spi} {self.enc_name}>"


def sa_to_entries(sa):
    """把一个 IPsecSA 转成 Wireshark SAEntry.

    :return: (entry, 说明信息);entry 为 None 表示 Wireshark 不支持该算法组合
    """
    suite = sa.suite
    if suite.aead:
        enc_name = suite.aead.tshark_enc
        if not enc_name:
            return None, f"Wireshark esp_sa 不支持 AEAD 算法 {suite.aead.name}(如 SM4-GCM),无法解密"
        # AEAD 自带完整性校验,认证列填 NULL(留空会被 UAT 拒绝)
        return (
            SAEntry(sa.src, sa.dst, sa.spi, enc_name, sa.keys["aead"], "NULL", ""),
            f"AEAD {suite.aead.name} -> {enc_name}",
        )

    enc_name = suite.cipher.tshark_enc if suite.cipher else None
    if suite.cipher and not enc_name:
        return None, f"Wireshark esp_sa 不支持加密算法 {suite.cipher.name}(SM4 系列通常不支持)"
    auth_name = suite.auth.tshark_auth if suite.auth else ""
    if suite.auth and not auth_name:
        auth_name = AUTH_NAME_FALLBACK
        note = f"认证算法 {suite.auth.name} 无对应 Wireshark 名,按不校验处理"
    else:
        note = "组合模式"
    key = sa.keys.get("cipher", "")
    return (
        SAEntry(sa.src, sa.dst, sa.spi, enc_name or "NULL", key, auth_name, sa.keys.get("auth", "")),
        note,
    )


class Capture:
    """远端抓包 + 本地 tshark 解密分析.

    :param exc: 执行环境
    :param local_dir: pcap 落地到本地的目录
    :param tshark: tshark 可执行文件路径(默认取环境变量 TSHARK 或 PATH 上的 tshark)
    """

    def __init__(self, exc, local_dir="./ipsec_capture", tshark=None):
        self.exc = exc
        self.local_dir = local_dir
        os.makedirs(local_dir, exist_ok=True)
        self._remote_procs = []   # (target, remote_pcap_path)
        self.probe = TsharkProbe(tshark)

    # ------------------------------------------------------------ 抓包
    def start(self, target, ifname, pcap_name="ipsec.pcap",
              filter_expr="", snaplen=0):
        """后台启动 tcpdump,返回远端 pcap 路径."""
        remote_path = f"/tmp/{pcap_name}"
        filt = f" {filter_expr}" if filter_expr else ""
        cmd = (
            f"nohup tcpdump -i {ifname} -w {remote_path} -s {snaplen or 65535}{filt} "
            f"> /dev/null 2>&1 & echo $!"
        )
        res = self.exc.exec_cmd(target, cmd, timeout=20)
        if not res.ok:
            raise TsharkDecodeError(f"[{target}] 启动 tcpdump 失败: {res.text}")
        logger.info("[%s] 开始在 %s 抓包 -> %s", target, ifname, remote_path)
        self._remote_procs.append((target, remote_path))
        return remote_path

    def stop(self, target):
        """停止该端所有 tcpdump."""
        res = self.exc.exec_cmd(target, "pkill -f tcpdump", timeout=15)
        logger.debug("[%s] 停止抓包(rc=%s)", target, res.rc)

    def stop_all(self):
        for target, _ in list(self._remote_procs):
            self.stop(target)

    def fetch(self, target, remote_path, local_name=None):
        """把远端 pcap 取回本地(base64 传输,避免依赖 SFTP 环境差异)."""
        local_name = local_name or os.path.basename(remote_path)
        local_path = os.path.join(self.local_dir, f"{target}_{local_name}")
        cmd = f"base64 -w0 {remote_path} 2>/dev/null || base64 {remote_path}"
        res = self.exc.exec_cmd(target, cmd, timeout=120)
        if not res.ok:
            raise TsharkDecodeError(f"[{target}] 读取 pcap 失败: {res.text}")

        import base64

        data = base64.b64decode("".join(res.out))
        with open(local_path, "wb") as fp:
            fp.write(data)
        logger.info("[%s] pcap 拉取完成: %s (%d bytes)", target, local_path, len(data))
        return local_path

    def cleanup_remote(self, target):
        for tgt, path in list(self._remote_procs):
            if tgt == target:
                self.exc.exec_cmd(target, f"rm -f {path}", timeout=10)
        self._remote_procs = [p for p in self._remote_procs if p[0] != target]

    # ------------------------------------------------------------ 命令生成
    def build_decode_argv(
        self,
        pcap,
        entries,
        nat_t=False,
        nat_t_port=4500,
        display_filter="",
        extra_opts=(),
        check_auth=False,
    ):
        """生成可直接交给 subprocess 的 argv 列表(不经 shell,避免引号被吃掉).

        :param check_auth: 是否开启 ESP 认证校验。默认关闭——认证密钥不全或算法名
            对不上时会让解密直接失败,关闭则只做解密不校验完整性。

        UAT 值里必须保留双引号,走 shell=True 时引号会被剥离导致解析失败,
        所以执行一律用列表传参。
        """
        if not entries:
            raise TsharkDecodeError("没有可用于解密的 SA 条目")
        argv = [self.probe.exe, "-r", pcap]

        # 只下发当前版本真实存在的 preference(esp.enable / esp.udp_encapsulation
        # 在 3.x/4.x 均不存在,下发会 unknown preference 直接退出)
        enc_pref = self.probe.first_pref("esp.enable_encryption_decode")
        if enc_pref:
            argv += ["-o", f"{enc_pref}:TRUE"]
        if check_auth:
            auth_pref = self.probe.first_pref("esp.enable_authentication_check")
            if auth_pref:
                argv += ["-o", f"{auth_pref}:TRUE"]

        if nat_t:
            # NAT-T:让 tshark 把 UDP 4500 按 UDPENCAP 解,等价于 GUI 的 Decode As
            argv += ["-d", f"udp.port=={nat_t_port},udpencap"]

        argv += ["-o", "uat:esp_sa:" + ",".join(e.csv_fields() for e in entries)]
        argv += list(extra_opts)
        if display_filter:
            argv += ["-Y", display_filter]
        return argv

    def build_decode_cmd(self, pcap, entries, nat_t=False, nat_t_port=4500,
                         display_filter="", extra_opts=(), check_auth=False):
        """生成用于日志/手工执行的命令字符串(引号已按平台处理)."""
        argv = self.build_decode_argv(pcap, entries, nat_t=nat_t, nat_t_port=nat_t_port,
                                      display_filter=display_filter, extra_opts=extra_opts,
                                      check_auth=check_auth)
        return " ".join(_shell_quote(a) for a in argv)

    # ------------------------------------------------------------ 解密分析
    def analyze(
        self,
        pcap,
        sas,
        nat_t=False,
        expect_plaintext="icmp",
        check_algo=True,
    ):
        """对 pcap 做加密/解密双向校验.

        返回统计结果:ESP 包数、解密后明文包数、未能解密的 SA 及原因、tshark 版本。
        """
        entries = []
        skipped = []
        for sa in sas:
            entry, note = sa_to_entries(sa)
            if entry is None:
                skipped.append(f"{sa.suite.describe()}: {note}")
                continue
            if check_algo and not self.probe.algo_supported(entry.enc_name, entry.auth_name):
                skipped.append(
                    f"{sa.suite.describe()}: tshark {self.probe.version_str()} 不接受算法名 "
                    f"'{entry.enc_name}'/'{entry.auth_name}'"
                )
                continue
            entries.append(entry)

        result = {
            "esp_packets": 0, "plaintext_packets": 0,
            "decrypted": False, "skipped": skipped,
            "tshark_version": self.probe.version_str(),
            "note": "",
        }
        if not entries:
            result["note"] = "没有可用于解密的 SA(多为国密算法或当前 tshark 不支持的算法名)"
            return result

        esp_argv = self.build_decode_argv(pcap, entries, nat_t=nat_t, display_filter="esp")
        result["esp_packets"] = self._count_lines(esp_argv)

        plain_argv = self.build_decode_argv(pcap, entries, nat_t=nat_t,
                                            display_filter=expect_plaintext)
        result["plaintext_packets"] = self._count_lines(plain_argv)
        result["decrypted"] = result["plaintext_packets"] > 0
        if not result["decrypted"]:
            result["note"] = "未解出明文:检查密钥/SPI/NAT-T 设置是否与实际流量匹配"
        return result

    def _count_lines(self, argv):
        """执行 tshark 并统计输出行数;tshark 不存在或失败返回 -1."""
        try:
            proc = subprocess.run(argv, capture_output=True, text=True, timeout=180)
        except FileNotFoundError:
            logger.error("本机未安装 tshark(%s),跳过解密分析", self.probe.exe)
            return -1
        except subprocess.SubprocessError as exc:  # noqa: BLE001
            logger.error("tshark 执行失败: %s", exc)
            return -1
        err = (proc.stderr or "").strip()
        if proc.returncode != 0 and err:
            logger.warning("tshark 返回 %s: %s", proc.returncode, err[:200])
        return len([ln for ln in proc.stdout.splitlines() if ln.strip()])

    # ------------------------------------------------------------ 辅助判断
    @staticmethod
    def looks_like_esp(hexdump_line):
        """粗略判断抓包内容是否为 ESP(无 SPI 字段时按常见特征判断)."""
        return bool(re.search(r"\bESP\b|proto ESP \(50\)|spi:", hexdump_line, re.IGNORECASE))


def _shell_quote(text):
    """按平台给字符串加引号,仅用于日志/展示."""
    if os.name == "nt":
        return '"%s"' % str(text).replace('"', '\\"')
    import shlex

    return shlex.quote(str(text))
##############################################################
"""nic_tf.ipsec —— IPsec / IPsec 卸载测试能力.

分层::

    crypto    算法与密钥规则(AEAD / SM4 / SM3 / HMAC / AES ...)
    models    SA / Policy / Bundle 数据模型 + ip xfrm 命令生成
    xfrm      远端 xfrm 下发、查询、清理、能力检查
    scenario  卸载矩阵、多 SA / 多 IP、VF↔PF / VF↔VF / PF↔对端PF 编排
    capture   tcpdump 抓包 + tshark 解密验证
    traffic   ping 打流与结果校验
    iperf     iperf3 / iperf2 后台打流(输出文件 + listening/connected + 跌0判定)
"""

from .capture import (
    AUTH_NAME_FALLBACK,
    Capture,
    SAEntry,
    TsharkDecodeError,
    TsharkProbe,
    find_tshark,
    sa_to_entries,
)
from .crypto import (
    AEAD_SALT_BYTES,
    CryptoAlgo,
    Suite,
    aead,
    auth,
    cipher,
    list_algos,
    resolve,
    suite_aead,
    suite_cbc_hmac,
    suite_sm4_cbc_sm3,
    suite_sm4_gcm,
)
from .iperf import (
    DEFAULT_RUN_DIR,
    BandwidthSample,
    IperfConfig,
    IperfResult,
    IperfRole,
    IperfRunner,
    IperfSession,
    IperfVersion,
    has_bandwidth_drop,
    to_mbps,
)
from .models import (
    Bundle,
    EncapConfig,
    IPsecPolicy,
    IPsecSA,
    LimitConfig,
    OffloadMode,
    ReplayConfig,
    Selector,
    gen_spi,
    parse_count,
    parse_seconds,
    parse_size,
    random_spi,
)
from .scenario import LinkSpec, Scenario
from .traffic import Traffic
from .xfrm import XfrmManager, parse_xfrm_state

__all__ = [
    # crypto
    "CryptoAlgo", "Suite", "AEAD_SALT_BYTES",
    "aead", "cipher", "auth", "resolve", "list_algos",
    "suite_aead", "suite_cbc_hmac", "suite_sm4_gcm", "suite_sm4_cbc_sm3",
    # models
    "IPsecSA", "IPsecPolicy", "Bundle", "OffloadMode", "ReplayConfig",
    "EncapConfig", "Selector", "gen_spi", "random_spi",
    # 生命周期限制(limit byte-hard / packet-hard / time-hard ...)
    "LimitConfig", "parse_size", "parse_count", "parse_seconds",
    # managers
    "XfrmManager", "parse_xfrm_state", "Scenario", "LinkSpec",
    "Capture", "SAEntry", "sa_to_entries", "TsharkDecodeError",
    "TsharkProbe", "find_tshark", "AUTH_NAME_FALLBACK",
    "Traffic",
    # iperf 后台打流
    "IperfRunner", "IperfSession", "IperfResult", "IperfConfig",
    "IperfVersion", "IperfRole", "BandwidthSample", "DEFAULT_RUN_DIR",
    "to_mbps", "has_bandwidth_drop",
]
#########################################################################
"""IPsec 卸载综合场景:算法矩阵 / 卸载矩阵 / 多SA / 多IP / NAT-T / 抗重放 / tshark 解密.

运行::

    python examples/case_ipsec_offload.py --backend fake          # 干跑(不碰真实设备)
    python examples/case_ipsec_offload.py --conf nic_conf.yaml    # 真实环境
    python examples/case_ipsec_offload.py --tag offload           # 只跑卸载矩阵
    python examples/case_ipsec_offload.py --algo gcm --no-decrypt # 跳过 tshark 解密
"""


import argparse
import os
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from nic_tf import close_env, init_env  # noqa: E402
from nic_tf.common.log import set_level  # noqa: E402
from nic_tf.ipsec import (  # noqa: E402
    Capture,
    EncapConfig,
    OffloadMode,
    ReplayConfig,
    Scenario,
    Traffic,
    sa_to_entries,
    suite_aead,
    suite_cbc_hmac,
    suite_sm4_cbc_sm3,
    suite_sm4_gcm,
)
from nic_tf.ipsec.models import MODE_TUNNEL, IPsecSA, Selector  # noqa: E402
from nic_tf.runner import CaseRunner  # noqa: E402

SUITES = {
    "gcm": lambda: suite_aead("rfc4106_gcm_aes", 128),      # AES-GCM (AEAD)
    "ccm": lambda: suite_aead("rfc4309_ccm_aes", 128),      # AES-CCM
    "chacha": lambda: suite_aead("rfc7539_chacha20_poly1305"),
    "aes-sha": lambda: suite_cbc_hmac("cbc_aes", "hmac_sha256"),
    "sm4-gcm": suite_sm4_gcm,                                # 国密 AEAD
    "sm4-sm3": suite_sm4_cbc_sm3,                            # 国密 CBC+HMAC-SM3
}


def build_parser():
    p = argparse.ArgumentParser(description="IPsec offload 综合场景")
    p.add_argument("--conf", default=os.getenv("NIC_TF_CONF", "nic_conf.yaml"))
    p.add_argument("--backend", default=os.getenv("NIC_TF_BACKEND", "paramiko"),
                   help="paramiko / local / fake")
    p.add_argument("--algo", default="", help="只跑指定算法(默认全部)")
    p.add_argument("--sa-count", type=int, default=1, help="每个 IP 对的 SA 数量")
    p.add_argument("--extra-ips", default="", help="额外业务 IP,逗号分隔(一端口多IP)")
    p.add_argument("--no-decrypt", action="store_true", help="跳过 tshark 解密")
    p.add_argument("--tag", default="")
    p.add_argument("--pattern", default="")
    p.add_argument("--log-level", default="INFO")
    return p


def main():
    args = build_parser().parse_args()
    set_level(args.log_level)
    env = init_env(args.conf, backend=args.backend)
    runner = CaseRunner(env)
    scen = Scenario(env)
    traffic = Traffic(env)
    algo_names = [args.algo] if args.algo else list(SUITES)

    # ---------------------------------------------------------- 1. SA 命令生成
    @runner.case(tags=["build"])
    def case_cmd_generation(env):
        """每种算法都能生成合法的 xfrm 命令(含 SM4/SM3)。"""
        for name in algo_names:
            sa = IPsecSA(src="1.1.1.1", dst="2.2.2.2", spi=0x1001, suite=SUITES[name](),
                         offload=OffloadMode.PACKET, dev="ens2f0", key_seed=name)
            cmd = sa.add_cmd()
            assert "ip xfrm state add" in cmd, f"{name} 命令异常"
            assert "offload packet dev ens2f0 dir out" in cmd, f"{name} 缺少全卸载参数"
            # AEAD 密钥 = 密钥本体 + 4 字节 salt:AES-GCM-128 -> 20 字节 -> 40 hex
            if name == "gcm":
                assert len(sa.keys["aead"]) == 40, \
                    f"rfc4106 key 应为 (16+4)字节,实际 {len(sa.keys['aead'])//2} 字节"
            print(f"        [{name}] {cmd[:110]}...")

    @runner.case(tags=["build"])
    def case_natt_cmd(env):
        """NAT-T 与 transport/tunnel 模式、半卸载命令形态。"""
        sa = IPsecSA(src="1.1.1.1", dst="2.2.2.2", spi=0x1002,
                     suite=suite_aead(), offload=OffloadMode.CRYPTO, dev="ens2f0",
                     encap=EncapConfig.natt(), key_seed="natt")
        cmd = sa.add_cmd()
        assert "encap espinudp 4500 4500 0.0.0.0" in cmd, "缺少 NAT-T 封装"
        assert "offload crypto dev ens2f0 dir out" in cmd, "半卸载参数错误"
        tun = IPsecSA(src="1.1.1.1", dst="2.2.2.2", spi=0x1003, mode=MODE_TUNNEL,
                      suite=suite_aead(), offload=OffloadMode.SOFTWARE,
                      selector=Selector(src="10.0.0.1", dst="10.0.0.2"), key_seed="tun")
        tun_cmd = tun.add_cmd()
        assert "mode tunnel" in tun_cmd and "sel src 10.0.0.1" in tun_cmd, "tunnel sel 缺失"
        assert "offload" not in tun_cmd, "纯软模式不应出现 offload 参数"

    @runner.case(tags=["replay"])
    def case_replay_window(env):
        """replay-window 与 oseq(hard limit 场景构造)。"""
        sa = IPsecSA(src="1.1.1.1", dst="2.2.2.2", spi=0x1004, suite=suite_aead(),
                     offload=OffloadMode.PACKET, dev="ens2f0",
                     replay=ReplayConfig(window=64, oseq=0xfffffff0), key_seed="rep")
        cmd = sa.add_cmd()
        assert "replay-window 64" in cmd
        assert "replay-oseq 4294967280" in cmd, "hard-limit 序号未写入"

    # ---------------------------------------------------------- 2. 下发 & 矩阵
    @runner.case(tags=["offload", "apply"])
    def case_apply_all_algos(env):
        """把每种算法的 SA 下发到两端,并核对 xfrm state 回显。"""
        for name in algo_names:
            bundles = scen.pf_to_peer_pf(
                suite=SUITES[name](),
                local_offload=OffloadMode.PACKET,
                remote_offload=OffloadMode.PACKET,
                sa_count=args.sa_count,
            )
            result = scen.apply(bundles)
            failed = [k for k, v in result.items() if not v]
            assert not failed, f"{name} 下发失败: {failed}"
            print(f"        [{name}] 下发 {len(bundles)} 条 SA OK")

    @runner.case(tags=["offload", "matrix"])
    def case_offload_matrix(env):
        """本端/对端卸载方式两两组合(含全卸载 vs 纯软这类非对称场景)。"""
        for local_m, remote_m in scen.offload_matrix():
            bundles = scen.pf_to_peer_pf(
                suite=suite_aead(), local_offload=local_m, remote_offload=remote_m
            )
            for bundle in bundles:
                assert bundle.local_offload == local_m
                assert bundle.remote_offload == remote_m
                assert bundle.local_out.keys == bundle.remote_in.keys, "两端密钥必须一致"
            scen.apply(bundles)
            print(f"        local={local_m:<9} remote={remote_m:<9} -> {len(bundles)} SA")

    # ---------------------------------------------------------- 3. 多 SA / 多 IP
    @runner.case(tags=["multi"])
    def case_multi_sa_per_ip(env):
        """同一个 IP 对上配置多条 SA(不同 spi/reqid)。"""
        bundles = scen.pf_to_peer_pf(suite=suite_aead(), sa_count=3)
        spis = [b.local_out.spi for b in bundles]
        assert len(set(spis)) == 3, f"多 SA 的 SPI 应互不相同: {spis}"
        result = scen.apply(bundles)
        assert all(result.values()), f"多 SA 下发失败: {result}"

    @runner.case(tags=["multi"])
    def case_multi_ip_per_port(env):
        """一个端口配置多个 IP,每个 IP 各自一套 SA。"""
        extra = [ip for ip in args.extra_ips.split(",") if ip]
        if not extra:
            extra = ["192.168.10.21", "192.168.10.22"]
        dut_pf, peer_pf = env.ports.pairs()[0]
        main_ip = dut_pf.ip
        for ip in extra:
            dut_pf.add_ip(ip)
        assert main_ip in dut_pf.ips, f"原 IP {main_ip} 不应被覆盖: {dut_pf.ips}"
        bundles = scen.pf_to_peer_pf(
            suite=suite_aead(), local_ips=dut_pf.ips, remote_ips=peer_pf.ips
        )
        assert len(bundles) == len(dut_pf.ips) * len(peer_pf.ips), \
            f"SA 数量应覆盖 IP 组合: {len(bundles)}"
        assert scen.apply(bundles), "多 IP 场景下发失败"
        print(f"        {dut_pf.name} 承载 IP={dut_pf.ips} -> {len(bundles)} 条 SA")

    # ---------------------------------------------------------- 4. 拓扑
    @runner.case(tags=["topo"])
    def case_vf_to_pf(env):
        """VF 与 PF 之间建立 IPsec(卸载设备应回溯到物理 PF)。"""
        bundles = scen.vf_to_pf(suite=suite_aead(), vf_index=0)
        for bundle in bundles:
            dev = bundle.local_out.dev
            assert dev and not dev.startswith("ens2f0v"), f"offload dev 应为 PF: {dev}"
        result = scen.apply(bundles)
        assert all(result.values()), result

    @runner.case(tags=["topo"])
    def case_vf_to_vf_same_pf(env):
        """同一 PF 下两个 VF 互通。"""
        bundles = scen.vf_to_vf_same_pf(suite=suite_aead(), vf_pair=(0, 1))
        assert bundles, "未生成 VF<->VF 的 SA"
        result = scen.apply(bundles)
        assert all(result.values()), result

    # ---------------------------------------------------------- 5. 打流
    @runner.case(tags=["traffic"])
    def case_ping_over_ipsec(env):
        """建立 SA 后,业务 ping 仍应通。"""
        dut_pf, peer_pf = env.ports.pairs()[0]
        scen.pf_to_peer_pf(suite=suite_aead())
        report = traffic.ping_report("dut", peer_pf.ip, count=3)
        assert report["ok"], f"IPsec 链路 ping 不通: {report.get('text')}"
        print(f"        ping {peer_pf.ip} loss={report.get('loss')}%")

    # ---------------------------------------------------------- 6. 抓包解密
    @runner.case(tags=["capture"])
    def case_tshark_decrypt_cmd(env):
        """生成 tshark 解密命令(SM 系列会明确标记为不可解密)。"""
        if args.no_decrypt:
            print("        (跳过)")
            return
        cap = Capture(env)
        print(f"        tshark 版本: {cap.probe.version_str()} ({cap.probe.exe})")
        cases = {
            "gcm": SUITES["gcm"](),
            "sm4-sm3": SUITES["sm4-sm3"](),
        }
        for name, suite in cases.items():
            sa = IPsecSA(src="192.168.10.10", dst="192.168.10.11", spi=0x1001,
                         suite=suite, key_seed=name)
            entry, note = sa_to_entries(sa)
            if entry is None:
                print(f"        [{name}] 不可解密: {note}")
                continue
            # 算法名是枚举值,必须确认当前 tshark 认得,否则整条 UAT 会被拒
            supported = cap.probe.algo_supported(entry.enc_name, entry.auth_name)
            if not supported:
                print(f"        [{name}] 当前 tshark 不接受算法名 "
                      f"'{entry.enc_name}',已跳过")
                continue
            cmd = cap.build_decode_cmd("/tmp/cap.pcap", [entry], nat_t=False)
            assert "uat:esp_sa:" in cmd and "esp.enable_encryption_decode:TRUE" in cmd
            assert "esp.enable:TRUE" not in cmd     # 该 preference 不存在,不能下发
            print(f"        [{name}] {note} -> {cmd[:100]}...")

    try:
        code = runner.main(pattern=args.pattern, tag=args.tag)
    finally:
        scen.teardown(("dut", "peer"))
        close_env()
    return code


if __name__ == "__main__":
    raise SystemExit(main())
##############################################################
"""IPsec 生命周期限制(limit)场景:byte-hard / packet-hard / time-hard 的验证.

覆盖点::

    1. limit 命令语法(每个限制项各带一个 limit 关键字)
    2. 单位换算(1G / 512MB / 10k / 1m30s)
    3. SA 与 Policy 的命令生成位置、update 改 limit
    4. 真实 ip xfrm state 输出的 lifetime 解析(制表符缩进 + spi 带十进制括号)
    5. 下发回读校验(verify_limit)
    6. hard 到限:packets / bytes / seconds 三个维度分别验证到期行为

运行::

    python examples/case_ipsec_limit.py --backend fake        # 干跑(不碰真实设备)
    python examples/case_ipsec_limit.py --conf nic_conf.yaml  # 真实环境
    python examples/case_ipsec_limit.py --tag build           # 只跑命令/解析类用例
    python examples/case_ipsec_limit.py --tag live            # 只跑下发/到期类用例
"""


import argparse
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from nic_tf import close_env, init_env  # noqa: E402
from nic_tf.common.log import set_level  # noqa: E402
from nic_tf.ipsec import (  # noqa: E402
    IPsecPolicy,
    IPsecSA,
    LimitConfig,
    OffloadMode,
    Scenario,
    Traffic,
    XfrmManager,
    parse_count,
    parse_seconds,
    parse_size,
    parse_xfrm_state,
    suite_aead,
)
from nic_tf.runner import CaseRunner  # noqa: E402

# ------------------------------------------------------------------ 样例输出
# 真实内核 `ip xfrm state` 的输出形态:制表符缩进、spi/reqid 带十进制括号、
# lifetime 三个维度各占一行。用这段文本离线验证解析逻辑。
REAL_STATE_OUTPUT = """\
src 192.168.10.10 dst 192.168.10.20
\tproto esp spi 0x00001001(4097) reqid 0x00001001(4097) mode transport
\treplay-window 32 seq 0x00000000 flag af-unspec (0x00100000)
\taead rfc4106(gcm(aes)) 0x5d7a647bf9ab37a4fb43a9648ce83e80dbe5d27d 128
\toffload packet dev ens2f0 dir out
\tlifetime config:
\t  limit: soft (INF)(bytes), hard (104857600)(bytes)
\t  limit: soft (500)(packets), hard (1000)(packets)
\t  limit: soft (INF)(seconds), hard (3600)(seconds)
\t  add 2026-09-26 23:20:00 use -
\tlifetime current:
\t  65536(bytes), 640(packets)
\t  add 2026-09-26 23:20:00 use 2026-09-26 23:20:01
\tstats:
\t  replay-window 0 replay 0 failed 0
"""


def build_parser():
    p = argparse.ArgumentParser(description="IPsec limit(byte/packet/time hard) 场景")
    p.add_argument("--conf", default="nic_conf.yaml", help="拓扑配置,默认 nic_conf.yaml")
    p.add_argument("--backend", default="paramiko",
                   help="后端:paramiko(默认) / local / fake(干跑)")
    p.add_argument("--tag", default=None, help="只跑指定 tag 的用例")
    p.add_argument("--pattern", default=None, help="用例名正则过滤")
    p.add_argument("--log-level", default="WARNING", help="日志级别,默认 WARNING")
    p.add_argument("--stop-on-fail", action="store_true", help="首例失败即停")
    return p


def main():
    args = build_parser().parse_args()
    set_level(args.log_level)
    env = init_env(args.conf, backend=args.backend)
    runner = CaseRunner(env, stop_on_fail=args.stop_on_fail)
    xfrm = XfrmManager(env)
    scen = Scenario(env)
    traffic = Traffic(env)

    # ============================================================ 1. 命令生成
    @runner.case(tags=["build"])
    def case_each_limit_own_keyword(env):
        """每个限制项都必须各带一个 `limit` 关键字(LIMIT-LIST 语法)."""
        cfg = LimitConfig.hard(byte="1G", packets=1000000, seconds=3600)
        cli = cfg.cli()
        assert cli.count(" limit ") + cli.startswith("limit") * 1 == 3, cli
        assert cli == (
            "limit byte-hard 1073741824 limit packet-hard 1000000 limit time-hard 3600"
        ), cli
        assert not LimitConfig().cli(), "空配置不应产出 limit 片段"
        print(f"        {cli}")

    @runner.case(tags=["build"])
    def case_unit_conversion(env):
        """1G / 512MB / 10k / 1m30s 这类写法要换算成整数."""
        assert parse_size("1G") == 1024 ** 3, parse_size("1G")
        assert parse_size("512MB") == 512 * 1000 ** 2, parse_size("512MB")
        assert parse_size("2GiB") == 2 * 1024 ** 3
        assert parse_size(1024) == 1024
        assert parse_count("10k") == 10000 and parse_count("1m") == 10 ** 6
        assert parse_seconds("90") == 90
        assert parse_seconds("1m30s") == 90, parse_seconds("1m30s")
        assert parse_seconds("2h") == 7200
        for bad in ("abc", "1X"):
            try:
                parse_size(bad)
            except ValueError:
                continue
            raise AssertionError(f"非法取值 {bad} 未被拒绝")
        print("        1G=1073741824B, 512MB=512000000B, 1m30s=90s, 10k=10000pkt")

    @runner.case(tags=["build"])
    def case_limit_from_dict_and_factory(env):
        """dict 构造(兼容 byte-hard / byte_hard 两种 key)+ 硬/软限制合法性自检."""
        cfg = LimitConfig.from_dict({
            "byte-hard": "512MB", "packet-hard": "10k",
            "time-hard": "1m30s", "time-use-hard": "2h", "unknown": 1,
        })
        assert cfg.as_dict() == {
            "byte-hard": 512000000, "packet-hard": 10000,
            "time-hard": 90, "time-use-hard": 7200,
        }, cfg.as_dict()
        assert cfg.describe() == (
            "byte-hard=488.28MB packet-hard=10000pkt time-hard=90s time-use-hard=7200s"
        ), cfg.describe()

        soft = LimitConfig(byte_soft="2G", byte_hard="1G")
        problems = soft.problems()
        assert problems and "byte-soft" in problems[0], problems
        assert LimitConfig(byte_soft=100, byte_hard=1000).problems() == []
        print(f"        describe: {cfg.describe()}")

    @runner.case(tags=["build"])
    def case_state_cmd_limit_position(env):
        """limit 片段要出现在算法/重放之后、offload 之前(man page 的顺序)."""
        sa = IPsecSA(
            src="1.1.1.1", dst="2.2.2.2", spi=0x2000, offload=OffloadMode.PACKET,
            dev="ens2f0", suite=suite_aead(),
            limit=LimitConfig.hard(byte="1G", packets=1000000, seconds=3600),
        )
        cmd = sa.add_cmd()
        limit_cli = "limit byte-hard 1073741824 limit packet-hard 1000000 limit time-hard 3600"
        assert limit_cli in cmd, cmd
        assert cmd.index("128 replay-window 32 " + limit_cli) < cmd.index("offload packet"), cmd
        assert sa.update_cmd().startswith("ip xfrm state update"), sa.update_cmd()
        # set_limit 应可链式改写已有 SA
        sa.set_limit({"packet-hard": 7})
        assert sa.add_cmd().count("limit ") == 1 and "limit packet-hard 7" in sa.add_cmd()
        print(f"        {cmd[:150]}...")

    @runner.case(tags=["build"])
    def case_policy_limit(env):
        """policy 同样支持 LIMIT-LIST,位置在 tmpl 之前."""
        pol = IPsecPolicy(src="1.1.1.1", dst="2.2.2.2", tmpl_src="1.1.1.1",
                          tmpl_dst="2.2.2.2", limit={"byte-hard": "1G"})
        cmd = pol.add_cmd()
        assert "limit byte-hard 1073741824 tmpl " in cmd, cmd
        print(f"        {cmd}")

    @runner.case(tags=["build"])
    def case_two_ends_different_limit(env):
        """两端可以配不同的 limit(本地 patcket-hard vs 远端 byte-hard)."""
        dut_pf, peer_pf = env.ports.pairs()[0]
        bundles = scen.pf_to_peer_pf(
            limit_local=LimitConfig(packet_hard=100),
            limit_remote=LimitConfig(byte_hard="1G"),
        )
        assert bundles, "应生成至少一组 SA"
        local_cmd = bundles[0].local_out.add_cmd()
        remote_cmd = bundles[0].remote_in.add_cmd()
        assert "limit packet-hard 100 " in local_cmd, local_cmd
        assert "limit byte-hard 1073741824 " in remote_cmd, remote_cmd
        print(f"        {dut_pf.name}({bundles[0].local_target}): packet-hard 100")
        print(f"        {peer_pf.name}({bundles[0].remote_target}): byte-hard 1G")

    # ============================================================ 2. 输出解析
    @runner.case(tags=["parse"])
    def case_parse_real_state_output(env):
        """真实输出(制表符缩进 + spi 带括号)要能解析出 limit 与用量."""
        states = parse_xfrm_state(REAL_STATE_OUTPUT.splitlines(), with_stats=True)
        assert len(states) == 1, len(states)
        st = states[0]
        assert st["spi"] == "0x00001001", st["spi"]          # 十进制括号被剥掉
        assert st["_spi_int"] == 0x1001, st["_spi_int"]
        assert st["offload"] == "packet dev ens2f0 dir out", st["offload"]
        assert st["offload_dev"] == "ens2f0" and st["offload_dir"] == "out"
        assert st["lft_cfg"]["bytes"] == {"soft": None, "hard": 104857600}, st["lft_cfg"]
        assert st["lft_cfg"]["packets"] == {"soft": 500, "hard": 1000}, st["lft_cfg"]
        assert st["lft_cfg"]["seconds"]["hard"] == 3600
        assert st["lft_cur"] == {"bytes": 65536, "packets": 640}, st["lft_cur"]
        print(f"        lft_cfg={st['lft_cfg']['packets']} lft_cur={st['lft_cur']}")

    # ============================================================ 3. 下发与到期
    @runner.case(tags=["live"])
    def case_verify_limit_readback(env):
        """下发的 limit 必须能被 `ip xfrm state` 回读到(否则等于没生效)."""
        xfrm.state_flush("dut")
        limit = LimitConfig(byte_hard="1M", packet_hard=1000, time_hard=3600)
        sa = IPsecSA(src="1.1.1.1", dst="2.2.2.2", spi=0x3001, limit=limit)
        assert xfrm.state_add("dut", sa).ok

        ok, problems = xfrm.verify_limit("dut", sa.src, sa.dst, sa.spi, limit)
        assert ok, problems
        # 期望值与实际不符时应当报错
        ok2, problems2 = xfrm.verify_limit(
            "dut", sa.src, sa.dst, sa.spi, LimitConfig(packet_hard=999))
        assert not ok2 and "packet-hard" in problems2[0], problems2
        print(f"        readback OK: {limit.describe()}")

    @runner.case(tags=["live"])
    def case_packet_hard_expires(env):
        """packet-hard 到限后 SA 会被内核删除(打流 -> 轮询 -> 消失)."""
        xfrm.state_flush("dut")
        sa = IPsecSA(src="1.1.1.1", dst="2.2.2.2", spi=0x3002,
                     limit=LimitConfig(packet_hard=12))
        assert xfrm.state_add("dut", sa).ok

        usage = xfrm.limit_usage("dut", sa.src, sa.dst, sa.spi)
        assert usage["packets"]["ratio"] == 0.0, usage

        for _ in range(3):                       # 15 个包 > packet-hard 12
            traffic.ping("dut", "2.2.2.2", src_ip="1.1.1.1", count=5)

        gone, elapsed = xfrm.wait_state_gone("dut", sa.src, sa.dst, sa.spi, timeout=5, poll=0.2)
        assert gone, f"{elapsed}s 后 SA 仍在,packet-hard 未生效"
        print(f"        packet-hard=12 -> {elapsed}s 后 SA 到期删除")

    @runner.case(tags=["live"])
    def case_byte_hard_expires(env):
        """byte-hard 到限同样触发删除(用大包快速跨过阈值)."""
        xfrm.state_flush("dut")
        sa = IPsecSA(src="1.1.1.1", dst="2.2.2.2", spi=0x3003,
                     limit=LimitConfig(byte_hard=1000))
        assert xfrm.state_add("dut", sa).ok

        for _ in range(4):                       # 4 * 5 * 56B = 1120B > byte-hard 1000
            res = xfrm.wait_limit_expire("dut", sa.src, sa.dst, sa.spi,
                                         kind="bytes", timeout=0.4, poll=0.1)
            if res["ok"]:
                break
            traffic.ping("dut", "2.2.2.2", src_ip="1.1.1.1", count=5, size=56)
        res = xfrm.wait_limit_expire("dut", sa.src, sa.dst, sa.spi,
                                     kind="bytes", timeout=1, poll=0.1)
        assert res["ok"], res["reason"]
        print(f"        byte-hard=1000 -> {res['reason']}")

    @runner.case(tags=["live"])
    def case_time_hard_expires(env):
        """time-hard 按 SA 添加时刻起算,到期后 SA 消失."""
        xfrm.state_flush("dut")
        sa = IPsecSA(src="1.1.1.1", dst="2.2.2.2", spi=0x3004,
                     limit=LimitConfig(time_hard=1))          # 1 秒后到期
        assert xfrm.state_add("dut", sa).ok

        res = xfrm.wait_limit_expire("dut", sa.src, sa.dst, sa.spi,
                                     kind="seconds", timeout=8, poll=0.2)
        assert res["ok"], res["reason"]
        print(f"        time-hard=1s -> {res['reason']}")

    @runner.case(tags=["live"])
    def case_update_limit_on_live_sa(env):
        """已存在的 SA 可以通过 `ip xfrm state update` 改 limit."""
        xfrm.state_flush("dut")
        sa = IPsecSA(src="1.1.1.1", dst="2.2.2.2", spi=0x3005,
                     limit=LimitConfig(packet_hard=100))
        assert xfrm.state_add("dut", sa).ok
        before = LimitConfig(packet_hard=100)
        assert xfrm.verify_limit("dut", sa.src, sa.dst, sa.spi, before)[0]

        new_limit = LimitConfig(packet_hard=5, byte_hard="10M")
        assert xfrm.update_limit("dut", sa, new_limit), "update 失败"
        ok, problems = xfrm.verify_limit("dut", sa.src, sa.dst, sa.spi, new_limit)
        assert ok, problems
        assert sa.limit.packet_hard == 100, "update_limit 不应修改传入的 sa 对象"
        print(f"        packet-hard 100 -> 5, byte-hard -> 10M")

    try:
        raise SystemExit(runner.main(tag=args.tag, pattern=args.pattern))
    finally:
        close_env()


if __name__ == "__main__":
    main()



posted on 2026-09-27 11:38  vanness_205  阅读(14)  评论(0)    收藏  举报