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

Add DERP generate_204 endpoint for captive portal detection. #1600

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# DO NOT EDIT, generated with cmd/gh-action-integration-generator/main.go
# To regenerate, run "go generate" in cmd/gh-action-integration-generator/

name: Integration Test v2 - TestDERPValidateEmbedded

on: [pull_request]

concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
TestDERPValidateEmbedded:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
with:
fetch-depth: 2

- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: satackey/action-docker-layer-caching@main
continue-on-error: true

- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v34
with:
files: |
*.nix
go.*
**/*.go
integration_test/
config-example.yaml

- name: Run TestDERPValidateEmbedded
uses: Wandalen/wretry.action@master
if: steps.changed-files.outputs.any_changed == 'true'
with:
attempt_limit: 5
command: |
nix develop --command -- docker run \
--tty --rm \
--volume ~/.cache/hs-integration-go:/go \
--name headscale-test-suite \
--volume $PWD:$PWD -w $PWD/integration \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume $PWD/control_logs:/tmp/control \
golang:1 \
go run gotest.tools/gotestsum@latest -- ./... \
-failfast \
-timeout 120m \
-parallel 1 \
-run "^TestDERPValidateEmbedded$"

- uses: actions/upload-artifact@v3
if: always() && steps.changed-files.outputs.any_changed == 'true'
with:
name: logs
path: "control_logs/*.log"

- uses: actions/upload-artifact@v3
if: always() && steps.changed-files.outputs.any_changed == 'true'
with:
name: pprof
path: "control_logs/*.pprof.tar"
30 changes: 30 additions & 0 deletions hscontrol/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,11 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *mux.Router {
router.HandleFunc("/derp", h.DERPServer.DERPHandler)
router.HandleFunc("/derp/probe", derpServer.DERPProbeHandler)
router.HandleFunc("/bootstrap-dns", derpServer.DERPBootstrapDNSHandler(h.DERPMap))

// Only add to main muxer if running on port 80
if strings.HasSuffix(h.cfg.Addr, ":80") {
router.HandleFunc("/generate_204", derpServer.DERPNoContextHandler)
}
}

apiRouter := router.PathPrefix("/api").Subrouter()
Expand Down Expand Up @@ -696,6 +701,31 @@ func (h *Headscale) Serve() error {
log.Info().
Msgf("listening and serving HTTP on: %s", h.cfg.Addr)

// If headscale is not listening on port 80 and embedded DERP server
// is enabled, run a small http endpoint for generate204.
// This is not configurable as captive portal busting requires http/80.
if h.cfg.DERP.ServerEnabled || !strings.HasSuffix(h.cfg.Addr, ":80") {
httpDerpMux := http.NewServeMux()
httpDerpMux.HandleFunc("/generate_204", derpServer.DERPNoContextHandler)

addr := "0.0.0.0:80"
httpDerpServer := &http.Server{
Addr: addr,
Handler: httpDerpMux,
ReadTimeout: types.HTTPReadTimeout,
}

httpDerpListener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("binding port 80 for DERP HTTP endpoint: %w", err)
}

errorGroup.Go(func() error { return httpDerpServer.Serve(httpDerpListener) })

log.Info().
Msgf("listening and serving HTTP DERP generate_204 on: %s", addr)
}

promMux := http.NewServeMux()
promMux.Handle("/metrics", promhttp.Handler())

Expand Down
41 changes: 41 additions & 0 deletions hscontrol/derp/server/derp_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import (
// headers and it will begin writing & reading the DERP protocol immediately
// following its HTTP request.
const fastStartHeader = "Derp-Fast-Start"
const (
noContentChallengeHeader = "X-Tailscale-Challenge"
noContentResponseHeader = "X-Tailscale-Response"
)

type DERPServer struct {
serverURL string
Expand Down Expand Up @@ -204,6 +208,43 @@ func DERPProbeHandler(
}
}

// DERPNoContextHandler is the endpoint clients use to determine if they are behind a captive portal
// Clients challenge this with the X-Tailscale-Challenge header and expect the challenge value within X-Tailscale-Response
// https://github.com/tailscale/tailscale/blob/955e2fcbfb4fe7ff9b8dbd665ba24ef2008c676e/cmd/derper/derper.go#L324
func DERPNoContextHandler(
writer http.ResponseWriter,
req *http.Request,
) {
switch req.Method {
case http.MethodHead, http.MethodGet:
if challenge := req.Header.Get(noContentChallengeHeader); challenge != "" {
badChar := strings.IndexFunc(challenge, func(r rune) bool {
return !isChallengeChar(r)
}) != -1
if len(challenge) <= 64 && !badChar {
writer.Header().Set(noContentResponseHeader, "response "+challenge)
}
}
writer.WriteHeader(http.StatusNoContent)
default:
writer.WriteHeader(http.StatusMethodNotAllowed)
_, err := writer.Write([]byte("bogus captive portal method"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
}
}

func isChallengeChar(c rune) bool {
// Semi-randomly chosen as a limited set of valid characters
return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
('0' <= c && c <= '9') ||
c == '.' || c == '-' || c == '_'
}

// DERPBootstrapDNSHandler implements the /bootsrap-dns endpoint
// Described in https://github.com/tailscale/tailscale/issues/1405,
// this endpoint provides a way to help a client when it fails to start up
Expand Down
81 changes: 80 additions & 1 deletion integration/embedded_derp_test.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package integration

import (
"encoding/json"
"fmt"
"log"
"net/url"
"strings"
"testing"

"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/integration/dockertestutil"
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/ory/dockertest/v3"
"tailscale.com/ipn/ipnstate"
)

type EmbeddedDERPServerScenario struct {
Expand Down Expand Up @@ -55,7 +58,7 @@ func TestDERPServerScenario(t *testing.T) {
spec,
hsic.WithConfigEnv(headscaleConfig),
hsic.WithTestName("derpserver"),
hsic.WithExtraPorts([]string{"3478/udp"}),
hsic.WithExtraPorts("3478/udp"),
hsic.WithTLS(),
hsic.WithHostnameAsServerURL(),
)
Expand All @@ -80,6 +83,82 @@ func TestDERPServerScenario(t *testing.T) {
t.Logf("%d successful pings out of %d", success, len(allClients)*len(allIps))
}

func TestDERPValidateEmbedded(t *testing.T) {
IntegrationSkip(t)

scenario, err := NewScenario()
assertNoErr(t, err)
// defer scenario.Shutdown()

spec := map[string]int{
"user1": 1,
}

headscaleConfig := map[string]string{
"HEADSCALE_DERP_URLS": "",
"HEADSCALE_DERP_SERVER_ENABLED": "true",
"HEADSCALE_DERP_SERVER_REGION_ID": "999",
"HEADSCALE_DERP_SERVER_REGION_CODE": "headscale",
"HEADSCALE_DERP_SERVER_REGION_NAME": "Headscale Embedded DERP",
"HEADSCALE_DERP_SERVER_STUN_LISTEN_ADDR": "0.0.0.0:3478",
"HEADSCALE_DERP_SERVER_PRIVATE_KEY_PATH": "/tmp/derp.key",

// Magic DNS breaks the docker DNS system which means
// DERP cannot look up the DERP server for some things.
"HEADSCALE_DNS_CONFIG_MAGIC_DNS": "0",

// Envknob for enabling DERP debug logs
"DERP_DEBUG_LOGS": "true",
"DERP_PROBER_DEBUG_LOGS": "true",
}

err = scenario.CreateHeadscaleEnv(
spec,
[]tsic.Option{},
hsic.WithConfigEnv(headscaleConfig),
hsic.WithTestName("derpvalidate"),
hsic.WithExtraPorts("3478/udp", "80/tcp"),
hsic.WithTLS(),
hsic.WithHostnameAsServerURL(),
)
assertNoErrHeadscaleEnv(t, err)

allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err)

err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)

assertClientsState(t, allClients)

if len(allClients) != 1 {
t.Fatalf("expected 1 client, got: %d", len(allClients))
}

client := allClients[0]

var derpReport ipnstate.DebugDERPRegionReport
stdout, stderr, err := client.Execute([]string{"tailscale", "debug", "derp", "999"})
if err != nil {
t.Fatalf("executing debug derp report, stderr: %s, err: %s", stderr, err)
}

t.Logf("DERP report: \n%s", stdout)

err = json.Unmarshal([]byte(stdout), &derpReport)
if err != nil {
t.Fatalf("unmarshalling debug derp report, content: %s, err: %s", stdout, err)
}

for _, warn := range derpReport.Warnings {
if strings.Contains(warn, "captive portal check") {
t.Errorf(
"derp report contains warning about portal check, generate_204 endpoint not working",
)
}
}
}

func (s *EmbeddedDERPServerScenario) CreateHeadscaleEnv(
users map[string]int,
opts ...hsic.Option,
Expand Down
4 changes: 3 additions & 1 deletion integration/hsic/hsic.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,14 @@ func WithConfigEnv(configEnv map[string]string) Option {
// WithPort sets the port on where to run Headscale.
func WithPort(port int) Option {
return func(hsic *HeadscaleInContainer) {
hsic.env["HEADSCALE_LISTEN_ADDR"] = fmt.Sprintf("0.0.0.0:%d", port)
hsic.env["HEADSCALE_SERVER_URL"] = fmt.Sprintf("http://headscale:%d", port)
hsic.port = port
}
}

// WithExtraPorts exposes additional ports on the container (e.g. 3478/udp for STUN).
func WithExtraPorts(ports []string) Option {
func WithExtraPorts(ports ...string) Option {
return func(hsic *HeadscaleInContainer) {
hsic.extraPorts = ports
}
Expand Down
Loading