// Package xip provides functions to create a DNS server which, when queried // with a hostname with an embedded IP address, returns that IP Address. It // was inspired by xip.io, which was created by Sam Stephenson package xip import ( "bufio" "errors" "fmt" "io" "log" "net" "net/http" "net/netip" "os" "regexp" "strconv" "strings" "time" "golang.org/x/net/dns/dnsmessage" ) //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate // Xip is meant to be a singleton that holds global state for the DNS server type Xip struct { DnsAmplificationAttackDelay chan struct{} // for throttling metrics.status.sslip.io Metrics Metrics // DNS server metrics BlocklistStrings []string // list of blacklisted strings that shouldn't appear in public hostnames BlocklistCIDRs []net.IPNet // list of blacklisted CIDRs; no A/AAAA records should resolve to IPs in these CIDRs BlocklistUpdated time.Time // The most recent time the Blocklist was updated NameServers []dnsmessage.NSResource // The list of authoritative name servers (NS) Public bool // Whether to resolve public IPs; set to false if security-conscious } // Metrics contains the counters of the important/interesting queries type Metrics struct { Start time.Time Queries int TCPQueries int UDPQueries int AnsweredQueries int AnsweredAQueries int AnsweredAAAAQueries int AnsweredTXTSrcIPQueries int AnsweredTXTVersionQueries int AnsweredNSDNS01ChallengeQueries int AnsweredBlockedQueries int AnsweredPTRQueriesIPv4 int AnsweredPTRQueriesIPv6 int } // DomainCustomization is a value that is returned for a specific query. // The map key is the domain in question, e.g. "sslip.io." (always include trailing dot). // For example, when querying for MX records for "sslip.io", return the protonmail servers, // but when querying for MX records for generic queries, e.g. "127.0.0.1.sslip.io", return the // default (which happens to be no MX records). // // Noticeably absent are the NS records and SOA records. They don't need to be customized // because they are always the same, regardless of the domain being queried. type DomainCustomization struct { A []dnsmessage.AResource AAAA []dnsmessage.AAAAResource CNAME dnsmessage.CNAMEResource MX []dnsmessage.MXResource TXT func(*Xip, net.IP) ([]dnsmessage.TXTResource, error) // Unlike the other record types, TXT is a function in order to enable more complex behavior // e.g. IP address of the query's source } // DomainCustomizations is a lookup table for specially-crafted records // e.g. MX records for sslip.io. // The string key should always be lower-cased // DomainCustomizations{"sslip.io": ...} NOT DomainCustomizations{"sSLip.iO": ...} // DNS hostnames are technically case-insensitive type DomainCustomizations map[string]DomainCustomization // There's nothing like global variables to make my heart pound with joy. // Some of these are global because they are, in essence, constants which // I don't want to waste time recreating with every function call. var ( ipv4REDots = regexp.MustCompile(`(^|[.-])(((25[0-5]|(2[0-4]|1?\d)?\d)\.){3}(25[0-5]|(2[0-4]|1?\d)?\d))($|[.-])`) ipv4REDashes = regexp.MustCompile(`(^|[.-])(((25[0-5]|(2[0-4]|1?\d)?\d)-){3}(25[0-5]|(2[0-4]|1?\d)?\d))($|[.-])`) // https://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses ipv6RE = regexp.MustCompile(`(^|[.-])(([[:xdigit:]]{1,4}-){7}[[:xdigit:]]{1,4}|([[:xdigit:]]{1,4}-){1,7}-|([[:xdigit:]]{1,4}-){1,6}-[[:xdigit:]]{1,4}|([[:xdigit:]]{1,4}-){1,5}(-[[:xdigit:]]{1,4}){1,2}|([[:xdigit:]]{1,4}-){1,4}(-[[:xdigit:]]{1,4}){1,3}|([[:xdigit:]]{1,4}-){1,3}(-[[:xdigit:]]{1,4}){1,4}|([[:xdigit:]]{1,4}-){1,2}(-[[:xdigit:]]{1,4}){1,5}|[[:xdigit:]]{1,4}-((-[[:xdigit:]]{1,4}){1,6})|-((-[[:xdigit:]]{1,4}){1,7}|-)|fe80-(-[[:xdigit:]]{0,4}){0,4}%[\da-zA-Z]+|--(ffff(-0{1,4})?-)?((25[0-5]|(2[0-4]|1?\d)?\d)\.){3}(25[0-5]|(2[0-4]|1?\d)?\d)|([[:xdigit:]]{1,4}-){1,4}-((25[0-5]|(2[0-4]|1?\d)?\d)\.){3}(25[0-5]|(2[0-4]|1?\d)?\d))($|[.-])`) ipv4ReverseRE = regexp.MustCompile(`^(.*)\.in-addr\.arpa\.$`) ipv6ReverseRE = regexp.MustCompile(`^(([[:xdigit:]]\.){32})ip6\.arpa\.`) dns01ChallengeRE = regexp.MustCompile(`(?i)_acme-challenge\.`) // (?i) → non-capturing case insensitive mbox, _ = dnsmessage.NewName("briancunnie.gmail.com.") mx1, _ = dnsmessage.NewName("mail.protonmail.ch.") mx2, _ = dnsmessage.NewName("mailsec.protonmail.ch.") dkim1, _ = dnsmessage.NewName("protonmail.domainkey.dw4gykv5i2brtkjglrf34wf6kbxpa5hgtmg2xqopinhgxn5axo73a.domains.proton.ch.") dkim2, _ = dnsmessage.NewName("protonmail2.domainkey.dw4gykv5i2brtkjglrf34wf6kbxpa5hgtmg2xqopinhgxn5axo73a.domains.proton.ch.") dkim3, _ = dnsmessage.NewName("protonmail3.domainkey.dw4gykv5i2brtkjglrf34wf6kbxpa5hgtmg2xqopinhgxn5axo73a.domains.proton.ch.") VersionSemantic = "0.0.0" VersionDate = "0001/01/01-99:99:99-0800" VersionGitHash = "cafexxx" MetricsBufferSize = 200 // big enough to run our tests, and small enough to prevent DNS amplification attacks Customizations = DomainCustomizations{ "sslip.io.": { MX: []dnsmessage.MXResource{ { Pref: 10, MX: mx1, }, { Pref: 20, MX: mx2, }, }, TXT: TXTSslipIoSPF, }, // nameserver addresses; we get queries for those every once in a while // CNAMEs for sslip.io for DKIM signing "protonmail._domainkey.sslip.io.": { CNAME: dnsmessage.CNAMEResource{ CNAME: dkim1, }, }, "protonmail2._domainkey.sslip.io.": { CNAME: dnsmessage.CNAMEResource{ CNAME: dkim2, }, }, "protonmail3._domainkey.sslip.io.": { CNAME: dnsmessage.CNAMEResource{ CNAME: dkim3, }, }, // Special-purpose TXT records "ip.sslip.io.": { TXT: TXTIp, }, "version.status.sslip.io.": { TXT: func(x *Xip, _ net.IP) ([]dnsmessage.TXTResource, error) { x.Metrics.AnsweredTXTVersionQueries++ return []dnsmessage.TXTResource{ {TXT: []string{VersionSemantic}}, // e.g. "2.2.1' {TXT: []string{VersionDate}}, // e.g. "2021/10/03-15:08:54+0100" {TXT: []string{VersionGitHash}}, // e.g. "9339c0d" }, nil }, }, "metrics.status.sslip.io.": { TXT: TXTMetrics, }, } ) // Response Why do I have a crazy struct of fields of arrays of functions? // It's because I can't use dnsmessage.Builder as I had hoped; specifically // I need to set the Header _after_ I process the message, but Builder expects // it to be set first, so I use the functions as a sort of batch process to // create the Builder. What in Header needs to be tweaked? Certain TXT records // need to unset the authoritative field, and queries for ANY record need // to set the rcode. type Response struct { Header dnsmessage.Header Answers []func(*dnsmessage.Builder) error Authorities []func(*dnsmessage.Builder) error Additionals []func(*dnsmessage.Builder) error } // NewXip follows convention for constructors: https://go.dev/doc/effective_go#allocation_new func NewXip(blocklistURL string, nameservers []string, addresses []string) (x *Xip, logmessages []string) { x = &Xip{Metrics: Metrics{Start: time.Now()}} // Download the blocklist logmessages = append(logmessages, x.downloadBlockList(blocklistURL)) // re-download the blocklist every hour so I don't need to restart servers after updating blocklist go func() { for { time.Sleep(1 * time.Hour) _ = x.downloadBlockList(blocklistURL) // uh-oh, I lose the log message. } }() // Parse and set our nameservers for _, ns := range nameservers { if len(ns) == 0 { logmessages = append(logmessages, fmt.Sprintf(`-nameservers: ignoring zero-length nameserver ""`)) continue } // all nameservers must be absolute (end in ".") if ns[len(ns)-1] != '.' { ns += "." } // nameservers must be DNS-compliant nsName, err := dnsmessage.NewName(ns) if err != nil { logmessages = append(logmessages, fmt.Sprintf(`-nameservers: ignoring invalid nameserver "%s"`, ns)) continue } x.NameServers = append(x.NameServers, dnsmessage.NSResource{ NS: nsName}) logmessages = append(logmessages, fmt.Sprintf(`Adding nameserver "%s"`, ns)) } // Parse and set our addresses for _, address := range addresses { hostAddr := strings.Split(address, "=") if len(hostAddr) != 2 { logmessages = append(logmessages, fmt.Sprintf(`-addresses: arguments should be in the format "host=ip", not "%s"`, address)) continue } host := hostAddr[0] ip := net.ParseIP(hostAddr[1]) // all hosts must be absolute (end in ".") if host[len(host)-1] != '.' { host += "." } if ip == nil { // bad IP address logmessages = append(logmessages, fmt.Sprintf(`-addresses: "%s" is not assigned a valid IP "%s"`, hostAddr, ip.String())) continue } if ip.To4() != nil { // we have an IPv4 var ABytes [4]byte // copy the _last_ four bytes of the 16-byte IP, not the first four bytes. Cost me 2 hours. copy(ABytes[0:4], ip[12:]) // Thanks https://stackoverflow.com/questions/42605337/cannot-assign-to-struct-field-in-a-map var hostEntry = DomainCustomization{} if _, ok := Customizations[host]; ok { hostEntry = Customizations[host] } hostEntry.A = append(hostEntry.A, dnsmessage.AResource{A: ABytes}) Customizations[host] = hostEntry } else { // We're pretty sure it's IPv6 at this point, but we check anyway if ip.To16() == nil { // it's not IPv6, and I don't know what it is logmessages = append(logmessages, fmt.Sprintf(`-addresses: "%s" is not IPv4 or IPv6 "%s"`, hostAddr, ip.String())) continue } var AAAABytes [16]byte copy(AAAABytes[0:16], ip) // Thanks https://stackoverflow.com/questions/42605337/cannot-assign-to-struct-field-in-a-map var hostEntry = DomainCustomization{} if _, ok := Customizations[host]; ok { hostEntry = Customizations[host] } hostEntry.AAAA = append(hostEntry.AAAA, dnsmessage.AAAAResource{AAAA: AAAABytes}) Customizations[host] = hostEntry } // print out the added records in a manner similar to the way they're set on the cmdline logmessages = append(logmessages, fmt.Sprintf(`Adding record "%s=%s"`, host, ip)) } // We want to make sure that our DNS server isn't used in a DNS amplification attack. // The endpoint we're worried about is metrics.status.sslip.io, whose reply is // ~400 bytes with a query of ~100 bytes (4x amplification). We accomplish this by // using channels with a quarter-second delay. Max throughput 1.2 kBytes/sec. // // We want to balance this delay against our desire to run tests quickly, so we buffer // the channel with enough room to accommodate our tests. // // We also want to have fun playing with channels dnsAmplificationAttackDelay := make(chan struct{}, MetricsBufferSize) x.DnsAmplificationAttackDelay = dnsAmplificationAttackDelay go func() { // fill up the channel's buffer so that our tests aren't slowed down (~85 tests) for i := 0; i < MetricsBufferSize; i++ { dnsAmplificationAttackDelay <- struct{}{} } // now put on the brakes for users trying to leverage our server in a DNS amplification attack for { dnsAmplificationAttackDelay <- struct{}{} time.Sleep(250 * time.Millisecond) } }() return x, logmessages } // QueryResponse takes in a raw (packed) DNS query and returns a raw (packed) // DNS response, a string (for logging) that describes the query and the // response, and an error. It takes in the raw data to offload as much as // possible from main(). main() is hard to unit test, but functions like // QueryResponse are not as hard. // // Examples of log strings returned: // // 78.46.204.247.33654: TypeA 127-0-0-1.sslip.io ? 127.0.0.1 // 78.46.204.247.33654: TypeA non-existent.sslip.io ? nil, SOA // 78.46.204.247.33654: TypeNS www.example.com ? NS // 78.46.204.247.33654: TypeSOA www.example.com ? SOA // 2600::.33654: TypeAAAA --1.sslip.io ? ::1 func (x *Xip) QueryResponse(queryBytes []byte, srcAddr net.IP) (responseBytes []byte, logMessage string, err error) { var queryHeader dnsmessage.Header var p dnsmessage.Parser var response Response if queryHeader, err = p.Start(queryBytes); err != nil { return nil, "", err } var q dnsmessage.Question // we only answer the first question even though there technically may be more than one; // de facto there's one and only one question if q, err = p.Question(); err != nil { return nil, "", err } response, logMessage, err = x.processQuestion(q, srcAddr) if err != nil { return nil, "", err } response.Header.ID = queryHeader.ID response.Header.RecursionDesired = queryHeader.RecursionDesired x.Metrics.Queries++ b := dnsmessage.NewBuilder(nil, response.Header) b.EnableCompression() if err = b.StartQuestions(); err != nil { return nil, "", err } if err = b.Question(q); err != nil { return } if err = b.StartAnswers(); err != nil { return nil, "", err } for _, answer := range response.Answers { if err = answer(&b); err != nil { return nil, "", err } } if err = b.StartAuthorities(); err != nil { return nil, "", err } for _, authority := range response.Authorities { if err = authority(&b); err != nil { return nil, "", err } } if err = b.StartAdditionals(); err != nil { return nil, "", err } for _, additionals := range response.Additionals { if err = additionals(&b); err != nil { return nil, "", err } } if responseBytes, err = b.Finish(); err != nil { return nil, "", err } return responseBytes, logMessage, nil } func (x *Xip) processQuestion(q dnsmessage.Question, srcAddr net.IP) (response Response, logMessage string, err error) { logMessage = q.Type.String() + " " + q.Name.String() + " ? " response = Response{ Header: dnsmessage.Header{ ID: 0, // this will later be replaced with query.ID Response: true, OpCode: 0, Authoritative: true, // We're able to white label domains by always being authoritative Truncated: false, RecursionDesired: false, // this will later be replaced with query.RecursionDesired RecursionAvailable: false, // We are not recursing servers, so recursion is never available. Prevents DDOS RCode: dnsmessage.RCodeSuccess, // assume success, may be replaced later }, } if IsAcmeChallenge(q.Name.String()) && !x.blocklist(q.Name.String()) { // thanks, @NormanR // delegate everything to its stripped (remove "_acme-challenge.") address, e.g. // dig _acme-challenge.127-0-0-1.sslip.io mx → NS 127-0-0-1.sslip.io response.Header.Authoritative = false // we're delegating, so we're not authoritative return x.NSResponse(q.Name, response, logMessage) } switch q.Type { case dnsmessage.TypeA: { return x.nameToAwithBlocklist(q, response, logMessage) } case dnsmessage.TypeAAAA: { return x.nameToAAAAwithBlocklist(q, response, logMessage) } case dnsmessage.TypeALL: { // We don't implement type ANY, so return "NotImplemented" like CloudFlare (1.1.1.1) // https://blog.cloudflare.com/rfc8482-saying-goodbye-to-any/ // Google (8.8.8.8) returns every record they can find (A, AAAA, SOA, NS, MX, ...). response.Header.RCode = dnsmessage.RCodeNotImplemented return response, logMessage + "NotImplemented", nil } case dnsmessage.TypeCNAME: { // If there is a CNAME, there can only be 1, and only from Customizations var cname *dnsmessage.CNAMEResource cname = CNAMEResource(q.Name.String()) if cname == nil { // No Answers, only 1 Authorities soaHeader, soaResource := SOAAuthority(q.Name) response.Authorities = append(response.Authorities, func(b *dnsmessage.Builder) error { if err = b.SOAResource(soaHeader, soaResource); err != nil { return err } return nil }) return response, logMessage + "nil, SOA " + soaLogMessage(soaResource), nil } x.Metrics.AnsweredQueries++ response.Answers = append(response.Answers, // 1 CNAME record, via Customizations func(b *dnsmessage.Builder) error { err = b.CNAMEResource(dnsmessage.ResourceHeader{ Name: q.Name, Type: dnsmessage.TypeCNAME, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; long TTL, these IP addrs don't change Length: 0, }, *cname) if err != nil { return err } return nil }) return response, logMessage + cname.CNAME.String(), nil } case dnsmessage.TypeMX: { mailExchangers := MXResources(q.Name.String()) var logMessages []string // We can be sure that len(mailExchangers) > 1, but we check anyway if len(mailExchangers) == 0 { return response, "", errors.New("no MX records, but there should be one") } x.Metrics.AnsweredQueries++ response.Answers = append(response.Answers, // 1 or more A records; A records > 1 only available via Customizations func(b *dnsmessage.Builder) error { for _, mailExchanger := range mailExchangers { err = b.MXResource(dnsmessage.ResourceHeader{ Name: q.Name, Type: dnsmessage.TypeMX, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; long TTL, these IP addrs don't change Length: 0, }, mailExchanger) } if err != nil { return err } return nil }) for _, mailExchanger := range mailExchangers { logMessages = append(logMessages, strconv.Itoa(int(mailExchanger.Pref))+" "+mailExchanger.MX.String()) } return response, logMessage + strings.Join(logMessages, ", "), nil } case dnsmessage.TypeNS: { return x.NSResponse(q.Name, response, logMessage) } case dnsmessage.TypeSOA: { x.Metrics.AnsweredQueries++ soaResource := SOAResource(q.Name) response.Answers = append(response.Answers, func(b *dnsmessage.Builder) error { err = b.SOAResource(dnsmessage.ResourceHeader{ Name: q.Name, Type: dnsmessage.TypeSOA, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; long TTL, these IP addrs don't change Length: 0, }, soaResource) if err != nil { return err } return nil }) return response, logMessage + soaLogMessage(soaResource), nil } case dnsmessage.TypeTXT: { // if it's an "_acme-challenge." TXT, we return no answer but an NS authority & not authoritative // if it's customized records, we return them in the Answers // otherwise we return no Answers and Authorities SOA if IsAcmeChallenge(q.Name.String()) { // No Answers, Not Authoritative, Authorities contain NS records response.Header.Authoritative = false nameServers := x.NSResources(q.Name.String()) var logMessages []string for _, nameServer := range nameServers { response.Authorities = append(response.Authorities, // 1 or more A records; A records > 1 only available via Customizations func(b *dnsmessage.Builder) error { err = b.NSResource(dnsmessage.ResourceHeader{ Name: q.Name, Type: dnsmessage.TypeNS, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; long TTL, these IP addrs don't change Length: 0, }, nameServer) if err != nil { return err } return nil }) logMessages = append(logMessages, nameServer.NS.String()) } return response, logMessage + "nil, NS " + strings.Join(logMessages, ", "), nil } var txts []dnsmessage.TXTResource txts, err = x.TXTResources(q.Name.String(), srcAddr) if err != nil { return response, "", err } if len(txts) > 0 { x.Metrics.AnsweredQueries++ } response.Answers = append(response.Answers, // 1 or more TXT records via Customizations // Technically there can be more than one TXT record, but practically there can only be one record // but with multiple strings func(b *dnsmessage.Builder) error { for _, txt := range txts { err = b.TXTResource(dnsmessage.ResourceHeader{ Name: q.Name, Type: dnsmessage.TypeTXT, Class: dnsmessage.ClassINET, TTL: 180, // 3 minutes to allow key-value to propagate Length: 0, }, txt) if err != nil { return err } } return nil }) var logMessageTXTss []string for _, txt := range txts { var logMessageTXTs []string for _, TXTstring := range txt.TXT { logMessageTXTs = append(logMessageTXTs, TXTstring) } logMessageTXTss = append(logMessageTXTss, `["`+strings.Join(logMessageTXTs, `", "`)+`"]`) } if len(logMessageTXTss) == 0 { return response, logMessage + "nil, SOA " + soaLogMessage(SOAResource(q.Name)), nil } return response, logMessage + strings.Join(logMessageTXTss, ", "), nil } case dnsmessage.TypePTR: { var ptr *dnsmessage.PTRResource ptr = x.PTRResource([]byte(q.Name.String())) if ptr == nil { // No Answers, only 1 Authorities soaHeader, soaResource := SOAAuthority(dnsmessage.MustNewName("sslip.io.")) response.Authorities = append(response.Authorities, func(b *dnsmessage.Builder) error { if err = b.SOAResource(soaHeader, soaResource); err != nil { return err } return nil }) return response, logMessage + "nil, SOA " + soaLogMessage(soaResource), nil } //x.Metrics.AnsweredQueries++ response.Answers = append(response.Answers, // 1 CNAME record, via Customizations func(b *dnsmessage.Builder) error { err = b.PTRResource(dnsmessage.ResourceHeader{ Name: q.Name, Type: dnsmessage.TypePTR, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; long TTL, these IP addrs don't change Length: 0, }, *ptr) if err != nil { return err } return nil }) return response, logMessage + ptr.PTR.String(), nil } default: { // default is the same case as an A/AAAA record which is not found, // i.e. we return no answers, but we return an authority section // No Answers, only 1 Authorities soaHeader, soaResource := SOAAuthority(q.Name) response.Authorities = append(response.Authorities, func(b *dnsmessage.Builder) error { if err = b.SOAResource(soaHeader, soaResource); err != nil { return err } return nil }) return response, logMessage + "nil, SOA " + soaLogMessage(soaResource), nil } } } // NSResponse sets the Answers/Authorities depending upon whether we're delegating or authoritative // (whether it's an "_acme-challenge." domain or not). Either way, it supplies the Additionals // (IP addresses of the nameservers). func (x *Xip) NSResponse(name dnsmessage.Name, response Response, logMessage string) (Response, string, error) { nameServers := x.NSResources(name.String()) var logMessages []string if response.Header.Authoritative { // we're authoritative, so we reply with the answers response.Answers = append(response.Answers, func(b *dnsmessage.Builder) error { return buildNSRecords(b, name, x.NameServers) }) } else { // we're NOT authoritative, so we reply who is authoritative response.Authorities = append(response.Authorities, func(b *dnsmessage.Builder) error { return buildNSRecords(b, name, nameServers) }) logMessage += "nil, NS " // we're not supplying an answer; we're supplying the NS record that's authoritative } response.Additionals = append(response.Additionals, func(b *dnsmessage.Builder) error { for _, nameServer := range nameServers { for _, aResource := range NameToA(nameServer.NS.String(), true) { err := b.AResource(dnsmessage.ResourceHeader{ Name: nameServer.NS, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; long TTL, these IP addrs don't change Length: 0, }, aResource) if err != nil { return err } } for _, aaaaResource := range NameToAAAA(nameServer.NS.String(), true) { err := b.AAAAResource(dnsmessage.ResourceHeader{ Name: nameServer.NS, Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; long TTL, these IP addrs don't change Length: 0, }, aaaaResource) if err != nil { return err } } } return nil }) for _, nameServer := range nameServers { logMessages = append(logMessages, nameServer.NS.String()) } return response, logMessage + strings.Join(logMessages, ", "), nil } func buildNSRecords(b *dnsmessage.Builder, name dnsmessage.Name, nameServers []dnsmessage.NSResource) error { for _, nameServer := range nameServers { err := b.NSResource(dnsmessage.ResourceHeader{ Name: name, Type: dnsmessage.TypeNS, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; long TTL, these IP addrs don't change Length: 0, }, nameServer) if err != nil { return err } } return nil } // NameToA returns an []AResource that matched the hostname; it returns an // array of zero-or-one records func NameToA(fqdnString string, public bool) []dnsmessage.AResource { fqdn := []byte(fqdnString) // is it a customized A record? If so, return early if domain, ok := Customizations[strings.ToLower(fqdnString)]; ok && len(domain.A) > 0 { return domain.A } for _, ipv4RE := range []*regexp.Regexp{ipv4REDashes, ipv4REDots} { if ipv4RE.Match(fqdn) { match := string(ipv4RE.FindSubmatch(fqdn)[2]) match = strings.Replace(match, "-", ".", -1) ipv4address := net.ParseIP(match).To4() // We shouldn't reach here because `match` should always be valid, but we're not optimists if ipv4address == nil { // e.g. "ubuntu20.04.235.249.181-notify.sslip.io." <- the leading zero is the problem log.Printf("----> Should be valid A but isn't: %s\n", fqdn) // TODO: delete this return []dnsmessage.AResource{} } if (!public) && IsPublic(ipv4address) { return []dnsmessage.AResource{} } return []dnsmessage.AResource{ {A: [4]byte{ipv4address[0], ipv4address[1], ipv4address[2], ipv4address[3]}}, } } } return []dnsmessage.AResource{} } // NameToAAAA returns an []AAAAResource that matched the hostname func NameToAAAA(fqdnString string, public bool) []dnsmessage.AAAAResource { fqdn := []byte(fqdnString) // is it a customized AAAA record? If so, return early if domain, ok := Customizations[strings.ToLower(fqdnString)]; ok && len(domain.AAAA) > 0 { return domain.AAAA } if !ipv6RE.Match(fqdn) { return []dnsmessage.AAAAResource{} } ipv6RE.Longest() match := string(ipv6RE.FindSubmatch(fqdn)[2]) match = strings.Replace(match, "-", ":", -1) ipv16address := net.ParseIP(match).To16() if ipv16address == nil { // We shouldn't reach here because `match` should always be valid, but we're not optimists log.Printf("----> Should be valid AAAA but isn't: %s\n", fqdn) // TODO: delete this return []dnsmessage.AAAAResource{} } if (!public) && IsPublic(ipv16address) { return []dnsmessage.AAAAResource{} } AAAAR := dnsmessage.AAAAResource{} for i := range ipv16address { AAAAR.AAAA[i] = ipv16address[i] } return []dnsmessage.AAAAResource{AAAAR} } // CNAMEResource returns the CNAME via Customizations, otherwise nil func CNAMEResource(fqdnString string) *dnsmessage.CNAMEResource { if domain, ok := Customizations[strings.ToLower(fqdnString)]; ok && domain.CNAME != (dnsmessage.CNAMEResource{}) { return &domain.CNAME } return nil } // MXResources returns either 1 or more MX records set via Customizations or // an MX record pointing to the queried record func MXResources(fqdnString string) []dnsmessage.MXResource { if domain, ok := Customizations[strings.ToLower(fqdnString)]; ok && len(domain.MX) > 0 { return domain.MX } mx, _ := dnsmessage.NewName(fqdnString) return []dnsmessage.MXResource{ { Pref: 0, MX: mx, }, } } func IsAcmeChallenge(fqdnString string) bool { if dns01ChallengeRE.MatchString(fqdnString) { ipv4s := NameToA(fqdnString, true) ipv6s := NameToAAAA(fqdnString, true) if len(ipv4s) > 0 || len(ipv6s) > 0 { return true } } return false } func (x *Xip) NSResources(fqdnString string) []dnsmessage.NSResource { if x.blocklist(fqdnString) { x.Metrics.AnsweredQueries++ x.Metrics.AnsweredBlockedQueries++ return x.NameServers } if IsAcmeChallenge(fqdnString) { x.Metrics.AnsweredNSDNS01ChallengeQueries++ strippedFqdn := dns01ChallengeRE.ReplaceAllString(fqdnString, "") ns, _ := dnsmessage.NewName(strippedFqdn) return []dnsmessage.NSResource{{NS: ns}} } x.Metrics.AnsweredQueries++ return x.NameServers } // TXTResources returns TXT records from Customizations func (x *Xip) TXTResources(fqdn string, ip net.IP) ([]dnsmessage.TXTResource, error) { if domain, ok := Customizations[strings.ToLower(fqdn)]; ok { // Customizations[strings.ToLower(fqdn)] returns a _function_, // we call that function, which has the same return signature as this method if domain.TXT != nil { return domain.TXT(x, ip) } } return nil, nil } func SOAAuthority(name dnsmessage.Name) (dnsmessage.ResourceHeader, dnsmessage.SOAResource) { return dnsmessage.ResourceHeader{ Name: name, Type: dnsmessage.TypeSOA, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; it's not gonna change Length: 0, }, SOAResource(name) } // SOAResource returns the hard-coded (except MNAME) SOA func SOAResource(name dnsmessage.Name) dnsmessage.SOAResource { return dnsmessage.SOAResource{ NS: name, MBox: mbox, Serial: 2024050500, // cribbed the Refresh/Retry/Expire from google.com. // MinTTL was 300, but I dropped to 180 for faster // key-value propagation Refresh: 900, Retry: 900, Expire: 1800, MinTTL: 180, } } // PTRResource returns the PTR record, otherwise nil func (x *Xip) PTRResource(fqdn []byte) *dnsmessage.PTRResource { // "reverse", for example, means "1.0.0.127", as in "1.0.0.127.in-addr.arpa" // the regular IP would be "127.0.0.1" if ipv4ReverseRE.Match(fqdn) { reversedIPv4 := ipv4ReverseRE.FindSubmatch(fqdn)[1] reversedIPv4address := net.ParseIP(string(reversedIPv4)).To4() if reversedIPv4address == nil { return nil } ip := netip.AddrFrom4([4]byte{ reversedIPv4address[3], reversedIPv4address[2], reversedIPv4address[1], reversedIPv4address[0], }) ptrName, err := dnsmessage.NewName(strings.ReplaceAll(ip.String(), ".", "-") + ".sslip.io.") if err != nil { return nil } x.Metrics.AnsweredQueries++ x.Metrics.AnsweredPTRQueriesIPv4++ return &dnsmessage.PTRResource{ PTR: ptrName, } } if ipv6ReverseRE.Match(fqdn) { b := ipv6ReverseRE.FindSubmatch(fqdn)[1] reversed := []byte{ b[62], b[60], b[58], b[56], ':', b[54], b[52], b[50], b[48], ':', b[46], b[44], b[42], b[40], ':', b[38], b[36], b[34], b[32], ':', b[30], b[28], b[26], b[24], ':', b[22], b[20], b[18], b[16], ':', b[14], b[12], b[10], b[8], ':', b[6], b[4], b[2], b[0], } ip := net.ParseIP(string(reversed)).To16() if ip == nil { return nil } ptrName, err := dnsmessage.NewName(strings.ReplaceAll(ip.String(), ":", "-") + ".sslip.io.") if err != nil { return nil } x.Metrics.AnsweredQueries++ x.Metrics.AnsweredPTRQueriesIPv6++ return &dnsmessage.PTRResource{ PTR: ptrName, } } return nil } // TXTSslipIoSPF SFP records for sslio.io func TXTSslipIoSPF(_ *Xip, _ net.IP) ([]dnsmessage.TXTResource, error) { // Although multiple TXT records with multiple strings are allowed, we're sticking // with a multiple TXT records with a single string apiece because that's what ProtonMail requires // and that's what google.com does. return []dnsmessage.TXTResource{ {TXT: []string{"protonmail-verification=ce0ca3f5010aa7a2cf8bcc693778338ffde73e26"}}, // ProtonMail verification; don't delete {TXT: []string{"v=spf1 include:_spf.protonmail.ch mx ~all"}}, }, nil // Sender Policy Framework } // TXTIp when TXT for "ip.sslip.io" is queried, return the IP address of the querier func TXTIp(x *Xip, srcAddr net.IP) ([]dnsmessage.TXTResource, error) { x.Metrics.AnsweredTXTSrcIPQueries++ return []dnsmessage.TXTResource{{TXT: []string{srcAddr.String()}}}, nil } // TXTMetrics when TXT for "metrics.sslip.io" is queried, return the cumulative metrics func TXTMetrics(x *Xip, _ net.IP) (txtResources []dnsmessage.TXTResource, err error) { <-x.DnsAmplificationAttackDelay var metrics []string uptime := time.Since(x.Metrics.Start) metrics = append(metrics, fmt.Sprintf("Uptime: %.0f", uptime.Seconds())) metrics = append(metrics, fmt.Sprintf("Blocklist: %s %d,%d", x.BlocklistUpdated.Format("2006-01-02 15:04:05-07"), len(x.BlocklistStrings), len(x.BlocklistCIDRs))) metrics = append(metrics, fmt.Sprintf("Queries: %d (%.1f/s)", x.Metrics.Queries, float64(x.Metrics.Queries)/uptime.Seconds())) metrics = append(metrics, fmt.Sprintf("TCP/UDP: %d/%d", x.Metrics.TCPQueries, x.Metrics.UDPQueries)) metrics = append(metrics, fmt.Sprintf("Answered Queries: %d (%.1f/s)", x.Metrics.AnsweredQueries, float64(x.Metrics.AnsweredQueries)/uptime.Seconds())) metrics = append(metrics, fmt.Sprintf("A: %d", x.Metrics.AnsweredAQueries)) metrics = append(metrics, fmt.Sprintf("AAAA: %d", x.Metrics.AnsweredAAAAQueries)) metrics = append(metrics, fmt.Sprintf("TXT Source: %d", x.Metrics.AnsweredTXTSrcIPQueries)) metrics = append(metrics, fmt.Sprintf("TXT Version: %d", x.Metrics.AnsweredTXTVersionQueries)) metrics = append(metrics, fmt.Sprintf("PTR IPv4/IPv6: %d/%d", x.Metrics.AnsweredPTRQueriesIPv4, x.Metrics.AnsweredPTRQueriesIPv6)) metrics = append(metrics, fmt.Sprintf("NS DNS-01: %d", x.Metrics.AnsweredNSDNS01ChallengeQueries)) metrics = append(metrics, fmt.Sprintf("Blocked: %d", x.Metrics.AnsweredBlockedQueries)) for _, metric := range metrics { txtResources = append(txtResources, dnsmessage.TXTResource{TXT: []string{metric}}) } return txtResources, nil } // soaLogMessage returns an easy-to-read string for logging SOA Answers/Authorities func soaLogMessage(soaResource dnsmessage.SOAResource) string { return soaResource.NS.String() + " " + soaResource.MBox.String() + " " + strconv.Itoa(int(soaResource.Serial)) + " " + strconv.Itoa(int(soaResource.Refresh)) + " " + strconv.Itoa(int(soaResource.Retry)) + " " + strconv.Itoa(int(soaResource.Expire)) + " " + strconv.Itoa(int(soaResource.MinTTL)) } // MostlyEquals compares all fields except `Start` (timestamp) func (a Metrics) MostlyEquals(b Metrics) bool { if a.Queries == b.Queries && a.TCPQueries == b.TCPQueries && a.UDPQueries == b.UDPQueries && a.AnsweredQueries == b.AnsweredQueries && a.AnsweredAQueries == b.AnsweredAQueries && a.AnsweredAAAAQueries == b.AnsweredAAAAQueries && a.AnsweredTXTSrcIPQueries == b.AnsweredTXTSrcIPQueries && a.AnsweredTXTVersionQueries == b.AnsweredTXTVersionQueries && a.AnsweredPTRQueriesIPv4 == b.AnsweredPTRQueriesIPv4 && a.AnsweredPTRQueriesIPv6 == b.AnsweredPTRQueriesIPv6 && a.AnsweredNSDNS01ChallengeQueries == b.AnsweredNSDNS01ChallengeQueries && a.AnsweredBlockedQueries == b.AnsweredBlockedQueries { return true } return false } func (x *Xip) downloadBlockList(blocklistURL string) string { var err error var blocklistReader io.ReadCloser // file protocol's purpose: so I can run tests while flying with no internet // secondary purpose: don't hammer GitHub when running tests fileProtocolRE := regexp.MustCompile(`^file://`) if fileProtocolRE.MatchString(blocklistURL) { blocklistPath := strings.TrimPrefix(blocklistURL, "file://") blocklistReader, err = os.Open(blocklistPath) if err != nil { return fmt.Sprintf(`failed to open blocklist "%s": %s`, blocklistPath, err.Error()) } //noinspection GoUnhandledErrorResult defer blocklistReader.Close() } else { resp, err := http.Get(blocklistURL) if err != nil { return fmt.Sprintf(`failed to download blocklist "%s": %s`, blocklistURL, err.Error()) } blocklistReader = resp.Body //noinspection GoUnhandledErrorResult defer blocklistReader.Close() if resp.StatusCode > 299 { return fmt.Sprintf(`failed to download blocklist "%s", HTTP status: "%d"`, blocklistURL, resp.StatusCode) } } blocklistStrings, blocklistCIDRs, err := ReadBlocklist(blocklistReader) if err != nil { return fmt.Sprintf(`failed to parse blocklist "%s": %s`, blocklistURL, err.Error()) } x.BlocklistStrings = blocklistStrings x.BlocklistCIDRs = blocklistCIDRs x.BlocklistUpdated = time.Now() return fmt.Sprintf("Successfully downloaded blocklist from %s: %v, %v", blocklistURL, x.BlocklistStrings, x.BlocklistCIDRs) } // ReadBlocklist "sanitizes" the block list, removing comments, invalid characters // and lowercasing the names to be blocked. // public to make testing easier func ReadBlocklist(blocklist io.Reader) (stringBlocklists []string, cidrBlocklists []net.IPNet, err error) { scanner := bufio.NewScanner(blocklist) comments := regexp.MustCompile(`#.*`) invalidDNSchars := regexp.MustCompile(`[^-\da-z]`) invalidDNScharsWithSlashesDotsAndColons := regexp.MustCompile(`[^-_\da-z/.:]`) for scanner.Scan() { line := scanner.Text() line = strings.ToLower(line) line = comments.ReplaceAllString(line, "") // strip comments line = invalidDNScharsWithSlashesDotsAndColons.ReplaceAllString(line, "") // strip invalid characters _, ipcidr, err := net.ParseCIDR(line) if err != nil { line = invalidDNSchars.ReplaceAllString(line, "") // strip invalid DNS characters if line == "" { continue } stringBlocklists = append(stringBlocklists, line) } else { cidrBlocklists = append(cidrBlocklists, *ipcidr) } } if err = scanner.Err(); err != nil { return []string{}, []net.IPNet{}, err } return stringBlocklists, cidrBlocklists, nil } func (x *Xip) blocklist(hostname string) bool { aResources := NameToA(hostname, true) aaaaResources := NameToAAAA(hostname, true) var ip net.IP if len(aResources) == 1 { ip = aResources[0].A[:] } if len(aaaaResources) == 1 { ip = aaaaResources[0].AAAA[:] } if len(aResources) == 0 && len(aaaaResources) == 0 { return false } if ip.IsPrivate() { return false } for _, blockstring := range x.BlocklistStrings { if strings.Contains(hostname, blockstring) { return true } } for _, blockCIDR := range x.BlocklistCIDRs { if blockCIDR.Contains(ip) { return true } } return false } func (x *Xip) nameToAwithBlocklist(q dnsmessage.Question, response Response, logMessage string) (_ Response, _ string, err error) { var nameToAs []dnsmessage.AResource nameToAs = NameToA(q.Name.String(), x.Public) if len(nameToAs) == 0 { // No Answers, only 1 Authorities soaHeader, soaResource := SOAAuthority(q.Name) response.Authorities = append(response.Authorities, func(b *dnsmessage.Builder) error { if err = b.SOAResource(soaHeader, soaResource); err != nil { return err } return nil }) return response, logMessage + "nil, SOA " + soaLogMessage(soaResource), nil } if x.blocklist(q.Name.String()) { x.Metrics.AnsweredQueries++ x.Metrics.AnsweredBlockedQueries++ response.Answers = append(response.Answers, // 1 or more A records; A records > 1 only available via Customizations func(b *dnsmessage.Builder) error { err = b.AResource(dnsmessage.ResourceHeader{ Name: q.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; long TTL, these IP addrs don't change Length: 0, }, Customizations["ns-aws.sslip.io."].A[0]) if err != nil { return err } return nil }) return response, logMessage + net.IP(Customizations["ns-aws.sslip.io."].A[0].A[:]).String(), nil } x.Metrics.AnsweredQueries++ x.Metrics.AnsweredAQueries++ response.Answers = append(response.Answers, // 1 or more A records; A records > 1 only available via Customizations func(b *dnsmessage.Builder) error { for _, nameToA := range nameToAs { err = b.AResource(dnsmessage.ResourceHeader{ Name: q.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 3600, // 60 * 60 == 1 hour; short TTL in case we need to block them Length: 0, }, nameToA) if err != nil { return err } } return nil }) var logMessages []string for _, nameToA := range nameToAs { ip := net.IP(nameToA.A[:]) logMessages = append(logMessages, ip.String()) } return response, logMessage + strings.Join(logMessages, ", "), nil } func IsPublic(ip net.IP) (isPublic bool) { if ip.IsPrivate() { // RFC 1918, 4193 return false } if ip4 := ip.To4(); ip4 != nil { // IPv4 loopback if ip4[0] == 127 { return false } // IPv4 link-local if ip4[0] == 169 && ip4[1] == 254 { return false } // CG-NAT if ip4[0] == 100 && ip4[1]&0xc0 == 64 { return false } return true } // IPv6 loopback ::1 if ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 && ip[4] == 0 && ip[5] == 0 && ip[6] == 0 && ip[7] == 0 && ip[8] == 0 && ip[9] == 0 && ip[10] == 0 && ip[11] == 0 && ip[12] == 0 && ip[13] == 0 && ip[14] == 0 && ip[15] == 1 { return false } // IPv6 link-local fe80::/10 if ip[0] == 0xfe && ip[1] == 0x80 && ip[2]&0xc0 == 0 { return false } // IPv4/IPv6 Translation private internet 64:ff9b:1::/48 if ip[0] == 0 && ip[1] == 0x64 && ip[2] == 0xff && ip[3] == 0x9b && ip[4] == 0 && ip[5] == 1 && ip[6] == 0 && ip[7] == 0 && ip[8] == 0 && ip[9] == 0 { return false } // Teredo Tunneling 2001::/32 // ORCHIDv2 (?) 2001:20::/28 if ip[0] == 0x20 && ip[1] == 1 && ip[2] == 0 && ip[3]&0xf0 == 0x20 { return false } // Documentation 2001:db8::/32 if ip[0] == 0x20 && ip[1] == 1 && ip[2] == 0x0d && ip[3] == 0xb8 { return false } // Private internets fc00::/7 return true } func (x *Xip) nameToAAAAwithBlocklist(q dnsmessage.Question, response Response, logMessage string) (_ Response, _ string, err error) { var nameToAAAAs []dnsmessage.AAAAResource nameToAAAAs = NameToAAAA(q.Name.String(), x.Public) if len(nameToAAAAs) == 0 { // No Answers, only 1 Authorities soaHeader, soaResource := SOAAuthority(q.Name) response.Authorities = append(response.Authorities, func(b *dnsmessage.Builder) error { if err = b.SOAResource(soaHeader, soaResource); err != nil { return err } return nil }) return response, logMessage + "nil, SOA " + soaLogMessage(soaResource), nil } if x.blocklist(q.Name.String()) { x.Metrics.AnsweredQueries++ x.Metrics.AnsweredBlockedQueries++ response.Answers = append(response.Answers, // 1 or more A records; A records > 1 only available via Customizations func(b *dnsmessage.Builder) error { err = b.AAAAResource(dnsmessage.ResourceHeader{ Name: q.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 604800, // 60 * 60 * 24 * 7 == 1 week; long TTL, these IP addrs don't change Length: 0, }, Customizations["ns-aws.sslip.io."].AAAA[0]) if err != nil { return err } return nil }) return response, logMessage + net.IP(Customizations["ns-aws.sslip.io."].AAAA[0].AAAA[:]).String(), nil } x.Metrics.AnsweredQueries++ x.Metrics.AnsweredAAAAQueries++ response.Answers = append(response.Answers, // 1 or more AAAA records; AAAA records > 1 only available via Customizations func(b *dnsmessage.Builder) error { for _, nameToAAAA := range nameToAAAAs { err = b.AAAAResource(dnsmessage.ResourceHeader{ Name: q.Name, Type: dnsmessage.TypeAAAA, Class: dnsmessage.ClassINET, TTL: 3600, // 60 * 60 == 1 hour; short TTL in case we need to block them Length: 0, }, nameToAAAA) if err != nil { return err } } return nil }) var logMessages []string for _, nameToAAAA := range nameToAAAAs { ip := net.IP(nameToAAAA.AAAA[:]) logMessages = append(logMessages, ip.String()) } return response, logMessage + strings.Join(logMessages, ", "), nil }