ipn/ipnlocal: add c2n method to check on TLS cert fetch status

So the control plane can delete TXT records more aggressively
after client's done with ACME fetch.

Updates tailscale/corp#15848

Change-Id: I4f1140305bee11ee3eee93d4fec3aef2bd6c5a7e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
pull/10300/head
Brad Fitzpatrick 1 year ago committed by Brad Fitzpatrick
parent 664ebb14d9
commit cca27ef96a

@ -5,7 +5,9 @@ package ipnlocal
import ( import (
"bytes" "bytes"
"crypto/x509"
"encoding/json" "encoding/json"
"encoding/pem"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -23,6 +25,7 @@ import (
"github.com/kortschak/wol" "github.com/kortschak/wol"
"tailscale.com/clientupdate" "tailscale.com/clientupdate"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/net/sockstats" "tailscale.com/net/sockstats"
"tailscale.com/posture" "tailscale.com/posture"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
@ -48,6 +51,9 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
req("POST /logtail/flush"): handleC2NLogtailFlush, req("POST /logtail/flush"): handleC2NLogtailFlush,
req("POST /sockstats"): handleC2NSockStats, req("POST /sockstats"): handleC2NSockStats,
// Check TLS certificate status.
req("GET /tls-cert-status"): handleC2NTLSCertStatus,
// SSH // SSH
req("/ssh/usernames"): handleC2NSSHUsernames, req("/ssh/usernames"): handleC2NSSHUsernames,
@ -479,3 +485,54 @@ func handleC2NWoL(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
sort.Strings(res.SentTo) sort.Strings(res.SentTo)
writeJSON(w, &res) 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)
}

@ -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)
}
}
})
}
}

@ -41,6 +41,7 @@ import (
"tailscale.com/ipn/store" "tailscale.com/ipn/store"
"tailscale.com/ipn/store/mem" "tailscale.com/ipn/store/mem"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/util/testenv"
"tailscale.com/version" "tailscale.com/version"
"tailscale.com/version/distro" "tailscale.com/version/distro"
) )
@ -236,6 +237,8 @@ type certStore interface {
var errCertExpired = errors.New("cert expired") var errCertExpired = errors.New("cert expired")
var testX509Roots *x509.CertPool // set non-nil by tests
func (b *LocalBackend) getCertStore() (certStore, error) { func (b *LocalBackend) getCertStore() (certStore, error) {
switch b.store.(type) { switch b.store.(type) {
case *store.FileStore: case *store.FileStore:
@ -252,7 +255,10 @@ func (b *LocalBackend) getCertStore() (certStore, error) {
if err != nil { if err != nil {
return nil, err 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. // certFileStore implements certStore by storing the cert & key files in the named directory.

@ -6,6 +6,7 @@ package ipnlocal
import ( import (
"context" "context"
"errors" "errors"
"time"
) )
type TLSCertKeyPair struct { type TLSCertKeyPair struct {
@ -15,3 +16,15 @@ type TLSCertKeyPair struct {
func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) { func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertKeyPair, error) {
return nil, errors.New("not implemented for js/wasm") 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")
}

@ -75,3 +75,26 @@ type C2NAppConnectorDomainRoutesResponse struct {
// to a list of resolved IP addresses. // to a list of resolved IP addresses.
Domains map[string][]netip.Addr 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.
}

@ -120,7 +120,8 @@ type CapabilityVersion int
// - 77: 2023-10-03: Client understands Peers[].SelfNodeV6MasqAddrForThisPeer // - 77: 2023-10-03: Client understands Peers[].SelfNodeV6MasqAddrForThisPeer
// - 78: 2023-10-05: can handle c2n Wake-on-LAN sending // - 78: 2023-10-05: can handle c2n Wake-on-LAN sending
// - 79: 2023-10-05: Client understands UrgentSecurityUpdate in ClientVersion // - 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 type StableID string

Loading…
Cancel
Save