diff --git a/.dockerignore b/.dockerignore index 057a20e7..e3acf996 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ // development integration_test.go integration_test/ +!integration_test/etc_embedded_derp/tls/server.crt Dockerfile* docker-compose* diff --git a/CHANGELOG.md b/CHANGELOG.md index 8535b45f..6ce0c35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Users can now use emails in ACL's groups [#372](https://github.com/juanfont/headscale/issues/372) - Add shorthand aliases for commands and subcommands [#376](https://github.com/juanfont/headscale/pull/376) - Add `/windows` endpoint for Windows configuration instructions + registry file download [#392](https://github.com/juanfont/headscale/pull/392) +- Added embedded DERP server into Headscale [#388](https://github.com/juanfont/headscale/pull/388) ### Changes diff --git a/Dockerfile.tailscale b/Dockerfile.tailscale index f96c6b9c..32a8ce7b 100644 --- a/Dockerfile.tailscale +++ b/Dockerfile.tailscale @@ -7,5 +7,10 @@ RUN apt-get update \ && curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.gpg | apt-key add - \ && curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \ && apt-get update \ - && apt-get install -y tailscale=${TAILSCALE_VERSION} dnsutils \ + && apt-get install -y ca-certificates tailscale=${TAILSCALE_VERSION} dnsutils \ && rm -rf /var/lib/apt/lists/* + +ADD integration_test/etc_embedded_derp/tls/server.crt /usr/local/share/ca-certificates/ +RUN chmod 644 /usr/local/share/ca-certificates/server.crt + +RUN update-ca-certificates diff --git a/Makefile b/Makefile index 266dadb8..73630d3f 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,9 @@ test_integration: test_integration_cli: go test -tags integration -v integration_cli_test.go integration_common_test.go +test_integration_derp: + go test -tags integration -v integration_embedded_derp_test.go integration_common_test.go + coverprofile_func: go tool cover -func=coverage.out diff --git a/README.md b/README.md index 1b97b1aa..ec66b086 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ one of the maintainers. - Dual stack (IPv4 and IPv6) - Routing advertising (including exit nodes) - Ephemeral nodes +- Embedded [DERP server](https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp) ## Client OS support diff --git a/app.go b/app.go index 682d8e49..f1426bbb 100644 --- a/app.go +++ b/app.go @@ -120,10 +120,16 @@ type OIDCConfig struct { } type DERPConfig struct { - URLs []url.URL - Paths []string - AutoUpdate bool - UpdateFrequency time.Duration + ServerEnabled bool + ServerRegionID int + ServerRegionCode string + ServerRegionName string + STUNEnabled bool + STUNAddr string + URLs []url.URL + Paths []string + AutoUpdate bool + UpdateFrequency time.Duration } type CLIConfig struct { @@ -142,7 +148,8 @@ type Headscale struct { dbDebug bool privateKey *key.MachinePrivate - DERPMap *tailcfg.DERPMap + DERPMap *tailcfg.DERPMap + DERPServer *DERPServer aclPolicy *ACLPolicy aclRules []tailcfg.FilterRule @@ -178,7 +185,6 @@ func LookupTLSClientAuthMode(mode string) (tls.ClientAuthType, bool) { } } -// NewHeadscale returns the Headscale app. func NewHeadscale(cfg Config) (*Headscale, error) { privKey, err := readOrCreatePrivateKey(cfg.PrivateKeyPath) if err != nil { @@ -239,6 +245,14 @@ func NewHeadscale(cfg Config) (*Headscale, error) { } } + if cfg.DERP.ServerEnabled { + embeddedDERPServer, err := app.NewDERPServer() + if err != nil { + return nil, err + } + app.DERPServer = embeddedDERPServer + } + return &app, nil } @@ -463,6 +477,12 @@ func (h *Headscale) createRouter(grpcMux *runtime.ServeMux) *gin.Engine { router.GET("/swagger", SwaggerUI) router.GET("/swagger/v1/openapiv2.json", SwaggerAPIv1) + if h.cfg.DERP.ServerEnabled { + router.Any("/derp", h.DERPHandler) + router.Any("/derp/probe", h.DERPProbeHandler) + router.Any("/bootstrap-dns", h.DERPBootstrapDNSHandler) + } + api := router.Group("/api") api.Use(h.httpAuthenticationMiddleware) { @@ -481,6 +501,13 @@ func (h *Headscale) Serve() error { // Fetch an initial DERP Map before we start serving h.DERPMap = GetDERPMap(h.cfg.DERP) + if h.cfg.DERP.ServerEnabled { + h.DERPMap.Regions[h.DERPServer.region.RegionID] = &h.DERPServer.region + if h.cfg.DERP.STUNEnabled { + go h.ServeSTUN() + } + } + if h.cfg.DERP.AutoUpdate { derpMapCancelChannel := make(chan struct{}) defer func() { derpMapCancelChannel <- struct{}{} }() diff --git a/cmd/headscale/cli/server.go b/cmd/headscale/cli/server.go index 6d9ad194..c19580b9 100644 --- a/cmd/headscale/cli/server.go +++ b/cmd/headscale/cli/server.go @@ -1,8 +1,7 @@ package cli import ( - "log" - + "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -19,12 +18,12 @@ var serveCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { h, err := getHeadscaleApp() if err != nil { - log.Fatalf("Error initializing: %s", err) + log.Fatal().Caller().Err(err).Msg("Error initializing") } err = h.Serve() if err != nil { - log.Fatalf("Error initializing: %s", err) + log.Fatal().Caller().Err(err).Msg("Error starting server") } }, } diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 49fc23c1..dc7a4e9f 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -117,6 +117,13 @@ func LoadConfig(path string) error { } func GetDERPConfig() headscale.DERPConfig { + serverEnabled := viper.GetBool("derp.server.enabled") + serverRegionID := viper.GetInt("derp.server.region_id") + serverRegionCode := viper.GetString("derp.server.region_code") + serverRegionName := viper.GetString("derp.server.region_name") + stunEnabled := viper.GetBool("derp.server.stun.enabled") + stunAddr := viper.GetString("derp.server.stun.listen_addr") + urlStrs := viper.GetStringSlice("derp.urls") urls := make([]url.URL, len(urlStrs)) @@ -138,10 +145,16 @@ func GetDERPConfig() headscale.DERPConfig { updateFrequency := viper.GetDuration("derp.update_frequency") return headscale.DERPConfig{ - URLs: urls, - Paths: paths, - AutoUpdate: autoUpdate, - UpdateFrequency: updateFrequency, + ServerEnabled: serverEnabled, + ServerRegionID: serverRegionID, + ServerRegionCode: serverRegionCode, + ServerRegionName: serverRegionName, + STUNEnabled: stunEnabled, + STUNAddr: stunAddr, + URLs: urls, + Paths: paths, + AutoUpdate: autoUpdate, + UpdateFrequency: updateFrequency, } } diff --git a/config-example.yaml b/config-example.yaml index a8c3f289..2075e69a 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -55,6 +55,26 @@ ip_prefixes: # headscale needs a list of DERP servers that can be presented # to the clients. derp: + server: + # If enabled, runs the embedded DERP server and merges it into the rest of the DERP config + # The Headscale server_url defined above MUST be using https, DERP requires TLS to be in place + enabled: false + + # Region ID to use for the embedded DERP server. + # The local DERP prevails if the region ID collides with other region ID coming from + # the regular DERP config. + region_id: 999 + + # Region code and name are displayed in the Tailscale UI to identify a DERP region + region_code: "headscale" + region_name: "Headscale Embedded DERP" + + # If enabled, also listens in UDP at the configured address for STUN connections to help on NAT traversal + # For more details on how this works, check this great article: https://tailscale.com/blog/how-tailscale-works/ + stun: + enabled: false + listen_addr: "0.0.0.0:3478" + # List of externally available DERP maps encoded in JSON urls: - https://controlplane.tailscale.com/derpmap/default diff --git a/derp.go b/derp.go index 63e448db..7a9b2367 100644 --- a/derp.go +++ b/derp.go @@ -148,6 +148,7 @@ func (h *Headscale) scheduledDERPMapUpdateWorker(cancelChan <-chan struct{}) { case <-ticker.C: log.Info().Msg("Fetching DERPMap updates") h.DERPMap = GetDERPMap(h.cfg.DERP) + h.DERPMap.Regions[h.DERPServer.region.RegionID] = &h.DERPServer.region namespaces, err := h.ListNamespaces() if err != nil { diff --git a/derp_server.go b/derp_server.go new file mode 100644 index 00000000..11e3eb14 --- /dev/null +++ b/derp_server.go @@ -0,0 +1,233 @@ +package headscale + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + "tailscale.com/derp" + "tailscale.com/net/stun" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// fastStartHeader is the header (with value "1") that signals to the HTTP +// 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" + +type DERPServer struct { + tailscaleDERP *derp.Server + region tailcfg.DERPRegion +} + +func (h *Headscale) NewDERPServer() (*DERPServer, error) { + server := derp.NewServer(key.NodePrivate(*h.privateKey), log.Info().Msgf) + region, err := h.generateRegionLocalDERP() + if err != nil { + return nil, err + } + + return &DERPServer{server, region}, nil +} + +func (h *Headscale) generateRegionLocalDERP() (tailcfg.DERPRegion, error) { + serverURL, err := url.Parse(h.cfg.ServerURL) + if err != nil { + return tailcfg.DERPRegion{}, err + } + var host string + var port int + host, portStr, err := net.SplitHostPort(serverURL.Host) + if err != nil { + if serverURL.Scheme == "https" { + host = serverURL.Host + port = 443 + } else { + host = serverURL.Host + port = 80 + } + } else { + port, err = strconv.Atoi(portStr) + if err != nil { + return tailcfg.DERPRegion{}, err + } + } + + localDERPregion := tailcfg.DERPRegion{ + RegionID: h.cfg.DERP.ServerRegionID, + RegionCode: h.cfg.DERP.ServerRegionCode, + RegionName: h.cfg.DERP.ServerRegionName, + Avoid: false, + Nodes: []*tailcfg.DERPNode{ + { + Name: fmt.Sprintf("%d", h.cfg.DERP.ServerRegionID), + RegionID: h.cfg.DERP.ServerRegionID, + HostName: host, + DERPPort: port, + }, + }, + } + + if h.cfg.DERP.STUNEnabled { + _, portStr, err := net.SplitHostPort(h.cfg.DERP.STUNAddr) + if err != nil { + return tailcfg.DERPRegion{}, err + } + port, err := strconv.Atoi(portStr) + if err != nil { + return tailcfg.DERPRegion{}, err + } + localDERPregion.Nodes[0].STUNPort = port + } + + return localDERPregion, nil +} + +func (h *Headscale) DERPHandler(ctx *gin.Context) { + log.Trace().Caller().Msgf("/derp request from %v", ctx.ClientIP()) + up := strings.ToLower(ctx.Request.Header.Get("Upgrade")) + if up != "websocket" && up != "derp" { + if up != "" { + log.Warn().Caller().Msgf("Weird websockets connection upgrade: %q", up) + } + ctx.String(http.StatusUpgradeRequired, "DERP requires connection upgrade") + + return + } + + fastStart := ctx.Request.Header.Get(fastStartHeader) == "1" + + hijacker, ok := ctx.Writer.(http.Hijacker) + if !ok { + log.Error().Caller().Msg("DERP requires Hijacker interface from Gin") + ctx.String(http.StatusInternalServerError, "HTTP does not support general TCP support") + + return + } + + netConn, conn, err := hijacker.Hijack() + if err != nil { + log.Error().Caller().Err(err).Msgf("Hijack failed") + ctx.String(http.StatusInternalServerError, "HTTP does not support general TCP support") + + return + } + + if !fastStart { + pubKey := h.privateKey.Public() + pubKeyStr := pubKey.UntypedHexString() // nolint + fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\n"+ + "Upgrade: DERP\r\n"+ + "Connection: Upgrade\r\n"+ + "Derp-Version: %v\r\n"+ + "Derp-Public-Key: %s\r\n\r\n", + derp.ProtocolVersion, + pubKeyStr) + } + + h.DERPServer.tailscaleDERP.Accept(netConn, conn, netConn.RemoteAddr().String()) +} + +// DERPProbeHandler is the endpoint that js/wasm clients hit to measure +// DERP latency, since they can't do UDP STUN queries. +func (h *Headscale) DERPProbeHandler(ctx *gin.Context) { + switch ctx.Request.Method { + case "HEAD", "GET": + ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*") + default: + ctx.String(http.StatusMethodNotAllowed, "bogus probe method") + } +} + +// 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 +// because its DNS are broken. +// The initial implementation is here https://github.com/tailscale/tailscale/pull/1406 +// They have a cache, but not clear if that is really necessary at Headscale, uh, scale. +// An example implementation is found here https://derp.tailscale.com/bootstrap-dns +func (h *Headscale) DERPBootstrapDNSHandler(ctx *gin.Context) { + dnsEntries := make(map[string][]net.IP) + + resolvCtx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + var r net.Resolver + for _, region := range h.DERPMap.Regions { + for _, node := range region.Nodes { // we don't care if we override some nodes + addrs, err := r.LookupIP(resolvCtx, "ip", node.HostName) + if err != nil { + log.Trace().Caller().Err(err).Msgf("bootstrap DNS lookup failed %q", node.HostName) + + continue + } + dnsEntries[node.HostName] = addrs + } + } + ctx.JSON(http.StatusOK, dnsEntries) +} + +// ServeSTUN starts a STUN server on the configured addr. +func (h *Headscale) ServeSTUN() { + packetConn, err := net.ListenPacket("udp", h.cfg.DERP.STUNAddr) + if err != nil { + log.Fatal().Msgf("failed to open STUN listener: %v", err) + } + log.Info().Msgf("STUN server started at %s", packetConn.LocalAddr()) + + udpConn, ok := packetConn.(*net.UDPConn) + if !ok { + log.Fatal().Msg("STUN listener is not a UDP listener") + } + serverSTUNListener(context.Background(), udpConn) +} + +func serverSTUNListener(ctx context.Context, packetConn *net.UDPConn) { + var buf [64 << 10]byte + var ( + bytesRead int + udpAddr *net.UDPAddr + err error + ) + for { + bytesRead, udpAddr, err = packetConn.ReadFromUDP(buf[:]) + if err != nil { + if ctx.Err() != nil { + return + } + log.Error().Caller().Err(err).Msgf("STUN ReadFrom") + time.Sleep(time.Second) + + continue + } + log.Trace().Caller().Msgf("STUN request from %v", udpAddr) + pkt := buf[:bytesRead] + if !stun.Is(pkt) { + log.Trace().Caller().Msgf("UDP packet is not STUN") + + continue + } + txid, err := stun.ParseBindingRequest(pkt) + if err != nil { + log.Trace().Caller().Err(err).Msgf("STUN parse error") + + continue + } + + res := stun.Response(txid, udpAddr.IP, uint16(udpAddr.Port)) + _, err = packetConn.WriteTo(res, udpAddr) + if err != nil { + log.Trace().Caller().Err(err).Msgf("Issue writing to UDP") + + continue + } + } +} diff --git a/go.mod b/go.mod index a121826d..1ec291c3 100644 --- a/go.mod +++ b/go.mod @@ -46,8 +46,10 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.5.2 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect + github.com/akutz/memconn v0.1.0 // indirect github.com/atomicgo/cursor v0.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/ccding/go-stun/stun v0.0.0-20200514191101-4dc67bcdb029 // indirect github.com/cenkalti/backoff/v4 v4.1.2 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/containerd/continuity v0.2.2 // indirect @@ -100,6 +102,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.11 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -134,6 +137,7 @@ require ( golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect + golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/ini.v1 v1.66.4 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect diff --git a/go.sum b/go.sum index 558d3d7c..6d254a25 100644 --- a/go.sum +++ b/go.sum @@ -82,6 +82,8 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEV github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -106,6 +108,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/bufbuild/buf v0.37.0/go.mod h1:lQ1m2HkIaGOFba6w/aC3KYBHhKEOESP3gaAEpS3dAFM= +github.com/ccding/go-stun/stun v0.0.0-20200514191101-4dc67bcdb029 h1:POmUHfxXdeyM8Aomg4tKDcwATCFuW+cYLkj6pwsw9pc= +github.com/ccding/go-stun/stun v0.0.0-20200514191101-4dc67bcdb029/go.mod h1:Rpr5n9cGHYdM3S3IK8ROSUUUYjQOu+MSUCZDcJbYWi8= github.com/cenkalti/backoff/v4 v4.1.2 h1:6Yo7N8UP2K6LWZnW94DLVSSrbobcWdVzAYOisuDPIFo= github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -205,6 +209,7 @@ github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYF github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= +github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= @@ -570,6 +575,8 @@ github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceT github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= @@ -1083,6 +1090,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M= +golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/integration_common_test.go b/integration_common_test.go index de304d0d..70285fc4 100644 --- a/integration_common_test.go +++ b/integration_common_test.go @@ -6,6 +6,7 @@ package headscale import ( "bytes" "fmt" + "strings" "time" "github.com/ory/dockertest/v3" @@ -18,8 +19,15 @@ const DOCKER_EXECUTE_TIMEOUT = 10 * time.Second var ( IpPrefix4 = netaddr.MustParseIPPrefix("100.64.0.0/10") IpPrefix6 = netaddr.MustParseIPPrefix("fd7a:115c:a1e0::/48") + + tailscaleVersions = []string{"1.22.0", "1.20.4", "1.18.2", "1.16.2", "1.14.3", "1.12.3"} ) +type TestNamespace struct { + count int + tailscales map[string]dockertest.Resource +} + type ExecuteCommandConfig struct { timeout time.Duration } @@ -119,3 +127,35 @@ func DockerAllowNetworkAdministration(config *docker.HostConfig) { Target: "/dev/net/tun", }) } + +func getIPs( + tailscales map[string]dockertest.Resource, +) (map[string][]netaddr.IP, error) { + ips := make(map[string][]netaddr.IP) + for hostname, tailscale := range tailscales { + command := []string{"tailscale", "ip"} + + result, err := ExecuteCommand( + &tailscale, + command, + []string{}, + ) + if err != nil { + return nil, err + } + + for _, address := range strings.Split(result, "\n") { + address = strings.TrimSuffix(address, "\n") + if len(address) < 1 { + continue + } + ip, err := netaddr.ParseIP(address) + if err != nil { + return nil, err + } + ips[hostname] = append(ips[hostname], ip) + } + } + + return ips, nil +} diff --git a/integration_embedded_derp_test.go b/integration_embedded_derp_test.go new file mode 100644 index 00000000..a1737173 --- /dev/null +++ b/integration_embedded_derp_test.go @@ -0,0 +1,396 @@ +//go:build integration + +package headscale + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "path" + "strings" + "sync" + "testing" + "time" + + v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/ccding/go-stun/stun" +) + +const ( + headscaleHostname = "headscale-derp" + namespaceName = "derpnamespace" + totalContainers = 3 +) + +type IntegrationDERPTestSuite struct { + suite.Suite + stats *suite.SuiteInformation + + pool dockertest.Pool + networks map[int]dockertest.Network // so we keep the containers isolated + headscale dockertest.Resource + + tailscales map[string]dockertest.Resource + joinWaitGroup sync.WaitGroup +} + +func TestDERPIntegrationTestSuite(t *testing.T) { + s := new(IntegrationDERPTestSuite) + + s.tailscales = make(map[string]dockertest.Resource) + s.networks = make(map[int]dockertest.Network) + + suite.Run(t, s) + + // HandleStats, which allows us to check if we passed and save logs + // is called after TearDown, so we cannot tear down containers before + // we have potentially saved the logs. + for _, tailscale := range s.tailscales { + if err := s.pool.Purge(&tailscale); err != nil { + log.Printf("Could not purge resource: %s\n", err) + } + } + + if !s.stats.Passed() { + err := s.saveLog(&s.headscale, "test_output") + if err != nil { + log.Printf("Could not save log: %s\n", err) + } + } + if err := s.pool.Purge(&s.headscale); err != nil { + log.Printf("Could not purge resource: %s\n", err) + } + + for _, network := range s.networks { + if err := network.Close(); err != nil { + log.Printf("Could not close network: %s\n", err) + } + } +} + +func (s *IntegrationDERPTestSuite) SetupSuite() { + if ppool, err := dockertest.NewPool(""); err == nil { + s.pool = *ppool + } else { + log.Fatalf("Could not connect to docker: %s", err) + } + + for i := 0; i < totalContainers; i++ { + if pnetwork, err := s.pool.CreateNetwork(fmt.Sprintf("headscale-derp-%d", i)); err == nil { + s.networks[i] = *pnetwork + } else { + log.Fatalf("Could not create network: %s", err) + } + } + + headscaleBuildOptions := &dockertest.BuildOptions{ + Dockerfile: "Dockerfile", + ContextDir: ".", + } + + currentPath, err := os.Getwd() + if err != nil { + log.Fatalf("Could not determine current path: %s", err) + } + + headscaleOptions := &dockertest.RunOptions{ + Name: headscaleHostname, + Mounts: []string{ + fmt.Sprintf("%s/integration_test/etc_embedded_derp:/etc/headscale", currentPath), + }, + Cmd: []string{"headscale", "serve"}, + ExposedPorts: []string{"8443/tcp", "3478/udp"}, + PortBindings: map[docker.Port][]docker.PortBinding{ + "8443/tcp": {{HostPort: "8443"}}, + "3478/udp": {{HostPort: "3478"}}, + }, + } + + log.Println("Creating headscale container") + if pheadscale, err := s.pool.BuildAndRunWithBuildOptions(headscaleBuildOptions, headscaleOptions, DockerRestartPolicy); err == nil { + s.headscale = *pheadscale + } else { + log.Fatalf("Could not start resource: %s", err) + } + log.Println("Created headscale container to test DERP") + + log.Println("Creating tailscale containers") + + for i := 0; i < totalContainers; i++ { + version := tailscaleVersions[i%len(tailscaleVersions)] + hostname, container := s.tailscaleContainer( + fmt.Sprint(i), + version, + s.networks[i], + ) + s.tailscales[hostname] = *container + } + + log.Println("Waiting for headscale to be ready") + hostEndpoint := fmt.Sprintf("localhost:%s", s.headscale.GetPort("8443/tcp")) + + if err := s.pool.Retry(func() error { + url := fmt.Sprintf("https://%s/health", hostEndpoint) + insecureTransport := http.DefaultTransport.(*http.Transport).Clone() + insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + client := &http.Client{Transport: insecureTransport} + resp, err := client.Get(url) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status code not OK") + } + + return nil + }); err != nil { + // TODO(kradalby): If we cannot access headscale, or any other fatal error during + // test setup, we need to abort and tear down. However, testify does not seem to + // support that at the moment: + // https://github.com/stretchr/testify/issues/849 + return // fmt.Errorf("Could not connect to headscale: %s", err) + } + log.Println("headscale container is ready") + + log.Printf("Creating headscale namespace: %s\n", namespaceName) + result, err := ExecuteCommand( + &s.headscale, + []string{"headscale", "namespaces", "create", namespaceName}, + []string{}, + ) + log.Println("headscale create namespace result: ", result) + assert.Nil(s.T(), err) + + log.Printf("Creating pre auth key for %s\n", namespaceName) + preAuthResult, err := ExecuteCommand( + &s.headscale, + []string{ + "headscale", + "--namespace", + namespaceName, + "preauthkeys", + "create", + "--reusable", + "--expiration", + "24h", + "--output", + "json", + }, + []string{"LOG_LEVEL=error"}, + ) + assert.Nil(s.T(), err) + + var preAuthKey v1.PreAuthKey + err = json.Unmarshal([]byte(preAuthResult), &preAuthKey) + assert.Nil(s.T(), err) + assert.True(s.T(), preAuthKey.Reusable) + + headscaleEndpoint := fmt.Sprintf("https://headscale:%s", s.headscale.GetPort("8443/tcp")) + + log.Printf( + "Joining tailscale containers to headscale at %s\n", + headscaleEndpoint, + ) + for hostname, tailscale := range s.tailscales { + s.joinWaitGroup.Add(1) + go s.Join(headscaleEndpoint, preAuthKey.Key, hostname, tailscale) + } + + s.joinWaitGroup.Wait() + + // The nodes need a bit of time to get their updated maps from headscale + // TODO: See if we can have a more deterministic wait here. + time.Sleep(60 * time.Second) +} + +func (s *IntegrationDERPTestSuite) Join( + endpoint, key, hostname string, + tailscale dockertest.Resource, +) { + defer s.joinWaitGroup.Done() + + command := []string{ + "tailscale", + "up", + "-login-server", + endpoint, + "--authkey", + key, + "--hostname", + hostname, + } + + log.Println("Join command:", command) + log.Printf("Running join command for %s\n", hostname) + _, err := ExecuteCommand( + &tailscale, + command, + []string{}, + ) + assert.Nil(s.T(), err) + log.Printf("%s joined\n", hostname) +} + +func (s *IntegrationDERPTestSuite) tailscaleContainer(identifier, version string, network dockertest.Network, +) (string, *dockertest.Resource) { + tailscaleBuildOptions := &dockertest.BuildOptions{ + Dockerfile: "Dockerfile.tailscale", + ContextDir: ".", + BuildArgs: []docker.BuildArg{ + { + Name: "TAILSCALE_VERSION", + Value: version, + }, + }, + } + hostname := fmt.Sprintf( + "tailscale-%s-%s", + strings.Replace(version, ".", "-", -1), + identifier, + ) + tailscaleOptions := &dockertest.RunOptions{ + Name: hostname, + Networks: []*dockertest.Network{&network}, + Cmd: []string{ + "tailscaled", "--tun=tsdev", + }, + + // expose the host IP address, so we can access it from inside the container + ExtraHosts: []string{"host.docker.internal:host-gateway", "headscale:host-gateway"}, + } + + pts, err := s.pool.BuildAndRunWithBuildOptions( + tailscaleBuildOptions, + tailscaleOptions, + DockerRestartPolicy, + DockerAllowLocalIPv6, + DockerAllowNetworkAdministration, + ) + if err != nil { + log.Fatalf("Could not start resource: %s", err) + } + log.Printf("Created %s container\n", hostname) + + return hostname, pts +} + +func (s *IntegrationDERPTestSuite) TearDownSuite() { +} + +func (s *IntegrationDERPTestSuite) HandleStats( + suiteName string, + stats *suite.SuiteInformation, +) { + s.stats = stats +} + +func (s *IntegrationDERPTestSuite) saveLog( + resource *dockertest.Resource, + basePath string, +) error { + err := os.MkdirAll(basePath, os.ModePerm) + if err != nil { + return err + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + err = s.pool.Client.Logs( + docker.LogsOptions{ + Context: context.TODO(), + Container: resource.Container.ID, + OutputStream: &stdout, + ErrorStream: &stderr, + Tail: "all", + RawTerminal: false, + Stdout: true, + Stderr: true, + Follow: false, + Timestamps: false, + }, + ) + if err != nil { + return err + } + + log.Printf("Saving logs for %s to %s\n", resource.Container.Name, basePath) + + err = ioutil.WriteFile( + path.Join(basePath, resource.Container.Name+".stdout.log"), + []byte(stdout.String()), + 0o644, + ) + if err != nil { + return err + } + + err = ioutil.WriteFile( + path.Join(basePath, resource.Container.Name+".stderr.log"), + []byte(stdout.String()), + 0o644, + ) + if err != nil { + return err + } + + return nil +} + +func (s *IntegrationDERPTestSuite) TestPingAllPeersByHostname() { + ips, err := getIPs(s.tailscales) + assert.Nil(s.T(), err) + for hostname, tailscale := range s.tailscales { + for peername := range ips { + if peername == hostname { + continue + } + s.T().Run(fmt.Sprintf("%s-%s", hostname, peername), func(t *testing.T) { + command := []string{ + "tailscale", "ping", + "--timeout=10s", + "--c=5", + "--until-direct=false", + peername, + } + + log.Printf( + "Pinging using hostname from %s to %s\n", + hostname, + peername, + ) + log.Println(command) + result, err := ExecuteCommand( + &tailscale, + command, + []string{}, + ) + assert.Nil(t, err) + log.Printf("Result for %s: %s\n", hostname, result) + assert.Contains(t, result, "via DERP(headscale)") + }) + } + } +} + +func (s *IntegrationDERPTestSuite) TestDERPSTUN() { + headscaleSTUNAddr := fmt.Sprintf("localhost:%s", s.headscale.GetPort("3478/udp")) + client := stun.NewClient() + client.SetVerbose(true) + client.SetVVerbose(true) + client.SetServerAddr(headscaleSTUNAddr) + _, _, err := client.Discover() + assert.Nil(s.T(), err) +} diff --git a/integration_test.go b/integration_test.go index 03d6d2f2..1649f322 100644 --- a/integration_test.go +++ b/integration_test.go @@ -29,13 +29,6 @@ import ( "tailscale.com/ipn/ipnstate" ) -var tailscaleVersions = []string{"1.20.4", "1.18.2", "1.16.2", "1.14.3", "1.12.3"} - -type TestNamespace struct { - count int - tailscales map[string]dockertest.Resource -} - type IntegrationTestSuite struct { suite.Suite stats *suite.SuiteInformation @@ -687,38 +680,6 @@ func (s *IntegrationTestSuite) TestMagicDNS() { } } -func getIPs( - tailscales map[string]dockertest.Resource, -) (map[string][]netaddr.IP, error) { - ips := make(map[string][]netaddr.IP) - for hostname, tailscale := range tailscales { - command := []string{"tailscale", "ip"} - - result, err := ExecuteCommand( - &tailscale, - command, - []string{}, - ) - if err != nil { - return nil, err - } - - for _, address := range strings.Split(result, "\n") { - address = strings.TrimSuffix(address, "\n") - if len(address) < 1 { - continue - } - ip, err := netaddr.ParseIP(address) - if err != nil { - return nil, err - } - ips[hostname] = append(ips[hostname], ip) - } - } - - return ips, nil -} - func getAPIURLs( tailscales map[string]dockertest.Resource, ) (map[netaddr.IP]string, error) { diff --git a/integration_test/etc_embedded_derp/config.yaml b/integration_test/etc_embedded_derp/config.yaml new file mode 100644 index 00000000..1531d347 --- /dev/null +++ b/integration_test/etc_embedded_derp/config.yaml @@ -0,0 +1,29 @@ +log_level: trace +acl_policy_path: "" +db_type: sqlite3 +ephemeral_node_inactivity_timeout: 30m +ip_prefixes: + - fd7a:115c:a1e0::/48 + - 100.64.0.0/10 +dns_config: + base_domain: headscale.net + magic_dns: true + domains: [] + nameservers: + - 1.1.1.1 +db_path: /tmp/integration_test_db.sqlite3 +private_key_path: private.key +listen_addr: 0.0.0.0:8443 +server_url: https://headscale:8443 +tls_cert_path: "/etc/headscale/tls/server.crt" +tls_key_path: "/etc/headscale/tls/server.key" +tls_client_auth_mode: disabled +derp: + server: + enabled: true + region_id: 999 + region_code: "headscale" + region_name: "Headscale Embedded DERP" + stun: + enabled: true + listen_addr: "0.0.0.0:3478" diff --git a/integration_test/etc_embedded_derp/tls/server.crt b/integration_test/etc_embedded_derp/tls/server.crt new file mode 100644 index 00000000..95556495 --- /dev/null +++ b/integration_test/etc_embedded_derp/tls/server.crt @@ -0,0 +1,22 @@ + +-----BEGIN CERTIFICATE----- +MIIC8jCCAdqgAwIBAgIULbu+UbSTMG/LtxooLLh7BgSEyqEwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJaGVhZHNjYWxlMCAXDTIyMDMwNTE2NDgwM1oYDzI1MjEx +MTA0MTY0ODAzWjAUMRIwEAYDVQQDDAloZWFkc2NhbGUwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDqcfpToLZUF0rlNwXkkt3lbyw4Cl4TJdx36o2PKaOK +U+tze/IjRsCWeMwrcR1o9TNZcxsD+c2J48D1WATuQJlMeg+2UJXGaTGRKkkbPMy3 +5m7AFf/Q16UEOgm2NYjZaQ8faRGIMYURG/6sXmNeETJvBixpBev9yKJuVXgqHNS4 +NpEkNwdOCuAZXrmw0HCbiusawJOay4tFvhH14rav8Uimonl8UTNVXufMzyUOuoaQ +TGflmzYX3hIoswRnTPlIWFoqObvx2Q8H+of3uQJXy0m8I6OrIoXLNxnqYMfFls79 +9SYgVc2jPsCbh5fwyRbx2Hof7sIZ1K/mNgxJRG1E3ZiLAgMBAAGjOjA4MBQGA1Ud +EQQNMAuCCWhlYWRzY2FsZTALBgNVHQ8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUH +AwEwDQYJKoZIhvcNAQELBQADggEBANGlVN7NCsJaKz0k0nhlRGK+tcxn2p1PXN/i +Iy+JX8ahixPC4ocRwOhrXgb390ZXLLwq08HrWYRB/Wi1VUzCp5d8dVxvrR43dJ+v +L2EOBiIKgcu2C3pWW1qRR46/EoXUU9kSH2VNBvIhNufi32kEOidoDzxtQf6qVCoF +guUt1JkAqrynv1UvR/2ZRM/WzM/oJ8qfECwrwDxyYhkqU5Z5jCWg0C6kPIBvNdzt +B0eheWS+ZxVwkePTR4e17kIafwknth3lo+orxVrq/xC+OVM1bGrt2ZyD64ZvEqQl +w6kgbzBdLScAQptWOFThwhnJsg0UbYKimZsnYmjVEuN59TJv92M= +-----END CERTIFICATE----- + +(Expires on Nov 4 16:48:03 2521 GMT) + diff --git a/integration_test/etc_embedded_derp/tls/server.key b/integration_test/etc_embedded_derp/tls/server.key new file mode 100644 index 00000000..8a2df34b --- /dev/null +++ b/integration_test/etc_embedded_derp/tls/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQDqcfpToLZUF0rl +NwXkkt3lbyw4Cl4TJdx36o2PKaOKU+tze/IjRsCWeMwrcR1o9TNZcxsD+c2J48D1 +WATuQJlMeg+2UJXGaTGRKkkbPMy35m7AFf/Q16UEOgm2NYjZaQ8faRGIMYURG/6s +XmNeETJvBixpBev9yKJuVXgqHNS4NpEkNwdOCuAZXrmw0HCbiusawJOay4tFvhH1 +4rav8Uimonl8UTNVXufMzyUOuoaQTGflmzYX3hIoswRnTPlIWFoqObvx2Q8H+of3 +uQJXy0m8I6OrIoXLNxnqYMfFls799SYgVc2jPsCbh5fwyRbx2Hof7sIZ1K/mNgxJ +RG1E3ZiLAgMBAAECggEBALu1Ni/u5Qy++YA8ZcN0s6UXNdhItLmv/q0kZuLQ+9et +CT8VZfFInLndTdsaXenDKLHdryunviFA8SV+q7P2lMbek+Xs735EiyMnMBFWxLIZ +FWNGOeQERGL19QCmLEOmEi2b+iWJQHlKaMWpbPXL3w11a+lKjIBNO4ALfoJ5QveZ +cGMKsJdm/mpqBvLeNeh2eAFk3Gp6sT1g80Ge8NkgyzFBNIqnut0eerM15kPTc6Qz +12JLaOXUuV3PrcB4PN4nOwrTDg88GDNOQtc1Pc9r4nOHyLfr8X7QEtj1wXSwmOuK +d6ynMnAmoxVA9wEnupLbil1bzohRzpsTpkmDruYaBEECgYEA/Z09I8D6mt2NVqIE +KyvLjBK39ijSV9r3/lvB2Ple2OOL5YQEd+yTrIFy+3zdUnDgD1zmNnXjmjvHZ9Lc +IFf2o06AF84QLNB5gLPdDQkGNFdDqUxljBrfAfE3oANmPS/B0SijMGOOOiDO2FtO +xl1nfRr78mswuRs9awoUWCdNRKUCgYEA7KaTYKIQW/FEjw9lshp74q5vbn6zoXF5 +7N8VkwI+bBVNvRbM9XZ8qhfgRdu9eXs5oL/N4mSYY54I8fA//pJ0Z2vpmureMm1V +mL5WBUmSD9DIbAchoK+sRiQhVmNMBQC6cHMABA7RfXvBeGvWrm9pKCS6ZLgLjkjp +PsmAcaXQcW8CgYEA2inAxljjOwUK6FNGsrxhxIT1qtNC3kCGxE+6WSNq67gSR8Vg +8qiX//T7LEslOB3RIGYRwxd2St7RkgZZRZllmOWWWuPwFhzf6E7RAL2akLvggGov +kG4tGEagSw2hjVDfsUT73ExHtMk0Jfmlsg33UC8+PDLpHtLH6qQpDAwC8+ECgYEA +o+AqOIWhvHmT11l7O915Ip1WzvZwYADbxLsrDnVEUsZh4epTHjvh0kvcY6PqTqCV +ZIrOANNWb811Nkz/k8NJVoD08PFp0xPBbZeIq/qpachTsfMyRzq/mobUiyUR9Hjv +ooUQYr78NOApNsG+lWbTNBhS9wI4BlzZIECbcJe5g4MCgYEAndRoy8S+S0Hx/S8a +O3hzXeDmivmgWqn8NVD4AKOovpkz4PaIVVQbAQkiNfAx8/DavPvjEKAbDezJ4ECV +j7IsOWtDVI7pd6eF9fTcECwisrda8aUoiOap8AQb48153Vx+g2N4Vy3uH0xJs4cz +TDALZPOBg8VlV+HEFDP43sp9Bf0= +-----END PRIVATE KEY-----