Mini DHCP server
If you have a device that uses DHCP but you just want want to connect directly point to point, this mini dhcp python script implements a simple DHCPv4 server.
Usage
# Either stop NetworkManager
sudo systemctl stop NetworkManager
# Configure your interface with a static IP
sudo ip addr add 192.168.9.2/24 dev eth0
sudo ip link set eth0 up
# Or if you prefer the network manager UI
nm-connection-editor
# Run the DHCP server
sudo -E python3 mini_dhcp.py --iface eth0 --server-ip 192.168.9.2
--pool 192.168.9.5-192.168.9.10 --router 192.168.9.1 --dns 1.1.1.1
--mask 255.255.255.0 --lease 6000
# Dont forget to start Network manager if you stopped it
sudo systemctl start NetworkManagerUnderstanding DORA: The DHCP Handshake
DHCP uses a 4-step process called DORA to assign IP addresses:
1. Discover (Client → Server, Broadcast)
The client broadcasts a DHCPDISCOVER message to find available DHCP servers.
2. Offer (Server → Client, Unicast/Broadcast)
The server responds with a DHCPOFFER, proposing an IP address and network configuration.
3. Request (Client → Server, Broadcast)
The client broadcasts a DHCPREQUEST to accept the offer (broadcast because multiple servers may have responded).
4. Acknowledge (Server → Client, Unicast/Broadcast)
The server sends a DHCPACK confirming the lease and finalizing the configuration.
After DORA: The client configures its network interface with the assigned IP and can now communicate on the network.
https://en.wikipedia.org/wiki/Dynamic_Host_Configuration_Protocol
#!/usr/bin/env python3
"""
mini_dhcp.py — a minimal DHCPv4 server for test/lab use.
Requires: pip install scapy
Run as root: sudo python3 mini_dhcp.py --iface eth0 --server-ip 192.168.50.1 \
--pool 192.168.50.100-192.168.50.150 --router 192.168.50.1 --dns 1.1.1.1 \
--mask 255.255.255.0 --lease 600
"""
import argparse
import ipaddress
import time
from collections import OrderedDict
from scapy.all import (
BOOTP, DHCP, Ether, IP, UDP, conf, get_if_hwaddr, sendp, sniff
)
# ----------------- Helpers -----------------
def ip_to_int(ip):
return int(ipaddress.IPv4Address(ip))
def int_to_ip(n):
return str(ipaddress.IPv4Address(n))
def parse_pool(pool_str):
# "A.B.C.D-E.F.G.H" or "A.B.C.D-N" (end last octet)
if "-" not in pool_str:
raise ValueError("Pool must be START-END (e.g. 192.168.50.100-192.168.50.150)")
a, b = pool_str.split("-", 1)
start = ipaddress.IPv4Address(a.strip())
try:
# Full end IP
end = ipaddress.IPv4Address(b.strip())
except ipaddress.AddressValueError:
# Shorthand last octet, like "100-150"
base = a.strip().rsplit(".", 1)[0]
end = ipaddress.IPv4Address(f"{base}.{int(b.strip())}")
if int(end) < int(start):
raise ValueError("Pool end must be >= start")
return [int_to_ip(i) for i in range(int(start), int(end) + 1)]
# ----------------- DHCP Server -----------------
class MiniDHCP:
def __init__(self, iface, server_ip, pool, mask, router, dns, lease):
self.iface = iface
self.server_ip = server_ip
self.server_mac = get_if_hwaddr(iface)
self.pool = pool[:] # list of IP strings
self.mask = mask
self.router = router
self.dns = dns
self.lease = int(lease)
self.leases = OrderedDict() # mac -> {"ip": ip, "exp": epoch}
conf.iface = iface
print(f"[mini-dhcp] iface={iface} server_ip={server_ip} mac={self.server_mac}")
print(f"[mini-dhcp] pool={pool[0]} .. {pool[-1]} mask={mask} router={router} dns={dns} lease={self.lease}s")
def _cleanup_expired(self):
now = time.time()
expired = [mac for mac, d in self.leases.items() if d["exp"] <= now]
for mac in expired:
ip = self.leases[mac]["ip"]
print(f"[lease] expired {mac} -> {ip}")
del self.leases[mac]
def _is_in_pool(self, ip):
return ip in self.pool
def _alloc_ip(self, mac, requested=None):
self._cleanup_expired()
# Existing lease?
if mac in self.leases:
ip = self.leases[mac]["ip"]
self.leases[mac]["exp"] = time.time() + self.lease
return ip
# Requested IP strongly preferred if available
if requested:
if self._is_in_pool(requested):
in_use = {d["ip"] for m, d in self.leases.items() if m != mac}
if requested not in in_use:
ip = requested
self.leases[mac] = {"ip": ip, "exp": time.time() + self.lease}
print(f"[lease] new {mac} -> {ip} (requested)")
return ip
else:
print(f"[WARN] Requested IP {requested} already in use")
else:
print(f"[WARN] Requested IP {requested} not in pool")
# Fallback: first free IP from pool
in_use = {d["ip"] for d in self.leases.values()}
free = [ip for ip in self.pool if ip not in in_use]
if not free:
return None
ip = free[0]
self.leases[mac] = {"ip": ip, "exp": time.time() + self.lease}
print(f"[lease] new {mac} -> {ip}")
return ip
def _dhcp_options_common(self):
return [
("server_id", self.server_ip),
("lease_time", self.lease),
("subnet_mask", self.mask),
("router", self.router),
("name_server", self.dns),
"end",
]
def _send_offer(self, req, yiaddr):
client_mac = self._mac_of(req)
# Check broadcast flag (bit 15 of flags field)
broadcast = (req[BOOTP].flags & 0x8000) != 0
if broadcast:
# Client wants broadcast response
eth_dst = "ff:ff:ff:ff:ff:ff"
ip_dst = "255.255.255.255"
else:
# Client wants unicast response
# Layer 2 unicast to client MAC, but Layer 3 broadcast
# This is the most compatible approach for OFFER when client has no IP yet
eth_dst = client_mac
ip_dst = "255.255.255.255"
pkt = (
Ether(src=self.server_mac, dst=eth_dst)
/ IP(src=self.server_ip, dst=ip_dst)
/ UDP(sport=67, dport=68)
/ BOOTP(op=2, yiaddr=yiaddr, siaddr=self.server_ip,
chaddr=req[BOOTP].chaddr, xid=req[BOOTP].xid, flags=req[BOOTP].flags)
/ DHCP(options=[("message-type", "offer")] + self._dhcp_options_common())
)
sendp(pkt, verbose=0, iface=self.iface)
mode = "broadcast" if broadcast else "unicast-L2"
print(f"[OFFER] {yiaddr} -> {client_mac} ({mode}) xid=0x{req[BOOTP].xid:08x}")
def _send_ack(self, req, yiaddr):
client_mac = self._mac_of(req)
# Check broadcast flag (bit 15 of flags field)
broadcast = (req[BOOTP].flags & 0x8000) != 0
if broadcast:
# Client wants broadcast response
eth_dst = "ff:ff:ff:ff:ff:ff"
ip_dst = "255.255.255.255"
else:
# Client wants unicast response
# For ACK, client may have configured the IP, so we can try unicast at L3 too
eth_dst = client_mac
ip_dst = yiaddr
pkt = (
Ether(src=self.server_mac, dst=eth_dst)
/ IP(src=self.server_ip, dst=ip_dst)
/ UDP(sport=67, dport=68)
/ BOOTP(op=2, yiaddr=yiaddr, siaddr=self.server_ip,
chaddr=req[BOOTP].chaddr, xid=req[BOOTP].xid, flags=req[BOOTP].flags)
/ DHCP(options=[("message-type", "ack")] + self._dhcp_options_common())
)
sendp(pkt, verbose=0, iface=self.iface)
mode = "broadcast" if broadcast else "unicast"
print(f"[ACK] {yiaddr} -> {client_mac} ({mode}) xid=0x{req[BOOTP].xid:08x}")
def _mac_of(self, req):
# chaddr is 16 bytes; first six are MAC
mac_bytes = req[BOOTP].chaddr[:6]
return ":".join(f"{b:02x}" for b in mac_bytes)
def _get_dhcp_opt(self, req, name):
for opt in req[DHCP].options:
if isinstance(opt, tuple) and opt[0] == name:
return opt[1]
return None
def handle(self, pkt):
if not (pkt.haslayer(BOOTP) and pkt.haslayer(DHCP)):
return
dhcp_type = self._get_dhcp_opt(pkt, "message-type")
if dhcp_type is None:
return
mac = self._mac_of(pkt)
# Ignore requests from our own MAC (server interface)
if mac.lower() == self.server_mac.lower():
return
requested_ip = self._get_dhcp_opt(pkt, "requested_addr")
if isinstance(requested_ip, bytes):
requested_ip = ".".join(str(b) for b in requested_ip)
if dhcp_type == 1: # DHCPDISCOVER
ip = self._alloc_ip(mac, requested=requested_ip)
if ip:
self._send_offer(pkt, ip)
else:
print("[WARN] No free IPs to offer")
elif dhcp_type == 3: # DHCPREQUEST
# Try to honor requested; otherwise whatever we allocated
ip = self._alloc_ip(mac, requested=requested_ip)
if ip:
self._send_ack(pkt, ip)
else:
print("[WARN] No free IPs to ack")
# Optional: handle DHCPRELEASE (type 7) if you want to free early
elif dhcp_type == 7:
if mac in self.leases:
ip = self.leases[mac]["ip"]
del self.leases[mac]
print(f"[lease] released {mac} -> {ip}")
def send_initial_offer(self, target_mac, target_ip):
"""
Send an unsolicited OFFER to a known MAC/IP.
Useful for point-to-point links where client skips DISCOVER.
"""
# Build fake BOOTP request with matching chaddr/xid
xid = int(time.time()) & 0xFFFFFFFF # some random-ish xid
fake_req = BOOTP(chaddr=bytes.fromhex(target_mac.replace(':','')) + b'\x00'*10, xid=xid, flags=0)
pkt = (
Ether(src=self.server_mac, dst="ff:ff:ff:ff:ff:ff")
/ IP(src=self.server_ip, dst="255.255.255.255")
/ UDP(sport=67, dport=68)
/ BOOTP(op=2, yiaddr=target_ip, siaddr=self.server_ip,
chaddr=fake_req.chaddr, xid=fake_req.xid, flags=0)
/ DHCP(options=[("message-type", "offer")] + self._dhcp_options_common())
)
sendp(pkt, verbose=0, iface=self.iface)
print(f"[IMMEDIATE OFFER] {target_ip} -> {target_mac} xid=0x{xid:08x}")
def main():
ap = argparse.ArgumentParser(description="Minimal DHCPv4 server (lab only)")
ap.add_argument("--iface", default="eth0", help="Network interface to listen on")
ap.add_argument("--server-ip", required=True, help="Server IP (typically the gateway IP)")
ap.add_argument("--pool", required=True, help="Pool range START-END (e.g. 192.168.50.100-192.168.50.150)")
ap.add_argument("--mask", default="255.255.255.0", help="Subnet mask")
ap.add_argument("--router", required=True, help="Default gateway (router) to advertise")
ap.add_argument("--dns", default="1.1.1.1", help="DNS server to advertise")
ap.add_argument("--lease", type=int, default=600, help="Lease time seconds")
ap.add_argument("--immediate-offer", nargs=2, metavar=("MAC", "IP"),
help="Send immediate OFFER to MAC IP (e.g. aa:bb:cc:dd:ee:ff 10.42.0.1)")
args = ap.parse_args()
pool = parse_pool(args.pool)
server = MiniDHCP(
iface=args.iface,
server_ip=args.server_ip,
pool=pool,
mask=args.mask,
router=args.router,
dns=args.dns,
lease=args.lease,
)
if args.immediate_offer:
mac, ip = args.immediate_offer
server.send_initial_offer(mac, ip)
print("[mini-dhcp] Listening for DHCP on UDP 67/68 … Ctrl+C to stop.")
sniff(
iface=args.iface,
store=False,
prn=server.handle,
filter="udp and (port 67 or port 68)",
)
if __name__ == "__main__":
main()The use case for this is in a point to point configuration or a local switch/hub running without a dhcp server. You will need scapy installed. https://scapy.readthedocs.io/en/latest/introduction.html