diff --git a/examples/go-client-with-refresh/dynamic/dynamic.go b/examples/go-client-with-refresh/dynamic/dynamic.go new file mode 100644 index 0000000000000..aaf13ef0cbfc8 --- /dev/null +++ b/examples/go-client-with-refresh/dynamic/dynamic.go @@ -0,0 +1,126 @@ +package dynamic + +import ( + "crypto/tls" + "crypto/x509" + "github.com/gravitational/teleport/api/client" + "github.com/gravitational/teleport/api/identityfile" + "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/api/utils/keys" + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + "golang.org/x/net/http2" + "sync" +) + +type DynamicIdentityFile struct { + mu sync.RWMutex + tlsCert *tls.Certificate + pool *x509.CertPool + + path string + clusterName string +} + +func NewDynamicIdentityFile(path string, clusterName string) (*DynamicIdentityFile, error) { + d := &DynamicIdentityFile{ + path: path, + clusterName: clusterName, + } + + err := d.Reload() + if err != nil { + return nil, trace.Wrap(err) + } + + return d, nil +} + +func (d *DynamicIdentityFile) Reload() error { + id, err := identityfile.ReadFile(d.path) + if err != nil { + return trace.Wrap(err) + } + + cert, err := keys.X509KeyPair(id.Certs.TLS, id.PrivateKey) + if err != nil { + return trace.Wrap(err) + } + + pool := x509.NewCertPool() + for _, caCerts := range id.CACerts.TLS { + if !pool.AppendCertsFromPEM(caCerts) { + return trace.BadParameter("invalid CA cert PEM") + } + } + + d.mu.Lock() + d.pool = pool + d.tlsCert = &cert + d.mu.Unlock() + return nil +} + +func (d *DynamicIdentityFile) Dialer( + cfg client.Config, +) (client.ContextDialer, error) { + // Returning a dialer isn't necessary for this credential. + return nil, trace.NotImplemented("no dialer") +} + +func (d *DynamicIdentityFile) TLSConfig() (*tls.Config, error) { + cfg := &tls.Config{ + // GetClientCertificate is used instead of the static Certificates + // field. + Certificates: nil, + // Encoded cluster name required to ensure requests are routed to the + // correct cloud tenants. + ServerName: utils.EncodeClusterName(d.clusterName), + GetClientCertificate: func( + info *tls.CertificateRequestInfo, + ) (*tls.Certificate, error) { + // GetClientCertificate callback is used to allow us to dynamically + // change the certificate when reloaded. + d.mu.RLock() + defer d.mu.RUnlock() + return d.tlsCert, nil + }, + // InsecureSkipVerify is forced true to ensure that only our + // VerifyConnection callback is used to verify the server's presented + // certificate. + InsecureSkipVerify: true, + VerifyConnection: func(state tls.ConnectionState) error { + // This VerifyConnection callback is based on the standard library + // implementation of verifyServerCertificate in the tls package. + // We provide our own implementation so we can dynamically handle + // a changing CA Roots pool. + d.mu.RLock() + defer d.mu.RUnlock() + + opts := x509.VerifyOptions{ + DNSName: state.ServerName, + Intermediates: x509.NewCertPool(), + Roots: d.pool, + } + for _, cert := range state.PeerCertificates[1:] { + // Whilst we don't currently use intermediate certs at + // Teleport, including this here means that we are + // future-proofed in case we do. + opts.Intermediates.AddCert(cert) + } + _, err := state.PeerCertificates[0].Verify(opts) + return err + }, + NextProtos: []string{http2.NextProtoTLS}, + } + + return cfg, nil +} + +func (d *DynamicIdentityFile) SSHClientConfig() (*ssh.ClientConfig, error) { + // For now, SSH Client Config is disabled until I can wrap my head around + // the changes needed to make an SSH config dynamic. + // This means the auth server must be available directly or using + // the ALPN/SNI. + return nil, trace.NotImplemented("no ssh config") +} diff --git a/examples/go-client-with-refresh/main.go b/examples/go-client-with-refresh/main.go new file mode 100644 index 0000000000000..1a985ce52350d --- /dev/null +++ b/examples/go-client-with-refresh/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "fmt" + "github.com/gravitational/teleport/api/client" + apidefaults "github.com/gravitational/teleport/api/defaults" + "github.com/gravitational/teleport/examples/go-client-with-refresh/dynamic" + teleUtils "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/trace" + "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" + "google.golang.org/grpc" + "os" + "os/signal" + "time" +) + +func main() { + ctx, cancel := signal.NotifyContext( + context.Background(), + unix.SIGTERM, + unix.SIGINT, + ) + defer cancel() + + log := teleUtils.NewLogger() + if err := run(ctx, log); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func run(ctx context.Context, log logrus.FieldLogger) error { + proxyAddr := os.Getenv("PROXY_ADDR") // e.g noah.teleport.sh:443 + identityFilePath := os.Getenv("TELEPORT_IDENTITY_FILE") // e.g ./identity-file + clusterName := os.Getenv("CLUSTER_NAME") // e.g noah.teleport.sh + + cred, err := dynamic.NewDynamicIdentityFile(identityFilePath, clusterName) + go func() { + // This goroutine loop could be replaced with a file watcher. + for { + time.Sleep(time.Second * 30) + if err := cred.Reload(); err != nil { + log.WithError(err).Warn("Failed to reload identity file") + continue + } + log.Info("Successfully reloaded identity file from disk. New client connections will use this identity.") + } + }() + + cfg := client.Config{ + Addrs: []string{proxyAddr}, + Credentials: []client.Credentials{ + cred, + }, + DialOpts: []grpc.DialOption{ + // Provides better feedback on connection errors + grpc.WithReturnConnectionError(), + }, + // ALPNSNIAuthDialClusterName allows the client to connect to the + // auth server through the proxy. + ALPNSNIAuthDialClusterName: clusterName, + } + clt, err := client.New(ctx, cfg) + if err != nil { + return trace.Wrap(err) + } + defer clt.Close() + + return monitorLoop(ctx, log, clt) +} + +// This loop replicates some work that needs to run continously against the +// Teleport API and have access to an up to date client. +func monitorLoop( + ctx context.Context, + log logrus.FieldLogger, + clt *client.Client, +) error { + for { + // Exit is context is cancelled. + if err := ctx.Err(); err != nil { + log.Info( + "Detected context cancellation, exiting watch loop!", + ) + return nil + } + + // This action represents any unary action against the Teleport API + start := time.Now() + nodes, err := clt.GetNodes(ctx, apidefaults.Namespace) + if err != nil { + log.WithError(err).Error("failed to fetch nodes") + } else { + log.WithFields(logrus.Fields{ + "count": len(nodes), + "duration": time.Since(start), + }).Info("Fetched nodes list") + } + + time.Sleep(5 * time.Second) + } +}