diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index 50e5da550..ac64dbe9f 100644 --- a/ipn/ipnlocal/c2n.go +++ b/ipn/ipnlocal/c2n.go @@ -5,7 +5,9 @@ import ( "bytes" + "crypto/x509" "encoding/json" + "encoding/pem" "errors" "fmt" "io" @@ -23,6 +25,7 @@ "github.com/kortschak/wol" "tailscale.com/clientupdate" "tailscale.com/envknob" + "tailscale.com/ipn" "tailscale.com/net/sockstats" "tailscale.com/posture" "tailscale.com/tailcfg" @@ -48,6 +51,9 @@ req("POST /logtail/flush"): handleC2NLogtailFlush, req("POST /sockstats"): handleC2NSockStats, + // Check TLS certificate status. + req("GET /tls-cert-status"): handleC2NTLSCertStatus, + // SSH req("/ssh/usernames"): handleC2NSSHUsernames, @@ -479,3 +485,54 @@ func handleC2NWoL(b *LocalBackend, w http.ResponseWriter, r *http.Request) { sort.Strings(res.SentTo) writeJSON(w, &res) } + +// 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) +} diff --git a/ipn/ipnlocal/c2n_test.go b/ipn/ipnlocal/c2n_test.go new file mode 100644 index 000000000..cc96909b4 --- /dev/null +++ b/ipn/ipnlocal/c2n_test.go @@ -0,0 +1,134 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package ipnlocal + +import ( + "crypto/x509" + "encoding/json" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "tailscale.com/ipn/store/mem" + "tailscale.com/tailcfg" + "tailscale.com/tstest" + "tailscale.com/types/logger" + "tailscale.com/util/cmpx" + "tailscale.com/util/must" +) + +func TestHandleC2NTLSCertStatus(t *testing.T) { + b := &LocalBackend{ + store: &mem.Store{}, + varRoot: t.TempDir(), + } + 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 := cmpx.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/ipn/ipnlocal/cert.go b/ipn/ipnlocal/cert.go index 1e3ee94ad..d094cc9f8 100644 --- a/ipn/ipnlocal/cert.go +++ b/ipn/ipnlocal/cert.go @@ -41,6 +41,7 @@ "tailscale.com/ipn/store" "tailscale.com/ipn/store/mem" "tailscale.com/types/logger" + "tailscale.com/util/testenv" "tailscale.com/version" "tailscale.com/version/distro" ) @@ -236,6 +237,8 @@ type certStore interface { 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: @@ -252,7 +255,10 @@ func (b *LocalBackend) getCertStore() (certStore, error) { if err != nil { return nil, err } - return certFileStore{dir: dir}, nil + 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. diff --git a/ipn/ipnlocal/cert_js.go b/ipn/ipnlocal/cert_js.go index a5fdfc4ba..6acc57a60 100644 --- a/ipn/ipnlocal/cert_js.go +++ b/ipn/ipnlocal/cert_js.go @@ -6,6 +6,7 @@ import ( "context" "errors" + "time" ) type TLSCertKeyPair struct { @@ -15,3 +16,15 @@ type TLSCertKeyPair struct { func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) { return nil, errors.New("not implemented for js/wasm") } + +var errCertExpired = errors.New("cert expired") + +type certStore interface{} + +func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKeyPair, err error) { + return nil, errors.New("not implemented for js/wasm") +} + +func (b *LocalBackend) getCertStore() (certStore, error) { + return nil, errors.New("not implemented for js/wasm") +} diff --git a/tailcfg/c2ntypes.go b/tailcfg/c2ntypes.go index 550b8affe..929c50ae2 100644 --- a/tailcfg/c2ntypes.go +++ b/tailcfg/c2ntypes.go @@ -75,3 +75,26 @@ type C2NAppConnectorDomainRoutesResponse struct { // to a list of resolved IP addresses. Domains map[string][]netip.Addr } + +// C2NTLSCertInfo describes the state of a cached TLS certificate. +type C2NTLSCertInfo struct { + // Valid means that the node has a cached and valid (not expired) + // certificate. + Valid bool `json:",omitempty"` + // Error is the error string if the certificate is not valid. If error is + // non-empty, the other booleans below might say why. + Error string `json:",omitempty"` + + // Missing is whether the error string indicates a missing certificate + // that's never been fetched or isn't on disk. + Missing bool `json:",omitempty"` + + // Expired is whether the error string indicates an expired certificate. + Expired bool `json:",omitempty"` + + NotBefore string `json:",omitempty"` // RFC3339, if Valid + NotAfter string `json:",omitempty"` // RFC3339, if Valid + + // TODO(bradfitz): add fields for whether an ACME fetch is currently in + // process and when it started, etc. +} diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 42b6e08d3..320d0abbc 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -120,7 +120,8 @@ // - 77: 2023-10-03: Client understands Peers[].SelfNodeV6MasqAddrForThisPeer // - 78: 2023-10-05: can handle c2n Wake-on-LAN sending // - 79: 2023-10-05: Client understands UrgentSecurityUpdate in ClientVersion -const CurrentCapabilityVersion CapabilityVersion = 79 +// - 80: 2023-11-16: can handle c2n GET /tls-cert-status +const CurrentCapabilityVersion CapabilityVersion = 80 type StableID string