Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@

**Minimum supported Tailscale client version: v1.xx.0**

### Tailscale Serve HTTPS certificates

Headscale can now advertise per-node certificate domains and handle the
`/machine/set-dns` endpoint used by `tailscale cert` and `tailscale serve
--https`. Nodes publish ACME DNS-01 TXT challenges through Headscale, which
validates the request belongs to the authenticated node and writes the TXT RRset
through a registered `libdns` provider.

Headscale does not run an authoritative DNS server for this feature and does
not store issued certificates or private keys. A concrete `libdns` provider
must be registered in the Headscale build and configured under
`dns.certificates`.

## 0.29.0 (2026-06-17)

**Minimum supported Tailscale client version: v1.80.0**
Expand Down
22 changes: 22 additions & 0 deletions config-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,28 @@ dns:
# `hostname.base_domain` (e.g., _myhost.example.com_).
base_domain: example.com

# Per-node HTTPS certificates for `tailscale cert` and
# `tailscale serve --https`.
#
# When enabled, Headscale advertises each node's FQDN in DNSConfig.CertDomains
# and accepts ACME DNS-01 TXT updates from authenticated nodes on
# /machine/set-dns. The TXT records are published through a registered libdns
# provider; Headscale does not run an authoritative DNS server for this.
certificates:
enabled: false
# Name of a registered libdns provider factory.
provider: ""
# DNS zone passed to libdns. Empty defaults to dns.base_domain.
zone: ""
# TXT record TTL. Empty or 0 uses the provider default.
ttl: 0s
# Optional delay after libdns accepts the record, useful for eventually
# consistent DNS provider APIs.
propagation_wait: 0s
# Provider-specific settings passed unchanged to the registered provider
# factory. Exact keys depend on the provider package used in your build.
provider_config: {}

# Whether to use the local DNS settings of a node or override the local DNS
# settings (default) and force the use of Headscale's DNS configuration.
override_local_dns: true
Expand Down
4 changes: 3 additions & 1 deletion docs/about/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,7 @@ provides on overview of Headscale's feature and compatibility with the Tailscale
- [x] Update user profile from identity provider
- [ ] OIDC groups cannot be used in ACLs
- [ ] [Funnel](https://tailscale.com/docs/features/tailscale-funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040))
- [ ] [Serve](https://tailscale.com/docs/features/tailscale-serve) ([#1234](https://github.com/juanfont/headscale/issues/1921))
- [x] [Serve](https://tailscale.com/docs/features/tailscale-serve) ([#1921](https://github.com/juanfont/headscale/issues/1921))
- [x] HTTP Serve with MagicDNS
- [x] [HTTPS Serve and `tailscale cert`](../ref/serve.md) with `dns.certificates` and a registered `libdns` provider
- [ ] [Network flow logs](https://tailscale.com/docs/features/logging/network-flow-logs) ([#1687](https://github.com/juanfont/headscale/issues/1687))
104 changes: 104 additions & 0 deletions docs/ref/serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Tailscale Serve and certificates

[Tailscale Serve](https://tailscale.com/docs/features/tailscale-serve) is a
client-side feature: the Serve configuration and TLS private keys live on the
node, not on Headscale.

Plain HTTP Serve works with MagicDNS. HTTPS Serve and `tailscale cert` require
Headscale to advertise the node's MagicDNS name as certificate-eligible and to
publish ACME DNS-01 TXT challenge records for the node.

## Certificate flow

1. Headscale advertises the node's FQDN in `DNSConfig.CertDomains`.
This is the client signal that avoids:

```console
HTTPS cert support is not enabled/configured for your tailnet.
```

2. The node runs `tailscale cert <node>.<base_domain>` or
`tailscale serve --https`.
3. The node starts an ACME order locally. Headscale never sees the ACME account
key, certificate private key, or issued certificate.
4. The node calls Headscale's Noise endpoint `/machine/set-dns` with a
`TXT` record for `_acme-challenge.<node>.<base_domain>`.
5. Headscale validates that the Noise machine key owns the node key and that
the requested TXT name belongs to that node's own FQDN.
6. Headscale publishes the TXT RRset through the configured `libdns`
`RecordSetter`.
7. The client asks the ACME CA to validate DNS-01 and stores the issued
certificate locally.

## Configuration

```yaml
dns:
magic_dns: true
base_domain: example.com

certificates:
enabled: true
provider: "provider-name"
zone: "example.com"
ttl: 2m
propagation_wait: 10s
provider_config:
token: "provider-api-token"
```

`dns.certificates.enabled` requires:

- `dns.magic_dns: true`
- `dns.base_domain` set
- `dns.certificates.provider` set
- a Headscale build that registers a matching `libdns` provider factory

`zone` defaults to `dns.base_domain` when empty. `provider_config` is passed
unchanged to the registered provider factory and is omitted from JSON debug
configuration output because it commonly contains API credentials.

## Provider registration

Headscale's core implementation is provider-neutral. It imports only
`github.com/libdns/libdns`; it does not import Cloudflare, Hetzner, Route53, or
any other provider package directly.

A concrete provider package must be included in the build and register a
factory with:

```go
hscontrol.RegisterDNSCertificateProvider("provider-name", func(config map[string]string) (libdns.RecordSetter, error) {
// Construct and return the provider-specific libdns RecordSetter.
})
```

The factory receives `dns.certificates.provider_config`.

## Security properties

- Nodes can only publish `TXT` records.
- Nodes can only publish `_acme-challenge.<their-node-fqdn>`.
- The Noise machine key must match the node key in the request.
- Concurrent TXT values for the same challenge name are preserved in the RRset
so overlapping ACME validations do not replace each other.
- Challenge values are retained in memory only for a short period and then
dropped from Headscale's local RRset cache.

## Usage

After enabling the config and restarting Headscale, clients need a fresh netmap
containing `DNSConfig.CertDomains`. This normally happens automatically; if a
client still says certificate support is not enabled, restart or reconnect the
client and check that the node's map response contains its FQDN in
`CertDomains`.

Then run:

```console
tailscale cert <node>.<base_domain>
tailscale serve --https=443 localhost:8080
```

Funnel remains unsupported because it depends on Tailscale's public ingress
infrastructure.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jagottsicher/termcolor v1.0.2
github.com/libdns/libdns v1.1.1
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
github.com/ory/dockertest/v3 v3.12.0
github.com/philip-bui/grpc-zerolog v1.0.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
Expand Down
8 changes: 8 additions & 0 deletions hscontrol/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ type Headscale struct {
extraRecordMan *dns.ExtraRecordsMan
authProvider AuthProvider
mapBatcher *mapper.Batcher
dnsCertManager *dnsCertificateManager

clientStreamsOpen sync.WaitGroup
}
Expand Down Expand Up @@ -237,6 +238,13 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
}
}

if cfg.DNSConfig.Certificates.Enabled {
app.dnsCertManager, err = newDNSCertificateManager(cfg.DNSConfig.Certificates)
if err != nil {
return nil, fmt.Errorf("creating DNS certificate manager: %w", err)
}
}

if cfg.DERP.ServerEnabled {
derpServerKey, err := readOrCreatePrivateKey(cfg.DERP.ServerPrivateKeyPath)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions hscontrol/mapper/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@ func generateDNSConfig(
}
}

if cfg.DNSConfig.Certificates.Enabled {
if fqdn, err := node.GetFQDN(cfg.BaseDomain); err == nil {
dnsConfig.CertDomains = []string{strings.TrimSuffix(fqdn, ".")}
}
}

return dnsConfig
}

Expand Down
41 changes: 41 additions & 0 deletions hscontrol/mapper/mapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,47 @@ func TestNextDNSCapMapRendering(t *testing.T) {
})
}

func TestGenerateDNSConfigCertDomains(t *testing.T) {
t.Parallel()

node := (&types.Node{
ID: 1,
Hostname: "node1",
GivenName: "node1",
IPv4: iap("100.64.0.1"),
Hostinfo: &tailcfg.Hostinfo{OS: "linux"},
}).View()

mkConfig := func(enabled bool) *types.Config {
return &types.Config{
BaseDomain: "tailnet.example.com",
DNSConfig: types.DNSConfig{
Certificates: types.DNSCertificatesConfig{
Enabled: enabled,
},
},
TailcfgDNSConfig: &tailcfg.DNSConfig{
Domains: []string{"tailnet.example.com"},
Proxied: true,
},
}
}

t.Run("enabled", func(t *testing.T) {
t.Parallel()

got := generateDNSConfig(mkConfig(true), node, nil)
require.Equal(t, []string{"node1.tailnet.example.com"}, got.CertDomains)
})

t.Run("disabled", func(t *testing.T) {
t.Parallel()

got := generateDNSConfig(mkConfig(false), node, nil)
require.Empty(t, got.CertDomains)
})
}

// TestBuildFromChangeFiltersPeerPatchesByVisibility proves that incremental
// peer-change patches (online/offline, endpoint, key-expiry) are restricted to
// the recipient's ACL-visible peer set, the same way buildTailPeers filters
Expand Down
64 changes: 57 additions & 7 deletions hscontrol/noise.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,17 +172,15 @@ func (h *Headscale) NoiseUpgradeHandler(
// SSH Check mode endpoint, consulted to validate if a given SSH connection should be accepted or rejected.
r.Get("/ssh/action/{src_node_id}/to/{dst_node_id}", ns.SSHActionHandler)

// Not implemented yet
//
// /whoami is a debug endpoint to validate that the client can communicate over the connection,
// not clear if there is a specific response, it looks like it is just logged.
// https://github.com/tailscale/tailscale/blob/dfba01ca9bd8c4df02c3c32f400d9aeb897c5fc7/cmd/tailscale/cli/debug.go#L1138
r.Get("/whoami", ns.NotImplementedHandler)

// client sends a [tailcfg.SetDNSRequest] to this endpoints and expect
// client sends a [tailcfg.SetDNSRequest] to this endpoint and expects
// the server to create or update this DNS record "somewhere".
// It is typically a TXT record for an ACME challenge.
r.Post("/set-dns", ns.NotImplementedHandler)
r.Post("/set-dns", ns.SetDNSHandler)

// A patch of [tailcfg.SetDeviceAttributesRequest] to update device attributes.
// We currently do not support device attributes.
Expand Down Expand Up @@ -706,7 +704,7 @@ func (ns *noiseServer) PollNetMapHandler(
return
}

nv, err := ns.getAndValidateNode(mapRequest)
nv, err := ns.getAndValidateNode(mapRequest.NodeKey)
if err != nil {
httpError(writer, err)
return
Expand Down Expand Up @@ -785,8 +783,8 @@ func (ns *noiseServer) RegistrationHandler(

// getAndValidateNode retrieves the node from the database using the NodeKey
// and validates that it matches the MachineKey from the Noise session.
func (ns *noiseServer) getAndValidateNode(mapRequest tailcfg.MapRequest) (types.NodeView, error) {
nv, ok := ns.headscale.state.GetNodeByNodeKey(mapRequest.NodeKey)
func (ns *noiseServer) getAndValidateNode(nodeKey key.NodePublic) (types.NodeView, error) {
nv, ok := ns.headscale.state.GetNodeByNodeKey(nodeKey)
if !ok {
return types.NodeView{}, NewHTTPError(http.StatusNotFound, "node not found", nil)
}
Expand All @@ -798,3 +796,55 @@ func (ns *noiseServer) getAndValidateNode(mapRequest tailcfg.MapRequest) (types.

return nv, nil
}

// SetDNSHandler handles ACME DNS-01 TXT updates from Tailscale clients for
// `tailscale cert` and `tailscale serve --https`.
func (ns *noiseServer) SetDNSHandler(writer http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
httpError(writer, errMethodNotAllowed)

return
}

if ns.headscale.dnsCertManager == nil {
httpError(writer, NewHTTPError(http.StatusNotImplemented, "DNS certificates are not enabled", nil))

return
}

var dnsReq tailcfg.SetDNSRequest
if err := json.NewDecoder(req.Body).Decode(&dnsReq); err != nil {
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid SetDNSRequest body", err))

return
}

if rejectUnsupported(writer, dnsReq.Version, ns.machineKey, dnsReq.NodeKey) {
return
}

nv, err := ns.getAndValidateNode(dnsReq.NodeKey)
if err != nil {
httpError(writer, err)

return
}

if err := ns.headscale.dnsCertManager.setDNS(
req.Context(),
nv,
ns.headscale.cfg.BaseDomain,
dnsReq,
); err != nil {
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid DNS record update", err))

return
}

writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(http.StatusOK)

if err := json.NewEncoder(writer).Encode(tailcfg.SetDNSResponse{}); err != nil {
log.Error().Caller().Err(err).Msg("noise set-dns handler: failed to encode SetDNSResponse")
}
}
Loading