mirror of
https://github.com/nwtgck/handy-sshd.git
synced 2025-06-08 07:03:04 +00:00
Compare commits
89 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9ad810900d | ||
![]() |
9fdeeb1a84 | ||
![]() |
f4814644ec | ||
![]() |
5951dd5bdc | ||
![]() |
11f7602ab8 | ||
![]() |
fadb7e3233 | ||
![]() |
7beaab3f22 | ||
![]() |
cc8c8e7916 | ||
![]() |
0a867b860d | ||
![]() |
98d68900ba | ||
![]() |
e4acc725d5 | ||
![]() |
71493a7895 | ||
![]() |
c1ec261ed6 | ||
![]() |
42c23a9b69 | ||
![]() |
fed11ed08a | ||
![]() |
739f825f40 | ||
![]() |
45257031fa | ||
![]() |
2977f90966 | ||
![]() |
f3231aec39 | ||
![]() |
f6d96d1a78 | ||
![]() |
f41ff25604 | ||
![]() |
fc55b1a7f1 | ||
![]() |
df7755ecc3 | ||
![]() |
2b6594cea3 | ||
![]() |
b4f174cf61 | ||
![]() |
a72a61a47f | ||
![]() |
9a54ed3dd3 | ||
![]() |
7d29c35f97 | ||
![]() |
6baaf4ac0e | ||
![]() |
1f51c1d3af | ||
![]() |
e5eb9593ad | ||
![]() |
4a4c6c7602 | ||
![]() |
6d1f48c6cf | ||
![]() |
5639819bd9 | ||
![]() |
c8121690aa | ||
![]() |
7cc32f3e30 | ||
![]() |
5bad4baad4 | ||
![]() |
6e09a6fdb7 | ||
![]() |
28bd116388 | ||
![]() |
3cdb3fe048 | ||
![]() |
29b7c6f24c | ||
![]() |
5a44e6cdaa | ||
![]() |
b59d8c9b68 | ||
![]() |
4a2df34cfc | ||
![]() |
04d5f34d73 | ||
![]() |
fb77c1c5a2 | ||
![]() |
c9ab431395 | ||
![]() |
4479e20482 | ||
![]() |
11bdaeb354 | ||
![]() |
f508fb2ddb | ||
![]() |
7fc00e16e3 | ||
![]() |
d7d3d615aa | ||
![]() |
a2739a8c1e | ||
![]() |
19a0a8a7c5 | ||
![]() |
886d93ba25 | ||
![]() |
2d5c82a7bd | ||
![]() |
e5fbf86f8d | ||
![]() |
3ef24a9954 | ||
![]() |
445676f901 | ||
![]() |
edfd9533f3 | ||
![]() |
d3a0a420f4 | ||
![]() |
ef2fa8f26d | ||
![]() |
79ffadacd0 | ||
![]() |
5068de6f1f | ||
![]() |
8aa0cf8b37 | ||
![]() |
6937695b52 | ||
![]() |
9703d25277 | ||
![]() |
b1973ce4e8 | ||
![]() |
5cc6ad44c6 | ||
![]() |
4b7793e445 | ||
![]() |
7b324d52e6 | ||
![]() |
36b1ade1c0 | ||
![]() |
6c183ffac4 | ||
![]() |
663c9fae81 | ||
![]() |
f36e6f8702 | ||
![]() |
2295abacdd | ||
![]() |
3b0b650318 | ||
![]() |
695d0603b3 | ||
![]() |
9e10ac7dea | ||
![]() |
cad0e3fa23 | ||
![]() |
35c29a6de2 | ||
![]() |
aac75f81a6 | ||
![]() |
b5f2030e3d | ||
![]() |
5184ebed95 | ||
![]() |
4ba028c8a1 | ||
![]() |
54f0a6069e | ||
![]() |
bfe1e922ec | ||
![]() |
bd0d0aae99 | ||
![]() |
faf81be83c |
14 changed files with 1243 additions and 200 deletions
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
@ -3,12 +3,11 @@ name: CI
|
||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_multi_platform:
|
test:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Go 1.x
|
- uses: actions/setup-go@v5
|
||||||
uses: actions/setup-go@v4
|
|
||||||
with:
|
with:
|
||||||
go-version: "1.20"
|
go-version: "1.20"
|
||||||
- name: Build for multi-platform
|
- name: Build for multi-platform
|
||||||
|
@ -29,3 +28,5 @@ jobs:
|
||||||
# Build
|
# Build
|
||||||
CGO_ENABLED=0 go build -o "${BUILD_PATH}/handy-sshd${EXTENSION}" main/main.go
|
CGO_ENABLED=0 go build -o "${BUILD_PATH}/handy-sshd${EXTENSION}" main/main.go
|
||||||
done
|
done
|
||||||
|
- name: Test
|
||||||
|
run: go test -v ./...
|
||||||
|
|
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
@ -9,17 +9,17 @@ jobs:
|
||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.20"
|
go-version: "1.20"
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v4
|
uses: goreleaser/goreleaser-action@v5
|
||||||
with:
|
with:
|
||||||
version: v1.3.1
|
version: v1.19.2
|
||||||
args: release --rm-dist
|
args: release --rm-dist
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||||
|
|
43
CHANGELOG.md
43
CHANGELOG.md
|
@ -5,6 +5,41 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.3] - 2024-05-27
|
||||||
|
### Changed
|
||||||
|
* Update dependencies
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Wait non-nil process when pty open failed
|
||||||
|
|
||||||
|
## [0.4.2] - 2024-04-05
|
||||||
|
### Changed
|
||||||
|
* Update dependencies
|
||||||
|
|
||||||
|
## [0.4.1] - 2023-09-13
|
||||||
|
### Changed
|
||||||
|
* Update dependencies
|
||||||
|
|
||||||
|
## [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
|
||||||
|
* Support Unix domain socket remote port forwarding
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Handle multiple global requests simultaneously
|
||||||
|
|
||||||
|
## [0.2.1] - 2023-08-09
|
||||||
|
### Changed
|
||||||
|
* Update dependencies
|
||||||
|
|
||||||
## [0.2.0] - 2023-08-09
|
## [0.2.0] - 2023-08-09
|
||||||
### Added
|
### Added
|
||||||
* Add permissions
|
* Add permissions
|
||||||
|
@ -16,5 +51,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||||
### Added
|
### Added
|
||||||
* Initial release
|
* Initial release
|
||||||
|
|
||||||
[Unreleased]: https://github.com/nwtgck/handy-sshd/compare/v0.2.0...HEAD
|
[Unreleased]: https://github.com/nwtgck/handy-sshd/compare/v0.4.2...HEAD
|
||||||
|
[0.4.3]: https://github.com/nwtgck/handy-sshd/compare/v0.4.2...v0.4.3
|
||||||
|
[0.4.2]: https://github.com/nwtgck/handy-sshd/compare/v0.4.1...v0.4.2
|
||||||
|
[0.4.1]: https://github.com/nwtgck/handy-sshd/compare/v0.4.0...v0.4.1
|
||||||
|
[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
|
[0.2.0]: https://github.com/nwtgck/handy-sshd/compare/v0.1.0...v0.2.0
|
||||||
|
|
85
README.md
85
README.md
|
@ -6,8 +6,8 @@ Portable SSH Server
|
||||||
## Install on Ubuntu/Debian
|
## Install on Ubuntu/Debian
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/nwtgck/handy-sshd/releases/download/v0.1.0/handy-sshd-0.1.0-linux-amd64.deb
|
wget https://github.com/nwtgck/handy-sshd/releases/download/v0.4.2/handy-sshd-0.4.2-linux-amd64.deb
|
||||||
sudo dpkg -i handy-sshd-0.1.0-linux-amd64.deb
|
sudo dpkg -i handy-sshd-0.4.2-linux-amd64.deb
|
||||||
```
|
```
|
||||||
|
|
||||||
## Install on Mac
|
## Install on Mac
|
||||||
|
@ -21,40 +21,62 @@ Get more executables in [the releases](https://github.com/nwtgck/handy-sshd/rele
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Listen on 2222 and accept user name "john" with password "mypassword"
|
# Listen on 2222 and accept user name "john" with password "mypass"
|
||||||
handy-sshd -p 2222 --user "john:mypassword"
|
handy-sshd -p 2222 -u john:mypass
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Listen on 2222 and accept user name "john" without password
|
# Listen on 2222 and accept user name "john" without password
|
||||||
handy-sshd -p 2222 --user "john:"
|
handy-sshd -p 2222 -u john:
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Listen on 2222 and accept users "john" and "alice" without password
|
# 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
|
```bash
|
||||||
# Listen on unix domain socket
|
# 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
|
## Permissions
|
||||||
**All permissions are allowed when nothing is specified.** There are some permissions.
|
There are several permissions:
|
||||||
|
* --allow-direct-streamlocal
|
||||||
* --allow-direct-tcpip
|
* --allow-direct-tcpip
|
||||||
* --allow-execute
|
* --allow-execute
|
||||||
* --allow-sftp
|
* --allow-sftp
|
||||||
|
* --allow-streamlocal-forward
|
||||||
* --allow-tcpip-forward
|
* --allow-tcpip-forward
|
||||||
|
|
||||||
Specifying `--allow-direct-tcpip` and `--allow-execute` for example allows only them.
|
**All permissions are allowed when nothing is specified.** The log shows "allowed: " and "NOT allowed: " permissions as follows:
|
||||||
The log shows "allowed: " and "NOT allowed: " permissions as follows.
|
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ handy-sshd --user "john:" --allow-direct-tcpip --allow-execute
|
$ handy-sshd -u "john:"
|
||||||
2023/08/09 20:49:35 INFO listening on :2222...
|
2023/08/11 11:40:44 INFO listening on :2222...
|
||||||
2023/08/09 20:49:35 INFO allowed: "direct-tcpip", "execute"
|
2023/08/11 11:40:44 INFO allowed: "tcpip-forward", "direct-tcpip", "execute", "sftp", "streamlocal-forward", "direct-streamlocal"
|
||||||
2023/08/09 20:49:35 INFO NOT allowed: "tcpip-forward", "sftp"
|
2023/08/11 11:40:44 INFO NOT allowed: none
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, specifying `--allow-direct-tcpip` and `--allow-execute` allows only them:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ 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"
|
||||||
```
|
```
|
||||||
|
|
||||||
## --help
|
## --help
|
||||||
|
@ -65,16 +87,29 @@ Portable SSH server
|
||||||
Usage:
|
Usage:
|
||||||
handy-sshd [flags]
|
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:
|
Flags:
|
||||||
--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-execute client can use shell/interactive shell
|
--allow-direct-tcpip client can use local forwarding (ssh -L) and SOCKS proxy (ssh -D)
|
||||||
--allow-sftp client can use SFTP and SSHFS
|
--allow-execute client can use shell/interactive shell
|
||||||
--allow-tcpip-forward client can use remote forwarding
|
--allow-sftp client can use SFTP and SSHFS
|
||||||
-h, --help help for handy-sshd
|
--allow-streamlocal-forward client can use Unix domain socket remote forwarding (ssh -R)
|
||||||
--host string SSH server host (e.g. 127.0.0.1)
|
--allow-tcpip-forward client can use remote forwarding (ssh -R)
|
||||||
-p, --port uint16 SSH server port (default 2222)
|
-h, --help help for handy-sshd
|
||||||
--shell string Shell
|
--host string SSH server host to listen (e.g. 127.0.0.1)
|
||||||
--unix-socket string Unix-domain socket
|
-p, --port uint16 port to listen (default 2222)
|
||||||
--user stringArray SSH user name (e.g. "john:mypassword")
|
--shell string Shell
|
||||||
-v, --version show version
|
--unix-socket string Unix domain socket to listen
|
||||||
|
-u, --user stringArray SSH user name (e.g. "john:mypass")
|
||||||
|
-v, --version show version
|
||||||
```
|
```
|
||||||
|
|
270
cmd/root.go
270
cmd/root.go
|
@ -13,7 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var flag struct {
|
type flagType struct {
|
||||||
//dnsServer string
|
//dnsServer string
|
||||||
showsVersion bool
|
showsVersion bool
|
||||||
sshHost string
|
sshHost string
|
||||||
|
@ -22,20 +22,17 @@ var flag struct {
|
||||||
sshShell string
|
sshShell string
|
||||||
sshUsers []string
|
sshUsers []string
|
||||||
|
|
||||||
allowTcpipForward bool
|
allowTcpipForward bool
|
||||||
allowDirectTcpip bool
|
allowDirectTcpip bool
|
||||||
allowExecute bool
|
allowExecute bool
|
||||||
allowSftp bool
|
allowSftp bool
|
||||||
|
allowStreamlocalForward bool
|
||||||
|
allowDirectStreamlocal bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var allPermissionFlags = []struct {
|
type permissionFlagType = struct {
|
||||||
name string
|
name string
|
||||||
flagPtr *bool
|
flagPtr *bool
|
||||||
}{
|
|
||||||
{name: "tcpip-forward", flagPtr: &flag.allowTcpipForward},
|
|
||||||
{name: "direct-tcpip", flagPtr: &flag.allowDirectTcpip},
|
|
||||||
{name: "execute", flagPtr: &flag.allowExecute},
|
|
||||||
{name: "sftp", flagPtr: &flag.allowSftp},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type sshUser struct {
|
type sshUser struct {
|
||||||
|
@ -45,136 +42,167 @@ type sshUser struct {
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize()
|
cobra.OnInitialize()
|
||||||
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")
|
|
||||||
// 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.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")`)
|
|
||||||
|
|
||||||
// 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.allowExecute, "allow-execute", "", false, "client can use shell/interactive shell")
|
|
||||||
RootCmd.PersistentFlags().BoolVarP(&flag.allowSftp, "allow-sftp", "", false, "client can use SFTP and SSHFS")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var RootCmd = &cobra.Command{
|
func RootCmd() *cobra.Command {
|
||||||
Use: os.Args[0],
|
var flag flagType
|
||||||
Short: "handy-sshd",
|
allPermissionFlags := []permissionFlagType{
|
||||||
Long: "Portable SSH server",
|
{name: "tcpip-forward", flagPtr: &flag.allowTcpipForward},
|
||||||
SilenceUsage: true,
|
{name: "direct-tcpip", flagPtr: &flag.allowDirectTcpip},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
{name: "execute", flagPtr: &flag.allowExecute},
|
||||||
if flag.showsVersion {
|
{name: "sftp", flagPtr: &flag.allowSftp},
|
||||||
fmt.Println(version.Version)
|
{name: "streamlocal-forward", flagPtr: &flag.allowStreamlocalForward},
|
||||||
return nil
|
{name: "direct-streamlocal", flagPtr: &flag.allowDirectStreamlocal},
|
||||||
}
|
}
|
||||||
logger := slog.Default()
|
rootCmd := cobra.Command{
|
||||||
|
Use: os.Args[0],
|
||||||
|
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
|
||||||
|
|
||||||
// Allow all permissions if all permission is not set
|
# Listen on 22 and accept the user without password
|
||||||
{
|
handy-sshd -p 22 -u john:
|
||||||
allPermissionFalse := true
|
|
||||||
|
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 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 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", "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 (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 (ssh -R)")
|
||||||
|
rootCmd.PersistentFlags().BoolVarP(&flag.allowDirectStreamlocal, "allow-direct-streamlocal", "", false, "client can use Unix domain socket local forwarding (ssh -L)")
|
||||||
|
|
||||||
|
return &rootCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootRunEWithExtra(cmd *cobra.Command, args []string, flag *flagType, allPermissionFlags []permissionFlagType) error {
|
||||||
|
if flag.showsVersion {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), version.Version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger := slog.Default()
|
||||||
|
|
||||||
|
// Allow all permissions if all permission is not set
|
||||||
|
{
|
||||||
|
allPermissionFalse := true
|
||||||
|
for _, permissionFlag := range allPermissionFlags {
|
||||||
|
allPermissionFalse = allPermissionFalse && !*permissionFlag.flagPtr
|
||||||
|
}
|
||||||
|
if allPermissionFalse {
|
||||||
for _, permissionFlag := range allPermissionFlags {
|
for _, permissionFlag := range allPermissionFlags {
|
||||||
allPermissionFalse = allPermissionFalse && !*permissionFlag.flagPtr
|
*permissionFlag.flagPtr = true
|
||||||
}
|
|
||||||
if allPermissionFalse {
|
|
||||||
for _, permissionFlag := range allPermissionFlags {
|
|
||||||
*permissionFlag.flagPtr = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sshServer := &handy_sshd.Server{
|
sshServer := &handy_sshd.Server{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
AllowTcpipForward: flag.allowTcpipForward,
|
AllowTcpipForward: flag.allowTcpipForward,
|
||||||
AllowDirectTcpip: flag.allowDirectTcpip,
|
AllowDirectTcpip: flag.allowDirectTcpip,
|
||||||
AllowExecute: flag.allowExecute,
|
AllowExecute: flag.allowExecute,
|
||||||
AllowSftp: flag.allowSftp,
|
AllowSftp: flag.allowSftp,
|
||||||
|
AllowStreamlocalForward: flag.allowStreamlocalForward,
|
||||||
|
AllowDirectStreamlocal: flag.allowDirectStreamlocal,
|
||||||
|
}
|
||||||
|
var sshUsers []sshUser
|
||||||
|
for _, u := range flag.sshUsers {
|
||||||
|
splits := strings.SplitN(u, ":", 2)
|
||||||
|
if len(splits) != 2 {
|
||||||
|
return fmt.Errorf("invalid user format: %s", u)
|
||||||
}
|
}
|
||||||
var sshUsers []sshUser
|
sshUsers = append(sshUsers, sshUser{name: splits[0], password: splits[1]})
|
||||||
for _, u := range flag.sshUsers {
|
}
|
||||||
splits := strings.SplitN(u, ":", 2)
|
if len(sshUsers) == 0 {
|
||||||
if len(splits) != 2 {
|
return fmt.Errorf(`No user specified
|
||||||
return fmt.Errorf("invalid user format: %s", u)
|
e.g. --user "john:mypass"
|
||||||
}
|
|
||||||
sshUsers = append(sshUsers, sshUser{name: splits[0], password: splits[1]})
|
|
||||||
}
|
|
||||||
if len(sshUsers) == 0 {
|
|
||||||
return fmt.Errorf(`No user specified
|
|
||||||
e.g. --user "john:mypassword"
|
|
||||||
e.g. --user "john:"`)
|
e.g. --user "john:"`)
|
||||||
}
|
}
|
||||||
// (base: https://gist.github.com/jpillora/b480fde82bff51a06238)
|
// (base: https://gist.github.com/jpillora/b480fde82bff51a06238)
|
||||||
sshConfig := &ssh.ServerConfig{
|
sshConfig := &ssh.ServerConfig{
|
||||||
//Define a function to run when a client attempts a password login
|
//Define a function to run when a client attempts a password login
|
||||||
PasswordCallback: func(metadata ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
PasswordCallback: func(metadata ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
||||||
for _, user := range sshUsers {
|
for _, user := range sshUsers {
|
||||||
// No auth required
|
// No auth required
|
||||||
if user.name == metadata.User() && user.password == string(pass) {
|
if user.name == metadata.User() && user.password == string(pass) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("password rejected for %q", metadata.User())
|
}
|
||||||
},
|
return nil, fmt.Errorf("password rejected for %q", metadata.User())
|
||||||
NoClientAuth: true,
|
},
|
||||||
NoClientAuthCallback: func(metadata ssh.ConnMetadata) (*ssh.Permissions, error) {
|
NoClientAuth: true,
|
||||||
for _, user := range sshUsers {
|
NoClientAuthCallback: func(metadata ssh.ConnMetadata) (*ssh.Permissions, error) {
|
||||||
// No auth required
|
for _, user := range sshUsers {
|
||||||
if user.name == metadata.User() && user.password == "" {
|
// No auth required
|
||||||
return nil, nil
|
if user.name == metadata.User() && user.password == "" {
|
||||||
}
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("%s auth required", metadata.User())
|
}
|
||||||
},
|
return nil, fmt.Errorf("%s auth required", metadata.User())
|
||||||
}
|
},
|
||||||
// TODO: specify priv_key by flags
|
}
|
||||||
pri, err := ssh.ParsePrivateKey([]byte(defaultHostKeyPem))
|
// TODO: specify priv_key by flags
|
||||||
|
pri, err := ssh.ParsePrivateKey([]byte(defaultHostKeyPem))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sshConfig.AddHostKey(pri)
|
||||||
|
|
||||||
|
var ln net.Listener
|
||||||
|
if flag.sshUnixSocket == "" {
|
||||||
|
address := net.JoinHostPort(flag.sshHost, strconv.Itoa(int(flag.sshPort)))
|
||||||
|
ln, err = net.Listen("tcp", address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
sshConfig.AddHostKey(pri)
|
logger.Info(fmt.Sprintf("listening on %s...", address))
|
||||||
|
} else {
|
||||||
var ln net.Listener
|
ln, err = net.Listen("unix", flag.sshUnixSocket)
|
||||||
if flag.sshUnixSocket == "" {
|
if err != nil {
|
||||||
address := net.JoinHostPort(flag.sshHost, strconv.Itoa(int(flag.sshPort)))
|
return err
|
||||||
ln, err = net.Listen("tcp", address)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logger.Info(fmt.Sprintf("listening on %s...", address))
|
|
||||||
} else {
|
|
||||||
ln, err = net.Listen("unix", flag.sshUnixSocket)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
logger.Info(fmt.Sprintf("listening on %s...", flag.sshUnixSocket))
|
|
||||||
}
|
}
|
||||||
defer ln.Close()
|
logger.Info(fmt.Sprintf("listening on %s...", flag.sshUnixSocket))
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
showPermissions(logger)
|
showPermissions(logger, allPermissionFlags)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
conn, err := ln.Accept()
|
conn, err := ln.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to accept TCP connection", "err", err)
|
logger.Error("failed to accept TCP connection", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
|
||||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, sshConfig)
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("failed to handshake", "err", err)
|
|
||||||
conn.Close()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
logger.Info("new SSH connection", "remote_address", sshConn.RemoteAddr(), "client_version", string(sshConn.ClientVersion()))
|
|
||||||
go sshServer.HandleGlobalRequests(sshConn, reqs)
|
|
||||||
go sshServer.HandleChannels(flag.sshShell, chans)
|
|
||||||
}
|
}
|
||||||
},
|
sshConn, chans, reqs, err := ssh.NewServerConn(conn, sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
logger.Info("failed to handshake", "err", err)
|
||||||
|
conn.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.Info("new SSH connection", "remote_address", sshConn.RemoteAddr(), "client_version", string(sshConn.ClientVersion()))
|
||||||
|
go sshServer.HandleGlobalRequests(sshConn, reqs)
|
||||||
|
go sshServer.HandleChannels(flag.sshShell, chans)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func showPermissions(logger *slog.Logger) {
|
func showPermissions(logger *slog.Logger, allPermissionFlags []permissionFlagType) {
|
||||||
var allowedList []string
|
var allowedList []string
|
||||||
var notAllowedList []string
|
var notAllowedList []string
|
||||||
for _, permissionFlag := range allPermissionFlags {
|
for _, permissionFlag := range allPermissionFlags {
|
||||||
|
|
327
cmd/root_test.go
Normal file
327
cmd/root_test.go
Normal file
|
@ -0,0 +1,327 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"github.com/nwtgck/handy-sshd/version"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVersion(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
rootCmd.SetArgs([]string{"--version"})
|
||||||
|
var stdoutBuf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&stdoutBuf)
|
||||||
|
assert.NoError(t, rootCmd.Execute())
|
||||||
|
assert.Equal(t, version.Version+"\n", stdoutBuf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroUsers(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
rootCmd.SetArgs([]string{})
|
||||||
|
var stderrBuf bytes.Buffer
|
||||||
|
rootCmd.SetErr(&stderrBuf)
|
||||||
|
assert.Error(t, rootCmd.Execute())
|
||||||
|
assert.Equal(t, `Error: No user specified
|
||||||
|
e.g. --user "john:mypass"
|
||||||
|
e.g. --user "john:"
|
||||||
|
`, stderrBuf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllPermissionsAllowed(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
port := getAvailableTcpPort()
|
||||||
|
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass"})
|
||||||
|
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("mypass")},
|
||||||
|
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)
|
||||||
|
assertRemotePortForwarding(t, client)
|
||||||
|
assertLocalPortForwarding(t, client)
|
||||||
|
assertExec(t, client)
|
||||||
|
assertPtyTerminal(t, client)
|
||||||
|
assertSftp(t, client)
|
||||||
|
assertUnixRemotePortForwarding(t, client)
|
||||||
|
assertUnixLocalPortForwarding(t, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyPassword(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
port := getAvailableTcpPort()
|
||||||
|
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:"})
|
||||||
|
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",
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipleUsers(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
port := getAvailableTcpPort()
|
||||||
|
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass1", "--user", "alex:mypass2"})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
var stderrBuf bytes.Buffer
|
||||||
|
rootCmd.SetErr(&stderrBuf)
|
||||||
|
rootCmd.ExecuteContext(ctx)
|
||||||
|
}()
|
||||||
|
waitTCPServer(port)
|
||||||
|
address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||||
|
|
||||||
|
for _, user := range []struct {
|
||||||
|
name string
|
||||||
|
password string
|
||||||
|
}{{name: "john", password: "mypass1"}, {name: "alex", password: "mypass2"}} {
|
||||||
|
sshClientConfig := &ssh.ClientConfig{
|
||||||
|
User: user.name,
|
||||||
|
Auth: []ssh.AuthMethod{ssh.Password(user.password)},
|
||||||
|
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, err := ssh.Dial("tcp", address, sshClientConfig)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongPassword(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
port := getAvailableTcpPort()
|
||||||
|
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass"})
|
||||||
|
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("mywrongpassword")},
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
}
|
||||||
|
address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||||
|
_, err := ssh.Dial("tcp", address, sshClientConfig)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, `ssh: handshake failed: ssh: unable to authenticate, attempted methods [none password], no supported methods remain`, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowExecute(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
port := getAvailableTcpPort()
|
||||||
|
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-execute"})
|
||||||
|
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("mypass")},
|
||||||
|
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)
|
||||||
|
assertExec(t, client)
|
||||||
|
assertPtyTerminal(t, client)
|
||||||
|
assertNoSftp(t, client)
|
||||||
|
assertNoUnixRemotePortForwarding(t, client)
|
||||||
|
assertNoUnixLocalPortForwarding(t, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowTcpipForward(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
port := getAvailableTcpPort()
|
||||||
|
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-tcpip-forward"})
|
||||||
|
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("mypass")},
|
||||||
|
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)
|
||||||
|
assertRemotePortForwarding(t, client)
|
||||||
|
assertNoLocalPortForwarding(t, client)
|
||||||
|
assertNoExec(t, client)
|
||||||
|
assertNoPtyTerminal(t, client)
|
||||||
|
assertNoSftp(t, client)
|
||||||
|
assertNoUnixRemotePortForwarding(t, client)
|
||||||
|
assertNoUnixLocalPortForwarding(t, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowStreamlocalForward(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
port := getAvailableTcpPort()
|
||||||
|
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-streamlocal-forward"})
|
||||||
|
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("mypass")},
|
||||||
|
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)
|
||||||
|
assertUnixRemotePortForwarding(t, client)
|
||||||
|
assertNoUnixLocalPortForwarding(t, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowDirectTcpip(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
port := getAvailableTcpPort()
|
||||||
|
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-direct-tcpip"})
|
||||||
|
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("mypass")},
|
||||||
|
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)
|
||||||
|
assertLocalPortForwarding(t, client)
|
||||||
|
assertNoExec(t, client)
|
||||||
|
assertNoPtyTerminal(t, client)
|
||||||
|
assertNoSftp(t, client)
|
||||||
|
assertNoUnixRemotePortForwarding(t, client)
|
||||||
|
assertNoUnixLocalPortForwarding(t, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowDirectStreamlocal(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
port := getAvailableTcpPort()
|
||||||
|
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--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("mypass")},
|
||||||
|
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)
|
||||||
|
assertNoUnixRemotePortForwarding(t, client)
|
||||||
|
assertUnixLocalPortForwarding(t, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowSftp(t *testing.T) {
|
||||||
|
rootCmd := RootCmd()
|
||||||
|
port := getAvailableTcpPort()
|
||||||
|
rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypass", "--allow-sftp"})
|
||||||
|
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("mypass")},
|
||||||
|
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)
|
||||||
|
assertNoUnixRemotePortForwarding(t, client)
|
||||||
|
assertSftp(t, client)
|
||||||
|
}
|
283
cmd/test_util_test.go
Normal file
283
cmd/test_util_test.go
Normal file
|
@ -0,0 +1,283 @@
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getAvailableTcpPort() int {
|
||||||
|
ln, err := net.Listen("tcp", ":0")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
return ln.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitTCPServer(port int) {
|
||||||
|
for {
|
||||||
|
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertExec(t *testing.T, client *ssh.Client) {
|
||||||
|
session, err := client.NewSession()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer session.Close()
|
||||||
|
whoamiBytes, err := session.Output("whoami")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
expectedWhoamiBytes, err := exec.Command("whoami").Output()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, string(expectedWhoamiBytes), string(whoamiBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoExec(t *testing.T, client *ssh.Client) {
|
||||||
|
session, err := client.NewSession()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer session.Close()
|
||||||
|
_, err = session.Output("whoami")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "ssh: command whoami failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPtyTerminal(t *testing.T, client *ssh.Client) {
|
||||||
|
session, err := client.NewSession()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
err = session.RequestPty("xterm", 100, 200, ssh.TerminalModes{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
stdin, err := session.StdinPipe()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = stdin.Write([]byte("echo helloworldviapty\r"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
stdout, err := session.StdoutPipe()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
stdoutBytesChan := make(chan []byte)
|
||||||
|
go func() {
|
||||||
|
var buff bytes.Buffer
|
||||||
|
_, err := io.Copy(&buff, stdout)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
stdoutBytesChan <- buff.Bytes()
|
||||||
|
}()
|
||||||
|
err = session.Shell()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
session.Close()
|
||||||
|
stdoutBytes := <-stdoutBytesChan
|
||||||
|
assert.Contains(t, string(stdoutBytes), "helloworldviapty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoPtyTerminal(t *testing.T, client *ssh.Client) {
|
||||||
|
session, err := client.NewSession()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer session.Close()
|
||||||
|
err = session.RequestPty("xterm", 100, 200, ssh.TerminalModes{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "ssh: pty-req failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertLocalPortForwarding(t *testing.T, client *ssh.Client) {
|
||||||
|
var remoteTcpPort int
|
||||||
|
acceptedConnChan := make(chan net.Conn)
|
||||||
|
{
|
||||||
|
ln, err := net.Listen("tcp", ":0")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
remoteTcpPort = ln.Addr().(*net.TCPAddr).Port
|
||||||
|
go func() {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
acceptedConnChan <- conn
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
raddr := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: remoteTcpPort}
|
||||||
|
conn, err := client.DialTCP("tcp", nil, raddr)
|
||||||
|
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 assertNoLocalPortForwarding(t *testing.T, client *ssh.Client) {
|
||||||
|
raddr := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1234}
|
||||||
|
_, err := client.DialTCP("tcp", nil, raddr)
|
||||||
|
assert.Error(t, err)
|
||||||
|
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)))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer ln.Close()
|
||||||
|
acceptedConnChan := make(chan net.Conn)
|
||||||
|
go func() {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
acceptedConnChan <- conn
|
||||||
|
}()
|
||||||
|
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(remotePort)))
|
||||||
|
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 assertNoRemotePortForwarding(t *testing.T, client *ssh.Client) {
|
||||||
|
_, err := client.Listen("tcp", "127.0.0.1:5678")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "ssh: tcpip-forward request denied by peer", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertUnixRemotePortForwarding(t *testing.T, client *ssh.Client) {
|
||||||
|
remoteUnixSocket := path.Join(os.TempDir(), "test-unix-socket-"+uuid.New().String())
|
||||||
|
ln, err := client.ListenUnix(remoteUnixSocket)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer os.Remove(remoteUnixSocket)
|
||||||
|
defer ln.Close()
|
||||||
|
acceptedConnChan := make(chan net.Conn)
|
||||||
|
go func() {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
acceptedConnChan <- conn
|
||||||
|
}()
|
||||||
|
conn, err := net.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 assertNoUnixRemotePortForwarding(t *testing.T, client *ssh.Client) {
|
||||||
|
remoteUnixSocket := path.Join(os.TempDir(), "test-unix-socket-"+uuid.New().String())
|
||||||
|
_, err := client.ListenUnix(remoteUnixSocket)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "ssh: streamlocal-forward@openssh.com request denied by peer", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSftp(t *testing.T, client *ssh.Client) {
|
||||||
|
sftpClient, err := sftp.NewClient(client)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = sftpClient.Getwd()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoSftp(t *testing.T, client *ssh.Client) {
|
||||||
|
_, err := sftp.NewClient(client)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "ssh: subsystem request failed", err.Error())
|
||||||
|
}
|
23
go.mod
23
go.mod
|
@ -3,19 +3,26 @@ module github.com/nwtgck/handy-sshd
|
||||||
go 1.20
|
go 1.20
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/creack/pty v1.1.18
|
github.com/creack/pty v1.1.21
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/mattn/go-shellwords v1.0.12
|
github.com/mattn/go-shellwords v1.0.12
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/pkg/sftp v1.13.5
|
github.com/pkg/sftp v1.13.9
|
||||||
github.com/spf13/cobra v1.7.0
|
github.com/spf13/cobra v1.9.1
|
||||||
golang.org/x/crypto v0.12.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/exp v0.0.0-20230809094429-853ea248256d
|
golang.org/x/crypto v0.33.0
|
||||||
|
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/kr/fs v0.1.0 // indirect
|
github.com/kr/fs v0.1.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/stretchr/testify v1.8.4 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
golang.org/x/sys v0.11.0 // indirect
|
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
122
go.sum
122
go.sum
|
@ -1,46 +1,120 @@
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||||
|
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
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 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
||||||
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
|
||||||
github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
|
github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/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.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
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.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
golang.org/x/exp v0.0.0-20230809094429-853ea248256d h1:wu5bD43Ana/nF1ZmaLr3lW/FQeJU8CcI+Ln7yWHViXE=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/exp v0.0.0-20230809094429-853ea248256d/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
|
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4=
|
||||||
|
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
@ -6,7 +6,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := cmd.RootCmd.Execute(); err != nil {
|
if err := cmd.RootCmd().Execute(); err != nil {
|
||||||
os.Exit(-1)
|
os.Exit(-1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,9 +31,11 @@ func (s *Server) createPty(shell string, connection ssh.Channel) (*os.File, erro
|
||||||
Status: 0,
|
Status: 0,
|
||||||
}))
|
}))
|
||||||
connection.Close()
|
connection.Close()
|
||||||
_, err := sh.Process.Wait()
|
if sh.Process != nil {
|
||||||
if err != nil {
|
_, err := sh.Process.Wait()
|
||||||
s.Logger.Info("failed to exit shell", err)
|
if err != nil {
|
||||||
|
s.Logger.Info("failed to exit shell", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.Logger.Info("session closed")
|
s.Logger.Info("session closed")
|
||||||
}
|
}
|
||||||
|
|
199
server.go
199
server.go
|
@ -15,6 +15,7 @@ import (
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mattn/go-shellwords"
|
"github.com/mattn/go-shellwords"
|
||||||
|
"github.com/nwtgck/handy-sshd/sync_generics"
|
||||||
"github.com/pkg/sftp"
|
"github.com/pkg/sftp"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
"golang.org/x/exp/slog"
|
"golang.org/x/exp/slog"
|
||||||
|
@ -27,13 +28,16 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
bindAddressToListener sync_generics.Map[string, net.Listener]
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
AllowTcpipForward bool
|
AllowTcpipForward bool
|
||||||
AllowDirectTcpip 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.
|
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
|
AllowSftp bool
|
||||||
|
AllowStreamlocalForward bool
|
||||||
|
AllowDirectStreamlocal bool
|
||||||
|
|
||||||
// TODO: DNS server ?
|
// TODO: DNS server ?
|
||||||
}
|
}
|
||||||
|
@ -59,6 +63,12 @@ func (s *Server) handleChannel(shell string, newChannel ssh.NewChannel) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
s.handleDirectTcpip(newChannel)
|
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:
|
default:
|
||||||
newChannel.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", newChannel.ChannelType()))
|
newChannel.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", newChannel.ChannelType()))
|
||||||
}
|
}
|
||||||
|
@ -114,6 +124,8 @@ func (s *Server) handleSession(shell string, newChannel ssh.NewChannel) {
|
||||||
}
|
}
|
||||||
case "subsystem":
|
case "subsystem":
|
||||||
s.handleSessionSubSystem(req, connection)
|
s.handleSessionSubSystem(req, connection)
|
||||||
|
default:
|
||||||
|
s.Logger.Info("unsupported request", "req_type", req.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -197,7 +209,7 @@ func (s *Server) handleDirectTcpip(newChannel ssh.NewChannel) {
|
||||||
SourcePort uint32
|
SourcePort uint32
|
||||||
}
|
}
|
||||||
if err := ssh.Unmarshal(newChannel.ExtraData(), &msg); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
channel, reqs, err := newChannel.Accept()
|
channel, reqs, err := newChannel.Accept()
|
||||||
|
@ -227,6 +239,44 @@ func (s *Server) handleDirectTcpip(newChannel ssh.NewChannel) {
|
||||||
return
|
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.
|
// parseDims extracts terminal dimensions (width x height) from the provided buffer.
|
||||||
|
@ -266,7 +316,26 @@ func (s *Server) HandleGlobalRequests(sshConn *ssh.ServerConn, reqs <-chan *ssh.
|
||||||
req.Reply(false, nil)
|
req.Reply(false, nil)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
s.handleTcpipForward(sshConn, req)
|
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")
|
||||||
|
req.Reply(false, nil)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
s.handleStreamlocalForward(sshConn, req)
|
||||||
|
}()
|
||||||
|
case "cancel-streamlocal-forward@openssh.com":
|
||||||
|
go func() {
|
||||||
|
s.cancelStreamlocalForward(req)
|
||||||
|
}()
|
||||||
default:
|
default:
|
||||||
// discard
|
// discard
|
||||||
if req.WantReply {
|
if req.WantReply {
|
||||||
|
@ -287,11 +356,14 @@ func (s *Server) handleTcpipForward(sshConn *ssh.ServerConn, req *ssh.Request) {
|
||||||
req.Reply(false, nil)
|
req.Reply(false, nil)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
req.Reply(false, nil)
|
req.Reply(false, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.bindAddressToListener.Store(address, ln)
|
||||||
|
req.Reply(true, nil)
|
||||||
go func() {
|
go func() {
|
||||||
sshConn.Wait()
|
sshConn.Wait()
|
||||||
ln.Close()
|
ln.Close()
|
||||||
|
@ -300,6 +372,7 @@ func (s *Server) handleTcpipForward(sshConn *ssh.ServerConn, req *ssh.Request) {
|
||||||
for {
|
for {
|
||||||
conn, err := ln.Accept()
|
conn, err := ln.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.Logger.Info("failed to accept", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var replyMsg struct {
|
var replyMsg struct {
|
||||||
|
@ -310,6 +383,14 @@ func (s *Server) handleTcpipForward(sshConn *ssh.ServerConn, req *ssh.Request) {
|
||||||
}
|
}
|
||||||
replyMsg.Addr = msg.Addr
|
replyMsg.Addr = msg.Addr
|
||||||
replyMsg.Port = msg.Port
|
replyMsg.Port = msg.Port
|
||||||
|
originatorAddr, originatorPortStr, err := net.SplitHostPort(conn.RemoteAddr().String())
|
||||||
|
if err == nil {
|
||||||
|
originatorPort, _ := strconv.Atoi(originatorPortStr)
|
||||||
|
replyMsg.OriginatorAddr = originatorAddr
|
||||||
|
replyMsg.OriginatorPort = uint32(originatorPort)
|
||||||
|
} else {
|
||||||
|
s.Logger.Error("failed to split remote address", "remote_address", conn.RemoteAddr())
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
channel, reqs, err := sshConn.OpenChannel("forwarded-tcpip", ssh.Marshal(&replyMsg))
|
channel, reqs, err := sshConn.OpenChannel("forwarded-tcpip", ssh.Marshal(&replyMsg))
|
||||||
|
@ -332,3 +413,105 @@ 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
|
||||||
|
var msg struct {
|
||||||
|
SocketPath string
|
||||||
|
}
|
||||||
|
if err := ssh.Unmarshal(req.Payload, &msg); err != nil {
|
||||||
|
req.Reply(false, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("unix", msg.SocketPath)
|
||||||
|
if err != nil {
|
||||||
|
req.Reply(false, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.bindAddressToListener.Store(msg.SocketPath, ln)
|
||||||
|
req.Reply(true, nil)
|
||||||
|
go func() {
|
||||||
|
sshConn.Wait()
|
||||||
|
ln.Close()
|
||||||
|
s.Logger.Info("connection closed", "address", ln.Addr().String())
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
s.Logger.Info("failed to accept", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// https://github.com/openssh/openssh-portable/blob/f9f18006678d2eac8b0c5a5dddf17ab7c50d1e9f/PROTOCOL#L255
|
||||||
|
var replyMsg struct {
|
||||||
|
SocketPath string
|
||||||
|
Reserved string
|
||||||
|
}
|
||||||
|
replyMsg.SocketPath = msg.SocketPath
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
channel, reqs, err := sshConn.OpenChannel("forwarded-streamlocal@openssh.com", ssh.Marshal(&replyMsg))
|
||||||
|
if err != nil {
|
||||||
|
req.Reply(false, nil)
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go ssh.DiscardRequests(reqs)
|
||||||
|
go func() {
|
||||||
|
io.Copy(channel, conn)
|
||||||
|
conn.Close()
|
||||||
|
channel.Close()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
io.Copy(conn, channel)
|
||||||
|
conn.Close()
|
||||||
|
channel.Close()
|
||||||
|
}()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
62
sync_generics/map.go
Normal file
62
sync_generics/map.go
Normal file
|
@ -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)
|
||||||
|
}
|
|
@ -1,3 +1,3 @@
|
||||||
package version
|
package version
|
||||||
|
|
||||||
const Version = "0.2.0"
|
const Version = "0.4.3"
|
||||||
|
|
Loading…
Add table
Reference in a new issue