diff --git a/hscontrol/app.go b/hscontrol/app.go index 205026b9..a44ac5af 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -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] = ®ion } @@ -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] = ®ion } @@ -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) diff --git a/hscontrol/derp/server/derp_server.go b/hscontrol/derp/server/derp_server.go index f75a085e..fbb422c9 100644 --- a/hscontrol/derp/server/derp_server.go +++ b/hscontrol/derp/server/derp_server.go @@ -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, }, }, } diff --git a/hscontrol/derp/server/derp_server_test.go b/hscontrol/derp/server/derp_server_test.go new file mode 100644 index 00000000..d85bea71 --- /dev/null +++ b/hscontrol/derp/server/derp_server_test.go @@ -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) + }) + } +}