Compare commits

...

23 Commits

Author SHA1 Message Date
Juan Font Alonso
2d79179141 Updated changelog 2022-11-15 21:28:26 +01:00
Juan Font Alonso
275cc28193 Do not strip nodekey prefix on handle expired 2022-11-15 21:28:26 +01:00
Juan Font
c5ba7552c5 Added more logging 2022-11-15 21:28:26 +01:00
Juan Font
8909f801bb Added more debug messages in OIDC registration 2022-11-15 21:28:26 +01:00
Steven Honson
3d4af52b3a Releases: use flavor to set the tag suffix 2022-11-15 11:36:38 +01:00
Juan Font
6391555dab Updated changelog 2022-11-15 08:42:29 +01:00
Juan Font
8cc5b2174b Remove Alpine Linux container 2022-11-15 08:42:29 +01:00
Juan Font
9269dd01f5 Move Tailscale old versions to TS2019 list 2022-11-14 23:06:30 +01:00
Juan Font
ef68f17a96 Return the correct error on cache miss 2022-11-14 18:34:27 +01:00
Juan Font
f74266f8f8 OIDC code cleanup and harmonize with regular web auth 2022-11-14 18:34:27 +01:00
Kristoffer Dalby
46df219ed3 Add testname identifier to hs container
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
835288d864 Remove unused variable
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
93d56362af Lock and unify headscale start/get method
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
4799859be0 Fix renamed method
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
8e44596171 less verbose command output
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
d479234058 Split ts versions into 2019/2021 for dedicated tests later
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
3fc5866de0 Remove duplicate function
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
f3c40086ac Make TLS setup work automatically
This commit injects the per-test-generated tls certs into the tailscale
container and makes sure all can ping all. It does not test any of the
DERP isolation yet.

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
09ed21edd8 Remove duplicate function
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
456479eaa1 Rename and move wait for headscale
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
cb87852825 Add nolint to gosec stuff that doesnt matter because test
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
69440058bb Clean up cert function
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
Kristoffer Dalby
9bc6ac0f35 Make TLS setup work automatically
This commit injects the per-test-generated tls certs into the tailscale
container and makes sure all can ping all. It does not test any of the
DERP isolation yet.

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2022-11-14 16:50:28 +01:00
14 changed files with 566 additions and 297 deletions

View File

@@ -65,7 +65,6 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value=latest
type=sha type=sha
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
@@ -125,13 +124,12 @@ jobs:
${{ secrets.DOCKERHUB_USERNAME }}/headscale ${{ secrets.DOCKERHUB_USERNAME }}/headscale
ghcr.io/${{ github.repository_owner }}/headscale ghcr.io/${{ github.repository_owner }}/headscale
flavor: | flavor: |
latest=false suffix=-debug,onlatest=true
tags: | tags: |
type=semver,pattern={{version}}-debug type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}-debug type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}-debug type=semver,pattern={{major}}
type=raw,value=latest-debug type=sha
type=sha,suffix=-debug
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
@@ -161,69 +159,3 @@ jobs:
run: | run: |
rm -rf /tmp/.buildx-cache-debug rm -rf /tmp/.buildx-cache-debug
mv /tmp/.buildx-cache-debug-new /tmp/.buildx-cache-debug mv /tmp/.buildx-cache-debug-new /tmp/.buildx-cache-debug
docker-alpine-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Set up QEMU for multiple platforms
uses: docker/setup-qemu-action@master
with:
platforms: arm64,amd64
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache-alpine
key: ${{ runner.os }}-buildx-alpine-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-alpine-
- name: Docker meta
id: meta-alpine
uses: docker/metadata-action@v3
with:
# list of Docker images to use as base name for tags
images: |
${{ secrets.DOCKERHUB_USERNAME }}/headscale
ghcr.io/${{ github.repository_owner }}/headscale
flavor: |
latest=false
tags: |
type=semver,pattern={{version}}-alpine
type=semver,pattern={{major}}.{{minor}}-alpine
type=semver,pattern={{major}}-alpine
type=raw,value=latest-alpine
type=sha,suffix=-alpine
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
push: true
context: .
file: Dockerfile.alpine
tags: ${{ steps.meta-alpine.outputs.tags }}
labels: ${{ steps.meta-alpine.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache-alpine
cache-to: type=local,dest=/tmp/.buildx-cache-alpine-new
build-args: |
VERSION=${{ steps.meta-alpine.outputs.version }}
- name: Prepare cache for next build
run: |
rm -rf /tmp/.buildx-cache-alpine
mv /tmp/.buildx-cache-alpine-new /tmp/.buildx-cache-alpine

View File

@@ -5,6 +5,7 @@
### BREAKING ### BREAKING
- Log level option `log_level` was moved to a distinct `log` config section and renamed to `level` [#768](https://github.com/juanfont/headscale/pull/768) - Log level option `log_level` was moved to a distinct `log` config section and renamed to `level` [#768](https://github.com/juanfont/headscale/pull/768)
- Removed Alpine Linux container image [#962](https://github.com/juanfont/headscale/pull/962)
### Changes ### Changes
@@ -25,6 +26,7 @@
- Add `dns_config.override_local_dns` option [#905](https://github.com/juanfont/headscale/pull/905) - Add `dns_config.override_local_dns` option [#905](https://github.com/juanfont/headscale/pull/905)
- Fix some DNS config issues [#660](https://github.com/juanfont/headscale/issues/660) - Fix some DNS config issues [#660](https://github.com/juanfont/headscale/issues/660)
- Make it possible to disable TS2019 with build flag [#928](https://github.com/juanfont/headscale/pull/928) - Make it possible to disable TS2019 with build flag [#928](https://github.com/juanfont/headscale/pull/928)
- Fix OIDC registration issues [#960](https://github.com/juanfont/headscale/pull/960) and [#971](https://github.com/juanfont/headscale/pull/971)
## 0.16.4 (2022-08-21) ## 0.16.4 (2022-08-21)

View File

@@ -1,24 +0,0 @@
# Builder image
FROM docker.io/golang:1.19.0-alpine AS build
ARG VERSION=dev
ENV GOPATH /go
WORKDIR /go/src/headscale
COPY go.mod go.sum /go/src/headscale/
RUN apk add gcc musl-dev
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go install -ldflags="-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=$VERSION" -a ./cmd/headscale
RUN strip /go/bin/headscale
RUN test -e /go/bin/headscale
# Production image
FROM docker.io/alpine:latest
COPY --from=build /go/bin/headscale /bin/headscale
ENV TZ UTC
EXPOSE 8080/tcp
CMD ["headscale"]

View File

@@ -10,6 +10,8 @@ import (
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"github.com/juanfont/headscale/integration/hsic"
) )
var errParseAuthPage = errors.New("failed to parse auth page") var errParseAuthPage = errors.New("failed to parse auth page")
@@ -35,7 +37,7 @@ func TestAuthWebFlowAuthenticationPingAll(t *testing.T) {
"namespace2": len(TailscaleVersions), "namespace2": len(TailscaleVersions),
} }
err = scenario.CreateHeadscaleEnv(spec) err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("webauthping"))
if err != nil { if err != nil {
t.Errorf("failed to create headscale environment: %s", err) t.Errorf("failed to create headscale environment: %s", err)
} }
@@ -76,13 +78,16 @@ func TestAuthWebFlowAuthenticationPingAll(t *testing.T) {
} }
} }
func (s *AuthWebFlowScenario) CreateHeadscaleEnv(namespaces map[string]int) error { func (s *AuthWebFlowScenario) CreateHeadscaleEnv(
err := s.StartHeadscale() namespaces map[string]int,
opts ...hsic.Option,
) error {
headscale, err := s.Headscale(opts...)
if err != nil { if err != nil {
return err return err
} }
err = s.Headscale().WaitForReady() err = headscale.WaitForReady()
if err != nil { if err != nil {
return err return err
} }
@@ -99,7 +104,7 @@ func (s *AuthWebFlowScenario) CreateHeadscaleEnv(namespaces map[string]int) erro
return err return err
} }
err = s.runTailscaleUp(namespaceName, s.Headscale().GetEndpoint()) err = s.runTailscaleUp(namespaceName, headscale.GetEndpoint())
if err != nil { if err != nil {
return err return err
} }
@@ -145,8 +150,13 @@ func (s *AuthWebFlowScenario) runTailscaleUp(
} }
func (s *AuthWebFlowScenario) runHeadscaleRegister(namespaceStr string, loginURL *url.URL) error { func (s *AuthWebFlowScenario) runHeadscaleRegister(namespaceStr string, loginURL *url.URL) error {
headscale, err := s.Headscale()
if err != nil {
return err
}
log.Printf("loginURL: %s", loginURL) log.Printf("loginURL: %s", loginURL)
loginURL.Host = fmt.Sprintf("%s:8080", s.Headscale().GetIP()) loginURL.Host = fmt.Sprintf("%s:8080", headscale.GetIP())
loginURL.Scheme = "http" loginURL.Scheme = "http"
httpClient := &http.Client{} httpClient := &http.Client{}
@@ -177,8 +187,10 @@ func (s *AuthWebFlowScenario) runHeadscaleRegister(namespaceStr string, loginURL
key := keySep[1] key := keySep[1]
log.Printf("registering node %s", key) log.Printf("registering node %s", key)
if headscale, ok := s.controlServers["headscale"]; ok { if headscale, err := s.Headscale(); err == nil {
_, err = headscale.Execute([]string{"headscale", "-n", namespaceStr, "nodes", "register", "--key", key}) _, err = headscale.Execute(
[]string{"headscale", "-n", namespaceStr, "nodes", "register", "--key", key},
)
if err != nil { if err != nil {
log.Printf("failed to register node: %s", err) log.Printf("failed to register node: %s", err)

View File

@@ -7,6 +7,7 @@ import (
"time" "time"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1" v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/integration/hsic"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -36,11 +37,14 @@ func TestNamespaceCommand(t *testing.T) {
"namespace2": 0, "namespace2": 0,
} }
err = scenario.CreateHeadscaleEnv(spec) err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clins"))
assert.NoError(t, err)
headscale, err := scenario.Headscale()
assert.NoError(t, err) assert.NoError(t, err)
var listNamespaces []v1.Namespace var listNamespaces []v1.Namespace
err = executeAndUnmarshal(scenario.Headscale(), err = executeAndUnmarshal(headscale,
[]string{ []string{
"headscale", "headscale",
"namespaces", "namespaces",
@@ -61,7 +65,7 @@ func TestNamespaceCommand(t *testing.T) {
result, result,
) )
_, err = scenario.Headscale().Execute( _, err = headscale.Execute(
[]string{ []string{
"headscale", "headscale",
"namespaces", "namespaces",
@@ -75,7 +79,7 @@ func TestNamespaceCommand(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
var listAfterRenameNamespaces []v1.Namespace var listAfterRenameNamespaces []v1.Namespace
err = executeAndUnmarshal(scenario.Headscale(), err = executeAndUnmarshal(headscale,
[]string{ []string{
"headscale", "headscale",
"namespaces", "namespaces",
@@ -114,7 +118,10 @@ func TestPreAuthKeyCommand(t *testing.T) {
namespace: 0, namespace: 0,
} }
err = scenario.CreateHeadscaleEnv(spec) err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clipak"))
assert.NoError(t, err)
headscale, err := scenario.Headscale()
assert.NoError(t, err) assert.NoError(t, err)
keys := make([]*v1.PreAuthKey, count) keys := make([]*v1.PreAuthKey, count)
@@ -123,7 +130,7 @@ func TestPreAuthKeyCommand(t *testing.T) {
for index := 0; index < count; index++ { for index := 0; index < count; index++ {
var preAuthKey v1.PreAuthKey var preAuthKey v1.PreAuthKey
err := executeAndUnmarshal( err := executeAndUnmarshal(
scenario.Headscale(), headscale,
[]string{ []string{
"headscale", "headscale",
"preauthkeys", "preauthkeys",
@@ -149,7 +156,7 @@ func TestPreAuthKeyCommand(t *testing.T) {
var listedPreAuthKeys []v1.PreAuthKey var listedPreAuthKeys []v1.PreAuthKey
err = executeAndUnmarshal( err = executeAndUnmarshal(
scenario.Headscale(), headscale,
[]string{ []string{
"headscale", "headscale",
"preauthkeys", "preauthkeys",
@@ -202,7 +209,7 @@ func TestPreAuthKeyCommand(t *testing.T) {
} }
// Test key expiry // Test key expiry
_, err = scenario.Headscale().Execute( _, err = headscale.Execute(
[]string{ []string{
"headscale", "headscale",
"preauthkeys", "preauthkeys",
@@ -216,7 +223,7 @@ func TestPreAuthKeyCommand(t *testing.T) {
var listedPreAuthKeysAfterExpire []v1.PreAuthKey var listedPreAuthKeysAfterExpire []v1.PreAuthKey
err = executeAndUnmarshal( err = executeAndUnmarshal(
scenario.Headscale(), headscale,
[]string{ []string{
"headscale", "headscale",
"preauthkeys", "preauthkeys",
@@ -251,12 +258,15 @@ func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) {
namespace: 0, namespace: 0,
} }
err = scenario.CreateHeadscaleEnv(spec) err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clipaknaexp"))
assert.NoError(t, err)
headscale, err := scenario.Headscale()
assert.NoError(t, err) assert.NoError(t, err)
var preAuthKey v1.PreAuthKey var preAuthKey v1.PreAuthKey
err = executeAndUnmarshal( err = executeAndUnmarshal(
scenario.Headscale(), headscale,
[]string{ []string{
"headscale", "headscale",
"preauthkeys", "preauthkeys",
@@ -273,7 +283,7 @@ func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) {
var listedPreAuthKeys []v1.PreAuthKey var listedPreAuthKeys []v1.PreAuthKey
err = executeAndUnmarshal( err = executeAndUnmarshal(
scenario.Headscale(), headscale,
[]string{ []string{
"headscale", "headscale",
"preauthkeys", "preauthkeys",
@@ -313,12 +323,15 @@ func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) {
namespace: 0, namespace: 0,
} }
err = scenario.CreateHeadscaleEnv(spec) err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("clipakresueeph"))
assert.NoError(t, err)
headscale, err := scenario.Headscale()
assert.NoError(t, err) assert.NoError(t, err)
var preAuthReusableKey v1.PreAuthKey var preAuthReusableKey v1.PreAuthKey
err = executeAndUnmarshal( err = executeAndUnmarshal(
scenario.Headscale(), headscale,
[]string{ []string{
"headscale", "headscale",
"preauthkeys", "preauthkeys",
@@ -335,7 +348,7 @@ func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) {
var preAuthEphemeralKey v1.PreAuthKey var preAuthEphemeralKey v1.PreAuthKey
err = executeAndUnmarshal( err = executeAndUnmarshal(
scenario.Headscale(), headscale,
[]string{ []string{
"headscale", "headscale",
"preauthkeys", "preauthkeys",
@@ -355,7 +368,7 @@ func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) {
var listedPreAuthKeys []v1.PreAuthKey var listedPreAuthKeys []v1.PreAuthKey
err = executeAndUnmarshal( err = executeAndUnmarshal(
scenario.Headscale(), headscale,
[]string{ []string{
"headscale", "headscale",
"preauthkeys", "preauthkeys",

View File

@@ -13,4 +13,7 @@ type ControlServer interface {
CreateNamespace(namespace string) error CreateNamespace(namespace string) error
CreateAuthKey(namespace string) (*v1.PreAuthKey, error) CreateAuthKey(namespace string) (*v1.PreAuthKey, error)
ListMachinesInNamespace(namespace string) ([]*v1.Machine, error) ListMachinesInNamespace(namespace string) ([]*v1.Machine, error)
GetCert() []byte
GetHostname() string
GetIP() string
} }

View File

@@ -6,6 +6,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/juanfont/headscale/integration/hsic"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -22,7 +23,7 @@ func TestPingAllByIP(t *testing.T) {
"namespace2": len(TailscaleVersions), "namespace2": len(TailscaleVersions),
} }
err = scenario.CreateHeadscaleEnv(spec) err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("pingallbyip"))
if err != nil { if err != nil {
t.Errorf("failed to create headscale environment: %s", err) t.Errorf("failed to create headscale environment: %s", err)
} }
@@ -77,7 +78,7 @@ func TestPingAllByHostname(t *testing.T) {
"namespace4": len(TailscaleVersions) - 1, "namespace4": len(TailscaleVersions) - 1,
} }
err = scenario.CreateHeadscaleEnv(spec) err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("pingallbyname"))
if err != nil { if err != nil {
t.Errorf("failed to create headscale environment: %s", err) t.Errorf("failed to create headscale environment: %s", err)
} }
@@ -144,7 +145,7 @@ func TestTaildrop(t *testing.T) {
"taildrop": len(TailscaleVersions) - 1, "taildrop": len(TailscaleVersions) - 1,
} }
err = scenario.CreateHeadscaleEnv(spec) err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("taildrop"))
if err != nil { if err != nil {
t.Errorf("failed to create headscale environment: %s", err) t.Errorf("failed to create headscale environment: %s", err)
} }
@@ -271,7 +272,7 @@ func TestResolveMagicDNS(t *testing.T) {
"magicdns2": len(TailscaleVersions) - 1, "magicdns2": len(TailscaleVersions) - 1,
} }
err = scenario.CreateHeadscaleEnv(spec) err = scenario.CreateHeadscaleEnv(spec, hsic.WithTestName("magicdns"))
if err != nil { if err != nil {
t.Errorf("failed to create headscale environment: %s", err) t.Errorf("failed to create headscale environment: %s", err)
} }

View File

@@ -1,52 +1,83 @@
package hsic package hsic
import ( import (
"archive/tar"
"bytes" "bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json" "encoding/json"
"encoding/pem"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"math/big"
"net"
"net/http" "net/http"
"path/filepath" "time"
"github.com/juanfont/headscale" "github.com/juanfont/headscale"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1" v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/integration/dockertestutil" "github.com/juanfont/headscale/integration/dockertestutil"
"github.com/juanfont/headscale/integration/integrationutil"
"github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
) )
const ( const (
hsicHashLength = 6 hsicHashLength = 6
dockerContextPath = "../." dockerContextPath = "../."
aclPolicyPath = "/etc/headscale/acl.hujson" aclPolicyPath = "/etc/headscale/acl.hujson"
tlsCertPath = "/etc/headscale/tls.cert"
tlsKeyPath = "/etc/headscale/tls.key"
headscaleDefaultPort = 8080
) )
var errHeadscaleStatusCodeNotOk = errors.New("headscale status code not ok") var errHeadscaleStatusCodeNotOk = errors.New("headscale status code not ok")
type HeadscaleInContainer struct { type HeadscaleInContainer struct {
hostname string hostname string
port int
pool *dockertest.Pool pool *dockertest.Pool
container *dockertest.Resource container *dockertest.Resource
network *dockertest.Network network *dockertest.Network
// optional config // optional config
port int
aclPolicy *headscale.ACLPolicy aclPolicy *headscale.ACLPolicy
env []string env []string
tlsCert []byte
tlsKey []byte
} }
type Option = func(c *HeadscaleInContainer) type Option = func(c *HeadscaleInContainer)
func WithACLPolicy(acl *headscale.ACLPolicy) Option { func WithACLPolicy(acl *headscale.ACLPolicy) Option {
return func(hsic *HeadscaleInContainer) { return func(hsic *HeadscaleInContainer) {
// TODO(kradalby): Move somewhere appropriate
hsic.env = append(hsic.env, fmt.Sprintf("HEADSCALE_ACL_POLICY_PATH=%s", aclPolicyPath))
hsic.aclPolicy = acl hsic.aclPolicy = acl
} }
} }
func WithTLS() Option {
return func(hsic *HeadscaleInContainer) {
cert, key, err := createCertificate()
if err != nil {
log.Fatalf("failed to create certificates for headscale test: %s", err)
}
// TODO(kradalby): Move somewhere appropriate
hsic.env = append(hsic.env, fmt.Sprintf("HEADSCALE_TLS_CERT_PATH=%s", tlsCertPath))
hsic.env = append(hsic.env, fmt.Sprintf("HEADSCALE_TLS_KEY_PATH=%s", tlsKeyPath))
hsic.env = append(hsic.env, "HEADSCALE_TLS_CLIENT_AUTH_MODE=disabled")
hsic.tlsCert = cert
hsic.tlsKey = key
}
}
func WithConfigEnv(configEnv map[string]string) Option { func WithConfigEnv(configEnv map[string]string) Option {
return func(hsic *HeadscaleInContainer) { return func(hsic *HeadscaleInContainer) {
env := []string{} env := []string{}
@@ -59,9 +90,23 @@ func WithConfigEnv(configEnv map[string]string) Option {
} }
} }
func WithPort(port int) Option {
return func(hsic *HeadscaleInContainer) {
hsic.port = port
}
}
func WithTestName(testName string) Option {
return func(hsic *HeadscaleInContainer) {
hash, _ := headscale.GenerateRandomStringDNSSafe(hsicHashLength)
hostname := fmt.Sprintf("hs-%s-%s", testName, hash)
hsic.hostname = hostname
}
}
func New( func New(
pool *dockertest.Pool, pool *dockertest.Pool,
port int,
network *dockertest.Network, network *dockertest.Network,
opts ...Option, opts ...Option,
) (*HeadscaleInContainer, error) { ) (*HeadscaleInContainer, error) {
@@ -71,11 +116,10 @@ func New(
} }
hostname := fmt.Sprintf("hs-%s", hash) hostname := fmt.Sprintf("hs-%s", hash)
portProto := fmt.Sprintf("%d/tcp", port)
hsic := &HeadscaleInContainer{ hsic := &HeadscaleInContainer{
hostname: hostname, hostname: hostname,
port: port, port: headscaleDefaultPort,
pool: pool, pool: pool,
network: network, network: network,
@@ -85,9 +129,9 @@ func New(
opt(hsic) opt(hsic)
} }
if hsic.aclPolicy != nil { log.Println("NAME: ", hsic.hostname)
hsic.env = append(hsic.env, fmt.Sprintf("HEADSCALE_ACL_POLICY_PATH=%s", aclPolicyPath))
} portProto := fmt.Sprintf("%d/tcp", hsic.port)
headscaleBuildOptions := &dockertest.BuildOptions{ headscaleBuildOptions := &dockertest.BuildOptions{
Dockerfile: "Dockerfile.debug", Dockerfile: "Dockerfile.debug",
@@ -95,7 +139,7 @@ func New(
} }
runOptions := &dockertest.RunOptions{ runOptions := &dockertest.RunOptions{
Name: hostname, Name: hsic.hostname,
ExposedPorts: []string{portProto}, ExposedPorts: []string{portProto},
Networks: []*dockertest.Network{network}, Networks: []*dockertest.Network{network},
// Cmd: []string{"headscale", "serve"}, // Cmd: []string{"headscale", "serve"},
@@ -108,7 +152,7 @@ func New(
// dockertest isnt very good at handling containers that has already // dockertest isnt very good at handling containers that has already
// been created, this is an attempt to make sure this container isnt // been created, this is an attempt to make sure this container isnt
// present. // present.
err = pool.RemoveContainerByName(hostname) err = pool.RemoveContainerByName(hsic.hostname)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -123,7 +167,7 @@ func New(
if err != nil { if err != nil {
return nil, fmt.Errorf("could not start headscale container: %w", err) return nil, fmt.Errorf("could not start headscale container: %w", err)
} }
log.Printf("Created %s container\n", hostname) log.Printf("Created %s container\n", hsic.hostname)
hsic.container = container hsic.container = container
@@ -144,9 +188,25 @@ func New(
} }
} }
if hsic.hasTLS() {
err = hsic.WriteFile(tlsCertPath, hsic.tlsCert)
if err != nil {
return nil, fmt.Errorf("failed to write TLS certificate to container: %w", err)
}
err = hsic.WriteFile(tlsKeyPath, hsic.tlsKey)
if err != nil {
return nil, fmt.Errorf("failed to write TLS key to container: %w", err)
}
}
return hsic, nil return hsic, nil
} }
func (t *HeadscaleInContainer) hasTLS() bool {
return len(t.tlsCert) != 0 && len(t.tlsKey) != 0
}
func (t *HeadscaleInContainer) Shutdown() error { func (t *HeadscaleInContainer) Shutdown() error {
return t.pool.Purge(t.container) return t.pool.Purge(t.container)
} }
@@ -154,8 +214,6 @@ func (t *HeadscaleInContainer) Shutdown() error {
func (t *HeadscaleInContainer) Execute( func (t *HeadscaleInContainer) Execute(
command []string, command []string,
) (string, error) { ) (string, error) {
log.Println("command", command)
log.Printf("running command for %s\n", t.hostname)
stdout, stderr, err := dockertestutil.ExecuteCommand( stdout, stderr, err := dockertestutil.ExecuteCommand(
t.container, t.container,
command, command,
@@ -164,13 +222,13 @@ func (t *HeadscaleInContainer) Execute(
if err != nil { if err != nil {
log.Printf("command stderr: %s\n", stderr) log.Printf("command stderr: %s\n", stderr)
return "", err
}
if stdout != "" { if stdout != "" {
log.Printf("command stdout: %s\n", stdout) log.Printf("command stdout: %s\n", stdout)
} }
return "", err
}
return stdout, nil return stdout, nil
} }
@@ -183,11 +241,7 @@ func (t *HeadscaleInContainer) GetPort() string {
} }
func (t *HeadscaleInContainer) GetHealthEndpoint() string { func (t *HeadscaleInContainer) GetHealthEndpoint() string {
hostEndpoint := fmt.Sprintf("%s:%d", return fmt.Sprintf("%s/health", t.GetEndpoint())
t.GetIP(),
t.port)
return fmt.Sprintf("http://%s/health", hostEndpoint)
} }
func (t *HeadscaleInContainer) GetEndpoint() string { func (t *HeadscaleInContainer) GetEndpoint() string {
@@ -195,17 +249,39 @@ func (t *HeadscaleInContainer) GetEndpoint() string {
t.GetIP(), t.GetIP(),
t.port) t.port)
if t.hasTLS() {
return fmt.Sprintf("https://%s", hostEndpoint)
}
return fmt.Sprintf("http://%s", hostEndpoint) return fmt.Sprintf("http://%s", hostEndpoint)
} }
func (t *HeadscaleInContainer) GetCert() []byte {
return t.tlsCert
}
func (t *HeadscaleInContainer) GetHostname() string {
return t.hostname
}
func (t *HeadscaleInContainer) WaitForReady() error { func (t *HeadscaleInContainer) WaitForReady() error {
url := t.GetHealthEndpoint() url := t.GetHealthEndpoint()
log.Printf("waiting for headscale to be ready at %s", url) log.Printf("waiting for headscale to be ready at %s", url)
client := &http.Client{}
if t.hasTLS() {
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 { return t.pool.Retry(func() error {
resp, err := http.Get(url) //nolint resp, err := client.Get(url) //nolint
if err != nil { if err != nil {
log.Printf("ready err: %s", err)
return fmt.Errorf("headscale is not ready: %w", err) return fmt.Errorf("headscale is not ready: %w", err)
} }
@@ -292,55 +368,87 @@ func (t *HeadscaleInContainer) ListMachinesInNamespace(
} }
func (t *HeadscaleInContainer) WriteFile(path string, data []byte) error { func (t *HeadscaleInContainer) WriteFile(path string, data []byte) error {
dirPath, fileName := filepath.Split(path) return integrationutil.WriteFileToContainer(t.pool, t.container, path, data)
file := bytes.NewReader(data)
buf := bytes.NewBuffer([]byte{})
tarWriter := tar.NewWriter(buf)
header := &tar.Header{
Name: fileName,
Size: file.Size(),
// Mode: int64(stat.Mode()),
// ModTime: stat.ModTime(),
} }
err := tarWriter.WriteHeader(header) //nolint
if err != nil { func createCertificate() ([]byte, []byte, error) {
return fmt.Errorf("failed write file header to tar: %w", err) // From:
} // https://shaneutt.com/blog/golang-ca-and-signed-cert-go/
_, err = io.Copy(tarWriter, file) ca := &x509.Certificate{
if err != nil { SerialNumber: big.NewInt(2019),
return fmt.Errorf("failed to copy file to tar: %w", err) Subject: pkix.Name{
} Organization: []string{"Headscale testing INC"},
Country: []string{"NL"},
err = tarWriter.Close() Locality: []string{"Leiden"},
if err != nil {
return fmt.Errorf("failed to close tar: %w", err)
}
log.Printf("tar: %s", buf.String())
// Ensure the directory is present inside the container
_, err = t.Execute([]string{"mkdir", "-p", dirPath})
if err != nil {
return fmt.Errorf("failed to ensure directory: %w", err)
}
err = t.pool.Client.UploadToContainer(
t.container.Container.ID,
docker.UploadToContainerOptions{
NoOverwriteDirNonDir: false,
Path: dirPath,
InputStream: bytes.NewReader(buf.Bytes()),
}, },
NotBefore: time.Now(),
NotAfter: time.Now().Add(30 * time.Minute),
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{
Organization: []string{"Headscale testing INC"},
Country: []string{"NL"},
Locality: []string{"Leiden"},
},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
NotBefore: time.Now(),
NotAfter: time.Now().Add(30 * time.Minute),
SubjectKeyId: []byte{1, 2, 3, 4, 6},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
}
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 { if err != nil {
return err return nil, nil, err
} }
return nil 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

@@ -0,0 +1,77 @@
package integrationutil
import (
"archive/tar"
"bytes"
"fmt"
"io"
"log"
"path/filepath"
"github.com/juanfont/headscale/integration/dockertestutil"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
)
func WriteFileToContainer(
pool *dockertest.Pool,
container *dockertest.Resource,
path string,
data []byte,
) error {
dirPath, fileName := filepath.Split(path)
file := bytes.NewReader(data)
buf := bytes.NewBuffer([]byte{})
tarWriter := tar.NewWriter(buf)
header := &tar.Header{
Name: fileName,
Size: file.Size(),
// Mode: int64(stat.Mode()),
// ModTime: stat.ModTime(),
}
err := tarWriter.WriteHeader(header)
if err != nil {
return fmt.Errorf("failed write file header to tar: %w", err)
}
_, err = io.Copy(tarWriter, file)
if err != nil {
return fmt.Errorf("failed to copy file to tar: %w", err)
}
err = tarWriter.Close()
if err != nil {
return fmt.Errorf("failed to close tar: %w", err)
}
log.Printf("tar: %s", buf.String())
// Ensure the directory is present inside the container
_, _, err = dockertestutil.ExecuteCommand(
container,
[]string{"mkdir", "-p", dirPath},
[]string{},
)
if err != nil {
return fmt.Errorf("failed to ensure directory: %w", err)
}
err = pool.Client.UploadToContainer(
container.Container.ID,
docker.UploadToContainerOptions{
NoOverwriteDirNonDir: false,
Path: dirPath,
InputStream: bytes.NewReader(buf.Bytes()),
},
)
if err != nil {
return err
}
return nil
}

View File

@@ -15,22 +15,28 @@ import (
"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"
"github.com/puzpuzpuz/xsync/v2"
) )
const ( const (
scenarioHashLength = 6 scenarioHashLength = 6
maxWait = 60 * time.Second maxWait = 60 * time.Second
headscalePort = 8080
) )
var ( var (
errNoHeadscaleAvailable = errors.New("no headscale available") errNoHeadscaleAvailable = errors.New("no headscale available")
errNoNamespaceAvailable = errors.New("no namespace available") errNoNamespaceAvailable = errors.New("no namespace available")
TailscaleVersions = []string{
// Tailscale started adding TS2021 support in CapabilityVersion>=28 (v1.24.0), but
// proper support in Headscale was only added for CapabilityVersion>=39 clients (v1.30.0).
tailscaleVersions2021 = []string{
"head", "head",
"unstable", "unstable",
"1.32.1", "1.32.1",
"1.30.2", "1.30.2",
}
tailscaleVersions2019 = []string{
"1.28.0", "1.28.0",
"1.26.2", "1.26.2",
"1.24.2", "1.24.2",
@@ -38,13 +44,20 @@ var (
"1.20.4", "1.20.4",
"1.18.2", "1.18.2",
"1.16.2", "1.16.2",
}
// These versions seem to fail when fetching from apt. // tailscaleVersionsUnavailable = []string{
// // These versions seem to fail when fetching from apt.
// "1.14.6", // "1.14.6",
// "1.12.4", // "1.12.4",
// "1.10.2", // "1.10.2",
// "1.8.7", // "1.8.7",
} // }.
TailscaleVersions = append(
tailscaleVersions2021,
tailscaleVersions2019...,
)
) )
type Namespace struct { type Namespace struct {
@@ -59,12 +72,14 @@ type Namespace struct {
type Scenario struct { 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 map[string]ControlServer controlServers *xsync.MapOf[string, ControlServer]
namespaces map[string]*Namespace namespaces map[string]*Namespace
pool *dockertest.Pool pool *dockertest.Pool
network *dockertest.Network network *dockertest.Network
headscaleLock sync.Mutex
} }
func NewScenario() (*Scenario, error) { func NewScenario() (*Scenario, error) {
@@ -99,7 +114,7 @@ func NewScenario() (*Scenario, error) {
} }
return &Scenario{ return &Scenario{
controlServers: make(map[string]ControlServer), controlServers: xsync.NewMapOf[ControlServer](),
namespaces: make(map[string]*Namespace), namespaces: make(map[string]*Namespace),
pool: pool, pool: pool,
@@ -108,13 +123,18 @@ func NewScenario() (*Scenario, error) {
} }
func (s *Scenario) Shutdown() error { func (s *Scenario) Shutdown() error {
for _, control := range s.controlServers { s.controlServers.Range(func(_ string, control ControlServer) bool {
err := control.Shutdown() err := control.Shutdown()
if err != nil { if err != nil {
return fmt.Errorf("failed to tear down control: %w", err) log.Printf(
} "Failed to shut down control: %s",
fmt.Errorf("failed to tear down control: %w", err),
)
} }
return true
})
for namespaceName, namespace := range s.namespaces { for namespaceName, namespace := range s.namespaces {
for _, client := range namespace.Clients { for _, client := range namespace.Clients {
log.Printf("removing client %s in namespace %s", client.Hostname(), namespaceName) log.Printf("removing client %s in namespace %s", client.Hostname(), namespaceName)
@@ -150,36 +170,31 @@ func (s *Scenario) Namespaces() []string {
// Note: These functions assume that there is a _single_ headscale instance for now // Note: These functions assume that there is a _single_ headscale instance for now
// TODO(kradalby): make port and headscale configurable, multiple instances support? // TODO(kradalby): make port and headscale configurable, multiple instances support?
func (s *Scenario) StartHeadscale() error { func (s *Scenario) Headscale(opts ...hsic.Option) (ControlServer, error) {
headscale, err := hsic.New(s.pool, headscalePort, s.network, s.headscaleLock.Lock()
hsic.WithACLPolicy( defer s.headscaleLock.Unlock()
&headscale.ACLPolicy{
ACLs: []headscale.ACL{ if headscale, ok := s.controlServers.Load("headscale"); ok {
{ return headscale, nil
Action: "accept", }
Sources: []string{"*"},
Destinations: []string{"*:*"}, headscale, err := hsic.New(s.pool, s.network, opts...)
},
},
},
),
)
if err != nil { if err != nil {
return fmt.Errorf("failed to create headscale container: %w", err) return nil, fmt.Errorf("failed to create headscale container: %w", err)
} }
s.controlServers["headscale"] = headscale err = headscale.WaitForReady()
if err != nil {
return nil return nil, fmt.Errorf("failed reach headscale container: %w", err)
} }
func (s *Scenario) Headscale() *hsic.HeadscaleInContainer { s.controlServers.Store("headscale", headscale)
//nolint
return s.controlServers["headscale"].(*hsic.HeadscaleInContainer) return headscale, nil
} }
func (s *Scenario) CreatePreAuthKey(namespace string) (*v1.PreAuthKey, error) { func (s *Scenario) CreatePreAuthKey(namespace string) (*v1.PreAuthKey, error) {
if headscale, ok := s.controlServers["headscale"]; ok { if headscale, err := s.Headscale(); err == nil {
key, err := headscale.CreateAuthKey(namespace) key, err := headscale.CreateAuthKey(namespace)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create namespace: %w", err) return nil, fmt.Errorf("failed to create namespace: %w", err)
@@ -192,7 +207,7 @@ func (s *Scenario) CreatePreAuthKey(namespace string) (*v1.PreAuthKey, error) {
} }
func (s *Scenario) CreateNamespace(namespace string) error { func (s *Scenario) CreateNamespace(namespace string) error {
if headscale, ok := s.controlServers["headscale"]; ok { if headscale, err := s.Headscale(); err == nil {
err := headscale.CreateNamespace(namespace) err := headscale.CreateNamespace(namespace)
if err != nil { if err != nil {
return fmt.Errorf("failed to create namespace: %w", err) return fmt.Errorf("failed to create namespace: %w", err)
@@ -222,16 +237,36 @@ func (s *Scenario) CreateTailscaleNodesInNamespace(
version = TailscaleVersions[i%len(TailscaleVersions)] version = TailscaleVersions[i%len(TailscaleVersions)]
} }
headscale, err := s.Headscale()
if err != nil {
return fmt.Errorf("failed to create tailscale node: %w", err)
}
cert := headscale.GetCert()
hostname := headscale.GetHostname()
namespace.createWaitGroup.Add(1) namespace.createWaitGroup.Add(1)
go func() { go func() {
defer namespace.createWaitGroup.Done() defer namespace.createWaitGroup.Done()
// TODO(kradalby): error handle this // TODO(kradalby): error handle this
tsClient, err := tsic.New(s.pool, version, s.network) tsClient, err := tsic.New(
s.pool,
version,
s.network,
tsic.WithHeadscaleTLS(cert),
tsic.WithHeadscaleName(hostname),
)
if err != nil { if err != nil {
// return fmt.Errorf("failed to add tailscale node: %w", err) // return fmt.Errorf("failed to add tailscale node: %w", err)
log.Printf("failed to add tailscale node: %s", err) log.Printf("failed to create tailscale node: %s", err)
}
err = tsClient.WaitForReady()
if err != nil {
// return fmt.Errorf("failed to add tailscale node: %w", err)
log.Printf("failed to wait for tailscaled: %s", err)
} }
namespace.Clients[tsClient.Hostname()] = tsClient namespace.Clients[tsClient.Hostname()] = tsClient
@@ -306,13 +341,8 @@ func (s *Scenario) WaitForTailscaleSync() error {
// CreateHeadscaleEnv is a conventient method returning a set up Headcale // CreateHeadscaleEnv is a conventient method returning a set up Headcale
// test environment with nodes of all versions, joined to the server with X // test environment with nodes of all versions, joined to the server with X
// namespaces. // namespaces.
func (s *Scenario) CreateHeadscaleEnv(namespaces map[string]int) error { func (s *Scenario) CreateHeadscaleEnv(namespaces map[string]int, opts ...hsic.Option) error {
err := s.StartHeadscale() headscale, err := s.Headscale(opts...)
if err != nil {
return err
}
err = s.Headscale().WaitForReady()
if err != nil { if err != nil {
return err return err
} }
@@ -333,7 +363,7 @@ func (s *Scenario) CreateHeadscaleEnv(namespaces map[string]int) error {
return err return err
} }
err = s.RunTailscaleUp(namespaceName, s.Headscale().GetEndpoint(), key.GetKey()) err = s.RunTailscaleUp(namespaceName, headscale.GetEndpoint(), key.GetKey())
if err != nil { if err != nil {
return err return err
} }

View File

@@ -34,12 +34,12 @@ func TestHeadscale(t *testing.T) {
} }
t.Run("start-headscale", func(t *testing.T) { t.Run("start-headscale", func(t *testing.T) {
err = scenario.StartHeadscale() headscale, err := scenario.Headscale()
if err != nil { if err != nil {
t.Errorf("failed to create start headcale: %s", err) t.Errorf("failed to create start headcale: %s", err)
} }
err = scenario.Headscale().WaitForReady() err = headscale.WaitForReady()
if err != nil { if err != nil {
t.Errorf("headscale failed to become ready: %s", err) t.Errorf("headscale failed to become ready: %s", err)
} }
@@ -117,12 +117,11 @@ func TestTailscaleNodesJoiningHeadcale(t *testing.T) {
} }
t.Run("start-headscale", func(t *testing.T) { t.Run("start-headscale", func(t *testing.T) {
err = scenario.StartHeadscale() headscale, err := scenario.Headscale()
if err != nil { if err != nil {
t.Errorf("failed to create start headcale: %s", err) t.Errorf("failed to create start headcale: %s", err)
} }
headscale := scenario.Headscale()
err = headscale.WaitForReady() err = headscale.WaitForReady()
if err != nil { if err != nil {
t.Errorf("headscale failed to become ready: %s", err) t.Errorf("headscale failed to become ready: %s", err)
@@ -157,7 +156,16 @@ func TestTailscaleNodesJoiningHeadcale(t *testing.T) {
t.Errorf("failed to create preauthkey: %s", err) t.Errorf("failed to create preauthkey: %s", err)
} }
err = scenario.RunTailscaleUp(namespace, scenario.Headscale().GetEndpoint(), key.GetKey()) headscale, err := scenario.Headscale()
if err != nil {
t.Errorf("failed to create start headcale: %s", err)
}
err = scenario.RunTailscaleUp(
namespace,
headscale.GetEndpoint(),
key.GetKey(),
)
if err != nil { if err != nil {
t.Errorf("failed to login: %s", err) t.Errorf("failed to login: %s", err)
} }

View File

@@ -12,6 +12,7 @@ import (
"github.com/cenkalti/backoff/v4" "github.com/cenkalti/backoff/v4"
"github.com/juanfont/headscale" "github.com/juanfont/headscale"
"github.com/juanfont/headscale/integration/dockertestutil" "github.com/juanfont/headscale/integration/dockertestutil"
"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"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
@@ -20,6 +21,7 @@ import (
const ( const (
tsicHashLength = 6 tsicHashLength = 6
dockerContextPath = "../." dockerContextPath = "../."
headscaleCertPath = "/usr/local/share/ca-certificates/headscale.crt"
) )
var ( var (
@@ -41,12 +43,51 @@ type TailscaleInContainer struct {
// "cache" // "cache"
ips []netip.Addr ips []netip.Addr
fqdn string fqdn string
// optional config
headscaleCert []byte
headscaleHostname string
}
type Option = func(c *TailscaleInContainer)
func WithHeadscaleTLS(cert []byte) Option {
return func(tsic *TailscaleInContainer) {
tsic.headscaleCert = cert
}
}
func WithOrCreateNetwork(network *dockertest.Network) Option {
return func(tsic *TailscaleInContainer) {
if network != nil {
tsic.network = network
return
}
network, err := dockertestutil.GetFirstOrCreateNetwork(
tsic.pool,
fmt.Sprintf("%s-network", tsic.hostname),
)
if err != nil {
log.Fatalf("failed to create network: %s", err)
}
tsic.network = network
}
}
func WithHeadscaleName(hsName string) Option {
return func(tsic *TailscaleInContainer) {
tsic.headscaleHostname = hsName
}
} }
func New( func New(
pool *dockertest.Pool, pool *dockertest.Pool,
version string, version string,
network *dockertest.Network, network *dockertest.Network,
opts ...Option,
) (*TailscaleInContainer, error) { ) (*TailscaleInContainer, error) {
hash, err := headscale.GenerateRandomStringDNSSafe(tsicHashLength) hash, err := headscale.GenerateRandomStringDNSSafe(tsicHashLength)
if err != nil { if err != nil {
@@ -55,20 +96,38 @@ func New(
hostname := fmt.Sprintf("ts-%s-%s", strings.ReplaceAll(version, ".", "-"), hash) hostname := fmt.Sprintf("ts-%s-%s", strings.ReplaceAll(version, ".", "-"), hash)
// TODO(kradalby): figure out why we need to "refresh" the network here. tsic := &TailscaleInContainer{
// network, err = dockertestutil.GetFirstOrCreateNetwork(pool, network.Network.Name) version: version,
// if err != nil { hostname: hostname,
// return nil, err
// } pool: pool,
network: network,
}
for _, opt := range opts {
opt(tsic)
}
tailscaleOptions := &dockertest.RunOptions{ tailscaleOptions := &dockertest.RunOptions{
Name: hostname, Name: hostname,
Networks: []*dockertest.Network{network}, Networks: []*dockertest.Network{network},
Cmd: []string{ // Cmd: []string{
"tailscaled", "--tun=tsdev", // "tailscaled", "--tun=tsdev",
// },
Entrypoint: []string{
"/bin/bash",
"-c",
"/bin/sleep 3 ; update-ca-certificates ; tailscaled --tun=tsdev",
}, },
} }
if tsic.headscaleHostname != "" {
tailscaleOptions.ExtraHosts = []string{
"host.docker.internal:host-gateway",
fmt.Sprintf("%s:host-gateway", tsic.headscaleHostname),
}
}
// dockertest isnt very good at handling containers that has already // dockertest isnt very good at handling containers that has already
// been created, this is an attempt to make sure this container isnt // been created, this is an attempt to make sure this container isnt
// present. // present.
@@ -89,14 +148,20 @@ func New(
} }
log.Printf("Created %s container\n", hostname) log.Printf("Created %s container\n", hostname)
return &TailscaleInContainer{ tsic.container = container
version: version,
hostname: hostname,
pool: pool, if tsic.hasTLS() {
container: container, err = tsic.WriteFile(headscaleCertPath, tsic.headscaleCert)
network: network, if err != nil {
}, nil return nil, fmt.Errorf("failed to write TLS certificate to container: %w", err)
}
}
return tsic, nil
}
func (t *TailscaleInContainer) hasTLS() bool {
return len(t.headscaleCert) != 0
} }
func (t *TailscaleInContainer) Shutdown() error { func (t *TailscaleInContainer) Shutdown() error {
@@ -114,8 +179,6 @@ func (t *TailscaleInContainer) Version() string {
func (t *TailscaleInContainer) Execute( func (t *TailscaleInContainer) Execute(
command []string, command []string,
) (string, string, error) { ) (string, string, error) {
log.Println("command", command)
log.Printf("running command for %s\n", t.hostname)
stdout, stderr, err := dockertestutil.ExecuteCommand( stdout, stderr, err := dockertestutil.ExecuteCommand(
t.container, t.container,
command, command,
@@ -318,6 +381,10 @@ func (t *TailscaleInContainer) Ping(hostnameOrIP string) error {
}) })
} }
func (t *TailscaleInContainer) WriteFile(path string, data []byte) error {
return integrationutil.WriteFileToContainer(t.pool, t.container, path, data)
}
func createTailscaleBuildOptions(version string) *dockertest.BuildOptions { func createTailscaleBuildOptions(version string) *dockertest.BuildOptions {
var tailscaleBuildOptions *dockertest.BuildOptions var tailscaleBuildOptions *dockertest.BuildOptions
switch version { switch version {

92
oidc.go
View File

@@ -76,19 +76,54 @@ func (h *Headscale) RegisterOIDC(
) { ) {
vars := mux.Vars(req) vars := mux.Vars(req)
nodeKeyStr, ok := vars["nkey"] nodeKeyStr, ok := vars["nkey"]
if !ok || nodeKeyStr == "" {
log.Debug().
Caller().
Str("node_key", nodeKeyStr).
Bool("ok", ok).
Msg("Received oidc register call")
if !NodePublicKeyRegex.Match([]byte(nodeKeyStr)) {
log.Warn().Str("node_key", nodeKeyStr).Msg("Invalid node key passed to registration url")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusUnauthorized)
_, err := writer.Write([]byte("Unauthorized"))
if err != nil {
log.Error(). log.Error().
Caller(). Caller().
Msg("Missing node key in URL") Err(err).
http.Error(writer, "Missing node key in URL", http.StatusBadRequest) Msg("Failed to write response")
}
return return
} }
log.Trace(). // We need to make sure we dont open for XSS style injections, if the parameter that
// is passed as a key is not parsable/validated as a NodePublic key, then fail to render
// the template and log an error.
var nodeKey key.NodePublic
err := nodeKey.UnmarshalText(
[]byte(NodePublicKeyEnsurePrefix(nodeKeyStr)),
)
if !ok || nodeKeyStr == "" || err != nil {
log.Warn().
Err(err).
Msg("Failed to parse incoming nodekey in OIDC registration")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest)
_, err := writer.Write([]byte("Wrong params"))
if err != nil {
log.Error().
Caller(). Caller().
Str("node_key", nodeKeyStr). Err(err).
Msg("Received oidc register call") Msg("Failed to write response")
}
return
}
randomBlob := make([]byte, randomByteSize) randomBlob := make([]byte, randomByteSize)
if _, err := rand.Read(randomBlob); err != nil { if _, err := rand.Read(randomBlob); err != nil {
@@ -103,7 +138,7 @@ func (h *Headscale) RegisterOIDC(
stateStr := hex.EncodeToString(randomBlob)[:32] stateStr := hex.EncodeToString(randomBlob)[:32]
// place the node key into the state cache, so it can be retrieved later // place the node key into the state cache, so it can be retrieved later
h.registrationCache.Set(stateStr, nodeKeyStr, registerCacheExpiration) h.registrationCache.Set(stateStr, NodePublicKeyStripPrefix(nodeKey), registerCacheExpiration)
// Add any extra parameter provided in the configuration to the Authorize Endpoint request // Add any extra parameter provided in the configuration to the Authorize Endpoint request
extras := make([]oauth2.AuthCodeOption, 0, len(h.cfg.OIDC.ExtraParams)) extras := make([]oauth2.AuthCodeOption, 0, len(h.cfg.OIDC.ExtraParams))
@@ -405,8 +440,8 @@ func (h *Headscale) validateMachineForOIDCCallback(
claims *IDTokenClaims, claims *IDTokenClaims,
) (*key.NodePublic, bool, error) { ) (*key.NodePublic, bool, error) {
// retrieve machinekey from state cache // retrieve machinekey from state cache
machineKeyIf, machineKeyFound := h.registrationCache.Get(state) nodeKeyIf, nodeKeyFound := h.registrationCache.Get(state)
if !machineKeyFound { if !nodeKeyFound {
log.Error(). log.Error().
Msg("requested machine state key expired before authorisation completed") Msg("requested machine state key expired before authorisation completed")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
@@ -419,20 +454,38 @@ func (h *Headscale) validateMachineForOIDCCallback(
Msg("Failed to write response") Msg("Failed to write response")
} }
return nil, false, errOIDCInvalidMachineState return nil, false, errOIDCNodeKeyMissing
} }
var nodeKey key.NodePublic var nodeKey key.NodePublic
nodeKeyFromCache, nodeKeyOK := machineKeyIf.(string) nodeKeyFromCache, nodeKeyOK := nodeKeyIf.(string)
if !nodeKeyOK {
log.Error().
Msg("requested machine state key is not a string")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest)
_, err := writer.Write([]byte("state is invalid"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
return nil, false, errOIDCInvalidMachineState
}
err := nodeKey.UnmarshalText( err := nodeKey.UnmarshalText(
[]byte(NodePublicKeyEnsurePrefix(nodeKeyFromCache)), []byte(NodePublicKeyEnsurePrefix(nodeKeyFromCache)),
) )
if err != nil { if err != nil {
log.Error(). log.Error().
Str("nodeKey", nodeKeyFromCache).
Bool("nodeKeyOK", nodeKeyOK).
Msg("could not parse node public key") Msg("could not parse node public key")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusBadRequest) writer.WriteHeader(http.StatusBadRequest)
_, werr := writer.Write([]byte("could not parse public key")) _, werr := writer.Write([]byte("could not parse node public key"))
if werr != nil { if werr != nil {
log.Error(). log.Error().
Caller(). Caller().
@@ -443,21 +496,6 @@ func (h *Headscale) validateMachineForOIDCCallback(
return nil, false, err return nil, false, err
} }
if !nodeKeyOK {
log.Error().Msg("could not get node key from cache")
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(http.StatusInternalServerError)
_, err := writer.Write([]byte("could not get node key from cache"))
if err != nil {
log.Error().
Caller().
Err(err).
Msg("Failed to write response")
}
return nil, false, errOIDCNodeKeyMissing
}
// retrieve machine information if it exist // retrieve machine information if it exist
// The error is not important, because if it does not // The error is not important, because if it does not
// exist, then this is a new machine and we will move // exist, then this is a new machine and we will move

View File

@@ -490,6 +490,7 @@ func (h *Headscale) handleNewMachineCommon(
Bool("noise", machineKey.IsZero()). Bool("noise", machineKey.IsZero()).
Str("machine", registerRequest.Hostinfo.Hostname). Str("machine", registerRequest.Hostinfo.Hostname).
Msg("The node seems to be new, sending auth url") Msg("The node seems to be new, sending auth url")
if h.oauth2Config != nil { if h.oauth2Config != nil {
resp.AuthURL = fmt.Sprintf( resp.AuthURL = fmt.Sprintf(
"%s/oidc/register/%s", "%s/oidc/register/%s",
@@ -528,6 +529,7 @@ func (h *Headscale) handleNewMachineCommon(
log.Info(). log.Info().
Caller(). Caller().
Bool("noise", machineKey.IsZero()). Bool("noise", machineKey.IsZero()).
Str("AuthURL", resp.AuthURL).
Str("machine", registerRequest.Hostinfo.Hostname). Str("machine", registerRequest.Hostinfo.Hostname).
Msg("Successfully sent auth url") Msg("Successfully sent auth url")
} }
@@ -726,7 +728,7 @@ func (h *Headscale) handleMachineExpiredCommon(
if h.oauth2Config != nil { if h.oauth2Config != nil {
resp.AuthURL = fmt.Sprintf("%s/oidc/register/%s", resp.AuthURL = fmt.Sprintf("%s/oidc/register/%s",
strings.TrimSuffix(h.cfg.ServerURL, "/"), strings.TrimSuffix(h.cfg.ServerURL, "/"),
NodePublicKeyStripPrefix(registerRequest.NodeKey)) registerRequest.NodeKey)
} else { } else {
resp.AuthURL = fmt.Sprintf("%s/register/%s", resp.AuthURL = fmt.Sprintf("%s/register/%s",
strings.TrimSuffix(h.cfg.ServerURL, "/"), strings.TrimSuffix(h.cfg.ServerURL, "/"),