mirror of
https://github.com/nwtgck/handy-sshd.git
synced 2025-06-07 14:43:05 +00:00
implement basic handy-sshd
This commit is contained in:
parent
fbdc66d85a
commit
101f99e80e
15 changed files with 754 additions and 0 deletions
12
.github/dependabot.yml
vendored
Normal file
12
.github/dependabot.yml
vendored
Normal 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
31
.github/workflows/ci.yml
vendored
Normal 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
25
.github/workflows/release.yml
vendored
Normal 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
49
.goreleaser.yml
Normal 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
21
LICENSE
Normal 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
2
README.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
# handy-sshd
|
||||
Portable SSH Server
|
28
cmd/key.go
Normal file
28
cmd/key.go
Normal 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
117
cmd/root.go
Normal 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
21
go.mod
Normal 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
46
go.sum
Normal 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
14
main/main.go
Normal 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
66
pty_related_unix.go
Normal 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)})
|
||||
}
|
19
pty_related_unsupported.go
Normal file
19
pty_related_unsupported.go
Normal 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
300
server.go
Normal 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
3
version/version.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package version
|
||||
|
||||
const Version = "0.1.0-SNAPSHOT"
|
Loading…
Add table
Reference in a new issue