mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-28 01:06:50 +09:00
Every client version gets a certificate from Pebble through rfc2136 and a TSIG-checked DNS server in the test process, then runs each serve mode; identity headers name the requesting user. Updates #2137 Updates #1921
464 lines
14 KiB
Go
464 lines
14 KiB
Go
package integration
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/juanfont/headscale/integration/dockertestutil"
|
|
"github.com/juanfont/headscale/integration/hsic"
|
|
"github.com/juanfont/headscale/integration/integrationutil"
|
|
"github.com/juanfont/headscale/integration/tsic"
|
|
"github.com/letsencrypt/pebble/v2/ca"
|
|
"github.com/letsencrypt/pebble/v2/db"
|
|
"github.com/letsencrypt/pebble/v2/va"
|
|
"github.com/letsencrypt/pebble/v2/wfe"
|
|
"github.com/miekg/dns"
|
|
"github.com/ory/dockertest/v3"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
var errNoSuiteAddr = errors.New("test suite has no address in the scenario network")
|
|
|
|
const (
|
|
webserverPort = 80
|
|
// webserverListing is in every page tsic's web server returns.
|
|
webserverListing = "Directory listing"
|
|
// headersPort is where headersEcho listens.
|
|
headersPort = 8081
|
|
// spoofedLogin is an identity header a client claims for itself.
|
|
spoofedLogin = "Tailscale-User-Login: mallory@test.no"
|
|
)
|
|
|
|
// headersEcho answers every request with the request's headers.
|
|
const headersEcho = `import sys
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
|
|
class Echo(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
self.wfile.write(str(self.headers).encode())
|
|
|
|
|
|
HTTPServer(("127.0.0.1", int(sys.argv[1])), Echo).serve_forever()
|
|
`
|
|
|
|
// TestTailscaleCert runs `tailscale cert` on every client version against
|
|
// headscale's real rfc2136 provider, then runs every `tailscale serve`
|
|
// mode on each and fetches it from another user's node. A TSIG-checked
|
|
// DNS server and Pebble (Let's Encrypt's test CA) run inside this test
|
|
// process; the containers reach them through the test suite's address in
|
|
// the scenario network.
|
|
//
|
|
// Not parallel: Pebble reads its knobs from the environment.
|
|
func TestTailscaleCert(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
|
|
spec := ScenarioSpec{
|
|
NodesPerUser: len(MustTestVersions),
|
|
Users: []string{"user1"},
|
|
}
|
|
|
|
scenario, err := NewScenario(spec)
|
|
require.NoError(t, err)
|
|
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
suiteIP, err := suiteAddrIn(scenario.Networks()[0])
|
|
require.NoError(t, err)
|
|
|
|
tsigKey := base64.StdEncoding.EncodeToString([]byte("headscale-integration-tsig-key"))
|
|
zone := startUpdateDNS(t, "headscale.", tsigKey)
|
|
directoryURL, pebbleCA, issuerRoot := startPebble(t, zone, suiteIP)
|
|
|
|
err = scenario.CreateHeadscaleEnv(
|
|
[]tsic.Option{
|
|
tsic.WithCACert(pebbleCA),
|
|
// So curl trusts certificates Pebble issues.
|
|
tsic.WithCACert(issuerRoot),
|
|
tsic.WithPackages("curl"),
|
|
// Backend for the serve proxies.
|
|
tsic.WithWebserver(webserverPort),
|
|
tsic.WithExtraCommands("export TS_DEBUG_ACME_DIRECTORY_URL=" + directoryURL),
|
|
},
|
|
hsic.WithTestName("cert"),
|
|
hsic.WithConfigEnv(map[string]string{
|
|
"HEADSCALE_DNS_CERTIFICATES_ENABLED": "true",
|
|
"HEADSCALE_DNS_CHALLENGE_PROVIDER_NAME": "rfc2136",
|
|
"HEADSCALE_DNS_CHALLENGE_PROVIDER_SERVER": net.JoinHostPort(suiteIP, zone.port),
|
|
"HEADSCALE_DNS_CHALLENGE_PROVIDER_KEY_NAME": "headscale.",
|
|
"HEADSCALE_DNS_CHALLENGE_PROVIDER_KEY_ALG": dns.HmacSHA256,
|
|
"HEADSCALE_DNS_CHALLENGE_PROVIDER_KEY": tsigKey,
|
|
"HEADSCALE_DNS_CHALLENGE_PROPAGATION_TIMEOUT": "0s",
|
|
}),
|
|
)
|
|
requireNoErrHeadscaleEnv(t, err)
|
|
|
|
allClients, err := scenario.ListTailscaleClients()
|
|
requireNoErrListClients(t, err)
|
|
|
|
roots := x509.NewCertPool()
|
|
require.True(t, roots.AppendCertsFromPEM(issuerRoot))
|
|
|
|
for _, client := range allClients {
|
|
fqdn, err := client.FQDN()
|
|
require.NoError(t, err)
|
|
|
|
fqdn = strings.TrimSuffix(fqdn, ".")
|
|
|
|
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
status, err := client.Status()
|
|
assert.NoError(c, err)
|
|
assert.Contains(c, status.CertDomains, fqdn)
|
|
}, 30*time.Second, 500*time.Millisecond, "%s should be offered a certificate name", client.Hostname())
|
|
|
|
_, stderr, err := client.Execute(
|
|
[]string{"tailscale", "cert", "--cert-file", "/tmp/cert.pem", "--key-file", "/tmp/key.pem", fqdn},
|
|
dockertestutil.ExecuteCommandTimeout(2*time.Minute),
|
|
)
|
|
require.NoError(t, err, "tailscale cert on %s: %s", client.Hostname(), stderr)
|
|
|
|
certPEM, err := client.ReadFile("/tmp/cert.pem")
|
|
require.NoError(t, err)
|
|
|
|
leaf, intermediates := parseChain(t, certPEM)
|
|
_, err = leaf.Verify(x509.VerifyOptions{
|
|
DNSName: fqdn,
|
|
Roots: roots,
|
|
Intermediates: intermediates,
|
|
})
|
|
require.NoError(t, err, "certificate for %s", fqdn)
|
|
}
|
|
|
|
// serve refuses HTTPS without the https cap. With it, every serve mode
|
|
// works: HTTPS from text or a proxy, plain HTTP, raw TCP, and TCP behind
|
|
// TLS with the certificate obtained above. Proxied requests carry the
|
|
// identity of the user-owned node that sent them, replacing any the
|
|
// client sent itself. The fetcher belongs to a second user, whose
|
|
// display name differs from its login, so the headers must name the
|
|
// requester rather than the serving node's owner, and login apart from
|
|
// name.
|
|
headscale, err := scenario.Headscale()
|
|
require.NoError(t, err)
|
|
|
|
_, err = headscale.Execute([]string{
|
|
"headscale", "users", "create", "user2",
|
|
"--email=user2@test.no", "--display-name=User Two",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
users, err := headscale.MapUsers()
|
|
require.NoError(t, err)
|
|
|
|
key, err := scenario.CreatePreAuthKey(mustParseID(users["user2"].Id), false, false)
|
|
require.NoError(t, err)
|
|
|
|
fetcher, err := scenario.CreateTailscaleNode("head",
|
|
tsic.WithNetwork(scenario.Networks()[0]),
|
|
tsic.WithCACert(issuerRoot),
|
|
tsic.WithPackages("curl"),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
scenario.GetOrCreateUser("user2").Clients[fetcher.Hostname()] = fetcher
|
|
|
|
require.NoError(t, fetcher.Login(headscale.GetEndpoint(), key.Key))
|
|
require.NoError(t, fetcher.WaitForRunning(integrationutil.PeerSyncTimeout()))
|
|
|
|
backend := fmt.Sprintf("127.0.0.1:%d", webserverPort)
|
|
headersBackend := fmt.Sprintf("127.0.0.1:%d", headersPort)
|
|
|
|
for _, client := range allClients {
|
|
fqdn, err := client.FQDN()
|
|
require.NoError(t, err)
|
|
|
|
fqdn = strings.TrimSuffix(fqdn, ".")
|
|
|
|
require.NoError(t, client.WriteFile("/tmp/headers.py", []byte(headersEcho)))
|
|
_, stderr, err := client.Execute([]string{
|
|
"sh", "-c", fmt.Sprintf("python3 /tmp/headers.py %d >/dev/null 2>&1 &", headersPort),
|
|
})
|
|
require.NoError(t, err, "starting headers echo on %s: %s", client.Hostname(), stderr)
|
|
|
|
for _, args := range [][]string{
|
|
{"text:" + client.Hostname()},
|
|
{"--https=8443", "http://" + backend},
|
|
{"--http=8080", "text:" + client.Hostname()},
|
|
{"--tcp=2222", "tcp://" + backend},
|
|
{"--tls-terminated-tcp=9443", "tcp://" + backend},
|
|
{"--https=8444", "http://" + headersBackend},
|
|
} {
|
|
_, stderr, err := client.Execute(append([]string{"tailscale", "serve", "--bg", "--yes"}, args...))
|
|
require.NoError(t, err, "tailscale serve %v on %s: %s", args, client.Hostname(), stderr)
|
|
}
|
|
|
|
for url, wants := range map[string][]string{
|
|
"https://" + fqdn: {client.Hostname()},
|
|
"https://" + fqdn + ":8443": {webserverListing},
|
|
"http://" + fqdn + ":8080": {client.Hostname()},
|
|
"http://" + fqdn + ":2222": {webserverListing},
|
|
"https://" + fqdn + ":9443": {webserverListing},
|
|
"https://" + fqdn + ":8444": {
|
|
"Tailscale-User-Login: user2@test.no",
|
|
"Tailscale-User-Name: User Two",
|
|
},
|
|
} {
|
|
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
body, _, err := fetcher.Execute([]string{
|
|
"curl", "--silent", "--fail", "--max-time", "10",
|
|
"--header", spoofedLogin,
|
|
"--connect-to", fqdn + "::" + client.MustIPv4().String() + ":", url,
|
|
}, dockertestutil.ExecuteCommandTimeout(20*time.Second))
|
|
assert.NoError(c, err)
|
|
|
|
for _, want := range wants {
|
|
assert.Contains(c, body, want)
|
|
}
|
|
|
|
assert.NotContains(c, body, spoofedLogin)
|
|
}, 60*time.Second, time.Second, "%s should be served", url)
|
|
}
|
|
}
|
|
}
|
|
|
|
// suiteAddrIn returns this test process's address in network, which the
|
|
// containers on that network can reach.
|
|
func suiteAddrIn(network *dockertest.Network) (string, error) {
|
|
addrs, err := net.InterfaceAddrs()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for _, cfg := range network.Network.IPAM.Config {
|
|
_, subnet, err := net.ParseCIDR(cfg.Subnet)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, addr := range addrs {
|
|
if ipnet, ok := addr.(*net.IPNet); ok && subnet.Contains(ipnet.IP) {
|
|
return ipnet.IP.String(), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", errNoSuiteAddr
|
|
}
|
|
|
|
// updateDNS is an authoritative DNS server for one zone that accepts
|
|
// RFC 2136 updates signed with a TSIG key, like a BIND zone with an
|
|
// update-policy, so an issued certificate proves headscale signs them. It
|
|
// serves TCP only: both rfc2136 and Pebble use TCP.
|
|
type updateDNS struct {
|
|
addr, port string
|
|
|
|
mu sync.Mutex
|
|
txt map[string][]string
|
|
}
|
|
|
|
func startUpdateDNS(t *testing.T, keyName, key string) *updateDNS {
|
|
t.Helper()
|
|
|
|
ln, err := net.Listen("tcp", ":0") //nolint:noctx,gosec // containers must reach it
|
|
require.NoError(t, err)
|
|
|
|
_, port, err := net.SplitHostPort(ln.Addr().String())
|
|
require.NoError(t, err)
|
|
|
|
z := &updateDNS{addr: net.JoinHostPort("127.0.0.1", port), port: port, txt: map[string][]string{}}
|
|
srv := &dns.Server{
|
|
Listener: ln,
|
|
TsigSecret: map[string]string{keyName: key},
|
|
Handler: z,
|
|
// The default rejects UPDATE as not implemented.
|
|
MsgAcceptFunc: func(dns.Header) dns.MsgAcceptAction { return dns.MsgAccept },
|
|
}
|
|
|
|
go func() { _ = srv.ActivateAndServe() }()
|
|
|
|
t.Cleanup(func() { _ = srv.Shutdown() })
|
|
|
|
return z
|
|
}
|
|
|
|
func (z *updateDNS) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
|
m := new(dns.Msg)
|
|
m.SetReply(r)
|
|
m.Authoritative = true
|
|
|
|
z.mu.Lock()
|
|
defer z.mu.Unlock()
|
|
|
|
if r.Opcode == dns.OpcodeUpdate {
|
|
if r.IsTsig() == nil || w.TsigStatus() != nil {
|
|
m.Rcode = dns.RcodeRefused
|
|
} else {
|
|
for _, rr := range r.Ns {
|
|
txt, ok := rr.(*dns.TXT)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
name := strings.ToLower(txt.Hdr.Name)
|
|
value := strings.Join(txt.Txt, "")
|
|
|
|
switch txt.Hdr.Class {
|
|
case dns.ClassINET:
|
|
z.txt[name] = append(z.txt[name], value)
|
|
case dns.ClassNONE:
|
|
z.txt[name] = slices.DeleteFunc(z.txt[name], func(v string) bool { return v == value })
|
|
}
|
|
}
|
|
|
|
// rfc2136 rejects an unsigned reply to a signed update.
|
|
tsig := r.IsTsig()
|
|
m.SetTsig(tsig.Hdr.Name, tsig.Algorithm, 300, time.Now().Unix())
|
|
}
|
|
|
|
_ = w.WriteMsg(m)
|
|
|
|
return
|
|
}
|
|
|
|
for _, q := range r.Question {
|
|
if q.Qtype != dns.TypeTXT {
|
|
continue
|
|
}
|
|
|
|
for _, v := range z.txt[strings.ToLower(q.Name)] {
|
|
m.Answer = append(m.Answer, &dns.TXT{
|
|
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60},
|
|
Txt: []string{v},
|
|
})
|
|
}
|
|
}
|
|
|
|
_ = w.WriteMsg(m)
|
|
}
|
|
|
|
// startPebble runs Pebble, validating DNS-01 challenges against resolver,
|
|
// and returns its ACME directory URL as the containers see it, the
|
|
// certificate they must trust to reach it, and the root of the
|
|
// certificates it issues. Pebble only accepts requests signed for an
|
|
// https URL, so it cannot run over plain HTTP.
|
|
func startPebble(t *testing.T, resolver *updateDNS, suiteIP string) (string, []byte, []byte) {
|
|
t.Helper()
|
|
|
|
// Validate at once and deterministically: no random delay, no
|
|
// rejected nonces, no authorization reuse across orders.
|
|
t.Setenv("PEBBLE_VA_NOSLEEP", "1")
|
|
t.Setenv("PEBBLE_WFE_NONCEREJECT", "0")
|
|
t.Setenv("PEBBLE_AUTHZREUSE", "0")
|
|
|
|
logger := log.New(os.Stderr, "pebble ", log.LstdFlags)
|
|
store := db.NewMemoryStore()
|
|
authority := ca.New(logger, store, "", "ecdsa", 0, 1, map[string]ca.Profile{
|
|
"default": {Description: "default"},
|
|
})
|
|
validator := va.New(logger, 0, 0, false, resolver.addr, store)
|
|
acme := wfe.New(logger, store, validator, authority, []string{"pebble.letsencrypt.org"}, false, false, 0, 0)
|
|
|
|
ln, err := net.Listen("tcp", ":0") //nolint:noctx,gosec // containers must reach it
|
|
require.NoError(t, err)
|
|
|
|
cert, certPEM := selfSignedCert(t, suiteIP)
|
|
srv := &http.Server{
|
|
Handler: withFinalizeLocation(acme.Handler()),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
|
}
|
|
|
|
go func() { _ = srv.ServeTLS(ln, "", "") }()
|
|
|
|
t.Cleanup(func() { _ = srv.Close() })
|
|
|
|
port := strconv.Itoa(ln.Addr().(*net.TCPAddr).Port) //nolint:forcetypeassert // tcp listener
|
|
|
|
issuerRoot := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: authority.GetRootCert(0).DER})
|
|
|
|
return "https://" + net.JoinHostPort(suiteIP, port) + wfe.DirectoryPath, certPEM, issuerRoot
|
|
}
|
|
|
|
// withFinalizeLocation adds the order URL as Location to finalize replies.
|
|
// The Tailscale client polls the order at that URL until the certificate
|
|
// is issued; Let's Encrypt sends it, Pebble does not.
|
|
func withFinalizeLocation(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if id, ok := strings.CutPrefix(r.URL.Path, "/finalize-order/"); ok {
|
|
w.Header().Set("Location", "https://"+r.Host+"/my-order/"+id)
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// selfSignedCert returns a certificate for ip that is its own CA.
|
|
func selfSignedCert(t *testing.T, ip string) (tls.Certificate, []byte) {
|
|
t.Helper()
|
|
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "pebble"},
|
|
IPAddresses: []net.IP{net.ParseIP(ip)},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
IsCA: true,
|
|
BasicConstraintsValid: true,
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
}
|
|
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
require.NoError(t, err)
|
|
|
|
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key},
|
|
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
}
|
|
|
|
// parseChain returns the leaf of a PEM chain and a pool of the rest.
|
|
func parseChain(t *testing.T, chainPEM []byte) (*x509.Certificate, *x509.CertPool) {
|
|
t.Helper()
|
|
|
|
var certs []*x509.Certificate
|
|
|
|
for block, rest := pem.Decode(chainPEM); block != nil; block, rest = pem.Decode(rest) {
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
require.NoError(t, err)
|
|
|
|
certs = append(certs, cert)
|
|
}
|
|
|
|
require.NotEmpty(t, certs, "no certificate in PEM")
|
|
|
|
intermediates := x509.NewCertPool()
|
|
for _, c := range certs[1:] {
|
|
intermediates.AddCert(c)
|
|
}
|
|
|
|
return certs[0], intermediates
|
|
}
|