diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b4e937..9541c217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ [#2600](https://github.com/juanfont/headscale/pull/2600) - Refactor Debian/Ubuntu packaging and drop support for Ubuntu 20.04. [#2614](https://github.com/juanfont/headscale/pull/2614) +- Support client verify for DERP + [#2046](https://github.com/juanfont/headscale/pull/2046) ## 0.26.0 (2025-05-14) diff --git a/config-example.yaml b/config-example.yaml index b62ca02e..047fb731 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -85,6 +85,9 @@ derp: region_code: "headscale" region_name: "Headscale Embedded DERP" + # Only allow clients associated with this server access + verify_clients: true + # Listens over UDP at the configured address for STUN connections - to help with NAT traversal. # When the embedded DERP server is enabled stun_listen_addr MUST be defined. # diff --git a/hscontrol/app.go b/hscontrol/app.go index d62acb34..6dddc311 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -226,6 +226,14 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) { ) } + if cfg.DERP.ServerVerifyClients { + t := http.DefaultTransport.(*http.Transport) //nolint:forcetypeassert + t.RegisterProtocol( + derpServer.DerpVerifyScheme, + derpServer.NewDERPVerifyTransport(app.handleVerifyRequest), + ) + } + embeddedDERPServer, err := derpServer.NewDERPServer( cfg.ServerURL, key.NodePrivate(*derpServerKey), diff --git a/hscontrol/derp/server/derp_server.go b/hscontrol/derp/server/derp_server.go index 0c97806f..ae7bf03e 100644 --- a/hscontrol/derp/server/derp_server.go +++ b/hscontrol/derp/server/derp_server.go @@ -2,9 +2,11 @@ package server import ( "bufio" + "bytes" "context" "encoding/json" "fmt" + "io" "net" "net/http" "net/netip" @@ -28,7 +30,10 @@ import ( // server that the DERP HTTP client does not want the HTTP 101 response // headers and it will begin writing & reading the DERP protocol immediately // following its HTTP request. -const fastStartHeader = "Derp-Fast-Start" +const ( + fastStartHeader = "Derp-Fast-Start" + DerpVerifyScheme = "headscale-derp-verify" +) type DERPServer struct { serverURL string @@ -45,6 +50,11 @@ func NewDERPServer( log.Trace().Caller().Msg("Creating new embedded DERP server") server := derp.NewServer(derpKey, util.TSLogfWrapper()) // nolint // zerolinter complains + if cfg.ServerVerifyClients { + server.SetVerifyClientURL(DerpVerifyScheme + "://verify") + server.SetVerifyClientURLFailOpen(false) + } + return &DERPServer{ serverURL: serverURL, key: derpKey, @@ -360,3 +370,29 @@ func serverSTUNListener(ctx context.Context, packetConn *net.UDPConn) { } } } + +func NewDERPVerifyTransport(handleVerifyRequest func(*http.Request, io.Writer) error) *DERPVerifyTransport { + return &DERPVerifyTransport{ + handleVerifyRequest: handleVerifyRequest, + } +} + +type DERPVerifyTransport struct { + handleVerifyRequest func(*http.Request, io.Writer) error +} + +func (t *DERPVerifyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + buf := new(bytes.Buffer) + if err := t.handleVerifyRequest(req, buf); err != nil { + log.Error().Caller().Err(err).Msg("Failed to handle client verify request: ") + + return nil, err + } + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(buf), + } + + return resp, nil +} diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go index e55fce49..602dae81 100644 --- a/hscontrol/handlers.go +++ b/hscontrol/handlers.go @@ -81,28 +81,33 @@ func parseCabailityVersion(req *http.Request) (tailcfg.CapabilityVersion, error) return tailcfg.CapabilityVersion(clientCapabilityVersion), nil } -func (h *Headscale) derpRequestIsAllowed( +func (h *Headscale) handleVerifyRequest( req *http.Request, -) (bool, error) { + writer io.Writer, +) error { body, err := io.ReadAll(req.Body) if err != nil { - return false, fmt.Errorf("cannot read request body: %w", err) + return fmt.Errorf("cannot read request body: %w", err) } var derpAdmitClientRequest tailcfg.DERPAdmitClientRequest if err := json.Unmarshal(body, &derpAdmitClientRequest); err != nil { - return false, fmt.Errorf("cannot parse derpAdmitClientRequest: %w", err) + return fmt.Errorf("cannot parse derpAdmitClientRequest: %w", err) } nodes, err := h.db.ListNodes() if err != nil { - return false, fmt.Errorf("cannot list nodes: %w", err) + return fmt.Errorf("cannot list nodes: %w", err) } - return nodes.ContainsNodeKey(derpAdmitClientRequest.NodePublic), nil + resp := &tailcfg.DERPAdmitClientResponse{ + Allow: nodes.ContainsNodeKey(derpAdmitClientRequest.NodePublic), + } + return json.NewEncoder(writer).Encode(resp) } -// see https://github.com/tailscale/tailscale/blob/964282d34f06ecc06ce644769c66b0b31d118340/derp/derp_server.go#L1159, Derp use verifyClientsURL to verify whether a client is allowed to connect to the DERP server. +// VerifyHandler see https://github.com/tailscale/tailscale/blob/964282d34f06ecc06ce644769c66b0b31d118340/derp/derp_server.go#L1159 +// DERP use verifyClientsURL to verify whether a client is allowed to connect to the DERP server. func (h *Headscale) VerifyHandler( writer http.ResponseWriter, req *http.Request, @@ -112,18 +117,12 @@ func (h *Headscale) VerifyHandler( return } - allow, err := h.derpRequestIsAllowed(req) + err := h.handleVerifyRequest(req, writer) if err != nil { httpError(writer, err) return } - - resp := tailcfg.DERPAdmitClientResponse{ - Allow: allow, - } - writer.Header().Set("Content-Type", "application/json") - json.NewEncoder(writer).Encode(resp) } // KeyHandler provides the Headscale pub key diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index a0fcfd45..09e6f818 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -194,6 +194,7 @@ type DERPConfig struct { ServerRegionCode string ServerRegionName string ServerPrivateKeyPath string + ServerVerifyClients bool STUNAddr string URLs []url.URL Paths []string @@ -458,6 +459,7 @@ func derpConfig() DERPConfig { serverRegionID := viper.GetInt("derp.server.region_id") serverRegionCode := viper.GetString("derp.server.region_code") serverRegionName := viper.GetString("derp.server.region_name") + serverVerifyClients := viper.GetBool("derp.server.verify_clients") stunAddr := viper.GetString("derp.server.stun_listen_addr") privateKeyPath := util.AbsolutePathFromConfigPath( viper.GetString("derp.server.private_key_path"), @@ -502,6 +504,7 @@ func derpConfig() DERPConfig { ServerRegionID: serverRegionID, ServerRegionCode: serverRegionCode, ServerRegionName: serverRegionName, + ServerVerifyClients: serverVerifyClients, ServerPrivateKeyPath: privateKeyPath, STUNAddr: stunAddr, URLs: urls, diff --git a/integration/derp_verify_endpoint_test.go b/integration/derp_verify_endpoint_test.go index 20ed4872..23879d56 100644 --- a/integration/derp_verify_endpoint_test.go +++ b/integration/derp_verify_endpoint_test.go @@ -1,11 +1,10 @@ package integration import ( - "encoding/json" + "context" "fmt" "net" "strconv" - "strings" "testing" "github.com/juanfont/headscale/hscontrol/util" @@ -13,7 +12,11 @@ import ( "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/integrationutil" "github.com/juanfont/headscale/integration/tsic" + "tailscale.com/derp" + "tailscale.com/derp/derphttp" + "tailscale.com/net/netmon" "tailscale.com/tailcfg" + "tailscale.com/types/key" ) func TestDERPVerifyEndpoint(t *testing.T) { @@ -46,23 +49,24 @@ func TestDERPVerifyEndpoint(t *testing.T) { ) assertNoErr(t, err) + derpRegion := tailcfg.DERPRegion{ + RegionCode: "test-derpverify", + RegionName: "TestDerpVerify", + Nodes: []*tailcfg.DERPNode{ + { + Name: "TestDerpVerify", + RegionID: 900, + HostName: derper.GetHostname(), + STUNPort: derper.GetSTUNPort(), + STUNOnly: false, + DERPPort: derper.GetDERPPort(), + InsecureForTests: true, + }, + }, + } derpMap := tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ - 900: { - RegionID: 900, - RegionCode: "test-derpverify", - RegionName: "TestDerpVerify", - Nodes: []*tailcfg.DERPNode{ - { - Name: "TestDerpVerify", - RegionID: 900, - HostName: derper.GetHostname(), - STUNPort: derper.GetSTUNPort(), - STUNOnly: false, - DERPPort: derper.GetDERPPort(), - }, - }, - }, + 900: &derpRegion, }, } @@ -76,21 +80,42 @@ func TestDERPVerifyEndpoint(t *testing.T) { allClients, err := scenario.ListTailscaleClients() assertNoErrListClients(t, err) - for _, client := range allClients { - report, err := client.DebugDERPRegion("test-derpverify") - assertNoErr(t, err) - successful := false - for _, line := range report.Info { - if strings.Contains(line, "Successfully established a DERP connection with node") { - successful = true + fakeKey := key.NewNode() + DERPVerify(t, fakeKey, derpRegion, false) - break - } - } - if !successful { - stJSON, err := json.Marshal(report) - assertNoErr(t, err) - t.Errorf("Client %s could not establish a DERP connection: %s", client.Hostname(), string(stJSON)) - } + for _, client := range allClients { + nodeKey, err := client.GetNodePrivateKey() + assertNoErr(t, err) + DERPVerify(t, *nodeKey, derpRegion, true) + } +} + +func DERPVerify( + t *testing.T, + nodeKey key.NodePrivate, + region tailcfg.DERPRegion, + expectSuccess bool, +) { + t.Helper() + + c := derphttp.NewRegionClient(nodeKey, t.Logf, netmon.NewStatic(), func() *tailcfg.DERPRegion { + return ®ion + }) + defer c.Close() + + var result error + if err := c.Connect(context.Background()); err != nil { + result = fmt.Errorf("client Connect: %w", err) + } + if m, err := c.Recv(); err != nil { + result = fmt.Errorf("client first Recv: %w", err) + } else if v, ok := m.(derp.ServerInfoMessage); !ok { + result = fmt.Errorf("client first Recv was unexpected type %T", v) + } + + if expectSuccess && result != nil { + t.Fatalf("DERP verify failed unexpectedly for client %s. Expected success but got error: %v", nodeKey.Public(), result) + } else if !expectSuccess && result == nil { + t.Fatalf("DERP verify succeeded unexpectedly for client %s. Expected failure but it succeeded.", nodeKey.Public()) } } diff --git a/integration/embedded_derp_test.go b/integration/embedded_derp_test.go index 0d930186..ca4e8a14 100644 --- a/integration/embedded_derp_test.go +++ b/integration/embedded_derp_test.go @@ -2,6 +2,8 @@ package integration import ( "strings" + "tailscale.com/tailcfg" + "tailscale.com/types/key" "testing" "time" @@ -39,6 +41,28 @@ func TestDERPServerScenario(t *testing.T) { t.Fail() } } + + hsServer, err := scenario.Headscale() + assertNoErrGetHeadscale(t, err) + + derpRegion := tailcfg.DERPRegion{ + RegionCode: "test-derpverify", + RegionName: "TestDerpVerify", + Nodes: []*tailcfg.DERPNode{ + { + Name: "TestDerpVerify", + RegionID: 900, + HostName: hsServer.GetHostname(), + STUNPort: 3478, + STUNOnly: false, + DERPPort: 443, + InsecureForTests: true, + }, + }, + } + + fakeKey := key.NewNode() + DERPVerify(t, fakeKey, derpRegion, false) }) } @@ -99,9 +123,10 @@ func derpServerScenario( hsic.WithPort(443), hsic.WithTLS(), hsic.WithConfigEnv(map[string]string{ - "HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "true", - "HEADSCALE_DERP_UPDATE_FREQUENCY": "10s", - "HEADSCALE_LISTEN_ADDR": "0.0.0.0:443", + "HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "true", + "HEADSCALE_DERP_UPDATE_FREQUENCY": "10s", + "HEADSCALE_LISTEN_ADDR": "0.0.0.0:443", + "HEADSCALE_DERP_SERVER_VERIFY_CLIENTS": "true", }), ) assertNoErrHeadscaleEnv(t, err) diff --git a/integration/tailscale.go b/integration/tailscale.go index 94b08364..e8a93b45 100644 --- a/integration/tailscale.go +++ b/integration/tailscale.go @@ -11,6 +11,7 @@ import ( "github.com/juanfont/headscale/integration/tsic" "tailscale.com/ipn/ipnstate" "tailscale.com/net/netcheck" + "tailscale.com/types/key" "tailscale.com/types/netmap" ) @@ -37,6 +38,7 @@ type TailscaleClient interface { MustStatus() *ipnstate.Status Netmap() (*netmap.NetworkMap, error) DebugDERPRegion(region string) (*ipnstate.DebugDERPRegionReport, error) + GetNodePrivateKey() (*key.NodePrivate, error) Netcheck() (*netcheck.Report, error) WaitForNeedsLogin() error WaitForRunning() error diff --git a/integration/tsic/tsic.go b/integration/tsic/tsic.go index 57770d41..28de2527 100644 --- a/integration/tsic/tsic.go +++ b/integration/tsic/tsic.go @@ -26,7 +26,10 @@ import ( "github.com/ory/dockertest/v3/docker" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" + "tailscale.com/ipn/store/mem" "tailscale.com/net/netcheck" + "tailscale.com/paths" + "tailscale.com/types/key" "tailscale.com/types/netmap" ) @@ -1228,3 +1231,29 @@ func (t *TailscaleInContainer) ReadFile(path string) ([]byte, error) { return out.Bytes(), nil } + +func (t *TailscaleInContainer) GetNodePrivateKey() (*key.NodePrivate, error) { + state, err := t.ReadFile(paths.DefaultTailscaledStateFile()) + if err != nil { + return nil, fmt.Errorf("failed to read state file: %w", err) + } + store := &mem.Store{} + if err = store.LoadFromJSON(state); err != nil { + return nil, fmt.Errorf("failed to unmarshal state file: %w", err) + } + + currentProfileKey, err := store.ReadState(ipn.CurrentProfileStateKey) + if err != nil { + return nil, fmt.Errorf("failed to read current profile state key: %w", err) + } + currentProfile, err := store.ReadState(ipn.StateKey(currentProfileKey)) + if err != nil { + return nil, fmt.Errorf("failed to read current profile state: %w", err) + } + + p := &ipn.Prefs{} + if err = json.Unmarshal(currentProfile, &p); err != nil { + return nil, fmt.Errorf("failed to unmarshal current profile state: %w", err) + } + return &p.Persist.PrivateNodeKey, nil +}