Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add verify client config for embedded DERP #2260

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions config-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ derp:
region_code: "headscale"
region_name: "Headscale Embedded DERP"

# Verify clients to this DERP server using the Headscale node list
verify_clients: true
seiuneko marked this conversation as resolved.
Show resolved Hide resolved

# 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.
#
Expand Down
8 changes: 8 additions & 0 deletions hscontrol/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,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),
Expand Down
41 changes: 40 additions & 1 deletion hscontrol/derp/server/derp_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package server

import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/netip"
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -360,3 +370,32 @@ 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 verify request")

return nil, err
}

resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(buf),
}

return resp, nil
}
35 changes: 17 additions & 18 deletions hscontrol/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,35 @@ func parseCabailityVersion(req *http.Request) (tailcfg.CapabilityVersion, error)

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),
}
if err = json.NewEncoder(writer).Encode(resp); err != nil {
return fmt.Errorf("cannot encode response: %w", err)
}

return nil
}

// 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,
Expand All @@ -92,28 +101,18 @@ func (h *Headscale) VerifyHandler(
Str("handler", "/verify").
Msg("verify client")

allow, err := h.handleVerifyRequest(req)
if err != nil {
if err := h.handleVerifyRequest(req, writer); err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to verify client")
http.Error(writer, "Internal error", http.StatusInternalServerError)
}

resp := tailcfg.DERPAdmitClientResponse{
Allow: allow,
return
}

writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
err = json.NewEncoder(writer).Encode(resp)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
}

// KeyHandler provides the Headscale pub key
Expand Down
3 changes: 3 additions & 0 deletions hscontrol/types/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ type DERPConfig struct {
ServerRegionCode string
ServerRegionName string
ServerPrivateKeyPath string
ServerVerifyClients bool
STUNAddr string
URLs []url.URL
Paths []string
Expand Down Expand Up @@ -431,6 +432,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"),
Expand Down Expand Up @@ -475,6 +477,7 @@ func derpConfig() DERPConfig {
ServerRegionID: serverRegionID,
ServerRegionCode: serverRegionCode,
ServerRegionName: serverRegionName,
ServerVerifyClients: serverVerifyClients,
ServerPrivateKeyPath: privateKeyPath,
STUNAddr: stunAddr,
URLs: urls,
Expand Down
85 changes: 54 additions & 31 deletions integration/derp_verify_endpoint_test.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
package integration

import (
"encoding/json"
"context"
"fmt"
"net"
"strconv"
"strings"
"testing"

"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/integration/dsic"
"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) {
Expand Down Expand Up @@ -45,23 +48,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,
},
}

Expand All @@ -76,21 +80,40 @@ func TestDERPVerifyEndpoint(t *testing.T) {
allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err)

fakeKey := key.NewNode()
DERPVerify(t, fakeKey, derpRegion, false)
seiuneko marked this conversation as resolved.
Show resolved Hide resolved

for _, client := range allClients {
report, err := client.DebugDERPRegion("test-derpverify")
nodeKey, err := client.GetNodePrivateKey()
assertNoErr(t, err)
successful := false
for _, line := range report.Info {
if strings.Contains(line, "Successfully established a DERP connection with node") {
successful = true

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))
}
DERPVerify(t, *nodeKey, derpRegion, true)
}
seiuneko marked this conversation as resolved.
Show resolved Hide resolved
}

func DERPVerify(
t *testing.T,
nodeKey key.NodePrivate,
region tailcfg.DERPRegion,
expectSuccess bool,
) {
IntegrationSkip(t)
seiuneko marked this conversation as resolved.
Show resolved Hide resolved

c := derphttp.NewRegionClient(nodeKey, t.Logf, netmon.NewStatic(), func() *tailcfg.DERPRegion {
return &region
})
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())
}
}
37 changes: 34 additions & 3 deletions integration/embedded_derp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/ory/dockertest/v3"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)

type ClientsSpec struct {
Expand Down Expand Up @@ -107,9 +109,10 @@ func derpServerScenario(
hsic.WithTLS(),
hsic.WithHostnameAsServerURL(),
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)
Expand Down Expand Up @@ -185,6 +188,34 @@ func derpServerScenario(

t.Logf("Run2: %d successful pings out of %d", success, len(allClients)*len(allHostnames))

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)
seiuneko marked this conversation as resolved.
Show resolved Hide resolved

for _, client := range allClients {
nodeKey, err := client.GetNodePrivateKey()
assertNoErr(t, err)
DERPVerify(t, *nodeKey, derpRegion, true)
}
seiuneko marked this conversation as resolved.
Show resolved Hide resolved

for _, check := range furtherAssertions {
check(&scenario)
}
Expand Down
2 changes: 2 additions & 0 deletions integration/tailscale.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/juanfont/headscale/integration/tsic"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netcheck"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
)

Expand All @@ -31,6 +32,7 @@ type TailscaleClient interface {
Status(...bool) (*ipnstate.Status, error)
Netmap() (*netmap.NetworkMap, error)
DebugDERPRegion(region string) (*ipnstate.DebugDERPRegionReport, error)
GetNodePrivateKey() (*key.NodePrivate, error)
Netcheck() (*netcheck.Report, error)
WaitForNeedsLogin() error
WaitForRunning() error
Expand Down
Loading