ForcePilot/backend/package/yuxi/channel/extensions/irc/security.py
Kris 061cc7076b feat(channel): 添加 IRC 渠道扩展插件
新增 IRC 渠道的完整扩展实现,包括客户端连接管理、消息收发与流式处理、安全策略与配对验证、消息去重与规范化、状态监控与健康探测、配置模型与账户管理、错误处理等模块。
2026-05-21 10:59:12 +08:00

164 lines
5.2 KiB
Python

import logging
import re
import secrets
import time
from yuxi.channel.extensions.irc.config import DmPolicy, GroupPolicy, IrcConfig
from yuxi.channel.extensions.irc.normalize import build_irc_allowlist_candidates
from yuxi.channel.extensions.irc.types import ResolvedIrcAccount
logger = logging.getLogger(__name__)
_PAIRING_CODES: dict[str, tuple[str, float]] = {}
_CODE_TTL_SECONDS = 300
_ID_LABEL = "ircNick"
def build_irc_mention_regex(nick: str) -> re.Pattern:
escaped = re.escape(nick)
return re.compile(rf"\b{escaped}\b[:,]?", re.IGNORECASE)
def is_mentioned(nick: str, text: str) -> bool:
pattern = build_irc_mention_regex(nick)
return bool(pattern.search(text))
def resolve_dm_policy(account: ResolvedIrcAccount) -> DmPolicy:
return account.config.dm_policy
def resolve_group_policy(account: ResolvedIrcAccount) -> GroupPolicy:
return account.config.group_policy
def match_irc_group(
target: str,
groups: dict[str, object],
channels: list[str],
account_nick: str,
) -> object | None:
normalized = target.lower()
for channel in channels:
if channel.lower() == normalized:
key = channel
if key in groups:
return (key, groups[key])
return (key, None)
for group_key, group_cfg in groups.items():
if group_key.lower() == normalized:
return (group_key, group_cfg)
if group_key == "*":
return (group_key, group_cfg)
return None
def check_allowlist_match(
nick: str,
user: str | None,
host: str | None,
allowlist: list[str],
allow_name_matching: bool = False,
) -> bool:
if "*" in allowlist:
return True
candidates = build_irc_allowlist_candidates(nick, user, host, allow_name_matching)
allowlist_lower = {entry.lower() for entry in allowlist}
for candidate in candidates:
if candidate in allowlist_lower:
return True
return False
def resolve_irc_require_mention(
target: str,
groups: dict[str, object],
channels: list[str],
) -> bool:
normalized = target.lower()
for channel in channels:
if channel.lower() == normalized:
match = groups.get(channel)
if match is not None and hasattr(match, "require_mention"):
return match.require_mention
break
wildcard = groups.get("*")
if wildcard is not None and hasattr(wildcard, "require_mention"):
return wildcard.require_mention
return True
def collect_irc_security_warnings(account: ResolvedIrcAccount) -> list[str]:
warnings = []
if not account.tls:
warnings.append(
"IRC TLS is disabled — traffic and credentials are transmitted in plaintext"
)
nickserv = account.config.nickserv
if nickserv.register_nick:
if not account.nickserv_password:
warnings.append(
"NickServ registration is enabled but no NickServ password is configured"
)
else:
warnings.append(
"NickServ registration is enabled — disable 'register' after initial registration"
)
if account.config.group_policy == GroupPolicy.OPEN:
warnings.append(
"Group policy is set to 'open' — all channels can receive messages (mention-gated). "
"Consider setting to 'allowlist'."
)
for entry in account.config.allow_from:
if "!" not in entry and "@" not in entry and entry != "*":
warnings.append(
f"Allowlist entry '{entry}' is a bare nick without !user@host — "
"it could be spoofed. Use full nick!user@host format for security."
)
return warnings
def collect_mutable_allowlist_warnings(cfg: IrcConfig) -> list[str]:
warnings = []
for account_cfg in cfg.accounts.values():
for entry in account_cfg.allow_from:
if "!" not in entry and "@" not in entry and entry != "*":
warnings.append(
f"Account '{account_cfg.name or 'unknown'}': mutable allowlist entry '{entry}' "
"(bare nick, could be spoofed)"
)
return warnings
async def generate_irc_pairing_code(peer_id: str) -> str:
code = f"{secrets.randbelow(1_000_000):06d}"
_PAIRING_CODES[peer_id] = (code, time.monotonic())
logger.info("IRC pairing code generated for peer %s", peer_id)
return code
async def verify_irc_pairing_code(peer_id: str, code: str) -> bool:
stored = _PAIRING_CODES.get(peer_id)
if stored is None:
return False
stored_code, created_at = stored
if time.monotonic() - created_at > _CODE_TTL_SECONDS:
_PAIRING_CODES.pop(peer_id, None)
return False
if not secrets.compare_digest(stored_code, code):
return False
_PAIRING_CODES.pop(peer_id, None)
logger.info("IRC pairing code verified for peer %s", peer_id)
return True
def normalize_irc_allow_entry(entry: str) -> str:
stripped = entry.strip()
for prefix in ("irc:", "IRC:"):
if stripped.startswith(prefix):
stripped = stripped[len(prefix):]
if "!" in stripped or "@" in stripped:
return stripped.lower()
return stripped