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

123
server/api/api.go Normal file
View File

@ -0,0 +1,123 @@
package api
import (
"bytes"
"docker4ssh/config"
"docker4ssh/ssh"
"encoding/json"
"fmt"
"go.uber.org/zap"
"io"
"io/ioutil"
"net"
"net/http"
"strings"
)
type EndpointHandler struct {
http.Handler
auth bool
get func(http.ResponseWriter, *http.Request, *ssh.User) (interface{}, int)
post func(http.ResponseWriter, *http.Request, *ssh.User) (interface{}, int)
}
func (h *EndpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ip := strings.Split(r.RemoteAddr, ":")[0]
zap.S().Infof("User connected to api with remote address %s", ip)
w.Header().Add("Content-Type", "application/json")
user := ssh.GetUser(ip)
// checks if auth should be checked and if so and no user could be found, response an error
if h.auth && user == nil {
zap.S().Errorf("Could not find api user with ip %s", ip)
json.NewEncoder(w).Encode(APIError{Message: "unauthorized"})
return
} else if user != nil {
zap.S().Debugf("API ip %s is %s", ip, user.ID)
}
raw := bytes.Buffer{}
if r.ContentLength > 0 {
io.Copy(&raw, r.Body)
defer r.Body.Close()
if !json.Valid(raw.Bytes()) {
zap.S().Errorf("API user %s sent invalid body", ip)
w.WriteHeader(http.StatusNotAcceptable)
json.NewEncoder(w).Encode(APIError{Message: "invalid body"})
return
}
r.Body = ioutil.NopCloser(&raw)
}
zap.S().Debugf("API user %s request - \"%s %s %s\" \"%s\" \"%s\"", ip, r.Method, r.URL.Path, r.Proto, r.UserAgent(), raw.String())
var response interface{}
var code int
switch r.Method {
case http.MethodGet:
if h.get != nil {
response, code = h.get(w, r, user)
}
case http.MethodPost:
if h.post != nil {
response, code = h.post(w, r, user)
}
}
if response == nil && code == 0 {
zap.S().Infof("API user %s sent invalid method: %s", ip, r.Method)
response = APIError{Message: fmt.Sprintf("invalid method '%s'", r.Method)}
code = http.StatusConflict
} else {
zap.S().Infof("API user %s issued %s successfully", ip, r.URL.Path)
}
w.WriteHeader(code)
if response != nil {
json.NewEncoder(w).Encode(response)
}
}
func ServeAPI(config *config.Config) (errChan chan error, closer func() error) {
errChan = make(chan error, 1)
mux := http.NewServeMux()
mux.Handle("/ping", &EndpointHandler{
get: PingGet,
})
mux.Handle("/error", &EndpointHandler{
get: ErrorGet,
})
mux.Handle("/info", &EndpointHandler{
get: InfoGet,
auth: true,
})
mux.Handle("/config", &EndpointHandler{
get: ConfigGet,
post: ConfigPost,
auth: true,
})
mux.Handle("/auth", &EndpointHandler{
get: AuthGet,
post: AuthPost,
auth: true,
})
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", config.Api.Port))
if err != nil {
errChan <- err
return
}
go func() {
errChan <- http.Serve(listener, mux)
}()
return errChan, listener.Close
}

80
server/api/auth.go Normal file
View File

@ -0,0 +1,80 @@
package api
import (
"docker4ssh/database"
"docker4ssh/ssh"
"encoding/json"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"net/http"
)
type authGetResponse struct {
User string `json:"user"`
HasPassword bool `json:"has_password"`
}
func AuthGet(w http.ResponseWriter, r *http.Request, user *ssh.User) (interface{}, int) {
auth, ok := database.GetDatabase().GetAuthByContainer(user.Container.FullContainerID)
if ok {
return authGetResponse{
User: *auth.User,
HasPassword: auth.Password != nil,
}, http.StatusOK
} else {
return APIError{Message: "no auth is set"}, http.StatusNotFound
}
}
type authPostRequest struct {
User *string `json:"user"`
Password *string `json:"password"`
}
func AuthPost(w http.ResponseWriter, r *http.Request, user *ssh.User) (interface{}, int) {
var request authPostRequest
json.NewDecoder(r.Body).Decode(&request)
defer r.Body.Close()
db := database.GetDatabase()
auth, _ := db.GetAuthByContainer(user.Container.FullContainerID)
if request.User != nil {
if *request.User == "" {
return APIError{Message: "new username cannot be empty"}, http.StatusNotAcceptable
}
if err := db.SetAuth(user.Container.FullContainerID, database.Auth{
User: request.User,
}); err != nil {
zap.S().Errorf("Error while updating user for user %s: %v", user.ID, err)
return APIError{Message: "failed to process user"}, http.StatusInternalServerError
}
zap.S().Infof("Updated password for %s", user.Container.ContainerID)
}
if request.Password != nil && *request.Password == "" {
if err := db.DeleteAuth(user.Container.FullContainerID); err != nil {
zap.S().Errorf("Error while deleting auth for user %s: %v", user.ID, err)
return APIError{Message: "failed to delete auth"}, http.StatusInternalServerError
}
zap.S().Infof("Deleted authenticiation for %s", user.Container.ContainerID)
} else if request.Password != nil {
pwd, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
if err != nil {
zap.S().Errorf("Error while updating password for user %s: %v", user.ID, err)
return APIError{Message: "failed to process password"}, http.StatusInternalServerError
}
var username string
if auth.User == nil {
username = user.Container.FullContainerID
} else {
username = *auth.User
}
if err = db.SetAuth(user.Container.FullContainerID, database.NewUnsafeAuth(username, pwd)); err != nil {
return APIError{Message: "failed to update authentication"}, http.StatusInternalServerError
}
zap.S().Infof("Updated password for %s", user.Container.ContainerID)
}
return nil, http.StatusOK
}

124
server/api/config.go Normal file
View File

@ -0,0 +1,124 @@
package api
import (
"context"
"docker4ssh/docker"
"docker4ssh/ssh"
"encoding/json"
"fmt"
"go.uber.org/zap"
"net/http"
"reflect"
"strings"
)
type configGetResponse struct {
NetworkMode docker.NetworkMode `json:"network_mode"`
Configurable bool `json:"configurable"`
RunLevel docker.RunLevel `json:"run_level"`
StartupInformation bool `json:"startup_information"`
ExitAfter string `json:"exit_after"`
KeepOnExit bool `json:"keep_on_exit"`
}
func ConfigGet(w http.ResponseWriter, r *http.Request, user *ssh.User) (interface{}, int) {
config := user.Container.Config()
return configGetResponse{
config.NetworkMode,
config.Configurable,
config.RunLevel,
config.StartupInformation,
config.ExitAfter,
config.KeepOnExit,
}, http.StatusOK
}
type configPostRequest configGetResponse
var configPostRequestLookup, _ = structJsonLookup(configPostRequest{})
type configPostResponse struct {
Message string `json:"message"`
Rejected []configPostResponseRejected `json:"rejected"`
}
type configPostResponseRejected struct {
Name string `json:"name"`
Description string `json:"description"`
}
func ConfigPost(w http.ResponseWriter, r *http.Request, user *ssh.User) (interface{}, int) {
var requestBody map[string]interface{}
json.NewDecoder(r.Body).Decode(&requestBody)
defer r.Body.Close()
var change bool
var response configPostResponse
updatedConfig := user.Container.Config()
for k, v := range requestBody {
if v == nil {
continue
}
kind, ok := configPostRequestLookup[k]
if !ok {
response.Rejected = append(response.Rejected, configPostResponseRejected{
Name: k,
Description: fmt.Sprintf("name / field %s does not exist", k),
})
} else {
valueKind := reflect.TypeOf(v).Kind()
if valueKind != kind && valueKind == reflect.Float64 && kind == reflect.Int {
valueKind = reflect.Int
}
if valueKind != kind {
response.Rejected = append(response.Rejected, configPostResponseRejected{
Name: k,
Description: fmt.Sprintf("value should be type %s, got type %s", kind, valueKind),
})
}
change = true
switch k {
case "network_mode":
updatedConfig.NetworkMode = docker.NetworkMode(v.(float64))
case "configurable":
updatedConfig.Configurable = v.(bool)
case "run_level":
updatedConfig.RunLevel = docker.RunLevel(v.(float64))
case "startup_information":
updatedConfig.StartupInformation = v.(bool)
case "exit_after":
updatedConfig.ExitAfter = v.(string)
case "keep_on_exit":
updatedConfig.KeepOnExit = v.(bool)
}
}
}
if len(response.Rejected) > 0 {
var arr []string
for _, rejected := range response.Rejected {
arr = append(arr, rejected.Name)
}
if len(response.Rejected) == 1 {
response.Message = fmt.Sprintf("1 invalid configuration was found: %s", strings.Join(arr, ", "))
return response, http.StatusNotAcceptable
} else if len(response.Rejected) > 1 {
response.Message = fmt.Sprintf("%d invalid configurations were found: %s", len(response.Rejected), strings.Join(arr, ", "))
return response, http.StatusNotAcceptable
}
} else if change {
if err := user.Container.UpdateConfig(context.Background(), updatedConfig); err != nil {
zap.S().Errorf("Error while updating config for API user %s: %v", user.ID, err)
response.Message = "Internal error while updating the config"
return response, http.StatusInternalServerError
}
}
return nil, http.StatusOK
}

12
server/api/error.go Normal file
View File

@ -0,0 +1,12 @@
package api
import (
"docker4ssh/ssh"
"net/http"
)
type errorGetResponse APIError
func ErrorGet(w http.ResponseWriter, r *http.Request, user *ssh.User) (interface{}, int) {
return APIError{Message: "Example error message"}, http.StatusBadRequest
}

16
server/api/info.go Normal file
View File

@ -0,0 +1,16 @@
package api
import (
"docker4ssh/ssh"
"net/http"
)
type infoGetResponse struct {
ContainerID string `json:"container_id"`
}
func InfoGet(w http.ResponseWriter, r *http.Request, user *ssh.User) (interface{}, int) {
return infoGetResponse{
ContainerID: user.Container.FullContainerID,
}, http.StatusOK
}

15
server/api/ping.go Normal file
View File

@ -0,0 +1,15 @@
package api
import (
"docker4ssh/ssh"
"net/http"
"time"
)
type pingGetResponse struct {
Received int64 `json:"received"`
}
func PingGet(w http.ResponseWriter, r *http.Request, user *ssh.User) (interface{}, int) {
return pingGetResponse{Received: time.Now().UnixNano()}, http.StatusOK
}

35
server/api/utils.go Normal file
View File

@ -0,0 +1,35 @@
package api
import (
"fmt"
"reflect"
"strings"
)
type APIError struct {
Message string `json:"message"`
}
func structJsonLookup(v interface{}) (map[string]reflect.Kind, error) {
rt := reflect.TypeOf(v)
if rt.Kind() != reflect.Struct {
return nil, fmt.Errorf("given interface is not a struct")
}
lookup := make(map[string]reflect.Kind)
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
name := strings.Split(field.Tag.Get("json"), ",")[0]
value := field.Type.Kind()
if field.Type.Kind() == reflect.Struct {
value = reflect.Map
}
lookup[name] = value
}
return lookup, nil
}

0
server/build/docker4ssh Normal file
View File

18
server/cmd/cmd.go Normal file
View File

@ -0,0 +1,18 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"os"
)
var rootCmd = &cobra.Command{
Use: "docker4ssh",
Short: "Docker containers and more via ssh",
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "%v", err)
}
}

160
server/cmd/start.go Normal file
View File

@ -0,0 +1,160 @@
package cmd
import (
"docker4ssh/api"
c "docker4ssh/config"
"docker4ssh/database"
"docker4ssh/docker"
"docker4ssh/logging"
"docker4ssh/ssh"
"docker4ssh/validate"
"fmt"
"github.com/spf13/cobra"
"go.uber.org/zap"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
var startCmd = &cobra.Command{
Use: "start",
Short: "Starts the docker4ssh server",
Args: cobra.MaximumNArgs(0),
PreRunE: func(cmd *cobra.Command, args []string) error {
return preStart()
},
Run: func(cmd *cobra.Command, args []string) {
start()
},
}
func preStart() error {
if !docker.IsRunning() {
return fmt.Errorf("docker daemon is not running")
}
cli, err := docker.InitCli()
if err != nil {
return err
}
config, err := c.InitConfig(true)
if err != nil {
return err
}
validator := validate.NewConfigValidator(cli, false, config)
if result := validator.ValidateLogging(); !result.Ok() {
return fmt.Errorf(result.String())
}
level := zap.NewAtomicLevel()
level.UnmarshalText([]byte(config.Logging.Level))
var outputFiles, errorFiles []string
if config.Logging.ConsoleOutput {
outputFiles = append(outputFiles, "/dev/stdout")
}
if config.Logging.OutputFile != "" {
outputFiles = append(outputFiles, config.Logging.OutputFile)
}
if config.Logging.ConsoleError {
errorFiles = append(errorFiles, "/dev/stderr")
}
if config.Logging.ErrorFile != "" {
errorFiles = append(errorFiles, config.Logging.ErrorFile)
}
logging.InitLogging(level, outputFiles, errorFiles)
if result := validator.Validate(); !result.Ok() {
return fmt.Errorf(result.String())
}
c.SetConfig(config)
db, err := database.NewSqlite3Connection(config.Database.Sqlite3File)
if err != nil {
zap.S().Fatalf("Failed to initialize database: %v", err)
}
database.SetDatabase(db)
return nil
}
func start() {
config := c.GetConfig()
if config.SSH.Passphrase == "" {
zap.S().Warn("YOU HAVE AN EMPTY PASSPHRASE WHICH IS INSECURE, SUGGESTING CREATING A NEW SSH KEY WITH A PASSPHRASE.\n" +
"IF YOU'RE DOWNLOADED THIS VERSION FROM THE RELEASES (https://github.com/ByteDream/docker4ssh/releases/latest), MAKE SURE TO CHANGE YOUR SSH KEY IMMEDIATELY BECAUSE ANYONE COULD DECRYPT THE SSH SESSION!!\n" +
"USE 'ssh-keygen -t ed25519 -f /etc/docker4ssh/docker4ssh.key -b 4096' AND UPDATE THE PASSPHRASE IN /etc/docker4ssh/docker4ssh.conf UNDER ssh.Passphrase")
}
serverConfig, err := ssh.NewSSHConfig(config)
if err != nil {
zap.S().Fatalf("Failed to initialize ssh server config: %v", err)
}
sshErrChan, sshCloser := ssh.StartServing(config, serverConfig)
zap.S().Infof("Started ssh serving on port %d", config.SSH.Port)
apiErrChan, apiCloser := api.ServeAPI(config)
zap.S().Infof("Started api serving on port %d", config.Api.Port)
done := make(chan struct{})
sig := make(chan os.Signal)
signal.Notify(sig, syscall.SIGUSR1, os.Interrupt, os.Kill, syscall.SIGINT, syscall.SIGTERM)
go func() {
s := <-sig
if sshCloser != nil {
sshCloser()
}
if apiCloser != nil {
apiCloser()
}
database.GetDatabase().Close()
if s != syscall.SIGUSR1 {
// Errorf is called here instead of Fatalf because the original exit signal should be kept to exit with it later
zap.S().Errorf("(FATAL actually) received abort signal %d: %s", s.(syscall.Signal), strings.ToUpper(s.String()))
os.Exit(int(s.(syscall.Signal)))
}
done <- struct{}{}
}()
select {
case err = <-sshErrChan:
case err = <-apiErrChan:
}
if err != nil {
zap.S().Errorf("Failed to start working: %v", err)
sig <- os.Interrupt
} else {
select {
case <-sig:
if err != nil {
zap.S().Errorf("Serving failed due error: %v", err)
} else {
zap.S().Info("Serving stopped")
}
default:
sig <- syscall.SIGUSR1
}
}
select {
case <-done:
case <-time.After(5 * time.Second):
// if the timeout of 5 seconds expires, forcefully exit
os.Exit(int(syscall.SIGKILL))
}
}
func init() {
rootCmd.AddCommand(startCmd)
}

159
server/cmd/validate.go Normal file
View File

@ -0,0 +1,159 @@
package cmd
import (
c "docker4ssh/config"
"docker4ssh/docker"
"docker4ssh/validate"
"fmt"
"github.com/docker/docker/client"
"github.com/spf13/cobra"
"os"
"path/filepath"
"strings"
)
var cli *client.Client
var validateCmd = &cobra.Command{
Use: "validate",
Short: "Validate docker4ssh specific files (config / profile files)",
PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) {
cli, err = docker.InitCli()
return err
},
}
var validateStrictFlag bool
var validateConfigCmd = &cobra.Command{
Use: "config [files]",
Short: "Validate a docker4ssh config file",
RunE: func(cmd *cobra.Command, args []string) error {
return validateConfig(args)
},
}
var validateConfigFileFlag string
var validateProfileCmd = &cobra.Command{
Use: "profile [files]",
Short: "Validate docker4ssh profile files",
RunE: func(cmd *cobra.Command, args []string) error {
return validateProfile(args)
},
}
func validateConfig(args []string) error {
config, err := c.LoadConfig(validateConfigFileFlag, false)
if err != nil {
return err
}
validator := validate.NewConfigValidator(cli, validateStrictFlag, config)
var result *validate.ValidatorResult
if len(args) == 0 {
result = validator.Validate()
} else {
var validateFuncs []func() *validate.ValidatorResult
for _, arg := range args {
switch strings.ToLower(arg) {
case "profile":
validateFuncs = append(validateFuncs, validator.ValidateProfile)
case "api":
validateFuncs = append(validateFuncs, validator.ValidateAPI)
case "ssh":
validateFuncs = append(validateFuncs, validator.ValidateSSH)
case "database":
validateFuncs = append(validateFuncs, validator.ValidateDatabase)
case "network":
validateFuncs = append(validateFuncs, validator.ValidateNetwork)
case "logging":
validateFuncs = append(validateFuncs, validator.ValidateLogging)
default:
return fmt.Errorf("'%s' is not a valid config section", arg)
}
}
var errors []*validate.ValidateError
for _, validateFunc := range validateFuncs {
errors = append(errors, validateFunc().Errors...)
}
result = &validate.ValidatorResult{
Strict: validateStrictFlag,
Errors: errors,
}
}
fmt.Println(result.String())
if len(result.Errors) > 0 {
os.Exit(1)
}
return nil
}
func validateProfile(args []string) error {
var files []string
if len(args) == 0 {
args = append(args, "/etc/docker4ssh/profile")
}
for _, arg := range args {
stat, err := os.Stat(arg)
if os.IsNotExist(err) {
return fmt.Errorf("file %s does not exist: %v", arg, err)
}
if stat.IsDir() {
dir, err := os.ReadDir(arg)
if err != nil {
return fmt.Errorf("failed to read directory %s: %v", arg, err)
}
for _, file := range dir {
path, err := filepath.Abs(file.Name())
if err != nil {
return err
}
files = append(files, path)
}
}
}
var profiles c.Profiles
for _, file := range files {
p, err := c.LoadProfileFile(file, c.HardcodedPreProfile())
if err != nil {
return err
}
profiles = append(profiles, p...)
}
var errors []*validate.ValidateError
for _, profile := range profiles {
errors = append(errors, validate.NewProfileValidator(cli, validateStrictFlag, profile).Validate().Errors...)
}
result := validate.ValidatorResult{
Strict: validateStrictFlag,
Errors: errors,
}
fmt.Println(result.String())
return nil
}
func init() {
rootCmd.AddCommand(validateCmd)
validateCmd.PersistentFlags().BoolVarP(&validateStrictFlag, "strict", "s", false, "If the check should be strict")
validateCmd.AddCommand(validateConfigCmd)
validateConfigCmd.Flags().StringVarP(&validateConfigFileFlag, "file", "f", "/etc/docker4ssh/docker4ssh.conf", "Specify a file to check")
validateCmd.AddCommand(validateProfileCmd)
}

189
server/config/config.go Normal file
View File

@ -0,0 +1,189 @@
package config
import (
"fmt"
"github.com/BurntSushi/toml"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
)
var globConfig *Config
type Config struct {
Profile struct {
Dir string `toml:"Dir"`
Default struct {
Password string `toml:"Password"`
NetworkMode int `toml:"NetworkMode"`
Configurable bool `toml:"Configurable"`
RunLevel int `toml:"RunLevel"`
StartupInformation bool `toml:"StartupInformation"`
ExitAfter string `toml:"ExitAfter"`
KeepOnExit bool `toml:"KeepOnExit"`
} `toml:"default"`
Dynamic struct {
Enable bool `toml:"Enable"`
Password string `toml:"Password"`
NetworkMode int `toml:"NetworkMode"`
Configurable bool `toml:"Configurable"`
RunLevel int `toml:"RunLevel"`
StartupInformation bool `toml:"StartupInformation"`
ExitAfter string `toml:"ExitAfter"`
KeepOnExit bool `toml:"KeepOnExit"`
} `toml:"dynamic"`
} `toml:"profile"`
Api struct {
Port uint16 `toml:"Port"`
Configure struct {
Binary string `toml:"Binary"`
Man string `toml:"Man"`
} `toml:"configure"`
} `toml:"api"`
SSH struct {
Port uint16 `toml:"Port"`
Keyfile string `toml:"Keyfile"`
Passphrase string `toml:"Passphrase"`
} `toml:"ssh"`
Database struct {
Sqlite3File string `toml:"Sqlite3File"`
} `toml:"Database"`
Network struct {
Default struct {
Subnet string `toml:"Subnet"`
} `toml:"default"`
Isolate struct {
Subnet string `toml:"Subnet"`
} `toml:"isolate"`
} `toml:"network"`
Logging struct {
Level string `toml:"Level"`
OutputFile string `toml:"OutputFile"`
ErrorFile string `toml:"ErrorFile"`
ConsoleOutput bool `toml:"ConsoleOutput"`
ConsoleError bool `toml:"ConsoleError"`
} `toml:"logging"`
}
func InitConfig(includeEnv bool) (*Config, error) {
configFiles := []string{
"./docker4ssh.conf",
"~/.docker4ssh",
"~/.config/docker4ssh.conf",
"/etc/docker4ssh/docker4ssh.conf",
}
for _, file := range configFiles {
if _, err := os.Stat(file); !os.IsNotExist(err) {
return LoadConfig(file, includeEnv)
}
}
return nil, fmt.Errorf("no speicfied config file (%s) could be found", strings.Join(configFiles, ", "))
}
func LoadConfig(file string, includeEnv bool) (*Config, error) {
config := &Config{}
if _, err := toml.DecodeFile(file, config); err != nil {
return nil, err
}
// make paths absolute
dir := filepath.Dir(file)
config.Profile.Dir = absoluteFile(dir, config.Profile.Dir)
config.Api.Configure.Binary = absoluteFile(dir, config.Api.Configure.Binary)
config.Api.Configure.Man = absoluteFile(dir, config.Api.Configure.Man)
config.SSH.Keyfile = absoluteFile(dir, config.SSH.Keyfile)
config.Database.Sqlite3File = absoluteFile(dir, config.Database.Sqlite3File)
config.Logging.OutputFile = absoluteFile(dir, config.Logging.OutputFile)
config.Logging.ErrorFile = absoluteFile(dir, config.Logging.ErrorFile)
if includeEnv {
if err := updateFromEnv(config); err != nil {
return nil, err
}
}
return config, nil
}
func absoluteFile(path, file string) string {
if filepath.IsAbs(file) {
return file
}
return filepath.Join(path, file)
}
// updateFromEnv looks up if specific environment variable are given which can
// also be used to configure the program.
// Every key in the config file can also be specified via environment variables.
// The env variable syntax is SECTION_KEY -> e.g. DEFAULT_PASSWORD or API_PORT
func updateFromEnv(config *Config) error {
re := reflect.ValueOf(config).Elem()
rt := re.Type()
for i := 0; i < re.NumField(); i++ {
rf := re.Field(i)
ree := rt.Field(i)
if err := envParseField(strings.ToUpper(ree.Tag.Get("toml")), rf); err != nil {
return err
}
}
return nil
}
func envParseField(prefix string, value reflect.Value) error {
for j := 0; j < value.NumField(); j++ {
rtt := value.Type().Field(j)
rff := value.Field(j)
if rff.Kind() == reflect.Struct {
if err := envParseField(fmt.Sprintf("%s_%s", prefix, strings.ToUpper(rtt.Tag.Get("toml"))), rff); err != nil {
return err
}
continue
}
envName := fmt.Sprintf("%s_%s", prefix, strings.ToUpper(rtt.Tag.Get("toml")))
val, ok := os.LookupEnv(envName)
if !ok {
continue
}
var expected string
switch rff.Kind() {
case reflect.String:
rff.SetString(val)
continue
case reflect.Bool:
b, err := strconv.ParseBool(val)
if err == nil {
rff.SetBool(b)
continue
}
expected = "true / false (boolean)"
case reflect.Uint16:
ui, err := strconv.ParseUint(val, 10, 16)
if err == nil {
rff.SetUint(ui)
continue
}
expected = "number (uint16)"
default:
return fmt.Errorf("parsed not implemented config type '%s'", rff.Kind())
}
return fmt.Errorf("failed to parse environment variable '%s': cannot parse value '%s' as %s", envName, val, expected)
}
return nil
}
func GetConfig() *Config {
return globConfig
}
func SetConfig(config *Config) {
globConfig = config
}

254
server/config/profile.go Normal file
View File

@ -0,0 +1,254 @@
package config
import (
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/json"
"fmt"
"github.com/BurntSushi/toml"
"go.uber.org/zap"
"hash"
"os"
"path/filepath"
"regexp"
"strings"
)
type Profile struct {
name string
Username *regexp.Regexp
Password *regexp.Regexp
passwordHashAlgo hash.Hash
NetworkMode int
Configurable bool
RunLevel int
StartupInformation bool
ExitAfter string
KeepOnExit bool
Image string
ContainerID string
}
func (p *Profile) Name() string {
return p.name
}
func (p *Profile) Match(user string, password []byte) bool {
// username should only be nil if profile was generated from Config.Profile.Dynamic
if p.Username == nil || p.Username.MatchString(user) {
if p.passwordHashAlgo != nil {
password = p.passwordHashAlgo.Sum(password)
}
return p.Password.Match(password)
}
return false
}
type preProfile struct {
Username string
Password string
NetworkMode int
Configurable bool
RunLevel int
StartupInformation bool
ExitAfter string
KeepOnExit bool
Image string
Container string
}
func LoadProfileFile(path string, defaultPreProfile preProfile) (Profiles, error) {
var rawProfile map[string]interface{}
if _, err := toml.DecodeFile(path, &rawProfile); err != nil {
return nil, err
}
profiles, err := parseRawProfile(rawProfile, path, defaultPreProfile)
if err != nil {
return nil, err
}
return profiles, nil
}
func LoadProfileDir(path string, defaultPreProfile preProfile) (Profiles, error) {
dir, err := os.ReadDir(path)
if err != nil {
return nil, err
}
var profiles Profiles
for i, profileConf := range dir {
p, err := LoadProfileFile(filepath.Join(path, profileConf.Name()), defaultPreProfile)
if err != nil {
return nil, err
}
profiles = append(profiles, p...)
zap.S().Debugf("Pre-loaded file %d (%s) with %d profile(s)", i+1, profileConf.Name(), len(p))
}
return profiles, nil
}
func parseRawProfile(rawProfile map[string]interface{}, path string, defaultPreProfile preProfile) (profiles []*Profile, err error) {
var count int
for key, value := range rawProfile {
rawValue, err := json.Marshal(value)
if err != nil {
return nil, err
}
pp := preProfile{
NetworkMode: 3,
RunLevel: 1,
StartupInformation: true,
}
if err = json.Unmarshal(rawValue, &pp); err != nil {
return nil, fmt.Errorf("failed to parse %s profile conf file %s: %v", key, path, err)
}
var rawUsername string
if rawUsername = strings.TrimPrefix(pp.Username, "regex:"); rawUsername == pp.Username {
rawUsername = strings.ReplaceAll(rawUsername, "*", ".*")
}
if !strings.HasSuffix(rawUsername, "$") {
rawUsername += "$"
}
username, err := regexp.Compile("(?m)" + rawUsername)
if err != nil {
return nil, fmt.Errorf("failed to parse %s profile username regex for conf file %s: %v", key, path, err)
}
var rawPassword string
if rawPassword = strings.TrimPrefix(pp.Password, "regex:"); rawUsername == pp.Password {
rawPassword = strings.ReplaceAll(rawPassword, "*", ".*")
}
algo, rawPasswordOrHash := getHash(rawPassword)
if algo == nil && rawPasswordOrHash == "" {
rawPasswordOrHash = ".*"
}
if !strings.HasSuffix(rawPasswordOrHash, "$") {
rawPasswordOrHash += "$"
}
password, err := regexp.Compile("(?m)" + rawPasswordOrHash)
if err != nil {
return nil, fmt.Errorf("failed to parse %s profile password regex for conf file %s: %v", key, path, err)
}
if (pp.Image == "") == (pp.Container == "") {
return nil, fmt.Errorf("failed to interpret %s profile image / container definition for conf file %s: `Image` or `Container` must be specified, not both nor none of them", key, path)
}
profiles = append(profiles, &Profile{
name: key,
Username: username,
Password: password,
passwordHashAlgo: algo,
NetworkMode: pp.NetworkMode,
Configurable: pp.Configurable,
RunLevel: pp.RunLevel,
StartupInformation: pp.StartupInformation,
ExitAfter: pp.ExitAfter,
KeepOnExit: pp.KeepOnExit,
Image: pp.Image,
ContainerID: pp.Container,
})
count++
zap.S().Debugf("Pre-loaded profile %s (%d)", key, count)
}
return
}
type Profiles []*Profile
func (ps Profiles) GetByName(name string) (*Profile, bool) {
for _, profile := range ps {
if profile.name == name {
return profile, true
}
}
return nil, false
}
func (ps Profiles) Match(user string, password []byte) (*Profile, bool) {
for _, profile := range ps {
if profile.Match(user, password) {
return profile, true
}
}
return nil, false
}
func DefaultPreProfileFromConfig(config *Config) preProfile {
defaultProfile := config.Profile.Default
return preProfile{
Password: defaultProfile.Password,
NetworkMode: defaultProfile.NetworkMode,
Configurable: defaultProfile.Configurable,
RunLevel: defaultProfile.RunLevel,
StartupInformation: defaultProfile.StartupInformation,
ExitAfter: defaultProfile.ExitAfter,
KeepOnExit: defaultProfile.KeepOnExit,
}
}
func HardcodedPreProfile() preProfile {
return preProfile{
NetworkMode: 3,
RunLevel: 1,
StartupInformation: true,
}
}
func DynamicProfileFromConfig(config *Config, defaultPreProfile preProfile) (Profile, error) {
raw, err := json.Marshal(config.Profile.Dynamic)
if err != nil {
return Profile{}, err
}
json.Unmarshal(raw, &defaultPreProfile)
algo, rawPasswordOrHash := getHash(defaultPreProfile.Password)
if algo == nil && rawPasswordOrHash == "" {
rawPasswordOrHash = ".*"
}
password, err := regexp.Compile("(?m)" + rawPasswordOrHash)
if err != nil {
return Profile{}, fmt.Errorf("failed to parse password regex: %v ", err)
}
return Profile{
name: "",
Username: nil,
Password: password,
passwordHashAlgo: algo,
NetworkMode: defaultPreProfile.NetworkMode,
Configurable: defaultPreProfile.Configurable,
RunLevel: defaultPreProfile.RunLevel,
StartupInformation: defaultPreProfile.StartupInformation,
ExitAfter: defaultPreProfile.ExitAfter,
KeepOnExit: defaultPreProfile.KeepOnExit,
}, nil
}
func getHash(password string) (algo hash.Hash, raw string) {
split := strings.SplitN(password, ":", 1)
if len(split) == 1 {
return nil, password
} else {
raw = split[1]
}
switch split[0] {
case "sha1":
algo = sha1.New()
case "sha256":
algo = sha256.New()
case "sha512":
algo = sha512.New()
default:
algo = nil
}
return
}

67
server/database/auth.go Normal file
View File

@ -0,0 +1,67 @@
package database
import (
"golang.org/x/crypto/bcrypt"
)
type Auth struct {
User *string `json:"user"`
Password *[]byte `json:"password"`
}
func NewAuth(user string, password []byte) (Auth, error) {
hash, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost)
if err != nil {
return Auth{}, err
}
return Auth{
&user,
&hash,
}, nil
}
func NewUnsafeAuth(user string, password []byte) Auth {
auth, _ := NewAuth(user, password)
return auth
}
func (db *Database) SetAuth(containerID string, auth Auth) error {
if auth.User != nil {
_, err := db.Exec("INSERT INTO auth (container_id, user) VALUES ($1, $2) ON CONFLICT (container_id) DO UPDATE SET user=$2", containerID, *auth.User)
if err != nil {
return err
}
}
if auth.Password != nil {
_, err := db.Exec("INSERT INTO auth (container_id, password) VALUES ($1, $2) ON CONFLICT (container_id) DO UPDATE SET password=$2", containerID, *auth.Password)
if err != nil {
return err
}
}
return nil
}
// GetAuthByContainer returns the auth by a container id
func (db *Database) GetAuthByContainer(containerID string) (auth Auth, exists bool) {
if err := db.QueryRow("SELECT user, password FROM auth WHERE container_id=$1", containerID).Scan(&auth.User, &auth.Password); err != nil {
return Auth{}, false
}
return auth, true
}
func (db *Database) GetContainerByAuth(auth Auth) (containerID string, exists bool) {
// return true if `auth` contains a nil pointer or no auth was found in the database.
// hopefully this is no security issue
if auth.User == nil || auth.Password == nil {
return "", false
}
if err := db.QueryRow("SELECT container_id FROM auth WHERE user=$1 AND password=$2 OR password IS NULL", auth.User, auth.Password).Scan(&containerID); err != nil {
return "", false
}
return containerID, true
}
func (db *Database) DeleteAuth(containerID string) error {
_, err := db.Exec("DELETE FROM auth WHERE container_id=$1", containerID)
return err
}

View File

@ -0,0 +1,34 @@
package database
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
var globalDB *Database
type Database struct {
*sql.DB
}
func newDatabaseConnection(driverName, dataSource string) (*Database, error) {
database, err := sql.Open(driverName, dataSource)
if err != nil {
return nil, err
}
db := &Database{DB: database}
return db, nil
}
func NewSqlite3Connection(databaseFile string) (*Database, error) {
return newDatabaseConnection("sqlite3", databaseFile)
}
func GetDatabase() *Database {
return globalDB
}
func SetDatabase(database *Database) {
globalDB = database
}

11
server/database/delete.go Normal file
View File

@ -0,0 +1,11 @@
package database
func (db *Database) Delete(containerID string) error {
if _, err := db.Exec("DELETE FROM auth WHERE container_id=$1", containerID); err != nil {
return err
}
if _, err := db.Exec("DELETE FROM settings WHERE container_id=$1", containerID); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,75 @@
package database
import (
"database/sql"
"encoding/json"
"fmt"
"reflect"
"strings"
)
// Settings is the raw version of docker.Config
type Settings struct {
NetworkMode *int `json:"network_mode"`
Configurable *bool `json:"configurable"`
RunLevel *int `json:"run_level"`
StartupInformation *bool `json:"startup_information"`
ExitAfter *string `json:"exit_after"`
KeepOnExit *bool `json:"keep_on_exit"`
}
func (db *Database) SettingsByContainerID(containerID string) (Settings, error) {
row := db.QueryRow("SELECT network_mode, configurable, run_level, startup_information, exit_after, keep_on_exit FROM settings WHERE container_id LIKE $1", fmt.Sprintf("%s%%", containerID))
var settings Settings
if err := row.Scan(&settings.NetworkMode, &settings.Configurable, &settings.RunLevel, &settings.StartupInformation, &settings.ExitAfter, &settings.KeepOnExit); err != nil {
return Settings{}, err
}
return settings, nil
}
func (db *Database) SetSettings(containerID string, settings Settings) error {
query := make(map[string]interface{}, 0)
body, _ := json.Marshal(settings)
json.Unmarshal(body, &query)
var keys, values []string
for k, v := range query {
if v != nil {
keys = append(keys, k)
switch reflect.ValueOf(v).Kind() {
case reflect.String:
values = append(values, fmt.Sprintf("\"%v\"", v))
case reflect.Bool:
if v.(bool) {
values = append(values, fmt.Sprintf("%v", 1))
} else {
values = append(values, fmt.Sprintf("%v", 0))
}
default:
values = append(values, fmt.Sprintf("%v", v))
}
}
}
err := db.QueryRow("SELECT 1 FROM settings WHERE container_id=$1", containerID).Scan()
if err == sql.ErrNoRows {
keys = append(keys, "container_id")
values = append(values, fmt.Sprintf("\"%s\"", containerID))
_, err = db.Exec(fmt.Sprintf("INSERT INTO settings (%s) VALUES (%s)", strings.Join(keys, ", "), strings.Join(values, ", ")))
} else if len(keys) > 0 {
var set []string
for i := 0; i < len(keys); i++ {
set = append(set, fmt.Sprintf("%s=%s", keys[i], values[i]))
}
_, err = db.Exec(fmt.Sprintf("UPDATE settings SET %s WHERE container_id=$1", strings.Join(set, ", ")), containerID)
} else {
err = nil
}
return err
}

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
}

41
server/go.mod Normal file
View File

@ -0,0 +1,41 @@
module docker4ssh
go 1.17
require (
github.com/BurntSushi/toml v0.4.1
github.com/docker/docker v20.10.11+incompatible
github.com/docker/go-units v0.4.0
github.com/mattn/go-sqlite3 v1.14.9
github.com/morikuni/aec v1.0.0
github.com/spf13/cobra v1.0.0
go.uber.org/zap v1.19.1
golang.org/x/crypto v0.0.0-20211202192323-5770296d904e
)
require (
github.com/Microsoft/go-winio v0.4.17 // indirect
github.com/containerd/containerd v1.5.8 // indirect
github.com/docker/distribution v2.7.1+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect
google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a // indirect
google.golang.org/grpc v1.42.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
)
replace golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 => github.com/ByteDream/term v0.0.0-20211025115508-891a970291e6

1005
server/go.sum Normal file

File diff suppressed because it is too large Load Diff

32
server/logging/logging.go Normal file
View File

@ -0,0 +1,32 @@
package logging
import (
"fmt"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func InitLogging(level zap.AtomicLevel, outputFiles, errorFiles []string) {
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("[2006-01-02 15:04:05] -")
encoderConfig.ConsoleSeparator = " "
encoderConfig.EncodeLevel = func(level zapcore.Level, encoder zapcore.PrimitiveArrayEncoder) {
encoder.AppendString(fmt.Sprintf("%s:", level.CapitalString()))
}
encoderConfig.EncodeCaller = nil
config := zap.NewProductionConfig()
config.EncoderConfig = encoderConfig
config.Encoding = "console"
config.Level = level
config.OutputPaths = outputFiles
config.ErrorOutputPaths = errorFiles
logger, err := config.Build()
if err != nil {
panic(err)
}
zap.ReplaceGlobals(logger)
}

9
server/main.go Normal file
View File

@ -0,0 +1,9 @@
package main
import (
"docker4ssh/cmd"
)
func main() {
cmd.Execute()
}

68
server/ssh/config.go Normal file
View File

@ -0,0 +1,68 @@
package ssh
import (
c "docker4ssh/config"
"docker4ssh/database"
"fmt"
"golang.org/x/crypto/ssh"
"io/ioutil"
)
func NewSSHConfig(config *c.Config) (*ssh.ServerConfig, error) {
db := database.GetDatabase()
sshConfig := &ssh.ServerConfig{
MaxAuthTries: 3,
PasswordCallback: func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
if containerID, exists := db.GetContainerByAuth(database.NewUnsafeAuth(conn.User(), password)); exists && containerID != "" {
return &ssh.Permissions{
CriticalOptions: map[string]string{
"containerID": containerID,
},
}, nil
} else if profile, ok := profiles.Match(conn.User(), password); ok {
return &ssh.Permissions{
CriticalOptions: map[string]string{
"profile": profile.Name(),
},
}, nil
} else if config.Profile.Dynamic.Enable && dynamicProfile.Match(conn.User(), password) {
return &ssh.Permissions{
CriticalOptions: map[string]string{
"profile": "dynamic",
"image": conn.User(),
},
}, nil
}
// i think logging the wrong password is a bit unsafe.
// if you have e.g. just a type in it isn't very well to see your nearly correct password in the logs
return nil, fmt.Errorf("%s tried to connect with user %s but entered wrong a password", conn.RemoteAddr().String(), conn.User())
},
}
sshConfig.SetDefaults()
key, err := parseSSHPrivateKey(config.SSH.Keyfile, []byte(config.SSH.Passphrase))
if err != nil {
return nil, err
}
sshConfig.AddHostKey(key)
return sshConfig, nil
}
func parseSSHPrivateKey(path string, password []byte) (ssh.Signer, error) {
keyBytes, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
var key ssh.Signer
if len(password) == 0 {
key, err = ssh.ParsePrivateKey(keyBytes)
} else {
key, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, password)
}
if err != nil {
return nil, err
}
return key, nil
}

201
server/ssh/connection.go Normal file
View File

@ -0,0 +1,201 @@
package ssh
import (
"bytes"
"context"
"database/sql"
"docker4ssh/database"
"docker4ssh/docker"
"docker4ssh/utils"
"fmt"
"go.uber.org/zap"
"strconv"
"sync"
"time"
)
var (
allContainers []*docker.InteractiveContainer
)
func closeAllContainers(ctx context.Context) {
var wg sync.WaitGroup
for _, container := range allContainers {
wg.Add(1)
container := container
go func() {
container.Stop(ctx)
wg.Done()
}()
}
wg.Wait()
}
func connection(client *docker.Client, user *User) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
container, ok := getContainer(ctx, client, user)
if !ok {
zap.S().Errorf("Failed to create container for %s", user.ID)
return
}
user.Container = container.SimpleContainer
var found bool
for _, cont := range allContainers {
if cont == container {
found = true
}
}
if !found {
allContainers = append(allContainers, container)
}
// check if the container is running and start it if not
if running, err := container.Running(ctx); err == nil && !running {
if err = container.Start(ctx); err != nil {
zap.S().Errorf("Failed to start container %s: %v", container.ContainerID, err)
fmt.Fprintln(user.Terminal, "Failed to start container")
return
}
zap.S().Infof("Started container %s with internal id '%s', ip '%s'", container.ContainerID, container.ContainerID, container.Network.IP)
} else if err != nil {
zap.S().Errorf("Failed to get container running state: %v", err)
fmt.Fprintln(user.Terminal, "Failed to check container running state")
}
config := container.Config()
if user.Profile.StartupInformation {
buf := &bytes.Buffer{}
fmt.Fprintf(buf, "┌───Container────────────────┐\r\n")
fmt.Fprintf(buf, "│ Container ID: %-12s │\r\n", container.ContainerID)
fmt.Fprintf(buf, "│ Network Mode: %-12s │\r\n", config.NetworkMode.Name())
fmt.Fprintf(buf, "│ Configurable: %-12t │\r\n", config.Configurable)
fmt.Fprintf(buf, "│ Run Level: %-12s │\r\n", config.RunLevel.Name())
fmt.Fprintf(buf, "│ Exit After: %-12s │\r\n", config.ExitAfter)
fmt.Fprintf(buf, "│ Keep On Exit: %-12t │\r\n", config.KeepOnExit)
fmt.Fprintf(buf, "└──────────────Information───┘\r\n")
user.Terminal.Write(buf.Bytes())
}
// start a new terminal session
if err := container.Terminal(ctx, user.Terminal); err != nil {
zap.S().Errorf("Failed to serve %s terminal: %v", container.ContainerID, err)
fmt.Fprintln(user.Terminal, "Failed to serve terminal")
}
if config.RunLevel == docker.User && container.TerminalCount() == 0 {
if err := container.Stop(ctx); err != nil {
zap.S().Errorf("Error occoured while stopping container %s: %v", container.ContainerID, err)
} else {
lenBefore := len(allContainers)
for i, cont := range allContainers {
if cont == container {
allContainers[i] = allContainers[lenBefore-1]
allContainers = allContainers[:lenBefore-1]
break
}
}
if lenBefore == len(allContainers) {
zap.S().Warnf("Stopped container %s, but failed to remove it from the global container scope", container.ContainerID)
} else {
zap.S().Infof("Stopped container %s", container.ContainerID)
}
}
}
zap.S().Infof("Stopped session for user %s", user.ID)
}
func getContainer(ctx context.Context, client *docker.Client, user *User) (container *docker.InteractiveContainer, ok bool) {
db := database.GetDatabase()
var config docker.Config
// check if the user has a container (id) assigned
if user.Profile.ContainerID != "" {
for _, cont := range allContainers {
if cont.FullContainerID == user.Profile.ContainerID {
return cont, true
}
}
settings, err := db.SettingsByContainerID(user.Profile.ContainerID)
if err != nil {
zap.S().Errorf("Failed to get stored container config for container %s: %v", user.Profile.ContainerID, err)
fmt.Fprintf(user.Terminal, "Could not connect to saved container")
return nil, false
}
config = docker.Config{
NetworkMode: docker.NetworkMode(*settings.NetworkMode),
Configurable: *settings.Configurable,
RunLevel: docker.RunLevel(*settings.RunLevel),
StartupInformation: *settings.StartupInformation,
ExitAfter: *settings.ExitAfter,
KeepOnExit: *settings.KeepOnExit,
}
container, err = docker.InteractiveContainerFromID(ctx, client, config, user.Profile.ContainerID)
if err != nil {
zap.S().Errorf("Failed to get container from id %s: %v", user.Profile.ContainerID, err)
fmt.Fprintf(user.Terminal, "Failed to get container")
return nil, false
}
zap.S().Infof("Re-used container %s for user %s", user.Profile.ContainerID, user.ID)
} else {
config = docker.Config{
NetworkMode: docker.NetworkMode(user.Profile.NetworkMode),
Configurable: user.Profile.Configurable,
RunLevel: docker.RunLevel(user.Profile.RunLevel),
StartupInformation: user.Profile.StartupInformation,
ExitAfter: user.Profile.ExitAfter,
KeepOnExit: user.Profile.KeepOnExit,
}
image, out, err := docker.NewImage(ctx, client.Client, user.Profile.Image)
if err != nil {
zap.S().Errorf("Failed to get '%s' image for profile %s: %v", user.Profile.Image, user.Profile.Name(), err)
fmt.Fprintf(user.Terminal, "Failed to get image %s", image.Ref())
return nil, false
}
if out != nil {
if err := utils.DisplayJSONMessagesStream(out, user.Terminal, user.Terminal); err != nil {
zap.S().Fatalf("Failed to fetch '%s' docker image: %v", image.Ref(), err)
fmt.Fprintf(user.Terminal, "Failed to fetch image %s", image.Ref())
return nil, false
}
}
container, err = docker.NewInteractiveContainer(ctx, client, config, image, strconv.Itoa(int(time.Now().Unix())))
if err != nil {
zap.S().Errorf("Failed to create interactive container: %v", err)
fmt.Fprintln(user.Terminal, "Failed to create interactive container")
return nil, false
}
zap.S().Infof("Created new %s container (%s) for user %s", image.Ref(), container.ContainerID, user.ID)
}
if _, err := db.SettingsByContainerID(container.FullContainerID); err != nil {
if err == sql.ErrNoRows {
rawNetworkMode := int(config.NetworkMode)
rawRunLevel := int(config.RunLevel)
if err := db.SetSettings(container.FullContainerID, database.Settings{
NetworkMode: &rawNetworkMode,
Configurable: &config.Configurable,
RunLevel: &rawRunLevel,
StartupInformation: &config.StartupInformation,
ExitAfter: &config.ExitAfter,
KeepOnExit: &config.KeepOnExit,
}); err != nil {
zap.S().Errorf("Failed to update settings for container %s for user %s: %v", container.ContainerID, user.ID, err)
return nil, false
}
}
}
return container, true
}

79
server/ssh/handle.go Normal file
View File

@ -0,0 +1,79 @@
package ssh
import (
"docker4ssh/docker"
"fmt"
"go.uber.org/zap"
"golang.org/x/crypto/ssh"
)
type RequestType string
const (
RequestPtyReq RequestType = "pty-req"
RequestWindowChange RequestType = "window-change"
)
type PtyReqPayload struct {
Term string
Width, Height uint32
PixelWidth, PixelHeight uint32
Modes []byte
}
func handleChannels(chans <-chan ssh.NewChannel, client *docker.Client, user *User) {
for channel := range chans {
go handleChannel(channel, client, user)
}
}
func handleChannel(channel ssh.NewChannel, client *docker.Client, user *User) {
if t := channel.ChannelType(); t != "session" {
channel.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", t))
return
}
conn, requests, err := channel.Accept()
if err != nil {
zap.S().Warnf("Failed to accept channel for user %s", user.ID)
return
}
defer conn.Close()
user.Terminal.ReadWriter = conn
// handle all other request besides the normal user input.
// currently, only 'pty-req' is implemented which determines a terminal size change
go handleRequest(requests, user)
// this handles the actual user terminal connection.
// blocks until the session has finished
connection(client, user)
zap.S().Debugf("Session for user %s ended", user.ID)
}
func handleRequest(requests <-chan *ssh.Request, user *User) {
for request := range requests {
switch RequestType(request.Type) {
case RequestPtyReq:
// this could spam the logs when the user resizes his window constantly
// log()
var ptyReq PtyReqPayload
ssh.Unmarshal(request.Payload, &ptyReq)
user.Terminal.Width = ptyReq.Width
user.Terminal.Height = ptyReq.Height
case RequestWindowChange:
// prevent from logging
default:
zap.S().Debugf("New request from user %s - Type: %s, Want Reply: %t, Payload: '%s'", user.ID, request.Type, request.WantReply, request.Payload)
}
if request.WantReply {
request.Reply(true, nil)
}
}
}

190
server/ssh/ssh.go Normal file
View File

@ -0,0 +1,190 @@
package ssh
import (
"context"
"crypto/md5"
c "docker4ssh/config"
"docker4ssh/database"
"docker4ssh/docker"
"docker4ssh/terminal"
"encoding/hex"
"fmt"
"go.uber.org/zap"
"golang.org/x/crypto/ssh"
"net"
"regexp"
"strings"
)
var (
users = make([]*User, 0)
profiles c.Profiles
dynamicProfile c.Profile
)
type User struct {
*ssh.ServerConn
ID string
IP string
Profile *c.Profile
Terminal *terminal.Terminal
Container *docker.SimpleContainer
}
func GetUser(ip string) *User {
for _, user := range users {
if container := user.Container; container != nil && container.Network.IP == ip {
return user
}
}
return nil
}
type extras struct {
containerID string
}
func StartServing(config *c.Config, serverConfig *ssh.ServerConfig) (errChan chan error, closer func() error) {
errChan = make(chan error, 1)
var err error
profiles, err = c.LoadProfileDir(config.Profile.Dir, c.DefaultPreProfileFromConfig(config))
if err != nil {
errChan <- err
return
}
zap.S().Debugf("Loaded %d profile(s)", len(profiles))
if config.Profile.Dynamic.Enable {
dynamicProfile, err = c.DynamicProfileFromConfig(config, c.DefaultPreProfileFromConfig(config))
if err != nil {
errChan <- err
return
}
zap.S().Debugf("Loaded dynamic profile")
}
cli, err := docker.InitCli()
if err != nil {
errChan <- err
return
}
zap.S().Debugf("Initialized docker cli")
network, err := docker.InitNetwork(context.Background(), cli, config)
if err != nil {
errChan <- err
return
}
zap.S().Debugf("Initialized docker networks")
client := &docker.Client{
Client: cli,
Database: database.GetDatabase(),
Network: network,
}
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", config.SSH.Port))
if err != nil {
errChan <- err
return
}
zap.S().Debugf("Created ssh listener")
var closed bool
go func() {
db := database.GetDatabase()
for {
conn, err := listener.Accept()
if err != nil {
if closed {
return
}
zap.S().Errorf("Failed to accept new ssh user: %v", err)
continue
}
serverConn, chans, requests, err := ssh.NewServerConn(conn, serverConfig)
if err != nil {
zap.S().Errorf("Failed to establish new ssh connection: %v", err)
continue
}
idBytes := md5.Sum([]byte(strings.Split(serverConn.User(), ":")[0]))
idString := hex.EncodeToString(idBytes[:])
zap.S().Infof("New ssh connection from %s with %s (%s)", serverConn.RemoteAddr().String(), serverConn.ClientVersion(), idString)
var profile *c.Profile
if name, ok := serverConn.Permissions.CriticalOptions["profile"]; ok {
if name == "dynamic" {
if image, ok := serverConn.Permissions.CriticalOptions["image"]; ok {
tempDynamicProfile := dynamicProfile
tempDynamicProfile.Image = image
profile = &tempDynamicProfile
}
}
if profile == nil {
if profile, ok = profiles.GetByName(name); !ok {
zap.S().Errorf("Failed to get profile %s", name)
continue
}
}
} else if containerID, ok := serverConn.Permissions.CriticalOptions["containerID"]; ok {
if settings, err := db.SettingsByContainerID(containerID); err == nil {
profile = &c.Profile{
NetworkMode: *settings.NetworkMode,
Configurable: *settings.Configurable,
RunLevel: *settings.RunLevel,
StartupInformation: *settings.StartupInformation,
ExitAfter: *settings.ExitAfter,
KeepOnExit: *settings.KeepOnExit,
ContainerID: containerID,
}
} else {
for _, container := range allContainers {
if container.ContainerID == containerID {
cconfig := c.GetConfig()
profile = &c.Profile{
Password: regexp.MustCompile(cconfig.Profile.Default.Password),
NetworkMode: cconfig.Profile.Default.NetworkMode,
Configurable: cconfig.Profile.Default.Configurable,
RunLevel: cconfig.Profile.Default.RunLevel,
StartupInformation: cconfig.Profile.Default.StartupInformation,
ExitAfter: cconfig.Profile.Default.ExitAfter,
KeepOnExit: cconfig.Profile.Default.KeepOnExit,
Image: "",
ContainerID: containerID,
}
}
}
}
}
zap.S().Debugf("User %s has profile %s", idString, profile.Name())
user := &User{
ServerConn: serverConn,
ID: idString,
Terminal: &terminal.Terminal{},
Profile: profile,
}
users = append(users, user)
go ssh.DiscardRequests(requests)
go handleChannels(chans, client, user)
}
}()
return errChan, func() error {
closed = true
// close all containers
closeAllContainers(context.Background())
// close the listener
return listener.Close()
}
}

View File

@ -0,0 +1,9 @@
package terminal
import "io"
type Terminal struct {
io.ReadWriter
Width, Height uint32
}

27
server/utils/convert.go Normal file
View File

@ -0,0 +1,27 @@
package utils
import (
"regexp"
"strings"
)
func UsernameToRegex(username string) (*regexp.Regexp, error) {
var rawUsername string
if rawUsername = strings.TrimPrefix(username, "regex:"); rawUsername == username {
rawUsername = strings.ReplaceAll(rawUsername, "*", ".*")
}
return regexp.Compile(rawUsername)
}
func PasswordToRegex(password string) (*regexp.Regexp, error) {
splitPassword := strings.SplitN(password, ":", 1)
if len(splitPassword) > 1 {
switch splitPassword[0] {
case "regex":
return regexp.Compile(splitPassword[1])
case "sha1", "sha256", "sha512":
password = splitPassword[1]
}
}
return regexp.Compile(strings.ReplaceAll(password, "*", ".*"))
}

238
server/utils/jsonmessage.go Normal file
View File

@ -0,0 +1,238 @@
// adopted from https://github.com/docker/cli/blob/a32cd16160f1b41c1c4ae7bee4dac929d1484e59/vendor/github.com/docker/docker/pkg/jsonmessage/jsonmessage.go
package utils
import (
"docker4ssh/terminal"
"encoding/json"
"fmt"
"github.com/docker/go-units"
"github.com/morikuni/aec"
"io"
"strings"
"time"
)
// RFC3339NanoFixed is time.RFC3339Nano with nanoseconds padded using zeros to
// ensure the formatted time is always the same number of characters.
const RFC3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
// JSONError wraps a concrete Code and Message, `Code` is
// an integer error code, `Message` is the error message.
type JSONError struct {
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
func (e *JSONError) Error() string {
return e.Message
}
// JSONProgress describes a progress. terminalFd is the fd of the current terminal,
// Start is the initial value for the operation. Current is the current status and
// value of the progress made towards Total. Total is the end value describing when
// we made 100% progress for an operation.
type JSONProgress struct {
Terminal *terminal.Terminal
Current int64 `json:"current,omitempty"`
Total int64 `json:"total,omitempty"`
Start int64 `json:"start,omitempty"`
// If true, don't show xB/yB
HideCounts bool `json:"hidecounts,omitempty"`
Units string `json:"units,omitempty"`
}
func (p *JSONProgress) String() string {
var (
width = p.Terminal.Width
pbBox string
numbersBox string
timeLeftBox string
)
if p.Current <= 0 && p.Total <= 0 {
return ""
}
if p.Total <= 0 {
switch p.Units {
case "":
current := units.HumanSize(float64(p.Current))
return fmt.Sprintf("%8v", current)
default:
return fmt.Sprintf("%d %s", p.Current, p.Units)
}
}
percentage := int(float64(p.Current)/float64(p.Total)*100) / 2
if percentage > 50 {
percentage = 50
}
if width > 110 {
// this number can't be negative gh#7136
numSpaces := 0
if 50-percentage > 0 {
numSpaces = 50 - percentage
}
pbBox = fmt.Sprintf("[%s>%s] ", strings.Repeat("=", percentage), strings.Repeat(" ", numSpaces))
}
switch {
case p.HideCounts:
case p.Units == "": // no units, use bytes
current := units.HumanSize(float64(p.Current))
total := units.HumanSize(float64(p.Total))
numbersBox = fmt.Sprintf("%8v/%v", current, total)
if p.Current > p.Total {
// remove total display if the reported current is wonky.
numbersBox = fmt.Sprintf("%8v", current)
}
default:
numbersBox = fmt.Sprintf("%d/%d %s", p.Current, p.Total, p.Units)
if p.Current > p.Total {
// remove total display if the reported current is wonky.
numbersBox = fmt.Sprintf("%d %s", p.Current, p.Units)
}
}
if p.Current > 0 && p.Start > 0 && percentage < 50 {
fromStart := time.Now().Sub(time.Unix(p.Start, 0))
perEntry := fromStart / time.Duration(p.Current)
left := time.Duration(p.Total-p.Current) * perEntry
left = (left / time.Second) * time.Second
if width > 50 {
timeLeftBox = " " + left.String()
}
}
return pbBox + numbersBox + timeLeftBox
}
// JSONMessage defines a message struct. It describes
// the created time, where it from, status, ID of the
// message. It's used for docker events.
type JSONMessage struct {
Stream string `json:"stream,omitempty"`
Status string `json:"status,omitempty"`
Progress *JSONProgress `json:"progressDetail,omitempty"`
ProgressMessage string `json:"progress,omitempty"` // deprecated
ID string `json:"id,omitempty"`
From string `json:"from,omitempty"`
Time int64 `json:"time,omitempty"`
TimeNano int64 `json:"timeNano,omitempty"`
Error *JSONError `json:"errorDetail,omitempty"`
ErrorMessage string `json:"error,omitempty"` // deprecated
// Aux contains out-of-band data, such as digests for push signing and image id after building.
Aux *json.RawMessage `json:"aux,omitempty"`
}
func clearLine(out io.Writer) {
eraseMode := aec.EraseModes.All
cl := aec.EraseLine(eraseMode)
fmt.Fprint(out, cl)
}
func cursorUp(out io.Writer, l uint) {
fmt.Fprint(out, aec.Up(l))
}
func cursorDown(out io.Writer, l uint) {
fmt.Fprint(out, aec.Down(l))
}
// Display displays the JSONMessage to `out`. If `isTerminal` is true, it will erase the
// entire current line when displaying the progressbar.
func (jm *JSONMessage) Display(out io.Writer) error {
if jm.Error != nil {
if jm.Error.Code == 401 {
return fmt.Errorf("authentication is required")
}
return jm.Error
}
var endl string
if jm.Stream == "" && jm.Progress != nil {
clearLine(out)
endl = "\r"
fmt.Fprint(out, endl)
} else if jm.Progress != nil && jm.Progress.String() != "" { // disable progressbar in non-terminal
return nil
}
if jm.TimeNano != 0 {
fmt.Fprintf(out, "%s ", time.Unix(0, jm.TimeNano).Format(RFC3339NanoFixed))
} else if jm.Time != 0 {
fmt.Fprintf(out, "%s ", time.Unix(jm.Time, 0).Format(RFC3339NanoFixed))
}
if jm.ID != "" {
fmt.Fprintf(out, "%s: ", jm.ID)
}
if jm.From != "" {
fmt.Fprintf(out, "(from %s) ", jm.From)
}
if jm.Progress != nil {
fmt.Fprintf(out, "%s %s%s", jm.Status, jm.Progress.String(), endl)
} else if jm.ProgressMessage != "" { // deprecated
fmt.Fprintf(out, "%s %s%s", jm.Status, jm.ProgressMessage, endl)
} else if jm.Stream != "" {
fmt.Fprintf(out, "%s%s", jm.Stream, endl)
} else {
fmt.Fprintf(out, "%s%s\r\n", jm.Status, endl)
}
return nil
}
// DisplayJSONMessagesStream displays a json message stream from `in` to `out`, `isTerminal`
// describes if `out` is a terminal. If this is the case, it will print `\n` at the end of
// each line and move the cursor while displaying.
func DisplayJSONMessagesStream(in io.Reader, out io.Writer, term *terminal.Terminal) error {
var (
dec = json.NewDecoder(in)
ids = make(map[string]uint)
)
for {
var diff uint
var jm JSONMessage
if err := dec.Decode(&jm); err != nil {
if err == io.EOF {
break
}
return err
}
if jm.Progress != nil {
jm.Progress.Terminal = term
}
if jm.ID != "" && (jm.Progress != nil || jm.ProgressMessage != "") {
line, ok := ids[jm.ID]
if !ok {
// NOTE: This approach of using len(id) to
// figure out the number of lines of history
// only works as long as we clear the history
// when we output something that's not
// accounted for in the map, such as a line
// with no ID.
line = uint(len(ids))
ids[jm.ID] = line
fmt.Fprintf(out, "\r\n")
}
diff = uint(len(ids)) - line
cursorUp(out, diff)
} else {
// When outputting something that isn't progress
// output, clear the history of previous lines. We
// don't want progress entries from some previous
// operation to be updated (for example, pull -a
// with multiple tags).
ids = make(map[string]uint)
}
err := jm.Display(out)
if jm.ID != "" {
cursorDown(out, diff)
}
if err != nil {
return err
}
}
return nil
}

33
server/validate/error.go Normal file
View File

@ -0,0 +1,33 @@
package validate
import "fmt"
func newValidateError(section, key string, value interface{}, message string, original error) *ValidateError {
return &ValidateError{
section: section,
key: key,
value: value,
message: message,
originalError: original,
}
}
type ValidateError struct {
error
section string
key string
value interface{}
message string
originalError error
}
func (ve *ValidateError) Error() string {
if ve.originalError != nil {
return fmt.Sprintf("failed to validate %s.%s (%v), %s: %v", ve.section, ve.key, ve.value, ve.message, ve.originalError)
} else {
return fmt.Sprintf("failed to validate %s.%s (%v), %s", ve.section, ve.key, ve.value, ve.message)
}
}

View File

@ -0,0 +1,45 @@
package validate
import (
"fmt"
"github.com/docker/docker/client"
"strings"
)
type Validator struct {
Cli *client.Client
Strict bool
}
type ValidatorResult struct {
Strict bool
Errors []*ValidateError
}
func (vr *ValidatorResult) Ok() bool {
return len(vr.Errors) == 0
}
func (vr *ValidatorResult) String() string {
builder := strings.Builder{}
if len(vr.Errors) == 0 {
if vr.Strict {
builder.WriteString("Validated all files, no errors were found. You're good to go (strict mode on)")
} else {
builder.WriteString("Validated all files, no errors were found. You're good to go")
}
} else {
if vr.Strict {
builder.WriteString(fmt.Sprintf("Found %d errors (strict mode on)\n\n", len(vr.Errors)))
} else {
builder.WriteString(fmt.Sprintf("Found %d errors\n\n", len(vr.Errors)))
}
for _, err := range vr.Errors {
builder.WriteString(fmt.Sprintf("%v\n", err))
}
}
return builder.String()
}

View File

@ -0,0 +1,264 @@
package validate
import (
"docker4ssh/config"
"docker4ssh/docker"
"docker4ssh/utils"
"fmt"
"github.com/docker/docker/client"
"go.uber.org/zap"
s "golang.org/x/crypto/ssh"
"io/ioutil"
"net"
"os"
"path/filepath"
"strings"
)
func NewConfigValidator(cli *client.Client, strict bool, config *config.Config) *ConfigValidator {
return &ConfigValidator{
Validator: &Validator{
Cli: cli,
Strict: strict,
},
Config: config,
}
}
type ConfigValidator struct {
*Validator
Config *config.Config
}
func (cv *ConfigValidator) Validate() *ValidatorResult {
errors := make([]*ValidateError, 0)
errors = append(errors, cv.ValidateProfile().Errors...)
errors = append(errors, cv.ValidateAPI().Errors...)
errors = append(errors, cv.ValidateSSH().Errors...)
errors = append(errors, cv.ValidateDatabase().Errors...)
errors = append(errors, cv.ValidateNetwork().Errors...)
errors = append(errors, cv.ValidateLogging().Errors...)
return &ValidatorResult{
Strict: cv.Strict,
Errors: errors,
}
}
func (cv *ConfigValidator) ValidateProfile() *ValidatorResult {
errors := make([]*ValidateError, 0)
errors = append(errors, cv.validateProfileDefault()...)
errors = append(errors, cv.validateProfileDynamic()...)
return &ValidatorResult{
Strict: cv.Strict,
Errors: errors,
}
}
func (cv *ConfigValidator) validateProfileDefault() []*ValidateError {
profileDefault := cv.Config.Profile.Default
errors := make([]*ValidateError, 0)
if _, err := utils.PasswordToRegex(profileDefault.Password); err != nil {
errors = append(errors, newValidateError("profile.default", "Password", profileDefault.Password, "not a valid regex string", err))
}
networkMode := docker.NetworkMode(profileDefault.NetworkMode)
if docker.Off > networkMode || networkMode > docker.None {
errors = append(errors, newValidateError("profile.default", "NetworkMode", profileDefault.NetworkMode, "not a valid network mode", nil))
}
runLevel := docker.RunLevel(profileDefault.RunLevel)
if docker.User > runLevel || runLevel > docker.Forever {
errors = append(errors, newValidateError("profile.default", "RunLevel", profileDefault.RunLevel, "is not a valid run level", nil))
}
return errors
}
func (cv *ConfigValidator) validateProfileDynamic() []*ValidateError {
profileDynamic := cv.Config.Profile.Dynamic
errors := make([]*ValidateError, 0)
if !profileDynamic.Enable && !cv.Strict {
return errors
}
if _, err := utils.PasswordToRegex(profileDynamic.Password); err != nil {
errors = append(errors, newValidateError("profile.dynamic", "Password", profileDynamic.Password, "not a valid regex string", err))
}
networkMode := docker.NetworkMode(profileDynamic.NetworkMode)
if docker.Off > networkMode || networkMode > docker.None {
errors = append(errors, newValidateError("profile.dynamic", "NetworkMode", profileDynamic.NetworkMode, "not a valid network mode", nil))
}
runLevel := docker.RunLevel(profileDynamic.RunLevel)
if docker.User > runLevel || runLevel > docker.Forever {
errors = append(errors, newValidateError("profile.dynamic", "RunLevel", profileDynamic.RunLevel, "is not a valid run level", nil))
}
return errors
}
func (cv *ConfigValidator) ValidateAPI() *ValidatorResult {
api := cv.Config.Api
errors := make([]*ValidateError, 0)
if cv.Strict && !isPortFree(api.Port) {
errors = append(errors, newValidateError("api", "Port", api.Port, "port is already in use", nil))
}
errors = append(errors, cv.validateAPIConfigure().Errors...)
return &ValidatorResult{
Strict: cv.Strict,
Errors: errors,
}
}
func (cv *ConfigValidator) validateAPIConfigure() *ValidatorResult {
apiConfigure := cv.Config.Api.Configure
errors := make([]*ValidateError, 0)
for k, v := range map[string]string{"Binary": apiConfigure.Binary, "Man": apiConfigure.Man} {
path := absolutePath("", v)
if msg, err, ok := fileOk(path); !ok {
errors = append(errors, newValidateError("api.configure", k, path, msg, err))
}
}
return &ValidatorResult{
Strict: cv.Strict,
Errors: errors,
}
}
func (cv *ConfigValidator) ValidateSSH() *ValidatorResult {
ssh := cv.Config.SSH
errors := make([]*ValidateError, 0)
if cv.Strict && !isPortFree(ssh.Port) {
errors = append(errors, newValidateError("api", "Port", ssh.Port, "port is already in use", nil))
}
path := absolutePath("", ssh.Keyfile)
if msg, err, ok := fileOk(path); !ok {
errors = append(errors, newValidateError("ssh", "Keyfile", path, msg, err))
} else {
keyBytes, err := ioutil.ReadFile(path)
if err != nil {
panic(fmt.Sprintf("failed to read file %s: %v", path, err))
}
if ssh.Passphrase == "" {
if _, err = s.ParsePrivateKey(keyBytes); err != nil {
errors = append(errors, newValidateError("ssh", "Passphrase", ssh.Passphrase, "failed to parse ssh keyfile without password", err))
}
} else {
if _, err = s.ParsePrivateKeyWithPassphrase(keyBytes, []byte(ssh.Passphrase)); err != nil {
errors = append(errors, newValidateError("ssh", "Passphrase", ssh.Passphrase, "failed to parse ssh keyfile with password", err))
}
}
}
return &ValidatorResult{
Strict: cv.Strict,
Errors: errors,
}
}
func (cv *ConfigValidator) ValidateDatabase() *ValidatorResult {
database := cv.Config.Database
errors := make([]*ValidateError, 0)
path := absolutePath("", database.Sqlite3File)
if msg, err, ok := fileOk(path); !ok {
errors = append(errors, newValidateError("database", "Sqlite3File", path, msg, err))
}
// TODO: implement sql database schema
return &ValidatorResult{
Strict: cv.Strict,
Errors: errors,
}
}
func (cv *ConfigValidator) ValidateNetwork() *ValidatorResult {
network := cv.Config.Network
errors := make([]*ValidateError, 0)
if strings.Index(network.Default.Subnet, "/") == -1 {
errors = append(errors, newValidateError("network.default", "Subnet", network.Default.Subnet, "no network mask is given", nil))
} else if subnet, _, err := net.ParseCIDR(network.Default.Subnet); err != nil {
errors = append(errors, newValidateError("network.default", "Subnet", network.Default.Subnet, "invalid subnet ip", err))
} else if subnet == nil {
errors = append(errors, newValidateError("network.default", "Subnet", network.Default.Subnet, "invalid subnet ip", nil))
}
if strings.Index(network.Isolate.Subnet, "/") == -1 {
errors = append(errors, newValidateError("network.isolate", "Subnet", network.Isolate.Subnet, "no network mask is given", nil))
} else if subnet, _, err := net.ParseCIDR(network.Isolate.Subnet); err != nil {
errors = append(errors, newValidateError("network.isolate", "Subnet", network.Isolate.Subnet, "invalid subnet ip", err))
} else if subnet == nil {
errors = append(errors, newValidateError("network.isolate", "Subnet", network.Isolate.Subnet, "invalid subnet ip", nil))
}
return &ValidatorResult{
Strict: cv.Strict,
Errors: errors,
}
}
func (cv *ConfigValidator) ValidateLogging() *ValidatorResult {
logging := cv.Config.Logging
errors := make([]*ValidateError, 0)
level := zap.NewAtomicLevel()
if err := level.UnmarshalText([]byte(logging.Level)); err != nil {
errors = append(errors, newValidateError("logging", "Level", logging.Level, "invalid level", err))
}
if cv.Strict {
path := absolutePath("", logging.OutputFile)
if msg, err, ok := fileOk(path); !ok {
errors = append(errors, newValidateError("logging", "OutputFile", logging.OutputFile, msg, err))
}
path = absolutePath("", logging.ErrorFile)
if msg, err, ok := fileOk(path); !ok {
errors = append(errors, newValidateError("logging", "ErrorFile", logging.ErrorFile, msg, err))
}
}
return &ValidatorResult{
Strict: cv.Strict,
Errors: errors,
}
}
func isPortFree(port uint16) bool {
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if listener != nil {
listener.Close()
}
return err == nil && port != 0
}
func absolutePath(parentPath, filePath string) (path string) {
if filepath.IsAbs(filePath) {
path = filePath
} else {
path = filepath.Join(parentPath, filePath)
}
return
}
func fileOk(path string) (string, error, bool) {
if info, err := os.Stat(path); os.IsNotExist(err) {
return "file does not exist", err, false
} else if info.IsDir() {
return "file is an directory", nil, false
} else if err != nil {
return "unexpected error", err, false
}
return "", nil, true
}

View File

@ -0,0 +1,64 @@
package validate
import (
"context"
"docker4ssh/config"
"docker4ssh/docker"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
)
func NewProfileValidator(cli *client.Client, strict bool, profile *config.Profile) *ProfileValidator {
return &ProfileValidator{
Validator: &Validator{
Cli: cli,
Strict: strict,
},
Profile: profile,
}
}
type ProfileValidator struct {
*Validator
Profile *config.Profile
}
func (pv *ProfileValidator) Validate() *ValidatorResult {
profile := pv.Profile
errors := make([]*ValidateError, 0)
networkMode := docker.NetworkMode(profile.NetworkMode)
if docker.Off > networkMode || networkMode > docker.None {
errors = append(errors, newValidateError(profile.Name(), "NetworkMode", profile.NetworkMode, "not a valid network mode", nil))
}
runLevel := docker.RunLevel(profile.RunLevel)
if docker.User > runLevel || runLevel > docker.Forever {
errors = append(errors, newValidateError(profile.Name(), "RunLevel", profile.RunLevel, "is not a valid run level", nil))
}
if profile.Image == "" && profile.ContainerID == "" {
errors = append(errors, newValidateError(profile.Name(), "image/container", "", "Image OR Container must be specified, neither both nor none", nil))
} else if pv.Strict {
if profile.Image != "" {
list, err := pv.Cli.ImageList(context.Background(), types.ImageListOptions{
Filters: filters.NewArgs(filters.Arg("reference", profile.Image)),
})
if err != nil || len(list) == 0 {
errors = append(errors, newValidateError(profile.Name(), "Image", profile.Image, "image does not exist", nil))
}
} else if profile.ContainerID != "" {
list, err := pv.Cli.ContainerList(context.Background(), types.ContainerListOptions{
Filters: filters.NewArgs(filters.Arg("id", profile.ContainerID)),
})
if err != nil || len(list) == 0 {
errors = append(errors, newValidateError(profile.Name(), "Image", profile.Image, "container does not exist", nil))
}
}
}
return &ValidatorResult{
Strict: pv.Strict,
Errors: errors,
}
}