mirror of
https://github.com/bytedream/docker4ssh.git
synced 2025-05-09 12:15:11 +02:00
638 lines
17 KiB
Go
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
|
|
}
|