diff --git a/cmd/ssh.go b/cmd/ssh.go index 76c6e4c..38ee568 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -10,7 +10,8 @@ import ( var SSH wisski_distillery.Command = ssh{} type ssh struct { - Bind string `short:"b" long:"bind" description:"address to listen on" default:"127.0.0.1:2223"` + Bind string `short:"b" long:"bind" description:"address to listen on" default:"127.0.0.1:2223"` + PrivateKeyPath string `short:"p" long:"private-key-path" description:"Path to store private host keys in" required:"1"` } func (s ssh) Description() wisski_distillery.Description { @@ -30,7 +31,7 @@ var errSSHListen = exit.Error{ func (s ssh) Run(context wisski_distillery.Context) error { dis := context.Environment - server, err := dis.SSH().Server(dis.Context(), context.IOStream) + server, err := dis.SSH().Server(dis.Context(), s.PrivateKeyPath, context.IOStream) if err != nil { return err } diff --git a/go.mod b/go.mod index cbf47cc..ec7f726 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/gorilla/websocket v1.5.0 github.com/pkg/errors v0.9.1 github.com/tkw1536/goprogram v0.1.1 - github.com/tkw1536/proxyssh v0.2.2 + golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 gorm.io/driver/mysql v1.3.6 @@ -26,7 +26,6 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-isatty v0.0.16 // indirect - golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d // indirect golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect golang.org/x/term v0.0.0-20220919170432-7a66f970e087 // indirect ) diff --git a/go.sum b/go.sum index 3310a2b..bb29a0f 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/tkw1536/goprogram v0.1.1 h1:gamK9OuRqoX2yQlA/nkgfVHHZWd/u2uUj6vJMYrYa70= github.com/tkw1536/goprogram v0.1.1/go.mod h1:Jqs0sTMzhrAGCX3JQrlEwQ0WRWQACCvuQQkaBDp65pE= -github.com/tkw1536/proxyssh v0.2.2 h1:2NVlTsRFFVfGaQH6B0Ci8n0ura1Qk4uEcZM0cDXAIF4= -github.com/tkw1536/proxyssh v0.2.2/go.mod h1:N6fExNESwKnKV1d0JGT3fvke+Aym22VNCAPEGJTofPQ= golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d h1:3qF+Z8Hkrw9sOhrFHti9TlB1Hkac1x+DNRkv0XQiFjo= golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 h1:fGZugkZk2UgYBxtpKmvub51Yno1LJDeEsRp2xGD+0gY= diff --git a/internal/dis/component/ssh/ssh.go b/internal/dis/component/ssh/ssh.go deleted file mode 100644 index 7770c8f..0000000 --- a/internal/dis/component/ssh/ssh.go +++ /dev/null @@ -1,104 +0,0 @@ -package ssh - -import ( - "bufio" - "context" - "fmt" - "io" - - "github.com/FAU-CDI/wisski-distillery/internal/dis/component" - "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" - "github.com/FAU-CDI/wisski-distillery/pkg/sshx" - "github.com/gliderlabs/ssh" - "github.com/tkw1536/goprogram/stream" - "github.com/tkw1536/proxyssh/feature" -) - -type SSH struct { - component.Base - Instances *instances.Instances -} - -const ( - etx rune = 3 - eot rune = 4 -) - -const welcomeMessage = `Welcome to the WissKI SSH Server. -You've successfully authenticated, but we don't provide shell access to the main server. -You may use this connection as part of a proxy jump to connect to your server. -For example: - -ssh -J %s:2222 www-data@%s - -Press CTRL-C to close this connection. -` - -// Server returns an ssh server that implements the main ssh server -func (s *SSH) Server(context context.Context, ios stream.IOStream) (*ssh.Server, error) { - var server ssh.Server - - banner := fmt.Sprintf(welcomeMessage, s.Config.DefaultDomain, "example."+s.Config.DefaultDomain) - - server.Handle(func(session ssh.Session) { - io.WriteString(session, banner) - - buffer := bufio.NewReader(session) - for { - res, _, err := buffer.ReadRune() - if err != nil { - return - } - if res == etx || res == eot { - return - } - } - - }) - server.PublicKeyHandler = feature.AuthorizeKeys( - slogger{IOStream: ios}, - func(ctx ssh.Context) (keys []ssh.PublicKey, err error) { - keys, err = s.GlobalKeys() - if err != nil { - return nil, err - } - - instances, err := s.Instances.All() - if err != nil { - return nil, err - } - - for _, instance := range instances { - ikeys, err := instance.SSH().Keys() - if err != nil { - continue - } - keys = append(keys, ikeys...) - } - return keys, nil - }) - return &server, nil -} - -func (s *SSH) GlobalKeys() ([]ssh.PublicKey, error) { - file, err := s.Environment.Open(s.Config.GlobalAuthorizedKeysFile) - if err != nil { - return nil, err - } - bytes, err := io.ReadAll(file) - if err != nil { - return nil, err - } - return sshx.ParseAllKeys(bytes), nil -} - -type slogger struct { - stream.IOStream -} - -func (s slogger) Print(v ...any) { - fmt.Fprint(s.Stderr, v...) -} -func (s slogger) Printf(format string, v ...any) { - fmt.Fprintf(s.Stderr, format, v...) -} diff --git a/internal/dis/component/ssh2/server.go b/internal/dis/component/ssh2/server.go new file mode 100644 index 0000000..aef1707 --- /dev/null +++ b/internal/dis/component/ssh2/server.go @@ -0,0 +1,38 @@ +package ssh2 + +import ( + "context" + + "github.com/gliderlabs/ssh" + "github.com/tkw1536/goprogram/stream" +) + +const ( + etx rune = 3 + eot rune = 4 +) + +const welcomeMessage = `Welcome to the WissKI SSH Server. +You've successfully authenticated, but we don't provide shell access to the main server. +You may use this connection as part of a proxy jump to connect to your server. +For example: + +ssh -J %s:2222 www-data@%s + +Press CTRL-C to close this connection. +` + +// Server returns an ssh server that implements the main ssh server +func (ssh2 *SSH2) Server(context context.Context, privateKeyPath string, io stream.IOStream) (*ssh.Server, error) { + var server ssh.Server + + if err := ssh2.setupHostKeys(io, privateKeyPath, &server); err != nil { + return nil, err + } + + ssh2.setupForwardHandler(&server) + ssh2.setupHandler(&server) + ssh2.setupAuth(&server) + + return &server, nil +} diff --git a/internal/dis/component/ssh2/server_auth.go b/internal/dis/component/ssh2/server_auth.go new file mode 100644 index 0000000..afa1cf7 --- /dev/null +++ b/internal/dis/component/ssh2/server_auth.go @@ -0,0 +1,110 @@ +package ssh2 + +import ( + "time" + + "github.com/gliderlabs/ssh" +) + +func (ssh2 *SSH2) setupAuth(server *ssh.Server) { + server.PublicKeyHandler = ssh2.handleAuth +} + +// ssh2Key is a type of context keys for this package +type ssh2Key int + +const ( + // permissions represents the permissions for the given session + permission ssh2Key = iota +) + +func setPermissions(context ssh.Context, permissions map[string]bool) { + context.SetValue(permission, permissions) +} + +// hasPermission checks if the given context permits access to the given slug. +// The empty slug checks for global access. +func hasPermission(context ssh.Context, slug string) bool { + value, ok := context.Value(permission).(map[string]bool) + return ok && value[slug] +} + +// getAnyPermission gets some instance the user has access to. +// If the user does not have access to anything, returns "", false. +// If the user has superuser access, but there are no instances, returns "", true. +func getAnyPermission(context ssh.Context) (string, bool) { + value, ok := context.Value(permission).(map[string]bool) + if !ok { + return "", false + } + + for slug, ok := range value { + if ok && slug != "" { + return slug, true + } + } + + return "", (false || value[""]) +} + +const authDelay = time.Second / 10 + +func (ssh2 *SSH2) handleAuth(ctx ssh.Context, key ssh.PublicKey) bool { + return slowdown(func() (ok bool) { + permissions := make(map[string]bool) + + // grab the global permissions + { + globalKeys, err := ssh2.GlobalKeys() + if err != nil { + return false + } + permissions[""] = isKey(globalKeys, key) + ok = permissions[""] + } + + // grab permissions for each instance + { + instances, err := ssh2.Instances.All() + if err != nil { + return false + } + + for _, instance := range instances { + ikeys, err := instance.SSH().Keys() + if err != nil { + continue + } + access := isKey(ikeys, key) + + permissions[instance.Slug] = access || permissions[""] + ok = ok || access + } + } + + setPermissions(ctx, permissions) + return + }, authDelay) +} + +// slowdown invokes f immediatly, but only returns the result to the caller after at least duration. +// It can be used to prevent timing attacks +func slowdown[T any](f func() T, duration time.Duration) T { + result := make(chan T, 1) + go func() { + result <- f() + }() + time.Sleep(duration) + return <-result +} + +// isKey checks if keys contains key in O(len(keys)) +func isKey(keys []ssh.PublicKey, key ssh.PublicKey) bool { + var res bool + for _, ak := range keys { + if ssh.KeysEqual(ak, key) { + res = true + } + } + return res +} diff --git a/internal/dis/component/ssh2/server_forward.go b/internal/dis/component/ssh2/server_forward.go new file mode 100644 index 0000000..227b4ee --- /dev/null +++ b/internal/dis/component/ssh2/server_forward.go @@ -0,0 +1,72 @@ +package ssh2 + +import ( + "io" + "net" + + "github.com/gliderlabs/ssh" + gossh "golang.org/x/crypto/ssh" +) + +// direct-tcpip data struct as specified in RFC4254, Section 7.2 +type localForwardChannelData struct { + DestAddr string + DestPort uint32 + + OriginAddr string + OriginPort uint32 +} + +// seetupForwardHandler sets up the forwarding handler for the ssh server +func (ssh2 *SSH2) setupForwardHandler(server *ssh.Server) { + if server.ChannelHandlers == nil { + server.ChannelHandlers = make(map[string]ssh.ChannelHandler) + for n, h := range ssh.DefaultChannelHandlers { + server.ChannelHandlers[n] = h + } + } + server.ChannelHandlers["direct-tcpip"] = ssh2.handleDirectTCP +} + +// handleDirectTCP handles a direct tcp connection for the server +func (ssh2 *SSH2) handleDirectTCP(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) { + d := localForwardChannelData{} + if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil { + newChan.Reject(gossh.ConnectionFailed, "error parsing forward data: "+err.Error()) + return + } + + slug, ok := ssh2.Config.SlugFromHost(d.DestAddr) + if !ok || d.DestPort != 22 || !hasPermission(ctx, slug) { + newChan.Reject(gossh.Prohibited, "permission denied") + return + } + + // TODO: move this into an instance function somewhere + dest := net.JoinHostPort(slug+"."+ssh2.Config.DefaultDomain+".wisski", "22") + + var dialer net.Dialer + dconn, err := dialer.DialContext(ctx, "tcp", dest) + if err != nil { + newChan.Reject(gossh.ConnectionFailed, err.Error()) + return + } + + ch, reqs, err := newChan.Accept() + if err != nil { + dconn.Close() + return + } + go gossh.DiscardRequests(reqs) + + go func() { + defer ch.Close() + defer dconn.Close() + io.Copy(ch, dconn) + }() + go func() { + defer ch.Close() + defer dconn.Close() + io.Copy(dconn, ch) + }() +} diff --git a/internal/dis/component/ssh2/server_handler.go b/internal/dis/component/ssh2/server_handler.go new file mode 100644 index 0000000..7faf9cc --- /dev/null +++ b/internal/dis/component/ssh2/server_handler.go @@ -0,0 +1,32 @@ +package ssh2 + +import ( + "bufio" + "fmt" + "io" + + "github.com/gliderlabs/ssh" +) + +func (ssh2 *SSH2) setupHandler(server *ssh.Server) { + server.Handle(ssh2.handleConnection) +} + +func (ssh2 *SSH2) handleConnection(session ssh.Session) { + slug, _ := getAnyPermission(session.Context()) + banner := fmt.Sprintf(welcomeMessage, ssh2.Config.DefaultDomain, slug+"."+ssh2.Config.DefaultDomain) + + io.WriteString(session, banner) + + // wait until the user closes + buffer := bufio.NewReader(session) + for { + res, _, err := buffer.ReadRune() + if err != nil { + return + } + if res == etx || res == eot { + return + } + } +} diff --git a/internal/dis/component/ssh2/server_hostkeys.go b/internal/dis/component/ssh2/server_hostkeys.go new file mode 100644 index 0000000..d8d5188 --- /dev/null +++ b/internal/dis/component/ssh2/server_hostkeys.go @@ -0,0 +1,304 @@ +package ssh2 + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "io" + + "github.com/FAU-CDI/wisski-distillery/pkg/environment" + "github.com/gliderlabs/ssh" + + "github.com/pkg/errors" + "github.com/tkw1536/goprogram/stream" + gossh "golang.org/x/crypto/ssh" +) + +func (ssh2 *SSH2) setupHostKeys(io stream.IOStream, privateKeyPath string, server *ssh.Server) error { + return ssh2.UseOrMakeHostKeys(io, server, privateKeyPath, nil) +} + +// UseOrMakeHostKeys is like UseOrMakeHostKey except that it accepts multiple HostKeyAlgorithms. +// For each key algorithm, the privateKeyPath is appended with "_" + the name of the algorithm in question. +// +// When algorithms is nil, picks a reasonable set of default algorithms. +func (ssh2 *SSH2) UseOrMakeHostKeys(io stream.IOStream, server *ssh.Server, privateKeyPath string, algorithms []HostKeyAlgorithm) error { + if algorithms == nil { + algorithms = []HostKeyAlgorithm{RSAAlgorithm, ED25519Algorithm} + } + + for _, algorithm := range algorithms { + path := privateKeyPath + "_" + string(algorithm) + if err := ssh2.UseOrMakeHostKey(io, server, path, algorithm); err != nil { + return err + } + } + return nil +} + +// UseOrMakeHostKey attempts to load a host key from the given privateKeyPath. +// If the path does not exist, a new host key is generated. +// It then adds this hostkey to the priovided server. +// +// All parameters except the server are passed to ReadOrMakeHostKey. +// Please see the appropriate documentation for that function. +func (ssh2 *SSH2) UseOrMakeHostKey(io stream.IOStream, server *ssh.Server, privateKeyPath string, algorithm HostKeyAlgorithm) error { + key, err := ssh2.ReadOrMakeHostKey(io, privateKeyPath, algorithm) + if err != nil { + return err + } + + // use the host key + server.AddHostKey(key) + return nil +} + +// ReadOrMakeHostKey attempts to load a host key from the given privateKeyPath. +// If the path does not exist, a new key is generated. +// +// This function assumes that if there is a host key in privateKeyPath it uses the provided HostKeyAlgorithm. +// It makes no attempt at verifiying this; the key mail fail to load and return an error, or it may load incorrect data. +func (ssh2 *SSH2) ReadOrMakeHostKey(io stream.IOStream, privateKeyPath string, algorithm HostKeyAlgorithm) (key gossh.Signer, err error) { + hostKey := NewHostKey(algorithm) + + if _, e := ssh2.Environment.Lstat(privateKeyPath); environment.IsNotExist(e) { // path doesn't exist => generate a new key there! + err = ssh2.makeHostKey(io, hostKey, privateKeyPath) + if err != nil { + err = errors.Wrap(err, "Unable to generate new host key") + return + } + } + err = ssh2.loadHostKey(io, hostKey, privateKeyPath) + if err != nil { + return nil, err + } + return hostKey, nil +} + +// loadHostKey loadsa host key +func (ssh2 *SSH2) loadHostKey(io stream.IOStream, key HostKey, path string) (err error) { + io.EPrintf("Loading hostkey (algorithm %s) from %q", key.Algorithm(), path) + + // read all the bytes from the file + privateKeyBytes, err := environment.ReadFile(ssh2.Environment, path) + if err != nil { + err = errors.Wrap(err, "Unable to read private key bytes") + return + } + + // if the length is nil, return + if len(privateKeyBytes) == 0 { + err = errors.New("No bytes were read from the private key") + return + } + + // decode the pem and unmarshal it + privateKeyPEM, _ := pem.Decode(privateKeyBytes) + if privateKeyPEM == nil { + err = errors.New("pem.Decode() returned nil") + return + } + return key.UnmarshalPEM(privateKeyPEM) +} + +// makeHostKey makes a new host key +func (ssh2 *SSH2) makeHostKey(io stream.IOStream, key HostKey, path string) error { + io.EPrintf("Writing hostkey (algorithm %s) to %q", key.Algorithm(), path) + + if err := key.Generate(0, nil); err != nil { + return errors.Wrap(err, "Failed to generate key") + } + + privateKeyPEM, err := key.MarshalPEM() + if err != nil { + return errors.Wrap(err, "Failed to marshal key") + } + + // generate and write private key as PEM + privateKeyFile, err := ssh2.Environment.Create(path, environment.DefaultFilePerm) + defer privateKeyFile.Close() + if err != nil { + return err + } + return pem.Encode(privateKeyFile, privateKeyPEM) +} + +// HostKey represents an pair of ssh private key and algorithm. +// Once the hostkey is generated or loaded, it is safe for concurrent accesses. +type HostKey interface { + ssh.Signer + + // Algorithm is the Algorithm used by this HostKey implementation. + Algorithm() HostKeyAlgorithm + + // Generate generates a new HostKey, discarding whatever was previsouly contained. + // + // keySize is the desired public key size in bits. When keySize is 0, a sensible default is used. + // random is the source of randomness. If random is nil, crypto/rand.Reader will be used. + Generate(keySize int, random io.Reader) error + + // MarshalPEM marshals the private key into a pem.Block to be used for exporting. + // The format is not guaranteed to follow any kind of standard, only that it is readable with the corresponding UnmarshalPEM. + MarshalPEM() (*pem.Block, error) + + // UnmarshalPEM unmarshals the private key from a pem.Block. + // It is only compatible with whatever MarshalPEM() outputted. + UnmarshalPEM(block *pem.Block) error +} + +// HostKeyAlgorithm is an enumerated value that represents a specific algorithm used for host keys. +type HostKeyAlgorithm string + +const ( + // RSAAlgorithm represents the RSA Algorithm + RSAAlgorithm HostKeyAlgorithm = "rsa" + + // ED25519Algorithm represents the ED25519 algorithm + ED25519Algorithm HostKeyAlgorithm = "ed25519" +) + +// NewHostKey returns a new empty HostKey for the provided HostKey Algorithm. +// An unsupported HostKeyAlgorithm will result in a call to panic(). +func NewHostKey(algorithm HostKeyAlgorithm) HostKey { + switch algorithm { + case RSAAlgorithm: + return &rsaHostKey{defaultBitSize: 4096} + case ED25519Algorithm: + return &ed25519HostKey{} + default: + panic("Unsupported HostKeyAlgorithm") + } +} + +// +// ed25519 key +// + +type ed25519HostKey struct { + ssh.Signer + pk *ed25519.PrivateKey +} + +func init() { + var _ HostKey = (*ed25519HostKey)(nil) +} + +func (ek *ed25519HostKey) Algorithm() HostKeyAlgorithm { + return ED25519Algorithm +} + +var errKeySizeUnsupported = errors.New("ed25519HostKey.Generate(): keySize not supported") + +func (ek *ed25519HostKey) Generate(keySize int, random io.Reader) (err error) { + if keySize != 0 && keySize != ed25519.PublicKeySize { + return errKeySizeUnsupported + } + if random == nil { + random = rand.Reader + } + + _, pr, err := ed25519.GenerateKey(random) + if err != nil { + return + } + + // store the private key and setup the signer + ek.pk = &pr + ek.Signer, err = gossh.NewSignerFromKey(ek.pk) + + // return + return +} + +func (ek *ed25519HostKey) MarshalPEM() (block *pem.Block, err error) { + block = &pem.Block{Type: "PRIVATE KEY", Bytes: ek.pk.Seed()} + return +} + +func (ek *ed25519HostKey) UnmarshalPEM(block *pem.Block) (err error) { + if block.Type != "PRIVATE KEY" { + err = errors.New("Expected 'PRIVATE KEY' in PEM format") + return + } + + pk := ed25519.NewKeyFromSeed(block.Bytes) + + // store the private key and setup the signer + ek.pk = &pk + ek.Signer, err = gossh.NewSignerFromKey(ek.pk) + + return nil +} + +// +// rsa key +// + +type rsaHostKey struct { + ssh.Signer + + pk *rsa.PrivateKey + + defaultBitSize int +} + +func init() { + var _ HostKey = (*rsaHostKey)(nil) +} + +func (rk *rsaHostKey) Algorithm() HostKeyAlgorithm { + return RSAAlgorithm +} + +func (rk *rsaHostKey) Generate(keySize int, random io.Reader) (err error) { + if keySize == 0 { + keySize = rk.defaultBitSize + } + if random == nil { + random = rand.Reader + } + + rk.pk, err = rsa.GenerateKey(random, keySize) + if err != nil { + return err + } + + // store the signer + rk.Signer, err = gossh.NewSignerFromKey(rk.pk) + return +} + +func (rk *rsaHostKey) MarshalPEM() (block *pem.Block, err error) { + block = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rk.pk)} + return +} + +func (rk *rsaHostKey) UnmarshalPEM(block *pem.Block) (err error) { + if block.Type != "RSA PRIVATE KEY" { + err = errors.New("Expected 'RSA PRIVATE KEY' in PEM format") + return + } + + // parse either a PKCS1 or PKCS8 + var parsedKey interface{} + if parsedKey, err = x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { + if parsedKey, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { // note this returns type `interface{}` + err = errors.Wrap(err, "Expected PKCS1 or PKCS8 private key") + return + } + } + + pk, isRSA := parsedKey.(*rsa.PrivateKey) + if !isRSA { + err = errors.New("Expected an rsa.PrivateKey") + return + } + + // store the private key and setup the signer + rk.pk = pk + rk.Signer, err = gossh.NewSignerFromKey(rk.pk) + + return +} diff --git a/internal/dis/component/ssh2/ssh2.env b/internal/dis/component/ssh2/ssh2.env new file mode 100644 index 0000000..2b65aa7 --- /dev/null +++ b/internal/dis/component/ssh2/ssh2.env @@ -0,0 +1,10 @@ +HOST_RULE=${HOST_RULE} + +CONFIG_PATH=${CONFIG_PATH} +DEPLOY_ROOT=${DEPLOY_ROOT} +GLOBAL_AUTHORIZED_KEYS_FILE=${GLOBAL_AUTHORIZED_KEYS_FILE} +SELF_OVERRIDES_FILE=${SELF_OVERRIDES_FILE} +SELF_RESOLVER_BLOCK_FILE=${SELF_RESOLVER_BLOCK_FILE} + +DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME} +HTTPS_ENABLED=${HTTPS_ENABLED} \ No newline at end of file diff --git a/internal/dis/component/ssh2/ssh2.go b/internal/dis/component/ssh2/ssh2.go new file mode 100644 index 0000000..c8b0d4a --- /dev/null +++ b/internal/dis/component/ssh2/ssh2.go @@ -0,0 +1,28 @@ +package ssh2 + +import ( + "io" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" + "github.com/FAU-CDI/wisski-distillery/pkg/sshx" + "github.com/gliderlabs/ssh" +) + +type SSH2 struct { + component.Base + Instances *instances.Instances +} + +// GlobalKeys returns the global authorized keys +func (s *SSH2) GlobalKeys() ([]ssh.PublicKey, error) { + file, err := s.Environment.Open(s.Config.GlobalAuthorizedKeysFile) + if err != nil { + return nil, err + } + bytes, err := io.ReadAll(file) + if err != nil { + return nil, err + } + return sshx.ParseAllKeys(bytes), nil +} diff --git a/internal/dis/component/ssh2/ssh2/.dockerignore b/internal/dis/component/ssh2/ssh2/.dockerignore new file mode 100644 index 0000000..6320cd2 --- /dev/null +++ b/internal/dis/component/ssh2/ssh2/.dockerignore @@ -0,0 +1 @@ +data \ No newline at end of file diff --git a/internal/dis/component/ssh2/ssh2/Dockerfile b/internal/dis/component/ssh2/ssh2/Dockerfile new file mode 100644 index 0000000..309d5e8 --- /dev/null +++ b/internal/dis/component/ssh2/ssh2/Dockerfile @@ -0,0 +1,5 @@ +FROM docker.io/library/docker:20.10-cli + +COPY wdcli /wdcli +EXPOSE 2222 +CMD ["/wdcli","--internal-in-docker","ssh","--private-key-path", "/data/", "--bind","0.0.0.0:2222"] \ No newline at end of file diff --git a/internal/dis/component/ssh2/ssh2/docker-compose.yml b/internal/dis/component/ssh2/ssh2/docker-compose.yml new file mode 100644 index 0000000..b954b04 --- /dev/null +++ b/internal/dis/component/ssh2/ssh2/docker-compose.yml @@ -0,0 +1,23 @@ +version: "3.7" + +services: + dis: + build: . + restart: always + environment: + CONFIG_PATH: ${CONFIG_PATH} + ports: + - "2223:2222" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + - "${CONFIG_PATH}:${CONFIG_PATH}:ro" + - "${DEPLOY_ROOT}:${DEPLOY_ROOT}:rw" + - "${GLOBAL_AUTHORIZED_KEYS_FILE}:${GLOBAL_AUTHORIZED_KEYS_FILE}:ro" + - "${SELF_OVERRIDES_FILE}:${SELF_OVERRIDES_FILE}:ro" + - "${SELF_RESOLVER_BLOCK_FILE}:${SELF_RESOLVER_BLOCK_FILE}:ro" + - "./data/:/data/" + +networks: + default: + name: ${DOCKER_NETWORK_NAME} + external: true diff --git a/internal/dis/component/ssh2/stack.go b/internal/dis/component/ssh2/stack.go new file mode 100644 index 0000000..a9d8200 --- /dev/null +++ b/internal/dis/component/ssh2/stack.go @@ -0,0 +1,47 @@ +package ssh2 + +import ( + "embed" + "path/filepath" + + "github.com/FAU-CDI/wisski-distillery/internal/bootstrap" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/pkg/environment" +) + +func (ssh SSH2) Path() string { + return filepath.Join(ssh.Still.Config.DeployRoot, "core", "ssh2") +} + +//go:embed all:ssh2 ssh2.env +var resources embed.FS + +func (ssh *SSH2) Stack(env environment.Environment) component.StackWithResources { + stt := component.MakeStack(ssh, env, component.StackWithResources{ + Resources: resources, + ContextPath: "ssh2", + EnvPath: "ssh2.env", + + EnvContext: map[string]string{ + "DOCKER_NETWORK_NAME": ssh.Config.DockerNetworkName, + "HOST_RULE": ssh.Config.DefaultHostRule(), + "HTTPS_ENABLED": ssh.Config.HTTPSEnabledEnv(), + + "CONFIG_PATH": ssh.Config.ConfigPath, + "DEPLOY_ROOT": ssh.Config.DeployRoot, + + "GLOBAL_AUTHORIZED_KEYS_FILE": ssh.Config.GlobalAuthorizedKeysFile, + "SELF_OVERRIDES_FILE": ssh.Config.SelfOverridesFile, + "SELF_RESOLVER_BLOCK_FILE": ssh.Config.SelfResolverBlockFile, + }, + + CopyContextFiles: []string{bootstrap.Executable}, + }) + return stt +} + +func (ssh SSH2) Context(parent component.InstallationContext) component.InstallationContext { + return component.InstallationContext{ + bootstrap.Executable: ssh.Config.CurrentExecutable(ssh.Environment), // TODO: Does this make sense? + } +} diff --git a/internal/dis/distillery.go b/internal/dis/distillery.go index f435be5..150373b 100644 --- a/internal/dis/distillery.go +++ b/internal/dis/distillery.go @@ -20,7 +20,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component/resolver" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/solr" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql" - "github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/triplestore" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/web" "github.com/FAU-CDI/wisski-distillery/pkg/lazy" @@ -73,8 +73,8 @@ func (dis *Distillery) Resolver() *resolver.Resolver { func (dis *Distillery) SQL() *sql.SQL { return export[*sql.SQL](dis) } -func (dis *Distillery) SSH() *ssh.SSH { - return export[*ssh.SSH](dis) +func (dis *Distillery) SSH() *ssh2.SSH2 { + return export[*ssh2.SSH2](dis) } func (dis *Distillery) Triplestore() *triplestore.Triplestore { @@ -138,7 +138,7 @@ func (dis *Distillery) allComponents() []initFunc { auto[*exporter.Pathbuilders], // ssh server - auto[*ssh.SSH], + auto[*ssh2.SSH2], // Control server auto[*control.Control], diff --git a/internal/wisski/ingredient/barrel/barrel/.dockerignore b/internal/wisski/ingredient/barrel/barrel/.dockerignore index bdd4064..54eb0ba 100644 --- a/internal/wisski/ingredient/barrel/barrel/.dockerignore +++ b/internal/wisski/ingredient/barrel/barrel/.dockerignore @@ -5,4 +5,5 @@ !conf/* !scripts/* !patch/* +!ssh/* !wisskiutils/* \ No newline at end of file diff --git a/internal/wisski/ingredient/barrel/barrel/Dockerfile b/internal/wisski/ingredient/barrel/barrel/Dockerfile index 2d9cc39..ffedbcb 100644 --- a/internal/wisski/ingredient/barrel/barrel/Dockerfile +++ b/internal/wisski/ingredient/barrel/barrel/Dockerfile @@ -2,9 +2,10 @@ FROM docker.io/library/php:8.0-apache-bullseye ARG COMPOSER_VERSION=2.3.8 WORKDIR /var/www -# install and enable the various required php extension -RUN apt-get update && apt-get install -y \ +# install and enable the various required php extensions and dropbear ssh server +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ curl \ + openssh-server \ default-mysql-client \ git \ imagemagick \ @@ -89,6 +90,7 @@ RUN a2ensite wisski VOLUME /var/www/.composer VOLUME /var/www/data + # Add and configure the entrypoint ADD scripts/entrypoint.sh /entrypoint.sh @@ -101,6 +103,12 @@ ADD wisskiutils/ /wisskiutils # Add the user_shell.sh ADD scripts/user_shell.sh /user_shell.sh +ADD ssh/ /ssh/ +VOLUME /ssh/hostkeys/ +RUN chmod 700 /ssh/keys.sh && \ + chmod 700 /ssh/start.sh && \ + chmod 777 /user_shell.sh && \ + chsh www-data --shell /user_shell.sh # expose port 8080 EXPOSE 8080 \ No newline at end of file diff --git a/internal/wisski/ingredient/barrel/barrel/docker-compose.yml b/internal/wisski/ingredient/barrel/barrel/docker-compose.yml index 8c96bc2..e46c3b4 100644 --- a/internal/wisski/ingredient/barrel/barrel/docker-compose.yml +++ b/internal/wisski/ingredient/barrel/barrel/docker-compose.yml @@ -24,6 +24,7 @@ services: - ${GLOBAL_AUTHORIZED_KEYS_FILE}:/var/www/.ssh/global_authorized_keys:ro - ${DATA_PATH}/.composer:/var/www/.composer - ${DATA_PATH}/data:/var/www/data + - ${DATA_PATH}/hostkeys:/ssh/hostkeys:rw - ${DATA_PATH}/authorized_keys:/var/www/.ssh/authorized_keys - ${RUNTIME_DIR}:/runtime:ro diff --git a/internal/wisski/ingredient/barrel/barrel/scripts/entrypoint.sh b/internal/wisski/ingredient/barrel/barrel/scripts/entrypoint.sh index 1f1dd76..76336d0 100755 --- a/internal/wisski/ingredient/barrel/barrel/scripts/entrypoint.sh +++ b/internal/wisski/ingredient/barrel/barrel/scripts/entrypoint.sh @@ -7,5 +7,8 @@ chown www-data:www-data /var/www chown www-data:www-data /var/www/.composer chown www-data:www-data /var/www/data/ +# start up dropbear +/ssh/start.sh & + # run the original entrypoint docker-php-entrypoint "$@" \ No newline at end of file diff --git a/internal/wisski/ingredient/barrel/barrel/scripts/user_shell.sh b/internal/wisski/ingredient/barrel/barrel/scripts/user_shell.sh index df94abb..10a738c 100755 --- a/internal/wisski/ingredient/barrel/barrel/scripts/user_shell.sh +++ b/internal/wisski/ingredient/barrel/barrel/scripts/user_shell.sh @@ -1,5 +1,12 @@ #!/bin/bash +set -e # This script is used to start a user shell inside the docker container. cd "/var/www/data/project" -sudo -u www-data "PATH=/var/www/data/project/vendor/bin:$PATH" /bin/bash "$@" \ No newline at end of file +export "PATH=/var/www/data/project/vendor/bin:$PATH" + +if [ "$USER" = "www-data" ]; then + /bin/bash "$@" +else + sudo -u www-data /bin/bash "$@" +fi; \ No newline at end of file diff --git a/internal/wisski/ingredient/barrel/barrel/ssh/keys.sh b/internal/wisski/ingredient/barrel/barrel/ssh/keys.sh new file mode 100644 index 0000000..80c4775 --- /dev/null +++ b/internal/wisski/ingredient/barrel/barrel/ssh/keys.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +cat /var/www/.ssh/authorized_keys /var/www/.ssh/global_authorized_keys 2> /dev/null || exit 0 diff --git a/internal/wisski/ingredient/barrel/barrel/ssh/sshd_config b/internal/wisski/ingredient/barrel/barrel/ssh/sshd_config new file mode 100644 index 0000000..231f8c0 --- /dev/null +++ b/internal/wisski/ingredient/barrel/barrel/ssh/sshd_config @@ -0,0 +1,27 @@ +# sshd_config file for distillery ssh server + +# listen on port 22 +Port 22 +ListenAddress 0.0.0.0 + +# Use hostkeys from /ssh/hostkeys +HostKey /ssh/hostkeys/ssh_host_rsa_key +HostKey /ssh/hostkeys/ssh_host_ecdsa_key +HostKey /ssh/hostkeys/ssh_host_ed25519_key + +# Disable forwarding and motd +X11Forwarding no +PrintMotd no + +# allow sftp +Subsystem sftp /usr/lib/openssh/sftp-server + +# allow only www-data to login +AllowUsers www-data + +# allow only public keys using /ssh/keys.sh +PubkeyAuthentication yes +AuthenticationMethods publickey +AuthorizedKeysFile none +AuthorizedKeysCommand /ssh/keys.sh +AuthorizedKeysCommandUser root \ No newline at end of file diff --git a/internal/wisski/ingredient/barrel/barrel/ssh/start.sh b/internal/wisski/ingredient/barrel/barrel/ssh/start.sh new file mode 100644 index 0000000..c2a188a --- /dev/null +++ b/internal/wisski/ingredient/barrel/barrel/ssh/start.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# create the sshd directory +if [ ! -d /run/sshd ]; then + mkdir /run/sshd + chmod 0755 /run/sshd +fi + +# regenerate key files if they do not yet exist +[[ -f "/ssh/hostkeys/ssh_host_rsa_key" ]] || ssh-keygen -q -N "" -t dsa -f /ssh/hostkeys/ssh_host_rsa_key +[[ -f "/ssh/hostkeys/ssh_host_ecdsa_key" ]] || ssh-keygen -q -N "" -t ecdsa -f /ssh/hostkeys/ssh_host_ecdsa_key +[[ -f "/ssh/hostkeys/ssh_host_ed25519_key" ]] || ssh-keygen -q -N "" -t ed25519 -f /ssh/hostkeys/ssh_host_ed25519_key + +/usr/sbin/sshd -e -D -f /ssh/sshd_config \ No newline at end of file