From 7087045b387746ab5cd746ef752f7c945b8aa3da Mon Sep 17 00:00:00 2001 From: Henry Barreto Date: Mon, 25 Sep 2023 17:54:05 -0300 Subject: [PATCH] feat(agent,pkg): add connector mode to agent What is the ShellHub Connector? ShellHub Connector is a new kind of ShellHub Agent that turns [Docker](https://www.docker.com/) containers into ShellHub Devices. It instances a new ShellHub Agent, in memory, for each container running, redirecting the SSH IO, connections in general, and authentication credentials to its file system. > As most of the containers don't have passwords set for its users per default, it rejects the connection every time for users without credentials. What is working now? The initial implementation has support for these kinds of connections: - [x] Shell - [x] Exec - [x] Heredoc Running Connector To initialize the ShellHub Connector, enter the `agent/` directory, build and run the agent's binary with *connector* sub command. ```sh go build -ldflags "-X main.AgentVersion=latest" -o agent-native && SERVER_ADDRESS="http://localhost/" PRIVATE_KEYS="/tmp/shellhub/" TENANT_ID="00000000-0000-4000-0000-000000000000" ./agent-native connector ``` Environmental variables To configure the ShellHub Connector, you can/must provide these environmental variables. - SERVER_ADDRESS (**required**) Set the ShellHub server address of the agent will use to connect - PRIVATE_KEYS (**required**) Specify the path to store the devices/containers private keys. If not provided, the agent will generate a new one. - TENANT_ID (**required**) Sets the account tenant ID used during communication to associate the devices to a specific tenant. - KEEPALIVE_INTERVAL Determine the interval to send the keep alive message to the server. This has a direct impact on the bandwidth used by the device when in idle state. Docker notes As this implementation uses the Docker Client, you can override the environment variables provided for Docker in order to change some behaviors, but the ShellHub Connector doesn't guarantee its right operation with these changes. Check [Docker Client documentation about this](https://pkg.go.dev/github.com/docker/docker@v24.0.5+incompatible/client# FromEnv) for more information. --- agent/connector/connector.go | 32 +++ agent/connector/docker.go | 222 ++++++++++++++++++ agent/main.go | 47 ++++ pkg/agent/agent.go | 118 ++++++++-- pkg/agent/pkg/osauth/auth.go | 37 +++ pkg/agent/pkg/osauth/mocks/osauther.go | 42 ++++ .../server/modes/connector/authenticator.go | 166 +++++++++++++ pkg/agent/server/modes/connector/connector.go | 83 +++++++ pkg/agent/server/modes/connector/sessioner.go | 212 +++++++++++++++++ pkg/agent/server/modes/modes.go | 5 + pkg/agent/server/server.go | 18 +- pkg/agent/server/session.go | 2 + 12 files changed, 961 insertions(+), 23 deletions(-) create mode 100644 agent/connector/connector.go create mode 100644 agent/connector/docker.go create mode 100644 pkg/agent/server/modes/connector/authenticator.go create mode 100644 pkg/agent/server/modes/connector/connector.go create mode 100644 pkg/agent/server/modes/connector/sessioner.go diff --git a/agent/connector/connector.go b/agent/connector/connector.go new file mode 100644 index 00000000000..4ac63be2a6f --- /dev/null +++ b/agent/connector/connector.go @@ -0,0 +1,32 @@ +package connector + +import ( + "context" +) + +// Container is a struct that represents a container that will be managed by the connector. +type Container struct { + // ID is the container ID. + ID string + // ServerAddress is the ShellHub address of the server that the agent will connect to. + ServerAddress string + // Tenant is the tenant ID of the namespace that the agent belongs to. + Tenant string + // PrivateKey is the private key of the device. Specify the path to store the container private key. If not + // provided, the agent will generate a new one. This is required. + PrivateKey string + // Cancel is a function that is used to stop the goroutine that is running the agent for this container. + Cancel context.CancelFunc +} + +// Connector is an interface that defines the methods that a connector must implement. +type Connector interface { + // List lists all containers running on the host. + List(ctx context.Context) ([]string, error) + // Start starts the agent for the container with the given ID. + Start(ctx context.Context, id string) + // Stop stops the agent for the container with the given ID. + Stop(ctx context.Context, id string) + // Listen listens for events and starts or stops the agent for the container that was created or removed. + Listen(ctx context.Context) error +} diff --git a/agent/connector/docker.go b/agent/connector/docker.go new file mode 100644 index 00000000000..f8b2c4d7512 --- /dev/null +++ b/agent/connector/docker.go @@ -0,0 +1,222 @@ +package connector + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/events" + dockerclient "github.com/docker/docker/client" + "github.com/shellhub-io/shellhub/pkg/agent" + log "github.com/sirupsen/logrus" +) + +var _ Connector = new(DockerConnector) + +// DockerConnector is a struct that represents a connector that uses Docker as the container runtime. +type DockerConnector struct { + mu sync.Mutex + // server is the ShellHub address of the server that the agent will connect to. + server string + // tenant is the tenant ID of the namespace that the agent belongs to. + tenant string + // cli is the Docker client. + cli *dockerclient.Client + // privateKeys is the path to the directory that contains the private keys for the containers. + privateKeys string + // cancels is a map that contains the cancel functions for each container. + // This is used to stop the agent for a container, marking as done its context and closing the agent. + cancels map[string]context.CancelFunc +} + +// NewDockerConnector creates a new [Connector] that uses Docker as the container runtime. +func NewDockerConnector(server string, tenant string, privateKey string) (Connector, error) { + cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation()) + if err != nil { + return nil, err + } + + return &DockerConnector{ + server: server, + tenant: tenant, + cli: cli, + privateKeys: privateKey, + cancels: make(map[string]context.CancelFunc), + }, nil +} + +// events returns the docker events. +func (d *DockerConnector) events(ctx context.Context) (<-chan events.Message, <-chan error) { + return d.cli.Events(ctx, types.EventsOptions{}) +} + +func (d *DockerConnector) List(ctx context.Context) ([]string, error) { + containers, err := d.cli.ContainerList(ctx, types.ContainerListOptions{}) + if err != nil { + return nil, err + } + + list := make([]string, len(containers)) + for i, container := range containers { + list[i] = container.ID + } + + return list, nil +} + +// Start starts the agent for the container with the given ID. +func (d *DockerConnector) Start(ctx context.Context, id string) { + id = id[:12] + + d.mu.Lock() + ctx, d.cancels[id] = context.WithCancel(ctx) + d.mu.Unlock() + + privateKey := fmt.Sprintf("%s/%s.key", d.privateKeys, id) + go initContainerAgent(ctx, Container{ + ID: id, + ServerAddress: d.server, + Tenant: d.tenant, + PrivateKey: privateKey, + Cancel: d.cancels[id], + }) +} + +// Stop stops the agent for the container with the given ID. +func (d *DockerConnector) Stop(_ context.Context, id string) { + id = id[:12] + + d.mu.Lock() + defer d.mu.Unlock() + + cancel, ok := d.cancels[id] + if ok { + cancel() + delete(d.cancels, id) + } +} + +// Listen listens for events and starts or stops the agent for the containers. +func (d *DockerConnector) Listen(ctx context.Context) error { + containers, err := d.List(ctx) + if err != nil { + return err + } + + for _, container := range containers { + d.Start(ctx, container) + } + + events, errs := d.events(ctx) + for { + select { + case <-ctx.Done(): + return nil + case err := <-errs: + return err + case container := <-events: + // NOTICE: "start" and "die" Docker's events are call every time a new container start or stop, + // independently how the command was run. For example, if a container was started with `docker run -d`, the + // "start" event will be called, but if the same container was started with `docker start `, + // the "start" event will be called too. The same happens with the "die" event. + switch container.Action { + case "start": + d.Start(ctx, container.ID) + case "die": + d.Stop(ctx, container.ID) + } + } + } +} + +// initContainerAgent initializes the agent for a container. +func initContainerAgent(ctx context.Context, container Container) { + cfg := &agent.Config{ + ServerAddress: container.ServerAddress, + TenantID: container.Tenant, + PrivateKey: container.PrivateKey, + PreferredHostname: container.ID, + PreferredIdentity: container.ID, + Mode: agent.ModeConnector, + KeepAliveInterval: 30, + } + + log.WithFields(log.Fields{ + "id": container.ID, + "identity": cfg.PreferredIdentity, + "hostname": cfg.PreferredHostname, + "tenant_id": cfg.TenantID, + "server_address": cfg.ServerAddress, + "timestamp": time.Now(), + }).Info("Connector container started") + + ag, err := agent.NewAgentWithConfig(cfg) + if err != nil { + log.WithError(err).WithFields(log.Fields{ + "id": container.ID, + "configuration": cfg, + }).Fatal("Failed to create agent") + } + + if err := ag.Initialize(); err != nil { + log.WithError(err).WithFields(log.Fields{ + "id": container.ID, + "configuration": cfg, + }).Fatal("Failed to initialize agent") + } + + go func() { + if err := ag.Ping(ctx, 0); err != nil { + log.WithError(err).WithFields(log.Fields{ + "id": container.ID, + "identity": cfg.PreferredIdentity, + "hostname": cfg.PreferredHostname, + "tenant_id": cfg.TenantID, + "server_address": cfg.ServerAddress, + "timestamp": time.Now(), + }).Fatal("Failed to ping server") + } + + log.WithFields(log.Fields{ + "id": container.ID, + "identity": cfg.PreferredIdentity, + "hostname": cfg.PreferredHostname, + "tenant_id": cfg.TenantID, + "server_address": cfg.ServerAddress, + "timestamp": time.Now(), + }).Info("Stopped pinging server") + }() + + log.WithFields(log.Fields{ + "id": container.ID, + "identity": cfg.PreferredIdentity, + "hostname": cfg.PreferredHostname, + "tenant_id": cfg.TenantID, + "server_address": cfg.ServerAddress, + "timestamp": time.Now(), + }).Info("Listening for connections") + + // NOTICE(r): listing for connection and wait for a channel message to close the agent. It will receives + // this mensagem when something out of this goroutine send a `done`, what will cause the agent closes + // and no more connection to be allowed until it be started again. + if err := ag.Listen(ctx); err != nil { + log.WithError(err).WithFields(log.Fields{ + "id": container.ID, + "identity": cfg.PreferredIdentity, + "hostname": cfg.PreferredHostname, + "tenant_id": cfg.TenantID, + "server_address": cfg.ServerAddress, + "timestamp": time.Now(), + }).Fatal("Failed to listen for connections") + } + + log.WithFields(log.Fields{ + "id": container.ID, + "identity": cfg.PreferredIdentity, + "hostname": cfg.PreferredHostname, + "tenant_id": cfg.TenantID, + "server_address": cfg.ServerAddress, + }).Info("Connector container done") +} diff --git a/agent/main.go b/agent/main.go index 460878b4976..35641ad31e7 100644 --- a/agent/main.go +++ b/agent/main.go @@ -4,11 +4,13 @@ import ( "encoding/json" "fmt" "os" + "path" "runtime" "time" "github.com/Masterminds/semver" "github.com/kelseyhightower/envconfig" + "github.com/shellhub-io/shellhub/agent/connector" "github.com/shellhub-io/shellhub/pkg/agent" "github.com/shellhub-io/shellhub/pkg/agent/pkg/selfupdater" "github.com/shellhub-io/shellhub/pkg/envs" @@ -263,6 +265,51 @@ It is initialized by the agent when a new SFTP session is created.`, }, }) + rootCmd.AddCommand(&cobra.Command{ // nolint: exhaustruct + Use: "connector", + Short: "Starts the Connector", + Long: "Starts the Connector, a special kind of Agent that turns your docker containers into ShellHub devices.", + Run: func(cmd *cobra.Command, args []string) { + cfg, err := envs.ParseWithPrefix[agent.ConfigConnector]("shellhub") + if err != nil { + envconfig.Usage("shellhub", &cfg) // nolint:errcheck + log.Fatal(err) + } + + cfg.PrivateKeys = path.Dir(cfg.PrivateKeys) + + log.WithFields(log.Fields{ + "version": AgentVersion, + "address": cfg.ServerAddress, + "tenant_id": cfg.TenantID, + "private_keys": cfg.PrivateKeys, + }).Info("Starting ShellHub Connector") + + connector, err := connector.NewDockerConnector(cfg.ServerAddress, cfg.TenantID, cfg.PrivateKeys) + if err != nil { + log.WithError(err).WithFields(log.Fields{ + "version": AgentVersion, + "address": cfg.ServerAddress, + "tenant_id": cfg.TenantID, + }).Fatal("Failed to create connector") + } + + if err := connector.Listen(cmd.Context()); err != nil { + log.WithError(err).WithFields(log.Fields{ + "version": AgentVersion, + "address": cfg.ServerAddress, + "tenant_id": cfg.TenantID, + }).Fatal("Failed to listen for connections") + } + + log.WithFields(log.Fields{ + "version": AgentVersion, + "address": cfg.ServerAddress, + "tenant_id": cfg.TenantID, + }).Info("Connector stopped") + }, + }) + rootCmd.Version = AgentVersion rootCmd.SetVersionTemplate(fmt.Sprintf("{{ .Name }} version: {{ .Version }}\ngo: %s\n", diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index a06b7043b3d..eca2b3db480 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -22,6 +22,7 @@ // PrivateKey: "/tmp/shellhub.key", // } // +// ctx := context.Background() // ag, err := NewAgentWithConfig(&cfg) // if err != nil { // panic(err) @@ -31,14 +32,7 @@ // panic(err) // } // -// listing := make(chan bool) -// go func() { -// <-listing -// -// log.Println("listing") -// }() -// -// ag.Listen(listing) +// ag.Listen(ctx) // } // // [ShellHub Agent]: https://github.com/shellhub-io/shellhub/tree/master/agent @@ -58,12 +52,14 @@ import ( "time" "github.com/Masterminds/semver" + dockerclient "github.com/docker/docker/client" "github.com/labstack/echo/v4" "github.com/pkg/errors" "github.com/shellhub-io/shellhub/pkg/agent/pkg/keygen" "github.com/shellhub-io/shellhub/pkg/agent/pkg/sysinfo" "github.com/shellhub-io/shellhub/pkg/agent/pkg/tunnel" "github.com/shellhub-io/shellhub/pkg/agent/server" + "github.com/shellhub-io/shellhub/pkg/agent/server/modes" "github.com/shellhub-io/shellhub/pkg/api/client" "github.com/shellhub-io/shellhub/pkg/models" "github.com/shellhub-io/shellhub/pkg/revdial" @@ -85,6 +81,30 @@ var AgentVersion string // [ShellHub Agent]: https://github.com/shellhub-io/shellhub/tree/master/agent var AgentPlatform string +// Mode is the Agente execution mode. +// +// The agent can be executed in two different modes: host and connector. +// The host mode is the default mode, where the agent will listen for incoming connections and will be responsible for +// the SSH server. The connector mode is used to turn all containers inside a host into a single device and be +// responsible for the SSH server of all containers. +// +// Check [ModeHost] and [ModeConnector] for more information. +type Mode string + +const ( + // ModeHost is the Agent execution mode for `host`. + // + // The host mode is the default mode one, and turns the host machine into a ShellHub's Agent. The host is + // responsible for the SSH server, authentication and authorization, `/etc/passwd`, `/etc/shadow`, and etc. + ModeHost Mode = "host" + // ModeConnector is the Agent execution mode for `connector`. + // + // The connector mode is used to turn a container inside a host into a single device ShellHub's Agent. The host is + // responsible for the SSH server, but the authentication and authorization is made by either the conainer + // internals, `passwd` or `shadow`, or by the ShellHub API. + ModeConnector Mode = "connector" +) + // Config provides the configuration for the agent service. type Config struct { // Set the ShellHub Cloud server address the agent will use to connect. @@ -118,6 +138,32 @@ type Config struct { // multi-user mode (with root privileges) is enabled by default. // NOTE: The password hash could be generated by ```openssl passwd```. SingleUserPassword string `envconfig:"simple_user_password"` + // Mode is the Agent execution mode that it will operate. + // + // Check [Mode] for more information. + Mode Mode `envconfig:"mode" default:"host"` +} + +// ConfigConnector provides the configuration for the agent connector service. +type ConfigConnector struct { + // Set the ShellHub server address the agent will use to connect. + // This is required. + ServerAddress string `envconfig:"server_address" required:"true"` + + // Specify the path to store the devices/containers private keys. + // If not provided, the agent will generate a new one. + // This is required. + PrivateKeys string `envconfig:"private_keys" required:"true"` + + // Sets the account tenant id used during communication to associate the + // devices to a specific tenant. + // This is required. + TenantID string `envconfig:"tenant_id" required:"true"` + + // Determine the interval to send the keep alive message to the server. This + // has a direct impact of the bandwidth used by the device when in idle + // state. Default is 30 seconds. + KeepAliveInterval int `envconfig:"keepalive_interval" default:"30"` } type Agent struct { @@ -268,20 +314,47 @@ func (a *Agent) generateDeviceIdentity() error { // loadDeviceInfo load some device informations like OS name, version, arch and platform. func (a *Agent) loadDeviceInfo() error { - osrelease, err := sysinfo.GetOSRelease() - if err != nil { - return err - } + switch a.config.Mode { + case ModeHost: + osrelease, err := sysinfo.GetOSRelease() + if err != nil { + return err + } - a.Info = &models.DeviceInfo{ - ID: osrelease.ID, - PrettyName: osrelease.Name, - Version: AgentVersion, - Arch: runtime.GOARCH, - Platform: AgentPlatform, - } + a.Info = &models.DeviceInfo{ + ID: osrelease.ID, + PrettyName: osrelease.Name, + Version: AgentVersion, + Arch: runtime.GOARCH, + Platform: AgentPlatform, + } - return nil + return nil + case ModeConnector: + cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation()) + if err != nil { + return err + } + + defer cli.Close() + + info, err := cli.ContainerInspect(context.Background(), a.config.PreferredIdentity) + if err != nil { + return err + } + + a.Info = &models.DeviceInfo{ + ID: "docker", + PrettyName: info.Config.Image, + Version: AgentVersion, + Arch: runtime.GOARCH, + Platform: AgentPlatform, + } + + return nil + default: + return errors.New("invalid Agent execution mode") + } } // probeServerInfo probe server information. @@ -326,7 +399,7 @@ func (a *Agent) Close() error { // listening parameter is a channel that is notified when the agent is listing for connections. It can be used to // start to ping the server, synchronizing device information or other tasks. func (a *Agent) Listen(ctx context.Context) error { - a.server = server.NewServer(a.cli, a.authData, a.config.PrivateKey, a.config.KeepAliveInterval, a.config.SingleUserPassword) + a.server = server.NewServer(a.cli, a.authData, a.config.PrivateKey, a.config.KeepAliveInterval, a.config.SingleUserPassword, modes.Mode(a.config.Mode)) serv := a.server @@ -546,6 +619,9 @@ func (a *Agent) Ping(ctx context.Context, durantion time.Duration) error { "version": AgentVersion, "tenant_id": a.authData.Namespace, "server_address": a.config.ServerAddress, + "name": a.authData.Name, + "hostname": a.config.PreferredHostname, + "identity": a.config.PreferredIdentity, "timestamp": time.Now(), }).Info("Ping") } diff --git a/pkg/agent/pkg/osauth/auth.go b/pkg/agent/pkg/osauth/auth.go index 32c369d85b5..23e9d7ba31e 100644 --- a/pkg/agent/pkg/osauth/auth.go +++ b/pkg/agent/pkg/osauth/auth.go @@ -2,6 +2,7 @@ package osauth import ( "bufio" + "errors" "fmt" "io" "os" @@ -19,8 +20,10 @@ import ( //go:generate mockery --name=OSAuther --filename=osauther.go type OSAuther interface { AuthUser(username, password string) bool + AuthUserFromShadow(username, password string, shadow io.Reader) bool VerifyPasswordHash(hash, password string) bool LookupUser(username string) *User + LookupUserFromPasswd(username string, passwd io.Reader) (*User, error) } type OSAuth struct{} @@ -50,6 +53,21 @@ func (l *OSAuth) AuthUser(username, password string) bool { return false } +// AuthUserFromShadow checks if the given username and password are valid for the given shadow file. +// TODO: Use this functin inside the AuthUser. +func (l *OSAuth) AuthUserFromShadow(username string, password string, shadow io.Reader) bool { + entries, err := parseShadowReader(shadow) + if err != nil { + return false + } + + if entry, ok := entries[username]; ok { + return l.VerifyPasswordHash(entry.Password, password) + } + + return false +} + func (l *OSAuth) VerifyPasswordHash(hash, password string) bool { if hash == "" { logrus.Error("Password entry is empty") @@ -80,6 +98,25 @@ func (l *OSAuth) VerifyPasswordHash(hash, password string) bool { return err == nil } +// ErrUserNotFound is returned when the user is not found in the passwd file. +var ErrUserNotFound = errors.New("user not found") + +// LookupUserFromPasswd reads the passwd file from the given reader and returns the user, if found. +// TODO: Use this function inside the LookupUser. +func (l *OSAuth) LookupUserFromPasswd(username string, passwd io.Reader) (*User, error) { + entries, err := parsePasswdReader(passwd) + if err != nil { + return nil, err + } + + user, found := entries[username] + if !found { + return nil, ErrUserNotFound + } + + return &user, nil +} + func (l *OSAuth) LookupUser(username string) *User { if os.Geteuid() != 0 { return singleUser() diff --git a/pkg/agent/pkg/osauth/mocks/osauther.go b/pkg/agent/pkg/osauth/mocks/osauther.go index 1b9271b440b..1930c16a6a1 100644 --- a/pkg/agent/pkg/osauth/mocks/osauther.go +++ b/pkg/agent/pkg/osauth/mocks/osauther.go @@ -3,6 +3,8 @@ package mocks import ( + io "io" + osauth "github.com/shellhub-io/shellhub/pkg/agent/pkg/osauth" mock "github.com/stretchr/testify/mock" ) @@ -26,6 +28,20 @@ func (_m *OSAuther) AuthUser(username string, password string) bool { return r0 } +// AuthUserFromShadow provides a mock function with given fields: username, password, shadow +func (_m *OSAuther) AuthUserFromShadow(username string, password string, shadow io.Reader) bool { + ret := _m.Called(username, password, shadow) + + var r0 bool + if rf, ok := ret.Get(0).(func(string, string, io.Reader) bool); ok { + r0 = rf(username, password, shadow) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + // LookupUser provides a mock function with given fields: username func (_m *OSAuther) LookupUser(username string) *osauth.User { ret := _m.Called(username) @@ -42,6 +58,32 @@ func (_m *OSAuther) LookupUser(username string) *osauth.User { return r0 } +// LookupUserFromPasswd provides a mock function with given fields: username, passwd +func (_m *OSAuther) LookupUserFromPasswd(username string, passwd io.Reader) (*osauth.User, error) { + ret := _m.Called(username, passwd) + + var r0 *osauth.User + var r1 error + if rf, ok := ret.Get(0).(func(string, io.Reader) (*osauth.User, error)); ok { + return rf(username, passwd) + } + if rf, ok := ret.Get(0).(func(string, io.Reader) *osauth.User); ok { + r0 = rf(username, passwd) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*osauth.User) + } + } + + if rf, ok := ret.Get(1).(func(string, io.Reader) error); ok { + r1 = rf(username, passwd) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // VerifyPasswordHash provides a mock function with given fields: hash, password func (_m *OSAuther) VerifyPasswordHash(hash string, password string) bool { ret := _m.Called(hash, password) diff --git a/pkg/agent/server/modes/connector/authenticator.go b/pkg/agent/server/modes/connector/authenticator.go new file mode 100644 index 00000000000..7a63dded9d0 --- /dev/null +++ b/pkg/agent/server/modes/connector/authenticator.go @@ -0,0 +1,166 @@ +package connector + +import ( + "archive/tar" + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + + dockerclient "github.com/docker/docker/client" + gliderssh "github.com/gliderlabs/ssh" + "github.com/shellhub-io/shellhub/pkg/agent/pkg/osauth" + "github.com/shellhub-io/shellhub/pkg/agent/server/modes" + "github.com/shellhub-io/shellhub/pkg/api/client" + "github.com/shellhub-io/shellhub/pkg/models" + gossh "golang.org/x/crypto/ssh" +) + +// NOTICE: Ensures the Authenticator interface is implemented. +var _ modes.Authenticator = (*Authenticator)(nil) + +// Authenticator implements the Authenticator interface when the server is running in connector mode. +type Authenticator struct { + // api is a client to communicate with the ShellHub's API. + api client.Client + // authData is the authentication data received from the API to authenticate the device. + authData *models.DeviceAuthResponse + // container is the device name. + // + // NOTICE: Uses a pointer for later assignment. + container *string + // docker is a client to communicate with the Docker's API. + docker dockerclient.APIClient + // osauth is an instance of the OSAuth interface to authenticate the user on the Operating System. + osauth osauth.OSAuther +} + +// NewAuthenticator creates a new instance of Authenticator for the connector mode. +func NewAuthenticator(api client.Client, docker dockerclient.APIClient, authData *models.DeviceAuthResponse, container *string) *Authenticator { + return &Authenticator{ + api: api, + authData: authData, + container: container, + docker: docker, + osauth: new(osauth.OSAuth), + } +} + +// getPasswd return a [io.Reader] for the container's passwd file. +func getPasswd(ctx context.Context, cli dockerclient.APIClient, container string) (io.Reader, error) { + passwdTar, _, err := cli.CopyFromContainer(ctx, container, "/etc/passwd") + if err != nil { + return nil, err + } + + passwd := tar.NewReader(passwdTar) + if _, err := passwd.Next(); err != nil { + return nil, err + } + + return passwd, nil +} + +// Password handles the server's SSH password authentication when server is running in connector mode. +func (a *Authenticator) Password(ctx gliderssh.Context, username string, password string) bool { + passwd, err := getPasswd(ctx, a.docker, *a.container) + if err != nil { + return false + } + + user, err := a.osauth.LookupUserFromPasswd(username, passwd) + if err != nil { + return false + } + + if user.Password == "" { + // NOTICE(r): when the user doesn't have password, we block the login. + return false + } + + shadowTar, _, err := a.docker.CopyFromContainer(ctx, *a.container, "/etc/shadow") + if err != nil { + return false + } + + shadow := tar.NewReader(shadowTar) + if _, err := shadow.Next(); err != nil { + return false + } + + if !a.osauth.AuthUserFromShadow(username, password, shadow) { + return false + } + + // NOTICE: set the osauth.User to the context to be obtained later on. + ctx.SetValue("user", user) + + return true +} + +// PublicKey handles the server's SSH public key authentication when server is running in connector mode. +func (a *Authenticator) PublicKey(ctx gliderssh.Context, username string, key gliderssh.PublicKey) bool { + passwd, err := getPasswd(ctx, a.docker, *a.container) + if err != nil { + return false + } + + user, err := a.osauth.LookupUserFromPasswd(username, passwd) + if err != nil { + return false + } + + type Signature struct { + Username string + Namespace string + } + + sig := &Signature{ + Username: username, + Namespace: *a.container, + } + + sigBytes, err := json.Marshal(sig) + if err != nil { + return false + } + + sigHash := sha256.Sum256(sigBytes) + + res, err := a.api.AuthPublicKey(&models.PublicKeyAuthRequest{ + Fingerprint: gossh.FingerprintLegacyMD5(key), + Data: string(sigBytes), + }, a.authData.Token) + if err != nil { + return false + } + + digest, err := base64.StdEncoding.DecodeString(res.Signature) + if err != nil { + return false + } + + cryptoKey, ok := key.(gossh.CryptoPublicKey) + if !ok { + return false + } + + pubCrypto := cryptoKey.CryptoPublicKey() + + pubKey, ok := pubCrypto.(*rsa.PublicKey) + if !ok { + return false + } + + if err = rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, sigHash[:], digest); err != nil { + return false + } + + // NOTICE: set the osauth.User to the context to be obtained later on. + ctx.SetValue("user", user) + + return true +} diff --git a/pkg/agent/server/modes/connector/connector.go b/pkg/agent/server/modes/connector/connector.go new file mode 100644 index 00000000000..3a78417cb5a --- /dev/null +++ b/pkg/agent/server/modes/connector/connector.go @@ -0,0 +1,83 @@ +// Package connector defines methods for authentication and sessions handles to SSH when it is running in connector mode. +// +// Connector mode means that the SSH's server runs in the host machine, but redirect the IO to a specific docker +// container, maning its authentication through the container's "/etc/passwd", "/etc/shadow" and etc. +package connector + +import ( + "context" + + "github.com/docker/docker/api/types" + dockerclient "github.com/docker/docker/client" + "github.com/docker/docker/pkg/process" + "github.com/shellhub-io/shellhub/pkg/agent/pkg/osauth" +) + +func attachShellToContainer(ctx context.Context, cli dockerclient.APIClient, container string, user *osauth.User, size [2]uint) (*types.HijackedResponse, string, error) { + return attachToContainer(ctx, cli, "shell", container, user, true, []string{}, size) +} + +func attachExecToContainer(ctx context.Context, cli dockerclient.APIClient, container string, user *osauth.User, isPty bool, commands []string, size [2]uint) (*types.HijackedResponse, string, error) { + return attachToContainer(ctx, cli, "exec", container, user, isPty, commands, size) +} + +func attachHereDocToContainer(ctx context.Context, cli dockerclient.APIClient, container string, user *osauth.User, size [2]uint) (*types.HijackedResponse, string, error) { + return attachToContainer(ctx, cli, "heredoc", container, user, false, []string{}, size) +} + +func attachToContainer(ctx context.Context, cli dockerclient.APIClient, requestType string, container string, user *osauth.User, isPty bool, commands []string, size [2]uint) (*types.HijackedResponse, string, error) { + if user.Shell == "" { + user.Shell = "/bin/sh" + } + + id, err := cli.ContainerExecCreate(ctx, container, types.ExecConfig{ + User: user.Username, + Tty: isPty, + ConsoleSize: &size, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Cmd: func() []string { + switch requestType { + case "shell": + return []string{user.Shell} + case "exec": + // NOTE(r): when the exec session's has `-t` or `-tt` flag, the command must be executed into a tty/pty. + // the Shell's `-c` flag is used to do this. + if isPty { + return append([]string{user.Shell, "-c"}, commands...) + } + + return commands + case "heredoc": + return []string{user.Shell} + default: + return []string{} + } + }(), + }) + if err != nil { + return nil, "", err + } + + res, err := cli.ContainerExecAttach(ctx, id.ID, types.ExecStartCheck{ + Tty: isPty, + ConsoleSize: &size, + }) + + return &res, id.ID, err +} + +func exitCodeExecFromContainer(cli dockerclient.APIClient, id string) (int, error) { + inspected, err := cli.ContainerExecInspect(context.Background(), id) + if err != nil { + return -1, err + } + + if inspected.Running { + // NOTICE: when a process is running after the exec command, it is necessary to kill it. + return 0, process.Kill(inspected.Pid) + } + + return inspected.ExitCode, nil +} diff --git a/pkg/agent/server/modes/connector/sessioner.go b/pkg/agent/server/modes/connector/sessioner.go new file mode 100644 index 00000000000..20e7f3d7de7 --- /dev/null +++ b/pkg/agent/server/modes/connector/sessioner.go @@ -0,0 +1,212 @@ +package connector + +import ( + "errors" + "fmt" + "io" + "sync" + + dockerclient "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + gliderssh "github.com/gliderlabs/ssh" + "github.com/shellhub-io/shellhub/pkg/agent/pkg/osauth" + "github.com/shellhub-io/shellhub/pkg/agent/server/modes" +) + +var ErrUserNotFound = errors.New("user not found on context") + +// NOTICE: Ensures the Sessioner interface is implemented. +var _ modes.Sessioner = (*Sessioner)(nil) + +// Sessioner implements the Sessioner interface when the server is running in connector mode. +type Sessioner struct { + // container is the device name. + // + // NOTICE: It's a pointer because when the server is created, we don't know the device name yet, that is set later. + container *string + docker dockerclient.APIClient +} + +// NewSessioner creates a new instance of Sessioner for the connector mode. +// The container is a pointer to a string because when the server is created, we don't know the device name yet, that +// is set later. +func NewSessioner(container *string, docker dockerclient.APIClient) *Sessioner { + return &Sessioner{ + container: container, + docker: docker, + } +} + +// Shell handles the server's SSH shell session when server is running in connector mode. +func (s *Sessioner) Shell(session gliderssh.Session) error { + sspty, _, _ := session.Pty() + + // NOTICE(r): To identify what the container the connector should connect to, we use the `deviceName` as the container name + container := *s.container + + user, ok := session.Context().Value("user").(*osauth.User) + if !ok { + return ErrUserNotFound + } + + resp, id, err := attachShellToContainer(session.Context(), s.docker, container, user, [2]uint{uint(sspty.Window.Height), uint(sspty.Window.Width)}) + if err != nil { + return err + } + defer resp.Close() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + code, err := exitCodeExecFromContainer(s.docker, id) + if err != nil { + fmt.Println(err) + } + + session.Exit(code) //nolint:errcheck + }() + + if _, err := io.Copy(session, resp.Conn); err != nil && err != io.EOF { + fmt.Println(err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + defer resp.Close() + + if _, err := io.Copy(resp.Conn, session); err != nil && err != io.EOF { + fmt.Println(err) + } + }() + + wg.Wait() + + return nil +} + +// Exec handles the SSH's server exec session when server is running in connector mode. +func (s *Sessioner) Exec(session gliderssh.Session) error { + sspty, _, isPty := session.Pty() + + // NOTICE(r): To identify what the container the connector should connect to, we use the `deviceName` as the container name + container := *s.container + + user, ok := session.Context().Value("user").(*osauth.User) + if !ok { + return ErrUserNotFound + } + + resp, id, err := attachExecToContainer(session.Context(), s.docker, container, user, isPty, session.Command(), [2]uint{uint(sspty.Window.Height), uint(sspty.Window.Width)}) + if err != nil { + return err + } + defer resp.Close() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + code, err := exitCodeExecFromContainer(s.docker, id) + if err != nil { + fmt.Println(err) + } + + session.Exit(code) //nolint:errcheck + }() + + // NOTICE: According to the [Docker] documentation, we can "demultiplex" a command sent to container, but only + // when the exec started doesn't allocate a TTY. As a result, we check if the exec's is requesting it and do + // what was recommended by [Docker]'s to get the stdout and stderr separately. + // + // [Docker]: https://pkg.go.dev/github.com/docker/docker/client#Client.ContainerAttach + if isPty { + if _, err := io.Copy(session, resp.Reader); err != nil && err != io.EOF { + fmt.Println(err) + } + } else { + if _, err := stdcopy.StdCopy(session, session.Stderr(), resp.Reader); err != nil && err != io.EOF { + fmt.Println(err) + } + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + defer resp.CloseWrite() //nolint:errcheck + + if _, err := io.Copy(resp.Conn, session); err != nil && err != io.EOF { + fmt.Println(err) + } + }() + + wg.Wait() + + return nil +} + +// Heredoc handles the server's SSH heredoc session when server is running in connector mode. +// +// heredoc is special block of code that contains multi-line strings that will be redirected to a stdin of a shell. It +// request a shell, but doesn't allocate a pty. +func (s *Sessioner) Heredoc(session gliderssh.Session) error { + sspty, _, _ := session.Pty() + + // NOTICE(r): To identify what the container the connector should connect to, we use the `deviceName` as the container name + container := *s.container + + user, ok := session.Context().Value("user").(*osauth.User) + if !ok { + return ErrUserNotFound + } + + resp, id, err := attachHereDocToContainer(session.Context(), s.docker, container, user, [2]uint{uint(sspty.Window.Height), uint(sspty.Window.Width)}) + if err != nil { + return err + } + defer resp.Close() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + code, err := exitCodeExecFromContainer(s.docker, id) + if err != nil { + fmt.Println(err) + } + + session.Exit(code) //nolint:errcheck + }() + + if _, err := stdcopy.StdCopy(session, session.Stderr(), resp.Reader); err != nil && err != io.EOF { + fmt.Println(err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + defer resp.CloseWrite() //nolint:errcheck + + if _, err := io.Copy(resp.Conn, session); err != nil && err != io.EOF { + fmt.Println(err) + } + }() + + wg.Wait() + + return nil +} + +// SFTP handles the SSH's server sftp session when server is running in connector mode. +// +// sftp is a subsystem of SSH that allows file operations over SSH. +func (s *Sessioner) SFTP(_ gliderssh.Session) error { + return errors.New("SFTP isn't supported to ShellHub Agent in connector mode") +} diff --git a/pkg/agent/server/modes/modes.go b/pkg/agent/server/modes/modes.go index 3cd010ec1a4..6cd0a18c5d4 100644 --- a/pkg/agent/server/modes/modes.go +++ b/pkg/agent/server/modes/modes.go @@ -12,6 +12,11 @@ const ( // HostMode mode means that the SSH's server runs in the host machine, using the host "/etc/passwd", "/etc/shadow", // redirecting the SSH's connection to the device sdin, stdout and stderr and etc. HostMode Mode = "host" + // ConnectorMode represents the SSH's server connector mode. + // + // ConnectorMode mode means that the SSH's server runs in the host machine, but redirect the IO to a specific docker + // container, maning its authentication through the container's "/etc/passwd", "/etc/shadow" and etc. + ConnectorMode Mode = "connector" ) // Authenticator defines the authentication methods used by the SSH's server. diff --git a/pkg/agent/server/server.go b/pkg/agent/server/server.go index b8e70f1415f..b63963faf4e 100644 --- a/pkg/agent/server/server.go +++ b/pkg/agent/server/server.go @@ -6,8 +6,10 @@ import ( "sync" "time" + dockerclient "github.com/docker/docker/client" gliderssh "github.com/gliderlabs/ssh" "github.com/shellhub-io/shellhub/pkg/agent/server/modes" + "github.com/shellhub-io/shellhub/pkg/agent/server/modes/connector" "github.com/shellhub-io/shellhub/pkg/agent/server/modes/host" "github.com/shellhub-io/shellhub/pkg/api/client" "github.com/shellhub-io/shellhub/pkg/models" @@ -86,7 +88,7 @@ const ( ) // NewServer creates a new server SSH agent server. -func NewServer(api client.Client, authData *models.DeviceAuthResponse, privateKey string, keepAliveInterval int, singleUserPassword string) *Server { +func NewServer(api client.Client, authData *models.DeviceAuthResponse, privateKey string, keepAliveInterval int, singleUserPassword string, mode modes.Mode) *Server { server := &Server{ api: api, authData: authData, @@ -94,13 +96,25 @@ func NewServer(api client.Client, authData *models.DeviceAuthResponse, privateKe Sessions: make(map[string]net.Conn), keepAliveInterval: keepAliveInterval, singleUserPassword: singleUserPassword, - mode: modes.HostMode, + mode: mode, } switch server.mode { case modes.HostMode: server.authenticator = host.NewAuthenticator(api, authData, singleUserPassword, &server.deviceName) server.sessioner = host.NewSessioner(&server.deviceName, server.cmds) + case modes.ConnectorMode: + cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv) + if err != nil { + log.Fatal(err) + } + + server.authenticator = connector.NewAuthenticator(api, cli, authData, &server.deviceName) + server.sessioner = connector.NewSessioner(&server.deviceName, cli) + default: + log.WithFields(log.Fields{ + "mode": server.mode, + }).Fatal("Invalid server mode") } server.sshd = &gliderssh.Server{ diff --git a/pkg/agent/server/session.go b/pkg/agent/server/session.go index 9e8f449775e..c4e6d967dba 100644 --- a/pkg/agent/server/session.go +++ b/pkg/agent/server/session.go @@ -60,6 +60,8 @@ func (s *Server) sessionHandler(session gliderssh.Session) { return } + log.WithField("type", sessionType).Info("Request type got") + switch sessionType { case SessionTypeShell: s.sessioner.Shell(session) //nolint:errcheck