diff --git a/.golangci.yml b/.golangci.yml index e0b8bc07e..79bb1a456 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,8 +4,6 @@ run: - ".*_test.go" skip-dirs: - app - # - cmd - - config - ui/containers - ui/dialogs - ui/images diff --git a/config/add.go b/config/add.go index 84de083bd..09034360a 100644 --- a/config/add.go +++ b/config/add.go @@ -12,50 +12,59 @@ import ( "github.com/rs/zerolog/log" ) -// Add adds new service connection +// Add adds new service connection. func (c *Config) Add(name string, uri string, identity string) error { log.Debug().Msgf("config: adding new service %s %s %s", name, uri, identity) + newService, err := validateNewService(name, uri, identity) if err != nil { return err } + if err := c.add(name, newService); err != nil { return err } + if err := c.Write(); err != nil { return err } + return c.reload() } func (c *Config) add(name string, newService Service) error { c.mu.Lock() defer c.mu.Unlock() + for serviceName := range c.Services { if serviceName == name { - return fmt.Errorf("duplicated service name") + return ErrDuplicatedServiceName } } c.Services[name] = newService + return nil } // most of codes are from: -// https://github.com/containers/podman/blob/main/cmd/podman/system/connection/add.go -func validateNewService(name string, dest string, identity string) (Service, error) { +// https://github.com/containers/podman/blob/main/cmd/podman/system/connection/add.go. +func validateNewService(name string, dest string, identity string) (Service, error) { //nolint:gocognit,cyclop var ( service Service serviceIdentity string ) + if name == "" { - return service, fmt.Errorf("empty service name %q", name) + return service, ErrEmptyServiceName } + if dest == "" { - return service, fmt.Errorf("empty URI %q", dest) + return service, ErrEmptyURIDestination } + if match, err := regexp.Match("^[A-Za-z][A-Za-z0-9+.-]*://", []byte(dest)); err != nil { - return service, fmt.Errorf("%v invalid destition", err) + return service, fmt.Errorf("%w invalid destition", err) } else if !match { dest = "ssh://" + dest } @@ -64,6 +73,7 @@ func validateNewService(name string, dest string, identity string) (Service, err if err != nil { return service, err } + switch uri.Scheme { case "ssh": if uri.User.Username() == "" { @@ -71,16 +81,20 @@ func validateNewService(name string, dest string, identity string) (Service, err return service, err } } + serviceIdentity, err = utils.ResolveHomeDir(identity) if err != nil { return service, err } + if identity == "" { - return service, fmt.Errorf("%q empty identity field for SSH connection", identity) + return service, ErrEmptySSHIdentity } + if uri.Port() == "" { uri.Host = net.JoinHostPort(uri.Hostname(), "22") } + if uri.Path == "" || uri.Path == "/" { if uri.Path, err = getUDS(uri, serviceIdentity); err != nil { return service, err @@ -88,9 +102,11 @@ func validateNewService(name string, dest string, identity string) (Service, err } case "unix": if identity != "" { - return service, fmt.Errorf("identity option not supported for unix scheme") + return service, fmt.Errorf("%w identity", ErrInvalidUnixSchemaOption) } + info, err := os.Stat(uri.Path) + switch { case errors.Is(err, os.ErrNotExist): log.Warn().Msgf("config: %q does not exists", uri.Path) @@ -99,15 +115,14 @@ func validateNewService(name string, dest string, identity string) (Service, err case err != nil: return service, err case info.Mode()&os.ModeSocket == 0: - return service, fmt.Errorf("%q exists and is not a unix domain socket", uri.Path) + return service, fmt.Errorf("%w %q", ErrFileNotUnixSocket, uri.Path) } - case "tcp": if identity != "" { - return service, fmt.Errorf("identity option not supported for tcp scheme") + return service, fmt.Errorf("%w identity", ErrInvalidTCPSchemaOption) } default: - return service, fmt.Errorf("%q invalid schema name", uri.Scheme) + return service, fmt.Errorf("%w %q", ErrInvalidURISchemaName, uri.Scheme) } service.Identity = serviceIdentity diff --git a/config/config.go b/config/config.go index 6eb5e33c9..d994bc363 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "os" "sort" "sync" @@ -13,18 +14,30 @@ const ( // _configPath is the path to the podman-tui/podman-tui.conf // inside a given config directory. _configPath = "podman-tui/podman-tui.conf" - // UserAppConfig holds the user podman-tui config path + // UserAppConfig holds the user podman-tui config path. UserAppConfig = ".config/" + _configPath ) -// Config contains configuration options for container tools +var ( + ErrRemotePodmanUDSReport = errors.New("remote podman failed to report its UDS socket") + ErrInvalidURISchemaName = errors.New("invalid schema name") + ErrInvalidTCPSchemaOption = errors.New("invalid option for tcp") + ErrInvalidUnixSchemaOption = errors.New("invalid option for unix") + ErrFileNotUnixSocket = errors.New("not a unix domain socket") + ErrEmptySSHIdentity = errors.New("empty identity field for SSH connection") + ErrEmptyURIDestination = errors.New("empty URI destination") + ErrEmptyServiceName = errors.New("empty service name") + ErrDuplicatedServiceName = errors.New("duplicated service name") +) + +// Config contains configuration options for container tools. type Config struct { mu sync.Mutex // Services specify the service destination connections Services map[string]Service `toml:"services,omitempty"` } -// Service represents remote service destination +// Service represents remote service destination. type Service struct { // URI, required. Example: ssh://root@example.com:22/run/podman/podman.sock URI string `toml:"uri"` @@ -36,9 +49,10 @@ type Service struct { Default bool `toml:"default,omitempty"` } -// NewConfig returns new config +// NewConfig returns new config. func NewConfig() (*Config, error) { log.Debug().Msgf("config: new") + path, err := configPath() if err != nil { return nil, err @@ -54,9 +68,8 @@ func NewConfig() (*Config, error) { return nil, err } } - if err := newConfig.addLocalHostIfEmptyConfig(); err != nil { - return nil, err - } + + newConfig.addLocalHostIfEmptyConfig() defaultConn := newConfig.getDefault() if defaultConn.URI != "" { @@ -66,28 +79,25 @@ func NewConfig() (*Config, error) { return newConfig, nil } -func (c *Config) addLocalHostIfEmptyConfig() error { +func (c *Config) addLocalHostIfEmptyConfig() { if len(c.Services) > 0 { - return nil - } - localSocket, err := localNodeUnixSocket() - if err != nil { - return err + return } c.Services = make(map[string]Service) c.Services["localhost"] = Service{ - URI: localSocket, + URI: localNodeUnixSocket(), Default: true, } - return nil } -// ServicesConnections returns list of available connections +// ServicesConnections returns list of available connections. func (c *Config) ServicesConnections() []registry.Connection { - var conn []registry.Connection + conn := make([]registry.Connection, 0) + c.mu.Lock() defer c.mu.Unlock() + for name, service := range c.Services { conn = append(conn, registry.Connection{ Name: name, @@ -96,7 +106,9 @@ func (c *Config) ServicesConnections() []registry.Connection { Default: service.Default, }) } + sort.Sort(connectionListSortedName{conn}) + return conn } diff --git a/config/default.go b/config/default.go index 0b7e4684b..ed92743a0 100644 --- a/config/default.go +++ b/config/default.go @@ -8,6 +8,7 @@ import ( // SetDefaultService sets default service name. func (c *Config) SetDefaultService(name string) error { log.Debug().Msgf("config: set %s as default service", name) + if err := c.setDef(name); err != nil { return err } @@ -15,26 +16,32 @@ func (c *Config) SetDefaultService(name string) error { if err := c.Write(); err != nil { return err } + return c.reload() } func (c *Config) setDef(name string) error { c.mu.Lock() defer c.mu.Unlock() + for key := range c.Services { dest := c.Services[key] dest.Default = false + if key == name { dest.Default = true } + c.Services[key] = dest } + return nil } func (c *Config) getDefault() registry.Connection { c.mu.Lock() defer c.mu.Unlock() + for name, service := range c.Services { if service.Default { return registry.Connection{ @@ -44,5 +51,6 @@ func (c *Config) getDefault() registry.Connection { } } } + return registry.Connection{} } diff --git a/config/read.go b/config/read.go index 4b7522db9..9dc4529db 100644 --- a/config/read.go +++ b/config/read.go @@ -9,27 +9,34 @@ import ( func (c *Config) readConfigFromFile(path string) error { log.Debug().Msgf("config: reading configuration file %q", path) + c.mu.Lock() defer c.mu.Unlock() + meta, err := toml.DecodeFile(path, c) if err != nil { - return fmt.Errorf("config: %v decode configuration %q", err, path) + return fmt.Errorf("config: %w decode configuration %q", err, path) } + keys := meta.Undecoded() if len(keys) > 0 { log.Debug().Msgf("config: failed to decode the keys %q from %q.", keys, path) } + return nil } func (c *Config) reload() error { log.Debug().Msgf("config: reload configuration") + path, err := configPath() if err != nil { return err } + if err := c.readConfigFromFile(path); err != nil { return err } + return nil } diff --git a/config/remove.go b/config/remove.go index 4266ca175..fef9028b5 100644 --- a/config/remove.go +++ b/config/remove.go @@ -2,23 +2,26 @@ package config import "github.com/rs/zerolog/log" -// Remove removes a service from config +// Remove removes a service from config. func (c *Config) Remove(name string) error { log.Debug().Msgf("config: remove service %q", name) + c.remove(name) + if err := c.Write(); err != nil { return err } + return c.reload() } func (c *Config) remove(name string) { c.mu.Lock() defer c.mu.Unlock() + for serviceName := range c.Services { if serviceName == name { delete(c.Services, name) } } - } diff --git a/config/utils.go b/config/utils.go index 78dbf76eb..275f97e3e 100644 --- a/config/utils.go +++ b/config/utils.go @@ -23,6 +23,7 @@ func configPath() (string, error) { if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" { return filepath.Join(configHome, _configPath), nil } + home, err := utils.UserHomeDir() if err != nil { return "", err @@ -31,10 +32,13 @@ func configPath() (string, error) { return filepath.Join(home, UserAppConfig), nil } -// localNodeUnixSocket return local node unix socket file -func localNodeUnixSocket() (string, error) { - var sockDir string - var socket string +// localNodeUnixSocket return local node unix socket file. +func localNodeUnixSocket() string { + var ( + sockDir string + socket string + ) + currentUser := os.Getenv("USER") uid := os.Getenv("UID") @@ -45,7 +49,8 @@ func localNodeUnixSocket() (string, error) { } socket = "unix:" + sockDir + "/podman/podman.sock" - return socket, nil + + return socket } func getUserInfo(uri *url.URL) (*url.Userinfo, error) { @@ -53,15 +58,16 @@ func getUserInfo(uri *url.URL) (*url.Userinfo, error) { usr *user.User err error ) + if u, found := os.LookupEnv("_CONTAINERS_ROOTLESS_UID"); found { usr, err = user.LookupId(u) if err != nil { - return nil, fmt.Errorf("%v failed to lookup rootless user", err) + return nil, fmt.Errorf("%w failed to lookup rootless user", err) } } else { usr, err = user.Current() if err != nil { - return nil, fmt.Errorf("%v failed to obtain current user", err) + return nil, fmt.Errorf("%w failed to obtain current user", err) } } @@ -69,25 +75,29 @@ func getUserInfo(uri *url.URL) (*url.Userinfo, error) { if set { return url.UserPassword(usr.Username, pw), nil } + return url.User(usr.Username), nil } -// most of the codes are from https://github.com/containers/podman/blob/main/cmd/podman/system/connection/add.go +// most of the codes are from https://github.com/containers/podman/blob/main/cmd/podman/system/connection/add.go. func getUDS(uri *url.URL, iden string) (string, error) { cfg, err := validateAndConfigure(uri, iden) if err != nil { - return "", fmt.Errorf("%v failed to validate", err) + return "", fmt.Errorf("%w failed to validate", err) } + dial, err := ssh.Dial("tcp", uri.Host, cfg) if err != nil { - return "", fmt.Errorf("%v failed to connect", err) + return "", fmt.Errorf("%w failed to connect", err) } + defer dial.Close() session, err := dial.NewSession() if err != nil { - return "", fmt.Errorf("%v failed to create new ssh session on %q", err, uri.Host) + return "", fmt.Errorf("%w failed to create new ssh session on %q", err, uri.Host) } + defer session.Close() // Override podman binary for testing etc @@ -95,6 +105,7 @@ func getUDS(uri *url.URL, iden string) (string, error) { if v, found := os.LookupEnv("PODMAN_BINARY"); found { podman = v } + infoJSON, err := execRemoteCommand(dial, podman+" info --format=json") if err != nil { return "", err @@ -102,43 +113,54 @@ func getUDS(uri *url.URL, iden string) (string, error) { var info define.Info if err := json.Unmarshal(infoJSON, &info); err != nil { - return "", fmt.Errorf("%v failed to parse 'podman info' results", err) + return "", fmt.Errorf("%w failed to parse 'podman info' results", err) } if info.Host.RemoteSocket == nil || len(info.Host.RemoteSocket.Path) == 0 { - return "", fmt.Errorf("remote podman %q failed to report its UDS socket", uri.Host) + return "", fmt.Errorf("%w %s", ErrRemotePodmanUDSReport, uri.Host) } + return info.Host.RemoteSocket.Path, nil } -// validateAndConfigure will take a ssh url and an identity key (rsa and the like) and ensure the information given is valid -// iden iden can be blank to mean no identity key -// once the function validates the information it creates and returns an ssh.ClientConfig -func validateAndConfigure(uri *url.URL, iden string) (*ssh.ClientConfig, error) { +// validateAndConfigure will take a ssh url and an identity key (rsa and the like) +// and ensure the information given is valid iden can be blank to mean no identity key +// once the function validates the information it creates and returns an ssh.ClientConfig. +func validateAndConfigure(uri *url.URL, iden string) (*ssh.ClientConfig, error) { //nolint:cyclop var signers []ssh.Signer + if iden != "" { // iden might be blank if coming from image scp or if no validation is needed value := iden passPhrase := "" + if v, found := os.LookupEnv("CONTAINER_PASSPHRASE"); found { passPhrase = v } + if passPhrase == "" { passPhrase = "_empty_pass_" } + s, err := cntssh.PublicKey(value, []byte(passPhrase)) if err != nil { - return nil, fmt.Errorf("%v failed to read identity %q, set 'CONTAINER_PASSPHRASE' variable if password is required", err, value) + infoText := "set 'CONTAINER_PASSPHRASE' variable if password is required" + + return nil, fmt.Errorf("%w failed to read identity %q, %s", err, value, infoText) } + signers = append(signers, s) log.Debug().Msgf("config: SSH Ident Key %q %s %s", value, ssh.FingerprintSHA256(s.PublicKey()), s.PublicKey().Type()) } - if sock, found := os.LookupEnv("SSH_AUTH_SOCK"); found { // validate ssh information, specifically the unix file socket used by the ssh agent. + + // validate ssh information, specifically the unix file socket used by the ssh agent. + if sock, found := os.LookupEnv("SSH_AUTH_SOCK"); found { log.Debug().Msgf("config: Found SSH_AUTH_SOCK %q, ssh-agent signer enabled", sock) c, err := net.Dial("unix", sock) if err != nil { return nil, err } + agentSigners, err := agent.NewClient(c).Signers() if err != nil { return nil, err @@ -150,21 +172,28 @@ func validateAndConfigure(uri *url.URL, iden string) (*ssh.ClientConfig, error) log.Debug().Msgf("config: SSH Agent Key %s %s", ssh.FingerprintSHA256(s.PublicKey()), s.PublicKey().Type()) } } - var authMethods []ssh.AuthMethod // now we validate and check for the authorization methods, most notaibly public key authorization + + // now we validate and check for the authorization methods, most notaibly public key authorization. + var authMethods []ssh.AuthMethod + if len(signers) > 0 { - var dedup = make(map[string]ssh.Signer) + dedup := make(map[string]ssh.Signer) + for _, s := range signers { fp := ssh.FingerprintSHA256(s.PublicKey()) if _, found := dedup[fp]; found { log.Debug().Msgf("config: Dedup SSH Key %s %s", ssh.FingerprintSHA256(s.PublicKey()), s.PublicKey().Type()) } + dedup[fp] = s } var uniq []ssh.Signer + for _, s := range dedup { uniq = append(uniq, s) } + authMethods = append(authMethods, ssh.PublicKeysCallback(func() ([]ssh.Signer, error) { return uniq, nil })) @@ -174,30 +203,38 @@ func validateAndConfigure(uri *url.URL, iden string) (*ssh.ClientConfig, error) if err != nil { return nil, err } + cfg := &ssh.ClientConfig{ User: uri.User.Username(), Auth: authMethods, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec Timeout: tick, } + return cfg, nil } // execRemoteCommand takes a ssh client connection and a command to run and executes the -// command on the specified client. The function returns the Stdout from the client or the Stderr +// command on the specified client. The function returns the Stdout from the client or the Stderr. func execRemoteCommand(dial *ssh.Client, run string) ([]byte, error) { sess, err := dial.NewSession() // new ssh client session if err != nil { return nil, err } + defer sess.Close() - var buffer bytes.Buffer - var bufferErr bytes.Buffer - sess.Stdout = &buffer // output from client funneled into buffer - sess.Stderr = &bufferErr // err form client funneled into buffer + var ( + buffer bytes.Buffer + bufferErr bytes.Buffer + ) + + sess.Stdout = &buffer // output from client funneled into buffer + sess.Stderr = &bufferErr // err form client funneled into buffer + if err := sess.Run(run); err != nil { // run the command on the ssh client - return nil, fmt.Errorf("%v %s", err, bufferErr.String()) + return nil, fmt.Errorf("%w %s", err, bufferErr.String()) } + return buffer.Bytes(), nil } diff --git a/config/write.go b/config/write.go index 7d9e88668..1a473ad9b 100644 --- a/config/write.go +++ b/config/write.go @@ -8,24 +8,32 @@ import ( "github.com/rs/zerolog/log" ) -// Write writes config +// Write writes config. func (c *Config) Write() error { var err error + c.mu.Lock() defer c.mu.Unlock() + path, err := configPath() if err != nil { return err } + log.Debug().Msgf("config: write configuration file %q", path) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { //nolint:gomnd return err } - configFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) + + configFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o640) //nolint:gomnd if err != nil { return err } + defer configFile.Close() + enc := toml.NewEncoder(configFile) + return enc.Encode(c) }