Compare commits

...

15 commits

Author SHA1 Message Date
ArcticLampyrid
9264a4cd40
Merge 121be57b2d into 29119bb7f4 2024-11-18 05:47:39 +01:00
nblock
29119bb7f4
Misc doc fixes (#2240)
Some checks are pending
Build / build (push) Waiting to run
Build documentation / build (push) Waiting to run
Build documentation / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
* Link back to node registration docs
* adjust wording in apple docs
* Mention client specific page to check if headscale works

Ref: #2238
2024-11-18 05:46:58 +01:00
ArcticLampyrid
121be57b2d
refactor: make doVerify a seperated func 2024-11-17 01:57:04 +08:00
ArcticLampyrid
b39925f576
tests: fix TestDERPVerifyEndpoint 2024-11-17 01:47:30 +08:00
ArcticLampyrid
dfebd176df
refactor(verify-client): simplify error handling 2024-11-15 03:12:26 +08:00
ArcticLampyrid
bb79471139
tests(verify-client): perfer to use CreateHeadscaleEnv 2024-11-15 03:07:00 +08:00
ArcticLampyrid
28a40c0019
chore: cleanup 2024-11-15 02:59:05 +08:00
ArcticLampyrid
b0f074d18d
tests: fix derper failure 2024-11-15 02:50:02 +08:00
ArcticLampyrid
689c0983dc
ci: fix tests order 2024-11-15 02:34:58 +08:00
ArcticLampyrid
4f294c243c
tests(dsic): use string builder for cmd args 2024-11-15 02:33:48 +08:00
ArcticLampyrid
b3beb73f3f
refactor: introduce func ContainsNodeKey 2024-11-15 02:32:03 +08:00
ArcticLampyrid
b6adc84dc6
tests: use tailcfg.DERPMap instead of []byte 2024-11-15 02:31:36 +08:00
ArcticLampyrid
c726224e53
tests: add integration test for DERP verify endpoint 2024-11-15 02:19:52 +08:00
117503445
a5f27b2265
docs: fix doc for integration test 2024-11-15 02:19:52 +08:00
117503445
13454a3f2d
feat: support client verify for derp 2024-11-15 02:19:52 +08:00
17 changed files with 743 additions and 124 deletions

View file

@ -38,6 +38,7 @@ jobs:
- TestNodeMoveCommand - TestNodeMoveCommand
- TestPolicyCommand - TestPolicyCommand
- TestPolicyBrokenConfigCommand - TestPolicyBrokenConfigCommand
- TestDERPVerifyEndpoint
- TestResolveMagicDNS - TestResolveMagicDNS
- TestValidateResolvConf - TestValidateResolvConf
- TestDERPServerScenario - TestDERPServerScenario

19
Dockerfile.derper Normal file
View file

@ -0,0 +1,19 @@
# For testing purposes only
FROM golang:alpine AS build-env
WORKDIR /go/src
RUN apk add --no-cache git
ARG VERSION_BRANCH=main
RUN git clone https://github.com/tailscale/tailscale.git --branch=$VERSION_BRANCH --depth=1
WORKDIR /go/src/tailscale
ARG TARGETARCH
RUN GOARCH=$TARGETARCH go install -v ./cmd/derper
FROM alpine:3.18
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables curl
COPY --from=build-env /go/bin/* /usr/local/bin/
ENTRYPOINT [ "/usr/local/bin/derper" ]

View file

@ -45,11 +45,11 @@ headscale server.
ACLs have to be written in [huJSON](https://github.com/tailscale/hujson). ACLs have to be written in [huJSON](https://github.com/tailscale/hujson).
When registering the servers we will need to add the flag When [registering the servers](../usage/getting-started.md#register-a-node) we
`--advertise-tags=tag:<tag1>,tag:<tag2>`, and the user that is will need to add the flag `--advertise-tags=tag:<tag1>,tag:<tag2>`, and the user
registering the server should be allowed to do it. Since anyone can add tags to that is registering the server should be allowed to do it. Since anyone can add
a server they can register, the check of the tags is done on headscale server tags to a server they can register, the check of the tags is done on headscale
and only valid tags are applied. A tag is valid if the user that is server and only valid tags are applied. A tag is valid if the user that is
registering it is allowed to do it. registering it is allowed to do it.
To use ACLs in headscale, you must edit your `config.yaml` file. In there you will find a `policy.path` parameter. This will need to point to your ACL file. More info on how these policies are written can be found [here](https://tailscale.com/kb/1018/acls/). To use ACLs in headscale, you must edit your `config.yaml` file. In there you will find a `policy.path` parameter. This will need to point to your ACL file. More info on how these policies are written can be found [here](https://tailscale.com/kb/1018/acls/).

View file

@ -60,7 +60,7 @@ Install the official Tailscale tvOS client from the [App Store](https://apps.app
### Configuring the headscale URL ### Configuring the headscale URL
- Go Settings (the apple tvOS settings) > Apps > Tailscale - Open Settings (the Apple tvOS settings) > Apps > Tailscale
- Under `ALTERNATE COORDINATION SERVER URL`, select `URL` - Under `ALTERNATE COORDINATION SERVER URL`, select `URL`
- Enter the URL of your headscale instance (e.g `https://headscale.example.com`) and press `OK` - Enter the URL of your headscale instance (e.g `https://headscale.example.com`) and press `OK`
- Return to the tvOS Home screen - Return to the tvOS Home screen

View file

@ -9,6 +9,8 @@ This page helps you get started with headscale and provides a few usage examples
installation instructions. installation instructions.
* The configuration file exists and is adjusted to suit your environment, see * The configuration file exists and is adjusted to suit your environment, see
[Configuration](../ref/configuration.md) for details. [Configuration](../ref/configuration.md) for details.
* Headscale is reachable from the Internet. Verify this by opening client specific setup instructions in your
browser, e.g. https://headscale.example.com/windows
* The Tailscale client is installed, see [Client and operating system support](../about/clients.md) for more * The Tailscale client is installed, see [Client and operating system support](../about/clients.md) for more
information. information.

View file

@ -457,6 +457,8 @@ func (h *Headscale) createRouter(grpcMux *grpcRuntime.ServeMux) *mux.Router {
router.HandleFunc("/swagger/v1/openapiv2.json", headscale.SwaggerAPIv1). router.HandleFunc("/swagger/v1/openapiv2.json", headscale.SwaggerAPIv1).
Methods(http.MethodGet) Methods(http.MethodGet)
router.HandleFunc("/verify", h.VerifyHandler).Methods(http.MethodPost)
if h.cfg.DERP.ServerEnabled { if h.cfg.DERP.ServerEnabled {
router.HandleFunc("/derp", h.DERPServer.DERPHandler) router.HandleFunc("/derp", h.DERPServer.DERPHandler)
router.HandleFunc("/derp/probe", derpServer.DERPProbeHandler) router.HandleFunc("/derp/probe", derpServer.DERPProbeHandler)

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -56,6 +57,65 @@ func parseCabailityVersion(req *http.Request) (tailcfg.CapabilityVersion, error)
return tailcfg.CapabilityVersion(clientCapabilityVersion), nil return tailcfg.CapabilityVersion(clientCapabilityVersion), nil
} }
func (h *Headscale) handleVerifyRequest(
req *http.Request,
) (bool, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return false, 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)
}
nodes, err := h.db.ListNodes()
if err != nil {
return false, fmt.Errorf("cannot list nodes: %w", err)
}
return nodes.ContainsNodeKey(derpAdmitClientRequest.NodePublic), 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.
func (h *Headscale) VerifyHandler(
writer http.ResponseWriter,
req *http.Request,
) {
if req.Method != http.MethodPost {
http.Error(writer, "Wrong method", http.StatusMethodNotAllowed)
return
}
log.Debug().
Str("handler", "/verify").
Msg("verify client")
allow, err := h.handleVerifyRequest(req)
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to verify client")
http.Error(writer, "Internal error", http.StatusInternalServerError)
}
resp := tailcfg.DERPAdmitClientResponse{
Allow: allow,
}
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 // KeyHandler provides the Headscale pub key
// Listens in /key. // Listens in /key.
func (h *Headscale) KeyHandler( func (h *Headscale) KeyHandler(

View file

@ -223,6 +223,16 @@ func (nodes Nodes) FilterByIP(ip netip.Addr) Nodes {
return found return found
} }
func (nodes Nodes) ContainsNodeKey(nodeKey key.NodePublic) bool {
for _, node := range nodes {
if node.NodeKey == nodeKey {
return true
}
}
return false
}
func (node *Node) Proto() *v1.Node { func (node *Node) Proto() *v1.Node {
nodeProto := &v1.Node{ nodeProto := &v1.Node{
Id: uint64(node.ID), Id: uint64(node.ID),

View file

@ -11,10 +11,10 @@ Tests are located in files ending with `_test.go` and the framework are located
## Running integration tests locally ## Running integration tests locally
The easiest way to run tests locally is to use `[act](INSERT LINK)`, a local GitHub Actions runner: The easiest way to run tests locally is to use [act](https://github.com/nektos/act), a local GitHub Actions runner:
``` ```
act pull_request -W .github/workflows/test-integration-v2-TestPingAllByIP.yaml act pull_request -W .github/workflows/test-integration.yaml
``` ```
Alternatively, the `docker run` command in each GitHub workflow file can be used. Alternatively, the `docker run` command in each GitHub workflow file can be used.

View file

@ -0,0 +1,96 @@
package integration
import (
"encoding/json"
"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/tailcfg"
)
func TestDERPVerifyEndpoint(t *testing.T) {
IntegrationSkip(t)
// Generate random hostname for the headscale instance
hash, err := util.GenerateRandomStringDNSSafe(6)
assertNoErr(t, err)
testName := "derpverify"
hostname := fmt.Sprintf("hs-%s-%s", testName, hash)
headscalePort := 8080
// Create cert for headscale
certHeadscale, keyHeadscale, err := integrationutil.CreateCertificate(hostname)
assertNoErr(t, err)
scenario, err := NewScenario(dockertestMaxWait())
assertNoErr(t, err)
defer scenario.ShutdownAssertNoPanics(t)
spec := map[string]int{
"user1": len(MustTestVersions),
}
derper, err := scenario.CreateDERPServer("head",
dsic.WithCACert(certHeadscale),
dsic.WithVerifyClientURL(fmt.Sprintf("https://%s/verify", net.JoinHostPort(hostname, strconv.Itoa(headscalePort)))),
)
assertNoErr(t, err)
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(),
},
},
},
},
}
err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{tsic.WithCACert(derper.GetCert())},
hsic.WithHostname(hostname),
hsic.WithPort(headscalePort),
hsic.WithCustomTLS(certHeadscale, keyHeadscale),
hsic.WithHostnameAsServerURL(),
hsic.WithDERPConfig(derpMap))
assertNoErrHeadscaleEnv(t, err)
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
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))
}
}
}

321
integration/dsic/dsic.go Normal file
View file

@ -0,0 +1,321 @@
package dsic
import (
"crypto/tls"
"errors"
"fmt"
"log"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/integration/dockertestutil"
"github.com/juanfont/headscale/integration/integrationutil"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
)
const (
dsicHashLength = 6
dockerContextPath = "../."
caCertRoot = "/usr/local/share/ca-certificates"
DERPerCertRoot = "/usr/local/share/derper-certs"
dockerExecuteTimeout = 60 * time.Second
)
var errDERPerStatusCodeNotOk = errors.New("DERPer status code not OK")
// DERPServerInContainer represents DERP Server in Container (DSIC).
type DERPServerInContainer struct {
version string
hostname string
pool *dockertest.Pool
container *dockertest.Resource
network *dockertest.Network
stunPort int
derpPort int
caCerts [][]byte
tlsCert []byte
tlsKey []byte
withExtraHosts []string
withVerifyClientURL string
workdir string
}
// Option represent optional settings that can be given to a
// DERPer instance.
type Option = func(c *DERPServerInContainer)
// WithCACert adds it to the trusted surtificate of the Tailscale container.
func WithCACert(cert []byte) Option {
return func(dsic *DERPServerInContainer) {
dsic.caCerts = append(dsic.caCerts, cert)
}
}
// WithOrCreateNetwork sets the Docker container network to use with
// the DERPer instance, if the parameter is nil, a new network,
// isolating the DERPer, will be created. If a network is
// passed, the DERPer instance will join the given network.
func WithOrCreateNetwork(network *dockertest.Network) Option {
return func(tsic *DERPServerInContainer) {
if network != nil {
tsic.network = network
return
}
network, err := dockertestutil.GetFirstOrCreateNetwork(
tsic.pool,
tsic.hostname+"-network",
)
if err != nil {
log.Fatalf("failed to create network: %s", err)
}
tsic.network = network
}
}
// WithDockerWorkdir allows the docker working directory to be set.
func WithDockerWorkdir(dir string) Option {
return func(tsic *DERPServerInContainer) {
tsic.workdir = dir
}
}
// WithVerifyClientURL sets the URL to verify the client.
func WithVerifyClientURL(url string) Option {
return func(tsic *DERPServerInContainer) {
tsic.withVerifyClientURL = url
}
}
// WithExtraHosts adds extra hosts to the container.
func WithExtraHosts(hosts []string) Option {
return func(tsic *DERPServerInContainer) {
tsic.withExtraHosts = hosts
}
}
// New returns a new TailscaleInContainer instance.
func New(
pool *dockertest.Pool,
version string,
network *dockertest.Network,
opts ...Option,
) (*DERPServerInContainer, error) {
hash, err := util.GenerateRandomStringDNSSafe(dsicHashLength)
if err != nil {
return nil, err
}
hostname := fmt.Sprintf("derp-%s-%s", strings.ReplaceAll(version, ".", "-"), hash)
tlsCert, tlsKey, err := integrationutil.CreateCertificate(hostname)
if err != nil {
return nil, fmt.Errorf("failed to create certificates for headscale test: %w", err)
}
dsic := &DERPServerInContainer{
version: version,
hostname: hostname,
pool: pool,
network: network,
tlsCert: tlsCert,
tlsKey: tlsKey,
stunPort: 3478, //nolint
derpPort: 443, //nolint
}
for _, opt := range opts {
opt(dsic)
}
var cmdArgs strings.Builder
fmt.Fprintf(&cmdArgs, "--hostname=%s", hostname)
fmt.Fprintf(&cmdArgs, " --certmode=manual")
fmt.Fprintf(&cmdArgs, " --certdir=%s", DERPerCertRoot)
fmt.Fprintf(&cmdArgs, " --a=:%d", dsic.derpPort)
fmt.Fprintf(&cmdArgs, " --stun=true")
fmt.Fprintf(&cmdArgs, " --stun-port=%d", dsic.stunPort)
if dsic.withVerifyClientURL != "" {
fmt.Fprintf(&cmdArgs, " --verify-client-url=%s", dsic.withVerifyClientURL)
}
runOptions := &dockertest.RunOptions{
Name: hostname,
Networks: []*dockertest.Network{dsic.network},
ExtraHosts: dsic.withExtraHosts,
// we currently need to give us some time to inject the certificate further down.
Entrypoint: []string{"/bin/sh", "-c", "/bin/sleep 3 ; update-ca-certificates ; derper " + cmdArgs.String()},
ExposedPorts: []string{
"80/tcp",
fmt.Sprintf("%d/tcp", dsic.derpPort),
fmt.Sprintf("%d/udp", dsic.stunPort),
},
}
if dsic.workdir != "" {
runOptions.WorkingDir = dsic.workdir
}
// dockertest isnt very good at handling containers that has already
// been created, this is an attempt to make sure this container isnt
// present.
err = pool.RemoveContainerByName(hostname)
if err != nil {
return nil, err
}
var container *dockertest.Resource
buildOptions := &dockertest.BuildOptions{
Dockerfile: "Dockerfile.derper",
ContextDir: dockerContextPath,
BuildArgs: []docker.BuildArg{},
}
switch version {
case "head":
buildOptions.BuildArgs = append(buildOptions.BuildArgs, docker.BuildArg{
Name: "VERSION_BRANCH",
Value: "main",
})
default:
buildOptions.BuildArgs = append(buildOptions.BuildArgs, docker.BuildArg{
Name: "VERSION_BRANCH",
Value: "v" + version,
})
}
container, err = pool.BuildAndRunWithBuildOptions(
buildOptions,
runOptions,
dockertestutil.DockerRestartPolicy,
dockertestutil.DockerAllowLocalIPv6,
dockertestutil.DockerAllowNetworkAdministration,
)
if err != nil {
return nil, fmt.Errorf(
"%s could not start tailscale DERPer container (version: %s): %w",
hostname,
version,
err,
)
}
log.Printf("Created %s container\n", hostname)
dsic.container = container
for i, cert := range dsic.caCerts {
err = dsic.WriteFile(fmt.Sprintf("%s/user-%d.crt", caCertRoot, i), cert)
if err != nil {
return nil, fmt.Errorf("failed to write TLS certificate to container: %w", err)
}
}
if len(dsic.tlsCert) != 0 {
err = dsic.WriteFile(fmt.Sprintf("%s/%s.crt", DERPerCertRoot, dsic.hostname), dsic.tlsCert)
if err != nil {
return nil, fmt.Errorf("failed to write TLS certificate to container: %w", err)
}
}
if len(dsic.tlsKey) != 0 {
err = dsic.WriteFile(fmt.Sprintf("%s/%s.key", DERPerCertRoot, dsic.hostname), dsic.tlsKey)
if err != nil {
return nil, fmt.Errorf("failed to write TLS key to container: %w", err)
}
}
return dsic, nil
}
// Shutdown stops and cleans up the DERPer container.
func (t *DERPServerInContainer) Shutdown() error {
err := t.SaveLog("/tmp/control")
if err != nil {
log.Printf(
"Failed to save log from %s: %s",
t.hostname,
fmt.Errorf("failed to save log: %w", err),
)
}
return t.pool.Purge(t.container)
}
// GetCert returns the TLS certificate of the DERPer instance.
func (t *DERPServerInContainer) GetCert() []byte {
return t.tlsCert
}
// Hostname returns the hostname of the DERPer instance.
func (t *DERPServerInContainer) Hostname() string {
return t.hostname
}
// Version returns the running DERPer version of the instance.
func (t *DERPServerInContainer) Version() string {
return t.version
}
// ID returns the Docker container ID of the DERPServerInContainer
// instance.
func (t *DERPServerInContainer) ID() string {
return t.container.Container.ID
}
func (t *DERPServerInContainer) GetHostname() string {
return t.hostname
}
// GetSTUNPort returns the STUN port of the DERPer instance.
func (t *DERPServerInContainer) GetSTUNPort() int {
return t.stunPort
}
// GetDERPPort returns the DERP port of the DERPer instance.
func (t *DERPServerInContainer) GetDERPPort() int {
return t.derpPort
}
// WaitForRunning blocks until the DERPer instance is ready to be used.
func (t *DERPServerInContainer) WaitForRunning() error {
url := "https://" + net.JoinHostPort(t.GetHostname(), strconv.Itoa(t.GetDERPPort())) + "/"
log.Printf("waiting for DERPer to be ready at %s", url)
insecureTransport := http.DefaultTransport.(*http.Transport).Clone() //nolint
insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint
client := &http.Client{Transport: insecureTransport}
return t.pool.Retry(func() error {
resp, err := client.Get(url) //nolint
if err != nil {
return fmt.Errorf("headscale is not ready: %w", err)
}
if resp.StatusCode != http.StatusOK {
return errDERPerStatusCodeNotOk
}
return nil
})
}
// ConnectToNetwork connects the DERPer instance to a network.
func (t *DERPServerInContainer) ConnectToNetwork(network *dockertest.Network) error {
return t.container.ConnectToNetwork(network)
}
// WriteFile save file inside the container.
func (t *DERPServerInContainer) WriteFile(path string, data []byte) error {
return integrationutil.WriteFileToContainer(t.pool, t.container, path, data)
}
// SaveLog saves the current stdout log of the container to a path
// on the host system.
func (t *DERPServerInContainer) SaveLog(path string) error {
_, _, err := dockertestutil.SaveLog(t.pool, t.container, path)
return err
}

View file

@ -307,7 +307,7 @@ func (s *EmbeddedDERPServerScenario) CreateTailscaleIsolatedNodesInUser(
cert := hsServer.GetCert() cert := hsServer.GetCert()
opts = append(opts, opts = append(opts,
tsic.WithHeadscaleTLS(cert), tsic.WithCACert(cert),
) )
user.createWaitGroup.Go(func() error { user.createWaitGroup.Go(func() error {

View file

@ -1,19 +1,12 @@
package hsic package hsic
import ( import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls" "crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json" "encoding/json"
"encoding/pem"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log" "log"
"math/big"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@ -32,11 +25,14 @@ import (
"github.com/juanfont/headscale/integration/integrationutil" "github.com/juanfont/headscale/integration/integrationutil"
"github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker" "github.com/ory/dockertest/v3/docker"
"gopkg.in/yaml.v3"
"tailscale.com/tailcfg"
) )
const ( const (
hsicHashLength = 6 hsicHashLength = 6
dockerContextPath = "../." dockerContextPath = "../."
caCertRoot = "/usr/local/share/ca-certificates"
aclPolicyPath = "/etc/headscale/acl.hujson" aclPolicyPath = "/etc/headscale/acl.hujson"
tlsCertPath = "/etc/headscale/tls.cert" tlsCertPath = "/etc/headscale/tls.cert"
tlsKeyPath = "/etc/headscale/tls.key" tlsKeyPath = "/etc/headscale/tls.key"
@ -64,6 +60,7 @@ type HeadscaleInContainer struct {
// optional config // optional config
port int port int
extraPorts []string extraPorts []string
caCerts [][]byte
hostPortBindings map[string][]string hostPortBindings map[string][]string
aclPolicy *policy.ACLPolicy aclPolicy *policy.ACLPolicy
env map[string]string env map[string]string
@ -88,18 +85,29 @@ func WithACLPolicy(acl *policy.ACLPolicy) Option {
} }
} }
// WithCACert adds it to the trusted surtificate of the container.
func WithCACert(cert []byte) Option {
return func(hsic *HeadscaleInContainer) {
hsic.caCerts = append(hsic.caCerts, cert)
}
}
// WithTLS creates certificates and enables HTTPS. // WithTLS creates certificates and enables HTTPS.
func WithTLS() Option { func WithTLS() Option {
return func(hsic *HeadscaleInContainer) { return func(hsic *HeadscaleInContainer) {
cert, key, err := createCertificate(hsic.hostname) cert, key, err := integrationutil.CreateCertificate(hsic.hostname)
if err != nil { if err != nil {
log.Fatalf("failed to create certificates for headscale test: %s", err) log.Fatalf("failed to create certificates for headscale test: %s", err)
} }
// TODO(kradalby): Move somewhere appropriate hsic.tlsCert = cert
hsic.env["HEADSCALE_TLS_CERT_PATH"] = tlsCertPath hsic.tlsKey = key
hsic.env["HEADSCALE_TLS_KEY_PATH"] = tlsKeyPath }
}
// WithCustomTLS uses the given certificates for the Headscale instance.
func WithCustomTLS(cert, key []byte) Option {
return func(hsic *HeadscaleInContainer) {
hsic.tlsCert = cert hsic.tlsCert = cert
hsic.tlsKey = key hsic.tlsKey = key
} }
@ -146,6 +154,13 @@ func WithTestName(testName string) Option {
} }
} }
// WithHostname sets the hostname of the Headscale instance.
func WithHostname(hostname string) Option {
return func(hsic *HeadscaleInContainer) {
hsic.hostname = hostname
}
}
// WithHostnameAsServerURL sets the Headscale ServerURL based on // WithHostnameAsServerURL sets the Headscale ServerURL based on
// the Hostname. // the Hostname.
func WithHostnameAsServerURL() Option { func WithHostnameAsServerURL() Option {
@ -203,6 +218,34 @@ func WithEmbeddedDERPServerOnly() Option {
} }
} }
// WithDERPConfig configures Headscale use a custom
// DERP server only.
func WithDERPConfig(derpMap tailcfg.DERPMap) Option {
return func(hsic *HeadscaleInContainer) {
contents, err := yaml.Marshal(derpMap)
if err != nil {
log.Fatalf("failed to marshal DERP map: %s", err)
return
}
hsic.env["HEADSCALE_DERP_PATHS"] = "/etc/headscale/derp.yml"
hsic.filesInContainer = append(hsic.filesInContainer,
fileInContainer{
path: "/etc/headscale/derp.yml",
contents: contents,
})
// Disable global DERP server and embedded DERP server
hsic.env["HEADSCALE_DERP_URLS"] = ""
hsic.env["HEADSCALE_DERP_SERVER_ENABLED"] = "false"
// Envknob for enabling DERP debug logs
hsic.env["DERP_DEBUG_LOGS"] = "true"
hsic.env["DERP_PROBER_DEBUG_LOGS"] = "true"
}
}
// WithTuning allows changing the tuning settings easily. // WithTuning allows changing the tuning settings easily.
func WithTuning(batchTimeout time.Duration, mapSessionChanSize int) Option { func WithTuning(batchTimeout time.Duration, mapSessionChanSize int) Option {
return func(hsic *HeadscaleInContainer) { return func(hsic *HeadscaleInContainer) {
@ -300,6 +343,10 @@ func New(
"HEADSCALE_DEBUG_HIGH_CARDINALITY_METRICS=1", "HEADSCALE_DEBUG_HIGH_CARDINALITY_METRICS=1",
"HEADSCALE_DEBUG_DUMP_CONFIG=1", "HEADSCALE_DEBUG_DUMP_CONFIG=1",
} }
if hsic.hasTLS() {
hsic.env["HEADSCALE_TLS_CERT_PATH"] = tlsCertPath
hsic.env["HEADSCALE_TLS_KEY_PATH"] = tlsKeyPath
}
for key, value := range hsic.env { for key, value := range hsic.env {
env = append(env, fmt.Sprintf("%s=%s", key, value)) env = append(env, fmt.Sprintf("%s=%s", key, value))
} }
@ -313,7 +360,7 @@ func New(
// Cmd: []string{"headscale", "serve"}, // Cmd: []string{"headscale", "serve"},
// TODO(kradalby): Get rid of this hack, we currently need to give us some // TODO(kradalby): Get rid of this hack, we currently need to give us some
// to inject the headscale configuration further down. // to inject the headscale configuration further down.
Entrypoint: []string{"/bin/bash", "-c", "/bin/sleep 3 ; headscale serve ; /bin/sleep 30"}, Entrypoint: []string{"/bin/bash", "-c", "/bin/sleep 3 ; update-ca-certificates ; headscale serve ; /bin/sleep 30"},
Env: env, Env: env,
} }
@ -351,6 +398,14 @@ func New(
hsic.container = container hsic.container = container
// Write the CA certificates to the container
for i, cert := range hsic.caCerts {
err = hsic.WriteFile(fmt.Sprintf("%s/user-%d.crt", caCertRoot, i), cert)
if err != nil {
return nil, fmt.Errorf("failed to write TLS certificate to container: %w", err)
}
}
err = hsic.WriteFile("/etc/headscale/config.yaml", []byte(MinimumConfigYAML())) err = hsic.WriteFile("/etc/headscale/config.yaml", []byte(MinimumConfigYAML()))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to write headscale config to container: %w", err) return nil, fmt.Errorf("failed to write headscale config to container: %w", err)
@ -749,86 +804,3 @@ func (t *HeadscaleInContainer) SendInterrupt() error {
return nil return nil
} }
// nolint
func createCertificate(hostname string) ([]byte, []byte, error) {
// From:
// https://shaneutt.com/blog/golang-ca-and-signed-cert-go/
ca := &x509.Certificate{
SerialNumber: big.NewInt(2019),
Subject: pkix.Name{
Organization: []string{"Headscale testing INC"},
Country: []string{"NL"},
Locality: []string{"Leiden"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(60 * time.Hour),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageClientAuth,
x509.ExtKeyUsageServerAuth,
},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, err
}
cert := &x509.Certificate{
SerialNumber: big.NewInt(1658),
Subject: pkix.Name{
CommonName: hostname,
Organization: []string{"Headscale testing INC"},
Country: []string{"NL"},
Locality: []string{"Leiden"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(60 * time.Minute),
SubjectKeyId: []byte{1, 2, 3, 4, 6},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
DNSNames: []string{hostname},
}
certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, err
}
certBytes, err := x509.CreateCertificate(
rand.Reader,
cert,
ca,
&certPrivKey.PublicKey,
caPrivKey,
)
if err != nil {
return nil, nil, err
}
certPEM := new(bytes.Buffer)
err = pem.Encode(certPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
if err != nil {
return nil, nil, err
}
certPrivKeyPEM := new(bytes.Buffer)
err = pem.Encode(certPrivKeyPEM, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey),
})
if err != nil {
return nil, nil, err
}
return certPEM.Bytes(), certPrivKeyPEM.Bytes(), nil
}

View file

@ -3,9 +3,16 @@ package integrationutil
import ( import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt" "fmt"
"io" "io"
"math/big"
"path/filepath" "path/filepath"
"time"
"github.com/juanfont/headscale/integration/dockertestutil" "github.com/juanfont/headscale/integration/dockertestutil"
"github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3"
@ -93,3 +100,86 @@ func FetchPathFromContainer(
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// nolint
func CreateCertificate(hostname string) ([]byte, []byte, error) {
// From:
// https://shaneutt.com/blog/golang-ca-and-signed-cert-go/
ca := &x509.Certificate{
SerialNumber: big.NewInt(2019),
Subject: pkix.Name{
Organization: []string{"Headscale testing INC"},
Country: []string{"NL"},
Locality: []string{"Leiden"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(60 * time.Hour),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageClientAuth,
x509.ExtKeyUsageServerAuth,
},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, err
}
cert := &x509.Certificate{
SerialNumber: big.NewInt(1658),
Subject: pkix.Name{
CommonName: hostname,
Organization: []string{"Headscale testing INC"},
Country: []string{"NL"},
Locality: []string{"Leiden"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(60 * time.Minute),
SubjectKeyId: []byte{1, 2, 3, 4, 6},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
DNSNames: []string{hostname},
}
certPrivKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, err
}
certBytes, err := x509.CreateCertificate(
rand.Reader,
cert,
ca,
&certPrivKey.PublicKey,
caPrivKey,
)
if err != nil {
return nil, nil, err
}
certPEM := new(bytes.Buffer)
err = pem.Encode(certPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
if err != nil {
return nil, nil, err
}
certPrivKeyPEM := new(bytes.Buffer)
err = pem.Encode(certPrivKeyPEM, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(certPrivKey),
})
if err != nil {
return nil, nil, err
}
return certPEM.Bytes(), certPrivKeyPEM.Bytes(), nil
}

View file

@ -14,6 +14,7 @@ import (
v1 "github.com/juanfont/headscale/gen/go/headscale/v1" v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/integration/dockertestutil" "github.com/juanfont/headscale/integration/dockertestutil"
"github.com/juanfont/headscale/integration/dsic"
"github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic" "github.com/juanfont/headscale/integration/tsic"
"github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3"
@ -140,6 +141,7 @@ type Scenario struct {
// TODO(kradalby): support multiple headcales for later, currently only // TODO(kradalby): support multiple headcales for later, currently only
// use one. // use one.
controlServers *xsync.MapOf[string, ControlServer] controlServers *xsync.MapOf[string, ControlServer]
derpServers []*dsic.DERPServerInContainer
users map[string]*User users map[string]*User
@ -224,6 +226,13 @@ func (s *Scenario) ShutdownAssertNoPanics(t *testing.T) {
} }
} }
for _, derp := range s.derpServers {
err := derp.Shutdown()
if err != nil {
log.Printf("failed to tear down derp server: %s", err)
}
}
if err := s.pool.RemoveNetwork(s.network); err != nil { if err := s.pool.RemoveNetwork(s.network); err != nil {
log.Printf("failed to remove network: %s", err) log.Printf("failed to remove network: %s", err)
} }
@ -352,7 +361,7 @@ func (s *Scenario) CreateTailscaleNodesInUser(
hostname := headscale.GetHostname() hostname := headscale.GetHostname()
opts = append(opts, opts = append(opts,
tsic.WithHeadscaleTLS(cert), tsic.WithCACert(cert),
tsic.WithHeadscaleName(hostname), tsic.WithHeadscaleName(hostname),
) )
@ -651,3 +660,20 @@ func (s *Scenario) WaitForTailscaleLogout() error {
return nil return nil
} }
// CreateDERPServer creates a new DERP server in a container.
func (s *Scenario) CreateDERPServer(version string, opts ...dsic.Option) (*dsic.DERPServerInContainer, error) {
derp, err := dsic.New(s.pool, version, s.network, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create DERP server: %w", err)
}
err = derp.WaitForRunning()
if err != nil {
return nil, fmt.Errorf("failed to reach DERP server: %w", err)
}
s.derpServers = append(s.derpServers, derp)
return derp, nil
}

View file

@ -30,6 +30,7 @@ type TailscaleClient interface {
FQDN() (string, error) FQDN() (string, error)
Status(...bool) (*ipnstate.Status, error) Status(...bool) (*ipnstate.Status, error)
Netmap() (*netmap.NetworkMap, error) Netmap() (*netmap.NetworkMap, error)
DebugDERPRegion(region string) (*ipnstate.DebugDERPRegionReport, error)
Netcheck() (*netcheck.Report, error) Netcheck() (*netcheck.Report, error)
WaitForNeedsLogin() error WaitForNeedsLogin() error
WaitForRunning() error WaitForRunning() error

View file

@ -32,7 +32,7 @@ const (
defaultPingTimeout = 300 * time.Millisecond defaultPingTimeout = 300 * time.Millisecond
defaultPingCount = 10 defaultPingCount = 10
dockerContextPath = "../." dockerContextPath = "../."
headscaleCertPath = "/usr/local/share/ca-certificates/headscale.crt" caCertRoot = "/usr/local/share/ca-certificates"
dockerExecuteTimeout = 60 * time.Second dockerExecuteTimeout = 60 * time.Second
) )
@ -65,7 +65,7 @@ type TailscaleInContainer struct {
fqdn string fqdn string
// optional config // optional config
headscaleCert []byte caCerts [][]byte
headscaleHostname string headscaleHostname string
withWebsocketDERP bool withWebsocketDERP bool
withSSH bool withSSH bool
@ -80,11 +80,10 @@ type TailscaleInContainer struct {
// Tailscale instance. // Tailscale instance.
type Option = func(c *TailscaleInContainer) type Option = func(c *TailscaleInContainer)
// WithHeadscaleTLS takes the certificate of the Headscale instance // WithCACert adds it to the trusted surtificate of the Tailscale container.
// and adds it to the trusted surtificate of the Tailscale container. func WithCACert(cert []byte) Option {
func WithHeadscaleTLS(cert []byte) Option {
return func(tsic *TailscaleInContainer) { return func(tsic *TailscaleInContainer) {
tsic.headscaleCert = cert tsic.caCerts = append(tsic.caCerts, cert)
} }
} }
@ -113,7 +112,7 @@ func WithOrCreateNetwork(network *dockertest.Network) Option {
} }
// WithHeadscaleName set the name of the headscale instance, // WithHeadscaleName set the name of the headscale instance,
// mostly useful in combination with TLS and WithHeadscaleTLS. // mostly useful in combination with TLS and WithCACert.
func WithHeadscaleName(hsName string) Option { func WithHeadscaleName(hsName string) Option {
return func(tsic *TailscaleInContainer) { return func(tsic *TailscaleInContainer) {
tsic.headscaleHostname = hsName tsic.headscaleHostname = hsName
@ -225,12 +224,8 @@ func New(
) )
} }
if tsic.headscaleHostname != "" { tailscaleOptions.ExtraHosts = append(tailscaleOptions.ExtraHosts,
tailscaleOptions.ExtraHosts = []string{ "host.docker.internal:host-gateway")
"host.docker.internal:host-gateway",
fmt.Sprintf("%s:host-gateway", tsic.headscaleHostname),
}
}
if tsic.workdir != "" { if tsic.workdir != "" {
tailscaleOptions.WorkingDir = tsic.workdir tailscaleOptions.WorkingDir = tsic.workdir
@ -294,8 +289,8 @@ func New(
tsic.container = container tsic.container = container
if tsic.hasTLS() { for i, cert := range tsic.caCerts {
err = tsic.WriteFile(headscaleCertPath, tsic.headscaleCert) err = tsic.WriteFile(fmt.Sprintf("%s/user-%d.crt", caCertRoot, i), cert)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to write TLS certificate to container: %w", err) return nil, fmt.Errorf("failed to write TLS certificate to container: %w", err)
} }
@ -304,10 +299,6 @@ func New(
return tsic, nil return tsic, nil
} }
func (t *TailscaleInContainer) hasTLS() bool {
return len(t.headscaleCert) != 0
}
// Shutdown stops and cleans up the Tailscale container. // Shutdown stops and cleans up the Tailscale container.
func (t *TailscaleInContainer) Shutdown() error { func (t *TailscaleInContainer) Shutdown() error {
err := t.SaveLog("/tmp/control") err := t.SaveLog("/tmp/control")
@ -682,6 +673,34 @@ func (t *TailscaleInContainer) watchIPN(ctx context.Context) (*ipn.Notify, error
} }
} }
func (t *TailscaleInContainer) DebugDERPRegion(region string) (*ipnstate.DebugDERPRegionReport, error) {
if !util.TailscaleVersionNewerOrEqual("1.34", t.version) {
panic("tsic.DebugDERPRegion() called with unsupported version: " + t.version)
}
command := []string{
"tailscale",
"debug",
"derp",
region,
}
result, stderr, err := t.Execute(command)
if err != nil {
fmt.Printf("stderr: %s\n", stderr) // nolint
return nil, fmt.Errorf("failed to execute tailscale debug derp command: %w", err)
}
var report ipnstate.DebugDERPRegionReport
err = json.Unmarshal([]byte(result), &report)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal tailscale derp region report: %w", err)
}
return &report, err
}
// Netcheck returns the current Netcheck Report (netcheck.Report) of the Tailscale instance. // Netcheck returns the current Netcheck Report (netcheck.Report) of the Tailscale instance.
func (t *TailscaleInContainer) Netcheck() (*netcheck.Report, error) { func (t *TailscaleInContainer) Netcheck() (*netcheck.Report, error) {
command := []string{ command := []string{