| 1 | """ |
| 2 | OVERSIGHT DNS beacon server. |
| 3 | |
| 4 | Runs as an authoritative nameserver for the beacon domain (e.g. `beacon.example.com`). |
| 5 | Every DNS lookup against `<token_id>.t.<beacon_domain>` is logged as an event in |
| 6 | the registry, then answered with a generic A record so the resolver is satisfied. |
| 7 | |
| 8 | Why DNS beacons? |
| 9 | - They fire on document preview in tools that do hostname resolution for |
| 10 | linked images even when the HTTP fetch is blocked (many security sandboxes). |
| 11 | - They fire before any HTTP request, giving us earlier detection. |
| 12 | - They work through DNS-over-HTTPS resolvers, which are often allowed in |
| 13 | airgapped / restricted environments while direct HTTP is blocked. |
| 14 | |
| 15 | Deployment: |
| 16 | - Run on a public IP (same host as the registry is fine). |
| 17 | - Configure DNS glue: your beacon domain's parent zone NS records point |
| 18 | here on UDP port 53. |
| 19 | - The registry must publish an HTTP endpoint `POST /dns_event` that this |
| 20 | server calls for every incoming query. |
| 21 | |
| 22 | Startup: |
| 23 | sudo python -m oversight_dns.server \\ |
| 24 | --beacon-domain beacon.example.com \\ |
| 25 | --registry-url http://localhost:8765 \\ |
| 26 | --answer-ip 203.0.113.10 |
| 27 | |
| 28 | Run as root to bind :53, or use authbind/setcap to avoid root. |
| 29 | """ |
| 30 | |
| 31 | from __future__ import annotations |
| 32 | |
| 33 | import argparse |
| 34 | import logging |
| 35 | import os |
| 36 | import sys |
| 37 | import time |
| 38 | from pathlib import Path |
| 39 | |
| 40 | try: |
| 41 | from dnslib import DNSRecord, DNSHeader, RR, QTYPE, A |
| 42 | from dnslib.server import DNSServer, BaseResolver |
| 43 | except ImportError: |
| 44 | print("dnslib not installed. pip install dnslib") |
| 45 | sys.exit(1) |
| 46 | |
| 47 | import httpx |
| 48 | |
| 49 | |
| 50 | log = logging.getLogger("oversight_dns") |
| 51 | |
| 52 | |
| 53 | class OversightResolver(BaseResolver): |
| 54 | """Resolves queries matching <token_id>.t.<beacon_domain> and logs them.""" |
| 55 | |
| 56 | def __init__( |
| 57 | self, |
| 58 | beacon_domain: str, |
| 59 | registry_url: str, |
| 60 | answer_ip: str, |
| 61 | registry_secret: str = "", |
| 62 | ): |
| 63 | self.beacon_domain = beacon_domain.rstrip(".").lower() |
| 64 | self.registry_url = registry_url.rstrip("/") |
| 65 | self.answer_ip = answer_ip |
| 66 | self.registry_secret = registry_secret |
| 67 | self.token_suffix = f".t.{self.beacon_domain}" |
| 68 | |
| 69 | def resolve(self, request, handler): |
| 70 | reply = request.reply() |
| 71 | qname = str(request.q.qname).rstrip(".").lower() |
| 72 | qtype = QTYPE[request.q.qtype] |
| 73 | |
| 74 | client_ip = handler.client_address[0] if handler.client_address else "unknown" |
| 75 | |
| 76 | token_id = None |
| 77 | if qname.endswith(self.token_suffix): |
| 78 | prefix = qname[: -len(self.token_suffix)] |
| 79 | if all(c in "0123456789abcdef" for c in prefix) and len(prefix) == 32: |
| 80 | token_id = prefix |
| 81 | |
| 82 | if token_id: |
| 83 | log.info(f"DNS beacon fired: token={token_id[:16]}... client={client_ip} qtype={qtype}") |
| 84 | try: |
| 85 | headers = {} |
| 86 | if self.registry_secret: |
| 87 | headers["X-Oversight-DNS-Secret"] = self.registry_secret |
| 88 | httpx.post( |
| 89 | f"{self.registry_url}/dns_event", |
| 90 | json={ |
| 91 | "token_id": token_id, |
| 92 | "client_ip": client_ip, |
| 93 | "qtype": qtype, |
| 94 | "qname": qname, |
| 95 | }, |
| 96 | headers=headers, |
| 97 | timeout=2.0, |
| 98 | ) |
| 99 | except Exception as e: |
| 100 | log.warning(f"registry report failed: {e}") |
| 101 | |
| 102 | if request.q.qtype == QTYPE.A: |
| 103 | reply.add_answer(RR(qname, QTYPE.A, rdata=A(self.answer_ip), ttl=60)) |
| 104 | return reply |
| 105 | |
| 106 | |
| 107 | def main(): |
| 108 | p = argparse.ArgumentParser(description="OVERSIGHT DNS beacon server") |
| 109 | p.add_argument("--beacon-domain", required=True, |
| 110 | help="your beacon domain, e.g. beacon.example.com") |
| 111 | p.add_argument("--registry-url", required=True, |
| 112 | help="URL of the OVERSIGHT registry, e.g. http://localhost:8765") |
| 113 | p.add_argument("--answer-ip", required=True, |
| 114 | help="A-record answer IP (usually this server's public IP)") |
| 115 | p.add_argument("--registry-secret", default=os.environ.get("OVERSIGHT_DNS_EVENT_SECRET", ""), |
| 116 | help="shared secret sent to registry /dns_event") |
| 117 | p.add_argument("--port", type=int, default=53) |
| 118 | p.add_argument("--address", default="0.0.0.0") |
| 119 | p.add_argument("--log-level", default="INFO") |
| 120 | args = p.parse_args() |
| 121 | |
| 122 | logging.basicConfig(level=args.log_level, |
| 123 | format="%(asctime)s %(levelname)s %(name)s %(message)s") |
| 124 | |
| 125 | if not args.registry_secret and "localhost" not in args.registry_url and "127.0.0.1" not in args.registry_url: |
| 126 | log.warning("no registry secret configured; public registry callbacks may be rejected") |
| 127 | |
| 128 | resolver = OversightResolver( |
| 129 | args.beacon_domain, |
| 130 | args.registry_url, |
| 131 | args.answer_ip, |
| 132 | registry_secret=args.registry_secret, |
| 133 | ) |
| 134 | server = DNSServer(resolver, port=args.port, address=args.address, |
| 135 | tcp=False) |
| 136 | tcp_server = DNSServer(resolver, port=args.port, address=args.address, |
| 137 | tcp=True) |
| 138 | |
| 139 | log.info(f"OVERSIGHT DNS beacon server starting on {args.address}:{args.port}") |
| 140 | log.info(f" beacon domain: {args.beacon_domain}") |
| 141 | log.info(f" token pattern: <token>.t.{args.beacon_domain}") |
| 142 | log.info(f" registry: {args.registry_url}") |
| 143 | log.info(f" answer IP: {args.answer_ip}") |
| 144 | |
| 145 | server.start_thread() |
| 146 | tcp_server.start_thread() |
| 147 | |
| 148 | try: |
| 149 | while True: |
| 150 | time.sleep(60) |
| 151 | except KeyboardInterrupt: |
| 152 | log.info("shutting down") |
| 153 | server.stop() |
| 154 | tcp_server.stop() |
| 155 | |
| 156 | |
| 157 | if __name__ == "__main__": |
| 158 | main() |