From 9c8357ef07126dc7356a421f5e72d502ea962d53 Mon Sep 17 00:00:00 2001 From: 1fexd Date: Sat, 19 Jul 2025 15:12:01 +0200 Subject: [PATCH 1/3] Implement tailscale cert PoC --- config-example.yaml | 6 ++++ go.mod | 2 ++ go.sum | 4 +++ hscontrol/app.go | 13 +++++--- hscontrol/mapper/mapper.go | 11 +++++++ hscontrol/noise.go | 51 +++++++++++++++++++++++++++++ hscontrol/tlscert.go | 66 ++++++++++++++++++++++++++++++++++++++ hscontrol/types/common.go | 1 + hscontrol/types/config.go | 36 +++++++++++++++++++-- 9 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 hscontrol/tlscert.go diff --git a/config-example.yaml b/config-example.yaml index 44f87676..c17862b2 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -272,6 +272,12 @@ dns: # The FQDN of the hosts will be # `hostname.base_domain` (e.g., _myhost.example.com_). base_domain: example.com + tls_cert: + type: hetzner + hetzner: + api_token: "" + zone_id: "" + ttl: 0 # Whether to use the local DNS settings of a node (default) or override the # local DNS settings and force the use of Headscale's DNS configuration. diff --git a/go.mod b/go.mod index 399cc807..f2198ffa 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,8 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.0 github.com/jagottsicher/termcolor v1.0.2 github.com/klauspost/compress v1.18.0 + github.com/libdns/hetzner v1.0.0 + github.com/libdns/libdns v1.1.0 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 diff --git a/go.sum b/go.sum index 3696736b..1be8872a 100644 --- a/go.sum +++ b/go.sum @@ -318,6 +318,10 @@ 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.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/libdns/hetzner v1.0.0 h1:dFcgqTIfdiKQTqoqBBtgU9CewD8JSnB7p6BKxQ5kheM= +github.com/libdns/hetzner v1.0.0/go.mod h1:OmuTyXMHTfy2nCqbt9KYkf0KwQSvo0ZeFGxEQSl3r2w= +github.com/libdns/libdns v1.1.0 h1:9ze/tWvt7Df6sbhOJRB8jT33GHEHpEQXdtkE3hPthbU= +github.com/libdns/libdns v1.1.0/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= diff --git a/hscontrol/app.go b/hscontrol/app.go index bb98f82d..498ec8d0 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -81,10 +81,11 @@ type Headscale struct { DERPServer *derpServer.DERPServer // Things that generate changes - extraRecordMan *dns.ExtraRecordsMan - mapper *mapper.Mapper - nodeNotifier *notifier.Notifier - authProvider AuthProvider + extraRecordMan *dns.ExtraRecordsMan + mapper *mapper.Mapper + nodeNotifier *notifier.Notifier + authProvider AuthProvider + tlsCertProvider TlsCertProvider pollNetMapStreamWG sync.WaitGroup } @@ -194,6 +195,10 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) { } } + if app.cfg.DNSConfig.TlsCert.Type == types.TlsCertHetzner { + app.tlsCertProvider = NewHetznerTlsCertProvider(app.cfg.DNSConfig.TlsCert.Hetzner) + } + if cfg.DERP.ServerEnabled { derpServerKey, err := readOrCreatePrivateKey(cfg.DERP.ServerPrivateKeyPath) if err != nil { diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go index 553658f5..0799cdc6 100644 --- a/hscontrol/mapper/mapper.go +++ b/hscontrol/mapper/mapper.go @@ -126,10 +126,21 @@ func generateDNSConfig( dnsConfig := cfg.TailcfgDNSConfig.Clone() addNextDNSMetadata(dnsConfig.Resolvers, node) + addHostNameToCertDomains(dnsConfig, node) return dnsConfig } +func addHostNameToCertDomains(dnsConfig *tailcfg.DNSConfig, node types.NodeView) { + hostName := strings.ToLower(node.Hostname()) + for i, certDomain := range dnsConfig.CertDomains { + prependedDomain := fmt.Sprintf("%s.%s", hostName, certDomain) + if prependedDomain != certDomain { + dnsConfig.CertDomains[i] = prependedDomain + } + } +} + // If any nextdns DoH resolvers are present in the list of resolvers it will // take metadata from the node metadata and instruct tailscale to add it // to the requests. This makes it possible to identify from which device the diff --git a/hscontrol/noise.go b/hscontrol/noise.go index ec4e4e5b..92a3b092 100644 --- a/hscontrol/noise.go +++ b/hscontrol/noise.go @@ -101,6 +101,8 @@ func (h *Headscale) NoiseUpgradeHandler( router.HandleFunc("/machine/register", noiseServer.NoiseRegistrationHandler). Methods(http.MethodPost) + router.HandleFunc("/machine/set-dns", noiseServer.NoiseSetDnsHandler). + Methods(http.MethodPost) // Endpoints outside of the register endpoint must use getAndValidateNode to // get the node to ensure that the MachineKey matches the Node setting up the // connection. @@ -234,6 +236,55 @@ func regErr(err error) *tailcfg.RegisterResponse { return &tailcfg.RegisterResponse{Error: err.Error()} } +func (ns *noiseServer) NoiseSetDnsHandler( + writer http.ResponseWriter, + req *http.Request, +) { + if req.Method != http.MethodPost { + httpError(writer, errMethodNotAllowed) + + return + } + + setDnsRequest, setDnsResponse := func() (*tailcfg.SetDNSRequest, *tailcfg.SetDNSResponse) { + var resp *tailcfg.SetDNSResponse + + body, err := io.ReadAll(req.Body) + if err != nil { + return &tailcfg.SetDNSRequest{}, &tailcfg.SetDNSResponse{} + } + + var setDnsReq tailcfg.SetDNSRequest + if err := json.Unmarshal(body, &setDnsReq); err != nil { + return &setDnsReq, &tailcfg.SetDNSResponse{} + } + + ns.nodeKey = setDnsReq.NodeKey + + resp, err = ns.headscale.handleSetDns(req.Context(), setDnsReq) + if err != nil { + return &setDnsReq, &tailcfg.SetDNSResponse{} + } + + return &setDnsReq, resp + }() + + // Reject unsupported versions + if rejectUnsupported(writer, setDnsRequest.Version, ns.machineKey, setDnsRequest.NodeKey) { + return + } + + respBody, err := json.Marshal(setDnsResponse) + if err != nil { + httpError(writer, err) + return + } + + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.WriteHeader(http.StatusOK) + writer.Write(respBody) +} + // NoiseRegistrationHandler handles the actual registration process of a node. func (ns *noiseServer) NoiseRegistrationHandler( writer http.ResponseWriter, diff --git a/hscontrol/tlscert.go b/hscontrol/tlscert.go new file mode 100644 index 00000000..c8315489 --- /dev/null +++ b/hscontrol/tlscert.go @@ -0,0 +1,66 @@ +package hscontrol + +import ( + "context" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/libdns/hetzner" + "github.com/libdns/libdns" + "tailscale.com/tailcfg" +) + +type TlsCertProvider interface { + GetRecordSetter() libdns.RecordSetter + GetZoneName() string + CreateRecord(request tailcfg.SetDNSRequest) libdns.Record +} + +func NewHetznerTlsCertProvider(config types.HetznerTlsCertConfig) *TlsCertProviderHetzner { + return &TlsCertProviderHetzner{ + Provider: hetzner.New(config.ApiToken), + ZoneId: config.ZoneId, + ZoneName: config.ZoneName, + Ttl: config.Ttl, + } +} + +type TlsCertProviderHetzner struct { + Provider libdns.RecordSetter + ZoneId string + ZoneName string + Ttl int +} + +func (p *TlsCertProviderHetzner) GetRecordSetter() libdns.RecordSetter { + return p.Provider +} + +func (p *TlsCertProviderHetzner) GetZoneName() string { + return p.ZoneName +} + +func (p *TlsCertProviderHetzner) CreateRecord(request tailcfg.SetDNSRequest) libdns.Record { + return &hetzner.Record{ + ZoneID: p.ZoneId, + Type: request.Type, + Name: request.Name, + Value: request.Value, + TTL: p.Ttl, + } +} + +func (h *Headscale) handleSetDns( + ctx context.Context, + setDnsReq tailcfg.SetDNSRequest, +) (*tailcfg.SetDNSResponse, error) { + zoneName := h.tlsCertProvider.GetZoneName() + recordSetter := h.tlsCertProvider.GetRecordSetter() + libDnsRecord := h.tlsCertProvider.CreateRecord(setDnsReq) + + _, err := recordSetter.SetRecords(ctx, zoneName, []libdns.Record{libDnsRecord}) + if err != nil { + return nil, err + } + + return &tailcfg.SetDNSResponse{}, nil +} diff --git a/hscontrol/types/common.go b/hscontrol/types/common.go index 51e11757..f1ad39be 100644 --- a/hscontrol/types/common.go +++ b/hscontrol/types/common.go @@ -17,6 +17,7 @@ const ( SelfUpdateIdentifier = "self-update" DatabasePostgres = "postgres" DatabaseSqlite = "sqlite3" + TlsCertHetzner = "hetzner" ) var ErrCannotParsePrefix = errors.New("cannot parse prefix") diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 1e35303e..7661f372 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -101,15 +101,29 @@ type Config struct { } type DNSConfig struct { - MagicDNS bool `mapstructure:"magic_dns"` - BaseDomain string `mapstructure:"base_domain"` - OverrideLocalDNS bool `mapstructure:"override_local_dns"` + MagicDNS bool `mapstructure:"magic_dns"` + BaseDomain string `mapstructure:"base_domain"` + TlsCert TlsCertConfig `mapstructure:"tls_cert"` + OverrideLocalDNS bool `mapstructure:"override_local_dns"` Nameservers Nameservers SearchDomains []string `mapstructure:"search_domains"` ExtraRecords []tailcfg.DNSRecord `mapstructure:"extra_records"` ExtraRecordsPath string `mapstructure:"extra_records_path"` } +type TlsCertConfig struct { + // Type sets the tls cert type, one of "hetzner", ... + Type string + Hetzner HetznerTlsCertConfig `mapstructure:"hetzner"` +} + +type HetznerTlsCertConfig struct { + ApiToken string `mapstructure:"api_token"` + ZoneId string `mapstructure:"zone_id"` + ZoneName string `mapstructure:"zone_name"` + Ttl int `mapstructure:"ttl"` +} + type Nameservers struct { Global []string Split map[string][]string @@ -639,6 +653,22 @@ func dns() (DNSConfig, error) { dns.SearchDomains = viper.GetStringSlice("dns.search_domains") dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path") + if viper.IsSet("dns.tls_cert") { + zoneName := dns.BaseDomain + if viper.IsSet("dns.tls_cert.hetzner.zone_name") { + zoneName = viper.GetString("dns.tls_cert.hetzner.zone_name") + } + + dns.TlsCert = TlsCertConfig{ + Hetzner: HetznerTlsCertConfig{ + ApiToken: viper.GetString("dns.tls_cert.hetzner.api_token"), + ZoneId: viper.GetString("dns.tls_cert.hetzner.zone_id"), + ZoneName: zoneName, + Ttl: viper.GetInt("dns.tls_cert.hetzner.ttl"), + }, + } + } + if viper.IsSet("dns.extra_records") { var extraRecords []tailcfg.DNSRecord From 018fdd7a2bb2a222433d1efe78563df751667219 Mon Sep 17 00:00:00 2001 From: 1fexd Date: Sat, 19 Jul 2025 20:42:04 +0200 Subject: [PATCH 2/3] fix: Re-added CertDomains to cfg --- hscontrol/types/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 7661f372..7bae745e 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -779,6 +779,7 @@ func dnsToTailcfgDNS(dns DNSConfig) *tailcfg.DNSConfig { cfg.Routes = routes if dns.BaseDomain != "" { cfg.Domains = []string{dns.BaseDomain} + cfg.CertDomains = cfg.Domains } cfg.Domains = append(cfg.Domains, dns.SearchDomains...) From 16a76c9b96c9c3a5fb06fcce910aebe138ee8d08 Mon Sep 17 00:00:00 2001 From: 1fexd Date: Sat, 19 Jul 2025 21:29:30 +0200 Subject: [PATCH 3/3] fix: Missing config type deserialization --- hscontrol/types/config.go | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 7bae745e..643d55fe 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -113,7 +113,7 @@ type DNSConfig struct { type TlsCertConfig struct { // Type sets the tls cert type, one of "hetzner", ... - Type string + Type string `mapstructure:"type"` Hetzner HetznerTlsCertConfig `mapstructure:"hetzner"` } @@ -654,18 +654,22 @@ func dns() (DNSConfig, error) { dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path") if viper.IsSet("dns.tls_cert") { - zoneName := dns.BaseDomain - if viper.IsSet("dns.tls_cert.hetzner.zone_name") { - zoneName = viper.GetString("dns.tls_cert.hetzner.zone_name") - } + certType := viper.GetString("dns.tls_cert.type") + if certType == TlsCertHetzner { + zoneName := dns.BaseDomain + if viper.IsSet("dns.tls_cert.hetzner.zone_name") { + zoneName = viper.GetString("dns.tls_cert.hetzner.zone_name") + } - dns.TlsCert = TlsCertConfig{ - Hetzner: HetznerTlsCertConfig{ - ApiToken: viper.GetString("dns.tls_cert.hetzner.api_token"), - ZoneId: viper.GetString("dns.tls_cert.hetzner.zone_id"), - ZoneName: zoneName, - Ttl: viper.GetInt("dns.tls_cert.hetzner.ttl"), - }, + dns.TlsCert = TlsCertConfig{ + Type: certType, + Hetzner: HetznerTlsCertConfig{ + ApiToken: viper.GetString("dns.tls_cert.hetzner.api_token"), + ZoneId: viper.GetString("dns.tls_cert.hetzner.zone_id"), + ZoneName: zoneName, + Ttl: viper.GetInt("dns.tls_cert.hetzner.ttl"), + }, + } } }