diff --git a/CHANGELOG.md b/CHANGELOG.md index fb65850..3ab4bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) ## [Unreleased] +## [0.4.0] - 2023-08-11 +### Added +* Add `-u` short flag for `--user` +* Support "cancel-tcpip-forward" and "cancel-streamlocal-forward@openssh.com" + +### Changed +* Add more examples to help message + ## [0.3.0] - 2023-08-11 ### Added * Support Unix domain socket local port forwarding @@ -28,7 +36,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) ### Added * Initial release -[Unreleased]: https://github.com/nwtgck/handy-sshd/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/nwtgck/handy-sshd/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/nwtgck/handy-sshd/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/nwtgck/handy-sshd/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/nwtgck/handy-sshd/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/nwtgck/handy-sshd/compare/v0.1.0...v0.2.0 diff --git a/README.md b/README.md index a648d72..9008795 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Portable SSH Server ## Install on Ubuntu/Debian ```bash -wget https://github.com/nwtgck/handy-sshd/releases/download/v0.2.0/handy-sshd-0.2.0-linux-amd64.deb -sudo dpkg -i handy-sshd-0.2.0-linux-amd64.deb +wget https://github.com/nwtgck/handy-sshd/releases/download/v0.3.0/handy-sshd-0.3.0-linux-amd64.deb +sudo dpkg -i handy-sshd-0.3.0-linux-amd64.deb ``` ## Install on Mac @@ -21,25 +21,37 @@ Get more executables in [the releases](https://github.com/nwtgck/handy-sshd/rele ## Examples ```bash -# Listen on 2222 and accept user name "john" with password "mypassword" -handy-sshd -p 2222 --user "john:mypassword" +# Listen on 2222 and accept user name "john" with password "mypass" +handy-sshd -p 2222 -u john:mypass ``` ```bash # Listen on 2222 and accept user name "john" without password -handy-sshd -p 2222 --user "john:" +handy-sshd -p 2222 -u john: ``` ```bash # Listen on 2222 and accept users "john" and "alice" without password -handy-sshd -p 2222 --user "john:" --user "alice:" +handy-sshd -p 2222 -u john: -u alice: ``` ```bash # Listen on unix domain socket -handy-sshd --unix-socket /tmp/my-unix-socket --user "john:" +handy-sshd --unix-socket /tmp/my-unix-socket -u john: ``` +## Features +An SSH client can use +* Shell/Interactive shell +* Local port forwarding (ssh -L) +* Remote port forwarding (ssh -R) +* [SOCKS proxy](https://wikipedia.org/wiki/SOCKS) (dynamic port forwarding) +* SFTP +* [SSHFS](https://wikipedia.org/wiki/SSHFS) +* Unix domain socket (local/remote port forwarding) + +All features are enabled by default. You can allow only some of them using permission flags. + ## Permissions There are several permissions: * --allow-direct-streamlocal @@ -52,7 +64,7 @@ There are several permissions: **All permissions are allowed when nothing is specified.** The log shows "allowed: " and "NOT allowed: " permissions as follows: ```console -$ handy-sshd --user "john:" +$ handy-sshd -u "john:" 2023/08/11 11:40:44 INFO listening on :2222... 2023/08/11 11:40:44 INFO allowed: "tcpip-forward", "direct-tcpip", "execute", "sftp", "streamlocal-forward", "direct-streamlocal" 2023/08/11 11:40:44 INFO NOT allowed: none @@ -61,7 +73,7 @@ $ handy-sshd --user "john:" For example, specifying `--allow-direct-tcpip` and `--allow-execute` allows only them: ```console -$ handy-sshd --user "john:" --allow-direct-tcpip --allow-execute +$ handy-sshd -u "john:" --allow-direct-tcpip --allow-execute 2023/08/11 11:41:03 INFO listening on :2222... 2023/08/11 11:41:03 INFO allowed: "direct-tcpip", "execute" 2023/08/11 11:41:03 INFO NOT allowed: "tcpip-forward", "sftp", "streamlocal-forward", "direct-streamlocal" @@ -75,18 +87,29 @@ Portable SSH server Usage: handy-sshd [flags] +Examples: +# Listen on 2222 and accept user name "john" with password "mypass" +handy-sshd -u john:mypass + +# Listen on 22 and accept the user without password +handy-sshd -p 22 -u john: + +Permissions: +All permissions are allowed by default. +For example, specifying --allow-direct-tcpip and --allow-execute allows only them. + Flags: - --allow-direct-streamlocal client can use Unix domain socket local forwarding - --allow-direct-tcpip client can use local forwarding and SOCKS proxy + --allow-direct-streamlocal client can use Unix domain socket local forwarding (ssh -L) + --allow-direct-tcpip client can use local forwarding (ssh -L) and SOCKS proxy (ssh -D) --allow-execute client can use shell/interactive shell --allow-sftp client can use SFTP and SSHFS - --allow-streamlocal-forward client can use Unix domain socket remote forwarding - --allow-tcpip-forward client can use remote forwarding + --allow-streamlocal-forward client can use Unix domain socket remote forwarding (ssh -R) + --allow-tcpip-forward client can use remote forwarding (ssh -R) -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) + --host string SSH server host to listen (e.g. 127.0.0.1) + -p, --port uint16 port to listen (default 2222) --shell string Shell - --unix-socket string Unix domain socket - --user stringArray SSH user name (e.g. "john:mypassword") + --unix-socket string Unix domain socket to listen + -u, --user stringArray SSH user name (e.g. "john:mypass") -v, --version show version ``` diff --git a/cmd/root.go b/cmd/root.go index a2d6b66..96c7afb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,27 +59,36 @@ func RootCmd() *cobra.Command { Short: "handy-sshd", Long: "Portable SSH server", SilenceUsage: true, + Example: `# Listen on 2222 and accept user name "john" with password "mypass" +handy-sshd -u john:mypass + +# Listen on 22 and accept the user without password +handy-sshd -p 22 -u john: + +Permissions: +All permissions are allowed by default. +For example, specifying --allow-direct-tcpip and --allow-execute allows only them.`, RunE: func(cmd *cobra.Command, args []string) error { return rootRunEWithExtra(cmd, args, &flag, allPermissionFlags) }, } rootCmd.PersistentFlags().BoolVarP(&flag.showsVersion, "version", "v", false, "show version") - 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") + rootCmd.PersistentFlags().StringVarP(&flag.sshHost, "host", "", "", "SSH server host to listen (e.g. 127.0.0.1)") + rootCmd.PersistentFlags().Uint16VarP(&flag.sshPort, "port", "p", 2222, "port to listen") // 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 to listen") 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")`) + rootCmd.PersistentFlags().StringArrayVarP(&flag.sshUsers, "user", "u", nil, `SSH user name (e.g. "john:mypass")`) // Permission flags - rootCmd.PersistentFlags().BoolVarP(&flag.allowTcpipForward, "allow-tcpip-forward", "", false, "client can use remote forwarding") - rootCmd.PersistentFlags().BoolVarP(&flag.allowDirectTcpip, "allow-direct-tcpip", "", false, "client can use local forwarding and SOCKS proxy") + rootCmd.PersistentFlags().BoolVarP(&flag.allowTcpipForward, "allow-tcpip-forward", "", false, "client can use remote forwarding (ssh -R)") + rootCmd.PersistentFlags().BoolVarP(&flag.allowDirectTcpip, "allow-direct-tcpip", "", false, "client can use local forwarding (ssh -L) and SOCKS proxy (ssh -D)") 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.allowStreamlocalForward, "allow-streamlocal-forward", "", false, "client can use Unix domain socket remote forwarding") - rootCmd.PersistentFlags().BoolVarP(&flag.allowDirectStreamlocal, "allow-direct-streamlocal", "", false, "client can use Unix domain socket local forwarding") + rootCmd.PersistentFlags().BoolVarP(&flag.allowStreamlocalForward, "allow-streamlocal-forward", "", false, "client can use Unix domain socket remote forwarding (ssh -R)") + rootCmd.PersistentFlags().BoolVarP(&flag.allowDirectStreamlocal, "allow-direct-streamlocal", "", false, "client can use Unix domain socket local forwarding (ssh -L)") return &rootCmd } @@ -123,7 +132,7 @@ func rootRunEWithExtra(cmd *cobra.Command, args []string, flag *flagType, allPer } if len(sshUsers) == 0 { return fmt.Errorf(`No user specified -e.g. --user "john:mypassword" +e.g. --user "john:mypass" e.g. --user "john:"`) } // (base: https://gist.github.com/jpillora/b480fde82bff51a06238) diff --git a/cmd/root_test.go b/cmd/root_test.go index 1960213..5d1faf4 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -27,7 +27,7 @@ func TestZeroUsers(t *testing.T) { rootCmd.SetErr(&stderrBuf) assert.Error(t, rootCmd.Execute()) assert.Equal(t, `Error: No user specified -e.g. --user "john:mypassword" +e.g. --user "john:mypass" e.g. --user "john:" `, stderrBuf.String()) } @@ -35,7 +35,7 @@ e.g. --user "john:" func TestAllPermissionsAllowed(t *testing.T) { rootCmd := RootCmd() port := getAvailableTcpPort() - rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword"}) + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { @@ -46,7 +46,7 @@ func TestAllPermissionsAllowed(t *testing.T) { waitTCPServer(port) sshClientConfig := &ssh.ClientConfig{ User: "john", - Auth: []ssh.AuthMethod{ssh.Password("mypassword")}, + Auth: []ssh.AuthMethod{ssh.Password("mypass")}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) @@ -90,7 +90,7 @@ func TestEmptyPassword(t *testing.T) { func TestMultipleUsers(t *testing.T) { rootCmd := RootCmd() port := getAvailableTcpPort() - rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword1", "--user", "alex:mypassword2"}) + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass1", "--user", "alex:mypass2"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { @@ -104,7 +104,7 @@ func TestMultipleUsers(t *testing.T) { for _, user := range []struct { name string password string - }{{name: "john", password: "mypassword1"}, {name: "alex", password: "mypassword2"}} { + }{{name: "john", password: "mypass1"}, {name: "alex", password: "mypass2"}} { sshClientConfig := &ssh.ClientConfig{ User: user.name, Auth: []ssh.AuthMethod{ssh.Password(user.password)}, @@ -121,7 +121,7 @@ func TestMultipleUsers(t *testing.T) { func TestWrongPassword(t *testing.T) { rootCmd := RootCmd() port := getAvailableTcpPort() - rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword"}) + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { @@ -144,7 +144,7 @@ func TestWrongPassword(t *testing.T) { func TestAllowExecute(t *testing.T) { rootCmd := RootCmd() port := getAvailableTcpPort() - rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword", "--allow-execute"}) + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-execute"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { @@ -155,7 +155,7 @@ func TestAllowExecute(t *testing.T) { waitTCPServer(port) sshClientConfig := &ssh.ClientConfig{ User: "john", - Auth: []ssh.AuthMethod{ssh.Password("mypassword")}, + Auth: []ssh.AuthMethod{ssh.Password("mypass")}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) @@ -175,7 +175,7 @@ func TestAllowExecute(t *testing.T) { func TestAllowTcpipForward(t *testing.T) { rootCmd := RootCmd() port := getAvailableTcpPort() - rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword", "--allow-tcpip-forward"}) + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-tcpip-forward"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { @@ -186,7 +186,7 @@ func TestAllowTcpipForward(t *testing.T) { waitTCPServer(port) sshClientConfig := &ssh.ClientConfig{ User: "john", - Auth: []ssh.AuthMethod{ssh.Password("mypassword")}, + Auth: []ssh.AuthMethod{ssh.Password("mypass")}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) @@ -206,7 +206,7 @@ func TestAllowTcpipForward(t *testing.T) { func TestAllowStreamlocalForward(t *testing.T) { rootCmd := RootCmd() port := getAvailableTcpPort() - rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword", "--allow-streamlocal-forward"}) + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-streamlocal-forward"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { @@ -217,7 +217,7 @@ func TestAllowStreamlocalForward(t *testing.T) { waitTCPServer(port) sshClientConfig := &ssh.ClientConfig{ User: "john", - Auth: []ssh.AuthMethod{ssh.Password("mypassword")}, + Auth: []ssh.AuthMethod{ssh.Password("mypass")}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) @@ -237,7 +237,7 @@ func TestAllowStreamlocalForward(t *testing.T) { func TestAllowDirectTcpip(t *testing.T) { rootCmd := RootCmd() port := getAvailableTcpPort() - rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword", "--allow-direct-tcpip"}) + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-direct-tcpip"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { @@ -248,7 +248,7 @@ func TestAllowDirectTcpip(t *testing.T) { waitTCPServer(port) sshClientConfig := &ssh.ClientConfig{ User: "john", - Auth: []ssh.AuthMethod{ssh.Password("mypassword")}, + Auth: []ssh.AuthMethod{ssh.Password("mypass")}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) @@ -268,7 +268,7 @@ func TestAllowDirectTcpip(t *testing.T) { func TestAllowDirectStreamlocal(t *testing.T) { rootCmd := RootCmd() port := getAvailableTcpPort() - rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword", "--allow-direct-streamlocal"}) + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-direct-streamlocal"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { @@ -279,7 +279,7 @@ func TestAllowDirectStreamlocal(t *testing.T) { waitTCPServer(port) sshClientConfig := &ssh.ClientConfig{ User: "john", - Auth: []ssh.AuthMethod{ssh.Password("mypassword")}, + Auth: []ssh.AuthMethod{ssh.Password("mypass")}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) @@ -299,7 +299,7 @@ func TestAllowDirectStreamlocal(t *testing.T) { func TestAllowSftp(t *testing.T) { rootCmd := RootCmd() port := getAvailableTcpPort() - rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword", "--allow-sftp"}) + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-sftp"}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { @@ -310,7 +310,7 @@ func TestAllowSftp(t *testing.T) { waitTCPServer(port) sshClientConfig := &ssh.ClientConfig{ User: "john", - Auth: []ssh.AuthMethod{ssh.Password("mypassword")}, + Auth: []ssh.AuthMethod{ssh.Password("mypass")}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) diff --git a/go.mod b/go.mod index de21a8d..12e59fd 100644 --- a/go.mod +++ b/go.mod @@ -11,15 +11,18 @@ require ( github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.4 golang.org/x/crypto v0.12.0 - golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819 + golang.org/x/exp v0.0.0-20230810033253-352e893a4cad ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/fs v0.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/sys v0.11.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c0e1b80..906520c 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -10,14 +11,25 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go= github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= @@ -30,8 +42,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819 h1:EDuYyU/MkFXllv9QF9819VlI9a4tzGuCbhG0ExK9o1U= -golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/exp v0.0.0-20230810033253-352e893a4cad h1:g0bG7Z4uG+OgH2QDODnjp6ggkk1bJDsINcuWmJN1iJU= +golang.org/x/exp v0.0.0-20230810033253-352e893a4cad/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -43,8 +55,9 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server.go b/server.go index 7e1dedd..882c316 100644 --- a/server.go +++ b/server.go @@ -15,6 +15,7 @@ import ( "encoding/pem" "fmt" "github.com/mattn/go-shellwords" + "github.com/nwtgck/handy-sshd/sync_generics" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" "golang.org/x/exp/slog" @@ -27,7 +28,8 @@ import ( ) type Server struct { - Logger *slog.Logger + Logger *slog.Logger + bindAddressToListener sync_generics.Map[string, net.Listener] // Permissions AllowTcpipForward bool @@ -123,7 +125,7 @@ func (s *Server) handleSession(shell string, newChannel ssh.NewChannel) { case "subsystem": s.handleSessionSubSystem(req, connection) default: - s.Logger.Info("unknown request", "req_type", req.Type) + s.Logger.Info("unsupported request", "req_type", req.Type) } } } @@ -317,6 +319,10 @@ func (s *Server) HandleGlobalRequests(sshConn *ssh.ServerConn, reqs <-chan *ssh. go func() { s.handleTcpipForward(sshConn, req) }() + case "cancel-tcpip-forward": + go func() { + s.cancelTcpipForward(req) + }() case "streamlocal-forward@openssh.com": if !s.AllowStreamlocalForward { s.Logger.Info("streamlocal-forward not allowed") @@ -326,8 +332,10 @@ func (s *Server) HandleGlobalRequests(sshConn *ssh.ServerConn, reqs <-chan *ssh. go func() { s.handleStreamlocalForward(sshConn, req) }() - // TODO: support cancel-tcpip-forward - // TODO: support cancel-streamlocal-forward@openssh.com + case "cancel-streamlocal-forward@openssh.com": + go func() { + s.cancelStreamlocalForward(req) + }() default: // discard if req.WantReply { @@ -348,11 +356,13 @@ func (s *Server) handleTcpipForward(sshConn *ssh.ServerConn, req *ssh.Request) { req.Reply(false, nil) return } - ln, err := net.Listen("tcp", net.JoinHostPort(msg.Addr, strconv.Itoa(int(msg.Port)))) + address := net.JoinHostPort(msg.Addr, strconv.Itoa(int(msg.Port))) + ln, err := net.Listen("tcp", address) if err != nil { req.Reply(false, nil) return } + s.bindAddressToListener.Store(address, ln) req.Reply(true, nil) go func() { sshConn.Wait() @@ -404,6 +414,29 @@ func (s *Server) handleTcpipForward(sshConn *ssh.ServerConn, req *ssh.Request) { } } +// https://datatracker.ietf.org/doc/html/rfc4254#section-7.1 +func (s *Server) cancelTcpipForward(req *ssh.Request) { + var msg struct { + Addr string + Port uint32 + } + if err := ssh.Unmarshal(req.Payload, &msg); err != nil { + req.Reply(false, nil) + return + } + address := net.JoinHostPort(msg.Addr, strconv.Itoa(int(msg.Port))) + ln, loaded := s.bindAddressToListener.LoadAndDelete(address) + if !loaded { + req.Reply(false, nil) + s.Logger.Info("failed to find listener", "address", address) + } + if err := ln.Close(); err != nil { + req.Reply(false, nil) + s.Logger.Info("failed to close", "err", err) + } + req.Reply(true, nil) +} + // client side: https://github.com/golang/crypto/blob/b4ddeeda5bc71549846db71ba23e83ecb26f36ed/ssh/streamlocal.go#L34 func (s *Server) handleStreamlocalForward(sshConn *ssh.ServerConn, req *ssh.Request) { // https://github.com/openssh/openssh-portable/blob/f9f18006678d2eac8b0c5a5dddf17ab7c50d1e9f/PROTOCOL#L272 @@ -419,6 +452,7 @@ func (s *Server) handleStreamlocalForward(sshConn *ssh.ServerConn, req *ssh.Requ req.Reply(false, nil) return } + s.bindAddressToListener.Store(msg.SocketPath, ln) req.Reply(true, nil) go func() { sshConn.Wait() @@ -459,3 +493,25 @@ func (s *Server) handleStreamlocalForward(sshConn *ssh.ServerConn, req *ssh.Requ }() } } + +func (s *Server) cancelStreamlocalForward(req *ssh.Request) { + // https://github.com/openssh/openssh-portable/blob/f9f18006678d2eac8b0c5a5dddf17ab7c50d1e9f/PROTOCOL#L280 + var msg struct { + SocketPath string + } + if err := ssh.Unmarshal(req.Payload, &msg); err != nil { + req.Reply(false, nil) + return + } + ln, loaded := s.bindAddressToListener.LoadAndDelete(msg.SocketPath) + if !loaded { + s.Logger.Info("failed to find listener", "address", msg.SocketPath) + req.Reply(false, nil) + return + } + if err := ln.Close(); err != nil { + req.Reply(false, nil) + s.Logger.Info("failed to close", "err", err) + } + req.Reply(true, nil) +} diff --git a/sync_generics/map.go b/sync_generics/map.go new file mode 100644 index 0000000..ab31102 --- /dev/null +++ b/sync_generics/map.go @@ -0,0 +1,62 @@ +package sync_generics + +import "sync" + +type Map[K any, V any] struct { + inner sync.Map +} + +func (m *Map[K, V]) CompareAndDelete(key K, old V) (deleted bool) { + deleted = m.inner.CompareAndDelete(key, old) + return +} + +func (m *Map[K, V]) CompareAndSwap(key K, old V, new V) bool { + return m.inner.CompareAndSwap(key, old, new) +} + +func (m *Map[K, V]) Delete(key K) { + m.inner.Delete(key) +} + +func (m *Map[K, V]) Load(key K) (value V, ok bool) { + _value, ok := m.inner.Load(key) + value = nilSafeTypeAssertion[V](_value) + return +} + +func (m *Map[K, V]) LoadAndDelete(key K) (value V, loaded bool) { + _value, loaded := m.inner.LoadAndDelete(key) + value = nilSafeTypeAssertion[V](_value) + return +} + +func (m *Map[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) { + _actual, loaded := m.inner.LoadOrStore(key, value) + actual = nilSafeTypeAssertion[V](_actual) + return +} + +func (m *Map[K, V]) Range(f func(key K, value V) bool) { + m.inner.Range(func(key, value any) bool { + return f(nilSafeTypeAssertion[K](key), nilSafeTypeAssertion[V](value)) + }) +} + +func (m *Map[K, V]) Store(key K, value V) { + m.inner.Store(key, value) +} + +func (m *Map[K, V]) Swap(key K, value V) (previous V, loaded bool) { + _previous, loaded := m.inner.Swap(key, value) + previous = nilSafeTypeAssertion[V](_previous) + return +} + +func nilSafeTypeAssertion[T any](value any) T { + var zero T + if value == nil { + return zero + } + return value.(T) +} diff --git a/version/version.go b/version/version.go index ee59a7c..c1635d2 100644 --- a/version/version.go +++ b/version/version.go @@ -1,3 +1,3 @@ package version -const Version = "0.3.0" +const Version = "0.4.0"