mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-28 01:06:50 +09:00
integration: add tailscale cert test
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
This commit is contained in:
1 parent
eef0d1fe47
commit
2a30fb6b3b
5 files changed
+472
-2
No files matched your search
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"vendor": {
|
"vendor": {
|
||||||
"goModSum": "sha256-mZW7/mudUF/yEjla+10vo9pTH/NGy5cpyOA7Educf9k=",
|
"goModSum": "sha256-uRy4uneBtIBymQ6tXa4e8NqFKeePPlo5vAQksLjJjZ4=",
|
||||||
"sri": "sha256-LgnMChKtW6WnmpH5dEUxiLCRppyU3Rz21RZ6Q1MedKA="
|
"sri": "sha256-D+t9VtKxtuyHYtdhxyF34wqnNpObObl7H3E/wjcXTKU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,7 @@ require (
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/jagottsicher/termcolor v1.0.2
|
github.com/jagottsicher/termcolor v1.0.2
|
||||||
github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974
|
github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974
|
||||||
|
github.com/letsencrypt/pebble/v2 v2.10.1
|
||||||
github.com/libdns/acmedns v0.5.0
|
github.com/libdns/acmedns v0.5.0
|
||||||
github.com/libdns/cloudflare v0.2.2
|
github.com/libdns/cloudflare v0.2.2
|
||||||
github.com/libdns/hetzner/v2 v2.0.1
|
github.com/libdns/hetzner/v2 v2.0.1
|
||||||
@@ -173,6 +174,7 @@ require (
|
|||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/klauspost/compress v1.20.1 // indirect
|
github.com/klauspost/compress v1.20.1 // indirect
|
||||||
github.com/kradalby/gigahost-go/client v0.0.0-20261014144355-025b38b667ed // indirect
|
github.com/kradalby/gigahost-go/client v0.0.0-20261014144355-025b38b667ed // indirect
|
||||||
|
github.com/letsencrypt/challtestsrv v1.4.2 // indirect
|
||||||
github.com/lib/pq v1.12.3 // indirect
|
github.com/lib/pq v1.12.3 // indirect
|
||||||
github.com/lithammer/fuzzysearch v1.1.8 // indirect
|
github.com/lithammer/fuzzysearch v1.1.8 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
|
|||||||
@@ -322,6 +322,10 @@ github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974/go.mod h1
|
|||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
|
github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
|
||||||
|
github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
|
||||||
|
github.com/letsencrypt/pebble/v2 v2.10.1 h1:oKHx3lgN4e5Nno2LKTMrVx+b+NkDptkO9aDireiBDGE=
|
||||||
|
github.com/letsencrypt/pebble/v2 v2.10.1/go.mod h1:KtYhQ4YTjT5MtoCZ6RTCXlbrrz6cKyXROCuTpIUDJFY=
|
||||||
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ type TailscaleClient interface {
|
|||||||
ContainerID() string
|
ContainerID() string
|
||||||
MustID() types.NodeID
|
MustID() types.NodeID
|
||||||
ReadFile(path string) ([]byte, error)
|
ReadFile(path string) ([]byte, error)
|
||||||
|
WriteFile(path string, data []byte) error
|
||||||
PacketFilter() ([]filter.Match, error)
|
PacketFilter() ([]filter.Match, error)
|
||||||
ConnectToNetwork(network *dockertest.Network) error
|
ConnectToNetwork(network *dockertest.Network) error
|
||||||
DisconnectFromNetwork(network *dockertest.Network) error
|
DisconnectFromNetwork(network *dockertest.Network) error
|
||||||
|
|||||||
Reference in new issue
Block a user