Files
fips/testing/chaos/sim/config_gen.py
T
Johnathan Corgan ec64a0dce1 Add TCP transport implementation and test harness support
Implement TCP transport for FIPS enabling firewall traversal and serving
as the foundation for future Tor transport. This is the first
connection-oriented transport in the system.

Key design decisions:
- FMP header-based framing: reuses existing 4-byte FMP common prefix for
  packet boundary recovery with zero framing overhead
- Session survives TCP reconnection: Noise/MMP/FSP state bound to npub,
  not TCP connection; MMP liveness is sole authority for peer death
- Connect-on-send: fresh connection on first send, transparent reconnect
- close_connection() trait method for cross-connection deduplication cleanup

New transport files:
- src/transport/tcp/mod.rs: TcpTransport, connection pool, accept loop
- src/transport/tcp/stream.rs: FMP-aware stream reader (shared with Tor)

Modified: transport trait (close_connection), TcpConfig, TransportHandle
match arms, create_transports(), initiate_connection() for connection-
oriented links, cross-connection tie-breaker cleanup, design docs.

Tree announce loop and TCP stability fixes:
- Preserve tree announce rate-limit state across reconnection: carry
  forward last_tree_announce_sent_ms when a peer reconnects so the
  rate-limit window isn't reset to zero
- Drop oversize TCP packets at sender: pre-send MTU check returns
  MtuExceeded instead of writing to the stream, preventing receiver-side
  connection teardown and reset-reconnect cycles

Chaos harness:
- TCP transport support: tcp_edges/has_tcp/tcp_peers in SimTopology,
  transport-aware config_gen with per-edge transport type, TCP port 443,
  pure-TCP node support
- Include all non-Ethernet edges in directed_outbound()
- Fix netem/links log messages to say "IP-based" instead of "UDP"
- Add tcp-chain, tcp-only, and tcp-mesh scenario files

Static harness:
- Transport-aware config generation (get_default_transport, transport_port)
- TCP transport injection via Python post-processing
- Add tcp-chain topology and docker-compose profile
2026-02-27 00:41:37 +00:00

187 lines
6.0 KiB
Python

"""FIPS node config generation from template + topology."""
from __future__ import annotations
import os
from copy import deepcopy
import yaml
from .topology import SimTopology
def _deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge override into base (override wins on conflicts)."""
result = deepcopy(base)
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = deepcopy(value)
return result
# Path to the shared node config template
_TEMPLATE_PATH = os.path.join(
os.path.dirname(__file__), "..", "configs", "node.template.yaml"
)
def _load_template() -> str:
with open(_TEMPLATE_PATH) as f:
return f.read()
_TRANSPORT_PORTS = {
"udp": 2121,
"tcp": 443,
}
def generate_peers_block(
topology: SimTopology, node_id: str, outbound_peers: list[str]
) -> str:
"""Generate the YAML peers block for a node.
Only includes peers that this node is responsible for connecting to
(outbound direction). The link is still bidirectional once established.
Transport type and port are determined per-edge from the topology.
"""
if not outbound_peers:
return " []"
lines = []
for peer_id in sorted(outbound_peers):
peer = topology.nodes[peer_id]
transport = topology.transport_for_edge(node_id, peer_id)
port = _TRANSPORT_PORTS.get(transport, 2121)
lines.append(f' - npub: "{peer.npub}"')
lines.append(f' alias: "{peer_id}"')
lines.append(f" addresses:")
lines.append(f" - transport: {transport}")
lines.append(f' addr: "{peer.docker_ip}:{port}"')
lines.append(f" connect_policy: auto_connect")
return "\n".join(lines)
def _build_ethernet_config(iface: str) -> dict:
"""Build an Ethernet transport config dict for a single interface."""
return {
"interface": iface,
"discovery": True,
"announce": True,
"auto_connect": True,
"accept_connections": True,
"beacon_interval_secs": 10,
}
def _inject_ethernet_transports(parsed: dict, eth_ifaces: list[str]):
"""Inject Ethernet transport config into a parsed FIPS config.
For a single interface, uses the single-instance format.
For multiple interfaces, uses the named-instances format.
Pure-Ethernet nodes (no UDP peers) have their UDP transport removed.
"""
if not eth_ifaces:
return
transports = parsed.setdefault("transports", {})
if len(eth_ifaces) == 1:
transports["ethernet"] = _build_ethernet_config(eth_ifaces[0])
else:
transports["ethernet"] = {
iface: _build_ethernet_config(iface) for iface in eth_ifaces
}
def _inject_tcp_transport(parsed: dict):
"""Inject TCP transport config into a parsed FIPS config."""
transports = parsed.setdefault("transports", {})
transports["tcp"] = {
"bind_addr": "0.0.0.0:443",
}
def generate_node_config(
topology: SimTopology,
node_id: str,
outbound_peers: list[str],
fips_overrides: dict | None = None,
) -> str:
"""Generate a complete FIPS config YAML for one node."""
template = _load_template()
node = topology.nodes[node_id]
peers_yaml = generate_peers_block(topology, node_id, outbound_peers)
config = template
config = config.replace("{{NODE_NAME}}", node_id.upper())
config = config.replace("{{TOPOLOGY}}", "sim")
config = config.replace("{{NPUB}}", node.npub)
config = config.replace("{{NSEC}}", node.nsec)
config = config.replace("{{PEERS}}", peers_yaml)
# Determine which transports this node participates in
eth_ifaces = topology.ethernet_interfaces(node_id)
has_tcp = bool(topology.tcp_peers(node_id))
has_udp = _has_transport_peers(topology, node_id, "udp")
# Inject non-UDP transport configs and handle pure-transport nodes
needs_yaml_rewrite = eth_ifaces or has_tcp or not has_udp or fips_overrides
if needs_yaml_rewrite:
parsed = yaml.safe_load(config)
if fips_overrides:
parsed = _deep_merge(parsed, fips_overrides)
if eth_ifaces:
_inject_ethernet_transports(parsed, eth_ifaces)
if has_tcp:
_inject_tcp_transport(parsed)
if not has_udp:
# No UDP edges: remove UDP transport
transports = parsed.get("transports", {})
transports.pop("udp", None)
config = yaml.dump(parsed, default_flow_style=False, sort_keys=False)
return config
def _has_transport_peers(topology: SimTopology, node_id: str, transport: str) -> bool:
"""Check if a node has any edges (inbound or outbound) using the given transport."""
for peer_id in topology.nodes[node_id].peers:
edge = (min(node_id, peer_id), max(node_id, peer_id))
if topology.edge_transport.get(edge, "udp") == transport:
return True
return False
def generate_npubs_env(topology: SimTopology) -> str:
"""Generate npubs.env content mapping NPUB_<ID>=<npub> for all nodes."""
lines = []
for node_id in sorted(topology.nodes):
node = topology.nodes[node_id]
env_name = f"NPUB_{node_id.upper()}"
lines.append(f"{env_name}={node.npub}")
return "\n".join(lines) + "\n"
def write_configs(
topology: SimTopology,
output_dir: str,
fips_overrides: dict | None = None,
):
"""Write all node configs and npubs.env to the output directory."""
os.makedirs(output_dir, exist_ok=True)
outbound = topology.directed_outbound()
for node_id in topology.nodes:
config = generate_node_config(
topology, node_id, outbound[node_id], fips_overrides
)
path = os.path.join(output_dir, f"{node_id}.yaml")
with open(path, "w") as f:
f.write(config)
env_path = os.path.join(output_dir, "npubs.env")
with open(env_path, "w") as f:
f.write(generate_npubs_env(topology))