diff --git a/cmd/pomerium-cli/cache.go b/cmd/pomerium-cli/cache.go new file mode 100644 index 000000000..729729b86 --- /dev/null +++ b/cmd/pomerium-cli/cache.go @@ -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 + } +} diff --git a/cmd/pomerium-cli/cli.go b/cmd/pomerium-cli/cli.go index abf949379..ef1b7d77e 100644 --- a/cmd/pomerium-cli/cli.go +++ b/cmd/pomerium-cli/cli.go @@ -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: - -` diff --git a/cmd/pomerium-cli/kubernetes.go b/cmd/pomerium-cli/kubernetes.go new file mode 100644 index 000000000..c8697080a --- /dev/null +++ b/cmd/pomerium-cli/kubernetes.go @@ -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"` +} diff --git a/cmd/pomerium-cli/main.go b/cmd/pomerium-cli/main.go new file mode 100644 index 000000000..c87f0efd4 --- /dev/null +++ b/cmd/pomerium-cli/main.go @@ -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) +} diff --git a/go.mod b/go.mod index a9213205d..365fc1637 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b9be85d97..c7cfe8383 100644 --- a/go.sum +++ b/go.sum @@ -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=