diff --git a/go.mod b/go.mod index ae5dadd5..9769492c 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/frankban/quicktest v1.10.0 // indirect github.com/fsnotify/fsnotify v1.4.9 + github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 github.com/go-redis/redis/v8 v8.2.3 github.com/justinas/alice v1.2.0 github.com/mbland/hmacauth v0.0.0-20170912233209-44256dfd4bfa diff --git a/go.sum b/go.sum index ed64398e..32d59900 100644 --- a/go.sum +++ b/go.sum @@ -64,7 +64,10 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 h1:Mn26/9ZMNWSw9C9ERFA1PUxfmGpolnw2v0bKOREu5ew= +github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= diff --git a/main.go b/main.go index 151c8331..173540b3 100644 --- a/main.go +++ b/main.go @@ -12,36 +12,26 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/validation" + "github.com/spf13/pflag" ) func main() { logger.SetFlags(logger.Lshortfile) - flagSet := options.NewFlagSet() - config := flagSet.String("config", "", "path to config file") - showVersion := flagSet.Bool("version", false, "print version string") - - err := flagSet.Parse(os.Args[1:]) - if err != nil { - logger.Printf("ERROR: Failed to parse flags: %v", err) - os.Exit(1) - } + configFlagSet := pflag.NewFlagSet("oauth2-proxy", pflag.ContinueOnError) + config := configFlagSet.String("config", "", "path to config file") + alphaConfig := configFlagSet.String("alpha-config", "", "path to alpha config file (use at your own risk - the structure in this config file may change between minor releases)") + showVersion := configFlagSet.Bool("version", false, "print version string") + configFlagSet.Parse(os.Args[1:]) if *showVersion { fmt.Printf("oauth2-proxy %s (built with %s)\n", VERSION, runtime.Version()) return } - legacyOpts := options.NewLegacyOptions() - err = options.Load(*config, flagSet, legacyOpts) + opts, err := loadConfiguration(*config, *alphaConfig, configFlagSet, os.Args[1:]) if err != nil { - logger.Errorf("ERROR: Failed to load config: %v", err) - os.Exit(1) - } - - opts, err := legacyOpts.ToOptions() - if err != nil { - logger.Errorf("ERROR: Failed to convert config: %v", err) + logger.Printf("ERROR: %v", err) os.Exit(1) } @@ -74,3 +64,73 @@ func main() { }() s.ListenAndServe() } + +// loadConfiguration will load in the user's configuration. +// It will either load the alpha configuration (if alphaConfig is given) +// or the legacy configuration. +func loadConfiguration(config, alphaConfig string, extraFlags *pflag.FlagSet, args []string) (*options.Options, error) { + if alphaConfig != "" { + logger.Printf("WARNING: You are using alpha configuration. The structure in this configuration file may change without notice. You MUST remove conflicting options from your existing configuration.") + return loadAlphaOptions(config, alphaConfig, extraFlags, args) + } + return loadLegacyOptions(config, extraFlags, args) +} + +// loadLegacyOptions loads the old toml options using the legacy flagset +// and legacy options struct. +func loadLegacyOptions(config string, extraFlags *pflag.FlagSet, args []string) (*options.Options, error) { + optionsFlagSet := options.NewLegacyFlagSet() + optionsFlagSet.AddFlagSet(extraFlags) + if err := optionsFlagSet.Parse(args); err != nil { + return nil, fmt.Errorf("failed to parse flags: %v", err) + } + + legacyOpts := options.NewLegacyOptions() + if err := options.Load(config, optionsFlagSet, legacyOpts); err != nil { + return nil, fmt.Errorf("failed to load config: %v", err) + } + + opts, err := legacyOpts.ToOptions() + if err != nil { + return nil, fmt.Errorf("failed to convert config: %v", err) + } + + return opts, nil +} + +// loadAlphaOptions loads the old style config excluding options converted to +// the new alpha format, then merges the alpha options, loaded from YAML, +// into the core configuration. +func loadAlphaOptions(config, alphaConfig string, extraFlags *pflag.FlagSet, args []string) (*options.Options, error) { + opts, err := loadOptions(config, extraFlags, args) + if err != nil { + return nil, fmt.Errorf("failed to load core options: %v", err) + } + + alphaOpts := &options.AlphaOptions{} + if err := options.LoadYAML(alphaConfig, alphaOpts); err != nil { + return nil, fmt.Errorf("failed to load alpha options: %v", err) + } + + alphaOpts.MergeInto(opts) + return opts, nil +} + +// loadOptions loads the configuration using the old style format into the +// core options.Options struct. +// This means that none of the options that have been converted to alpha config +// will be loaded using this method. +func loadOptions(config string, extraFlags *pflag.FlagSet, args []string) (*options.Options, error) { + optionsFlagSet := options.NewFlagSet() + optionsFlagSet.AddFlagSet(extraFlags) + if err := optionsFlagSet.Parse(args); err != nil { + return nil, fmt.Errorf("failed to parse flags: %v", err) + } + + opts := options.NewOptions() + if err := options.Load(config, optionsFlagSet, opts); err != nil { + return nil, fmt.Errorf("failed to load config: %v", err) + } + + return opts, nil +} diff --git a/main_suite_test.go b/main_suite_test.go new file mode 100644 index 00000000..faa0383a --- /dev/null +++ b/main_suite_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "testing" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestMainSuite(t *testing.T) { + logger.SetOutput(GinkgoWriter) + + RegisterFailHandler(Fail) + RunSpecs(t, "Main Suite") +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000..a91940e7 --- /dev/null +++ b/main_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "errors" + "io/ioutil" + "os" + "time" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" + "github.com/spf13/pflag" +) + +var _ = Describe("Configuration Loading Suite", func() { + const testLegacyConfig = ` +upstreams="http://httpbin" +set_basic_auth="true" +basic_auth_password="super-secret-password" +` + + const testAlphaConfig = ` +upstreams: + - id: / + path: / + uri: http://httpbin + flushInterval: 1s + passHostHeader: true + proxyWebSockets: true +injectRequestHeaders: +- name: Authorization + values: + - claim: user + prefix: "Basic " + basicAuthPassword: + value: c3VwZXItc2VjcmV0LXBhc3N3b3Jk +- name: X-Forwarded-Groups + values: + - claim: groups +- name: X-Forwarded-User + values: + - claim: user +- name: X-Forwarded-Email + values: + - claim: email +- name: X-Forwarded-Preferred-Username + values: + - claim: preferred_username +injectResponseHeaders: +- name: Authorization + values: + - claim: user + prefix: "Basic " + basicAuthPassword: + value: c3VwZXItc2VjcmV0LXBhc3N3b3Jk +` + + const testCoreConfig = ` +http_address="0.0.0.0:4180" +cookie_secret="OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" +provider="oidc" +email_domains="example.com" +oidc_issuer_url="http://dex.localhost:4190/dex" +client_secret="b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK" +client_id="oauth2-proxy" +cookie_secure="false" + +redirect_url="http://localhost:4180/oauth2/callback" +` + + boolPtr := func(b bool) *bool { + return &b + } + + durationPtr := func(d time.Duration) *options.Duration { + du := options.Duration(d) + return &du + } + + testExpectedOptions := func() *options.Options { + opts, err := options.NewLegacyOptions().ToOptions() + Expect(err).ToNot(HaveOccurred()) + + opts.HTTPAddress = "0.0.0.0:4180" + opts.Cookie.Secret = "OQINaROshtE9TcZkNAm-5Zs2Pv3xaWytBmc5W7sPX7w=" + opts.ProviderType = "oidc" + opts.EmailDomains = []string{"example.com"} + opts.OIDCIssuerURL = "http://dex.localhost:4190/dex" + opts.ClientSecret = "b2F1dGgyLXByb3h5LWNsaWVudC1zZWNyZXQK" + opts.ClientID = "oauth2-proxy" + opts.Cookie.Secure = false + opts.RawRedirectURL = "http://localhost:4180/oauth2/callback" + + opts.UpstreamServers = options.Upstreams{ + { + ID: "/", + Path: "/", + URI: "http://httpbin", + FlushInterval: durationPtr(options.DefaultUpstreamFlushInterval), + PassHostHeader: boolPtr(true), + ProxyWebSockets: boolPtr(true), + }, + } + + authHeader := options.Header{ + Name: "Authorization", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "user", + Prefix: "Basic ", + BasicAuthPassword: &options.SecretSource{ + Value: []byte("super-secret-password"), + }, + }, + }, + }, + } + + opts.InjectRequestHeaders = append([]options.Header{authHeader}, opts.InjectRequestHeaders...) + opts.InjectResponseHeaders = append(opts.InjectResponseHeaders, authHeader) + return opts + } + + type loadConfigurationTableInput struct { + configContent string + alphaConfigContent string + args []string + extraFlags func() *pflag.FlagSet + expectedOptions func() *options.Options + expectedErr error + } + + DescribeTable("LoadConfiguration", + func(in loadConfigurationTableInput) { + var configFileName, alphaConfigFileName string + + defer func() { + if configFileName != "" { + Expect(os.Remove(configFileName)).To(Succeed()) + } + if alphaConfigFileName != "" { + Expect(os.Remove(alphaConfigFileName)).To(Succeed()) + } + }() + + if in.configContent != "" { + By("Writing the config to a temporary file", func() { + file, err := ioutil.TempFile("", "oauth2-proxy-test-config-XXXX.cfg") + Expect(err).ToNot(HaveOccurred()) + defer file.Close() + + configFileName = file.Name() + + _, err = file.WriteString(in.configContent) + Expect(err).ToNot(HaveOccurred()) + }) + } + + if in.alphaConfigContent != "" { + By("Writing the config to a temporary file", func() { + file, err := ioutil.TempFile("", "oauth2-proxy-test-alpha-config-XXXX.yaml") + Expect(err).ToNot(HaveOccurred()) + defer file.Close() + + alphaConfigFileName = file.Name() + + _, err = file.WriteString(in.alphaConfigContent) + Expect(err).ToNot(HaveOccurred()) + }) + } + + extraFlags := pflag.NewFlagSet("test-flagset", pflag.ExitOnError) + if in.extraFlags != nil { + extraFlags = in.extraFlags() + } + + opts, err := loadConfiguration(configFileName, alphaConfigFileName, extraFlags, in.args) + if in.expectedErr != nil { + Expect(err).To(MatchError(in.expectedErr.Error())) + } else { + Expect(err).ToNot(HaveOccurred()) + } + Expect(in.expectedOptions).ToNot(BeNil()) + Expect(opts).To(Equal(in.expectedOptions())) + }, + Entry("with legacy configuration", loadConfigurationTableInput{ + configContent: testCoreConfig + testLegacyConfig, + expectedOptions: testExpectedOptions, + }), + Entry("with alpha configuration", loadConfigurationTableInput{ + configContent: testCoreConfig, + alphaConfigContent: testAlphaConfig, + expectedOptions: testExpectedOptions, + }), + Entry("with bad legacy configuration", loadConfigurationTableInput{ + configContent: testCoreConfig + "unknown_field=\"something\"", + expectedOptions: func() *options.Options { return nil }, + expectedErr: errors.New("failed to load config: error unmarshalling config: 1 error(s) decoding:\n\n* '' has invalid keys: unknown_field"), + }), + Entry("with bad alpha configuration", loadConfigurationTableInput{ + configContent: testCoreConfig, + alphaConfigContent: testAlphaConfig + ":", + expectedOptions: func() *options.Options { return nil }, + expectedErr: errors.New("failed to load alpha options: error unmarshalling config: error converting YAML to JSON: yaml: line 34: did not find expected key"), + }), + Entry("with alpha configuration and bad core configuration", loadConfigurationTableInput{ + configContent: testCoreConfig + "unknown_field=\"something\"", + alphaConfigContent: testAlphaConfig, + expectedOptions: func() *options.Options { return nil }, + expectedErr: errors.New("failed to load core options: failed to load config: error unmarshalling config: 1 error(s) decoding:\n\n* '' has invalid keys: unknown_field"), + }), + ) +}) diff --git a/pkg/apis/options/alpha_options.go b/pkg/apis/options/alpha_options.go index 1086ee2a..4ff2b0e4 100644 --- a/pkg/apis/options/alpha_options.go +++ b/pkg/apis/options/alpha_options.go @@ -29,3 +29,11 @@ type AlphaOptions struct { // or from a static secret value. InjectResponseHeaders []Header `json:"injectResponseHeaders,omitempty"` } + +// MergeInto replaces alpha options in the Options struct with the values +// from the AlphaOptions +func (a *AlphaOptions) MergeInto(opts *Options) { + opts.UpstreamServers = a.Upstreams + opts.InjectRequestHeaders = a.InjectRequestHeaders + opts.InjectResponseHeaders = a.InjectResponseHeaders +} diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index 35edf680..d3fabd58 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -39,6 +39,15 @@ func NewLegacyOptions() *LegacyOptions { } } +func NewLegacyFlagSet() *pflag.FlagSet { + flagSet := NewFlagSet() + + flagSet.AddFlagSet(legacyUpstreamsFlagSet()) + flagSet.AddFlagSet(legacyHeadersFlagSet()) + + return flagSet +} + func (l *LegacyOptions) ToOptions() (*Options, error) { upstreams, err := l.LegacyUpstreams.convert() if err != nil { diff --git a/pkg/apis/options/load.go b/pkg/apis/options/load.go index aeb39b9a..9ead8a3b 100644 --- a/pkg/apis/options/load.go +++ b/pkg/apis/options/load.go @@ -1,10 +1,13 @@ package options import ( + "errors" "fmt" + "io/ioutil" "reflect" "strings" + "github.com/ghodss/yaml" "github.com/mitchellh/mapstructure" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -132,3 +135,28 @@ func isUnexported(name string) bool { first := string(name[0]) return first == strings.ToLower(first) } + +// LoadYAML will load a YAML based configuration file into the options interface provided. +func LoadYAML(configFileName string, into interface{}) error { + v := viper.New() + v.SetConfigFile(configFileName) + v.SetConfigType("yaml") + v.SetTypeByDefaultValue(true) + + if configFileName == "" { + return errors.New("no configuration file provided") + } + + data, err := ioutil.ReadFile(configFileName) + if err != nil { + return fmt.Errorf("unable to load config file: %w", err) + } + + // UnmarshalStrict will return an error if the config includes options that are + // not mapped to felds of the into struct + if err := yaml.UnmarshalStrict(data, into, yaml.DisallowUnknownFields); err != nil { + return fmt.Errorf("error unmarshalling config: %w", err) + } + + return nil +} diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go index 9f6f5d4f..28fd79e6 100644 --- a/pkg/apis/options/load_test.go +++ b/pkg/apis/options/load_test.go @@ -1,9 +1,11 @@ package options import ( + "errors" "fmt" "io/ioutil" "os" + "time" . "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo/extensions/table" @@ -295,10 +297,199 @@ var _ = Describe("Load", func() { expectedOutput: NewOptions(), }), Entry("with an empty LegacyOptions struct, should return default values", &testOptionsTableInput{ - flagSet: NewFlagSet, + flagSet: NewLegacyFlagSet, input: &LegacyOptions{}, expectedOutput: NewLegacyOptions(), }), ) }) }) + +var _ = Describe("LoadYAML", func() { + Context("with a testOptions structure", func() { + type TestOptionSubStruct struct { + StringSliceOption []string `yaml:"stringSliceOption,omitempty"` + } + + type TestOptions struct { + StringOption string `yaml:"stringOption,omitempty"` + Sub TestOptionSubStruct `yaml:"sub,omitempty"` + + // Check that embedded fields can be unmarshalled + TestOptionSubStruct `yaml:",inline,squash"` + } + + var testOptionsConfigBytesFull = []byte(` +stringOption: foo +stringSliceOption: +- a +- b +- c +sub: + stringSliceOption: + - d + - e +`) + + type loadYAMLTableInput struct { + configFile []byte + input interface{} + expectedErr error + expectedOutput interface{} + } + + DescribeTable("LoadYAML", + func(in loadYAMLTableInput) { + var configFileName string + + if in.configFile != nil { + By("Creating a config file") + configFile, err := ioutil.TempFile("", "oauth2-proxy-test-config-file") + Expect(err).ToNot(HaveOccurred()) + defer configFile.Close() + + _, err = configFile.Write(in.configFile) + Expect(err).ToNot(HaveOccurred()) + defer os.Remove(configFile.Name()) + + configFileName = configFile.Name() + } + + var input interface{} + if in.input != nil { + input = in.input + } else { + input = &TestOptions{} + } + err := LoadYAML(configFileName, input) + if in.expectedErr != nil { + Expect(err).To(MatchError(in.expectedErr.Error())) + } else { + Expect(err).ToNot(HaveOccurred()) + } + Expect(input).To(Equal(in.expectedOutput)) + }, + Entry("with a valid input", loadYAMLTableInput{ + configFile: testOptionsConfigBytesFull, + input: &TestOptions{}, + expectedOutput: &TestOptions{ + StringOption: "foo", + Sub: TestOptionSubStruct{ + StringSliceOption: []string{"d", "e"}, + }, + TestOptionSubStruct: TestOptionSubStruct{ + StringSliceOption: []string{"a", "b", "c"}, + }, + }, + }), + Entry("with no config file", loadYAMLTableInput{ + configFile: nil, + input: &TestOptions{}, + expectedOutput: &TestOptions{}, + expectedErr: errors.New("no configuration file provided"), + }), + Entry("with invalid YAML", loadYAMLTableInput{ + configFile: []byte("\tfoo: bar"), + input: &TestOptions{}, + expectedOutput: &TestOptions{}, + expectedErr: errors.New("error unmarshalling config: error converting YAML to JSON: yaml: found character that cannot start any token"), + }), + Entry("with extra fields in the YAML", loadYAMLTableInput{ + configFile: append(testOptionsConfigBytesFull, []byte("foo: bar\n")...), + input: &TestOptions{}, + expectedOutput: &TestOptions{ + StringOption: "foo", + Sub: TestOptionSubStruct{ + StringSliceOption: []string{"d", "e"}, + }, + TestOptionSubStruct: TestOptionSubStruct{ + StringSliceOption: []string{"a", "b", "c"}, + }, + }, + expectedErr: errors.New("error unmarshalling config: error unmarshaling JSON: while decoding JSON: json: unknown field \"foo\""), + }), + Entry("with an incorrect type for a string field", loadYAMLTableInput{ + configFile: []byte(`stringOption: ["a", "b"]`), + input: &TestOptions{}, + expectedOutput: &TestOptions{}, + expectedErr: errors.New("error unmarshalling config: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal array into Go struct field TestOptions.StringOption of type string"), + }), + Entry("with an incorrect type for an array field", loadYAMLTableInput{ + configFile: []byte(`stringSliceOption: "a"`), + input: &TestOptions{}, + expectedOutput: &TestOptions{}, + expectedErr: errors.New("error unmarshalling config: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go struct field TestOptions.StringSliceOption of type []string"), + }), + ) + }) + + It("should load a full example AlphaOptions", func() { + config := []byte(` +upstreams: +- id: httpbin + path: / + uri: http://httpbin + flushInterval: 500ms +injectRequestHeaders: +- name: X-Forwarded-User + values: + - claim: user +injectResponseHeaders: +- name: X-Secret + values: + - value: c2VjcmV0 +`) + + By("Creating a config file") + configFile, err := ioutil.TempFile("", "oauth2-proxy-test-alpha-config-file") + Expect(err).ToNot(HaveOccurred()) + defer configFile.Close() + + _, err = configFile.Write(config) + Expect(err).ToNot(HaveOccurred()) + defer os.Remove(configFile.Name()) + + configFileName := configFile.Name() + + By("Loading the example config") + into := &AlphaOptions{} + Expect(LoadYAML(configFileName, into)).To(Succeed()) + + flushInterval := Duration(500 * time.Millisecond) + + Expect(into).To(Equal(&AlphaOptions{ + Upstreams: []Upstream{ + { + ID: "httpbin", + Path: "/", + URI: "http://httpbin", + FlushInterval: &flushInterval, + }, + }, + InjectRequestHeaders: []Header{ + { + Name: "X-Forwarded-User", + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "user", + }, + }, + }, + }, + }, + InjectResponseHeaders: []Header{ + { + Name: "X-Secret", + Values: []HeaderValue{ + { + SecretSource: &SecretSource{ + Value: []byte("secret"), + }, + }, + }, + }, + }, + })) + }) +}) diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go index f6c13abb..6cb95f54 100644 --- a/pkg/apis/options/options.go +++ b/pkg/apis/options/options.go @@ -246,8 +246,6 @@ func NewFlagSet() *pflag.FlagSet { flagSet.AddFlagSet(cookieFlagSet()) flagSet.AddFlagSet(loggingFlagSet()) - flagSet.AddFlagSet(legacyUpstreamsFlagSet()) - flagSet.AddFlagSet(legacyHeadersFlagSet()) return flagSet }