pomerium-cli k8s exec-credential (#1073)

* wip

* wip

* remove dead code

* add logging about errors for caching credentials

* rename subcommand
This commit is contained in:
Caleb Doxsey 2020-07-16 11:40:43 -06:00 committed by GitHub
parent ee1f9093ee
commit 5df10d1539
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 445 additions and 98 deletions

96
cmd/pomerium-cli/cache.go Normal file
View file

@ -0,0 +1,96 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
func configHome() string {
cfgDir, err := os.UserConfigDir()
if err != nil {
fatalf("error getting user config dir: %v", err)
}
ch := filepath.Join(cfgDir, "pomerium-cli")
err = os.MkdirAll(ch, 0755)
if err != nil {
fatalf("error creating user config dir: %v", err)
}
return ch
}
func cachePath() string {
return filepath.Join(configHome(), "cache", "exec-credential")
}
func cachedCredentialPath(serverURL string) string {
h := sha256.New()
_, _ = h.Write([]byte(serverURL))
id := hex.EncodeToString(h.Sum(nil))
return filepath.Join(cachePath(), id+".json")
}
func loadCachedCredential(serverURL string) *ExecCredential {
fn := cachedCredentialPath(serverURL)
f, err := os.Open(fn)
if err != nil {
return nil
}
defer f.Close()
var creds ExecCredential
err = json.NewDecoder(f).Decode(&creds)
if err != nil {
_ = os.Remove(fn)
return nil
}
if creds.Status == nil {
_ = os.Remove(fn)
return nil
}
ts := creds.Status.ExpirationTimestamp
if ts.IsZero() || ts.Before(time.Now()) {
_ = os.Remove(fn)
return nil
}
return &creds
}
func saveCachedCredential(serverURL string, creds *ExecCredential) {
fn := cachedCredentialPath(serverURL)
err := os.MkdirAll(filepath.Dir(fn), 0755)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create cache directory: %v", err)
return
}
f, err := os.Create(fn)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create cache file: %v", err)
return
}
err = json.NewEncoder(f).Encode(creds)
if err != nil {
_ = f.Close()
fmt.Fprintf(os.Stderr, "failed to encode credentials to cache file: %v", err)
return
}
err = f.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to close cache file: %v", err)
return
}
}

View file

@ -3,14 +3,15 @@ package main
import (
"bufio"
"errors"
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/pomerium/pomerium/internal/encoding/jws"
"github.com/spf13/cobra"
"gopkg.in/square/go-jose.v2/jwt"
"github.com/pomerium/pomerium/internal/encoding/jws"
)
type stringSlice []string
@ -29,6 +30,10 @@ func (i *stringSlice) Set(value string) error {
return nil
}
func (i *stringSlice) Type() string {
return "slice"
}
type serviceAccount struct {
// Standard claims (as specified in RFC 7519).
jwt.Claims
@ -40,104 +45,74 @@ type serviceAccount struct {
ImpersonateGroups []string `json:"impersonate_groups,omitempty"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "\n⛔%s\n\n", err)
printHelp(flags)
os.Exit(1)
}
os.Exit(0)
var serviceAccountOptions struct {
aud stringSlice
groups stringSlice
impersonateGroups stringSlice
expiry time.Duration
serviceAccount serviceAccount
}
var flags *flag.FlagSet
func run() error {
var sa serviceAccount
// temporary variables we will use to hydrate our service account
// struct from basic types pulled in from our flags
var (
aud stringSlice
groups stringSlice
impersonateGroups stringSlice
expiry time.Duration
)
flags = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
flags.StringVar(&sa.Email, "email", "", "Email")
flags.StringVar(&sa.ImpersonateEmail, "impersonate_email", "", "Impersonation Email (optional)")
flags.StringVar(&sa.Issuer, "iss", "", "Issuing Server (e.g authenticate.int.pomerium.io)")
flags.StringVar(&sa.Subject, "sub", "", "Subject (typically User's GUID)")
flags.StringVar(&sa.User, "user", "", "User (typically User's GUID)")
flags.Var(&aud, "aud", "Audience (e.g. httpbin.int.pomerium.io,prometheus.int.pomerium.io)")
flags.Var(&groups, "groups", "Groups (e.g. admins@pomerium.io,users@pomerium.io)")
flags.Var(&impersonateGroups, "impersonate_groups", "Impersonation Groups (optional)")
flags.DurationVar(&expiry, "expiry", time.Hour, "Expiry")
if err := flags.Parse(os.Args[1:]); err != nil {
return err
}
// hydrate our session
sa.Audience = jwt.Audience(aud)
sa.Groups = []string(groups)
sa.ImpersonateGroups = []string(impersonateGroups)
sa.Expiry = jwt.NewNumericDate(time.Now().Add(expiry))
sa.IssuedAt = jwt.NewNumericDate(time.Now())
sa.NotBefore = jwt.NewNumericDate(time.Now())
var sharedKey string
args := flags.Args()
if len(args) == 1 {
sharedKey = args[0]
} else {
fmt.Print("Enter base64 encoded shared key >")
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
sharedKey = scanner.Text()
}
if sharedKey == "" {
return errors.New("shared key required")
}
if sa.Email == "" {
return errors.New("email is required")
}
if len(sa.Audience) == 0 {
return errors.New("aud is required")
}
if sa.Issuer == "" {
return errors.New("iss is required")
}
encoder, err := jws.NewHS256Signer([]byte(sharedKey), sa.Issuer)
if err != nil {
return fmt.Errorf("bad shared key: %w", err)
}
raw, err := encoder.Marshal(sa)
if err != nil {
return fmt.Errorf("bad encode: %w", err)
}
fmt.Fprintf(os.Stdout, "%s", raw)
return nil
func init() {
flags := serviceAccountCmd.PersistentFlags()
flags.StringVar(&serviceAccountOptions.serviceAccount.Email, "email", "", "Email")
flags.StringVar(&serviceAccountOptions.serviceAccount.ImpersonateEmail, "impersonate_email", "", "Impersonation Email (optional)")
flags.StringVar(&serviceAccountOptions.serviceAccount.Issuer, "iss", "", "Issuing Server (e.g authenticate.int.pomerium.io)")
flags.StringVar(&serviceAccountOptions.serviceAccount.Subject, "sub", "", "Subject (typically User's GUID)")
flags.StringVar(&serviceAccountOptions.serviceAccount.User, "user", "", "User (typically User's GUID)")
flags.Var(&serviceAccountOptions.aud, "aud", "Audience (e.g. httpbin.int.pomerium.io,prometheus.int.pomerium.io)")
flags.Var(&serviceAccountOptions.groups, "groups", "Groups (e.g. admins@pomerium.io,users@pomerium.io)")
flags.Var(&serviceAccountOptions.impersonateGroups, "impersonate_groups", "Impersonation Groups (optional)")
flags.DurationVar(&serviceAccountOptions.expiry, "expiry", time.Hour, "Expiry")
rootCmd.AddCommand(serviceAccountCmd)
}
func printHelp(fs *flag.FlagSet) {
fmt.Fprintf(os.Stderr, strings.TrimSpace(help)+"\n\n", os.Args[0])
fs.PrintDefaults()
var serviceAccountCmd = &cobra.Command{
Use: "service-account",
Short: "generates a pomerium service account from a shared key.",
RunE: func(cmd *cobra.Command, args []string) error {
// hydrate our session
serviceAccountOptions.serviceAccount.Audience = jwt.Audience(serviceAccountOptions.aud)
serviceAccountOptions.serviceAccount.Groups = []string(serviceAccountOptions.groups)
serviceAccountOptions.serviceAccount.ImpersonateGroups = []string(serviceAccountOptions.impersonateGroups)
serviceAccountOptions.serviceAccount.Expiry = jwt.NewNumericDate(time.Now().Add(serviceAccountOptions.expiry))
serviceAccountOptions.serviceAccount.IssuedAt = jwt.NewNumericDate(time.Now())
serviceAccountOptions.serviceAccount.NotBefore = jwt.NewNumericDate(time.Now())
var sharedKey string
if len(args) == 1 {
sharedKey = args[0]
} else {
fmt.Print("Enter base64 encoded shared key >")
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
sharedKey = scanner.Text()
}
if sharedKey == "" {
return errors.New("shared key required")
}
if serviceAccountOptions.serviceAccount.Email == "" {
return errors.New("email is required")
}
if len(serviceAccountOptions.serviceAccount.Audience) == 0 {
return errors.New("aud is required")
}
if serviceAccountOptions.serviceAccount.Issuer == "" {
return errors.New("iss is required")
}
encoder, err := jws.NewHS256Signer([]byte(sharedKey), serviceAccountOptions.serviceAccount.Issuer)
if err != nil {
return fmt.Errorf("bad shared key: %w", err)
}
raw, err := encoder.Marshal(serviceAccountOptions.serviceAccount)
if err != nil {
return fmt.Errorf("bad encode: %w", err)
}
fmt.Fprintf(os.Stdout, "%s", raw)
return nil
},
}
const help = `
pomerium-cli generates a pomerium service account from a shared key.
Usage: %[1]s [flags] [base64'd shared secret setting]
For additional help see:
https://www.pomerium.io
https://jwt.io/
Flags:
`

View file

@ -0,0 +1,244 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"time"
"github.com/skratchdot/open-golang/open"
"github.com/spf13/cobra"
"github.com/square/go-jose/jwt"
"golang.org/x/crypto/ssh/terminal"
"golang.org/x/sync/errgroup"
)
func init() {
kubernetesCmd.AddCommand(kubernetesExecCredentialCmd)
rootCmd.AddCommand(kubernetesCmd)
}
var kubernetesCmd = &cobra.Command{
Use: "k8s",
}
var kubernetesExecCredentialCmd = &cobra.Command{
Use: "exec-credential",
RunE: func(cmd *cobra.Command, args []string) error {
if !terminal.IsTerminal(int(os.Stdin.Fd())) {
return fmt.Errorf("only interactive sessions are supported")
}
if len(args) < 1 {
return fmt.Errorf("server url is required")
}
serverURL, err := url.Parse(args[0])
if err != nil {
return fmt.Errorf("invalid server url: %v", err)
}
creds := loadCachedCredential(serverURL.String())
if creds != nil {
printCreds(creds)
return nil
}
li, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fatalf("failed to start listener: %v", err)
}
defer li.Close()
incomingJWT := make(chan string)
eg, ctx := errgroup.WithContext(context.Background())
eg.Go(func() error {
return runHTTPServer(ctx, li, incomingJWT)
})
eg.Go(func() error {
return runOpenBrowser(ctx, li, serverURL)
})
eg.Go(func() error {
return runHandleJWT(ctx, serverURL, incomingJWT)
})
err = eg.Wait()
if err != nil {
fatalf("%s", err)
}
return nil
},
}
func runHTTPServer(ctx context.Context, li net.Listener, incomingJWT chan string) error {
var srv *http.Server
srv = &http.Server{
BaseContext: func(li net.Listener) context.Context {
return ctx
},
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
jwt := r.FormValue("pomerium_jwt")
if jwt == "" {
http.Error(w, "not found", http.StatusNotFound)
return
}
incomingJWT <- jwt
w.Header().Set("Content-Type", "text/plain")
io.WriteString(w, "login complete, you may close this page")
go func() { _ = srv.Shutdown(ctx) }()
}),
}
err := srv.Serve(li)
if err == http.ErrServerClosed {
err = nil
}
return err
}
func runOpenBrowser(ctx context.Context, li net.Listener, serverURL *url.URL) error {
dst := serverURL.ResolveReference(&url.URL{
Path: "/.pomerium/api/v1/login",
RawQuery: url.Values{
"pomerium_redirect_uri": {fmt.Sprintf("http://%s", li.Addr().String())},
}.Encode(),
})
req, err := http.NewRequestWithContext(ctx, "GET", dst.String(), nil)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to get login url: %w", err)
}
defer res.Body.Close()
if res.StatusCode/100 != 2 {
return fmt.Errorf("failed to get login url: %s", res.Status)
}
bs, err := ioutil.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed to read login url: %w", err)
}
return open.Run(string(bs))
}
func runHandleJWT(ctx context.Context, serverURL *url.URL, incomingJWT chan string) error {
var rawjwt string
select {
case <-ctx.Done():
return ctx.Err()
case rawjwt = <-incomingJWT:
}
creds, err := parseToken(rawjwt)
if err != nil {
return err
}
saveCachedCredential(serverURL.String(), creds)
printCreds(creds)
return nil
}
func parseToken(rawjwt string) (*ExecCredential, error) {
tok, err := jwt.ParseSigned(rawjwt)
if err != nil {
return nil, err
}
var claims struct {
Exp int64 `json:"exp"`
}
err = tok.UnsafeClaimsWithoutVerification(&claims)
if err != nil {
return nil, err
}
expiresAt := time.Unix(claims.Exp, 0)
if expiresAt.IsZero() {
expiresAt = time.Now().Add(time.Minute)
}
return &ExecCredential{
TypeMeta: TypeMeta{
APIVersion: "client.authentication.k8s.io/v1beta1",
Kind: "ExecCredential",
},
Status: &ExecCredentialStatus{
ExpirationTimestamp: time.Now().Add(time.Second * 10),
Token: "Pomerium-" + rawjwt,
},
}, nil
}
func printCreds(creds *ExecCredential) {
bs, err := json.Marshal(creds)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to encode credentials: %v\n", err)
}
fmt.Println(string(bs))
}
// TypeMeta describes an individual object in an API response or request
// with strings representing the type of the object and its API schema version.
// Structures that are versioned or persisted should inline TypeMeta.
//
// +k8s:deepcopy-gen=false
type TypeMeta struct {
// Kind is a string value representing the REST resource this object represents.
// Servers may infer this from the endpoint the client submits requests to.
// Cannot be updated.
// In CamelCase.
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
// +optional
Kind string `json:"kind,omitempty" protobuf:"bytes,1,opt,name=kind"`
// APIVersion defines the versioned schema of this representation of an object.
// Servers should convert recognized schemas to the latest internal value, and
// may reject unrecognized values.
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
// +optional
APIVersion string `json:"apiVersion,omitempty" protobuf:"bytes,2,opt,name=apiVersion"`
}
// ExecCredential is used by exec-based plugins to communicate credentials to
// HTTP transports.
type ExecCredential struct {
TypeMeta `json:",inline"`
// Status is filled in by the plugin and holds the credentials that the transport
// should use to contact the API.
// +optional
Status *ExecCredentialStatus `json:"status,omitempty"`
}
// ExecCredentialStatus holds credentials for the transport to use.
//
// Token and ClientKeyData are sensitive fields. This data should only be
// transmitted in-memory between client and exec plugin process. Exec plugin
// itself should at least be protected via file permissions.
type ExecCredentialStatus struct {
// ExpirationTimestamp indicates a time when the provided credentials expire.
// +optional
ExpirationTimestamp time.Time `json:"expirationTimestamp,omitempty"`
// Token is a bearer token used by the client for request authentication.
Token string `json:"token,omitempty"`
// PEM-encoded client TLS certificates (including intermediates, if any).
ClientCertificateData string `json:"clientCertificateData,omitempty"`
// PEM-encoded private key for the above certificate.
ClientKeyData string `json:"clientKeyData,omitempty"`
}

24
cmd/pomerium-cli/main.go Normal file
View file

@ -0,0 +1,24 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "pomerium-cli",
}
func main() {
err := rootCmd.Execute()
if err != nil {
fatalf("%s", err.Error())
}
}
func fatalf(msg string, args ...interface{}) {
fmt.Fprintf(os.Stderr, msg+"\n", args...)
os.Exit(1)
}

3
go.mod
View file

@ -44,11 +44,14 @@ require (
github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563 // indirect
github.com/rs/cors v1.7.0
github.com/rs/zerolog v1.19.0
github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c
github.com/spf13/afero v1.2.2 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/spf13/cobra v0.0.0-20181021141114-fe5e611709b0
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.7.0
github.com/square/go-jose v2.5.1+incompatible
github.com/stretchr/testify v1.6.1
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80
github.com/uber/jaeger-client-go v2.20.1+incompatible // indirect

5
go.sum
View file

@ -283,6 +283,7 @@ github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
@ -468,6 +469,7 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c h1:fyKiXKO1/I/B6Y2U8T7WdQGWzwehOuGIrljPtt7YTTI=
github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
@ -482,6 +484,7 @@ github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTd
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.0-20181021141114-fe5e611709b0 h1:BgSbPgT2Zu8hDen1jJDGLWO8voaSRVrwsk18Q/uSh5M=
github.com/spf13/cobra v0.0.0-20181021141114-fe5e611709b0/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
@ -492,6 +495,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/square/go-jose v2.5.1+incompatible h1:FC+BwI9FzJZWpKaE0yUhFNbp/CyFHndARzuGVME/LGk=
github.com/square/go-jose v2.5.1+incompatible/go.mod h1:7MxpAF/1WTVUu8Am+T5kNy+t0902CaLWM4Z745MkOa8=
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=