Zion Boggan
repos/Oversight/oversight_dns/server.py
zionboggan.com ↗
158 lines · python
History for this file →
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()