Merge branch 'feature/direct-streamlocal' into develop

This commit is contained in:
Ryo Ota 2023-08-11 09:50:06 +09:00
commit 4b7793e445
7 changed files with 176 additions and 32 deletions

View file

@ -41,20 +41,29 @@ handy-sshd --unix-socket /tmp/my-unix-socket --user "john:"
```
## Permissions
**All permissions are allowed when nothing is specified.** There are some permissions.
There are several permissions:
* --allow-direct-streamlocal
* --allow-direct-tcpip
* --allow-execute
* --allow-sftp
* --allow-tcpip-forward
Specifying `--allow-direct-tcpip` and `--allow-execute` for example allows only them.
The log shows "allowed: " and "NOT allowed: " permissions as follows.
**All permissions are allowed when nothing is specified.** The log shows "allowed: " and "NOT allowed: " permissions as follows:
```console
$ handy-sshd --user "john:"
2023/08/11 09:42:05 INFO listening on :2222...
2023/08/11 09:42:05 INFO allowed: "tcpip-forward", "direct-tcpip", "execute", "sftp", "direct-streamlocal"
2023/08/11 09:42:05 INFO NOT allowed: none
```
For example, specifying `--allow-direct-tcpip` and `--allow-execute` allows only them:
```console
$ handy-sshd --user "john:" --allow-direct-tcpip --allow-execute
2023/08/09 20:49:35 INFO listening on :2222...
2023/08/09 20:49:35 INFO allowed: "direct-tcpip", "execute"
2023/08/09 20:49:35 INFO NOT allowed: "tcpip-forward", "sftp"
2023/08/11 09:38:58 INFO listening on :2222...
2023/08/11 09:38:58 INFO allowed: "direct-tcpip", "execute"
2023/08/11 09:38:58 INFO NOT allowed: "tcpip-forward", "sftp", "direct-streamlocal"
```
## --help
@ -66,15 +75,16 @@ Usage:
handy-sshd [flags]
Flags:
--allow-direct-tcpip client can use local forwarding and SOCKS proxy
--allow-execute client can use shell/interactive shell
--allow-sftp client can use SFTP and SSHFS
--allow-tcpip-forward client can use remote forwarding
-h, --help help for handy-sshd
--host string SSH server host (e.g. 127.0.0.1)
-p, --port uint16 SSH server port (default 2222)
--shell string Shell
--unix-socket string Unix-domain socket
--user stringArray SSH user name (e.g. "john:mypassword")
-v, --version show version
--allow-direct-streamlocal client can use Unix domain socket local forwarding
--allow-direct-tcpip client can use local forwarding and SOCKS proxy
--allow-execute client can use shell/interactive shell
--allow-sftp client can use SFTP and SSHFS
--allow-tcpip-forward client can use remote forwarding
-h, --help help for handy-sshd
--host string SSH server host (e.g. 127.0.0.1)
-p, --port uint16 SSH server port (default 2222)
--shell string Shell
--unix-socket string Unix domain socket
--user stringArray SSH user name (e.g. "john:mypassword")
-v, --version show version
```

View file

@ -22,10 +22,11 @@ type flagType struct {
sshShell string
sshUsers []string
allowTcpipForward bool
allowDirectTcpip bool
allowExecute bool
allowSftp bool
allowTcpipForward bool
allowDirectTcpip bool
allowExecute bool
allowSftp bool
allowDirectStreamlocal bool
}
type permissionFlagType = struct {
@ -49,6 +50,7 @@ func RootCmd() *cobra.Command {
{name: "direct-tcpip", flagPtr: &flag.allowDirectTcpip},
{name: "execute", flagPtr: &flag.allowExecute},
{name: "sftp", flagPtr: &flag.allowSftp},
{name: "direct-streamlocal", flagPtr: &flag.allowDirectStreamlocal},
}
rootCmd := cobra.Command{
Use: os.Args[0],
@ -64,7 +66,7 @@ func RootCmd() *cobra.Command {
rootCmd.PersistentFlags().StringVarP(&flag.sshHost, "host", "", "", "SSH server host (e.g. 127.0.0.1)")
rootCmd.PersistentFlags().Uint16VarP(&flag.sshPort, "port", "p", 2222, "SSH server port")
// NOTE: long name 'unix-socket' is from curl (ref: https://curl.se/docs/manpage.html)
rootCmd.PersistentFlags().StringVarP(&flag.sshUnixSocket, "unix-socket", "", "", "Unix-domain socket")
rootCmd.PersistentFlags().StringVarP(&flag.sshUnixSocket, "unix-socket", "", "", "Unix domain socket")
rootCmd.PersistentFlags().StringVarP(&flag.sshShell, "shell", "", "", "Shell")
//rootCmd.PersistentFlags().StringVar(&flag.dnsServer, "dns-server", "", "DNS server (e.g. 1.1.1.1:53)")
rootCmd.PersistentFlags().StringArrayVarP(&flag.sshUsers, "user", "", nil, `SSH user name (e.g. "john:mypassword")`)
@ -74,6 +76,7 @@ func RootCmd() *cobra.Command {
rootCmd.PersistentFlags().BoolVarP(&flag.allowDirectTcpip, "allow-direct-tcpip", "", false, "client can use local forwarding and SOCKS proxy")
rootCmd.PersistentFlags().BoolVarP(&flag.allowExecute, "allow-execute", "", false, "client can use shell/interactive shell")
rootCmd.PersistentFlags().BoolVarP(&flag.allowSftp, "allow-sftp", "", false, "client can use SFTP and SSHFS")
rootCmd.PersistentFlags().BoolVarP(&flag.allowDirectStreamlocal, "allow-direct-streamlocal", "", false, "client can use Unix domain socket local forwarding")
return &rootCmd
}
@ -99,11 +102,12 @@ func rootRunEWithExtra(cmd *cobra.Command, args []string, flag *flagType, allPer
}
sshServer := &handy_sshd.Server{
Logger: logger,
AllowTcpipForward: flag.allowTcpipForward,
AllowDirectTcpip: flag.allowDirectTcpip,
AllowExecute: flag.allowExecute,
AllowSftp: flag.allowSftp,
Logger: logger,
AllowTcpipForward: flag.allowTcpipForward,
AllowDirectTcpip: flag.allowDirectTcpip,
AllowExecute: flag.allowExecute,
AllowSftp: flag.allowSftp,
AllowDirectStreamlocal: flag.allowDirectStreamlocal,
}
var sshUsers []sshUser
for _, u := range flag.sshUsers {

View file

@ -60,6 +60,7 @@ func TestAllPermissionsAllowed(t *testing.T) {
assertExec(t, client)
assertPtyTerminal(t, client)
assertSftp(t, client)
assertUnixLocalPortForwarding(t, client)
}
func TestEmptyPassword(t *testing.T) {
@ -166,6 +167,7 @@ func TestAllowExecute(t *testing.T) {
assertExec(t, client)
assertPtyTerminal(t, client)
assertNoSftp(t, client)
assertNoUnixLocalPortForwarding(t, client)
}
func TestAllowTcpipForward(t *testing.T) {
@ -195,6 +197,7 @@ func TestAllowTcpipForward(t *testing.T) {
assertNoExec(t, client)
assertNoPtyTerminal(t, client)
assertNoSftp(t, client)
assertNoUnixLocalPortForwarding(t, client)
}
func TestAllowDirectTcpip(t *testing.T) {
@ -224,6 +227,37 @@ func TestAllowDirectTcpip(t *testing.T) {
assertNoExec(t, client)
assertNoPtyTerminal(t, client)
assertNoSftp(t, client)
assertNoUnixLocalPortForwarding(t, client)
}
func TestAllowDirectStreamlocal(t *testing.T) {
rootCmd := RootCmd()
port := getAvailableTcpPort()
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword", "--allow-direct-streamlocal"})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
var stderrBuf bytes.Buffer
rootCmd.SetErr(&stderrBuf)
rootCmd.ExecuteContext(ctx)
}()
waitTCPServer(port)
sshClientConfig := &ssh.ClientConfig{
User: "john",
Auth: []ssh.AuthMethod{ssh.Password("mypassword")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
client, err := ssh.Dial("tcp", address, sshClientConfig)
assert.NoError(t, err)
defer client.Close()
assert.NoError(t, err)
assertNoRemotePortForwarding(t, client)
assertNoLocalPortForwarding(t, client)
assertNoExec(t, client)
assertNoPtyTerminal(t, client)
assertNoSftp(t, client)
assertUnixLocalPortForwarding(t, client)
}
func TestAllowSftp(t *testing.T) {

View file

@ -2,12 +2,15 @@ package cmd
import (
"bytes"
"github.com/google/uuid"
"github.com/pkg/sftp"
"github.com/stretchr/testify/assert"
"golang.org/x/crypto/ssh"
"io"
"net"
"os"
"os/exec"
"path"
"strconv"
"testing"
"time"
@ -135,6 +138,51 @@ func assertNoLocalPortForwarding(t *testing.T, client *ssh.Client) {
assert.Equal(t, "ssh: rejected: administratively prohibited (direct-tcpip not allowed)", err.Error())
}
func assertUnixLocalPortForwarding(t *testing.T, client *ssh.Client) {
remoteUnixSocket := path.Join(os.TempDir(), "test-unix-socket-"+uuid.New().String())
acceptedConnChan := make(chan net.Conn)
{
ln, err := net.Listen("unix", remoteUnixSocket)
assert.NoError(t, err)
defer os.Remove(remoteUnixSocket)
go func() {
conn, err := ln.Accept()
assert.NoError(t, err)
acceptedConnChan <- conn
}()
}
conn, err := client.Dial("unix", remoteUnixSocket)
assert.NoError(t, err)
defer conn.Close()
acceptedConn := <-acceptedConnChan
defer acceptedConn.Close()
{
localToRemote := [3]byte{1, 2, 3}
_, err = conn.Write(localToRemote[:])
assert.NoError(t, err)
var buf [len(localToRemote)]byte
_, err = io.ReadFull(acceptedConn, buf[:])
assert.NoError(t, err)
assert.Equal(t, buf, localToRemote)
}
{
remoteToLocal := [4]byte{10, 20, 30, 40}
_, err = acceptedConn.Write(remoteToLocal[:])
assert.NoError(t, err)
var buf [len(remoteToLocal)]byte
_, err = io.ReadFull(conn, buf[:])
assert.NoError(t, err)
assert.Equal(t, buf, remoteToLocal)
}
}
func assertNoUnixLocalPortForwarding(t *testing.T, client *ssh.Client) {
remoteUnixSocket := path.Join(os.TempDir(), "test-unix-socket-"+uuid.New().String())
_, err := client.Dial("unix", remoteUnixSocket)
assert.Error(t, err)
assert.Equal(t, "ssh: rejected: administratively prohibited (direct-streamlocal (Unix domain socket) not allowed)", err.Error())
}
func assertRemotePortForwarding(t *testing.T, client *ssh.Client) {
remotePort := getAvailableTcpPort()
ln, err := client.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(remotePort)))

1
go.mod
View file

@ -4,6 +4,7 @@ go 1.20
require (
github.com/creack/pty v1.1.18
github.com/google/uuid v1.3.0
github.com/mattn/go-shellwords v1.0.12
github.com/pkg/errors v0.9.1
github.com/pkg/sftp v1.13.5

2
go.sum
View file

@ -4,6 +4,8 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=

View file

@ -30,10 +30,11 @@ type Server struct {
Logger *slog.Logger
// Permissions
AllowTcpipForward bool
AllowDirectTcpip bool
AllowExecute bool // this should not be split into "allow-exec" and "allow-pty-req" for now because "pty-req" can be used not for shell execution.
AllowSftp bool
AllowTcpipForward bool
AllowDirectTcpip bool
AllowExecute bool // this should not be split into "allow-exec" and "allow-pty-req" for now because "pty-req" can be used not for shell execution.
AllowSftp bool
AllowDirectStreamlocal bool
// TODO: DNS server ?
}
@ -59,6 +60,12 @@ func (s *Server) handleChannel(shell string, newChannel ssh.NewChannel) {
break
}
s.handleDirectTcpip(newChannel)
case "direct-streamlocal@openssh.com":
if !s.AllowDirectStreamlocal {
newChannel.Reject(ssh.Prohibited, "direct-streamlocal (Unix domain socket) not allowed")
break
}
s.handleDirectStreamlocal(newChannel)
default:
newChannel.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", newChannel.ChannelType()))
}
@ -197,7 +204,7 @@ func (s *Server) handleDirectTcpip(newChannel ssh.NewChannel) {
SourcePort uint32
}
if err := ssh.Unmarshal(newChannel.ExtraData(), &msg); err != nil {
s.Logger.Info("failed to parse message", "err", err)
s.Logger.Info("failed to parse direct-tcpip message", "err", err)
return
}
channel, reqs, err := newChannel.Accept()
@ -227,6 +234,44 @@ func (s *Server) handleDirectTcpip(newChannel ssh.NewChannel) {
return
}
// client side: https://github.com/golang/crypto/blob/b4ddeeda5bc71549846db71ba23e83ecb26f36ed/ssh/streamlocal.go#L52
func (s *Server) handleDirectStreamlocal(newChannel ssh.NewChannel) {
// https://github.com/openssh/openssh-portable/blob/f9f18006678d2eac8b0c5a5dddf17ab7c50d1e9f/PROTOCOL#L237
var msg struct {
SocketPath string
Reserved0 string
Reserved1 uint32
}
if err := ssh.Unmarshal(newChannel.ExtraData(), &msg); err != nil {
s.Logger.Info("failed to parse direct-streamlocal message", "err", err)
return
}
channel, reqs, err := newChannel.Accept()
if err != nil {
s.Logger.Info("failed to accept", "err", err)
return
}
go ssh.DiscardRequests(reqs)
conn, err := net.Dial("unix", msg.SocketPath)
if err != nil {
s.Logger.Info("failed to dial", "err", err)
channel.Close()
return
}
var closeOnce sync.Once
closer := func() {
channel.Close()
conn.Close()
}
go func() {
io.Copy(channel, conn)
closeOnce.Do(closer)
}()
io.Copy(conn, channel)
closeOnce.Do(closer)
return
}
// =======================
// parseDims extracts terminal dimensions (width x height) from the provided buffer.