velxio/backend/app/services/picow_net/dhcp.py

157 lines
4.2 KiB
Python

"""
DHCP server — synthesises OFFER and ACK responses so MicroPython's
DHCP client receives a real handshake and ``wlan.ifconfig()`` returns
proper addresses.
We answer two messages:
- DISCOVER → OFFER (with yiaddr=STA_IP, sets the lease)
- REQUEST → ACK (confirms the lease)
Other DHCP messages (DECLINE, RELEASE, INFORM) are accepted but no
visible response — RELEASE just clears our (single-tenant) lease.
Per RFC 2131:
- server identifier (option 54) = GATEWAY_IP
- subnet mask (option 1) = 255.255.255.0
- router (option 3) = GATEWAY_IP
- DNS servers (option 6) = DNS_IP
- lease time (option 51) = DHCP_LEASE_SECONDS
"""
from __future__ import annotations
import struct
from typing import Optional
from .consts import (
DHCP_ACK,
DHCP_DISCOVER,
DHCP_LEASE_SECONDS,
DHCP_OFFER,
DHCP_REQUEST,
DNS_IP,
GATEWAY_IP,
GATEWAY_MAC,
STA_IP,
ip_to_bytes,
)
from .protocols import (
BROADCAST_MAC,
Dhcp,
UDP,
make_frame_ipv4,
)
# DHCP option codes
OPT_SUBNET_MASK = 1
OPT_ROUTER = 3
OPT_DNS = 6
OPT_HOSTNAME = 12
OPT_REQUESTED_IP = 50
OPT_LEASE_TIME = 51
OPT_MESSAGE_TYPE = 53
OPT_SERVER_ID = 54
OPT_PARAMETER_REQUEST_LIST = 55
OPT_RENEWAL_TIME = 58
OPT_REBINDING_TIME = 59
OPT_END = 255
class DhcpServer:
"""One instance per simulated chip — single-tenant network."""
def __init__(self) -> None:
self.lease_active = False
def handle(
self,
chip_mac: bytes,
udp: UDP,
) -> Optional[tuple[bytes, bytes, UDP]]:
"""
Returns (dst_ip, src_ip, response_udp) or None.
Caller wraps in IPv4+Ethernet using GATEWAY_MAC as src.
"""
try:
req = Dhcp.parse(udp.payload)
except ValueError:
return None
if req.op != 1: # not BOOTREQUEST
return None
msg_type_bytes = req.options.get(OPT_MESSAGE_TYPE, b'')
if not msg_type_bytes:
return None
msg_type = msg_type_bytes[0]
if msg_type == DHCP_DISCOVER:
return self._build_response(req, DHCP_OFFER, chip_mac)
if msg_type == DHCP_REQUEST:
self.lease_active = True
return self._build_response(req, DHCP_ACK, chip_mac)
# DECLINE / RELEASE / INFORM — ignored.
return None
def _build_response(
self,
req: Dhcp,
msg_type: int,
chip_mac: bytes,
) -> tuple[bytes, bytes, UDP]:
gw_ip = ip_to_bytes(GATEWAY_IP)
sta_ip = ip_to_bytes(STA_IP)
dns_ip = ip_to_bytes(DNS_IP)
lease_secs = DHCP_LEASE_SECONDS
resp = Dhcp(
op=2, # BOOTREPLY
xid=req.xid,
flags=req.flags,
ciaddr=req.ciaddr,
yiaddr=sta_ip,
siaddr=gw_ip,
giaddr=req.giaddr,
chaddr=chip_mac.ljust(16, b'\x00'),
options={
OPT_MESSAGE_TYPE: bytes([msg_type]),
OPT_SERVER_ID: gw_ip,
OPT_LEASE_TIME: struct.pack('!I', lease_secs),
OPT_RENEWAL_TIME: struct.pack('!I', lease_secs // 2),
OPT_REBINDING_TIME: struct.pack('!I', (lease_secs * 7) // 8),
OPT_SUBNET_MASK: bytes([255, 255, 255, 0]),
OPT_ROUTER: gw_ip,
OPT_DNS: dns_ip,
},
)
# DHCP responses go from server (gateway, port 67) to client
# (broadcast or unicast STA, port 68).
out_udp = UDP(
src_port=67,
dst_port=68,
payload=resp.to_bytes(),
)
return sta_ip, gw_ip, out_udp
def is_dhcp_traffic(udp: UDP) -> bool:
return udp.dst_port == 67 or udp.src_port == 67
def make_dhcp_frame(
chip_mac: bytes,
src_ip: bytes,
dst_ip: bytes,
udp: UDP,
use_broadcast_dst: bool = True,
) -> bytes:
"""Wrap a DHCP UDP segment in IPv4 + Ethernet for delivery to the chip."""
dst_mac = BROADCAST_MAC if use_broadcast_dst else chip_mac
return make_frame_ipv4(
dst_mac=dst_mac,
src_mac=GATEWAY_MAC,
src_ip=src_ip,
dst_ip=dst_ip,
protocol=17, # UDP
l4_payload=udp.to_bytes(src_ip, dst_ip),
)