diff --git a/CHANGELOG-3.4.md b/CHANGELOG-3.4.md index eca0f73be4e..4b9fbf169b1 100644 --- a/CHANGELOG-3.4.md +++ b/CHANGELOG-3.4.md @@ -48,6 +48,8 @@ See [code changes](https://github.com/coreos/etcd/compare/v3.3.0...v3.4.0) and [ - e.g. exit with error on `ETCD_INITIAL_CLUSTER_TOKEN=abc etcd --initial-cluster-token=def`. - e.g. exit with error on `ETCDCTL_ENDPOINTS=abc.com ETCDCTL_API=3 etcdctl endpoint health --endpoints=def.com`. - Change [`etcdserverpb.AuthRoleRevokePermissionRequest/key,range_end` fields type from `string` to `bytes`](https://github.com/coreos/etcd/pull/9433). +- Change [`embed.Config.CorsInfo` in `*cors.CORSInfo` type to `embed.Config.CORS` in `map[string]struct{}` type](https://github.com/coreos/etcd/pull/9490). +- Remove [`pkg/cors` package](https://github.com/coreos/etcd/pull/9490). - Move `"github.com/coreos/etcd/snap"` to [`"github.com/coreos/etcd/raftsnap"`](https://github.com/coreos/etcd/pull/9211). - Move `"github.com/coreos/etcd/etcdserver/auth"` to [`"github.com/coreos/etcd/etcdserver/v2auth"`](https://github.com/coreos/etcd/pull/9275). - Move `"github.com/coreos/etcd/error"` to [`"github.com/coreos/etcd/etcdserver/v2error"`](https://github.com/coreos/etcd/pull/9274). @@ -78,11 +80,11 @@ See [security doc](https://github.com/coreos/etcd/blob/master/Documentation/op-g - Client origin enforce policy works as follow: - If client connection is secure via HTTPS, allow any hostnames.. - If client connection is not secure and `"HostWhitelist"` is not empty, only allow HTTP requests whose Host field is listed in whitelist. - - By default, `"HostWhitelist"` is empty, which means insecure server allows all client HTTP requests. + - By default, `"HostWhitelist"` is `"*"`, which means insecure server allows all client HTTP requests. - Note that the client origin policy is enforced whether authentication is enabled or not, for tighter controls. - When specifying hostnames, loopback addresses are not added automatically. To allow loopback interfaces, add them to whitelist manually (e.g. `"localhost"`, `"127.0.0.1"`, etc.). - e.g. `etcd --host-whitelist example.com`, then the server will reject all HTTP requests whose Host field is not `example.com` (also rejects requests to `"localhost"`). -- TODO: Support `CORS`. +- Support [`etcd --cors`](https://github.com/coreos/etcd/pull/9490) in v3 HTTP requests (gRPC gateway). - TODO: Support [TLS cipher suite lists](TODO). - Support [`ttl` field for `etcd` Authentication JWT token](https://github.com/coreos/etcd/pull/8302). - e.g. `etcd --auth-token jwt,pub-key=,priv-key=,sign-method=,ttl=5m`. @@ -108,6 +110,7 @@ See [security doc](https://github.com/coreos/etcd/blob/master/Documentation/op-g - If not given, etcd queries `_etcd-server-ssl._tcp.[YOUR_HOST]` and `_etcd-server._tcp.[YOUR_HOST]`. - If `--discovery-srv-name="foo"`, then query `_etcd-server-ssl-foo._tcp.[YOUR_HOST]` and `_etcd-server-foo._tcp.[YOUR_HOST]`. - Useful for operating multiple etcd clusters under the same domain. +- Support [`etcd --cors`](https://github.com/coreos/etcd/pull/9490) in v3 HTTP requests (gRPC gateway). ### Added: `embed` @@ -140,6 +143,7 @@ See [security doc](https://github.com/coreos/etcd/blob/master/Documentation/op-g - To deprecate [`/v3beta`](https://github.com/coreos/etcd/issues/9189) in `v3.5`. - Add API endpoints [`/{v3beta,v3}/lease/leases, /{v3beta,v3}/lease/revoke, /{v3beta,v3}/lease/timetolive`](https://github.com/coreos/etcd/pull/9450). - To deprecate [`/{v3beta,v3}/kv/lease/leases, /{v3beta,v3}/kv/lease/revoke, /{v3beta,v3}/kv/lease/timetolive`](https://github.com/coreos/etcd/issues/9430) in `v3.5`. +- Support [`etcd --cors`](https://github.com/coreos/etcd/pull/9490) in v3 HTTP requests (gRPC gateway). ### Package `raft` diff --git a/embed/config.go b/embed/config.go index 013f3ecae2b..b654503c0ff 100644 --- a/embed/config.go +++ b/embed/config.go @@ -28,7 +28,7 @@ import ( "github.com/coreos/etcd/compactor" "github.com/coreos/etcd/etcdserver" - "github.com/coreos/etcd/pkg/cors" + "github.com/coreos/etcd/pkg/flags" "github.com/coreos/etcd/pkg/netutil" "github.com/coreos/etcd/pkg/srv" "github.com/coreos/etcd/pkg/transport" @@ -79,9 +79,8 @@ var ( DefaultInitialAdvertisePeerURLs = "http://localhost:2380" DefaultAdvertiseClientURLs = "http://localhost:2379" - defaultHostname string - defaultHostStatus error - defaultHostWhitelist = []string{} // if empty, allow all + defaultHostname string + defaultHostStatus error ) var ( @@ -107,7 +106,6 @@ func init() { // Config holds the arguments for configuring an etcd server. type Config struct { - CorsInfo *cors.CORSInfo LPUrls, LCUrls []url.URL Dir string `json:"data-dir"` WalDir string `json:"wal-dir"` @@ -171,6 +169,8 @@ type Config struct { PeerTLSInfo transport.TLSInfo PeerAutoTLS bool + CORS map[string]struct{} + // HostWhitelist lists acceptable hostnames from HTTP client requests. // Client origin policy protects against "DNS Rebinding" attacks // to insecure etcd servers. That is, any website can simply create @@ -186,16 +186,16 @@ type Config struct { // Note that the client origin policy is enforced whether authentication // is enabled or not, for tighter controls. // - // By default, "HostWhitelist" is empty, which allows any hostnames. + // By default, "HostWhitelist" is "*", which allows any hostnames. // Note that when specifying hostnames, loopback addresses are not added - // automatically. To allow loopback interfaces, leave it empty or add them - // to whitelist manually (e.g. "localhost", "127.0.0.1", etc.). + // automatically. To allow loopback interfaces, leave it empty or set it "*", + // or add them to whitelist manually (e.g. "localhost", "127.0.0.1", etc.). // // CVE-2018-5702 reference: // - https://bugs.chromium.org/p/project-zero/issues/detail?id=1447#c2 // - https://github.com/transmission/transmission/pull/468 // - https://github.com/coreos/etcd/issues/9353 - HostWhitelist []string `json:"host-whitelist"` + HostWhitelist map[string]struct{} Debug bool `json:"debug"` LogPkgLevels string `json:"log-package-levels"` @@ -237,11 +237,14 @@ type configYAML struct { // configJSON has file options that are translated into Config options type configJSON struct { - LPUrlsJSON string `json:"listen-peer-urls"` - LCUrlsJSON string `json:"listen-client-urls"` - CorsJSON string `json:"cors"` - APUrlsJSON string `json:"initial-advertise-peer-urls"` - ACUrlsJSON string `json:"advertise-client-urls"` + LPUrlsJSON string `json:"listen-peer-urls"` + LCUrlsJSON string `json:"listen-client-urls"` + APUrlsJSON string `json:"initial-advertise-peer-urls"` + ACUrlsJSON string `json:"advertise-client-urls"` + + CORSJSON string `json:"cors"` + HostWhitelistJSON string `json:"host-whitelist"` + ClientSecurityJSON securityConfig `json:"client-transport-security"` PeerSecurityJSON securityConfig `json:"peer-transport-security"` } @@ -261,7 +264,6 @@ func NewConfig() *Config { lcurl, _ := url.Parse(DefaultListenClientURLs) acurl, _ := url.Parse(DefaultAdvertiseClientURLs) cfg := &Config{ - CorsInfo: &cors.CORSInfo{}, MaxSnapFiles: DefaultMaxSnapshots, MaxWalFiles: DefaultMaxWALs, Name: DefaultName, @@ -283,7 +285,8 @@ func NewConfig() *Config { LogOutput: DefaultLogOutput, Metrics: "basic", EnableV2: DefaultEnableV2, - HostWhitelist: defaultHostWhitelist, + CORS: map[string]struct{}{"*": {}}, + HostWhitelist: map[string]struct{}{"*": {}}, AuthToken: "simple", PreVote: false, // TODO: enable by default in v3.5 } @@ -381,12 +384,6 @@ func (cfg *configYAML) configFromFile(path string) error { cfg.LCUrls = []url.URL(u) } - if cfg.CorsJSON != "" { - if err := cfg.CorsInfo.Set(cfg.CorsJSON); err != nil { - plog.Panicf("unexpected error setting up cors: %v", err) - } - } - if cfg.APUrlsJSON != "" { u, err := types.NewURLs(strings.Split(cfg.APUrlsJSON, ",")) if err != nil { @@ -411,6 +408,16 @@ func (cfg *configYAML) configFromFile(path string) error { cfg.ListenMetricsUrls = []url.URL(u) } + if cfg.CORSJSON != "" { + uv := flags.NewUniqueURLsWithExceptions(cfg.CORSJSON, "*") + cfg.CORS = uv.Values + } + + if cfg.HostWhitelistJSON != "" { + uv := flags.NewUniqueStringsValue(cfg.HostWhitelistJSON) + cfg.HostWhitelist = uv.Values + } + // If a discovery flag is set, clear default initial cluster set by InitialClusterFromName if (cfg.Durl != "" || cfg.DNSCluster != "") && cfg.InitialCluster == defaultInitialCluster { cfg.InitialCluster = "" diff --git a/embed/etcd.go b/embed/etcd.go index 116c4c7c19e..04e743deda5 100644 --- a/embed/etcd.go +++ b/embed/etcd.go @@ -23,6 +23,7 @@ import ( "net" "net/http" "net/url" + "sort" "strconv" "sync" "time" @@ -33,7 +34,6 @@ import ( "github.com/coreos/etcd/etcdserver/api/v2v3" "github.com/coreos/etcd/etcdserver/api/v3client" "github.com/coreos/etcd/etcdserver/api/v3rpc" - "github.com/coreos/etcd/pkg/cors" "github.com/coreos/etcd/pkg/debugutil" runtimeutil "github.com/coreos/etcd/pkg/runtime" "github.com/coreos/etcd/pkg/transport" @@ -168,6 +168,8 @@ func StartEtcd(inCfg *Config) (e *Etcd, err error) { StrictReconfigCheck: cfg.StrictReconfigCheck, ClientCertAuthEnabled: cfg.ClientTLSInfo.ClientCertAuth, AuthToken: cfg.AuthToken, + CORS: cfg.CORS, + HostWhitelist: cfg.HostWhitelist, InitialCorruptCheck: cfg.ExperimentalInitialCorruptCheck, CorruptCheckTime: cfg.ExperimentalCorruptCheckTime, PreVote: cfg.PreVote, @@ -175,17 +177,26 @@ func StartEtcd(inCfg *Config) (e *Etcd, err error) { ForceNewCluster: cfg.ForceNewCluster, } - srvcfg.HostWhitelist = make(map[string]struct{}, len(cfg.HostWhitelist)) - for _, h := range cfg.HostWhitelist { - if h != "" { - srvcfg.HostWhitelist[h] = struct{}{} - } - } - if e.Server, err = etcdserver.NewServer(srvcfg); err != nil { return e, err } - plog.Infof("%s starting with host whitelist %q", e.Server.ID(), cfg.HostWhitelist) + + if len(e.cfg.CORS) > 0 { + ss := make([]string, 0, len(e.cfg.CORS)) + for v := range e.cfg.CORS { + ss = append(ss, v) + } + sort.Strings(ss) + plog.Infof("%s starting with cors %q", e.Server.ID(), ss) + } + if len(e.cfg.HostWhitelist) > 0 { + ss := make([]string, 0, len(e.cfg.HostWhitelist)) + for v := range e.cfg.HostWhitelist { + ss = append(ss, v) + } + sort.Strings(ss) + plog.Infof("%s starting with host whitelist %q", e.Server.ID(), ss) + } // buffer channel so goroutines on closed connections won't wait forever e.errc = make(chan error, len(e.Peers)+len(e.Clients)+2*len(e.sctxs)) @@ -479,10 +490,6 @@ func (e *Etcd) serveClients() (err error) { plog.Infof("ClientTLS: %s", e.cfg.ClientTLSInfo) } - if e.cfg.CorsInfo.String() != "" { - plog.Infof("cors = %s", e.cfg.CorsInfo) - } - // Start a client server goroutine for each listen address var h http.Handler if e.Config().EnableV2 { @@ -497,7 +504,6 @@ func (e *Etcd) serveClients() (err error) { etcdhttp.HandleBasic(mux, e.Server) h = mux } - h = http.Handler(&cors.CORSHandler{Handler: h, Info: e.cfg.CorsInfo}) gopts := []grpc.ServerOption{} if e.cfg.GRPCKeepAliveMinTime > time.Duration(0) { diff --git a/embed/serve.go b/embed/serve.go index 72f162dc165..5f78719a09e 100644 --- a/embed/serve.go +++ b/embed/serve.go @@ -116,7 +116,7 @@ func (sctx *serveCtx) serve( httpmux := sctx.createMux(gwmux, handler) srvhttp := &http.Server{ - Handler: wrapMux(s, httpmux), + Handler: createAccessController(s, httpmux), ErrorLog: logger, // do not log user error } httpl := m.Match(cmux.HTTP1()) @@ -159,7 +159,7 @@ func (sctx *serveCtx) serve( httpmux := sctx.createMux(gwmux, handler) srv := &http.Server{ - Handler: wrapMux(s, httpmux), + Handler: createAccessController(s, httpmux), TLSConfig: tlscfg, ErrorLog: logger, // do not log user error } @@ -250,20 +250,20 @@ func (sctx *serveCtx) createMux(gwmux *gw.ServeMux, handler http.Handler) *http. return httpmux } -// wrapMux wraps HTTP multiplexer: +// createAccessController wraps HTTP multiplexer: // - mutate gRPC gateway request paths // - check hostname whitelist // client HTTP requests goes here first -func wrapMux(s *etcdserver.EtcdServer, mux *http.ServeMux) http.Handler { - return &httpWrapper{s: s, mux: mux} +func createAccessController(s *etcdserver.EtcdServer, mux *http.ServeMux) http.Handler { + return &accessController{s: s, mux: mux} } -type httpWrapper struct { +type accessController struct { s *etcdserver.EtcdServer mux *http.ServeMux } -func (m *httpWrapper) ServeHTTP(rw http.ResponseWriter, req *http.Request) { +func (ac *accessController) ServeHTTP(rw http.ResponseWriter, req *http.Request) { // redirect for backward compatibilities if req != nil && req.URL != nil && strings.HasPrefix(req.URL.Path, "/v3beta/") { req.URL.Path = strings.Replace(req.URL.Path, "/v3beta/", "/v3/", 1) @@ -271,7 +271,7 @@ func (m *httpWrapper) ServeHTTP(rw http.ResponseWriter, req *http.Request) { if req.TLS == nil { // check origin if client connection is not secure host := httputil.GetHostname(req) - if !m.s.IsHostWhitelisted(host) { + if !ac.s.AccessController.IsHostWhitelisted(host) { plog.Warningf("rejecting HTTP request from %q to prevent DNS rebinding attacks", host) // TODO: use Go's "http.StatusMisdirectedRequest" (421) // https://github.com/golang/go/commit/4b8a7eafef039af1834ef9bfa879257c4a72b7b5 @@ -280,7 +280,26 @@ func (m *httpWrapper) ServeHTTP(rw http.ResponseWriter, req *http.Request) { } } - m.mux.ServeHTTP(rw, req) + // Write CORS header. + if ac.s.AccessController.OriginAllowed("*") { + addCORSHeader(rw, "*") + } else if origin := req.Header.Get("Origin"); ac.s.OriginAllowed(origin) { + addCORSHeader(rw, origin) + } + + if req.Method == "OPTIONS" { + rw.WriteHeader(http.StatusOK) + return + } + + ac.mux.ServeHTTP(rw, req) +} + +// addCORSHeader adds the correct cors headers given an origin +func addCORSHeader(w http.ResponseWriter, origin string) { + w.Header().Add("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + w.Header().Add("Access-Control-Allow-Origin", origin) + w.Header().Add("Access-Control-Allow-Headers", "accept, content-type, authorization") } // https://github.com/transmission/transmission/pull/468 @@ -297,6 +316,35 @@ This requirement has been added to help prevent "DNS Rebinding" attacks (CVE-201 `, host) } +// WrapCORS wraps existing handler with CORS. +// TODO: deprecate this after v2 proxy deprecate +func WrapCORS(cors map[string]struct{}, h http.Handler) http.Handler { + return &corsHandler{ + ac: &etcdserver.AccessController{CORS: cors}, + h: h, + } +} + +type corsHandler struct { + ac *etcdserver.AccessController + h http.Handler +} + +func (ch *corsHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if ch.ac.OriginAllowed("*") { + addCORSHeader(rw, "*") + } else if origin := req.Header.Get("Origin"); ch.ac.OriginAllowed(origin) { + addCORSHeader(rw, origin) + } + + if req.Method == "OPTIONS" { + rw.WriteHeader(http.StatusOK) + return + } + + ch.h.ServeHTTP(rw, req) +} + func (sctx *serveCtx) registerUserHandler(s string, h http.Handler) { if sctx.userHandlers[s] != nil { plog.Warningf("path %s already registered by user handler", s) diff --git a/etcdmain/config.go b/etcdmain/config.go index 6a74c473734..9bb3b83581c 100644 --- a/etcdmain/config.go +++ b/etcdmain/config.go @@ -128,12 +128,22 @@ func newConfig() *config { fs.StringVar(&cfg.configFile, "config-file", "", "Path to the server configuration file") // member - fs.Var(cfg.ec.CorsInfo, "cors", "Comma-separated white list of origins for CORS (cross-origin resource sharing).") fs.StringVar(&cfg.ec.Dir, "data-dir", cfg.ec.Dir, "Path to the data directory.") fs.StringVar(&cfg.ec.WalDir, "wal-dir", cfg.ec.WalDir, "Path to the dedicated wal directory.") - fs.Var(flags.NewURLsValue(embed.DefaultListenPeerURLs), "listen-peer-urls", "List of URLs to listen on for peer traffic.") - fs.Var(flags.NewURLsValue(embed.DefaultListenClientURLs), "listen-client-urls", "List of URLs to listen on for client traffic.") - fs.Var(flags.NewURLsValue(""), "listen-metrics-urls", "List of URLs to listen on for metrics.") + fs.Var( + flags.NewUniqueURLsWithExceptions(embed.DefaultListenPeerURLs, ""), + "listen-peer-urls", + "List of URLs to listen on for peer traffic.", + ) + fs.Var( + flags.NewUniqueURLsWithExceptions(embed.DefaultListenClientURLs, ""), "listen-client-urls", + "List of URLs to listen on for client traffic.", + ) + fs.Var( + flags.NewUniqueURLsWithExceptions("", ""), + "listen-metrics-urls", + "List of URLs to listen on for metrics.", + ) fs.UintVar(&cfg.ec.MaxSnapFiles, "max-snapshots", cfg.ec.MaxSnapFiles, "Maximum number of snapshot files to retain (0 is unlimited).") fs.UintVar(&cfg.ec.MaxWalFiles, "max-wals", cfg.ec.MaxWalFiles, "Maximum number of wal files to retain (0 is unlimited).") fs.StringVar(&cfg.ec.Name, "name", cfg.ec.Name, "Human-readable name for this member.") @@ -148,8 +158,16 @@ func newConfig() *config { fs.DurationVar(&cfg.ec.GRPCKeepAliveTimeout, "grpc-keepalive-timeout", cfg.ec.GRPCKeepAliveTimeout, "Additional duration of wait before closing a non-responsive connection (0 to disable).") // clustering - fs.Var(flags.NewURLsValue(embed.DefaultInitialAdvertisePeerURLs), "initial-advertise-peer-urls", "List of this member's peer URLs to advertise to the rest of the cluster.") - fs.Var(flags.NewURLsValue(embed.DefaultAdvertiseClientURLs), "advertise-client-urls", "List of this member's client URLs to advertise to the public.") + fs.Var( + flags.NewUniqueURLsWithExceptions(embed.DefaultInitialAdvertisePeerURLs, ""), + "initial-advertise-peer-urls", + "List of this member's peer URLs to advertise to the rest of the cluster.", + ) + fs.Var( + flags.NewUniqueURLsWithExceptions(embed.DefaultAdvertiseClientURLs, ""), + "advertise-client-urls", + "List of this member's client URLs to advertise to the public.", + ) fs.StringVar(&cfg.ec.Durl, "discovery", cfg.ec.Durl, "Discovery URL used to bootstrap the cluster.") fs.Var(cfg.cf.fallback, "discovery-fallback", fmt.Sprintf("Valid values include %q", cfg.cf.fallback.Valids())) @@ -186,7 +204,13 @@ func newConfig() *config { fs.BoolVar(&cfg.ec.PeerAutoTLS, "peer-auto-tls", false, "Peer TLS using generated certificates") fs.StringVar(&cfg.ec.PeerTLSInfo.CRLFile, "peer-crl-file", "", "Path to the peer certificate revocation list file.") fs.StringVar(&cfg.ec.PeerTLSInfo.AllowedCN, "peer-cert-allowed-cn", "", "Allowed CN for inter peer authentication.") - fs.Var(flags.NewStringsValue(""), "host-whitelist", "Comma-separated acceptable hostnames from HTTP client requests, if server is not secure (empty means allow all).") + + fs.Var( + flags.NewUniqueURLsWithExceptions("*", "*"), + "cors", + "Comma-separated white list of origins for CORS, or cross-origin resource sharing, (empty or * means allow all)", + ) + fs.Var(flags.NewUniqueStringsValue("*"), "host-whitelist", "Comma-separated acceptable hostnames from HTTP client requests, if server is not secure (empty means allow all).") // logging fs.BoolVar(&cfg.ec.Debug, "debug", false, "Enable debug-level logging for etcd.") @@ -261,12 +285,14 @@ func (cfg *config) configFromCmdLine() error { plog.Fatalf("%v", err) } - cfg.ec.LPUrls = flags.URLsFromFlag(cfg.cf.flagSet, "listen-peer-urls") - cfg.ec.APUrls = flags.URLsFromFlag(cfg.cf.flagSet, "initial-advertise-peer-urls") - cfg.ec.LCUrls = flags.URLsFromFlag(cfg.cf.flagSet, "listen-client-urls") - cfg.ec.ACUrls = flags.URLsFromFlag(cfg.cf.flagSet, "advertise-client-urls") - cfg.ec.HostWhitelist = flags.StringsFromFlag(cfg.cf.flagSet, "host-whitelist") - cfg.ec.ListenMetricsUrls = flags.URLsFromFlag(cfg.cf.flagSet, "listen-metrics-urls") + cfg.ec.LPUrls = flags.UniqueURLsFromFlag(cfg.cf.flagSet, "listen-peer-urls") + cfg.ec.APUrls = flags.UniqueURLsFromFlag(cfg.cf.flagSet, "initial-advertise-peer-urls") + cfg.ec.LCUrls = flags.UniqueURLsFromFlag(cfg.cf.flagSet, "listen-client-urls") + cfg.ec.ACUrls = flags.UniqueURLsFromFlag(cfg.cf.flagSet, "advertise-client-urls") + cfg.ec.ListenMetricsUrls = flags.UniqueURLsFromFlag(cfg.cf.flagSet, "listen-metrics-urls") + + cfg.ec.CORS = flags.UniqueURLsMapFromFlag(cfg.cf.flagSet, "cors") + cfg.ec.HostWhitelist = flags.UniqueStringsMapFromFlag(cfg.cf.flagSet, "host-whitelist") cfg.ec.ClusterState = cfg.cf.clusterState.String() cfg.cp.Fallback = cfg.cf.fallback.String() diff --git a/etcdmain/config_test.go b/etcdmain/config_test.go index 6ba7cd9573f..ef202a417fb 100644 --- a/etcdmain/config_test.go +++ b/etcdmain/config_test.go @@ -567,10 +567,10 @@ func validateClusteringFlags(t *testing.T, cfg *config) { t.Errorf("initialClusterToken = %v, want %v", cfg.ec.InitialClusterToken, wcfg.ec.InitialClusterToken) } if !reflect.DeepEqual(cfg.ec.APUrls, wcfg.ec.APUrls) { - t.Errorf("initial-advertise-peer-urls = %v, want %v", cfg.ec.LPUrls, wcfg.ec.LPUrls) + t.Errorf("initial-advertise-peer-urls = %v, want %v", cfg.ec.APUrls, wcfg.ec.APUrls) } if !reflect.DeepEqual(cfg.ec.ACUrls, wcfg.ec.ACUrls) { - t.Errorf("advertise-client-urls = %v, want %v", cfg.ec.LCUrls, wcfg.ec.LCUrls) + t.Errorf("advertise-client-urls = %v, want %v", cfg.ec.ACUrls, wcfg.ec.ACUrls) } } diff --git a/etcdmain/etcd.go b/etcdmain/etcd.go index 87e9b25bb0f..5697f9bb534 100644 --- a/etcdmain/etcd.go +++ b/etcdmain/etcd.go @@ -30,7 +30,6 @@ import ( "github.com/coreos/etcd/embed" "github.com/coreos/etcd/etcdserver" "github.com/coreos/etcd/etcdserver/api/etcdhttp" - "github.com/coreos/etcd/pkg/cors" "github.com/coreos/etcd/pkg/fileutil" pkgioutil "github.com/coreos/etcd/pkg/ioutil" "github.com/coreos/etcd/pkg/osutil" @@ -301,10 +300,7 @@ func startProxy(cfg *config) error { return clientURLs } ph := httpproxy.NewHandler(pt, uf, time.Duration(cfg.cp.ProxyFailureWaitMs)*time.Millisecond, time.Duration(cfg.cp.ProxyRefreshIntervalMs)*time.Millisecond) - ph = &cors.CORSHandler{ - Handler: ph, - Info: cfg.ec.CorsInfo, - } + ph = embed.WrapCORS(cfg.ec.CORS, ph) if cfg.isReadonlyProxy() { ph = httpproxy.NewReadonlyHandler(ph) diff --git a/etcdmain/help.go b/etcdmain/help.go index 88c5870fe23..72556ddc2a2 100644 --- a/etcdmain/help.go +++ b/etcdmain/help.go @@ -21,177 +21,172 @@ import ( ) var ( - usageline = `usage: etcd [flags] - start an etcd server + usageline = `Usage: - etcd --version - show the version of etcd + etcd [flags] + Start an etcd server. - etcd -h | --help - show the help information about etcd + etcd --version + Show the version of etcd. - etcd --config-file - path to the server configuration file + etcd -h | --help + Show the help information about etcd. - etcd gateway - run the stateless pass-through etcd TCP connection forwarding proxy + etcd --config-file + Path to the server configuration file. - etcd grpc-proxy - run the stateless etcd v3 gRPC L7 reverse proxy - ` + etcd gateway + Run the stateless pass-through etcd TCP connection forwarding proxy. + + etcd grpc-proxy + Run the stateless etcd v3 gRPC L7 reverse proxy. +` flagsline = ` -member flags: - - --name 'default' - human-readable name for this member. - --data-dir '${name}.etcd' - path to the data directory. - --wal-dir '' - path to the dedicated wal directory. - --snapshot-count '100000' - number of committed transactions to trigger a snapshot to disk. - --heartbeat-interval '100' - time (in milliseconds) of a heartbeat interval. - --election-timeout '1000' - time (in milliseconds) for an election to timeout. See tuning documentation for details. - --listen-peer-urls 'http://localhost:2380' - list of URLs to listen on for peer traffic. - --listen-client-urls 'http://localhost:2379' - list of URLs to listen on for client traffic. - --max-snapshots '` + strconv.Itoa(embed.DefaultMaxSnapshots) + `' - maximum number of snapshot files to retain (0 is unlimited). - --max-wals '` + strconv.Itoa(embed.DefaultMaxWALs) + `' - maximum number of wal files to retain (0 is unlimited). - --cors '' - comma-separated whitelist of origins for CORS (cross-origin resource sharing). - --quota-backend-bytes '0' - raise alarms when backend size exceeds the given quota (0 defaults to low space quota). - --max-txn-ops '128' - maximum number of operations permitted in a transaction. - --max-request-bytes '1572864' - maximum client request size in bytes the server will accept. - --grpc-keepalive-min-time '5s' - minimum duration interval that a client should wait before pinging server. - --grpc-keepalive-interval '2h' - frequency duration of server-to-client ping to check if a connection is alive (0 to disable). - --grpc-keepalive-timeout '20s' - additional duration of wait before closing a non-responsive connection (0 to disable). - -clustering flags: - - --initial-advertise-peer-urls 'http://localhost:2380' - list of this member's peer URLs to advertise to the rest of the cluster. - --initial-cluster 'default=http://localhost:2380' - initial cluster configuration for bootstrapping. - --initial-cluster-state 'new' - initial cluster state ('new' or 'existing'). - --initial-cluster-token 'etcd-cluster' - initial cluster token for the etcd cluster during bootstrap. - Specifying this can protect you from unintended cross-cluster interaction when running multiple clusters. - --advertise-client-urls 'http://localhost:2379' - list of this member's client URLs to advertise to the public. - The client URLs advertised should be accessible to machines that talk to etcd cluster. etcd client libraries parse these URLs to connect to the cluster. - --discovery '' - discovery URL used to bootstrap the cluster. - --discovery-fallback 'proxy' - expected behavior ('exit' or 'proxy') when discovery services fails. - "proxy" supports v2 API only. - --discovery-proxy '' - HTTP proxy to use for traffic to discovery service. - --discovery-srv '' - dns srv domain used to bootstrap the cluster. - --discovery-srv-name '' - suffix to the dns srv name queried when bootstrapping. - --strict-reconfig-check '` + strconv.FormatBool(embed.DefaultStrictReconfigCheck) + `' - reject reconfiguration requests that would cause quorum loss. - --pre-vote 'false' - enable to run an additional Raft election phase. - --auto-compaction-retention '0' - auto compaction retention length. 0 means disable auto compaction. - --auto-compaction-mode 'periodic' - interpret 'auto-compaction-retention' one of: periodic|revision. 'periodic' for duration based retention, defaulting to hours if no time unit is provided (e.g. '5m'). 'revision' for revision number based retention. - --enable-v2 '` + strconv.FormatBool(embed.DefaultEnableV2) + `' - Accept etcd V2 client requests. - -proxy flags (v2 API only): - - --proxy 'off' - proxy mode setting ('off', 'readonly' or 'on'). - --proxy-failure-wait 5000 - time (in milliseconds) an endpoint will be held in a failed state. - --proxy-refresh-interval 30000 - time (in milliseconds) of the endpoints refresh interval. - --proxy-dial-timeout 1000 - time (in milliseconds) for a dial to timeout. - --proxy-write-timeout 5000 - time (in milliseconds) for a write to timeout. - --proxy-read-timeout 0 - time (in milliseconds) for a read to timeout. - -security flags: - - --cert-file '' - path to the client server TLS cert file. - --key-file '' - path to the client server TLS key file. - --client-cert-auth 'false' - enable client cert authentication. - --client-crl-file '' - path to the client certificate revocation list file. - --trusted-ca-file '' - path to the client server TLS trusted CA cert file. - --auto-tls 'false' - client TLS using generated certificates. - --peer-cert-file '' - path to the peer server TLS cert file. - --peer-key-file '' - path to the peer server TLS key file. - --peer-client-cert-auth 'false' - enable peer client cert authentication. - --peer-trusted-ca-file '' - path to the peer server TLS trusted CA file. - --peer-auto-tls 'false' - peer TLS using self-generated certificates if --peer-key-file and --peer-cert-file are not provided. - --peer-crl-file '' - path to the peer certificate revocation list file. - --host-whitelist '' - acceptable hostnames from HTTP client requests, if server is not secure (empty means allow all). - -logging flags - - --debug 'false' - enable debug-level logging for etcd. - --log-package-levels '' - specify a particular log level for each etcd package (eg: 'etcdmain=CRITICAL,etcdserver=DEBUG'). - --log-output 'default' - specify 'stdout' or 'stderr' to skip journald logging even when running under systemd. - -profiling flags: - --enable-pprof 'false' - Enable runtime profiling data via HTTP server. Address is at client URL + "/debug/pprof/" - --metrics 'basic' - Set level of detail for exported metrics, specify 'extensive' to include histogram metrics. - --listen-metrics-urls '' - List of URLs to listen on for metrics. - -auth flags: - --auth-token 'simple' - Specify a v3 authentication token type and its options ('simple' or 'jwt'). - -experimental flags: - --experimental-initial-corrupt-check 'false' - enable to check data corruption before serving any client/peer traffic. - --experimental-corrupt-check-time '0s' - duration of time between cluster corruption check passes. - --experimental-enable-v2v3 '' - serve v2 requests through the v3 backend under a given prefix. - - -Please be CAUTIOUS when using unsafe flags because it will break the guarantees -given by the consensus protocol. - -unsafe flags: - --force-new-cluster 'false' - force to create a new one-member cluster. +Member: + --name 'default' + Human-readable name for this member. + --data-dir '${name}.etcd' + Path to the data directory. + --wal-dir '' + Path to the dedicated wal directory. + --snapshot-count '100000' + Number of committed transactions to trigger a snapshot to disk. + --heartbeat-interval '100' + Time (in milliseconds) of a heartbeat interval. + --election-timeout '1000' + Time (in milliseconds) for an election to timeout. See tuning documentation for details. + --listen-peer-urls 'http://localhost:2380' + List of URLs to listen on for peer traffic. + --listen-client-urls 'http://localhost:2379' + List of URLs to listen on for client traffic. + --max-snapshots '` + strconv.Itoa(embed.DefaultMaxSnapshots) + `' + Maximum number of snapshot files to retain (0 is unlimited). + --max-wals '` + strconv.Itoa(embed.DefaultMaxWALs) + `' + Maximum number of wal files to retain (0 is unlimited). + --quota-backend-bytes '0' + Raise alarms when backend size exceeds the given quota (0 defaults to low space quota). + --max-txn-ops '128' + Maximum number of operations permitted in a transaction. + --max-request-bytes '1572864' + Maximum client request size in bytes the server will accept. + --grpc-keepalive-min-time '5s' + Minimum duration interval that a client should wait before pinging server. + --grpc-keepalive-interval '2h' + Frequency duration of server-to-client ping to check if a connection is alive (0 to disable). + --grpc-keepalive-timeout '20s' + Additional duration of wait before closing a non-responsive connection (0 to disable). + +Clustering: + --initial-advertise-peer-urls 'http://localhost:2380' + List of this member's peer URLs to advertise to the rest of the cluster. + --initial-cluster 'default=http://localhost:2380' + Initial cluster configuration for bootstrapping. + --initial-cluster-state 'new' + Initial cluster state ('new' or 'existing'). + --initial-cluster-token 'etcd-cluster' + Initial cluster token for the etcd cluster during bootstrap. + Specifying this can protect you from unintended cross-cluster interaction when running multiple clusters. + --advertise-client-urls 'http://localhost:2379' + List of this member's client URLs to advertise to the public. + The client URLs advertised should be accessible to machines that talk to etcd cluster. etcd client libraries parse these URLs to connect to the cluster. + --discovery '' + Discovery URL used to bootstrap the cluster. + --discovery-fallback 'proxy' + Expected behavior ('exit' or 'proxy') when discovery services fails. + "proxy" supports v2 API only. + --discovery-proxy '' + HTTP proxy to use for traffic to discovery service. + --discovery-srv '' + DNS srv domain used to bootstrap the cluster. + --discovery-srv-name '' + Suffix to the dns srv name queried when bootstrapping. + --strict-reconfig-check '` + strconv.FormatBool(embed.DefaultStrictReconfigCheck) + `' + Reject reconfiguration requests that would cause quorum loss. + --pre-vote 'false' + Enable to run an additional Raft election phase. + --auto-compaction-retention '0' + Auto compaction retention length. 0 means disable auto compaction. + --auto-compaction-mode 'periodic' + Interpret 'auto-compaction-retention' one of: periodic|revision. 'periodic' for duration based retention, defaulting to hours if no time unit is provided (e.g. '5m'). 'revision' for revision number based retention. + --enable-v2 '` + strconv.FormatBool(embed.DefaultEnableV2) + `' + Accept etcd V2 client requests. + +Security: + --cert-file '' + Path to the client server TLS cert file. + --key-file '' + Path to the client server TLS key file. + --client-cert-auth 'false' + Enable client cert authentication. + --client-crl-file '' + Path to the client certificate revocation list file. + --trusted-ca-file '' + Path to the client server TLS trusted CA cert file. + --auto-tls 'false' + Client TLS using generated certificates. + --peer-cert-file '' + Path to the peer server TLS cert file. + --peer-key-file '' + Path to the peer server TLS key file. + --peer-client-cert-auth 'false' + Enable peer client cert authentication. + --peer-trusted-ca-file '' + Path to the peer server TLS trusted CA file. + --peer-auto-tls 'false' + Peer TLS using self-generated certificates if --peer-key-file and --peer-cert-file are not provided. + --peer-crl-file '' + Path to the peer certificate revocation list file. + --cors '*' + Comma-separated whitelist of origins for CORS, or cross-origin resource sharing, (empty or * means allow all). + --host-whitelist '*' + Acceptable hostnames from HTTP client requests, if server is not secure (empty or * means allow all). + +Auth: + --auth-token 'simple' + Specify a v3 authentication token type and its options ('simple' or 'jwt'). + +Profiling: + --enable-pprof 'false' + Enable runtime profiling data via HTTP server. Address is at client URL + "/debug/pprof/" + --metrics 'basic' + Set level of detail for exported metrics, specify 'extensive' to include histogram metrics. + --listen-metrics-urls '' + List of URLs to listen on for metrics. + +Logging: + --debug 'false' + Enable debug-level logging for etcd. + --log-package-levels '' + Specify a particular log level for each etcd package (eg: 'etcdmain=CRITICAL,etcdserver=DEBUG'). + --log-output 'default' + Specify 'stdout' or 'stderr' to skip journald logging even when running under systemd. + +v2 Proxy (to be deprecated in v4): + --proxy 'off' + Proxy mode setting ('off', 'readonly' or 'on'). + --proxy-failure-wait 5000 + Time (in milliseconds) an endpoint will be held in a failed state. + --proxy-refresh-interval 30000 + Time (in milliseconds) of the endpoints refresh interval. + --proxy-dial-timeout 1000 + Time (in milliseconds) for a dial to timeout. + --proxy-write-timeout 5000 + Time (in milliseconds) for a write to timeout. + --proxy-read-timeout 0 + Time (in milliseconds) for a read to timeout. + +Experimental feature: + --experimental-initial-corrupt-check 'false' + Enable to check data corruption before serving any client/peer traffic. + --experimental-corrupt-check-time '0s' + Duration of time between cluster corruption check passes. + --experimental-enable-v2v3 '' + Serve v2 requests through the v3 backend under a given prefix. + +Unsafe feature: + --force-new-cluster 'false' + Force to create a new one-member cluster. + +CAUTIOUS with unsafe flag! It may break the guarantees given by the consensus protocol! ` ) diff --git a/etcdserver/config.go b/etcdserver/config.go index dedd6b48470..70dbf944bba 100644 --- a/etcdserver/config.go +++ b/etcdserver/config.go @@ -46,6 +46,8 @@ type ServerConfig struct { NewCluster bool PeerTLSInfo transport.TLSInfo + CORS map[string]struct{} + // HostWhitelist lists acceptable hostnames from client requests. // If server is insecure (no TLS), server only accepts requests // whose Host header value exists in this white list. diff --git a/etcdserver/server.go b/etcdserver/server.go index 5a27467260e..4307eddac3e 100644 --- a/etcdserver/server.go +++ b/etcdserver/server.go @@ -253,7 +253,7 @@ type EtcdServer struct { leadTimeMu sync.RWMutex leadElectedTime time.Time - hostWhitelist map[string]struct{} + *AccessController } // NewServer creates a new EtcdServer from the supplied configuration. The @@ -434,16 +434,16 @@ func NewServer(cfg ServerConfig) (srv *EtcdServer, err error) { storage: NewStorage(w, ss), }, ), - id: id, - attributes: membership.Attributes{Name: cfg.Name, ClientURLs: cfg.ClientURLs.StringSlice()}, - cluster: cl, - stats: sstats, - lstats: lstats, - SyncTicker: time.NewTicker(500 * time.Millisecond), - peerRt: prt, - reqIDGen: idutil.NewGenerator(uint16(id), time.Now()), - forceVersionC: make(chan struct{}), - hostWhitelist: cfg.HostWhitelist, + id: id, + attributes: membership.Attributes{Name: cfg.Name, ClientURLs: cfg.ClientURLs.StringSlice()}, + cluster: cl, + stats: sstats, + lstats: lstats, + SyncTicker: time.NewTicker(500 * time.Millisecond), + peerRt: prt, + reqIDGen: idutil.NewGenerator(uint16(id), time.Now()), + forceVersionC: make(chan struct{}), + AccessController: &AccessController{CORS: cfg.CORS, HostWhitelist: cfg.HostWhitelist}, } srv.applyV2 = &applierV2store{store: srv.v2store, cluster: srv.cluster} @@ -673,16 +673,6 @@ func (s *EtcdServer) ReportSnapshot(id uint64, status raft.SnapshotStatus) { s.r.ReportSnapshot(id, status) } -// IsHostWhitelisted returns true if the host is whitelisted. -// If whitelist is empty, allow all. -func (s *EtcdServer) IsHostWhitelisted(host string) bool { - if len(s.hostWhitelist) == 0 { // allow all - return true - } - _, ok := s.hostWhitelist[host] - return ok -} - type etcdProgress struct { confState raftpb.ConfState snapi uint64 diff --git a/etcdserver/server_access_control.go b/etcdserver/server_access_control.go new file mode 100644 index 00000000000..09e2255ccca --- /dev/null +++ b/etcdserver/server_access_control.go @@ -0,0 +1,65 @@ +// Copyright 2018 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package etcdserver + +import "sync" + +// AccessController controls etcd server HTTP request access. +type AccessController struct { + corsMu sync.RWMutex + CORS map[string]struct{} + hostWhitelistMu sync.RWMutex + HostWhitelist map[string]struct{} +} + +// NewAccessController returns a new "AccessController" with default "*" values. +func NewAccessController() *AccessController { + return &AccessController{ + CORS: map[string]struct{}{"*": {}}, + HostWhitelist: map[string]struct{}{"*": {}}, + } +} + +// OriginAllowed determines whether the server will allow a given CORS origin. +// If CORS is empty, allow all. +func (ac *AccessController) OriginAllowed(origin string) bool { + ac.corsMu.RLock() + defer ac.corsMu.RUnlock() + if len(ac.CORS) == 0 { // allow all + return true + } + _, ok := ac.CORS["*"] + if ok { + return true + } + _, ok = ac.CORS[origin] + return ok +} + +// IsHostWhitelisted returns true if the host is whitelisted. +// If whitelist is empty, allow all. +func (ac *AccessController) IsHostWhitelisted(host string) bool { + ac.hostWhitelistMu.RLock() + defer ac.hostWhitelistMu.RUnlock() + if len(ac.HostWhitelist) == 0 { // allow all + return true + } + _, ok := ac.HostWhitelist["*"] + if ok { + return true + } + _, ok = ac.HostWhitelist[host] + return ok +} diff --git a/pkg/cors/cors.go b/pkg/cors/cors.go deleted file mode 100644 index 0c64f16a390..00000000000 --- a/pkg/cors/cors.go +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright 2015 The etcd Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Package cors handles cross-origin HTTP requests (CORS). -package cors - -import ( - "fmt" - "net/http" - "net/url" - "sort" - "strings" -) - -type CORSInfo map[string]bool - -// Set implements the flag.Value interface to allow users to define a list of CORS origins -func (ci *CORSInfo) Set(s string) error { - m := make(map[string]bool) - for _, v := range strings.Split(s, ",") { - v = strings.TrimSpace(v) - if v == "" { - continue - } - if v != "*" { - if _, err := url.Parse(v); err != nil { - return fmt.Errorf("Invalid CORS origin: %s", err) - } - } - m[v] = true - - } - *ci = CORSInfo(m) - return nil -} - -func (ci *CORSInfo) String() string { - o := make([]string, 0) - for k := range *ci { - o = append(o, k) - } - sort.StringSlice(o).Sort() - return strings.Join(o, ",") -} - -// OriginAllowed determines whether the server will allow a given CORS origin. -func (c CORSInfo) OriginAllowed(origin string) bool { - return c["*"] || c[origin] -} - -type CORSHandler struct { - Handler http.Handler - Info *CORSInfo -} - -// addHeader adds the correct cors headers given an origin -func (h *CORSHandler) addHeader(w http.ResponseWriter, origin string) { - w.Header().Add("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") - w.Header().Add("Access-Control-Allow-Origin", origin) - w.Header().Add("Access-Control-Allow-Headers", "accept, content-type, authorization") -} - -// ServeHTTP adds the correct CORS headers based on the origin and returns immediately -// with a 200 OK if the method is OPTIONS. -func (h *CORSHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - // Write CORS header. - if h.Info.OriginAllowed("*") { - h.addHeader(w, "*") - } else if origin := req.Header.Get("Origin"); h.Info.OriginAllowed(origin) { - h.addHeader(w, origin) - } - - if req.Method == "OPTIONS" { - w.WriteHeader(http.StatusOK) - return - } - - h.Handler.ServeHTTP(w, req) -} diff --git a/pkg/cors/cors_test.go b/pkg/cors/cors_test.go deleted file mode 100644 index d36e9c0c6c7..00000000000 --- a/pkg/cors/cors_test.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright 2015 The etcd Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cors - -import ( - "net/http" - "net/http/httptest" - "reflect" - "testing" -) - -func TestCORSInfo(t *testing.T) { - tests := []struct { - s string - winfo CORSInfo - ws string - }{ - {"", CORSInfo{}, ""}, - {"http://127.0.0.1", CORSInfo{"http://127.0.0.1": true}, "http://127.0.0.1"}, - {"*", CORSInfo{"*": true}, "*"}, - // with space around - {" http://127.0.0.1 ", CORSInfo{"http://127.0.0.1": true}, "http://127.0.0.1"}, - // multiple addrs - { - "http://127.0.0.1,http://127.0.0.2", - CORSInfo{"http://127.0.0.1": true, "http://127.0.0.2": true}, - "http://127.0.0.1,http://127.0.0.2", - }, - } - for i, tt := range tests { - info := CORSInfo{} - if err := info.Set(tt.s); err != nil { - t.Errorf("#%d: set error = %v, want nil", i, err) - } - if !reflect.DeepEqual(info, tt.winfo) { - t.Errorf("#%d: info = %v, want %v", i, info, tt.winfo) - } - if g := info.String(); g != tt.ws { - t.Errorf("#%d: info string = %s, want %s", i, g, tt.ws) - } - } -} - -func TestCORSInfoOriginAllowed(t *testing.T) { - tests := []struct { - set string - origin string - wallowed bool - }{ - {"http://127.0.0.1,http://127.0.0.2", "http://127.0.0.1", true}, - {"http://127.0.0.1,http://127.0.0.2", "http://127.0.0.2", true}, - {"http://127.0.0.1,http://127.0.0.2", "*", false}, - {"http://127.0.0.1,http://127.0.0.2", "http://127.0.0.3", false}, - {"*", "*", true}, - {"*", "http://127.0.0.1", true}, - } - for i, tt := range tests { - info := CORSInfo{} - if err := info.Set(tt.set); err != nil { - t.Errorf("#%d: set error = %v, want nil", i, err) - } - if g := info.OriginAllowed(tt.origin); g != tt.wallowed { - t.Errorf("#%d: allowed = %v, want %v", i, g, tt.wallowed) - } - } -} - -func TestCORSHandler(t *testing.T) { - info := &CORSInfo{} - if err := info.Set("http://127.0.0.1,http://127.0.0.2"); err != nil { - t.Fatalf("unexpected set error: %v", err) - } - h := &CORSHandler{ - Handler: http.NotFoundHandler(), - Info: info, - } - - header := func(origin string) http.Header { - return http.Header{ - "Access-Control-Allow-Methods": []string{"POST, GET, OPTIONS, PUT, DELETE"}, - "Access-Control-Allow-Origin": []string{origin}, - "Access-Control-Allow-Headers": []string{"accept, content-type, authorization"}, - } - } - tests := []struct { - method string - origin string - wcode int - wheader http.Header - }{ - {"GET", "http://127.0.0.1", http.StatusNotFound, header("http://127.0.0.1")}, - {"GET", "http://127.0.0.2", http.StatusNotFound, header("http://127.0.0.2")}, - {"GET", "http://127.0.0.3", http.StatusNotFound, http.Header{}}, - {"OPTIONS", "http://127.0.0.1", http.StatusOK, header("http://127.0.0.1")}, - } - for i, tt := range tests { - rr := httptest.NewRecorder() - req := &http.Request{ - Method: tt.method, - Header: http.Header{"Origin": []string{tt.origin}}, - } - h.ServeHTTP(rr, req) - if rr.Code != tt.wcode { - t.Errorf("#%d: code = %v, want %v", i, rr.Code, tt.wcode) - } - // it is set by http package, and there is no need to test it - rr.HeaderMap.Del("Content-Type") - rr.HeaderMap.Del("X-Content-Type-Options") - if !reflect.DeepEqual(rr.HeaderMap, tt.wheader) { - t.Errorf("#%d: header = %+v, want %+v", i, rr.HeaderMap, tt.wheader) - } - } -} diff --git a/pkg/flags/unique_strings.go b/pkg/flags/unique_strings.go new file mode 100644 index 00000000000..909cc00121a --- /dev/null +++ b/pkg/flags/unique_strings.go @@ -0,0 +1,76 @@ +// Copyright 2018 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package flags + +import ( + "flag" + "sort" + "strings" +) + +// UniqueStringsValue wraps a list of unique strings. +// The values are set in order. +type UniqueStringsValue struct { + Values map[string]struct{} +} + +// Set parses a command line set of strings, separated by comma. +// Implements "flag.Value" interface. +// The values are set in order. +func (us *UniqueStringsValue) Set(s string) error { + us.Values = make(map[string]struct{}) + for _, v := range strings.Split(s, ",") { + us.Values[v] = struct{}{} + } + return nil +} + +// String implements "flag.Value" interface. +func (us *UniqueStringsValue) String() string { + return strings.Join(us.stringSlice(), ",") +} + +func (us *UniqueStringsValue) stringSlice() []string { + ss := make([]string, 0, len(us.Values)) + for v := range us.Values { + ss = append(ss, v) + } + sort.Strings(ss) + return ss +} + +// NewUniqueStringsValue implements string slice as "flag.Value" interface. +// Given value is to be separated by comma. +// The values are set in order. +func NewUniqueStringsValue(s string) (us *UniqueStringsValue) { + us = &UniqueStringsValue{Values: make(map[string]struct{})} + if s == "" { + return us + } + if err := us.Set(s); err != nil { + plog.Panicf("new UniqueStringsValue should never fail: %v", err) + } + return us +} + +// UniqueStringsFromFlag returns a string slice from the flag. +func UniqueStringsFromFlag(fs *flag.FlagSet, flagName string) []string { + return []string((*fs.Lookup(flagName).Value.(*UniqueStringsValue)).stringSlice()) +} + +// UniqueStringsMapFromFlag returns a map of strings from the flag. +func UniqueStringsMapFromFlag(fs *flag.FlagSet, flagName string) map[string]struct{} { + return (*fs.Lookup(flagName).Value.(*UniqueStringsValue)).Values +} diff --git a/pkg/flags/unique_strings_test.go b/pkg/flags/unique_strings_test.go new file mode 100644 index 00000000000..86d2b0fc2b0 --- /dev/null +++ b/pkg/flags/unique_strings_test.go @@ -0,0 +1,68 @@ +// Copyright 2018 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package flags + +import ( + "reflect" + "testing" +) + +func TestNewUniqueStrings(t *testing.T) { + tests := []struct { + s string + exp map[string]struct{} + rs string + }{ + { // non-URL but allowed by exception + s: "*", + exp: map[string]struct{}{"*": {}}, + rs: "*", + }, + { + s: "", + exp: map[string]struct{}{}, + rs: "", + }, + { + s: "example.com", + exp: map[string]struct{}{"example.com": {}}, + rs: "example.com", + }, + { + s: "localhost,localhost", + exp: map[string]struct{}{"localhost": {}}, + rs: "localhost", + }, + { + s: "b.com,a.com", + exp: map[string]struct{}{"a.com": {}, "b.com": {}}, + rs: "a.com,b.com", + }, + { + s: "c.com,b.com", + exp: map[string]struct{}{"b.com": {}, "c.com": {}}, + rs: "b.com,c.com", + }, + } + for i := range tests { + uv := NewUniqueStringsValue(tests[i].s) + if !reflect.DeepEqual(tests[i].exp, uv.Values) { + t.Fatalf("#%d: expected %+v, got %+v", i, tests[i].exp, uv.Values) + } + if uv.String() != tests[i].rs { + t.Fatalf("#%d: expected %q, got %q", i, tests[i].rs, uv.String()) + } + } +} diff --git a/pkg/flags/unique_urls.go b/pkg/flags/unique_urls.go new file mode 100644 index 00000000000..5f0b1451a0c --- /dev/null +++ b/pkg/flags/unique_urls.go @@ -0,0 +1,92 @@ +// Copyright 2018 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package flags + +import ( + "flag" + "net/url" + "sort" + "strings" + + "github.com/coreos/etcd/pkg/types" +) + +// UniqueURLs contains unique URLs +// with non-URL exceptions. +type UniqueURLs struct { + Values map[string]struct{} + uss []url.URL + Allowed map[string]struct{} +} + +// Set parses a command line set of URLs formatted like: +// http://127.0.0.1:2380,http://10.1.1.2:80 +// Implements "flag.Value" interface. +func (us *UniqueURLs) Set(s string) error { + if _, ok := us.Values[s]; ok { + return nil + } + if _, ok := us.Allowed[s]; ok { + us.Values[s] = struct{}{} + return nil + } + ss, err := types.NewURLs(strings.Split(s, ",")) + if err != nil { + return err + } + us.Values = make(map[string]struct{}) + us.uss = make([]url.URL, 0) + for _, v := range ss { + us.Values[v.String()] = struct{}{} + us.uss = append(us.uss, v) + } + return nil +} + +// String implements "flag.Value" interface. +func (us *UniqueURLs) String() string { + all := make([]string, 0, len(us.Values)) + for u := range us.Values { + all = append(all, u) + } + sort.Strings(all) + return strings.Join(all, ",") +} + +// NewUniqueURLsWithExceptions implements "url.URL" slice as flag.Value interface. +// Given value is to be separated by comma. +func NewUniqueURLsWithExceptions(s string, exceptions ...string) *UniqueURLs { + us := &UniqueURLs{Values: make(map[string]struct{}), Allowed: make(map[string]struct{})} + for _, v := range exceptions { + us.Allowed[v] = struct{}{} + } + if s == "" { + return us + } + if err := us.Set(s); err != nil { + plog.Panicf("new UniqueURLs should never fail: %v", err) + } + return us +} + +// UniqueURLsFromFlag returns a slice from urls got from the flag. +func UniqueURLsFromFlag(fs *flag.FlagSet, urlsFlagName string) []url.URL { + return (*fs.Lookup(urlsFlagName).Value.(*UniqueURLs)).uss +} + +// UniqueURLsMapFromFlag returns a map from url strings got from the flag. +func UniqueURLsMapFromFlag(fs *flag.FlagSet, urlsFlagName string) map[string]struct{} { + return (*fs.Lookup(urlsFlagName).Value.(*UniqueURLs)).Values +} diff --git a/pkg/flags/unique_urls_test.go b/pkg/flags/unique_urls_test.go new file mode 100644 index 00000000000..adc4a6b5a19 --- /dev/null +++ b/pkg/flags/unique_urls_test.go @@ -0,0 +1,93 @@ +// Copyright 2018 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package flags + +import ( + "reflect" + "testing" +) + +func TestNewUniqueURLsWithExceptions(t *testing.T) { + tests := []struct { + s string + exp map[string]struct{} + rs string + exception string + }{ + { // non-URL but allowed by exception + s: "*", + exp: map[string]struct{}{"*": {}}, + rs: "*", + exception: "*", + }, + { + s: "", + exp: map[string]struct{}{}, + rs: "", + exception: "*", + }, + { + s: "https://1.2.3.4:8080", + exp: map[string]struct{}{"https://1.2.3.4:8080": {}}, + rs: "https://1.2.3.4:8080", + exception: "*", + }, + { + s: "https://1.2.3.4:8080,https://1.2.3.4:8080", + exp: map[string]struct{}{"https://1.2.3.4:8080": {}}, + rs: "https://1.2.3.4:8080", + exception: "*", + }, + { + s: "http://10.1.1.1:80", + exp: map[string]struct{}{"http://10.1.1.1:80": {}}, + rs: "http://10.1.1.1:80", + exception: "*", + }, + { + s: "http://localhost:80", + exp: map[string]struct{}{"http://localhost:80": {}}, + rs: "http://localhost:80", + exception: "*", + }, + { + s: "http://:80", + exp: map[string]struct{}{"http://:80": {}}, + rs: "http://:80", + exception: "*", + }, + { + s: "https://localhost:5,https://localhost:3", + exp: map[string]struct{}{"https://localhost:3": {}, "https://localhost:5": {}}, + rs: "https://localhost:3,https://localhost:5", + exception: "*", + }, + { + s: "http://localhost:5,https://localhost:3", + exp: map[string]struct{}{"https://localhost:3": {}, "http://localhost:5": {}}, + rs: "http://localhost:5,https://localhost:3", + exception: "*", + }, + } + for i := range tests { + uv := NewUniqueURLsWithExceptions(tests[i].s, tests[i].exception) + if !reflect.DeepEqual(tests[i].exp, uv.Values) { + t.Fatalf("#%d: expected %+v, got %+v", i, tests[i].exp, uv.Values) + } + if uv.String() != tests[i].rs { + t.Fatalf("#%d: expected %q, got %q", i, tests[i].rs, uv.String()) + } + } +}