diff --git a/api-docs/swagger.yml b/api-docs/swagger.yml index fc9a125e..f069b27a 100644 --- a/api-docs/swagger.yml +++ b/api-docs/swagger.yml @@ -155,6 +155,9 @@ definitions: items: type: "string" description: "Overrides to environmental variables" + registryAuth: + type: "string" + description: "Private registry base64-encoded basic auth (as present in ~/.docker/config.json)" externalDocs: description: "More documentation available on Github" url: "http://docs.get-faas.com" diff --git a/docs/managing-images.md b/docs/managing-images.md new file mode 100644 index 00000000..3e969cd2 --- /dev/null +++ b/docs/managing-images.md @@ -0,0 +1,86 @@ +# Managing images + +## Using private Docker registries + +FaaS supports running functions from Docker images in private Docker registries. +The registry credentials can be passed on function deployment, and are then handled by Swarm for image polling. + +### Deploy functions with private registries credentials + +A `POST` request on `/system/function` allows you to specify private registry credentials, as a base64-encoded basic auth (user:password). +``` +curl -XPOST /system/functions -d { + "service": "functionName", + "image": "privateregistry.domain.com/user/function", + "envProcess": "/usr/bin/myprocess", + "network": "func_functions", + "registryAuth": "dXNlcjpwYXNzd29yZA==" +} +``` + +Base64-encoded basic auth can be resolved using your registry username and password: +```` +echo -n "user:password" | base64 +```` + +You can also find it in your `~/.docker/config.json` Docker credentials store, as a result of the `docker login` command: +``` +cat ~/.docker/config.json +{ + "auths": { + "privateregistry.domain.com": { + "auth": "dXNlcjpwYXNzd29yZA==" + } + } +} +``` + +### Deploy your own private Docker registry + +If you wish to deploy your own private registry, you can follow [Docker official documentation](https://docs.docker.com/registry/deploying/). + +A quick way to get started for a private registry with TLS and authentication +is to create a VM with port 443 open to the world (for letsencrypt registration), and a registered DNS ($YOURHOST). +Then, create these two files in the current directory: + +``` +# docker-compose.yml +version: '2' + +services: + + registry: + restart: always + image: registry:2 + ports: + - 5000:5000 + - 443:5000 + environment: + REGISTRY_AUTH: htpasswd + REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd + REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm + REGISTRY_HTTP_TLS_LETSENCRYPT_CACHEFILE: /letsencrypt/cache + REGISTRY_HTTP_TLS_LETSENCRYPT_EMAIL: your@email.com + volumes: + - ./data:/var/lib/registry + - ./auth:/auth + - ./letsencrypt:/letsencrypt +``` + +``` +# auth/htpasswd (generated with `docker run --entrypoint htpasswd registry:2 -Bbn testuser testpassword`) +testuser:$2y$05$Bl9siDMe7ieQHLM8e7ifaOklKrHmXymbMqfmqXs7zssj6MMGQW4le +``` + +Your registry is ready to be deployed by running `docker-compose up -d`. + +On the client machine, you can now login and use the newly setup registry: +``` +docker pull ubuntu && docker tag ubuntu $YOURHOST/ubuntu +docker login $YOURHOST # will add encoded registry credentials to ~/.docker/config.json + Username: testuser + Password: testpassword +docker push $YOURHOST/ubuntu +``` + +Images pushed to this registry can be used as functions with FaaS, provided you pass the appropriate `registryAuth` parameter at deployment time. diff --git a/gateway/handlers/functionshandler.go b/gateway/handlers/functionshandler.go index 513bf03a..866fa4e7 100644 --- a/gateway/handlers/functionshandler.go +++ b/gateway/handlers/functionshandler.go @@ -5,19 +5,24 @@ package handlers import ( "context" + "encoding/base64" "encoding/json" + "errors" "fmt" "log" "net/http" + "strings" "io/ioutil" "github.com/alexellis/faas/gateway/metrics" "github.com/alexellis/faas/gateway/requests" + "github.com/docker/distribution/reference" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/client" + "github.com/docker/docker/registry" "github.com/prometheus/client_golang/prometheus" io_prometheus_client "github.com/prometheus/client_model/go" ) @@ -160,6 +165,16 @@ func MakeNewFunctionHandler(metricsOptions metrics.MetricOptions, c *client.Clie // w.WriteHeader(http.StatusNotImplemented) options := types.ServiceCreateOptions{} + if len(request.RegistryAuth) > 0 { + auth, err := BuildEncodedAuthConfig(request.RegistryAuth, request.Image) + if err != nil { + log.Println("Error while building registry auth configuration", err) + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid registry auth")) + return + } + options.EncodedRegistryAuth = auth + } spec := makeSpec(&request) response, err := c.ServiceCreate(context.Background(), spec, options) @@ -208,3 +223,43 @@ func makeSpec(request *requests.CreateFunctionRequest) swarm.ServiceSpec { return spec } + +func BuildEncodedAuthConfig(basicAuthB64 string, dockerImage string) (string, error) { + // extract registry server address + distributionRef, err := reference.ParseNormalizedNamed(dockerImage) + if err != nil { + return "", err + } + repoInfo, err := registry.ParseRepositoryInfo(distributionRef) + if err != nil { + return "", err + } + // extract registry user & password + user, password, err := userPasswordFromBasicAuth(basicAuthB64) + if err != nil { + return "", err + } + // build encoded registry auth config + buf, err := json.Marshal(types.AuthConfig{ + Username: user, + Password: password, + ServerAddress: repoInfo.Index.Name, + }) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(buf), nil +} + +func userPasswordFromBasicAuth(basicAuthB64 string) (string, string, error) { + c, err := base64.StdEncoding.DecodeString(basicAuthB64) + if err != nil { + return "", "", err + } + cs := string(c) + s := strings.IndexByte(cs, ':') + if s < 0 { + return "", "", errors.New("Invalid basic auth") + } + return cs[:s], cs[s+1:], nil +} diff --git a/gateway/requests/requests.go b/gateway/requests/requests.go index a319bd59..ae9d3449 100644 --- a/gateway/requests/requests.go +++ b/gateway/requests/requests.go @@ -18,6 +18,11 @@ type CreateFunctionRequest struct { // EnvVars provides overrides for functions. EnvVars map[string]string `json:"envVars"` + + // RegistryAuth is the registry authentication (optional) + // in the same encoded format as Docker native credentials + // (see ~/.docker/config.json) + RegistryAuth string `json:"registryAuth,omitempty"` } type DeleteFunctionRequest struct { diff --git a/gateway/tests/registryauth_test.go b/gateway/tests/registryauth_test.go new file mode 100644 index 00000000..5af28ee2 --- /dev/null +++ b/gateway/tests/registryauth_test.go @@ -0,0 +1,73 @@ +// Copyright (c) Alex Ellis 2017. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +package tests + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/alexellis/faas/gateway/handlers" + "github.com/docker/docker/api/types" +) + +func TestBuildEncodedAuthConfig(t *testing.T) { + // custom repository with valid data + assertValidEncodedAuthConfig(t, "user", "password", "my.repository.com/user/imagename", "my.repository.com") + assertValidEncodedAuthConfig(t, "user", "weird:password:", "my.repository.com/user/imagename", "my.repository.com") + assertValidEncodedAuthConfig(t, "userWithNoPassword", "", "my.repository.com/user/imagename", "my.repository.com") + assertValidEncodedAuthConfig(t, "", "", "my.repository.com/user/imagename", "my.repository.com") + + // docker hub default repository + assertValidEncodedAuthConfig(t, "user", "password", "user/imagename", "docker.io") + assertValidEncodedAuthConfig(t, "", "", "user/imagename", "docker.io") + + // invalid base64 basic auth + assertEncodedAuthError(t, "invalidBasicAuth", "my.repository.com/user/imagename") + + // invalid docker image name + assertEncodedAuthError(t, b64BasicAuth("user", "password"), "") + assertEncodedAuthError(t, b64BasicAuth("user", "password"), "invalid name") +} + +func assertValidEncodedAuthConfig(t *testing.T, user, password, imageName, expectedRegistryHost string) { + encodedAuthConfig, err := handlers.BuildEncodedAuthConfig(b64BasicAuth(user, password), imageName) + if err != nil { + t.Log("Unexpected error while building auth config with correct values") + t.Fail() + } + + authConfig := &types.AuthConfig{} + authJSON := base64.NewDecoder(base64.URLEncoding, strings.NewReader(encodedAuthConfig)) + if err := json.NewDecoder(authJSON).Decode(authConfig); err != nil { + t.Log("Invalid encoded auth", err) + t.Fail() + } + + if user != authConfig.Username { + t.Log("Auth config username mismatch", user, authConfig.Username) + t.Fail() + } + if password != authConfig.Password { + t.Log("Auth config password mismatch", password, authConfig.Password) + t.Fail() + } + if expectedRegistryHost != authConfig.ServerAddress { + t.Log("Auth config registry server address mismatch", expectedRegistryHost, authConfig.ServerAddress) + t.Fail() + } +} + +func assertEncodedAuthError(t *testing.T, b64BasicAuth, imageName string) { + _, err := handlers.BuildEncodedAuthConfig(b64BasicAuth, imageName) + if err == nil { + t.Log("Expected an error to be returned") + t.Fail() + } +} + +func b64BasicAuth(user, password string) string { + return base64.StdEncoding.EncodeToString([]byte(user + ":" + password)) +}