From d3029d09e7949f92254496b2100f42c33b70d77c Mon Sep 17 00:00:00 2001 From: Neur0toxine Date: Sat, 18 Nov 2023 16:14:39 +0300 Subject: [PATCH] refactor, initial work for ssh driver (still wip) --- cmd/root.go | 4 +- go.mod | 1 + go.sum | 2 + internal/api/server.go | 7 +- internal/docker/api.go | 16 ++- internal/docker/convert.go | 2 + internal/server/container_list.go | 30 ++++ internal/server/driver/base/base.go | 46 +++++++ .../driver/{iface/driver.go => base/iface.go} | 7 +- internal/server/driver/construct.go | 4 +- internal/server/driver/null/driver.go | 30 ++-- internal/server/driver/plugin/driver.go | 30 ++-- internal/server/driver/ssh/driver.go | 128 ++++++++++++++++-- internal/server/driver/ssh/params.go | 59 ++------ internal/server/driver/ssh/sshconfig.go | 21 +++ internal/server/driver/ssh/types/auth.go | 73 ++++++++++ .../server/driver/ssh/types/domain_mode.go | 8 ++ .../server/driver/ssh/types/keep_alive.go | 6 + internal/server/manager.go | 46 ++++++- pkg/convert/convert.go | 32 +++-- pkg/dto/models.go | 15 +- pkg/plugin/pb/pb.proto | 22 +-- 22 files changed, 461 insertions(+), 128 deletions(-) create mode 100644 internal/server/container_list.go create mode 100644 internal/server/driver/base/base.go rename internal/server/driver/{iface/driver.go => base/iface.go} (62%) create mode 100644 internal/server/driver/ssh/sshconfig.go create mode 100644 internal/server/driver/ssh/types/auth.go create mode 100644 internal/server/driver/ssh/types/domain_mode.go create mode 100644 internal/server/driver/ssh/types/keep_alive.go diff --git a/cmd/root.go b/cmd/root.go index 5982227..9311b64 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -36,7 +36,6 @@ var rootCmd = &cobra.Command{ for id, item := range docker.Default.Containers() { err := server.DefaultManager.ProcessEvent(dto.Event{ Type: dto.EventStart, - ID: id, Container: item, }) if err != nil { @@ -54,7 +53,8 @@ var rootCmd = &cobra.Command{ for event := range events { err := server.DefaultManager.ProcessEvent(event) if err != nil { - logger.Sugar.Errorw("cannot expose container", "id", event.ID, "error", err) + logger.Sugar.Errorw("cannot expose container", + "id", event.Container.ID, "error", err) } } }() diff --git a/go.mod b/go.mod index efd871a..a9132ff 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/docker/docker v24.0.7+incompatible github.com/docker/go-connections v0.4.0 github.com/go-playground/validator/v10 v10.16.0 + github.com/kevinburke/ssh_config v1.2.0 github.com/mitchellh/mapstructure v1.5.0 github.com/spf13/cast v1.5.1 github.com/spf13/cobra v1.8.0 diff --git a/go.sum b/go.sum index 4b6f054..4757b9a 100644 --- a/go.sum +++ b/go.sum @@ -162,6 +162,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= diff --git a/internal/api/server.go b/internal/api/server.go index 941dfdc..45f3bb1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -10,7 +10,7 @@ import ( "github.com/Neur0toxine/sshpoke/internal/logger" "github.com/Neur0toxine/sshpoke/internal/server" "github.com/Neur0toxine/sshpoke/internal/server/driver/plugin" - "github.com/Neur0toxine/sshpoke/pkg/dto" + "github.com/Neur0toxine/sshpoke/pkg/convert" plugin2 "github.com/Neur0toxine/sshpoke/pkg/plugin" "github.com/Neur0toxine/sshpoke/pkg/plugin/pb" "google.golang.org/grpc" @@ -44,10 +44,7 @@ func (p *pluginAPI) EventStatus(ctx context.Context, msg *pb.EventStatusMessage) if pl == nil { return nil, ErrUnauthorized } - pl.HandleStatus(dto.EventStatus{ - ID: msg.Id, - Error: msg.Error, - }) + pl.PushEventStatus(convert.MessageToAppEventStatus(msg)) return &emptypb.Empty{}, nil } diff --git a/internal/docker/api.go b/internal/docker/api.go index bf6f47b..a69c3cc 100644 --- a/internal/docker/api.go +++ b/internal/docker/api.go @@ -50,6 +50,21 @@ func (d *Docker) Containers() map[string]dto.Container { return containers } +func (d *Docker) GetContainer(id string) (dto.Container, bool) { + container, err := d.cli.ContainerList(d.ctx, types.ContainerListOptions{ + Filters: filters.NewArgs(filters.Arg("id", id)), + All: true, + }) + if err != nil || len(container) != 1 { + return dto.Container{}, false + } + converted, ok := dockerContainerToInternal(container[0]) + if !ok { + return dto.Container{}, false + } + return converted, true +} + func (d *Docker) Listen() (chan dto.Event, error) { cli, err := client.NewClientWithOpts(config.Default.Docker.Opts) if err != nil { @@ -83,7 +98,6 @@ func (d *Docker) Listen() (chan dto.Event, error) { } newEvent := dto.Event{ Type: eventType, - ID: event.Actor.ID, Container: converted, } msg := "exposing container" diff --git a/internal/docker/convert.go b/internal/docker/convert.go index fde0b1d..b6cfb6c 100644 --- a/internal/docker/convert.go +++ b/internal/docker/convert.go @@ -77,6 +77,8 @@ func dockerContainerToInternal(container types.Container) (result dto.Container, } return dto.Container{ + ID: container.ID, + Names: container.Names, IP: ip, Port: uint16(port), Server: labels.Server, diff --git a/internal/server/container_list.go b/internal/server/container_list.go new file mode 100644 index 0000000..1ab83ab --- /dev/null +++ b/internal/server/container_list.go @@ -0,0 +1,30 @@ +package server + +import ( + "encoding/json" + + "github.com/Neur0toxine/sshpoke/pkg/dto" +) + +type Connections map[string]dto.Container + +func (c Connections) MarshalJSON() ([]byte, error) { + items := []dto.Container{} + for _, item := range c { + items = append(items, item) + } + return json.Marshal(items) +} + +func (c *Connections) UnmarshalJSON(data []byte) error { + var items []dto.Container + if err := json.Unmarshal(data, &items); err != nil { + return err + } + m := make(map[string]dto.Container, len(items)) + for _, item := range items { + m[item.ID] = item + } + *c = m + return nil +} diff --git a/internal/server/driver/base/base.go b/internal/server/driver/base/base.go new file mode 100644 index 0000000..3b854b6 --- /dev/null +++ b/internal/server/driver/base/base.go @@ -0,0 +1,46 @@ +package base + +import ( + "context" + + "github.com/Neur0toxine/sshpoke/internal/logger" + "github.com/Neur0toxine/sshpoke/pkg/dto" + "go.uber.org/zap" +) + +type Base struct { + ctx context.Context + name string + log *zap.SugaredLogger + eventStatusCb EventStatusCallback +} + +func New(ctx context.Context, name string) Base { + return Base{ + ctx: ctx, + name: name, + log: logger.Default.With(zap.String("serverName", name)).Sugar(), + } +} + +func (b *Base) SetEventStatusCallback(callback EventStatusCallback) { + b.eventStatusCb = callback +} + +func (b *Base) PushEventStatus(status dto.EventStatus) { + if b.eventStatusCb != nil { + b.eventStatusCb(status) + } +} + +func (b *Base) Context() context.Context { + return b.ctx +} + +func (b *Base) Name() string { + return b.name +} + +func (b *Base) Log() *zap.SugaredLogger { + return b.log +} diff --git a/internal/server/driver/iface/driver.go b/internal/server/driver/base/iface.go similarity index 62% rename from internal/server/driver/iface/driver.go rename to internal/server/driver/base/iface.go index d4ff797..cf276bb 100644 --- a/internal/server/driver/iface/driver.go +++ b/internal/server/driver/base/iface.go @@ -1,17 +1,22 @@ -package iface +package base import ( "context" "github.com/Neur0toxine/sshpoke/internal/config" "github.com/Neur0toxine/sshpoke/pkg/dto" + "go.uber.org/zap" ) type DriverConstructor func(ctx context.Context, name string, params config.DriverParams) (Driver, error) +type EventStatusCallback func(status dto.EventStatus) type Driver interface { Name() string + SetEventStatusCallback(callback EventStatusCallback) + PushEventStatus(status dto.EventStatus) Handle(event dto.Event) error Driver() config.DriverType + Log() *zap.SugaredLogger WaitForShutdown() } diff --git a/internal/server/driver/construct.go b/internal/server/driver/construct.go index 2fc23a2..69490f0 100644 --- a/internal/server/driver/construct.go +++ b/internal/server/driver/construct.go @@ -4,13 +4,13 @@ import ( "context" "github.com/Neur0toxine/sshpoke/internal/config" - "github.com/Neur0toxine/sshpoke/internal/server/driver/iface" + "github.com/Neur0toxine/sshpoke/internal/server/driver/base" "github.com/Neur0toxine/sshpoke/internal/server/driver/null" "github.com/Neur0toxine/sshpoke/internal/server/driver/plugin" "github.com/Neur0toxine/sshpoke/internal/server/driver/ssh" ) -func New(ctx context.Context, name string, driver config.DriverType, params config.DriverParams) (iface.Driver, error) { +func New(ctx context.Context, name string, driver config.DriverType, params config.DriverParams) (base.Driver, error) { switch driver { case config.DriverSSH: return ssh.New(ctx, name, params) diff --git a/internal/server/driver/null/driver.go b/internal/server/driver/null/driver.go index 30eb200..3250a70 100644 --- a/internal/server/driver/null/driver.go +++ b/internal/server/driver/null/driver.go @@ -4,30 +4,40 @@ import ( "context" "github.com/Neur0toxine/sshpoke/internal/config" - "github.com/Neur0toxine/sshpoke/internal/logger" - "github.com/Neur0toxine/sshpoke/internal/server/driver/iface" + "github.com/Neur0toxine/sshpoke/internal/server/driver/base" "github.com/Neur0toxine/sshpoke/pkg/dto" ) // Null driver only logs container events to debug log. It is used when user provides invalid driver type. // You can use it directly, but it won't do anything, so... why bother? type Null struct { - name string + base.Base } -func New(ctx context.Context, name string, params config.DriverParams) (iface.Driver, error) { - return &Null{name: name}, nil +func New(ctx context.Context, name string, params config.DriverParams) (base.Driver, error) { + return &Null{ + Base: base.New(ctx, name), + }, nil } func (d *Null) Handle(event dto.Event) error { - logger.Sugar.Debugw("handling event with null driver", "serverName", d.name, "event", event) + d.Log().Debugw("handling event with null driver", "serverName", d.Name(), "event", event) + switch event.Type { + case dto.EventStart: + d.PushEventStatus(dto.EventStatus{ + Type: dto.EventStart, + ID: event.Container.ID, + Domain: "https://" + event.Container.ID + "null.dev", + }) + case dto.EventStop, dto.EventShutdown: + d.PushEventStatus(dto.EventStatus{ + Type: event.Type, + ID: event.Container.ID, + }) + } return nil } -func (d *Null) Name() string { - return d.name -} - func (d *Null) Driver() config.DriverType { return config.DriverNull } diff --git a/internal/server/driver/plugin/driver.go b/internal/server/driver/plugin/driver.go index 6a5cc83..e80fc64 100644 --- a/internal/server/driver/plugin/driver.go +++ b/internal/server/driver/plugin/driver.go @@ -7,8 +7,7 @@ import ( "sync/atomic" "github.com/Neur0toxine/sshpoke/internal/config" - "github.com/Neur0toxine/sshpoke/internal/logger" - "github.com/Neur0toxine/sshpoke/internal/server/driver/iface" + "github.com/Neur0toxine/sshpoke/internal/server/driver/base" "github.com/Neur0toxine/sshpoke/internal/server/driver/util" "github.com/Neur0toxine/sshpoke/pkg/dto" ) @@ -17,8 +16,7 @@ var ErrAlreadyConnected = errors.New("already connected") // Driver plugin uses RPC to communicate with external plugin. type Driver struct { - ctx context.Context - name string + base.Base params Params send *Queue[dto.Event] listening atomic.Bool @@ -29,16 +27,14 @@ type EventStream interface { } type Plugin interface { - iface.Driver + base.Driver Token() string Listen(ctx context.Context, stream EventStream) error - HandleStatus(event dto.EventStatus) } -func New(ctx context.Context, name string, params config.DriverParams) (iface.Driver, error) { +func New(ctx context.Context, name string, params config.DriverParams) (base.Driver, error) { drv := &Driver{ - name: name, - ctx: ctx, + Base: base.New(ctx, name), send: NewQueue[dto.Event](), } if err := util.UnmarshalParams(params, &drv.params); err != nil { @@ -56,10 +52,6 @@ func (d *Driver) Handle(event dto.Event) error { return nil } -func (d *Driver) Name() string { - return d.name -} - func (d *Driver) Driver() config.DriverType { return config.DriverPlugin } @@ -88,21 +80,17 @@ func (d *Driver) Listen(ctx context.Context, stream EventStream) error { return nil } if err != nil { - logger.Sugar.Errorw("error writing event to plugin", - "server", d.name, "error", err) + d.Log().Errorw("error writing event to plugin", + "server", d.Name(), "error", err) return err } } } } -func (d *Driver) HandleStatus(event dto.EventStatus) { - logger.Sugar.Errorw("plugin error", "serverName", d.name, "id", event.ID, "error", event.Error) -} - func (d *Driver) isDone() bool { select { - case <-d.ctx.Done(): + case <-d.Context().Done(): return true default: return false @@ -110,6 +98,6 @@ func (d *Driver) isDone() bool { } func (d *Driver) WaitForShutdown() { - <-d.ctx.Done() + <-d.Context().Done() return } diff --git a/internal/server/driver/ssh/driver.go b/internal/server/driver/ssh/driver.go index 2aef4db..69b81d7 100644 --- a/internal/server/driver/ssh/driver.go +++ b/internal/server/driver/ssh/driver.go @@ -3,20 +3,26 @@ package ssh import ( "context" "errors" + "fmt" + "os" + "path" + "strings" "sync" "github.com/Neur0toxine/sshpoke/internal/config" - "github.com/Neur0toxine/sshpoke/internal/server/driver/iface" + "github.com/Neur0toxine/sshpoke/internal/server/driver/base" + "github.com/Neur0toxine/sshpoke/internal/server/driver/ssh/types" "github.com/Neur0toxine/sshpoke/internal/server/driver/util" "github.com/Neur0toxine/sshpoke/internal/server/proto/sshtun" "github.com/Neur0toxine/sshpoke/pkg/dto" + "golang.org/x/crypto/ssh" ) type SSH struct { - ctx context.Context - name string + base.Base params Params sessions map[string]conn + keys []ssh.Signer wg sync.WaitGroup } @@ -25,21 +31,47 @@ type conn struct { tun *sshtun.Tunnel } -func New(ctx context.Context, name string, params config.DriverParams) (iface.Driver, error) { - drv := &SSH{ctx: ctx, name: name, sessions: make(map[string]conn)} +func New(ctx context.Context, name string, params config.DriverParams) (base.Driver, error) { + drv := &SSH{ + Base: base.New(ctx, name), + sessions: make(map[string]conn), + } if err := util.UnmarshalParams(params, &drv.params); err != nil { return nil, err } + drv.populateFromSSHConfig() + if err := drv.parseKeys(); err != nil { + return nil, err + } return drv, nil } -func (d *SSH) Handle(event dto.Event) error { - // TODO: Implement event handling & connections management. - return errors.New(d.name + " server handler is not implemented yet") +func (d *SSH) populateFromSSHConfig() { + if d.params.Auth.Directory == "" { + return + } + cfg, err := parseSSHConfig(types.SmartPath(path.Join(string(d.params.Auth.Directory), "config"))) + if err != nil { + return + } + if user, err := cfg.Get(d.params.Address, "User"); err == nil && user != "" { + d.params.Auth.User = user + } + if usePass, err := cfg.Get(d.params.Address, "PasswordAuthentication"); err == nil && usePass == "yes" { + d.params.Auth.Type = types.AuthTypePassword + } + if keyfile, err := cfg.Get(d.params.Address, "IdentityFile"); err == nil && keyfile != "" { + resolvedKeyFile, err := types.SmartPath(keyfile).Resolve(false) + if err == nil { + d.params.Auth.Type = types.AuthTypeKey + d.params.Auth.Keyfile = resolvedKeyFile + } + } } -func (d *SSH) Name() string { - return d.name +func (d *SSH) Handle(event dto.Event) error { + // TODO: Implement event handling & connections management. + return errors.New("server handler is not implemented yet") } func (d *SSH) Driver() config.DriverType { @@ -49,3 +81,79 @@ func (d *SSH) Driver() config.DriverType { func (d *SSH) WaitForShutdown() { d.wg.Wait() } + +func (d *SSH) parseKeys() error { + if d.params.Auth.Type != types.AuthTypeKey { + return nil + } + dir, err := d.params.Auth.Directory.Resolve(true) + if err != nil { + return fmt.Errorf("cannot parse keys: %s", err) + } + if d.params.Auth.Keyfile != "" { + key, err := parseKey(path.Join(dir, d.params.Auth.Keyfile)) + if err != nil { + return err + } + d.keys = []ssh.Signer{key} + return nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("cannot read key directory: %s", err) + } + keys := []ssh.Signer{} + for _, entry := range entries { + if entry.IsDir() { + d.Log().Debugf("skipping '%s' because it's a directory", entry.Name()) + continue + } + info, err := entry.Info() + if err != nil { + d.Log().Debugf("skipping '%s' because stat failed: %s", entry.Name(), err) + continue + } + if strings.HasSuffix(entry.Name(), ".pub") { + d.Log().Debugf("skipping '%s' because it's probably a public key", entry.Name()) + continue + } + if entry.Name() == "config" { + d.Log().Debugf("skipping '%s' because it's probably a ssh-config file", entry.Name()) + continue + } + if entry.Name() == "known_hosts" { + d.Log().Debugf( + "skipping '%s' because it's probably a list of hosts generated by OpenSSH", entry.Name()) + continue + } + // this file is too small to be a private key + if info.Size() < 256 { + d.Log().Debugf("skipping '%s' because the file is smaller than 256 bytes", entry.Name()) + continue + } + key, err := parseKey(path.Join(dir, entry.Name())) + if err != nil { + d.Log().Debugf("skipping '%s' because it's probably not a key: %s", entry.Name(), err) + continue + } + d.Log().Debugf("loading key '%s', type: %s", entry.Name(), key.PublicKey().Type()) + keys = append(keys, key) + } + if len(keys) == 0 { + return errors.New("no keys in the provided directory") + } + d.keys = keys + return nil +} + +func parseKey(keyFile string) (ssh.Signer, error) { + keyData, err := os.ReadFile(keyFile) + if err != nil { + return nil, err + } + key, err := ssh.ParsePrivateKey(keyData) + if err != nil { + return nil, err + } + return key, nil +} diff --git a/internal/server/driver/ssh/params.go b/internal/server/driver/ssh/params.go index 429c621..2602ac8 100644 --- a/internal/server/driver/ssh/params.go +++ b/internal/server/driver/ssh/params.go @@ -1,63 +1,24 @@ package ssh import ( - "fmt" - + "github.com/Neur0toxine/sshpoke/internal/server/driver/ssh/types" "github.com/Neur0toxine/sshpoke/internal/server/driver/util" ) type Params struct { - Address string `mapstructure:"address" validate:"required"` - Auth Auth `mapstructure:"auth"` - KeepAlive KeepAlive `mapstructure:"keepalive"` - Domain string `mapstructure:"domain"` - DomainProto string `mapstructure:"domain_proto"` - DomainExtractRegex string `mapstructure:"domain_extract_regex" validate:"validregexp"` - Mode DomainMode `mapstructure:"mode" validate:"required,oneof=single multi"` - Prefix bool `mapstructure:"prefix"` -} - -type AuthType string - -const ( - AuthTypePasswordless AuthType = "passwordless" - AuthTypePassword AuthType = "password" - AuthTypeKey AuthType = "key" -) - -type DomainMode string - -const ( - DomainModeSingle DomainMode = "single" - DomainModeMulti DomainMode = "multi" -) - -type Auth struct { - Type AuthType `mapstructure:"type" validate:"required,oneof=passwordless password key"` - User string `mapstructure:"user"` - Password string `mapstructure:"password"` - Directory string `mapstructure:"directory"` - Keyfile string `mapstructure:"keyfile"` -} - -func (a Auth) validate() error { - if a.Type == AuthTypePassword && a.Password == "" { - return fmt.Errorf("password must be provided for authentication type '%s'", AuthTypePassword) - } - if a.Type == AuthTypeKey && a.Directory == "" { - return fmt.Errorf("password must be provided for authentication type '%s'", AuthTypePassword) - } - return nil -} - -type KeepAlive struct { - Interval int `mapstructure:"interval" validate:"gte=0"` - MaxAttempts int `mapstructure:"max_attempts" validate:"gte=1"` + Address string `mapstructure:"address" validate:"required"` + Auth types.Auth `mapstructure:"auth"` + KeepAlive types.KeepAlive `mapstructure:"keepalive"` + Domain string `mapstructure:"domain"` + DomainProto string `mapstructure:"domain_proto"` + DomainExtractRegex string `mapstructure:"domain_extract_regex" validate:"validregexp"` + Mode types.DomainMode `mapstructure:"mode" validate:"required,oneof=single multi"` + Prefix bool `mapstructure:"prefix"` } func (p *Params) Validate() error { if err := util.Validator.Struct(p); err != nil { return err } - return p.Auth.validate() + return p.Auth.Validate() } diff --git a/internal/server/driver/ssh/sshconfig.go b/internal/server/driver/ssh/sshconfig.go new file mode 100644 index 0000000..c74767f --- /dev/null +++ b/internal/server/driver/ssh/sshconfig.go @@ -0,0 +1,21 @@ +package ssh + +import ( + "bytes" + "os" + + "github.com/Neur0toxine/sshpoke/internal/server/driver/ssh/types" + "github.com/kevinburke/ssh_config" +) + +func parseSSHConfig(filePath types.SmartPath) (*ssh_config.Config, error) { + fileName, err := filePath.Resolve(false) + if err != nil { + return nil, err + } + file, err := os.ReadFile(fileName) + if err != nil { + return nil, err + } + return ssh_config.Decode(bytes.NewReader(file)) +} diff --git a/internal/server/driver/ssh/types/auth.go b/internal/server/driver/ssh/types/auth.go new file mode 100644 index 0000000..96b9733 --- /dev/null +++ b/internal/server/driver/ssh/types/auth.go @@ -0,0 +1,73 @@ +package types + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" +) + +var envMatcherRegExp = regexp.MustCompile(`\$[\w\d\_]`) + +type ( + AuthType string + SmartPath string +) + +const ( + AuthTypePasswordless AuthType = "passwordless" + AuthTypePassword AuthType = "password" + AuthTypeKey AuthType = "key" +) + +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 (a Auth) Validate() error { + if a.Type == AuthTypePassword && a.Password == "" { + return fmt.Errorf("password must be provided for authentication type '%s'", AuthTypePassword) + } + if a.Type == AuthTypeKey && a.Directory == "" { + return fmt.Errorf("password must be provided for authentication type '%s'", AuthTypePassword) + } + return nil +} diff --git a/internal/server/driver/ssh/types/domain_mode.go b/internal/server/driver/ssh/types/domain_mode.go new file mode 100644 index 0000000..524b030 --- /dev/null +++ b/internal/server/driver/ssh/types/domain_mode.go @@ -0,0 +1,8 @@ +package types + +type DomainMode string + +const ( + DomainModeSingle DomainMode = "single" + DomainModeMulti DomainMode = "multi" +) diff --git a/internal/server/driver/ssh/types/keep_alive.go b/internal/server/driver/ssh/types/keep_alive.go new file mode 100644 index 0000000..519a3bc --- /dev/null +++ b/internal/server/driver/ssh/types/keep_alive.go @@ -0,0 +1,6 @@ +package types + +type KeepAlive struct { + Interval int `mapstructure:"interval" validate:"gte=0"` + MaxAttempts int `mapstructure:"max_attempts" validate:"gte=1"` +} diff --git a/internal/server/manager.go b/internal/server/manager.go index 1d2e3a4..cc7dd60 100644 --- a/internal/server/manager.go +++ b/internal/server/manager.go @@ -6,20 +6,28 @@ import ( "sync" "github.com/Neur0toxine/sshpoke/internal/config" + "github.com/Neur0toxine/sshpoke/internal/docker" "github.com/Neur0toxine/sshpoke/internal/logger" "github.com/Neur0toxine/sshpoke/internal/server/driver" - "github.com/Neur0toxine/sshpoke/internal/server/driver/iface" + "github.com/Neur0toxine/sshpoke/internal/server/driver/base" "github.com/Neur0toxine/sshpoke/internal/server/driver/plugin" "github.com/Neur0toxine/sshpoke/pkg/dto" ) type Manager struct { rw sync.RWMutex - servers map[string]iface.Driver + servers map[string]base.Driver plugins map[string]plugin.Plugin + statusMap map[string]serverStatus + statusLock sync.RWMutex defaultServer string } +type serverStatus struct { + Name string `json:"name"` + Connections Connections `json:"connections"` +} + var DefaultManager *Manager var ( ErrNoServer = errors.New("server is not specified") @@ -28,7 +36,7 @@ var ( func NewManager(ctx context.Context, servers []config.Server, defaultServer string) *Manager { m := &Manager{ - servers: make(map[string]iface.Driver), + servers: make(map[string]base.Driver), plugins: make(map[string]plugin.Plugin), defaultServer: defaultServer, } @@ -38,6 +46,7 @@ func NewManager(ctx context.Context, servers []config.Server, defaultServer stri logger.Sugar.Errorf("cannot initialize server '%s': %s", serverConfig.Name, err) continue } + server.SetEventStatusCallback(m.eventStatusCallback(server.Name())) if server.Driver() == config.DriverPlugin { pl := server.(plugin.Plugin) if pl.Token() == "" { @@ -73,6 +82,37 @@ func (m *Manager) ProcessEvent(event dto.Event) error { return srv.Handle(event) } +func (m *Manager) eventStatusCallback(serverName string) base.EventStatusCallback { + return func(status dto.EventStatus) { + m.processEventStatus(serverName, status) + } +} + +func (m *Manager) processEventStatus(serverName string, event dto.EventStatus) { + m.statusLock.RLock() + _, exists := m.statusMap[serverName] + if !exists { + return + } + m.statusLock.RUnlock() + defer m.statusLock.Unlock() + m.statusLock.Lock() + item, found := docker.Default.GetContainer(event.ID) + if !found { + return + } + switch event.Type { + case dto.EventStart: + item.Domain = event.Domain + case dto.EventStop, dto.EventShutdown, dto.EventError: + item.Domain = "" + + default: + return + } + m.statusMap[serverName].Connections[item.ID] = item +} + func (m *Manager) PluginByToken(token string) plugin.Plugin { server, ok := m.plugins[token] if !ok { diff --git a/pkg/convert/convert.go b/pkg/convert/convert.go index 54bfa73..d75ee2b 100644 --- a/pkg/convert/convert.go +++ b/pkg/convert/convert.go @@ -9,14 +9,15 @@ import ( func MessageToAppEvent(event *pb.EventMessage) dto.Event { return dto.Event{ - Type: MessageEventTypeToApp(event.Type), - ID: event.Id, + Type: MessageEventTypeToApp(event.GetType()), Container: dto.Container{ - IP: net.ParseIP(event.Container.Ip), - Port: uint16(event.Container.Port), - Server: event.Container.Server, - Prefix: event.Container.Prefix, - Domain: event.Container.Domain, + ID: event.GetContainer().GetId(), + Names: event.GetContainer().GetNames(), + IP: net.ParseIP(event.GetContainer().GetIp()), + Port: uint16(event.GetContainer().GetPort()), + Server: event.GetContainer().GetServer(), + Prefix: event.GetContainer().GetPrefix(), + Domain: event.GetContainer().GetDomain(), }, } } @@ -24,8 +25,9 @@ func MessageToAppEvent(event *pb.EventMessage) dto.Event { func AppEventToMessage(event dto.Event) *pb.EventMessage { return &pb.EventMessage{ Type: AppEventTypeToMessage(event.Type), - Id: event.ID, Container: &pb.Container{ + Id: event.Container.ID, + Names: event.Container.Names, Ip: event.Container.IP.String(), Port: uint32(event.Container.Port), Server: event.Container.Server, @@ -35,8 +37,18 @@ func AppEventToMessage(event dto.Event) *pb.EventMessage { } } +func MessageToAppEventStatus(val *pb.EventStatusMessage) dto.EventStatus { + return dto.EventStatus{ + Type: MessageEventTypeToApp(val.GetType()), + ID: val.GetId(), + Error: val.GetError(), + Domain: val.GetDomain(), + } +} + func AppEventStatusToMessage(status dto.EventStatus) *pb.EventStatusMessage { return &pb.EventStatusMessage{ + Type: AppEventTypeToMessage(status.Type), Id: status.ID, Error: status.Error, Domain: status.Domain, @@ -58,7 +70,9 @@ func AppEventTypeToMessage(typ dto.EventType) pb.EventType { case 1: return pb.EventType_EVENT_STOP case 2: - fallthrough + return pb.EventType_EVENT_SHUTDOWN + case 3: + return pb.EventType_EVENT_ERROR default: return pb.EventType_EVENT_UNKNOWN } diff --git a/pkg/dto/models.go b/pkg/dto/models.go index 7029844..8f097d4 100644 --- a/pkg/dto/models.go +++ b/pkg/dto/models.go @@ -8,6 +8,7 @@ const ( EventStart EventType = iota EventStop EventShutdown + EventError EventUnknown ) @@ -24,20 +25,22 @@ func TypeFromAction(action string) EventType { type Event struct { Type EventType - ID string Container Container } type EventStatus struct { + Type EventType ID string Error string Domain string } type Container struct { - IP net.IP - Port uint16 - Server string - Prefix string - Domain string + ID string `json:"id"` + Names []string `json:"names"` + IP net.IP `json:"ip"` + Port uint16 `json:"port"` + Server string `json:"-"` + Prefix string `json:"prefix"` + Domain string `json:"domain"` } diff --git a/pkg/plugin/pb/pb.proto b/pkg/plugin/pb/pb.proto index 308607c..338749b 100644 --- a/pkg/plugin/pb/pb.proto +++ b/pkg/plugin/pb/pb.proto @@ -13,15 +13,18 @@ enum EventType { EVENT_START = 0; EVENT_STOP = 1; EVENT_SHUTDOWN = 2; - EVENT_UNKNOWN = 3; + EVENT_ERROR = 3; + EVENT_UNKNOWN = 4; } message Container { - string ip = 1; - uint32 port = 2; - string server = 3; - string prefix = 4; - string domain = 5; + string id = 1; + repeated string names = 2; + string ip = 3; + uint32 port = 4; + string server = 5; + string prefix = 6; + string domain = 7; } message EventMessage { @@ -31,7 +34,8 @@ message EventMessage { } message EventStatusMessage { - string id = 1; - string error = 2; - string domain = 3; + EventType type = 1; + string id = 2; + string error = 3; + string domain = 4; }