Initial commit

This commit is contained in:
2021-12-19 17:30:51 +01:00
commit a589014106
65 changed files with 7437 additions and 0 deletions

12
server/docker/client.go Normal file
View File

@ -0,0 +1,12 @@
package docker
import (
"docker4ssh/database"
"github.com/docker/docker/client"
)
type Client struct {
Client *client.Client
Database *database.Database
Network Network
}

637
server/docker/container.go Normal file
View File

@ -0,0 +1,637 @@
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
}

120
server/docker/docker.go Normal file
View File

@ -0,0 +1,120 @@
package docker
import (
"github.com/docker/docker/client"
"os"
)
type NetworkMode int
const (
Off NetworkMode = iota + 1
// Isolate isolates the container from the host and the host's
// network. Therefore, no configurations can be changed from
// within the container
Isolate
// Host is the default docker networking configuration
Host
// Docker is the same configuration you get when you start a
// container via the command line
Docker
// None disables all isolation between the docker container
// and the host, so inside the network the container can act
// as the host. So it has access to the host's network directly
None
)
func (nm NetworkMode) Name() string {
switch nm {
case Off:
return "Off"
case Isolate:
return "Iso"
case Host:
return "Host"
case Docker:
return "Docker"
case None:
return "None"
}
return "invalid network"
}
func (nm NetworkMode) NetworkName() string {
switch nm {
case Off:
return "none"
case Isolate:
return "docker4ssh-full"
case Host:
return "bridge"
case Docker:
return "docker4ssh-def"
case None:
return "none"
}
return ""
}
type RunLevel int
const (
User RunLevel = iota + 1
Container
Forever
)
func (rl RunLevel) Name() string {
switch rl {
case User:
return "User"
case Container:
return "Container"
case Forever:
return "Forever"
}
return ""
}
type Config struct {
// NetworkMode describes the level of isolation of the container to the host system.
// Mostly changes the network of the container, see NetworkNames for more details
NetworkMode NetworkMode
// If Configurable is true, the container can change settings for itself
Configurable bool
// RunLevel describes the container behavior.
// If the RunLevel is User, the container will exit when the user disconnects.
// If the RunLevel is Container, the container keeps running if the user disconnects
// and ExitAfter is specified and the specified process has not finished yet.
// If the RunLevel is Forever, the container keeps running forever unless ExitAfter
// is specified and the specified process ends.
//
// Note: It also automatically exits if ExitAfter is specified and the specified
// process ends, even if the user is still connected to the container
RunLevel RunLevel
// StartupInformation defines if information about the container like its (shorthand)
// container id, NetworkMode, RunLevel, etc. should be shown when connecting to it
StartupInformation bool
// ExitAfter contains a process name after which end the container should stop
ExitAfter string
// When KeepOnExit is true, the container won't get deleted if it stops working
KeepOnExit bool
}
func InitCli() (*client.Client, error) {
return client.NewClientWithOpts()
}
func IsRunning() bool {
_, err := os.Stat("/var/run/docker.sock")
return !os.IsNotExist(err)
}

41
server/docker/image.go Normal file
View File

@ -0,0 +1,41 @@
package docker
import (
"context"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"io"
)
type Image struct {
ref string
}
func (i Image) Ref() string {
return i.ref
}
// NewImage creates a new Image instance
func NewImage(ctx context.Context, cli *client.Client, ref string) (Image, io.ReadCloser, error) {
summary, err := cli.ImageList(ctx, types.ImageListOptions{
Filters: filters.NewArgs(filters.Arg("reference", ref)),
})
if err != nil {
return Image{}, nil, err
}
if len(summary) > 0 {
return Image{
ref: ref,
}, nil, nil
} else {
out, err := cli.ImagePull(ctx, ref, types.ImagePullOptions{})
if err != nil {
return Image{}, nil, err
}
return Image{
ref: ref,
}, out, nil
}
}

86
server/docker/network.go Normal file
View File

@ -0,0 +1,86 @@
package docker
import (
"context"
c "docker4ssh/config"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
)
type Network map[NetworkMode]string
// InitNetwork initializes a new docker4ssh network
func InitNetwork(ctx context.Context, cli *client.Client, config *c.Config) (Network, error) {
n := Network{}
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{})
if err != nil {
return nil, err
}
for _, dockerNetwork := range networks {
var mode NetworkMode
switch dockerNetwork.Name {
case "none":
mode = Off
case "docker4ssh-iso":
mode = Isolate
case "bridge":
mode = Host
case "docker4ssh-def":
mode = Docker
case "host":
mode = None
default:
continue
}
n[mode] = dockerNetwork.ID
}
if _, ok := n[Isolate]; !ok {
// create a new network which isolates the container from the host,
// but not from the network
resp, err := cli.NetworkCreate(ctx, "docker4ssh-iso", types.NetworkCreate{
CheckDuplicate: true,
Driver: "bridge",
EnableIPv6: false,
IPAM: &network.IPAM{
Driver: "default",
Config: []network.IPAMConfig{
{
Subnet: config.Network.Isolate.Subnet,
},
},
},
})
if err != nil {
return nil, err
}
n[Isolate] = resp.ID
}
if _, ok := n[Docker]; !ok {
// the standard network for all containers
resp, err := cli.NetworkCreate(ctx, "docker4ssh-def", types.NetworkCreate{
CheckDuplicate: true,
Driver: "bridge",
EnableIPv6: false,
IPAM: &network.IPAM{
Driver: "default",
Config: []network.IPAMConfig{
{
Subnet: config.Network.Default.Subnet,
},
},
},
})
if err != nil {
return nil, err
}
n[Docker] = resp.ID
}
return n, nil
}