Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@ jobs:
path: sbom.spdx.json

- name: Vulnerability scan
run: |
if command -v trivy &>/dev/null; then
trivy fs . --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1
else
echo "trivy not available; skipping vulnerability scan"
fi
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
Expand Down
2 changes: 2 additions & 0 deletions netengine/diagnostic/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ def _parse_compose_port(raw_port: object) -> tuple[int, str] | None:
return raw_port, "tcp"
if isinstance(raw_port, dict):
published = raw_port.get("published") or raw_port.get("target")
if published is None:
return None
proto = str(raw_port.get("protocol") or "tcp").lower()
if published is None:
return None
Expand Down
67 changes: 67 additions & 0 deletions netengine/handlers/dns.py
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,73 @@ def _send() -> bytes:
logger.debug(f"SOA query to {server_ip}:53 failed: {type(exc).__name__}: {exc}")
return False

# ─────────────────────────────────────────────
# Reverse DNS (reverse_dns profile feature)
# ─────────────────────────────────────────────

async def add_reverse_zone(self, context: "PhaseContext", cidr: str, gateway_ip: str) -> None:
"""Create an in-addr.arpa zone for the AND subnet and register a PTR for the gateway."""
import ipaddress as _ip

network = _ip.ip_network(cidr, strict=False)
octets = str(network.network_address).split(".")
# For a /24 the reverse zone is <third>.<second>.<first>.in-addr.arpa
rev_zone = f"{octets[2]}.{octets[1]}.{octets[0]}.in-addr.arpa"

dns_output = context.runtime_state.dns_output
if dns_output is None:
context.logger.warning("DNS phase output missing; skipping reverse zone setup")
return

zone_files: dict[str, str] = dns_output.get("zone_files", {})
if rev_zone not in zone_files:
zone_files[rev_zone] = self._empty_zone(rev_zone)
dns_output["zone_files"] = zone_files

# Add a PTR record for the gateway IP
gw_last_octet = gateway_ip.split(".")[-1]
await self.add_zone_record(
context=context,
zone=rev_zone,
record_type="PTR",
name=gw_last_octet,
value=f"gateway.{rev_zone}",
ttl=300,
)
context.logger.info(f"Reverse DNS zone registered: {rev_zone}")

async def remove_reverse_zone(self, context: "PhaseContext", cidr: str) -> None:
"""Remove the in-addr.arpa zone for the AND subnet."""
import ipaddress as _ip

network = _ip.ip_network(cidr, strict=False)
octets = str(network.network_address).split(".")
rev_zone = f"{octets[2]}.{octets[1]}.{octets[0]}.in-addr.arpa"

dns_output = context.runtime_state.dns_output
if dns_output is None:
return

zone_files = dns_output.get("zone_files", {})
zone_files.pop(rev_zone, None)
dns_output["zone_files"] = zone_files

zone_file_path = context.zone_dir and (
__import__("pathlib").Path(context.zone_dir) / "zones" / rev_zone
)
if zone_file_path and zone_file_path.exists():
zone_file_path.unlink()
context.logger.info(f"Reverse DNS zone removed: {rev_zone}")

@staticmethod
def _empty_zone(zone_name: str) -> str:
return (
f"$ORIGIN {zone_name}.\n"
f"@ 300 IN SOA ns1.{zone_name}. hostmaster.{zone_name}. "
f"1 3600 900 604800 300\n"
f"@ 300 IN NS ns1.{zone_name}.\n"
)

# ─────────────────────────────────────────────
# Event Emission
# ─────────────────────────────────────────────
Expand Down
114 changes: 114 additions & 0 deletions netengine/handlers/gateway_handler.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ipaddress
import os
import tempfile
from typing import TYPE_CHECKING, Any, Optional
Expand Down Expand Up @@ -325,3 +326,116 @@ async def remove_peer_routing(self, peer_name: str) -> None:
self.gateway_container,
["rm", "-f", f"/etc/nftables/rules/peer_{peer_name}.nft"],
)

# ─────────────────────────────────────────────
# DHCP (dynamic_ip profile feature)
# ─────────────────────────────────────────────

async def setup_dhcp(self, and_name: str, cidr: str, gateway_ip: str) -> None:
"""Write a dnsmasq config for the AND subnet and reload dnsmasq."""
network = ipaddress.ip_network(cidr, strict=False)
# Reserve .1 (gateway) and broadcast; hand out .2 through penultimate
start = str(network.network_address + 2)
end = str(network.broadcast_address - 1)
conf = (
f"interface=eth_{and_name}\n"
f"dhcp-range={start},{end},12h\n"
f"dhcp-option=3,{gateway_ip}\n"
f"dhcp-option=6,{gateway_ip}\n"
)
conf_path = f"/etc/dnsmasq.d/{and_name}.conf"
with tempfile.NamedTemporaryFile(mode="w", suffix=".conf", delete=False) as f:
f.write(conf)
tmp_path = f.name
try:
await self.docker.copy_to_container(self.gateway_container, tmp_path, conf_path)
finally:
os.unlink(tmp_path)

# Signal running dnsmasq to reload; start it if not yet running.
exit_code, _ = await self.docker.exec_command(
self.gateway_container, ["pkill", "-SIGHUP", "dnsmasq"]
)
if exit_code != 0:
_, err = await self.docker.exec_command(
self.gateway_container,
["dnsmasq", "--conf-dir=/etc/dnsmasq.d", "--keep-in-foreground"],
)
if err and "already" not in err.lower():
raise GatewayError(f"Failed to start dnsmasq for {and_name}: {err}")

async def remove_dhcp(self, and_name: str) -> None:
"""Remove dnsmasq config for the AND and reload."""
await self.docker.exec_command(
self.gateway_container, ["rm", "-f", f"/etc/dnsmasq.d/{and_name}.conf"]
)
await self.docker.exec_command(self.gateway_container, ["pkill", "-SIGHUP", "dnsmasq"])

# ─────────────────────────────────────────────
# BGP speaker (bgp profile feature)
# ─────────────────────────────────────────────

async def setup_bgp(self, and_name: str, cidr: str, gateway_ip: str, bgp_mode: str) -> None:
"""Provision a Bird2 BGP speaker sidecar container for the AND."""
bird_conf = self._bird_conf(and_name, cidr, gateway_ip)
conf_path = f"/etc/bird/bird_{and_name}.conf"
with tempfile.NamedTemporaryFile(mode="w", suffix=".conf", delete=False) as f:
f.write(bird_conf)
tmp_path = f.name

container_name = f"netengine_bgp_{and_name}"
bridge_name = f"netengines_and_{and_name}"
try:
await self.docker.start_container(
name=container_name,
image="pierrecdn/bird:2.0.9",
command=["bird", "-c", "/etc/bird/bird.conf", "-f"],
volumes={},
network=bridge_name,
ip=str(ipaddress.ip_network(cidr, strict=False).network_address + 2),
environment={},
)
except Exception as exc:
os.unlink(tmp_path)
if bgp_mode == "required":
raise GatewayError(
f"BGP speaker required but failed to start for {and_name}: {exc}"
) from exc
return

try:
await self.docker.copy_to_container(container_name, tmp_path, conf_path)
finally:
os.unlink(tmp_path)

await self.docker.exec_command(container_name, ["birdc", "configure"])

async def remove_bgp(self, and_name: str) -> None:
"""Stop and remove the BGP speaker sidecar for the AND."""
container_name = f"netengine_bgp_{and_name}"
try:
await self.docker.stop_container(container_name)
except Exception:
pass

def _bird_conf(self, and_name: str, cidr: str, gateway_ip: str) -> str:
return f"""\
router id {gateway_ip};

protocol device {{}}

protocol direct {{
ipv4;
}}

protocol kernel {{
ipv4 {{
export all;
}};
}}

protocol static netengine_{and_name} {{
ipv4;
route {cidr} blackhole;
}}
"""
44 changes: 43 additions & 1 deletion netengine/phases/phase_ands.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from netengine.events.schema import EventEnvelope
from netengine.handlers._base import BasePhaseHandler
from netengine.handlers.context import PhaseContext
from netengine.handlers.dns import DNSHandler
from netengine.handlers.docker_handler import DockerHandler
from netengine.handlers.gateway_handler import GatewayHandler

Expand Down Expand Up @@ -287,7 +288,48 @@ async def _provision_and(
except Exception as e:
logger.warning(f"Failed to register DNS for {dns_suffix}: {e}")

# 7. Store state
# 8. DHCP (dynamic_ip)
if profile_obj.dynamic_ip:
try:
await gateway.setup_dhcp(
and_name=and_instance.name, cidr=cidr, gateway_ip=gateway_ip
)
logger.info(f"DHCP configured for {and_instance.name}")
except Exception as e:
logger.warning(f"DHCP setup failed for {and_instance.name} (non-fatal): {e}")

# 9. Reverse DNS (reverse_dns)
if profile_obj.reverse_dns:
try:
dns_handler = DNSHandler()
await dns_handler.add_reverse_zone(
context=context, cidr=cidr, gateway_ip=gateway_ip
)
except Exception as e:
logger.warning(f"Reverse DNS setup failed for {and_instance.name} (non-fatal): {e}")

# 10. BGP speaker (bgp)
if profile_obj.bgp is not None:
try:
await gateway.setup_bgp(
and_name=and_instance.name,
cidr=cidr,
gateway_ip=gateway_ip,
bgp_mode=profile_obj.bgp,
)
logger.info(
f"BGP speaker provisioned for {and_instance.name} (mode={profile_obj.bgp})"
)
except Exception as e:
if profile_obj.bgp == "required":
raise RuntimeError(
f"Required BGP setup failed for {and_instance.name}: {e}"
) from e
logger.warning(
f"BGP setup failed for {and_instance.name} (optional, non-fatal): {e}"
)

# 11. Store state
and_data = {
"name": and_instance.name,
"org": and_instance.org,
Expand Down
12 changes: 6 additions & 6 deletions netengine/spec/feature_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,21 @@ class FeatureStateEntry:
),
FeatureStateEntry(
path="ands.profiles.*.dynamic_ip",
state="unsupported",
state="experimental",
stage="alpha",
reason="dynamic IP allocation is not implemented",
reason="DHCP via dnsmasq in gateway container; requires dnsmasq installed in gateway image",
),
FeatureStateEntry(
path="ands.profiles.*.reverse_dns",
state="unsupported",
state="experimental",
stage="alpha",
reason="reverse DNS delegation is not implemented",
reason="in-addr.arpa zone provisioning available; not yet propagated to external resolvers",
),
FeatureStateEntry(
path="ands.profiles.*.bgp",
state="unsupported",
state="experimental",
stage="alpha",
reason="BGP profile configuration is not implemented",
reason="Bird2 BGP speaker sidecar; requires pierrecdn/bird:2.0.9 image available",
),
FeatureStateEntry(
path="pki.intermediate_ca_enabled",
Expand Down
5 changes: 5 additions & 0 deletions scripts/generate_license_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ def field(meta: metadata.PackageMetadata, name: str) -> str:
return compact or "UNKNOWN"


_INFRA_PACKAGES = frozenset({"pip", "setuptools", "wheel", "distribute"})


def main() -> int:
rows = []
for dist in sorted(metadata.distributions(), key=lambda d: (d.metadata.get("Name") or "").lower()):
meta = dist.metadata
name = field(meta, "Name")
if name.lower() in _INFRA_PACKAGES:
continue
version = dist.version or "UNKNOWN"
license_expr = field(meta, "License-Expression")
license_field = license_expr if license_expr != "UNKNOWN" else field(meta, "License")
Expand Down
Loading