diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index 22956a299..c844f67f7 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -246,22 +246,22 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa crypto/internal/boring/bbig from crypto/ecdsa+ crypto/internal/boring/sig from crypto/internal/boring crypto/internal/constanttime from crypto/internal/fips140/edwards25519+ - crypto/internal/fips140 from crypto/internal/fips140/aes+ + crypto/internal/fips140 from crypto/fips140+ crypto/internal/fips140/aes from crypto/aes+ crypto/internal/fips140/aes/gcm from crypto/cipher+ crypto/internal/fips140/alias from crypto/cipher+ crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+ - crypto/internal/fips140/check from crypto/internal/fips140/aes+ - crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+ + crypto/internal/fips140/check from crypto/fips140+ + crypto/internal/fips140/drbg from crypto/hpke+ crypto/internal/fips140/ecdh from crypto/ecdh crypto/internal/fips140/ecdsa from crypto/ecdsa crypto/internal/fips140/ed25519 from crypto/ed25519 crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519 crypto/internal/fips140/edwards25519/field from crypto/ecdh+ - crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+ + crypto/internal/fips140/hkdf from crypto/hkdf+ crypto/internal/fips140/hmac from crypto/hmac+ crypto/internal/fips140/mlkem from crypto/mlkem - crypto/internal/fips140/nistec from crypto/elliptic+ + crypto/internal/fips140/nistec from crypto/ecdsa+ crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec crypto/internal/fips140/rsa from crypto/rsa crypto/internal/fips140/sha256 from crypto/internal/fips140/check+ @@ -327,13 +327,13 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa internal/filepathlite from os+ internal/fmtsort from fmt+ internal/goarch from crypto/internal/fips140deps/cpu+ - internal/godebug from crypto/internal/fips140deps/godebug+ + internal/godebug from crypto/ed25519+ internal/godebugs from internal/godebug+ internal/goexperiment from net/http/pprof+ internal/goos from crypto/x509+ internal/msan from internal/runtime/maps+ internal/nettrace from net+ - internal/oserror from io/fs+ + internal/oserror from internal/syscall/windows+ internal/poll from net+ internal/profile from net/http/pprof internal/profilerecord from runtime+ @@ -343,9 +343,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa internal/runtime/atomic from internal/runtime/exithook+ L internal/runtime/cgroup from runtime internal/runtime/exithook from runtime - internal/runtime/gc from runtime+ + internal/runtime/gc from internal/runtime/gc/scan+ internal/runtime/gc/scan from runtime - internal/runtime/maps from reflect+ + internal/runtime/maps from hash/maphash+ internal/runtime/math from internal/runtime/maps+ internal/runtime/pprof/label from runtime+ internal/runtime/sys from crypto/subtle+ @@ -359,7 +359,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa internal/synctest from sync internal/syscall/execenv from os+ LD internal/syscall/unix from crypto/internal/sysrand+ - W internal/syscall/windows from crypto/internal/sysrand+ + W internal/syscall/windows from crypto/internal/fips140deps/time+ W internal/syscall/windows/registry from mime+ W internal/syscall/windows/sysdll from internal/syscall/windows+ internal/testlog from os diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index 68941dbf9..fc0ebc42c 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -752,7 +752,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/ipn/ipnlocal/netmapcache from tailscale.com/ipn/ipnlocal tailscale.com/ipn/ipnstate from tailscale.com/client/local+ tailscale.com/ipn/localapi from tailscale.com/tsnet - tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+ + tailscale.com/ipn/store from tailscale.com/ipn/store/kubestore+ tailscale.com/ipn/store/kubestore from tailscale.com/cmd/k8s-operator tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+ tailscale.com/k8s-operator from tailscale.com/cmd/k8s-operator+ @@ -828,7 +828,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/sessionrecording from tailscale.com/k8s-operator/sessionrecording+ tailscale.com/syncs from tailscale.com/control/controlknobs+ tailscale.com/tailcfg from tailscale.com/client/local+ - tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal + tailscale.com/tempfork/acme from tailscale.com/feature/acme tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock tailscale.com/tempfork/httprec from tailscale.com/feature/c2n tailscale.com/tka from tailscale.com/client/local+ @@ -1020,22 +1020,22 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ crypto/internal/boring/bbig from crypto/ecdsa+ crypto/internal/boring/sig from crypto/internal/boring crypto/internal/constanttime from crypto/internal/fips140/edwards25519+ - crypto/internal/fips140 from crypto/internal/fips140/aes+ + crypto/internal/fips140 from crypto/fips140+ crypto/internal/fips140/aes from crypto/aes+ crypto/internal/fips140/aes/gcm from crypto/cipher+ crypto/internal/fips140/alias from crypto/cipher+ crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+ - crypto/internal/fips140/check from crypto/internal/fips140/aes+ - crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+ + crypto/internal/fips140/check from crypto/fips140+ + crypto/internal/fips140/drbg from crypto/hpke+ crypto/internal/fips140/ecdh from crypto/ecdh crypto/internal/fips140/ecdsa from crypto/ecdsa crypto/internal/fips140/ed25519 from crypto/ed25519 crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519 crypto/internal/fips140/edwards25519/field from crypto/ecdh+ - crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+ + crypto/internal/fips140/hkdf from crypto/hkdf+ crypto/internal/fips140/hmac from crypto/hmac+ crypto/internal/fips140/mlkem from crypto/mlkem - crypto/internal/fips140/nistec from crypto/elliptic+ + crypto/internal/fips140/nistec from crypto/ecdsa+ crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec crypto/internal/fips140/rsa from crypto/rsa crypto/internal/fips140/sha256 from crypto/internal/fips140/check+ @@ -1115,14 +1115,14 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ internal/filepathlite from os+ internal/fmtsort from fmt+ internal/goarch from crypto/internal/fips140deps/cpu+ - internal/godebug from crypto/internal/fips140deps/godebug+ + internal/godebug from crypto/ed25519+ internal/godebugs from internal/godebug+ internal/goexperiment from net/http/pprof+ internal/goos from crypto/x509+ internal/lazyregexp from go/doc internal/msan from internal/runtime/maps+ internal/nettrace from net+ - internal/oserror from io/fs+ + internal/oserror from internal/syscall/windows+ internal/poll from net+ internal/profile from net/http/pprof internal/profilerecord from runtime+ @@ -1132,9 +1132,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ internal/runtime/atomic from internal/runtime/exithook+ L internal/runtime/cgroup from runtime internal/runtime/exithook from runtime - internal/runtime/gc from runtime+ + internal/runtime/gc from internal/runtime/gc/scan+ internal/runtime/gc/scan from runtime - internal/runtime/maps from reflect+ + internal/runtime/maps from hash/maphash+ internal/runtime/math from internal/runtime/maps+ internal/runtime/pprof/label from runtime+ internal/runtime/sys from crypto/subtle+ @@ -1148,7 +1148,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ internal/synctest from sync internal/syscall/execenv from os+ LD internal/syscall/unix from crypto/internal/sysrand+ - W internal/syscall/windows from crypto/internal/sysrand+ + W internal/syscall/windows from crypto/internal/fips140deps/time+ W internal/syscall/windows/registry from mime+ W internal/syscall/windows/sysdll from internal/syscall/windows+ internal/testlog from os diff --git a/cmd/stund/depaware.txt b/cmd/stund/depaware.txt index 239b728e1..bdd02b70b 100644 --- a/cmd/stund/depaware.txt +++ b/cmd/stund/depaware.txt @@ -141,22 +141,22 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar crypto/internal/boring/bbig from crypto/ecdsa+ crypto/internal/boring/sig from crypto/internal/boring crypto/internal/constanttime from crypto/internal/fips140/edwards25519+ - crypto/internal/fips140 from crypto/internal/fips140/aes+ + crypto/internal/fips140 from crypto/fips140+ crypto/internal/fips140/aes from crypto/aes+ crypto/internal/fips140/aes/gcm from crypto/cipher+ crypto/internal/fips140/alias from crypto/cipher+ crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+ - crypto/internal/fips140/check from crypto/internal/fips140/aes+ - crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+ + crypto/internal/fips140/check from crypto/fips140+ + crypto/internal/fips140/drbg from crypto/hpke+ crypto/internal/fips140/ecdh from crypto/ecdh crypto/internal/fips140/ecdsa from crypto/ecdsa crypto/internal/fips140/ed25519 from crypto/ed25519 crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519 crypto/internal/fips140/edwards25519/field from crypto/ecdh+ - crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+ + crypto/internal/fips140/hkdf from crypto/hkdf+ crypto/internal/fips140/hmac from crypto/hmac+ crypto/internal/fips140/mlkem from crypto/mlkem - crypto/internal/fips140/nistec from crypto/elliptic+ + crypto/internal/fips140/nistec from crypto/ecdsa+ crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec crypto/internal/fips140/rsa from crypto/rsa crypto/internal/fips140/sha256 from crypto/internal/fips140/check+ @@ -221,13 +221,13 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar internal/filepathlite from os+ internal/fmtsort from fmt internal/goarch from crypto/internal/fips140deps/cpu+ - internal/godebug from crypto/internal/fips140deps/godebug+ + internal/godebug from crypto/ed25519+ internal/godebugs from internal/godebug+ internal/goexperiment from net/http/pprof+ internal/goos from crypto/x509+ internal/msan from internal/runtime/maps+ internal/nettrace from net+ - internal/oserror from io/fs+ + internal/oserror from internal/syscall/windows+ internal/poll from net+ internal/profile from net/http/pprof internal/profilerecord from runtime+ @@ -237,9 +237,9 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar internal/runtime/atomic from internal/runtime/exithook+ L internal/runtime/cgroup from runtime internal/runtime/exithook from runtime - internal/runtime/gc from runtime+ + internal/runtime/gc from internal/runtime/gc/scan+ internal/runtime/gc/scan from runtime - internal/runtime/maps from reflect+ + internal/runtime/maps from hash/maphash+ internal/runtime/math from internal/runtime/maps+ internal/runtime/pprof/label from runtime+ internal/runtime/sys from crypto/subtle+ @@ -253,7 +253,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar internal/synctest from sync internal/syscall/execenv from os LD internal/syscall/unix from crypto/internal/sysrand+ - W internal/syscall/windows from crypto/internal/sysrand+ + W internal/syscall/windows from crypto/internal/fips140deps/time+ W internal/syscall/windows/registry from mime+ W internal/syscall/windows/sysdll from internal/syscall/windows+ internal/testlog from os diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 6fc246bd3..32fa8901b 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -403,22 +403,22 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep crypto/internal/boring/bbig from crypto/ecdsa+ crypto/internal/boring/sig from crypto/internal/boring crypto/internal/constanttime from crypto/internal/fips140/edwards25519+ - crypto/internal/fips140 from crypto/internal/fips140/aes+ + crypto/internal/fips140 from crypto/fips140+ crypto/internal/fips140/aes from crypto/aes+ crypto/internal/fips140/aes/gcm from crypto/cipher+ crypto/internal/fips140/alias from crypto/cipher+ crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+ - crypto/internal/fips140/check from crypto/internal/fips140/aes+ - crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+ + crypto/internal/fips140/check from crypto/fips140+ + crypto/internal/fips140/drbg from crypto/hpke+ crypto/internal/fips140/ecdh from crypto/ecdh crypto/internal/fips140/ecdsa from crypto/ecdsa crypto/internal/fips140/ed25519 from crypto/ed25519 crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519 crypto/internal/fips140/edwards25519/field from crypto/ecdh+ - crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+ + crypto/internal/fips140/hkdf from crypto/hkdf+ crypto/internal/fips140/hmac from crypto/hmac+ crypto/internal/fips140/mlkem from crypto/mlkem - crypto/internal/fips140/nistec from crypto/elliptic+ + crypto/internal/fips140/nistec from crypto/ecdsa+ crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec crypto/internal/fips140/pbkdf2 from crypto/pbkdf2 crypto/internal/fips140/rsa from crypto/rsa @@ -502,7 +502,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep internal/goos from crypto/x509+ internal/msan from internal/runtime/maps+ internal/nettrace from net+ - internal/oserror from io/fs+ + internal/oserror from internal/syscall/windows+ internal/poll from net+ internal/profile from net/http/pprof internal/profilerecord from runtime+ @@ -512,9 +512,9 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep internal/runtime/atomic from internal/runtime/exithook+ L internal/runtime/cgroup from runtime internal/runtime/exithook from runtime - internal/runtime/gc from runtime+ + internal/runtime/gc from internal/runtime/gc/scan+ internal/runtime/gc/scan from runtime - internal/runtime/maps from reflect+ + internal/runtime/maps from hash/maphash+ internal/runtime/math from internal/runtime/maps+ internal/runtime/pprof/label from runtime+ internal/runtime/sys from crypto/subtle+ @@ -528,7 +528,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep internal/synctest from sync internal/syscall/execenv from os+ LD internal/syscall/unix from crypto/internal/sysrand+ - W internal/syscall/windows from crypto/internal/sysrand+ + W internal/syscall/windows from crypto/internal/fips140deps/time+ W internal/syscall/windows/registry from mime+ W internal/syscall/windows/sysdll from internal/syscall/windows+ internal/testlog from os diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index b54571abb..3efd65338 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -405,7 +405,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de LD 💣 tailscale.com/ssh/tailssh from tailscale.com/feature/ssh tailscale.com/syncs from tailscale.com/cmd/tailscaled+ tailscale.com/tailcfg from tailscale.com/client/local+ - tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal + tailscale.com/tempfork/acme from tailscale.com/feature/acme tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock tailscale.com/tempfork/httprec from tailscale.com/feature/c2n tailscale.com/tka from tailscale.com/client/local+ diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 456aa9881..90836ef05 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -169,7 +169,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar tailscale.com/ipn/ipnlocal/netmapcache from tailscale.com/ipn/ipnlocal tailscale.com/ipn/ipnstate from tailscale.com/client/local+ tailscale.com/ipn/localapi from tailscale.com/tsnet - tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+ + tailscale.com/ipn/store from tailscale.com/tsnet+ tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+ tailscale.com/kube/kubetypes from tailscale.com/envknob tailscale.com/licenses from tailscale.com/client/web @@ -179,7 +179,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar tailscale.com/logtail from tailscale.com/control/controlclient+ tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+ tailscale.com/metrics from tailscale.com/tsweb+ - tailscale.com/net/bakedroots from tailscale.com/ipn/ipnlocal+ + tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+ 💣 tailscale.com/net/batching from tailscale.com/wgengine/magicsock tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+ tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+ @@ -226,7 +226,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar 💣 tailscale.com/safesocket from tailscale.com/client/local+ tailscale.com/syncs from tailscale.com/control/controlhttp+ tailscale.com/tailcfg from tailscale.com/client/local+ - tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal + tailscale.com/tempfork/acme from tailscale.com/feature/acme tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock tailscale.com/tempfork/httprec from tailscale.com/feature/c2n tailscale.com/tka from tailscale.com/client/local+ @@ -414,22 +414,22 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar crypto/internal/boring/bbig from crypto/ecdsa+ crypto/internal/boring/sig from crypto/internal/boring crypto/internal/constanttime from crypto/internal/fips140/edwards25519+ - crypto/internal/fips140 from crypto/internal/fips140/aes+ + crypto/internal/fips140 from crypto/fips140+ crypto/internal/fips140/aes from crypto/aes+ crypto/internal/fips140/aes/gcm from crypto/cipher+ crypto/internal/fips140/alias from crypto/cipher+ crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+ - crypto/internal/fips140/check from crypto/internal/fips140/aes+ - crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+ + crypto/internal/fips140/check from crypto/fips140+ + crypto/internal/fips140/drbg from crypto/hpke+ crypto/internal/fips140/ecdh from crypto/ecdh crypto/internal/fips140/ecdsa from crypto/ecdsa crypto/internal/fips140/ed25519 from crypto/ed25519 crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519 crypto/internal/fips140/edwards25519/field from crypto/ecdh+ - crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+ + crypto/internal/fips140/hkdf from crypto/hkdf+ crypto/internal/fips140/hmac from crypto/hmac+ crypto/internal/fips140/mlkem from crypto/mlkem - crypto/internal/fips140/nistec from crypto/elliptic+ + crypto/internal/fips140/nistec from crypto/ecdsa+ crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec crypto/internal/fips140/pbkdf2 from crypto/pbkdf2 crypto/internal/fips140/rsa from crypto/rsa @@ -500,13 +500,13 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar internal/filepathlite from os+ internal/fmtsort from fmt+ internal/goarch from crypto/internal/fips140deps/cpu+ - internal/godebug from crypto/internal/fips140deps/godebug+ + internal/godebug from crypto/ed25519+ internal/godebugs from internal/godebug+ internal/goexperiment from net/http/pprof+ internal/goos from crypto/x509+ internal/msan from internal/runtime/maps+ internal/nettrace from net+ - internal/oserror from io/fs+ + internal/oserror from internal/syscall/windows+ internal/poll from net+ internal/profile from net/http/pprof internal/profilerecord from runtime+ @@ -516,9 +516,9 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar internal/runtime/atomic from internal/runtime/exithook+ L internal/runtime/cgroup from runtime internal/runtime/exithook from runtime - internal/runtime/gc from runtime+ + internal/runtime/gc from internal/runtime/gc/scan+ internal/runtime/gc/scan from runtime - internal/runtime/maps from reflect+ + internal/runtime/maps from hash/maphash+ internal/runtime/math from internal/runtime/maps+ internal/runtime/pprof/label from runtime+ internal/runtime/sys from crypto/subtle+ @@ -532,7 +532,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar internal/synctest from sync internal/syscall/execenv from os+ LD internal/syscall/unix from crypto/internal/sysrand+ - W internal/syscall/windows from crypto/internal/sysrand+ + W internal/syscall/windows from crypto/internal/fips140deps/time+ W internal/syscall/windows/registry from mime+ W internal/syscall/windows/sysdll from internal/syscall/windows+ internal/testlog from os diff --git a/feature/acme/acme.go b/feature/acme/acme.go index 4f9cfd8fd..9947bc7f1 100644 --- a/feature/acme/acme.go +++ b/feature/acme/acme.go @@ -3,19 +3,35 @@ // Package acme registers the ACME/TLS-cert feature and implements its // associated [ipnext.Extension]. The extension owns the per-LocalBackend -// state previously held in package-level globals and on LocalBackend -// fields (ACME serialization mutex, in-flight cert tracking, etc.). +// ACME serialization mutex, in-flight cert tracking, the refresh loop's +// cancel func, and the test-only cert override; together with the cert +// acquisition logic in this package, it is everything tailscaled needs +// to obtain and renew TLS certificates via ACME. // -// The cert code that runs against this state still lives in -// [tailscale.com/ipn/ipnlocal]; this extension simply owns the state -// and installs a hook so cert.go can find it from a *LocalBackend. +// In builds without ACME support (js or ts_omit_acme), this package is +// not linked in; [ipn/ipnlocal] then exposes only stub wrappers that +// return errNoCerts or no-op. package acme import ( + "context" + "crypto/tls" + "errors" + "net/http" + "sync" + "sync/atomic" + "time" + "tailscale.com/feature" + "tailscale.com/health" + "tailscale.com/ipn" "tailscale.com/ipn/ipnext" "tailscale.com/ipn/ipnlocal" + "tailscale.com/syncs" + "tailscale.com/tsconst" "tailscale.com/types/logger" + "tailscale.com/util/clientmetric" + "tailscale.com/util/set" ) // featureName is the name of the feature implemented by this package. @@ -24,39 +40,211 @@ const featureName = "acme" func init() { feature.Register(featureName) ipnext.RegisterExtension(featureName, newExtension) - ipnlocal.HookCertState.Set(certStateFor) + + ipnlocal.HookGetCertPEM.Set(getCertPEMHook) + ipnlocal.HookGetACMETLSALPNCert.Set(getACMETLSALPNCertHook) + ipnlocal.HookGetACMETLSALPNProto.Set(getACMETLSALPNProtoHook) + ipnlocal.HookUpdateCertRefreshLoop.Set(updateCertRefreshLoopHook) + ipnlocal.HookShutdownCertRefreshLoop.Set(shutdownCertRefreshLoopHook) + ipnlocal.HookConfigureCertsForTest.Set(configureCertsForTestHook) + ipnlocal.HookHandleC2NTLSCertStatus.Set(handleC2NTLSCertStatus) } +// errNoExt is returned when a hook is invoked on a [*ipnlocal.LocalBackend] +// that has no [extension] registered (shouldn't happen in practice, but +// guards against misuse from tests that swap extension registrations). +var errNoExt = errors.New("acme extension not registered on this LocalBackend") + +// extension is the ACME/cert [ipnext.Extension]. It owns the +// per-[*ipnlocal.LocalBackend] state previously held in package-level +// globals and in [*ipnlocal.LocalBackend] fields. +// +// All methods that take a [*ipnlocal.LocalBackend] argument operate on +// the backend the extension was instantiated for; the argument is +// passed through from the hook in [ipn/ipnlocal] rather than stored on +// the extension, which keeps the extension's lifecycle independent of +// any specific backend reference. +type extension struct { + logf logger.Logf + + // acmeMu serializes ACME operations so concurrent requests for + // certs don't slam ACME. The first goroutine through populates the + // on-disk cache and the rest reuse it. + acmeMu syncs.Mutex + + // renewMu guards renewCertAt. + // Lock order: acmeMu before renewMu. + renewMu syncs.Mutex + renewCertAt map[string]time.Time // lazily initialized under renewMu + + // pendingACMETLSALPNCerts maps SNI names to short-lived ACME + // tls-alpn-01 challenge certificates while an ACME order is + // waiting for validation. Entries are deleted by the cleanup + // function returned from storeACMETLSALPNCert after the challenge + // validation path finishes, whether it succeeds or fails. + pendingACMETLSALPNCerts syncs.Map[string, *tls.Certificate] + + // pendingCertDomains tracks the set of domains for which an ACME + // issuance is currently in flight with no usable cached cert. It + // backs the tls-cert-pending health Warnable. + // Guarded by pendingCertDomainsMu. + pendingCertDomainsMu sync.Mutex + pendingCertDomains set.Set[string] + + // wg tracks all background goroutines spawned by this extension + // (async cert renewals, the cert refresh loop and its per-domain + // workers). [extension.Shutdown] waits on it. + wg sync.WaitGroup + + // goroutinesStarted counts goroutines started via [extension.Go]. + // Tests use it to assert whether an operation kicked off async work. + goroutinesStarted atomic.Int64 + + // mu guards the test/lifecycle fields below. + mu sync.Mutex + + // getCertForTest is used to retrieve TLS certificates in tests. + // See [LocalBackend.ConfigureCertsForTest]. + getCertForTest func(hostname string) (*ipnlocal.TLSCertKeyPair, error) + + // certRefreshCancel cancels the background TLS cert refresh loop + // that periodically pokes [LocalBackend.GetCertPEM] so renewals + // happen on idle nodes. Non-nil while the loop is running. + certRefreshCancel context.CancelFunc +} + +// Go runs f in a new goroutine tracked by e.wg. [extension.Shutdown] +// waits for all such goroutines to finish. +func (e *extension) Go(f func()) { + e.wg.Add(1) + e.goroutinesStarted.Add(1) + go func() { + defer e.wg.Done() + f() + }() +} + +// newExtension is the [ipnext.NewExtensionFn] registered for this +// feature. It is called once per [*ipnlocal.LocalBackend]. func newExtension(logf logger.Logf, _ ipnext.SafeBackend) (ipnext.Extension, error) { return &extension{ - state: new(ipnlocal.CertState), - logf: logger.WithPrefix(logf, featureName+": "), + logf: logger.WithPrefix(logf, featureName+": "), }, nil } -// extension is an [ipnext.Extension] that owns the per-LocalBackend -// ACME/cert state. Most of the cert logic still lives in ipnlocal; -// this extension exists to give that state a non-global home. -type extension struct { - state *ipnlocal.CertState - logf logger.Logf -} - // Name implements [ipnext.Extension]. func (e *extension) Name() string { return featureName } // Init implements [ipnext.Extension]. func (e *extension) Init(ipnext.Host) error { return nil } -// Shutdown implements [ipnext.Extension]. -func (e *extension) Shutdown() error { return nil } +// Shutdown implements [ipnext.Extension]. It cancels the cert refresh +// loop if it's running, then waits for all in-flight goroutines +// (async renewals, refresh loop workers) to finish. +func (e *extension) Shutdown() error { + e.mu.Lock() + if e.certRefreshCancel != nil { + e.certRefreshCancel() + e.certRefreshCancel = nil + } + e.mu.Unlock() + e.wg.Wait() + return nil +} -// certStateFor returns the [ipnlocal.CertState] owned by the acme -// extension registered on b, or nil if none. -func certStateFor(b *ipnlocal.LocalBackend) *ipnlocal.CertState { +// extFor returns the [*extension] for b, or an error if no acme +// extension is registered on b. +func extFor(b *ipnlocal.LocalBackend) (*extension, error) { e, ok := ipnlocal.GetExt[*extension](b) if !ok { - return nil + return nil, errNoExt } - return e.state + return e, nil } + +// Hook adapter funcs that thread (b *ipnlocal.LocalBackend) into the +// extension's methods. These are what get installed in +// [ipnlocal.Hook*] at init time. + +func getCertPEMHook(ctx context.Context, b *ipnlocal.LocalBackend, domain string, minValidity time.Duration) (*ipnlocal.TLSCertKeyPair, error) { + e, err := extFor(b) + if err != nil { + return nil, err + } + return e.getCertPEMWithValidity(ctx, b, domain, minValidity) +} + +func getACMETLSALPNCertHook(b *ipnlocal.LocalBackend, hi *tls.ClientHelloInfo) (*tls.Certificate, bool) { + e, err := extFor(b) + if err != nil { + return nil, false + } + return e.getACMETLSALPNCert(hi) +} + +func getACMETLSALPNProtoHook(b *ipnlocal.LocalBackend, hi *tls.ClientHelloInfo) (string, bool) { + e, err := extFor(b) + if err != nil { + return "", false + } + return e.getACMETLSALPNProto(hi) +} + +func updateCertRefreshLoopHook(b *ipnlocal.LocalBackend, state ipn.State, sc ipn.ServeConfigView) { + e, err := extFor(b) + if err != nil { + return + } + e.updateCertRefreshLoop(b, state, sc) +} + +func shutdownCertRefreshLoopHook(b *ipnlocal.LocalBackend) { + e, err := extFor(b) + if err != nil { + return + } + e.Shutdown() +} + +func configureCertsForTestHook(b *ipnlocal.LocalBackend, getCert func(string) (*ipnlocal.TLSCertKeyPair, error)) { + e, err := extFor(b) + if err != nil { + panic(err) + } + e.mu.Lock() + defer e.mu.Unlock() + e.getCertForTest = getCert +} + +func handleC2NTLSCertStatus(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) { + e, err := extFor(b) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + e.handleC2NTLSCertStatus(b, w, r) +} + +// ACME / cert metrics. These are package-level (process-wide) because +// they aggregate across all [*extension]s in the process. +var ( + metricACMEDNS01Start = clientmetric.NewCounter("cert_acme_dns01_start") + metricACMEDNS01Success = clientmetric.NewCounter("cert_acme_dns01_success") + metricACMEDNS01Failure = clientmetric.NewCounter("cert_acme_dns01_failure") + metricACMETLSALPN01Start = clientmetric.NewCounter("cert_acme_tls_alpn01_start") + metricACMETLSALPN01Success = clientmetric.NewCounter("cert_acme_tls_alpn01_success") + metricACMETLSALPN01Failure = clientmetric.NewCounter("cert_acme_tls_alpn01_failure") +) + +// certPendingWarnable fires while ACME is fetching a TLS certificate +// for which no usable cached copy exists (initial issuance or after +// the cached cert has expired). Async renewal of a still-valid cert +// does not fire it. +var certPendingWarnable = health.Register(&health.Warnable{ + Code: tsconst.HealthWarnableTLSCertPending, + Title: "Fetching TLS certificate", + Severity: health.SeverityLow, + Text: func(args health.Args) string { + return "Fetching TLS certificate via ACME for: " + args[health.ArgDomains] + }, +}) diff --git a/feature/acme/c2n.go b/feature/acme/c2n.go new file mode 100644 index 000000000..a1b921ead --- /dev/null +++ b/feature/acme/c2n.go @@ -0,0 +1,75 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package acme + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + "time" + + "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/tailcfg" +) + +// handleC2NTLSCertStatus returns info about the last TLS certificate +// issued for the provided domain. It is the implementation of +// [ipnlocal.HookHandleC2NTLSCertStatus]; control calls it to clean up +// DNS TXT records when they're no longer needed by LetsEncrypt. +// +// It does not kick off a cert fetch or async refresh. It only reports +// anything that's already sitting on disk, and only reports metadata +// about the public cert (stuff that'd be the in CT logs anyway). +func (e *extension) handleC2NTLSCertStatus(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) { + cs, err := e.getCertStore(b) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + domain := r.FormValue("domain") + if domain == "" { + http.Error(w, "no 'domain'", http.StatusBadRequest) + return + } + + ret := &tailcfg.C2NTLSCertInfo{} + pair, err := getCertPEMCached(cs, domain, b.Clock().Now()) + ret.Valid = err == nil + if err != nil { + ret.Error = err.Error() + if errors.Is(err, errCertExpired) { + ret.Expired = true + } else if errors.Is(err, ipn.ErrStateNotExist) { + ret.Missing = true + ret.Error = "no certificate" + } + } else { + block, _ := pem.Decode(pair.CertPEM) + if block == nil { + ret.Error = "invalid PEM" + ret.Valid = false + } else { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + ret.Error = fmt.Sprintf("invalid certificate: %v", err) + ret.Valid = false + } else { + ret.NotBefore = cert.NotBefore.UTC().Format(time.RFC3339) + ret.NotAfter = cert.NotAfter.UTC().Format(time.RFC3339) + } + } + } + + writeJSON(w, ret) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} diff --git a/feature/acme/c2n_test.go b/feature/acme/c2n_test.go new file mode 100644 index 000000000..d6eb4f898 --- /dev/null +++ b/feature/acme/c2n_test.go @@ -0,0 +1,135 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ios && !android && !js + +package acme + +import ( + "cmp" + "crypto/x509" + "encoding/json" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "tailscale.com/ipn/ipnlocal/ipnlocaltest" + "tailscale.com/tailcfg" + "tailscale.com/tstest" + "tailscale.com/types/logger" + "tailscale.com/util/must" +) + +func TestHandleC2NTLSCertStatus(t *testing.T) { + b := ipnlocaltest.NewBackend(t) + b.SetVarRoot(t.TempDir()) + e := extOf(t, b) + + certDirPath, err := certDir(b) + if err != nil { + t.Fatalf("certDir error: %v", err) + } + if _, err := e.getCertStore(b); err != nil { + t.Fatalf("getCertStore error: %v", err) + } + + testRoot, err := certTestFS.ReadFile("testdata/rootCA.pem") + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(testRoot) { + t.Fatal("Unable to add test CA to the cert pool") + } + testX509Roots = roots + defer func() { testX509Roots = nil }() + + tests := []struct { + name string + domain string + copyFile bool // copy testdata/example.com.pem to the certDir + wantStatus int // 0 means 200 + wantError string // wanted non-JSON non-200 error + now time.Time + want *tailcfg.C2NTLSCertInfo + }{ + { + name: "no-domain", + wantStatus: 400, + wantError: "no 'domain'\n", + }, + { + name: "missing", + domain: "example.com", + want: &tailcfg.C2NTLSCertInfo{ + Error: "no certificate", + Missing: true, + }, + }, + { + name: "valid", + domain: "example.com", + now: time.Date(2023, time.February, 20, 0, 0, 0, 0, time.UTC), + copyFile: true, + want: &tailcfg.C2NTLSCertInfo{ + Valid: true, + NotBefore: "2023-02-07T20:34:18Z", + NotAfter: "2025-05-07T19:34:18Z", + }, + }, + { + name: "expired", + domain: "example.com", + now: time.Date(2030, time.February, 20, 0, 0, 0, 0, time.UTC), + copyFile: true, + want: &tailcfg.C2NTLSCertInfo{ + Error: "cert expired", + Expired: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.RemoveAll(certDirPath) // reset per test + if tt.copyFile { + os.MkdirAll(certDirPath, 0755) + if err := os.WriteFile(filepath.Join(certDirPath, "example.com.crt"), + must.Get(certTestFS.ReadFile("testdata/example.com.pem")), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDirPath, "example.com.key"), + must.Get(certTestFS.ReadFile("testdata/example.com-key.pem")), 0644); err != nil { + t.Fatal(err) + } + } + b.ForTest().SetClock(tstest.NewClock(tstest.ClockOpts{ + Start: tt.now, + })) + + rec := httptest.NewRecorder() + e.handleC2NTLSCertStatus(b, rec, httptest.NewRequest("GET", "/tls-cert-status?domain="+url.QueryEscape(tt.domain), nil)) + res := rec.Result() + wantStatus := cmp.Or(tt.wantStatus, 200) + if res.StatusCode != wantStatus { + t.Fatalf("status code = %v; want %v. Body: %s", res.Status, wantStatus, rec.Body.Bytes()) + } + if wantStatus == 200 { + var got tailcfg.C2NTLSCertInfo + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("bad JSON: %v", err) + } + if !reflect.DeepEqual(&got, tt.want) { + t.Errorf("got %v; want %v", logger.AsJSON(got), logger.AsJSON(tt.want)) + } + } else if tt.wantError != "" { + if got := rec.Body.String(); got != tt.wantError { + t.Errorf("body = %q; want %q", got, tt.wantError) + } + } + }) + } +} diff --git a/feature/acme/cert.go b/feature/acme/cert.go new file mode 100644 index 000000000..2d8b89f9a --- /dev/null +++ b/feature/acme/cert.go @@ -0,0 +1,706 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package acme + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "log" + randv2 "math/rand/v2" + "net" + "slices" + "strings" + "time" + + "tailscale.com/envknob" + "tailscale.com/health" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/tailcfg" + "tailscale.com/tempfork/acme" + "tailscale.com/types/logger" + "tailscale.com/util/mak" + "tailscale.com/util/set" + "tailscale.com/util/slicesx" + "tailscale.com/util/testenv" +) + +type acmeChallengeType string + +const ( + acmeChallengeDNS01 acmeChallengeType = "dns-01" + acmeChallengeTLSALPN01 acmeChallengeType = "tls-alpn-01" +) + +var acmeDebug = envknob.RegisterBool("TS_DEBUG_ACME") + +// getACMETLSALPNCert returns the short-lived ACME challenge certificate +// for hi.ServerName. The ok result reports whether hi offered acme-tls/1 +// and an ACME order is actively waiting on that challenge for +// hi.ServerName. +func (e *extension) getACMETLSALPNCert(hi *tls.ClientHelloInfo) (cert *tls.Certificate, ok bool) { + if hi == nil || hi.ServerName == "" || !slices.Contains(hi.SupportedProtos, acme.ALPNProto) { + return nil, false + } + cert, ok = e.pendingACMETLSALPNCerts.Load(hi.ServerName) + return cert, ok +} + +// getACMETLSALPNProto reports whether serveTLSConfig should advertise +// an ACME ALPN protocol for this ClientHello. +func (e *extension) getACMETLSALPNProto(hi *tls.ClientHelloInfo) (proto string, ok bool) { + if _, ok := e.getACMETLSALPNCert(hi); !ok { + return "", false + } + return acme.ALPNProto, true +} + +// storeACMETLSALPNCert publishes cert to Serve TLS handshakes for domain +// until the returned cleanup function is called. +func (e *extension) storeACMETLSALPNCert(domain string, cert *tls.Certificate) (cleanup func()) { + e.pendingACMETLSALPNCerts.Store(domain, cert) + return func() { + e.pendingACMETLSALPNCerts.Delete(domain) + } +} + +// getCertPEMWithValidity gets the TLSCertKeyPair for domain, either +// from cache or via ACME. ACME is used for new domain certs, existing +// expired certs, or existing certs that should be renewed sooner than +// minValidity. +func (e *extension) getCertPEMWithValidity(ctx context.Context, b *ipnlocal.LocalBackend, domain string, minValidity time.Duration) (*ipnlocal.TLSCertKeyPair, error) { + e.mu.Lock() + getCertForTest := e.getCertForTest + e.mu.Unlock() + + if getCertForTest != nil { + testenv.AssertInTest() + return getCertForTest(domain) + } + + if !validLookingCertDomain(domain) { + return nil, errors.New("invalid domain") + } + + certDomain, err := e.resolveCertDomain(b, domain) + if err != nil { + return nil, err + } + logf := logger.WithPrefix(b.Logger(), fmt.Sprintf("cert(%q): ", domain)) + now := b.Clock().Now() + traceACME := func(v any) { + if !acmeDebug() { + return + } + j, _ := json.MarshalIndent(v, "", "\t") + log.Printf("acme %T: %s", v, j) + } + + cs, err := e.getCertStore(b) + if err != nil { + return nil, err + } + + pair, cacheErr := getCertPEMCached(cs, certDomain, now) + if cacheErr == nil { + if envknob.IsCertShareReadOnlyMode() { + return pair, nil + } + // If we got here, we have a valid unexpired cert. + // Check whether we should start an async renewal. + shouldRenew, err := e.shouldStartDomainRenewal(b, cs, certDomain, now, pair, minValidity) + if err != nil { + logf("error checking for certificate renewal: %v", err) + // Renewal check failed, but the current cert is valid and not + // expired, so it's safe to return. + return pair, nil + } + if !shouldRenew { + return pair, nil + } + if minValidity == 0 { + logf("starting async renewal") + // Start renewal in the background, return current valid cert. + e.Go(func() { + if _, err := getCertPEM(context.Background(), e, b, cs, logf, traceACME, certDomain, now, minValidity); err != nil { + logf("async renewal failed: getCertPem: %v", err) + } + }) + return pair, nil + } + // If the caller requested a specific validity duration, fall through + // to synchronous renewal to fulfill that. + logf("starting sync renewal") + } + + if envknob.IsCertShareReadOnlyMode() { + return nil, fmt.Errorf("retrieving cached TLS certificate failed and cert store is configured in read-only mode, not attempting to issue a new certificate: %w", cacheErr) + } + + pair, err = getCertPEM(ctx, e, b, cs, logf, traceACME, certDomain, now, minValidity) + if err != nil { + logf("getCertPEM: %v", err) + return nil, err + } + return pair, nil +} + +// shouldStartDomainRenewal reports whether the domain's cert should be +// renewed based on the current time, the cert's expiry, and the ARI +// check. +func (e *extension) shouldStartDomainRenewal(b *ipnlocal.LocalBackend, cs certStore, domain string, now time.Time, pair *ipnlocal.TLSCertKeyPair, minValidity time.Duration) (bool, error) { + if minValidity != 0 { + cert, err := parseCertificate(pair) + if err != nil { + return false, fmt.Errorf("parsing certificate: %w", err) + } + return cert.NotAfter.Sub(now) < minValidity, nil + } + e.renewMu.Lock() + defer e.renewMu.Unlock() + if renewAt, ok := e.renewCertAt[domain]; ok { + return now.After(renewAt), nil + } + + renewTime, err := e.domainRenewalTimeByARI(b, cs, pair) + if err != nil { + // Log any ARI failure and fall back to checking for renewal by expiry. + b.Logger()("acme: ARI check failed: %v; falling back to expiry-based check", err) + renewTime, err = domainRenewalTimeByExpiry(pair) + if err != nil { + return false, err + } + } + + mak.Set(&e.renewCertAt, domain, renewTime) + return now.After(renewTime), nil +} + +func (e *extension) domainRenewed(domain string) { + e.renewMu.Lock() + defer e.renewMu.Unlock() + delete(e.renewCertAt, domain) +} + +func domainRenewalTimeByExpiry(pair *ipnlocal.TLSCertKeyPair) (time.Time, error) { + cert, err := parseCertificate(pair) + if err != nil { + return time.Time{}, fmt.Errorf("parsing certificate: %w", err) + } + + certLifetime := cert.NotAfter.Sub(cert.NotBefore) + if certLifetime < 0 { + return time.Time{}, fmt.Errorf("negative certificate lifetime %v", certLifetime) + } + + // Per https://github.com/tailscale/tailscale/issues/8204, check + // whether we're more than 2/3 of the way through the certificate's + // lifetime, which is the officially-recommended best practice by Let's + // Encrypt. + renewalDuration := certLifetime * 2 / 3 + renewAt := cert.NotBefore.Add(renewalDuration) + return renewAt, nil +} + +func (e *extension) shouldUseACMETLSALPN01(b *ipnlocal.LocalBackend, domain string, previous *ipnlocal.TLSCertKeyPair, logf logger.Logf) bool { + if isWildcardDomain(domain) { + logf("acme: using dns-01: tls-alpn-01 does not support wildcard certificates") + return false + } + if !b.HasFunnelForHostPort(domain, 443) { + logf("acme: using dns-01: Funnel is not enabled for %s:443", domain) + return false + } + if e.isBYOFunnelDomain(b, domain) { + // BYO Funnel domain: dns-01 is not a viable path because control + // does not own the user's DNS zone. Use tls-alpn-01 even on + // first issuance. + logf("acme: using tls-alpn-01 (BYO Funnel domain)") + return true + } + if previous == nil { + logf("acme: using dns-01: no cached certificate for Funnel renewal") + return false + } + logf("acme: using tls-alpn-01") + return true +} + +// isBYOFunnelDomain reports whether domain is a "bring your own" Funnel +// hostname: a domain that is not in the netmap's CertDomains but is +// referenced as a Funnel target on :443 by the local serve config. +// BYO domains can only be issued via tls-alpn-01 because control does +// not own their DNS zone. +func (e *extension) isBYOFunnelDomain(b *ipnlocal.LocalBackend, domain string) bool { + if domain == "" || isWildcardDomain(domain) { + return false + } + nm := b.NetMapNoPeers() + if nm != nil && slices.Contains(nm.DNS.CertDomains, domain) { + return false + } + return b.HasFunnelForHostPort(domain, 443) +} + +func challengeByType(challenges []*acme.Challenge, typ string) *acme.Challenge { + for _, ch := range challenges { + if ch.Type == typ { + return ch + } + } + return nil +} + +func isWildcardDomain(domain string) bool { + return strings.HasPrefix(domain, "*.") +} + +func (e *extension) domainRenewalTimeByARI(b *ipnlocal.LocalBackend, cs certStore, pair *ipnlocal.TLSCertKeyPair) (time.Time, error) { + var blocks []*pem.Block + rest := pair.CertPEM + for len(rest) > 0 { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + return time.Time{}, fmt.Errorf("parsing certificate PEM") + } + blocks = append(blocks, block) + } + if len(blocks) < 1 { + return time.Time{}, fmt.Errorf("could not parse certificate chain from certStore, got %d PEM block(s)", len(blocks)) + } + ac, err := acmeClient(cs) + if err != nil { + return time.Time{}, err + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ri, err := ac.FetchRenewalInfo(ctx, blocks[0].Bytes) + if err != nil { + return time.Time{}, fmt.Errorf("failed to fetch renewal info from ACME server: %w", err) + } + if acmeDebug() { + b.Logger()("acme: ARI response: %+v", ri) + } + + // Select a random time in the suggested window and renew if that time has + // passed. Time is randomized per recommendation in + // https://datatracker.ietf.org/doc/draft-ietf-acme-ari/ + start, end := ri.SuggestedWindow.Start, ri.SuggestedWindow.End + renewTime := start.Add(randv2.N(end.Sub(start))) + return renewTime, nil +} + +// getCertPEM checks if a cert needs to be renewed and if so, renews it. +// domain is the resolved cert domain (e.g., "*.node.ts.net" for +// wildcards). It can be overridden in tests. +var getCertPEM = func(ctx context.Context, e *extension, b *ipnlocal.LocalBackend, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*ipnlocal.TLSCertKeyPair, error) { + e.acmeMu.Lock() + defer e.acmeMu.Unlock() + + // In case this method was triggered multiple times in parallel (when + // serving incoming requests), check whether one of the other goroutines + // already renewed the cert before us. + previous, err := getCertPEMCached(cs, domain, now) + if err == nil { + // shouldStartDomainRenewal caches its result so it's OK to call this + // frequently. + shouldRenew, err := e.shouldStartDomainRenewal(b, cs, domain, now, previous, minValidity) + if err != nil { + logf("error checking for certificate renewal: %v", err) + } else if !shouldRenew { + return previous, nil + } + } else if !errors.Is(err, ipn.ErrStateNotExist) && !errors.Is(err, errCertExpired) { + return nil, err + } + + // If we have no usable cached cert (either nothing on disk, or what is + // on disk has expired or otherwise failed verification), surface a + // health warning to the user for the duration of the ACME flow. We + // don't fire the warning when previous is non-nil because then we have + // a working cert and the renewal is happening behind the scenes. + if previous == nil { + e.setCertPending(b, domain, true) + defer e.setCertPending(b, domain, false) + } + + ac, err := acmeClient(cs) + if err != nil { + return nil, err + } + + if !isDefaultDirectoryURL(ac.DirectoryURL) { + logf("acme: using Directory URL %q", ac.DirectoryURL) + } + + a, err := ac.GetReg(ctx, "" /* pre-RFC param */) + switch { + case err == nil: + // Great, already registered. + logf("already had ACME account.") + case err == acme.ErrNoAccount: + a, err = ac.Register(ctx, new(acme.Account), acme.AcceptTOS) + if err == acme.ErrAccountAlreadyExists { + // Potential race. Double check. + a, err = ac.GetReg(ctx, "" /* pre-RFC param */) + } + if err != nil { + return nil, fmt.Errorf("acme.Register: %w", err) + } + logf("registered ACME account.") + traceACME(a) + default: + return nil, fmt.Errorf("acme.GetReg: %w", err) + + } + if a.Status != acme.StatusValid { + return nil, fmt.Errorf("unexpected ACME account status %q", a.Status) + } + + // If we have a previous cert, include it in the order. Assuming we're + // within the ARI renewal window this should exclude us from LE rate + // limits. + // Note that this order extension will fail renewals if the ACME account key has changed + // since the last issuance, see + // https://github.com/tailscale/tailscale/issues/18251 + var opts []acme.OrderOption + if previous != nil && !envknob.Bool("TS_DEBUG_ACME_FORCE_RENEWAL") { + prevCrt, err := parseCertificate(previous) + if err == nil { + opts = append(opts, acme.WithOrderReplacesCert(prevCrt)) + } + } + + issueArgs := acmeCertIssueArgs{ + cs: cs, + logf: logf, + traceACME: traceACME, + domain: domain, + opts: opts, + } + if e.shouldUseACMETLSALPN01(b, domain, previous, logf) { + issueArgs.challengeType = acmeChallengeTLSALPN01 + pair, err := e.issueACMECert(ctx, b, ac, issueArgs) + if err == nil { + return pair, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + if e.isBYOFunnelDomain(b, domain) { + // BYO domains have no working dns-01 path (control does not + // own the zone), so surface the tls-alpn-01 error instead of + // burning an ACME attempt on a guaranteed-to-fail fallback. + return nil, err + } + logf("acme: tls-alpn-01 failed; falling back to dns-01: %v", err) + } + issueArgs.challengeType = acmeChallengeDNS01 + return e.issueACMECert(ctx, b, ac, issueArgs) +} + +type acmeCertIssueArgs struct { + cs certStore // certificate and ACME account storage + logf logger.Logf // logs ACME progress and failures + traceACME func(any) // optional hook for logging ACME messages + domain string // certificate domain being issued + opts []acme.OrderOption // ACME order options + challengeType acmeChallengeType // challenge type to fulfill +} + +func (args acmeCertIssueArgs) baseDomain() string { return strings.TrimPrefix(args.domain, "*.") } +func (args acmeCertIssueArgs) isWildcard() bool { return isWildcardDomain(args.domain) } + +func (e *extension) issueACMECert(ctx context.Context, b *ipnlocal.LocalBackend, ac *acme.Client, args acmeCertIssueArgs) (ret *ipnlocal.TLSCertKeyPair, err error) { + if args.traceACME == nil { + args.traceACME = func(any) {} + } + + switch args.challengeType { + case acmeChallengeTLSALPN01: + metricACMETLSALPN01Start.Add(1) + defer func() { + if err == nil { + metricACMETLSALPN01Success.Add(1) + } else { + metricACMETLSALPN01Failure.Add(1) + } + }() + case acmeChallengeDNS01: + metricACMEDNS01Start.Add(1) + defer func() { + if err == nil { + metricACMEDNS01Success.Add(1) + } else { + metricACMEDNS01Failure.Add(1) + } + }() + default: + return nil, fmt.Errorf("unknown ACME challenge type %q", args.challengeType) + } + + // For wildcards, we need to authorize both the wildcard and base domain. + var authzIDs []acme.AuthzID + if args.isWildcard() { + authzIDs = []acme.AuthzID{ + {Type: "dns", Value: args.domain}, + {Type: "dns", Value: args.baseDomain()}, + } + } else { + authzIDs = []acme.AuthzID{{Type: "dns", Value: args.domain}} + } + order, err := ac.AuthorizeOrder(ctx, authzIDs, args.opts...) + if err != nil { + return nil, err + } + args.traceACME(order) + + for _, aurl := range order.AuthzURLs { + az, err := ac.GetAuthorization(ctx, aurl) + if err != nil { + return nil, err + } + args.traceACME(az) + switch args.challengeType { + case acmeChallengeTLSALPN01: + ch := challengeByType(az.Challenges, string(acmeChallengeTLSALPN01)) + if ch == nil { + return nil, errors.New("tls-alpn-01 challenge not offered") + } + cert, err := ac.TLSALPN01ChallengeCert(ch.Token, az.Identifier.Value) + if err != nil { + return nil, fmt.Errorf("TLSALPN01ChallengeCert: %w", err) + } + cleanup := e.storeACMETLSALPNCert(az.Identifier.Value, &cert) + defer cleanup() + chal, err := ac.Accept(ctx, ch) + if err != nil { + return nil, fmt.Errorf("Accept: %v", err) + } + args.traceACME(chal) + case acmeChallengeDNS01: + if err := fulfillACMEDNS01Challenge(ctx, b, ac, az, args.logf, args.traceACME); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unknown ACME challenge type %q", args.challengeType) + } + } + + orderURI := order.URI + order, err = ac.WaitOrder(ctx, orderURI) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if oe, ok := err.(*acme.OrderError); ok { + args.logf("acme: WaitOrder: OrderError status %q", oe.Status) + } else { + args.logf("acme: WaitOrder error: %v", err) + } + return nil, err + } + args.traceACME(order) + + certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + var privPEM bytes.Buffer + if err := encodeECDSAKey(&privPEM, certPrivKey); err != nil { + return nil, err + } + + csr, err := certRequest(certPrivKey, args.domain, nil) + if err != nil { + return nil, err + } + + args.logf("requesting cert...") + args.traceACME(csr) + der, _, err := ac.CreateOrderCert(ctx, order.FinalizeURL, csr, true) + if err != nil { + return nil, fmt.Errorf("CreateOrder: %v", err) + } + args.logf("got cert") + + var certPEM bytes.Buffer + for _, b := range der { + pb := &pem.Block{Type: "CERTIFICATE", Bytes: b} + if err := pem.Encode(&certPEM, pb); err != nil { + return nil, err + } + } + if err := args.cs.WriteTLSCertAndKey(args.domain, certPEM.Bytes(), privPEM.Bytes()); err != nil { + return nil, err + } + e.domainRenewed(args.domain) + + return &ipnlocal.TLSCertKeyPair{CertPEM: certPEM.Bytes(), KeyPEM: privPEM.Bytes()}, nil +} + +func fulfillACMEDNS01Challenge(ctx context.Context, b *ipnlocal.LocalBackend, ac *acme.Client, az *acme.Authorization, logf logger.Logf, traceACME func(any)) error { + for _, ch := range az.Challenges { + if ch.Type != string(acmeChallengeDNS01) { + continue + } + rec, err := ac.DNS01ChallengeRecord(ch.Token) + if err != nil { + return err + } + // For wildcards, the challenge is on the base domain. + // e.g., "*.node.ts.net" -> "_acme-challenge.node.ts.net" + key := "_acme-challenge." + strings.TrimPrefix(az.Identifier.Value, "*.") + + // Do a best-effort lookup to see if we've already created this DNS name + // in a previous attempt. Don't burn too much time on it, though. Worst + // case we ask the server to create something that already exists. + var resolver net.Resolver + lookupCtx, lookupCancel := context.WithTimeout(ctx, 500*time.Millisecond) + txts, _ := resolver.LookupTXT(lookupCtx, key) + lookupCancel() + if slices.Contains(txts, rec) { + logf("TXT record already existed for %s", key) + } else { + logf("starting SetDNS call for %s...", key) + err = b.SetDNS(ctx, key, rec) + if err != nil { + return fmt.Errorf("SetDNS %q => %q: %w", key, rec, err) + } + logf("did SetDNS for %s", key) + } + + chal, err := ac.Accept(ctx, ch) + if err != nil { + return fmt.Errorf("Accept: %v", err) + } + traceACME(chal) + return nil + } + return errors.New("dns-01 challenge not offered") +} + +// validLookingCertDomain reports whether name looks like a valid domain +// name that we might be able to get a cert for. +// +// It's a light check primarily for double checking before it's used as +// part of a filesystem path. The actual validation happens in +// resolveCertDomain. +func validLookingCertDomain(name string) bool { + if name == "" || + strings.Contains(name, "..") || + strings.ContainsAny(name, ":/\\\x00") || + !strings.Contains(name, ".") { + return false + } + // Only allow * as a wildcard prefix "*.domain.tld" + if rest, ok := strings.CutPrefix(name, "*."); ok { + if strings.Contains(rest, "*") || !strings.Contains(rest, ".") { + return false + } + } else if strings.Contains(name, "*") { + return false + } + return true +} + +// resolveCertDomain validates a domain and returns the cert domain to use. +// +// - "node.ts.net" -> "node.ts.net" (exact CertDomain match) +// - "*.node.ts.net" -> "*.node.ts.net" (explicit wildcard, requires NodeAttrDNSSubdomainResolve) +// - "foo.com" -> "foo.com" (bring-your-own Funnel domain referenced by the +// local serve config; issued via tls-alpn-01 in getCertPEM) +// +// Subdomain requests like "app.node.ts.net" are rejected; callers should +// request "*.node.ts.net" explicitly for subdomain coverage. +func (e *extension) resolveCertDomain(b *ipnlocal.LocalBackend, domain string) (string, error) { + if domain == "" { + return "", errors.New("missing domain name") + } + + // Read the netmap once to get both CertDomains and capabilities atomically. + nm := b.NetMapNoPeers() + if nm == nil { + return "", errors.New("no netmap available") + } + certDomains := nm.DNS.CertDomains + if len(certDomains) == 0 && !e.isBYOFunnelDomain(b, domain) { + return "", errors.New("your Tailscale account does not support getting TLS certs") + } + + // Wildcard request like "*.node.ts.net". + if base, ok := strings.CutPrefix(domain, "*."); ok { + if !nm.AllCaps.Contains(tailcfg.NodeAttrDNSSubdomainResolve) { + return "", fmt.Errorf("wildcard certificates are not enabled for this node") + } + if !slices.Contains(certDomains, base) { + return "", fmt.Errorf("invalid domain %q; wildcard certificates are not enabled for this domain", domain) + } + return domain, nil + } + + // Exact CertDomain match. + if slices.Contains(certDomains, domain) { + return domain, nil + } + + // Bring-your-own Funnel domain (e.g. "foo.com"). The serve config + // references the domain as a Funnel target on :443; cert acquisition + // happens via tls-alpn-01 in getCertPEM. + if e.isBYOFunnelDomain(b, domain) { + return domain, nil + } + + return "", fmt.Errorf("invalid domain %q; must be one of %q", domain, certDomains) +} + +// setCertPending sets or clears the in-flight ACME issuance state for +// domain and updates the [certPendingWarnable] to reflect the current +// set of pending domains. +func (e *extension) setCertPending(b *ipnlocal.LocalBackend, domain string, pending bool) { + e.pendingCertDomainsMu.Lock() + defer e.pendingCertDomainsMu.Unlock() + if pending { + e.pendingCertDomains.Make() + e.pendingCertDomains.Add(domain) + } else { + e.pendingCertDomains.Delete(domain) + } + if e.pendingCertDomains.Len() == 0 { + b.HealthTracker().SetHealthy(certPendingWarnable) + return + } + b.HealthTracker().SetUnhealthy(certPendingWarnable, health.Args{ + health.ArgDomains: joinedPendingCertDomainsLocked(e.pendingCertDomains), + }) +} + +func joinedPendingCertDomainsLocked(s set.Set[string]) string { + ds := slicesx.MapKeys(s) + slices.Sort(ds) + return strings.Join(ds, ", ") +} + +// parseCertificate returns the leaf certificate from the given +// TLSCertKeyPair's CertPEM. +func parseCertificate(kp *ipnlocal.TLSCertKeyPair) (*x509.Certificate, error) { + block, _ := pem.Decode(kp.CertPEM) + if block == nil { + return nil, fmt.Errorf("error parsing certificate PEM") + } + if block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("PEM block is %q, not a CERTIFICATE", block.Type) + } + return x509.ParseCertificate(block.Bytes) +} diff --git a/ipn/ipnlocal/cert_test.go b/feature/acme/cert_test.go similarity index 78% rename from ipn/ipnlocal/cert_test.go rename to feature/acme/cert_test.go index 6b04db7f2..bf7cdc330 100644 --- a/ipn/ipnlocal/cert_test.go +++ b/feature/acme/cert_test.go @@ -3,7 +3,7 @@ //go:build !ios && !android && !js -package ipnlocal +package acme import ( "context" @@ -27,6 +27,8 @@ import ( "tailscale.com/envknob" "tailscale.com/health" "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/ipn/ipnlocal/ipnlocaltest" "tailscale.com/ipn/store/mem" "tailscale.com/tailcfg" "tailscale.com/tempfork/acme" @@ -38,6 +40,20 @@ import ( "tailscale.com/util/set" ) +//go:embed testdata/* +var certTestFS embed.FS + +// extOf returns the [*extension] registered on b, failing the test if +// it's not present. +func extOf(t *testing.T, b *ipnlocal.LocalBackend) *extension { + t.Helper() + e, ok := ipnlocal.GetExt[*extension](b) + if !ok { + t.Fatal("acme extension not registered on backend") + } + return e +} + func TestCertRequest(t *testing.T) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -120,62 +136,46 @@ func TestResolveCertDomain(t *testing.T) { name: "wildcard_without_cap", domain: "*.node.ts.net", certDomains: []string{"node.ts.net"}, - hasCap: false, wantErr: "wildcard certificates are not enabled for this node", }, { - name: "subdomain_with_cap_rejected", - domain: "app.node.ts.net", + name: "wildcard_wrong_domain_with_cap", + domain: "*.other.com", certDomains: []string{"node.ts.net"}, hasCap: true, - wantErr: `invalid domain "app.node.ts.net"; must be one of ["node.ts.net"]`, + wantErr: `invalid domain "*.other.com"; wildcard certificates are not enabled for this domain`, }, { - name: "subdomain_without_cap_rejected", - domain: "app.node.ts.net", + name: "missing_domain", + domain: "", certDomains: []string{"node.ts.net"}, - hasCap: false, - wantErr: `invalid domain "app.node.ts.net"; must be one of ["node.ts.net"]`, + wantErr: "missing domain name", }, { - name: "multi_level_subdomain_rejected", - domain: "a.b.node.ts.net", - certDomains: []string{"node.ts.net"}, + name: "no_cert_domains_with_cap", + domain: "node.ts.net", + certDomains: nil, hasCap: true, - wantErr: `invalid domain "a.b.node.ts.net"; must be one of ["node.ts.net"]`, + wantErr: "your Tailscale account does not support getting TLS certs", }, { - name: "wildcard_no_matching_parent", - domain: "*.unrelated.ts.net", - certDomains: []string{"node.ts.net"}, - hasCap: true, - wantErr: `invalid domain "*.unrelated.ts.net"; wildcard certificates are not enabled for this domain`, - }, - { - name: "subdomain_unrelated_rejected", - domain: "app.unrelated.ts.net", - certDomains: []string{"node.ts.net"}, - hasCap: true, - wantErr: `invalid domain "app.unrelated.ts.net"; must be one of ["node.ts.net"]`, - }, - { - name: "no_cert_domains", + name: "no_cert_domains_without_cap", domain: "node.ts.net", certDomains: nil, wantErr: "your Tailscale account does not support getting TLS certs", }, { - name: "wildcard_no_cert_domains", - domain: "*.foo.ts.net", - certDomains: nil, - hasCap: true, - wantErr: "your Tailscale account does not support getting TLS certs", + name: "subdomain_request_rejected_without_cap", + domain: "app.node.ts.net", + certDomains: []string{"node.ts.net"}, + wantErr: `invalid domain "app.node.ts.net"; must be one of ["node.ts.net"]`, }, { - name: "empty_domain", - domain: "", + name: "subdomain_request_rejected_with_cap", + domain: "app.node.ts.net", certDomains: []string{"node.ts.net"}, - wantErr: "missing domain name", + hasCap: true, + wantErr: `invalid domain "app.node.ts.net"; must be one of ["node.ts.net"]`, }, { name: "nil_netmap", @@ -187,26 +187,24 @@ func TestResolveCertDomain(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := newTestLocalBackend(t) + b := ipnlocaltest.NewBackend(t) + e := extOf(t, b) if !tt.skipNetmap { - // Set up netmap with CertDomains and capability var allCaps set.Set[tailcfg.NodeCapability] if tt.hasCap { allCaps = set.Of(tailcfg.NodeAttrDNSSubdomainResolve) } - b.mu.Lock() - b.currentNode().SetNetMap(&netmap.NetworkMap{ + b.ForTest().SetNetMap(&netmap.NetworkMap{ SelfNode: (&tailcfg.Node{}).View(), DNS: tailcfg.DNSConfig{ CertDomains: tt.certDomains, }, AllCaps: allCaps, }) - b.mu.Unlock() } - got, err := b.resolveCertDomain(tt.domain) + got, err := e.resolveCertDomain(b, tt.domain) if tt.wantErr != "" { if err == nil { t.Errorf("resolveCertDomain(%q) = %q, want error %q", tt.domain, got, tt.wantErr) @@ -257,32 +255,33 @@ func TestValidLookingCertDomain(t *testing.T) { } func TestACMETLSALPNCertHook(t *testing.T) { - b := newTestLocalBackend(t) + b := ipnlocaltest.NewBackend(t) + e := extOf(t, b) cert := &tls.Certificate{} - cleanup := b.storeACMETLSALPNCert("example.com", cert) + cleanup := e.storeACMETLSALPNCert("example.com", cert) defer cleanup() - if got, ok := b.getACMETLSALPNCert(&tls.ClientHelloInfo{ + if got, ok := b.ForTest().GetACMETLSALPNCert(&tls.ClientHelloInfo{ ServerName: "example.com", SupportedProtos: []string{acme.ALPNProto}, }); !ok || got != cert { t.Fatalf("getACMETLSALPNCert = %v, %v; want stored cert, true", got, ok) } - if _, ok := b.getACMETLSALPNCert(&tls.ClientHelloInfo{ + if _, ok := b.ForTest().GetACMETLSALPNCert(&tls.ClientHelloInfo{ ServerName: "example.com", SupportedProtos: []string{"http/1.1"}, }); ok { t.Fatal("getACMETLSALPNCert without acme ALPN = ok, want false") } - if _, ok := b.getACMETLSALPNCert(&tls.ClientHelloInfo{ + if _, ok := b.ForTest().GetACMETLSALPNCert(&tls.ClientHelloInfo{ ServerName: "other.example.com", SupportedProtos: []string{acme.ALPNProto}, }); ok { t.Fatal("getACMETLSALPNCert for other name = ok, want false") } - otherBackend := newTestLocalBackend(t) - if _, ok := otherBackend.getACMETLSALPNCert(&tls.ClientHelloInfo{ + otherBackend := ipnlocaltest.NewBackend(t) + if _, ok := otherBackend.ForTest().GetACMETLSALPNCert(&tls.ClientHelloInfo{ ServerName: "example.com", SupportedProtos: []string{acme.ALPNProto}, }); ok { @@ -290,52 +289,31 @@ func TestACMETLSALPNCertHook(t *testing.T) { } } -func TestServeTLSConfigNextProtos(t *testing.T) { - b := newTestLocalBackend(t) - getCert := func(*tls.ClientHelloInfo) (*tls.Certificate, error) { - return nil, nil - } - - httpsConfig := b.serveTLSConfig(getCert, serveTLSNextProtos()) - if got, want := httpsConfig.NextProtos, []string{"h2", "http/1.1"}; !slices.Equal(got, want) { - t.Fatalf("HTTPS NextProtos = %q; want %q", got, want) - } - - tcpForwardConfig := b.serveTLSConfig(getCert, nil) - if got := tcpForwardConfig.NextProtos; got != nil { - t.Fatalf("TLS-terminated TCP forward NextProtos = %q; want nil", got) - } -} - func TestShouldUseACMETLSALPN01(t *testing.T) { const ( tsNetDomain = "node.ts.net" byoDomain = "foo.com" ) - previous := &TLSCertKeyPair{} + previous := &ipnlocal.TLSCertKeyPair{} - setFunnel := func(b *LocalBackend, hosts ...string) { + setFunnel := func(b *ipnlocal.LocalBackend, hosts ...string) { funnel := map[ipn.HostPort]bool{} for _, h := range hosts { funnel[ipn.HostPort(h+":443")] = true } - b.mu.Lock() - b.serveConfig = (&ipn.ServeConfig{AllowFunnel: funnel}).View() - b.mu.Unlock() + b.ForTest().SetServeConfig((&ipn.ServeConfig{AllowFunnel: funnel}).View()) } - setNetmap := func(b *LocalBackend, certDomains ...string) { - b.mu.Lock() - b.currentNode().SetNetMap(&netmap.NetworkMap{ + setNetmap := func(b *ipnlocal.LocalBackend, certDomains ...string) { + b.ForTest().SetNetMap(&netmap.NetworkMap{ SelfNode: (&tailcfg.Node{}).View(), DNS: tailcfg.DNSConfig{CertDomains: certDomains}, }) - b.mu.Unlock() } tests := []struct { name string domain string - previous *TLSCertKeyPair + previous *ipnlocal.TLSCertKeyPair funnel []string netmap []string // CertDomains; if nil, no netmap installed want bool @@ -399,12 +377,13 @@ func TestShouldUseACMETLSALPN01(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := newTestLocalBackend(t) + b := ipnlocaltest.NewBackend(t) + e := extOf(t, b) if tt.netmap != nil { setNetmap(b, tt.netmap...) } setFunnel(b, tt.funnel...) - if got := b.shouldUseACMETLSALPN01(tt.domain, tt.previous, t.Logf); got != tt.want { + if got := e.shouldUseACMETLSALPN01(b, tt.domain, tt.previous, t.Logf); got != tt.want { t.Errorf("shouldUseACMETLSALPN01(%q, previous=%v) = %v, want %v", tt.domain, tt.previous != nil, got, tt.want) } @@ -413,22 +392,18 @@ func TestShouldUseACMETLSALPN01(t *testing.T) { } func TestIsBYOFunnelDomain(t *testing.T) { - setFunnel := func(b *LocalBackend, hosts ...string) { + setFunnel := func(b *ipnlocal.LocalBackend, hosts ...string) { funnel := map[ipn.HostPort]bool{} for _, h := range hosts { funnel[ipn.HostPort(h+":443")] = true } - b.mu.Lock() - b.serveConfig = (&ipn.ServeConfig{AllowFunnel: funnel}).View() - b.mu.Unlock() + b.ForTest().SetServeConfig((&ipn.ServeConfig{AllowFunnel: funnel}).View()) } - setNetmap := func(b *LocalBackend, certDomains ...string) { - b.mu.Lock() - b.currentNode().SetNetMap(&netmap.NetworkMap{ + setNetmap := func(b *ipnlocal.LocalBackend, certDomains ...string) { + b.ForTest().SetNetMap(&netmap.NetworkMap{ SelfNode: (&tailcfg.Node{}).View(), DNS: tailcfg.DNSConfig{CertDomains: certDomains}, }) - b.mu.Unlock() } tests := []struct { @@ -446,10 +421,11 @@ func TestIsBYOFunnelDomain(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := newTestLocalBackend(t) + b := ipnlocaltest.NewBackend(t) + e := extOf(t, b) setNetmap(b, tt.certDomains...) setFunnel(b, tt.funnel...) - if got := b.isBYOFunnelDomain(tt.domain); got != tt.want { + if got := e.isBYOFunnelDomain(b, tt.domain); got != tt.want { t.Errorf("isBYOFunnelDomain(%q) = %v, want %v", tt.domain, got, tt.want) } }) @@ -461,41 +437,36 @@ func TestResolveCertDomainBYO(t *testing.T) { tsNetDomain = "node.ts.net" byoDomain = "foo.com" ) - b := newTestLocalBackend(t) - b.mu.Lock() - b.currentNode().SetNetMap(&netmap.NetworkMap{ + b := ipnlocaltest.NewBackend(t) + e := extOf(t, b) + b.ForTest().SetNetMap(&netmap.NetworkMap{ SelfNode: (&tailcfg.Node{}).View(), DNS: tailcfg.DNSConfig{CertDomains: []string{tsNetDomain}}, }) - b.mu.Unlock() // Without a serve config, BYO is rejected. - if _, err := b.resolveCertDomain(byoDomain); err == nil { + if _, err := e.resolveCertDomain(b, byoDomain); err == nil { t.Fatalf("resolveCertDomain(%q) without serve config: want error, got nil", byoDomain) } // Web entry alone (no AllowFunnel) is not enough; the gate is Funnel. - b.mu.Lock() - b.serveConfig = (&ipn.ServeConfig{ + b.ForTest().SetServeConfig((&ipn.ServeConfig{ Web: map[ipn.HostPort]*ipn.WebServerConfig{ byoDomain + ":443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:8080"}}}, }, - }).View() - b.mu.Unlock() - if _, err := b.resolveCertDomain(byoDomain); err == nil { + }).View()) + if _, err := e.resolveCertDomain(b, byoDomain); err == nil { t.Fatalf("resolveCertDomain(%q) with Web but no Funnel: want error, got nil", byoDomain) } // With AllowFunnel, BYO is accepted. - b.mu.Lock() - b.serveConfig = (&ipn.ServeConfig{ + b.ForTest().SetServeConfig((&ipn.ServeConfig{ Web: map[ipn.HostPort]*ipn.WebServerConfig{ byoDomain + ":443": {Handlers: map[string]*ipn.HTTPHandler{"/": {Proxy: "http://127.0.0.1:8080"}}}, }, AllowFunnel: map[ipn.HostPort]bool{byoDomain + ":443": true}, - }).View() - b.mu.Unlock() - got, err := b.resolveCertDomain(byoDomain) + }).View()) + got, err := e.resolveCertDomain(b, byoDomain) if err != nil { t.Fatalf("resolveCertDomain(%q): %v", byoDomain, err) } @@ -504,7 +475,7 @@ func TestResolveCertDomainBYO(t *testing.T) { } // The ts.net path still works alongside BYO entries. - got, err = b.resolveCertDomain(tsNetDomain) + got, err = e.resolveCertDomain(b, tsNetDomain) if err != nil { t.Fatalf("resolveCertDomain(%q): %v", tsNetDomain, err) } @@ -513,9 +484,6 @@ func TestResolveCertDomainBYO(t *testing.T) { } } -//go:embed testdata/* -var certTestFS embed.FS - func TestCertStoreRoundTrip(t *testing.T) { const testDomain = "example.com" @@ -586,7 +554,7 @@ func TestCertStoreRoundTrip(t *testing.T) { } func TestShouldStartDomainRenewal(t *testing.T) { - mustMakePair := func(template *x509.Certificate) *TLSCertKeyPair { + mustMakePair := func(template *x509.Certificate) *ipnlocal.TLSCertKeyPair { priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { panic(err) @@ -601,7 +569,7 @@ func TestShouldStartDomainRenewal(t *testing.T) { Bytes: b, }) - return &TLSCertKeyPair{ + return &ipnlocal.TLSCertKeyPair{ Cached: false, CertPEM: certPEM, KeyPEM: []byte("unused"), @@ -644,10 +612,9 @@ func TestShouldStartDomainRenewal(t *testing.T) { want: false, }, } - b := new(LocalBackend) for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - ret, err := b.domainRenewalTimeByExpiry(mustMakePair(&x509.Certificate{ + ret, err := domainRenewalTimeByExpiry(mustMakePair(&x509.Certificate{ SerialNumber: big.NewInt(2019), Subject: subject, NotBefore: tt.notBefore, @@ -688,24 +655,23 @@ func TestDebugACMEDirectoryURL(t *testing.T) { func TestGetCertPEMWithValidity(t *testing.T) { const testDomain = "example.com" - b := newTestLocalBackend(t) - b.varRoot = t.TempDir() + b := ipnlocaltest.NewBackend(t) + b.SetVarRoot(t.TempDir()) + e := extOf(t, b) - // Set up netmap with CertDomains so resolveCertDomain works - b.mu.Lock() - b.currentNode().SetNetMap(&netmap.NetworkMap{ + // Set up netmap with CertDomains so resolveCertDomain works. + b.ForTest().SetNetMap(&netmap.NetworkMap{ SelfNode: (&tailcfg.Node{}).View(), DNS: tailcfg.DNSConfig{ CertDomains: []string{testDomain}, }, }) - b.mu.Unlock() - certDir, err := b.certDir() + certDirPath, err := certDir(b) if err != nil { t.Fatalf("certDir error: %v", err) } - if _, err := b.getCertStore(); err != nil { + if _, err := e.getCertStore(b); err != nil { t.Fatalf("getCertStore error: %v", err) } testRoot, err := certTestFS.ReadFile("testdata/rootCA.pem") @@ -781,54 +747,46 @@ func TestGetCertPEMWithValidity(t *testing.T) { envknob.Setenv("TS_CERT_SHARE_MODE", "") } - os.RemoveAll(certDir) + os.RemoveAll(certDirPath) if tt.storeCerts { - os.MkdirAll(certDir, 0755) - if err := os.WriteFile(filepath.Join(certDir, "example.com.crt"), - must.Get(os.ReadFile("testdata/example.com.pem")), 0644); err != nil { + os.MkdirAll(certDirPath, 0755) + if err := os.WriteFile(filepath.Join(certDirPath, "example.com.crt"), + must.Get(certTestFS.ReadFile("testdata/example.com.pem")), 0644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(certDir, "example.com.key"), - must.Get(os.ReadFile("testdata/example.com-key.pem")), 0644); err != nil { + if err := os.WriteFile(filepath.Join(certDirPath, "example.com.key"), + must.Get(certTestFS.ReadFile("testdata/example.com-key.pem")), 0644); err != nil { t.Fatal(err) } } - b.clock = tstest.NewClock(tstest.ClockOpts{Start: tt.now}) + b.ForTest().SetClock(tstest.NewClock(tstest.ClockOpts{Start: tt.now})) - allDone := make(chan bool, 1) - defer b.goTracker.AddDoneCallback(func() { - b.mu.Lock() - defer b.mu.Unlock() - if b.goTracker.RunningGoroutines() > 0 { - return - } - select { - case allDone <- true: - default: - } - })() - - // Set to true if get getCertPEM is called. GetCertPEM can be called in a goroutine for async - // renewal or in the main goroutine if issuance is required to obtain valid TLS credentials. + // Set to true if getCertPEM is called. GetCertPEM can be called in + // a goroutine for async renewal or in the main goroutine if + // issuance is required to obtain valid TLS credentials. getCertPemWasCalled := false - getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*TLSCertKeyPair, error) { + orig := getCertPEM + getCertPEM = func(ctx context.Context, e *extension, b *ipnlocal.LocalBackend, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*ipnlocal.TLSCertKeyPair, error) { getCertPemWasCalled = true return nil, nil } - prevGoRoutines := b.goTracker.StartedGoroutines() + t.Cleanup(func() { getCertPEM = orig }) + prevGo := e.goroutinesStarted.Load() _, err = b.GetCertPEMWithValidity(context.Background(), testDomain, 0) if (err != nil) != tt.wantErr { t.Errorf("b.GetCertPemWithValidity got err %v, wants error: '%v'", err, tt.wantErr) } - // GetCertPEMWithValidity calls getCertPEM in a goroutine if async renewal is needed. That's the - // only goroutine it starts, so this can be used to test if async renewal was started. - gotAsyncRenewal := b.goTracker.StartedGoroutines()-prevGoRoutines != 0 + // GetCertPEMWithValidity spawns one tracked goroutine (via + // extension.Go) iff it kicked off async renewal. + gotAsyncRenewal := e.goroutinesStarted.Load()-prevGo != 0 if gotAsyncRenewal { + done := make(chan struct{}) + go func() { e.wg.Wait(); close(done) }() select { case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for goroutines to finish") - case <-allDone: + t.Fatal("timed out waiting for async renewal goroutine to finish") + case <-done: } } // Verify that async renewal was triggered if expected. @@ -845,24 +803,25 @@ func TestGetCertPEMWithValidity(t *testing.T) { } func TestCertPendingWarnable(t *testing.T) { - b := newTestLocalBackend(t) + b := ipnlocaltest.NewBackend(t) + e := extOf(t, b) // currentWarning returns the pending warning's rendered text and // domain-list arg, or "", "" if the warnable is currently healthy. currentWarning := func() (text, domains string) { - ws, ok := b.health.CurrentState().Warnings[tsconst.HealthWarnableTLSCertPending] + ws, ok := b.HealthTracker().CurrentState().Warnings[tsconst.HealthWarnableTLSCertPending] if !ok { return "", "" } return ws.Text, ws.Args[health.ArgDomains] } - if b.health.IsUnhealthy(certPendingWarnable) { + if b.HealthTracker().IsUnhealthy(certPendingWarnable) { t.Fatal("warnable unexpectedly unhealthy before any setCertPending") } - b.setCertPending("a.example.com", true) - if !b.health.IsUnhealthy(certPendingWarnable) { + e.setCertPending(b, "a.example.com", true) + if !b.HealthTracker().IsUnhealthy(certPendingWarnable) { t.Fatal("warnable not unhealthy after first setCertPending") } if text, domains := currentWarning(); domains != "a.example.com" || @@ -870,8 +829,8 @@ func TestCertPendingWarnable(t *testing.T) { t.Errorf("after first setCertPending: text=%q domains=%q", text, domains) } - b.setCertPending("b.example.com", true) - if !b.health.IsUnhealthy(certPendingWarnable) { + e.setCertPending(b, "b.example.com", true) + if !b.HealthTracker().IsUnhealthy(certPendingWarnable) { t.Fatal("warnable not unhealthy after second setCertPending") } if text, domains := currentWarning(); domains != "a.example.com, b.example.com" || @@ -879,8 +838,8 @@ func TestCertPendingWarnable(t *testing.T) { t.Errorf("after second setCertPending: text=%q domains=%q", text, domains) } - b.setCertPending("a.example.com", false) - if !b.health.IsUnhealthy(certPendingWarnable) { + e.setCertPending(b, "a.example.com", false) + if !b.HealthTracker().IsUnhealthy(certPendingWarnable) { t.Fatal("warnable cleared too early; one domain still pending") } if text, domains := currentWarning(); domains != "b.example.com" || @@ -888,8 +847,8 @@ func TestCertPendingWarnable(t *testing.T) { t.Errorf("after clearing a.example.com: text=%q domains=%q", text, domains) } - b.setCertPending("b.example.com", false) - if b.health.IsUnhealthy(certPendingWarnable) { + e.setCertPending(b, "b.example.com", false) + if b.HealthTracker().IsUnhealthy(certPendingWarnable) { t.Fatal("warnable still unhealthy after clearing all domains") } if text, domains := currentWarning(); text != "" || domains != "" { @@ -962,17 +921,17 @@ func TestRefreshApplicableCerts(t *testing.T) { certDomain = "node1.example.com" byoDomain = "byo.example.org" ) - b := newTestLocalBackend(t) - b.varRoot = t.TempDir() + b := ipnlocaltest.NewBackend(t) + b.SetVarRoot(t.TempDir()) + e := extOf(t, b) - b.mu.Lock() - b.currentNode().SetNetMap(&netmap.NetworkMap{ + b.ForTest().SetNetMap(&netmap.NetworkMap{ SelfNode: (&tailcfg.Node{}).View(), DNS: tailcfg.DNSConfig{ CertDomains: []string{certDomain}, }, }) - b.serveConfig = (&ipn.ServeConfig{ + b.ForTest().SetServeConfig((&ipn.ServeConfig{ Web: map[ipn.HostPort]*ipn.WebServerConfig{ ipn.HostPort(certDomain + ":443"): {}, ipn.HostPort(byoDomain + ":443"): {}, @@ -982,16 +941,15 @@ func TestRefreshApplicableCerts(t *testing.T) { AllowFunnel: map[ipn.HostPort]bool{ ipn.HostPort(byoDomain + ":443"): true, }, - }).View() - b.mu.Unlock() + }).View()) gotCh := make(chan string, 4) - b.ForTest().ConfigureCerts(func(host string) (*TLSCertKeyPair, error) { + b.ForTest().ConfigureCerts(func(host string) (*ipnlocal.TLSCertKeyPair, error) { gotCh <- host - return &TLSCertKeyPair{}, nil + return &ipnlocal.TLSCertKeyPair{}, nil }) - b.refreshApplicableCerts(context.Background()) + e.refreshApplicableCerts(context.Background(), b) want := set.Of(certDomain, byoDomain) got := set.Set[string]{} diff --git a/feature/acme/certstore.go b/feature/acme/certstore.go new file mode 100644 index 000000000..8e62a2cfc --- /dev/null +++ b/feature/acme/certstore.go @@ -0,0 +1,444 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package acme + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "tailscale.com/atomicfile" + "tailscale.com/envknob" + "tailscale.com/feature/buildfeatures" + "tailscale.com/hostinfo" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/ipn/store" + "tailscale.com/ipn/store/mem" + "tailscale.com/net/bakedroots" + "tailscale.com/tempfork/acme" + "tailscale.com/util/testenv" + "tailscale.com/version" + "tailscale.com/version/distro" +) + +// certStore provides a way to perist and retrieve TLS certificates. +// As of 2023-02-01, we store certs in directories on disk everywhere +// except on Kubernetes, where we use the state store. +type certStore interface { + // Read returns the cert and key for domain, if they exist and are valid + // for now. If they're expired, it returns errCertExpired. + // If they don't exist, it returns ipn.ErrStateNotExist. + Read(domain string, now time.Time) (*ipnlocal.TLSCertKeyPair, error) + // ACMEKey returns the value previously stored via WriteACMEKey. + // It is a PEM encoded ECDSA key. + ACMEKey() ([]byte, error) + // WriteACMEKey stores the provided PEM encoded ECDSA key. + WriteACMEKey([]byte) error + // WriteTLSCertAndKey writes the cert and key for domain. + WriteTLSCertAndKey(domain string, cert, key []byte) error +} + +var errCertExpired = errors.New("cert expired") + +var testX509Roots *x509.CertPool // set non-nil by tests + +// certDir returns (creating if needed) the directory in which cached +// cert keypairs are stored. +func certDir(b *ipnlocal.LocalBackend) (string, error) { + d := b.TailscaleVarRoot() + + // As a workaround for Synology DSM6 not having a "var" directory, use the + // app's "etc" directory (on a small partition) to hold certs at least. + // See https://github.com/tailscale/tailscale/issues/4060#issuecomment-1186592251 + if buildfeatures.HasSynology && d == "" && runtime.GOOS == "linux" && distro.Get() == distro.Synology && distro.DSMVersion() == 6 { + d = "/var/packages/Tailscale/etc" // base; we append "certs" below + } + if d == "" { + return "", errors.New("no TailscaleVarRoot") + } + full := filepath.Join(d, "certs") + if err := os.MkdirAll(full, 0700); err != nil { + return "", err + } + return full, nil +} + +func (e *extension) getCertStore(b *ipnlocal.LocalBackend) (certStore, error) { + st := b.Sys().StateStore.Get() + switch st.(type) { + case *store.FileStore: + case *mem.Store: + default: + if hostinfo.GetEnvType() == hostinfo.Kubernetes { + // We're running in Kubernetes with a custom StateStore, + // use that instead of the cert directory. + // TODO(maisem): expand this to other environments? + return certStateStore{StateStore: st}, nil + } + } + dir, err := certDir(b) + if err != nil { + return nil, err + } + if testX509Roots != nil && !testenv.InTest() { + panic("use of test hook outside of tests") + } + return certFileStore{dir: dir, testRoots: testX509Roots}, nil +} + +// certFileStore implements certStore by storing the cert & key files in +// the named directory. +type certFileStore struct { + dir string + + // This field allows a test to override the CA root(s) for certificate + // verification. If nil the default system pool is used. + testRoots *x509.CertPool +} + +const acmePEMName = "acme-account.key.pem" + +func (f certFileStore) ACMEKey() ([]byte, error) { + pemName := filepath.Join(f.dir, acmePEMName) + v, err := os.ReadFile(pemName) + if err != nil { + if os.IsNotExist(err) { + return nil, ipn.ErrStateNotExist + } + return nil, err + } + return v, nil +} + +func (f certFileStore) WriteACMEKey(b []byte) error { + pemName := filepath.Join(f.dir, acmePEMName) + return atomicfile.WriteFile(pemName, b, 0600) +} + +func (f certFileStore) Read(domain string, now time.Time) (*ipnlocal.TLSCertKeyPair, error) { + certPEM, err := os.ReadFile(certFile(f.dir, domain)) + if err != nil { + if os.IsNotExist(err) { + return nil, ipn.ErrStateNotExist + } + return nil, err + } + keyPEM, err := os.ReadFile(keyFile(f.dir, domain)) + if err != nil { + if os.IsNotExist(err) { + return nil, ipn.ErrStateNotExist + } + return nil, err + } + if !validCertPEM(domain, keyPEM, certPEM, f.testRoots, now) { + return nil, errCertExpired + } + return &ipnlocal.TLSCertKeyPair{CertPEM: certPEM, KeyPEM: keyPEM, Cached: true}, nil +} + +func (f certFileStore) WriteCert(domain string, cert []byte) error { + return atomicfile.WriteFile(certFile(f.dir, domain), cert, 0644) +} + +func (f certFileStore) WriteKey(domain string, key []byte) error { + return atomicfile.WriteFile(keyFile(f.dir, domain), key, 0600) +} + +func (f certFileStore) WriteTLSCertAndKey(domain string, cert, key []byte) error { + if err := f.WriteKey(domain, key); err != nil { + return err + } + return f.WriteCert(domain, cert) +} + +// certStateStore implements certStore by storing the cert & key files +// in an ipn.StateStore. +type certStateStore struct { + ipn.StateStore + + // This field allows a test to override the CA root(s) for certificate + // verification. If nil the default system pool is used. + testRoots *x509.CertPool +} + +// TLSCertKeyReader is an interface implemented by state stores where it +// makes sense to read the TLS cert and key in a single operation that +// can be distinguished from generic state value reads. Currently this +// is only implemented by the kubestore.Store, which, in some cases, +// needs to read cert and key from a non-cached TLS Secret. +type TLSCertKeyReader interface { + ReadTLSCertAndKey(domain string) ([]byte, []byte, error) +} + +func (s certStateStore) Read(domain string, now time.Time) (*ipnlocal.TLSCertKeyPair, error) { + // If we're using a store that supports atomic reads, use that + if kr, ok := s.StateStore.(TLSCertKeyReader); ok { + cert, key, err := kr.ReadTLSCertAndKey(domain) + if err != nil { + return nil, err + } + if !validCertPEM(domain, key, cert, s.testRoots, now) { + return nil, errCertExpired + } + return &ipnlocal.TLSCertKeyPair{CertPEM: cert, KeyPEM: key, Cached: true}, nil + } + + // Otherwise fall back to separate reads + certPEM, err := s.ReadState(ipn.StateKey(domain + ".crt")) + if err != nil { + return nil, err + } + keyPEM, err := s.ReadState(ipn.StateKey(domain + ".key")) + if err != nil { + return nil, err + } + if !validCertPEM(domain, keyPEM, certPEM, s.testRoots, now) { + return nil, errCertExpired + } + return &ipnlocal.TLSCertKeyPair{CertPEM: certPEM, KeyPEM: keyPEM, Cached: true}, nil +} + +func (s certStateStore) WriteCert(domain string, cert []byte) error { + return ipn.WriteState(s.StateStore, ipn.StateKey(domain+".crt"), cert) +} + +func (s certStateStore) WriteKey(domain string, key []byte) error { + return ipn.WriteState(s.StateStore, ipn.StateKey(domain+".key"), key) +} + +func (s certStateStore) ACMEKey() ([]byte, error) { + return s.ReadState(ipn.StateKey(acmePEMName)) +} + +func (s certStateStore) WriteACMEKey(key []byte) error { + return ipn.WriteState(s.StateStore, ipn.StateKey(acmePEMName), key) +} + +// TLSCertKeyWriter is an interface implemented by state stores that can +// write the TLS cert and key in a single atomic operation. Currently +// this is only implemented by the kubestore.StoreKube. +type TLSCertKeyWriter interface { + WriteTLSCertAndKey(domain string, cert, key []byte) error +} + +// WriteTLSCertAndKey writes the TLS cert and key for domain to the +// current LocalBackend's StateStore. +func (s certStateStore) WriteTLSCertAndKey(domain string, cert, key []byte) error { + // If we're using a store that supports atomic writes, use that. + if aw, ok := s.StateStore.(TLSCertKeyWriter); ok { + return aw.WriteTLSCertAndKey(domain, cert, key) + } + // Otherwise fall back to separate writes for cert and key. + if err := s.WriteKey(domain, key); err != nil { + return err + } + return s.WriteCert(domain, cert) +} + +func keyFile(dir, domain string) string { + return filepath.Join(dir, strings.Replace(domain, "*.", "wildcard_.", 1)+".key") +} +func certFile(dir, domain string) string { + return filepath.Join(dir, strings.Replace(domain, "*.", "wildcard_.", 1)+".crt") +} + +// getCertPEMCached returns a non-nil keyPair if a cached keypair for +// domain exists in the certStore that is valid at the provided now time. +// +// If the keypair is expired, it returns errCertExpired. +// If the keypair doesn't exist, it returns ipn.ErrStateNotExist. +func getCertPEMCached(cs certStore, domain string, now time.Time) (p *ipnlocal.TLSCertKeyPair, err error) { + if !validLookingCertDomain(domain) { + // Before we read files from disk using it, validate it's halfway + // reasonable looking. + return nil, fmt.Errorf("invalid domain %q", domain) + } + return cs.Read(domain, now) +} + +// certRequest generates a CSR for the given domain and optional SANs. +func certRequest(key crypto.Signer, domain string, ext []pkix.Extension) ([]byte, error) { + dnsNames := []string{domain} + if base, ok := strings.CutPrefix(domain, "*."); ok { + // Wildcard cert must also include the base domain as a SAN. + // This is load-bearing: getCertPEMCached validates certs using + // the storage key (base domain), which only passes x509 verification + // if the base domain is in DNSNames. + dnsNames = append(dnsNames, base) + } + req := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: domain}, + DNSNames: dnsNames, + ExtraExtensions: ext, + } + return x509.CreateCertificateRequest(rand.Reader, req, key) +} + +func encodeECDSAKey(w io.Writer, key *ecdsa.PrivateKey) error { + b, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + pb := &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} + return pem.Encode(w, pb) +} + +// parsePrivateKey is a copy of x/crypto/acme's parsePrivateKey. +// +// Attempt to parse the given private key DER block. OpenSSL 0.9.8 +// generates PKCS#1 private keys by default, while OpenSSL 1.0.0 +// generates PKCS#8 keys. OpenSSL ecparam generates SEC1 EC private keys +// for ECDSA. We try all three. +// +// Inspired by parsePrivateKey in crypto/tls/tls.go. +func parsePrivateKey(der []byte) (crypto.Signer, error) { + if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return key, nil + } + if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { + switch key := key.(type) { + case *rsa.PrivateKey: + return key, nil + case *ecdsa.PrivateKey: + return key, nil + default: + return nil, errors.New("acme/autocert: unknown private key type in PKCS#8 wrapping") + } + } + if key, err := x509.ParseECPrivateKey(der); err == nil { + return key, nil + } + + return nil, errors.New("acme/autocert: failed to parse private key") +} + +func acmeKey(cs certStore) (crypto.Signer, error) { + if v, err := cs.ACMEKey(); err == nil { + priv, _ := pem.Decode(v) + if priv == nil || !strings.Contains(priv.Type, "PRIVATE") { + return nil, errors.New("acme/autocert: invalid account key found in cache") + } + return parsePrivateKey(priv.Bytes) + } else if !errors.Is(err, ipn.ErrStateNotExist) { + return nil, err + } + + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + var pemBuf bytes.Buffer + if err := encodeECDSAKey(&pemBuf, privKey); err != nil { + return nil, err + } + if err := cs.WriteACMEKey(pemBuf.Bytes()); err != nil { + return nil, err + } + return privKey, nil +} + +func acmeClient(cs certStore) (*acme.Client, error) { + key, err := acmeKey(cs) + if err != nil { + return nil, fmt.Errorf("acmeKey: %w", err) + } + // Note: if we add support for additional ACME providers (other than + // LetsEncrypt), we should make sure that they support ARI extension (see + // shouldStartDomainRenewalARI). + return &acme.Client{ + Key: key, + UserAgent: "tailscaled/" + version.Long(), + DirectoryURL: envknob.String("TS_DEBUG_ACME_DIRECTORY_URL"), + }, nil +} + +// validCertPEM reports whether the given certificate is valid for +// domain at now. +// +// If roots != nil, it is used instead of the system root pool. This is +// meant to support testing; production code should pass roots == nil. +func validCertPEM(domain string, keyPEM, certPEM []byte, roots *x509.CertPool, now time.Time) bool { + if len(keyPEM) == 0 || len(certPEM) == 0 { + return false + } + tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return false + } + + var leaf *x509.Certificate + intermediates := x509.NewCertPool() + for i, certDER := range tlsCert.Certificate { + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return false + } + if i == 0 { + leaf = cert + } else { + intermediates.AddCert(cert) + } + } + return validateLeaf(leaf, intermediates, domain, now, roots) +} + +// validateLeaf is a helper for [validCertPEM]. +// +// If called with roots == nil, it will use the system root pool as well +// as the baked-in roots. If non-nil, only those roots are used. +func validateLeaf(leaf *x509.Certificate, intermediates *x509.CertPool, domain string, now time.Time, roots *x509.CertPool) bool { + if leaf == nil { + return false + } + _, err := leaf.Verify(x509.VerifyOptions{ + DNSName: domain, + CurrentTime: now, + Roots: roots, + Intermediates: intermediates, + }) + if err != nil && roots == nil { + // If validation failed and they specified nil for roots (meaning to use + // the system roots), then give it another chance to validate using the + // binary's baked-in roots (LetsEncrypt). See tailscale/tailscale#14690. + return validateLeaf(leaf, intermediates, domain, now, bakedroots.Get()) + } + + if err == nil { + return true + } + + // When pointed at a non-prod ACME server, we don't expect to have the CA + // in our system or baked-in roots. Verify only throws UnknownAuthorityError + // after first checking the leaf cert's expiry, hostnames etc, so we know + // that the only reason for an error is to do with constructing a full chain. + // Allow this error so that cert caching still works in testing environments. + if errors.As(err, &x509.UnknownAuthorityError{}) { + acmeURL := envknob.String("TS_DEBUG_ACME_DIRECTORY_URL") + if !isDefaultDirectoryURL(acmeURL) { + return true + } + } + + return false +} + +func isDefaultDirectoryURL(u string) bool { + return u == "" || u == acme.LetsEncryptURL +} diff --git a/feature/acme/refresh.go b/feature/acme/refresh.go new file mode 100644 index 000000000..4e8bd9355 --- /dev/null +++ b/feature/acme/refresh.go @@ -0,0 +1,154 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package acme + +import ( + "context" + "net" + "time" + + "tailscale.com/envknob" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/util/set" +) + +// certRefreshInterval is how often the background loop iterates the set +// of applicable cert domains and pokes the renewal machinery. The loop +// is only started while there's at least one HTTPS Web entry in the +// ServeConfig, so this cadence doesn't tick on idle/mobile nodes. +const certRefreshInterval = time.Hour + +// updateCertRefreshLoop starts or stops the background TLS cert refresh +// loop based on whether the backend currently has any HTTPS-serving +// hostname whose cert should be kept fresh. The loop runs only while: +// +// - the node is in [ipn.Running], and +// - the current [ipn.ServeConfig] has at least one HTTPS-serving entry. +// +// We deliberately don't keep an idle timer around on hosts that have no +// certs to maintain (e.g. mobile devices that never run Serve), so this +// is called whenever any of those inputs change: state transitions and +// ServeConfig reloads. The caller (in [ipn/ipnlocal]) holds b.mu when +// invoking this; we use our own e.mu for the refresh-loop bookkeeping. +func (e *extension) updateCertRefreshLoop(b *ipnlocal.LocalBackend, state ipn.State, sc ipn.ServeConfigView) { + shouldRun := state == ipn.Running && serveConfigUsesACMECerts(sc) + + e.mu.Lock() + defer e.mu.Unlock() + switch { + case shouldRun && e.certRefreshCancel == nil: + ctx, cancel := context.WithCancel(context.Background()) + e.certRefreshCancel = cancel + e.Go(func() { e.certRefreshLoop(ctx, b) }) + case !shouldRun && e.certRefreshCancel != nil: + e.certRefreshCancel() + e.certRefreshCancel = nil + } +} + +// certRefreshLoop periodically iterates the domains configured for +// Serve or Funnel HTTPS and calls GetCertPEM on each. The existing +// renewal machinery in getCertPEM decides whether anything needs to +// happen (ARI check or expiry-based fallback); the loop just ensures +// it runs even on nodes that see no inbound TLS traffic. +// +// The first iteration runs immediately so that a node coming back +// online with stale or absent certs starts ACME within seconds rather +// than waiting a full interval. +func (e *extension) certRefreshLoop(ctx context.Context, b *ipnlocal.LocalBackend) { + if envknob.IsCertShareReadOnlyMode() { + b.Logger()("cert refresh loop: cert-share read-only mode; loop is a no-op") + return + } + + ticker, tickerCh := b.Clock().NewTicker(certRefreshInterval) + defer ticker.Stop() + for { + e.refreshApplicableCerts(ctx, b) + select { + case <-tickerCh: + case <-ctx.Done(): + return + } + } +} + +// refreshApplicableCerts is one iteration of the cert refresh loop. +// +// It enumerates the Serve/Funnel-configured HTTPS hostnames, keeps +// those that resolveCertDomain accepts (CertDomain, wildcard, or BYO +// Funnel domain), and calls [LocalBackend.GetCertPEM] for each. The +// renewal decision is delegated to the existing logic in getCertPEM. +func (e *extension) refreshApplicableCerts(ctx context.Context, b *ipnlocal.LocalBackend) { + sc := b.ServeConfig() + if !sc.Valid() { + return + } + + want := set.Set[string]{} + consider := func(host string) { + if host == "" { + return + } + if _, err := e.resolveCertDomain(b, host); err != nil { + return + } + want.Add(host) + } + for hp := range sc.Webs() { + host, _, err := net.SplitHostPort(string(hp)) + if err != nil { + continue + } + consider(host) + } + for _, tcp := range sc.TCPs() { + consider(tcp.TerminateTLS()) + } + for _, svc := range sc.Services().All() { + for _, tcp := range svc.TCP().All() { + consider(tcp.TerminateTLS()) + } + } + if want.Len() == 0 { + return + } + + for d := range want { + e.Go(func() { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + if _, err := e.getCertPEMWithValidity(ctx, b, d, 0); err != nil { + b.Logger()("cert refresh: %s: %v", d, err) + } + }) + } +} + +// serveConfigUsesACMECerts reports whether sc has any entry that +// causes tailscaled to obtain ACME-managed TLS certs: an HTTPS Web +// entry (background, foreground, or service) or a TCP handler with +// TerminateTLS set (`tailscale serve --tls-terminated-tcp`). +func serveConfigUsesACMECerts(sc ipn.ServeConfigView) bool { + if !sc.Valid() { + return false + } + for range sc.Webs() { + return true + } + for _, tcp := range sc.TCPs() { + if tcp.TerminateTLS() != "" { + return true + } + } + for _, svc := range sc.Services().All() { + for _, tcp := range svc.TCP().All() { + if tcp.TerminateTLS() != "" { + return true + } + } + } + return false +} diff --git a/feature/acme/testdata/example.com-key.pem b/feature/acme/testdata/example.com-key.pem new file mode 100644 index 000000000..06902f4c9 --- /dev/null +++ b/feature/acme/testdata/example.com-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCejQaJrntrJSgE +QtScyTU6TXOU+v1FdFjrsyHFK5mjV1C5pVQxnLn93GRshtIrGOLLrd3Wv2TVYZOX +xH7f1ZLFbneDURCXbS+7nmsg+TLHRSRKfODbE3oYZj7NSJ163CCvwSJKTdmLpXbn +ui9F04tyk0zxO4Wre4ukwf6xtse8G5zl2RJrueiVAiouTG/pJdIS08dGQa0GM1n9 +Aesa+TerlZcpRZR6X402yQqa8q/QqbIuzrlfDmgOb8sm6T8+JMtj3hEvnYdpMVOg +w/XiTlX0v/YrB9sVQ9XnqGsqwTL0OMG0choMNKipwLi2n+XPSCIiRhi666zNNivE +K1qaPS5RAgMBAAECggEAV9dAGQWPISR70CiKjLa5A60nbRHFQjackTE0c32daC6W +7dOYGsh/DxOMm8fyJqhp9nhEYJa3MbUWxU27ER3NbA6wrhM6gvqeKG8zYRhPNrGq +0o3vMdDPozb6cldZ0Fimz1jMO6h373NjtiyjxibWqkrLpRbaDtCq5EQKbMEcVa2D +Xt5hxCOaCA3OZ/mAcGUNFmDNgNsGP/r6eXdI5pbqnUNMPkv/JsHl8h2HuyKUm4hf +TRnXPAak6DkUod9QXYFKVBVPa5pjiO09e0aiMUvJ8vYd/6bNIsAKWLPa1PYuUE2l +kg8Nik+P/XLzffKsLxiFKY0nCqrorM9K5q7baofGdQKBgQDPujjebFg6OKw6MS3S +PESopvL//C/XgtgifcSSZCWzIZRVBVTbbJCGRtqFzF0XO4YRX3EOAyD/L7wYUPzO ++W3AU2W3/DVJYdcm2CASABbHNy0kk52LI0HHAssbFDgyB9XuuWP+vVZk7B5OmCAD +Bppuj6Mnu03i282nKNJzvRiVnwKBgQDDZUXv22K8y7GkKw/ZW/wQP2zBNtFc15he +1EOyUGHlXuQixnDSaqonkwec6IOlo7Sx/vwO/7+v4Jzc24Wq3DFAmMu/EYJgvI+m +m3kpB4H7Xus4JqnhxqN7GB7zOdguCWZF1HLemZNZlVrUjG5mQ9cizzvvYptnQDLq +FEJ1hddWDwKBgB+vy276Xfb7oCH8UH4KXXrQhK7RvEaGmgug3bRq/Gk3zRWvC4Ox +KtagxkK0qtqZZNkPkwJNLeJfWLTo3beAyuIUlqabHVHFT/mH7FRymQbofsVekyCf +TzBZV7wYuH3BPjv9IajBHwWkEvdwMyni/vmwhXXRF49schF2o6uuA6sHAoGBAL1J +Xnb+EKjUq0JedPwcIBOdXb3PXQKT2QgEmZAkTrHlOxx1INa2fh/YT4ext9a+wE2u +tn/RQeEfttY90z+yEASEAN0YGTWddYvxEW6t1z2stjGvQuN1ium0dEcrwkDW2jzL +knwSSqx+A3/kiw6GqeMO3wEIhYOArdIVzkwLXJABAoGAOXLGhz5u5FWjF3zAeYme +uHTU/3Z3jeI80PvShGrgAakPOBt3cIFpUaiOEslcqqgDUSGE3EnmkRqaEch+UapF +ty6Zz7cKjXhQSWOjew1uUW2ANNEpsnYbmZOOnfvosd7jfHSVbL6KIhWmIdC6h0NP +c/bJnTXEEVsWjLZTwYaq0Us= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/feature/acme/testdata/example.com.pem b/feature/acme/testdata/example.com.pem new file mode 100644 index 000000000..588850813 --- /dev/null +++ b/feature/acme/testdata/example.com.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEcDCCAtigAwIBAgIRAPmUKRkyFAkVVxFblB/233cwDQYJKoZIhvcNAQELBQAw +gZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnJv +bWJlcmdlckBzdGFyZHVzdC5sb2NhbCAoTWljaGFlbCBKLiBGcm9tYmVyZ2VyKTFB +MD8GA1UEAww4bWtjZXJ0IGZyb21iZXJnZXJAc3RhcmR1c3QubG9jYWwgKE1pY2hh +ZWwgSi4gRnJvbWJlcmdlcikwHhcNMjMwMjA3MjAzNDE4WhcNMjUwNTA3MTkzNDE4 +WjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4 +BgNVBAsMMWZyb21iZXJnZXJAc3RhcmR1c3QubG9jYWwgKE1pY2hhZWwgSi4gRnJv +bWJlcmdlcikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCejQaJrntr +JSgEQtScyTU6TXOU+v1FdFjrsyHFK5mjV1C5pVQxnLn93GRshtIrGOLLrd3Wv2TV +YZOXxH7f1ZLFbneDURCXbS+7nmsg+TLHRSRKfODbE3oYZj7NSJ163CCvwSJKTdmL +pXbnui9F04tyk0zxO4Wre4ukwf6xtse8G5zl2RJrueiVAiouTG/pJdIS08dGQa0G +M1n9Aesa+TerlZcpRZR6X402yQqa8q/QqbIuzrlfDmgOb8sm6T8+JMtj3hEvnYdp +MVOgw/XiTlX0v/YrB9sVQ9XnqGsqwTL0OMG0choMNKipwLi2n+XPSCIiRhi666zN +NivEK1qaPS5RAgMBAAGjYDBeMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr +BgEFBQcDATAfBgNVHSMEGDAWgBTXyq2jQVrnqQKL8fB9C4L0QJftwDAWBgNVHREE +DzANggtleGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAAOCAYEAQWzpOaBkRR4M+WqB +CsT4ARyM6WpZ+jpeSblCzPdlDRW+50G1HV7K930zayq4DwncPY/SqSn0Q31WuzZv +bTWHkWa+MLPGYANHsusOmMR8Eh16G4+5+GGf8psWa0npAYO35cuNkyyCCc1LEB4M +NrzCB2+KZ+SyOdfCCA5VzEKN3I8wvVLaYovi24Zjwv+0uETG92TlZmLQRhj8uPxN +deeLM45aBkQZSYCbGMDVDK/XYKBkNLn3kxD/eZeXxxr41v4pH44+46FkYcYJzdn8 +ccAg5LRGieqTozhLiXARNK1vTy6kR1l/Az8DIx6GN4sP2/LMFYFijiiOCDKS1wWA +xQgZeHt4GIuBym+Kd+Z5KXcP0AT+47Cby3+B10Kq8vHwjTELiF0UFeEYYMdynPAW +pbEwVLhsfMsBqFtj3dsxHr8Kz3rnarOYzkaw7EMZnLAthb2CN7y5uGV9imQC5RMI +/qZdRSuCYZ3A1E/WJkGbPY/YdPql/IE+LIAgKGFHZZNftBCo +-----END CERTIFICATE----- \ No newline at end of file diff --git a/feature/acme/testdata/rootCA.pem b/feature/acme/testdata/rootCA.pem new file mode 100644 index 000000000..88a16f47a --- /dev/null +++ b/feature/acme/testdata/rootCA.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFEDCCA3igAwIBAgIRANf5NdPojIfj70wMfJVYUg8wDQYJKoZIhvcNAQELBQAw +gZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnJv +bWJlcmdlckBzdGFyZHVzdC5sb2NhbCAoTWljaGFlbCBKLiBGcm9tYmVyZ2VyKTFB +MD8GA1UEAww4bWtjZXJ0IGZyb21iZXJnZXJAc3RhcmR1c3QubG9jYWwgKE1pY2hh +ZWwgSi4gRnJvbWJlcmdlcikwHhcNMjMwMjA3MjAzNDE4WhcNMzMwMjA3MjAzNDE4 +WjCBnzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTowOAYDVQQLDDFm +cm9tYmVyZ2VyQHN0YXJkdXN0LmxvY2FsIChNaWNoYWVsIEouIEZyb21iZXJnZXIp +MUEwPwYDVQQDDDhta2NlcnQgZnJvbWJlcmdlckBzdGFyZHVzdC5sb2NhbCAoTWlj +aGFlbCBKLiBGcm9tYmVyZ2VyKTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoC +ggGBAL5uXNnrZ6dgjcvK0Hc7ZNUIRYEWst9qbO0P9H7le08pJ6d9T2BUWruZtVjk +Q12msv5/bVWHhVk8dZclI9FLXuMsIrocH8bsoP4wruPMyRyp6EedSKODN51fFSRv +/jHbS5vzUVAWTYy9qYmd6qL0uhsHCZCCT6gfigamHPUFKM3sHDn5ZHWvySMwcyGl +AicmPAIkBWqiCZAkB5+WM7+oyRLjmrIalfWIZYxW/rojGLwTfneHv6J5WjVQnpJB +ayWCzCzaiXukK9MeBWeTOe8UfVN0Engd74/rjLWvjbfC+uZSr6RVkZvs2jANLwPF +zgzBPHgRPfAhszU1NNAMjnNQ47+OMOTKRt7e6jYzhO5fyO1qVAAvGBqcfpj+JfDk +cccaUMhUvdiGrhGf1V1tN/PislxvALirzcFipjD01isBKwn0fxRugzvJNrjEo8RA +RvbcdeKcwex7M0o/Cd0+G2B13gZNOFvR33PmG7iTpp7IUrUKfQg28I83Sp8tMY3s +ljJSawIDAQABo0UwQzAOBgNVHQ8BAf8EBAMCAgQwEgYDVR0TAQH/BAgwBgEB/wIB +ADAdBgNVHQ4EFgQU18qto0Fa56kCi/HwfQuC9ECX7cAwDQYJKoZIhvcNAQELBQAD +ggGBAAzs96LwZVOsRSlBdQqMo8oMAvs7HgnYbXt8SqaACLX3+kJ3cV/vrCE3iJrW +ma4CiQbxS/HqsiZjota5m4lYeEevRnUDpXhp+7ugZTiz33Flm1RU99c9UYfQ+919 +ANPAKeqNpoPco/HF5Bz0ocepjcfKQrVZZNTj6noLs8o12FHBLO5976AcF9mqlNfh +8/F0gDJXq6+x7VT5y8u0rY004XKPRe3CklRt8kpeMiP6mhRyyUehOaHeIbNx8ubi +Pi44ByN/ueAnuRhF9zYtyZVZZOaSLysJge01tuPXF8rBXGruoJIv35xTTBa9BzaP +YDOGbGn1ZnajdNagHqCba8vjTLDSpqMvgRj3TFrGHdETA2LDQat38uVxX8gxm68K +va5Tyv7n+6BQ5YTpJjTPnmSJKaXZrrhdLPvG0OU2TxeEsvbcm5LFQofirOOw86Se +vzF2cQ94mmHRZiEk0Av3NO0jF93ELDrBCuiccVyEKq6TknuvPQlutCXKDOYSEb8I +MHctBg== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/ipn/ipnlocal/c2n_test.go b/ipn/ipnlocal/c2n_test.go index e5b15e79a..8c5d24461 100644 --- a/ipn/ipnlocal/c2n_test.go +++ b/ipn/ipnlocal/c2n_test.go @@ -5,144 +5,19 @@ package ipnlocal import ( "bytes" - "cmp" - "crypto/x509" "encoding/json" "net/http/httptest" - "net/url" - "os" - "path/filepath" - "reflect" "testing" - "time" - "tailscale.com/health" - "tailscale.com/ipn/store/mem" "tailscale.com/tailcfg" - "tailscale.com/tstest" "tailscale.com/types/key" - "tailscale.com/types/logger" "tailscale.com/types/netmap" "tailscale.com/types/views" - "tailscale.com/util/eventbus/eventbustest" - "tailscale.com/util/must" gcmp "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) -func TestHandleC2NTLSCertStatus(t *testing.T) { - b := &LocalBackend{ - store: &mem.Store{}, - varRoot: t.TempDir(), - health: health.NewTracker(eventbustest.NewBus(t)), - } - certDir, err := b.certDir() - if err != nil { - t.Fatalf("certDir error: %v", err) - } - if _, err := b.getCertStore(); err != nil { - t.Fatalf("getCertStore error: %v", err) - } - - testRoot, err := certTestFS.ReadFile("testdata/rootCA.pem") - if err != nil { - t.Fatal(err) - } - roots := x509.NewCertPool() - if !roots.AppendCertsFromPEM(testRoot) { - t.Fatal("Unable to add test CA to the cert pool") - } - testX509Roots = roots - defer func() { testX509Roots = nil }() - - tests := []struct { - name string - domain string - copyFile bool // copy testdata/example.com.pem to the certDir - wantStatus int // 0 means 200 - wantError string // wanted non-JSON non-200 error - now time.Time - want *tailcfg.C2NTLSCertInfo - }{ - { - name: "no-domain", - wantStatus: 400, - wantError: "no 'domain'\n", - }, - { - name: "missing", - domain: "example.com", - want: &tailcfg.C2NTLSCertInfo{ - Error: "no certificate", - Missing: true, - }, - }, - { - name: "valid", - domain: "example.com", - now: time.Date(2023, time.February, 20, 0, 0, 0, 0, time.UTC), - copyFile: true, - want: &tailcfg.C2NTLSCertInfo{ - Valid: true, - NotBefore: "2023-02-07T20:34:18Z", - NotAfter: "2025-05-07T19:34:18Z", - }, - }, - { - name: "expired", - domain: "example.com", - now: time.Date(2030, time.February, 20, 0, 0, 0, 0, time.UTC), - copyFile: true, - want: &tailcfg.C2NTLSCertInfo{ - Error: "cert expired", - Expired: true, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - os.RemoveAll(certDir) // reset per test - if tt.copyFile { - os.MkdirAll(certDir, 0755) - if err := os.WriteFile(filepath.Join(certDir, "example.com.crt"), - must.Get(os.ReadFile("testdata/example.com.pem")), 0644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(certDir, "example.com.key"), - must.Get(os.ReadFile("testdata/example.com-key.pem")), 0644); err != nil { - t.Fatal(err) - } - } - b.clock = tstest.NewClock(tstest.ClockOpts{ - Start: tt.now, - }) - - rec := httptest.NewRecorder() - handleC2NTLSCertStatus(b, rec, httptest.NewRequest("GET", "/tls-cert-status?domain="+url.QueryEscape(tt.domain), nil)) - res := rec.Result() - wantStatus := cmp.Or(tt.wantStatus, 200) - if res.StatusCode != wantStatus { - t.Fatalf("status code = %v; want %v. Body: %s", res.Status, wantStatus, rec.Body.Bytes()) - } - if wantStatus == 200 { - var got tailcfg.C2NTLSCertInfo - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("bad JSON: %v", err) - } - if !reflect.DeepEqual(&got, tt.want) { - t.Errorf("got %v; want %v", logger.AsJSON(got), logger.AsJSON(tt.want)) - } - } else if tt.wantError != "" { - if got := rec.Body.String(); got != tt.wantError { - t.Errorf("body = %q; want %q", got, tt.wantError) - } - } - }) - } - -} - func TestHandleC2NDebugNetmap(t *testing.T) { nm := &netmap.NetworkMap{ SelfNode: (&tailcfg.Node{ diff --git a/ipn/ipnlocal/cert.go b/ipn/ipnlocal/cert.go index 254bc3996..247fc2568 100644 --- a/ipn/ipnlocal/cert.go +++ b/ipn/ipnlocal/cert.go @@ -1,1361 +1,144 @@ // Copyright (c) Tailscale Inc & contributors // SPDX-License-Identifier: BSD-3-Clause -//go:build !js && !ts_omit_acme - package ipnlocal import ( - "bytes" "context" - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/json" - "encoding/pem" "errors" - "fmt" "io" - "log" - randv2 "math/rand/v2" - "net" "net/http" - "os" - "path/filepath" - "runtime" - "slices" - "strings" "time" - "tailscale.com/atomicfile" - "tailscale.com/envknob" - "tailscale.com/feature/buildfeatures" - "tailscale.com/health" - "tailscale.com/hostinfo" + "tailscale.com/feature" "tailscale.com/ipn" - "tailscale.com/ipn/store" - "tailscale.com/ipn/store/mem" - "tailscale.com/net/bakedroots" - "tailscale.com/tailcfg" - "tailscale.com/tempfork/acme" - "tailscale.com/tsconst" - "tailscale.com/types/logger" - "tailscale.com/util/clientmetric" - "tailscale.com/util/mak" - "tailscale.com/util/set" - "tailscale.com/util/slicesx" - "tailscale.com/util/testenv" - "tailscale.com/version" - "tailscale.com/version/distro" ) -func init() { - RegisterC2N("GET /tls-cert-status", handleC2NTLSCertStatus) - hookCertRefreshLoop.Set(certRefreshLoop) -} - -var ( - metricACMEDNS01Start = clientmetric.NewCounter("cert_acme_dns01_start") - metricACMEDNS01Success = clientmetric.NewCounter("cert_acme_dns01_success") - metricACMEDNS01Failure = clientmetric.NewCounter("cert_acme_dns01_failure") - metricACMETLSALPN01Start = clientmetric.NewCounter("cert_acme_tls_alpn01_start") - metricACMETLSALPN01Success = clientmetric.NewCounter("cert_acme_tls_alpn01_success") - metricACMETLSALPN01Failure = clientmetric.NewCounter("cert_acme_tls_alpn01_failure") -) - -// certPendingWarnable fires while ACME is fetching a TLS certificate for -// which no usable cached copy exists (initial issuance or after the cached -// cert has expired). Async renewal of a still-valid cert does not fire it. -var certPendingWarnable = health.Register(&health.Warnable{ - Code: tsconst.HealthWarnableTLSCertPending, - Title: "Fetching TLS certificate", - Severity: health.SeverityLow, - Text: func(args health.Args) string { - return fmt.Sprintf("Fetching TLS certificate via ACME for: %s", args[health.ArgDomains]) - }, -}) - -type acmeChallengeType string - -const ( - acmeChallengeDNS01 acmeChallengeType = "dns-01" - acmeChallengeTLSALPN01 acmeChallengeType = "tls-alpn-01" -) - -// serveTLSNextProtos returns the baseline ALPN protocols for ordinary Serve -// TLS traffic. ACME tls-alpn-01 is intentionally not advertised here; it is -// added dynamically by serveTLSConfig only while a matching challenge -// certificate is pending. -func serveTLSNextProtos() []string { - return []string{"h2", "http/1.1"} -} - -// getACMETLSALPNCert returns the short-lived ACME challenge certificate for -// hi.ServerName. The ok result reports whether hi offered acme-tls/1 and an -// ACME order is actively waiting on that challenge for hi.ServerName. -func (b *LocalBackend) getACMETLSALPNCert(hi *tls.ClientHelloInfo) (cert *tls.Certificate, ok bool) { - if hi == nil || hi.ServerName == "" || !slices.Contains(hi.SupportedProtos, acme.ALPNProto) { - return nil, false - } - cs := b.certState() - if cs == nil { - return nil, false - } - cert, ok = cs.pendingACMETLSALPNCerts.Load(hi.ServerName) - return cert, ok -} - -// getACMETLSALPNProto reports whether serveTLSConfig should advertise an ACME -// ALPN protocol for this ClientHello. The proto result is the protocol to -// advertise, and ok reports whether hi offered acme-tls/1 and an ACME order is -// actively waiting on that challenge for hi.ServerName. It is separate from -// getACMETLSALPNCert because Go selects ALPN before calling GetCertificate; -// both hooks must agree for the challenge handshake to complete. -func (b *LocalBackend) getACMETLSALPNProto(hi *tls.ClientHelloInfo) (proto string, ok bool) { - if _, ok := b.getACMETLSALPNCert(hi); !ok { - return "", false - } - return acme.ALPNProto, true -} - -// storeACMETLSALPNCert publishes cert to Serve TLS handshakes for domain until -// the returned cleanup function is called. -// -// It returns a no-op cleanup if the cert extension is not registered. -func (b *LocalBackend) storeACMETLSALPNCert(domain string, cert *tls.Certificate) (cleanup func()) { - cs := b.certState() - if cs == nil { - return func() {} - } - cs.pendingACMETLSALPNCerts.Store(domain, cert) - return func() { - cs.pendingACMETLSALPNCerts.Delete(domain) - } -} - -// certDir returns (creating if needed) the directory in which cached -// cert keypairs are stored. -func (b *LocalBackend) certDir() (string, error) { - d := b.TailscaleVarRoot() - - // As a workaround for Synology DSM6 not having a "var" directory, use the - // app's "etc" directory (on a small partition) to hold certs at least. - // See https://github.com/tailscale/tailscale/issues/4060#issuecomment-1186592251 - if buildfeatures.HasSynology && d == "" && runtime.GOOS == "linux" && distro.Get() == distro.Synology && distro.DSMVersion() == 6 { - d = "/var/packages/Tailscale/etc" // base; we append "certs" below - } - if d == "" { - return "", errors.New("no TailscaleVarRoot") - } - full := filepath.Join(d, "certs") - if err := os.MkdirAll(full, 0700); err != nil { - return "", err - } - return full, nil -} - -var acmeDebug = envknob.RegisterBool("TS_DEBUG_ACME") - -// GetCertPEM gets the TLSCertKeyPair for domain, either from cache or via the -// ACME process. ACME process is used for new domain certs, existing expired -// certs or existing certs that should get renewed due to upcoming expiry. -// -// If a cert is expired, it will be renewed synchronously otherwise it will be -// renewed asynchronously. -func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) { - return b.GetCertPEMWithValidity(ctx, domain, 0) -} - -// GetCertPEMWithValidity gets the TLSCertKeyPair for domain, either from cache -// or via the ACME process. ACME process is used for new domain certs, existing -// expired certs or existing certs that should get renewed sooner than -// minValidity. -// -// If a cert is expired, or expires sooner than minValidity, it will be renewed -// synchronously. Otherwise it will be renewed asynchronously. -// -// The domain must be one of: -// -// - An exact CertDomain (e.g., "node.ts.net") -// - A wildcard domain (e.g., "*.node.ts.net") -// - A bring-your-own Funnel domain referenced by the local serve config -// (e.g., "foo.com" when ServeConfig.AllowFunnel has "foo.com:443"). -// -// The wildcard format requires the NodeAttrDNSSubdomainResolve capability. -// ts.net domains are issued via dns-01 against control's DNS zone; BYO -// Funnel domains are issued via tls-alpn-01 over the same Funnel TLS path -// that serves real traffic. -func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string, minValidity time.Duration) (*TLSCertKeyPair, error) { - state := b.certState() - if state == nil { - return nil, errors.New("cert extension not registered") - } - - b.mu.Lock() - getCertForTest := state.getCertForTest - b.mu.Unlock() - - if getCertForTest != nil { - testenv.AssertInTest() - return getCertForTest(domain) - } - - if !validLookingCertDomain(domain) { - return nil, errors.New("invalid domain") - } - - certDomain, err := b.resolveCertDomain(domain) - if err != nil { - return nil, err - } - logf := logger.WithPrefix(b.logf, fmt.Sprintf("cert(%q): ", domain)) - now := b.clock.Now() - traceACME := func(v any) { - if !acmeDebug() { - return - } - j, _ := json.MarshalIndent(v, "", "\t") - log.Printf("acme %T: %s", v, j) - } - - cs, err := b.getCertStore() - if err != nil { - return nil, err - } - - if pair, err := getCertPEMCached(cs, certDomain, now); err == nil { - if envknob.IsCertShareReadOnlyMode() { - return pair, nil - } - // If we got here, we have a valid unexpired cert. - // Check whether we should start an async renewal. - shouldRenew, err := b.shouldStartDomainRenewal(cs, certDomain, now, pair, minValidity) - if err != nil { - logf("error checking for certificate renewal: %v", err) - // Renewal check failed, but the current cert is valid and not - // expired, so it's safe to return. - return pair, nil - } - if !shouldRenew { - return pair, nil - } - if minValidity == 0 { - logf("starting async renewal") - // Start renewal in the background, return current valid cert. - b.goTracker.Go(func() { - if _, err := getCertPEM(context.Background(), b, cs, logf, traceACME, certDomain, now, minValidity); err != nil { - logf("async renewal failed: getCertPem: %v", err) - } - }) - return pair, nil - } - // If the caller requested a specific validity duration, fall through - // to synchronous renewal to fulfill that. - logf("starting sync renewal") - } - - if envknob.IsCertShareReadOnlyMode() { - return nil, fmt.Errorf("retrieving cached TLS certificate failed and cert store is configured in read-only mode, not attempting to issue a new certificate: %w", err) - } - - pair, err := getCertPEM(ctx, b, cs, logf, traceACME, certDomain, now, minValidity) - if err != nil { - logf("getCertPEM: %v", err) - return nil, err - } - return pair, nil -} - -// shouldStartDomainRenewal reports whether the domain's cert should be renewed -// based on the current time, the cert's expiry, and the ARI check. -func (b *LocalBackend) shouldStartDomainRenewal(cs certStore, domain string, now time.Time, pair *TLSCertKeyPair, minValidity time.Duration) (bool, error) { - if minValidity != 0 { - cert, err := pair.parseCertificate() - if err != nil { - return false, fmt.Errorf("parsing certificate: %w", err) - } - return cert.NotAfter.Sub(now) < minValidity, nil - } - state := b.certState() - if state == nil { - return false, errors.New("cert extension not registered") - } - state.renewMu.Lock() - defer state.renewMu.Unlock() - if renewAt, ok := state.renewCertAt[domain]; ok { - return now.After(renewAt), nil - } - - renewTime, err := b.domainRenewalTimeByARI(cs, pair) - if err != nil { - // Log any ARI failure and fall back to checking for renewal by expiry. - b.logf("acme: ARI check failed: %v; falling back to expiry-based check", err) - renewTime, err = b.domainRenewalTimeByExpiry(pair) - if err != nil { - return false, err - } - } - - mak.Set(&state.renewCertAt, domain, renewTime) - return now.After(renewTime), nil -} - -func (b *LocalBackend) domainRenewed(domain string) { - state := b.certState() - if state == nil { - return - } - state.renewMu.Lock() - defer state.renewMu.Unlock() - delete(state.renewCertAt, domain) -} - -func (b *LocalBackend) domainRenewalTimeByExpiry(pair *TLSCertKeyPair) (time.Time, error) { - cert, err := pair.parseCertificate() - if err != nil { - return time.Time{}, fmt.Errorf("parsing certificate: %w", err) - } - - certLifetime := cert.NotAfter.Sub(cert.NotBefore) - if certLifetime < 0 { - return time.Time{}, fmt.Errorf("negative certificate lifetime %v", certLifetime) - } - - // Per https://github.com/tailscale/tailscale/issues/8204, check - // whether we're more than 2/3 of the way through the certificate's - // lifetime, which is the officially-recommended best practice by Let's - // Encrypt. - renewalDuration := certLifetime * 2 / 3 - renewAt := cert.NotBefore.Add(renewalDuration) - return renewAt, nil -} - -func (b *LocalBackend) shouldUseACMETLSALPN01(domain string, previous *TLSCertKeyPair, logf logger.Logf) bool { - if isWildcardDomain(domain) { - logf("acme: using dns-01: tls-alpn-01 does not support wildcard certificates") - return false - } - if !b.hasFunnelForHostPort(domain, 443) { - logf("acme: using dns-01: Funnel is not enabled for %s:443", domain) - return false - } - if b.isBYOFunnelDomain(domain) { - // BYO Funnel domain: dns-01 is not a viable path because control - // does not own the user's DNS zone. Use tls-alpn-01 even on - // first issuance. - logf("acme: using tls-alpn-01 (BYO Funnel domain)") - return true - } - if previous == nil { - logf("acme: using dns-01: no cached certificate for Funnel renewal") - return false - } - logf("acme: using tls-alpn-01") - return true -} - -// isBYOFunnelDomain reports whether domain is a "bring your own" Funnel -// hostname: a domain that is not in the netmap's CertDomains but is -// referenced as a Funnel target on :443 by the local serve config. -// BYO domains can only be issued via tls-alpn-01 because control does -// not own their DNS zone. -func (b *LocalBackend) isBYOFunnelDomain(domain string) bool { - if domain == "" || isWildcardDomain(domain) { - return false - } - nm := b.NetMapNoPeers() - if nm != nil && slices.Contains(nm.DNS.CertDomains, domain) { - return false - } - return b.hasFunnelForHostPort(domain, 443) -} - -func challengeByType(challenges []*acme.Challenge, typ string) *acme.Challenge { - for _, ch := range challenges { - if ch.Type == typ { - return ch - } - } - return nil -} - -func isWildcardDomain(domain string) bool { - return strings.HasPrefix(domain, "*.") -} - -func (b *LocalBackend) domainRenewalTimeByARI(cs certStore, pair *TLSCertKeyPair) (time.Time, error) { - var blocks []*pem.Block - rest := pair.CertPEM - for len(rest) > 0 { - var block *pem.Block - block, rest = pem.Decode(rest) - if block == nil { - return time.Time{}, fmt.Errorf("parsing certificate PEM") - } - blocks = append(blocks, block) - } - if len(blocks) < 1 { - return time.Time{}, fmt.Errorf("could not parse certificate chain from certStore, got %d PEM block(s)", len(blocks)) - } - ac, err := acmeClient(cs) - if err != nil { - return time.Time{}, err - } - ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second) - defer cancel() - ri, err := ac.FetchRenewalInfo(ctx, blocks[0].Bytes) - if err != nil { - return time.Time{}, fmt.Errorf("failed to fetch renewal info from ACME server: %w", err) - } - if acmeDebug() { - b.logf("acme: ARI response: %+v", ri) - } - - // Select a random time in the suggested window and renew if that time has - // passed. Time is randomized per recommendation in - // https://datatracker.ietf.org/doc/draft-ietf-acme-ari/ - start, end := ri.SuggestedWindow.Start, ri.SuggestedWindow.End - renewTime := start.Add(randv2.N(end.Sub(start))) - return renewTime, nil -} - -// certStore provides a way to perist and retrieve TLS certificates. -// As of 2023-02-01, we use store certs in directories on disk everywhere -// except on Kubernetes, where we use the state store. -type certStore interface { - // Read returns the cert and key for domain, if they exist and are valid - // for now. If they're expired, it returns errCertExpired. - // If they don't exist, it returns ipn.ErrStateNotExist. - Read(domain string, now time.Time) (*TLSCertKeyPair, error) - // ACMEKey returns the value previously stored via WriteACMEKey. - // It is a PEM encoded ECDSA key. - ACMEKey() ([]byte, error) - // WriteACMEKey stores the provided PEM encoded ECDSA key. - WriteACMEKey([]byte) error - // WriteTLSCertAndKey writes the cert and key for domain. - WriteTLSCertAndKey(domain string, cert, key []byte) error -} - -var errCertExpired = errors.New("cert expired") - -var testX509Roots *x509.CertPool // set non-nil by tests - -func (b *LocalBackend) getCertStore() (certStore, error) { - switch b.store.(type) { - case *store.FileStore: - case *mem.Store: - default: - if hostinfo.GetEnvType() == hostinfo.Kubernetes { - // We're running in Kubernetes with a custom StateStore, - // use that instead of the cert directory. - // TODO(maisem): expand this to other environments? - return certStateStore{StateStore: b.store}, nil - } - } - dir, err := b.certDir() - if err != nil { - return nil, err - } - if testX509Roots != nil && !testenv.InTest() { - panic("use of test hook outside of tests") - } - return certFileStore{dir: dir, testRoots: testX509Roots}, nil -} - -// certFileStore implements certStore by storing the cert & key files in the named directory. -type certFileStore struct { - dir string - - // This field allows a test to override the CA root(s) for certificate - // verification. If nil the default system pool is used. - testRoots *x509.CertPool -} - -const acmePEMName = "acme-account.key.pem" - -func (f certFileStore) ACMEKey() ([]byte, error) { - pemName := filepath.Join(f.dir, acmePEMName) - v, err := os.ReadFile(pemName) - if err != nil { - if os.IsNotExist(err) { - return nil, ipn.ErrStateNotExist - } - return nil, err - } - return v, nil -} - -func (f certFileStore) WriteACMEKey(b []byte) error { - pemName := filepath.Join(f.dir, acmePEMName) - return atomicfile.WriteFile(pemName, b, 0600) -} - -func (f certFileStore) Read(domain string, now time.Time) (*TLSCertKeyPair, error) { - certPEM, err := os.ReadFile(certFile(f.dir, domain)) - if err != nil { - if os.IsNotExist(err) { - return nil, ipn.ErrStateNotExist - } - return nil, err - } - keyPEM, err := os.ReadFile(keyFile(f.dir, domain)) - if err != nil { - if os.IsNotExist(err) { - return nil, ipn.ErrStateNotExist - } - return nil, err - } - if !validCertPEM(domain, keyPEM, certPEM, f.testRoots, now) { - return nil, errCertExpired - } - return &TLSCertKeyPair{CertPEM: certPEM, KeyPEM: keyPEM, Cached: true}, nil -} - -func (f certFileStore) WriteCert(domain string, cert []byte) error { - return atomicfile.WriteFile(certFile(f.dir, domain), cert, 0644) -} - -func (f certFileStore) WriteKey(domain string, key []byte) error { - return atomicfile.WriteFile(keyFile(f.dir, domain), key, 0600) -} - -func (f certFileStore) WriteTLSCertAndKey(domain string, cert, key []byte) error { - if err := f.WriteKey(domain, key); err != nil { - return err - } - return f.WriteCert(domain, cert) -} - -// certStateStore implements certStore by storing the cert & key files in an ipn.StateStore. -type certStateStore struct { - ipn.StateStore - - // This field allows a test to override the CA root(s) for certificate - // verification. If nil the default system pool is used. - testRoots *x509.CertPool -} - -// TLSCertKeyReader is an interface implemented by state stores where it makes -// sense to read the TLS cert and key in a single operation that can be -// distinguished from generic state value reads. Currently this is only implemented -// by the kubestore.Store, which, in some cases, need to read cert and key from a -// non-cached TLS Secret. -type TLSCertKeyReader interface { - ReadTLSCertAndKey(domain string) ([]byte, []byte, error) -} - -func (s certStateStore) Read(domain string, now time.Time) (*TLSCertKeyPair, error) { - // If we're using a store that supports atomic reads, use that - if kr, ok := s.StateStore.(TLSCertKeyReader); ok { - cert, key, err := kr.ReadTLSCertAndKey(domain) - if err != nil { - return nil, err - } - if !validCertPEM(domain, key, cert, s.testRoots, now) { - return nil, errCertExpired - } - return &TLSCertKeyPair{CertPEM: cert, KeyPEM: key, Cached: true}, nil - } - - // Otherwise fall back to separate reads - certPEM, err := s.ReadState(ipn.StateKey(domain + ".crt")) - if err != nil { - return nil, err - } - keyPEM, err := s.ReadState(ipn.StateKey(domain + ".key")) - if err != nil { - return nil, err - } - if !validCertPEM(domain, keyPEM, certPEM, s.testRoots, now) { - return nil, errCertExpired - } - return &TLSCertKeyPair{CertPEM: certPEM, KeyPEM: keyPEM, Cached: true}, nil -} - -func (s certStateStore) WriteCert(domain string, cert []byte) error { - return ipn.WriteState(s.StateStore, ipn.StateKey(domain+".crt"), cert) -} - -func (s certStateStore) WriteKey(domain string, key []byte) error { - return ipn.WriteState(s.StateStore, ipn.StateKey(domain+".key"), key) -} - -func (s certStateStore) ACMEKey() ([]byte, error) { - return s.ReadState(ipn.StateKey(acmePEMName)) -} - -func (s certStateStore) WriteACMEKey(key []byte) error { - return ipn.WriteState(s.StateStore, ipn.StateKey(acmePEMName), key) -} - -// TLSCertKeyWriter is an interface implemented by state stores that can write the TLS -// cert and key in a single atomic operation. Currently this is only implemented -// by the kubestore.StoreKube. -type TLSCertKeyWriter interface { - WriteTLSCertAndKey(domain string, cert, key []byte) error -} - -// WriteTLSCertAndKey writes the TLS cert and key for domain to the current -// LocalBackend's StateStore. -func (s certStateStore) WriteTLSCertAndKey(domain string, cert, key []byte) error { - // If we're using a store that supports atomic writes, use that. - if aw, ok := s.StateStore.(TLSCertKeyWriter); ok { - return aw.WriteTLSCertAndKey(domain, cert, key) - } - // Otherwise fall back to separate writes for cert and key. - if err := s.WriteKey(domain, key); err != nil { - return err - } - return s.WriteCert(domain, cert) -} - -// TLSCertKeyPair is a TLS public and private key, and whether they were obtained -// from cache or freshly obtained. +// TLSCertKeyPair is a TLS public and private key, and whether they were +// obtained from cache or freshly obtained. type TLSCertKeyPair struct { CertPEM []byte // public key, in PEM form KeyPEM []byte // private key, in PEM form Cached bool // whether result came from cache } -func (kp TLSCertKeyPair) parseCertificate() (*x509.Certificate, error) { - block, _ := pem.Decode(kp.CertPEM) - if block == nil { - return nil, fmt.Errorf("error parsing certificate PEM") - } - if block.Type != "CERTIFICATE" { - return nil, fmt.Errorf("PEM block is %q, not a CERTIFICATE", block.Type) - } - return x509.ParseCertificate(block.Bytes) -} +// errNoCerts is returned by the wrapper methods below when ACME/cert +// support is not compiled into this build. +var errNoCerts = errors.New("cert support not compiled in this build") -func keyFile(dir, domain string) string { - return filepath.Join(dir, strings.Replace(domain, "*.", "wildcard_.", 1)+".key") -} -func certFile(dir, domain string) string { - return filepath.Join(dir, strings.Replace(domain, "*.", "wildcard_.", 1)+".crt") -} +// Hooks installed by the feature/acme package at init time. In builds +// without ACME support (js or ts_omit_acme), feature/acme is not linked +// in and these hooks remain unset; the wrapper methods below then +// behave as no-ops or return errNoCerts. +var ( + // HookGetCertPEM implements [LocalBackend.GetCertPEMWithValidity]. + HookGetCertPEM feature.Hook[func(ctx context.Context, b *LocalBackend, domain string, minValidity time.Duration) (*TLSCertKeyPair, error)] -// getCertPEMCached returns a non-nil keyPair if a cached keypair for domain -// exists on disk in dir that is valid at the provided now time. -// -// If the keypair is expired, it returns errCertExpired. -// If the keypair doesn't exist, it returns ipn.ErrStateNotExist. -func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKeyPair, err error) { - if !validLookingCertDomain(domain) { - // Before we read files from disk using it, validate it's halfway - // reasonable looking. - return nil, fmt.Errorf("invalid domain %q", domain) - } - return cs.Read(domain, now) -} + // HookGetACMETLSALPNCert returns the ACME tls-alpn-01 challenge + // certificate for hi, if any. + HookGetACMETLSALPNCert feature.Hook[func(b *LocalBackend, hi *tls.ClientHelloInfo) (*tls.Certificate, bool)] -// getCertPem checks if a cert needs to be renewed and if so, renews it. -// domain is the resolved cert domain (e.g., "*.node.ts.net" for wildcards). -// It can be overridden in tests. -var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time, minValidity time.Duration) (*TLSCertKeyPair, error) { - state := b.certState() - if state == nil { - return nil, errors.New("cert extension not registered") - } - state.acmeMu.Lock() - defer state.acmeMu.Unlock() + // HookGetACMETLSALPNProto reports whether the ACME ALPN protocol + // should be advertised for hi. + HookGetACMETLSALPNProto feature.Hook[func(b *LocalBackend, hi *tls.ClientHelloInfo) (string, bool)] - // In case this method was triggered multiple times in parallel (when - // serving incoming requests), check whether one of the other goroutines - // already renewed the cert before us. - previous, err := getCertPEMCached(cs, domain, now) - if err == nil { - // shouldStartDomainRenewal caches its result so it's OK to call this - // frequently. - shouldRenew, err := b.shouldStartDomainRenewal(cs, domain, now, previous, minValidity) - if err != nil { - logf("error checking for certificate renewal: %v", err) - } else if !shouldRenew { - return previous, nil + // HookUpdateCertRefreshLoop is called when [LocalBackend]'s state + // or serve config changes, so the cert refresh loop can be + // (re)started or stopped. It is invoked with b.mu held. + HookUpdateCertRefreshLoop feature.Hook[func(b *LocalBackend, state ipn.State, sc ipn.ServeConfigView)] + + // HookShutdownCertRefreshLoop is called from + // [LocalBackend.Shutdown] to cancel the cert refresh loop. + HookShutdownCertRefreshLoop feature.Hook[func(b *LocalBackend)] + + // HookConfigureCertsForTest implements + // [LocalBackend.ConfigureCertsForTest]. + HookConfigureCertsForTest feature.Hook[func(b *LocalBackend, getCert func(string) (*TLSCertKeyPair, error))] + + // HookHandleC2NTLSCertStatus handles the GET /tls-cert-status + // control-to-node request. + HookHandleC2NTLSCertStatus feature.Hook[func(b *LocalBackend, w http.ResponseWriter, r *http.Request)] +) + +func init() { + RegisterC2N("GET /tls-cert-status", func(b *LocalBackend, w http.ResponseWriter, r *http.Request) { + if f, ok := HookHandleC2NTLSCertStatus.GetOk(); ok { + f(b, w, r) + return } - } else if !errors.Is(err, ipn.ErrStateNotExist) && !errors.Is(err, errCertExpired) { - return nil, err - } - - // If we have no usable cached cert (either nothing on disk, or what is - // on disk has expired or otherwise failed verification), surface a - // health warning to the user for the duration of the ACME flow. We - // don't fire the warning when previous is non-nil because then we have - // a working cert and the renewal is happening behind the scenes. - if previous == nil { - b.setCertPending(domain, true) - defer b.setCertPending(domain, false) - } - - ac, err := acmeClient(cs) - if err != nil { - return nil, err - } - - if !isDefaultDirectoryURL(ac.DirectoryURL) { - logf("acme: using Directory URL %q", ac.DirectoryURL) - } - - a, err := ac.GetReg(ctx, "" /* pre-RFC param */) - switch { - case err == nil: - // Great, already registered. - logf("already had ACME account.") - case err == acme.ErrNoAccount: - a, err = ac.Register(ctx, new(acme.Account), acme.AcceptTOS) - if err == acme.ErrAccountAlreadyExists { - // Potential race. Double check. - a, err = ac.GetReg(ctx, "" /* pre-RFC param */) - } - if err != nil { - return nil, fmt.Errorf("acme.Register: %w", err) - } - logf("registered ACME account.") - traceACME(a) - default: - return nil, fmt.Errorf("acme.GetReg: %w", err) - - } - if a.Status != acme.StatusValid { - return nil, fmt.Errorf("unexpected ACME account status %q", a.Status) - } - - // If we have a previous cert, include it in the order. Assuming we're - // within the ARI renewal window this should exclude us from LE rate - // limits. - // Note that this order extension will fail renewals if the ACME account key has changed - // since the last issuance, see - // https://github.com/tailscale/tailscale/issues/18251 - var opts []acme.OrderOption - if previous != nil && !envknob.Bool("TS_DEBUG_ACME_FORCE_RENEWAL") { - prevCrt, err := previous.parseCertificate() - if err == nil { - opts = append(opts, acme.WithOrderReplacesCert(prevCrt)) - } - } - - issueArgs := acmeCertIssueArgs{ - cs: cs, - logf: logf, - traceACME: traceACME, - domain: domain, - opts: opts, - } - if b.shouldUseACMETLSALPN01(domain, previous, logf) { - issueArgs.challengeType = acmeChallengeTLSALPN01 - pair, err := b.issueACMECert(ctx, ac, issueArgs) - if err == nil { - return pair, nil - } - if ctx.Err() != nil { - return nil, ctx.Err() - } - if b.isBYOFunnelDomain(domain) { - // BYO domains have no working dns-01 path (control does not - // own the zone), so surface the tls-alpn-01 error instead of - // burning an ACME attempt on a guaranteed-to-fail fallback. - return nil, err - } - logf("acme: tls-alpn-01 failed; falling back to dns-01: %v", err) - } - issueArgs.challengeType = acmeChallengeDNS01 - return b.issueACMECert(ctx, ac, issueArgs) -} - -type acmeCertIssueArgs struct { - cs certStore // certificate and ACME account storage - logf logger.Logf // logs ACME progress and failures - traceACME func(any) // optional hook for logging ACME messages - domain string // certificate domain being issued - opts []acme.OrderOption // ACME order options - challengeType acmeChallengeType // challenge type to fulfill -} - -func (args acmeCertIssueArgs) baseDomain() string { return strings.TrimPrefix(args.domain, "*.") } -func (args acmeCertIssueArgs) isWildcard() bool { return isWildcardDomain(args.domain) } - -func (b *LocalBackend) issueACMECert(ctx context.Context, ac *acme.Client, args acmeCertIssueArgs) (ret *TLSCertKeyPair, err error) { - if args.traceACME == nil { - args.traceACME = func(any) {} - } - - switch args.challengeType { - case acmeChallengeTLSALPN01: - metricACMETLSALPN01Start.Add(1) - defer func() { - if err == nil { - metricACMETLSALPN01Success.Add(1) - } else { - metricACMETLSALPN01Failure.Add(1) - } - }() - case acmeChallengeDNS01: - metricACMEDNS01Start.Add(1) - defer func() { - if err == nil { - metricACMEDNS01Success.Add(1) - } else { - metricACMEDNS01Failure.Add(1) - } - }() - default: - return nil, fmt.Errorf("unknown ACME challenge type %q", args.challengeType) - } - - // For wildcards, we need to authorize both the wildcard and base domain. - var authzIDs []acme.AuthzID - if args.isWildcard() { - authzIDs = []acme.AuthzID{ - {Type: "dns", Value: args.domain}, - {Type: "dns", Value: args.baseDomain()}, - } - } else { - authzIDs = []acme.AuthzID{{Type: "dns", Value: args.domain}} - } - order, err := ac.AuthorizeOrder(ctx, authzIDs, args.opts...) - if err != nil { - return nil, err - } - args.traceACME(order) - - for _, aurl := range order.AuthzURLs { - az, err := ac.GetAuthorization(ctx, aurl) - if err != nil { - return nil, err - } - args.traceACME(az) - switch args.challengeType { - case acmeChallengeTLSALPN01: - ch := challengeByType(az.Challenges, string(acmeChallengeTLSALPN01)) - if ch == nil { - return nil, errors.New("tls-alpn-01 challenge not offered") - } - cert, err := ac.TLSALPN01ChallengeCert(ch.Token, az.Identifier.Value) - if err != nil { - return nil, fmt.Errorf("TLSALPN01ChallengeCert: %w", err) - } - cleanup := b.storeACMETLSALPNCert(az.Identifier.Value, &cert) - defer cleanup() - chal, err := ac.Accept(ctx, ch) - if err != nil { - return nil, fmt.Errorf("Accept: %v", err) - } - args.traceACME(chal) - case acmeChallengeDNS01: - if err := b.fulfillACMEDNS01Challenge(ctx, ac, az, args.logf, args.traceACME); err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("unknown ACME challenge type %q", args.challengeType) - } - } - - orderURI := order.URI - order, err = ac.WaitOrder(ctx, orderURI) - if err != nil { - if ctx.Err() != nil { - return nil, ctx.Err() - } - if oe, ok := err.(*acme.OrderError); ok { - args.logf("acme: WaitOrder: OrderError status %q", oe.Status) - } else { - args.logf("acme: WaitOrder error: %v", err) - } - return nil, err - } - args.traceACME(order) - - certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - var privPEM bytes.Buffer - if err := encodeECDSAKey(&privPEM, certPrivKey); err != nil { - return nil, err - } - - csr, err := certRequest(certPrivKey, args.domain, nil) - if err != nil { - return nil, err - } - - args.logf("requesting cert...") - args.traceACME(csr) - der, _, err := ac.CreateOrderCert(ctx, order.FinalizeURL, csr, true) - if err != nil { - return nil, fmt.Errorf("CreateOrder: %v", err) - } - args.logf("got cert") - - var certPEM bytes.Buffer - for _, b := range der { - pb := &pem.Block{Type: "CERTIFICATE", Bytes: b} - if err := pem.Encode(&certPEM, pb); err != nil { - return nil, err - } - } - if err := args.cs.WriteTLSCertAndKey(args.domain, certPEM.Bytes(), privPEM.Bytes()); err != nil { - return nil, err - } - b.domainRenewed(args.domain) - - return &TLSCertKeyPair{CertPEM: certPEM.Bytes(), KeyPEM: privPEM.Bytes()}, nil -} - -func (b *LocalBackend) fulfillACMEDNS01Challenge(ctx context.Context, ac *acme.Client, az *acme.Authorization, logf logger.Logf, traceACME func(any)) error { - for _, ch := range az.Challenges { - if ch.Type != string(acmeChallengeDNS01) { - continue - } - rec, err := ac.DNS01ChallengeRecord(ch.Token) - if err != nil { - return err - } - // For wildcards, the challenge is on the base domain. - // e.g., "*.node.ts.net" -> "_acme-challenge.node.ts.net" - key := "_acme-challenge." + strings.TrimPrefix(az.Identifier.Value, "*.") - - // Do a best-effort lookup to see if we've already created this DNS name - // in a previous attempt. Don't burn too much time on it, though. Worst - // case we ask the server to create something that already exists. - var resolver net.Resolver - lookupCtx, lookupCancel := context.WithTimeout(ctx, 500*time.Millisecond) - txts, _ := resolver.LookupTXT(lookupCtx, key) - lookupCancel() - if slices.Contains(txts, rec) { - logf("TXT record already existed for %s", key) - } else { - logf("starting SetDNS call for %s...", key) - err = b.SetDNS(ctx, key, rec) - if err != nil { - return fmt.Errorf("SetDNS %q => %q: %w", key, rec, err) - } - logf("did SetDNS for %s", key) - } - - chal, err := ac.Accept(ctx, ch) - if err != nil { - return fmt.Errorf("Accept: %v", err) - } - traceACME(chal) - return nil - } - return errors.New("dns-01 challenge not offered") -} - -// certRequest generates a CSR for the given domain and optional SANs. -func certRequest(key crypto.Signer, domain string, ext []pkix.Extension) ([]byte, error) { - dnsNames := []string{domain} - if base, ok := strings.CutPrefix(domain, "*."); ok { - // Wildcard cert must also include the base domain as a SAN. - // This is load-bearing: getCertPEMCached validates certs using - // the storage key (base domain), which only passes x509 verification - // if the base domain is in DNSNames. - dnsNames = append(dnsNames, base) - } - req := &x509.CertificateRequest{ - Subject: pkix.Name{CommonName: domain}, - DNSNames: dnsNames, - ExtraExtensions: ext, - } - return x509.CreateCertificateRequest(rand.Reader, req, key) -} - -func encodeECDSAKey(w io.Writer, key *ecdsa.PrivateKey) error { - b, err := x509.MarshalECPrivateKey(key) - if err != nil { - return err - } - pb := &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} - return pem.Encode(w, pb) -} - -// parsePrivateKey is a copy of x/crypto/acme's parsePrivateKey. -// -// Attempt to parse the given private key DER block. OpenSSL 0.9.8 generates -// PKCS#1 private keys by default, while OpenSSL 1.0.0 generates PKCS#8 keys. -// OpenSSL ecparam generates SEC1 EC private keys for ECDSA. We try all three. -// -// Inspired by parsePrivateKey in crypto/tls/tls.go. -func parsePrivateKey(der []byte) (crypto.Signer, error) { - if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { - return key, nil - } - if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { - switch key := key.(type) { - case *rsa.PrivateKey: - return key, nil - case *ecdsa.PrivateKey: - return key, nil - default: - return nil, errors.New("acme/autocert: unknown private key type in PKCS#8 wrapping") - } - } - if key, err := x509.ParseECPrivateKey(der); err == nil { - return key, nil - } - - return nil, errors.New("acme/autocert: failed to parse private key") -} - -func acmeKey(cs certStore) (crypto.Signer, error) { - if v, err := cs.ACMEKey(); err == nil { - priv, _ := pem.Decode(v) - if priv == nil || !strings.Contains(priv.Type, "PRIVATE") { - return nil, errors.New("acme/autocert: invalid account key found in cache") - } - return parsePrivateKey(priv.Bytes) - } else if !errors.Is(err, ipn.ErrStateNotExist) { - return nil, err - } - - privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - var pemBuf bytes.Buffer - if err := encodeECDSAKey(&pemBuf, privKey); err != nil { - return nil, err - } - if err := cs.WriteACMEKey(pemBuf.Bytes()); err != nil { - return nil, err - } - return privKey, nil -} - -func acmeClient(cs certStore) (*acme.Client, error) { - key, err := acmeKey(cs) - if err != nil { - return nil, fmt.Errorf("acmeKey: %w", err) - } - // Note: if we add support for additional ACME providers (other than - // LetsEncrypt), we should make sure that they support ARI extension (see - // shouldStartDomainRenewalARI). - return &acme.Client{ - Key: key, - UserAgent: "tailscaled/" + version.Long(), - DirectoryURL: envknob.String("TS_DEBUG_ACME_DIRECTORY_URL"), - }, nil -} - -// validCertPEM reports whether the given certificate is valid for domain at now. -// -// If roots != nil, it is used instead of the system root pool. This is meant -// to support testing; production code should pass roots == nil. -func validCertPEM(domain string, keyPEM, certPEM []byte, roots *x509.CertPool, now time.Time) bool { - if len(keyPEM) == 0 || len(certPEM) == 0 { - return false - } - tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - return false - } - - var leaf *x509.Certificate - intermediates := x509.NewCertPool() - for i, certDER := range tlsCert.Certificate { - cert, err := x509.ParseCertificate(certDER) - if err != nil { - return false - } - if i == 0 { - leaf = cert - } else { - intermediates.AddCert(cert) - } - } - return validateLeaf(leaf, intermediates, domain, now, roots) -} - -// validateLeaf is a helper for [validCertPEM]. -// -// If called with roots == nil, it will use the system root pool as well as the -// baked-in roots. If non-nil, only those roots are used. -func validateLeaf(leaf *x509.Certificate, intermediates *x509.CertPool, domain string, now time.Time, roots *x509.CertPool) bool { - if leaf == nil { - return false - } - _, err := leaf.Verify(x509.VerifyOptions{ - DNSName: domain, - CurrentTime: now, - Roots: roots, - Intermediates: intermediates, - }) - if err != nil && roots == nil { - // If validation failed and they specified nil for roots (meaning to use - // the system roots), then give it another chance to validate using the - // binary's baked-in roots (LetsEncrypt). See tailscale/tailscale#14690. - return validateLeaf(leaf, intermediates, domain, now, bakedroots.Get()) - } - - if err == nil { - return true - } - - // When pointed at a non-prod ACME server, we don't expect to have the CA - // in our system or baked-in roots. Verify only throws UnknownAuthorityError - // after first checking the leaf cert's expiry, hostnames etc, so we know - // that the only reason for an error is to do with constructing a full chain. - // Allow this error so that cert caching still works in testing environments. - if errors.As(err, &x509.UnknownAuthorityError{}) { - acmeURL := envknob.String("TS_DEBUG_ACME_DIRECTORY_URL") - if !isDefaultDirectoryURL(acmeURL) { - return true - } - } - - return false -} - -func isDefaultDirectoryURL(u string) bool { - return u == "" || u == acme.LetsEncryptURL -} - -// validLookingCertDomain reports whether name looks like a valid domain name that -// we might be able to get a cert for. -// -// It's a light check primarily for double checking before it's used -// as part of a filesystem path. The actual validation happens in resolveCertDomain. -func validLookingCertDomain(name string) bool { - if name == "" || - strings.Contains(name, "..") || - strings.ContainsAny(name, ":/\\\x00") || - !strings.Contains(name, ".") { - return false - } - // Only allow * as a wildcard prefix "*.domain.tld" - if rest, ok := strings.CutPrefix(name, "*."); ok { - if strings.Contains(rest, "*") || !strings.Contains(rest, ".") { - return false - } - } else if strings.Contains(name, "*") { - return false - } - return true -} - -// resolveCertDomain validates a domain and returns the cert domain to use. -// -// - "node.ts.net" -> "node.ts.net" (exact CertDomain match) -// - "*.node.ts.net" -> "*.node.ts.net" (explicit wildcard, requires NodeAttrDNSSubdomainResolve) -// - "foo.com" -> "foo.com" (bring-your-own Funnel domain referenced by the -// local serve config; issued via tls-alpn-01 in getCertPEM) -// -// Subdomain requests like "app.node.ts.net" are rejected; callers should -// request "*.node.ts.net" explicitly for subdomain coverage. -func (b *LocalBackend) resolveCertDomain(domain string) (string, error) { - if domain == "" { - return "", errors.New("missing domain name") - } - - // Read the netmap once to get both CertDomains and capabilities atomically. - nm := b.NetMapNoPeers() - if nm == nil { - return "", errors.New("no netmap available") - } - certDomains := nm.DNS.CertDomains - if len(certDomains) == 0 && !b.isBYOFunnelDomain(domain) { - return "", errors.New("your Tailscale account does not support getting TLS certs") - } - - // Wildcard request like "*.node.ts.net". - if base, ok := strings.CutPrefix(domain, "*."); ok { - if !nm.AllCaps.Contains(tailcfg.NodeAttrDNSSubdomainResolve) { - return "", fmt.Errorf("wildcard certificates are not enabled for this node") - } - if !slices.Contains(certDomains, base) { - return "", fmt.Errorf("invalid domain %q; wildcard certificates are not enabled for this domain", domain) - } - return domain, nil - } - - // Exact CertDomain match. - if slices.Contains(certDomains, domain) { - return domain, nil - } - - // Bring-your-own Funnel domain (e.g. "foo.com"). The serve config - // references the domain as a Funnel target on :443; cert acquisition - // happens via tls-alpn-01 in getCertPEM. - if b.isBYOFunnelDomain(domain) { - return domain, nil - } - - return "", fmt.Errorf("invalid domain %q; must be one of %q", domain, certDomains) -} - -// handleC2NTLSCertStatus returns info about the last TLS certificate issued for the -// provided domain. This can be called by the controlplane to clean up DNS TXT -// records when they're no longer needed by LetsEncrypt. -// -// It does not kick off a cert fetch or async refresh. It only reports anything -// that's already sitting on disk, and only reports metadata about the public -// cert (stuff that'd be the in CT logs anyway). -func handleC2NTLSCertStatus(b *LocalBackend, w http.ResponseWriter, r *http.Request) { - cs, err := b.getCertStore() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - domain := r.FormValue("domain") - if domain == "" { - http.Error(w, "no 'domain'", http.StatusBadRequest) - return - } - - ret := &tailcfg.C2NTLSCertInfo{} - pair, err := getCertPEMCached(cs, domain, b.clock.Now()) - ret.Valid = err == nil - if err != nil { - ret.Error = err.Error() - if errors.Is(err, errCertExpired) { - ret.Expired = true - } else if errors.Is(err, ipn.ErrStateNotExist) { - ret.Missing = true - ret.Error = "no certificate" - } - } else { - block, _ := pem.Decode(pair.CertPEM) - if block == nil { - ret.Error = "invalid PEM" - ret.Valid = false - } else { - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - ret.Error = fmt.Sprintf("invalid certificate: %v", err) - ret.Valid = false - } else { - ret.NotBefore = cert.NotBefore.UTC().Format(time.RFC3339) - ret.NotAfter = cert.NotAfter.UTC().Format(time.RFC3339) - } - } - } - - writeJSON(w, ret) -} - -// setCertPending sets or clears the in-flight ACME issuance state for -// domain and updates the [certPendingWarnable] to reflect the current set -// of pending domains. -func (b *LocalBackend) setCertPending(domain string, pending bool) { - state := b.certState() - if state == nil { - return - } - state.pendingCertDomainsMu.Lock() - defer state.pendingCertDomainsMu.Unlock() - if pending { - state.pendingCertDomains.Make() - state.pendingCertDomains.Add(domain) - } else { - state.pendingCertDomains.Delete(domain) - } - if state.pendingCertDomains.Len() == 0 { - b.health.SetHealthy(certPendingWarnable) - return - } - b.health.SetUnhealthy(certPendingWarnable, health.Args{ - health.ArgDomains: joinedPendingCertDomainsLocked(state.pendingCertDomains), + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"Missing":true}`) // a minimal tailcfg.C2NTLSCertInfo }) } -func joinedPendingCertDomainsLocked(s set.Set[string]) string { - ds := slicesx.MapKeys(s) - slices.Sort(ds) - return strings.Join(ds, ", ") +// GetCertPEM returns a TLSCertKeyPair for domain, either from the local +// cache or by issuing a new cert via ACME. See +// [LocalBackend.GetCertPEMWithValidity] for the full semantics. +func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) { + return b.GetCertPEMWithValidity(ctx, domain, 0) } -// certRefreshInterval is how often the background loop iterates the set of -// applicable cert domains and pokes the renewal machinery. The loop is -// only started while there's at least one HTTPS Web entry in the -// ServeConfig, so this cadence doesn't tick on idle/mobile nodes. -const certRefreshInterval = time.Hour - -// certRefreshLoop periodically iterates the domains configured for Serve or -// Funnel HTTPS and calls GetCertPEM on each. The existing renewal machinery -// in getCertPEM decides whether anything needs to happen (ARI check or -// expiry-based fallback); the loop just ensures it runs even on nodes that -// see no inbound TLS traffic. +// GetCertPEMWithValidity is like [LocalBackend.GetCertPEM] but with a +// minimum cert validity duration. If the cached cert would expire +// sooner than minValidity, it is renewed synchronously. // -// The first iteration runs immediately so that a node coming back online -// with stale or absent certs starts ACME within seconds rather than -// waiting a full interval. +// The domain must be one of: // -// Set as [hookCertRefreshLoop] in cert.go's init. -func certRefreshLoop(b *LocalBackend, ctx context.Context) { - if envknob.IsCertShareReadOnlyMode() { - b.logf("cert refresh loop: cert-share read-only mode; loop is a no-op") - return +// - An exact CertDomain (e.g., "node.ts.net") +// - A wildcard domain (e.g., "*.node.ts.net") +// - A bring-your-own Funnel domain referenced by the local serve +// config (e.g., "foo.com" when ServeConfig.AllowFunnel has +// "foo.com:443"). +func (b *LocalBackend) GetCertPEMWithValidity(ctx context.Context, domain string, minValidity time.Duration) (*TLSCertKeyPair, error) { + if f, ok := HookGetCertPEM.GetOk(); ok { + return f(ctx, b, domain, minValidity) } + return nil, errNoCerts +} - b.refreshApplicableCerts(ctx) +// getACMETLSALPNCert returns the short-lived ACME challenge certificate +// for hi.ServerName, if any. The ok result reports whether hi offered +// acme-tls/1 and an ACME order is actively waiting on that challenge +// for hi.ServerName. +func (b *LocalBackend) getACMETLSALPNCert(hi *tls.ClientHelloInfo) (*tls.Certificate, bool) { + if f, ok := HookGetACMETLSALPNCert.GetOk(); ok { + return f(b, hi) + } + return nil, false +} - ticker, tickerCh := b.clock.NewTicker(certRefreshInterval) - defer ticker.Stop() - for { - select { - case <-tickerCh: - b.refreshApplicableCerts(ctx) - case <-ctx.Done(): - return - } +// getACMETLSALPNProto reports whether serveTLSConfig should advertise +// an ACME ALPN protocol for this ClientHello. +func (b *LocalBackend) getACMETLSALPNProto(hi *tls.ClientHelloInfo) (string, bool) { + if f, ok := HookGetACMETLSALPNProto.GetOk(); ok { + return f(b, hi) + } + return "", false +} + +// updateCertRefreshLoopLocked is called when b.state or b.serveConfig +// changes, so the cert refresh loop can be (re)started or stopped. +// b.mu must be held. +func (b *LocalBackend) updateCertRefreshLoopLocked() { + if f, ok := HookUpdateCertRefreshLoop.GetOk(); ok { + f(b, b.state, b.serveConfig) } } -// refreshApplicableCerts is one iteration of the cert refresh loop. -// -// It enumerates the Serve/Funnel-configured HTTPS hostnames, keeps those -// that [LocalBackend.resolveCertDomain] accepts (CertDomain, wildcard, or -// BYO Funnel domain), and calls [LocalBackend.GetCertPEM] for each. The -// renewal decision is delegated to the existing logic in [getCertPEM]. -func (b *LocalBackend) refreshApplicableCerts(ctx context.Context) { - sc := b.ServeConfig() - if !sc.Valid() { - return - } - - want := set.Set[string]{} - consider := func(host string) { - if host == "" { - return - } - if _, err := b.resolveCertDomain(host); err != nil { - return - } - want.Add(host) - } - for hp := range sc.Webs() { - host, _, err := net.SplitHostPort(string(hp)) - if err != nil { - continue - } - consider(host) - } - for _, tcp := range sc.TCPs() { - consider(tcp.TerminateTLS()) - } - for _, svc := range sc.Services().All() { - for _, tcp := range svc.TCP().All() { - consider(tcp.TerminateTLS()) - } - } - if want.Len() == 0 { - return - } - - for d := range want { - b.goTracker.Go(func() { - ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - if _, err := b.GetCertPEM(ctx, d); err != nil { - b.logf("cert refresh: %s: %v", d, err) - } - }) +// shutdownCertRefreshLoopLocked is called from +// [LocalBackend.Shutdown] to cancel the cert refresh loop. +// b.mu must be held. +func (b *LocalBackend) shutdownCertRefreshLoopLocked() { + if f, ok := HookShutdownCertRefreshLoop.GetOk(); ok { + f(b) } } + +// serveTLSNextProtos returns the baseline ALPN protocols for ordinary +// Serve TLS traffic. ACME tls-alpn-01 is intentionally not advertised +// here; it is added dynamically by serveTLSConfig only while a matching +// challenge certificate is pending. +func serveTLSNextProtos() []string { + return []string{"h2", "http/1.1"} +} diff --git a/ipn/ipnlocal/cert_disabled.go b/ipn/ipnlocal/cert_disabled.go deleted file mode 100644 index dce02567c..000000000 --- a/ipn/ipnlocal/cert_disabled.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -//go:build js || ts_omit_acme - -package ipnlocal - -import ( - "context" - "crypto/tls" - "errors" - "io" - "net/http" - "time" -) - -func init() { - RegisterC2N("GET /tls-cert-status", handleC2NTLSCertStatusDisabled) -} - -var errNoCerts = errors.New("cert support not compiled in this build") - -type TLSCertKeyPair struct { - CertPEM, KeyPEM []byte -} - -func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) { - return nil, errNoCerts -} - -func serveTLSNextProtos() []string { - return []string{"h2", "http/1.1"} -} - -func (b *LocalBackend) getACMETLSALPNCert(hi *tls.ClientHelloInfo) (*tls.Certificate, bool) { - return nil, false -} - -func (b *LocalBackend) getACMETLSALPNProto(hi *tls.ClientHelloInfo) (string, bool) { - return "", false -} - -var errCertExpired = errors.New("cert expired") - -type certStore interface{} - -func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKeyPair, err error) { - return nil, errNoCerts -} - -func (b *LocalBackend) getCertStore() (certStore, error) { - return nil, errNoCerts -} - -func handleC2NTLSCertStatusDisabled(b *LocalBackend, w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - io.WriteString(w, `{"Missing":true}`) // a minimal tailcfg.C2NTLSCertInfo -} diff --git a/ipn/ipnlocal/cert_state.go b/ipn/ipnlocal/cert_state.go deleted file mode 100644 index 1f16685ce..000000000 --- a/ipn/ipnlocal/cert_state.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -package ipnlocal - -import ( - "context" - "crypto/tls" - "sync" - "time" - - "tailscale.com/feature" - "tailscale.com/syncs" - "tailscale.com/util/set" -) - -// CertState holds the per-[LocalBackend] state owned by the -// feature/acme extension. The struct lives in this package so that -// cert.go can access its fields directly without method indirection, -// while the extension in feature/acme remains the canonical owner. -// -// In builds without ACME support (js or ts_omit_acme), no extension -// constructs a CertState, and [LocalBackend.certState] returns nil. -// -// CertState is safe for concurrent use; the individual fields document -// their own synchronization. -// -// TODO(bradfitz): continue moving all this cert code into feature/acme's package. -// This type being here was a compromise to keep the PR small during the move. -type CertState struct { - // acmeMu serializes ACME operations so concurrent requests for - // certs don't slam ACME. The first goroutine through populates the - // on-disk cache and the rest reuse it. - acmeMu syncs.Mutex - - // renewMu guards renewCertAt. - // Lock order: acmeMu before renewMu. - renewMu syncs.Mutex - renewCertAt map[string]time.Time // lazily initialized under renewMu - - // pendingACMETLSALPNCerts maps SNI names to short-lived ACME - // tls-alpn-01 challenge certificates while an ACME order is - // waiting for validation. Entries are deleted by the cleanup - // function returned from storeACMETLSALPNCert after the challenge - // validation path finishes, whether it succeeds or fails. - pendingACMETLSALPNCerts syncs.Map[string, *tls.Certificate] // "foo.bar.com" => challenge cert - - // pendingCertDomains tracks the set of domains for which an ACME - // issuance is currently in flight with no usable cached cert. It - // backs the tls-cert-pending health Warnable. - // Guarded by pendingCertDomainsMu. - pendingCertDomainsMu sync.Mutex - pendingCertDomains set.Set[string] - - // getCertForTest is used to retrieve TLS certificates in tests. - // See [forTest.ConfigureCerts]. Guarded by the containing - // [LocalBackend]'s mutex (b.mu). - getCertForTest func(hostname string) (*TLSCertKeyPair, error) - - // certRefreshCancel cancels the background TLS cert refresh loop - // that periodically pokes [LocalBackend.GetCertPEM] so renewals - // happen on idle nodes. Guarded by the containing [LocalBackend]'s - // mutex (b.mu). Non-nil while the loop is running. - certRefreshCancel context.CancelFunc -} - -// hookCertState is set by the feature/acme extension at init time -// to a function that returns the [CertState] for backend b, or nil -// if the cert extension is not registered (e.g. in builds with -// ts_omit_acme or js). -var hookCertState feature.Hook[func(*LocalBackend) *CertState] - -// HookCertState exposes [hookCertState] to the feature/acme package -// for installation. It must be set exactly once at init time. -var HookCertState = &hookCertState - -// certState returns the cert state for b, or nil if the cert -// extension is not registered. -func (b *LocalBackend) certState() *CertState { - if f, ok := hookCertState.GetOk(); ok { - return f(b) - } - return nil -} diff --git a/ipn/ipnlocal/cert_state_test.go b/ipn/ipnlocal/cert_state_test.go deleted file mode 100644 index 32e22cdeb..000000000 --- a/ipn/ipnlocal/cert_state_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Tailscale Inc & contributors -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !js && !ts_omit_acme - -package ipnlocal - -import "sync" - -// In tests we can't import feature/acme (it would import this package -// and form a cycle), so the real cert extension is never registered. -// Install a default [hookCertState] provider here that lazily creates -// a [CertState] per [LocalBackend]. -// -// Tests that want different behavior can use -// [feature.Hook.SetForTest] to override this hook for the duration -// of the test. -func init() { - if hookCertState.IsSet() { - return - } - var ( - mu sync.Mutex - states = map[*LocalBackend]*CertState{} - ) - hookCertState.Set(func(b *LocalBackend) *CertState { - mu.Lock() - defer mu.Unlock() - if s, ok := states[b]; ok { - return s - } - s := new(CertState) - states[b] = s - return s - }) -} diff --git a/ipn/ipnlocal/fortest.go b/ipn/ipnlocal/fortest.go index 398ea302c..f820dca09 100644 --- a/ipn/ipnlocal/fortest.go +++ b/ipn/ipnlocal/fortest.go @@ -4,12 +4,14 @@ package ipnlocal import ( + "crypto/tls" "net/http" "tailscale.com/control/controlclient" "tailscale.com/ipn" "tailscale.com/ipn/ipnauth" "tailscale.com/tailcfg" + "tailscale.com/tstime" "tailscale.com/types/key" "tailscale.com/types/netmap" "tailscale.com/util/testenv" @@ -99,16 +101,45 @@ func (f forTest) CurrentUser() (ipn.WindowsUserID, ipnauth.Actor) { // ConfigureCerts sets a certificate retrieval function to be used by this // local backend, skipping the usual ACME certificate registration. func (f forTest) ConfigureCerts(getCert func(hostname string) (*TLSCertKeyPair, error)) { - b := f.b - cs := b.certState() - if cs == nil { + hook, ok := HookConfigureCertsForTest.GetOk() + if !ok { panic("forTest.ConfigureCerts called without cert extension registered") } - b.mu.Lock() - cs.getCertForTest = getCert - b.mu.Unlock() + hook(f.b, getCert) } +// GetACMETLSALPNCert returns the short-lived ACME tls-alpn-01 challenge +// certificate for hi, if any. +func (f forTest) GetACMETLSALPNCert(hi *tls.ClientHelloInfo) (*tls.Certificate, bool) { + return f.b.getACMETLSALPNCert(hi) +} + +// SetServeConfig installs sc as the backend's current +// [ipn.ServeConfig] without going through the validation in +// [LocalBackend.SetServeConfig]. It is intended for tests that need a +// specific serve config without first standing up the prerequisites +// (netmap, prefs, etc.). +func (f forTest) SetServeConfig(sc ipn.ServeConfigView) { + b := f.b + b.mu.Lock() + defer b.mu.Unlock() + b.serveConfig = sc +} + +// SetNetMap installs nm as the backend's current netmap without going +// through control-plane plumbing. It is intended for tests that need a +// specific netmap (e.g. CertDomains, capabilities). +func (f forTest) SetNetMap(nm *netmap.NetworkMap) { + b := f.b + b.mu.Lock() + defer b.mu.Unlock() + b.currentNode().SetNetMap(nm) +} + +// SetClock replaces b's clock with c, for tests that need +// time-dependent behavior to be deterministic. +func (f forTest) SetClock(c tstime.Clock) { f.b.clock = c } + // SetPrefs replaces the current prefs with newp. func (f forTest) SetPrefs(newp *ipn.Prefs) { if newp == nil { diff --git a/ipn/ipnlocal/ipnlocaltest/ipnlocaltest.go b/ipn/ipnlocal/ipnlocaltest/ipnlocaltest.go new file mode 100644 index 000000000..01e6036e4 --- /dev/null +++ b/ipn/ipnlocal/ipnlocaltest/ipnlocaltest.go @@ -0,0 +1,64 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package ipnlocaltest provides test helpers for constructing a +// [*ipnlocal.LocalBackend] from external test packages that cannot +// access ipnlocal's internal test helpers. +package ipnlocaltest + +import ( + "testing" + + "tailscale.com/ipn/ipnlocal" + "tailscale.com/ipn/store/mem" + "tailscale.com/net/netmon" + "tailscale.com/net/tsdial" + "tailscale.com/tsd" + "tailscale.com/types/logger" + "tailscale.com/types/logid" + "tailscale.com/util/eventbus/eventbustest" + "tailscale.com/util/testenv" + "tailscale.com/wgengine" +) + +// NewBackend creates a new [*ipnlocal.LocalBackend] suitable for tests, +// using an in-memory state store, a fake userspace engine, and a static +// network monitor. Shutdown is registered as a t.Cleanup. +func NewBackend(t testing.TB) *ipnlocal.LocalBackend { + testenv.AssertInTest() + bus := eventbustest.NewBus(t) + return NewBackendWithSys(t, tsd.NewSystemWithBus(bus)) +} + +// NewBackendWithSys creates a new [*ipnlocal.LocalBackend] with the +// given [*tsd.System]. Missing components in sys (state store, engine, +// dialer) are filled in with test fakes. +func NewBackendWithSys(t testing.TB, sys *tsd.System) *ipnlocal.LocalBackend { + testenv.AssertInTest() + var logf logger.Logf = logger.Discard + if _, ok := sys.StateStore.GetOK(); !ok { + sys.Set(new(mem.Store)) + t.Log("Added memory store for testing") + } + if _, ok := sys.Engine.GetOK(); !ok { + eng, err := wgengine.NewFakeUserspaceEngine(logf, sys.Set, sys.HealthTracker.Get(), sys.UserMetricsRegistry(), sys.Bus.Get()) + if err != nil { + t.Fatalf("NewFakeUserspaceEngine: %v", err) + } + t.Cleanup(eng.Close) + sys.Set(eng) + t.Log("Added fake userspace engine for testing") + } + if _, ok := sys.Dialer.GetOK(); !ok { + dialer := tsdial.NewDialer(netmon.NewStatic()) + dialer.SetBus(sys.Bus.Get()) + sys.Set(dialer) + t.Log("Added static dialer for testing") + } + lb, err := ipnlocal.NewLocalBackend(logf, logid.PublicID{}, sys, 0) + if err != nil { + t.Fatalf("NewLocalBackend: %v", err) + } + t.Cleanup(lb.Shutdown) + return lb +} diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 0f6f2ffa6..867357871 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -1221,11 +1221,6 @@ var ( hookCheckCaptivePortalLoop feature.Hook[func(*LocalBackend, context.Context)] ) -// hookCertRefreshLoop is set by the ACME-enabled cert code to a function -// that periodically refreshes TLS certs for Serve/Funnel-configured -// domains so renewals proceed even on otherwise-idle nodes. -var hookCertRefreshLoop feature.Hook[func(*LocalBackend, context.Context)] - func (b *LocalBackend) onHealthChange(change health.Change) { if !buildfeatures.HasHealth { return @@ -1327,12 +1322,7 @@ func (b *LocalBackend) Shutdown() { b.captiveCancel() } - if buildfeatures.HasACME { - if state := b.certState(); state != nil && state.certRefreshCancel != nil { - state.certRefreshCancel() - state.certRefreshCancel = nil - } - } + b.shutdownCertRefreshLoopLocked() b.stopReconnectTimerLocked() @@ -7478,69 +7468,6 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn. // The LocalBackend's mutex is held while calling. var hookMaybeMutateHostinfoLocked feature.Hooks[func(*LocalBackend, *tailcfg.Hostinfo, ipn.PrefsView) bool] -// updateCertRefreshLoopLocked starts or stops the background TLS cert -// refresh loop based on whether we currently have any HTTPS-serving -// hostname whose cert we should keep fresh. The loop runs only while: -// -// - ACME support is compiled in, -// - the node is in [ipn.Running], and -// - the current [ipn.ServeConfig] has at least one HTTPS Web entry. -// -// We deliberately don't keep an idle timer around on hosts that have no -// certs to maintain (e.g. mobile devices that never run Serve), so this -// must be called whenever any of those inputs change: state transitions -// and ServeConfig reloads. -// -// b.mu must be held. -func (b *LocalBackend) updateCertRefreshLoopLocked() { - if !buildfeatures.HasACME { - return - } - state := b.certState() - if state == nil { - return - } - shouldRun := hookCertRefreshLoop.IsSet() && - b.state == ipn.Running && - serveConfigUsesACMECerts(b.serveConfig) - - switch { - case shouldRun && state.certRefreshCancel == nil: - ctx, cancel := context.WithCancel(b.ctx) - state.certRefreshCancel = cancel - b.goTracker.Go(func() { hookCertRefreshLoop.Get()(b, ctx) }) - case !shouldRun && state.certRefreshCancel != nil: - state.certRefreshCancel() - state.certRefreshCancel = nil - } -} - -// serveConfigUsesACMECerts reports whether sc has any entry that -// causes tailscaled to obtain ACME-managed TLS certs: an HTTPS Web -// entry (background, foreground, or service) or a TCP handler with -// TerminateTLS set (`tailscale serve --tls-terminated-tcp`). -func serveConfigUsesACMECerts(sc ipn.ServeConfigView) bool { - if !sc.Valid() { - return false - } - for range sc.Webs() { - return true - } - for _, tcp := range sc.TCPs() { - if tcp.TerminateTLS() != "" { - return true - } - } - for _, svc := range sc.Services().All() { - for _, tcp := range svc.TCP().All() { - if tcp.TerminateTLS() != "" { - return true - } - } - } - return false -} - // maybeSentHostinfoIfChangedLocked updates the hostinfo.ServicesHash, hostinfo.WireIngress and // hostinfo.IngressEnabled fields and kicks off a Hostinfo update if the values have changed. // diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 922de57d2..7a63fdbf0 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -1367,7 +1367,9 @@ func (b *LocalBackend) serveTLSConfig(getCert func(*tls.ClientHelloInfo) (*tls.C return base } -func (b *LocalBackend) hasFunnelForHostPort(host string, port uint16) bool { +// HasFunnelForHostPort reports whether the LocalBackend's serve config +// has Funnel enabled for host:port. +func (b *LocalBackend) HasFunnelForHostPort(host string, port uint16) bool { b.mu.Lock() defer b.mu.Unlock() if !b.serveConfig.Valid() { diff --git a/ipn/ipnlocal/serve_disabled.go b/ipn/ipnlocal/serve_disabled.go index c5624c497..7123ebe3b 100644 --- a/ipn/ipnlocal/serve_disabled.go +++ b/ipn/ipnlocal/serve_disabled.go @@ -28,7 +28,7 @@ type funnelFlow = struct{} func (*LocalBackend) hasIngressEnabledLocked() bool { return false } func (*LocalBackend) shouldWireInactiveIngressLocked() bool { return false } -func (*LocalBackend) hasFunnelForHostPort(host string, port uint16) bool { +func (*LocalBackend) HasFunnelForHostPort(host string, port uint16) bool { return false } diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index f3b7ad6f1..1add83efe 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -165,7 +165,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) tailscale.com/ipn/ipnlocal/netmapcache from tailscale.com/ipn/ipnlocal tailscale.com/ipn/ipnstate from tailscale.com/client/local+ tailscale.com/ipn/localapi from tailscale.com/tsnet - tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+ + tailscale.com/ipn/store from tailscale.com/tsnet+ tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+ tailscale.com/kube/kubetypes from tailscale.com/envknob LDWI tailscale.com/licenses from tailscale.com/client/web @@ -175,7 +175,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) tailscale.com/logtail from tailscale.com/control/controlclient+ tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+ tailscale.com/metrics from tailscale.com/tsweb+ - tailscale.com/net/bakedroots from tailscale.com/ipn/ipnlocal+ + tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+ 💣 tailscale.com/net/batching from tailscale.com/wgengine/magicsock tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+ tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+ @@ -222,7 +222,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) 💣 tailscale.com/safesocket from tailscale.com/client/local+ tailscale.com/syncs from tailscale.com/control/controlhttp+ tailscale.com/tailcfg from tailscale.com/client/local+ - tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal + tailscale.com/tempfork/acme from tailscale.com/feature/acme tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock tailscale.com/tempfork/httprec from tailscale.com/feature/c2n tailscale.com/tka from tailscale.com/client/local+ @@ -407,22 +407,22 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) crypto/internal/boring/bbig from crypto/ecdsa+ crypto/internal/boring/sig from crypto/internal/boring crypto/internal/constanttime from crypto/internal/fips140/edwards25519+ - crypto/internal/fips140 from crypto/internal/fips140/aes+ + crypto/internal/fips140 from crypto/fips140+ crypto/internal/fips140/aes from crypto/aes+ crypto/internal/fips140/aes/gcm from crypto/cipher+ crypto/internal/fips140/alias from crypto/cipher+ crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+ - crypto/internal/fips140/check from crypto/internal/fips140/aes+ - crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+ + crypto/internal/fips140/check from crypto/fips140+ + crypto/internal/fips140/drbg from crypto/hpke+ crypto/internal/fips140/ecdh from crypto/ecdh crypto/internal/fips140/ecdsa from crypto/ecdsa crypto/internal/fips140/ed25519 from crypto/ed25519 crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519 crypto/internal/fips140/edwards25519/field from crypto/ecdh+ - crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+ + crypto/internal/fips140/hkdf from crypto/hkdf+ crypto/internal/fips140/hmac from crypto/hmac+ crypto/internal/fips140/mlkem from crypto/mlkem - crypto/internal/fips140/nistec from crypto/elliptic+ + crypto/internal/fips140/nistec from crypto/ecdsa+ crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec crypto/internal/fips140/rsa from crypto/rsa crypto/internal/fips140/sha256 from crypto/internal/fips140/check+ @@ -491,13 +491,13 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) internal/filepathlite from os+ internal/fmtsort from fmt+ internal/goarch from crypto/internal/fips140deps/cpu+ - internal/godebug from crypto/internal/fips140deps/godebug+ + internal/godebug from crypto/ed25519+ internal/godebugs from internal/godebug+ internal/goexperiment from net/http/pprof+ internal/goos from crypto/x509+ internal/msan from internal/runtime/maps+ internal/nettrace from net+ - internal/oserror from io/fs+ + internal/oserror from internal/syscall/windows+ internal/poll from net+ LDWI internal/profile from net/http/pprof internal/profilerecord from runtime+ @@ -507,9 +507,9 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) internal/runtime/atomic from internal/runtime/exithook+ LA internal/runtime/cgroup from runtime internal/runtime/exithook from runtime - internal/runtime/gc from runtime+ + internal/runtime/gc from internal/runtime/gc/scan+ internal/runtime/gc/scan from runtime - internal/runtime/maps from reflect+ + internal/runtime/maps from hash/maphash+ internal/runtime/math from internal/runtime/maps+ internal/runtime/pprof/label from runtime+ internal/runtime/sys from crypto/subtle+ @@ -523,7 +523,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) internal/synctest from sync internal/syscall/execenv from os+ LDAI internal/syscall/unix from crypto/internal/sysrand+ - W internal/syscall/windows from crypto/internal/sysrand+ + W internal/syscall/windows from crypto/internal/fips140deps/time+ W internal/syscall/windows/registry from mime+ W internal/syscall/windows/sysdll from internal/syscall/windows+ internal/testlog from os