Browse Source

Merge pull request #9490 from gyuho/cors

*: support CORS for v3 HTTP requests
Gyuho Lee 7 years ago
parent
commit
473793be1f

+ 6 - 2
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 `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`.
   - 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 [`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/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/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).
 - 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:
   - Client origin enforce policy works as follow:
     - If client connection is secure via HTTPS, allow any hostnames..
     - 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.
     - 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.
   - 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.).
   - 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"`).
   - 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).
 - TODO: Support [TLS cipher suite lists](TODO).
 - Support [`ttl` field for `etcd` Authentication JWT token](https://github.com/coreos/etcd/pull/8302).
 - Support [`ttl` field for `etcd` Authentication JWT token](https://github.com/coreos/etcd/pull/8302).
   - e.g. `etcd --auth-token jwt,pub-key=<pub key path>,priv-key=<priv key path>,sign-method=<sign method>,ttl=5m`.
   - e.g. `etcd --auth-token jwt,pub-key=<pub key path>,priv-key=<priv key path>,sign-method=<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 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]`.
   - 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.
   - 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`
 ### 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`.
   - 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).
 - 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`.
   - 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`
 ### Package `raft`
 
 

+ 29 - 22
embed/config.go

@@ -28,7 +28,7 @@ import (
 
 
 	"github.com/coreos/etcd/compactor"
 	"github.com/coreos/etcd/compactor"
 	"github.com/coreos/etcd/etcdserver"
 	"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/netutil"
 	"github.com/coreos/etcd/pkg/srv"
 	"github.com/coreos/etcd/pkg/srv"
 	"github.com/coreos/etcd/pkg/transport"
 	"github.com/coreos/etcd/pkg/transport"
@@ -79,9 +79,8 @@ var (
 	DefaultInitialAdvertisePeerURLs = "http://localhost:2380"
 	DefaultInitialAdvertisePeerURLs = "http://localhost:2380"
 	DefaultAdvertiseClientURLs      = "http://localhost:2379"
 	DefaultAdvertiseClientURLs      = "http://localhost:2379"
 
 
-	defaultHostname      string
-	defaultHostStatus    error
-	defaultHostWhitelist = []string{} // if empty, allow all
+	defaultHostname   string
+	defaultHostStatus error
 )
 )
 
 
 var (
 var (
@@ -107,7 +106,6 @@ func init() {
 
 
 // Config holds the arguments for configuring an etcd server.
 // Config holds the arguments for configuring an etcd server.
 type Config struct {
 type Config struct {
-	CorsInfo       *cors.CORSInfo
 	LPUrls, LCUrls []url.URL
 	LPUrls, LCUrls []url.URL
 	Dir            string `json:"data-dir"`
 	Dir            string `json:"data-dir"`
 	WalDir         string `json:"wal-dir"`
 	WalDir         string `json:"wal-dir"`
@@ -171,6 +169,8 @@ type Config struct {
 	PeerTLSInfo   transport.TLSInfo
 	PeerTLSInfo   transport.TLSInfo
 	PeerAutoTLS   bool
 	PeerAutoTLS   bool
 
 
+	CORS map[string]struct{}
+
 	// HostWhitelist lists acceptable hostnames from HTTP client requests.
 	// HostWhitelist lists acceptable hostnames from HTTP client requests.
 	// Client origin policy protects against "DNS Rebinding" attacks
 	// Client origin policy protects against "DNS Rebinding" attacks
 	// to insecure etcd servers. That is, any website can simply create
 	// 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
 	// Note that the client origin policy is enforced whether authentication
 	// is enabled or not, for tighter controls.
 	// 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
 	// 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:
 	// CVE-2018-5702 reference:
 	// - https://bugs.chromium.org/p/project-zero/issues/detail?id=1447#c2
 	// - https://bugs.chromium.org/p/project-zero/issues/detail?id=1447#c2
 	// - https://github.com/transmission/transmission/pull/468
 	// - https://github.com/transmission/transmission/pull/468
 	// - https://github.com/coreos/etcd/issues/9353
 	// - https://github.com/coreos/etcd/issues/9353
-	HostWhitelist []string `json:"host-whitelist"`
+	HostWhitelist map[string]struct{}
 
 
 	Debug                 bool   `json:"debug"`
 	Debug                 bool   `json:"debug"`
 	LogPkgLevels          string `json:"log-package-levels"`
 	LogPkgLevels          string `json:"log-package-levels"`
@@ -237,11 +237,14 @@ type configYAML struct {
 
 
 // configJSON has file options that are translated into Config options
 // configJSON has file options that are translated into Config options
 type configJSON struct {
 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"`
 	ClientSecurityJSON securityConfig `json:"client-transport-security"`
 	PeerSecurityJSON   securityConfig `json:"peer-transport-security"`
 	PeerSecurityJSON   securityConfig `json:"peer-transport-security"`
 }
 }
@@ -261,7 +264,6 @@ func NewConfig() *Config {
 	lcurl, _ := url.Parse(DefaultListenClientURLs)
 	lcurl, _ := url.Parse(DefaultListenClientURLs)
 	acurl, _ := url.Parse(DefaultAdvertiseClientURLs)
 	acurl, _ := url.Parse(DefaultAdvertiseClientURLs)
 	cfg := &Config{
 	cfg := &Config{
-		CorsInfo:              &cors.CORSInfo{},
 		MaxSnapFiles:          DefaultMaxSnapshots,
 		MaxSnapFiles:          DefaultMaxSnapshots,
 		MaxWalFiles:           DefaultMaxWALs,
 		MaxWalFiles:           DefaultMaxWALs,
 		Name:                  DefaultName,
 		Name:                  DefaultName,
@@ -283,7 +285,8 @@ func NewConfig() *Config {
 		LogOutput:             DefaultLogOutput,
 		LogOutput:             DefaultLogOutput,
 		Metrics:               "basic",
 		Metrics:               "basic",
 		EnableV2:              DefaultEnableV2,
 		EnableV2:              DefaultEnableV2,
-		HostWhitelist:         defaultHostWhitelist,
+		CORS:                  map[string]struct{}{"*": {}},
+		HostWhitelist:         map[string]struct{}{"*": {}},
 		AuthToken:             "simple",
 		AuthToken:             "simple",
 		PreVote:               false, // TODO: enable by default in v3.5
 		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)
 		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 != "" {
 	if cfg.APUrlsJSON != "" {
 		u, err := types.NewURLs(strings.Split(cfg.APUrlsJSON, ","))
 		u, err := types.NewURLs(strings.Split(cfg.APUrlsJSON, ","))
 		if err != nil {
 		if err != nil {
@@ -411,6 +408,16 @@ func (cfg *configYAML) configFromFile(path string) error {
 		cfg.ListenMetricsUrls = []url.URL(u)
 		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 a discovery flag is set, clear default initial cluster set by InitialClusterFromName
 	if (cfg.Durl != "" || cfg.DNSCluster != "") && cfg.InitialCluster == defaultInitialCluster {
 	if (cfg.Durl != "" || cfg.DNSCluster != "") && cfg.InitialCluster == defaultInitialCluster {
 		cfg.InitialCluster = ""
 		cfg.InitialCluster = ""

+ 20 - 14
embed/etcd.go

@@ -23,6 +23,7 @@ import (
 	"net"
 	"net"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
+	"sort"
 	"strconv"
 	"strconv"
 	"sync"
 	"sync"
 	"time"
 	"time"
@@ -33,7 +34,6 @@ import (
 	"github.com/coreos/etcd/etcdserver/api/v2v3"
 	"github.com/coreos/etcd/etcdserver/api/v2v3"
 	"github.com/coreos/etcd/etcdserver/api/v3client"
 	"github.com/coreos/etcd/etcdserver/api/v3client"
 	"github.com/coreos/etcd/etcdserver/api/v3rpc"
 	"github.com/coreos/etcd/etcdserver/api/v3rpc"
-	"github.com/coreos/etcd/pkg/cors"
 	"github.com/coreos/etcd/pkg/debugutil"
 	"github.com/coreos/etcd/pkg/debugutil"
 	runtimeutil "github.com/coreos/etcd/pkg/runtime"
 	runtimeutil "github.com/coreos/etcd/pkg/runtime"
 	"github.com/coreos/etcd/pkg/transport"
 	"github.com/coreos/etcd/pkg/transport"
@@ -168,6 +168,8 @@ func StartEtcd(inCfg *Config) (e *Etcd, err error) {
 		StrictReconfigCheck:     cfg.StrictReconfigCheck,
 		StrictReconfigCheck:     cfg.StrictReconfigCheck,
 		ClientCertAuthEnabled:   cfg.ClientTLSInfo.ClientCertAuth,
 		ClientCertAuthEnabled:   cfg.ClientTLSInfo.ClientCertAuth,
 		AuthToken:               cfg.AuthToken,
 		AuthToken:               cfg.AuthToken,
+		CORS:                    cfg.CORS,
+		HostWhitelist:           cfg.HostWhitelist,
 		InitialCorruptCheck:     cfg.ExperimentalInitialCorruptCheck,
 		InitialCorruptCheck:     cfg.ExperimentalInitialCorruptCheck,
 		CorruptCheckTime:        cfg.ExperimentalCorruptCheckTime,
 		CorruptCheckTime:        cfg.ExperimentalCorruptCheckTime,
 		PreVote:                 cfg.PreVote,
 		PreVote:                 cfg.PreVote,
@@ -175,17 +177,26 @@ func StartEtcd(inCfg *Config) (e *Etcd, err error) {
 		ForceNewCluster:         cfg.ForceNewCluster,
 		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 {
 	if e.Server, err = etcdserver.NewServer(srvcfg); err != nil {
 		return e, err
 		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
 	// 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))
 	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)
 		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
 	// Start a client server goroutine for each listen address
 	var h http.Handler
 	var h http.Handler
 	if e.Config().EnableV2 {
 	if e.Config().EnableV2 {
@@ -497,7 +504,6 @@ func (e *Etcd) serveClients() (err error) {
 		etcdhttp.HandleBasic(mux, e.Server)
 		etcdhttp.HandleBasic(mux, e.Server)
 		h = mux
 		h = mux
 	}
 	}
-	h = http.Handler(&cors.CORSHandler{Handler: h, Info: e.cfg.CorsInfo})
 
 
 	gopts := []grpc.ServerOption{}
 	gopts := []grpc.ServerOption{}
 	if e.cfg.GRPCKeepAliveMinTime > time.Duration(0) {
 	if e.cfg.GRPCKeepAliveMinTime > time.Duration(0) {

+ 57 - 9
embed/serve.go

@@ -116,7 +116,7 @@ func (sctx *serveCtx) serve(
 		httpmux := sctx.createMux(gwmux, handler)
 		httpmux := sctx.createMux(gwmux, handler)
 
 
 		srvhttp := &http.Server{
 		srvhttp := &http.Server{
-			Handler:  wrapMux(s, httpmux),
+			Handler:  createAccessController(s, httpmux),
 			ErrorLog: logger, // do not log user error
 			ErrorLog: logger, // do not log user error
 		}
 		}
 		httpl := m.Match(cmux.HTTP1())
 		httpl := m.Match(cmux.HTTP1())
@@ -159,7 +159,7 @@ func (sctx *serveCtx) serve(
 		httpmux := sctx.createMux(gwmux, handler)
 		httpmux := sctx.createMux(gwmux, handler)
 
 
 		srv := &http.Server{
 		srv := &http.Server{
-			Handler:   wrapMux(s, httpmux),
+			Handler:   createAccessController(s, httpmux),
 			TLSConfig: tlscfg,
 			TLSConfig: tlscfg,
 			ErrorLog:  logger, // do not log user error
 			ErrorLog:  logger, // do not log user error
 		}
 		}
@@ -250,20 +250,20 @@ func (sctx *serveCtx) createMux(gwmux *gw.ServeMux, handler http.Handler) *http.
 	return httpmux
 	return httpmux
 }
 }
 
 
-// wrapMux wraps HTTP multiplexer:
+// createAccessController wraps HTTP multiplexer:
 // - mutate gRPC gateway request paths
 // - mutate gRPC gateway request paths
 // - check hostname whitelist
 // - check hostname whitelist
 // client HTTP requests goes here first
 // 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
 	s   *etcdserver.EtcdServer
 	mux *http.ServeMux
 	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
 	// redirect for backward compatibilities
 	if req != nil && req.URL != nil && strings.HasPrefix(req.URL.Path, "/v3beta/") {
 	if req != nil && req.URL != nil && strings.HasPrefix(req.URL.Path, "/v3beta/") {
 		req.URL.Path = strings.Replace(req.URL.Path, "/v3beta/", "/v3/", 1)
 		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
 	if req.TLS == nil { // check origin if client connection is not secure
 		host := httputil.GetHostname(req)
 		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)
 			plog.Warningf("rejecting HTTP request from %q to prevent DNS rebinding attacks", host)
 			// TODO: use Go's "http.StatusMisdirectedRequest" (421)
 			// TODO: use Go's "http.StatusMisdirectedRequest" (421)
 			// https://github.com/golang/go/commit/4b8a7eafef039af1834ef9bfa879257c4a72b7b5
 			// 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
 // 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)
 `, 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) {
 func (sctx *serveCtx) registerUserHandler(s string, h http.Handler) {
 	if sctx.userHandlers[s] != nil {
 	if sctx.userHandlers[s] != nil {
 		plog.Warningf("path %s already registered by user handler", s)
 		plog.Warningf("path %s already registered by user handler", s)

+ 39 - 13
etcdmain/config.go

@@ -128,12 +128,22 @@ func newConfig() *config {
 	fs.StringVar(&cfg.configFile, "config-file", "", "Path to the server configuration file")
 	fs.StringVar(&cfg.configFile, "config-file", "", "Path to the server configuration file")
 
 
 	// member
 	// 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.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.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.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.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.")
 	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).")
 	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
 	// 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.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()))
 	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.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.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.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
 	// logging
 	fs.BoolVar(&cfg.ec.Debug, "debug", false, "Enable debug-level logging for etcd.")
 	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)
 		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.ec.ClusterState = cfg.cf.clusterState.String()
 	cfg.cp.Fallback = cfg.cf.fallback.String()
 	cfg.cp.Fallback = cfg.cf.fallback.String()

+ 2 - 2
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)
 		t.Errorf("initialClusterToken = %v, want %v", cfg.ec.InitialClusterToken, wcfg.ec.InitialClusterToken)
 	}
 	}
 	if !reflect.DeepEqual(cfg.ec.APUrls, wcfg.ec.APUrls) {
 	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) {
 	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)
 	}
 	}
 }
 }
 
 

+ 1 - 5
etcdmain/etcd.go

@@ -30,7 +30,6 @@ import (
 	"github.com/coreos/etcd/embed"
 	"github.com/coreos/etcd/embed"
 	"github.com/coreos/etcd/etcdserver"
 	"github.com/coreos/etcd/etcdserver"
 	"github.com/coreos/etcd/etcdserver/api/etcdhttp"
 	"github.com/coreos/etcd/etcdserver/api/etcdhttp"
-	"github.com/coreos/etcd/pkg/cors"
 	"github.com/coreos/etcd/pkg/fileutil"
 	"github.com/coreos/etcd/pkg/fileutil"
 	pkgioutil "github.com/coreos/etcd/pkg/ioutil"
 	pkgioutil "github.com/coreos/etcd/pkg/ioutil"
 	"github.com/coreos/etcd/pkg/osutil"
 	"github.com/coreos/etcd/pkg/osutil"
@@ -301,10 +300,7 @@ func startProxy(cfg *config) error {
 		return clientURLs
 		return clientURLs
 	}
 	}
 	ph := httpproxy.NewHandler(pt, uf, time.Duration(cfg.cp.ProxyFailureWaitMs)*time.Millisecond, time.Duration(cfg.cp.ProxyRefreshIntervalMs)*time.Millisecond)
 	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() {
 	if cfg.isReadonlyProxy() {
 		ph = httpproxy.NewReadonlyHandler(ph)
 		ph = httpproxy.NewReadonlyHandler(ph)

+ 161 - 166
etcdmain/help.go

@@ -21,177 +21,172 @@ import (
 )
 )
 
 
 var (
 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 = `
 	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!
 `
 `
 )
 )

+ 2 - 0
etcdserver/config.go

@@ -46,6 +46,8 @@ type ServerConfig struct {
 	NewCluster          bool
 	NewCluster          bool
 	PeerTLSInfo         transport.TLSInfo
 	PeerTLSInfo         transport.TLSInfo
 
 
+	CORS map[string]struct{}
+
 	// HostWhitelist lists acceptable hostnames from client requests.
 	// HostWhitelist lists acceptable hostnames from client requests.
 	// If server is insecure (no TLS), server only accepts requests
 	// If server is insecure (no TLS), server only accepts requests
 	// whose Host header value exists in this white list.
 	// whose Host header value exists in this white list.

+ 11 - 21
etcdserver/server.go

@@ -253,7 +253,7 @@ type EtcdServer struct {
 	leadTimeMu      sync.RWMutex
 	leadTimeMu      sync.RWMutex
 	leadElectedTime time.Time
 	leadElectedTime time.Time
 
 
-	hostWhitelist map[string]struct{}
+	*AccessController
 }
 }
 
 
 // NewServer creates a new EtcdServer from the supplied configuration. The
 // 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),
 				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}
 	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)
 	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 {
 type etcdProgress struct {
 	confState raftpb.ConfState
 	confState raftpb.ConfState
 	snapi     uint64
 	snapi     uint64

+ 65 - 0
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
+}

+ 0 - 90
pkg/cors/cors.go

@@ -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)
-}

+ 0 - 125
pkg/cors/cors_test.go

@@ -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)
-		}
-	}
-}

+ 76 - 0
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
+}

+ 68 - 0
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())
+		}
+	}
+}

+ 92 - 0
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
+}

+ 93 - 0
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())
+		}
+	}
+}