From bc0d9ff6a6555d9ea76be8869e76c7119faf7af1 Mon Sep 17 00:00:00 2001 From: Nelson Jeppesen <50854675+NelsonJeppesen@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:58:43 -0700 Subject: [PATCH 1/4] config: add dns.nameservers.use_with_exit_node Add a per-nameserver list that marks the listed resolvers with Tailscale's dnstype.Resolver.UseWithExitNode flag, so those resolvers keep being used when a client selects an exit node instead of having all DNS delegated to the exit node. This lets a self-hosted resolver on the tailnet be reached directly. The flag requires client capability version 125 (Tailscale v1.88); older clients ignore it. Default is empty, preserving current behaviour. Fixes #2816 --- CHANGELOG.md | 1 + config-example.yaml | 12 ++++ docs/ref/dns.md | 28 ++++++++ hscontrol/types/config.go | 44 +++++++++++-- hscontrol/types/config_test.go | 65 +++++++++++++++++-- .../testdata/dns-use-with-exit-node.yaml | 26 ++++++++ 6 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 hscontrol/types/testdata/dns-use-with-exit-node.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 785d7d855..653837ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ keys remain all-access. - Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) - Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341) +- Add `dns.nameservers.use_with_exit_node` to keep selected resolvers active when a client uses an exit node, so a self-hosted resolver can be reached without routing DNS through the exit node (requires Tailscale v1.88+) [#2816](https://github.com/juanfont/headscale/issues/2816) ## 0.29.2 (2026-07-01) diff --git a/config-example.yaml b/config-example.yaml index 09f0356b8..e9a1aaf82 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -337,6 +337,18 @@ dns: # - 1.1.1.1 # - 8.8.8.8 + # By default, when a client selects an exit node, Tailscale routes all of + # that client's DNS through the exit node and ignores the nameservers + # above. List the nameserver addresses here (from `global` or `split`) that + # should keep being used even while an exit node is selected. This is useful + # to reach a self-hosted resolver (e.g. Pi-hole on the tailnet) directly, + # without the round trip through the exit node. + # + # Requires a Tailscale client from v1.88 or newer (capability version 125); + # older clients silently ignore this setting. + use_with_exit_node: [] + # - 100.64.0.53 + # Set custom DNS search domains. With MagicDNS enabled, # your tailnet base_domain is always the first search domain. search_domains: [] diff --git a/docs/ref/dns.md b/docs/ref/dns.md index fe1862eff..655bb4f67 100644 --- a/docs/ref/dns.md +++ b/docs/ref/dns.md @@ -3,6 +3,34 @@ Headscale supports [most DNS features](../about/features.md) from Tailscale. DNS related settings can be configured within the `dns` section of the [configuration file](configuration.md). +## Keeping nameservers active when using an exit node + +By default, when a client selects an exit node, Tailscale sends **all** of that client's DNS through the exit node and +ignores the nameservers configured in Headscale. This is usually desirable, but it prevents reaching a self-hosted +resolver (for example a Pi-hole running on the tailnet) directly: the query has to take the round trip through the exit +node first. + +The `dns.nameservers.use_with_exit_node` option lists the nameserver addresses that should keep being used even while an +exit node is selected. Listed addresses must also appear in `dns.nameservers.global` or `dns.nameservers.split`. + +```yaml title="config.yaml" +dns: + nameservers: + global: + - 100.64.0.53 + use_with_exit_node: + - 100.64.0.53 +``` + +When the listed resolver is reachable within the tailnet (such as `100.64.0.53`), the client talks to it directly +instead of routing the query through the exit node. + +!!! warning "Requires a recent Tailscale client" + + This maps to Tailscale's [`UseWithExitNode`](https://tailscale.com/kb/1054/dns#nameservers-and-exit-nodes) resolver + flag, added in **capability version 125 (Tailscale v1.88)**. Older clients silently ignore the setting and continue + to send DNS through the exit node. + ## Setting extra DNS records Headscale allows to set extra DNS records which are made available via diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 4f0b4ecfa..71670408c 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -167,6 +167,12 @@ type DNSConfig struct { type Nameservers struct { Global []string Split map[string][]string + // UseWithExitNode lists nameserver addresses (from Global or Split) + // that should keep being used even when a client selects an exit node. + // Without this, Tailscale clients delegate all DNS to the exit node, + // ignoring the configured resolvers. Requires client capability version + // 125 (Tailscale v1.88 or newer); older clients ignore the flag. + UseWithExitNode []string `mapstructure:"use_with_exit_node"` } type SqliteConfig struct { @@ -446,6 +452,7 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("dns.override_local_dns", true) viper.SetDefault("dns.nameservers.global", []string{}) viper.SetDefault("dns.nameservers.split", map[string]string{}) + viper.SetDefault("dns.nameservers.use_with_exit_node", []string{}) viper.SetDefault("dns.search_domains", []string{}) viper.SetDefault("derp.server.enabled", false) @@ -914,6 +921,9 @@ func dns() (DNSConfig, error) { dns.OverrideLocalDNS = viper.GetBool("dns.override_local_dns") dns.Nameservers.Global = viper.GetStringSlice("dns.nameservers.global") dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split") + dns.Nameservers.UseWithExitNode = viper.GetStringSlice( + "dns.nameservers.use_with_exit_node", + ) dns.SearchDomains = viper.GetStringSlice("dns.search_domains") dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path") @@ -936,13 +946,20 @@ func dns() (DNSConfig, error) { // If a nameserver is a valid URL, it will be used as a DoH resolver. // If a nameserver is neither a valid URL nor a valid IP, it will be ignored. // When domain is non-empty, it is included in the warning for invalid entries. -func parseResolvers(nameservers []string, domain string) []*dnstype.Resolver { +// Resolvers whose address is present in useWithExitNode are marked so Tailscale +// clients keep using them even when an exit node is selected. +func parseResolvers( + nameservers []string, + domain string, + useWithExitNode map[string]bool, +) []*dnstype.Resolver { var resolvers []*dnstype.Resolver for _, nsStr := range nameservers { if _, err := netip.ParseAddr(nsStr); err == nil { //nolint:noinlineerr resolvers = append(resolvers, &dnstype.Resolver{ - Addr: nsStr, + Addr: nsStr, + UseWithExitNode: useWithExitNode[nsStr], }) continue @@ -950,7 +967,8 @@ func parseResolvers(nameservers []string, domain string) []*dnstype.Resolver { if _, err := url.Parse(nsStr); err == nil { //nolint:noinlineerr resolvers = append(resolvers, &dnstype.Resolver{ - Addr: nsStr, + Addr: nsStr, + UseWithExitNode: useWithExitNode[nsStr], }) continue @@ -967,18 +985,34 @@ func parseResolvers(nameservers []string, domain string) []*dnstype.Resolver { return resolvers } +// useWithExitNodeSet returns the set of nameserver addresses that should keep +// being used when an exit node is selected. +func (d *DNSConfig) useWithExitNodeSet() map[string]bool { + if len(d.Nameservers.UseWithExitNode) == 0 { + return nil + } + + set := make(map[string]bool, len(d.Nameservers.UseWithExitNode)) + for _, ns := range d.Nameservers.UseWithExitNode { + set[ns] = true + } + + return set +} + // globalResolvers returns the global DNS resolvers // defined in the config file. func (d *DNSConfig) globalResolvers() []*dnstype.Resolver { - return parseResolvers(d.Nameservers.Global, "") + return parseResolvers(d.Nameservers.Global, "", d.useWithExitNodeSet()) } // splitResolvers returns a map of domain to DNS resolvers. func (d *DNSConfig) splitResolvers() map[string][]*dnstype.Resolver { routes := make(map[string][]*dnstype.Resolver) + useWithExitNode := d.useWithExitNodeSet() for domain, nameservers := range d.Nameservers.Split { - routes[domain] = parseResolvers(nameservers, domain) + routes[domain] = parseResolvers(nameservers, domain, useWithExitNode) } return routes diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go index d0859758c..27f28db58 100644 --- a/hscontrol/types/config_test.go +++ b/hscontrol/types/config_test.go @@ -52,10 +52,15 @@ func TestReadConfig(t *testing.T) { "darp.headscale.net": {"1.1.1.1", "8.8.8.8"}, "foo.bar.com": {"1.1.1.1"}, }, + UseWithExitNode: []string{}, }, ExtraRecords: []tailcfg.DNSRecord{ {Name: "grafana.myvpn.example.com", Type: "A", Value: "100.64.0.3"}, - {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"}, + { + Name: "prometheus.myvpn.example.com", + Type: "A", + Value: "100.64.0.4", + }, }, SearchDomains: []string{"test.com", "bar.com"}, }, @@ -87,7 +92,11 @@ func TestReadConfig(t *testing.T) { }, ExtraRecords: []tailcfg.DNSRecord{ {Name: "grafana.myvpn.example.com", Type: "A", Value: "100.64.0.3"}, - {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"}, + { + Name: "prometheus.myvpn.example.com", + Type: "A", + Value: "100.64.0.4", + }, }, }, }, @@ -118,10 +127,15 @@ func TestReadConfig(t *testing.T) { "darp.headscale.net": {"1.1.1.1", "8.8.8.8"}, "foo.bar.com": {"1.1.1.1"}, }, + UseWithExitNode: []string{}, }, ExtraRecords: []tailcfg.DNSRecord{ {Name: "grafana.myvpn.example.com", Type: "A", Value: "100.64.0.3"}, - {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"}, + { + Name: "prometheus.myvpn.example.com", + Type: "A", + Value: "100.64.0.4", + }, }, SearchDomains: []string{"test.com", "bar.com"}, }, @@ -153,7 +167,11 @@ func TestReadConfig(t *testing.T) { }, ExtraRecords: []tailcfg.DNSRecord{ {Name: "grafana.myvpn.example.com", Type: "A", Value: "100.64.0.3"}, - {Name: "prometheus.myvpn.example.com", Type: "A", Value: "100.64.0.4"}, + { + Name: "prometheus.myvpn.example.com", + Type: "A", + Value: "100.64.0.4", + }, }, }, }, @@ -220,6 +238,34 @@ func TestReadConfig(t *testing.T) { }, }, }, + { + name: "dns-use-with-exit-node", + configPath: "testdata/dns-use-with-exit-node.yaml", + setup: func(t *testing.T) (any, error) { //nolint:thelper + _, err := LoadServerConfig() + if err != nil { + return nil, err + } + + dns, err := dns() + if err != nil { + return nil, err + } + + return dnsToTailcfgDNS(dns), nil + }, + want: &tailcfg.DNSConfig{ + Proxied: true, + Domains: []string{"derp2.no"}, + Resolvers: []*dnstype.Resolver{ + {Addr: "1.1.1.1", UseWithExitNode: true}, + {Addr: "1.0.0.1"}, + }, + Routes: map[string][]*dnstype.Resolver{ + "foo.bar.com": {{Addr: "8.8.8.8", UseWithExitNode: true}}, + }, + }, + }, { name: "policy-path-is-loaded", configPath: "testdata/policy-path-is-loaded.yaml", @@ -495,7 +541,10 @@ dns: override_local_dns: false oidc:` + tt.oidcBlock + "\n") - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), configYaml, 0o600)) + require.NoError( + t, + os.WriteFile(filepath.Join(tmpDir, "config.yaml"), configYaml, 0o600), + ) require.NoError(t, LoadConfig(tmpDir, false)) err := validateServerConfig() @@ -735,7 +784,11 @@ func TestTrustedProxies(t *testing.T) { require.NoError(t, err) - if diff := cmp.Diff(tt.want, got, cmpopts.EquateComparable(netip.Prefix{})); diff != "" { + if diff := cmp.Diff( + tt.want, + got, + cmpopts.EquateComparable(netip.Prefix{}), + ); diff != "" { t.Errorf("trustedProxies() mismatch (-want +got):\n%s", diff) } }) diff --git a/hscontrol/types/testdata/dns-use-with-exit-node.yaml b/hscontrol/types/testdata/dns-use-with-exit-node.yaml new file mode 100644 index 000000000..79db7c27b --- /dev/null +++ b/hscontrol/types/testdata/dns-use-with-exit-node.yaml @@ -0,0 +1,26 @@ +noise: + private_key_path: "private_key.pem" + +prefixes: + v6: fd7a:115c:a1e0::/48 + v4: 100.64.0.0/10 + +database: + type: sqlite3 + +server_url: "https://server.derp.no" + +dns: + magic_dns: true + base_domain: derp2.no + override_local_dns: true + nameservers: + global: + - 1.1.1.1 + - 1.0.0.1 + split: + foo.bar.com: + - 8.8.8.8 + use_with_exit_node: + - 1.1.1.1 + - 8.8.8.8 From b2dd8e0a95c6d7b9b5352c4fd44ba7329a294a56 Mon Sep 17 00:00:00 2001 From: Nelson Jeppesen <50854675+NelsonJeppesen@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:27:45 -0700 Subject: [PATCH 2/4] integration: test DNS use_with_exit_node survives exit node Add TestExitNodeUseWithExitNodeDNS: with an exit node selected, a resolver listed in dns.nameservers.use_with_exit_node keeps its UseWithExitNode flag in the client netmap, while an unlisted resolver does not. Register it in the SQLite CI matrix via the workflow generator. The client feature needs Tailscale v1.88+, so the scenario pins the head version. --- .github/workflows/test-integration.yaml | 1 + integration/route_test.go | 155 ++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 0e2a03377..afab141b4 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -319,6 +319,7 @@ jobs: - TestExitRoutesWithAutogroupInternetACL - TestSubnetRouterMultiNetwork - TestSubnetRouterMultiNetworkExitNode + - TestExitNodeUseWithExitNodeDNS - TestAutoApproveMultiNetwork/authkey-tag.* - TestAutoApproveMultiNetwork/authkey-user.* - TestAutoApproveMultiNetwork/authkey-group.* diff --git a/integration/route_test.go b/integration/route_test.go index 387a0ae1f..d6faa3fe7 100644 --- a/integration/route_test.go +++ b/integration/route_test.go @@ -27,6 +27,7 @@ import ( "tailscale.com/ipn/ipnstate" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" + "tailscale.com/types/dnstype" "tailscale.com/types/ipproto" "tailscale.com/types/views" "tailscale.com/util/must" @@ -2151,6 +2152,160 @@ func TestSubnetRouterMultiNetworkExitNode(t *testing.T) { }, 10*time.Second, 200*time.Millisecond, "user2 traceroute should go through user1 exit node") } +// TestExitNodeUseWithExitNodeDNS verifies that nameservers listed in +// dns.nameservers.use_with_exit_node keep their UseWithExitNode flag when sent +// to clients, so a client that selects an exit node still resolves via the +// configured resolver instead of having all DNS delegated to the exit node. +// Nameservers not in the list must not carry the flag. See issue #2816. +func TestExitNodeUseWithExitNodeDNS(t *testing.T) { + IntegrationSkip(t) + + // The UseWithExitNode resolver flag requires Tailscale capability version + // 125 (v1.88+); older clients ignore it. Pin to head so the netmap carries + // the flag. + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{"user1", "user2"}, + Versions: []string{"head"}, + } + + scenario, err := NewScenario(spec) + require.NoErrorf(t, err, "failed to create scenario: %s", err) + + defer scenario.ShutdownAssertNoPanics(t) + + // keepResolver is reachable within the tailnet and should keep being used + // when an exit node is selected. dropResolver is configured but not listed, + // so it must not carry the flag. + const ( + keepResolver = "100.64.0.53" + dropResolver = "1.1.1.1" + ) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("rt-exitdns"), + hsic.WithConfigEnv(map[string]string{ + "HEADSCALE_DNS_OVERRIDE_LOCAL_DNS": "true", + "HEADSCALE_DNS_NAMESERVERS_GLOBAL": keepResolver + " " + dropResolver, + "HEADSCALE_DNS_NAMESERVERS_USE_WITH_EXIT_NODE": keepResolver, + }), + ) + requireNoErrHeadscaleEnv(t, err) + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + + err = scenario.WaitForTailscaleSync() + requireNoErrSync(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + assert.NotNil(t, headscale) + + var user1c, user2c TailscaleClient + + for _, c := range allClients { + s := c.MustStatus() + switch s.User[s.Self.UserID].LoginName { + case "user1@test.no": + user1c = c + case "user2@test.no": + user2c = c + } + } + + require.NotNil(t, user1c) + require.NotNil(t, user2c) + + // Advertise the exit node on user1c. + _, _, err = user1c.Execute([]string{ + "tailscale", "set", "--advertise-exit-node", + }) + require.NoErrorf(t, err, "failed to advertise exit node: %s", err) + + // headscale should see the two exit routes announced. + var nodes []*clientv1.Node + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err = headscale.ListNodes() + assert.NoError(c, err) + assert.Len(c, nodes, 2) + requireNodeRouteCountWithCollect(c, nodes[0], 2, 0, 0) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "exit routes should be announced") + + // Approve the exit routes. + _, err = headscale.ApproveRoutes( + mustParseID(nodes[0].Id), + []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}, + ) + require.NoError(t, err) + + // The exit node becomes an option for user2c. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := user2c.Status() + assert.NoError(c, err) + + for _, peerKey := range status.Peers() { + assert.True( + c, + status.Peer[peerKey].ExitNodeOption, + "peer should be an exit node option", + ) + } + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "exit node should be visible to client") + + // user2c selects user1c as its exit node. + _, _, err = user2c.Execute([]string{ + "tailscale", "set", "--exit-node", user1c.Hostname(), + }) + require.NoErrorf(t, err, "failed to set exit node: %s", err) + + // The exit node becomes active. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := user2c.Status() + assert.NoError(c, err) + assert.NotNil(c, status.ExitNodeStatus, "exit node should be active") + }, 30*time.Second, 500*time.Millisecond, "exit node activation") + + // The netmap DNS config carries the UseWithExitNode flag per resolver. The + // listed resolver must keep it; the unlisted one must not, regardless of + // the active exit node. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nm, err := user2c.Netmap() + assert.NoError(c, err) + + var keep, drop *dnstype.Resolver + + for _, r := range nm.DNS.Resolvers { + switch r.Addr { + case keepResolver: + keep = r + case dropResolver: + drop = r + } + } + + if assert.NotNil(c, keep, "resolver %s should be present", keepResolver) { + assert.True( + c, + keep.UseWithExitNode, + "resolver %s should keep UseWithExitNode under an exit node", + keepResolver, + ) + } + + if assert.NotNil(c, drop, "resolver %s should be present", dropResolver) { + assert.False( + c, + drop.UseWithExitNode, + "resolver %s should not have UseWithExitNode set", + dropResolver, + ) + } + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "configured resolver should stay active under exit node") +} + func MustFindNode(hostname string, nodes []*clientv1.Node) *clientv1.Node { for _, node := range nodes { if node.Name == hostname { From 4d85a7b2c107b4465c28c8da268c9456b5b083a5 Mon Sep 17 00:00:00 2001 From: Nelson Jeppesen <50854675+NelsonJeppesen@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:29:58 -0700 Subject: [PATCH 3/4] CHANGELOG: point use_with_exit_node entry at the PR --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 653837ff7..b3d8df962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ keys remain all-access. - Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) - Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341) -- Add `dns.nameservers.use_with_exit_node` to keep selected resolvers active when a client uses an exit node, so a self-hosted resolver can be reached without routing DNS through the exit node (requires Tailscale v1.88+) [#2816](https://github.com/juanfont/headscale/issues/2816) +- Add `dns.nameservers.use_with_exit_node` to keep selected resolvers active when a client uses an exit node, so a self-hosted resolver can be reached without routing DNS through the exit node (requires Tailscale v1.88+) [#3376](https://github.com/juanfont/headscale/pull/3376) ## 0.29.2 (2026-07-01) From 96c49fe02a4145300dd8a49d50a1fb6ee3ea2a5b Mon Sep 17 00:00:00 2001 From: Nelson Jeppesen <50854675+NelsonJeppesen@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:35:44 -0700 Subject: [PATCH 4/4] types: cover split-DNS selectivity for use_with_exit_node Assert a listed split-domain resolver keeps UseWithExitNode while an unlisted split resolver in another domain does not, matching the client's per-resolver useWithExitNodeRoutes filtering. --- hscontrol/types/config_test.go | 3 ++- hscontrol/types/testdata/dns-use-with-exit-node.yaml | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go index 27f28db58..a53f64296 100644 --- a/hscontrol/types/config_test.go +++ b/hscontrol/types/config_test.go @@ -262,7 +262,8 @@ func TestReadConfig(t *testing.T) { {Addr: "1.0.0.1"}, }, Routes: map[string][]*dnstype.Resolver{ - "foo.bar.com": {{Addr: "8.8.8.8", UseWithExitNode: true}}, + "foo.bar.com": {{Addr: "8.8.8.8", UseWithExitNode: true}}, + "baz.example.com": {{Addr: "9.9.9.9"}}, }, }, }, diff --git a/hscontrol/types/testdata/dns-use-with-exit-node.yaml b/hscontrol/types/testdata/dns-use-with-exit-node.yaml index 79db7c27b..1132748f4 100644 --- a/hscontrol/types/testdata/dns-use-with-exit-node.yaml +++ b/hscontrol/types/testdata/dns-use-with-exit-node.yaml @@ -21,6 +21,8 @@ dns: split: foo.bar.com: - 8.8.8.8 + baz.example.com: + - 9.9.9.9 use_with_exit_node: - 1.1.1.1 - 8.8.8.8