ask for pass if missing or use env pass, add token support (#60)

This commit is contained in:
T. von Dein
2026-07-01 10:58:39 +02:00
parent f5c8a23589
commit 6436bcfb22
6 changed files with 258 additions and 171 deletions

View File

@@ -113,6 +113,7 @@ Configure `esctl` with environment variables:
- `ES_URI`: elasticsearch uri - `ES_URI`: elasticsearch uri
- `ES_USER`: username - `ES_USER`: username
- `ES_PASS`: password - `ES_PASS`: password
- `ES_TOKEN`: API token, instead of user+password
Or create a config file such as this: Or create a config file such as this:
@@ -121,11 +122,10 @@ clusters:
foobar: foobar:
uri: https://es.foo.bar:9200/ uri: https://es.foo.bar:9200/
user: elastic user: elastic
pass: 123456 pass: ******
other: other:
uri: https://myes.foo:9200/ uri: https://myes.foo:9200/
user: elastic token: ******
pass: asdasdasd
``` ```
and specify it with `-c configfile`. You may also put clusters into a and specify it with `-c configfile`. You may also put clusters into a

View File

@@ -61,12 +61,6 @@ func ClusterStatus(conf *cfg.Config) *cli.Command {
Aliases: []string{"s"}, Aliases: []string{"s"},
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.BoolFlag{
Name: "all",
Usage: "show status of all clusters",
Destination: &conf.All,
Aliases: []string{"a"},
},
&cli.BoolFlag{ &cli.BoolFlag{
Name: "verbose", Name: "verbose",
Usage: "include verbose statistics", Usage: "include verbose statistics",

View File

@@ -17,26 +17,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package cfg package cfg
import ( import (
"context"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"strings"
"syscall"
"time"
"github.com/elastic/elastic-transport-go/v8/elastictransport" "github.com/elastic/elastic-transport-go/v8/elastictransport"
"github.com/elastic/go-elasticsearch/v9" "github.com/elastic/go-elasticsearch/v9"
"golang.org/x/term"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// used in general config struct // used in general config struct
type Cluster struct { type Cluster struct {
Uri, User, Pass string Name, Uri, User, Pass, Token string
client *elasticsearch.TypedClient client *elasticsearch.TypedClient
Default bool Default, DebugHTTP bool
} }
// used just for writing back to the config file // used just for writing back to the config file
type ClusterConfig struct { type ClusterConfig struct {
Uri, User, Pass string Uri, User, Pass, Token string
Default bool Default bool
} }
@@ -45,17 +51,97 @@ type WriteConfig struct {
Clusters map[string]*ClusterConfig Clusters map[string]*ClusterConfig
} }
func (cluster *Cluster) SetClient(client *elasticsearch.TypedClient) {
cluster.client = client
}
func (cluster *Cluster) getTransport() elastictransport.Option {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
if cluster.DebugHTTP {
return elastictransport.WithTransport(
&DebugTransport{Transport: transport},
)
}
return elastictransport.WithTransport(transport)
}
func (cluster *Cluster) getDefaultOptions() []elasticsearch.Option {
// These headers are not needed with ES 9, but with ES 8, we set
// them here so every API call uses it. The only exception being
// the api repl, which does it on its own.
headers := http.Header{}
headers.Add("content-type", "application/json")
headers.Add("Accept", "application/json")
return []elasticsearch.Option{
elasticsearch.WithAddresses(cluster.Uri),
elasticsearch.WithTransportOptions(
cluster.getTransport(),
elastictransport.WithHeader(headers),
),
}
}
// return the go-elasticsearch client object but before doing that,
// check if we need to tune auth
func (cluster *Cluster) ES() *elasticsearch.TypedClient { func (cluster *Cluster) ES() *elasticsearch.TypedClient {
if cluster.client == nil { if cluster.client == nil {
fmt.Println("no current cluster, use 'esctl cluster switch <name>' to set one") fmt.Println("no current cluster, use 'esctl cluster switch <name>' to set one")
os.Exit(1) os.Exit(1)
} }
if err := cluster.CheckAuth(); err != nil {
fmt.Printf("Error: %s", err)
os.Exit(1)
}
return cluster.client return cluster.client
} }
func (cluster *Cluster) SetClient(client *elasticsearch.TypedClient) { // add authentication to es client, if not yet done
cluster.client = client func (cluster *Cluster) CheckAuth() error {
if cluster.Pass == "" && cluster.User != "" && cluster.Token == "" && cluster.Default {
// no token - user is set, but no password.
// check if the env var is set
pass := os.Getenv("ES_PASS")
if pass != "" {
cluster.Pass = pass
} else {
// k, try interactively
fmt.Printf("Enter password for elasticsearch user %s@%s: ", cluster.User, cluster.Name)
pass, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return err
}
passwd := strings.TrimSpace(string(pass))
if passwd == "" {
return errors.New("password empty")
}
cluster.Pass = string(pass)
fmt.Println()
}
opts := cluster.getDefaultOptions()
opts = append(opts, elasticsearch.WithBasicAuth(cluster.User, cluster.Pass))
es, err := elasticsearch.NewTyped(opts...)
if err != nil {
return fmt.Errorf("failed to setup elasticsearch connection: %w", err)
}
cluster.SetClient(es)
}
return nil
} }
// set Default=true for the given cluster in the config (if exists) // set Default=true for the given cluster in the config (if exists)
@@ -72,6 +158,7 @@ func (conf *Config) SwitchCluster(name string) error {
Uri: cluster.Uri, Uri: cluster.Uri,
User: cluster.User, User: cluster.User,
Pass: cluster.Pass, Pass: cluster.Pass,
Token: cluster.Token,
Default: false, Default: false,
} }
@@ -97,24 +184,62 @@ func (conf *Config) SwitchCluster(name string) error {
return nil return nil
} }
func (conf *Config) SetupES() error { // We do NOT use go-elasticsearch to check for cluster reachability,
// These headers are not needed with ES 9, but with ES 8, we set // because at this stage, auth may not have been configured. So
// them here so every API call uses it. The only exception being // instead we just connect to the cluster using plan net/http, ignore
// the api repl, which does it on its own. // HTTP response status and return true if we could just reach ith
headers := http.Header{} func (cluster *Cluster) IsReachable() (bool, error) {
headers.Add("content-type", "application/json") ctx, cancel := context.WithTimeout(
headers.Add("Accept", "application/json") context.Background(),
time.Duration(500)*time.Millisecond)
defer cancel()
for _, cluster := range conf.Clusters { req, err := http.NewRequestWithContext(
es, err := elasticsearch.NewTyped( ctx,
elasticsearch.WithAddresses(cluster.Uri), "GET",
elasticsearch.WithBasicAuth(cluster.User, cluster.Pass), cluster.Uri,
elasticsearch.WithTransportOptions( nil,
conf.getTransport(),
elastictransport.WithHeader(headers),
),
) )
if err != nil {
return false, err
}
client := &http.Client{Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}}
resp, err := client.Do(req)
if err != nil {
return false, err
}
if resp != nil {
// at this stage we do not care if the elasticsearch cluster
// accepts our request or if it's misconfigured in some way
return true, nil
}
return false, nil
}
func (conf *Config) SetupES() error {
for name, cluster := range conf.Clusters {
cluster.Name = name
cluster.DebugHTTP = conf.DebugHTTP
opts := cluster.getDefaultOptions()
switch {
case cluster.Pass != "" && cluster.User != "":
opts = append(opts, elasticsearch.WithBasicAuth(cluster.User, cluster.Pass))
case cluster.Token != "":
opts = append(opts, elasticsearch.WithAPIKey(cluster.Token))
}
es, err := elasticsearch.NewTyped(opts...)
if err != nil { if err != nil {
return fmt.Errorf("failed to setup elasticsearch connection: %w", err) return fmt.Errorf("failed to setup elasticsearch connection: %w", err)
} }

View File

@@ -28,7 +28,7 @@ import (
) )
const ( const (
Version string = `v0.0.23` Version string = `v0.0.24`
) )
var ( var (
@@ -216,15 +216,14 @@ func (conf *Config) LoadEnv() error {
Uri: os.Getenv("ES_URI"), Uri: os.Getenv("ES_URI"),
User: os.Getenv("ES_USER"), User: os.Getenv("ES_USER"),
Pass: os.Getenv("ES_PASS"), Pass: os.Getenv("ES_PASS"),
Token: os.Getenv("ES_TOKEN"),
} }
switch { switch {
case cluster.Uri == "": case cluster.Uri == "":
return errors.New("ES_URI unset") return errors.New("ES_URI unset")
case cluster.User == "": case cluster.User == "" || cluster.Token == "":
return errors.New("ES_USER unset") return errors.New("ES_USER and ES_TOKEN unset")
case cluster.Pass == "":
return errors.New("ES_PASS unset")
} }
conf.Clusters["default"] = &cluster conf.Clusters["default"] = &cluster

View File

@@ -18,13 +18,10 @@ package cfg
import ( import (
"bytes" "bytes"
"crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/elastic/elastic-transport-go/v8/elastictransport"
) )
// used to print uri, path and body of a request made by the go-client // used to print uri, path and body of a request made by the go-client
@@ -62,17 +59,3 @@ func (t *DebugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.Transport.RoundTrip(req) return t.Transport.RoundTrip(req)
} }
func (conf *Config) getTransport() elastictransport.Option {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
if conf.DebugHTTP {
return elastictransport.WithTransport(
&DebugTransport{Transport: transport},
)
}
return elastictransport.WithTransport(transport)
}

View File

@@ -17,7 +17,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package es package es
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
@@ -38,32 +37,33 @@ import (
type ClusterIndices map[string]map[string]*types.IndicesRecord type ClusterIndices map[string]map[string]*types.IndicesRecord
func ClusterList(conf *cfg.Config) error { func ClusterList(conf *cfg.Config) error {
table := printer.NewTable(conf, 4, len(conf.Clusters)) table := printer.NewTable(conf, 5, len(conf.Clusters))
table.Addheaders("cluster", "uri", "reachable", "current") table.Addheaders("cluster", "uri", "reachable", "current", "error")
idx := 0 idx := 0
for name, cluster := range conf.Clusters { for name, cluster := range conf.Clusters {
reachable := "no" reachable := "no"
current := "no" current := "no"
errmsg := ""
_, err := cluster.ES().Cluster.Health(). online, err := cluster.IsReachable()
Do(context.Background())
if err == nil { if online {
reachable = printer.Colorize(conf, "green", "reachable") reachable = printer.Colorize(conf, "green", "reachable")
} }
if cluster.Default { if cluster.Default {
current = printer.Colorize(conf, "green", "yes") current = printer.Colorize(conf, "green", "yes")
if err != nil { if !online {
reachable = printer.Colorize(conf, "red", "no") reachable = printer.Colorize(conf, "red", "no")
errmsg = err.Error()
} }
} }
table.Entries[idx] = []string{name, cluster.Uri, reachable, current} table.Entries[idx] = []string{name, cluster.Uri, reachable, current, errmsg}
idx++ idx++
} }
@@ -79,25 +79,12 @@ func ClusterList(conf *cfg.Config) error {
// We're using goroutines here to parallelize API requests, since we // We're using goroutines here to parallelize API requests, since we
// have to do 3 of'em for each cluster. This speeds things up. // have to do 3 of'em for each cluster. This speeds things up.
func ClusterStatus(conf *cfg.Config) error { func ClusterStatus(conf *cfg.Config) error {
clusters := []string{}
gocount := 5 gocount := 5
if conf.Verbose { if conf.Verbose {
gocount++ gocount++
} }
if conf.All {
for key := range conf.Clusters {
clusters = append(clusters, key)
}
} else {
clusters = []string{"default"}
}
for _, cluster := range clusters {
es := conf.DefaultCluster.ES() es := conf.DefaultCluster.ES()
if cluster != "default" {
es = conf.Clusters[cluster].ES()
}
responses := make(chan apiResponse, gocount) responses := make(chan apiResponse, gocount)
wg := &sync.WaitGroup{} wg := &sync.WaitGroup{}
@@ -176,7 +163,7 @@ func ClusterStatus(conf *cfg.Config) error {
} }
table := printer.NewTable(conf, 2, 7) table := printer.NewTable(conf, 2, 7)
table.Addheaders(cluster, "status") table.Addheaders(conf.DefaultCluster.Name, "status")
table.Entries = [][]string{ table.Entries = [][]string{
{"Cluster Name", clusterhealth.ClusterName}, {"Cluster Name", clusterhealth.ClusterName},
@@ -207,7 +194,6 @@ func ClusterStatus(conf *cfg.Config) error {
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
return err return err
} }
}
return nil return nil
} }