diff --git a/cmd/root.go b/cmd/root.go index a9a0697..f9d4d90 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,8 +21,23 @@ var rootCmd = &cobra.Command{ Short: "Expose your Docker services to the Internet via SSH.", Long: `sshpoke is a CLI application that listens to the docker socket and automatically exposes relevant services to the Internet.`, Run: func(cmd *cobra.Command, args []string) { + var err error ctx, cancel := context.WithCancel(context.Background()) - events, err := docker.ListenToEvents(ctx) + docker.Default, err = docker.New(ctx) + if err != nil { + logger.Sugar.Fatalf("cannot connect to docker daemon: %s", err) + } + + for id, item := range docker.Default.Containers() { + logger.Sugar.Debugw("registering container", + "id", id, + "ip", item.IP.String(), + "port", item.Port, + "server", item.Server, + "domain", item.Domain) + } + + events, err := docker.Default.Listen() if err != nil { logger.Sugar.Fatalf("cannot listen to docker events: %s", err) } diff --git a/go.mod b/go.mod index 88569e6..5109fc7 100644 --- a/go.mod +++ b/go.mod @@ -10,12 +10,10 @@ require ( github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.17.0 go.uber.org/zap v1.26.0 - golang.design/x/lockfree v0.0.1 ) require ( github.com/Microsoft/go-winio v0.6.1 // indirect - github.com/changkun/lockfree v0.0.1 // indirect github.com/distribution/reference v0.5.0 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/go-units v0.5.0 // indirect diff --git a/go.sum b/go.sum index a1e1015..03cdfb6 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,6 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/changkun/lockfree v0.0.1 h1:5WefVJLglY4IHRqOQmh6Ao6wkJYaJkarshKU8VUtId4= -github.com/changkun/lockfree v0.0.1/go.mod h1:3bKiaXn/iNzIPlSvSOMSVbRQUQtAp8qUAyBUtzU11s4= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -227,8 +225,6 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -golang.design/x/lockfree v0.0.1 h1:IHFNwZgM5bnZYWkEbzn5lWHMYr8WsRBdCJ/RBVY0xMM= -golang.design/x/lockfree v0.0.1/go.mod h1:iaZUx6UgZaOdePjzI6wFd+seYMl1i0rsG8+xKvA8c4I= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/internal/docker/api.go b/internal/docker/api.go index b77ff94..aa36ec1 100644 --- a/internal/docker/api.go +++ b/internal/docker/api.go @@ -7,25 +7,101 @@ import ( "github.com/Neur0toxine/sshpoke/internal/config" "github.com/Neur0toxine/sshpoke/internal/logger" + "github.com/Neur0toxine/sshpoke/internal/model" "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/events" + "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" ) -func ListenToEvents(ctx context.Context) (chan events.Message, error) { +var Default *Docker + +type Docker struct { + cli *client.Client + ctx context.Context +} + +func New(ctx context.Context) (*Docker, error) { + cli, err := client.NewClientWithOpts(config.DefaultConfig.Docker.Opts) + if err != nil { + return nil, err + } + return &Docker{ + cli: cli, + ctx: ctx, + }, nil +} + +func (d *Docker) Containers() map[string]model.Container { + items, err := d.cli.ContainerList(d.ctx, types.ContainerListOptions{ + Filters: filters.NewArgs(filters.Arg("status", "running")), + }) + if err != nil { + logger.Sugar.Errorf("cannot get containers list: %s", err) + return nil + } + containers := map[string]model.Container{} + for _, item := range items { + container, ok := dockerContainerToInternal(item) + if !ok { + continue + } + containers[item.ID] = container + } + return containers +} + +func (d *Docker) Listen() (chan model.ContainerEvent, error) { cli, err := client.NewClientWithOpts(config.DefaultConfig.Docker.Opts) if err != nil { return nil, err } - output := make(chan events.Message) + output := make(chan model.ContainerEvent) go func() { for { - eventSource, errSource := cli.Events(ctx, types.EventsOptions{}) + eventSource, errSource := cli.Events(d.ctx, types.EventsOptions{ + Filters: filters.NewArgs(filters.Arg("type", "container")), + }) select { case event := <-eventSource: - logger.Sugar.Debugf("docker event: %#v", event) - output <- event + eventType := model.TypeFromAction(event.Action) + if (eventType != model.EventStart && eventType != model.EventStop) || !actorEnabled(event.Actor) { + continue + } + if eventType == model.EventStop { + logger.Sugar.Debugw("stopping session", + "type", event.Action, "container.id", event.Actor.ID) + output <- model.ContainerEvent{ + Type: eventType, + ID: event.Actor.ID, + } + continue + } + container, err := d.cli.ContainerList(d.ctx, types.ContainerListOptions{ + Filters: filters.NewArgs(filters.Arg("id", event.Actor.ID)), + }) + if err != nil || len(container) != 1 { + logger.Sugar.Errorw("cannot get container info", + "id", event.Actor.ID, "err", err) + continue + } + converted, ok := dockerContainerToInternal(container[0]) + if !ok { + continue + } + newEvent := model.ContainerEvent{ + Type: eventType, + ID: event.Actor.ID, + Container: converted, + } + logger.Sugar.Debugw("exposing container", + "type", event.Action, + "container.id", event.Actor.ID, + "container.ip", converted.IP.String(), + "container.port", converted.Port, + "container.server", converted.Server, + "container.domain", converted.Domain) + output <- newEvent case err := <-errSource: if errors.Is(err, context.Canceled) { logger.Sugar.Debug("stopping docker event listener...") diff --git a/internal/docker/convert.go b/internal/docker/convert.go new file mode 100644 index 0000000..fe804e7 --- /dev/null +++ b/internal/docker/convert.go @@ -0,0 +1,92 @@ +package docker + +import ( + "net" + "strconv" + + "github.com/Neur0toxine/sshpoke/internal/logger" + "github.com/Neur0toxine/sshpoke/internal/model" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/events" + "github.com/docker/docker/api/types/network" + "github.com/mitchellh/mapstructure" +) + +type labelsConfig struct { + Enable boolStr `mapstructure:"sshpoke.enable"` + Network string `mapstructure:"sshpoke.network"` + Server string `mapstructure:"sshpoke.server"` + Port string `mapstructure:"sshpoke.port"` + Domain string `mapstructure:"sshpoke.domain"` +} + +type boolStr string + +func (b boolStr) Bool() bool { + return string(b) == "true" +} + +func actorEnabled(actor events.Actor) bool { + label, ok := actor.Attributes["sshpoke.enable"] + if !ok { + return false + } + return boolStr(label).Bool() +} + +func dockerContainerToInternal(container types.Container) (result model.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() { + 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 container.NetworkSettings == nil || + container.NetworkSettings.Networks == nil || + len(container.NetworkSettings.Networks) == 0 { + logger.Sugar.Debugf("skipping container %s because network settings are empty", container.ID) + return result, false + } + var ip net.IP + if len(container.NetworkSettings.Networks) == 1 { + ip = net.ParseIP(getKeyVal(container.NetworkSettings.Networks).IPAddress) + } else { + if labels.Network == "" { + logger.Sugar.Debugf("container %s has %d networks and 'sshpoke.network' label is not specified", + container.ID, len(container.NetworkSettings.Networks)) + return result, false + } + ntwrk, exists := container.NetworkSettings.Networks[labels.Network] + if !exists { + logger.Sugar.Debugf("container %s does not have network %s", container.ID, labels.Network) + return result, false + } + ip = net.ParseIP(ntwrk.IPAddress) + } + port, err := strconv.Atoi(labels.Port) + if err != nil { + logger.Sugar.Debugf("skipping container %s because 'sshpoke.port' is not numeric", container.ID) + return result, false + } + + return model.Container{ + IP: ip, + Port: uint16(port), + Server: labels.Server, + Domain: labels.Domain, + }, true +} + +func getKeyVal(m map[string]*network.EndpointSettings) *network.EndpointSettings { + for _, v := range m { + return v + } + return nil +} diff --git a/internal/model/container.go b/internal/model/container.go new file mode 100644 index 0000000..43fedf1 --- /dev/null +++ b/internal/model/container.go @@ -0,0 +1,35 @@ +package model + +import "net" + +type EventType uint8 + +const ( + EventStart EventType = iota + EventStop + EventUnknown +) + +func TypeFromAction(action string) EventType { + switch action { + case "start": + return EventStart + case "stop", "die": + return EventStop + default: + return EventUnknown + } +} + +type ContainerEvent struct { + Type EventType + ID string + Container Container +} + +type Container struct { + IP net.IP + Port uint16 + Server string + Domain string +}