mirror of
https://github.com/pomerium/pomerium.git
synced 2025-06-01 02:12:50 +02:00
Refactor to central options struct and parsing
This commit is contained in:
parent
5970d6c766
commit
ebb6df6c3f
12 changed files with 415 additions and 511 deletions
|
@ -6,9 +6,8 @@ import (
|
|||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/pomerium/envconfig"
|
||||
"github.com/pomerium/pomerium/internal/config"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/cryptutil"
|
||||
"github.com/pomerium/pomerium/internal/identity"
|
||||
|
@ -16,53 +15,10 @@ import (
|
|||
"github.com/pomerium/pomerium/internal/templates"
|
||||
)
|
||||
|
||||
var defaultOptions = &Options{
|
||||
CookieName: "_pomerium_authenticate",
|
||||
CookieHTTPOnly: true,
|
||||
CookieSecure: true,
|
||||
CookieExpire: time.Duration(14) * time.Hour,
|
||||
CookieRefresh: time.Duration(30) * time.Minute,
|
||||
}
|
||||
|
||||
// Options details the available configuration settings for the authenticate service
|
||||
type Options struct {
|
||||
AuthenticateURL *url.URL `envconfig:"AUTHENTICATE_SERVICE_URL"`
|
||||
|
||||
// SharedKey is used to authenticate requests between services
|
||||
SharedKey string `envconfig:"SHARED_SECRET"`
|
||||
// Session/Cookie management
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
|
||||
CookieName string
|
||||
CookieSecret string `envconfig:"COOKIE_SECRET"`
|
||||
CookieDomain string `envconfig:"COOKIE_DOMAIN"`
|
||||
CookieSecure bool `envconfig:"COOKIE_SECURE"`
|
||||
CookieHTTPOnly bool `envconfig:"COOKIE_HTTP_ONLY"`
|
||||
CookieExpire time.Duration `envconfig:"COOKIE_EXPIRE"`
|
||||
CookieRefresh time.Duration `envconfig:"COOKIE_REFRESH"`
|
||||
|
||||
// Identity provider configuration variables as specified by RFC6749
|
||||
// https://openid.net/specs/openid-connect-basic-1_0.html#RFC6749
|
||||
ClientID string `envconfig:"IDP_CLIENT_ID"`
|
||||
ClientSecret string `envconfig:"IDP_CLIENT_SECRET"`
|
||||
Provider string `envconfig:"IDP_PROVIDER"`
|
||||
ProviderURL string `envconfig:"IDP_PROVIDER_URL"`
|
||||
Scopes []string `envconfig:"IDP_SCOPES"`
|
||||
ServiceAccount string `envconfig:"IDP_SERVICE_ACCOUNT"`
|
||||
}
|
||||
|
||||
// OptionsFromEnvConfig builds the authenticate service's configuration environmental variables
|
||||
func OptionsFromEnvConfig() (*Options, error) {
|
||||
o := defaultOptions
|
||||
if err := envconfig.Process("", o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// Validate checks to see if configuration values are valid for the authenticate service.
|
||||
// ValidateOptions checks to see if configuration values are valid for the authenticate service.
|
||||
// The checks do not modify the internal state of the Option structure. Returns
|
||||
// on first error found.
|
||||
func (o *Options) Validate() error {
|
||||
func ValidateOptions(o *config.Options) error {
|
||||
if o.AuthenticateURL == nil {
|
||||
return errors.New("authenticate: 'AUTHENTICATE_SERVICE_URL' missing")
|
||||
}
|
||||
|
@ -98,11 +54,11 @@ type Authenticate struct {
|
|||
}
|
||||
|
||||
// New validates and creates a new authenticate service from a set of Options
|
||||
func New(opts *Options) (*Authenticate, error) {
|
||||
func New(opts *config.Options) (*Authenticate, error) {
|
||||
if opts == nil {
|
||||
return nil, errors.New("authenticate: options cannot be nil")
|
||||
}
|
||||
if err := opts.Validate(); err != nil {
|
||||
if err := ValidateOptions(opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decodedCookieSecret, _ := base64.StdEncoding.DecodeString(opts.CookieSecret)
|
||||
|
@ -112,7 +68,7 @@ func New(opts *Options) (*Authenticate, error) {
|
|||
}
|
||||
cookieStore, err := sessions.NewCookieStore(
|
||||
&sessions.CookieStoreOptions{
|
||||
Name: opts.CookieName,
|
||||
Name: opts.AuthenticateCookieName,
|
||||
CookieSecure: opts.CookieSecure,
|
||||
CookieHTTPOnly: opts.CookieHTTPOnly,
|
||||
CookieExpire: opts.CookieExpire,
|
||||
|
|
|
@ -2,23 +2,23 @@ package authenticate
|
|||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/config"
|
||||
)
|
||||
|
||||
func testOptions() *Options {
|
||||
func testOptions() *config.Options {
|
||||
redirectURL, _ := url.Parse("https://example.com/oauth2/callback")
|
||||
return &Options{
|
||||
AuthenticateURL: redirectURL,
|
||||
SharedKey: "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=",
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=",
|
||||
CookieSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=",
|
||||
CookieRefresh: time.Duration(1) * time.Hour,
|
||||
CookieExpire: time.Duration(168) * time.Hour,
|
||||
CookieName: "pomerium",
|
||||
return &config.Options{
|
||||
AuthenticateURL: redirectURL,
|
||||
SharedKey: "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=",
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=",
|
||||
CookieSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=",
|
||||
CookieRefresh: time.Duration(1) * time.Hour,
|
||||
CookieExpire: time.Duration(168) * time.Hour,
|
||||
AuthenticateCookieName: "pomerium",
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -41,11 +41,11 @@ func TestOptions_Validate(t *testing.T) {
|
|||
|
||||
tests := []struct {
|
||||
name string
|
||||
o *Options
|
||||
o *config.Options
|
||||
wantErr bool
|
||||
}{
|
||||
{"minimum options", good, false},
|
||||
{"nil options", &Options{}, true},
|
||||
{"nil options", &config.Options{}, true},
|
||||
{"bad redirect url", badRedirectURL, true},
|
||||
{"no cookie secret", emptyCookieSecret, true},
|
||||
{"invalid cookie secret", invalidCookieSecret, true},
|
||||
|
@ -57,46 +57,13 @@ func TestOptions_Validate(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
o := tt.o
|
||||
if err := o.Validate(); (err != nil) != tt.wantErr {
|
||||
if err := ValidateOptions(o); (err != nil) != tt.wantErr {
|
||||
t.Errorf("Options.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionsFromEnvConfig(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
want *Options
|
||||
envKey string
|
||||
envValue string
|
||||
wantErr bool
|
||||
}{
|
||||
{"good default, no env settings", defaultOptions, "", "", false},
|
||||
{"bad url", nil, "AUTHENTICATE_SERVICE_URL", "%.rjlw", true},
|
||||
{"good duration", defaultOptions, "COOKIE_EXPIRE", "1m", false},
|
||||
{"bad duration", nil, "COOKIE_REFRESH", "1sm", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envKey != "" {
|
||||
os.Setenv(tt.envKey, tt.envValue)
|
||||
defer os.Unsetenv(tt.envKey)
|
||||
}
|
||||
got, err := OptionsFromEnvConfig()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("OptionsFromEnvConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("OptionsFromEnvConfig() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
good := testOptions()
|
||||
good.Provider = "google"
|
||||
|
@ -106,7 +73,7 @@ func TestNew(t *testing.T) {
|
|||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *Options
|
||||
opts *config.Options
|
||||
// want *Authenticate
|
||||
wantErr bool
|
||||
}{
|
||||
|
|
|
@ -5,34 +5,14 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pomerium/envconfig"
|
||||
"github.com/pomerium/pomerium/internal/config"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/policy"
|
||||
)
|
||||
|
||||
// Options contains configuration settings for the authorize service.
|
||||
type Options struct {
|
||||
// SharedKey is used to validate requests between services
|
||||
SharedKey string `envconfig:"SHARED_SECRET" required:"true"`
|
||||
|
||||
// Policy is a base64 encoded yaml blob which enumerates
|
||||
// per-route access control policies.
|
||||
Policy string `envconfig:"POLICY"`
|
||||
PolicyFile string `envconfig:"POLICY_FILE"`
|
||||
}
|
||||
|
||||
// OptionsFromEnvConfig creates an authorize service options from environmental
|
||||
// variables.
|
||||
func OptionsFromEnvConfig() (*Options, error) {
|
||||
o := new(Options)
|
||||
if err := envconfig.Process("", o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// Validate checks to see if configuration values are valid for the
|
||||
// ValidateOptions checks to see if configuration values are valid for the
|
||||
// authorize service. Returns first error, if found.
|
||||
func (o *Options) Validate() error {
|
||||
func ValidateOptions(o *config.Options) error {
|
||||
decoded, err := base64.StdEncoding.DecodeString(o.SharedKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authorize: `SHARED_SECRET` setting is invalid base64: %v", err)
|
||||
|
@ -72,11 +52,11 @@ type Authorize struct {
|
|||
}
|
||||
|
||||
// New validates and creates a new Authorize service from a set of Options
|
||||
func New(opts *Options) (*Authorize, error) {
|
||||
func New(opts *config.Options) (*Authorize, error) {
|
||||
if opts == nil {
|
||||
return nil, errors.New("authorize: options cannot be nil")
|
||||
}
|
||||
if err := opts.Validate(); err != nil {
|
||||
if err := ValidateOptions(opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// errors handled by validate
|
||||
|
|
|
@ -4,40 +4,10 @@ import (
|
|||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOptionsFromEnvConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
os.Clearenv()
|
||||
tests := []struct {
|
||||
name string
|
||||
want *Options
|
||||
envKey string
|
||||
envValue string
|
||||
wantErr bool
|
||||
}{
|
||||
{"shared secret missing", nil, "", "", true},
|
||||
{"with secret", &Options{SharedKey: "aGkK"}, "SHARED_SECRET", "aGkK", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envKey != "" {
|
||||
os.Setenv(tt.envKey, tt.envValue)
|
||||
defer os.Unsetenv(tt.envKey)
|
||||
}
|
||||
got, err := OptionsFromEnvConfig()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("OptionsFromEnvConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("OptionsFromEnvConfig() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
"github.com/pomerium/pomerium/internal/config"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
@ -76,7 +46,7 @@ func TestNew(t *testing.T) {
|
|||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
o := &Options{SharedKey: tt.SharedKey, Policy: tt.Policy, PolicyFile: tt.PolicyFile}
|
||||
o := &config.Options{SharedKey: tt.SharedKey, Policy: tt.Policy, PolicyFile: tt.PolicyFile}
|
||||
if tt.name == "nil options" {
|
||||
o = nil
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
"github.com/pomerium/pomerium/authenticate"
|
||||
"github.com/pomerium/pomerium/authorize"
|
||||
"github.com/pomerium/pomerium/internal/config"
|
||||
"github.com/pomerium/pomerium/internal/https"
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
"github.com/pomerium/pomerium/internal/middleware"
|
||||
|
@ -30,7 +31,7 @@ func main() {
|
|||
fmt.Println(version.FullVersion())
|
||||
os.Exit(0)
|
||||
}
|
||||
opt, err := optionsFromEnvConfig()
|
||||
opt, err := config.OptionsFromEnvConfig()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("cmd/pomerium: options")
|
||||
}
|
||||
|
@ -41,17 +42,17 @@ func main() {
|
|||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
_, err = newAuthenticateService(opt.Services, mux, grpcServer)
|
||||
_, err = newAuthenticateService(opt, mux, grpcServer)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("cmd/pomerium: authenticate")
|
||||
}
|
||||
|
||||
_, err = newAuthorizeService(opt.Services, grpcServer)
|
||||
_, err = newAuthorizeService(opt, grpcServer)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("cmd/pomerium: authorize")
|
||||
}
|
||||
|
||||
_, err = newProxyService(opt.Services, mux)
|
||||
_, err = newProxyService(opt, mux)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("cmd/pomerium: proxy")
|
||||
}
|
||||
|
@ -102,32 +103,24 @@ func startRedirectServer(addr string) (*http.Server, error) {
|
|||
return srv, nil
|
||||
}
|
||||
|
||||
func newAuthenticateService(s string, mux *http.ServeMux, rpc *grpc.Server) (*authenticate.Authenticate, error) {
|
||||
if !isAuthenticate(s) {
|
||||
func newAuthenticateService(opt *config.Options, mux *http.ServeMux, rpc *grpc.Server) (*authenticate.Authenticate, error) {
|
||||
if opt == nil || !config.IsAuthenticate(opt.Services) {
|
||||
return nil, nil
|
||||
}
|
||||
opts, err := authenticate.OptionsFromEnvConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, err := authenticate.New(opts)
|
||||
service, err := authenticate.New(opt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pbAuthenticate.RegisterAuthenticatorServer(rpc, service)
|
||||
mux.Handle(urlutil.StripPort(opts.AuthenticateURL.Host)+"/", service.Handler())
|
||||
mux.Handle(urlutil.StripPort(opt.AuthenticateURL.Host)+"/", service.Handler())
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func newAuthorizeService(s string, rpc *grpc.Server) (*authorize.Authorize, error) {
|
||||
if !isAuthorize(s) {
|
||||
func newAuthorizeService(opt *config.Options, rpc *grpc.Server) (*authorize.Authorize, error) {
|
||||
if opt == nil || !config.IsAuthorize(opt.Services) {
|
||||
return nil, nil
|
||||
}
|
||||
opts, err := authorize.OptionsFromEnvConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, err := authorize.New(opts)
|
||||
service, err := authorize.New(opt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -135,15 +128,11 @@ func newAuthorizeService(s string, rpc *grpc.Server) (*authorize.Authorize, erro
|
|||
return service, nil
|
||||
}
|
||||
|
||||
func newProxyService(s string, mux *http.ServeMux) (*proxy.Proxy, error) {
|
||||
if !isProxy(s) {
|
||||
func newProxyService(opt *config.Options, mux *http.ServeMux) (*proxy.Proxy, error) {
|
||||
if opt == nil || !config.IsProxy(opt.Services) {
|
||||
return nil, nil
|
||||
}
|
||||
opts, err := proxy.OptionsFromEnvConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, err := proxy.New(opts)
|
||||
service, err := proxy.New(opt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -151,7 +140,7 @@ func newProxyService(s string, mux *http.ServeMux) (*proxy.Proxy, error) {
|
|||
return service, nil
|
||||
}
|
||||
|
||||
func wrapMiddleware(o *Options, mux *http.ServeMux) http.Handler {
|
||||
func wrapMiddleware(o *config.Options, mux *http.ServeMux) http.Handler {
|
||||
c := middleware.NewChain()
|
||||
c = c.Append(middleware.NewHandler(log.Logger))
|
||||
c = c.Append(middleware.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) {
|
||||
|
@ -179,3 +168,17 @@ func wrapMiddleware(o *Options, mux *http.ServeMux) http.Handler {
|
|||
c = c.Append(middleware.Healthcheck("/ping", version.UserAgent()))
|
||||
return c.Then(mux)
|
||||
}
|
||||
|
||||
func parseOptions() (*config.Options, error) {
|
||||
o, err := config.OptionsFromEnvConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if o.Debug {
|
||||
log.SetDebugMode()
|
||||
}
|
||||
if o.LogLevel != "" {
|
||||
log.SetLevel(o.LogLevel)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
|
|
@ -5,10 +5,13 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/config"
|
||||
"github.com/pomerium/pomerium/internal/middleware"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
@ -47,42 +50,41 @@ func Test_startRedirectServer(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_newAuthenticateService(t *testing.T) {
|
||||
os.Clearenv()
|
||||
grpcAuth := middleware.NewSharedSecretCred("test")
|
||||
grpcOpts := []grpc.ServerOption{grpc.UnaryInterceptor(grpcAuth.ValidateRequest)}
|
||||
grpcServer := grpc.NewServer(grpcOpts...)
|
||||
mux := http.NewServeMux()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
envKey string
|
||||
envValue string
|
||||
name string
|
||||
s string
|
||||
Field string
|
||||
Value string
|
||||
|
||||
wantHostname string
|
||||
wantErr bool
|
||||
}{
|
||||
{"wrong service", "proxy", "", "", "", false},
|
||||
{"bad", "authenticate", "SHARED_SECRET", "error!", "", true},
|
||||
{"bad emv", "authenticate", "COOKIE_REFRESH", "error!", "", true},
|
||||
{"good", "authenticate", "IDP_CLIENT_ID", "test", "auth.server.com", false},
|
||||
{"bad", "authenticate", "SharedKey", "error!", "", true},
|
||||
{"good", "authenticate", "ClientID", "test", "auth.server.com", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
os.Setenv("IDP_PROVIDER", "google")
|
||||
os.Setenv("IDP_CLIENT_SECRET", "TEST")
|
||||
os.Setenv("SHARED_SECRET", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=")
|
||||
os.Setenv("COOKIE_SECRET", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=")
|
||||
os.Setenv("AUTHENTICATE_SERVICE_URL", "http://auth.server.com")
|
||||
defer os.Unsetenv("IDP_CLIENT_ID")
|
||||
defer os.Unsetenv("IDP_CLIENT_SECRET")
|
||||
defer os.Unsetenv("SHARED_SECRET")
|
||||
defer os.Unsetenv("COOKIE_SECRET")
|
||||
authURL, _ := url.Parse("http://auth.server.com")
|
||||
testOpts := config.NewOptions()
|
||||
testOpts.Provider = "google"
|
||||
testOpts.ClientSecret = "TEST"
|
||||
testOpts.SharedKey = "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM="
|
||||
testOpts.CookieSecret = "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM="
|
||||
testOpts.AuthenticateURL = authURL
|
||||
testOpts.Services = tt.s
|
||||
|
||||
os.Setenv(tt.envKey, tt.envValue)
|
||||
defer os.Unsetenv(tt.envKey)
|
||||
if tt.Field != "" {
|
||||
testOptsField := reflect.ValueOf(testOpts).Elem().FieldByName(tt.Field)
|
||||
testOptsField.Set(reflect.ValueOf(tt).FieldByName("Value"))
|
||||
}
|
||||
|
||||
_, err := newAuthenticateService(tt.s, mux, grpcServer)
|
||||
_, err := newAuthenticateService(testOpts, mux, grpcServer)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("newAuthenticateService() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
@ -99,27 +101,31 @@ func Test_newAuthorizeService(t *testing.T) {
|
|||
grpcServer := grpc.NewServer(grpcOpts...)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
envKey string
|
||||
envValue string
|
||||
name string
|
||||
s string
|
||||
Field string
|
||||
Value string
|
||||
|
||||
wantErr bool
|
||||
}{
|
||||
{"wrong service", "proxy", "", "", false},
|
||||
{"bad option parsing", "authorize", "SHARED_SECRET", "false", true},
|
||||
{"bad env", "authorize", "POLICY", "error!", true},
|
||||
{"good", "authorize", "SHARED_SECRET", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=", false},
|
||||
{"bad option parsing", "authorize", "SharedKey", "false", true},
|
||||
{"bad env", "authorize", "Policy", "error!", true},
|
||||
{"good", "authorize", "SharedKey", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
os.Setenv("POLICY", "LSBmcm9tOiBodHRwYmluLmNvcnAuYmV5b25kcGVyaW1ldGVyLmNvbQogIHRvOiBodHRwOi8vaHR0cGJpbgogIGFsbG93ZWRfZG9tYWluczoKICAgIC0gcG9tZXJpdW0uaW8KICBjb3JzX2FsbG93X3ByZWZsaWdodDogdHJ1ZQogIHRpbWVvdXQ6IDMwcwotIGZyb206IGV4dGVybmFsLWh0dHBiaW4uY29ycC5iZXlvbmRwZXJpbWV0ZXIuY29tCiAgdG86IGh0dHBiaW4ub3JnCiAgYWxsb3dlZF9kb21haW5zOgogICAgLSBnbWFpbC5jb20KLSBmcm9tOiB3ZWlyZGx5c3NsLmNvcnAuYmV5b25kcGVyaW1ldGVyLmNvbQogIHRvOiBodHRwOi8vbmV2ZXJzc2wuY29tCiAgYWxsb3dlZF91c2VyczoKICAgIC0gYmRkQHBvbWVyaXVtLmlvCiAgYWxsb3dlZF9ncm91cHM6CiAgICAtIGFkbWlucwogICAgLSBkZXZlbG9wZXJzCi0gZnJvbTogaGVsbG8uY29ycC5iZXlvbmRwZXJpbWV0ZXIuY29tCiAgdG86IGh0dHA6Ly9oZWxsbzo4MDgwCiAgYWxsb3dlZF9ncm91cHM6CiAgICAtIGFkbWlucw==")
|
||||
os.Setenv("COOKIE_SECRET", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=")
|
||||
defer os.Unsetenv("SHARED_SECRET")
|
||||
defer os.Unsetenv("COOKIE_SECRET")
|
||||
os.Setenv(tt.envKey, tt.envValue)
|
||||
defer os.Unsetenv(tt.envKey)
|
||||
_, err := newAuthorizeService(tt.s, grpcServer)
|
||||
testOpts := config.NewOptions()
|
||||
testOpts.Services = tt.s
|
||||
testOpts.CookieSecret = "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM="
|
||||
testOpts.Policy = "LSBmcm9tOiBodHRwYmluLmNvcnAuYmV5b25kcGVyaW1ldGVyLmNvbQogIHRvOiBodHRwOi8vaHR0cGJpbgogIGFsbG93ZWRfZG9tYWluczoKICAgIC0gcG9tZXJpdW0uaW8KICBjb3JzX2FsbG93X3ByZWZsaWdodDogdHJ1ZQogIHRpbWVvdXQ6IDMwcwotIGZyb206IGV4dGVybmFsLWh0dHBiaW4uY29ycC5iZXlvbmRwZXJpbWV0ZXIuY29tCiAgdG86IGh0dHBiaW4ub3JnCiAgYWxsb3dlZF9kb21haW5zOgogICAgLSBnbWFpbC5jb20KLSBmcm9tOiB3ZWlyZGx5c3NsLmNvcnAuYmV5b25kcGVyaW1ldGVyLmNvbQogIHRvOiBodHRwOi8vbmV2ZXJzc2wuY29tCiAgYWxsb3dlZF91c2VyczoKICAgIC0gYmRkQHBvbWVyaXVtLmlvCiAgYWxsb3dlZF9ncm91cHM6CiAgICAtIGFkbWlucwogICAgLSBkZXZlbG9wZXJzCi0gZnJvbTogaGVsbG8uY29ycC5iZXlvbmRwZXJpbWV0ZXIuY29tCiAgdG86IGh0dHA6Ly9oZWxsbzo4MDgwCiAgYWxsb3dlZF9ncm91cHM6CiAgICAtIGFkbWlucw=="
|
||||
|
||||
if tt.Field != "" {
|
||||
testOptsField := reflect.ValueOf(testOpts).Elem().FieldByName(tt.Field)
|
||||
testOptsField.Set(reflect.ValueOf(tt).FieldByName("Value"))
|
||||
}
|
||||
|
||||
_, err := newAuthorizeService(testOpts, grpcServer)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("newAuthorizeService() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
@ -131,34 +137,33 @@ func Test_newAuthorizeService(t *testing.T) {
|
|||
func Test_newProxyeService(t *testing.T) {
|
||||
os.Clearenv()
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
envKey string
|
||||
envValue string
|
||||
name string
|
||||
s string
|
||||
Field string
|
||||
Value string
|
||||
|
||||
wantErr bool
|
||||
}{
|
||||
{"wrong service", "authenticate", "", "", false},
|
||||
{"bad option parsing", "proxy", "SHARED_SECRET", "false", true},
|
||||
{"bad env", "proxy", "POLICY", "error!", true},
|
||||
{"bad encoding for envar", "proxy", "COOKIE_REFRESH", "error!", true},
|
||||
{"good", "proxy", "SHARED_SECRET", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=", false},
|
||||
{"bad option parsing", "proxy", "SharedKey", "false", true},
|
||||
{"bad env", "proxy", "Policy", "error!", true},
|
||||
{"good", "proxy", "SharedKey", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
testOpts := config.NewOptions()
|
||||
testOpts.AuthenticateURL, _ = url.Parse("https://authenticate.example.com")
|
||||
testOpts.AuthorizeURL, _ = url.Parse("https://authorize.example.com")
|
||||
testOpts.Policy = "LSBmcm9tOiBodHRwYmluLmNvcnAuYmV5b25kcGVyaW1ldGVyLmNvbQogIHRvOiBodHRwOi8vaHR0cGJpbgogIGFsbG93ZWRfZG9tYWluczoKICAgIC0gcG9tZXJpdW0uaW8KICBjb3JzX2FsbG93X3ByZWZsaWdodDogdHJ1ZQogIHRpbWVvdXQ6IDMwcwotIGZyb206IGV4dGVybmFsLWh0dHBiaW4uY29ycC5iZXlvbmRwZXJpbWV0ZXIuY29tCiAgdG86IGh0dHBiaW4ub3JnCiAgYWxsb3dlZF9kb21haW5zOgogICAgLSBnbWFpbC5jb20KLSBmcm9tOiB3ZWlyZGx5c3NsLmNvcnAuYmV5b25kcGVyaW1ldGVyLmNvbQogIHRvOiBodHRwOi8vbmV2ZXJzc2wuY29tCiAgYWxsb3dlZF91c2VyczoKICAgIC0gYmRkQHBvbWVyaXVtLmlvCiAgYWxsb3dlZF9ncm91cHM6CiAgICAtIGFkbWlucwogICAgLSBkZXZlbG9wZXJzCi0gZnJvbTogaGVsbG8uY29ycC5iZXlvbmRwZXJpbWV0ZXIuY29tCiAgdG86IGh0dHA6Ly9oZWxsbzo4MDgwCiAgYWxsb3dlZF9ncm91cHM6CiAgICAtIGFkbWlucw=="
|
||||
testOpts.CookieSecret = "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM="
|
||||
testOpts.Services = tt.s
|
||||
|
||||
os.Setenv("AUTHENTICATE_SERVICE_URL", "https://authenticate.example.com")
|
||||
os.Setenv("AUTHORIZE_SERVICE_URL", "https://authorize.example.com")
|
||||
os.Setenv("POLICY", "LSBmcm9tOiBodHRwYmluLmNvcnAuYmV5b25kcGVyaW1ldGVyLmNvbQogIHRvOiBodHRwOi8vaHR0cGJpbgogIGFsbG93ZWRfZG9tYWluczoKICAgIC0gcG9tZXJpdW0uaW8KICBjb3JzX2FsbG93X3ByZWZsaWdodDogdHJ1ZQogIHRpbWVvdXQ6IDMwcwotIGZyb206IGV4dGVybmFsLWh0dHBiaW4uY29ycC5iZXlvbmRwZXJpbWV0ZXIuY29tCiAgdG86IGh0dHBiaW4ub3JnCiAgYWxsb3dlZF9kb21haW5zOgogICAgLSBnbWFpbC5jb20KLSBmcm9tOiB3ZWlyZGx5c3NsLmNvcnAuYmV5b25kcGVyaW1ldGVyLmNvbQogIHRvOiBodHRwOi8vbmV2ZXJzc2wuY29tCiAgYWxsb3dlZF91c2VyczoKICAgIC0gYmRkQHBvbWVyaXVtLmlvCiAgYWxsb3dlZF9ncm91cHM6CiAgICAtIGFkbWlucwogICAgLSBkZXZlbG9wZXJzCi0gZnJvbTogaGVsbG8uY29ycC5iZXlvbmRwZXJpbWV0ZXIuY29tCiAgdG86IGh0dHA6Ly9oZWxsbzo4MDgwCiAgYWxsb3dlZF9ncm91cHM6CiAgICAtIGFkbWlucw==")
|
||||
os.Setenv("COOKIE_SECRET", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=")
|
||||
defer os.Unsetenv("AUTHENTICATE_SERVICE_URL")
|
||||
defer os.Unsetenv("AUTHORIZE_SERVICE_URL")
|
||||
defer os.Unsetenv("SHARED_SECRET")
|
||||
defer os.Unsetenv("COOKIE_SECRET")
|
||||
os.Setenv(tt.envKey, tt.envValue)
|
||||
defer os.Unsetenv(tt.envKey)
|
||||
_, err := newProxyService(tt.s, mux)
|
||||
if tt.Field != "" {
|
||||
testOptsField := reflect.ValueOf(testOpts).Elem().FieldByName(tt.Field)
|
||||
testOptsField.Set(reflect.ValueOf(tt).FieldByName("Value"))
|
||||
}
|
||||
_, err := newProxyService(testOpts, mux)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("newProxyService() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
@ -168,7 +173,7 @@ func Test_newProxyeService(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_wrapMiddleware(t *testing.T) {
|
||||
o := &Options{
|
||||
o := &config.Options{
|
||||
Services: "all",
|
||||
Headers: map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
|
@ -197,3 +202,34 @@ func Test_wrapMiddleware(t *testing.T) {
|
|||
t.Errorf("handler returned unexpected body: got %v want %v", body, expected)
|
||||
}
|
||||
}
|
||||
func Test_parseOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envKey string
|
||||
envValue string
|
||||
|
||||
wantSharedKey string
|
||||
wantErr bool
|
||||
}{
|
||||
{"no shared secret", "", "", "", true},
|
||||
{"good", "SHARED_SECRET", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=", "YixWi1MYh77NMECGGIJQevoonYtVF+ZPRkQZrrmeRqM=", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
os.Setenv(tt.envKey, tt.envValue)
|
||||
defer os.Unsetenv(tt.envKey)
|
||||
|
||||
got, err := parseOptions()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseOptions() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != nil && got.SharedKey != tt.wantSharedKey {
|
||||
t.Errorf("parseOptions()\n")
|
||||
t.Errorf("got: %+v\n", got.SharedKey)
|
||||
t.Errorf("want: %+v\n", tt.wantSharedKey)
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,140 +0,0 @@
|
|||
package main // import "github.com/pomerium/pomerium/cmd/pomerium"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pomerium/envconfig"
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
)
|
||||
|
||||
// DisableHeaderKey is the key used to check whether to disable setting header
|
||||
const DisableHeaderKey = "disable"
|
||||
|
||||
// Options are the global environmental flags used to set up pomerium's services.
|
||||
// If a base64 encoded certificate and key are not provided as environmental variables,
|
||||
// or if a file location is not provided, the server will attempt to find a matching keypair
|
||||
// in the local directory as `./cert.pem` and `./privkey.pem` respectively.
|
||||
type Options struct {
|
||||
// Debug outputs human-readable logs to Stdout.
|
||||
Debug bool `envconfig:"POMERIUM_DEBUG"`
|
||||
|
||||
// LogLevel sets the global override for log level. All Loggers will use at least this value.
|
||||
// Possible options are "info","warn", and "error". Defaults to "debug".
|
||||
LogLevel string `envconfig:"LOG_LEVEL"`
|
||||
|
||||
// SharedKey is the shared secret authorization key used to mutually authenticate
|
||||
// requests between services.
|
||||
SharedKey string `envconfig:"SHARED_SECRET"`
|
||||
|
||||
// Services is a list enabled service mode. If none are selected, "all" is used.
|
||||
// Available options are : "all", "authenticate", "proxy".
|
||||
Services string `envconfig:"SERVICES"`
|
||||
|
||||
// Addr specifies the host and port on which the server should serve
|
||||
// HTTPS requests. If empty, ":https" is used.
|
||||
Addr string `envconfig:"ADDRESS"`
|
||||
|
||||
// Cert and Key specifies the base64 encoded TLS certificates to use.
|
||||
Cert string `envconfig:"CERTIFICATE"`
|
||||
Key string `envconfig:"CERTIFICATE_KEY"`
|
||||
|
||||
// CertFile and KeyFile specifies the TLS certificates to use.
|
||||
CertFile string `envconfig:"CERTIFICATE_FILE"`
|
||||
KeyFile string `envconfig:"CERTIFICATE_KEY_FILE"`
|
||||
|
||||
// HttpRedirectAddr, if set, specifies the host and port to run the HTTP
|
||||
// to HTTPS redirect server on. For example, ":http" would start a server
|
||||
// on port 80. If empty, no redirect server is started.
|
||||
HTTPRedirectAddr string `envconfig:"HTTP_REDIRECT_ADDR"`
|
||||
|
||||
// Headers to set on all proxied requests. Add a 'disable' key map to turn off.
|
||||
Headers map[string]string `envconfig:"HEADERS"`
|
||||
|
||||
// Timeout settings : https://github.com/pomerium/pomerium/issues/40
|
||||
ReadTimeout time.Duration `envconfig:"TIMEOUT_READ"`
|
||||
WriteTimeout time.Duration `envconfig:"TIMEOUT_WRITE"`
|
||||
ReadHeaderTimeout time.Duration `envconfig:"TIMEOUT_READ_HEADER"`
|
||||
IdleTimeout time.Duration `envconfig:"TIMEOUT_IDLE"`
|
||||
}
|
||||
|
||||
var defaultOptions = &Options{
|
||||
Debug: false,
|
||||
LogLevel: "debug",
|
||||
Services: "all",
|
||||
Headers: map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
|
||||
},
|
||||
}
|
||||
|
||||
// optionsFromEnvConfig builds the main binary's configuration
|
||||
// options from provided environmental variables
|
||||
func optionsFromEnvConfig() (*Options, error) {
|
||||
o := defaultOptions
|
||||
if err := envconfig.Process("", o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isValidService(o.Services) {
|
||||
return nil, fmt.Errorf("%s is an invalid service type", o.Services)
|
||||
}
|
||||
if o.SharedKey == "" {
|
||||
return nil, errors.New("shared-key cannot be empty")
|
||||
}
|
||||
if o.Debug {
|
||||
log.SetDebugMode()
|
||||
}
|
||||
if o.LogLevel != "" {
|
||||
log.SetLevel(o.LogLevel)
|
||||
}
|
||||
if _, disable := o.Headers[DisableHeaderKey]; disable {
|
||||
o.Headers = make(map[string]string)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// isValidService checks to see if a service is a valid service mode
|
||||
func isValidService(s string) bool {
|
||||
switch s {
|
||||
case
|
||||
"all",
|
||||
"proxy",
|
||||
"authorize",
|
||||
"authenticate":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isAuthenticate(s string) bool {
|
||||
switch s {
|
||||
case
|
||||
"all",
|
||||
"authenticate":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isAuthorize(s string) bool {
|
||||
switch s {
|
||||
case
|
||||
"all",
|
||||
"authorize":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isProxy(s string) bool {
|
||||
switch s {
|
||||
case
|
||||
"all",
|
||||
"proxy":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
226
internal/config/options.go
Normal file
226
internal/config/options.go
Normal file
|
@ -0,0 +1,226 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pomerium/envconfig"
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
"github.com/pomerium/pomerium/internal/policy"
|
||||
)
|
||||
|
||||
// DisableHeaderKey is the key used to check whether to disable setting header
|
||||
const DisableHeaderKey = "disable"
|
||||
|
||||
// Options are the global environmental flags used to set up pomerium's services.
|
||||
// If a base64 encoded certificate and key are not provided as environmental variables,
|
||||
// or if a file location is not provided, the server will attempt to find a matching keypair
|
||||
// in the local directory as `./cert.pem` and `./privkey.pem` respectively.
|
||||
type Options struct {
|
||||
// Debug outputs human-readable logs to Stdout.
|
||||
Debug bool `envconfig:"POMERIUM_DEBUG"`
|
||||
|
||||
// LogLevel sets the global override for log level. All Loggers will use at least this value.
|
||||
// Possible options are "info","warn", and "error". Defaults to "debug".
|
||||
LogLevel string `envconfig:"LOG_LEVEL"`
|
||||
|
||||
// SharedKey is the shared secret authorization key used to mutually authenticate
|
||||
// requests between services.
|
||||
SharedKey string `envconfig:"SHARED_SECRET"`
|
||||
|
||||
// Services is a list enabled service mode. If none are selected, "all" is used.
|
||||
// Available options are : "all", "authenticate", "proxy".
|
||||
Services string `envconfig:"SERVICES"`
|
||||
|
||||
// Addr specifies the host and port on which the server should serve
|
||||
// HTTPS requests. If empty, ":https" is used.
|
||||
Addr string `envconfig:"ADDRESS"`
|
||||
|
||||
// Cert and Key specifies the base64 encoded TLS certificates to use.
|
||||
Cert string `envconfig:"CERTIFICATE"`
|
||||
Key string `envconfig:"CERTIFICATE_KEY"`
|
||||
|
||||
// CertFile and KeyFile specifies the TLS certificates to use.
|
||||
CertFile string `envconfig:"CERTIFICATE_FILE"`
|
||||
KeyFile string `envconfig:"CERTIFICATE_KEY_FILE"`
|
||||
|
||||
// HttpRedirectAddr, if set, specifies the host and port to run the HTTP
|
||||
// to HTTPS redirect server on. For example, ":http" would start a server
|
||||
// on port 80. If empty, no redirect server is started.
|
||||
HTTPRedirectAddr string `envconfig:"HTTP_REDIRECT_ADDR"`
|
||||
|
||||
// Timeout settings : https://github.com/pomerium/pomerium/issues/40
|
||||
ReadTimeout time.Duration `envconfig:"TIMEOUT_READ"`
|
||||
WriteTimeout time.Duration `envconfig:"TIMEOUT_WRITE"`
|
||||
ReadHeaderTimeout time.Duration `envconfig:"TIMEOUT_READ_HEADER"`
|
||||
IdleTimeout time.Duration `envconfig:"TIMEOUT_IDLE"`
|
||||
|
||||
// Policy is a base64 encoded yaml blob which enumerates
|
||||
// per-route access control policies.
|
||||
Policy string `envconfig:"POLICY"`
|
||||
PolicyFile string `envconfig:"POLICY_FILE"`
|
||||
|
||||
// AuthenticateURL represents the externally accessible http endpoints
|
||||
// used for authentication requests and callbacks
|
||||
AuthenticateURL *url.URL `envconfig:"AUTHENTICATE_SERVICE_URL"`
|
||||
|
||||
// Session/Cookie management
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
|
||||
AuthenticateCookieName string
|
||||
ProxyCookieName string
|
||||
CookieSecret string `envconfig:"COOKIE_SECRET"`
|
||||
CookieDomain string `envconfig:"COOKIE_DOMAIN"`
|
||||
CookieSecure bool `envconfig:"COOKIE_SECURE"`
|
||||
CookieHTTPOnly bool `envconfig:"COOKIE_HTTP_ONLY"`
|
||||
CookieExpire time.Duration `envconfig:"COOKIE_EXPIRE"`
|
||||
CookieRefresh time.Duration `envconfig:"COOKIE_REFRESH"`
|
||||
|
||||
// Identity provider configuration variables as specified by RFC6749
|
||||
// https://openid.net/specs/openid-connect-basic-1_0.html#RFC6749
|
||||
ClientID string `envconfig:"IDP_CLIENT_ID"`
|
||||
ClientSecret string `envconfig:"IDP_CLIENT_SECRET"`
|
||||
Provider string `envconfig:"IDP_PROVIDER"`
|
||||
ProviderURL string `envconfig:"IDP_PROVIDER_URL"`
|
||||
Scopes []string `envconfig:"IDP_SCOPES"`
|
||||
ServiceAccount string `envconfig:"IDP_SERVICE_ACCOUNT"`
|
||||
|
||||
Policies []policy.Policy `envconfig:"POLICY"`
|
||||
|
||||
// AuthenticateInternalAddr is used as an override when using a load balancer
|
||||
// or ingress that does not natively support routing gRPC.
|
||||
AuthenticateInternalAddr string `envconfig:"AUTHENTICATE_INTERNAL_URL"`
|
||||
|
||||
// AuthorizeURL is the routable destination of the authorize service's
|
||||
// gRPC endpoint. NOTE: As above, many load balancers do not support
|
||||
// externally routed gRPC so this may be an internal location.
|
||||
AuthorizeURL *url.URL `envconfig:"AUTHORIZE_SERVICE_URL"`
|
||||
|
||||
// Settings to enable custom behind-the-ingress service communication
|
||||
OverrideCertificateName string `envconfig:"OVERRIDE_CERTIFICATE_NAME"`
|
||||
CA string `envconfig:"CERTIFICATE_AUTHORITY"`
|
||||
CAFile string `envconfig:"CERTIFICATE_AUTHORITY_FILE"`
|
||||
|
||||
// SigningKey is a base64 encoded private key used to add a JWT-signature.
|
||||
// https://www.pomerium.io/docs/signed-headers.html
|
||||
SigningKey string `envconfig:"SIGNING_KEY"`
|
||||
|
||||
// Headers to set on all proxied requests. Add a 'disable' key map to turn off.
|
||||
Headers map[string]string `envconfig:"HEADERS"`
|
||||
|
||||
// Sub-routes
|
||||
Routes map[string]string `envconfig:"ROUTES"`
|
||||
DefaultUpstreamTimeout time.Duration `envconfig:"DEFAULT_UPSTREAM_TIMEOUT"`
|
||||
}
|
||||
|
||||
// NewOptions returns a new options struct with default vaules
|
||||
func NewOptions() *Options {
|
||||
o := &Options{
|
||||
Debug: false,
|
||||
LogLevel: "debug",
|
||||
Services: "all",
|
||||
AuthenticateCookieName: "_pomerium_authenticate",
|
||||
CookieHTTPOnly: true,
|
||||
CookieSecure: true,
|
||||
CookieExpire: time.Duration(14) * time.Hour,
|
||||
CookieRefresh: time.Duration(30) * time.Minute,
|
||||
ProxyCookieName: "_pomerium_proxy",
|
||||
DefaultUpstreamTimeout: time.Duration(30) * time.Second,
|
||||
Headers: map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
|
||||
},
|
||||
Addr: ":https",
|
||||
CertFile: filepath.Join(findPwd(), "cert.pem"),
|
||||
KeyFile: filepath.Join(findPwd(), "privkey.pem"),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 0, // support streaming by default
|
||||
IdleTimeout: 5 * time.Minute,
|
||||
AuthenticateURL: new(url.URL),
|
||||
AuthorizeURL: new(url.URL),
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// OptionsFromEnvConfig builds the main binary's configuration
|
||||
// options from provided environmental variables
|
||||
func OptionsFromEnvConfig() (*Options, error) {
|
||||
o := NewOptions()
|
||||
if err := envconfig.Process("", o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !IsValidService(o.Services) {
|
||||
return nil, fmt.Errorf("%s is an invalid service type", o.Services)
|
||||
}
|
||||
if o.SharedKey == "" {
|
||||
return nil, errors.New("shared-key cannot be empty")
|
||||
}
|
||||
if o.Debug {
|
||||
log.SetDebugMode()
|
||||
}
|
||||
if o.LogLevel != "" {
|
||||
log.SetLevel(o.LogLevel)
|
||||
}
|
||||
if _, disable := o.Headers[DisableHeaderKey]; disable {
|
||||
o.Headers = make(map[string]string)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// findPwd returns best guess at current working directory
|
||||
func findPwd() string {
|
||||
p, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// isValidService checks to see if a service is a valid service mode
|
||||
func IsValidService(s string) bool {
|
||||
switch s {
|
||||
case
|
||||
"all",
|
||||
"proxy",
|
||||
"authorize",
|
||||
"authenticate":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsAuthenticate(s string) bool {
|
||||
switch s {
|
||||
case
|
||||
"all",
|
||||
"authenticate":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsAuthorize(s string) bool {
|
||||
switch s {
|
||||
case
|
||||
"all",
|
||||
"authorize":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsProxy(s string) bool {
|
||||
switch s {
|
||||
case
|
||||
"all",
|
||||
"proxy":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package main // import "github.com/pomerium/pomerium/cmd/pomerium"
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
@ -7,7 +7,7 @@ import (
|
|||
)
|
||||
|
||||
func Test_optionsFromEnvConfig(t *testing.T) {
|
||||
good := defaultOptions
|
||||
good := NewOptions()
|
||||
good.SharedKey = "test"
|
||||
tests := []struct {
|
||||
name string
|
||||
|
@ -31,7 +31,7 @@ func Test_optionsFromEnvConfig(t *testing.T) {
|
|||
if tt.envKey != "SHARED_SECRET" {
|
||||
os.Setenv("SHARED_SECRET", "test")
|
||||
}
|
||||
got, err := optionsFromEnvConfig()
|
||||
got, err := OptionsFromEnvConfig()
|
||||
os.Unsetenv(tt.envKey)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
|
@ -39,7 +39,7 @@ func Test_optionsFromEnvConfig(t *testing.T) {
|
|||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("optionsFromEnvConfig() = got %v, want %v", got, tt.want)
|
||||
t.Errorf("optionsFromEnvConfig() = got %#v,\n want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ func Test_isValidService(t *testing.T) {
|
|||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isValidService(tt.service); got != tt.want {
|
||||
if got := IsValidService(tt.service); got != tt.want {
|
||||
t.Errorf("isValidService() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
|
@ -83,7 +83,7 @@ func Test_isAuthenticate(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isAuthenticate(tt.service); got != tt.want {
|
||||
if got := IsAuthenticate(tt.service); got != tt.want {
|
||||
t.Errorf("isAuthenticate() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
|
@ -12,6 +12,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/config"
|
||||
"github.com/pomerium/pomerium/internal/sessions"
|
||||
"github.com/pomerium/pomerium/proxy/clients"
|
||||
)
|
||||
|
@ -361,7 +362,7 @@ func TestProxy_Proxy(t *testing.T) {
|
|||
|
||||
tests := []struct {
|
||||
name string
|
||||
options *Options
|
||||
options *config.Options
|
||||
method string
|
||||
header http.Header
|
||||
host string
|
||||
|
@ -371,8 +372,8 @@ func TestProxy_Proxy(t *testing.T) {
|
|||
wantStatus int
|
||||
}{
|
||||
// weirdly, we want 503 here because that means proxy is trying to route a domain (example.com) that we dont control. Weird. I know.
|
||||
{"good", opts, http.MethodGet, defaultHeaders, "https://corp.example.com/test", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthenticate{}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusServiceUnavailable},
|
||||
{"good cors preflight", optsCORS, http.MethodOptions, goodCORSHeaders, "https://corp.example.com/test", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthenticate{}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusServiceUnavailable},
|
||||
{"good", opts, http.MethodGet, defaultHeaders, "https://corp.example.notatld/test", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthenticate{}, clients.MockAuthorize{AuthorizeResponse: true}, http.StatusBadGateway},
|
||||
{"good cors preflight", optsCORS, http.MethodOptions, goodCORSHeaders, "https://corp.example.notatld/test", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthenticate{}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusForbidden},
|
||||
// same request as above, but with cors_allow_preflight=false in the policy
|
||||
{"valid cors, but not allowed", opts, http.MethodOptions, goodCORSHeaders, "https://corp.example.com/test", &sessions.MockSessionStore{Session: goodSession}, clients.MockAuthenticate{}, clients.MockAuthorize{AuthorizeResponse: false}, http.StatusForbidden},
|
||||
// cors allowed, but the request is missing proper headers
|
||||
|
|
|
@ -10,9 +10,8 @@ import (
|
|||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pomerium/envconfig"
|
||||
"github.com/pomerium/pomerium/internal/config"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/cryptutil"
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
|
@ -33,71 +32,9 @@ const (
|
|||
HeaderGroups = "x-pomerium-authenticated-user-groups"
|
||||
)
|
||||
|
||||
// Options represents the configurations available for the proxy service.
|
||||
type Options struct {
|
||||
Policy string `envconfig:"POLICY"`
|
||||
PolicyFile string `envconfig:"POLICY_FILE"`
|
||||
|
||||
// AuthenticateURL represents the externally accessible http endpoints
|
||||
// used for authentication requests and callbacks
|
||||
AuthenticateURL *url.URL `envconfig:"AUTHENTICATE_SERVICE_URL"`
|
||||
// AuthenticateInternalAddr is used as an override when using a load balancer
|
||||
// or ingress that does not natively support routing gRPC.
|
||||
AuthenticateInternalAddr string `envconfig:"AUTHENTICATE_INTERNAL_URL"`
|
||||
|
||||
// AuthorizeURL is the routable destination of the authorize service's
|
||||
// gRPC endpoint. NOTE: As above, many load balancers do not support
|
||||
// externally routed gRPC so this may be an internal location.
|
||||
AuthorizeURL *url.URL `envconfig:"AUTHORIZE_SERVICE_URL"`
|
||||
|
||||
// Settings to enable custom behind-the-ingress service communication
|
||||
OverrideCertificateName string `envconfig:"OVERRIDE_CERTIFICATE_NAME"`
|
||||
CA string `envconfig:"CERTIFICATE_AUTHORITY"`
|
||||
CAFile string `envconfig:"CERTIFICATE_AUTHORITY_FILE"`
|
||||
|
||||
// SigningKey is a base64 encoded private key used to add a JWT-signature.
|
||||
// https://www.pomerium.io/docs/signed-headers.html
|
||||
SigningKey string `envconfig:"SIGNING_KEY"`
|
||||
// SharedKey is a 32 byte random key used to authenticate access between services.
|
||||
SharedKey string `envconfig:"SHARED_SECRET"`
|
||||
|
||||
// Session/Cookie management
|
||||
CookieName string
|
||||
CookieSecret string `envconfig:"COOKIE_SECRET"`
|
||||
CookieDomain string `envconfig:"COOKIE_DOMAIN"`
|
||||
CookieSecure bool `envconfig:"COOKIE_SECURE"`
|
||||
CookieHTTPOnly bool `envconfig:"COOKIE_HTTP_ONLY"`
|
||||
CookieExpire time.Duration `envconfig:"COOKIE_EXPIRE"`
|
||||
CookieRefresh time.Duration `envconfig:"COOKIE_REFRESH"`
|
||||
|
||||
// Sub-routes
|
||||
Routes map[string]string `envconfig:"ROUTES"`
|
||||
DefaultUpstreamTimeout time.Duration `envconfig:"DEFAULT_UPSTREAM_TIMEOUT"`
|
||||
}
|
||||
|
||||
// NewOptions returns a new options struct
|
||||
var defaultOptions = &Options{
|
||||
CookieName: "_pomerium_proxy",
|
||||
CookieHTTPOnly: true,
|
||||
CookieSecure: true,
|
||||
CookieExpire: time.Duration(14) * time.Hour,
|
||||
CookieRefresh: time.Duration(30) * time.Minute,
|
||||
DefaultUpstreamTimeout: time.Duration(30) * time.Second,
|
||||
}
|
||||
|
||||
// OptionsFromEnvConfig builds the identity provider service's configuration
|
||||
// options from provided environmental variables
|
||||
func OptionsFromEnvConfig() (*Options, error) {
|
||||
o := defaultOptions
|
||||
if err := envconfig.Process("", o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// Validate checks that proper configuration settings are set to create
|
||||
// ValidateOptions checks that proper configuration settings are set to create
|
||||
// a proper Proxy instance
|
||||
func (o *Options) Validate() error {
|
||||
func ValidateOptions(o *config.Options) error {
|
||||
decoded, err := base64.StdEncoding.DecodeString(o.SharedKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authorize: `SHARED_SECRET` setting is invalid base64: %v", err)
|
||||
|
@ -187,11 +124,11 @@ type routeConfig struct {
|
|||
|
||||
// New takes a Proxy service from options and a validation function.
|
||||
// Function returns an error if options fail to validate.
|
||||
func New(opts *Options) (*Proxy, error) {
|
||||
func New(opts *config.Options) (*Proxy, error) {
|
||||
if opts == nil {
|
||||
return nil, errors.New("options cannot be nil")
|
||||
}
|
||||
if err := opts.Validate(); err != nil {
|
||||
if err := ValidateOptions(opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// error explicitly handled by validate
|
||||
|
@ -203,7 +140,7 @@ func New(opts *Options) (*Proxy, error) {
|
|||
|
||||
cookieStore, err := sessions.NewCookieStore(
|
||||
&sessions.CookieStoreOptions{
|
||||
Name: opts.CookieName,
|
||||
Name: opts.ProxyCookieName,
|
||||
CookieDomain: opts.CookieDomain,
|
||||
CookieSecure: opts.CookieSecure,
|
||||
CookieHTTPOnly: opts.CookieHTTPOnly,
|
||||
|
@ -326,11 +263,11 @@ func NewReverseProxy(to *url.URL) *httputil.ReverseProxy {
|
|||
}
|
||||
|
||||
// NewReverseProxyHandler applies handler specific options to a given route.
|
||||
func NewReverseProxyHandler(o *Options, proxy *httputil.ReverseProxy, route *policy.Policy) (http.Handler, error) {
|
||||
func NewReverseProxyHandler(o *config.Options, proxy *httputil.ReverseProxy, route *policy.Policy) (http.Handler, error) {
|
||||
up := &UpstreamProxy{
|
||||
name: route.Destination.Host,
|
||||
handler: proxy,
|
||||
cookieName: o.CookieName,
|
||||
cookieName: o.ProxyCookieName,
|
||||
}
|
||||
if len(o.SigningKey) != 0 {
|
||||
decodedSigningKey, _ := base64.StdEncoding.DecodeString(o.SigningKey)
|
||||
|
|
|
@ -7,49 +7,16 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/config"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/policy"
|
||||
)
|
||||
|
||||
var fixedDate = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC)
|
||||
|
||||
func TestOptionsFromEnvConfig(t *testing.T) {
|
||||
os.Clearenv()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
want *Options
|
||||
envKey string
|
||||
envValue string
|
||||
wantErr bool
|
||||
}{
|
||||
{"good default, no env settings", defaultOptions, "", "", false},
|
||||
{"bad url", nil, "AUTHENTICATE_SERVICE_URL", "%.ugly", true},
|
||||
{"good duration", defaultOptions, "COOKIE_REFRESH", "1m", false},
|
||||
{"bad duration", nil, "COOKIE_REFRESH", "1sm", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envKey != "" {
|
||||
os.Setenv(tt.envKey, tt.envValue)
|
||||
defer os.Unsetenv(tt.envKey)
|
||||
}
|
||||
got, err := OptionsFromEnvConfig()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("OptionsFromEnvConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("OptionsFromEnvConfig() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewReverseProxy(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
@ -88,7 +55,7 @@ func TestNewReverseProxyHandler(t *testing.T) {
|
|||
backendHost := net.JoinHostPort(backendHostname, backendPort)
|
||||
proxyURL, _ := url.Parse(backendURL.Scheme + "://" + backendHost + "/")
|
||||
proxyHandler := NewReverseProxy(proxyURL)
|
||||
opts := defaultOptions
|
||||
opts := config.NewOptions()
|
||||
opts.SigningKey = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU0zbXBaSVdYQ1g5eUVneFU2czU3Q2J0YlVOREJTQ0VBdFFGNWZVV0hwY1FvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFaFBRditMQUNQVk5tQlRLMHhTVHpicEVQa1JyazFlVXQxQk9hMzJTRWZVUHpOaTRJV2VaLwpLS0lUdDJxMUlxcFYyS01TYlZEeXI5aWp2L1hoOThpeUV3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo="
|
||||
route, err := policy.FromConfig([]byte(`[{"from":"corp.example.com","to":"example.com","timeout":"1s"}]`))
|
||||
if err != nil {
|
||||
|
@ -112,22 +79,23 @@ func TestNewReverseProxyHandler(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func testOptions() *Options {
|
||||
func testOptions() *config.Options {
|
||||
authenticateService, _ := url.Parse("https://authenticate.corp.beyondperimeter.com")
|
||||
authorizeService, _ := url.Parse("https://authorize.corp.beyondperimeter.com")
|
||||
configBlob := `[{"from":"corp.example.com","to":"example.com"}]` //valid yaml
|
||||
configBlob := `[{"from":"corp.example.notatld","to":"example.notatld"}]` //valid yaml
|
||||
policy := base64.URLEncoding.EncodeToString([]byte(configBlob))
|
||||
return &Options{
|
||||
Policy: policy,
|
||||
AuthenticateURL: authenticateService,
|
||||
AuthorizeURL: authorizeService,
|
||||
SharedKey: "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ=",
|
||||
CookieSecret: "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw=",
|
||||
CookieName: "pomerium",
|
||||
}
|
||||
|
||||
opts := config.NewOptions()
|
||||
opts.Policy = policy
|
||||
opts.AuthenticateURL = authenticateService
|
||||
opts.AuthorizeURL = authorizeService
|
||||
opts.SharedKey = "80ldlrU2d7w+wVpKNfevk6fmb8otEx6CqOfshj2LwhQ="
|
||||
opts.CookieSecret = "OromP1gurwGWjQPYb1nNgSxtbVB5NnLzX6z5WOKr0Yw="
|
||||
opts.ProxyCookieName = "pomerium"
|
||||
return opts
|
||||
}
|
||||
|
||||
func testOptionsWithCORS() *Options {
|
||||
func testOptionsWithCORS() *config.Options {
|
||||
configBlob := `[{"from":"corp.example.com","to":"example.com","cors_allow_preflight":true}]` //valid yaml
|
||||
opts := testOptions()
|
||||
opts.Policy = base64.URLEncoding.EncodeToString([]byte(configBlob))
|
||||
|
@ -168,11 +136,11 @@ func TestOptions_Validate(t *testing.T) {
|
|||
|
||||
tests := []struct {
|
||||
name string
|
||||
o *Options
|
||||
o *config.Options
|
||||
wantErr bool
|
||||
}{
|
||||
{"good - minimum options", good, false},
|
||||
{"nil options", &Options{}, true},
|
||||
{"nil options", &config.Options{}, true},
|
||||
{"from route", badFromRoute, true},
|
||||
{"to route", badToRoute, true},
|
||||
{"authenticate service url", badAuthURL, true},
|
||||
|
@ -191,7 +159,7 @@ func TestOptions_Validate(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
o := tt.o
|
||||
if err := o.Validate(); (err != nil) != tt.wantErr {
|
||||
if err := ValidateOptions(o); (err != nil) != tt.wantErr {
|
||||
t.Errorf("Options.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
|
@ -207,13 +175,13 @@ func TestNew(t *testing.T) {
|
|||
badRoutedProxy.SigningKey = "YmFkIGtleQo="
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *Options
|
||||
opts *config.Options
|
||||
wantProxy bool
|
||||
numRoutes int
|
||||
wantErr bool
|
||||
}{
|
||||
{"good", good, true, 1, false},
|
||||
{"empty options", &Options{}, false, 0, true},
|
||||
{"empty options", &config.Options{}, false, 0, true},
|
||||
{"nil options", nil, false, 0, true},
|
||||
{"short secret/validate sanity check", shortCookieLength, false, 0, true},
|
||||
{"invalid ec key, valid base64 though", badRoutedProxy, false, 0, true},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue