diff --git a/THOUGHTS.txt b/THOUGHTS.txt
index 0b8c9392..d8d2031c 100644
--- a/THOUGHTS.txt
+++ b/THOUGHTS.txt
@@ -207,3 +207,16 @@ reference build-time packages, so we have x86-64 glibc in there
 
 We don't need syslog just to accommodate ppp, there's an underdocumented
 option for it to log to a file descriptor
+
+Wed Sep 28 16:04:02 BST 2022
+
+Based on https://unix.stackexchange.com/a/431953 if we can forge
+ethernet packets we might be able to write tests for e.g. "is the vm
+running a dhcp server"
+
+Wed Sep 28 21:29:05 BST 2022
+
+We can use Python "scapy" to generate dhcp request packets, and Python
+'socket' model to send them encapsulated in UDP. Win
+
+It's extremely janky python
diff --git a/overlay.nix b/overlay.nix
index f37310fe..693816e7 100644
--- a/overlay.nix
+++ b/overlay.nix
@@ -12,6 +12,10 @@ final: prev: {
   s6-init-bin =  final.callPackage ./pkgs/s6-init-bin {};
   s6-rc-database = final.callPackage ./pkgs/s6-rc-database {};
 
+  dnsmasq = prev.dnsmasq.override {
+    dbusSupport = false;
+  };
+
   pppoe = final.callPackage ./pkgs/pppoe {};
   ppp =
     (prev.ppp.override {
diff --git a/pkgs/liminix-tools/networking/default.nix b/pkgs/liminix-tools/networking/default.nix
index 1592cb4f..57694d71 100644
--- a/pkgs/liminix-tools/networking/default.nix
+++ b/pkgs/liminix-tools/networking/default.nix
@@ -18,6 +18,8 @@ in {
       name = "${interface.device}.addr.${address}";
       up = "ip address add ${address}/${toString prefixLength} dev ${interface.device} ";
       down = "ip address del ${address}/${toString prefixLength} dev ${interface.device} ";
+    } // {
+      inherit (interface) device;
     };
   udhcpc = callPackage ./udhcpc.nix {};
   odhcpc = interface: { ... } @ args: longrun {
@@ -25,6 +27,7 @@ in {
     run = "odhcpcd ${interface.device}";
   };
   pppoe = callPackage ./pppoe.nix {};
+  dnsmasq = callPackage ./dnsmasq.nix {};
   route = { name, target, via, dependencies }:
     oneshot {
       inherit name;
diff --git a/pkgs/liminix-tools/networking/dnsmasq.nix b/pkgs/liminix-tools/networking/dnsmasq.nix
new file mode 100644
index 00000000..a7c34034
--- /dev/null
+++ b/pkgs/liminix-tools/networking/dnsmasq.nix
@@ -0,0 +1,39 @@
+{
+  liminix
+, dnsmasq
+, lib
+}:
+{
+  user ? "dnsmasq"
+, group ? "dnsmasq"
+, interface
+, upstreams ? []
+, ranges
+, domain
+} :
+let
+  inherit (liminix.services) longrun;
+  inherit (lib) concatStringsSep;
+  name = "${interface.device}.dnsmasq";
+in longrun {
+  inherit name;
+  dependencies = [ interface ];
+  run = ''
+    ${dnsmasq}/bin/dnsmasq \
+    --user=${user} \
+    --domain=${domain} \
+    --group=${group} \
+    --interface=${interface.device} \
+    ${lib.concatStringsSep " " (builtins.map (r: "--dhcp-range=${r}") ranges)} \
+    ${lib.concatStringsSep " " (builtins.map (r: "--server=${r}") upstreams)} \
+    --keep-in-foreground \
+    --dhcp-authoritative \
+    --no-resolv \
+    --log-dhcp \
+    --enable-ra \
+    --log-debug \
+    --log-facility=- \
+    --dhcp-leasefile=/run/${name}.leases \
+    --pid-file=/run/${name}.pid
+  '';
+}
diff --git a/tests/pppoe/configuration.nix b/tests/pppoe/configuration.nix
index 2f8210a3..811aa689 100644
--- a/tests/pppoe/configuration.nix
+++ b/tests/pppoe/configuration.nix
@@ -1,6 +1,6 @@
-{ config, pkgs, ... } :
+{ config, pkgs, lib, ... } :
 let
-  inherit (pkgs.liminix.networking) interface address pppoe route;
+  inherit (pkgs.liminix.networking) interface address pppoe route dnsmasq;
   inherit (pkgs.liminix.services) oneshot longrun bundle target output;
 in rec {
   services.loopback =
@@ -13,6 +13,10 @@ in rec {
       ];
     };
 
+  services.lan4 =
+    let iface = interface { type = "hardware"; device = "eth1";};
+    in address iface { family = "inet4"; address ="192.168.19.1"; prefixLength = 24;};
+
   kernel.config = {
     "IKCONFIG_PROC" = "y";
     "PPP" = "y";
@@ -53,14 +57,29 @@ in rec {
       dependencies = [iface];
     };
 
+  users.dnsmasq = {
+    uid = 51; gid= 51; gecos = "DNS/DHCP service user";
+    dir = "/run/dnsmasq";
+    shell = "/bin/false";
+  };
+  groups.dnsmasq = {
+    gid = 51; usernames = ["dnsmasq"];
+  };
+  services.dns =
+    dnsmasq {
+      interface = services.lan4;
+      ranges = ["192.168.19.10,192.168.19.253"];
+      domain = "fake.liminix.org";
+    };
+
   services.default = target {
     name = "default";
     contents = with services; [
       loopback
       defaultroute4
       packet_forwarding
+      dns
     ];
   };
-
   defaultProfile.packages = [ pkgs.hello ] ;
 }
diff --git a/tests/pppoe/run.sh b/tests/pppoe/run.sh
index 541b8a7d..48cb80de 100755
--- a/tests/pppoe/run.sh
+++ b/tests/pppoe/run.sh
@@ -23,3 +23,9 @@ fi
 
 ../../scripts/run-qemu.sh --background foo.sock result/vmlinux result/squashfs
 nix-shell -p expect --run "expect getaddress.expect"
+
+set -o pipefail
+response=$(nix-shell -p python3Packages.scapy --run 'python ./test-dhcp-service.py' )
+
+echo "$response"
+echo "$response" | nix-shell -p jq --run "jq -e 'select((.router ==  \"192.168.19.1\") and (.server_id==\"192.168.19.1\"))'"
diff --git a/tests/pppoe/test-dhcp-service.py b/tests/pppoe/test-dhcp-service.py
new file mode 100644
index 00000000..6f9ccaf0
--- /dev/null
+++ b/tests/pppoe/test-dhcp-service.py
@@ -0,0 +1,88 @@
+# forge packets for testing liminix and send them via the qemu udp
+# multicast socket interface
+
+MCAST_GRP = '230.0.0.1'
+MCAST_PORT = 1235
+MULTICAST_TTL = 2
+
+TIMEOUT = 10                    # seconds
+
+from warnings import filterwarnings
+filterwarnings("ignore")
+
+import random
+import binascii
+import socket
+import time
+import json
+
+from builtins import bytes, bytearray
+
+from scapy.all import Ether, IP, UDP, BOOTP, DHCP, sendp, send, raw
+
+class JSONEncoderWithBytes(json.JSONEncoder):
+    def default(self, obj):
+        if isinstance(obj, (bytes, bytearray)):
+            return obj.decode('utf-8')
+        return json.JSONEncoder.default(self, obj)
+
+
+def dhcp_option(pkt, label):
+    if pkt.haslayer(DHCP):
+        for i in pkt[DHCP].options:
+            l, v = i
+            if l == label:
+                return v
+    return None
+
+def is_dhcp_offer(pkt):
+    val = dhcp_option(pkt, 'message-type')
+    return (val == 2)
+
+
+
+def mac_to_bytes(mac_addr: str) -> bytes:
+    """ Converts a MAC address string to bytes.
+    """
+    return int(mac_addr.replace(":", ""), 16).to_bytes(6, "big")
+
+
+client_mac = "01:02:03:04:05:06"
+discover = (
+    Ether(dst="ff:ff:ff:ff:ff:ff") /
+    IP(src="0.0.0.0", dst="255.255.255.255") /
+    UDP(sport=68, dport=67) /
+    BOOTP(
+        chaddr=mac_to_bytes(client_mac),
+        xid=random.randint(1, 2**32-1),
+    ) /
+    DHCP(options=[("message-type", "discover"), "end"])
+)
+payload = raw(discover)
+
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+sock.settimeout(TIMEOUT)
+sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, MULTICAST_TTL)
+
+sock.bind((MCAST_GRP, MCAST_PORT))
+host = socket.gethostbyname(socket.gethostname())
+sock.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_IF, socket.inet_aton(host))
+sock.setsockopt(socket.SOL_IP, socket.IP_ADD_MEMBERSHIP,
+                socket.inet_aton(MCAST_GRP) + socket.inet_aton(host))
+
+endtime = time.time() + TIMEOUT
+sock.sendto(payload, (MCAST_GRP, MCAST_PORT))
+
+while time.time() < endtime:
+  try:
+    data, addr = sock.recvfrom(1024)
+  except socket.error:
+    print('Exception')
+  else:
+    reply = Ether(data)
+    if is_dhcp_offer(reply):
+        opts = dict([o for o in reply[DHCP].options if type(o) is tuple])
+        print(json.dumps(opts, cls=JSONEncoderWithBytes))
+        exit(0)
+exit(1)