From 36ce7aa82fd367d06b2a88162818ff804b6d611b Mon Sep 17 00:00:00 2001 From: Matthew McNeely Date: Sun, 5 Apr 2020 16:12:56 -0400 Subject: [PATCH] Initial commit --- .gitignore | 2 + LICENSE | 21 + Makefile | 34 ++ README.md | 27 + api/http/admin.go | 627 ++++++++++++++++++++++ api/http/auth.go | 211 ++++++++ api/http/handler.go | 127 +++++ api/model/principal.go | 9 + api/model/session.go | 18 + cmd/admin-service/Dockerfile | 6 + cmd/admin-service/main.go | 88 +++ cmd/auth-service/Dockerfile | 6 + cmd/auth-service/main.go | 89 ++++ cmd/migration/Dockerfile | 5 + cmd/migration/main.go | 47 ++ cmd/spec/admin.yaml | 885 +++++++++++++++++++++++++++++++ cmd/spec/main.go | 60 +++ cmd/spec/schemas.yaml | 206 +++++++ deploy/Dockerfile | 4 + deploy/README.md | 1 + dogpark-medium.png | Bin 0 -> 37232 bytes go.mod | 45 ++ go.sum | 788 +++++++++++++++++++++++++++ internal/db/conn.go | 59 +++ internal/db/statement.go | 37 ++ internal/db/testing.go | 62 +++ internal/geo/geolite.go | 77 +++ internal/geo/ipstack.go | 122 +++++ internal/geo/resolver.go | 45 ++ internal/geo/resolver_test.go | 37 ++ internal/model/aaguid.go | 137 +++++ internal/model/audit.go | 76 +++ internal/model/audit_test.go | 104 ++++ internal/model/err.go | 6 + internal/model/key.go | 139 +++++ internal/model/migrate.go | 68 +++ internal/model/principal.go | 230 ++++++++ internal/model/principal_test.go | 72 +++ internal/model/session.go | 133 +++++ internal/model/session_test.go | 81 +++ internal/model/state.go | 43 ++ internal/service/admin.go | 342 ++++++++++++ internal/service/admin_test.go | 190 +++++++ internal/service/auth.go | 451 ++++++++++++++++ internal/service/auth_test.go | 248 +++++++++ internal/service/defines.go | 11 + internal/service/error.go | 82 +++ internal/service/mds.go | 96 ++++ internal/service/mds_test.go | 39 ++ internal/service/option.go | 61 +++ internal/service/service.go | 93 ++++ internal/store/etcd.go | 242 +++++++++ internal/store/etcd_test.go | 201 +++++++ internal/store/manager.go | 56 ++ internal/util/collection.go | 49 ++ internal/util/collection_test.go | 44 ++ internal/util/config.go | 19 + internal/util/http.go | 74 +++ internal/util/params.go | 169 ++++++ internal/util/params_test.go | 36 ++ internal/util/random.go | 42 ++ internal/util/retry.go | 38 ++ internal/util/validate.go | 9 + internal/version.go | 22 + workspace/localhost-key.pem | 28 + workspace/localhost.crt | 18 + workspace/localhost.key | 28 + workspace/localhost.pem | 26 + workspace/services.yml | 122 +++++ 69 files changed, 7670 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 api/http/admin.go create mode 100644 api/http/auth.go create mode 100644 api/http/handler.go create mode 100644 api/model/principal.go create mode 100644 api/model/session.go create mode 100644 cmd/admin-service/Dockerfile create mode 100644 cmd/admin-service/main.go create mode 100644 cmd/auth-service/Dockerfile create mode 100644 cmd/auth-service/main.go create mode 100644 cmd/migration/Dockerfile create mode 100644 cmd/migration/main.go create mode 100644 cmd/spec/admin.yaml create mode 100644 cmd/spec/main.go create mode 100644 cmd/spec/schemas.yaml create mode 100644 deploy/Dockerfile create mode 100644 deploy/README.md create mode 100644 dogpark-medium.png create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/db/conn.go create mode 100644 internal/db/statement.go create mode 100644 internal/db/testing.go create mode 100644 internal/geo/geolite.go create mode 100644 internal/geo/ipstack.go create mode 100644 internal/geo/resolver.go create mode 100644 internal/geo/resolver_test.go create mode 100644 internal/model/aaguid.go create mode 100644 internal/model/audit.go create mode 100644 internal/model/audit_test.go create mode 100644 internal/model/err.go create mode 100644 internal/model/key.go create mode 100644 internal/model/migrate.go create mode 100644 internal/model/principal.go create mode 100644 internal/model/principal_test.go create mode 100644 internal/model/session.go create mode 100644 internal/model/session_test.go create mode 100644 internal/model/state.go create mode 100644 internal/service/admin.go create mode 100644 internal/service/admin_test.go create mode 100644 internal/service/auth.go create mode 100644 internal/service/auth_test.go create mode 100644 internal/service/defines.go create mode 100644 internal/service/error.go create mode 100644 internal/service/mds.go create mode 100644 internal/service/mds_test.go create mode 100644 internal/service/option.go create mode 100644 internal/service/service.go create mode 100644 internal/store/etcd.go create mode 100644 internal/store/etcd_test.go create mode 100644 internal/store/manager.go create mode 100644 internal/util/collection.go create mode 100644 internal/util/collection_test.go create mode 100644 internal/util/config.go create mode 100644 internal/util/http.go create mode 100644 internal/util/params.go create mode 100644 internal/util/params_test.go create mode 100644 internal/util/random.go create mode 100644 internal/util/retry.go create mode 100644 internal/util/validate.go create mode 100644 internal/version.go create mode 100644 workspace/localhost-key.pem create mode 100644 workspace/localhost.crt create mode 100644 workspace/localhost.key create mode 100644 workspace/localhost.pem create mode 100644 workspace/services.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d159989 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/workspace/.env +**/builds diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b6a76d2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Ofte LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fc267af --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +lint: + golangci-lint run || true + +test: + go test ./... + +test-verbose: + go test -v ./... + +version=v0.9.0 + +LD_FLAGS = -X 'github.com/ofte-auth/dogpark/internal.commit=$(shell git rev-parse HEAD)' \ + -X 'github.com/ofte-auth/dogpark/internal.buildDate=$(shell date)' \ + -X 'github.com/ofte-auth/dogpark/internal.version=$(version)' + +build-containers: + docker build -t services-deploy:latest deploy + GOOS=linux go build -ldflags="${LD_FLAGS}" -o ./cmd/migration/builds/migrate-linux ./cmd/migration/main.go + GOOS=linux go build -ldflags="${LD_FLAGS}" -o ./cmd/auth-service/builds/auth-service-linux ./cmd/auth-service/main.go + GOOS=linux go build -ldflags="${LD_FLAGS}" -o ./cmd/admin-service/builds/admin-service-linux ./cmd/admin-service/main.go + @echo --- If next commands fail, execute: docker login registry.gitlab.com + @echo --- Enter your username and password/personal access token for gitlab to access the docker registry + docker build cmd/migration -t registry.gitlab.com/ofte/docker-registry/ofte-migrate-cmd:latest + docker build cmd/auth-service -t registry.gitlab.com/ofte/docker-registry/ofte-auth-service:latest + docker build cmd/admin-service -t registry.gitlab.com/ofte/docker-registry/ofte-admin-service:latest + +deploy: + @echo --- If next commands fail, execute: docker login registry.gitlab.com + @echo --- Enter your username and password/personal access token for gitlab to access the docker registry + docker push registry.gitlab.com/ofte/docker-registry/ofte-migrate-cmd:latest + docker push registry.gitlab.com/ofte/docker-registry/ofte-auth-service:latest + docker push registry.gitlab.com/ofte/docker-registry/ofte-admin-service:latest + +.PHONY: lint test test-verbose build-containers deploy diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa55793 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ + + +# Dogpark + +A platform to easily integrate and manage the FIDO keys used in any organization. + +[TOC] + +## Introduction + +## Concepts + +## Components + +## Integration + +## Building + +## Dependencies + +## Deploying + +## Example implmentation + +## FAQ + +## Contributing diff --git a/api/http/admin.go b/api/http/admin.go new file mode 100644 index 0000000..c0a64ea --- /dev/null +++ b/api/http/admin.go @@ -0,0 +1,627 @@ +package http + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/cors" + apimodel "github.com/ofte-auth/dogpark/api/model" + "github.com/ofte-auth/dogpark/internal" + "github.com/ofte-auth/dogpark/internal/model" + "github.com/ofte-auth/dogpark/internal/service" + "github.com/ofte-auth/dogpark/internal/store" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + config "github.com/spf13/viper" +) + +// AdminHandler implements the Admin REST API. +type AdminHandler struct { + Handler + + service service.Admin +} + +// NewAdminHandler creates a new Admin API endpoint. +func NewAdminHandler(ctx context.Context, options ...func(*Handler) error) (*AdminHandler, error) { + var err error + handler := &AdminHandler{ + Handler: Handler{ + name: "admin-http-handler", + }, + } + for _, option := range options { + err := option(&(handler).Handler) + if err != nil { + return nil, err + } + } + handler.service, err = service.NewAdminService(ctx, + service.OptionDB(handler.db), + service.OptionKV(handler.kv), + service.OptionGeoResolver(handler.geo), + service.OptionParams(handler.options), + ) + if err != nil { + return nil, err + } + return handler, nil +} + +// Init ... +func (handler *AdminHandler) Init() { + + // TODO: add in support for your authn/z mechanism for these endpoints + // (and then delete the line following) + log.Warning("Service running without authorization in place") + + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(30 * time.Second)) + r.Use(ClientContext) + + allowedOrigins := config.GetStringSlice("cors_allowed_origins") + for _, v := range allowedOrigins { + if v == "*" { + log.Warning("cors_allowed_origins configured without restriction (*)") + } + } + + cors := cors.New(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link", "Results-Page", "Results-Limit", "Results-Total"}, + MaxAge: 300, + }) + r.Use(cors.Handler) + r.Use(service.ErrorHandler) + + r.Route("/admin/v1", func(r chi.Router) { + + r.Get("/version", handler.getVersion) + + r.Group(func(r chi.Router) { + r.Route("/principals", func(r chi.Router) { + r.Get("/", handler.listPrincipals) + r.Post("/", handler.addPrincipal) + r.Get("/{principalID}", handler.getPrincipal) + r.Put("/{principalID}", handler.updatePrincipal) + }) + }) + + r.Group(func(r chi.Router) { + r.Route("/keys", func(r chi.Router) { + r.Get("/", handler.listKeys) + r.Get("/{keyID}", handler.getKey) + r.Put("/{keyID}", handler.updateKey) + r.Delete("/{keyID}", handler.deleteKey) + }) + }) + + r.Group(func(r chi.Router) { + r.Route("/aaguids", func(r chi.Router) { + r.Get("/", handler.listAAGUIDs) + r.Post("/", handler.createAAGUID) + r.Get("/{aaguid}", handler.getAAGUID) + r.Put("/{aaguid}", handler.updateAAGUID) + r.Get("/whitelist", handler.aaguidWhitelist) + r.Get("/blacklist", handler.aaguidBlacklist) + }) + }) + + r.Group(func(r chi.Router) { + r.Route("/sessions", func(r chi.Router) { + r.Get("/", handler.sessions) + r.Get("/{sessionID}", handler.getSession) + r.Delete("/{sessionID}", handler.killSession) + }) + }) + + r.Group(func(r chi.Router) { + r.Route("/logs", func(r chi.Router) { + r.Get("/", handler.listLogs) + r.Get("/{id}", handler.getLog) + }) + }) + }) + + handler.router = r +} + +func (handler *AdminHandler) getVersion(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(internal.Version())) + w.WriteHeader(200) +} + +// Stop ... +func (handler *AdminHandler) Stop() error { + handler.service.Stop() + return nil +} + +var baseParams = util.NewStringSet("limit", "page", "orderBy", "orderDirection", "createdBefore", "createdAfter", "since") + +var adminParamRestrictions = map[string]util.StringSet{ + "listPrincipals": baseParams.Copy().Add("state").Add("deep").Add("hasKeys"), + "listKeys": baseParams.Copy().Add("state"), + "listAAGUIDs": baseParams.Copy().Add("state"), + "listSessions": baseParams.Copy().Add("state"), + "listLogs": baseParams.Copy().Add("group").Add("action").Add("isAnomaly").Add("principalId").Add("principalUsername").Add("keyId").Add("sessionId").Add("ipAddr"), +} + +func (handler *AdminHandler) listPrincipals(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiParams, err := util.NewAPIParams(r, adminParamRestrictions["listPrincipals"]) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "parsing params"), "").BindHTTPRequest(r) + return + } + list, count, err := handler.service.Principals(ctx, apiParams) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "getting principals"), err.Error()).BindHTTPRequest(r) + return + } + apiParams.WritePaginationHeaders(w, count) + util.JSONResponse(w, list, 200) +} + +func (handler *AdminHandler) getPrincipal(w http.ResponseWriter, r *http.Request) { + var ( + p *model.Principal + err error + ) + ctx := r.Context() + id := chi.URLParam(r, "principalID") + if id == "" { + service.NewAPIError(400, errors.New("principal ID required"), "").BindHTTPRequest(r) + return + } + + p, err = handler.service.Principal(ctx, id) + if err == model.ErrRecordNotFound { + p, err = handler.service.PrincipalByUsername(ctx, id) + } + switch err { + case nil: + util.JSONResponse(w, p, 200) + case model.ErrRecordNotFound: + service.NewAPIError(404, errors.New("principal not found"), "").BindHTTPRequest(r) + return + default: + service.NewAPIError(500, errors.Wrap(err, "getting principal"), "").BindHTTPRequest(r) + return + } +} + +func (handler *AdminHandler) addPrincipal(w http.ResponseWriter, r *http.Request) { + var p *model.Principal + ctx := r.Context() + + var values map[string]string + body, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "reading request body"), "").BindHTTPRequest(r) + return + } + err = json.Unmarshal(body, &values) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "unmarshalling request body"), "").BindHTTPRequest(r) + return + } + p, err = handler.service.AddPrincipal(ctx, values) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "adding principal"), "").BindHTTPRequest(r) + return + } + util.JSONResponse(w, p, 201) +} + +func (handler *AdminHandler) updatePrincipal(w http.ResponseWriter, r *http.Request) { + var p *model.Principal + ctx := r.Context() + id := chi.URLParam(r, "principalID") + if id == "" { + service.NewAPIError(400, errors.New("principal ID required"), "").BindHTTPRequest(r) + return + } + + p, err := handler.service.Principal(ctx, id) + if err == model.ErrRecordNotFound { + p, err = handler.service.PrincipalByUsername(ctx, id) + } + switch err { + case nil: + // do nothing + case model.ErrRecordNotFound: + service.NewAPIError(404, errors.New("principal not found"), "").BindHTTPRequest(r) + return + default: + service.NewAPIError(400, errors.Wrap(err, "getting principal"), "").BindHTTPRequest(r) + return + } + var values map[string]string + body, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "reading request body"), "").BindHTTPRequest(r) + return + } + err = json.Unmarshal(body, &values) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "unmarshalling request body"), "").BindHTTPRequest(r) + return + } + p, _, err = handler.service.UpdatePrincipal(ctx, p.ID, values) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "updating principal"), "").BindHTTPRequest(r) + return + } + util.JSONResponse(w, p, 200) +} + +func (handler *AdminHandler) listKeys(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiParams, err := util.NewAPIParams(r, adminParamRestrictions["listKeys"]) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "parsing params"), "").BindHTTPRequest(r) + return + } + list, count, err := handler.service.FIDOKeys(ctx, apiParams) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "getting fidokeys"), "").BindHTTPRequest(r) + return + } + apiParams.WritePaginationHeaders(w, count) + + util.JSONResponse(w, list, 200) +} + +func (handler *AdminHandler) getKey(w http.ResponseWriter, r *http.Request) { + var ( + k *model.FIDOKey + err error + ) + ctx := r.Context() + id := chi.URLParam(r, "keyID") + if id == "" { + service.NewAPIError(400, errors.New("key ID required"), "").BindHTTPRequest(r) + return + } + k, err = handler.service.FIDOKey(ctx, id) + switch err { + case nil: + util.JSONResponse(w, k, 200) + case model.ErrRecordNotFound: + service.NewAPIError(404, errors.New("key not found"), "").BindHTTPRequest(r) + return + default: + service.NewAPIError(500, errors.Wrap(err, "getting key"), "").BindHTTPRequest(r) + return + } +} + +func (handler *AdminHandler) updateKey(w http.ResponseWriter, r *http.Request) { + var ( + k *model.FIDOKey + err error + ) + ctx := r.Context() + id := chi.URLParam(r, "keyID") + if id == "" { + service.NewAPIError(400, errors.New("authenticator ID required"), "").BindHTTPRequest(r) + return + } + k, err = handler.service.FIDOKey(ctx, id) + switch err { + case nil: + // do nothing + case model.ErrRecordNotFound: + service.NewAPIError(404, errors.New("key not found"), "").BindHTTPRequest(r) + return + default: + service.NewAPIError(400, errors.Wrap(err, "getting key"), "").BindHTTPRequest(r) + return + } + var values map[string]string + body, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "reading request body"), "").BindHTTPRequest(r) + return + } + err = json.Unmarshal(body, &values) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "unmarshalling request body"), "").BindHTTPRequest(r) + return + } + k, _, err = handler.service.UpdateFIDOKey(ctx, k.ID, values) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "updating authenticator"), "").BindHTTPRequest(r) + return + } + util.JSONResponse(w, k, 200) +} + +func (handler *AdminHandler) deleteKey(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := chi.URLParam(r, "keyID") + if id == "" { + service.NewAPIError(400, errors.New("key ID required"), "").BindHTTPRequest(r) + return + } + err := handler.service.DeleteFIDOKey(ctx, id) + switch err { + case nil: + w.WriteHeader(204) + case model.ErrRecordNotFound: + service.NewAPIError(404, errors.New("key not found"), "").BindHTTPRequest(r) + return + default: + service.NewAPIError(400, errors.Wrap(err, "getting key"), "").BindHTTPRequest(r) + return + } +} + +func (handler *AdminHandler) createAAGUID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var values map[string]string + body, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "reading request body"), "").BindHTTPRequest(r) + return + } + err = json.Unmarshal(body, &values) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "unmarshalling request body"), "").BindHTTPRequest(r) + return + } + aaguid, err := handler.service.AddAAGUID(ctx, values) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "adding AAGUID"), "").BindHTTPRequest(r) + return + } + util.JSONResponse(w, aaguid, 201) +} + +func (handler *AdminHandler) getAAGUID(w http.ResponseWriter, r *http.Request) { + var ( + aaguid *model.AAGUID + err error + ) + ctx := r.Context() + id := chi.URLParam(r, "aaguid") + if id == "" { + service.NewAPIError(400, errors.New("aaguid required"), "").BindHTTPRequest(r) + return + } + aaguid, err = handler.service.AAGUID(ctx, id) + switch err { + case nil: + util.JSONResponse(w, aaguid, 200) + case model.ErrRecordNotFound: + service.NewAPIError(404, errors.New("aaguid not found"), "").BindHTTPRequest(r) + return + default: + service.NewAPIError(400, errors.Wrap(err, "getting aaguid"), "").BindHTTPRequest(r) + return + } +} + +func (handler *AdminHandler) updateAAGUID(w http.ResponseWriter, r *http.Request) { + var ( + aaguid *model.AAGUID + err error + ) + ctx := r.Context() + id := chi.URLParam(r, "aaguid") + if id == "" { + service.NewAPIError(400, errors.New("aaguid parameter required"), "").BindHTTPRequest(r) + return + } + aaguid, err = handler.service.AAGUID(ctx, id) + switch err { + case nil: + // do nothing + case model.ErrRecordNotFound: + service.NewAPIError(404, errors.New("aaguid not found"), "").BindHTTPRequest(r) + return + default: + service.NewAPIError(400, errors.Wrap(err, "getting key"), "").BindHTTPRequest(r) + return + } + var values map[string]string + body, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "reading request body"), "").BindHTTPRequest(r) + return + } + err = json.Unmarshal(body, &values) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "unmarshalling request body"), "").BindHTTPRequest(r) + return + } + aaguid, _, err = handler.service.UpdateAAGUID(ctx, aaguid.ID, values) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "updating aaguid"), "").BindHTTPRequest(r) + return + } + util.JSONResponse(w, aaguid, 200) +} + +func (handler *AdminHandler) listAAGUIDs(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiParams, err := util.NewAPIParams(r, adminParamRestrictions["listAAGUIDs"]) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "parsing params"), "").BindHTTPRequest(r) + return + } + resp, count, err := handler.service.AAGUIDs(ctx, apiParams) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "getting aaguids"), "").BindHTTPRequest(r) + return + } + apiParams.WritePaginationHeaders(w, count) + util.JSONResponse(w, resp, 200) +} + +func (handler *AdminHandler) aaguidWhitelist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + resp, err := handler.service.AAGUIDWhitelist(ctx) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "getting aaguid whitelist"), "").BindHTTPRequest(r) + return + } + util.JSONResponse(w, resp.Values(), 200) +} + +func (handler *AdminHandler) aaguidBlacklist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + resp, err := handler.service.AAGUIDBlacklist(ctx) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "getting aaguid blacklist"), "").BindHTTPRequest(r) + return + } + util.JSONResponse(w, resp.Values(), 200) +} + +func (handler *AdminHandler) sessions(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiParams, err := util.NewAPIParams(r, adminParamRestrictions["listSessions"]) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "parsing params"), "").BindHTTPRequest(r) + return + } + resp, count, err := handler.service.Sessions(ctx, apiParams) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "getting sessions"), "").BindHTTPRequest(r) + return + } + apiParams.WritePaginationHeaders(w, count) + list := []*apimodel.Session{} + for _, v := range resp { + entry, err := handler.geo.Resolve(v.IPAddr) + if err != nil { + log.Errorln(err) + continue + } + entry.UserAgent = v.UserAgent + entry.Timestamp = v.UpdatedAt + list = append(list, &apimodel.Session{ + GeoEntry: *entry, + SessionID: v.ID, + State: v.State, + FIDOKeyID: v.FIDOKeyID, + AAGUID: v.AAGUID, + UserID: v.PrincipalID, + Username: v.PrincipalUsername, + Age: time.Since(v.CreatedAt).Truncate(time.Second).String(), + }) + } + util.JSONResponse(w, list, 200) +} + +func (handler *AdminHandler) getSession(w http.ResponseWriter, r *http.Request) { + var ( + session *model.Session + err error + ) + ctx := r.Context() + id := chi.URLParam(r, "sessionID") + if id == "" { + service.NewAPIError(400, errors.New("session ID required"), "").BindHTTPRequest(r) + return + } + session, err = handler.service.Session(ctx, id) + switch err { + case nil: + util.JSONResponse(w, session, 200) + case store.ErrorNoRecord: + service.NewAPIError(404, errors.New("session not found"), "").BindHTTPRequest(r) + return + default: + service.NewAPIError(400, errors.Wrap(err, "getting session"), "").BindHTTPRequest(r) + return + } +} + +func (handler *AdminHandler) killSession(w http.ResponseWriter, r *http.Request) { + var ( + err error + session *model.Session + ) + ctx := r.Context() + id := chi.URLParam(r, "sessionID") + if id == "" { + service.NewAPIError(400, errors.New("session ID required"), "").BindHTTPRequest(r) + return + } + session, err = handler.service.KillSession(ctx, id) + switch err { + case nil: + util.JSONResponse(w, session, 200) + case store.ErrorNoRecord: + service.NewAPIError(404, errors.New("session not found"), "").BindHTTPRequest(r) + return + default: + service.NewAPIError(400, errors.Wrap(err, "revoking session"), "").BindHTTPRequest(r) + return + } +} + +func (handler *AdminHandler) getLog(w http.ResponseWriter, r *http.Request) { + var ( + err error + entry *model.AuditEntry + ) + ctx := r.Context() + id := chi.URLParam(r, "id") + err = util.Validate.Var(id, "required,numeric,gt=0") + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "parsing id parameter"), err.Error()).BindHTTPRequest(r) + return + } + entryID, _ := strconv.ParseInt(id, 10, 64) + + entry, err = handler.service.LogByID(ctx, entryID) + switch err { + case nil: + util.JSONResponse(w, entry, 200) + case store.ErrorNoRecord: + service.NewAPIError(404, errors.New("entry not found"), "").BindHTTPRequest(r) + return + default: + service.NewAPIError(400, errors.Wrap(err, "getting entry"), "").BindHTTPRequest(r) + return + } +} + +func (handler *AdminHandler) listLogs(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiParams, err := util.NewAPIParams(r, adminParamRestrictions["listLogs"]) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "parsing params"), "").BindHTTPRequest(r) + return + } + list, count, err := handler.service.Logs(ctx, apiParams) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "getting log entries"), "").BindHTTPRequest(r) + return + } + apiParams.WritePaginationHeaders(w, count) + + util.JSONResponse(w, list, 200) +} diff --git a/api/http/auth.go b/api/http/auth.go new file mode 100644 index 0000000..d714c57 --- /dev/null +++ b/api/http/auth.go @@ -0,0 +1,211 @@ +package http + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "time" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/cors" + "github.com/ofte-auth/dogpark/api/model" + "github.com/ofte-auth/dogpark/internal" + "github.com/ofte-auth/dogpark/internal/service" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + config "github.com/spf13/viper" + "go.uber.org/multierr" +) + +// AuthHandler implements the Auth REST API. +type AuthHandler struct { + Handler + + service service.Auth +} + +// NewAuthHandler creates a new Auth API endpoint. +func NewAuthHandler(ctx context.Context, options ...func(*Handler) error) (*AuthHandler, error) { + var err error + handler := &AuthHandler{ + Handler: Handler{ + name: "auth-http-handler", + }, + } + + for _, option := range options { + err := option(&(handler).Handler) + if err != nil { + return nil, err + } + } + + handler.service, err = service.NewAuthService(ctx, + service.OptionDB(handler.db), + service.OptionKV(handler.kv), + service.OptionRP(handler.options["rpDisplayName"], handler.options["rpID"], handler.options["rpOrigin"]), + service.OptionGeoResolver(handler.geo), + ) + if err != nil { + return nil, err + } + return handler, nil +} + +// Init sets up the Dogpark HTTP handlers for FIDO registration and authorization. +// Note that there is no first factor authn/z employed here. +// +// Suggestions: +// - restrict the `cors_allowed_origins` to your webapp in which first factor authn is occuring +// - add your own authz middleware that checks authn header for valid session (and add to client) +func (handler *AuthHandler) Init() { + r := chi.NewRouter() + + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(30 * time.Second)) + r.Use(ClientContext) + + allowedOrigins := config.GetStringSlice("cors_allowed_origins") + for _, v := range allowedOrigins { + if v == "*" { + log.Warning("cors_allowed_origins configured without restriction (*)") + } + } + + cors := cors.New(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "Ofte-SessionID", "Ofte-AccessToken"}, + ExposedHeaders: []string{"Link", "Ofte-AccessToken"}, + MaxAge: 300, + }) + r.Use(cors.Handler) + r.Use(service.ErrorHandler) + + r.Route("/auth/v1", func(r chi.Router) { + + r.Get("/version", handler.getVersion) + r.Post("/principals", handler.getOrCreatePrincipal) + + r.Get("/start_fido_registration/{username}", handler.startFIDORegistration) + r.Post("/finish_fido_registration/{username}", handler.finishFIDORegistration) + r.Get("/start_fido_login/{username}", handler.startFIDOLogin) + r.Post("/finish_fido_login/{username}", handler.finishFIDOLogin) + }) + + handler.router = r +} + +// Stop ... +func (handler *AuthHandler) Stop() error { + handler.service.Stop() + return nil +} + +func (handler *AuthHandler) getVersion(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(internal.Version())) + w.WriteHeader(200) +} + +func (handler *AuthHandler) getOrCreatePrincipal(w http.ResponseWriter, r *http.Request) { + var ( + params map[string]string + ) + val := util.Validate.Var + body, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "reading request body"), "").BindHTTPRequest(r) + return + } + ctx := r.Context() + now := time.Now() + err = json.Unmarshal(body, ¶ms) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "unmarshalling request body"), "").BindHTTPRequest(r) + return + } + err = val(params["username"], "required") + err = multierr.Append(err, val(params["displayName"], "required")) + if err != nil { + service.NewAPIError(400, errors.Wrap(err, "required parameters missing"), "").BindHTTPRequest(r) + return + } + p, apiError := handler.service.GetOrCreatePrincipal(ctx, params) + if apiError != nil { + apiError.BindHTTPRequest(r) + return + } + code := 200 + if p.CreatedAt.After(now) { + code = 201 + } + util.JSONResponse(w, p, code) +} + +func (handler *AuthHandler) startFIDORegistration(w http.ResponseWriter, r *http.Request) { + username := chi.URLParam(r, "username") + if username == "" { + service.NewAPIError(400, errors.New("username required"), "starting FIDO registration").BindHTTPRequest(r) + return + } + options, err := handler.service.StartFIDORegistration(r.Context(), username) + if err != nil { + err.BindHTTPRequest(r) + return + } + util.JSONResponse(w, options, 200) +} + +func (handler *AuthHandler) finishFIDORegistration(w http.ResponseWriter, r *http.Request) { + username := chi.URLParam(r, "username") + if username == "" { + service.NewAPIError(400, errors.New("username required"), "finishing FIDO registration").BindHTTPRequest(r) + return + } + a11r, err := handler.service.FinishFIDORegistration(r.Context(), username, r) + if err != nil { + err.BindHTTPRequest(r) + return + } + util.JSONResponse(w, a11r, 200) +} + +func (handler *AuthHandler) startFIDOLogin(w http.ResponseWriter, r *http.Request) { + username := chi.URLParam(r, "username") + if username == "" { + service.NewAPIError(400, errors.New("username required"), "starting FIDO login").BindHTTPRequest(r) + return + } + assert, err := handler.service.StartFIDOLogin(r.Context(), username) + if err != nil { + err.BindHTTPRequest(r) + return + } + util.JSONResponse(w, assert, 200) +} + +func (handler *AuthHandler) finishFIDOLogin(w http.ResponseWriter, r *http.Request) { + username := chi.URLParam(r, "username") + if username == "" { + service.NewAPIError(400, errors.New("username required"), "starting FIDO login").BindHTTPRequest(r) + return + } + res, err := handler.service.FinishFIDOLogin(r.Context(), username, r) + if err != nil { + err.BindHTTPRequest(r) + return + } + p := &model.Principal{ + ID: res.ID, + Username: res.Username, + Icon: res.Icon, + } + util.JSONResponse(w, p, 200) +} diff --git a/api/http/handler.go b/api/http/handler.go new file mode 100644 index 0000000..e16c772 --- /dev/null +++ b/api/http/handler.go @@ -0,0 +1,127 @@ +package http + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-chi/chi" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/geo" + "github.com/ofte-auth/dogpark/internal/service" + "github.com/ofte-auth/dogpark/internal/store" + "github.com/ofte-auth/dogpark/internal/util" + log "github.com/sirupsen/logrus" +) + +// Handler is a base http-handling object. +type Handler struct { + name string + router *chi.Mux + db db.DB + kv store.Manager + geo geo.Resolver + ipAddress string + httpPort int + tlsCertificateFile string + tlsPrivateKeyFile string + options map[string]string +} + +// OptionDB applies a db connection option. +func OptionDB(db db.DB) func(*Handler) error { + return func(handler *Handler) error { + handler.db = db + return nil + } +} + +// OptionKV applies a key value manager option. +func OptionKV(kv store.Manager) func(*Handler) error { + return func(handler *Handler) error { + handler.kv = kv + return nil + } +} + +// OptionIPAddress applies a IP address option. +func OptionIPAddress(ipAddress string) func(*Handler) error { + return func(handler *Handler) error { + handler.ipAddress = ipAddress + return nil + } +} + +// OptionHTTPPort applies a TCP port option, used by the http handler. +func OptionHTTPPort(port int) func(*Handler) error { + return func(handler *Handler) error { + handler.httpPort = port + return nil + } +} + +// OptionGeoResolver applies a geo resolver option. +func OptionGeoResolver(geo geo.Resolver) func(*Handler) error { + return func(handler *Handler) error { + handler.geo = geo + return nil + } +} + +// OptionTLS applies TLS parameters, used by the http handler. +func OptionTLS(certFile, keyFile string) func(*Handler) error { + return func(handler *Handler) error { + if certFile == "" && keyFile == "" { + return nil + } + handler.tlsCertificateFile = certFile + handler.tlsPrivateKeyFile = keyFile + return nil + } +} + +// OptionParams applies a name,value option, more than one can be added. +func OptionParams(key, value string) func(*Handler) error { + return func(handler *Handler) error { + if handler.options == nil { + handler.options = make(map[string]string) + } + handler.options[key] = value + return nil + } +} + +func healthz(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte("ok")) +} + +// Start commences http handling. +func (handler *Handler) Start() error { + handler.router.Get("/healthz", healthz) + + address := fmt.Sprintf("%s:%d", handler.ipAddress, handler.httpPort) + if handler.tlsCertificateFile == "" { + log.WithFields(log.Fields{ + "service": handler.name, + "port": handler.httpPort, + }).Info("Starting http handler") + return http.ListenAndServe(address, handler.router) + } + log.WithFields(log.Fields{ + "service": handler.name, + "port": handler.httpPort, + }).Info("Starting https handler") + return http.ListenAndServeTLS(address, handler.tlsCertificateFile, handler.tlsPrivateKeyFile, handler.router) +} + +// ClientContext is http middleware that adds a request's ip address and +// user agent to the context. +func ClientContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ipAddr := util.ClientIP(r) + ctx := context.WithValue(r.Context(), service.ContextIPAddr, ipAddr) + ctx = context.WithValue(ctx, service.ContextUserAgent, r.UserAgent()) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/api/model/principal.go b/api/model/principal.go new file mode 100644 index 0000000..0ed349b --- /dev/null +++ b/api/model/principal.go @@ -0,0 +1,9 @@ +package model + +// Principal defines a user. +type Principal struct { + ID string `json:"id"` + Username string `json:"username"` + Icon string `json:"icon,omitempty"` + CASessionID string `json:"caSessionID,omitempty"` +} diff --git a/api/model/session.go b/api/model/session.go new file mode 100644 index 0000000..0582349 --- /dev/null +++ b/api/model/session.go @@ -0,0 +1,18 @@ +package model + +import ( + "github.com/ofte-auth/dogpark/internal/geo" +) + +// Session defines data describing an a11r session. +type Session struct { + geo.GeoEntry + + SessionID string `json:"session"` + State string `json:"state"` + FIDOKeyID string `json:"keyId"` + AAGUID string `json:"aaguid"` + UserID string `json:"userId"` + Username string `json:"username"` + Age string `json:"age"` +} diff --git a/cmd/admin-service/Dockerfile b/cmd/admin-service/Dockerfile new file mode 100644 index 0000000..38e6a78 --- /dev/null +++ b/cmd/admin-service/Dockerfile @@ -0,0 +1,6 @@ +FROM golang:1.13-alpine3.11 +FROM services-deploy:latest + +ADD builds/admin-service-linux /bin/ofte-admin-service + +ENTRYPOINT ["/bin/ofte-admin-service"] \ No newline at end of file diff --git a/cmd/admin-service/main.go b/cmd/admin-service/main.go new file mode 100644 index 0000000..7c9873f --- /dev/null +++ b/cmd/admin-service/main.go @@ -0,0 +1,88 @@ +// Copyright (c) 2020 Ofte LLC, +// subject to the terms and conditions defined in the file LICENSE + +package main + +import ( + "fmt" + "os" + "time" + + "github.com/ofte-auth/dogpark/internal/geo" + + "github.com/fraugster/cli" + "github.com/ofte-auth/dogpark/api/http" + "github.com/ofte-auth/dogpark/internal" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/store" + "github.com/ofte-auth/dogpark/internal/util" + log "github.com/sirupsen/logrus" + config "github.com/spf13/viper" +) + +func main() { + var ( + err error + dbConn db.DB + ) + fmt.Println(internal.VersionVerbose()) + + util.InitConfig() + ctx := cli.Context() + + err = util.Retry(10, 250*time.Millisecond, func() error { + dbConn, err = db.GetConnection(util.AllConfigSettings()) + if err != nil { + log.WithError(err).WithField("service", "auth").Warning("Error connecting to db, retrying") + } + return err + }) + if err != nil { + log.WithError(err).WithField("service", "auth").Warning("Unable to connect to db, exiting") + os.Exit(1) + } + + var kv store.Manager + cfg := store.EtcdConfig{ + Endpoints: config.GetStringSlice("kv_endpoints"), + } + err = util.Retry(10, 250*time.Millisecond, func() error { + kv, err = store.NewManager(ctx, cfg) + if err != nil { + log.WithError(err).WithField("service", "auth").Warning("Error connecting to kv store, retrying") + } + return err + }) + if err != nil { + log.WithError(err).WithField("service", "auth").Warning("Unable to connect to kv store, exiting") + os.Exit(1) + } + + geoConfig := &geo.IPStackConfig{APIKey: config.GetString("ipstack_access_key")} + geoResolver, err := geo.NewGeoResolver(geoConfig) + if err != nil { + panic(err) + } + + httpService, err := http.NewAdminHandler(ctx, + http.OptionDB(dbConn), + http.OptionKV(kv), + http.OptionHTTPPort(config.GetInt("http_port")), + http.OptionTLS(config.GetString("tls_certificate_file"), config.GetString("tls_private_key_file")), + http.OptionGeoResolver(geoResolver), + http.OptionParams("fido_mds_token", config.GetString("fido_mds_token")), + ) + if err != nil { + panic(err) + } + httpService.Init() + go func() { + err = httpService.Start() + if err != nil { + panic(err) + } + }() + + <-ctx.Done() + _ = httpService.Stop() +} diff --git a/cmd/auth-service/Dockerfile b/cmd/auth-service/Dockerfile new file mode 100644 index 0000000..11750b1 --- /dev/null +++ b/cmd/auth-service/Dockerfile @@ -0,0 +1,6 @@ +FROM golang:1.13-alpine3.11 +FROM services-deploy:latest + +ADD builds/auth-service-linux /bin/ofte-auth-service + +ENTRYPOINT ["/bin/ofte-auth-service"] \ No newline at end of file diff --git a/cmd/auth-service/main.go b/cmd/auth-service/main.go new file mode 100644 index 0000000..de414e0 --- /dev/null +++ b/cmd/auth-service/main.go @@ -0,0 +1,89 @@ +// Copyright (c) 2020 Ofte LLC, +// subject to the terms and conditions defined in the file LICENSE + +package main + +import ( + "fmt" + "os" + "time" + + "github.com/fraugster/cli" + "github.com/ofte-auth/dogpark/api/http" + "github.com/ofte-auth/dogpark/internal" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/geo" + "github.com/ofte-auth/dogpark/internal/store" + "github.com/ofte-auth/dogpark/internal/util" + log "github.com/sirupsen/logrus" + config "github.com/spf13/viper" +) + +func main() { + var ( + err error + dbConn db.DB + ) + fmt.Println(internal.VersionVerbose()) + + util.InitConfig() + ctx := cli.Context() + + err = util.Retry(10, 250*time.Millisecond, func() error { + dbConn, err = db.GetConnection(util.AllConfigSettings()) + if err != nil { + log.WithError(err).WithField("service", "auth").Warning("Error connecting to db, retrying") + } + return err + }) + if err != nil { + log.WithError(err).WithField("service", "auth").Warning("Unable to connect to db, exiting") + os.Exit(1) + } + + var kv store.Manager + cfg := store.EtcdConfig{ + Endpoints: config.GetStringSlice("kv_endpoints"), + } + err = util.Retry(10, 250*time.Millisecond, func() error { + kv, err = store.NewManager(ctx, cfg) + if err != nil { + log.WithError(err).WithField("service", "auth").Warning("Error connecting to kv store, retrying") + } + return err + }) + if err != nil { + log.WithError(err).WithField("service", "auth").Warning("Unable to connect to kv store, exiting") + os.Exit(1) + } + + geoConfig := &geo.IPStackConfig{APIKey: config.GetString("ipstack_access_key")} + geoResolver, err := geo.NewGeoResolver(geoConfig) + if err != nil { + panic(err) + } + + httpService, err := http.NewAuthHandler(ctx, + http.OptionDB(dbConn), + http.OptionKV(kv), + http.OptionHTTPPort(config.GetInt("http_port")), + http.OptionTLS(config.GetString("tls_certificate_file"), config.GetString("tls_private_key_file")), + http.OptionGeoResolver(geoResolver), + http.OptionParams("rpDisplayName", config.GetString("rp_display_name")), + http.OptionParams("rpID", config.GetString("rp_id")), + http.OptionParams("rpOrigin", config.GetString("rp_origin")), + ) + if err != nil { + panic(err) + } + httpService.Init() + go func() { + err = httpService.Start() + if err != nil { + panic(err) + } + }() + + <-ctx.Done() + _ = httpService.Stop() +} diff --git a/cmd/migration/Dockerfile b/cmd/migration/Dockerfile new file mode 100644 index 0000000..fe43d6a --- /dev/null +++ b/cmd/migration/Dockerfile @@ -0,0 +1,5 @@ +FROM golang:1.13-alpine3.11 + +ADD builds/migrate-linux /bin/ofte-migrate + +ENTRYPOINT ["/bin/ofte-migrate"] \ No newline at end of file diff --git a/cmd/migration/main.go b/cmd/migration/main.go new file mode 100644 index 0000000..a4bca6c --- /dev/null +++ b/cmd/migration/main.go @@ -0,0 +1,47 @@ +// Copyright (c) 2020 Ofte LLC, +// subject to the terms and conditions defined in the file LICENSE + +package main + +import ( + "fmt" + "os" + "time" + + "github.com/ofte-auth/dogpark/internal" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/model" + "github.com/ofte-auth/dogpark/internal/util" + log "github.com/sirupsen/logrus" +) + +// Migrates the dogpark database tables and indexes. +func main() { + var ( + err error + dbConn db.DB + ) + + fmt.Println(internal.VersionVerbose()) + + util.InitConfig() + + err = util.Retry(10, 250*time.Millisecond, func() error { + dbConn, err = db.GetConnection(util.AllConfigSettings()) + if err != nil { + log.WithError(err).WithField("service", "auth").Warning("Error connecting to db, retrying") + } + return err + }) + if err != nil { + log.WithError(err).WithField("service", "auth").Warning("Unable to connect to db, exiting") + os.Exit(1) + } + + err = model.Migrate(dbConn) + if err != nil { + panic(err) + } + + fmt.Println("Migrate succeeded") +} diff --git a/cmd/spec/admin.yaml b/cmd/spec/admin.yaml new file mode 100644 index 0000000..a752c54 --- /dev/null +++ b/cmd/spec/admin.yaml @@ -0,0 +1,885 @@ +info: + license: + name: MIT + url: 'https://github.com/ofte-auth/dogpark/LICENSE.md' + title: Ofte Dogpark Admin + version: 0.9.0 + contact: + email: info@ofte.io + termsOfService: 'https://github.com/ofte-auth/dogpark/LICENSE.md' + description: | + The Ofte Dogpark Admin REST API. + + **Note that the base implementation of this API as found in the repo has no authentication mechanism in place.** +openapi: 3.0.0 +tags: + - name: principals + description: | + Operations dealing with managed principals (people). + + Dogpark manages data for principals that use FIDO keys. We only accept/store public information about the principal. Sensitive passwords and other artifacts are your (or your IDM's) responsibility. + - name: keys + description: | + Operations dealing with the keys generated from FIDO authenticators. + + We only store public key information. There could be multiple keys generated by a single authenticator. + - name: aaguids + description: | + Operations dealing AAGUIDs (authenticator attestation IDs). + + AAGUIDs uniquely identify classes of authenticators—which provides the ability to allow or block certain authenticators (white/blacklist). + - name: logs + description: Operations dealing with auditing logs +servers: + - url: 'https://localhost:2358' +paths: + /admin/v1/principals: + get: + summary: Get principals (known to Dogpark) + operationId: get-admin-v1-principals + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/v1.Principal' + headers: + Results-Page: + $ref: '#/components/headers/resultsPage' + Results-Limit: + $ref: '#/components/headers/resultsLimit' + Results-Total: + $ref: '#/components/headers/resultsTotal' + '400': + $ref: '#/components/responses/invalidRequest' + '500': + $ref: '#/components/responses/systemException' + description: get principals + tags: + - principals + parameters: + - schema: + type: boolean + in: query + name: hasKeys + description: Restrict results to principals that have registered keys + - schema: + type: string + enum: + - createdAt + - username + - state + in: query + name: orderBy + description: Order the results + - $ref: '#/components/parameters/orderDirectionParam' + - $ref: '#/components/parameters/stateParam' + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/limitParam' + - $ref: '#/components/parameters/sinceParam' + - $ref: '#/components/parameters/createdBeforeParam' + - $ref: '#/components/parameters/createdAfterParam' + - $ref: '#/components/parameters/deepParam' + post: + summary: Add a new principal + operationId: post-admin-v1-principal + requestBody: + required: true + content: + application/json: + schema: + properties: + id: + type: string + username: + type: string + example: jane@example.com + displayName: + type: string + example: Jane Example + icon: + type: string + example: https://example.com/icons/jane.jpg + state: + type: string + enum: + - active + - revoked + type: object + responses: + '201': + description: If added + content: + application/json: + schema: + $ref: '#/components/schemas/v1.Principal' + '400': + $ref: '#/components/responses/invalidRequest' + '500': + $ref: '#/components/responses/systemException' + description: update principal + tags: + - principals + /admin/v1/principal/{id}: + get: + summary: Get a principal + operationId: get-admin-v1-principal + parameters: + - in: path + name: id + schema: + type: string + required: true + description: the id or username of the principal to retrieve + responses: + '200': + description: If found + content: + application/json: + schema: + $ref: '#/components/schemas/v1.Principal' + '400': + $ref: '#/components/responses/invalidRequest' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/systemException' + description: get principal + tags: + - principals + put: + summary: Update a principal + operationId: put-admin-v1-principal + parameters: + - in: path + name: id + schema: + type: string + required: true + description: the id or username of the principal to update + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/v1.Principal' + responses: + '200': + description: If updated + content: + application/json: + schema: + $ref: '#/components/schemas/v1.Principal' + '400': + $ref: '#/components/responses/invalidRequest' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/systemException' + description: update principal + tags: + - principals + /admin/v1/keys: + get: + summary: Get keys managed by Dogpark + operationId: get-admin-v1-keys + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/v1.Key' + headers: + Results-Page: + $ref: '#/components/headers/resultsPage' + Results-Limit: + $ref: '#/components/headers/resultsLimit' + Results-Total: + $ref: '#/components/headers/resultsTotal' + '400': + $ref: '#/components/responses/invalidRequest' + '500': + $ref: '#/components/responses/systemException' + description: get keys + tags: + - keys + parameters: + - schema: + type: string + enum: + - createdAt + - state + - lastUsed + - aaguid + - notValidBefore + - notValidAfter + - principalUsername + - certCommonName + - certOrganization + - certSerial + in: query + name: orderBy + description: Order the results + - $ref: '#/components/parameters/orderDirectionParam' + - $ref: '#/components/parameters/stateParam' + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/limitParam' + - $ref: '#/components/parameters/sinceParam' + - $ref: '#/components/parameters/createdBeforeParam' + - $ref: '#/components/parameters/createdAfterParam' + /admin/v1/keys/{id}: + get: + summary: Get a FIDO key + operationId: get-admin-v1-key + parameters: + - in: path + name: id + schema: + type: string + required: true + description: the id of the key to retrieve + responses: + '200': + description: If found + content: + application/json: + schema: + $ref: '#/components/schemas/v1.Key' + '400': + $ref: '#/components/responses/invalidRequest' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/systemException' + description: get key + tags: + - keys + put: + summary: Update a FIDO key + operationId: put-admin-v1-key + parameters: + - in: path + name: id + schema: + type: string + required: true + description: the id of the FIDO key to update + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/v1.Key' + responses: + '200': + description: If updated + content: + application/json: + schema: + $ref: '#/components/schemas/v1.Key' + '400': + $ref: '#/components/responses/invalidRequest' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/systemException' + description: update key + tags: + - keys + delete: + summary: Delete a FIDO key + operationId: delete-admin-v1-key + parameters: + - in: path + name: id + schema: + type: string + required: true + description: the id of the FIDO key to delete + responses: + '204': + description: If deleted + '400': + $ref: '#/components/responses/invalidRequest' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/systemException' + description: delete key + tags: + - keys + /admin/v1/aaguids: + get: + summary: Get aaguids managed by Dogpark + operationId: get-admin-v1-aaguids + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/v1.AAGUID' + headers: + Results-Page: + $ref: '#/components/headers/resultsPage' + Results-Limit: + $ref: '#/components/headers/resultsLimit' + Results-Total: + $ref: '#/components/headers/resultsTotal' + '400': + $ref: '#/components/responses/invalidRequest' + '500': + $ref: '#/components/responses/systemException' + description: get aaguids + tags: + - aaguids + parameters: + - schema: + type: string + enum: + - state + in: query + name: orderBy + description: Order the results + - $ref: '#/components/parameters/orderDirectionParam' + - $ref: '#/components/parameters/stateParam' + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/limitParam' + post: + summary: Create a new aaguid + operationId: post-admin-v1-aaguid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/v1.AAGUID' + responses: + '201': + description: If added + content: + application/json: + schema: + $ref: '#/components/schemas/v1.AAGUID' + '400': + $ref: '#/components/responses/invalidRequest' + '500': + $ref: '#/components/responses/systemException' + description: create aaguid + tags: + - aaguids + /admin/v1/aaguid/{id}: + get: + summary: Get an aaguid + operationId: get-admin-v1-aaguid + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + description: the id the aaguid to retrieve + responses: + '200': + description: If found + content: + application/json: + schema: + $ref: '#/components/schemas/v1.AAGUID' + '400': + $ref: '#/components/responses/invalidRequest' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/systemException' + description: get aaguid + tags: + - aaguids + put: + summary: Update an aaguid + operationId: put-admin-v1-aaguid + parameters: + - in: path + name: id + schema: + type: string + required: true + description: the id the aaguid to update + requestBody: + required: true + content: + application/json: + schema: + properties: + label: + type: string + example: New aaguid for keys + state: + type: string + enum: + - active + - revoked + type: object + responses: + '200': + description: If updated + content: + application/json: + schema: + $ref: '#/components/schemas/v1.AAGUID' + '400': + $ref: '#/components/responses/invalidRequest' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/systemException' + description: update aaguid + tags: + - aaguids + /admin/v1/aaguids/whitelist: + get: + summary: Get whitelisted aaguids + operationId: get-admin-v1-aaguids-whitelist + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: string + format: uuid + '400': + $ref: '#/components/responses/invalidRequest' + '500': + $ref: '#/components/responses/systemException' + description: If there is a whitelist, only authenticators that match one of the whitelisted aaguids are valid. + tags: + - aaguids + /admin/v1/aaguids/blacklist: + get: + summary: Returns all blacklisted aaguids managed by Dogpark + operationId: get-admin-v1-aaguids-blacklist + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: string + format: uuid + '400': + $ref: '#/components/responses/invalidRequest' + '500': + $ref: '#/components/responses/systemException' + description: If there is a blacklist, authenticators that match one of the blacklisted aaguids will not be valid. + tags: + - aaguids + + /admin/v1/logs: + get: + summary: Get log/audit entries + description: | + Dogpark maintains logging for all auth and admin operations. + operationId: get-admin-v1-logs + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/v1.Log' + headers: + Results-Page: + $ref: '#/components/headers/resultsPage' + Results-Limit: + $ref: '#/components/headers/resultsLimit' + Results-Total: + $ref: '#/components/headers/resultsTotal' + '400': + $ref: '#/components/responses/invalidRequest' + '500': + $ref: '#/components/responses/systemException' + tags: + - logs + parameters: + - schema: + type: string + enum: + - action + - group + - anomaly + - createdAt + - aaguid + - principalId + - principalUsername + - certCommonName + - certOrganization + - certSerial + in: query + name: orderBy + description: Order the results + - $ref: '#/components/parameters/orderDirectionParam' + - $ref: '#/components/parameters/pageParam' + - $ref: '#/components/parameters/limitParam' + - $ref: '#/components/parameters/sinceParam' + - $ref: '#/components/parameters/createdBeforeParam' + - $ref: '#/components/parameters/createdAfterParam' + /admin/v1/logs/{id}: + get: + summary: Get a log entry + operationId: get-admin-v1-log + parameters: + - in: path + name: id + schema: + type: string + required: true + description: the id of the log entry to retrieve + responses: + '200': + description: If found + content: + application/json: + schema: + $ref: '#/components/schemas/v1.Log' + '400': + $ref: '#/components/responses/invalidRequest' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/systemException' + description: get log entry + tags: + - logs + +components: + schemas: + v1.AAGUID: + properties: + id: + format: uuid + type: string + label: + type: string + example: Yubico U2F Root CA Serial 457200631 + metadata: + readOnly: true + format: byte + type: string + state: + type: string + enum: + - active + - revoked + type: object + v1.Key: + type: object + properties: + aaguid: + readOnly: true + type: string + format: uuid + attestationType: + readOnly: true + type: string + certCommonName: + readOnly: true + type: string + certOrganization: + readOnly: true + type: string + certSerial: + readOnly: true + format: int64 + type: integer + createdAt: + readOnly: true + format: date-time + type: string + id: + readOnly: true + type: string + lastUsed: + readOnly: true + format: date-time + type: string + modifiedAt: + readOnly: true + format: date-time + type: string + notValidAfter: + readOnly: true + format: date-time + type: string + notValidBefore: + readOnly: true + format: date-time + type: string + principalId: + readOnly: true + type: string + publicKey: + readOnly: true + format: byte + type: string + state: + type: string + enum: + - active + - revoked + username: + readOnly: true + type: string + v1.Log: + properties: + group: + type: string + example: auth + action: + type: string + example: finishFIDOLogin + city: + type: string + example: Berlin + country: + type: string + example: Germany + createdAt: + format: date-time + type: string + data: + type: string + anomaly: + type: string + fidoAAGUID: + type: string + format: uuid + fidoKeyId: + type: string + id: + format: int64 + type: integer + ipAaddr: + type: string + latitude: + type: number + longitude: + type: number + principalId: + type: string + principalUsername: + type: string + region: + type: string + sessionId: + type: string + userAgent: + type: string + type: object + v1.Principal: + properties: + createdAt: + readOnly: true + format: date-time + type: string + displayName: + type: string + example: Joe Example + fidoKeys: + readOnly: true + items: + properties: + aaguid: + type: string + attestationType: + type: string + certCommonName: + type: string + certOrganization: + type: string + certSerial: + format: int64 + type: integer + createdAt: + format: date-time + type: string + id: + format: byte + type: string + lastUsed: + format: date-time + type: string + modifiedAt: + format: date-time + type: string + notValidAfter: + format: date-time + type: string + notValidBefore: + format: date-time + type: string + principalId: + type: string + publicKey: + format: byte + type: string + state: + type: string + username: + type: string + type: object + type: array + icon: + type: string + id: + readOnly: true + type: string + state: + type: string + enum: + - active + - revoked + username: + readOnly: true + type: string + example: joe@example.com + type: object + Error: + type: object + properties: + code: + type: integer + err: + type: string + detail: + type: string + required: + - code + - err + parameters: + deepParam: + name: deep + in: query + description: If true, retrieve record-related data in the request + required: false + schema: + type: boolean + default: false + limitParam: + name: limit + in: query + description: Maximum number of items to return + required: false + schema: + type: integer + format: int32 + minimum: 1 + maximum: 1000 + default: 20 + pageParam: + name: page + in: query + description: Number of pages to skip before returning the results + required: false + schema: + type: integer + format: int32 + minimum: 1 + default: 1 + orderDirectionParam: + name: orderDirection + in: query + description: Return results in ascending or descending order + required: false + schema: + default: asc + type: string + enum: + - asc + - desc + sinceParam: + name: since + in: query + description: 'Restrict results to a recent interval, ie. 1m, 8h, 24h' + required: false + schema: + type: string + createdBeforeParam: + name: createdBefore + in: query + description: Restrict results to those created before + required: false + schema: + type: string + format: date-time + createdAfterParam: + name: createdAfter + in: query + description: Restrict results to those created after date + required: false + schema: + type: string + format: date-time + stateParam: + name: state + in: query + description: Restrict results by state + required: false + schema: + type: string + enum: + - active + - revoked + headers: + resultsPage: + schema: + type: integer + description: The page that the results represent from results pagination + resultsLimit: + schema: + type: integer + description: The number of results requested + resultsTotal: + schema: + type: integer + description: The total number of results available + responses: + invalidRequest: + description: Errors were detected in the request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + notFound: + description: The specified resource was not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + systemException: + description: The system encountered an unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' \ No newline at end of file diff --git a/cmd/spec/main.go b/cmd/spec/main.go new file mode 100644 index 0000000..71d9449 --- /dev/null +++ b/cmd/spec/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3gen" + "github.com/ghodss/yaml" + "github.com/ofte-auth/dogpark/internal/model" +) + +// Used to generate openapi yaml file for components. +func main() { + components := openapi3.NewComponents() + components.Schemas = make(map[string]*openapi3.SchemaRef) + + key, _, err := openapi3gen.NewSchemaRefForValue(&model.FIDOKey{}) + if err != nil { + panic(err) + } + components.Schemas["v1.Key"] = key + + principal, _, err := openapi3gen.NewSchemaRefForValue(&model.Principal{}) + if err != nil { + panic(err) + } + components.Schemas["v1.Principal"] = principal + + aaguid, _, err := openapi3gen.NewSchemaRefForValue(&model.AAGUID{}) + if err != nil { + panic(err) + } + components.Schemas["v1.AAGUID"] = aaguid + + log, _, err := openapi3gen.NewSchemaRefForValue(&model.AuditEntry{}) + if err != nil { + panic(err) + } + components.Schemas["v1.Log"] = log + + b := &bytes.Buffer{} + err = json.NewEncoder(b).Encode(components.Schemas) + if err != nil { + panic(err) + } + + y, err := yaml.JSONToYAML(b.Bytes()) + if err != nil { + panic(err) + } + + err = ioutil.WriteFile("cmd/spec/schemas.yaml", y, 0644) + if err != nil { + panic(err) + } + fmt.Println("wrote schemas.yaml") +} diff --git a/cmd/spec/schemas.yaml b/cmd/spec/schemas.yaml new file mode 100644 index 0000000..a009c79 --- /dev/null +++ b/cmd/spec/schemas.yaml @@ -0,0 +1,206 @@ +v1.AAGUID: + properties: + id: + type: string + label: + type: string + metadata: + format: byte + type: string + state: + type: string + type: object +v1.Key: + properties: + aaguid: + type: string + attestationType: + type: string + caKey: + properties: + createdAt: + format: date-time + type: string + fidoKeyId: + format: byte + type: string + id: + format: byte + type: string + modifiedAt: + format: date-time + type: string + principalId: + format: byte + type: string + raw: + format: byte + type: string + type: object + certCommonName: + type: string + certOrganization: + type: string + certSerial: + format: int64 + type: integer + createdAt: + format: date-time + type: string + id: + format: byte + type: string + lastUsed: + format: date-time + type: string + modifiedAt: + format: date-time + type: string + nonce: + format: int64 + type: integer + notValidAfter: + format: date-time + type: string + notValidBefore: + format: date-time + type: string + principalId: + format: byte + type: string + publicKey: + format: byte + type: string + state: + type: string + username: + type: string + type: object +v1.Log: + properties: + action: + type: string + city: + type: string + country: + type: string + createdAt: + format: date-time + type: string + data: + type: string + error: + type: string + fidoAAGUID: + type: string + fidoKeyId: + format: byte + type: string + group: + type: string + id: + format: int64 + type: integer + ipAaddr: + type: string + latitude: + type: number + longitude: + type: number + principalId: + format: byte + type: string + principalUsername: + type: string + region: + type: string + sessionId: + type: string + userAgent: + type: string + type: object +v1.Principal: + properties: + createdAt: + format: date-time + type: string + displayName: + type: string + fidoKeys: + items: + properties: + aaguid: + type: string + attestationType: + type: string + caKey: + properties: + createdAt: + format: date-time + type: string + fidoKeyId: + format: byte + type: string + id: + format: byte + type: string + modifiedAt: + format: date-time + type: string + principalId: + format: byte + type: string + raw: + format: byte + type: string + type: object + certCommonName: + type: string + certOrganization: + type: string + certSerial: + format: int64 + type: integer + createdAt: + format: date-time + type: string + id: + format: byte + type: string + lastUsed: + format: date-time + type: string + modifiedAt: + format: date-time + type: string + nonce: + format: int64 + type: integer + notValidAfter: + format: date-time + type: string + notValidBefore: + format: date-time + type: string + principalId: + format: byte + type: string + publicKey: + format: byte + type: string + state: + type: string + username: + type: string + type: object + type: array + icon: + type: string + id: + format: byte + type: string + state: + type: string + username: + type: string + type: object diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..b1be499 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,4 @@ +FROM scratch + +COPY README.md /srv/README.md + diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..8f9eba7 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1 @@ +Add common artifacts in this folder \ No newline at end of file diff --git a/dogpark-medium.png b/dogpark-medium.png new file mode 100644 index 0000000000000000000000000000000000000000..993c6f3e10ca10ccb4fa5abcf9c7860ef1b43b75 GIT binary patch literal 37232 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 U8i~Px#07*qoM6N<$f+!ut!T github.com/coreos/go-systemd/v22 v22.0.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c1cf7ef --- /dev/null +++ b/go.sum @@ -0,0 +1,788 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +contrib.go.opencensus.io/exporter/ocagent v0.4.12/go.mod h1:450APlNTSR6FrvC3CTRqYosuDstRB9un7SOx2k/9ckA= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/azure-sdk-for-go v32.4.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest/autorest v0.1.0/go.mod h1:AKyIcETwSUFxIcs/Wnq/C+kwCtlEYGUVd7FPNb2slmg= +github.com/Azure/go-autorest/autorest v0.5.0/go.mod h1:9HLKlQjVBH6U3oDfsXOeVc56THsLPw1L03yban4xThw= +github.com/Azure/go-autorest/autorest/adal v0.1.0/go.mod h1:MeS4XhScH55IST095THyTxElntu7WqB7pNbZo8Q5G3E= +github.com/Azure/go-autorest/autorest/adal v0.2.0/go.mod h1:MeS4XhScH55IST095THyTxElntu7WqB7pNbZo8Q5G3E= +github.com/Azure/go-autorest/autorest/azure/auth v0.1.0/go.mod h1:Gf7/i2FUpyb/sGBLIFxTBzrNzBo7aPXXE3ZVeDRwdpM= +github.com/Azure/go-autorest/autorest/azure/cli v0.1.0/go.mod h1:Dk8CUAt/b/PzkfeRsWzVG9Yj3ps8mS8ECztu43rdU8U= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/to v0.2.0/go.mod h1:GunWKJp1AEqgMaGLV+iocmRAJWqST1wQYhyyjXJ3SJc= +github.com/Azure/go-autorest/autorest/validation v0.1.0/go.mod h1:Ha3z/SqBeaalWQvokg3NZAlQTalVMtOIAs1aGK7G6u8= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/tracing v0.1.0/go.mod h1:ROEEAFwXycQw7Sn3DXNtEedEvdeRAgDr0izn4z5Ij88= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= +github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= +github.com/Microsoft/hcsshim v0.8.7-0.20191101173118-65519b62243c/go.mod h1:7xhjOwRV2+0HXGmM0jxaEu+ZiXJFoVZOTfL/dmqbrD8= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/akamai/AkamaiOPEN-edgegrid-golang v0.9.0/go.mod h1:zpDJeKyp9ScW4NNrbdr+Eyxvry3ilGPewKoXw3XGN1k= +github.com/alangpierce/go-forceexport v0.0.0-20160317203124-8f1d6941cd75/go.mod h1:uAXEEpARkRhCZfEvy/y0Jcc888f9tHCc1W7/UeEtreE= +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20190808125512-07798873deee/go.mod h1:myCDvQSzCW+wB1WAlocEru4wMGJxy+vlxHdhegi1CDQ= +github.com/aliyun/aliyun-oss-go-sdk v0.0.0-20190307165228-86c17b95fcd5/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/antihax/optional v0.0.0-20180407024304-ca021399b1a6/go.mod h1:V8iCPQYkqmusNa815XgQio277wI47sdRh1dUOLdyC6Q= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aws/aws-sdk-go v1.23.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc= +github.com/beevik/ntp v0.2.0/go.mod h1:hIHWr+l3+/clUnF44zdK+CWW7fO8dR5cIylAQ76NRpg= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bwmarrin/discordgo v0.19.0/go.mod h1:O9S4p+ofTFwB02em7jkpkV8M3R0/PUVOwN61zSZ0r4Q= +github.com/bwmarrin/discordgo v0.20.1/go.mod h1:O9S4p+ofTFwB02em7jkpkV8M3R0/PUVOwN61zSZ0r4Q= +github.com/bwmarrin/discordgo v0.20.2/go.mod h1:O9S4p+ofTFwB02em7jkpkV8M3R0/PUVOwN61zSZ0r4Q= +github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= +github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/cfssl v0.0.0-20190726000631-633726f6bcb7 h1:Puu1hUwfps3+1CUzYdAZXijuvLuRMirgiXdf3zsM2Ig= +github.com/cloudflare/cfssl v0.0.0-20190726000631-633726f6bcb7/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= +github.com/cloudflare/cloudflare-go v0.10.2/go.mod h1:qhVI5MKwBGhdNU89ZRz2plgYutcJ5PCekLxXn56w6SY= +github.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko= +github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= +github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= +github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0= +github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o= +github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/bbolt v1.3.3 h1:n6AiVyVRKQFNb6mJlwESEvvLoDyiTzXX7ORAUlkeBdY= +github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/etcd v3.3.17+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/etcd v3.3.18+incompatible h1:Zz1aXgDrFFi1nadh58tA9ktt06cmPTwNNP3dXwIq1lE= +github.com/coreos/etcd v3.3.18+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.0.0 h1:XJIw/+VlJ+87J+doOxznsAWIdmWuViOVhkQamW5YV28= +github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpu/goacmedns v0.0.1/go.mod h1:sesf/pNnCYwUevQEQfEwY0Y3DydlQWSGZbaMElOWxok= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decker502/dnspod-go v0.2.0/go.mod h1:qsurYu1FgxcDwfSwXJdLt4kRsBLZeosEb9uq4Sy+08g= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= +github.com/dnaeon/go-vcr v0.0.0-20180814043457-aafff18a5cc2/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= +github.com/dnsimple/dnsimple-go v0.30.0/go.mod h1:O5TJ0/U6r7AfT8niYNlmohpLbCSG+c71tQlGr9SeGrg= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v1.4.2-0.20190710153559-aa8249ae1b8b/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v1.4.2-0.20191101170500-ac7306503d23/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo= +github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/duo-labs/webauthn v0.0.0-20200131223046-0864f70a0509 h1:XlqpA/EJNuSPkp6/yl2fCXcsQ/y5uw4GKkVBYmebdnE= +github.com/duo-labs/webauthn v0.0.0-20200131223046-0864f70a0509/go.mod h1:lLEqCfy8LnwEbRr1SrxujNtHadF3cEGY7MmR3vo0s3A= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/exoscale/egoscale v0.18.1/go.mod h1:Z7OOdzzTOz1Q1PjQXumlz9Wn/CddH0zSYdCF3rnBKXE= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/forestgiant/sliceutil v0.0.0-20160425183142-94783f95db6c/go.mod h1:pFdJbAhRf7rh6YYMUdIQGyzne6zYL1tCUW8QV2B3UfY= +github.com/fraugster/cli v1.1.0 h1:BVnJQpTkxTiXdzo/cx6MBRIyDj/sUauO9JkNy90Pz1w= +github.com/fraugster/cli v1.1.0/go.mod h1:tvJjLCpFu0hQm3nkOtUNvjtKZSx7rPDKVNnBcXY89uU= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsouza/go-dockerclient v1.4.4/go.mod h1:PrwszSL5fbmsESocROrOGq/NULMXRw+bajY0ltzD6MA= +github.com/fsouza/go-dockerclient v1.6.0/go.mod h1:YWwtNPuL4XTX1SKJQk86cWPmmqwx+4np9qfPbb+znGc= +github.com/getkin/kin-openapi v0.3.0 h1:xsQ4mA20YJDMgIHdHqMKZ66QUe6/hi+x6yLsTTz8xyQ= +github.com/getkin/kin-openapi v0.3.0/go.mod h1:W8dhxZgpE84ciM+VIItFqkmZ4eHtuomrdIHtASQIqi0= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-acme/lego/v3 v3.1.0/go.mod h1:074uqt+JS6plx+c9Xaiz6+L+GBb+7itGtzfcDM2AhEE= +github.com/go-acme/lego/v3 v3.3.0/go.mod h1:iGSY2vQrvQs3WezicSB/oVbO2eCrD88dpWPwb1qLqu0= +github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY= +github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0= +github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I= +github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY= +github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs= +github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-ini/ini v1.44.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-log/log v0.1.0 h1:wudGTNsiGzrD5ZjgIkVZ517ugi2XRe9Q/xRCzwEO4/U= +github.com/go-log/log v0.1.0/go.mod h1:4mBwpdRMFLiuXZDCwU2lKQFsoSCo72j3HqBK9d81N2M= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA= +github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= +github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= +github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191002201903-404acd9df4cc/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.0/go.mod h1:Qd/q+1AKNOZr9uGQzbzCmRO6sUih6GTPZv6a1/R87v0= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/certificate-transparency-go v1.0.21 h1:Yf1aXowfZ2nuboBsg7iYGLmwsOARdV86pfH3g95wXmE= +github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gophercloud/gophercloud v0.3.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.1.0 h1:THDBEeQ9xZ8JEaCLyLQqXMMdRqNr0QAUJTIkQAUtFjg= +github.com/grpc-ecosystem/go-grpc-middleware v1.1.0/go.mod h1:f5nM7jw/oeRSadq3xCzHAvxcr8HZnzsqU6ILg/0NiiE= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.8.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.12.1 h1:zCy2xE9ablevUOrUZc3Dl72Dt+ya2FNAvC2yLYMHzi4= +github.com/grpc-ecosystem/grpc-gateway v1.12.1/go.mod h1:8XEsbTttt/W+VvjtQhLACqCisSPWTxCZ7sBRjU6iH9c= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.3 h1:YPkqC67at8FYaadspW/6uE0COsBxS2656RLEr8Bppgk= +github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4= +github.com/ijc/Gotty v0.0.0-20170406111628-a8b993ba6abd/go.mod h1:3LVOLeyx9XVvwPgrt2be44XgSqndprz1G18rSk8KD84= +github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ= +github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jinzhu/gorm v1.9.12 h1:Drgk1clyWT9t9ERbzHza6Mj/8FY/CqMyVzOiHviMo6Q= +github.com/jinzhu/gorm v1.9.12/go.mod h1:vhTjlKSJUTWNtcbQtrMBFCxy7eXTzeCAzfL5fBZT/Qs= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/joncalhoun/qson v0.0.0-20170526102502-8a9cab3a62b1/go.mod h1:DFXrEwSRX0p/aSvxE21319menCBFeQO0jXpRj7LEZUA= +github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/kolo/xmlrpc v0.0.0-20190717152603-07c4ee3fd181/go.mod h1:o03bZfuBwAXHetKXuInt4S7omeXUu62/A845kiycsSQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/labbsr0x/bindman-dns-webhook v1.0.2/go.mod h1:p6b+VCXIR8NYKpDr8/dg1HKfQoRHCdcsROXKvmoehKA= +github.com/labbsr0x/goh v1.0.1/go.mod h1:8K2UhVoaWXcCU7Lxoa2omWnC8gyW8px7/lmO61c027w= +github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= +github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/linode/linodego v0.10.0/go.mod h1:cziNP7pbvE3mXIPneHj0oRY8L1WtGEIKlZ8LANE4eXA= +github.com/liquidweb/liquidweb-go v1.6.0/go.mod h1:UDcVnAMDkZxpw4Y7NOHkqoeiGacVLEIG/i5J9cyixzQ= +github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e h1:9MlwzLdW7QSDrhDjFlsEYmxpFyIoXmYRon3dt0io31k= +github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/lucas-clemente/quic-go v0.12.1/go.mod h1:UXJJPE4RfFef/xPO5wQm0tITK8gNfqwTxjbE7s3Vb8s= +github.com/lucas-clemente/quic-go v0.13.1 h1:CxtJTXQIh2aboCPk0M6vf530XOov6DZjVBiSE3nSj8s= +github.com/lucas-clemente/quic-go v0.13.1/go.mod h1:Vn3/Fb0/77b02SGhQk36KzOUmXgVpFfizUfW5WMaqyU= +github.com/lucas-clemente/quic-go v0.14.1/go.mod h1:Vn3/Fb0/77b02SGhQk36KzOUmXgVpFfizUfW5WMaqyU= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/marten-seemann/chacha20 v0.2.0 h1:f40vqzzx+3GdOmzQoItkLX5WLvHgPgyYqFFIO5Gh4hQ= +github.com/marten-seemann/chacha20 v0.2.0/go.mod h1:HSdjFau7GzYRj+ahFNwsO3ouVJr1HFkWoEwNDb4TMtE= +github.com/marten-seemann/qpack v0.1.0/go.mod h1:LFt1NU/Ptjip0C2CPkhimBz5CGE3WGDAUWqna+CNTrI= +github.com/marten-seemann/qtls v0.3.2/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= +github.com/marten-seemann/qtls v0.4.1 h1:YlT8QP3WCCvvok7MGEZkMldXbyqgr8oFg5/n8Gtbkks= +github.com/marten-seemann/qtls v0.4.1/go.mod h1:pxVXcHHw1pNIt8Qo0pwSYQEoZ8yYOOPXTCZLQQunvRc= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw= +github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-tty v0.0.0-20180219170247-931426f7535a/go.mod h1:XPvLUNfbS4fJH25nqRHfWLMa1ONC8Amw+mIA639KxkE= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mholt/certmagic v0.7.5/go.mod h1:91uJzK5K8IWtYQqTi5R2tsxV1pCde+wdGfaRaOZi6aQ= +github.com/mholt/certmagic v0.8.3/go.mod h1:91uJzK5K8IWtYQqTi5R2tsxV1pCde+wdGfaRaOZi6aQ= +github.com/mholt/certmagic v0.9.3/go.mod h1:nu8jbsbtwK4205EDH/ZUMTKsfYpJA1Q7MKXHfgTihNw= +github.com/micro/cli v0.2.0 h1:ut3rV5JWqZjsXIa2MvGF+qMUP8DAUTvHX9Br5gO4afA= +github.com/micro/cli v0.2.0/go.mod h1:jRT9gmfVKWSS6pkKcXQ8YhUyj6bzwxK8Fp5b0Y7qNnk= +github.com/micro/cli/v2 v2.1.2 h1:43J1lChg/rZCC1rvdqZNFSQDrGT7qfMrtp6/ztpIkEM= +github.com/micro/cli/v2 v2.1.2/go.mod h1:EguNh6DAoWKm9nmk+k/Rg0H3lQnDxqzu5x5srOtGtYg= +github.com/micro/go-micro v1.16.0/go.mod h1:A0F58bHLh2m0LAI9QyhvmbN8c1cxhAZo3cM6s+iDsrM= +github.com/micro/go-micro v1.18.0 h1:gP70EZVHpJuUIT0YWth192JmlIci+qMOEByHm83XE9E= +github.com/micro/go-micro v1.18.0/go.mod h1:klwUJL1gkdY1MHFyz+fFJXn52dKcty4hoe95Mp571AA= +github.com/micro/go-micro/v2 v2.3.0 h1:3seJJ7/pbhleZNe6gGHFJjOsAqvYGcy2ivc3P5PYnVQ= +github.com/micro/go-micro/v2 v2.3.0/go.mod h1:GR69d1AXMg/WjMNf/7K1VO6hCBJDIpqCqnVYNTV6M5w= +github.com/micro/mdns v0.3.0 h1:bYycYe+98AXR3s8Nq5qvt6C573uFTDPIYzJemWON0QE= +github.com/micro/mdns v0.3.0/go.mod h1:KJ0dW7KmicXU2BV++qkLlmHYcVv7/hHnbtguSWt9Aoc= +github.com/micro/protoc-gen-micro v1.0.0/go.mod h1:C8ij4DJhapBmypcT00AXdb0cZ675/3PqUO02buWWqbE= +github.com/miekg/dns v1.1.3/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.15/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.22 h1:Jm64b3bO9kP43ddLjL2EY3Io6bmy1qGb9Xxz6TqS6rc= +github.com/miekg/dns v1.1.22/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM= +github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-vnc v0.0.0-20150629162542-723ed9867aed/go.mod h1:3rdaFaCv4AyBgu5ALFM0+tSuHrBh6v692nyQe3ikrq0= +github.com/mitchellh/hashstructure v1.0.0 h1:ZkRJX1CyOoTkar7p/mLS5TZU4nJ1Rn/F8u9dGS02Q3Y= +github.com/mitchellh/hashstructure v1.0.0/go.mod h1:QjSHrPWS+BGUVBYkbTZWEnOh3G1DutKwClXU/ABz6AQ= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8= +github.com/nats-io/jwt v0.3.0 h1:xdnzwFETV++jNc4W1mw//qFyJGb2ABOombmZJQS4+Qo= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/jwt v0.3.2 h1:+RB5hMpXUUA2dfxuhBTEkMOrYmM+gKIZYS1KjSostMI= +github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= +github.com/nats-io/nats-server/v2 v2.1.0/go.mod h1:r5y0WgCag0dTj/qiHkHrXAcKQ/f5GMOZaEGdoxxnJ4I= +github.com/nats-io/nats-server/v2 v2.1.4 h1:BILRnsJ2Yb/fefiFbBWADpViGF69uh4sxe8poVDQ06g= +github.com/nats-io/nats-server/v2 v2.1.4/go.mod h1:Jw1Z28soD/QasIA2uWjXyM9El1jly3YwyFOuR8tH1rg= +github.com/nats-io/nats.go v1.8.1/go.mod h1:BrFz9vVn0fU3AcH9Vn4Kd7W0NpJ651tD5omQ3M8LwxM= +github.com/nats-io/nats.go v1.9.1 h1:ik3HbLhZ0YABLto7iX80pZLPw/6dx3T+++MZJwLnMrQ= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7TDb/4= +github.com/nats-io/nkeys v0.1.0 h1:qMd4+pRHgdr1nAClu+2h/2a5F2TmKcCzjCDazVgRoX4= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.3 h1:6JrEfig+HzTH85yxzhSVbjHRJv9cn0p6n3IngIcM5/k= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/nlopes/slack v0.6.0/go.mod h1:JzQ9m3PMAqcpeCam7UaHSuBuupz7CmpjehYMayT6YOk= +github.com/nlopes/slack v0.6.1-0.20191106133607-d06c2a2b3249/go.mod h1:JzQ9m3PMAqcpeCam7UaHSuBuupz7CmpjehYMayT6YOk= +github.com/nrdcg/auroradns v1.0.0/go.mod h1:6JPXKzIRzZzMqtTDgueIhTi6rFf1QvYE/HzqidhOhjw= +github.com/nrdcg/dnspod-go v0.3.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= +github.com/nrdcg/goinwx v0.6.1/go.mod h1:XPiut7enlbEdntAqalBIqcYcTEVhpv/dKWgDCX2SwKQ= +github.com/nrdcg/namesilo v0.2.1/go.mod h1:lwMvfQTyYq+BbjJd30ylEG4GPSS6PII0Tia4rRpRiyw= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/oracle/oci-go-sdk v7.0.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888= +github.com/oschwald/geoip2-golang v1.4.0 h1:5RlrjCgRyIGDz/mBmPfnAF4h8k0IAcRv9PvrpOfz+Ug= +github.com/oschwald/geoip2-golang v1.4.0/go.mod h1:8QwxJvRImBH+Zl6Aa6MaIcs5YdlZSTKtzmPGzQqi9ng= +github.com/oschwald/maxminddb-golang v1.6.0 h1:KAJSjdHQ8Kv45nFIbtoLGrGWqHFajOIm7skTyz/+Dls= +github.com/oschwald/maxminddb-golang v1.6.0/go.mod h1:DUJFucBg2cvqx42YmDa/+xHvb0elJtOm3o4aFQ/nb/w= +github.com/ovh/go-ovh v0.0.0-20181109152953-ba5adb4cf014/go.mod h1:joRatxRJaZBsY3JAOEMcoOp05CnZzsx4scTxi95DHyQ= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.0 h1:J8lpUdobwIeCI7OiSxHqEwJUKvJwicL5+3v1oe2Yb4k= +github.com/pkg/errors v0.9.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_golang v1.3.0 h1:miYCvYqFXtl/J9FIy8eNpBfYthAEFg+Ys0XyUVEcDsc= +github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.1.0 h1:ElTg5tNp4DqfV7UQjDqv2+RJlNzsDtvNAWccbItceIE= +github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/common v0.7.0 h1:L+1lyG48J1zAQXA3RBX/nG/B3gjlHq0zTt2tlbJLyCY= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.0.8 h1:+fpWZdT24pJBiqJdAwYBjPSk+5YmQzYNPYzQsdzLkt8= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2/go.mod h1:7tZKcyumwBO6qip7RNQ5r77yrssm9bfCowcLEBcU5IA= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sacloud/libsacloud v1.26.1/go.mod h1:79ZwATmHLIFZIMd7sxA3LwzVy/B77uj3LDoToVTxDoQ= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4 h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.6.2 h1:7aKfF+e8/k68gda3LOjo5RxiUqddoFxVq4BKBPrxk5E= +github.com/spf13/viper v1.6.2/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= +github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= +github.com/timewasted/linode v0.0.0-20160829202747-37e84520dcf7/go.mod h1:imsgLplxEC/etjIhdr3dNzV3JeT27LbVu5pYWm0JCBY= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 h1:LnC5Kc/wtumK+WB441p7ynQJzVuNRJiqddSIE3IlSEQ= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20200122045848-3419fae592fc h1:yUaosFVTJwnltaHbSNC3i82I92quFs+OFPRl8kNMVwo= +github.com/tmc/grpc-websocket-proxy v0.0.0-20200122045848-3419fae592fc/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/transip/gotransip v0.0.0-20190812104329-6d8d9179b66f/go.mod h1:i0f4R4o2HM0m3DZYQWsj6/MEowD57VzoH0v3d7igeFY= +github.com/tstranex/u2f v1.0.0 h1:HhJkSzDDlVSVIVt7pDJwCHQj67k7A5EeBgPmeD+pVsQ= +github.com/tstranex/u2f v1.0.0/go.mod h1:eahSLaqAS0zsIEv80+vXT7WanXs7MQQDg3j3wGBSayo= +github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/vultr/govultr v0.1.4/go.mod h1:9H008Uxr/C4vFNGLqKx232C206GL0PBHzOP0809bGNA= +github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= +github.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/ztrue/tracerr v0.3.0 h1:lDi6EgEYhPYPnKcjsYzmWw4EkFEoA/gfe+I9Y5f+h6Y= +github.com/ztrue/tracerr v0.3.0/go.mod h1:qEalzze4VN9O8tnhBXScfCrmoJo10o8TN5ciKjm6Mww= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.2.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0 h1:sFPn2GLc3poCkfrpIXGhBD2X0CMIo4Q/zSULXrj/+uc= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/ratelimit v0.0.0-20180316092928-c15da0234277/go.mod h1:2X8KaoNd1J0lZV+PxJk/5+DGbO/tpwLR1m++a7FnB/Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.12.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.uber.org/zap v1.13.0 h1:nR6NoDBgAf67s68NhaXbsojM+2gxp3S1hWkHDl27pVU= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +golang.org/x/crypto v0.0.0-20180621125126-a49355c7e3f8/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190130090550-b01c7a725664/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529 h1:iMGN4xG0cnqj3t+zOM8wUB0BiPKHEwSxEZCvzcbZuvk= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20190927123631-a832865fa7ad/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191108234033-bd318be0434a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d h1:1ZiEyfaQIg3Qh0EoqpwAakHVhecoE5wlSg5GjnafJGw= +golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20180611182652-db08ff08e862/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190228165749-92fc7df08ae7/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190930134127-c5a3c61f89f3/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191002035440-2ec189313ef0 h1:2mqDk8w/o6UmeUCu5Qiq2y7iMf6anbx+YA8d1JFoFrs= +golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191011234655-491137f69257/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191027093000-83d349e8ac1a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191109021931-daa7c04131f5 h1:bHNaocaoJxYBo5cw41UyTMLjYlb8wPY7+WFrnklbHOM= +golang.org/x/net v0.0.0-20191109021931-daa7c04131f5/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0 h1:MsuvTghUPjX762sGLnGsxC3HM0B5r83wEtYcYR8/vRs= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180622082034-63fc586f45fe/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190710143415-6ec70d6a5542/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191110163157-d32e6e3b99c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f h1:68K/z8GLUxV76xGSqwTWw2gyk/jwn79LUL43rES2g8o= +golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191224085550-c709ea063b76 h1:Dho5nD6R3PcW2SH1or8vS0dszDaXRxIw55lBX7XiE5g= +golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190921001708-c4c64cad1fd0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20190927181202-20e1ac93f88c/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200113173426-e1de0a7b01eb h1:EsMpWw4S8DM2QYm5idfmmWsv2N57GWi2tx3p96Gpja4= +google.golang.org/genproto v0.0.0-20200113173426-e1de0a7b01eb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.19.1/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0 h1:2dTRdpdFEEhJYQD8EMLB61nnrzSCTbG38PhqdhvOltg= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.30.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M= +gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= +gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.44.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ns1/ns1-go.v2 v2.0.0-20190730140822-b51389932cbc/go.mod h1:VV+3haRsgDiVLxyifmMBrBIuCWFBPYKbRssXB9z67Hw= +gopkg.in/resty.v1 v1.9.1/go.mod h1:vo52Hzryw9PnPHcJfPsBiFW62XhNx5OczbV9y+IMpgc= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= +gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g= +gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= +gopkg.in/telegram-bot-api.v4 v4.6.4/go.mod h1:5DpGO5dbumb40px+dXcwCpcjmeHNYLpk0bp3XRNvWDM= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3 h1:fvjTMHxHEw/mxHbtzPi3JCcKXQRAnQTBRo6YCJSVHKI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= diff --git a/internal/db/conn.go b/internal/db/conn.go new file mode 100644 index 0000000..0f21b1e --- /dev/null +++ b/internal/db/conn.go @@ -0,0 +1,59 @@ +package db + +import ( + "fmt" + "strings" + "time" + + "github.com/jinzhu/gorm" + "github.com/pkg/errors" + + // implicitly load postgres driver + _ "github.com/jinzhu/gorm/dialects/postgres" +) + +// DB aliases the ORM package. +type DB = *gorm.DB + +var paramKeys map[string]string = map[string]string{ + "db_host": "host", + "db_port": "port", + "db_name": "dbname", + "db_user": "user", + "db_password": "password", + "db_sslmode": "sslmode", +} + +// GetConnection opens the DB via gorm. The returned handle is safe for concurrent and reuse. +func GetConnection(params map[string]interface{}) (DB, error) { + var ( + err error + connectionParams strings.Builder + ) + + for k, v := range params { + if _, ok := paramKeys[k]; !ok { + continue + } + value, ok := v.(string) + if !ok { + return nil, errors.Errorf("converting parameter %s", k) + } + _, err = connectionParams.WriteString(fmt.Sprintf("%s=%s ", paramKeys[k], value)) + if err != nil { + return nil, err + } + } + return gorm.Open("postgres", connectionParams.String()) +} + +// CloseConnection closes the db connection. If it's a test DB, it also DELETEs the DB. +func CloseConnection(db DB) error { + _, ok := db.Get("testDB") + if ok { + // Allow auditing to complete in test DBs + time.Sleep(50 * time.Millisecond) + _ = DestroyTestDB(db) + } + return db.Close() +} diff --git a/internal/db/statement.go b/internal/db/statement.go new file mode 100644 index 0000000..122f2bc --- /dev/null +++ b/internal/db/statement.go @@ -0,0 +1,37 @@ +package db + +import "github.com/ofte-auth/dogpark/internal/util" + +// QueryStatement creates a default query and count statement from APIParams. +func QueryStatement(db DB, tableName string, params *util.APIParams, apiToDBFieldMap map[string]string) (DB, DB, error) { + if params == nil { + params = util.DefaultAPIParams() + } + for k, v := range params.AndFilters { + if fieldName, ok := apiToDBFieldMap[k]; ok { + delete(params.AndFilters, k) + params.AndFilters[fieldName] = v + } + } + db = db.New().Table(tableName) + if params.Deep { + db = db.Set("gorm:auto_preload", true) + } + if !params.CreatedBefore.IsZero() { + db = db.Where("created_at < ?", params.CreatedBefore) + } + if !params.CreatedAfter.IsZero() { + db = db.Where("created_at > ?", params.CreatedAfter) + } + db = db.Where(params.AndFilters) + countDB := db + + db = db.Limit(params.Limit).Offset(params.GetOffsetSQL()) + orderStatement := params.GetOrderBySQLStatement(apiToDBFieldMap) + if orderStatement != "" { + db = db.Order(orderStatement) + } + db = db.Where(params.AndFilters) + + return db, countDB, nil +} diff --git a/internal/db/testing.go b/internal/db/testing.go new file mode 100644 index 0000000..e26f8cd --- /dev/null +++ b/internal/db/testing.go @@ -0,0 +1,62 @@ +package db + +import ( + "fmt" + + "github.com/jinzhu/gorm" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" +) + +// GetTestDB creates a test db. +func GetTestDB() (*gorm.DB, error) { + + dbName := util.RandomAlphaString(16) + + db, err := gorm.Open("postgres", "host=localhost port=5432 user=postgres sslmode=require") + if err != nil { + return nil, err + } + + err = db.Exec(fmt.Sprintf("CREATE DATABASE %s TEMPLATE template0 ENCODING 'UTF8'", dbName)).Error + if err != nil { + return nil, err + } + + connString := fmt.Sprintf("host=localhost port=5432 dbname=%s user=postgres sslmode=require", dbName) + db, err = gorm.Open("postgres", connString) + if err != nil { + return nil, err + } + + db = db.Set("databaseName", dbName).Set("testDB", true) + + return db, err +} + +// DestroyTestDB destroys a test database. +func DestroyTestDB(db *gorm.DB) error { + + dbName, ok := db.Get("databaseName") + if !ok { + return errors.New("No databaseName in context") + } + dbName, ok = dbName.(string) + if !ok { + return errors.New("Invalid type for databaseName") + } + err := db.Close() + if err != nil { + return err + } + db, err = gorm.Open("postgres", "host=localhost port=5432 user=postgres sslmode=require") + if err != nil { + return err + } + defer db.Close() + err = db.Exec(fmt.Sprintf("DROP DATABASE %s", dbName)).Error + if err != nil { + return err + } + return nil +} diff --git a/internal/geo/geolite.go b/internal/geo/geolite.go new file mode 100644 index 0000000..88f1253 --- /dev/null +++ b/internal/geo/geolite.go @@ -0,0 +1,77 @@ +package geo + +import ( + "net" + "strings" + "time" + + geoip2 "github.com/oschwald/geoip2-golang" + "github.com/pkg/errors" +) + +// An implementation of the GeoResolver for the MaxMind GeoLite IP DB +// See https://dev.maxmind.com/geoip/geoip2/geolite2/ + +// MaxMindGeoLiteConfig ... +type MaxMindGeoLiteConfig struct { + DBLocation string +} + +type geoLiteImpl struct { + db *geoip2.Reader +} + +func (impl *geoLiteImpl) Init(options interface{}) error { + var ( + ok bool + err error + config *MaxMindGeoLiteConfig + ) + config, ok = options.(*MaxMindGeoLiteConfig) + if !ok { + return errors.New("expecting Init options to be *GeoLiteConfig") + } + + impl.db, err = geoip2.Open(config.DBLocation) + if err != nil { + return errors.Wrapf(err, "unable to open filename %s", config.DBLocation) + } + return nil +} + +func (impl *geoLiteImpl) Close() { + if impl.db != nil { + _ = impl.db.Close() + } +} + +func (impl *geoLiteImpl) Resolve(ipAddress string) (*GeoEntry, error) { + if ipAddress == "127.0.0.1" || strings.HasPrefix(ipAddress, "[::1]") { + ipAddress = "70.20.56.240" + } + ip := net.ParseIP(ipAddress) + record, err := impl.db.City(ip) + if err != nil { + return nil, err + } + + entry := &GeoEntry{ + IPAddress: ipAddress, + Timestamp: time.Now(), + } + if country, ok := record.Country.Names["en"]; ok { + entry.Country = country + } + if len(record.Subdivisions) > 0 { + if region, ok := record.Subdivisions[0].Names["en"]; ok { + entry.Region = region + } + } + if city, ok := record.City.Names["en"]; ok { + entry.City = city + } + entry.Latitude = record.Location.Latitude + entry.Longitude = record.Location.Longitude + + return entry, nil +} diff --git a/internal/geo/ipstack.go b/internal/geo/ipstack.go new file mode 100644 index 0000000..d3168cb --- /dev/null +++ b/internal/geo/ipstack.go @@ -0,0 +1,122 @@ +package geo + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + lru "github.com/hashicorp/golang-lru" + "github.com/pkg/errors" +) + +// An implementation of the ipstack.api geo REST API +// See https://ipstack.com + +// IPStackConfig ... +type IPStackConfig struct { + APIKey string +} + +type ipStackImpl struct { + apiKey string + cache *lru.Cache +} + +func (impl *ipStackImpl) Init(options interface{}) error { + var ( + ok bool + err error + config *IPStackConfig + ) + config, ok = options.(*IPStackConfig) + if !ok { + return errors.New("expecting Init options to be *IPStackConfig") + } + + impl.apiKey = config.APIKey + if len(impl.apiKey) == 0 { + return errors.New("no api key supplied") + } + impl.cache, err = lru.New(1024) + if err != nil { + return errors.Wrapf(err, "unable to create lru cache") + } + return nil +} + +func (impl *ipStackImpl) Close() { +} + +func (impl *ipStackImpl) Resolve(ipAddress string) (*GeoEntry, error) { + var ( + err error + entry *GeoEntry + ) + if ipAddress == "127.0.0.1" || strings.HasPrefix(ipAddress, "[::1]") { + ipAddress = "70.20.56.211" + } + val, ok := impl.cache.Get(ipAddress) + if ok { + return val.(*GeoEntry), nil + } + + resp, err := http.Get(fmt.Sprintf("http://api.ipstack.com/%s?access_key=%s", ipAddress, impl.apiKey)) + if err != nil || resp.StatusCode != 200 { + return nil, errors.Wrap(err, "getting geo ip entry") + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "reading response body") + } + defer resp.Body.Close() + + var values ipStackResponse + err = json.Unmarshal(body, &values) + if err != nil { + return nil, errors.Wrap(err, "unmarshalling response body") + } + + entry = &GeoEntry{ + IPAddress: values.IP, + Country: values.CountryName, + Region: values.RegionCode, + City: values.City, + Latitude: values.Latitude, + Longitude: values.Longitude, + Timestamp: time.Now(), + } + impl.cache.Add(ipAddress, entry) + return entry, nil +} + +type ipStackResponse struct { + IP string `json:"ip"` + Type string `json:"type"` + ContinentCode string `json:"continent_code"` + ContinentName string `json:"continent_name"` + CountryCode string `json:"country_code"` + CountryName string `json:"country_name"` + RegionCode string `json:"region_code"` + RegionName string `json:"region_name"` + City string `json:"city"` + Zip string `json:"zip"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Location struct { + GeonameID int `json:"geoname_id"` + Capital string `json:"capital"` + Languages []struct { + Code string `json:"code"` + Name string `json:"name"` + Native string `json:"native"` + } `json:"languages"` + CountryFlag string `json:"country_flag"` + CountryFlagEmoji string `json:"country_flag_emoji"` + CountryFlagEmojiUnicode string `json:"country_flag_emoji_unicode"` + CallingCode string `json:"calling_code"` + IsEu bool `json:"is_eu"` + } `json:"location"` +} diff --git a/internal/geo/resolver.go b/internal/geo/resolver.go new file mode 100644 index 0000000..6d06110 --- /dev/null +++ b/internal/geo/resolver.go @@ -0,0 +1,45 @@ +package geo + +import ( + "time" + + "github.com/pkg/errors" +) + +// Resolver defines an IP-Geographic region resolver. +type Resolver interface { + Init(options interface{}) error + Close() + Resolve(string) (*GeoEntry, error) +} + +// NewGeoResolver creates a new GeoResolver. +// Note: this currently invokes the MaxMind Geo Resolver with the +// open-source GeoIP database +func NewGeoResolver(options interface{}) (Resolver, error) { + var resolver Resolver + switch options.(type) { + case *MaxMindGeoLiteConfig: + resolver = new(geoLiteImpl) + case *IPStackConfig: + resolver = new(ipStackImpl) + default: + return nil, errors.Errorf("Unknown geo resolver config type %T", options) + } + if err := resolver.Init(options); err != nil { + return nil, err + } + return resolver, nil +} + +// GeoEntry defines data derived from browser info and ip address. +type GeoEntry struct { + IPAddress string `json:"ipAddress"` + UserAgent string `json:"userAgent"` + Country string `json:"country"` + Region string `json:"region"` + City string `json:"city"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/internal/geo/resolver_test.go b/internal/geo/resolver_test.go new file mode 100644 index 0000000..42d7e33 --- /dev/null +++ b/internal/geo/resolver_test.go @@ -0,0 +1,37 @@ +package geo + +import ( + "os" + "strings" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/require" +) + +func Test_GeoLiteImpl(t *testing.T) { + geoResolver, err := NewGeoResolver(&MaxMindGeoLiteConfig{"../../deploy/GeoLite2-City.mmdb"}) + if strings.Contains(err.Error(), "no such file") { + t.Skip(err.Error()) + return + } + require.NoError(t, err) + entry, err := geoResolver.Resolve("70.20.56.211") + require.NoError(t, err) + require.Equal(t, "United States", entry.Country) +} + +func Test_IPStackImpl(t *testing.T) { + resolver, err := NewGeoResolver(&IPStackConfig{ + APIKey: os.Getenv("IPSTACK_ACCESS_KEY"), + }) + if strings.Contains(err.Error(), "no api key supplied") { + t.Skip(err.Error()) + return + } + require.NoError(t, err) + entry, err := resolver.Resolve("70.20.56.211") + require.NoError(t, err) + require.Equal(t, "United States", entry.Country) + spew.Dump(entry) +} diff --git a/internal/model/aaguid.go b/internal/model/aaguid.go new file mode 100644 index 0000000..35c1e98 --- /dev/null +++ b/internal/model/aaguid.go @@ -0,0 +1,137 @@ +package model + +import ( + "context" + + "github.com/google/go-cmp/cmp" + "github.com/jinzhu/gorm" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" +) + +// AAGUID represents a Authenticator Attestation GUID. AAGUIDs uniquely identify +// a group (>100k) of authenticators. +// See https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-metadata-statement-v2.0-rd-20180702.html +// +// You can control whitelisting and blacklisting of AAGUIDs by updating an AAGUID's +// `State` variable. For instance, to block a AAGUID, update the a record's State variable +// to 'revoked'. This will prevent any authenticator with that AAGUID from authenticating. +// To whitelist one or more AAGUIDs, update a record's State variable to 'active'. Once one or more +// records have an 'active' State a whitelist is, in effect, created; authenticators with other AAGUIDs +// will not be able to authenticate. If `State` is empty or `issued`, the authenticator is neither explictly +// blacklisted nor whitelisted. +type AAGUID struct { + ID string `json:"id"` + Label string `json:"label" gorm:"index"` + State string `json:"state" gorm:"index"` + Metadata []byte `json:"metadata"` +} + +// AAGUIDByID returns a stored AAGUID by ID. +func AAGUIDByID(ctx context.Context, db db.DB, id string) (*AAGUID, error) { + k := new(AAGUID) + err := db.Where("id = ?", id).First(k).Error + if err == gorm.ErrRecordNotFound { + k = nil + err = ErrRecordNotFound + } + return k, err +} + +// Update ... +func (guid *AAGUID) Update(ctx context.Context, db db.DB, values map[string]string) (string, error) { + changes, err := guid.ApplyChanges(values) + if err != nil { + return "", errors.Wrap(err, "updating record") + } + return changes, db.Save(guid).Error +} + +// AllowedUpdateFields returns the fields that are mutable. +func (guid *AAGUID) AllowedUpdateFields() map[string]bool { + return map[string]bool{"state": true} +} + +// ApplyChanges updates the object with values found in the map and returns +// a description of the changes. +func (guid *AAGUID) ApplyChanges(values map[string]string) (string, error) { + orig := new(AAGUID) + *orig = *guid + allowed := guid.AllowedUpdateFields() + for k, v := range values { + if _, ok := allowed[k]; !ok { + continue + } + switch k { + case "state": + _, err := NewState(v) + if err != nil { + return "", err + } + guid.State = v + } + } + return cmp.Diff(orig, guid), nil +} + +// AAGUIDs returns a list of AAGUIDs. +func AAGUIDs(ctx context.Context, dbConn db.DB, params *util.APIParams) ([]*AAGUID, int64, error) { + var count int64 + entries := make([]*AAGUID, 0) + + dbConn, countDB, _ := db.QueryStatement(dbConn, "aa_guids", params, nil) + + err := dbConn.Find(&entries).Error + if err != nil { + return entries, 0, err + } + // get total record count + err = countDB.Select("count(*)").Row().Scan(&count) + + return entries, count, err +} + +// WhitelistAAGUIDs returns a list of all AAGUIDs that are in the whitelist. +func WhitelistAAGUIDs(ctx context.Context, db db.DB) (util.StringSet, error) { + results := make(util.StringSet) + args := map[string]interface{}{"state": StateActive} + rows, err := db.New().Table("aa_guids").Where(args).Select("id").Rows() + defer func() { + _ = rows.Close() + }() + if err != nil { + return nil, err + } + for rows.Next() { + var id string + err := rows.Scan(&id) + if err != nil { + return nil, err + } + results.Add(id) + } + return results, nil +} + +// BlacklistAAGUIDs returns a list of all AAGUIDs that are in the blacklist. +func BlacklistAAGUIDs(ctx context.Context, db db.DB) (util.StringSet, error) { + results := make(util.StringSet) + args := map[string]interface{}{"state": StateRevoked} + rows, err := db.New().Table("aa_guids").Where(args).Select("id").Rows() + defer func() { + _ = rows.Close() + }() + if err != nil { + return nil, err + } + for rows.Next() { + var id string + err := rows.Scan(&id) + if err != nil { + return nil, err + } + results.Add(id) + } + return results, nil +} diff --git a/internal/model/audit.go b/internal/model/audit.go new file mode 100644 index 0000000..e332ece --- /dev/null +++ b/internal/model/audit.go @@ -0,0 +1,76 @@ +package model + +import ( + "context" + "time" + + "github.com/jinzhu/gorm" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/util" +) + +// AuditEntry defines auditing entries stored in the audit table. +type AuditEntry struct { + ID int64 `json:"id" gorm:"auto_increment;unique_index"` + Group string `json:"group" gorm:"index"` + Anomaly string `json:"anomaly" gorm:"index"` + FidoKeyID string `json:"fidoKeyId" gorm:"index"` + FidoAAGUID string `json:"fidoAAGUID" gorm:"index"` + PrincipalID string `json:"principalId" gorm:"index"` + PrincipalUsername string `json:"principalUsername" gorm:"index"` + SessionID string `json:"sessionId" gorm:"index"` + Action string `json:"action" gorm:"index"` + Data string `json:"data,omitempty"` + IPAddr string `json:"ipAaddr,omitempty" gorm:"index"` + UserAgent string `json:"userAgent,omitempty"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Country string `json:"country,omitempty" gorm:"index"` + Region string `json:"region,omitempty" gorm:"index"` + City string `json:"city,omitempty" gorm:"index"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} + +var _auditAPIToDBFields = map[string]string{ + "createdAt": "created_at", + "keyId": "fido_key_id", + "aaguid": "fido_aa_guid", + "principalId": "principal_id", + "principalUsername": "principal_username", + "sessionId": "session_id", + "ipAddr": "ip_addr", +} + +// AuditEntryByID retrieves audit entries by ID. +func AuditEntryByID(db *gorm.DB, id int64) (*AuditEntry, error) { + ae := &AuditEntry{ + ID: id, + } + err := db.First(ae).Error + if err == gorm.ErrRecordNotFound { + ae = nil + err = ErrRecordNotFound + } + return ae, err +} + +// AuditEntries returns audit entries. +func AuditEntries(ctx context.Context, dbConn db.DB, params *util.APIParams) ([]*AuditEntry, int64, error) { + var count int64 + entries := make([]*AuditEntry, 0) + + dbConn, countDB, _ := db.QueryStatement(dbConn, "audit_entries", params, _auditAPIToDBFields) + if _, ok := params.AndFilters["isAnomaly"]; ok { + delete(params.AndFilters, "isAnomaly") + dbConn = dbConn.Where("anomaly != ''") + countDB = countDB.Where("anomaly != ''") + } + + err := dbConn.Find(&entries).Error + if err != nil { + return entries, 0, err + } + // get total record count + err = countDB.Select("count(*)").Row().Scan(&count) + return entries, count, err +} diff --git a/internal/model/audit_test.go b/internal/model/audit_test.go new file mode 100644 index 0000000..3f27d38 --- /dev/null +++ b/internal/model/audit_test.go @@ -0,0 +1,104 @@ +package model + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/stretchr/testify/assert" +) + +func Test_CreateAuditEntry(t *testing.T) { + dbConn, err := db.GetTestDB() + if err != nil { + if strings.Contains(err.Error(), "connection refused") { + t.Skip("Database not available, skipping") + return + } + t.Fatal(err) + } + err = Migrate(dbConn) + assert.NoError(t, err) + defer func() { + _ = db.CloseConnection(dbConn) + }() + + entry := &AuditEntry{ + Action: "create", + Data: "foo", + } + + err = dbConn.Create(entry).Error + assert.NoError(t, err) + + var entries []AuditEntry + dbConn.Where("action = ?", "create").Find(&entries) + + assert.True(t, len(entries) > 0) + + _, err = AuditEntryByID(dbConn, 0xffffffff) + assert.Error(t, err) +} + +func Test_SearchAuditEntry(t *testing.T) { + dbConn, err := db.GetTestDB() + if err != nil { + if strings.Contains(err.Error(), "connection refused") { + t.Skip("Database not available, skipping") + return + } + t.Fatal(err) + } + err = Migrate(dbConn) + assert.NoError(t, err) + defer func() { + _ = db.CloseConnection(dbConn) + }() + + for n := 0; n < 100; n++ { + entry := &AuditEntry{ + Data: "foo", + } + if n%2 == 0 { + entry.Action = "create" + entry.PrincipalID = "bill" + } else { + entry.Action = "delete" + entry.PrincipalID = "larry" + } + err = dbConn.Create(entry).Error + assert.NoError(t, err) + } + + ctx := context.Background() + params := &util.APIParams{ + AndFilters: map[string]interface{}{"action": "create"}, + Limit: 10, + Page: 1, + } + entries, count, err := AuditEntries(ctx, dbConn, params) + assert.NoError(t, err) + assert.Equal(t, int64(50), count) + assert.Equal(t, int64(1), entries[0].ID) + + params.Page = 5 + entries, count, err = AuditEntries(ctx, dbConn, params) + assert.NoError(t, err) + assert.Equal(t, int64(50), count) + assert.Equal(t, int64(81), entries[0].ID) + + params = util.DefaultAPIParams() + params.CreatedAfter = time.Now().Add(-1 * time.Minute) + _, count, err = AuditEntries(ctx, dbConn, params) + assert.NoError(t, err) + assert.Equal(t, int64(100), count) + + params = util.DefaultAPIParams() + params.CreatedAfter = time.Now().Add(1 * time.Minute) + _, count, err = AuditEntries(ctx, dbConn, params) + assert.NoError(t, err) + assert.Equal(t, int64(0), count) +} diff --git a/internal/model/err.go b/internal/model/err.go new file mode 100644 index 0000000..6e8a812 --- /dev/null +++ b/internal/model/err.go @@ -0,0 +1,6 @@ +package model + +import "github.com/jinzhu/gorm" + +// ErrRecordNotFound : localize "record not found" to model package. +var ErrRecordNotFound error = gorm.ErrRecordNotFound diff --git a/internal/model/key.go b/internal/model/key.go new file mode 100644 index 0000000..44ecea6 --- /dev/null +++ b/internal/model/key.go @@ -0,0 +1,139 @@ +package model + +import ( + "context" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/jinzhu/gorm" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" +) + +// FIDOKey represents a FIDO key generated by an authenticator. +type FIDOKey struct { + ID string `json:"id"` + AAGUID string `json:"aaguid" gorm:"index"` + State string `json:"state" gorm:"index"` + CertCommonName string `json:"certCommonName" gorm:"index"` + CertOrganization string `json:"certOrganization" gorm:"index"` + CertSerial int64 `json:"certSerial" gorm:"index"` + PrincipalID string `gorm:"index" json:"principalId"` + PrincipalUsername string `gorm:"index" json:"username"` + PublicKey []byte `json:"publicKey"` + AttestationType string `json:"attestationType"` + NotValidBefore time.Time `gorm:"index" json:"notValidBefore"` + NotValidAfter time.Time `gorm:"index" json:"notValidAfter"` + Nonce uint32 `json:"-"` + CAKey *CAKey `json:"caKey,omitempty"` + LastUsed time.Time `gorm:"index" json:"lastUsed"` + CreatedAt time.Time `gorm:"index" json:"createdAt"` + ModifiedAt time.Time `gorm:"index" json:"modifiedAt"` +} + +var _fidoKeyAPIToDBFields = map[string]string{ + "aaguid": "aa_guid", + "certCommonName": "cert_common_name", + "certOrganization": "cert_organization", + "certSerial": "cert_serial", + "principalID": "principal_id", + "principalUsername": "principal_username", + "attestationType": "attestation_type", + "notValidBefore": "not_valid_before", + "notValidAfter": "not_valid_after", + "lastUsed": "last_used", + "createdAt": "created_at", + "modifiedAt": "modified_at", +} + +// TableName overrides the naming of this table in the DB. +func (FIDOKey) TableName() string { + return "fido_keys" +} + +// FIDOKeyByID returns a stored FIDO key by ID. +func FIDOKeyByID(ctx context.Context, db db.DB, id string) (*FIDOKey, error) { + k := new(FIDOKey) + + err := db.Set("gorm:auto_preload", true).Where("id = ?", id).First(k).Error + if err == gorm.ErrRecordNotFound { + k = nil + err = ErrRecordNotFound + } + return k, err +} + +// Update ... +func (fk *FIDOKey) Update(ctx context.Context, db db.DB, values map[string]string) (string, error) { + changes, err := fk.ApplyChanges(values) + if err != nil { + return "", errors.Wrap(err, "updating record") + } + return changes, db.Save(fk).Error +} + +// AllowedUpdateFields returns the fields that are mutable. +func (fk *FIDOKey) AllowedUpdateFields() map[string]bool { + return map[string]bool{"state": true} +} + +// ApplyChanges updates the object with values found in the map and returns the "delta" +// of the changes. +func (fk *FIDOKey) ApplyChanges(values map[string]string) (string, error) { + orig := new(FIDOKey) + *orig = *fk + allowed := fk.AllowedUpdateFields() + for k, v := range values { + if _, ok := allowed[k]; !ok { + return "", errors.Errorf("update field not allowed %s", k) + } + switch k { + case "state": + _, err := NewState(v) + if err != nil { + return "", err + } + fk.State = v + } + } + return cmp.Diff(orig, fk), nil +} + +// TouchLastUsed updates the last used field with the current time. +func (fk *FIDOKey) TouchLastUsed(ctx context.Context, db db.DB) error { + fk.LastUsed = time.Now() + return db.Save(fk).Error +} + +// FIDOKeys returns a list of managed FIDOKeys. +func FIDOKeys(ctx context.Context, dbConn db.DB, params *util.APIParams) ([]*FIDOKey, int64, error) { + var count int64 + entries := make([]*FIDOKey, 0) + + dbConn, countDB, _ := db.QueryStatement(dbConn, "fido_keys", params, _fidoKeyAPIToDBFields) + err := dbConn.Find(&entries).Error + if err != nil { + return entries, 0, err + } + // get total record count + err = countDB.Select("count(*)").Row().Scan(&count) + return entries, count, err +} + +// CAKey represents an Ofte-specific key generated by an Ofte key device that implements +// continuous authentication. Only used when Ofte CA is integrated, see https://ofte.io. +type CAKey struct { + // Ofte key handle + ID string `json:"id"` + FIDOKeyID string `gorm:"column:fidokey_id" json:"fidoKeyId"` + PrincipalID string `gorm:"index" json:"principalId"` + Raw []byte `json:"raw"` + CreatedAt time.Time `gorm:"index" json:"createdAt"` + ModifiedAt time.Time `gorm:"index" json:"modifiedAt"` +} + +// TableName overrides the naming of this table in the DB. +func (CAKey) TableName() string { + return "ofte_keys" +} diff --git a/internal/model/migrate.go b/internal/model/migrate.go new file mode 100644 index 0000000..2b3fef8 --- /dev/null +++ b/internal/model/migrate.go @@ -0,0 +1,68 @@ +package model + +import ( + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +// Migrate ... +func Migrate(db *gorm.DB) error { + + if db == nil { + return errors.New("db cannot be nil") + } + + // Principals + err := db.AutoMigrate(&Principal{}).Error + if err != nil { + return err + } + + // Keys + err = db.AutoMigrate(&FIDOKey{}).Error + if err != nil { + return err + } + err = db.Model(&FIDOKey{}).AddForeignKey("principal_id", "principals(id)", "CASCADE", "CASCADE").Error + if err != nil { + return err + } + err = db.AutoMigrate(&CAKey{}).Error + if err != nil { + return err + } + err = db.Model(&CAKey{}).AddForeignKey("fidokey_id", "fido_keys(id)", "CASCADE", "CASCADE").Error + if err != nil { + return err + } + + // AAGUIDs + err = db.AutoMigrate(&AAGUID{}).Error + if err != nil { + return err + } + + // AuditEntries + err = db.AutoMigrate(&AuditEntry{}).Error + if err != nil { + return err + } + + // Principals KeyCount View + err = db.Exec(principalsKeyCountStatement).Error + if err != nil { + return err + } + + return nil +} + +const principalsKeyCountStatement = ` + CREATE OR REPLACE VIEW principals_keycount AS + SELECT principals.*, count(fido_keys.id) as number_of_keys + from principals + LEFT join fido_keys + on (principals.id = fido_keys.principal_id) + group by + principals.id +` diff --git a/internal/model/principal.go b/internal/model/principal.go new file mode 100644 index 0000000..dc7a21a --- /dev/null +++ b/internal/model/principal.go @@ -0,0 +1,230 @@ +package model + +import ( + "context" + "encoding/binary" + "encoding/hex" + "hash/fnv" + "time" + + "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/webauthn" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/jinzhu/gorm" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" +) + +// Principal identifies a person in the system. Only publicly available data is stored. +type Principal struct { + ID string `json:"id"` + Username string `gorm:"index" json:"username"` + State string `gorm:"index" json:"state"` + DisplayName string `json:"displayName"` + Icon string `json:"icon"` + CreatedAt time.Time `gorm:"index" json:"createdAt"` + FIDOKeys []*FIDOKey `json:"fidoKeys,omitempty"` +} + +// NewPrincipal creates a new Principal. +func NewPrincipal(id string, username string, state string, displayName string, icon string) *Principal { + return &Principal{ + ID: id, + Username: username, + State: state, + DisplayName: displayName, + Icon: icon, + } +} + +// BeforeCreate performs pre-insert steps. +func (p *Principal) BeforeCreate(scope *gorm.Scope) error { + if len(p.Username) == 0 && len(p.ID) == 0 { + return errors.New("Username and ID cannot both be nil") + } + if len(p.ID) == 0 { + // make a hash of the username + h := fnv.New64() + _, _ = h.Write([]byte(p.Username)) + id := make([]byte, 8) + binary.LittleEndian.PutUint64(id, h.Sum64()) + p.ID = hex.EncodeToString(id) + } + if len(p.Username) == 0 { + p.Username = p.ID + } + return nil +} + +// Insert ... +func (p *Principal) Insert(ctx context.Context, db db.DB) error { + return db.Create(p).Error +} + +// AddFIDOKey ... +func (p *Principal) AddFIDOKey(fk *FIDOKey) error { + p.FIDOKeys = append(p.FIDOKeys, fk) + return nil +} + +// Update ... +func (p *Principal) Update(ctx context.Context, db db.DB, values map[string]string) (string, error) { + changes, err := p.ApplyChanges(values) + if err != nil { + return "", errors.Wrap(err, "updating record") + } + return changes, db.Save(p).Error +} + +var _principalAllowedFields = map[string]bool{ + "state": true, + "displayName": true, + "icon": true, +} + +var _principalAPIToDBFields = map[string]string{ + "displayName": "display_name", + "createdAt": "created_at", +} + +// AllowedUpdateFields returns the fields that are mutable. +func (p *Principal) AllowedUpdateFields() map[string]bool { + return _principalAllowedFields +} + +// ApplyChanges updates the object with values found in the map and returns the "delta" +// of the changes. +func (p *Principal) ApplyChanges(values map[string]string) (string, error) { + orig := new(Principal) + *orig = *p + allowed := p.AllowedUpdateFields() + for k, v := range values { + if _, ok := allowed[k]; !ok { + return "", errors.Errorf("update field not allowed %s", k) + } + switch k { + case "username": + p.Username = v + case "state": + _, err := NewState(v) + if err != nil { + return "", err + } + p.State = v + case "displayName": + p.DisplayName = v + case "icon": + p.Icon = v + } + } + return cmp.Diff(orig, p), nil +} + +// CredentialList returns an array filled with all the principal's credentials. +func (p *Principal) CredentialList() []protocol.CredentialDescriptor { + credentialExcludeList := []protocol.CredentialDescriptor{} + for _, cred := range p.FIDOKeys { + id, _ := hex.DecodeString(cred.ID) + descriptor := protocol.CredentialDescriptor{ + Type: protocol.PublicKeyCredentialType, + CredentialID: id, + } + credentialExcludeList = append(credentialExcludeList, descriptor) + } + return credentialExcludeList +} + +// WebAuthnID return the principal's ID according to the RP. +func (p *Principal) WebAuthnID() []byte { + id, _ := hex.DecodeString(p.ID) + return id +} + +// WebAuthnName return the principal's username according to the RP. +func (p *Principal) WebAuthnName() string { + return p.Username +} + +// WebAuthnDisplayName return the principal's display name according to the RP. +func (p *Principal) WebAuthnDisplayName() string { + return p.DisplayName +} + +// WebAuthnIcon return the principal's icon URL according to the RP. +func (p *Principal) WebAuthnIcon() string { + return p.Icon +} + +// WebAuthnCredentials returns credentials owned by the user. +func (p *Principal) WebAuthnCredentials() []webauthn.Credential { + credentialList := []webauthn.Credential{} + for _, key := range p.FIDOKeys { + aaguid, err := uuid.Parse(key.AAGUID) + if err != nil { + panic(err) + } + id, _ := hex.DecodeString(key.ID) + credentialList = append(credentialList, webauthn.Credential{ + ID: id, + PublicKey: key.PublicKey, + AttestationType: key.AttestationType, + Authenticator: webauthn.Authenticator{ + AAGUID: aaguid[:], + SignCount: key.Nonce, + }, + }) + } + return credentialList +} + +// PrincipalByID returns a `Principal` by id. +func PrincipalByID(ctx context.Context, db db.DB, id string, preload bool) (*Principal, error) { + p := new(Principal) + + if preload { + db = db.Set("gorm:auto_preload", true) + } + err := db.Where("id = ?", id).Or("username = ?", id).First(p).Error + if err == gorm.ErrRecordNotFound { + p = nil + err = ErrRecordNotFound + } + return p, err +} + +// PrincipalByUsername returns a `Principal` by username. +func PrincipalByUsername(ctx context.Context, db db.DB, username string, preload bool) (*Principal, error) { + p := new(Principal) + if preload { + db = db.Set("gorm:auto_preload", true) + } + err := db.Where("username = ?", username).First(p).Error + if err == gorm.ErrRecordNotFound { + p = nil + err = ErrRecordNotFound + } + return p, err +} + +// Principals returns a list of principals. +func Principals(ctx context.Context, dbConn db.DB, params *util.APIParams) ([]*Principal, int64, error) { + var count int64 + entries := make([]*Principal, 0) + + dbConn, countDB, _ := db.QueryStatement(dbConn, "principals_keycount", params, _principalAPIToDBFields) + if _, ok := params.AndFilters["hasKeys"]; ok { + delete(params.AndFilters, "hasKeys") + dbConn = dbConn.Where("number_of_keys > 0") + countDB = countDB.Where("number_of_keys > 0") + } + + err := dbConn.Find(&entries).Error + if err != nil { + return entries, 0, err + } + // get total record count + err = countDB.Select("count(*)").Row().Scan(&count) + return entries, count, err +} diff --git a/internal/model/principal_test.go b/internal/model/principal_test.go new file mode 100644 index 0000000..b2beb68 --- /dev/null +++ b/internal/model/principal_test.go @@ -0,0 +1,72 @@ +package model + +import ( + "context" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + _ "github.com/jinzhu/gorm/dialects/postgres" +) + +func Test_PrincipalUpdate(t *testing.T) { + assert := assert.New(t) + + p := NewPrincipal("", "foo@bar.com", StateActive, "Bill Gates", "http:/pic.org/m") + + diff, err := p.ApplyChanges(map[string]string{"displayName": "Steve Jobs"}) + assert.NoError(err) + assert.Contains(diff, "\"Bill Gates\"") + + _, err = p.ApplyChanges(map[string]string{"id": "not_allowed"}) + assert.Error(err) +} + +func Test_PrincipalAndArtifacts(t *testing.T) { + + ctx := context.Background() + assert := assert.New(t) + dbConn, err := db.GetTestDB() + if err != nil { + if strings.Contains(err.Error(), "connection refused") { + t.Skip("Database not available, skipping") + return + } + t.Fatal(err) + } + err = Migrate(dbConn) + assert.NoError(err) + defer func() { + _ = db.CloseConnection(dbConn) + }() + + p := NewPrincipal("", "matthew@ofte.io", StateIssued, "Matthew McNeely", "http://pic.org/m") + err = dbConn.FirstOrCreate(p).Error + assert.NoError(err) + + t.Run("GetPrincipal", func(t *testing.T) { + p, err := PrincipalByUsername(ctx, dbConn, "matthew@ofte.io", true) + assert.NoError(err) + assert.Equal("http://pic.org/m", p.Icon, "Expect to retrieve attributes") + }) + t.Run("AddFIDOKey", func(t *testing.T) { + p, err := PrincipalByUsername(ctx, dbConn, "matthew@ofte.io", true) + assert.NoError(err) + fidoKey := &FIDOKey{ + ID: "some-FIDO-key", + PrincipalID: p.ID, + AAGUID: uuid.New().String(), + } + err = dbConn.Save(fidoKey).Error + assert.NoError(err) + + p, err = PrincipalByUsername(ctx, dbConn, "matthew@ofte.io", true) + assert.NoError(err) + require.Len(t, p.FIDOKeys, 1) + assert.Equal("some-FIDO-key", p.FIDOKeys[0].ID) + }) +} diff --git a/internal/model/session.go b/internal/model/session.go new file mode 100644 index 0000000..a1202cc --- /dev/null +++ b/internal/model/session.go @@ -0,0 +1,133 @@ +package model + +import ( + "bytes" + "context" + "encoding/gob" + "time" + + "github.com/google/uuid" + "github.com/ofte-auth/dogpark/internal/store" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" + "go.uber.org/multierr" +) + +// Constants for KV +const ( + SessionTTL = 40 + + CollectionSessions = "sessions" +) + +// Session represents a CA session. +type Session struct { + ID string + PrincipalID string + PrincipalUsername string + FIDOKeyID string + AAGUID string + State string + IPAddr string + UserAgent string + AgentSalt string // TBD: See https://github.com/ofte-auth/dogpark/issues/2 + Nonce uint32 + CreatedAt time.Time + UpdatedAt time.Time +} + +// NewSession creates a CA session. +func NewSession(principalID, fidoKeyID, aaguid, ipaddr, userAgent string) (*Session, error) { + var err error + switch { + case len(principalID) < 8: + err = multierr.Append(err, errors.New("invalid principal ID")) + case len(fidoKeyID) < 8: + err = multierr.Append(err, errors.New("invalid fidoKeyID")) + case len(aaguid) < 8: + err = multierr.Append(err, errors.New("invalid aaguid")) + case ipaddr == "": + err = multierr.Append(err, errors.New("invalid idaddr")) + case userAgent == "": + err = multierr.Append(err, errors.New("invalid userAgent")) + } + if err != nil { + return nil, err + } + return &Session{ + ID: uuid.New().String(), + PrincipalID: principalID, + FIDOKeyID: fidoKeyID, + AAGUID: aaguid, + State: StateActive, + IPAddr: ipaddr, + UserAgent: userAgent, + CreatedAt: time.Now(), + }, nil +} + +// Encode gobs a Session to serialized []byte. +func (s *Session) Encode() ([]byte, error) { + var buf bytes.Buffer + encoder := gob.NewEncoder(&buf) + err := encoder.Encode(s) + return buf.Bytes(), err +} + +// Decode gobs a serialized []byte to a Session. +func (s *Session) Decode(b []byte) error { + buf := bytes.NewBuffer(b) + decoder := gob.NewDecoder(buf) + return decoder.Decode(s) +} + +// Put or Update a Session in the kv store. +func (s *Session) Put(ctx context.Context, manager store.Manager, ttlSeconds int64) error { + if ttlSeconds == 0 { + ttlSeconds = SessionTTL + } + b, err := s.Encode() + if err != nil { + return err + } + if err = manager.Put(ctx, CollectionSessions, s.ID, b, ttlSeconds); err != nil { + return err + } + return nil +} + +// Delete removes the `Session`. +func (s *Session) Delete(ctx context.Context, manager store.Manager, sessionID string) error { + return manager.Delete(ctx, CollectionSessions, sessionID) +} + +// SessionByID gets a Session by its ID. +func SessionByID(ctx context.Context, manager store.Manager, id string) (*Session, error) { + b, err := manager.Get(ctx, CollectionSessions, id) + if err != nil { + return nil, err + } + session := new(Session) + if err = session.Decode(b); err != nil { + return nil, errors.Wrap(err, "decoding session from store") + } + return session, nil +} + +// Sessions returns sessions from the store. +func Sessions(ctx context.Context, manager store.Manager, params *util.APIParams) ([]*Session, int64, error) { + newestFirst := params.OrderDirection == "DESC" + results, total, err := manager.List(ctx, CollectionSessions, params.Limit, params.Page, newestFirst) + if err != nil { + return nil, 0, err + } + result := []*Session{} + for _, v := range results { + session := new(Session) + if err = session.Decode(v.Value); err != nil { + return nil, 0, errors.Wrap(err, "decoding session from list") + } + result = append(result, session) + } + return result, total, nil +} diff --git a/internal/model/session_test.go b/internal/model/session_test.go new file mode 100644 index 0000000..6370996 --- /dev/null +++ b/internal/model/session_test.go @@ -0,0 +1,81 @@ +package model + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/google/uuid" + "github.com/ofte-auth/dogpark/internal/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_NewSession(t *testing.T) { + type args struct { + principalID string + fidoKeyID string + ipaddr string + userAgent string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Happy Path", + args: args{ + principalID: "123456789", + fidoKeyID: "123456789", + ipaddr: "127.0.0.1", + userAgent: "test", + }, + wantErr: false, + }, + { + name: "Invalid principal ID", + args: args{ + principalID: "1234567", + fidoKeyID: "123456789", + ipaddr: "127.0.0.1", + userAgent: "test", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewSession(tt.args.principalID, tt.args.fidoKeyID, uuid.New().String(), tt.args.ipaddr, tt.args.userAgent) + if (err != nil) != tt.wantErr { + t.Errorf("NewSession() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != nil { + if !reflect.DeepEqual(got.FIDOKeyID, tt.args.fidoKeyID) { + t.Errorf("NewSession() = %v", got) + } + if time.Since(got.CreatedAt) > time.Second { + t.Errorf("NewSession() = %v, creation time anomaly", got) + } + } + }) + } +} + +func Test_SessionPut(t *testing.T) { + ctx := context.Background() + session, err := NewSession("pRiNciPaL", "FiDoKeYId", uuid.New().String(), "8.8.8.8", "Testing") + require.NoError(t, err) + manager, err := store.NewETCDMockManager() + require.NoError(t, err) + + err = session.Put(ctx, manager, 30) + assert.NoError(t, err) + + _, err = SessionByID(ctx, manager, session.ID) + assert.NoError(t, err) + + _ = manager.Close() +} diff --git a/internal/model/state.go b/internal/model/state.go new file mode 100644 index 0000000..1bb03d4 --- /dev/null +++ b/internal/model/state.go @@ -0,0 +1,43 @@ +package model + +import ( + "strings" + + "github.com/pkg/errors" +) + +// State ... +type State int + +// Constants for State +const ( + Issued State = iota + Active + Revoked + + StateIssued = "issued" + StateActive = "active" + StateRevoked = "revoked" +) + +func (s State) String() string { + return [...]string{ + StateIssued, + StateActive, + StateRevoked, + }[s] +} + +// NewState creates a new `State` from a string. +func NewState(state string) (State, error) { + switch strings.ToLower(state) { + case "issued": + return Issued, nil + case "active": + return Active, nil + case "revoked": + return Revoked, nil + default: + return -1, errors.Errorf("invalid state %s", state) + } +} diff --git a/internal/service/admin.go b/internal/service/admin.go new file mode 100644 index 0000000..76e3b29 --- /dev/null +++ b/internal/service/admin.go @@ -0,0 +1,342 @@ +package service + +import ( + "context" + + "github.com/ofte-auth/dogpark/internal/model" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +// Admin defines the admin service interface. +type Admin interface { + Principal(context.Context, string) (*model.Principal, error) + AddPrincipal(context.Context, map[string]string) (*model.Principal, error) + UpdatePrincipal(context.Context, string, map[string]string) (*model.Principal, string, error) + PrincipalByUsername(context.Context, string) (*model.Principal, error) + Principals(context.Context, *util.APIParams) ([]*model.Principal, int64, error) + + FIDOKey(context.Context, string) (*model.FIDOKey, error) + UpdateFIDOKey(context.Context, string, map[string]string) (*model.FIDOKey, string, error) + DeleteFIDOKey(context.Context, string) error + FIDOKeys(context.Context, *util.APIParams) ([]*model.FIDOKey, int64, error) + + AAGUID(context.Context, string) (*model.AAGUID, error) + AddAAGUID(context.Context, map[string]string) (*model.AAGUID, error) + UpdateAAGUID(context.Context, string, map[string]string) (*model.AAGUID, string, error) + AAGUIDs(context.Context, *util.APIParams) ([]*model.AAGUID, int64, error) + AAGUIDWhitelist(context.Context) (util.StringSet, error) + AAGUIDBlacklist(context.Context) (util.StringSet, error) + + Session(context.Context, string) (*model.Session, error) + Sessions(context.Context, *util.APIParams) ([]*model.Session, int64, error) + KillSession(context.Context, string) (*model.Session, error) + + LogByID(context.Context, int64) (*model.AuditEntry, error) + Logs(context.Context, *util.APIParams) ([]*model.AuditEntry, int64, error) + + Stop() +} + +type adminService struct { + Service +} + +// NewAdminService ... +func NewAdminService(ctx context.Context, options ...func(*Service) error) (Admin, error) { + service := &adminService{ + Service: Service{ + name: "dogpark-admin-service", + }, + } + for _, option := range options { + err := option(&(service).Service) + if err != nil { + return nil, err + } + } + if service.db == nil { + return nil, errors.New("db member is nil") + } + if service.kv == nil { + return nil, errors.New("kv member is nil") + } + if _, ok := service.params["fido_mds_token"]; !ok { + log.Warning("fido_mds_token not found, see https://fidoalliance.org/metadata/") + } + return service, nil +} + +func (s *adminService) Stop() { + s.Service.Stop() +} + +func (s *adminService) AddPrincipal(ctx context.Context, params map[string]string) (*model.Principal, error) { + var ( + p *model.Principal + err error + ) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "addPrincipal", p, nil, err) + }() + + p = model.NewPrincipal(params["id"], params["username"], model.StateActive, params["displayName"], params["icon"]) + err = s.db.Create(p).Error + if err != nil { + err = errors.Wrap(err, "Adding principal") + return nil, err + } + + return p, nil +} + +func (s *adminService) UpdatePrincipal(ctx context.Context, id string, values map[string]string) (*model.Principal, string, error) { + var ( + p *model.Principal + err error + ) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "updatePrincipal", p, nil, err) + }() + + p, err = model.PrincipalByID(ctx, s.db, id, true) + if err != nil { + return nil, "", err + } + diff, err := p.ApplyChanges(values) + if err != nil { + return nil, diff, err + } + err = s.db.Save(p).Error + + return p, diff, err +} + +func (s *adminService) Principal(ctx context.Context, id string) (*model.Principal, error) { + p, err := model.PrincipalByID(ctx, s.db, id, true) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "getPrincipalByID", p, nil, err) + }() + return p, err +} + +func (s *adminService) PrincipalByUsername(ctx context.Context, username string) (*model.Principal, error) { + p, err := model.PrincipalByUsername(ctx, s.db, username, true) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "getPrincipalByUsername", p, nil, err) + }() + + return p, err +} + +func (s *adminService) Principals(ctx context.Context, params *util.APIParams) ([]*model.Principal, int64, error) { + principals, count, err := model.Principals(ctx, s.db, params) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "listPrincipals", nil, nil, err) + }() + + return principals, count, err +} + +func (s *adminService) FIDOKey(ctx context.Context, id string) (*model.FIDOKey, error) { + fidoKey, err := model.FIDOKeyByID(ctx, s.db, id) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "getFIDOKey", nil, fidoKey, err) + }() + return fidoKey, err +} + +func (s *adminService) UpdateFIDOKey(ctx context.Context, id string, values map[string]string) (*model.FIDOKey, string, error) { + var diff string + k, err := model.FIDOKeyByID(ctx, s.db, id) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "updateFIDOKey", nil, k, err) + }() + + if err != nil { + return nil, diff, err + } + diff, err = k.ApplyChanges(values) + if err != nil { + return nil, diff, err + } + err = s.db.Save(k).Error + + return k, diff, err +} + +func (s *adminService) DeleteFIDOKey(ctx context.Context, id string) error { + k, err := model.FIDOKeyByID(ctx, s.db, id) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "deleteFIDOKey", nil, k, err) + }() + if err != nil { + return err + } + err = s.db.Delete(k).Error + + return err +} + +func (s *adminService) FIDOKeys(ctx context.Context, params *util.APIParams) ([]*model.FIDOKey, int64, error) { + keys, count, err := model.FIDOKeys(ctx, s.db, params) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "listFIDOKeys", nil, nil, err) + }() + + return keys, count, err +} + +func (s *adminService) AAGUID(ctx context.Context, id string) (*model.AAGUID, error) { + aaguid, err := model.AAGUIDByID(ctx, s.db, id) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "getAAGUID", nil, &model.FIDOKey{AAGUID: id}, err) + }() + + return aaguid, err +} + +func (s *adminService) AddAAGUID(ctx context.Context, params map[string]string) (*model.AAGUID, error) { + var ( + id string + err error + ) + id, ok := params["id"] + if !ok { + return nil, errors.New("id not in params") + } + guid := &model.AAGUID{ + ID: id, + Label: params["label"], + State: params["state"], + } + // Auditing + defer func() { + go s.Audit(ctx, "admin", "addAAGUID", nil, &model.FIDOKey{AAGUID: id}, err) + }() + + err = s.db.Create(guid).Error + if err != nil { + return nil, errors.Wrap(err, "adding AAGUID") + } + + if _, ok := s.params["fido_mds_token"]; ok { + go func() { + _ = UpdateFIDOMetadata(s.db, id, s.params["fido_mds_token"]) + }() + } + return guid, nil +} + +func (s *adminService) UpdateAAGUID(ctx context.Context, id string, values map[string]string) (*model.AAGUID, string, error) { + var diff string + aaguid, err := model.AAGUIDByID(ctx, s.db, id) + if err != nil { + return nil, diff, err + } + // Auditing + defer func() { + go s.Audit(ctx, "admin", "updateAAGUID", nil, &model.FIDOKey{AAGUID: id}, err) + }() + diff, err = aaguid.ApplyChanges(values) + if err != nil { + return nil, diff, err + } + err = s.db.Save(aaguid).Error + + return aaguid, diff, err +} + +func (s *adminService) AAGUIDs(ctx context.Context, params *util.APIParams) ([]*model.AAGUID, int64, error) { + list, count, err := model.AAGUIDs(ctx, s.db, params) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "listAAGUIDs", nil, nil, err) + }() + + return list, count, err +} + +func (s *adminService) AAGUIDWhitelist(ctx context.Context) (util.StringSet, error) { + set, err := model.WhitelistAAGUIDs(ctx, s.db) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "getAAGUIDsWhitelist", nil, nil, err) + }() + + return set, err +} + +func (s *adminService) AAGUIDBlacklist(ctx context.Context) (util.StringSet, error) { + set, err := model.BlacklistAAGUIDs(ctx, s.db) + // Auditing + defer func() { + go s.Audit(ctx, "admin", "getAAGUIDsBlacklist", nil, nil, err) + }() + + return set, err +} + +func (s *adminService) Session(ctx context.Context, id string) (*model.Session, error) { + session, err := model.SessionByID(ctx, s.kv, id) + defer func() { + go s.Audit(ctx, "admin", "getSession", nil, nil, err) + }() + + return session, err +} + +func (s *adminService) Sessions(ctx context.Context, params *util.APIParams) ([]*model.Session, int64, error) { + sessions, count, err := model.Sessions(ctx, s.kv, params) + defer func() { + go s.Audit(ctx, "admin", "listSessions", nil, nil, err) + }() + + return sessions, count, err +} + +func (s *adminService) KillSession(ctx context.Context, id string) (*model.Session, error) { + var ( + err error + session *model.Session + ) + defer func() { + p := &model.Principal{} + if session != nil { + p.ID = session.PrincipalID + p.Username = session.PrincipalUsername + } + go s.Audit(ctx, "admin", "killSession", p, nil, err) + }() + session, err = model.SessionByID(ctx, s.kv, id) + if err != nil { + return nil, err + } + session.State = model.StateRevoked + err = session.Put(ctx, s.kv, model.SessionTTL) + return session, err +} + +func (s *adminService) LogByID(ctx context.Context, id int64) (*model.AuditEntry, error) { + return model.AuditEntryByID(s.db, id) +} + +func (s *adminService) Logs(ctx context.Context, params *util.APIParams) ([]*model.AuditEntry, int64, error) { + list, count, err := model.AuditEntries(ctx, s.db, params) + defer func() { + go s.Audit(ctx, "admin", "listLogs", nil, nil, err) + }() + + return list, count, err +} diff --git a/internal/service/admin_test.go b/internal/service/admin_test.go new file mode 100644 index 0000000..15884bf --- /dev/null +++ b/internal/service/admin_test.go @@ -0,0 +1,190 @@ +package service + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/ofte-auth/dogpark/internal/model" + + "github.com/ofte-auth/dogpark/internal/util" + + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_AdminService_AddAndUpdatePrincipal(t *testing.T) { + ctx := context.Background() + assert := assert.New(t) + + service, err := getTestAdminService(ctx, t) + if err != nil { + return + } + defer func() { + service.Stop() + }() + + principal, err := service.AddPrincipal(ctx, map[string]string{ + "username": "joe@example.com", + "displayName": "joe", + "icon": "http://example.com/example.gif", + }) + assert.NoError(err) + assert.NotEmpty(principal.ID) + + principal, err = service.PrincipalByUsername(ctx, "joe@example.com") + assert.NoError(err) + assert.Equal("joe@example.com", principal.Username) + + principal, err = service.AddPrincipal(ctx, map[string]string{ + "id": "12345678", + "username": "joe2@example.com", + "displayName": "joe", + "icon": "http://example.com/example.gif", + }) + assert.NoError(err) + assert.Equal("active", principal.State) + + _, _, err = service.UpdatePrincipal(ctx, principal.ID, map[string]string{"displayName": "Abe"}) + assert.NoError(err) + + principal, err = service.Principal(ctx, principal.ID) + assert.NoError(err) + assert.Equal("Abe", principal.DisplayName) + + _, _, err = service.UpdatePrincipal(ctx, principal.ID, map[string]string{"foo": "bar"}) + assert.Error(err) + assert.Equal("update field not allowed foo", err.Error()) +} + +func Test_ListPrincipals(t *testing.T) { + ctx := context.Background() + assert := assert.New(t) + + service, err := getTestAdminService(ctx, t) + if err != nil { + return + } + defer func() { + service.Stop() + }() + + for _, v := range []string{"bill", "larry", "ada"} { + principal, err := service.AddPrincipal(ctx, map[string]string{ + "username": fmt.Sprintf("%s@example.com", v), + "displayName": v, + }) + assert.NoError(err) + assert.NotEmpty(principal.ID) + } + + l, count, err := service.Principals(ctx, util.DefaultAPIParams()) + assert.NoError(err) + assert.Len(l, 3) + assert.Equal(int64(3), count) + + l, count, err = service.Principals(ctx, &util.APIParams{ + AndFilters: map[string]interface{}{"state": "revoked"}, + }) + assert.NoError(err) + assert.NotNil(l) + assert.Equal(int64(0), count) + + l, count, err = service.Principals(ctx, &util.APIParams{ + AndFilters: map[string]interface{}{ + "username": "bill@example.com", + "displayName": "bill", + }, + }) + assert.NoError(err) + assert.NotNil(l) + assert.Equal(int64(1), count) +} + +func Test_AddAndUpdateAAGUIDs(t *testing.T) { + ctx := context.Background() + assert := assert.New(t) + + service, err := getTestAdminService(ctx, t) + if err != nil { + return + } + defer func() { + service.Stop() + }() + + uuid := "96a565a1-8c2c-406f-879f-573cb0f9cc15" + _, err = service.AddAAGUID(ctx, map[string]string{ + "id": uuid, + "label": "test", + }) + + assert.NoError(err) + + l, count, err := service.AAGUIDs(ctx, util.DefaultAPIParams()) + assert.Equal(int64(1), count) + assert.NoError(err) + assert.Len(l, 1) + +} + +func Test_AAGUIDWhiteAndBlackList(t *testing.T) { + + ctx := context.Background() + assert := assert.New(t) + + service, err := getTestAdminService(ctx, t) + if err != nil { + return + } + defer func() { + service.Stop() + }() + + uuid := "96a565a1-8c2c-406f-879f-573cb0f9cc15" + _, err = service.AddAAGUID(ctx, map[string]string{ + "id": uuid, + "label": "test", + "state": "active", + }) + assert.NoError(err) + + whitelist, err := service.AAGUIDWhitelist(ctx) + assert.NoError(err) + assert.True(whitelist.Has(uuid)) + + aaguid, _, err := service.UpdateAAGUID(ctx, uuid, map[string]string{"state": "revoked"}) + assert.NoError(err) + assert.Equal("revoked", aaguid.State) + + blacklist, err := service.AAGUIDBlacklist(ctx) + assert.NoError(err) + assert.True(blacklist.Has(uuid)) +} + +func getTestAdminService(ctx context.Context, t *testing.T) (Admin, error) { + db, err := db.GetTestDB() + if err != nil { + if strings.Contains(err.Error(), "connection refused") { + t.Skip("Database not available, skipping") + return nil, err + } + t.Fatal(err) + return nil, err + } + err = model.Migrate(db) + if err != nil { + return nil, err + } + + kv, err := store.NewETCDMockManager() + require.NoError(t, err) + + service, err := NewAdminService(ctx, OptionDB(db), OptionKV(kv)) + require.NoError(t, err) + return service, err +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..27aeef3 --- /dev/null +++ b/internal/service/auth.go @@ -0,0 +1,451 @@ +package service + +import ( + "context" + "crypto/x509" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/webauthn" + "github.com/google/uuid" + "github.com/ofte-auth/dogpark/internal/model" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" +) + +// Consts for auth services +const ( + CollectionPendingFIDORegistration = "fidoPendingReg" + CollectionPendingFIDOLogin = "fidoPendingLogin" +) + +// Error constants +var ( + ErrPrincipalRevoked = errors.New("principal rekoked") +) + +// Auth defines the auth service interface. +type Auth interface { + GetOrCreatePrincipal(context.Context, map[string]string) (*model.Principal, *APIError) + StartFIDORegistration(context.Context, string) (*protocol.CredentialCreation, *APIError) + FinishFIDORegistration(context.Context, string, *http.Request) (*model.FIDOKey, *APIError) + StartFIDOLogin(context.Context, string) (*protocol.CredentialAssertion, *APIError) + FinishFIDOLogin(context.Context, string, *http.Request) (*model.Principal, *APIError) + + Stop() +} + +type authService struct { + Service + webAuthn *webauthn.WebAuthn +} + +// NewAuthService creates a new instance. +func NewAuthService(ctx context.Context, options ...func(*Service) error) (Auth, error) { + var err error + service := &authService{ + Service: Service{ + name: "dogpark-auth-service", + }, + } + for _, option := range options { + err := option(&(service).Service) + if err != nil { + return nil, err + } + } + var required = []string{"rpDisplayName", "rpID", "rpOrigin"} + for _, v := range required { + if _, ok := service.params[v]; !ok { + return nil, errors.Errorf("required parameter %s missing", v) + } + } + service.webAuthn, err = webauthn.New(&webauthn.Config{ + RPDisplayName: service.params["rpDisplayName"], + RPID: service.params["rpID"], + RPOrigin: service.params["rpOrigin"], + RPIcon: service.params["rpIcon"], + AttestationPreference: protocol.PreferDirectAttestation, + }) + if err != nil { + return nil, errors.Wrap(err, "initializing protocol options") + } + return service, nil +} + +func (s *authService) Stop() { + s.Service.Stop() +} + +func (s *authService) GetOrCreatePrincipal(ctx context.Context, params map[string]string) (*model.Principal, *APIError) { + var ( + p *model.Principal + err error + ) + detail := "getting or creating principal" + // Auditing + defer func() { + go s.Audit(ctx, "auth", "getOrCreatePrincipal", p, nil, err) + }() + + id := params["id"] + if id == "" { + id = params["username"] + } + p, err = model.PrincipalByID(ctx, s.db, id, true) + + switch err { + case nil: + return p, nil + case model.ErrRecordNotFound: + // do nothing + default: + err = errors.Wrap(err, "getting principal by username") + return nil, NewAPIError(500, err, detail) + } + p = model.NewPrincipal(params["id"], params["username"], model.StateActive, params["displayName"], params["icon"]) + err = p.Insert(ctx, s.db) + if err != nil { + err = errors.Wrap(err, "inserting new principal record") + return nil, NewAPIError(400, err, detail) + } + return p, nil +} + +func (s *authService) StartFIDORegistration(ctx context.Context, username string) (*protocol.CredentialCreation, *APIError) { + var ( + p *model.Principal + err error + ) + detail := "starting FIDO registration" + // Auditing + defer func() { + go s.Audit(ctx, "auth", "startFIDORegistration", p, nil, err) + }() + + p, err = model.PrincipalByUsername(ctx, s.db, username, true) + if err != nil { + err = errors.Wrap(err, "locating principal by username") + return nil, NewAPIError(404, err, detail) + } + + if p.State != model.StateActive { + err = errors.Errorf("principal state (%s) not active", p.State) + return nil, NewAPIError(401, err, detail) + } + + registerOptions := func(credCreationOpts *protocol.PublicKeyCredentialCreationOptions) { + credCreationOpts.CredentialExcludeList = p.CredentialList() + } + options, sessionData, err := s.webAuthn.BeginRegistration(p, registerOptions) + if err != nil { + err = errors.Wrap(err, "beginning registration") + return nil, NewAPIError(400, err, detail) + } + + marshaledData, err := json.Marshal(sessionData) + if err != nil { + err = errors.Wrap(err, "marshaling reg session data") + return nil, NewAPIError(400, err, detail) + } + + // store the reg session data in the keystore with TTL + err = s.kv.Put(ctx, CollectionPendingFIDORegistration, username, marshaledData, 30) + if err != nil { + err = errors.Wrap(err, "storing reg session data") + return nil, NewAPIError(400, err, detail) + } + + return options, nil +} + +func (s *authService) FinishFIDORegistration(ctx context.Context, username string, r *http.Request) (*model.FIDOKey, *APIError) { + var ( + p *model.Principal + err error + fidoKey *model.FIDOKey + ) + detail := "finishing FIDO registration" + // Auditing + defer func() { + go s.Audit(ctx, "auth", "finishFIDORegistration", p, fidoKey, err) + }() + + p, err = model.PrincipalByUsername(ctx, s.db, username, true) + if err != nil { + err = errors.Wrap(err, "locating principal by username") + return nil, NewAPIError(404, err, detail) + } + sessionData := webauthn.SessionData{} + d, err := s.kv.Get(ctx, CollectionPendingFIDORegistration, username) + if err != nil { + err = errors.Wrap(err, "getting stored session data from keystore") + return nil, NewAPIError(400, err, detail) + } + _ = s.kv.Delete(ctx, CollectionPendingFIDORegistration, username) + + err = json.Unmarshal(d, &sessionData) + if err != nil { + err = errors.Wrap(err, "unmarshaling session data") + return nil, NewAPIError(400, err, detail) + } + + creationData, err := protocol.ParseCredentialCreationResponseBody(r.Body) + if err != nil { + err = errors.Wrapf(err, "parsing credential creation response '%s', '%s'", + err.(*protocol.Error).Details, + err.(*protocol.Error).DevInfo, + ) + return nil, NewAPIError(400, err, detail) + } + + credential, err := s.webAuthn.CreateCredential(p, sessionData, creationData) + if err != nil { + err = errors.Wrapf(err, "verifying the registration '%s', '%s'", + err.(*protocol.Error).Details, + err.(*protocol.Error).DevInfo, + ) + return nil, NewAPIError(400, err, detail) + } + + aaguid, err := uuid.FromBytes(credential.Authenticator.AAGUID) + if err != nil { + err = errors.Wrapf(err, "parsing aaguid '%v'", credential.Authenticator.AAGUID) + return nil, NewAPIError(400, err, detail) + } + + now := time.Now() + fidoKey = &model.FIDOKey{ + ID: hex.EncodeToString(credential.ID), + AAGUID: aaguid.String(), + State: model.StateActive, + PrincipalID: p.ID, + PrincipalUsername: p.Username, + PublicKey: credential.PublicKey, + AttestationType: credential.AttestationType, + Nonce: credential.Authenticator.SignCount, + LastUsed: now, + CreatedAt: now, + ModifiedAt: now, + } + + x5c, found := creationData.Response.AttestationObject.AttStatement["x5c"].([]interface{}) + if !found || len(x5c) == 0 { + err = errors.New("No certificate information found or made available") + return nil, NewAPIError(400, err, detail) + } + c := x5c[0] + cb, cv := c.([]byte) + if !cv { + err = errors.New("error getting certificate from x5c cert chain") + return nil, NewAPIError(400, err, detail) + } + ct, err := x509.ParseCertificate(cb) + if err != nil { + err = errors.Wrap(err, "error parsing certificate from ASN.1 data") + return nil, NewAPIError(400, err, detail) + } + fidoKey.CertCommonName = ct.Issuer.CommonName + if len(ct.Issuer.Organization) > 0 { + fidoKey.CertOrganization = ct.Issuer.Organization[0] + } + fidoKey.CertSerial = ct.SerialNumber.Int64() + if ct.NotBefore.After(time.Now()) || ct.NotAfter.Before(time.Now()) { + err = errors.Errorf("cert in chain outside of time bounds, notAfter: %s", ct.NotAfter) + return nil, NewAPIError(400, err, detail) + } + fidoKey.NotValidBefore = ct.NotBefore + fidoKey.NotValidAfter = ct.NotAfter + + // Check against AAGUID whitelist + whitelist, err := model.WhitelistAAGUIDs(ctx, s.db) + if err != nil { + err = errors.Wrap(err, "error getting aaguid whitelist") + return nil, NewAPIError(500, err, detail) + } + if len(whitelist) > 0 { + if !whitelist.Has(aaguid.String()) { + err = errors.Errorf("authenticator guid %s, %s is not in the whitelist %v", + aaguid.String(), + fidoKey.CertCommonName, + whitelist.Values(), + ) + return nil, NewAPIError(401, err, detail) + } + } + + // Check for revoked AAGUIDs + guid, err := model.AAGUIDByID(ctx, s.db, aaguid.String()) + switch err { + case nil: + if guid.State == model.StateRevoked { + err = errors.Errorf("authenticator guid %s, %s is blacklisted", guid.ID, guid.Label) + return nil, NewAPIError(401, err, detail) + } + case model.ErrRecordNotFound: + guid := &model.AAGUID{ + ID: aaguid.String(), + Label: fmt.Sprintf("%s %s", fidoKey.CertOrganization, fidoKey.CertCommonName), + } + err := s.db.Create(guid).Error + if err != nil { + err = errors.Wrapf(err, "inserting new aaguid, guid %s, %s", guid.ID, guid.Label) + return nil, NewAPIError(500, err, detail) + } + } + + err = s.db.Save(fidoKey).Error + if err != nil { + err = errors.Wrap(err, "saving fidokey record") + return nil, NewAPIError(500, err, detail) + } + + return fidoKey, nil +} + +func (s *authService) StartFIDOLogin(ctx context.Context, username string) (*protocol.CredentialAssertion, *APIError) { + var ( + p *model.Principal + err error + ) + detail := "starting FIDO login" + // Auditing + defer func() { + go s.Audit(ctx, "auth", "startFIDOLogin", p, nil, err) + }() + + p, err = model.PrincipalByUsername(ctx, s.db, username, true) + if err != nil { + err = errors.Wrap(err, "locating principal by username") + return nil, NewAPIError(400, err, detail) + } + + if p.State != model.StateActive { + err = errors.Errorf("principal state (%s) not active", p.State) + return nil, NewAPIError(401, err, detail) + } + + assert, sessionData, err := s.webAuthn.BeginLogin(p) + if err != nil { + err = errors.Wrap(err, "getting credential request options") + return nil, NewAPIError(400, err, detail) + } + marshaledData, err := json.Marshal(sessionData) + if err != nil { + err = errors.Wrap(err, "marshaling login session data") + return nil, NewAPIError(400, err, detail) + } + // store the login session data in the keystore with TTL + err = s.kv.Put(ctx, CollectionPendingFIDOLogin, username, marshaledData, 30) + if err != nil { + err = errors.Wrap(err, "storing reg session data") + return nil, NewAPIError(400, err, detail) + } + + return assert, nil +} + +func (s *authService) FinishFIDOLogin(ctx context.Context, username string, r *http.Request) (*model.Principal, *APIError) { + var ( + p *model.Principal + err error + fidoKey *model.FIDOKey + ) + detail := "finishing FIDO login" + // Auditing + defer func() { + go s.Audit(ctx, "auth", "finishFIDOLogin", p, fidoKey, err) + }() + + p, err = model.PrincipalByUsername(ctx, s.db, username, true) + if err != nil { + err = errors.Wrap(err, "locating principal by username") + return nil, NewAPIError(400, err, detail) + } + sessionData := webauthn.SessionData{} + d, err := s.kv.Get(ctx, CollectionPendingFIDOLogin, username) + if err != nil { + err = errors.Wrap(err, "getting stored session data from keystore") + return nil, NewAPIError(400, err, detail) + } + _ = s.kv.Delete(ctx, CollectionPendingFIDOLogin, username) + err = json.Unmarshal(d, &sessionData) + if err != nil { + err = errors.Wrap(err, "unmarshaling session data") + return nil, NewAPIError(400, err, detail) + } + + cred, err := s.webAuthn.FinishLogin(p, sessionData, r) + if err != nil { + err = errors.Wrap(err, "validating fido login") + return nil, NewAPIError(400, err, detail) + } + if cred.Authenticator.CloneWarning { + err = errors.Wrap(err, "cloned authenticator detected") + return nil, NewAPIError(400, err, detail) + } + + fidoKey, err = model.FIDOKeyByID(ctx, s.db, hex.EncodeToString(cred.ID)) + if err != nil { + err = errors.Wrap(err, "getting fido key by id") + return nil, NewAPIError(500, err, detail) + } + + // Check the key's revocation status + if fidoKey.State == model.StateRevoked { + err = errors.Errorf("key %v, aaaguid %s is revoked", fidoKey.ID, fidoKey.AAGUID) + return nil, NewAPIError(401, err, detail) + } + + // Check key's valid time interval + now := time.Now() + if now.Before(fidoKey.NotValidBefore) || now.After(fidoKey.NotValidAfter) { + err = errors.Errorf("time not within valid range, %s - %s", fidoKey.NotValidBefore, fidoKey.NotValidAfter) + return nil, NewAPIError(401, err, detail) + } + + // Check against AAGUID whitelist + whitelist, err := model.WhitelistAAGUIDs(ctx, s.db) + if err != nil { + err = errors.Wrap(err, "error getting aaguid whitelist") + return nil, NewAPIError(500, err, detail) + } + if len(whitelist) > 0 { + if !whitelist.Has(fidoKey.AAGUID) { + err = errors.Errorf("authenticator guid %s, %s is not in the whitelist %v", + fidoKey.AAGUID, + fidoKey.CertCommonName, + whitelist.Values(), + ) + return nil, NewAPIError(401, err, detail) + } + } + + // check against blacklisted authenticators + guid, err := model.AAGUIDByID(ctx, s.db, fidoKey.AAGUID) + if guid != nil { + if guid.State == model.StateRevoked { + err = errors.Errorf("authenticator, guid %s, %s is blacklisted", guid.ID, guid.Label) + return nil, NewAPIError(401, err, detail) + } + } + + fidoKey.Nonce = cred.Authenticator.SignCount + fidoKey.LastUsed = time.Now() + err = s.db.Save(fidoKey).Error + if err != nil { + err = errors.Wrap(err, "saving fido key") + return nil, NewAPIError(500, err, detail) + } + + session, _ := model.NewSession(p.ID, fidoKey.ID, fidoKey.AAGUID, util.ClientIP(r), r.UserAgent()) + if session != nil { + _ = session.Put(ctx, s.kv, model.SessionTTL) + } + + return p, nil +} diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go new file mode 100644 index 0000000..3d957c2 --- /dev/null +++ b/internal/service/auth_test.go @@ -0,0 +1,248 @@ +package service + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/duo-labs/webauthn/webauthn" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/model" + "github.com/ofte-auth/dogpark/internal/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_AuthService_AddAndUpdatePrincipal(t *testing.T) { + ctx := context.Background() + assert := assert.New(t) + + service, err := getTestAuthService(ctx, t) + if err != nil { + return + } + defer func() { + service.Stop() + }() + + principal, err := service.GetOrCreatePrincipal(ctx, map[string]string{ + "username": "joe@example.com", + "displayName": "joe", + "icon": "http://example.com/example.gif", + }) + assert.Nil(err) + assert.NotEmpty(principal.ID) +} + +func Test_AuthService_GetOrCreatePrincipal(t *testing.T) { + ctx := context.Background() + + service, err := getTestAuthService(ctx, t) + if err != nil { + return + } + defer func() { + service.Stop() + }() + + type args struct { + params map[string]string + } + tests := []struct { + name string + args args + want *model.Principal + err *APIError + }{ + { + name: "Happy path", + args: args{ + params: map[string]string{ + "username": "joe@example.com", + "displayName": "joe", + "icon": "http://example.com/example.gif", + }, + }, + want: &model.Principal{ + ID: "4a3b3fb154bd089f", + Username: "joe@example.com", + DisplayName: "joe", + Icon: "http://example.com/example.gif", + State: model.StateActive, + }, + err: nil, + }, + { + name: "ID supplied", + args: args{ + params: map[string]string{ + "id": "87654321", + "username": "joe@example.com", + "displayName": "joe", + "icon": "http://example.com/example.gif", + }, + }, + want: &model.Principal{ + ID: "87654321", + Username: "joe@example.com", + DisplayName: "joe", + Icon: "http://example.com/example.gif", + State: model.StateActive, + }, + err: nil, + }, + { + name: "Username not supplied", + args: args{ + params: map[string]string{ + "id": "12345678", + "displayName": "joe", + "icon": "http://example.com/example.gif", + }, + }, + want: &model.Principal{ + ID: "12345678", + Username: "12345678", + DisplayName: "joe", + Icon: "http://example.com/example.gif", + State: model.StateActive, + }, + err: nil, + }, + { + name: "Duplicate record should work", + args: args{ + params: map[string]string{ + "id": "12345678", + "displayName": "joe", + "icon": "http://example.com/example.gif", + }, + }, + want: &model.Principal{ + ID: "12345678", + Username: "12345678", + DisplayName: "joe", + Icon: "http://example.com/example.gif", + State: model.StateActive, + FIDOKeys: []*model.FIDOKey{}, + }, + err: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := service.GetOrCreatePrincipal(ctx, tt.args.params) + if p != nil { + tt.want.CreatedAt = p.CreatedAt + } + if !reflect.DeepEqual(p, tt.want) { + t.Errorf("authService.GetOrCreatePrincipal() got = %v, want %v", p, tt.want) + fmt.Println(cmp.Diff(tt.want, p)) + } + if !reflect.DeepEqual(err, tt.err) { + t.Errorf("authService.GetOrCreatePrincipal() got1 = %v, want %v", err, tt.err) + } + }) + } +} + +func Test_FIDORegisterAndLogin(t *testing.T) { + ctx := context.Background() + assert := assert.New(t) + + service, err := getTestAuthService(ctx, t) + if err != nil { + return + } + defer func() { + service.Stop() + }() + + principal, err := service.GetOrCreatePrincipal(ctx, map[string]string{ + "username": "matthew@ofte.io", + "displayName": "matthew", + "icon": "http://example.com/example.gif", + }) + assert.Nil(err) + assert.NotEmpty(principal.ID) + + credentialCreation, err := service.StartFIDORegistration(ctx, principal.Username) + assert.Nil(err) + assert.Equal("localhost", credentialCreation.Response.RelyingParty.CredentialEntity.Name) + + id, _ := hex.DecodeString(principal.ID) + mockSessionData := webauthn.SessionData{ + UserID: id, + Challenge: regChallenge, + } + marshaledData, err := json.Marshal(mockSessionData) + assert.Nil(err) + err = service.(*authService).kv.Put(ctx, CollectionPendingFIDORegistration, principal.Username, marshaledData, 30) + assert.Nil(err) + + r := new(http.Request) + r.Body = ioutil.NopCloser(strings.NewReader(mockCreationData)) + key, err := service.FinishFIDORegistration(ctx, principal.Username, r) + assert.Nil(err) + + assert.Equal(key.AAGUID, "b92c3f9a-c014-4056-887f-140a2501163b") + assert.Contains(key.CertCommonName, "Yubico U2F Root") + + credAssertion, err := service.StartFIDOLogin(ctx, principal.Username) + assert.Nil(err) + assert.Equal("localhost", credAssertion.Response.RelyingPartyID) + + mockSessionData.Challenge = loginChallenge + marshaledData, err = json.Marshal(mockSessionData) + assert.Nil(err) + err = service.(*authService).kv.Put(ctx, CollectionPendingFIDOLogin, principal.Username, marshaledData, 30) + assert.Nil(err) + + r = new(http.Request) + r.Body = ioutil.NopCloser(strings.NewReader(mockLoginData)) + p, err := service.FinishFIDOLogin(ctx, principal.Username, r) + assert.Nil(err) + assert.NotNil(p) +} + +func getTestAuthService(ctx context.Context, t *testing.T) (Auth, error) { + db, err := db.GetTestDB() + if err != nil { + if strings.Contains(err.Error(), "connection refused") { + t.Skip("Database not available, skipping") + return nil, err + } + t.Fatal(err) + return nil, err + } + err = model.Migrate(db) + if err != nil { + return nil, err + } + + kv, err := store.NewETCDMockManager() + require.NoError(t, err) + + service, err := NewAuthService(ctx, + OptionDB(db), + OptionKV(kv), + OptionRP("localhost", "localhost", "https://localhost:8888"), + ) + require.NoError(t, err) + return service, err +} + +const ( + regChallenge = "cZEA3K0oOaendChzB2R7f6P9wuxw7_7HRLbtsaFtq7k" + loginChallenge = "KduLT5CggERDlv3qQIDYzU2At7QMbCjXgsBP0StgDl4" + mockCreationData = `{"id":"IpSQ7UnDo19oEqSTIDKV73mW3j3VBmtKvNiWvrxSNfQW8rKazU9j22p3oiYkSewWkbq77nVs0vKIlog-mYiZ_g","rawId":"IpSQ7UnDo19oEqSTIDKV73mW3j3VBmtKvNiWvrxSNfQW8rKazU9j22p3oiYkSewWkbq77nVs0vKIlog-mYiZ_g","type":"public-key","response":{"attestationObject":"o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2lnWEYwRAIgKG9puBQ4PVrOF63D4AAvwqcP6uNOf0ZHoQ7IzsU8Wa8CIB0jSvEqvLkY_rChqLQ4hYpKY0OqTVprGNLn97YbYeRBY3g1Y4FZAsIwggK-MIIBpqADAgECAgRAAnmoMA0GCSqGSIb3DQEBCwUAMC4xLDAqBgNVBAMTI1l1YmljbyBVMkYgUm9vdCBDQSBTZXJpYWwgNDU3MjAwNjMxMCAXDTE0MDgwMTAwMDAwMFoYDzIwNTAwOTA0MDAwMDAwWjBvMQswCQYDVQQGEwJTRTESMBAGA1UECgwJWXViaWNvIEFCMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMSgwJgYDVQQDDB9ZdWJpY28gVTJGIEVFIFNlcmlhbCAxMDczOTA0MDQwMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXLcOpmwT8r_g_5OE0LNDIEjNoLb7h1AbcpvmzU1oBq3gUmZ2rf3Uby5RZE8Sd2VPKvDQj5bMVTu18UUVv76d0KNsMGowIgYJKwYBBAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjEwEwYLKwYBBAGC5RwCAQEEBAMCBSAwIQYLKwYBBAGC5RwBAQQEEgQQuSw_msAUQFaIfxQKJQEWOzAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCyh-RGBqjevMATMvXGypDOMTAmC493RiBfGtSOt4OjG8uRBoWUyte1pNumOBN-idM-Kn-2sXA1cvsIKCycbBQa2O9B18Su4YxU9Yv98cf_33qSEMo6vwpW-SfjVbykcrN7M6btWvuxwsYQMI5as6wmuz1Dzv8TPuAXtYBGnTXml1DySELmYBd5DXNuBOu_7-S2JE0ccR2zDvkwlOqV5X2fTWMdVJ7z7wnuWxnEF8JOzT-5i1D8KrV92mfcnSZ6Qa12ZrUJWvgiVATSmSx72qc8TtYKxnZODGOV2DOFBP-VzSHUqgAzSYKuuHMmxr4TMvE7Eq6k3-jp1vjduDgDlfmIaGF1dGhEYXRhWMRJlg3liA6MaHQ0Fw9kdmBbj-SuuaKGMseZXPO6gx2XY0UAAAABuSw_msAUQFaIfxQKJQEWOwBAIpSQ7UnDo19oEqSTIDKV73mW3j3VBmtKvNiWvrxSNfQW8rKazU9j22p3oiYkSewWkbq77nVs0vKIlog-mYiZ_qUBAgMmIAEhWCBXYI4v-KAjJt8m-36gxzCWa7bJiITBPMbnE3s2xbBrTiJYILgfKHtCACl-lNOO90Vg8QT19_6ylUkjCIJ9ks8fBfJP","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiY1pFQTNLMG9PYWVuZENoekIyUjdmNlA5d3V4dzdfN0hSTGJ0c2FGdHE3ayIsIm9yaWdpbiI6Imh0dHBzOi8vbG9jYWxob3N0Ojg4ODgiLCJjcm9zc09yaWdpbiI6ZmFsc2V9"}}` + mockLoginData = `{"id":"IpSQ7UnDo19oEqSTIDKV73mW3j3VBmtKvNiWvrxSNfQW8rKazU9j22p3oiYkSewWkbq77nVs0vKIlog-mYiZ_g","rawId":"IpSQ7UnDo19oEqSTIDKV73mW3j3VBmtKvNiWvrxSNfQW8rKazU9j22p3oiYkSewWkbq77nVs0vKIlog-mYiZ_g","type":"public-key","response":{"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAABA","clientDataJSON":"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiS2R1TFQ1Q2dnRVJEbHYzcVFJRFl6VTJBdDdRTWJDalhnc0JQMFN0Z0RsNCIsIm9yaWdpbiI6Imh0dHBzOi8vbG9jYWxob3N0Ojg4ODgiLCJjcm9zc09yaWdpbiI6ZmFsc2V9","signature":"MEUCIGe-0KOxgT6YF18dvzCOkNto3qFbqYLp01j9kWboYDiWAiEAmbDo4MwheBIVwfkXAGHPlf0RylBioEgB_CYYtGBNVuw","userHandle":""}}` +) diff --git a/internal/service/defines.go b/internal/service/defines.go new file mode 100644 index 0000000..d5dc418 --- /dev/null +++ b/internal/service/defines.go @@ -0,0 +1,11 @@ +package service + +// ContextKey is a type for context key values. +type ContextKey int + +// Consts for context keys +const ( + ContextError ContextKey = iota + ContextIPAddr + ContextUserAgent +) diff --git a/internal/service/error.go b/internal/service/error.go new file mode 100644 index 0000000..06be5f8 --- /dev/null +++ b/internal/service/error.go @@ -0,0 +1,82 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/ofte-auth/dogpark/internal/util" + log "github.com/sirupsen/logrus" + "github.com/ztrue/tracerr" +) + +// APIError defines a API error. +type APIError struct { + Code int `json:"code"` + Err error `json:"error"` + Detail string `json:"detail,omitempty"` +} + +// NewAPIError returns a new API error. If `source` is true, source code is also written to stdout. +func NewAPIError(code int, err error, detail string) *APIError { + apiError := &APIError{ + Code: code, + Err: err, + Detail: detail, + } + if code >= 500 { + apiError.Err = tracerr.Wrap(err) + } + return apiError +} + +func (e *APIError) Error() string { + return e.Err.Error() +} + +// BindHTTPRequest binds an API error to a HTTP Request's context. +func (e *APIError) BindHTTPRequest(r *http.Request) { + ctx := context.WithValue(r.Context(), ContextError, e) + *r = *r.Clone(ctx) +} + +// MarshalJSON ... +func (e *APIError) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Code int `json:"code"` + Error string `json:"error"` + Detail string `json:"detail,omitempty"` + }{ + Code: e.Code, + Error: e.Err.Error(), + Detail: e.Detail, + }) +} + +// ErrorHandler is middleware to log and process HTTP errors. +func ErrorHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + err := r.Context().Value(ContextError) + if err == nil { + return + } + switch err := err.(type) { + case *APIError: + log.WithField("error", err).Error(err) + util.JSONResponse(w, err, err.Code) + if err, ok := err.Err.(tracerr.Error); ok { + fmt.Println(err) + frames := err.StackTrace() + for _, v := range frames[1:4] { + fmt.Println(v) + } + fmt.Println("") + } + case error: + log.WithField("error", err).Error(err) + http.Error(w, err.(error).Error(), 500) + } + }) +} diff --git a/internal/service/mds.go b/internal/service/mds.go new file mode 100644 index 0000000..16eca92 --- /dev/null +++ b/internal/service/mds.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/model" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "go.uber.org/multierr" +) + +type mdsResult struct { + AAGUID string `json:"aaguid"` + Description string `json:"description"` +} + +// UpdateFIDOMetadata pulls metadata from the FIDO Alliance Metadata Service +// and associates it with the AAGUID (`id`). +func UpdateFIDOMetadata(db db.DB, id, mdsToken string) error { + val := util.Validate.Var + err := val(id, "required,uuid") + err = multierr.Append(err, val(mdsToken, "required,alphanum")) + if err != nil { + log.WithError(err).Error("Error validating function arguments") + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + url := fmt.Sprintf("https://mds2.fidoalliance.org/metadata/%s?token=%s", id, mdsToken) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + log.WithError(err).WithField("url", url).Warning("Error creating MDS url") + return err + } + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.WithError(err).WithField("url", url).Warning("Error querying MDS url") + return err + } + if resp.StatusCode != 200 { + log.WithField("url", url).WithField("status", resp.StatusCode).Warning("Non 200 result from MDS query") + return errors.New("Non 200 result from MDS query") + } + data, err := ioutil.ReadAll(resp.Body) + defer resp.Body.Close() + if err != nil { + log.WithError(err).WithField("url", url).Warning("Error reading MDS result") + return err + } + b, err := base64.StdEncoding.DecodeString(string(data)) + if err != nil { + log.WithError(err).Warning("Error decoding MDS result") + return err + } + aaguid, err := model.AAGUIDByID(ctx, db, id) + switch err { + case model.ErrRecordNotFound: + result := &mdsResult{} + err = json.Unmarshal(b, &result) + if err != nil { + log.WithError(err).Warning("Error parsing MDS result for description") + return err + } + if result.AAGUID != id { + log.Warning("GUID mismatch from MDS, not storing record") + return errors.New("GUID mismatch from MDS, not storing record") + } + aaguid = &model.AAGUID{ + ID: id, + State: "", + Label: result.Description, + Metadata: b, + } + err = db.Create(aaguid).Error + case nil: + aaguid.Metadata = b + err = db.Save(aaguid).Error + default: + // unexpected error with DB + } + if err != nil { + log.WithError(err).WithField("url", url).Warning("Error managing AAGUID data") + } + return err +} diff --git a/internal/service/mds_test.go b/internal/service/mds_test.go new file mode 100644 index 0000000..37a2f51 --- /dev/null +++ b/internal/service/mds_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "strings" + "testing" + + "github.com/go-playground/validator/v10" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const nullMDSToken = "ffffffffffffffffffffffffffffffffffffffffffffffff" + +func TestUpdateFIDOMetadata(t *testing.T) { + dbConn, err := db.GetTestDB() + if err != nil { + if strings.Contains(err.Error(), "connection refused") { + t.Skip("Database not available, skipping") + return + } + t.Fatal(err) + } + defer func() { + _ = db.CloseConnection(dbConn) + }() + + // Update this with your token, see https://fidoalliance.org/metadata/ + mdsToken := nullMDSToken + + err = UpdateFIDOMetadata(dbConn, "d384db22-4d50-ebde-2eac-5765cf1e2a44", mdsToken) + if mdsToken != nullMDSToken { + assert.NoError(t, err) + } + err = UpdateFIDOMetadata(dbConn, "", mdsToken) + assert.Error(t, err) + require.IsType(t, validator.ValidationErrors{}, err) + assert.Contains(t, err.Error(), "failed on the 'required' tag") +} diff --git a/internal/service/option.go b/internal/service/option.go new file mode 100644 index 0000000..ee844e6 --- /dev/null +++ b/internal/service/option.go @@ -0,0 +1,61 @@ +package service + +import ( + "github.com/micro/go-micro/v2/broker" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/geo" + "github.com/ofte-auth/dogpark/internal/store" +) + +// OptionDB set a DB connection option. +func OptionDB(db db.DB) func(*Service) error { + return func(svc *Service) error { + svc.db = db + return nil + } +} + +// OptionKV set a KV manager option. +func OptionKV(kv store.Manager) func(*Service) error { + return func(svc *Service) error { + svc.kv = kv + return nil + } +} + +// OptionRP sets relying party configation options. +func OptionRP(rpDisplayName, rpID, rpOrigin string) func(*Service) error { + return func(svc *Service) error { + if svc.params == nil { + svc.params = make(map[string]string) + } + svc.params["rpDisplayName"] = rpDisplayName + svc.params["rpID"] = rpID + svc.params["rpOrigin"] = rpOrigin + return nil + } +} + +// OptionGeoResolver sets a geo resolver. +func OptionGeoResolver(geo geo.Resolver) func(*Service) error { + return func(svc *Service) error { + svc.geo = geo + return nil + } +} + +// OptionParams sets a key,value option. Multiple can be set. +func OptionParams(params map[string]string) func(*Service) error { + return func(svc *Service) error { + svc.params = params + return nil + } +} + +// OptionMessageBroker sets a broker client option. +func OptionMessageBroker(broker broker.Broker) func(*Service) error { + return func(svc *Service) error { + svc.broker = broker + return nil + } +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..704fe82 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/micro/go-micro/v2/broker" + "github.com/ofte-auth/dogpark/internal/db" + "github.com/ofte-auth/dogpark/internal/geo" + "github.com/ofte-auth/dogpark/internal/model" + "github.com/ofte-auth/dogpark/internal/store" + log "github.com/sirupsen/logrus" +) + +// Service represents a base structure for services. +type Service struct { + name string + db db.DB + kv store.Manager + broker broker.Broker + geo geo.Resolver + params map[string]string +} + +// Stop closes all open handles. +func (s Service) Stop() { + if s.db != nil { + _ = db.CloseConnection(s.db) + } + if s.kv != nil { + _ = s.kv.Close() + } + if s.geo != nil { + s.geo.Close() + } +} + +// Audit sends auditing data to configured endpoints. +func (s Service) Audit(ctx context.Context, group, action string, p *model.Principal, key *model.FIDOKey, auditError error) { + entry := &model.AuditEntry{ + Group: group, + Action: action, + CreatedAt: time.Now(), + } + ipAddr, ok := ctx.Value(ContextIPAddr).(string) + if ok { + entry.IPAddr = ipAddr + } + userAgent, ok := ctx.Value(ContextUserAgent).(string) + if ok { + entry.UserAgent = userAgent + } + if p != nil { + entry.PrincipalID = p.ID + entry.PrincipalUsername = p.Username + } + if key != nil { + entry.FidoKeyID = key.ID + entry.FidoAAGUID = key.AAGUID + } + if entry.IPAddr != "" && s.geo != nil { + geoEntry, err := s.geo.Resolve(entry.IPAddr) + if err == nil { + entry.Latitude = geoEntry.Latitude + entry.Longitude = geoEntry.Longitude + entry.Country = geoEntry.Country + entry.Region = geoEntry.Region + entry.City = geoEntry.City + } else { + log.WithError(err).WithField("ip_addr", entry.IPAddr).Error("Resolving geo ip") + } + } + if auditError != nil { + entry.Anomaly = auditError.Error() + } + err := s.db.Create(entry).Error + if err != nil { + log.WithError(err).Error("Inserting audit entry") + } + + // If a message broker is configured, send the auditevent. + if s.broker != nil { + topic := fmt.Sprintf("dogpark.%s.%s", group, action) + body, _ := json.Marshal(entry) + message := &broker.Message{Body: body} + err = s.broker.Publish(topic, message) + if err != nil { + log.WithError(err).Error("Sending audit entry through message broker") + } + } +} diff --git a/internal/store/etcd.go b/internal/store/etcd.go new file mode 100644 index 0000000..9229df7 --- /dev/null +++ b/internal/store/etcd.go @@ -0,0 +1,242 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/coreos/etcd/clientv3" + "github.com/coreos/etcd/etcdserver/api/v3rpc/rpctypes" + "github.com/coreos/etcd/integration" + "github.com/ofte-auth/dogpark/internal/util" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + config "github.com/spf13/viper" +) + +// EtcdConfig defines etcd store configuration info +type EtcdConfig struct { + Endpoints []string +} + +type etcdManager struct { + cli *clientv3.Client + kv clientv3.KV + watcher clientv3.Watcher +} + +// NewETCDManager returns etcd store implementation +func NewETCDManager(ctx context.Context, config EtcdConfig) (Manager, error) { + if len(config.Endpoints) == 0 { + err := errors.New("no endpoints in config") + return nil, util.RetryStop{Err: err} + } + cfg := clientv3.Config{ + Context: ctx, + Endpoints: config.Endpoints, + DialTimeout: 10 * time.Second, + } + cli, err := clientv3.New(cfg) + if err != nil { + return nil, err + } + + mgr := &etcdManager{ + cli: cli, + kv: clientv3.NewKV(cli), + watcher: clientv3.NewWatcher(cli), + } + + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + _, err = cli.Get(ctx, "ping") + if err == context.DeadlineExceeded { + return nil, err + } + return mgr, nil +} + +// NewETCDMockManager returns a manager suitable for test environments. +func NewETCDMockManager() (Manager, error) { + + cfg := integration.ClusterConfig{Size: 1} + cluster := integration.NewClusterV3(nil, &cfg) + cli := cluster.RandClient() + + return &etcdManager{ + cli: cli, + kv: clientv3.NewKV(cli), + watcher: clientv3.NewWatcher(cli), + }, nil +} + +// Close ... +func (mgr *etcdManager) Close() error { + if mgr.watcher != nil { + _ = mgr.watcher.Close() + } + if mgr.cli != nil { + return mgr.cli.Close() + } + return nil +} + +// Put ... +func (mgr *etcdManager) Put(ctx context.Context, collection string, key string, value []byte, ttlSeconds int64) error { + var err error + if ttlSeconds > 0 { + var resp *clientv3.LeaseGrantResponse + var op clientv3.OpOption + resp, err = mgr.cli.Grant(ctx, ttlSeconds) + if err != nil { + return errors.Wrap(err, "obtaining etcd ttl lease") + } + op = clientv3.WithLease(resp.ID) + _, err = mgr.kv.Put(ctx, assembleKey(collection, key), string(value[:]), op) + } else { + _, err = mgr.kv.Put(ctx, assembleKey(collection, key), string(value[:])) + } + return err +} + +// Delete ... +func (mgr *etcdManager) Delete(ctx context.Context, collection string, key string) error { + _, err := mgr.kv.Delete(ctx, assembleKey(collection, key), clientv3.WithPrefix()) + return err +} + +// Watch ... +func (mgr *etcdManager) Watch(ctx context.Context, collection string) <-chan WatchResult { + return mgr.watch(ctx, assembleDir(collection)) +} + +func (mgr *etcdManager) watch(ctx context.Context, key string) <-chan WatchResult { + ch := make(chan WatchResult) + wch := mgr.watcher.Watch(ctx, key, clientv3.WithPrefix()) + + go func() { + for resp := range wch { + if err := resp.Err(); err != nil { + ch <- WatchResult{Err: err} + break + } + + for _, e := range resp.Events { + var opType OperationType + + switch e.Type { + case clientv3.EventTypePut: + opType = OperationTypePUT + case clientv3.EventTypeDelete: + opType = OperationTypeDELETE + default: + log.Warningf("etcd event type not supported: %v", e.Type) + } + + res := Result{ + Key: string(e.Kv.Key), + Value: e.Kv.Value, + } + ch <- WatchResult{Type: opType, Result: res} + } + } + }() + + return ch +} + +func (mgr *etcdManager) WatchKey(ctx context.Context, collection, key string) <-chan WatchResult { + return mgr.watch(ctx, assembleKey(collection, key)) +} + +// ErrorNoRecord ... +var ErrorNoRecord = errors.New("no key found") + +func (mgr *etcdManager) Get(ctx context.Context, collection string, key string) ([]byte, error) { + resp, err := mgr.kv.Get(ctx, assembleKey(collection, key)) + if err != nil { + return nil, err + } + if len(resp.Kvs) != 1 { + return nil, ErrorNoRecord + } + return resp.Kvs[0].Value, nil +} + +func (mgr *etcdManager) Exists(ctx context.Context, collection string, key string) (bool, error) { + resp, err := mgr.kv.Get(ctx, assembleKey(collection, key)) + if err == rpctypes.ErrGRPCKeyNotFound { + return false, nil + } + if resp != nil && resp.Count > 0 { + return true, nil + } + return false, errors.Wrap(err, "error fetching key from store") +} + +// List returns `limit` entries from `page` (1-based). It returns the total records ordered by +// mod revision along with the results. +func (mgr *etcdManager) List(ctx context.Context, collection string, limit, page int64, newestFirst bool) ([]Result, int64, error) { + opts := []clientv3.OpOption{clientv3.WithPrefix()} + if page <= 0 { + page = 1 + } + if limit <= 0 || limit > 0xffff { + limit = 100 + } + max := limit * page + opts = append(opts, clientv3.WithLimit(max)) + sortByOrder := clientv3.SortAscend + if newestFirst { + sortByOrder = clientv3.SortDescend + } + opts = append(opts, clientv3.WithSort(clientv3.SortByModRevision, sortByOrder)) + opts = append(opts, clientv3.WithKeysOnly()) + resp, err := mgr.kv.Get(ctx, assembleDir(collection), opts...) + if err != nil { + return nil, 0, err + } + arr := make([]Result, 0) + index := limit * (page - 1) + total := int64(len(resp.Kvs)) + if index < total { + lastIndex := index + limit + 1 + if lastIndex > total { + lastIndex = total + } + for _, v := range resp.Kvs[index:lastIndex] { + resp, err := mgr.kv.Get(ctx, string(v.Key)) + if err != nil { + return nil, 0, errors.Wrap(err, "getting key from store using list entry") + } + if len(resp.Kvs) != 1 { + // value ejected/deleted from store + continue + } + arr = append(arr, Result{string(v.Key), resp.Kvs[0].Value}) + } + } + + opts = []clientv3.OpOption{ + clientv3.WithPrefix(), + clientv3.WithCountOnly(), + } + resp, err = mgr.kv.Get(ctx, assembleDir(collection), opts...) + if err != nil { + return nil, 0, err + } + total = resp.Count + return arr, total, nil +} + +func assembleDir(collection string) string { + return fmt.Sprintf("/%s", collection) +} + +func assembleKey(collection, key string) string { + return fmt.Sprintf("%s/%s", assembleDir(collection), key) +} + +func init() { + config.SetDefault("KV_ENDPOINTS", []string{"http://localhost:2379"}) +} diff --git a/internal/store/etcd_test.go b/internal/store/etcd_test.go new file mode 100644 index 0000000..83004b1 --- /dev/null +++ b/internal/store/etcd_test.go @@ -0,0 +1,201 @@ +package store + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const etcdEndpoint = 31234 + +func Test_ETCDBasic(t *testing.T) { + containerID, err := createStartETCDContainer() + require.NoError(t, err) + defer func() { + _ = stopRemoveETCDContainer(containerID) + }() + ctx := context.Background() + + mgr, err := NewETCDManager(ctx, EtcdConfig{Endpoints: []string{fmt.Sprintf("http://127.0.0.1:%d", etcdEndpoint)}}) + require.NoError(t, err) + + err = mgr.Put(ctx, "foo", "bar", []byte{1}, 0) + assert.NoError(t, err) + err = mgr.Put(ctx, "foo", "baz", []byte{1}, 0) + assert.NoError(t, err) + + _, err = mgr.Get(ctx, "foo", "bar") + assert.NoError(t, err) + + _, err = mgr.Get(ctx, "bar", "foo") + assert.Equal(t, ErrorNoRecord, err) + + results, total, err := mgr.List(ctx, "foo", 20, 1, true) + require.NoError(t, err) + require.Equal(t, 2, len(results)) + require.Equal(t, int64(2), total) +} + +func Test_ETCDList(t *testing.T) { + containerID, err := createStartETCDContainer() + require.NoError(t, err) + defer func() { + _ = stopRemoveETCDContainer(containerID) + }() + ctx := context.Background() + + mgr, err := NewETCDManager(ctx, EtcdConfig{Endpoints: []string{fmt.Sprintf("http://127.0.0.1:%d", etcdEndpoint)}}) + require.NoError(t, err) + + for n := 1; n < 41; n++ { + key := fmt.Sprintf("bar%02d", n) + err = mgr.Put(ctx, "foo", key, []byte(fmt.Sprintf("value%02d", n)), 0) + require.NoError(t, err) + } + + results, total, err := mgr.List(ctx, "foo", 10, 1, true) + require.NoError(t, err) + require.Equal(t, 10, len(results)) + require.Equal(t, int64(40), total) + require.Equal(t, "/foo/bar40", results[0].Key) + require.Equal(t, "value40", string(results[0].Value)) + require.Equal(t, "/foo/bar31", results[9].Key) + + results, _, err = mgr.List(ctx, "foo", 10, 2, false) + require.NoError(t, err) + require.Equal(t, "/foo/bar11", results[0].Key) + require.Equal(t, "/foo/bar20", results[9].Key) + + // Outside of available pages + results, _, err = mgr.List(ctx, "foo", 10, 5, false) + require.NoError(t, err) + require.Equal(t, 0, len(results)) + + // Overflow + results, _, err = mgr.List(ctx, "foo", 30, 2, true) + require.NoError(t, err) + require.Equal(t, 10, len(results)) + require.Equal(t, "/foo/bar01", results[9].Key) +} + +func Test_ETCDWatch(t *testing.T) { + containerID, err := createStartETCDContainer() + require.NoError(t, err) + ctx := context.Background() + + mgr, err := NewETCDManager(context.Background(), EtcdConfig{Endpoints: []string{fmt.Sprintf("http://127.0.0.1:%d", etcdEndpoint)}}) + require.NoError(t, err) + + err = mgr.Put(ctx, "foo", "bar", []byte("hello"), 5) + assert.NoError(t, err) + + //resultChan := mgr.WatchKey("foo", "bar") + resultChan := mgr.Watch(ctx, "foo") + done := make(chan bool) + + go func() { + for { + result := <-resultChan + spew.Dump(result) + if result.Type == OperationTypeDELETE { + done <- true + } else if result.Type == OperationTypePUT { + assert.Equal(t, []byte("there"), result.Result.Value) + } + } + }() + + time.Sleep(time.Second * 1) + + err = mgr.Put(ctx, "foo", "bar", []byte("there"), 5) + assert.NoError(t, err) + + <-done + + err = stopRemoveETCDContainer(containerID) + require.NoError(t, err) +} + +func createStartETCDContainer() (string, error) { + cli, err := client.NewEnvClient() + if err != nil { + fmt.Println("Unable to create docker client") + panic(err) + } + + hostBinding1 := nat.PortBinding{ + HostIP: "0.0.0.0", + HostPort: fmt.Sprintf("%d/tcp", etcdEndpoint), + } + hostBinding2 := nat.PortBinding{ + HostIP: "0.0.0.0", + HostPort: fmt.Sprintf("%d/tcp", etcdEndpoint+1), + } + containerPort1, err := nat.NewPort("tcp", fmt.Sprintf("%d", etcdEndpoint)) + if err != nil { + return "", err + } + containerPort2, err := nat.NewPort("tcp", fmt.Sprintf("%d", etcdEndpoint+1)) + if err != nil { + return "", err + } + if err != nil { + return "", err + } + + portBinding := nat.PortMap{ + containerPort1: []nat.PortBinding{hostBinding1}, + containerPort2: []nat.PortBinding{hostBinding2}, + } + + env := []string{ + "ETCD_DATA_DIR=/data", + "ETCD_NAME=etcd01", + fmt.Sprintf("ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:%d", etcdEndpoint), + fmt.Sprintf("ETCD_LISTEN_PEER_URLS=http://0.0.0.0:%d", etcdEndpoint+1), + fmt.Sprintf("ETCD_ADVERTISE_CLIENT_URLS=http://127.0.0.1:%d", etcdEndpoint), + fmt.Sprintf("ETCD_INITIAL_ADVERTISE_PEER_URLS=http://127.0.0.1:%d", etcdEndpoint+1), + fmt.Sprintf("ETCD_INITIAL_CLUSTER=etcd01=http://127.0.0.1:%d", etcdEndpoint+1), + } + cont, err := cli.ContainerCreate( + context.Background(), + &container.Config{ + Image: "quay.io/coreos/etcd:v3.3.12", + Env: env, + ExposedPorts: nat.PortSet{ + nat.Port(fmt.Sprintf("%d/tcp", etcdEndpoint)): {}, + nat.Port(fmt.Sprintf("%d/tcp", etcdEndpoint+1)): {}, + }, + }, + &container.HostConfig{ + PortBindings: portBinding, + }, nil, "test_etcd") + if err != nil { + return "", err + } + + err = cli.ContainerStart(context.Background(), cont.ID, types.ContainerStartOptions{}) + return cont.ID, err +} + +func stopRemoveETCDContainer(ID string) error { + cli, err := client.NewEnvClient() + if err != nil { + return err + } + + err = cli.ContainerStop(context.Background(), ID, nil) + if err != nil { + return err + } + return cli.ContainerRemove(context.Background(), ID, types.ContainerRemoveOptions{}) +} diff --git a/internal/store/manager.go b/internal/store/manager.go new file mode 100644 index 0000000..4d27496 --- /dev/null +++ b/internal/store/manager.go @@ -0,0 +1,56 @@ +package store + +import ( + "context" + + "github.com/pkg/errors" +) + +// OperationType is the type of operation performed on a record +type OperationType int32 + +const ( + // OperationTypePUT Represents PUT storage operations + OperationTypePUT OperationType = 0 + // OperationTypeDELETE Represents DELETE storage operations + OperationTypeDELETE OperationType = 1 +) + +// WatchResult represents changes in a watched collection +type WatchResult struct { + Result Result + Type OperationType + Err error +} + +// Result represents a k/v result +type Result struct { + Key string + Value []byte +} + +// Manager defines a general interface for managing objects +type Manager interface { + Close() error + + // General-purpose collection/key/value storage + Put(ctx context.Context, collection string, key string, value []byte, ttlSeconds int64) error + Delete(ctx context.Context, collection string, key string) error + // Watch a collection for changes + Watch(ctx context.Context, collection string) <-chan WatchResult + // Watch a key for changes + WatchKey(ctx context.Context, collection, key string) <-chan WatchResult + + Get(ctx context.Context, collection string, key string) ([]byte, error) + Exists(ctx context.Context, collection string, key string) (bool, error) + List(cctx context.Context, collection string, limit, page int64, newestFirst bool) ([]Result, int64, error) +} + +// NewManager creates a new KV manager based on the type of config file supplied. +func NewManager(ctx context.Context, config interface{}) (Manager, error) { + switch cfg := config.(type) { + case EtcdConfig: + return NewETCDManager(ctx, cfg) + } + return nil, errors.Errorf("no KV manager found for config type %T", config) +} diff --git a/internal/util/collection.go b/internal/util/collection.go new file mode 100644 index 0000000..085d850 --- /dev/null +++ b/internal/util/collection.go @@ -0,0 +1,49 @@ +package util + +// StringSet : a Set for strings +type StringSet map[string]struct{} + +// NewStringSet creates a new StringSet from a number of passed string keys. +func NewStringSet(vals ...string) StringSet { + set := make(StringSet) + for _, val := range vals { + set[val] = struct{}{} + } + return set +} + +// Has tests the existence of `key` in the set. +func (set StringSet) Has(key string) bool { + _, ok := set[key] + return ok +} + +// Add puts a value into the set. +func (set StringSet) Add(key string) StringSet { + set[key] = struct{}{} + return set +} + +// Delete removes a value from the set. +func (set StringSet) Delete(key string) StringSet { + delete(set, key) + return set +} + +// Values returns the contents of the set. +func (set StringSet) Values() []string { + values := make([]string, 0) + for k := range set { + values = append(values, k) + } + return values +} + +// Copy returns a copy of a set. +func (set StringSet) Copy() StringSet { + copy := make(StringSet) + for k, v := range set { + copy[k] = v + } + return copy +} diff --git a/internal/util/collection_test.go b/internal/util/collection_test.go new file mode 100644 index 0000000..6446185 --- /dev/null +++ b/internal/util/collection_test.go @@ -0,0 +1,44 @@ +package util + +import ( + "testing" +) + +func Test_StringSet(t *testing.T) { + + set := NewStringSet("foo", "bar", "baz") + if !set.Has("foo") { + t.Fail() + } + if !set.Has("bar") { + t.Fail() + } + if set.Has("boofar") { + t.Fail() + } + values := set.Values() + if len(values) != 3 { + t.Fail() + } + + _, ok := set["bar"] + if !ok { + t.Fail() + } + _, ok = set["barfoo"] + if ok { + t.Fail() + } + + set.Add("fourth").Add("fifth") + if len(set) != 5 { + t.Fail() + } + + copy := set.Copy() + copy.Add("fubar") + + if set.Has("fubar") { + t.Fail() + } +} diff --git a/internal/util/config.go b/internal/util/config.go new file mode 100644 index 0000000..bd2240c --- /dev/null +++ b/internal/util/config.go @@ -0,0 +1,19 @@ +package util + +import ( + "github.com/spf13/viper" +) + +// Config is an alias for the config package. +type Config = *viper.Viper + +// InitConfig initializes the config system. +func InitConfig() { + viper.SetEnvPrefix("ofte") + viper.AutomaticEnv() +} + +// AllConfigSettings returns all flags, configs and environment variables. +func AllConfigSettings() map[string]interface{} { + return viper.AllSettings() +} diff --git a/internal/util/http.go b/internal/util/http.go new file mode 100644 index 0000000..cd67422 --- /dev/null +++ b/internal/util/http.go @@ -0,0 +1,74 @@ +package util + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + + "github.com/pkg/errors" +) + +// HTTPResponse defines a common JSON response. +type HTTPResponse struct { + Status string `json:"status"` + Value interface{} `json:"value,omitempty"` + Error string `json:"error,omitempty"` +} + +// JSONResponse encodes a JSON Response object. +func JSONResponse(w http.ResponseWriter, d interface{}, statusCode int) { + dj, err := json.Marshal(d) + if err != nil { + http.Error(w, "Error creating JSON response", http.StatusInternalServerError) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + fmt.Fprintf(w, "%s", dj) +} + +// ClientIP implements a best effort algorithm to return the real client IP, it parses +// X-Real-IP and X-Forwarded-For in order to work properly with reverse-proxies such us: nginx or haproxy. +// Use X-Forwarded-For before X-Real-Ip as nginx uses X-Real-Ip with the proxy's IP. +func ClientIP(r *http.Request) string { + clientIP := r.Header.Get("X-Forwarded-For") + clientIP = strings.TrimSpace(strings.Split(clientIP, ",")[0]) + if clientIP == "" { + clientIP = strings.TrimSpace(r.Header.Get("X-Real-Ip")) + } + if clientIP != "" { + return clientIP + } + if ip, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)); err == nil { + return ip + } + return "" +} + +// HTTPGetSkipVerify performs an HTTP GET request, skipping the default client's verification of the +// server's certificate chain and host name. Safe only for localhost/testing as per +// (CWE-295): TLS InsecureSkipVerify set true. +/* #nosec */ +func HTTPGetSkipVerify(url string) (*http.Response, error) { + if !strings.HasPrefix(url, "https://localhost") { + return nil, errors.New("skip verify only allowed to localhost") + } + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }}} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, errors.Wrap(err, "error creating request") + } + var resp *http.Response + if resp, err = client.Do(req); err != nil { + return nil, errors.Wrap(err, "error communicating with ofte service") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("ofte validation error, response code %d", resp.StatusCode) + } + return resp, nil +} diff --git a/internal/util/params.go b/internal/util/params.go new file mode 100644 index 0000000..e0674a2 --- /dev/null +++ b/internal/util/params.go @@ -0,0 +1,169 @@ +package util + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" +) + +const ( + limit = "limit" + page = "page" + orderBy = "orderBy" + orderDirection = "orderDirection" + createdBefore = "createdBefore" + createdAfter = "createdAfter" + since = "since" + deep = "deep" +) + +// APIParams represents common API parameters. +type APIParams struct { + Ordering []string + OrderDirection string + AndFilters map[string]interface{} + Limit int64 + Page int64 + CreatedBefore time.Time + CreatedAfter time.Time + Deep bool +} + +// NewAPIParams constructs a params object from an `http.Request`. If the optional allowed params set +// is passed, arguments are checked against those. +func NewAPIParams(request *http.Request, allowed StringSet) (*APIParams, error) { + var ( + err error + found bool + value []string + ) + params := &APIParams{ + Page: 1, + Limit: 20, + AndFilters: make(map[string]interface{}), + } + values := request.URL.Query() + if value, found = values[limit]; found { + delete(values, limit) + params.Limit, err = strconv.ParseInt(value[0], 10, 0) + if err != nil { + return nil, errors.Wrap(err, "converting limit argument to integer") + } + } + if value, found = values[page]; found { + delete(values, page) + params.Page, err = strconv.ParseInt(value[0], 10, 0) + if err != nil { + return nil, errors.Wrap(err, "converting page argument to integer") + } + } + if value, found = values[orderBy]; found { + delete(values, orderBy) + params.Ordering = value + } + if value, found = values[orderDirection]; found { + delete(values, orderDirection) + direction := strings.ToLower(value[0]) + if strings.Index(direction, "desc") == 0 { + params.OrderDirection = "DESC" + } + } + + parse := func(t string) (time.Time, error) { + timestamp, err := time.Parse(time.RFC3339, t) + if err != nil { + timestamp, err = time.Parse("2006-01-02", t) + } + return timestamp, err + } + if value, found = values[createdBefore]; found { + delete(values, createdBefore) + params.CreatedBefore, err = parse(value[0]) + if err != nil { + return nil, errors.Errorf("invalid time parameter %s, should be in RFC3339 or shortened (2006-06-24) format", value[0]) + } + } + if value, found = values[createdAfter]; found { + delete(values, createdAfter) + params.CreatedAfter, err = parse(value[0]) + if err != nil { + return nil, errors.Errorf("invalid time parameter %s, should be in RFC3339 or shortened (2006-06-24) format", value[0]) + } + } + if !params.CreatedAfter.IsZero() && params.CreatedAfter.Before(params.CreatedBefore) { + return nil, errors.New("createdAfter cannot be prior to createdBefore") + } + if value, found = values[since]; found { + delete(values, since) + duration, err := time.ParseDuration(value[0]) + if err != nil { + return nil, errors.Errorf("invalid duration parameter %s", value[0]) + } + params.CreatedAfter = time.Now().Add(-duration) + } + if value, found := values[deep]; found { + delete(values, deep) + params.Deep = (strings.ToLower(value[0]) == "true") + } + + for k, v := range values { + if allowed != nil { + if _, found = allowed[k]; !found { + return nil, errors.Errorf("invalid parameter %s", k) + } + } + params.AndFilters[k] = v[0] + } + return params, nil +} + +// DefaultAPIParams returns default APIParams. +func DefaultAPIParams() *APIParams { + return &APIParams{ + Page: 1, + Limit: 20, + AndFilters: make(map[string]interface{}), + } +} + +// GetOrderBySQLStatement returns an SQL statement for ordering. +func (params *APIParams) GetOrderBySQLStatement(fieldMap map[string]string) string { + var statement strings.Builder + for n, v := range params.Ordering { + field := v + if alias, ok := fieldMap[field]; ok { + field = alias + } + statement.WriteString(fmt.Sprintf("%s %s", field, params.OrderDirection)) + if n < len(params.Ordering)-1 { + statement.WriteString(",") + } + } + return statement.String() +} + +// GetOffsetSQL calculates the SQL offset from the page and limit parameters. +func (params *APIParams) GetOffsetSQL() int64 { + if params.Limit == 0 { + return -1 + } + return (params.Page - 1) * params.Limit +} + +// Constants for HTTP headers to support pagination. +const ( + ResultsPage = "Results-Page" + ResultsLimit = "Results-Limit" + ResultsTotal = "Results-Total" +) + +// WritePaginationHeaders adds pagination headers to a http response writer. +func (params *APIParams) WritePaginationHeaders(w http.ResponseWriter, total int64) { + w.Header().Set(ResultsPage, fmt.Sprintf("%d", params.Page)) + w.Header().Set(ResultsLimit, fmt.Sprintf("%d", params.Limit)) + w.Header().Set(ResultsTotal, fmt.Sprintf("%d", total)) +} diff --git a/internal/util/params_test.go b/internal/util/params_test.go new file mode 100644 index 0000000..1fcb383 --- /dev/null +++ b/internal/util/params_test.go @@ -0,0 +1,36 @@ +package util + +import ( + "net/http" + "net/url" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewAPIParams(t *testing.T) { + s := "https://foo.bar.com:5432/path?foo=bar&bar=foo1&bar=foo2&limit=10&page=2&orderBy=date&orderDirection=descending" + u, err := url.Parse(s) + assert.NoError(t, err) + + request := &http.Request{} + request.URL = u + + p, err := NewAPIParams(request, nil) + assert.NoError(t, err) + assert.True(t, reflect.DeepEqual(p, &APIParams{ + Ordering: []string{"date"}, + OrderDirection: "DESC", + Limit: 10, + Page: 2, + AndFilters: map[string]interface{}{ + "bar": "foo1", + "foo": "bar", + }, + })) + + _, err = NewAPIParams(request, NewStringSet("param1")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid parameter") +} diff --git a/internal/util/random.go b/internal/util/random.go new file mode 100644 index 0000000..c35732e --- /dev/null +++ b/internal/util/random.go @@ -0,0 +1,42 @@ +package util + +import ( + "crypto/rand" + "encoding/binary" + mrand "math/rand" + "time" +) + +const letters = "abcdefghijklmnpqrstuvwxyz" + +// RandomAlphaString returns a random alphanumeric string consisting of `length` characters. +// Note the shared math/Rand source should be seeded. +func RandomAlphaString(length int) string { + b := make([]byte, length) + for i := range b { + b[i] = letters[mrand.Intn(len(letters))] + } + return string(b) +} + +func init() { + mrand.Seed(time.Now().UnixNano()) +} + +// RandInt64 returns a cryptographically secure random number within the bounds 0, max. +func RandInt64(max int64) int64 { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0 + } + return int64(binary.LittleEndian.Uint64(b[:])) +} + +// RandUint32 generates a cryptographically secure integer +func RandUint32() uint32 { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return 0 + } + return binary.LittleEndian.Uint32(b[:]) +} diff --git a/internal/util/retry.go b/internal/util/retry.go new file mode 100644 index 0000000..4cd312a --- /dev/null +++ b/internal/util/retry.go @@ -0,0 +1,38 @@ +package util + +import ( + "math/rand" + "time" +) + +// Retry attempts a function `attempts` number of times. It exponentially +// backs off the initial `sleep` upon failures. +// Credit to Nick Stogner from a May 2017 post. +func Retry(attempts int, sleep time.Duration, f func() error) error { + if err := f(); err != nil { + if s, ok := err.(RetryStop); ok { + return s + } + + if attempts--; attempts > 0 { + // Add some randomness to prevent creating a Thundering Herd + jitter := time.Duration(rand.Int63n(int64(sleep))) + sleep = sleep + jitter/2 + + time.Sleep(sleep) + return Retry(attempts, 2*sleep, f) + } + return err + } + return nil +} + +// RetryStop : return this error from your Retry-ing function to abort +// future attempts. +type RetryStop struct { + Err error +} + +func (stop RetryStop) Error() string { + return stop.Err.Error() +} diff --git a/internal/util/validate.go b/internal/util/validate.go new file mode 100644 index 0000000..3d505dc --- /dev/null +++ b/internal/util/validate.go @@ -0,0 +1,9 @@ +package util + +import "github.com/go-playground/validator/v10" + +var Validate *validator.Validate + +func init() { + Validate = validator.New() +} diff --git a/internal/version.go b/internal/version.go new file mode 100644 index 0000000..8506635 --- /dev/null +++ b/internal/version.go @@ -0,0 +1,22 @@ +package internal + +import ( + "fmt" +) + +var ( + name string = "Ofte Dogpark" + version string = "v0.9.0" + buildDate string = "" + commit string = "" +) + +// Version returns the version string. +func Version() string { + return fmt.Sprintf("%s %s", name, version) +} + +// VersionVerbose return the verbose version string. +func VersionVerbose() string { + return fmt.Sprintf("%s %s %s %s", name, version, buildDate, commit) +} diff --git a/workspace/localhost-key.pem b/workspace/localhost-key.pem new file mode 100644 index 0000000..c755313 --- /dev/null +++ b/workspace/localhost-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDUryQSo5jpM0kT +Ud4XXzHnkG7U3k2xCDqgqceAl39JnnaG6csF9V13GAv/Y6yITywLwawApd4sCA9Q +HILW1fKswveY3+9jFNHQhAiFHVWnyvJkSSMxPE4e2Rfp+SXV1rVTqtBymbeqcxZ6 +MkNY7nc4NZpdKsNCwe0lBH7q+boOuZcSBkkBJ7t2yRBD2gPGVpdVYrYb/9SnsZFT +ao+VklwBU578mZW5oaPC8k1FSaugY/o/3szpYc/Wyp7LIOMWjCgXUdUalHsQOsm5 +SPbzMK6Mm65t14PtIQAeKsFuxN3Yu10uD3HERW1Bq5D9TOswm/50u2Wjx0c87vDk +piuFcc/DAgMBAAECggEBAIorffPd9GkLuF2kwfPNFE6rtlT0VuS7w9q8ca9IvJjH +KZlcKVklniLQrHqt9lhXlvXMTEHfgAZ69ffFjfqj45P41YGreYmU7PnZzO8tr4fi +gLZGDWbfFqFTyAVopvvVENfaELFiy78gJWodXUNZKHqL2EzOiyvDcJyr3wgoVIdb +Oq1EZ3VluZmq3p4ROElW381xrw5KVoc8EOcdZnanV6Jlw3I3l65iBzTBBgz4CWKJ +KfJsjY+lHnEx1RNblnwNyKRA3SGmoxLAcRPc+YICW+CdDpKJKkC69qKCoThuqb2P +Arm/39WxCRJpS05KKXgbQQRocK6vakdLS2AAAAH9/QECgYEA2ZL5GvCcmzhjNeTm +rqQ/l0fJyr2Ze06j1bLgKShxs7YSreiMaPBwTuvzYVyWccBZurxS06xl9zSeqq10 +oHTZXbl5pbgmtM6w8x9vAW3hzUrKw/cAy1wB7y/hAbsVXWTBIdidvbjIO9AOKmzt +3fGhRrwU8GiJ7rcoOqciCZbK5SkCgYEA+j8UwG3IUWhpjh+NgpMJ9Sfyy58Wwkwl +iu207+DHSzSpSdO4dJ1cflv/PcqA33nCZ4+5xFlAc5SzkGq6iRqBaM2TreDRmzI4 +hcNsZeTEjPbRsL5267Ap/TleN8ty4UHmBvIlQmTbk753zGYay9A4O8/f9SP1I3k8 +czbPRKZqHwsCgYBxiGYAjsnJnXT3rIhLXV3pfQZpiuJKG9EWNA8QqxAZ7Mp2gUCz +ibOxGyKXDY9bsDmiXZ8C4ZUbmBOrkHOpPxAl/iDiPuPDuY9QbnioUERhscN42q1J +cKt5uow5MPyHDYpSNQyq8X9a5shdjxXYmLvFg2ORx5siO+T8JjZ2tn8NcQKBgQDK +mq/ua/O3FVYAn0Mu6GUzezhz60W94XCz3miTneU82lIFV8kLgSwVkd5A5OcaB7aB +qje45Jnt+gK3dfG4dyE2/NoH+PE7OZnRkrr8dA8+Icb71fjqMSKNxhimC63i+juG +fB32dznfkGHltvUS9m3Q3yhvjME4CzwJ++IrrqBUqQKBgDvrqswZ3Q6HB3R39WnY +Dg+Ol3jKfR/0YQIp7/ePgEA51Rtq96lz/yKP8VK5rdp0Chd+G9E4QaLSzOyn0OBT +7Y+IVwAhKrEFLAecW473KkWHEXvDQfAzeI+hbJOv/lzXspvv4w+fdmemmpvLe8Dz +pxRZ/WVpwnotmBeRkWUW1YRT +-----END PRIVATE KEY----- diff --git a/workspace/localhost.crt b/workspace/localhost.crt new file mode 100644 index 0000000..b9cd4ba --- /dev/null +++ b/workspace/localhost.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC5TCCAc2gAwIBAgIJAIkw29um/dC5MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0xOTEyMTAyMTQ2MDVaFw0yMDAxMDkyMTQ2MDVaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAKYr6qT4WSeRbLcPMaZtlajry/gCOiqrwLw75X++1U7392hI4oQpGHRRhOdn +N/t11d52ebyu/XdMMYbpbVyDeWW5UKWT+gHqtujY1yl8oHSpI8Yw0T9/z8PP6RPP +TcqgvDI/+F12ynlyHlWSqOWULCwiyMkh84cYph2OWgEIrdPH3xCEkc1p/gF7dVPy +JlBhwJZAloktjV9o8Shghi/hmqoupQiJaRzakDXNlZYZWMz/wZvHCp7yjP/i7O0P +7awc3eOZ9fRDtdKrD0h5yLAhTv1Q84wUCR9cYsieXchu8S21ql3ZjEFwk/9/UTO0 +Yi2JBuZSJfxUAf6OGyVjGbYESHUCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo +b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B +AQsFAAOCAQEAc5sANhPcvZVQtX1BrgN0PqgYC4ZwK/8ih43+HK4cPiBuz9P2Nddn +S4q9MDMmlFfxJP/SgKiWS0rTGuqfzXKOu0Grgy5ylQFT1MUVm/gUwCJoEBfh3h/N +jvR0ReHVrb1HH3To3LiW1wZsirPccsR1xTKozUF2Ur+Aa5TMcaCJxSSq/cn6Up/k +B38HBVtJhCm2tEq9Df5t3jQlLTcflXuow+f3bksF1DTDWibIzYu5rra5gdIhp5Er +ckhw4HqPok+zDf2gd8L7Es5JHefwVfuzMHLSEkmF+GwZTPffz3n8fpxst5eapB9q +n9arAqcbU2Di0CtTmqUNuq5e1DHMhY7e7w== +-----END CERTIFICATE----- diff --git a/workspace/localhost.key b/workspace/localhost.key new file mode 100644 index 0000000..2a7a8a2 --- /dev/null +++ b/workspace/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCmK+qk+FknkWy3 +DzGmbZWo68v4Ajoqq8C8O+V/vtVO9/doSOKEKRh0UYTnZzf7ddXednm8rv13TDGG +6W1cg3lluVClk/oB6rbo2NcpfKB0qSPGMNE/f8/Dz+kTz03KoLwyP/hddsp5ch5V +kqjllCwsIsjJIfOHGKYdjloBCK3Tx98QhJHNaf4Be3VT8iZQYcCWQJaJLY1faPEo +YIYv4ZqqLqUIiWkc2pA1zZWWGVjM/8Gbxwqe8oz/4uztD+2sHN3jmfX0Q7XSqw9I +eciwIU79UPOMFAkfXGLInl3IbvEttapd2YxBcJP/f1EztGItiQbmUiX8VAH+jhsl +Yxm2BEh1AgMBAAECggEAef/8Qipjqn1GiBALr9j87AxSsD9SXUnEM272TBfbnCLJ +7jK0u7tATQYcwZgyrdgRsUbikfkX9qJmKlrvA+EzG9A2uZovD4E30TSCo97wHzaf +IT9uIWTBMU5QHU8yGfZwtaPpwRUaCpgSVVlbz1I+LBlNuP3IQgOC0mhVBDVPChCh +IHV4jABG0053KHf1eqSARCqlLz530fwPGyTV/r9/0Y6nxdP/ixHtGn5y8zJquXg1 +QyC/uqPyj2bJhy8MqaHA4cV9vDRRXILQfmP9hA0QJQv+iAY/+QMoqJ1BJDrTtVp3 +HfBtW75ZvOHbWtC1UuPykD7S9J85NFxr04qFiKxSAQKBgQDZD04jFjWmqORTC2CV +FRZOip5jWdkJ7kWE5igK0yN5y1FOvMcBcAWaABz+8DAVzwtaGhcLamLWchc5tT7y +muj3ng4YOdfuam2Cb7a2u/c0o+bx043J9vlc4cdA7o6+acn5ZF6XZ0ir3dbCQqQR +k5ZGhw8VfKLBZ4e9oRnIfzOpIQKBgQDD+4Q72BSjLPoU5x+dF9QaNiOr/gAivA0i +itsnmVMGMt9UnLIYguuFB8QNwQ9CzRNrNpKtMChiEXWsdhAVF2IGeUUgsX4zYxxN +cQgLlxs0XaYArYntsc8FEqu7GGxnX4ss5Zr9TNh5jgctK0T3LUwgsWcZVW+sTMN/ +Hv5K9iaQ1QKBgQCpDwaoxU+cMsdC9wWOmBH7snOSphQpa++xhyGA7Nogrn7xeI73 +S6zROW1cEu8gzVXmI5P3TDEXHV2BkO0qQAVbdzs7GzJXe4U3ppME2Hm+AjqJ91/k +AfxOn3t101hSbkrld4tFGSi809fFDeqD1hOhcugIsD6DrINI6wUN6CTwgQKBgAkL +Af8A6XLeEGwGfh7xiofrF5pIDhmMM870OUiKepo+nq94y372C4gH47P+xIWAkPTR +f9Md9b8Qry1WBUfz3EIQNnBbwEb+u7+XB5gBUPAJoi9F0qd1HOhPBD2N0vKyJenc +blphwVtaglpDNNty66BWjztMBesdX6ft9i3fTchBAoGAeX0Ns9BxJyhewYJzVgzY +GKmw3qbfRjAbGgP+lOMYLaid0JFKy2mtO3bImY0lRgNv1OrJvKcWu0yQOOilI4xo +Y4SBCoSXTHakCdmRcOaJv2kVXWPVUeRXfMaQhqEmq8k1bzF01wzWLVH8mWslcLP2 +1uBQWPcxI8XmwnBrJHzEcRA= +-----END PRIVATE KEY----- diff --git a/workspace/localhost.pem b/workspace/localhost.pem new file mode 100644 index 0000000..8d1b0b8 --- /dev/null +++ b/workspace/localhost.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEXTCCAsWgAwIBAgIQbw0+b6sIdQJ1GDbatte2cjANBgkqhkiG9w0BAQsFADCB +izEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTAwLgYDVQQLDCdtYXR0 +aGV3QGdyaWdvcmEubG9jYWwgKE1hdHRoZXcgTWNOZWVseSkxNzA1BgNVBAMMLm1r +Y2VydCBtYXR0aGV3QGdyaWdvcmEubG9jYWwgKE1hdHRoZXcgTWNOZWVseSkwHhcN +MTkwNjAxMDAwMDAwWhcNMzAwMjA1MTg0NzM2WjBbMScwJQYDVQQKEx5ta2NlcnQg +ZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxMDAuBgNVBAsMJ21hdHRoZXdAZ3JpZ29y +YS5sb2NhbCAoTWF0dGhldyBNY05lZWx5KTCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBANSvJBKjmOkzSRNR3hdfMeeQbtTeTbEIOqCpx4CXf0medobpywX1 +XXcYC/9jrIhPLAvBrACl3iwID1AcgtbV8qzC95jf72MU0dCECIUdVafK8mRJIzE8 +Th7ZF+n5JdXWtVOq0HKZt6pzFnoyQ1judzg1ml0qw0LB7SUEfur5ug65lxIGSQEn +u3bJEEPaA8ZWl1Vithv/1KexkVNqj5WSXAFTnvyZlbmho8LyTUVJq6Bj+j/ezOlh +z9bKnssg4xaMKBdR1RqUexA6yblI9vMwroybrm3Xg+0hAB4qwW7E3di7XS4PccRF +bUGrkP1M6zCb/nS7ZaPHRzzu8OSmK4Vxz8MCAwEAAaNsMGowDgYDVR0PAQH/BAQD +AgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgw +FoAUYcOoYsVzFWeDyI9wtFFENLE3BxowFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0G +CSqGSIb3DQEBCwUAA4IBgQCIWb8/oXuEqE9oPEOIEgrhr59GJ4CZshfXi7jLn9Mc +Ah1l5I0qlpmWFvwALysgxRnIHaEo8e+iZwotYiYp+S6OcnZreQTxw+TqKGCr4tzp +BlyUxJRrzbiShiFiNo25wKZ/jZ/WV03wmy6/ZFcipVYDHRw3I+2zYHOgDLQPPjDV +XxLroWW+CzKBsvqDEBL1olxzZdTqrgyah5gp6GWJiB7XW8KrCARTXE2oJce7OfKs +X6Kl+J+2H0ja6RsKH166sT2Q+yMlRUuqTt/sP+ChN/HHQ99/fuCfMYPfwajKBZKa +84OQHjfywY5FQWuMG/K/ZRbN7YtscTEmTHPkBto9jzC7V0A0mcjqOh96HckeLSSA +xZ7SrKBoxtCgJIGt5qqaEwfUr/wnV4TDGG+Bc9rmrMdNtDFqUCnsgOch67+ZpuFA +UYeYwPdYvsbZ7E2u2PNcFvLZrVzeuycmsS2SgqPw/txU9WCE/vYZyH5QLWkFKd4U +s2MYhULXDwSM6GL7MMdJKQM= +-----END CERTIFICATE----- diff --git a/workspace/services.yml b/workspace/services.yml new file mode 100644 index 0000000..54675ee --- /dev/null +++ b/workspace/services.yml @@ -0,0 +1,122 @@ +version: '2.1' + +services: + + etcd: + image: quay.io/coreos/etcd:v3.3.9 + hostname: etcd + container_name: ofte_etcd + command: + - etcd + - --data-dir=/data + - --name=etcd01 + - --listen-client-urls=http://0.0.0.0:2379 + - --listen-peer-urls=http://0.0.0.0:2380 + - --advertise-client-urls=http://127.0.0.1:2379 + - --initial-advertise-peer-urls=http://127.0.0.1:2380 + - --initial-cluster=etcd01=http://127.0.0.1:2380 + - --auto-compaction-mode=revision + - --auto-compaction-retention=1000 + + postgres: + image: registry.gitlab.com/ofte/docker-registry/postgres:latest + hostname: postgres + container_name: ofte_postgres + ports: + - 5432:5432 + environment: + - OFTE_DB_USER=ofte + - OFTE_DB_PASSWORD + volumes: + - pgdata:/var/lib/postgresql/data + + migrate: + image: registry.gitlab.com/ofte/docker-registry/ofte-migrate-cmd:latest + environment: + - OFTE_DB_HOST=postgres + - OFTE_DB_PORT=5432 + - OFTE_DB_NAME=ofte + - OFTE_DB_USER=ofte + - OFTE_DB_PASSWORD + - OFTE_DB_SSLMODE=require + depends_on: + - postgres + + auth-service: + image: registry.gitlab.com/ofte/docker-registry/ofte-auth-service:latest + hostname: auth-service + container_name: ofte_auth_service + ports: + - 2357:2357 + environment: + - OFTE_DB_HOST=postgres + - OFTE_DB_PORT=5432 + - OFTE_DB_NAME=ofte + - OFTE_DB_USER=ofte + - OFTE_DB_PASSWORD + - OFTE_DB_SSLMODE=require + - OFTE_KV_ENDPOINTS=http://etcd:2379 + - OFTE_HTTP_PORT=2357 + - OFTE_CORS_ALLOWED_ORIGINS=* + - OFTE_TLS_CERTIFICATE_FILE=/srv/certs/localhost.pem + - OFTE_TLS_PRIVATE_KEY_FILE=/srv/certs/localhost-key.pem + - OFTE_RP_DISPLAY_NAME=Ofte Demo + - OFTE_RP_ID=localhost + - OFTE_RP_ORIGIN=https://localhost:8888 + - OFTE_RP_ICON=https://ofte.io/img/ofte-logo.svg + - OFTE_FIDO_MDS_TOKEN + - OFTE_IPSTACK_ACCESS_KEY + depends_on: + - migrate + - etcd + volumes: + # mount the current directory (for pems) + - ./:/srv/certs + + admin-service: + image: registry.gitlab.com/ofte/docker-registry/ofte-admin-service:latest + hostname: admin-service + container_name: ofte_admin_service + ports: + - 2358:2358 + environment: + - OFTE_DB_HOST=postgres + - OFTE_DB_PORT=5432 + - OFTE_DB_NAME=ofte + - OFTE_DB_USER=ofte + - OFTE_DB_PASSWORD + - OFTE_DB_SSLMODE=require + - OFTE_KV_ENDPOINTS=http://etcd:2379 + - OFTE_HTTP_PORT=2358 + - OFTE_CORS_ALLOWED_ORIGINS=* + - OFTE_TLS_CERTIFICATE_FILE=/srv/certs/localhost.pem + - OFTE_TLS_PRIVATE_KEY_FILE=/srv/certs/localhost-key.pem + - OFTE_IPSTACK_ACCESS_KEY + depends_on: + - migrate + - etcd + volumes: + # mount the current directory (for pems) + - ./:/srv/certs + + admin-demo-service: + image: registry.gitlab.com/ofte/docker-registry/ofte-admin-demo:latest + hostname: admin-demo + container_name: ofte_admin_demo + ports: + - 2359:2359 + environment: + - PORT=2359 + # this must point to the *public* URL of the admin-service + - OFTE_ADMIN_ENDPOINT=https://localhost:2358 + - STATIC_FILES_LOCATION=/srv/web-app + - CERTIFICATE_FILE=/srv/certs/localhost.pem + - KEY_FILE=/srv/certs/localhost-key.pem + depends_on: + - admin-service + volumes: + # mount the current directory (for pems) + - ./:/srv/certs + +volumes: + pgdata: \ No newline at end of file