mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-29 16:56:32 -04:00
LetsEncrypt made certificates for bare IP addresses generally available in January 2026. They require the short-lived ACME certificate profile and are valid for about six days. Add a new --acme-ip-certs flag. When set (with the default --certmode=letsencrypt), connections that arrive by IP address (no TLS SNI, or an IP address SNI matching the connection's destination address) get a LetsEncrypt cert for that IP, obtained on demand using the "shortlived" profile and the HTTP-01 challenge served on derper's plaintext HTTP port. Because the certificate is requested for whatever address the connection actually arrived on, it works for both IPv4 and IPv6 with no per-address configuration, and a client can never make us request a certificate for an address that isn't ours. Connections with a DNS name in the SNI keep using the regular autocert manager for --hostname. autocert can't do any of this itself, as it neither orders IP address identifiers nor serves connections without SNI, so this adds a small dedicated cert manager using tailscale.com/tempfork/acme instead. Clients can then connect to https://<IP> without the DERPMap CertName pinning that self-signed certs from --certmode=manual require. Updates tailscale/corp#45167 Updates #11776 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I8e2d5b0a7c4f9e1b3d6a8c2f5e0b9d4a7c1f3e6d
212 lines
5.4 KiB
Go
212 lines
5.4 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"tailscale.com/derp/derphttp"
|
|
"tailscale.com/derp/derpserver"
|
|
"tailscale.com/net/netmon"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/key"
|
|
)
|
|
|
|
// Verify that in --certmode=manual mode, we can use a bare IP address
|
|
// as the --hostname and that GetCertificate will return it.
|
|
func TestCertIP(t *testing.T) {
|
|
dir := t.TempDir()
|
|
const hostname = "1.2.3.4"
|
|
|
|
priv, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
|
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ip := net.ParseIP(hostname)
|
|
if ip == nil {
|
|
t.Fatalf("invalid IP address %q", hostname)
|
|
}
|
|
template := &x509.Certificate{
|
|
SerialNumber: serialNumber,
|
|
Subject: pkix.Name{
|
|
Organization: []string{"Tailscale Test Corp"},
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
|
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
IPAddresses: []net.IP{ip},
|
|
}
|
|
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
certOut, err := os.Create(filepath.Join(dir, hostname+".crt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
|
|
t.Fatalf("Failed to write data to cert.pem: %v", err)
|
|
}
|
|
if err := certOut.Close(); err != nil {
|
|
t.Fatalf("Error closing cert.pem: %v", err)
|
|
}
|
|
|
|
keyOut, err := os.OpenFile(filepath.Join(dir, hostname+".key"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
|
|
if err != nil {
|
|
t.Fatalf("Unable to marshal private key: %v", err)
|
|
}
|
|
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
|
|
t.Fatalf("Failed to write data to key.pem: %v", err)
|
|
}
|
|
if err := keyOut.Close(); err != nil {
|
|
t.Fatalf("Error closing key.pem: %v", err)
|
|
}
|
|
|
|
cp, err := certProviderByCertMode("manual", dir, hostname, false, "", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := cp.TLSConfig().GetCertificate(&tls.ClientHelloInfo{
|
|
ServerName: "", // no SNI
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetCertificate: %v", err)
|
|
}
|
|
if back == nil {
|
|
t.Fatalf("GetCertificate returned nil")
|
|
}
|
|
}
|
|
|
|
// Test that we can dial a raw IP without using a hostname and without a WebPKI
|
|
// cert, validating the cert against the signature of the cert in the DERP map's
|
|
// DERPNode.
|
|
//
|
|
// See https://github.com/tailscale/tailscale/issues/11776.
|
|
func TestPinnedCertRawIP(t *testing.T) {
|
|
td := t.TempDir()
|
|
cp, err := NewManualCertManager(td, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("NewManualCertManager: %v", err)
|
|
}
|
|
|
|
cert, err := cp.TLSConfig().GetCertificate(&tls.ClientHelloInfo{
|
|
ServerName: "127.0.0.1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GetCertificate: %v", err)
|
|
}
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("Listen: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
|
|
ds := derpserver.New(key.NewNode(), t.Logf)
|
|
|
|
derpHandler := derpserver.Handler(ds)
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/derp", derpHandler)
|
|
|
|
var hs http.Server
|
|
hs.Handler = mux
|
|
hs.TLSConfig = cp.TLSConfig()
|
|
ds.ModifyTLSConfigToAddMetaCert(hs.TLSConfig)
|
|
go hs.ServeTLS(ln, "", "")
|
|
|
|
lnPort := ln.Addr().(*net.TCPAddr).Port
|
|
|
|
reg := &tailcfg.DERPRegion{
|
|
RegionID: 900,
|
|
Nodes: []*tailcfg.DERPNode{
|
|
{
|
|
RegionID: 900,
|
|
HostName: "127.0.0.1",
|
|
CertName: fmt.Sprintf("sha256-raw:%-02x", sha256.Sum256(cert.Leaf.Raw)),
|
|
DERPPort: lnPort,
|
|
},
|
|
},
|
|
}
|
|
|
|
netMon := netmon.NewStatic()
|
|
dc := derphttp.NewRegionClient(key.NewNode(), t.Logf, netMon, func() *tailcfg.DERPRegion {
|
|
return reg
|
|
})
|
|
defer dc.Close()
|
|
|
|
_, connClose, _, err := dc.DialRegionTLS(context.Background(), reg)
|
|
if err != nil {
|
|
t.Fatalf("DialRegionTLS: %v", err)
|
|
}
|
|
defer connClose.Close()
|
|
}
|
|
|
|
func TestGCPCertMode(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Missing EAB credentials
|
|
_, err := certProviderByCertMode("gcp", dir, "test.example.com", false, "", "", "test@example.com")
|
|
if err == nil {
|
|
t.Fatal("expected error when EAB credentials are missing")
|
|
}
|
|
|
|
// Missing email
|
|
_, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "")
|
|
if err == nil {
|
|
t.Fatal("expected error when email is missing")
|
|
}
|
|
|
|
// Invalid base64
|
|
_, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "not-valid!", "test@example.com")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid base64")
|
|
}
|
|
|
|
// Valid base64url (no padding)
|
|
cp, err := certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "test@example.com")
|
|
if err != nil {
|
|
t.Fatalf("base64url: %v", err)
|
|
}
|
|
if cp == nil {
|
|
t.Fatal("base64url: nil certProvider")
|
|
}
|
|
|
|
// Valid standard base64 (with padding, gcloud format)
|
|
cp, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk=", "test@example.com")
|
|
if err != nil {
|
|
t.Fatalf("base64: %v", err)
|
|
}
|
|
if cp == nil {
|
|
t.Fatal("base64: nil certProvider")
|
|
}
|
|
}
|