diff --git a/etc/blocklist-test.txt b/etc/blocklist-test.txt new file mode 100644 index 0000000..56d2a4c --- /dev/null +++ b/etc/blocklist-test.txt @@ -0,0 +1,10 @@ +# TESTING ONLY: List of "Forbidden" (blocked) names & CIDRs + +# This is a shortened variant meant to be used for testing (`ginkgo`) because +# the legitimate one has grown so long it clutters the test output + +raiffeisen # https://www.rbinternational.com/en/homepage.html +43-134-66-67 # Netflix, https://nf-43-134-66-67.sslip.io/sg +43.134.66.67/24 # Netflix +2601:646:100:69f7:cafe:bebe:cafe:bebe/112 # personal (Comcast) IPv6 range for testing blocklist + diff --git a/integration_flags_test.go b/integration_flags_test.go index 9714c07..be75414 100644 --- a/integration_flags_test.go +++ b/integration_flags_test.go @@ -18,7 +18,7 @@ var _ = Describe("flags", func() { var flags []string JustBeforeEach(func() { - flags = append(flags, "-port", strconv.Itoa(port), "-blocklistURL", "file://etc/blocklist.txt") + flags = append(flags, "-port", strconv.Itoa(port), "-blocklistURL", "file://etc/blocklist-test.txt") serverCmd = exec.Command(serverPath, flags...) serverSession, err = Start(serverCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) @@ -160,4 +160,77 @@ var _ = Describe("flags", func() { Eventually(string(serverSession.Err.Contents())).Should(MatchRegexp(`fc00--\.sslip\.io\. \? fc00::`)) }) }) + When("-delegates is set", func() { + BeforeEach(func() { + flags = []string{"-delegates=" + + "_acme-challenge.127-0-0-1.IP.io=ns.nono.io," + + "2600--.IP.IO=ns-1.nono.com," + + "_acme-challenge.73-189-219-4.ip.IO=ns-2.nono.com," + + "a.b.C=d.E.f"} + }) + When("the arguments are missing", func() { + BeforeEach(func() { + flags = []string{"-delegates="} + }) + It("should give an informative message", func() { + Expect(string(serverSession.Err.Contents())).Should(MatchRegexp(`-delegates: arguments should be in the format "delegatedDomain=nameserver", not ""`)) + }) + }) + When("the arguments are mangled", func() { + BeforeEach(func() { + flags = []string{"-delegates=blahblah"} + }) + It("should give an informative message", func() { + Expect(string(serverSession.Err.Contents())).Should(MatchRegexp(`-delegates: arguments should be in the format "delegatedDomain=nameserver", not "blahblah"`)) + }) + }) + When("only some of the arguments are mangled", func() { + BeforeEach(func() { + flags = []string{"-delegates=a.b=c.d,blahblah"} + }) + It("adds the correct ones, gives an informative message for the mangled ones", func() { + Expect(string(serverSession.Err.Contents())).Should(MatchRegexp(`Adding delegated NS record "a.b.=c.d."`)) + Expect(string(serverSession.Err.Contents())).Should(MatchRegexp(`-delegates: arguments should be in the format "delegatedDomain=nameserver", not "blahblah"`)) + }) + }) + When("looking up a delegated domain", func() { + It("should return a non-authoritative NS record pointing to the nameserver", func() { + digArgs := "@localhost _acme-challenge.127-0-0-1.IP.io -p " + strconv.Itoa(port) + digCmd := exec.Command("dig", strings.Split(digArgs, " ")...) + digSession, err := Start(digCmd, GinkgoWriter, GinkgoWriter) + Expect(err).ToNot(HaveOccurred()) + Eventually(digSession).Should(Say(`flags: qr rd; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 0`)) + Eventually(digSession).Should(Say(`;; AUTHORITY SECTION:`)) + Eventually(digSession).Should(Say(`_acme-challenge.127-0-0-1.IP.io. 604800 IN NS ns.nono.io.\n`)) + Eventually(digSession, 1).Should(Exit(0)) + Eventually(string(serverSession.Err.Contents())).Should(MatchRegexp(`_acme-challenge\.127-0-0-1\.IP\.io\. \? nil, NS ns\.nono\.io\.`)) + }) + }) + When("looking up the subdomain of a delegated domain", func() { + It("should return a non-authoritative NS record pointing to the nameserver", func() { + digArgs := "@localhost subdomain.2600--.IP.IO -p " + strconv.Itoa(port) + digCmd := exec.Command("dig", strings.Split(digArgs, " ")...) + digSession, err := Start(digCmd, GinkgoWriter, GinkgoWriter) + Expect(err).ToNot(HaveOccurred()) + Eventually(digSession).Should(Say(`flags: qr rd; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 0`)) + Eventually(digSession).Should(Say(`;; AUTHORITY SECTION:`)) + Eventually(digSession).Should(Say(`subdomain.2600--.IP.IO. 604800 IN NS ns-1.nono.com.\n`)) + Eventually(digSession, 1).Should(Exit(0)) + Eventually(string(serverSession.Err.Contents())).Should(MatchRegexp(`subdomain\.2600--\.IP\.IO\. \? nil, NS ns-1\.nono\.com\.`)) + }) + }) + When("looking up a delegated domain that wouldn't have resolved to an IP address", func() { + It("it delegates", func() { + digArgs := "@localhost a.b.c -p " + strconv.Itoa(port) + digCmd := exec.Command("dig", strings.Split(digArgs, " ")...) + digSession, err := Start(digCmd, GinkgoWriter, GinkgoWriter) + Expect(err).ToNot(HaveOccurred()) + Eventually(digSession).Should(Say(`flags: qr rd; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 0`)) + Eventually(digSession).Should(Say(`;; AUTHORITY SECTION:`)) + Eventually(digSession).Should(Say(`a.b.c. 604800 IN NS d.e.f.`)) + Eventually(digSession, 1).Should(Exit(0)) + Eventually(string(serverSession.Err.Contents())).Should(MatchRegexp(`a\.b\.c\. \? nil, NS d\.e\.f\.`)) + }) + }) + }) }) diff --git a/integration_speed_test.go b/integration_speed_test.go index b3ca901..afa6578 100644 --- a/integration_speed_test.go +++ b/integration_speed_test.go @@ -20,7 +20,7 @@ var _ = Describe("speed", func() { var flags []string JustBeforeEach(func() { - flags = append(flags, "-port", strconv.Itoa(port), "-blocklistURL", "file://etc/blocklist.txt") + flags = append(flags, "-port", strconv.Itoa(port), "-blocklistURL", "file://etc/blocklist-test.txt") serverCmd = exec.Command(serverPath, flags...) serverSession, err = Start(serverCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) diff --git a/integration_test.go b/integration_test.go index 3a6914e..8250d3b 100644 --- a/integration_test.go +++ b/integration_test.go @@ -26,7 +26,7 @@ var serverPath, _ = Build("main.go") var _ = BeforeSuite(func() { Expect(err).ToNot(HaveOccurred()) - serverCmd = exec.Command(serverPath, "-port", strconv.Itoa(port), "-blocklistURL", "file://etc/blocklist.txt") + serverCmd = exec.Command(serverPath, "-port", strconv.Itoa(port), "-blocklistURL", "file://etc/blocklist-test.txt") serverSession, err = Start(serverCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) // takes 0.455s to start up on macOS Big Sur 3.7 GHz Quad Core 22-nm Xeon E5-1620v2 processor (2013 Mac Pro) @@ -414,7 +414,7 @@ var _ = Describe("sslip.io-dns-server", func() { When("it can't bind to any UDP port", func() { It("prints an error message and exits", func() { Expect(err).ToNot(HaveOccurred()) - secondServerCmd := exec.Command(serverPath, "-port", strconv.Itoa(port), "-blocklistURL", "file://etc/blocklist.txt") + secondServerCmd := exec.Command(serverPath, "-port", strconv.Itoa(port), "-blocklistURL", "file://etc/blocklist-test.txt") secondServerSession, err := Start(secondServerCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) Eventually(secondServerSession.Err, 10).Should(Say("I couldn't bind via UDP to any IPs")) @@ -436,7 +436,7 @@ var _ = Describe("sslip.io-dns-server", func() { }) It("prints an error message and continues running", func() { Expect(err).ToNot(HaveOccurred()) - secondServerCmd := exec.Command(serverPath, "-port", strconv.Itoa(newPort), "-blocklistURL", "file://etc/blocklist.txt") + secondServerCmd := exec.Command(serverPath, "-port", strconv.Itoa(newPort), "-blocklistURL", "file://etc/blocklist-test.txt") secondServerSession, err := Start(secondServerCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) Eventually(secondServerSession.Err, 10).Should(Say(` version \d+\.\d+\.\d+ starting`)) @@ -456,7 +456,7 @@ var _ = Describe("sslip.io-dns-server", func() { }) It("prints an informative message and binds to the addresses it can", func() { Expect(err).ToNot(HaveOccurred()) - secondServerCmd := exec.Command(serverPath, "-port", strconv.Itoa(newPort), "-blocklistURL", "file://etc/blocklist.txt") + secondServerCmd := exec.Command(serverPath, "-port", strconv.Itoa(newPort), "-blocklistURL", "file://etc/blocklist-test.txt") secondServerSession, err := Start(secondServerCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) Eventually(secondServerSession.Err, 10).Should(Say(` version \d+\.\d+\.\d+ starting`)) @@ -479,7 +479,7 @@ var _ = Describe("sslip.io-dns-server", func() { }) It("prints an informative message and binds to the addresses it can", func() { Expect(err).ToNot(HaveOccurred()) - secondServerCmd := exec.Command(serverPath, "-port", strconv.Itoa(newPort), "-blocklistURL", "file://etc/blocklist.txt") + secondServerCmd := exec.Command(serverPath, "-port", strconv.Itoa(newPort), "-blocklistURL", "file://etc/blocklist-test.txt") secondServerSession, err := Start(secondServerCmd, GinkgoWriter, GinkgoWriter) Expect(err).ToNot(HaveOccurred()) Eventually(secondServerSession.Err, 10).Should(Say(` version \d+\.\d+\.\d+ starting`)) diff --git a/main.go b/main.go index 7e22015..11cfc06 100644 --- a/main.go +++ b/main.go @@ -33,6 +33,10 @@ func main() { "ns-gce.sslip.io=104.155.144.4,"+ "ns-gce.sslip.io=2600:1900:4000:4d12::", "comma-separated list of hosts and corresponding IPv4 and/or IPv6 address(es). If you're running your own sslip.io nameservers, add their hostnames and addresses here. If unsure, add to the list rather than replace") + var delegates = flag.String("delegates", "", "comma-separated list of domains you own "+ + "and nameservers you control to which to delegate, often used to acquire wildcard certificates from "+ + "Let's Encrypt via DNS challenge. Example: "+ + `-delegates=_acme-challenge.73-189-219-4.xip.nono.io=ns-437.awsdns-54.com.,_acme-challenge.73-189-219-4.xip.nono.io=ns-1097.awsdns-09.org."`) var bindPort = flag.Int("port", 53, "port the DNS server should bind to") var quiet = flag.Bool("quiet", false, "suppresses logging of each DNS response. Use this to avoid Google Cloud charging you $30/month to retain the logs of your GKE-based sslip.io server") var public = flag.Bool("public", true, "allows resolution of public IP addresses. If false, only resolves private IPs including localhost (127/8, ::1), link-local (169.254/16, fe80::/10), CG-NAT (100.64/12), private (10/8, 172.16/12, 192.168/16, fc/7). Set to false if you don't want miscreants impersonating you via public IPs. If unsure, set to false") @@ -41,7 +45,7 @@ func main() { log.Printf("blocklist URL: %s, name servers: %s, bind port: %d, quiet: %t", *blocklistURL, *nameservers, *bindPort, *quiet) - x, logmessages := xip.NewXip(*blocklistURL, strings.Split(*nameservers, ","), strings.Split(*addresses, ",")) + x, logmessages := xip.NewXip(*blocklistURL, strings.Split(*nameservers, ","), strings.Split(*addresses, ","), strings.Split(*delegates, ",")) x.Public = *public for _, logmessage := range logmessages { log.Println(logmessage) diff --git a/xip/xip.go b/xip/xip.go index b767837..a003915 100644 --- a/xip/xip.go +++ b/xip/xip.go @@ -64,6 +64,7 @@ type DomainCustomization struct { AAAA []dnsmessage.AAAAResource CNAME dnsmessage.CNAMEResource MX []dnsmessage.MXResource + NS []dnsmessage.NSResource 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 @@ -167,7 +168,7 @@ type Response struct { } // 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) { +func NewXip(blocklistURL string, nameservers []string, addresses []string, delegates []string) (x *Xip, logmessages []string) { x = &Xip{Metrics: Metrics{Start: time.Now()}} // Download the blocklist @@ -213,7 +214,7 @@ func NewXip(blocklistURL string, nameservers []string, addresses []string) (x *X if host[len(host)-1] != '.' { host += "." } - if ip == nil { // bad IP address + if ip == nil { // bad IP delegate logmessages = append(logmessages, fmt.Sprintf(`-addresses: "%s" is not assigned a valid IP "%s"`, hostAddr, ip.String())) continue } @@ -247,6 +248,38 @@ func NewXip(blocklistURL string, nameservers []string, addresses []string) (x *X // 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)) } + // Parse and set the nameservers of our delegated domains + for _, delegate := range delegates { + delegatedDomainAndNameserver := strings.Split(strings.ToLower(delegate), "=") + if len(delegatedDomainAndNameserver) != 2 { + logmessages = append(logmessages, fmt.Sprintf(`-delegates: arguments should be in the format "delegatedDomain=nameserver", not "%s"`, delegate)) + continue + } + delegatedDomain := delegatedDomainAndNameserver[0] + nameServer := delegatedDomainAndNameserver[1] + // all domains & nameservers must be absolute (end in ".") + if delegatedDomain[len(delegatedDomain)-1] != '.' { + delegatedDomain += "." + } + if nameServer[len(nameServer)-1] != '.' { + nameServer += "." + } + + // nameservers must be DNS-compliant + nsName, err := dnsmessage.NewName(nameServer) + if err != nil { + logmessages = append(logmessages, fmt.Sprintf(`-nameservers: ignoring invalid nameserver "%s"`, nameServer)) + continue + } + var domainEntry = DomainCustomization{} + if _, ok := Customizations[delegatedDomain]; ok { + domainEntry = Customizations[delegatedDomain] + } + domainEntry.NS = append(domainEntry.NS, dnsmessage.NSResource{NS: nsName}) + Customizations[delegatedDomain] = domainEntry + // print out the added records in a manner similar to the way they're set on the cmdline + logmessages = append(logmessages, fmt.Sprintf(`Adding delegated NS record "%s=%s"`, delegatedDomain, nsName.String())) + } // 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 @@ -360,11 +393,19 @@ func (x *Xip) processQuestion(q dnsmessage.Question, srcAddr net.IP) (response R RCode: dnsmessage.RCodeSuccess, // assume success, may be replaced later }, } + if IsDelegated(q.Name.String()) { + // if xip.pivotal.io has been delegated to ns-437.awsdns-54.com. + // and a query comes in for 127-0-0-1.cloudfoundry.xip.pivotal.io + // then don't resolve the A record; instead, return the delegated + // NS record, ns-437.awsdns-54.com. + response.Header.Authoritative = false + return x.NSResponse(q.Name, response, logMessage) + } 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 + response.Header.Authoritative = false return x.NSResponse(q.Name, response, logMessage) } switch q.Type { @@ -598,8 +639,6 @@ func (x *Xip) processQuestion(q dnsmessage.Question, srcAddr net.IP) (response R } // 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 @@ -753,9 +792,10 @@ func MXResources(fqdnString string) []dnsmessage.MXResource { } func IsAcmeChallenge(fqdnString string) bool { - if dns01ChallengeRE.MatchString(fqdnString) { - ipv4s := NameToA(fqdnString, true) - ipv6s := NameToAAAA(fqdnString, true) + fqdnStringLowerCased := strings.ToLower(fqdnString) + if dns01ChallengeRE.MatchString(fqdnStringLowerCased) { + ipv4s := NameToA(fqdnStringLowerCased, true) + ipv6s := NameToAAAA(fqdnStringLowerCased, true) if len(ipv4s) > 0 || len(ipv6s) > 0 { return true } @@ -763,15 +803,40 @@ func IsAcmeChallenge(fqdnString string) bool { return false } +func IsDelegated(fqdnString string) bool { + fqdnStringLowerCased := strings.ToLower(fqdnString) + for domain := range Customizations { + if Customizations[domain].NS == nil { // no nameserver? then it can't be delegated + continue + } + // the "." prevents "where.com" from being mistakenly recognized as a subdomain of "here.com" + if strings.HasSuffix(fqdnStringLowerCased, "."+domain) || fqdnStringLowerCased == domain { + return true + } + } + return false +} + func (x *Xip) NSResources(fqdnString string) []dnsmessage.NSResource { - if x.blocklist(fqdnString) { + fqdnStringLowerCased := strings.ToLower(fqdnString) + if x.blocklist(fqdnStringLowerCased) { x.Metrics.AnsweredQueries++ x.Metrics.AnsweredBlockedQueries++ return x.NameServers } - if IsAcmeChallenge(fqdnString) { + // Is this a delegated domain? Let's return the delegated nameservers + for domain := range Customizations { + if Customizations[domain].NS == nil { // no nameserver? then it can't be delegated + continue + } + // the "." prevents "where.com" from being mistakenly recognized as a subdomain of "here.com" + if strings.HasSuffix(fqdnStringLowerCased, "."+domain) || fqdnStringLowerCased == domain { + return Customizations[domain].NS + } + } + if IsAcmeChallenge(fqdnStringLowerCased) { x.Metrics.AnsweredNSDNS01ChallengeQueries++ - strippedFqdn := dns01ChallengeRE.ReplaceAllString(fqdnString, "") + strippedFqdn := dns01ChallengeRE.ReplaceAllString(fqdnStringLowerCased, "") ns, _ := dnsmessage.NewName(strippedFqdn) return []dnsmessage.NSResource{{NS: ns}} } diff --git a/xip/xip_test.go b/xip/xip_test.go index 2bd2400..3e72f89 100644 --- a/xip/xip_test.go +++ b/xip/xip_test.go @@ -79,7 +79,7 @@ var _ = Describe("Xip", func() { Describe("NSResources()", func() { When("we use the default nameservers", func() { - var x, _ = xip.NewXip("file:///", []string{"ns-aws.sslip.io.", "ns-azure.sslip.io.", "ns-gce.sslip.io."}, []string{}) + var x, _ = xip.NewXip("file:///", []string{"ns-aws.sslip.io.", "ns-azure.sslip.io.", "ns-gce.sslip.io."}, []string{}, []string{}) It("returns the name servers", func() { randomDomain := testhelper.Random8ByteString() + ".com." ns := x.NSResources(randomDomain) @@ -94,7 +94,7 @@ var _ = Describe("Xip", func() { randomDomain := "192.168.0.1." + testhelper.Random8ByteString() + ".com." ns := x.NSResources("_acme-challenge." + randomDomain) Expect(len(ns)).To(Equal(1)) - Expect(ns[0].NS.String()).To(Equal(randomDomain)) + Expect(ns[0].NS.String()).To(Equal(strings.ToLower(randomDomain))) aResources := xip.NameToA(randomDomain, true) Expect(len(aResources)).To(Equal(1)) Expect(err).ToNot(HaveOccurred()) @@ -109,9 +109,25 @@ var _ = Describe("Xip", func() { }) }) }) + When("we delegate domains to other nameservers", func() { + When(`we don't use the "=" in the arguments`, func() { + It("returns an informative log message", func() { + var _, logs = xip.NewXip("file://etc/blocklist-test.txt", []string{"ns-aws.sslip.io.", "ns-azure.sslip.io.", "ns-gce.sslip.io."}, []string{}, []string{"noEquals"}) + Expect(strings.Join(logs, "")).To(MatchRegexp(`"-delegates: arguments should be in the format "delegatedDomain=nameserver", not "noEquals"`)) + }) + }) + When(`there's no "." at the end of the delegated domain or nameserver`, func() { + It(`helpfully adds the "."`, func() { + var x, logs = xip.NewXip("file://etc/blocklist-test.txt", []string{"ns-aws.sslip.io.", "ns-azure.sslip.io.", "ns-gce.sslip.io."}, []string{}, []string{"a=b"}) + Expect(strings.Join(logs, "")).To(MatchRegexp(`Adding delegated NS record "a\.=b\."`)) + ns := x.NSResources("a.") + Expect(len(ns)).To(Equal(1)) + }) + }) + }) }) When("we override the default nameservers", func() { - var x, _ = xip.NewXip("file:///", []string{"mickey", "minn.ie.", "goo.fy"}, []string{}) + var x, _ = xip.NewXip("file:///", []string{"mickey", "minn.ie.", "goo.fy"}, []string{}, []string{}) It("returns the configured servers", func() { randomDomain := testhelper.Random8ByteString() + ".com." ns := x.NSResources(randomDomain) @@ -287,6 +303,36 @@ var _ = Describe("Xip", func() { }) }) }) + Describe("IsDelegated()", func() { + var nsName dnsmessage.Name + nsName, err = dnsmessage.NewName("1.com") + Expect(err).ToNot(HaveOccurred()) + xip.Customizations["a.com"] = xip.DomainCustomization{NS: []dnsmessage.NSResource{dnsmessage.NSResource{NS: nsName}}} + xip.Customizations["b.com"] = xip.DomainCustomization{} + + When("the domain is delegated", func() { + When("the fqdn exactly matches the domain", func() { + It("returns true", func() { + Expect(xip.IsDelegated("A.com")).To(BeTrue()) + }) + }) + When("the fqdn is a subdomain of the domain", func() { + It("returns true", func() { + Expect(xip.IsDelegated("b.a.COM")).To(BeTrue()) + }) + }) + When("the fqdn doesn't match the domain", func() { + It("returns false", func() { + Expect(xip.IsDelegated("Aa.com")).To(BeFalse()) + }) + }) + }) + When("the domain is customized but not delegated", func() { + It("returns false", func() { + Expect(xip.IsDelegated("b.COM")).To(BeFalse()) + }) + }) + }) Describe("NameToAAAA()", func() { DescribeTable("when it succeeds",