diff --git a/Dockerfile b/Dockerfile index 7ed384f..32b0c75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ ENV PSU_AUTH_TOKEN="" \ PSU_LOG_LEVEL="" \ PSU_LOGIN_PRINT="" \ PSU_PASSWORD="" \ + PSU_PROXY_ADDRESS="" \ + PSU_PROXY_ENDPOINT="" \ PSU_STACK_DEPLOY_ENDPOINT="" \ PSU_STACK_DEPLOY_ENV_FILE="" \ PSU_STACK_DEPLOY_REPLACE_ENV="" \ diff --git a/README.md b/README.md index 4b6728b..234c2dc 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - [YAML configuration file](#yaml-configuration-file) - [JSON configuration file](#json-configuration-file) - [Environment variables for deployed stacks](#environment-variables-for-deployed-stacks) + - [Endpoint's Docker API proxy](#endpoints-docker-api-proxy) - [Log level](#log-level) - [Exit statuses](#exit-statuses) - [Contributing](#contributing) @@ -160,6 +161,26 @@ echo "stack.deploy.env-file: .env" > .config.yml psu stack deploy django-stack -c /path/to/docker-compose.yml --config .config.yml ``` +### Endpoint's Docker API proxy + +If you want finer-grained control over an endpoint's Docker daemon you can expose it through a proxy and configure a local Docker client to use it. + +First, expose the endpoint's Docker API: + +```bash +psu proxy --endpoint primary --address 127.0.0.1:2375 +``` + +Then (in a different shell), configure a local Docker client to use the exposed API: + +```bash +export DOCKER_HOST=tcp://127.0.0.1:2375 +``` + +Now you can run `docker ...` commands in the `primary` endpoint as in a local Docker installation, **with the added benefit of using Portainer's RBAC**. + +*Note that creating stacks through `docker stack ...` instead of `psu stack ...` will give you *limited* control over them, as they are created outside of Portainer.* + ### Log level You can control how much noise you want the program to do by setting the log level. There are seven log levels: diff --git a/client/client.go b/client/client.go index 3dcfe6f..668a35d 100644 --- a/client/client.go +++ b/client/client.go @@ -60,6 +60,9 @@ type PortainerClient interface { // Run a function after receiving a response from Portainer AfterResponse(hook func(resp *http.Response) (err error)) + + // Proxy proxies a request to /endpoint/{id}/docker and returns its result + Proxy(endpointID portainer.EndpointID, req *http.Request) (resp *http.Response, err error) } type portainerClientImp struct { @@ -121,6 +124,22 @@ func (n *portainerClientImp) do(uri, method string, requestBody io.Reader, heade return } +func (n *portainerClientImp) doWithToken(uri, method string, requestBody io.Reader, headers http.Header) (resp *http.Response, err error) { + // Ensure there is an auth token + if n.token == "" { + n.token, err = n.AuthenticateUser(AuthenticateUserOptions{ + Username: n.user, + Password: n.password, + }) + if err != nil { + return + } + } + headers.Set("Authorization", "Bearer "+n.token) + + return n.do(uri, method, requestBody, headers) +} + // Do a JSON http request func (n *portainerClientImp) doJSON(uri, method string, headers http.Header, requestBody interface{}, responseBody interface{}) error { // Encode request body, if any diff --git a/client/endpoint_docker_proxy.go b/client/endpoint_docker_proxy.go new file mode 100644 index 0000000..ce493a3 --- /dev/null +++ b/client/endpoint_docker_proxy.go @@ -0,0 +1,12 @@ +package client + +import ( + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" +) + +func (n *portainerClientImp) Proxy(endpointID portainer.EndpointID, req *http.Request) (resp *http.Response, err error) { + return n.doWithToken(fmt.Sprintf("endpoints/%v/docker%s", endpointID, req.RequestURI), req.Method, req.Body, req.Header) +} diff --git a/cmd/proxy.go b/cmd/proxy.go new file mode 100644 index 0000000..e7682d0 --- /dev/null +++ b/cmd/proxy.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "io/ioutil" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/sirupsen/logrus" + + "github.com/spf13/viper" + + "github.com/greenled/portainer-stack-utils/common" + + "github.com/spf13/cobra" +) + +// proxyCmd represents the proxy command +var proxyCmd = &cobra.Command{ + Use: "proxy", + Short: "Start an HTTP proxy to an endpoint's Docker API", + Example: ` Expose "primary" endpoint's Docker API at 127.0.0.1:11000: + psu proxy --endpoint primary --address 127.0.0.1:11000 + + Configure local Docker client to connect to proxy: + export DOCKER_HOST=tcp://127.0.0.1:11000`, + Run: func(cmd *cobra.Command, args []string) { + portainerClient, err := common.GetClient() + common.CheckError(err) + + var endpoint portainer.Endpoint + if endpointName := viper.GetString("proxy.endpoint"); endpointName == "" { + // Guess endpoint if not set + logrus.WithFields(logrus.Fields{ + "implications": "Command will fail if there is not exactly one endpoint available", + }).Warning("Endpoint not set") + var endpointRetrievalErr error + endpoint, endpointRetrievalErr = common.GetDefaultEndpoint() + common.CheckError(endpointRetrievalErr) + logrus.WithFields(logrus.Fields{ + "endpoint": endpoint.Name, + }).Debug("Using the only available endpoint") + } else { + // Get endpoint by name + var endpointRetrievalErr error + endpoint, endpointRetrievalErr = common.GetEndpointByName(endpointName) + common.CheckError(endpointRetrievalErr) + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + resp, err := portainerClient.Proxy(endpoint.ID, r) + if err != nil { + logrus.Fatal(err) + } + + for key, value := range resp.Header { + for i := range value { + w.Header().Add(key, value[i]) + } + } + + w.WriteHeader(resp.StatusCode) + + if resp.Body != nil { + bodyBytes, err := ioutil.ReadAll(resp.Body) + defer resp.Body.Close() + if err != nil { + logrus.Fatal(err) + } + w.Write(bodyBytes) + } + }) + + err = http.ListenAndServe(viper.GetString("proxy.address"), nil) + if err != http.ErrServerClosed { + logrus.Fatal(err) + } + }, +} + +func init() { + rootCmd.AddCommand(proxyCmd) + + proxyCmd.Flags().String("endpoint", "", "Endpoint name.") + proxyCmd.Flags().String("address", "127.0.0.1:2375", "Address to bind to.") + viper.BindPFlag("proxy.endpoint", proxyCmd.Flags().Lookup("endpoint")) + viper.BindPFlag("proxy.address", proxyCmd.Flags().Lookup("address")) +}