"""``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()