mirror of
https://github.com/bytedream/docker4ssh.git
synced 2025-06-27 01:40:32 +02:00
Initial commit
This commit is contained in:
123
server/api/api.go
Normal file
123
server/api/api.go
Normal 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
80
server/api/auth.go
Normal 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
124
server/api/config.go
Normal 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
12
server/api/error.go
Normal 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
16
server/api/info.go
Normal 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
15
server/api/ping.go
Normal 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
35
server/api/utils.go
Normal 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
0
server/build/docker4ssh
Normal file
18
server/cmd/cmd.go
Normal file
18
server/cmd/cmd.go
Normal 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
160
server/cmd/start.go
Normal 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
159
server/cmd/validate.go
Normal 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
189
server/config/config.go
Normal 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
254
server/config/profile.go
Normal 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
67
server/database/auth.go
Normal 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
|
||||
}
|
34
server/database/database.go
Normal file
34
server/database/database.go
Normal 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
11
server/database/delete.go
Normal 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
|
||||
}
|
75
server/database/settings.go
Normal file
75
server/database/settings.go
Normal 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
12
server/docker/client.go
Normal 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
637
server/docker/container.go
Normal 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
120
server/docker/docker.go
Normal 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
41
server/docker/image.go
Normal 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
86
server/docker/network.go
Normal 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
41
server/go.mod
Normal 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
1005
server/go.sum
Normal file
File diff suppressed because it is too large
Load Diff
32
server/logging/logging.go
Normal file
32
server/logging/logging.go
Normal 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
9
server/main.go
Normal file
@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"docker4ssh/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
68
server/ssh/config.go
Normal file
68
server/ssh/config.go
Normal 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
201
server/ssh/connection.go
Normal 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
79
server/ssh/handle.go
Normal 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
190
server/ssh/ssh.go
Normal 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()
|
||||
}
|
||||
}
|
9
server/terminal/terminal.go
Normal file
9
server/terminal/terminal.go
Normal file
@ -0,0 +1,9 @@
|
||||
package terminal
|
||||
|
||||
import "io"
|
||||
|
||||
type Terminal struct {
|
||||
io.ReadWriter
|
||||
|
||||
Width, Height uint32
|
||||
}
|
27
server/utils/convert.go
Normal file
27
server/utils/convert.go
Normal 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
238
server/utils/jsonmessage.go
Normal 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
33
server/validate/error.go
Normal 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)
|
||||
}
|
||||
}
|
45
server/validate/validate.go
Normal file
45
server/validate/validate.go
Normal 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()
|
||||
}
|
264
server/validate/validate_config.go
Normal file
264
server/validate/validate_config.go
Normal 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
|
||||
}
|
64
server/validate/validate_profile.go
Normal file
64
server/validate/validate_profile.go
Normal 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,
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user