Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: credentials script should support both username and password #2870

Merged
merged 4 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
78 changes: 53 additions & 25 deletions docs/configure-harvest-basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,41 +267,69 @@ Pollers:

## Credentials Script

You can fetch authentication information via an external script by using the `credentials_script` section in
the `Pollers` section of your `harvest.yml` as shown in the [example below](#example).
The `credentials_script` feature allows you to fetch authentication information via an external script. This can be configured in the `Pollers` section of your `harvest.yml` file, as shown in the example below.

At runtime, Harvest will invoke the script referenced in the `credentials_script` `path` section.
Harvest will call the script with two arguments like so: `./script $addr $username`.
At runtime, Harvest will invoke the script specified in the `credentials_script` `path` section. Harvest will call the script with two arguments, where the second argument is optional: `./script $addr [$username]`.
rahulguptajss marked this conversation as resolved.
Show resolved Hide resolved

- The first argument is the address of the cluster taken from your `harvest.yaml` file, section `Pollers addr`
- The second argument is the username of the cluster taken from your `harvest.yaml` file, section `Pollers username`
- The first argument (`$addr`) is the address of the cluster taken from the `addr` field under the `Pollers` section of your `harvest.yml` file.
rahulguptajss marked this conversation as resolved.
Show resolved Hide resolved
- The second argument (`$username`) is the username for the cluster taken from the `username` field under the `Pollers` section of your `harvest.yml` file, if provided.
rahulguptajss marked this conversation as resolved.
Show resolved Hide resolved

The script should use the two arguments to look up and return the password via the script's `standard out`.
If the script doesn't finish within the specified `timeout`, Harvest will kill the script and any spawned processes.
The script should return the credentials through its standard output (stdout). Harvest supports two output formats from the script:
rahulguptajss marked this conversation as resolved.
Show resolved Hide resolved

Credential scripts are defined in your `harvest.yml` under the `Pollers` `credentials_script` section.
Below are the options for the `credentials_script` section
1. **YAML format:** If the script outputs a YAML object with `username` and `password` keys, Harvest will parse the YAML and use both the `username` and `password` from the script. For example, the script's stdout might be:
rahulguptajss marked this conversation as resolved.
Show resolved Hide resolved
```yaml
username: myuser
password: mypassword
```
If only the `password` is provided, Harvest will use the `username` from the `harvest.yml` file, if available.

rahulguptajss marked this conversation as resolved.
Show resolved Hide resolved
| parameter | type | description | default |
|-----------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
| path | string | absolute path to script that takes two arguments: addr and username, in that order | |
| schedule | go duration or `always` | schedule used to call the authentication script. If the value is `always`, the script will be called everytime a password is requested, otherwise use the earlier cached value | 24h |
| timeout | go duration | amount of time Harvest will wait for the script to finish before killing it and descendents | 10s |
2. **Plain text format:** If the script outputs plain text, Harvest will use the output as the password. The `username` will be taken from the `harvest.yml` file, if available. For example, the script's stdout might be:
rahulguptajss marked this conversation as resolved.
Show resolved Hide resolved
```
mypassword
```

If the script doesn't finish within the specified `timeout`, Harvest will terminate the script and any spawned processes.

Credential scripts are defined under the `credentials_script` section within `Pollers` in your `harvest.yml`. Below are the options for the `credentials_script` section:

| parameter | type | description | default |
|-----------|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
| path | string | Absolute path to the script that takes two arguments: `addr` and `username`, in that order. | |
| schedule | go duration or `always` | Schedule for calling the authentication script. If set to `always`, the script is called every time a password is requested; otherwise, the previously cached value is used. | 24h |
| timeout | go duration | Maximum time Harvest will wait for the script to finish before terminating it and its descendants. | 10s |

### Example

Here is an example of how to configure the `credentials_script` in the `harvest.yml` file:

```yaml
Pollers:
ontap1:
datacenter: rtp
addr: 10.1.1.1
collectors:
- Rest
- RestPerf
credentials_script:
path: ./get_pass
schedule: 3h
timeout: 10s
ontap1:
datacenter: rtp
addr: 10.1.1.1
username: admin # Optional: if not provided, the script must return the username
collectors:
- Rest
- RestPerf
credentials_script:
path: ./get_credentials
schedule: 3h
timeout: 10s
```

In this example, the `get_credentials` script should be located in the same directory as the `harvest.yml` file and should be executable. It should output the credentials in either YAML or plain text format. Here are two example scripts:

`get_credentials` that outputs YAML:
```bash
rahulguptajss marked this conversation as resolved.
Show resolved Hide resolved
#!/bin/bash
echo "username: myuser"
echo "password: mypassword"
```

`get_credentials` that outputs only the password in plain text:
```bash
#!/bin/bash
echo "mypassword"
```

### Troubleshooting
Expand Down
86 changes: 68 additions & 18 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/netapp/harvest/v2/pkg/errs"
"github.com/netapp/harvest/v2/pkg/logging"
"github.com/netapp/harvest/v2/third_party/mergo"
"gopkg.in/yaml.v3"
"net/http"
"os"
"os/exec"
Expand Down Expand Up @@ -43,7 +44,7 @@ type Credentials struct {
nextUpdate time.Time
logger *logging.Logger
authMu *sync.Mutex
cachedPassword string
cachedResponse ScriptResponse
}

// Expire will reset the credential schedule if the receiver has a CredentialsScript
Expand Down Expand Up @@ -71,39 +72,66 @@ func (c *Credentials) certs(poller *conf.Poller) (string, error) {
return c.fetchCerts(poller)
}

func (c *Credentials) password(poller *conf.Poller) (string, error) {
func (c *Credentials) password(poller *conf.Poller) (ScriptResponse, error) {
if poller.CredentialsScript.Path == "" {
return poller.Password, nil
return ScriptResponse{
Data: poller.Password,
Username: poller.Username,
}, nil
}

var response ScriptResponse
var err error
c.authMu.Lock()
defer c.authMu.Unlock()
if time.Now().After(c.nextUpdate) {
var err error
c.cachedPassword, err = c.fetchPassword(poller)
response, err = c.fetchPassword(poller)
if err != nil {
return "", err
return ScriptResponse{}, err
}
// Cache the new response and update the next update time.
c.cachedResponse = response
c.setNextUpdate()
}
return c.cachedPassword, nil
return c.cachedResponse, nil
}

func (c *Credentials) fetchPassword(p *conf.Poller) (string, error) {
return c.execScript(p.CredentialsScript.Path, "credential", p.CredentialsScript.Timeout, func(ctx context.Context, path string) *exec.Cmd {
func (c *Credentials) fetchPassword(p *conf.Poller) (ScriptResponse, error) {
response, err := c.execScript(p.CredentialsScript.Path, "credential", p.CredentialsScript.Timeout, func(ctx context.Context, path string) *exec.Cmd {
return exec.CommandContext(ctx, path, p.Addr, p.Username) // #nosec
})
if err != nil {
return ScriptResponse{}, err
}
// If username is empty, use harvest config poller username
if response.Username == "" {
response.Username = p.Username
}
return response, nil
}

func (c *Credentials) fetchCerts(p *conf.Poller) (string, error) {
return c.execScript(p.CertificateScript.Path, "certificate", p.CertificateScript.Timeout, func(ctx context.Context, path string) *exec.Cmd {
response, err := c.execScript(p.CertificateScript.Path, "certificate", p.CertificateScript.Timeout, func(ctx context.Context, path string) *exec.Cmd {
return exec.CommandContext(ctx, path, p.Addr) // #nosec
})
if err != nil {
return "", err
}

// The script is expected to return only the certificate data, so we don't need to check for a username.
return response.Data, nil
}

func (c *Credentials) execScript(cmdPath string, kind string, timeout string, e func(ctx context.Context, path string) *exec.Cmd) (string, error) {
type ScriptResponse struct {
Username string `yaml:"username"`
Data string `yaml:"password"`
}

func (c *Credentials) execScript(cmdPath string, kind string, timeout string, e func(ctx context.Context, path string) *exec.Cmd) (ScriptResponse, error) {
response := ScriptResponse{}
lookPath, err := exec.LookPath(cmdPath)
if err != nil {
return "", fmt.Errorf("script lookup failed kind=%s err=%w", kind, err)
return response, fmt.Errorf("script lookup failed kind=%s err=%w", kind, err)
}
if timeout == "" {
timeout = defaultTimeout
Expand Down Expand Up @@ -141,7 +169,7 @@ func (c *Credentials) execScript(cmdPath string, kind string, timeout string, e
Str("stdout", stdout.String()).
Str("kind", kind).
Msg("Failed to start script")
return "", fmt.Errorf("script start failed script=%s kind=%s err=%w", lookPath, kind, err)
return response, fmt.Errorf("script start failed script=%s kind=%s err=%w", lookPath, kind, err)
}
err = cmd.Wait()
if err != nil {
Expand All @@ -152,9 +180,31 @@ func (c *Credentials) execScript(cmdPath string, kind string, timeout string, e
Str("stdout", stdout.String()).
Str("kind", kind).
Msg("Failed to execute script")
return "", fmt.Errorf("script execute failed script=%s kind=%s err=%w", lookPath, kind, err)
return response, fmt.Errorf("script execute failed script=%s kind=%s err=%w", lookPath, kind, err)
}
return strings.TrimSpace(stdout.String()), nil

err = yaml.Unmarshal(stdout.Bytes(), &response)
if err != nil {
// Log the error but do not return it, we will try to use the output as plain text next.
c.logger.Debug().Err(err).
Str("script", lookPath).
Str("timeout", duration.String()).
Str("stderr", stderr.String()).
Str("stdout", stdout.String()).
Str("kind", kind).
Msg("Failed to parse YAML output. Treating as plain text.")
}

if err == nil && response.Data != "" {
// If parsing is successful and data is not empty, return the response.
// Username is optional, so it's okay if it's not present.
return response, nil
}

// If JSON parsing fails or the data is empty,
// assume the output is the data (password or certificate) in plain text for backward compatibility.
response.Data = strings.TrimSpace(stdout.String())
return response, nil
}

func (c *Credentials) setNextUpdate() {
Expand Down Expand Up @@ -275,13 +325,13 @@ func getPollerAuth(c *Credentials, poller *conf.Poller) (PollerAuth, error) {
}, nil
}
if poller.CredentialsScript.Path != "" {
pass, err := c.password(poller)
response, err := c.password(poller)
if err != nil {
return PollerAuth{}, err
}
return PollerAuth{
Username: poller.Username,
Password: pass,
Username: response.Username,
Password: response.Data,
HasCredentialScript: true,
Schedule: poller.CredentialsScript.Schedule,
insecureTLS: insecureTLS,
Expand Down
84 changes: 84 additions & 0 deletions pkg/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,90 @@ Pollers:
password: pass
ca_cert: testdata/ca.pem`,
},
{
name: "credentials_script returns username and password in YAML",
pollerName: "test",
want: PollerAuth{
Username: "script-username",
Password: "script-password",
HasCredentialScript: true,
},
yaml: `
Pollers:
test:
addr: a.b.c
credentials_script:
path: testdata/get_credentials_yaml
`,
},

{
name: "credentials_script returns only password in plain text",
pollerName: "test",
want: PollerAuth{
Username: "username", // Fallback to the username provided in the poller configuration
Password: "plain-text-password",
HasCredentialScript: true,
},
yaml: `
Pollers:
test:
addr: a.b.c
username: username
credentials_script:
path: testdata/get_password_plain
`,
},
{
name: "credentials_script returns only password in YAML format",
pollerName: "test",
want: PollerAuth{
Username: "username", // Fallback to the username provided in the poller configuration
Password: "script-password",
HasCredentialScript: true,
},
yaml: `
Pollers:
test:
addr: a.b.c
username: username
credentials_script:
path: testdata/get_credentials_yaml_password
`,
},
{
name: "credentials_script returns username and password in YAML, no username in poller config",
pollerName: "test",
want: PollerAuth{
Username: "script-username",
Password: "script-password",
HasCredentialScript: true,
},
yaml: `
Pollers:
test:
addr: a.b.c
credentials_script:
path: testdata/get_credentials_yaml
`,
},

{
name: "credentials_script returns only password in plain text, no username in poller config",
pollerName: "test",
want: PollerAuth{
Username: "", // No username provided, so it should be empty
Password: "plain-text-password",
HasCredentialScript: true,
},
yaml: `
Pollers:
test:
addr: a.b.c
credentials_script:
path: testdata/get_password_plain
`,
},
}

hostname, err := os.Hostname()
Expand Down
4 changes: 4 additions & 0 deletions pkg/auth/testdata/get_credentials_yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/bash
# Used by pkg/auth/auth_test.go
echo "username: script-username"
echo "password: script-password"
3 changes: 3 additions & 0 deletions pkg/auth/testdata/get_credentials_yaml_password
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash
# Used by pkg/auth/auth_test.go
echo "password: script-password"
3 changes: 3 additions & 0 deletions pkg/auth/testdata/get_password_plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash
# Used by pkg/auth/auth_test.go
echo "plain-text-password"