mirror of https://github.com/k3s-io/k3s
commit
203a3d0cec
|
@ -23,6 +23,7 @@ import (
|
|||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GoogleCloudPlatform/kubernetes/pkg/apiserver"
|
||||
|
@ -63,6 +64,7 @@ var (
|
|||
healthCheckMinions = flag.Bool("health_check_minions", true, "If true, health check minions and filter unhealthy ones. Default true.")
|
||||
eventTTL = flag.Duration("event_ttl", 48*time.Hour, "Amount of time to retain events. Default 2 days.")
|
||||
tokenAuthFile = flag.String("token_auth_file", "", "If set, the file that will be used to secure the API server via token authentication.")
|
||||
authorizationMode = flag.String("authorization_mode", "AlwaysAllow", "Selects how to do authorization. One of: "+strings.Join(apiserver.AuthorizationModeChoices, ","))
|
||||
etcdServerList util.StringList
|
||||
etcdConfigFile = flag.String("etcd_config", "", "The config file for the etcd client. Mutually exclusive with -etcd_servers.")
|
||||
corsAllowedOriginList util.StringList
|
||||
|
@ -159,6 +161,7 @@ func main() {
|
|||
ReadOnlyPort: *readOnlyPort,
|
||||
ReadWritePort: *port,
|
||||
PublicAddress: *publicAddressOverride,
|
||||
AuthorizationMode: *authorizationMode,
|
||||
}
|
||||
m := master.New(config)
|
||||
|
||||
|
|
|
@ -146,6 +146,7 @@ func startComponents(manifestURL string) (apiServerURL string) {
|
|||
KubeletClient: fakeKubeletClient{},
|
||||
EnableLogsSupport: false,
|
||||
APIPrefix: "/api",
|
||||
AuthorizationMode: "AlwaysAllow",
|
||||
|
||||
ReadWritePort: portNumber,
|
||||
ReadOnlyPort: portNumber,
|
||||
|
|
|
@ -41,7 +41,7 @@ start_etcd
|
|||
echo ""
|
||||
echo "Integration test cases..."
|
||||
echo ""
|
||||
GOFLAGS="-tags 'integration no-docker'" \
|
||||
GOFLAGS="-tags 'integration no-docker' -test.v" \
|
||||
"${KUBE_ROOT}/hack/test-go.sh" test/integration
|
||||
|
||||
echo ""
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
Copyright 2014 Google Inc. All rights reserved.
|
||||
|
||||
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 apiserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/GoogleCloudPlatform/kubernetes/pkg/auth/authorizer"
|
||||
)
|
||||
|
||||
// Attributes implements authorizer.Attributes interface.
|
||||
type Attributes struct {
|
||||
// TODO: add fields and methods when authorizer.Attributes is completed.
|
||||
}
|
||||
|
||||
// alwaysAllowAuthorizer is an implementation of authorizer.Attributes
|
||||
// which always says yes to an authorization request.
|
||||
// It is useful in tests and when using kubernetes in an open manner.
|
||||
type alwaysAllowAuthorizer struct{}
|
||||
|
||||
func (alwaysAllowAuthorizer) Authorize(a authorizer.Attributes) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// alwaysDenyAuthorizer is an implementation of authorizer.Attributes
|
||||
// which always says no to an authorization request.
|
||||
// It is useful in unit tests to force an operation to be forbidden.
|
||||
type alwaysDenyAuthorizer struct{}
|
||||
|
||||
func (alwaysDenyAuthorizer) Authorize(a authorizer.Attributes) (err error) {
|
||||
return errors.New("Everything is forbidden.")
|
||||
}
|
||||
|
||||
const (
|
||||
ModeAlwaysAllow string = "AlwaysAllow"
|
||||
ModeAlwaysDeny string = "AlwaysDeny"
|
||||
)
|
||||
|
||||
// Keep this list in sync with constant list above.
|
||||
var AuthorizationModeChoices = []string{ModeAlwaysAllow, ModeAlwaysDeny}
|
||||
|
||||
// NewAuthorizerFromAuthorizationConfig returns the right sort of authorizer.Authorizer
|
||||
// based on the authorizationMode xor an error. authorizationMode should be one of AuthorizationModeChoices.
|
||||
func NewAuthorizerFromAuthorizationConfig(authorizationMode string) (authorizer.Authorizer, error) {
|
||||
// Keep cases in sync with constant list above.
|
||||
switch authorizationMode {
|
||||
case ModeAlwaysAllow:
|
||||
return new(alwaysAllowAuthorizer), nil
|
||||
case ModeAlwaysDeny:
|
||||
return new(alwaysDenyAuthorizer), nil
|
||||
default:
|
||||
return nil, errors.New("Unknown authorization mode")
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@ import (
|
|||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/GoogleCloudPlatform/kubernetes/pkg/auth/authorizer"
|
||||
"github.com/GoogleCloudPlatform/kubernetes/pkg/httplog"
|
||||
"github.com/GoogleCloudPlatform/kubernetes/pkg/util"
|
||||
"github.com/golang/glog"
|
||||
|
@ -118,3 +119,24 @@ func CORS(handler http.Handler, allowedOriginPatterns []*regexp.Regexp, allowedM
|
|||
handler.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
// RequestAttributeGetter is a function that extracts authorizer.Attributes from an http.Request
|
||||
type RequestAttributeGetter func(req *http.Request) (attribs authorizer.Attributes)
|
||||
|
||||
// BasicAttributeGetter gets authorizer.Attributes from an http.Request.
|
||||
func BasicAttributeGetter(req *http.Request) (attribs authorizer.Attributes) {
|
||||
// TODO: fill in attributes once attributes are defined.
|
||||
return
|
||||
}
|
||||
|
||||
// WithAuthorizationCheck passes all authorized requests on to handler, and returns a forbidden error otherwise.
|
||||
func WithAuthorizationCheck(handler http.Handler, getAttribs RequestAttributeGetter, a authorizer.Authorizer) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
err := a.Authorize(getAttribs(req))
|
||||
if err == nil {
|
||||
handler.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
forbidden(w, req)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
Copyright 2014 Google Inc. All rights reserved.
|
||||
|
||||
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 authorizer
|
||||
|
||||
// Attributes is an interface used by an Authorizer to get information about a request
|
||||
// that is used to make an authorization decision.
|
||||
type Attributes interface {
|
||||
// TODO: add attribute getter functions, e.g. GetUserName(), per #1430.
|
||||
}
|
||||
|
||||
// Authorizer makes an authorization decision based on information gained by making
|
||||
// zero or more calls to methods of the Attributes interface. It returns nil when an action is
|
||||
// authorized, otherwise it returns an error.
|
||||
type Authorizer interface {
|
||||
Authorize(a Attributes) (err error)
|
||||
}
|
|
@ -66,6 +66,7 @@ type Config struct {
|
|||
APIPrefix string
|
||||
CorsAllowedOriginList util.StringList
|
||||
TokenAuthFile string
|
||||
AuthorizationMode string
|
||||
|
||||
// Number of masters running; all masters must be started with the
|
||||
// same value for this field. (Numbers > 1 currently untested.)
|
||||
|
@ -101,6 +102,7 @@ type Master struct {
|
|||
apiPrefix string
|
||||
corsAllowedOriginList util.StringList
|
||||
tokenAuthFile string
|
||||
authorizationzMode string
|
||||
masterCount int
|
||||
|
||||
// "Outputs"
|
||||
|
@ -220,9 +222,11 @@ func New(c *Config) *Master {
|
|||
apiPrefix: c.APIPrefix,
|
||||
corsAllowedOriginList: c.CorsAllowedOriginList,
|
||||
tokenAuthFile: c.TokenAuthFile,
|
||||
masterCount: c.MasterCount,
|
||||
readOnlyServer: net.JoinHostPort(c.PublicAddress, strconv.Itoa(int(c.ReadOnlyPort))),
|
||||
readWriteServer: net.JoinHostPort(c.PublicAddress, strconv.Itoa(int(c.ReadWritePort))),
|
||||
authorizationzMode: c.AuthorizationMode,
|
||||
|
||||
masterCount: c.MasterCount,
|
||||
readOnlyServer: net.JoinHostPort(c.PublicAddress, strconv.Itoa(int(c.ReadOnlyPort))),
|
||||
readWriteServer: net.JoinHostPort(c.PublicAddress, strconv.Itoa(int(c.ReadWritePort))),
|
||||
}
|
||||
m.masterServices = util.NewRunner(m.serviceWriterLoop, m.roServiceWriterLoop)
|
||||
m.init(c)
|
||||
|
@ -310,6 +314,14 @@ func (m *Master) init(c *Config) {
|
|||
handler = apiserver.CORS(handler, allowedOriginRegexps, nil, nil, "true")
|
||||
}
|
||||
|
||||
// Install Authorizer
|
||||
authorizer, err := apiserver.NewAuthorizerFromAuthorizationConfig(m.authorizationzMode)
|
||||
if err != nil {
|
||||
glog.Fatal(err)
|
||||
}
|
||||
handler = apiserver.WithAuthorizationCheck(handler, apiserver.BasicAttributeGetter, authorizer)
|
||||
|
||||
// Install Authenticator
|
||||
if authenticator != nil {
|
||||
handler = handlers.NewRequestAuthenticator(userContexts, authenticator, handlers.Unauthorized, handler)
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ package integration
|
|||
// to work for any client of the HTTP interface.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
@ -70,6 +71,7 @@ xyz987,bob,2
|
|||
EnableUISupport: false,
|
||||
APIPrefix: "/api",
|
||||
TokenAuthFile: f.Name(),
|
||||
AuthorizationMode: "AlwaysAllow",
|
||||
})
|
||||
|
||||
s := httptest.NewServer(m.Handler)
|
||||
|
@ -118,3 +120,293 @@ xyz987,bob,2
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bodies for requests used in subsequent tests.
|
||||
var aPod string = `
|
||||
{
|
||||
"kind": "Pod",
|
||||
"apiVersion": "v1beta1",
|
||||
"id": "a",
|
||||
"desiredState": {
|
||||
"manifest": {
|
||||
"version": "v1beta1",
|
||||
"id": "a",
|
||||
"containers": [{ "name": "foo", "image": "bar/foo", }]
|
||||
}
|
||||
},
|
||||
}
|
||||
`
|
||||
var aRC string = `
|
||||
{
|
||||
"kind": "ReplicationController",
|
||||
"apiVersion": "v1beta1",
|
||||
"id": "a",
|
||||
"desiredState": {
|
||||
"replicas": 2,
|
||||
"replicaSelector": {"name": "a"},
|
||||
"podTemplate": {
|
||||
"desiredState": {
|
||||
"manifest": {
|
||||
"version": "v1beta1",
|
||||
"id": "a",
|
||||
"containers": [{
|
||||
"name": "foo",
|
||||
"image": "bar/foo",
|
||||
}]
|
||||
}
|
||||
},
|
||||
"labels": {"name": "a"}
|
||||
}},
|
||||
"labels": {"name": "a"}
|
||||
}
|
||||
`
|
||||
var aService string = `
|
||||
{
|
||||
"kind": "Service",
|
||||
"apiVersion": "v1beta1",
|
||||
"id": "a",
|
||||
"port": 8000,
|
||||
"labels": { "name": "a" },
|
||||
"selector": { "name": "a" }
|
||||
}
|
||||
`
|
||||
var aMinion string = `
|
||||
{
|
||||
"kind": "Minion",
|
||||
"apiVersion": "v1beta1",
|
||||
"id": "a",
|
||||
"hostIP": "10.10.10.10",
|
||||
}
|
||||
`
|
||||
|
||||
var aEvent string = `
|
||||
{
|
||||
"kind": "Binding",
|
||||
"apiVersion": "v1beta1",
|
||||
"id": "a",
|
||||
"involvedObject": {
|
||||
{
|
||||
"kind": "Minion",
|
||||
"name": "a"
|
||||
"apiVersion": "v1beta1",
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var aBinding string = `
|
||||
{
|
||||
"kind": "Binding",
|
||||
"apiVersion": "v1beta1",
|
||||
"id": "a",
|
||||
"host": "10.10.10.10",
|
||||
"podID": "a"
|
||||
}
|
||||
`
|
||||
|
||||
var aEndpoints string = `
|
||||
{
|
||||
"kind": "Endpoints",
|
||||
"apiVersion": "v1beta1",
|
||||
"id": "a",
|
||||
"endpoints": ["10.10.1.1:1909"],
|
||||
}
|
||||
`
|
||||
|
||||
// Requests to try. Each one should be forbidden or not forbidden
|
||||
// depending on the authentication and authorization setup of the master.
|
||||
|
||||
func getTestRequests() []struct {
|
||||
verb string
|
||||
URL string
|
||||
body string
|
||||
} {
|
||||
requests := []struct {
|
||||
verb string
|
||||
URL string
|
||||
body string
|
||||
}{
|
||||
// Normal methods on pods
|
||||
{"GET", "/api/v1beta1/pods", ""},
|
||||
{"GET", "/api/v1beta1/pods/a", ""},
|
||||
{"POST", "/api/v1beta1/pods", aPod},
|
||||
{"PUT", "/api/v1beta1/pods", aPod},
|
||||
{"GET", "/api/v1beta1/pods", ""},
|
||||
{"GET", "/api/v1beta1/pods/a", ""},
|
||||
{"DELETE", "/api/v1beta1/pods", ""},
|
||||
|
||||
// Non-standard methods (not expected to work,
|
||||
// but expected to pass/fail authorization prior to
|
||||
// failing validation.
|
||||
{"PATCH", "/api/v1beta1/pods/a", ""},
|
||||
{"OPTIONS", "/api/v1beta1/pods", ""},
|
||||
{"OPTIONS", "/api/v1beta1/pods/a", ""},
|
||||
{"HEAD", "/api/v1beta1/pods", ""},
|
||||
{"HEAD", "/api/v1beta1/pods/a", ""},
|
||||
{"TRACE", "/api/v1beta1/pods", ""},
|
||||
{"TRACE", "/api/v1beta1/pods/a", ""},
|
||||
{"NOSUCHVERB", "/api/v1beta1/pods", ""},
|
||||
|
||||
// Normal methods on services
|
||||
{"GET", "/api/v1beta1/services", ""},
|
||||
{"GET", "/api/v1beta1/services/a", ""},
|
||||
{"POST", "/api/v1beta1/services", aService},
|
||||
{"PUT", "/api/v1beta1/services", aService},
|
||||
{"GET", "/api/v1beta1/services", ""},
|
||||
{"GET", "/api/v1beta1/services/a", ""},
|
||||
{"DELETE", "/api/v1beta1/services", ""},
|
||||
|
||||
// Normal methods on replicationControllers
|
||||
{"GET", "/api/v1beta1/replicationControllers", ""},
|
||||
{"GET", "/api/v1beta1/replicationControllers/a", ""},
|
||||
{"POST", "/api/v1beta1/replicationControllers", aRC},
|
||||
{"PUT", "/api/v1beta1/replicationControllers", aRC},
|
||||
{"GET", "/api/v1beta1/replicationControllers", ""},
|
||||
{"GET", "/api/v1beta1/replicationControllers/a", ""},
|
||||
{"DELETE", "/api/v1beta1/replicationControllers", ""},
|
||||
|
||||
// Normal methods on endpoints
|
||||
{"GET", "/api/v1beta1/endpoints", ""},
|
||||
{"GET", "/api/v1beta1/endpoints/a", ""},
|
||||
{"POST", "/api/v1beta1/endpoints", aEndpoints},
|
||||
{"PUT", "/api/v1beta1/endpoints", aEndpoints},
|
||||
{"GET", "/api/v1beta1/endpoints", ""},
|
||||
{"GET", "/api/v1beta1/endpoints/a", ""},
|
||||
{"DELETE", "/api/v1beta1/endpoints", ""},
|
||||
|
||||
// Normal methods on minions
|
||||
{"GET", "/api/v1beta1/minions", ""},
|
||||
{"GET", "/api/v1beta1/minions/a", ""},
|
||||
{"POST", "/api/v1beta1/minions", aMinion},
|
||||
{"PUT", "/api/v1beta1/minions", aMinion},
|
||||
{"GET", "/api/v1beta1/minions", ""},
|
||||
{"GET", "/api/v1beta1/minions/a", ""},
|
||||
{"DELETE", "/api/v1beta1/minions", ""},
|
||||
|
||||
// Normal methods on events
|
||||
{"GET", "/api/v1beta1/events", ""},
|
||||
{"GET", "/api/v1beta1/events/a", ""},
|
||||
{"POST", "/api/v1beta1/events", aEvent},
|
||||
{"PUT", "/api/v1beta1/events", aEvent},
|
||||
{"GET", "/api/v1beta1/events", ""},
|
||||
{"GET", "/api/v1beta1/events/a", ""},
|
||||
{"DELETE", "/api/v1beta1/events", ""},
|
||||
|
||||
// Normal methods on bindings
|
||||
{"GET", "/api/v1beta1/events", ""},
|
||||
{"GET", "/api/v1beta1/events/a", ""},
|
||||
{"POST", "/api/v1beta1/events", aBinding},
|
||||
{"PUT", "/api/v1beta1/events", aBinding},
|
||||
{"GET", "/api/v1beta1/events", ""},
|
||||
{"GET", "/api/v1beta1/events/a", ""},
|
||||
{"DELETE", "/api/v1beta1/events", ""},
|
||||
|
||||
// Non-existent object type.
|
||||
{"GET", "/api/v1beta1/foo", ""},
|
||||
{"GET", "/api/v1beta1/foo/a", ""},
|
||||
{"POST", "/api/v1beta1/foo", `{"foo": "foo"}`},
|
||||
{"PUT", "/api/v1beta1/foo", `{"foo": "foo"}`},
|
||||
{"GET", "/api/v1beta1/foo", ""},
|
||||
{"GET", "/api/v1beta1/foo/a", ""},
|
||||
{"DELETE", "/api/v1beta1/foo", ""},
|
||||
|
||||
// Operations
|
||||
{"GET", "/api/v1beta1/operations", ""},
|
||||
{"GET", "/api/v1beta1/operations/1234567890", ""},
|
||||
|
||||
// Special verbs on pods
|
||||
{"GET", "/api/v1beta1/proxy/pods/a", ""},
|
||||
{"GET", "/api/v1beta1/redirect/pods/a", ""},
|
||||
// TODO: test .../watch/..., which doesn't end before the test timeout.
|
||||
|
||||
// Non-object endpoints
|
||||
{"GET", "/", ""},
|
||||
{"GET", "/healthz", ""},
|
||||
{"GET", "/versions", ""},
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
// The TestAuthMode* tests tests a large number of URLs and checks that they
|
||||
// are FORBIDDEN or not, depending on the mode. They do not attempt to do
|
||||
// detailed verification of behaviour beyond authorization. They are not
|
||||
// fuzz tests.
|
||||
//
|
||||
// TODO(etune): write a fuzz test of the REST API.
|
||||
func TestAuthModeAlwaysAllow(t *testing.T) {
|
||||
deleteAllEtcdKeys()
|
||||
|
||||
// Set up a master
|
||||
|
||||
helper, err := master.NewEtcdHelper(newEtcdClient(), "v1beta1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
m := master.New(&master.Config{
|
||||
EtcdHelper: helper,
|
||||
EnableLogsSupport: false,
|
||||
EnableUISupport: false,
|
||||
APIPrefix: "/api",
|
||||
AuthorizationMode: "AlwaysAllow",
|
||||
})
|
||||
|
||||
s := httptest.NewServer(m.Handler)
|
||||
defer s.Close()
|
||||
transport := http.DefaultTransport
|
||||
|
||||
for _, r := range getTestRequests() {
|
||||
t.Logf("case %v", r)
|
||||
bodyBytes := bytes.NewReader([]byte(r.body))
|
||||
req, err := http.NewRequest(r.verb, s.URL+r.URL, bodyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
t.Errorf("Expected status other than Forbidden")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthModeAlwaysDeny(t *testing.T) {
|
||||
deleteAllEtcdKeys()
|
||||
|
||||
// Set up a master
|
||||
|
||||
helper, err := master.NewEtcdHelper(newEtcdClient(), "v1beta1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
m := master.New(&master.Config{
|
||||
EtcdHelper: helper,
|
||||
EnableLogsSupport: false,
|
||||
EnableUISupport: false,
|
||||
APIPrefix: "/api",
|
||||
AuthorizationMode: "AlwaysDeny",
|
||||
})
|
||||
|
||||
s := httptest.NewServer(m.Handler)
|
||||
defer s.Close()
|
||||
transport := http.DefaultTransport
|
||||
|
||||
for _, r := range getTestRequests() {
|
||||
t.Logf("case %v", r)
|
||||
bodyBytes := bytes.NewReader([]byte(r.body))
|
||||
req, err := http.NewRequest(r.verb, s.URL+r.URL, bodyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
resp, err := transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("Expected status Forbidden but got status %v", resp.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ func TestClient(t *testing.T) {
|
|||
EnableLogsSupport: false,
|
||||
EnableUISupport: false,
|
||||
APIPrefix: "/api",
|
||||
AuthorizationMode: "AlwaysAllow",
|
||||
})
|
||||
|
||||
s := httptest.NewServer(m.Handler)
|
||||
|
|
Loading…
Reference in New Issue