mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-18 02:48:40 +00:00
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>
This commit is contained in:
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)
|
||||||
|
}
|
||||||
|
134
ipn/ipnlocal/c2n_test.go
Normal file
134
ipn/ipnlocal/c2n_test.go
Normal file
@ -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…
x
Reference in New Issue
Block a user