mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-30 03:49:36 +09:00
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:
committed by
Kristoffer Dalby
parent
740f930523
commit
35a90e0018
+93
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user