seashell/internal/backends/proxy.go

273 lines
6.3 KiB
Go
Raw Normal View History

2024-08-04 05:31:40 +00:00
/*
* Seashell - SSH server with virtual hosts and username-based routing
*
* Copyright (C) 2024 Elara6331 <elara@elara.ws>
*
* This file is part of Seashell.
*
* Seashell is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Seashell is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Seashell. If not, see <http://www.gnu.org/licenses/>.
*/
package backends
import (
"errors"
"fmt"
"io"
"net"
"os"
"path"
"strconv"
2024-08-04 05:31:40 +00:00
"strings"
"github.com/gliderlabs/ssh"
"github.com/melbahja/goph"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"
"go.elara.ws/seashell/internal/config"
"go.elara.ws/seashell/internal/router"
"go.elara.ws/seashell/internal/sshctx"
gossh "golang.org/x/crypto/ssh"
)
// proxySettings represents settings for the proxy backend.
type proxySettings struct {
Host *string `cty:"host"`
Hosts *cty.Value `cty:"hosts"`
2024-08-04 05:31:40 +00:00
User *string `cty:"user"`
PrivkeyPath *string `cty:"privkey"`
UserMap *cty.Value `cty:"user_map"`
2024-08-04 05:31:40 +00:00
}
2024-08-04 23:35:24 +00:00
// Proxy is the proxy backend. It returns a handler that establishes a proxy
2024-08-04 05:31:40 +00:00
// session to a remote server based on the provided configuration.
func Proxy(route config.Route) router.Handler {
return func(sess ssh.Session, arg string) error {
user, _ := sshctx.GetUser(sess.Context())
var opts proxySettings
err := gocty.FromCtyValue(route.Settings, &opts)
if err != nil {
return err
}
pty, resizeCh, ok := sess.Pty()
if !ok {
return errors.New("this route only accepts pty sessions (try adding the -t flag)")
}
if opts.User == nil {
userMap := ctyObjToStringMap(opts.UserMap)
user, _ := sshctx.GetUser(sess.Context())
2024-08-04 23:35:24 +00:00
2024-08-04 05:31:40 +00:00
if muser, ok := userMap[user.Name]; ok {
opts.User = &muser
} else {
opts.User = &user.Name
}
}
matched := false
addr := arg
var portstr, pattern string
if opts.Host == nil {
hosts := ctyTupleToStrings(opts.Hosts)
if len(hosts) == 0 {
return errors.New("no host configuration provided")
}
for _, hostPattern := range hosts {
pattern, portstr, ok = strings.Cut(hostPattern, ":")
if !ok {
// addr is already set by the above statement, so just set the default port
portstr = "22"
}
matched, err = path.Match(pattern, arg)
if err != nil {
return err
}
if matched {
addr = arg
break
}
}
} else {
addr, portstr, ok = strings.Cut(*opts.Host, ":")
if !ok {
// addr is already set by the above statement, so just set the default port
portstr = "22"
}
}
if !route.Permissions.IsAllowed(user, addr) {
return router.ErrUnauthorized
}
if !matched {
return errors.New("provided argument doesn't match any host patterns in configuration")
}
port, err := strconv.ParseUint(portstr, 10, 16)
if err != nil {
return err
}
2024-08-04 05:31:40 +00:00
auth := goph.Auth{
gossh.PasswordCallback(requestPassword(opts, sess, addr)),
2024-08-04 05:31:40 +00:00
}
if opts.PrivkeyPath != nil {
data, err := os.ReadFile(*opts.PrivkeyPath)
if err != nil {
return err
}
pk, err := gossh.ParsePrivateKey(data)
if err != nil {
return err
}
auth = append(goph.Auth{gossh.PublicKeys(pk)}, auth...)
}
c, err := goph.NewConn(&goph.Config{
Auth: auth,
User: *opts.User,
Addr: addr,
Port: uint(port),
Callback: func(host string, remote net.Addr, key gossh.PublicKey) error {
found, err := goph.CheckKnownHost(host, remote, key, "")
if !found {
if err = goph.AddKnownHost(host, remote, key, ""); err != nil {
return err
}
} else if err != nil {
return err
}
return nil
},
})
2024-08-04 05:31:40 +00:00
if err != nil {
return err
}
2024-08-04 23:35:24 +00:00
2024-08-04 05:31:40 +00:00
baseCmd := sess.Command()
var userCmd string
if len(baseCmd) > 0 {
userCmd = baseCmd[0]
}
var userArgs []string
if len(baseCmd) > 1 {
userArgs = baseCmd[1:]
}
cmd, err := c.Command(userCmd, userArgs...)
if err != nil {
return err
}
err = cmd.RequestPty(pty.Term, pty.Window.Height, pty.Window.Width, nil)
if err != nil {
return err
}
go sshHandleResize(resizeCh, cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
defer stdin.Close()
go io.Copy(sess, stdout)
go io.Copy(stdin, sess)
if len(baseCmd) == 0 {
err = cmd.Shell()
} else {
err = cmd.Start()
}
if err != nil {
return err
}
return cmd.Wait()
}
}
// requestPassword asks the client for the remote server's password
func requestPassword(opts proxySettings, sess ssh.Session, addr string) func() (secret string, err error) {
2024-08-04 05:31:40 +00:00
return func() (secret string, err error) {
_, err = fmt.Fprintf(sess.Stderr(), "Password for %s@%s: ", *opts.User, addr)
2024-08-04 05:31:40 +00:00
if err != nil {
return "", err
}
pwd, err := readPassword(sess)
sess.Write([]byte{'\n'})
return strings.TrimSpace(pwd), err
}
}
// nomadHandleResize resizes the remote SSH pseudo-tty whenever it
// receives a client resize event over SSH.
func sshHandleResize(resizeCh <-chan ssh.Window, cmd *goph.Cmd) {
for newSize := range resizeCh {
cmd.WindowChange(newSize.Height, newSize.Width)
}
}
// readPassword reads a password from the SSH session, sending an asterisk
// for each character typed.
2024-08-04 23:35:24 +00:00
//
2024-08-04 05:31:40 +00:00
// It handles interrupts (Ctrl+C), EOF (Ctrl+D), and backspace.
// It returns what it read once it receives a carriage return or a newline.
func readPassword(sess ssh.Session) (string, error) {
var out []byte
for {
buf := make([]byte, 1)
_, err := sess.Read(buf)
if err != nil {
return "", err
}
switch buf[0] {
case '\r', '\n':
return string(out), nil
case '\x7F':
if len(out) != 0 {
out = out[:len(out)-1]
// Delete the last asterisk character
sess.Write([]byte("\x08 \x08"))
}
continue
case '\x03', '\x04':
sess.Close()
return "", errors.New("password entry canceled")
default:
// Give users some feedback that their password is being received
sess.Write([]byte{'*'})
}
out = append(out, buf[0])
}
}