package docker import ( "archive/tar" "bytes" "context" c "docker4ssh/config" "docker4ssh/database" "docker4ssh/terminal" "encoding/json" "fmt" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" "go.uber.org/zap" "io" "io/fs" "net" "os" "path/filepath" "reflect" "strings" "time" ) func simpleContainerFromID(ctx context.Context, client *Client, config Config, containerID string) (*SimpleContainer, error) { inspect, err := client.Client.ContainerInspect(ctx, containerID) if err != nil { return nil, err } sc := &SimpleContainer{ config: config, Image: Image{ ref: inspect.Image, }, ContainerID: containerID[:12], FullContainerID: containerID, client: client, cli: client.Client, } sc.init(ctx) return sc, nil } // newSimpleContainer creates a new container. // Currently, only for internal usage, may be changing in future func newSimpleContainer(ctx context.Context, client *Client, config Config, image Image, containerName string) (*SimpleContainer, error) { // create a new container from the given image and activate in- and output resp, err := client.Client.ContainerCreate(ctx, &container.Config{ Image: image.Ref(), AttachStderr: true, AttachStdin: true, Tty: true, AttachStdout: true, OpenStdin: true, }, nil, nil, nil, containerName) if err != nil { return nil, err } sc := &SimpleContainer{ config: config, Image: image, ContainerID: resp.ID[:12], FullContainerID: resp.ID, client: client, cli: client.Client, } sc.init(ctx) return sc, nil } // SimpleContainer is the basic struct to control a docker4ssh container type SimpleContainer struct { config Config Image Image ContainerID string FullContainerID string started bool cancel context.CancelFunc client *Client // cli is just a shortcut for Client.Client cli *client.Client Network struct { ID string IP string } } func (sc *SimpleContainer) init(ctx context.Context) { // disconnect from default docker network sc.cli.NetworkDisconnect(ctx, sc.client.Network[Host], sc.FullContainerID, true) } // Start starts the container func (sc *SimpleContainer) Start(ctx context.Context) error { if err := sc.cli.ContainerStart(ctx, sc.FullContainerID, types.ContainerStartOptions{}); err != nil { return err } if !sc.started { // initializes all settings. // as third argument is a pseudo empty used to // call every function in SimpleContainer.updateConfig. // for the same reason Config.Configurable and // Config.KeepOnExit are negated from their value in // sc.config if err := sc.updateConfig(ctx, Config{ Configurable: !sc.config.Configurable, KeepOnExit: !sc.config.KeepOnExit, }, sc.config); err != nil { return err } sc.started = true } return nil } // Stop stops the container func (sc *SimpleContainer) Stop(ctx context.Context) error { timeout := 0 * time.Second if err := sc.cli.ContainerStop(ctx, sc.FullContainerID, &timeout); err != nil { return err } if !sc.config.KeepOnExit { if err := sc.cli.ContainerRemove(ctx, sc.FullContainerID, types.ContainerRemoveOptions{Force: true}); err != nil { return err } // delete all references to the container in the database return sc.client.Database.Delete(sc.FullContainerID) } return nil } func (sc *SimpleContainer) Running(ctx context.Context) (bool, error) { resp, err := sc.cli.ContainerInspect(ctx, sc.FullContainerID) if err != nil { return false, err } return resp.State != nil && resp.State.Running, nil } // WaitUntilStop waits until the container stops running func (sc *SimpleContainer) WaitUntilStop(ctx context.Context) error { statusChan, errChan := sc.cli.ContainerWait(ctx, sc.FullContainerID, container.WaitConditionNotRunning) select { case err := <-errChan: return err case <-statusChan: } return nil } // ExecuteConn executes a command in the container and returns the connection to the output func (sc *SimpleContainer) ExecuteConn(ctx context.Context, command string, args ...string) (net.Conn, error) { execID, err := sc.cli.ContainerExecCreate(ctx, sc.FullContainerID, types.ExecConfig{ AttachStdout: true, AttachStderr: true, Cmd: append([]string{command}, args...), }) resp, err := sc.cli.ContainerExecAttach(ctx, execID.ID, types.ExecStartCheck{}) if err != nil { return nil, err } return resp.Conn, err } // Execute executes a command in the container and returns the response after finished func (sc *SimpleContainer) Execute(ctx context.Context, command string, args ...string) ([]byte, error) { buf := bytes.Buffer{} conn, err := sc.ExecuteConn(ctx, command, args...) if err != nil { return nil, err } io.Copy(&buf, conn) return buf.Bytes(), nil } // CopyFrom copies a file from the host system to the client. // Normal files and directories are accepted func (sc *SimpleContainer) CopyFrom(ctx context.Context, src, dst string) error { r, _, err := sc.cli.CopyFromContainer(ctx, sc.FullContainerID, src) if err != nil { return err } defer r.Close() tr := tar.NewReader(r) for { header, err := tr.Next() if err != nil { if err == io.EOF { return nil } return err } target := filepath.Join(dst, header.Name) switch header.Typeflag { case tar.TypeDir: if _, err := os.Stat(target); os.IsNotExist(err) { if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil { return err } } case tar.TypeReg: f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) if err != nil { return err } if _, err = io.Copy(f, tr); err != nil { return err } _ = f.Close() } } } // CopyTo copies a file from the container to host. // Normal files and directories are accepted func (sc *SimpleContainer) CopyTo(ctx context.Context, src, dst string) error { stat, err := os.Stat(src) if err != nil { return err } if stat.IsDir() { err = filepath.Walk(src, func(path string, info fs.FileInfo, err error) error { if err != nil { return err } file, err := os.Open(path) if err != nil { return err } header, err := tar.FileInfoHeader(info, info.Name()) if err != nil { return err } header.Name = strings.TrimPrefix(strings.TrimPrefix(path, src), "/") // write every file to the container. // it might be better to write the file content to a buffer or // store the file pointer in a slice and write the buffer / stored // file pointer to the tar writer when every file was walked // // TODO: Test if the two described methods are better than sending every file on it's own buf := &bytes.Buffer{} tw := tar.NewWriter(buf) if err = tw.WriteHeader(header); err != nil { return err } defer tw.Close() io.Copy(tw, file) err = sc.cli.CopyToContainer(ctx, sc.FullContainerID, dst, buf, types.CopyToContainerOptions{ AllowOverwriteDirWithFile: true, }) if err != nil { return err } return nil }) if err != nil { return err } } else { file, err := os.Open(src) if err != nil { return err } info, err := os.Lstat(src) if err != nil { return err } header, err := tar.FileInfoHeader(info, info.Name()) if err != nil { return err } header.Name = filepath.Base(src) buf := &bytes.Buffer{} tw := tar.NewWriter(buf) if err = tw.WriteHeader(header); err != nil { return err } defer tw.Close() _, _ = io.Copy(tw, file) err = sc.cli.CopyToContainer(ctx, sc.FullContainerID, dst, buf, types.CopyToContainerOptions{ AllowOverwriteDirWithFile: true, }) if err != nil { return err } } return nil } // Config returns the current container config func (sc *SimpleContainer) Config() Config { return sc.config } // UpdateConfig updates the container config func (sc *SimpleContainer) UpdateConfig(ctx context.Context, config Config) error { oldConfig := sc.config if err := sc.updateConfig(ctx, oldConfig, config); err != nil { return err } var ocm, ncm, sm map[string]interface{} sm = make(map[string]interface{}, 0) ocj, _ := json.Marshal(oldConfig) ncj, _ := json.Marshal(config) json.Unmarshal(ocj, &ocm) json.Unmarshal(ncj, &ncm) srt := reflect.TypeOf(database.Settings{}) for k, v := range ocm { newValue := ncm[k] if v != newValue && newValue != nil { field, ok := srt.FieldByName(k) if !ok { continue } sm[field.Tag.Get("json")] = newValue } } // marshal the map into new settings var settings database.Settings body, _ := json.Marshal(sm) json.Unmarshal(body, &settings) err := sc.client.Database.SetSettings(sc.FullContainerID, settings) if err != nil { return err } if config.KeepOnExit { if _, ok := sc.client.Database.GetAuthByContainer(sc.FullContainerID); !ok { if err = sc.client.Database.SetAuth(sc.FullContainerID, database.Auth{ User: &sc.ContainerID, }); err != nil { return err } } } sc.config = config return nil } func (sc *SimpleContainer) updateConfig(ctx context.Context, oldConfig, newConfig Config) error { if newConfig.NetworkMode != oldConfig.NetworkMode { if err := sc.setNetworkMode(ctx, oldConfig.NetworkMode, newConfig.NetworkMode, sc.client.Network != nil); err != nil { return err } zap.S().Debugf("Set network mode for %s to %s", sc.ContainerID, newConfig.NetworkMode.Name()) } if newConfig.Configurable != oldConfig.Configurable { if err := sc.setConfigurable(ctx, newConfig.Configurable); err != nil { return err } zap.S().Debugf("Set configurable for %s to %t", sc.ContainerID, newConfig.Configurable) } if newConfig.ExitAfter != oldConfig.ExitAfter { sc.setExitAfterListener(ctx, newConfig.RunLevel, newConfig.ExitAfter) zap.S().Debugf("Set exit after listener for %s", sc.ContainerID) } sc.config = newConfig return nil } // setNetworkMode changes the network mode for the container func (sc *SimpleContainer) setNetworkMode(ctx context.Context, oldMode, newMode NetworkMode, networking bool) error { var networkID string if !networking { networkID = sc.client.Network[Off] } else { networkID = sc.client.Network[newMode] } if networkID != "" { sc.cli.NetworkDisconnect(ctx, sc.client.Network[oldMode], sc.FullContainerID, true) // connect container to a network if err := sc.cli.NetworkConnect(ctx, networkID, sc.FullContainerID, &network.EndpointSettings{}); err != nil { return err } } // inspect the container to get its ip address (yes i was too lazy to implement // a service that generates the ips without docker) resp, err := sc.cli.ContainerInspect(ctx, sc.FullContainerID) if err != nil { return err } // update the internal network information sc.Network.ID = networkID sc.Network.IP = resp.NetworkSettings.Networks[newMode.NetworkName()].IPAddress return nil } func (sc *SimpleContainer) setConfigurable(ctx context.Context, configurable bool) error { cconfig := c.GetConfig() if configurable { for srcFile, dstDir := range map[string]string{cconfig.Api.Configure.Binary: "/bin", cconfig.Api.Configure.Man: "/usr/share/man/man1"} { if err := sc.CopyTo(ctx, srcFile, dstDir); err != nil { if strings.HasSuffix(dstDir, "/man1") { // man files aren't that necessary, so if the copy fails it throws only a warning. // this error gets thrown when the container is alpine linux, for example. // it does not have a /usr/share/man/man1 directory and the copy fails // TODO: Create a directory if not existing to prevent this error zap.S().Warnf("Failed to copy %s to %s/%s for %s: %v", srcFile, dstDir, filepath.Base(srcFile), sc.ContainerID, err) continue } else { return fmt.Errorf("failed to copy %s to %s/%s for %s: %v", srcFile, dstDir, filepath.Base(srcFile), sc.ContainerID, err) } } zap.S().Debugf("Copied %s to %s (%s)", srcFile, filepath.Join(dstDir, filepath.Base(srcFile)), sc.ContainerID) } resp, err := sc.cli.ContainerInspect(ctx, sc.FullContainerID) if err != nil { return err } _, err = sc.Execute(ctx, "sh", "-c", fmt.Sprintf("echo -n %s:%d > /etc/docker4ssh", resp.NetworkSettings.Networks[sc.config.NetworkMode.NetworkName()].Gateway, cconfig.Api.Port)) if err != nil { return err } zap.S().Debugf("Set ip and port of server for %s", sc.ContainerID) } else { _, err := sc.Execute(ctx, "rm", "-rf", fmt.Sprintf("/bin/%s", filepath.Base(cconfig.Api.Configure.Binary)), fmt.Sprintf("/usr/share/man/man1/%s", filepath.Base(cconfig.Api.Configure.Man)), "/etc/docker4ssh") if err != nil { return err } zap.S().Debugf("Removed all configurable related files from %s", sc.ContainerID) } return nil } // setAPIRoute sets the IP and port for docker container tools func (sc *SimpleContainer) setAPIRoute(ctx context.Context, activate bool) error { var err error if activate { var resp types.ContainerJSON resp, err = sc.cli.ContainerInspect(ctx, sc.FullContainerID) if err != nil { return err } cconfig := c.GetConfig() if resp.NetworkSettings != nil { _, err = sc.Execute(ctx, "sh", "-c", fmt.Sprintf("echo -n %s:%d > /etc/docker4ssh", resp.NetworkSettings.Networks[sc.config.NetworkMode.NetworkName()].Gateway, cconfig.Api.Port)) } } else { _, err = sc.Execute(ctx, "rm", "-rf", "/etc/docker4ssh") } return err } // setExitAfterListener listens for exit after processes func (sc *SimpleContainer) setExitAfterListener(ctx context.Context, runlevel RunLevel, process string) { if sc.cancel != nil { sc.cancel() } if process == "" { return } cancelCtx, cancel := context.WithCancel(ctx) sc.cancel = cancel go func() { var rawPid []byte var err error // check for the pid of Config.ExitAfter and wait 1 second if it wasn't found for { rawPid, err = sc.Execute(cancelCtx, "pidof", "-s", process) if len(rawPid) > 0 || err != nil { break } time.Sleep(1 * time.Second) } // sometimes garbage bytes are sent as well, they are getting filtered here var pid []byte for _, b := range rawPid { if b > '0' && b < '9' { pid = append(pid, b) } } pid = bytes.TrimSuffix(pid, []byte("\n")) if _, err = sc.Execute(cancelCtx, "sh", "-c", fmt.Sprintf("tail --pid=%s -f /dev/null", pid)); err != nil && cancelCtx.Err() == nil { zap.S().Errorf("Could not wait on process %s (%s) for %s", process, pid, sc.ContainerID) return } if runlevel != Forever { sc.Stop(context.Background()) } }() } func InteractiveContainerFromID(ctx context.Context, client *Client, config Config, containerID string) (*InteractiveContainer, error) { sc, err := simpleContainerFromID(ctx, client, config, containerID) if err != nil { return nil, err } return &InteractiveContainer{ SimpleContainer: sc, }, nil } func NewInteractiveContainer(ctx context.Context, cli *Client, config Config, image Image, containerName string) (*InteractiveContainer, error) { sc, err := newSimpleContainer(ctx, cli, config, image, containerName) if err != nil { return nil, err } return &InteractiveContainer{ SimpleContainer: sc, }, nil } type InteractiveContainer struct { *SimpleContainer terminalCount int } // TerminalCount returns the count of active terminals func (ic *InteractiveContainer) TerminalCount() int { return ic.terminalCount } // Terminal creates a new interactive terminal session for the container func (ic *InteractiveContainer) Terminal(ctx context.Context, term *terminal.Terminal) error { // get the default shell for the root user rawShell, err := ic.Execute(ctx, "sh", "-c", "getent passwd root | cut -d : -f 7") if err != nil { return err } // here we cut out only newlines (which also could've been done via // bytes.ReplaceAll or strings.ReplaceAll) and redundant bytes // which sometimes get returned too and which cannot be interpreted // by the docker engine shell := bytes.Buffer{} for _, b := range rawShell { if b > ' ' { shell.WriteByte(b) } } id, err := ic.cli.ContainerExecCreate(ctx, ic.FullContainerID, types.ExecConfig{ Tty: true, AttachStdin: true, AttachStdout: true, AttachStderr: true, Cmd: []string{shell.String()}, }) if err != nil { return err } resp, err := ic.cli.ContainerExecAttach(ctx, id.ID, types.ExecStartCheck{ Tty: true, }) if err != nil { return err } errChan := make(chan error) go func() { // copy every input to the container if _, err = io.Copy(term, resp.Conn); err != nil { errChan <- err } errChan <- nil }() go func() { // copy every output from the container if _, err = io.Copy(resp.Conn, term); err != nil { errChan <- err } errChan <- nil }() ic.terminalCount++ select { case err = <-errChan: resp.Conn.Close() } ic.terminalCount-- return err }