derp: serve a self-signed TLS listener for tests

HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR serves the router over throwaway TLS
and marks the embedded region InsecureForTests: TLS DERP for CA-less clients
(tailscale-rs, Android), plus the :443 noise fallback Go redials use.
This commit is contained in:
Kristoffer Dalby
2026-09-28 13:36:20 +00:00
committed by Kristoffer Dalby
parent 740f930523
commit 35a90e0018
3 changed files with 182 additions and 2 deletions
+93 -2
View File
@@ -3,10 +3,16 @@ package hscontrol
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"os"
@@ -377,7 +383,11 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
}
if h.cfg.DERP.ServerEnabled && h.cfg.DERP.AutomaticallyAddEmbeddedDerpRegion {
region, _ := h.DERPServer.GenerateRegion()
region, err := h.DERPServer.GenerateRegion()
if err != nil {
return nil, fmt.Errorf("generating embedded DERP region: %w", err)
}
derpMap.Regions[region.RegionID] = &region
}
@@ -564,7 +574,11 @@ func (h *Headscale) Serve() error {
}
if h.cfg.DERP.ServerEnabled && h.cfg.DERP.AutomaticallyAddEmbeddedDerpRegion {
region, _ := h.DERPServer.GenerateRegion()
region, err := h.DERPServer.GenerateRegion()
if err != nil {
return fmt.Errorf("generating embedded DERP region: %w", err)
}
derpMap.Regions[region.RegionID] = &region
}
@@ -726,6 +740,40 @@ func (h *Headscale) Serve() error {
log.Info().
Msgf("listening and serving HTTP on: %s", h.cfg.Addr)
var (
insecureTLSServer *http.Server
insecureTLSListener net.Listener
)
if addr := derpServer.DebugInsecureTLSListenAddr(); addr != "" {
insecureTLSConfig, err := selfSignedTLSConfig()
if err != nil {
return fmt.Errorf("creating self-signed TLS certificate: %w", err)
}
insecureTLSListener, err = tls.Listen("tcp", addr, insecureTLSConfig)
if err != nil {
return &types.ListenerBindError{
Listener: "insecure TLS",
YAMLKey: "HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR",
Addr: addr,
Err: err,
}
}
insecureTLSServer = &http.Server{
Handler: router,
ReadTimeout: types.HTTPTimeout,
WriteTimeout: types.HTTPTimeout,
}
errorGroup.Go(func() error { return insecureTLSServer.Serve(insecureTLSListener) })
log.Warn().
Str("addr", addr).
Msg("serving TLS with a self-signed certificate (HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR); for tests only")
}
if tlsBundle.ACMEServer != nil {
log.Info().Msgf(
"listening and serving ACME HTTP-01 challenge on: %s",
@@ -854,6 +902,15 @@ func (h *Headscale) Serve() error {
log.Error().Err(err).Msg("failed to shutdown http")
}
if insecureTLSServer != nil {
info("shutting down insecure TLS server")
err := insecureTLSServer.Shutdown(shutdownCtx)
if err != nil {
log.Error().Err(err).Msg("failed to shutdown insecure TLS server")
}
}
if tlsBundle.ACMEServer != nil {
info("shutting down ACME HTTP-01 challenge server")
@@ -891,6 +948,10 @@ func (h *Headscale) Serve() error {
httpListener.Close()
if insecureTLSListener != nil {
insecureTLSListener.Close()
}
// Stop listening (and unlink the socket if unix type):
info("closing socket listener")
socketListener.Close()
@@ -1011,6 +1072,36 @@ func (h *Headscale) getTLSSettings(ctx context.Context) (*tlsBundle, error) {
return &tlsBundle{Config: tlsConfig}, nil
}
// selfSignedTLSConfig returns a TLS config with a fresh in-memory certificate.
// Its only clients skip verification (DERP InsecureForTests, and the noise
// dialer, which authenticates the server itself), so the subject and validity
// are arbitrary.
func selfSignedTLSConfig() (*tls.Config, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
now := time.Now()
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "headscale"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(10, 0, 0),
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
return nil, err
}
return &tls.Config{
NextProtos: []string{"http/1.1"},
Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: priv}},
MinVersion: tls.VersionTLS12,
}, nil
}
func readOrCreatePrivateKey(path string) (*key.MachinePrivate, error) {
dir := filepath.Dir(path)
+29
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@@ -42,6 +43,16 @@ const (
// This is useful for integration testing where DNS resolution may be unreliable.
var debugUseDERPIP = envknob.Bool("HEADSCALE_DEBUG_DERP_USE_IP")
// DebugInsecureTLSListenAddr makes headscale also serve on this address over
// TLS with a throwaway self-signed certificate, advertised as the embedded
// DERP node with InsecureForTests. Test harnesses (nix/testkit.nix) need it:
// clients that cannot be handed a CA (tailscale-rs, Android) still get TLS
// DERP, and Go clients get the :443 noise fallback they switch to after a
// recent dial.
var DebugInsecureTLSListenAddr = envknob.RegisterString("HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR")
var errInsecureTLSPortZero = errors.New("HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR needs a fixed port: DERP clients cannot follow :0")
type DERPServer struct {
serverURL string
key key.NodePrivate
@@ -95,6 +106,22 @@ func (d *DERPServer) GenerateRegion() (tailcfg.DERPRegion, error) {
}
}
insecure := false
if addr := DebugInsecureTLSListenAddr(); addr != "" {
port, err = types.PortFromAddr(addr)
if err != nil {
return tailcfg.DERPRegion{}, fmt.Errorf("parsing HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR: %w", err)
}
// Clients read DERPPort 0 as 443, not as the random port :0 binds.
if port == 0 {
return tailcfg.DERPRegion{}, errInsecureTLSPortZero
}
insecure = true
}
// If debug flag is set, resolve hostname to IP address
if debugUseDERPIP {
ips, err := new(net.Resolver).LookupIPAddr(context.Background(), host)
@@ -120,6 +147,8 @@ func (d *DERPServer) GenerateRegion() (tailcfg.DERPRegion, error) {
DERPPort: port,
IPv4: d.cfg.IPv4,
IPv6: d.cfg.IPv6,
InsecureForTests: insecure,
},
},
}
+60
View File
@@ -0,0 +1,60 @@
package server
import (
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tailscale.com/envknob"
)
// TestGenerateRegionInsecureTLS pins the contract nix/testkit.nix relies on:
// with the knob set, the embedded node advertises the TLS listener's port and
// InsecureForTests, so clients without the self-signed cert can relay.
func TestGenerateRegionInsecureTLS(t *testing.T) {
tests := []struct {
name string
knob string
wantPort int
wantInsecure bool
wantErr bool
}{
{name: "unset keeps server_url port", knob: "", wantPort: 80},
{name: "set advertises TLS port", knob: "[::]:443", wantPort: 443, wantInsecure: true},
{name: "named port", knob: ":https", wantPort: 443, wantInsecure: true},
{name: "random port is refused", knob: ":0", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
envknob.Setenv("HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR", tt.knob)
t.Cleanup(func() { envknob.Setenv("HEADSCALE_DEBUG_INSECURE_TLS_LISTEN_ADDR", "") })
d := &DERPServer{
serverURL: "http://headscale",
cfg: &types.DERPConfig{
ServerRegionID: 999,
ServerRegionCode: "headscale",
STUNAddr: "[::]:3478",
},
}
region, err := d.GenerateRegion()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Len(t, region.Nodes, 1)
node := region.Nodes[0]
assert.Equal(t, "headscale", node.HostName)
assert.Equal(t, tt.wantPort, node.DERPPort)
assert.Equal(t, tt.wantInsecure, node.InsecureForTests)
assert.Equal(t, 3478, node.STUNPort)
})
}
}