diff --git a/README.md b/README.md index 01f44cf..802747a 100644 --- a/README.md +++ b/README.md @@ -201,21 +201,31 @@ Run `wishlist --help` to see all the options. ### SRV records -Wishlist can also find nodes from DNS SRV records, on one or more domains. +Wishlist can also find nodes from DNS `SRV` records, on one or more domains. Run `wishlist --srv.domain {your domain}` to get started. You can repeat the flag for multiple domains. -By default, Wishlist will set the name of the endpoint to the SRV target. -You can, however, customize that with a TXT record in the following format: +By default, Wishlist will set the name of the endpoint to the `SRV` target. +You can, however, customize that with a `TXT` record in the following format: ``` wishlist.name full.address:22=thename ``` -So, in this case, a SRV record pointing to `full.address` on port `22` will get +So, in this case, a `SRV` record pointing to `full.address` on port `22` will get the name `thename`. +Run `wishlist --help` to see all the options. + +### Hints + +You can use the `hints` key in the YAML configuration file to hint settings into +discovered endpoints. + +Check the [example configuration file](/_example/config.yaml) to learn +what options are available. + ### Using the binary ```sh diff --git a/_example/config.yaml b/_example/config.yaml index 4b94d1d..373a9fe 100644 --- a/_example/config.yaml +++ b/_example/config.yaml @@ -67,6 +67,68 @@ endpoints: - LANG - SOME_ENV +# Hints can be used to hint settings into discovered endpoints. +# +# You can use it to change the user, port, set remote commands, etc. +# +# A host can match multiple hints, in which all matched hints will be applied, +# the last one having the priority. +hints: + - # + # Glob to be used to match the discovered names. + match: "*.local" + + # SSH port to use. + port: 23234 + + # URL to be printed in the list. + link: + name: Optional link name + url: https://github.com/charmbracelet/wishlist + + # Descripton of the item. + description: "A description of this endpoint.\nCan have multiple lines." + + # User to use to connect. + # Defaults to the current remote user. + user: notme + + # Command to run against the remote address. + # Defaults to asking for a shell. + remote_command: uptime -a + + # Whether to forward the SSH agent. + # Will cause the connection to fail if no agent is available. + forward_agent: true # forwards the ssh agent + + # IdentityFiles to try to use to authorize. + # Only used in local mode. + identity_files: + - ~/.ssh/id_rsa + - ~/.ssh/id_ed25519 + + # Requests a TTY. + # Defaults to true if remote_command is empty. + request_tty: true + + # Connection timeout. + connect_timeout: 10s + + # Set environment variables into the connection. + # Analogous to SSH's SetEnv. + set_env: + - FOO=bar + - BAR=baz + + # Environments from the environment that match these keys will also be set + # into the connection. + # Analogous to SSH's SendEnv. + # Defaults to ["LC_*", "LANG"]. + send_env: + - LC_* + - LANG + - SOME_ENV + # Users to allow access to the list users: - # User login diff --git a/cmd/wishlist/main.go b/cmd/wishlist/main.go index 363b13e..413cb31 100644 --- a/cmd/wishlist/main.go +++ b/cmd/wishlist/main.go @@ -3,8 +3,10 @@ package main import ( "errors" "fmt" + "net" "os" "path/filepath" + "reflect" "runtime/debug" "sort" "strings" @@ -21,6 +23,7 @@ import ( "github.com/charmbracelet/wishlist/srv" "github.com/charmbracelet/wishlist/sshconfig" "github.com/charmbracelet/wishlist/zeroconf" + "github.com/gobwas/glob" "github.com/hashicorp/go-multierror" mcobra "github.com/muesli/mango-cobra" "github.com/muesli/roff" @@ -217,6 +220,52 @@ func userConfigPaths() []string { return append(paths, "/etc/ssh/ssh_config") } +func applyHints(seed []*wishlist.Endpoint, hints []wishlist.EndpointHint) []*wishlist.Endpoint { + for _, hint := range hints { + glob, err := glob.Compile(hint.Match) + if err != nil { + log.Error("invalid hint match", "match", hint.Match, "error", err) + continue + } + for i, end := range seed { + if !glob.Match(end.Name) { + continue + } + if hint.Port != "" { + host, _, _ := net.SplitHostPort(end.Address) + end.Address = net.JoinHostPort(host, hint.Port) + } + if s := hint.User; s != "" { + end.User = s + } + if s := hint.ForwardAgent; s != nil { + end.ForwardAgent = *s + } + if s := hint.RequestTTY; s != nil { + end.RequestTTY = *s + } + if s := hint.RemoteCommand; s != "" { + end.RemoteCommand = s + } + if s := hint.Desc; s != "" { + end.Desc = s + } + if s := hint.Link; !reflect.DeepEqual(s, wishlist.Link{}) { + end.Link = s + } + end.SendEnv = append(end.SendEnv, hint.SendEnv...) + end.SetEnv = append(end.SetEnv, hint.SetEnv...) + end.PreferredAuthentications = append(end.PreferredAuthentications, hint.PreferredAuthentications...) + end.IdentityFiles = append(end.IdentityFiles, hint.IdentityFiles...) + if s := hint.Timeout; s != 0 { + end.Timeout = s + } + seed[i] = end + } + } + return seed +} + func getConfig(configFile string, seed []*wishlist.Endpoint) (wishlist.Config, string, error) { var allErrs error for _, path := range append([]string{configFile}, userConfigPaths()...) { @@ -283,7 +332,7 @@ func getYAMLConfig(path string, seed []*wishlist.Endpoint) (wishlist.Config, err return config, fmt.Errorf("failed to parse config: %w", err) } - config.Endpoints = append(config.Endpoints, seed...) + config.Endpoints = append(config.Endpoints, applyHints(seed, config.Hints)...) return config, nil } diff --git a/cmd/wishlist/main_test.go b/cmd/wishlist/main_test.go index 4b92e3a..8a1f455 100644 --- a/cmd/wishlist/main_test.go +++ b/cmd/wishlist/main_test.go @@ -156,3 +156,64 @@ func TestUserConfigPaths(t *testing.T) { }, paths) }) } + +func TestApplyHints(t *testing.T) { + boolPtr := func(b bool) *bool { + return &b + } + result := applyHints([]*wishlist.Endpoint{ + { + Name: "foo.bar.local", + Address: "foo.bar.local:22", + }, + }, []wishlist.EndpointHint{ + { + Match: "match nothing", + User: "nope", + }, + { + Match: "*.local", + Port: "2345", + User: "carlos", + ForwardAgent: boolPtr(true), + RequestTTY: boolPtr(true), + RemoteCommand: "tmux a", + Desc: "The descriptions", + Link: wishlist.Link{ + Name: "foo.bar", + URL: "https://github.com/charmbracelet/wishlist", + }, + SendEnv: []string{"FOO_*"}, + SetEnv: []string{"FOO_TEST=bar"}, + PreferredAuthentications: []string{"publickey"}, + IdentityFiles: []string{"~/.ssh/charm_id_ed25519"}, + Timeout: time.Minute, + }, + { + Match: "invalid.*******$$$\a\a\a", + }, + { + Match: "foo.*.local", + Port: "22234", + }, + }) + require.Len(t, result, 1) + require.Equal(t, wishlist.Endpoint{ + Name: "foo.bar.local", + Address: "foo.bar.local:22234", + User: "carlos", + ForwardAgent: true, + RequestTTY: true, + RemoteCommand: "tmux a", + Desc: "The descriptions", + Link: wishlist.Link{ + Name: "foo.bar", + URL: "https://github.com/charmbracelet/wishlist", + }, + SendEnv: []string{"FOO_*"}, + SetEnv: []string{"FOO_TEST=bar"}, + PreferredAuthentications: []string{"publickey"}, + IdentityFiles: []string{"~/.ssh/charm_id_ed25519"}, + Timeout: time.Minute, + }, *result[0]) +} diff --git a/config.go b/config.go index 207ba77..9098344 100644 --- a/config.go +++ b/config.go @@ -54,6 +54,24 @@ type Endpoint struct { Middlewares []wish.Middleware `yaml:"-"` // wish middlewares you can use in the factory method. } +// EndpointHint can be used to match a discovered endpoint (through zeroconf +// for example) and set additional options into it. +type EndpointHint struct { + Match string `yaml:"match"` + Port string `yaml:"port"` + User string `yaml:"user"` + ForwardAgent *bool `yaml:"forward_agent"` + RequestTTY *bool `yaml:"request_tty"` + RemoteCommand string `yaml:"remote_command"` + Desc string `yaml:"description"` + Link Link `yaml:"link"` + SendEnv []string `yaml:"send_env"` + SetEnv []string `yaml:"set_env"` + PreferredAuthentications []string `yaml:"preferred_authentications"` + IdentityFiles []string `yaml:"identity_files"` + Timeout time.Duration `yaml:"connect_timeout"` +} + // Authentications returns either the client preferred authentications or the // default publickey,keyboard-interactive func (e Endpoint) Authentications() []string { @@ -128,6 +146,7 @@ type Config struct { Listen string `yaml:"listen"` // Address to listen on. Port int64 `yaml:"port"` // Port to start the first server on. Endpoints []*Endpoint `yaml:"endpoints"` // Endpoints to list. + Hints []EndpointHint `yaml:"hints"` // Endpoints hints to apply to discovered hosts. Factory func(Endpoint) (*ssh.Server, error) `yaml:"-"` // Factory used to create the SSH server for the given endpoint. Users []User `yaml:"users"` // Users allowed to access the list. Metrics Metrics `yaml:"metrics"` // Metrics configuration.