diff --git a/README.md b/README.md index 282a3150..b948a40f 100644 --- a/README.md +++ b/README.md @@ -30,18 +30,18 @@ Headscale implements this coordination server. - [x] Support for alternative IP ranges in the tailnets (default Tailscale's 100.64.0.0/10) - [x] DNS (passing DNS servers to nodes) - [x] Share nodes between ~~users~~ namespaces -- [ ] MagicDNS / Smart DNS +- [x] MagicDNS (see `docs/`) ## Client OS support -| OS | Supports headscale | -| --- | --- | -| Linux | Yes | -| OpenBSD | Yes | -| macOS | Yes (see `/apple` on your headscale for more information) | -| Windows | Yes | +| OS | Supports headscale | +| ------- | ----------------------------------------------------------------------------------------------------------------- | +| Linux | Yes | +| OpenBSD | Yes | +| macOS | Yes (see `/apple` on your headscale for more information) | +| Windows | Yes | | Android | [You need to compile the client yourself](https://github.com/juanfont/headscale/issues/58#issuecomment-885255270) | -| iOS | Not yet | +| iOS | Not yet | ## Roadmap 🤷 diff --git a/api.go b/api.go index ce3b242e..13955d39 100644 --- a/api.go +++ b/api.go @@ -225,7 +225,7 @@ func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m *Ma Str("func", "getMapResponse"). Str("machine", req.Hostinfo.Hostname). Msg("Creating Map response") - node, err := m.toNode(true) + node, err := m.toNode(h.cfg.BaseDomain, h.cfg.DNSConfig, true) if err != nil { log.Error(). Str("func", "getMapResponse"). @@ -249,7 +249,7 @@ func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m *Ma DisplayName: m.Namespace.Name, } - nodePeers, err := peers.toNodes(true) + nodePeers, err := peers.toNodes(h.cfg.BaseDomain, h.cfg.DNSConfig, true) if err != nil { log.Error(). Str("func", "getMapResponse"). @@ -258,20 +258,25 @@ func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m *Ma return nil, err } + var dnsConfig *tailcfg.DNSConfig + if h.cfg.DNSConfig != nil && h.cfg.DNSConfig.Proxied { // if MagicDNS is enabled + // Only inject the Search Domain of the current namespace - shared nodes should use their full FQDN + dnsConfig = h.cfg.DNSConfig.Clone() + dnsConfig.Domains = append(dnsConfig.Domains, fmt.Sprintf("%s.%s", m.Namespace.Name, h.cfg.BaseDomain)) + } else { + dnsConfig = h.cfg.DNSConfig + } + resp := tailcfg.MapResponse{ - KeepAlive: false, - Node: node, - Peers: nodePeers, - // TODO(kradalby): As per tailscale docs, if DNSConfig is nil, - // it means its not updated, maybe we can have some logic - // to check and only pass updates when its updates. - // This is probably more relevant if we try to implement - // "MagicDNS" - DNSConfig: h.cfg.DNSConfig, - SearchPaths: []string{}, - Domain: "headscale.net", + KeepAlive: false, + Node: node, + Peers: nodePeers, + DNSConfig: dnsConfig, + Domain: h.cfg.BaseDomain, PacketFilter: *h.aclRules, DERPMap: h.cfg.DerpMap, + + // TODO(juanfont): We should send the profiles of all the peers (this own namespace + those from the shared peers) UserProfiles: []tailcfg.UserProfile{profile}, } log.Trace(). @@ -337,6 +342,11 @@ func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgkey.Key, resp := tailcfg.RegisterResponse{} pak, err := h.checkKeyValidity(req.Auth.AuthKey) if err != nil { + log.Error(). + Str("func", "handleAuthKey"). + Str("machine", m.Name). + Err(err). + Msg("Failed authentication via AuthKey") resp.MachineAuthorized = false respBody, err := encode(resp, &idKey, h.privateKey) if err != nil { diff --git a/app.go b/app.go index acaa7f1b..864ae16a 100644 --- a/app.go +++ b/app.go @@ -13,12 +13,13 @@ import ( "github.com/rs/zerolog/log" "github.com/gin-gonic/gin" - "github.com/zsais/go-gin-prometheus" + ginprometheus "github.com/zsais/go-gin-prometheus" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" "gorm.io/gorm" "inet.af/netaddr" "tailscale.com/tailcfg" + "tailscale.com/types/dnstype" "tailscale.com/types/wgkey" ) @@ -30,6 +31,7 @@ type Config struct { DerpMap *tailcfg.DERPMap EphemeralNodeInactivityTimeout time.Duration IPPrefix netaddr.IPPrefix + BaseDomain string DBtype string DBpath string @@ -106,6 +108,17 @@ func NewHeadscale(cfg Config) (*Headscale, error) { return nil, err } + if h.cfg.DNSConfig != nil && h.cfg.DNSConfig.Proxied { // if MagicDNS + magicDNSDomains, err := generateMagicDNSRootDomains(h.cfg.IPPrefix, h.cfg.BaseDomain) + if err != nil { + return nil, err + } + h.cfg.DNSConfig.Routes = make(map[string][]dnstype.Resolver) + for _, d := range magicDNSDomains { + h.cfg.DNSConfig.Routes[d.WithoutTrailingDot()] = nil + } + } + return &h, nil } diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index ca09bf51..f879f91e 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -76,7 +76,7 @@ func LoadConfig(path string) error { } -func GetDNSConfig() *tailcfg.DNSConfig { +func GetDNSConfig() (*tailcfg.DNSConfig, string) { if viper.IsSet("dns_config") { dnsConfig := &tailcfg.DNSConfig{} @@ -108,10 +108,27 @@ func GetDNSConfig() *tailcfg.DNSConfig { dnsConfig.Domains = viper.GetStringSlice("dns_config.domains") } - return dnsConfig + if viper.IsSet("dns_config.magic_dns") { + magicDNS := viper.GetBool("dns_config.magic_dns") + if len(dnsConfig.Nameservers) > 0 { + dnsConfig.Proxied = magicDNS + } else if magicDNS { + log.Warn(). + Msg("Warning: dns_config.magic_dns is set, but no nameservers are configured. Ignoring magic_dns.") + } + } + + var baseDomain string + if viper.IsSet("dns_config.base_domain") { + baseDomain = viper.GetString("dns_config.base_domain") + } else { + baseDomain = "headscale.net" // does not really matter when MagicDNS is not enabled + } + + return dnsConfig, baseDomain } - return nil + return nil, "" } func absPath(path string) string { @@ -144,12 +161,15 @@ func getHeadscaleApp() (*headscale.Headscale, error) { return nil, err } + dnsConfig, baseDomain := GetDNSConfig() + cfg := headscale.Config{ ServerURL: viper.GetString("server_url"), Addr: viper.GetString("listen_addr"), PrivateKeyPath: absPath(viper.GetString("private_key_path")), DerpMap: derpMap, IPPrefix: netaddr.MustParseIPPrefix(viper.GetString("ip_prefix")), + BaseDomain: baseDomain, EphemeralNodeInactivityTimeout: viper.GetDuration("ephemeral_node_inactivity_timeout"), @@ -169,10 +189,10 @@ func getHeadscaleApp() (*headscale.Headscale, error) { TLSCertPath: absPath(viper.GetString("tls_cert_path")), TLSKeyPath: absPath(viper.GetString("tls_key_path")), + DNSConfig: dnsConfig, + ACMEEmail: viper.GetString("acme_email"), ACMEURL: viper.GetString("acme_url"), - - DNSConfig: GetDNSConfig(), } h, err := headscale.NewHeadscale(cfg) diff --git a/cmd/headscale/headscale_test.go b/cmd/headscale/headscale_test.go index 58bf5899..bddea94c 100644 --- a/cmd/headscale/headscale_test.go +++ b/cmd/headscale/headscale_test.go @@ -117,12 +117,12 @@ func (*Suite) TestDNSConfigLoading(c *check.C) { err = cli.LoadConfig(tmpDir) c.Assert(err, check.IsNil) - dnsConfig := cli.GetDNSConfig() - fmt.Println(dnsConfig) + dnsConfig, baseDomain := cli.GetDNSConfig() c.Assert(dnsConfig.Nameservers[0].String(), check.Equals, "1.1.1.1") - c.Assert(dnsConfig.Resolvers[0].Addr, check.Equals, "1.1.1.1") + c.Assert(dnsConfig.Proxied, check.Equals, true) + c.Assert(baseDomain, check.Equals, "example.com") } func writeConfig(c *check.C, tmpDir string, configYaml []byte) { diff --git a/config.json.postgres.example b/config.json.postgres.example index e9118204..9b6f737f 100644 --- a/config.json.postgres.example +++ b/config.json.postgres.example @@ -22,6 +22,9 @@ "dns_config": { "nameservers": [ "1.1.1.1" - ] + ], + "domains": [], + "magic_dns": true, + "base_domain": "example.com" } } diff --git a/config.json.sqlite.example b/config.json.sqlite.example index 5afa450f..74e15902 100644 --- a/config.json.sqlite.example +++ b/config.json.sqlite.example @@ -18,6 +18,9 @@ "dns_config": { "nameservers": [ "1.1.1.1" - ] + ], + "domains": [], + "magic_dns": true, + "base_domain": "example.com" } } diff --git a/dns.go b/dns.go new file mode 100644 index 00000000..353e10be --- /dev/null +++ b/dns.go @@ -0,0 +1,73 @@ +package headscale + +import ( + "fmt" + "strings" + + "inet.af/netaddr" + "tailscale.com/util/dnsname" +) + +// generateMagicDNSRootDomains generates a list of DNS entries to be included in `Routes` in `MapResponse`. +// This list of reverse DNS entries instructs the OS on what subnets and domains the Tailscale embedded DNS +// server (listening in 100.100.100.100 udp/53) should be used for. +// +// Tailscale.com includes in the list: +// - the `BaseDomain` of the user +// - the reverse DNS entry for IPv6 (0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa., see below more on IPv6) +// - the reverse DNS entries for the IPv4 subnets covered by the user's `IPPrefix`. +// In the public SaaS this is [64-127].100.in-addr.arpa. +// +// The main purpose of this function is then generating the list of IPv4 entries. For the 100.64.0.0/10, this +// is clear, and could be hardcoded. But we are allowing any range as `IPPrefix`, so we need to find out the +// subnets when we have 172.16.0.0/16 (i.e., [0-255].16.172.in-addr.arpa.), or any other subnet. +// +// How IN-ADDR.ARPA domains work is defined in RFC1035 (section 3.5). Tailscale.com seems to adhere to this, +// and do not make use of RFC2317 ("Classless IN-ADDR.ARPA delegation") - hence generating the entries for the next +// class block only. + +// From the netmask we can find out the wildcard bits (the bits that are not set in the netmask). +// This allows us to then calculate the subnets included in the subsequent class block and generate the entries. +func generateMagicDNSRootDomains(ipPrefix netaddr.IPPrefix, baseDomain string) ([]dnsname.FQDN, error) { + base, err := dnsname.ToFQDN(baseDomain) + if err != nil { + return nil, err + } + + // TODO(juanfont): we are not handing out IPv6 addresses yet + // and in fact this is Tailscale.com's range (note the fd7a:115c:a1e0: range in the fc00::/7 network) + ipv6base := dnsname.FQDN("0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa.") + fqdns := []dnsname.FQDN{base, ipv6base} + + // Conversion to the std lib net.IPnet, a bit easier to operate + netRange := ipPrefix.IPNet() + maskBits, _ := netRange.Mask.Size() + + // lastOctet is the last IP byte covered by the mask + lastOctet := maskBits / 8 + + // wildcardBits is the number of bits not under the mask in the lastOctet + wildcardBits := 8 - maskBits%8 + + // min is the value in the lastOctet byte of the IP + // max is basically 2^wildcardBits - i.e., the value when all the wildcardBits are set to 1 + min := uint(netRange.IP[lastOctet]) + max := uint((min + 1<= 0; i-- { + rdnsSlice = append(rdnsSlice, fmt.Sprintf("%d", netRange.IP[i])) + } + rdnsSlice = append(rdnsSlice, "in-addr.arpa.") + rdnsBase := strings.Join(rdnsSlice, ".") + + for i := min; i <= max; i++ { + fqdn, err := dnsname.ToFQDN(fmt.Sprintf("%d.%s", i, rdnsBase)) + if err != nil { + continue + } + fqdns = append(fqdns, fqdn) + } + return fqdns, nil +} diff --git a/dns_test.go b/dns_test.go new file mode 100644 index 00000000..87813203 --- /dev/null +++ b/dns_test.go @@ -0,0 +1,63 @@ +package headscale + +import ( + "gopkg.in/check.v1" + "inet.af/netaddr" +) + +func (s *Suite) TestMagicDNSRootDomains100(c *check.C) { + prefix := netaddr.MustParseIPPrefix("100.64.0.0/10") + domains, err := generateMagicDNSRootDomains(prefix, "headscale.net") + c.Assert(err, check.IsNil) + + found := false + for _, domain := range domains { + if domain == "64.100.in-addr.arpa." { + found = true + break + } + } + c.Assert(found, check.Equals, true) + + found = false + for _, domain := range domains { + if domain == "100.100.in-addr.arpa." { + found = true + break + } + } + c.Assert(found, check.Equals, true) + + found = false + for _, domain := range domains { + if domain == "127.100.in-addr.arpa." { + found = true + break + } + } + c.Assert(found, check.Equals, true) +} + +func (s *Suite) TestMagicDNSRootDomains172(c *check.C) { + prefix := netaddr.MustParseIPPrefix("172.16.0.0/16") + domains, err := generateMagicDNSRootDomains(prefix, "headscale.net") + c.Assert(err, check.IsNil) + + found := false + for _, domain := range domains { + if domain == "0.16.172.in-addr.arpa." { + found = true + break + } + } + c.Assert(found, check.Equals, true) + + found = false + for _, domain := range domains { + if domain == "255.16.172.in-addr.arpa." { + found = true + break + } + } + c.Assert(found, check.Equals, true) +} diff --git a/docs/DNS.md b/docs/DNS.md new file mode 100644 index 00000000..85bf9f44 --- /dev/null +++ b/docs/DNS.md @@ -0,0 +1,33 @@ +# DNS in Headscale + +Headscale supports Tailscale's DNS configuration and MagicDNS. Please have a look to their KB to better understand what this means: + +- https://tailscale.com/kb/1054/dns/ +- https://tailscale.com/kb/1081/magicdns/ +- https://tailscale.com/blog/2021-09-private-dns-with-magicdns/ + +Long story short, you can define the DNS servers you want to use in your tailnets, activate MagicDNS (so you don't have to remember the IP addresses of your nodes), define search domains, as well as predefined hosts. Headscale will inject that settings into your nodes. + + +## Configuration reference + +The setup is done via the `config.json` file, under the `dns_config` key. + +```json +{ + "server_url": "http://127.0.0.1:8001", + "listen_addr": "0.0.0.0:8001", + "private_key_path": "private.key", + //... + "dns_config": { + "nameservers": ["1.1.1.1", "8.8.8.8"], + "domains": [], + "magic_dns": true, + "base_domain": "example.com" + } +} +``` +- `nameservers`: The list of DNS servers to use. +- `domains`: Search domains to inject. +- `magic_dns`: Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). Only works if there is at least a nameserver defined. +- `base_domain`: Defines the base domain to create the hostnames for MagicDNS. `base_domain` must be a FQDNs, without the trailing dot. The FQDN of the hosts will be `hostname.namespace.base_domain` (e.g., _myhost.mynamespace.example.com_). \ No newline at end of file diff --git a/go.mod b/go.mod index 1fadd6b7..65165e0d 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect github.com/opencontainers/runc v1.0.2 // indirect github.com/ory/dockertest/v3 v3.7.0 - github.com/prometheus/client_golang v1.11.0 // indirect + github.com/prometheus/client_golang v1.11.0 github.com/pterm/pterm v0.12.30 github.com/rs/zerolog v1.25.0 github.com/spf13/cobra v1.2.1 @@ -30,7 +30,7 @@ require ( github.com/tailscale/hujson v0.0.0-20210818175511-7360507a6e88 github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/zsais/go-gin-prometheus v0.1.0 // indirect + github.com/zsais/go-gin-prometheus v0.1.0 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 golang.org/x/net v0.0.0-20210913180222-943fd674d43e // indirect golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 // indirect diff --git a/integration_test.go b/integration_test.go index a7805646..9bc51f99 100644 --- a/integration_test.go +++ b/integration_test.go @@ -592,7 +592,7 @@ func (s *IntegrationTestSuite) TestTailDrop() { _, err = executeCommand( &tailscale, command, - []string{"ALL_PROXY=socks5://localhost:1055/"}, + []string{"ALL_PROXY=socks5://localhost:1055"}, ) if err == nil { break @@ -645,6 +645,38 @@ func (s *IntegrationTestSuite) TestTailDrop() { } } +func (s *IntegrationTestSuite) TestMagicDNS() { + for namespace, scales := range s.namespaces { + ips, err := getIPs(scales.tailscales) + assert.Nil(s.T(), err) + for hostname, tailscale := range scales.tailscales { + for peername, ip := range ips { + s.T().Run(fmt.Sprintf("%s-%s", hostname, peername), func(t *testing.T) { + if peername != hostname { + command := []string{ + "tailscale", "ping", + "--timeout=10s", + "--c=20", + "--until-direct=true", + fmt.Sprintf("%s.%s.headscale.net", peername, namespace), + } + + fmt.Printf("Pinging using Hostname (magicdns) from %s (%s) to %s (%s)\n", hostname, ips[hostname], peername, ip) + result, err := executeCommand( + &tailscale, + command, + []string{}, + ) + assert.Nil(t, err) + fmt.Printf("Result for %s: %s\n", hostname, result) + assert.Contains(t, result, "pong") + } + }) + } + } + } +} + func getIPs(tailscales map[string]dockertest.Resource) (map[string]netaddr.IP, error) { ips := make(map[string]netaddr.IP) for hostname, tailscale := range tailscales { diff --git a/integration_test/etc/config.json b/integration_test/etc/config.json index 5454f2f7..dc23652d 100644 --- a/integration_test/etc/config.json +++ b/integration_test/etc/config.json @@ -7,5 +7,13 @@ "db_type": "sqlite3", "db_path": "/tmp/integration_test_db.sqlite3", "acl_policy_path": "", - "log_level": "trace" -} + "log_level": "trace", + "dns_config": { + "nameservers": [ + "1.1.1.1" + ], + "domains": [], + "magic_dns": true, + "base_domain": "headscale.net" + } +} \ No newline at end of file diff --git a/machine.go b/machine.go index 21d774b4..ecea378f 100644 --- a/machine.go +++ b/machine.go @@ -63,7 +63,7 @@ func (h *Headscale) getDirectPeers(m *Machine) (Machines, error) { Msg("Finding direct peers") machines := Machines{} - if err := h.db.Where("namespace_id = ? AND machine_key <> ? AND registered", + if err := h.db.Preload("Namespace").Where("namespace_id = ? AND machine_key <> ? AND registered", m.NamespaceID, m.MachineKey).Find(&machines).Error; err != nil { log.Error().Err(err).Msg("Error accessing db") return Machines{}, err @@ -273,11 +273,11 @@ func (ms MachinesP) String() string { return fmt.Sprintf("[ %s ](%d)", strings.Join(temp, ", "), len(temp)) } -func (ms Machines) toNodes(includeRoutes bool) ([]*tailcfg.Node, error) { +func (ms Machines) toNodes(baseDomain string, dnsConfig *tailcfg.DNSConfig, includeRoutes bool) ([]*tailcfg.Node, error) { nodes := make([]*tailcfg.Node, len(ms)) for index, machine := range ms { - node, err := machine.toNode(includeRoutes) + node, err := machine.toNode(baseDomain, dnsConfig, includeRoutes) if err != nil { return nil, err } @@ -290,7 +290,7 @@ func (ms Machines) toNodes(includeRoutes bool) ([]*tailcfg.Node, error) { // toNode converts a Machine into a Tailscale Node. includeRoutes is false for shared nodes // as per the expected behaviour in the official SaaS -func (m Machine) toNode(includeRoutes bool) (*tailcfg.Node, error) { +func (m Machine) toNode(baseDomain string, dnsConfig *tailcfg.DNSConfig, includeRoutes bool) (*tailcfg.Node, error) { nKey, err := wgkey.ParseHex(m.NodeKey) if err != nil { return nil, err @@ -385,10 +385,17 @@ func (m Machine) toNode(includeRoutes bool) (*tailcfg.Node, error) { keyExpiry = time.Time{} } + var hostname string + if dnsConfig != nil && dnsConfig.Proxied { // MagicDNS + hostname = fmt.Sprintf("%s.%s.%s", m.Name, m.Namespace.Name, baseDomain) + } else { + hostname = m.Name + } + n := tailcfg.Node{ ID: tailcfg.NodeID(m.ID), // this is the actual ID StableID: tailcfg.StableNodeID(strconv.FormatUint(m.ID, 10)), // in headscale, unlike tailcontrol server, IDs are permanent - Name: hostinfo.Hostname, + Name: hostname, User: tailcfg.UserID(m.NamespaceID), Key: tailcfg.NodeKey(nKey), KeyExpiry: keyExpiry,