DEVELOPMENT ENVIRONMENT

~liljamo/felu

b5195a96196fb17b7b40459ef750d9f916b6c313 — Jonni Liljamo 16 days ago 08a7a55
feat: handle update queries for _acme-challenge
2 files changed, 145 insertions(+), 0 deletions(-)

M internal/dns/handle.go
A internal/dns/update.go
M internal/dns/handle.go => internal/dns/handle.go +10 -0
@@ 20,10 20,12 @@ func handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) {
	m.Compress = false
	m.SetEdns0(4096, true)

	requestWasValidTsig := false
	if r.IsTsig() != nil {
		slog.Debug("Request is TSIG")
		if w.TsigStatus() == nil {
			slog.Debug("TSIG is valid")
			requestWasValidTsig = true
			// NOTE: The first argument here is the keyname.
			m.SetTsig(r.Extra[len(r.Extra)-1].(*dns.TSIG).Hdr.Name, dns.HmacSHA256, 300, time.Now().Unix())
		} else {


@@ 36,6 38,14 @@ func handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) {
	switch r.Opcode {
	case dns.OpcodeQuery:
		parseQuery(m, r)
	case dns.OpcodeUpdate:
		if requestWasValidTsig {
			parseUpdate(m, r)
		} else {
			// Don't process updates if request wasn't tsig.
			// NOTE: I figured FORMERR was the best for this. Do you have any objections?
			m.SetRcode(r, dns.RcodeFormatError)
		}
	default:
		slog.Info("Unsupported Opcode", slog.String("type", dns.OpcodeToString[r.Opcode]))
		m.SetRcode(r, dns.RcodeNotImplemented)

A internal/dns/update.go => internal/dns/update.go +135 -0
@@ 0,0 1,135 @@
/*
 * Copyright (C) 2024 Jonni Liljamo <jonni@liljamo.com>
 *
 * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
 * more information.
 */

package dns

import (
	"log/slog"
	"slices"
	"strings"

	"git.src.quest/~liljamo/felu/internal/db"
	"github.com/miekg/dns"
)

func parseUpdate(m *dns.Msg, r *dns.Msg) {
	// NOTE: We only handle updates for _acme-challenge TXT records.

	// In Updates, the Zone section only contains one entry (RFC2136 Section 2.3).
	// Question is Zone in Updates.
	if len(r.Question) > 1 {
		m.SetRcode(r, dns.RcodeFormatError)
		return
	}
	zone := r.Question[0]

	// In Updates, the Authority section contains the Update RRs.
	// Ns is Authority in Updates.

	// RFC 2136 Section 3.4.1 - Prescan
	for _, rr := range r.Ns {
		slog.Debug("update prescan", slog.String("rr", rr.String()))
		hdr := rr.Header()

		if !zoneOf(hdr.Name, zone.Name) {
			m.SetRcode(r, dns.RcodeNotZone)
			return
		}
		if hdr.Class == zone.Qclass {
			invalidTypes := []uint16{dns.TypeANY, dns.TypeAXFR, dns.TypeMAILA, dns.TypeMAILB}
			if slices.Contains(invalidTypes, hdr.Rrtype) {
				m.SetRcode(r, dns.RcodeFormatError)
				return
			}
		} else if hdr.Class == dns.ClassANY {
			invalidTypes := []uint16{dns.TypeAXFR, dns.TypeMAILA, dns.TypeMAILB}
			if hdr.Ttl != 0 ||
				hdr.Rdlength != 0 ||
				slices.Contains(invalidTypes, hdr.Rrtype) {
				m.SetRcode(r, dns.RcodeFormatError)
				return
			}
		} else if hdr.Class == dns.ClassNONE {
			invalidTypes := []uint16{dns.TypeANY, dns.TypeAXFR, dns.TypeMAILA, dns.TypeMAILB}
			if hdr.Ttl != 0 || slices.Contains(invalidTypes, hdr.Rrtype) {
				m.SetRcode(r, dns.RcodeFormatError)
				return
			}
		} else {
			m.SetRcode(r, dns.RcodeFormatError)
			return
		}
	}

	// RFC 2136 Section 3.4.2 - Update
	// NOTE: Or well, that's what it would be, but we just error out on anything
	//       that tries to update something else than TXT _acme-challenge, because
	//       that's the only thing we support right now.
	for _, rr := range r.Ns {
		slog.Debug("update", slog.String("rr", rr.String()))
		hdr := rr.Header()

		// TODO: Is ServFail really the best for these?
		// As per above, if it doesn't start with _acme-challenge, don't handle it.
		if !strings.HasPrefix(hdr.Name, "_acme-challenge.") {
			m.SetRcode(r, dns.RcodeServerFailure)
			return
		}
		// As per above, if it's something else than TXT, don't handle it.
		if hdr.Rrtype != dns.TypeTXT {
			m.SetRcode(r, dns.RcodeServerFailure)
			return
		}

		// Remove
		if hdr.Class == dns.ClassNONE {
			var ddnsDomain string
			if index := strings.IndexByte(zone.Name, '.'); index >= 0 {
				ddnsDomain = zone.Name[:index]
			} else {
				m.SetRcode(r, dns.RcodeNameError)
				return
			}

			slog.Debug("removing ACME challenge string", slog.String("ddnsDomain", ddnsDomain))

			err := db.DeleteDomainAcmeChallenge(ddnsDomain)
			if err != nil {
				slog.Error("Failed to delete domain ACME challenge", slog.String("err", err.Error()))
				m.SetRcode(r, dns.RcodeServerFailure)
				return
			}
		}

		// Insert
		if hdr.Class == zone.Qclass {
			// At this point, everything should be fine(TM), and we do the update.

			// Split the string representation of the RR into parts.
			parts := strings.Split(rr.String(), "\t")
			// Index 4 is the value, trim out any quotes around it.
			acmeChallengeString := strings.Trim(parts[4], "\"")

			var ddnsDomain string
			if index := strings.IndexByte(zone.Name, '.'); index >= 0 {
				ddnsDomain = zone.Name[:index]
			} else {
				m.SetRcode(r, dns.RcodeNameError)
				return
			}

			slog.Debug("setting ACME challenge string", slog.String("ddnsDomain", ddnsDomain))

			err := db.UpdateDomainAcmeChallenge(ddnsDomain, acmeChallengeString)
			if err != nil {
				slog.Error("Failed to update domain ACME challenge", slog.String("err", err.Error()))
				m.SetRcode(r, dns.RcodeServerFailure)
				return
			}
		}
	}
}