diff --git a/cmd/root.go b/cmd/root.go index cb9406e..f626974 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -117,7 +117,7 @@ func runRestServer() { func runDockerEventListener(ctx context.Context) { var err error - docker.Default, err = docker.New(ctx) + docker.Default, err = docker.New(ctx, config.Default.Services, config.Default.DefaultServer) if err != nil { logger.Sugar.Fatalf("cannot connect to docker daemon: %s", err) } diff --git a/examples/config.yml b/examples/config.yml index bced228..1f8e1aa 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -23,10 +23,31 @@ docker: version: ~ # Default server to use if `sshpoke.server` is not specified in the target container labels. default_server: mine +# Services configuration. These values will have higher priority than the container labels. +services: + # Container ID, one of container names or matcher regex. This value will be used to determine which container will + # receive the labels from 'params' section. Add / at the start and the end of the value to use it as regex. + # Examples: + # - name: a77eaa474cc634f0e4da4d4b72ebf71c03d1ae69329a07e42b830375e247e613 # Match by container ID (exact matcher). + # - name: my-container # Match by container name (exact matcher). + # - name: /my-container/ # Match by container name via regular expression (e.g. 'my-container_1' will also match). + - name: /service-web/ + # Same params as container labels but without 'sshpoke.' prefix. + params: + # Enable sshpoke for service. Replaces 'sshpoke.enable' in container labels. + enable: true + # Specifies container network. Replaces 'sshpoke.network' in container labels. + network: service_default + # Specifies server which will be used. Replaces 'sshpoke.server' in container labels. + server: ssh-demo-sish + # Specifies container port to be shared. Replaces 'sshpoke.port' in container labels. + port: 80 + # Specifies remote host to be used. Replaces 'sshpoke.remote_host' in container labels. + remote_host: remotehost # Servers configuration. servers: # Server name. - - name: mine + - name: ssh-demo-sish # Server driver. Each driver has its own set of params. Supported drivers: ssh, plugin, null. driver: ssh params: diff --git a/internal/config/model.go b/internal/config/model.go index 67fe599..bfbd2a7 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -4,6 +4,7 @@ import ( "net/http" "path/filepath" + "github.com/Neur0toxine/sshpoke/pkg/smarttypes" "github.com/docker/docker/client" "github.com/docker/go-connections/tlsconfig" ) @@ -15,9 +16,23 @@ type Config struct { API API `mapstructure:"api" json:"api"` Docker DockerConfig `mapstructure:"docker" json:"docker"` DefaultServer string `mapstructure:"default_server" json:"default_server"` + Services []Service `mapstructure:"services" json:"services,omitempty"` Servers []Server `mapstructure:"servers" json:"servers"` } +type Service struct { + Name smarttypes.MatchableString `mapstructure:"name" json:"name"` + Params ServiceLabels `mapstructure:"params" json:"params"` +} + +type ServiceLabels struct { + Enable smarttypes.BoolStr `mapstructure:"enable" json:"enable"` + Network string `mapstructure:"network" json:"network,omitempty"` + Server string `mapstructure:"server" json:"server,omitempty"` + Port string `mapstructure:"port" json:"port,omitempty"` + RemoteHost string `mapstructure:"remote_host" json:"remote_host,omitempty"` +} + type API struct { Rest WebAPI `mapstructure:"rest" json:"rest"` Plugin PluginAPI `mapstructure:"plugin" json:"plugin"` diff --git a/internal/docker/api.go b/internal/docker/api.go index 9eaea31..fabe9ff 100644 --- a/internal/docker/api.go +++ b/internal/docker/api.go @@ -8,6 +8,7 @@ import ( "github.com/Neur0toxine/sshpoke/internal/config" "github.com/Neur0toxine/sshpoke/internal/logger" "github.com/Neur0toxine/sshpoke/pkg/dto" + "github.com/Neur0toxine/sshpoke/pkg/smarttypes" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" @@ -16,21 +17,43 @@ import ( var Default *Docker type Docker struct { - cli *client.Client - ctx context.Context + cli *client.Client + ctx context.Context + services map[smarttypes.MatchableString]config.ServiceLabels + defaultServer string } -func New(ctx context.Context) (*Docker, error) { +func New(ctx context.Context, services []config.Service, defaultServer string) (*Docker, error) { cli, err := client.NewClientWithOpts(config.Default.Docker.Opts) if err != nil { return nil, err } + servicesMap := make(map[smarttypes.MatchableString]config.ServiceLabels) + for _, svc := range services { + servicesMap[svc.Name] = svc.Params + } return &Docker{ - cli: cli, - ctx: ctx, + cli: cli, + ctx: ctx, + services: servicesMap, + defaultServer: defaultServer, }, nil } +func (d *Docker) findServiceLabels(id string, names []string) *config.ServiceLabels { + if labels, ok := d.services[smarttypes.MatchableString(id)]; ok { + return &labels + } + for matcher, labels := range d.services { + for _, name := range names { + if matcher.Match(name) { + return &labels + } + } + } + return nil +} + func (d *Docker) Containers() map[string]dto.Container { items, err := d.cli.ContainerList(d.ctx, types.ContainerListOptions{ Filters: filters.NewArgs(filters.Arg("status", "running")), @@ -41,7 +64,7 @@ func (d *Docker) Containers() map[string]dto.Container { } containers := map[string]dto.Container{} for _, item := range items { - container, ok := dockerContainerToInternal(item) + container, ok := dockerContainerToInternal(item, d.findServiceLabels(item.ID, item.Names), d.defaultServer) if !ok { continue } @@ -58,7 +81,8 @@ func (d *Docker) GetContainer(id string, all bool) (dto.Container, bool) { if err != nil || len(container) != 1 { return dto.Container{}, false } - converted, ok := dockerContainerToInternal(container[0]) + converted, ok := dockerContainerToInternal( + container[0], d.findServiceLabels(container[0].ID, container[0].Names), d.defaultServer) if !ok { return dto.Container{}, false } @@ -92,7 +116,8 @@ func (d *Docker) Listen() (chan dto.Event, error) { "id", event.Actor.ID, "err", err) continue } - converted, ok := dockerContainerToInternal(container[0]) + converted, ok := dockerContainerToInternal(container[0], + d.findServiceLabels(container[0].ID, container[0].Names), d.defaultServer) if !ok { continue } diff --git a/internal/docker/convert.go b/internal/docker/convert.go index 1d9ddea..80c8a47 100644 --- a/internal/docker/convert.go +++ b/internal/docker/convert.go @@ -5,8 +5,10 @@ import ( "strconv" "strings" + "github.com/Neur0toxine/sshpoke/internal/config" "github.com/Neur0toxine/sshpoke/internal/logger" "github.com/Neur0toxine/sshpoke/pkg/dto" + "github.com/Neur0toxine/sshpoke/pkg/smarttypes" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/events" "github.com/docker/docker/api/types/network" @@ -14,40 +16,59 @@ import ( ) type labelsConfig struct { - Enable boolStr `mapstructure:"sshpoke.enable"` - Network string `mapstructure:"sshpoke.network"` - Server string `mapstructure:"sshpoke.server"` - Port string `mapstructure:"sshpoke.port"` - RemoteHost string `mapstructure:"sshpoke.remote_host"` + Enable smarttypes.BoolStr `mapstructure:"sshpoke.enable"` + Network string `mapstructure:"sshpoke.network"` + Server string `mapstructure:"sshpoke.server"` + Port string `mapstructure:"sshpoke.port"` + RemoteHost string `mapstructure:"sshpoke.remote_host"` } -type boolStr string - -func (b boolStr) Bool() bool { - return string(b) == "true" -} - -func actorEnabled(actor events.Actor) bool { +func actorEnabled(actor events.Actor) smarttypes.BoolStr { label, ok := actor.Attributes["sshpoke.enable"] if !ok { return false } - return boolStr(label).Bool() + return smarttypes.BoolFromStr(label) } -func dockerContainerToInternal(container types.Container) (result dto.Container, ok bool) { +func populateLabelsFromConfig(labels *labelsConfig, config *config.ServiceLabels) { + if labels.Enable != config.Enable { + labels.Enable = config.Enable + } + if labels.Server != config.Server { + labels.Server = config.Server + } + if labels.Network != config.Network { + labels.Network = config.Network + } + if labels.Port != config.Port { + labels.Port = config.Port + } + if labels.RemoteHost != config.RemoteHost { + labels.RemoteHost = config.RemoteHost + } +} + +func dockerContainerToInternal( + container types.Container, configLabels *config.ServiceLabels, defaultServer string) (result dto.Container, ok bool) { var labels labelsConfig if err := mapstructure.Decode(container.Labels, &labels); err != nil { logger.Sugar.Debugf("skipping container %s because configuration is invalid: %s", container.ID, err) return result, false } - if !labels.Enable.Bool() { + if configLabels != nil { + populateLabelsFromConfig(&labels, configLabels) + } + if !labels.Enable { logger.Sugar.Debugf("skipping container %s because sshpoke is not enabled for it", container.ID) return result, false } if labels.Server == "" { - logger.Sugar.Debugf("skipping container %s because 'sshpoke.server' is not defined", container.ID) - return result, false + if defaultServer == "" { + logger.Sugar.Debugf("skipping container %s because 'sshpoke.server' is not defined", container.ID) + return result, false + } + labels.Server = defaultServer } if container.NetworkSettings == nil || container.NetworkSettings.Networks == nil || diff --git a/internal/server/driver/ssh/driver.go b/internal/server/driver/ssh/driver.go index e277961..fe0f72c 100644 --- a/internal/server/driver/ssh/driver.go +++ b/internal/server/driver/ssh/driver.go @@ -21,6 +21,7 @@ import ( "github.com/Neur0toxine/sshpoke/pkg/dto" "github.com/Neur0toxine/sshpoke/pkg/proto/ssh" "github.com/Neur0toxine/sshpoke/pkg/proto/ssh/knownhosts" + "github.com/Neur0toxine/sshpoke/pkg/smarttypes" "go.uber.org/zap" ) @@ -145,8 +146,8 @@ func (d *SSH) buildHostKeyCallback() ssh.HostKeyCallback { return sshtun.FixedHostKeys(d.hostKeys) }() if d.params.Auth.Type == types.AuthTypeKey && d.params.Auth.Directory != "" && len(d.hostKeys) == 0 { - knownHostsPath := types.SmartPath(path.Join(string(d.params.Auth.Directory), KnownHostsFile)) - resolvedPath, err := knownHostsPath.Resolve(false) + knownHostsPath := smarttypes.Path(path.Join(string(d.params.Auth.Directory), KnownHostsFile)) + resolvedPath, err := knownHostsPath.File() if err != nil { return ssh.InsecureIgnoreHostKey() } @@ -237,7 +238,7 @@ func (d *SSH) populateFromSSHConfig() { if d.params.Auth.Directory == "" { return } - cfg, err := parseSSHConfig(types.SmartPath(path.Join(string(d.params.Auth.Directory), "config"))) + cfg, err := parseSSHConfig(smarttypes.Path(path.Join(string(d.params.Auth.Directory), "config"))) if err != nil { return } @@ -255,7 +256,7 @@ func (d *SSH) populateFromSSHConfig() { d.params.Auth.User = user } if keyfile, err := hostCfg.Get("IdentityFile"); err == nil && keyfile != "" { - resolvedKeyFile, err := types.SmartPath(keyfile).Resolve(false) + resolvedKeyFile, err := smarttypes.Path(keyfile).File() if err == nil { d.params.Auth.Type = types.AuthTypeKey d.params.Auth.Keyfile = resolvedKeyFile @@ -354,7 +355,7 @@ func (d *SSH) authenticator() ssh.AuthMethod { case types.AuthTypeKey: if d.params.Auth.Keyfile != "" { keyAuth, err := sshtun.AuthKeyFile( - types.SmartPath(path.Join(d.params.Auth.Directory.String(), d.params.Auth.Keyfile))) + smarttypes.Path(path.Join(d.params.Auth.Directory.String(), d.params.Auth.Keyfile))) if err != nil { return nil } diff --git a/internal/server/driver/ssh/sshconfig.go b/internal/server/driver/ssh/sshconfig.go index da08b30..d01ecbf 100644 --- a/internal/server/driver/ssh/sshconfig.go +++ b/internal/server/driver/ssh/sshconfig.go @@ -5,7 +5,7 @@ import ( "os" "strings" - "github.com/Neur0toxine/sshpoke/internal/server/driver/ssh/types" + "github.com/Neur0toxine/sshpoke/pkg/smarttypes" "github.com/kevinburke/ssh_config" ) @@ -19,8 +19,8 @@ func (c *hostConfig) Get(key string) (string, error) { return strings.TrimSpace(val), err } -func parseSSHConfig(filePath types.SmartPath) (*ssh_config.Config, error) { - fileName, err := filePath.Resolve(false) +func parseSSHConfig(filePath smarttypes.Path) (*ssh_config.Config, error) { + fileName, err := filePath.File() if err != nil { return nil, err } diff --git a/internal/server/driver/ssh/sshtun/auth.go b/internal/server/driver/ssh/sshtun/auth.go index 106d84e..8a8d76f 100644 --- a/internal/server/driver/ssh/sshtun/auth.go +++ b/internal/server/driver/ssh/sshtun/auth.go @@ -7,11 +7,11 @@ import ( "path" "strings" - "github.com/Neur0toxine/sshpoke/internal/server/driver/ssh/types" "github.com/Neur0toxine/sshpoke/pkg/proto/ssh" + "github.com/Neur0toxine/sshpoke/pkg/smarttypes" ) -func AuthKeyFile(keyFile types.SmartPath) (ssh.AuthMethod, error) { +func AuthKeyFile(keyFile smarttypes.Path) (ssh.AuthMethod, error) { key, err := readKey(keyFile) if err != nil { return nil, err @@ -19,7 +19,7 @@ func AuthKeyFile(keyFile types.SmartPath) (ssh.AuthMethod, error) { return ssh.PublicKeys(key), nil } -func AuthKeyDir(keyDir types.SmartPath) (ssh.AuthMethod, error) { +func AuthKeyDir(keyDir smarttypes.Path) (ssh.AuthMethod, error) { keys, err := readKeys(keyDir) if err != nil { return nil, err @@ -31,8 +31,8 @@ func AuthPassword(password string) ssh.AuthMethod { return ssh.Password(password) } -func readKeys(keyDir types.SmartPath) ([]ssh.Signer, error) { - dir, err := keyDir.Resolve(true) +func readKeys(keyDir smarttypes.Path) ([]ssh.Signer, error) { + dir, err := keyDir.Directory() if err != nil { return nil, fmt.Errorf("cannot parse keys: %s", err) } @@ -62,7 +62,7 @@ func readKeys(keyDir types.SmartPath) ([]ssh.Signer, error) { if info.Size() < 256 { continue } - key, err := readKey(types.SmartPath(path.Join(dir, entry.Name()))) + key, err := readKey(smarttypes.Path(path.Join(dir, entry.Name()))) if err != nil { continue } @@ -74,8 +74,8 @@ func readKeys(keyDir types.SmartPath) ([]ssh.Signer, error) { return keys, nil } -func readKey(keyFile types.SmartPath) (ssh.Signer, error) { - fileName, err := keyFile.Resolve(false) +func readKey(keyFile smarttypes.Path) (ssh.Signer, error) { + fileName, err := keyFile.File() if err != nil { return nil, err } diff --git a/internal/server/driver/ssh/sshtun/config.go b/internal/server/driver/ssh/sshtun/config.go index bb3c5bb..8e7650d 100644 --- a/internal/server/driver/ssh/sshtun/config.go +++ b/internal/server/driver/ssh/sshtun/config.go @@ -4,12 +4,12 @@ import ( "bytes" "os" - "github.com/Neur0toxine/sshpoke/internal/server/driver/ssh/types" + "github.com/Neur0toxine/sshpoke/pkg/smarttypes" "github.com/kevinburke/ssh_config" ) -func parseSSHConfig(filePath types.SmartPath) (*ssh_config.Config, error) { - fileName, err := filePath.Resolve(false) +func parseSSHConfig(filePath smarttypes.Path) (*ssh_config.Config, error) { + fileName, err := filePath.File() if err != nil { return nil, err } diff --git a/internal/server/driver/ssh/types/auth.go b/internal/server/driver/ssh/types/auth.go index 293e722..ff44a34 100644 --- a/internal/server/driver/ssh/types/auth.go +++ b/internal/server/driver/ssh/types/auth.go @@ -1,21 +1,12 @@ package types import ( - "errors" "fmt" - "os" - "path" - "path/filepath" - "regexp" - "strings" + + "github.com/Neur0toxine/sshpoke/pkg/smarttypes" ) -var envMatcherRegExp = regexp.MustCompile(`\$[\w\d\_]`) - -type ( - AuthType string - SmartPath string -) +type AuthType string const ( AuthTypePasswordless AuthType = "passwordless" @@ -24,46 +15,11 @@ const ( ) type Auth struct { - Type AuthType `mapstructure:"type" validate:"required,oneof=passwordless password key"` - User string `mapstructure:"user"` - Password string `mapstructure:"password"` - Directory SmartPath `mapstructure:"directory"` - Keyfile string `mapstructure:"keyfile"` -} - -func (k SmartPath) Resolve(shouldBeDirectory bool) (result string, err error) { - result = strings.TrimSpace(string(k)) - if strings.HasPrefix(result, "~/") { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", err - } - result = path.Join(homeDir, result[2:]) - } - for _, match := range envMatcherRegExp.FindAllString(string(k), -1) { - envVar := match[1:] - if envVar == "" { - continue - } - envVar = os.Getenv(envVar) - result = strings.ReplaceAll(result, match, envVar) - } - result, err = filepath.Abs(result) - if err != nil { - return - } - stat, err := os.Stat(result) - if err != nil { - return - } - if !stat.IsDir() && shouldBeDirectory { - err = errors.New("is not a directory") - } - return -} - -func (k SmartPath) String() string { - return string(k) + Type AuthType `mapstructure:"type" validate:"required,oneof=passwordless password key"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + Directory smarttypes.Path `mapstructure:"directory"` + Keyfile string `mapstructure:"keyfile"` } func (a Auth) Validate() error { diff --git a/pkg/smarttypes/bool_str.go b/pkg/smarttypes/bool_str.go new file mode 100644 index 0000000..c4c9d41 --- /dev/null +++ b/pkg/smarttypes/bool_str.go @@ -0,0 +1,19 @@ +package smarttypes + +type BoolStr bool + +func BoolFromStr(str string) BoolStr { + return str == "true" || str == "1" +} + +func (b BoolStr) MarshalText() ([]byte, error) { + if b { + return []byte("true"), nil + } + return []byte("false"), nil +} + +func (b *BoolStr) UnmarshalText(src []byte) error { + *b = BoolFromStr(string(src)) + return nil +} diff --git a/pkg/smarttypes/file_path.go b/pkg/smarttypes/file_path.go new file mode 100644 index 0000000..3c8b23a --- /dev/null +++ b/pkg/smarttypes/file_path.go @@ -0,0 +1,57 @@ +package smarttypes + +import ( + "errors" + "os" + "path" + "path/filepath" + "regexp" + "strings" +) + +var envMatcherRegExp = regexp.MustCompile(`\$[\w\d\_]`) + +type Path string + +func (k Path) File() (string, error) { + return k.resolve(false) +} + +func (k Path) Directory() (string, error) { + return k.resolve(true) +} + +func (k Path) resolve(shouldBeDirectory bool) (result string, err error) { + result = strings.TrimSpace(string(k)) + if strings.HasPrefix(result, "~/") { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + result = path.Join(homeDir, result[2:]) + } + for _, match := range envMatcherRegExp.FindAllString(string(k), -1) { + envVar := match[1:] + if envVar == "" { + continue + } + envVar = os.Getenv(envVar) + result = strings.ReplaceAll(result, match, envVar) + } + result, err = filepath.Abs(result) + if err != nil { + return + } + stat, err := os.Stat(result) + if err != nil { + return + } + if !stat.IsDir() && shouldBeDirectory { + err = errors.New("is not a directory") + } + return +} + +func (k Path) String() string { + return string(k) +} diff --git a/pkg/smarttypes/matchable_string.go b/pkg/smarttypes/matchable_string.go new file mode 100644 index 0000000..eb27848 --- /dev/null +++ b/pkg/smarttypes/matchable_string.go @@ -0,0 +1,47 @@ +package smarttypes + +import ( + "regexp" +) + +type MatchableString string + +func (m MatchableString) isExpr() bool { + if len(m) < 3 { + return false + } + if m[0] == '/' && m[len(m)-1] == '/' { + return true + } + return false +} + +func (m MatchableString) expr() *regexp.Regexp { + return regexp.MustCompile(string(m[1 : len(m)-2])) +} + +func (m MatchableString) Match(val string) bool { + if m.isExpr() { + return m.expr().Match([]byte(val)) + } + return val == string(m) +} + +func (m MatchableString) String() string { + return string(m) +} + +func (m MatchableString) MarshalText() ([]byte, error) { + return []byte(m), nil +} + +func (m *MatchableString) UnmarshalText(src []byte) error { + if len(src) > 2 && src[0] == '/' && src[len(src)-1] == '/' { + _, err := regexp.Compile(string(src[1 : len(src)-2])) + if err != nil { + return err + } + } + *m = MatchableString(src) + return nil +}