diff --git a/CHANGELOG.md b/CHANGELOG.md index 298fb85d..f1c3583a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - [#2800](https://github.com/oauth2-proxy/oauth2-proxy/pull/2800) Add some opencontainer labels to docker image (@halkeye) - [#2755](https://github.com/oauth2-proxy/oauth2-proxy/pull/2755) feat: add X-Envoy-External-Address as supported header (@bjencks) +- [#1985](https://github.com/oauth2-proxy/oauth2-proxy/pull/1985) Add support for systemd socket (@isodude) # V7.7.1 @@ -58,6 +59,7 @@ - [#2790](https://github.com/oauth2-proxy/oauth2-proxy/pull/2790) chore(deps): update all golang dependencies (@tuunit) - [#2607](https://github.com/oauth2-proxy/oauth2-proxy/pull/2607) fix(csrf): fix possible infinite loop (@Primexz) + # V7.6.0 ## Release Highlights diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md index eccc5061..32ea71ad 100644 --- a/docs/docs/configuration/overview.md +++ b/docs/docs/configuration/overview.md @@ -219,7 +219,7 @@ Provider specific options can be found on their respective subpages. | Flag / Config Field | Type | Description | Default | | ------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -| flag: `--http-address`
toml: `http_address` | string | `[http://]:` or `unix://` to listen on for HTTP clients. Square brackets are required for ipv6 address, e.g. `http://[::1]:4180` | `"127.0.0.1:4180"` | +| flag: `--http-address`
toml: `http_address` | string | `[http://]:` or `unix://` or `fd:` (case insensitive) to listen on for HTTP clients. Square brackets are required for ipv6 address, e.g. `http://[::1]:4180` | `"127.0.0.1:4180"` | | flag: `--https-address`
toml: `https_address` | string | `[https://]:` to listen on for HTTPS clients. Square brackets are required for ipv6 address, e.g. `https://[::1]:443` | `":443"` | | flag: `--metrics-address`
toml: `metrics_address` | string | the address prometheus metrics will be scraped from | `""` | | flag: `--metrics-secure-address`
toml: `metrics_secure_address` | string | the address prometheus metrics will be scraped from if using HTTPS | `""` | diff --git a/docs/docs/configuration/systemd_socket.md b/docs/docs/configuration/systemd_socket.md new file mode 100644 index 00000000..642e6f3f --- /dev/null +++ b/docs/docs/configuration/systemd_socket.md @@ -0,0 +1,43 @@ +--- +id: systemd_socket +title: Systemd Socket Activation +--- + +Pass an existing listener created by systemd.socket to oauth2-proxy. + +To do this create a socket: + +oauth2-proxy.socket +``` +[Socket] +ListenStream=%t/oauth2.sock +SocketGroup=www-data +SocketMode=0660 +``` + +Now it's possible to call this socket from e.g. nginx: +``` +server { + location /oauth2/ { + proxy_pass http://unix:/run/oauth2-proxy/oauth2.sock; +} +``` + +The oauth2-proxy should have `--http-address=fd:3` as a parameter. +Here fd is case insensitive and means file descriptor. The number 3 refers to the first non-stdin/stdout/stderr file descriptor, +systemd-socket-activate (which is what systemd.socket uses), listens to what it is told and passes +the listener it created onto the process, starting with file descriptor 3. + +``` +./oauth2-proxy \ + --http-address="fd:3" \ + --email-domain="yourcompany.com" \ + --upstream=http://127.0.0.1:8080/ \ + --cookie-secret=... \ + --cookie-secure=true \ + --provider=... \ + --client-id=... \ + --client-secret=... +``` + +Currently TLS is not supported (but it's doable). diff --git a/docs/docs/installation.md b/docs/docs/installation.md index 9f83a79f..701bac67 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -29,3 +29,4 @@ title: Installation 2. [Select a Provider and Register an OAuth Application with a Provider](configuration/providers/index.md) 3. [Configure OAuth2 Proxy using config file, command line options, or environment variables](configuration/overview.md) 4. [Configure SSL or Deploy behind an SSL endpoint](configuration/tls.md) (example provided for Nginx) +5. [Configure OAuth2 Proxy using systemd.socket](configuration/systemd_socket.md) (example provided for Nginx/Systemd) diff --git a/go.mod b/go.mod index 3e41554b..91f00c4a 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/bitly/go-simplejson v0.5.1 github.com/bsm/redislock v0.9.4 github.com/coreos/go-oidc/v3 v3.11.0 + github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/fsnotify/fsnotify v1.7.0 github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 github.com/go-jose/go-jose/v3 v3.0.3 diff --git a/go.sum b/go.sum index 256cf0f3..30400c94 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index bc48e631..cf04afd7 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -470,7 +470,7 @@ func legacyServerFlagset() *pflag.FlagSet { flagSet.String("metrics-secure-address", "", "the address /metrics will be served on for HTTPS clients (e.g. \":9100\")") flagSet.String("metrics-tls-cert-file", "", "path to certificate file for secure metrics server") flagSet.String("metrics-tls-key-file", "", "path to private key file for secure metrics server") - flagSet.String("http-address", "127.0.0.1:4180", "[http://]: or unix:// to listen on for HTTP clients") + flagSet.String("http-address", "127.0.0.1:4180", "[http://]: or unix:// or fd: (case insensitive) to listen on for HTTP clients") flagSet.String("https-address", ":443", ": to listen on for HTTPS clients") flagSet.String("tls-cert-file", "", "path to certificate file") flagSet.String("tls-key-file", "", "path to private key file") diff --git a/pkg/http/server.go b/pkg/http/server.go index 1771dc93..1cd4f039 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -7,15 +7,27 @@ import ( "fmt" "net" "net/http" + "os" + "strconv" "strings" "time" + "github.com/coreos/go-systemd/activation" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options/util" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" "golang.org/x/sync/errgroup" ) +// listenFdsStart corresponds to `SD_LISTEN_FDS_START`. +// Since the 3 first file descriptors in every linux process is +// stdin, stdout and stderr. The first usable file descriptor is 3. +// systemd-socket-activate will always assume that the first socket will be +// 3 and the rest follow. +const ( + listenFdsStart = 3 +) + // Server represents an HTTP or HTTPS server. type Server interface { // Start blocks and runs the server. @@ -35,6 +47,9 @@ type Opts struct { // TLS is the TLS configuration for the server. TLS *options.TLS + + // Let testing infrastructure circumvent parsing file descriptors + fdFiles []*os.File } // NewServer creates a new Server from the options given. @@ -42,6 +57,11 @@ func NewServer(opts Opts) (Server, error) { s := &server{ handler: opts.Handler, } + + if len(opts.fdFiles) > 0 { + s.fdFiles = opts.fdFiles + } + if err := s.setupListener(opts); err != nil { return nil, fmt.Errorf("error setting up listener: %v", err) } @@ -58,6 +78,30 @@ type server struct { listener net.Listener tlsListener net.Listener + + // ensure activation.Files are called once + fdFiles []*os.File +} + +// convert a string filedescriptor to an actual listener +func (s *server) fdToListener(bindAddress string) (net.Listener, error) { + fd, err := strconv.Atoi(bindAddress) + if err != nil { + return nil, fmt.Errorf("listen failed: fd with name is not implemented yet") + } + fdIndex := fd - listenFdsStart + + if len(s.fdFiles) == 0 { + s.fdFiles = activation.Files(true) + } + + l := len(s.fdFiles) + + if fdIndex < 0 || fdIndex >= l || l == 0 { + return nil, fmt.Errorf("listen failed: fd outside of range of available file descriptors") + } + + return net.FileListener(s.fdFiles[fdIndex]) } // setupListener sets the server listener if the HTTP server is enabled. @@ -69,6 +113,22 @@ func (s *server) setupListener(opts Opts) error { return nil } + // Use fd: as a prefix for systemd socket activation, it's generic + // enough and short. + // The most common usage would be --http-address fd:3. + // This causes oauth2-proxy to just assume that the third fd passed + // to the program is indeed a net.Listener and starts using it + // without setting up a new listener. + if strings.HasPrefix(strings.ToLower(opts.BindAddress), "fd:") { + listenAddr := opts.BindAddress[3:] + listener, err := s.fdToListener(listenAddr) + if err != nil { + err = fmt.Errorf("listen (%s, %s) failed: %v", "file", listenAddr, err) + } + s.listener = listener + return err + } + networkType := getNetworkScheme(opts.BindAddress) listenAddr := getListenAddress(opts.BindAddress) diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index f1abf5fc..8dfa13af 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "os" @@ -17,16 +18,17 @@ import ( const hello = "Hello World!" var _ = Describe("Server", func() { + handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { rw.Write([]byte(hello)) }) - Context("NewServer", func() { type newServerTableInput struct { opts Opts expectedErr error expectHTTPListener bool expectTLSListener bool + fdAddr string ipv6 bool } @@ -34,6 +36,15 @@ var _ = Describe("Server", func() { if in.ipv6 { skipDevContainer() } + + if in.fdAddr != "" { + l, err := net.Listen("tcp", in.fdAddr) + Expect(err).ToNot(HaveOccurred()) + f, err := l.(*net.TCPListener).File() + Expect(err).ToNot(HaveOccurred()) + in.opts.fdFiles = []*os.File{f} + } + srv, err := NewServer(in.opts) if in.expectedErr != nil { Expect(err).To(MatchError(ContainSubstring(in.expectedErr.Error()))) @@ -55,6 +66,46 @@ var _ = Describe("Server", func() { Expect(s.tlsListener.Close()).To(Succeed()) } }, + Entry("with a valid non-lowercase fd IPv4 bind address", &newServerTableInput{ + opts: Opts{ + Handler: handler, + BindAddress: "Fd:3", + }, + expectedErr: nil, + expectHTTPListener: true, + expectTLSListener: false, + fdAddr: "127.0.0.1:0", + }), + Entry("with a valid fd IPv4 bind address", &newServerTableInput{ + opts: Opts{ + Handler: handler, + BindAddress: "fd:3", + }, + expectedErr: nil, + expectHTTPListener: true, + expectTLSListener: false, + fdAddr: "127.0.0.1:0", + }), + Entry("with a invalid fd named bind address", &newServerTableInput{ + opts: Opts{ + Handler: handler, + BindAddress: "fd:hello", + }, + expectedErr: fmt.Errorf("error setting up listener: listen (file, %s) failed: listen failed: fd with name is not implemented yet", "hello"), + expectHTTPListener: true, + expectTLSListener: false, + fdAddr: "127.0.0.1:0", + }), + Entry("with a invalid fd IPv4 bind address", &newServerTableInput{ + opts: Opts{ + Handler: handler, + BindAddress: "fd:4", + }, + expectedErr: fmt.Errorf("error setting up listener: listen (file, %d) failed: listen failed: fd outside of range of available file descriptors", 4), + expectHTTPListener: true, + expectTLSListener: false, + fdAddr: "127.0.0.1:0", + }), Entry("with an ipv4 valid http bind address", &newServerTableInput{ opts: Opts{ Handler: handler, @@ -86,6 +137,21 @@ var _ = Describe("Server", func() { expectHTTPListener: false, expectTLSListener: true, }), + Entry("with a both a fd valid http and ipv4 valid https bind address, and valid TLS config", &newServerTableInput{ + opts: Opts{ + Handler: handler, + BindAddress: "fd:3", + SecureBindAddress: "127.0.0.1:0", + TLS: &options.TLS{ + Key: &ipv4KeyDataSource, + Cert: &ipv4CertDataSource, + }, + }, + expectedErr: nil, + expectHTTPListener: true, + expectTLSListener: true, + fdAddr: "127.0.0.1:0", + }), Entry("with a both a ipv4 valid http and ipv4 valid https bind address, and valid TLS config", &newServerTableInput{ opts: Opts{ Handler: handler, @@ -300,6 +366,27 @@ var _ = Describe("Server", func() { expectHTTPListener: false, expectTLSListener: true, }), + Entry("with valid fd IPv6 bind address", &newServerTableInput{ + opts: Opts{ + Handler: handler, + BindAddress: "fd:3", + }, + expectedErr: nil, + expectHTTPListener: true, + expectTLSListener: false, + fdAddr: "[::1]:0", + ipv6: true, + }), + Entry("with a invalid fd IPv6 bind address", &newServerTableInput{ + opts: Opts{ + Handler: handler, + BindAddress: "fd:4", + }, + expectedErr: fmt.Errorf("error setting up listener: listen (file, %d) failed: listen failed: fd outside of range of available file descriptors", 4), + expectHTTPListener: true, + expectTLSListener: false, + fdAddr: "[::1]:0", + }), Entry("with an ipv6 valid http bind address", &newServerTableInput{ opts: Opts{ Handler: handler, @@ -334,6 +421,22 @@ var _ = Describe("Server", func() { expectTLSListener: true, ipv6: true, }), + Entry("with a both a fd valid http and ipv6 valid https bind address, and valid TLS config", &newServerTableInput{ + opts: Opts{ + Handler: handler, + BindAddress: "fd:3", + SecureBindAddress: "[::1]:0", + TLS: &options.TLS{ + Key: &ipv6KeyDataSource, + Cert: &ipv6CertDataSource, + }, + }, + expectedErr: nil, + expectHTTPListener: true, + expectTLSListener: true, + fdAddr: "[::1]:0", + ipv6: true, + }), Entry("with a both a ipv6 valid http and ipv6 valid https bind address, and valid TLS config", &newServerTableInput{ opts: Opts{ Handler: handler, @@ -563,6 +666,58 @@ var _ = Describe("Server", func() { }) + Context("with an fd ipv4 http server", func() { + var listenAddr string + + BeforeEach(func() { + l, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + f, err := l.(*net.TCPListener).File() + Expect(err).ToNot(HaveOccurred()) + + srv, err = NewServer(Opts{ + Handler: handler, + BindAddress: "fd:3", + fdFiles: []*os.File{f}, + }) + Expect(err).ToNot(HaveOccurred()) + + listenAddr = fmt.Sprintf("http://%s/", l.Addr().String()) + }) + + It("Starts the server and serves the handler", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + resp, err := httpGet(ctx, listenAddr) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(hello)) + }) + + It("Stops the server when the context is cancelled", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + _, err := httpGet(ctx, listenAddr) + Expect(err).ToNot(HaveOccurred()) + + cancel() + + Eventually(func() error { + _, err := httpGet(ctx, listenAddr) + return err + }).Should(HaveOccurred()) + }) + }) + Context("with an ipv4 http server", func() { var listenAddr string @@ -682,6 +837,88 @@ var _ = Describe("Server", func() { }) }) + Context("with a fd ipv4 http and an ipv4 https server", func() { + var listenAddr, secureListenAddr string + + BeforeEach(func() { + l, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).ToNot(HaveOccurred()) + f, err := l.(*net.TCPListener).File() + Expect(err).ToNot(HaveOccurred()) + + srv, err = NewServer(Opts{ + Handler: handler, + BindAddress: "fd:3", + fdFiles: []*os.File{f}, + SecureBindAddress: "127.0.0.1:0", + TLS: &options.TLS{ + Key: &ipv4KeyDataSource, + Cert: &ipv4CertDataSource, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + s, ok := srv.(*server) + Expect(ok).To(BeTrue()) + + listenAddr = fmt.Sprintf("http://%s/", l.Addr().String()) + secureListenAddr = fmt.Sprintf("https://%s/", s.tlsListener.Addr().String()) + }) + + It("Starts the server and serves the handler on http", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + resp, err := httpGet(ctx, listenAddr) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(hello)) + }) + + It("Starts the server and serves the handler on https", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + resp, err := httpGet(ctx, secureListenAddr) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(hello)) + }) + + It("Stops both servers when the context is cancelled", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + _, err := httpGet(ctx, listenAddr) + Expect(err).ToNot(HaveOccurred()) + _, err = httpGet(ctx, secureListenAddr) + Expect(err).ToNot(HaveOccurred()) + + cancel() + + Eventually(func() error { + _, err := httpGet(ctx, listenAddr) + return err + }).Should(HaveOccurred()) + Eventually(func() error { + _, err := httpGet(ctx, secureListenAddr) + return err + }).Should(HaveOccurred()) + }) + }) + Context("with both an ipv4 http and an ipv4 https server", func() { var listenAddr, secureListenAddr string @@ -880,6 +1117,89 @@ var _ = Describe("Server", func() { }) }) + Context("with an fd ipv6 http and an ipv6 https server", func() { + var listenAddr, secureListenAddr string + + BeforeEach(func() { + skipDevContainer() + l, err := net.Listen("tcp", "[::1]:0") + Expect(err).ToNot(HaveOccurred()) + f, err := l.(*net.TCPListener).File() + Expect(err).ToNot(HaveOccurred()) + + srv, err = NewServer(Opts{ + Handler: handler, + BindAddress: "fd:3", + fdFiles: []*os.File{f}, + SecureBindAddress: "[::1]:0", + TLS: &options.TLS{ + Key: &ipv6KeyDataSource, + Cert: &ipv6CertDataSource, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + s, ok := srv.(*server) + Expect(ok).To(BeTrue()) + + listenAddr = fmt.Sprintf("http://%s/", l.Addr().String()) + secureListenAddr = fmt.Sprintf("https://%s/", s.tlsListener.Addr().String()) + }) + + It("Starts the server and serves the handler on http", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + resp, err := httpGet(ctx, listenAddr) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(hello)) + }) + + It("Starts the server and serves the handler on https", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + resp, err := httpGet(ctx, secureListenAddr) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + body, err := io.ReadAll(resp.Body) + Expect(err).ToNot(HaveOccurred()) + Expect(string(body)).To(Equal(hello)) + }) + + It("Stops both servers when the context is cancelled", func() { + go func() { + defer GinkgoRecover() + Expect(srv.Start(ctx)).To(Succeed()) + }() + + _, err := httpGet(ctx, listenAddr) + Expect(err).ToNot(HaveOccurred()) + _, err = httpGet(ctx, secureListenAddr) + Expect(err).ToNot(HaveOccurred()) + + cancel() + + Eventually(func() error { + _, err := httpGet(ctx, listenAddr) + return err + }).Should(HaveOccurred()) + Eventually(func() error { + _, err := httpGet(ctx, secureListenAddr) + return err + }).Should(HaveOccurred()) + }) + }) + Context("with both an ipv6 http and an ipv6 https server", func() { var listenAddr, secureListenAddr string