Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,22 @@ set OCTOPUS_API_KEY="API-XXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # replace with your API
octopus.exe space list # should list all the spaces
```

### Proxies

The CLI honours the standard `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` environment variables.

To point the CLI at a proxy without affecting other tools, set `OCTOPUS_PROXY` (or the `ProxyUrl` config key,
which `OCTOPUS_PROXY` overrides). It applies to both http and https requests, and `NO_PROXY` still applies.
`http`, `https`, `socks5` and `socks5h` proxy urls are supported.

```shell
export OCTOPUS_PROXY="http://proxy.example.com:3128"
```

Credentials can be embedded in the proxy url, or supplied separately with `OCTOPUS_PROXY_USERNAME` and
`OCTOPUS_PROXY_PASSWORD`. Credentials are read from the environment only, so a proxy password is never
written to the CLI config file.

### go-octopusdeploy library

The CLI depends heavily on the [go-octopusdeploy](https://github.com/OctopusDeploy/go-octopusdeploy) library, which manages
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
golang.org/x/exp v0.0.0-20230129154200-a960b3787bd2
golang.org/x/net v0.57.0
golang.org/x/term v0.45.0
)

Expand Down Expand Up @@ -53,7 +54,6 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
10 changes: 7 additions & 3 deletions pkg/apiclient/client_factory.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package apiclient

import (
"crypto/tls"
"errors"
"fmt"
"net/url"
Expand Down Expand Up @@ -121,13 +120,18 @@ func NewClientFactoryFromConfig(ask question.AskProvider) (ClientFactory, error)
return nil, errs
}

http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
transport, err := NewHttpTransport(ProxySettingsFromConfig(), true)
if err != nil {
return nil, err
}

// The spinner is only wanted in interactive mode, but that is not settled
// yet: this runs before cobra parses --no-prompt. The round-tripper decides
// per request instead.
spinnerRoundTripper := NewSpinnerRoundTripper(ask)
spinnerRoundTripper.Next = transport
httpClient := &http.Client{
Transport: NewSpinnerRoundTripper(ask),
Transport: spinnerRoundTripper,
}

var credentials octopusApiClient.ICredential
Expand Down
122 changes: 122 additions & 0 deletions pkg/apiclient/proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package apiclient

import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
"os"

"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/spf13/viper"
"golang.org/x/net/http/httpproxy"
)

// ProxySettings is the CLI's proxy configuration.
//
// Url takes precedence over the standard HTTP_PROXY/HTTPS_PROXY variables and
// applies to both schemes; when it is empty those variables are used instead.
// NO_PROXY is honoured either way. http, https, socks5 and socks5h proxies are
// supported, all by net/http itself.
type ProxySettings struct {
Url string
Username string
Password string
}

// ProxySettingsFromConfig reads the proxy settings from the viper config, which
// covers the ProxyUrl config file key and the OCTOPUS_PROXY environment variable.
// The credentials are deliberately read from the environment only, so that a
// proxy password is never written to the config file in plain text.
func ProxySettingsFromConfig() ProxySettings {
return ProxySettings{
Url: viper.GetString(constants.ConfigProxyUrl),
Username: os.Getenv(constants.EnvOctopusProxyUsername),
Password: os.Getenv(constants.EnvOctopusProxyPassword),
}
}

// ProxyFunc returns a function suitable for http.Transport.Proxy.
func (s ProxySettings) ProxyFunc() (func(*http.Request) (*url.URL, error), error) {
config := httpproxy.FromEnvironment()
if s.Url != "" {
// httpproxy silently ignores a proxy address it cannot parse, so validate it here
// to report a typo rather than quietly connecting directly.
if _, err := parseProxyUrl(s.Url); err != nil {
return nil, err
}
config.HTTPProxy = s.Url
config.HTTPSProxy = s.Url
config.CGI = false
}

proxyForUrl := config.ProxyFunc()
return func(request *http.Request) (*url.URL, error) {
proxyUrl, err := proxyForUrl(request.URL)
if err != nil || proxyUrl == nil {
return nil, err
}
return s.applyCredentials(proxyUrl), nil
}, nil
}

// applyCredentials adds the configured proxy credentials, unless the proxy url
// already carries its own.
func (s ProxySettings) applyCredentials(proxyUrl *url.URL) *url.URL {
if s.Username == "" || proxyUrl.User != nil {
return proxyUrl
}
withCredentials := *proxyUrl
withCredentials.User = url.UserPassword(s.Username, s.Password)
return &withCredentials
}

// NewHttpTransport returns the transport the CLI uses to talk to Octopus. It is
// a clone of http.DefaultTransport so the standard defaults are kept, with the
// proxy resolution replaced by ours.
func NewHttpTransport(settings ProxySettings, insecureSkipVerify bool) (*http.Transport, error) {
proxyFunc, err := settings.ProxyFunc()
if err != nil {
return nil, err
}

transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = proxyFunc
if insecureSkipVerify {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
return transport, nil
}

// RedactProxyUrl removes the password from a proxy url so it can be displayed.
func RedactProxyUrl(rawUrl string) string {
if rawUrl == "" {
return ""
}
parsed, err := parseProxyUrl(rawUrl)
if err != nil {
return "***" // can't parse it, so we can't tell whether it holds a password
}
if parsed.User == nil {
return rawUrl
}
return parsed.Redacted()
}

// parseProxyUrl mirrors how net/http parses a proxy address: a bare "host:port"
// is treated as http.
func parseProxyUrl(rawUrl string) (*url.URL, error) {
parsed, err := url.Parse(rawUrl)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
if withScheme, schemeErr := url.Parse("http://" + rawUrl); schemeErr == nil && withScheme.Host != "" {
return withScheme, nil
}
}
if err != nil {
return nil, fmt.Errorf("invalid proxy url '%s': %w", rawUrl, err)
}
if parsed.Host == "" {
return nil, fmt.Errorf("invalid proxy url '%s': no host specified", rawUrl)
}
return parsed, nil
}
Loading