services configuration inside config file

This commit is contained in:
Pavel 2023-11-29 18:52:59 +03:00
parent 64fa306f8d
commit f169b7f137
13 changed files with 260 additions and 98 deletions

View File

@ -117,7 +117,7 @@ func runRestServer() {
func runDockerEventListener(ctx context.Context) { func runDockerEventListener(ctx context.Context) {
var err error var err error
docker.Default, err = docker.New(ctx) docker.Default, err = docker.New(ctx, config.Default.Services, config.Default.DefaultServer)
if err != nil { if err != nil {
logger.Sugar.Fatalf("cannot connect to docker daemon: %s", err) logger.Sugar.Fatalf("cannot connect to docker daemon: %s", err)
} }

View File

@ -23,10 +23,31 @@ docker:
version: ~ version: ~
# Default server to use if `sshpoke.server` is not specified in the target container labels. # Default server to use if `sshpoke.server` is not specified in the target container labels.
default_server: mine 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 configuration.
servers: servers:
# Server name. # Server name.
- name: mine - name: ssh-demo-sish
# Server driver. Each driver has its own set of params. Supported drivers: ssh, plugin, null. # Server driver. Each driver has its own set of params. Supported drivers: ssh, plugin, null.
driver: ssh driver: ssh
params: params:

View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"github.com/Neur0toxine/sshpoke/pkg/smarttypes"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/docker/go-connections/tlsconfig" "github.com/docker/go-connections/tlsconfig"
) )
@ -15,9 +16,23 @@ type Config struct {
API API `mapstructure:"api" json:"api"` API API `mapstructure:"api" json:"api"`
Docker DockerConfig `mapstructure:"docker" json:"docker"` Docker DockerConfig `mapstructure:"docker" json:"docker"`
DefaultServer string `mapstructure:"default_server" json:"default_server"` DefaultServer string `mapstructure:"default_server" json:"default_server"`
Services []Service `mapstructure:"services" json:"services,omitempty"`
Servers []Server `mapstructure:"servers" json:"servers"` 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 { type API struct {
Rest WebAPI `mapstructure:"rest" json:"rest"` Rest WebAPI `mapstructure:"rest" json:"rest"`
Plugin PluginAPI `mapstructure:"plugin" json:"plugin"` Plugin PluginAPI `mapstructure:"plugin" json:"plugin"`

View File

@ -8,6 +8,7 @@ import (
"github.com/Neur0toxine/sshpoke/internal/config" "github.com/Neur0toxine/sshpoke/internal/config"
"github.com/Neur0toxine/sshpoke/internal/logger" "github.com/Neur0toxine/sshpoke/internal/logger"
"github.com/Neur0toxine/sshpoke/pkg/dto" "github.com/Neur0toxine/sshpoke/pkg/dto"
"github.com/Neur0toxine/sshpoke/pkg/smarttypes"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client" "github.com/docker/docker/client"
@ -18,19 +19,41 @@ var Default *Docker
type Docker struct { type Docker struct {
cli *client.Client cli *client.Client
ctx context.Context 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) cli, err := client.NewClientWithOpts(config.Default.Docker.Opts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
servicesMap := make(map[smarttypes.MatchableString]config.ServiceLabels)
for _, svc := range services {
servicesMap[svc.Name] = svc.Params
}
return &Docker{ return &Docker{
cli: cli, cli: cli,
ctx: ctx, ctx: ctx,
services: servicesMap,
defaultServer: defaultServer,
}, nil }, 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 { func (d *Docker) Containers() map[string]dto.Container {
items, err := d.cli.ContainerList(d.ctx, types.ContainerListOptions{ items, err := d.cli.ContainerList(d.ctx, types.ContainerListOptions{
Filters: filters.NewArgs(filters.Arg("status", "running")), Filters: filters.NewArgs(filters.Arg("status", "running")),
@ -41,7 +64,7 @@ func (d *Docker) Containers() map[string]dto.Container {
} }
containers := map[string]dto.Container{} containers := map[string]dto.Container{}
for _, item := range items { for _, item := range items {
container, ok := dockerContainerToInternal(item) container, ok := dockerContainerToInternal(item, d.findServiceLabels(item.ID, item.Names), d.defaultServer)
if !ok { if !ok {
continue continue
} }
@ -58,7 +81,8 @@ func (d *Docker) GetContainer(id string, all bool) (dto.Container, bool) {
if err != nil || len(container) != 1 { if err != nil || len(container) != 1 {
return dto.Container{}, false 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 { if !ok {
return dto.Container{}, false return dto.Container{}, false
} }
@ -92,7 +116,8 @@ func (d *Docker) Listen() (chan dto.Event, error) {
"id", event.Actor.ID, "err", err) "id", event.Actor.ID, "err", err)
continue continue
} }
converted, ok := dockerContainerToInternal(container[0]) converted, ok := dockerContainerToInternal(container[0],
d.findServiceLabels(container[0].ID, container[0].Names), d.defaultServer)
if !ok { if !ok {
continue continue
} }

View File

@ -5,8 +5,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/Neur0toxine/sshpoke/internal/config"
"github.com/Neur0toxine/sshpoke/internal/logger" "github.com/Neur0toxine/sshpoke/internal/logger"
"github.com/Neur0toxine/sshpoke/pkg/dto" "github.com/Neur0toxine/sshpoke/pkg/dto"
"github.com/Neur0toxine/sshpoke/pkg/smarttypes"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/events" "github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
@ -14,41 +16,60 @@ import (
) )
type labelsConfig struct { type labelsConfig struct {
Enable boolStr `mapstructure:"sshpoke.enable"` Enable smarttypes.BoolStr `mapstructure:"sshpoke.enable"`
Network string `mapstructure:"sshpoke.network"` Network string `mapstructure:"sshpoke.network"`
Server string `mapstructure:"sshpoke.server"` Server string `mapstructure:"sshpoke.server"`
Port string `mapstructure:"sshpoke.port"` Port string `mapstructure:"sshpoke.port"`
RemoteHost string `mapstructure:"sshpoke.remote_host"` RemoteHost string `mapstructure:"sshpoke.remote_host"`
} }
type boolStr string func actorEnabled(actor events.Actor) smarttypes.BoolStr {
func (b boolStr) Bool() bool {
return string(b) == "true"
}
func actorEnabled(actor events.Actor) bool {
label, ok := actor.Attributes["sshpoke.enable"] label, ok := actor.Attributes["sshpoke.enable"]
if !ok { if !ok {
return false 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 var labels labelsConfig
if err := mapstructure.Decode(container.Labels, &labels); err != nil { if err := mapstructure.Decode(container.Labels, &labels); err != nil {
logger.Sugar.Debugf("skipping container %s because configuration is invalid: %s", container.ID, err) logger.Sugar.Debugf("skipping container %s because configuration is invalid: %s", container.ID, err)
return result, false 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) logger.Sugar.Debugf("skipping container %s because sshpoke is not enabled for it", container.ID)
return result, false return result, false
} }
if labels.Server == "" { if labels.Server == "" {
if defaultServer == "" {
logger.Sugar.Debugf("skipping container %s because 'sshpoke.server' is not defined", container.ID) logger.Sugar.Debugf("skipping container %s because 'sshpoke.server' is not defined", container.ID)
return result, false return result, false
} }
labels.Server = defaultServer
}
if container.NetworkSettings == nil || if container.NetworkSettings == nil ||
container.NetworkSettings.Networks == nil || container.NetworkSettings.Networks == nil ||
len(container.NetworkSettings.Networks) == 0 { len(container.NetworkSettings.Networks) == 0 {

View File

@ -21,6 +21,7 @@ import (
"github.com/Neur0toxine/sshpoke/pkg/dto" "github.com/Neur0toxine/sshpoke/pkg/dto"
"github.com/Neur0toxine/sshpoke/pkg/proto/ssh" "github.com/Neur0toxine/sshpoke/pkg/proto/ssh"
"github.com/Neur0toxine/sshpoke/pkg/proto/ssh/knownhosts" "github.com/Neur0toxine/sshpoke/pkg/proto/ssh/knownhosts"
"github.com/Neur0toxine/sshpoke/pkg/smarttypes"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -145,8 +146,8 @@ func (d *SSH) buildHostKeyCallback() ssh.HostKeyCallback {
return sshtun.FixedHostKeys(d.hostKeys) return sshtun.FixedHostKeys(d.hostKeys)
}() }()
if d.params.Auth.Type == types.AuthTypeKey && d.params.Auth.Directory != "" && len(d.hostKeys) == 0 { 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)) knownHostsPath := smarttypes.Path(path.Join(string(d.params.Auth.Directory), KnownHostsFile))
resolvedPath, err := knownHostsPath.Resolve(false) resolvedPath, err := knownHostsPath.File()
if err != nil { if err != nil {
return ssh.InsecureIgnoreHostKey() return ssh.InsecureIgnoreHostKey()
} }
@ -237,7 +238,7 @@ func (d *SSH) populateFromSSHConfig() {
if d.params.Auth.Directory == "" { if d.params.Auth.Directory == "" {
return 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 { if err != nil {
return return
} }
@ -255,7 +256,7 @@ func (d *SSH) populateFromSSHConfig() {
d.params.Auth.User = user d.params.Auth.User = user
} }
if keyfile, err := hostCfg.Get("IdentityFile"); err == nil && keyfile != "" { 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 { if err == nil {
d.params.Auth.Type = types.AuthTypeKey d.params.Auth.Type = types.AuthTypeKey
d.params.Auth.Keyfile = resolvedKeyFile d.params.Auth.Keyfile = resolvedKeyFile
@ -354,7 +355,7 @@ func (d *SSH) authenticator() ssh.AuthMethod {
case types.AuthTypeKey: case types.AuthTypeKey:
if d.params.Auth.Keyfile != "" { if d.params.Auth.Keyfile != "" {
keyAuth, err := sshtun.AuthKeyFile( 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 { if err != nil {
return nil return nil
} }

View File

@ -5,7 +5,7 @@ import (
"os" "os"
"strings" "strings"
"github.com/Neur0toxine/sshpoke/internal/server/driver/ssh/types" "github.com/Neur0toxine/sshpoke/pkg/smarttypes"
"github.com/kevinburke/ssh_config" "github.com/kevinburke/ssh_config"
) )
@ -19,8 +19,8 @@ func (c *hostConfig) Get(key string) (string, error) {
return strings.TrimSpace(val), err return strings.TrimSpace(val), err
} }
func parseSSHConfig(filePath types.SmartPath) (*ssh_config.Config, error) { func parseSSHConfig(filePath smarttypes.Path) (*ssh_config.Config, error) {
fileName, err := filePath.Resolve(false) fileName, err := filePath.File()
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -7,11 +7,11 @@ import (
"path" "path"
"strings" "strings"
"github.com/Neur0toxine/sshpoke/internal/server/driver/ssh/types"
"github.com/Neur0toxine/sshpoke/pkg/proto/ssh" "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) key, err := readKey(keyFile)
if err != nil { if err != nil {
return nil, err return nil, err
@ -19,7 +19,7 @@ func AuthKeyFile(keyFile types.SmartPath) (ssh.AuthMethod, error) {
return ssh.PublicKeys(key), nil 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) keys, err := readKeys(keyDir)
if err != nil { if err != nil {
return nil, err return nil, err
@ -31,8 +31,8 @@ func AuthPassword(password string) ssh.AuthMethod {
return ssh.Password(password) return ssh.Password(password)
} }
func readKeys(keyDir types.SmartPath) ([]ssh.Signer, error) { func readKeys(keyDir smarttypes.Path) ([]ssh.Signer, error) {
dir, err := keyDir.Resolve(true) dir, err := keyDir.Directory()
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot parse keys: %s", err) 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 { if info.Size() < 256 {
continue continue
} }
key, err := readKey(types.SmartPath(path.Join(dir, entry.Name()))) key, err := readKey(smarttypes.Path(path.Join(dir, entry.Name())))
if err != nil { if err != nil {
continue continue
} }
@ -74,8 +74,8 @@ func readKeys(keyDir types.SmartPath) ([]ssh.Signer, error) {
return keys, nil return keys, nil
} }
func readKey(keyFile types.SmartPath) (ssh.Signer, error) { func readKey(keyFile smarttypes.Path) (ssh.Signer, error) {
fileName, err := keyFile.Resolve(false) fileName, err := keyFile.File()
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -4,12 +4,12 @@ import (
"bytes" "bytes"
"os" "os"
"github.com/Neur0toxine/sshpoke/internal/server/driver/ssh/types" "github.com/Neur0toxine/sshpoke/pkg/smarttypes"
"github.com/kevinburke/ssh_config" "github.com/kevinburke/ssh_config"
) )
func parseSSHConfig(filePath types.SmartPath) (*ssh_config.Config, error) { func parseSSHConfig(filePath smarttypes.Path) (*ssh_config.Config, error) {
fileName, err := filePath.Resolve(false) fileName, err := filePath.File()
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -1,21 +1,12 @@
package types package types
import ( import (
"errors"
"fmt" "fmt"
"os"
"path" "github.com/Neur0toxine/sshpoke/pkg/smarttypes"
"path/filepath"
"regexp"
"strings"
) )
var envMatcherRegExp = regexp.MustCompile(`\$[\w\d\_]`) type AuthType string
type (
AuthType string
SmartPath string
)
const ( const (
AuthTypePasswordless AuthType = "passwordless" AuthTypePasswordless AuthType = "passwordless"
@ -27,45 +18,10 @@ type Auth struct {
Type AuthType `mapstructure:"type" validate:"required,oneof=passwordless password key"` Type AuthType `mapstructure:"type" validate:"required,oneof=passwordless password key"`
User string `mapstructure:"user"` User string `mapstructure:"user"`
Password string `mapstructure:"password"` Password string `mapstructure:"password"`
Directory SmartPath `mapstructure:"directory"` Directory smarttypes.Path `mapstructure:"directory"`
Keyfile string `mapstructure:"keyfile"` 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)
}
func (a Auth) Validate() error { func (a Auth) Validate() error {
if a.Type == AuthTypePassword && a.Password == "" { if a.Type == AuthTypePassword && a.Password == "" {
return fmt.Errorf("password must be provided for authentication type '%s'", AuthTypePassword) return fmt.Errorf("password must be provided for authentication type '%s'", AuthTypePassword)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}