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 1 commit
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
51 changes: 26 additions & 25 deletions docs/configure-harvest-basic.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,41 +267,42 @@ 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` allows you to fetch authentication information via an external script. This feature 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 in the following manner: `./script $addr $username`.

- 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.

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. **JSON format:** If the script outputs a JSON object with `username` and `password` keys, Harvest will parse the JSON and use both the `username` and `password` from the script. For example, the script's stdout might be `{"username": "myuser", "password": "mypassword"}`.
2. **Plain text format:** If the script outputs plain text, Harvest will use the output as the password and the `username` from the `harvest.yml` file. For example, the script's stdout might be `mypassword`.

| 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 |
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

```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
collectors:
- Rest
- RestPerf
credentials_script:
path: ./get_pass
schedule: 3h
timeout: 10s
```

### Troubleshooting
Expand Down
75 changes: 57 additions & 18 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
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
}

type ScriptResponse struct {
Data string `json:"password"`
Username string `json:"username,omitempty"`
}

func (c *Credentials) execScript(cmdPath string, kind string, timeout string, e func(ctx context.Context, path string) *exec.Cmd) (string, error) {
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,20 @@ 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 = json.Unmarshal(stdout.Bytes(), &response)
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 +314,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
68 changes: 68 additions & 0 deletions pkg/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,74 @@ Pollers:
password: pass
ca_cert: testdata/ca.pem`,
},
{
name: "credentials_script returns username and password in JSON",
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_json
`,
},

{
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 username and password in JSON, 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_json
`,
},

{
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
3 changes: 3 additions & 0 deletions pkg/auth/testdata/get_credentials_json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash
# Used by pkg/auth/auth_test.go
echo '{"username": "script-username", "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"