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..c55d00b5d02 --- /dev/null +++ b/agent/connector/docker.go @@ -0,0 +1,224 @@ +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.RWMutex + // 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(ctx context.Context, id string) { + id = id[:12] + + d.mu.RLock() + cancel, ok := d.cancels[id] + if ok { + cancel() + } + d.mu.RUnlock() + + 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: + switch container.Action { + case "start": + d.Start(ctx, container.ID) + case "kill": + 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") + } + + listening := make(chan bool) + go func() { + // NOTICE: We only start to ping the server when the agent is ready to accept connections. + // It will make the agent ping to server after the ticker time set on ping function, what is 10 minutes by + // default. + <-listening + + if err := ag.Ping(ctx, nil); 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, listening); 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 1d84c682215..d7823e0eac2 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" @@ -265,6 +267,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 6227e55eb28..e7ca28eec03 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -58,12 +58,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" @@ -90,6 +92,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. @@ -123,6 +149,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 { @@ -271,20 +323,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. @@ -328,8 +407,8 @@ 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, listining chan bool) error { - a.server = server.NewServer(a.cli, a.authData, a.config.PrivateKey, a.config.KeepAliveInterval, a.config.SingleUserPassword) +func (a *Agent) Listen(ctx context.Context, listening chan bool) error { + 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 @@ -421,7 +500,7 @@ func (a *Agent) Listen(ctx context.Context, listining chan bool) error { "version": AgentVersion, "tenant_id": a.authData.Namespace, "server_address": a.config.ServerAddress, - }).Debug("stopped listening for connections") + }).Debug("Stopped listening for connections") return nil } @@ -452,11 +531,11 @@ func (a *Agent) Listen(ctx context.Context, listining chan bool) error { "sshid": sshid, }).Info("Server connection established") - throw(listining, true) + throw(listening, true) if err := a.tunnel.Listen(listener); err != nil { continue } - throw(listining, false) + throw(listening, false) } } @@ -496,6 +575,9 @@ func (a *Agent) Ping(ctx context.Context, ticker *time.Ticker) 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/server/modes/connector/authenticator.go b/pkg/agent/server/modes/connector/authenticator.go new file mode 100644 index 00000000000..0f1fafc3abc --- /dev/null +++ b/pkg/agent/server/modes/connector/authenticator.go @@ -0,0 +1,117 @@ +package connector + +import ( + "archive/tar" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + + 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/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 dockerclient.APIClient +} + +// 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, + } +} + +// 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 { + passwdTar, _, _ := a.docker.CopyFromContainer(ctx, *a.container, "/etc/passwd") + passwd := tar.NewReader(passwdTar) + passwd.Next() //nolint:errcheck + + user, err := LookupUserDocker(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, _, _ := a.docker.CopyFromContainer(ctx, *a.container, "/etc/shadow") + shadow := tar.NewReader(shadowTar) + shadow.Next() //nolint:errcheck + + return authUser(username, password, shadow) +} + +// 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 { + 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 + } + + 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..976a7eaeaf6 --- /dev/null +++ b/pkg/agent/server/modes/connector/connector.go @@ -0,0 +1,282 @@ +// 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 ( + "archive/tar" + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "os/user" + "strconv" + "strings" + + "github.com/GehirnInc/crypt" + "github.com/docker/docker/api/types" + dockerclient "github.com/docker/docker/client" + "github.com/shellhub-io/shellhub/pkg/agent/pkg/yescrypt" +) + +type User struct { + UID uint32 + GID uint32 + Username string + Password string + Name string + HomeDir string + Shell string +} + +type ShadowEntry struct { + Username string // Login name + Password string // Hashed password + Lastchanged int // Days since Jan 1, 1970 that password was last changed + Minimum int // The minimum number of days required between password changes i.e. the number of days left before the user is allowed to change his/her password + Maximum int // The maximum number of days the password is valid (after that user is forced to change his/her password) + Warn int // The number of days before password is to expire that user is warned that his/her password must be changed + Inactive int // The number of days after password expires that account is disabled + Expire int // Days since Jan 1, 1970 that account is disabled i.e. an absolute date specifying when the login may no longer be used. +} + +func parsePasswdLine(line string) (User, error) { + result := User{} + parts := strings.Split(strings.TrimSpace(line), ":") + if len(parts) != 7 { + return result, fmt.Errorf("passwd line had wrong number of parts %d != 7", len(parts)) + } + result.Username = strings.TrimSpace(parts[0]) + result.Password = strings.TrimSpace(parts[1]) + + uid, err := strconv.Atoi(parts[2]) + if err != nil { + return result, fmt.Errorf("passwd line had badly formatted uid %s", parts[2]) + } + result.UID = uint32(uid) + + gid, err := strconv.Atoi(parts[3]) + if err != nil { + return result, fmt.Errorf("passwd line had badly formatted gid %s", parts[3]) + } + result.GID = uint32(gid) + + result.Name = strings.TrimSpace(parts[4]) + result.HomeDir = strings.TrimSpace(parts[5]) + result.Shell = strings.TrimSpace(parts[6]) + + return result, nil +} + +func parsePasswdReader(r io.Reader) (map[string]User, error) { + lines := bufio.NewReader(r) + entries := make(map[string]User) + for { + line, _, err := lines.ReadLine() + if err != nil { + break + } + + if len(line) == 0 || strings.HasPrefix(string(line), "#") { + continue + } + + entry, err := parsePasswdLine(string(line)) + if err != nil { + return nil, err + } + + entries[entry.Username] = entry + } + + return entries, nil //nolint:nilerr +} + +func singleUser() *User { + var uid, gid int + var username, name, homeDir, shell string + u, err := user.Current() + uid, _ = strconv.Atoi(os.Getenv("UID")) + homeDir = os.Getenv("HOME") + shell = os.Getenv("SHELL") + if err == nil { + uid, _ = strconv.Atoi(u.Uid) + gid, _ = strconv.Atoi(u.Gid) + username = u.Username + name = u.Name + homeDir = u.HomeDir + } + + return &User{ + UID: uint32(uid), + GID: uint32(gid), + Username: username, + Name: name, + HomeDir: homeDir, + Shell: shell, + } +} + +func LookupUserDocker(username string, passwdFile io.Reader) (*User, error) { + entries, err := parsePasswdReader(passwdFile) + if err != nil { + return nil, err + } + + user, found := entries[username] + if !found { + return nil, errors.New("user not found") + } + + return &user, nil +} + +// TODO(r): split this function into smaller functions to avoid a lot of function parameters. +func newShellDocker(ctx context.Context, container string, cli dockerclient.APIClient, username string, isPty bool, commands []string, requestType string, size [2]uint) (net.Conn, error) { + passwdTar, _, err := cli.CopyFromContainer(ctx, container, "/etc/passwd") + if err != nil { + return nil, err + } + + passwd := tar.NewReader(passwdTar) + passwd.Next() //nolint:errcheck + + user, err := LookupUserDocker(username, passwd) + if err != nil { + return nil, err + } + + id, err := cli.ContainerExecCreate(ctx, container, types.ExecConfig{ + User: username, + // Privileged: true, + Tty: isPty, + ConsoleSize: &size, + AttachStdin: true, + AttachStderr: true, + AttachStdout: true, + Detach: false, + // DetachKeys: "", + // Env: []string{}, + // WorkingDir: "", + Cmd: func() []string { + switch requestType { + case "shell": + if user.Shell == "" { + return []string{"/bin/sh"} + } + + return []string{user.Shell} + case "exec": + return commands + default: + return []string{} + } + }(), + }) + + res, err := cli.ContainerExecAttach(ctx, id.ID, types.ExecStartCheck{ + Detach: false, + Tty: isPty, + }) + + return res.Conn, nil +} + +func parseShadowReader(r io.Reader) (map[string]ShadowEntry, error) { + lines := bufio.NewReader(r) + entries := make(map[string]ShadowEntry) + + for { + line, _, err := lines.ReadLine() + if err != nil { + break + } + + if len(line) == 0 || strings.HasPrefix(string(line), "#") { + continue + } + + entry, err := parseShadowLine(string(line)) + if err != nil { + return nil, err + } + + entries[entry.Username] = entry + } + + return entries, nil //nolint:nilerr +} + +func parseShadowLine(line string) (ShadowEntry, error) { + result := ShadowEntry{} + parts := strings.Split(strings.TrimSpace(line), ":") + if len(parts) != 9 { + return result, fmt.Errorf("shadow line had wrong number of parts %d != 9", len(parts)) + } + + result.Username = strings.TrimSpace(parts[0]) + result.Password = strings.TrimSpace(parts[1]) + + result.Lastchanged = parseIntString(parts[2]) + result.Minimum = parseIntString(parts[3]) + result.Maximum = parseIntString(parts[4]) + result.Warn = parseIntString(parts[5]) + result.Inactive = parseIntString(parts[6]) + result.Expire = parseIntString(parts[7]) + + return result, nil +} + +func parseIntString(value string) int { + if value != "" { + return 0 + } + + number, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return 0 + } + + return number +} + +func verifyPasswordHash(hash, password string) bool { + if hash == "" { + return false + } + + // If hash algorithm is yescrypt verify by ourselves, otherwise let's try crypt package + if strings.HasPrefix(hash, "$y$") { + return yescrypt.Verify(password, hash) + } + + if ok := crypt.IsHashSupported(hash); !ok { + return false + } + + crypt := crypt.NewFromHash(hash) + if crypt == nil { + return false + } + + err := crypt.Verify(hash, []byte(password)) + + return err == nil +} + +func authUser(username string, password string, shadowFile io.Reader) bool { + entries, err := parseShadowReader(shadowFile) + if err != nil { + return false + } + + if entry, ok := entries[username]; ok { + return verifyPasswordHash(entry.Password, password) + } + + return false +} diff --git a/pkg/agent/server/modes/connector/sessioner.go b/pkg/agent/server/modes/connector/sessioner.go new file mode 100644 index 00000000000..dc7d039b442 --- /dev/null +++ b/pkg/agent/server/modes/connector/sessioner.go @@ -0,0 +1,130 @@ +package connector + +import ( + "errors" + "fmt" + "io" + "os/exec" + + dockerclient "github.com/docker/docker/client" + gliderssh "github.com/gliderlabs/ssh" + "github.com/shellhub-io/shellhub/pkg/agent/server/modes" +) + +// 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, cmds map[string]*exec.Cmd) *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, _, isPty := session.Pty() + + // to identify what the container the connector should connect to, we use the `deviceName` as the container name + container := *s.container + conn, err := newShellDocker(session.Context(), container, s.docker, session.User(), isPty, []string{"/bin/sh"}, "shell", [2]uint{uint(sspty.Window.Height), uint(sspty.Window.Width)}) + if err != nil { + return err + } + + done := make(chan bool) + + go func() { + // container to SSH. + _, err := io.Copy(session, conn) + fmt.Println(err) + if err != nil { + fmt.Println(err) + } + + done <- true + }() + + go func() { + // SSH to container. + _, err := io.Copy(conn, session) + if err != nil { + fmt.Println(err) + } + + done <- true + }() + + // TODO(r): add conn to a list of opened connections. + + <-done + + 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() + + container := *s.container + conn, err := newShellDocker(session.Context(), container, s.docker, session.User(), isPty, session.Command(), "exec", [2]uint{uint(sspty.Window.Height), uint(sspty.Window.Width)}) + if err != nil { + return err + } + + done := make(chan bool) + + go func() { + // container to SSH. + _, err := io.Copy(session, conn) + fmt.Println(err) + if err != nil { + fmt.Println(err) + } + + done <- true + }() + + go func() { + // SSH to container. + _, err := io.Copy(conn, session) + if err != nil { + fmt.Println(err) + } + + done <- true + }() + + <-done + + 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 { + fmt.Fprint(session, "Heredoc isn't supported to ShellHub Agent in connector mode") + + return errors.New("Heredoc isn't supported to ShellHub Agent in connector mode") +} + +// 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(session 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..3c43ea19197 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, server.cmds) + default: + log.WithFields(log.Fields{ + "mode": server.mode, + }).Fatal("Invalid server mode") } server.sshd = &gliderssh.Server{ diff --git a/ui/src/components/Devices/DeviceIcon.vue b/ui/src/components/Devices/DeviceIcon.vue index 6f21e964480..a81a7bcd217 100644 --- a/ui/src/components/Devices/DeviceIcon.vue +++ b/ui/src/components/Devices/DeviceIcon.vue @@ -1,5 +1,10 @@ @@ -39,6 +44,7 @@ export default defineComponent({ "ubuntu-core": "fl-ubuntu", ubuntucore: "fl-ubuntu", void: "fl-void", + docker: "fl-docker", }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment