Commit 701999f

Ansari <ping@ansari.wtf>
2026-05-12 11:07:31
base dns structure direct from RFC
dns/answer.go
@@ -0,0 +1,33 @@
+package dns
+
+import "encoding/binary"
+
+// RR is a DNS Resource Record (an answer)
+// RFC 1035 section 3.2.1
+type RR struct {
+	Name  string
+	Type  uint16
+	Class uint16
+	TTL   uint32 // Time to live in seconds
+	RData []byte // Actual record data, varies by type
+}
+
+// Pack serializes an RR to wire format
+func (rr *RR) Pack() []byte {
+	var buf []byte
+	buf = append(buf, PackDomain(rr.Name)...)
+
+	// Type, Class, TTL, RDLength, RData
+	tmp := make([]byte, 8)
+	binary.BigEndian.PutUint16(tmp[0:2], rr.Type)
+	binary.BigEndian.PutUint16(tmp[2:4], rr.Class)
+	binary.BigEndian.PutUint32(tmp[4:8], rr.TTL)
+	buf = append(buf, tmp...)
+
+	// RDLength + RData
+	rdLen := make([]byte, 2)
+	binary.BigEndian.PutUint16(rdLen, uint16(len(rr.RData)))
+	buf = append(buf, rdLen...)
+	buf = append(buf, rr.RData...)
+	return buf
+}
dns/domain.go
@@ -0,0 +1,96 @@
+package dns
+
+import (
+	"encoding/binary"
+	"fmt"
+	"strings"
+)
+
+// PackDomain encodes a domain name into DNS wire format
+// "ansari.wtf" → [6]ansari[3]wtf[0]
+func PackDomain(domain string) []byte {
+	// Remove trailing dot if present
+	domain = strings.TrimSuffix(domain, ".")
+
+	if domain == "" {
+		return []byte{0} // root
+	}
+
+	var buf []byte
+	labels := strings.SplitSeq(domain, ".")
+	for label := range labels {
+		if len(label) > 63 {
+			panic("label too long") // RFC limit: 63 chars per label
+		}
+		buf = append(buf, byte(len(label)))
+		buf = append(buf, []byte(label)...)
+	}
+	buf = append(buf, 0) // null terminator
+	return buf
+}
+
+// UnpackDomain decodes a DNS name from wire format
+// Returns the name and how many bytes were consumed
+// Also handles compression pointers (0xC0 prefix)
+func UnpackDomain(buf []byte, offset int) (string, int, error) {
+	var labels []string
+	visited := make(map[int]bool) // detect pointer loops
+	origOffset := offset
+	jumped := false
+	jumpOffset := 0
+
+	for {
+		if offset >= len(buf) {
+			return "", 0, fmt.Errorf("name parse out of bounds at offset %d", offset)
+		}
+
+		length := int(buf[offset])
+
+		// Check for compression pointer: top 2 bits are 11 (0xC0)
+		if length&0xC0 == 0xC0 {
+			if offset+1 >= len(buf) {
+				return "", 0, fmt.Errorf("compression pointer out of bounds")
+			}
+			// Pointer is 14-bit offset into the message
+			ptr := int(binary.BigEndian.Uint16(buf[offset:offset+2]) &^ 0xC000)
+
+			if visited[ptr] {
+				return "", 0, fmt.Errorf("compression pointer loop detected")
+			}
+			visited[ptr] = true
+
+			if !jumped {
+				jumpOffset = offset + 2 // after the pointer, this is where we resume
+			}
+			jumped = true
+			offset = ptr
+			continue
+		}
+
+		// Normal label
+		if length == 0 {
+			// End of name
+			offset++ // consume the null byte
+			break
+		}
+
+		offset++ // move past the length byte
+		if offset+length > len(buf) {
+			return "", 0, fmt.Errorf("label out of bounds")
+		}
+		labels = append(labels, string(buf[offset:offset+length]))
+		offset += length
+	}
+
+	name := strings.Join(labels, ".") + "."
+
+	// If we jumped, return the offset after the pointer (2 bytes)
+	// If we didn't jump, return where we ended up
+	consumed := offset - origOffset
+	if jumped {
+		consumed = jumpOffset - origOffset
+	}
+
+	return name, consumed, nil
+}
+
dns/header.go
@@ -0,0 +1,127 @@
+package dns
+
+import (
+	"encoding/binary"
+	"fmt"
+)
+
+/**
+Every DNS packet has this structure:
+
++---------------------+
+|       Header        |  12 bytes, always
++---------------------+
+|      Question       |  variable
++---------------------+
+|      Answer         |  variable (in responses)
++---------------------+
+|     Authority       |  variable (we'll get to this)
++---------------------+
+|     Additional      |  variable
++---------------------+
+
+1byte - 8bits
+Here its 12 bytes - 96 bits
+
+Which inturns split into 16bit per section so totally its 6 section as follows:
+
+0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+|                      ID                       |
++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+|QR|   Opcode  |AA|TC|RD|RA|   Z    |   RCODE   |
++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+|                    QDCOUNT                    |
++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+|                    ANCOUNT                    |
++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+|                    NSCOUNT                    |
++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+|                    ARCOUNT                    |
++--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
+
+ID - 16 Random ID, clients sets and server echoes
+QR - QUERY/RESPONSE 0/1
+Opcode - most case its 0 means 'QUERY'
+AA - AUTHORATIVE ANSWER - determine whether its from the owning zone source or from recursive resolver
+TC - Truncated - response is way big so try with TCP (0/1)
+RD - Recurion Desired - client asking server to recurse and give full resolution (0/1)
+RA - Recursion Available - whether server can recurse or not (0/1)
+RCODE - Response Code - 0 → OK, 3 → NXDOMAIN (domain doesn't exist), 2 → Server failure, 5 → Refused
+QDCOUNT,ANCOUNT,NSCOUNT,ARCOUNT - Number of Questions,Answers,Authority Records and Additional Records
+
+**/
+
+type Header struct {
+	ID      uint16
+	Flags   uint16
+	QDCount uint16
+	ANCount uint16
+	NSCount uint16
+	ARCount uint16
+}
+
+const (
+	FlagQR     = 1 << 15
+	FlagAA     = 1 << 10
+	FlagTC     = 1 << 9
+	FlagRD     = 1 << 8
+	FlagRA     = 1 << 7
+	RCodeMask  = 0x000F // Bottom 4 bits
+	OpcodeMask = 0x7800 // Bits 11-14
+)
+
+func (h *Header) IsItResponse() bool {
+	return h.Flags&FlagQR != 0
+}
+
+func (h *Header) SetResponse() {
+	h.Flags |= FlagQR
+}
+
+func (h *Header) SetAA() {
+	h.Flags |= FlagAA
+}
+
+func (h *Header) SetRD() {
+	h.Flags |= FlagRD
+}
+
+func (h *Header) SetRA() {
+	h.Flags |= FlagRA
+}
+
+func (h *Header) SetRCode(code uint8) {
+	h.Flags = (h.Flags &^ RCodeMask) | uint16(code)
+}
+
+func (h *Header) GetRCode() uint8 {
+	return uint8(h.Flags & RCodeMask)
+}
+
+// Pack serializes the header to 12 bytes (big-endian per RFC)
+func (h *Header) Pack() []byte {
+	buf := make([]byte, 12)
+	binary.BigEndian.PutUint16(buf[0:2], h.ID)
+	binary.BigEndian.PutUint16(buf[2:4], h.Flags)
+	binary.BigEndian.PutUint16(buf[4:6], h.QDCount)
+	binary.BigEndian.PutUint16(buf[6:8], h.ANCount)
+	binary.BigEndian.PutUint16(buf[8:10], h.NSCount)
+	binary.BigEndian.PutUint16(buf[10:12], h.ARCount)
+	return buf
+}
+
+// UnpackHeader parses a 12-byte DNS header
+func UnpackHeader(buf []byte) (*Header, error) {
+	if len(buf) < 12 {
+		return nil, fmt.Errorf("buffer too short for header: %d bytes", len(buf))
+	}
+	return &Header{
+		ID:      binary.BigEndian.Uint16(buf[0:2]),
+		Flags:   binary.BigEndian.Uint16(buf[2:4]),
+		QDCount: binary.BigEndian.Uint16(buf[4:6]),
+		ANCount: binary.BigEndian.Uint16(buf[6:8]),
+		NSCount: binary.BigEndian.Uint16(buf[8:10]),
+		ARCount: binary.BigEndian.Uint16(buf[10:12]),
+	}, nil
+}
dns/message.go
@@ -0,0 +1,58 @@
+package dns
+
+import "fmt"
+
+type Message struct {
+	Header    Header
+	Questions []Question
+	Answers   []RR
+}
+
+// Pack serializes the entire DNS message to wire format
+func (m *Message) Pack() ([]byte, error) {
+	var buf []byte
+
+	m.Header.QDCount = uint16(len(m.Questions))
+	m.Header.ANCount = uint16(len(m.Answers))
+	m.Header.NSCount = 0
+	m.Header.ARCount = 0
+
+	buf = append(buf, m.Header.Pack()...)
+
+	for _, q := range m.Questions {
+		buf = append(buf, q.Pack()...)
+	}
+
+	for _, rr := range m.Answers {
+		buf = append(buf, rr.Pack()...)
+	}
+
+	return buf, nil
+}
+
+// Unpack parses a raw DNS message from wire format
+func UnpackMessage(buf []byte) (*Message, error) {
+	if len(buf) < 12 {
+		return nil, fmt.Errorf("message too short: %d bytes", len(buf))
+	}
+
+	header, err := UnpackHeader(buf)
+	if err != nil {
+		return nil, err
+	}
+
+	msg := &Message{Header: *header}
+	offset := 12 // start after header
+
+	// Parse questions
+	for i := 0; i < int(header.QDCount); i++ {
+		q, consumed, err := UnpackQuestion(buf, offset)
+		if err != nil {
+			return nil, fmt.Errorf("question %d: %w", i, err)
+		}
+		msg.Questions = append(msg.Questions, *q)
+		offset += consumed
+	}
+
+	return msg, nil
+}
dns/question.go
@@ -0,0 +1,57 @@
+package dns
+
+import (
+	"encoding/binary"
+	"fmt"
+)
+
+// Record types per RFC 1035 + RFC 3596
+const (
+	TypeA     = 1
+	TypeNS    = 2
+	TypeCNAME = 5
+	TypeMX    = 15
+	TypeTXT   = 16
+	TypeAAAA  = 28 // RFC 3596
+
+	ClassIN = 1 // Internet class
+)
+
+type Question struct {
+	Name  string
+	Type  uint16
+	Class uint16
+}
+
+// Pack serializes a question to wire format
+func (q *Question) Pack() []byte {
+	var buf []byte
+	buf = append(buf, PackDomain(q.Name)...)
+	buf = append(buf, 0, 0) // Type
+	buf = append(buf, 0, 0) // Class
+	binary.BigEndian.PutUint16(buf[len(buf)-4:], q.Type)
+	binary.BigEndian.PutUint16(buf[len(buf)-2:], q.Class)
+	return buf
+}
+
+// UnpackQuestion parses a question from wire format at offset
+func UnpackQuestion(buf []byte, offset int) (*Question, int, error) {
+	name, consumed, err := UnpackDomain(buf, offset)
+	if err != nil {
+		return nil, 0, fmt.Errorf("question name: %w", err)
+	}
+	offset += consumed
+
+	if offset+4 > len(buf) {
+		return nil, 0, fmt.Errorf("question too short for type/class")
+	}
+
+	qtype := binary.BigEndian.Uint16(buf[offset : offset+2])
+	qclass := binary.BigEndian.Uint16(buf[offset+2 : offset+4])
+
+	return &Question{
+		Name:  name,
+		Type:  qtype,
+		Class: qclass,
+	}, consumed + 4, nil
+}
dns/server.go
@@ -0,0 +1,82 @@
+package dns
+
+import (
+	"fmt"
+	"log"
+	"net"
+)
+
+type Handler func(req *Message) *Message
+
+type Server struct {
+	addr    string
+	conn    *net.UDPConn
+	handler Handler
+}
+
+func NewServer(addr string, handler Handler) *Server {
+	return &Server{
+		addr:    addr,
+		handler: handler,
+	}
+}
+
+func (s *Server) Start() error {
+	udpAddr, err := net.ResolveUDPAddr("udp", s.addr)
+	if err != nil {
+		return fmt.Errorf("resolve addr: %w", err)
+	}
+
+	// Bind the UDP socket
+	conn, err := net.ListenUDP("udp", udpAddr)
+	if err != nil {
+		return fmt.Errorf("listen udp: %w", err)
+	}
+	s.conn = conn
+
+	log.Printf("DNS server listening on %s", s.addr)
+
+	// Read loop — runs forever
+	buf := make([]byte, 512) // RFC 1035: max UDP DNS message is 512 bytes
+	for {
+		n, clientAddr, err := conn.ReadFromUDP(buf)
+		if err != nil {
+			log.Printf("read error: %v", err)
+			continue
+		}
+
+		// Handle each request in a goroutine
+		// so slow requests don't block other clients
+		go s.handlePacket(buf[:n], clientAddr)
+	}
+}
+
+func (s *Server) handlePacket(buf []byte, clientAddr *net.UDPAddr) {
+	// Parse the incoming message
+	req, err := UnpackMessage(buf)
+	if err != nil {
+		log.Printf("failed to parse DNS message from %s: %v", clientAddr, err)
+		return
+	}
+
+	log.Printf("query from %s: %s type=%d", clientAddr, req.Questions[0].Name, req.Questions[0].Type)
+
+	// Call our handler to build a response
+	resp := s.handler(req)
+	if resp == nil {
+		return
+	}
+
+	// Serialize response to wire format
+	respBytes, err := resp.Pack()
+	if err != nil {
+		log.Printf("failed to pack response: %v", err)
+		return
+	}
+
+	// Send it back to the client
+	_, err = s.conn.WriteToUDP(respBytes, clientAddr)
+	if err != nil {
+		log.Printf("failed to send response to %s: %v", clientAddr, err)
+	}
+}
dns/txt.go
@@ -0,0 +1,66 @@
+package dns
+
+import "fmt"
+
+const (
+	maxTXTString  = 255 // RFC 1035: each string max 255 bytes
+	maxUDPPayload = 512 // RFC 1035: max UDP DNS message
+)
+
+// PackTXT encodes a slice of strings into TXT RData wire format
+// Each string becomes a length-prefixed chunk
+// "hello" "world" → 0x05 h e l l o 0x05 w o r l d
+func PackTXT(strings []string) ([]byte, error) {
+	var rdata []byte
+
+	for _, s := range strings {
+		if len(s) > maxTXTString {
+			return nil, fmt.Errorf("TXT string exceeds 255 bytes: %d", len(s))
+		}
+		rdata = append(rdata, byte(len(s)))
+		rdata = append(rdata, []byte(s)...) 
+	}
+
+	return rdata, nil
+}
+
+// ChunkText splits a long string into 255-byte chunks
+// ready to be passed into PackTXT
+func ChunkText(text string) []string {
+	var chunks []string
+
+	for len(text) > 0 {
+		if len(text) <= maxTXTString {
+			chunks = append(chunks, text)
+			break
+		}
+		// Try to split on a word boundary near the 255 limit
+		cutoff := maxTXTString
+		for cutoff > 200 && text[cutoff] != ' ' {
+			cutoff--
+		}
+		chunks = append(chunks, text[:cutoff])
+		text = text[cutoff+1:] 
+	}
+
+	return chunks
+}
+
+// NewTXTRecord builds a complete RR with TXT type from a long string
+// It handles chunking automatically
+func NewTXTRecord(name string, ttl uint32, text string) (*RR, error) {
+	chunks := ChunkText(text)
+
+	rdata, err := PackTXT(chunks)
+	if err != nil {
+		return nil, fmt.Errorf("pack txt: %w", err)
+	}
+
+	return &RR{
+		Name:  name,
+		Type:  TypeTXT,
+		Class: ClassIN,
+		TTL:   ttl,
+		RData: rdata,
+	}, nil
+}