docker4ssh/server/docker/container.go
2021-12-19 17:30:51 +01:00

638 lines
17 KiB
Go

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
}