Skip to content

Commit 4d6a705

Browse files
authored
feat: add basic authentication support (#4)
* feat: add basic authentication support Add --user and --password flags to `esq config add` for environments that require HTTP basic auth (e.g. Elasticsearch clusters behind authentication proxies or with X-Pack security enabled). Credentials are stored in config.json and sent as Authorization headers on every request. The `config list` output now shows an auth indicator when credentials are configured. * fix: address security review feedback - Config file permissions tightened from 0644 to 0600 (owner-only) - Existing files get chmod'd on every save to fix legacy permissions - Add --password-stdin flag for secure password input (interactive terminal prompt via x/term, or piped input for scripting) - --password flag kept for convenience but help text warns about shell history exposure
1 parent da96cb3 commit 4d6a705

7 files changed

Lines changed: 75 additions & 10 deletions

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ require github.com/spf13/cobra v1.10.2
77
require (
88
github.com/inconshreveable/mousetrap v1.1.0 // indirect
99
github.com/spf13/pflag v1.0.10 // indirect
10+
golang.org/x/sys v0.41.0 // indirect
11+
golang.org/x/term v0.40.0 // indirect
1012
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,8 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
88
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
99
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
1010
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
11+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
12+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
13+
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
14+
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
1115
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

internal/cmd/config/config.go

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package configcmd
22

33
import (
4+
"bufio"
45
"fmt"
6+
"os"
57
"sort"
8+
"strings"
69

710
"github.com/enthus-appdev/esq-cli/internal/config"
811
"github.com/enthus-appdev/esq-cli/internal/output"
912
"github.com/spf13/cobra"
13+
"golang.org/x/term"
1014
)
1115

1216
// NewConfigCmd creates the config command group.
@@ -30,13 +34,14 @@ func NewConfigCmd() *cobra.Command {
3034
}
3135

3236
func newAddCmd() *cobra.Command {
33-
var url string
37+
var url, user, password string
38+
var passwordStdin bool
3439

3540
cmd := &cobra.Command{
3641
Use: "add <name>",
3742
Short: "Add an Elasticsearch environment",
3843
Example: ` esq config add prod --url http://es-prod:9200
39-
esq config add stage --url http://es-stage:9200
44+
esq config add stage --url http://es-stage:9200 --user elastic --password-stdin
4045
esq config add local --url http://localhost:9200`,
4146
Args: cobra.ExactArgs(1),
4247
RunE: func(cmd *cobra.Command, args []string) error {
@@ -46,13 +51,42 @@ func newAddCmd() *cobra.Command {
4651
return fmt.Errorf("--url is required\n\nExample:\n esq config add %s --url http://hostname:9200", name)
4752
}
4853

54+
if password != "" && passwordStdin {
55+
return fmt.Errorf("--password and --password-stdin are mutually exclusive")
56+
}
57+
58+
// Read password from stdin if requested
59+
if passwordStdin {
60+
if term.IsTerminal(int(os.Stdin.Fd())) {
61+
fmt.Fprint(os.Stderr, "Password: ")
62+
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
63+
fmt.Fprintln(os.Stderr)
64+
if err != nil {
65+
return fmt.Errorf("reading password: %w", err)
66+
}
67+
password = string(raw)
68+
} else {
69+
scanner := bufio.NewScanner(os.Stdin)
70+
if scanner.Scan() {
71+
password = strings.TrimRight(scanner.Text(), "\r\n")
72+
}
73+
if err := scanner.Err(); err != nil {
74+
return fmt.Errorf("reading password from stdin: %w", err)
75+
}
76+
}
77+
}
78+
4979
cfg, err := config.Load()
5080
if err != nil {
5181
return err
5282
}
5383

5484
_, exists := cfg.Environments[name]
55-
cfg.Environments[name] = &config.Environment{URL: url}
85+
cfg.Environments[name] = &config.Environment{
86+
URL: url,
87+
Username: user,
88+
Password: password,
89+
}
5690

5791
// Auto-set as current if it's the first environment
5892
if cfg.CurrentEnv == "" {
@@ -69,6 +103,10 @@ func newAddCmd() *cobra.Command {
69103
output.Success("Added environment %q (url: %s)", name, url)
70104
}
71105

106+
if user != "" {
107+
output.Info("Basic auth: %s", user)
108+
}
109+
72110
if cfg.CurrentEnv == name {
73111
output.Info("Active environment: %s", name)
74112
}
@@ -78,6 +116,9 @@ func newAddCmd() *cobra.Command {
78116
}
79117

80118
cmd.Flags().StringVar(&url, "url", "", "Elasticsearch base URL (e.g. http://hostname:9200)")
119+
cmd.Flags().StringVar(&user, "user", "", "Username for basic authentication")
120+
cmd.Flags().StringVar(&password, "password", "", "Password for basic authentication (visible in shell history, prefer --password-stdin)")
121+
cmd.Flags().BoolVar(&passwordStdin, "password-stdin", false, "Read password from stdin (prompted interactively if terminal)")
81122

82123
return cmd
83124
}
@@ -139,7 +180,11 @@ func newListCmd() *cobra.Command {
139180
if name == cfg.CurrentEnv {
140181
marker = "* "
141182
}
142-
fmt.Printf("%s%-10s %s\n", marker, name, env.URL)
183+
auth := ""
184+
if env.Username != "" {
185+
auth = fmt.Sprintf(" (auth: %s)", env.Username)
186+
}
187+
fmt.Printf("%s%-10s %s%s\n", marker, name, env.URL, auth)
143188
}
144189

145190
return nil

internal/cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func getClient() (*es.Client, string, error) {
7979
return nil, "", fmt.Errorf("environment %q not found (available: %s)", envName, formatEnvList(cfg))
8080
}
8181

82-
return es.NewClient(env.URL), envName, nil
82+
return es.NewClient(env.URL, env.Username, env.Password), envName, nil
8383
}
8484

8585
func formatEnvList(cfg *config.Config) string {

internal/cmd/search/search.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func getClient(cmd *cobra.Command) (*es.Client, string, error) {
105105
return nil, "", fmt.Errorf("environment %q not found", envName)
106106
}
107107

108-
return es.NewClient(env.URL), envName, nil
108+
return es.NewClient(env.URL, env.Username, env.Password), envName, nil
109109
}
110110

111111
// resolveIndex resolves a partial index name and prints info about ambiguity.

internal/config/config.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ type Config struct {
1717

1818
// Environment represents a single Elasticsearch cluster.
1919
type Environment struct {
20-
URL string `json:"url"`
20+
URL string `json:"url"`
21+
Username string `json:"username,omitempty"`
22+
Password string `json:"password,omitempty"`
2123
}
2224

2325
// configPath returns the path to the config file, respecting XDG_CONFIG_HOME.
@@ -66,9 +68,13 @@ func Save(cfg *Config) error {
6668
}
6769
data = append(data, '\n')
6870

69-
if err := os.WriteFile(path, data, 0o644); err != nil {
71+
if err := os.WriteFile(path, data, 0o600); err != nil {
7072
return fmt.Errorf("writing config: %w", err)
7173
}
74+
// Ensure restrictive permissions even if file already existed with wider perms.
75+
if err := os.Chmod(path, 0o600); err != nil {
76+
return fmt.Errorf("setting config permissions: %w", err)
77+
}
7278
return nil
7379
}
7480

internal/es/client.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ import (
1414
// Client is an Elasticsearch HTTP client.
1515
type Client struct {
1616
baseURL string
17+
username string
18+
password string
1719
httpClient *http.Client
1820
}
1921

2022
// NewClient creates a new Elasticsearch client.
21-
func NewClient(baseURL string) *Client {
23+
func NewClient(baseURL, username, password string) *Client {
2224
return &Client{
23-
baseURL: strings.TrimRight(baseURL, "/"),
25+
baseURL: strings.TrimRight(baseURL, "/"),
26+
username: username,
27+
password: password,
2428
httpClient: &http.Client{
2529
Timeout: 30 * time.Second,
2630
},
@@ -40,6 +44,10 @@ func (c *Client) request(method, path string, body io.Reader) ([]byte, error) {
4044
req.Header.Set("Content-Type", "application/json")
4145
}
4246

47+
if c.username != "" {
48+
req.SetBasicAuth(c.username, c.password)
49+
}
50+
4351
resp, err := c.httpClient.Do(req)
4452
if err != nil {
4553
return nil, fmt.Errorf("request failed: %w", err)

0 commit comments

Comments
 (0)