From 101f99e80e2add4de8151a27890564d6bd0425ce Mon Sep 17 00:00:00 2001 From: Ryo Ota Date: Wed, 9 Aug 2023 08:01:01 +0900 Subject: [PATCH] implement basic handy-sshd --- .github/dependabot.yml | 12 ++ .github/workflows/ci.yml | 31 ++++ .github/workflows/release.yml | 25 +++ .goreleaser.yml | 49 ++++++ LICENSE | 21 +++ README.md | 2 + cmd/key.go | 28 ++++ cmd/root.go | 117 +++++++++++++ go.mod | 21 +++ go.sum | 46 ++++++ main/main.go | 14 ++ pty_related_unix.go | 66 ++++++++ pty_related_unsupported.go | 19 +++ server.go | 300 ++++++++++++++++++++++++++++++++++ version/version.go | 3 + 15 files changed, 754 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/key.go create mode 100644 cmd/root.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main/main.go create mode 100644 pty_related_unix.go create mode 100644 pty_related_unsupported.go create mode 100644 server.go create mode 100644 version/version.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..aa5064f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: gomod + directory: "/" + schedule: + interval: daily + timezone: Asia/Tokyo + open-pull-requests-limit: 99 + reviewers: + - nwtgck + assignees: + - nwtgck diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8632df3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: [push] + +jobs: + build_multi_platform: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - name: Set up Go 1.x + uses: actions/setup-go@v2 + with: + go-version: "1.20" + - name: Build for multi-platform + run: | + set -xeu + DIST=dist + mkdir $DIST + # (from: https://www.digitalocean.com/community/tutorials/how-to-build-go-executables-for-multiple-platforms-on-ubuntu-16-04) + platforms=("linux/amd64" "darwin/amd64" "linux/arm" "windows/amd64") + for platform in "${platforms[@]}" + do + platform_split=(${platform//\// }) + export GOOS=${platform_split[0]} + export GOARCH=${platform_split[1]} + [ $GOOS = "windows" ] && EXTENSION='.exe' || EXTENSION='' + BUILD_PATH=handy-sshd-$GOOS-$GOARCH + mkdir $BUILD_PATH + # Build + CGO_ENABLED=0 go build -o "${BUILD_PATH}/handy-sshd${EXTENSION}" main/main.go + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..86c3c28 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: goreleaser + +on: + push: + tags: + - '*' + +jobs: + goreleaser: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: "1.20" + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + version: v1.3.1 + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GH_PAT }} diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..ba07201 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,49 @@ +project_name: handy-sshd +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + - freebsd + goarch: + - amd64 + - arm + - arm64 + - 386 + - ppc64le + - s390x + - mips64 + - mips64le + goarm: + - 6 + - 7 + main: ./main/main.go +archives: + - name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}-{{ .Mips }}{{ end }}' + format_overrides: + - goos: windows + format: zip +nfpms: + - license: MIT + maintainer: Ryo Ota + homepage: https://github.com/nwtgck/handy-sshd + description: "Portable SSH server" + formats: + - rpm + - deb + file_name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}-{{ .Mips }}{{ end }}' +checksum: + name_template: 'checksums.txt' +release: + github: + disable: false + prerelease: auto + name_template: "v{{.Version}}" +brews: + - tap: + owner: nwtgck + name: homebrew-handy-sshd + homepage: "https://github.com/nwtgck/handy-sshd" + description: "Portable SSH server" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb6c026 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Ryo Ota + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dda0cf --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# handy-sshd +Portable SSH Server diff --git a/cmd/key.go b/cmd/key.go new file mode 100644 index 0000000..92de7a6 --- /dev/null +++ b/cmd/key.go @@ -0,0 +1,28 @@ +package cmd + +const defaultHostKeyPem = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0Oycb9tjo2DAmQgTN9MxJFUxwy7rLb6cVth/Y4ex0qVxHt3j1ZADV7qG +bFyURR7p61UNxjx8UXppuW4Q8gwu2AEwLYhIt+jU0+dz9tq+AP9C8KPHO0Eb1pV/qLG0lRoQ +fP2CqAoo7CpvjiCRq/scekbDETbKpETXC+lpsnASXMwVZpeidty7MaUHi2PxGLS5XYSDIvA0 +VzsAP28Stvz+3GYcu/NN7LsF5VlS8/nDOTiVrNAlyN96hSnG4kA0rx3O4VxJDiKJLAvtNFaS +u0tIUFAXXKFEuAtwtSOwwfHl+ks/Y9XbOxrwvxcnfh02DikVBWCTkczxzK6VevDa9aC+SwID +AQABAoIBAAbLyOjwk4SogeCt0mY3UKVpv1/QgmZEtDQ1AFZ9/wBRpmTb0JdNWHZ9mErPwM5L +dk4pQEn0WhI9GlGlrW0aOASJwVtHJXUUtfubT0/hMgYOnPWpS4f3aGQ/Yl2GmPpdVIT5qsre +4xGPmH0LPSyBfh3SIx1HvClsyIFYMI/VNRu0mWUnB8yR1C2A2KRd0CpLabHpXEeYu8Ylqu30 +SozROMlzd00hLuYiuNK9X/y+9kPujrGkXZxOp9pHFXJxnTUpyxNK4BnNTKsoK/v3OwlFPzaW +ZST88m2pZgV4QdsatSfWbuMT+83ooiwy7KDIrINPEXQHc6P6WxTcdAfLxLoo5oUCgYEA89I/ +5PAfaSGn5375pgQFC78EJ8zxjUxW2fvPBCAQQ9pVWaH8DEfDvJgdQSSQXHbTR/KHaOkopKTS +qrvpAAKrArYFOdfdpxTVSOrbLqKx8z5SrBOAK6hp/snb9u/eYYpTg9diu8RAVTgD6PrYi7pz +zWpHL2JLmJS5H5JfYBxEigcCgYEA21wh1Ae3AaVkFIPN9aXWxFYQVcbz3EY4TKgRwaR2LLUP +nGw5vV+Fyp4B9iLgYjqZ/E52DzDmOsZ4Pw0ON1y7dcIcq9IhcRixkjzjKepAnKP1pVILaaUJ +VgkHFNI6jbftentF7NySqsxpi66aOtnFzMSPSzyI7TyT8ez+2qABKJ0CgYEAv+Ge1xUCI0KR +WOXcooJXVj8ljg0DrCd/0l0RNjXllwCkWr3YFfIEYM91dmbIFXyOGfkMB8w2aBwujp8DZzay +Tpfg1PzFO1Bx6ciqZbE0SjGp7jIKlFEd2Z4StetgH3M09nTzBsITvv0uVpPTB2Pc7rPNAcVh +qNqiNe6DkKeuaNECgYBkpq6i8nNHXxM/0oaTe2fDKNZP9Xz5ioLUsZ2MI6FRvDaQiJwpx4XF +RaESxkf86nSzb3D+YWqSd3S/QYdPYc5mJw4uzLkpgrIfrq5xEhpZhWX2WGICNIbHIldMd3YE +huuBcsTP/RmTIz4eqJv9+uSmo144oGsXp98ed6csu5QshQKBgQCIkrtpoPsFX113PtoFSb95 +5U5/1kIbkk1h48Gg4+OuxInok9sI/DfAS+scRaleAIyNTC9MFjui3gJ3c2V33iegfYlz12eR +9nwj61ntxU44Rlhh6KF15UOd7ByEI2MBY0uwqfvDzwcBQAQ5IiLy4FtXt8Cbs8ElAew3uiAy +G4TBwg== +-----END RSA PRIVATE KEY----- +` diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..18c8db3 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "fmt" + "github.com/nwtgck/handy-sshd" + "github.com/nwtgck/handy-sshd/version" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + "golang.org/x/exp/slog" + "net" + "os" + "strconv" + "strings" +) + +var flag struct { + //dnsServer string + showsVersion bool + sshHost string + sshPort uint16 + sshShell string + sshUsers []string +} + +type sshUser struct { + name string + password string +} + +func init() { + 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") + 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")`) +} + +var RootCmd = &cobra.Command{ + Use: os.Args[0], + Short: "handy-sshd", + Long: "Portable SSH server", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flag.showsVersion { + fmt.Println(version.Version) + return nil + } + logger := slog.Default() + sshServer := &handy_sshd.Server{ + Logger: logger, + } + 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) + } + sshUsers = append(sshUsers, sshUser{name: splits[0], password: splits[1]}) + } + + // (base: https://gist.github.com/jpillora/b480fde82bff51a06238) + sshConfig := &ssh.ServerConfig{ + //Define a function to run when a client attempts a password login + PasswordCallback: func(metadata ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + for _, user := range sshUsers { + // No auth required + if user.name == metadata.User() && user.password == string(pass) { + return nil, nil + } + } + return nil, fmt.Errorf("password rejected for %q", metadata.User()) + }, + NoClientAuth: true, + NoClientAuthCallback: func(metadata ssh.ConnMetadata) (*ssh.Permissions, error) { + for _, user := range sshUsers { + // No auth required + if user.name == metadata.User() && user.password == "" { + return nil, nil + } + } + return nil, fmt.Errorf("%s auth required", metadata.User()) + }, + } + // TODO: specify priv_key by flags + pri, err := ssh.ParsePrivateKey([]byte(defaultHostKeyPem)) + if err != nil { + return err + } + sshConfig.AddHostKey(pri) + + // TODO: unix socket support + address := net.JoinHostPort(flag.sshHost, strconv.Itoa(int(flag.sshPort))) + ln, err := net.Listen("tcp", address) + if err != nil { + return err + } + logger.Info(fmt.Sprintf("listening on %s...", address)) + for { + conn, err := ln.Accept() + if err != nil { + logger.Error("failed to accept TCP connection", "err", err) + 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", "client_version", string(sshConn.ClientVersion())) + go sshServer.HandleGlobalRequests(sshConn, reqs) + go sshServer.HandleChannels(flag.sshShell, chans) + } + }, +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b005b36 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/nwtgck/handy-sshd + +go 1.20 + +require ( + github.com/creack/pty v1.1.18 + github.com/mattn/go-shellwords v1.0.12 + github.com/pkg/errors v0.9.1 + github.com/pkg/sftp v1.13.5 + github.com/spf13/cobra v1.7.0 + golang.org/x/crypto v0.12.0 + golang.org/x/exp v0.0.0-20230807204917-050eac23e9de +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.8.4 // indirect + golang.org/x/sys v0.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7fee637 --- /dev/null +++ b/go.sum @@ -0,0 +1,46 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +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/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/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= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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-20230807204917-050eac23e9de h1:l5Za6utMv/HsBWWqzt4S8X17j+kt1uVETUX5UFhn2rE= +golang.org/x/exp v0.0.0-20230807204917-050eac23e9de/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= +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.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/main/main.go b/main/main.go new file mode 100644 index 0000000..55e5f6c --- /dev/null +++ b/main/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "fmt" + "github.com/nwtgck/handy-sshd/cmd" + "os" +) + +func main() { + if err := cmd.RootCmd.Execute(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(-1) + } +} diff --git a/pty_related_unix.go b/pty_related_unix.go new file mode 100644 index 0000000..3f9f6ca --- /dev/null +++ b/pty_related_unix.go @@ -0,0 +1,66 @@ +//go:build !windows +// +build !windows + +// NOTE: pty.Start() is not supported in Windows + +package handy_sshd + +import ( + "github.com/creack/pty" + "github.com/pkg/errors" + "golang.org/x/crypto/ssh" + "io" + "os" + "os/exec" + "sync" +) + +func (s *Server) createPty(shell string, connection ssh.Channel) (*os.File, error) { + if shell == "" { + shell = os.Getenv("SHELL") + } + if shell == "" { + shell = "sh" + } + // Fire up bash for this session + sh := exec.Command(shell) + + // Prepare teardown function + closer := func() { + connection.SendRequest("exit-status", false, ssh.Marshal(exitStatusMsg{ + Status: 0, + })) + connection.Close() + _, err := sh.Process.Wait() + if err != nil { + s.Logger.Info("failed to exit shell", err) + } + s.Logger.Info("session closed") + } + + // Allocate a terminal for this channel + s.Logger.Info("creating pty...") + shf, err := pty.Start(sh) + if err != nil { + s.Logger.Info("failed to start pty", "err", err) + closer() + return nil, errors.Errorf("could not start pty (%s)", err) + } + + // pipe session to bash and visa-versa + var once sync.Once + go func() { + io.Copy(connection, shf) + once.Do(closer) + }() + go func() { + io.Copy(shf, connection) + once.Do(closer) + }() + return shf, nil +} + +// setWinsize sets the size of the given pty. +func setWinsize(t *os.File, w, h uint32) error { + return pty.Setsize(t, &pty.Winsize{Rows: uint16(h), Cols: uint16(w)}) +} diff --git a/pty_related_unsupported.go b/pty_related_unsupported.go new file mode 100644 index 0000000..f0c6ee3 --- /dev/null +++ b/pty_related_unsupported.go @@ -0,0 +1,19 @@ +//go:build windows +// +build windows + +package handy_sshd + +import ( + "fmt" + "golang.org/x/crypto/ssh" + "os" +) + +func (s *Server) createPty(shell string, connection ssh.Channel) (*os.File, error) { + return nil, fmt.Errorf("creation of pty unsupported") +} + +// setWinsize sets the size of the given pty. +func setWinsize(t *os.File, w, h uint32) error { + return fmt.Errorf("set-win-size unsupported") +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..1ede795 --- /dev/null +++ b/server.go @@ -0,0 +1,300 @@ +// Copyright (c) 2021 Ryo Ota +// Released under the MIT License + +// Copyright (c) 2020 Jaime Pillora +// Released under the MIT License +// https://github.com/jpillora/sshd-lite/tree/master#mit-license + +package handy_sshd + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/binary" + "encoding/pem" + "fmt" + "github.com/mattn/go-shellwords" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" + "golang.org/x/exp/slog" + "io" + "net" + "os" + "os/exec" + "strconv" + "sync" +) + +type Server struct { + Logger *slog.Logger + // TODO: DNS server ? +} + +type exitStatusMsg struct { + Status uint32 +} + +func (s *Server) HandleChannels(shell string, chans <-chan ssh.NewChannel) { + // Service the incoming Channel channel in go routine + for newChannel := range chans { + go s.handleChannel(shell, newChannel) + } +} + +func (s *Server) handleChannel(shell string, newChannel ssh.NewChannel) { + switch newChannel.ChannelType() { + case "session": + s.handleSession(shell, newChannel) + case "direct-tcpip": + s.handleDirectTcpip(newChannel) + default: + newChannel.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", newChannel.ChannelType())) + } +} + +func (s *Server) handleSession(shell string, newChannel ssh.NewChannel) { + // At this point, we have the opportunity to reject the client's + // request for another logical connection + connection, requests, err := newChannel.Accept() + if err != nil { + s.Logger.Info("Could not accept channel", "err", err) + return + } + + var shf *os.File = nil + + for req := range requests { + switch req.Type { + case "exec": + s.handleExecRequest(req, connection) + case "shell": + // We only accept the default shell + // (i.e. no command in the Payload) + if len(req.Payload) == 0 { + req.Reply(true, nil) + } + case "pty-req": + termLen := req.Payload[3] + w, h := parseDims(req.Payload[termLen+4:]) + shf, err = s.createPty(shell, connection) + if err != nil { + req.Reply(false, nil) + return + } + setWinsize(shf, w, h) + // Responding true (OK) here will let the client + // know we have a pty ready for input + req.Reply(true, nil) + case "window-change": + w, h := parseDims(req.Payload) + if shf != nil { + setWinsize(shf, w, h) + } + case "subsystem": + s.handleSessionSubSystem(req, connection) + } + } +} + +func (s *Server) handleExecRequest(req *ssh.Request, connection ssh.Channel) { + var msg struct { + Command string + } + if err := ssh.Unmarshal(req.Payload, &msg); err != nil { + s.Logger.Info("failed to parse message in exec", "err", err) + return + } + cmdSlice, err := shellwords.Parse(msg.Command) + if err != nil { + return + } + cmd := exec.Command(cmdSlice[0], cmdSlice[1:]...) + stdin, err := cmd.StdinPipe() + if err != nil { + return + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return + } + stderr, err := cmd.StderrPipe() + if err != nil { + return + } + go io.Copy(stdin, connection) + go io.Copy(connection, stdout) + go io.Copy(connection, stderr) + req.Reply(true, nil) + var exitCode int + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + } + connection.SendRequest("exit-status", false, ssh.Marshal(exitStatusMsg{ + Status: uint32(exitCode), + })) + connection.Close() +} + +func (s *Server) handleSessionSubSystem(req *ssh.Request, connection ssh.Channel) { + // https://github.com/pkg/sftp/blob/42e9800606febe03f9cdf1d1283719af4a5e6456/examples/go-sftp-server/main.go#L111 + ok := string(req.Payload[4:]) == "sftp" + req.Reply(ok, nil) + + serverOptions := []sftp.ServerOption{ + sftp.WithDebug(os.Stderr), + } + sftpServer, err := sftp.NewServer(connection, serverOptions...) + if err != nil { + s.Logger.Info("failed to create sftp server", "err", err) + return + } + if err := sftpServer.Serve(); err == io.EOF { + sftpServer.Close() + } else if err != nil { + s.Logger.Info("failed to serve sftp server", "err", err) + return + } +} + +// (base: https://github.com/peertechde/zodiac/blob/110fdd2dfd27359546c1cd75a9fec5de2882bf42/pkg/server/server.go#L228) +func (s *Server) handleDirectTcpip(newChannel ssh.NewChannel) { + var msg struct { + RemoteAddr string + RemotePort uint32 + SourceAddr string + SourcePort uint32 + } + if err := ssh.Unmarshal(newChannel.ExtraData(), &msg); err != nil { + s.Logger.Info("failed to parse 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) + raddr := net.JoinHostPort(msg.RemoteAddr, strconv.Itoa(int(msg.RemotePort))) + conn, err := net.Dial("tcp", raddr) + 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. +func parseDims(b []byte) (uint32, uint32) { + w := binary.BigEndian.Uint32(b) + h := binary.BigEndian.Uint32(b[4:]) + return w, h +} + +// ====================== + +func GenerateKey() ([]byte, error) { + var r io.Reader + r = rand.Reader + priv, err := rsa.GenerateKey(r, 2048) + if err != nil { + return nil, err + } + err = priv.Validate() + if err != nil { + return nil, err + } + b := x509.MarshalPKCS1PrivateKey(priv) + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: b}), nil +} + +// Borrowed from https://github.com/creack/termios/blob/master/win/win.go + +// ====================================================================== + +func (s *Server) HandleGlobalRequests(sshConn *ssh.ServerConn, reqs <-chan *ssh.Request) { + for req := range reqs { + switch req.Type { + case "tcpip-forward": + s.handleTcpipForward(sshConn, req) + default: + // discard + if req.WantReply { + req.Reply(false, nil) + } + s.Logger.Info("request discarded", "request_type", req.Type) + } + } +} + +// https://datatracker.ietf.org/doc/html/rfc4254#section-7.1 +func (s *Server) handleTcpipForward(sshConn *ssh.ServerConn, req *ssh.Request) { + var msg struct { + Addr string + Port uint32 + } + if err := ssh.Unmarshal(req.Payload, &msg); err != nil { + req.Reply(false, nil) + return + } + ln, err := net.Listen("tcp", net.JoinHostPort(msg.Addr, strconv.Itoa(int(msg.Port)))) + if err != nil { + req.Reply(false, nil) + return + } + go func() { + sshConn.Wait() + ln.Close() + s.Logger.Info("connection closed", "address", ln.Addr().String()) + }() + for { + conn, err := ln.Accept() + if err != nil { + return + } + var replyMsg struct { + Addr string + Port uint32 + OriginatorAddr string + OriginatorPort uint32 + } + replyMsg.Addr = msg.Addr + replyMsg.Port = msg.Port + + go func() { + channel, reqs, err := sshConn.OpenChannel("forwarded-tcpip", 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() + }() + }() + } +} diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..4b651ca --- /dev/null +++ b/version/version.go @@ -0,0 +1,3 @@ +package version + +const Version = "0.1.0-SNAPSHOT"