implement basic handy-sshd

This commit is contained in:
Ryo Ota 2023-08-09 08:01:01 +09:00
parent fbdc66d85a
commit 101f99e80e
15 changed files with 754 additions and 0 deletions

12
.github/dependabot.yml vendored Normal file
View file

@ -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

31
.github/workflows/ci.yml vendored Normal file
View file

@ -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

25
.github/workflows/release.yml vendored Normal file
View file

@ -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 }}

49
.goreleaser.yml Normal file
View file

@ -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 <nwtgck@nwtgck.org>
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"

21
LICENSE Normal file
View file

@ -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.

2
README.md Normal file
View file

@ -0,0 +1,2 @@
# handy-sshd
Portable SSH Server

28
cmd/key.go Normal file
View file

@ -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-----
`

117
cmd/root.go Normal file
View file

@ -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)
}
},
}

21
go.mod Normal file
View file

@ -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
)

46
go.sum Normal file
View file

@ -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=

14
main/main.go Normal file
View file

@ -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)
}
}

66
pty_related_unix.go Normal file
View file

@ -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)})
}

View file

@ -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")
}

300
server.go Normal file
View file

@ -0,0 +1,300 @@
// Copyright (c) 2021 Ryo Ota
// Released under the MIT License
// Copyright (c) 2020 Jaime Pillora <dev@jpillora.com>
// 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()
}()
}()
}
}

3
version/version.go Normal file
View file

@ -0,0 +1,3 @@
package version
const Version = "0.1.0-SNAPSHOT"