mirror of
https://github.com/moul/sshportal.git
synced 2025-03-09 22:06:34 +08:00
Add telnet support
This commit is contained in:
parent
a9f86d1d01
commit
3ebcdd9c3d
5 changed files with 178 additions and 55 deletions
|
@ -3,6 +3,7 @@
|
|||
## master (unreleased)
|
||||
|
||||
* The default created user now has the same username as the user starting sshportal (was hardcoded "admin")
|
||||
* Add Telnet support
|
||||
|
||||
## v1.7.1 (2018-01-03)
|
||||
|
||||
|
|
21
README.md
21
README.md
|
@ -39,15 +39,18 @@ Jump host/Jump server without the jump, a.k.a Transparent SSH bastion
|
|||
* Audit log (logging every user action)
|
||||
* Host Keys verifications shared across users
|
||||
* Healthcheck user (replying OK to any user)
|
||||
* ipv4 and ipv6 support
|
||||
* [`scp`](https://linux.die.net/man/1/scp) support
|
||||
* [`rsync`](https://linux.die.net/man/1/rsync) support
|
||||
* [tunneling](https://www.ssh.com/ssh/tunneling/example) (local forward, remote forward, dynamic forward) support
|
||||
* [`sftp`](https://www.ssh.com/ssh/sftp/) support
|
||||
* [`ssh-agent`](https://www.ssh.com/ssh/agent) support
|
||||
* [`X11 forwarding`](http://en.tldp.org/HOWTO/XDMCP-HOWTO/ssh.html) support
|
||||
* Git support (can be used to easily use multiple user keys on GitHub, or access your own firewalled gitlab server)
|
||||
* Do not require any SSH client modification or custom `.ssh/config`, works with every tested SSH programming libraries and every tested SSH
|
||||
* SSH compatibility
|
||||
* ipv4 and ipv6 support
|
||||
* [`scp`](https://linux.die.net/man/1/scp) support
|
||||
* [`rsync`](https://linux.die.net/man/1/rsync) support
|
||||
* [tunneling](https://www.ssh.com/ssh/tunneling/example) (local forward, remote forward, dynamic forward) support
|
||||
* [`sftp`](https://www.ssh.com/ssh/sftp/) support
|
||||
* [`ssh-agent`](https://www.ssh.com/ssh/agent) support
|
||||
* [`X11 forwarding`](http://en.tldp.org/HOWTO/XDMCP-HOWTO/ssh.html) support
|
||||
* Git support (can be used to easily use multiple user keys on GitHub, or access your own firewalled gitlab server)
|
||||
* Do not require any SSH client modification or custom `.ssh/config`, works with every tested SSH programming libraries and every tested SSH clients
|
||||
* SSH to non-SSH proxy
|
||||
* [Telnet](https://www.ssh.com/ssh/telnet) support
|
||||
|
||||
## (Known) limitations
|
||||
|
||||
|
|
20
db.go
20
db.go
|
@ -158,6 +158,13 @@ const (
|
|||
ACLActionDeny = "deny"
|
||||
)
|
||||
|
||||
type BastionScheme string
|
||||
|
||||
const (
|
||||
BastionSchemeSSH BastionScheme = "ssh"
|
||||
BastionSchemeTelnet = "telnet"
|
||||
)
|
||||
|
||||
func init() {
|
||||
unixUserRegexp := regexp.MustCompile("[a-z_][a-z0-9_-]*")
|
||||
|
||||
|
@ -171,6 +178,7 @@ func init() {
|
|||
}
|
||||
|
||||
// Host helpers
|
||||
|
||||
func ParseInputURL(input string) (*url.URL, error) {
|
||||
if !strings.Contains(input, "://") {
|
||||
input = "ssh://" + input
|
||||
|
@ -195,15 +203,15 @@ func (host *Host) String() string {
|
|||
}
|
||||
return ""
|
||||
}
|
||||
func (host *Host) Scheme() string {
|
||||
func (host *Host) Scheme() BastionScheme {
|
||||
if host.URL != "" {
|
||||
u, err := url.Parse(host.URL)
|
||||
if err != nil {
|
||||
return "ssh"
|
||||
return BastionSchemeSSH
|
||||
}
|
||||
return u.Scheme
|
||||
return BastionScheme(u.Scheme)
|
||||
} else if host.Addr != "" {
|
||||
return "ssh"
|
||||
return BastionSchemeSSH
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
@ -268,9 +276,9 @@ func (host *Host) Port() uint64 {
|
|||
}
|
||||
defaultPort:
|
||||
switch host.Scheme() {
|
||||
case "ssh":
|
||||
case BastionSchemeSSH:
|
||||
return 22
|
||||
case "telnet":
|
||||
case BastionSchemeTelnet:
|
||||
return 23
|
||||
default:
|
||||
return 0
|
||||
|
|
104
ssh.go
104
ssh.go
|
@ -88,7 +88,7 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
|
|||
switch newChan.ChannelType() {
|
||||
case "session":
|
||||
default:
|
||||
// TODO: handle direct-tcp
|
||||
// TODO: handle direct-tcp (only for ssh scheme)
|
||||
if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil {
|
||||
log.Printf("error: failed to reject channel: %v", err)
|
||||
}
|
||||
|
@ -100,7 +100,7 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
|
|||
switch actx.userType() {
|
||||
case UserTypeBastion:
|
||||
log.Printf("New connection(bastion): sshUser=%q remote=%q local=%q dbUser=id:%q,email:%s", conn.User(), conn.RemoteAddr(), conn.LocalAddr(), actx.user.ID, actx.user.Email)
|
||||
host, clientConfig, err := bastionConfig(ctx)
|
||||
host, err := HostByName(actx.db, actx.inputUsername)
|
||||
if err != nil {
|
||||
ch, _, err2 := newChan.Accept()
|
||||
if err2 != nil {
|
||||
|
@ -112,66 +112,90 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
|
|||
return
|
||||
}
|
||||
|
||||
sess := Session{
|
||||
UserID: actx.user.ID,
|
||||
HostID: host.ID,
|
||||
Status: SessionStatusActive,
|
||||
}
|
||||
if err = actx.db.Create(&sess).Error; err != nil {
|
||||
switch host.Scheme() {
|
||||
case BastionSchemeSSH:
|
||||
clientConfig, err := bastionClientConfig(ctx, host)
|
||||
if err != nil {
|
||||
ch, _, err2 := newChan.Accept()
|
||||
if err2 != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ch, "error: %v\n", err)
|
||||
// FIXME: force close all channels
|
||||
_ = ch.Close()
|
||||
return
|
||||
}
|
||||
|
||||
sess := Session{
|
||||
UserID: actx.user.ID,
|
||||
HostID: host.ID,
|
||||
Status: SessionStatusActive,
|
||||
}
|
||||
if err = actx.db.Create(&sess).Error; err != nil {
|
||||
ch, _, err2 := newChan.Accept()
|
||||
if err2 != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ch, "error: %v\n", err)
|
||||
_ = ch.Close()
|
||||
return
|
||||
}
|
||||
|
||||
err = bastionsession.ChannelHandler(srv, conn, newChan, ctx, bastionsession.Config{
|
||||
Addr: host.DialAddr(),
|
||||
ClientConfig: clientConfig,
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
sessUpdate := Session{
|
||||
Status: SessionStatusClosed,
|
||||
ErrMsg: fmt.Sprintf("%v", err),
|
||||
StoppedAt: &now,
|
||||
}
|
||||
switch sessUpdate.ErrMsg {
|
||||
case "lch closed the connection", "rch closed the connection":
|
||||
sessUpdate.ErrMsg = ""
|
||||
}
|
||||
actx.db.Model(&sess).Updates(&sessUpdate)
|
||||
case BastionSchemeTelnet:
|
||||
tmpSrv := ssh.Server{
|
||||
// PtyCallback: srv.PtyCallback,
|
||||
Handler: telnetHandler(host),
|
||||
}
|
||||
ssh.DefaultChannelHandler(&tmpSrv, conn, newChan, ctx)
|
||||
default:
|
||||
ch, _, err2 := newChan.Accept()
|
||||
if err2 != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ch, "error: %v\n", err)
|
||||
fmt.Fprintf(ch, "error: unknown bastion scheme: %q\n", host.Scheme())
|
||||
// FIXME: force close all channels
|
||||
_ = ch.Close()
|
||||
return
|
||||
}
|
||||
|
||||
err = bastionsession.ChannelHandler(srv, conn, newChan, ctx, bastionsession.Config{
|
||||
Addr: host.DialAddr(),
|
||||
ClientConfig: clientConfig,
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
sessUpdate := Session{
|
||||
Status: SessionStatusClosed,
|
||||
ErrMsg: fmt.Sprintf("%v", err),
|
||||
StoppedAt: &now,
|
||||
}
|
||||
switch sessUpdate.ErrMsg {
|
||||
case "lch closed the connection", "rch closed the connection":
|
||||
sessUpdate.ErrMsg = ""
|
||||
}
|
||||
actx.db.Model(&sess).Updates(&sessUpdate)
|
||||
default: // shell
|
||||
ssh.DefaultChannelHandler(srv, conn, newChan, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func bastionConfig(ctx ssh.Context) (*Host, *gossh.ClientConfig, error) {
|
||||
func bastionClientConfig(ctx ssh.Context, host *Host) (*gossh.ClientConfig, error) {
|
||||
actx := ctx.Value(authContextKey).(*authContext)
|
||||
|
||||
host, err := HostByName(actx.db, actx.inputUsername)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
clientConfig, err := host.clientConfig(dynamicHostKey(actx.db, host))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tmpUser User
|
||||
if err = actx.db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", actx.user.ID).First(&tmpUser).Error; err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
var tmpHost Host
|
||||
if err = actx.db.Preload("Groups").Preload("Groups.ACLs").Where("id = ?", host.ID).First(&tmpHost).Error; err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
action, err2 := CheckACLs(tmpUser, tmpHost)
|
||||
if err2 != nil {
|
||||
return nil, nil, err2
|
||||
return nil, err2
|
||||
}
|
||||
|
||||
HostDecrypt(actx.globalContext.String("aes-key"), host)
|
||||
|
@ -180,11 +204,11 @@ func bastionConfig(ctx ssh.Context) (*Host, *gossh.ClientConfig, error) {
|
|||
switch action {
|
||||
case ACLActionAllow:
|
||||
case ACLActionDeny:
|
||||
return nil, nil, fmt.Errorf("you don't have permission to that host")
|
||||
return nil, fmt.Errorf("you don't have permission to that host")
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("invalid ACL action: %q", action)
|
||||
return nil, fmt.Errorf("invalid ACL action: %q", action)
|
||||
}
|
||||
return host, clientConfig, nil
|
||||
return clientConfig, nil
|
||||
}
|
||||
|
||||
func shellHandler(s ssh.Session) {
|
||||
|
|
87
telnet.go
Normal file
87
telnet.go
Normal file
|
@ -0,0 +1,87 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
oi "github.com/reiver/go-oi"
|
||||
telnet "github.com/reiver/go-telnet"
|
||||
)
|
||||
|
||||
type bastionTelnetCaller struct {
|
||||
ssh ssh.Session
|
||||
}
|
||||
|
||||
func (caller bastionTelnetCaller) CallTELNET(ctx telnet.Context, w telnet.Writer, r telnet.Reader) {
|
||||
go func(writer io.Writer, reader io.Reader) {
|
||||
var buffer [1]byte // Seems like the length of the buffer needs to be small, otherwise will have to wait for buffer to fill up.
|
||||
p := buffer[:]
|
||||
|
||||
for {
|
||||
// Read 1 byte.
|
||||
n, err := reader.Read(p)
|
||||
if n <= 0 && nil == err {
|
||||
continue
|
||||
} else if n <= 0 && nil != err {
|
||||
break
|
||||
}
|
||||
|
||||
if _, err = oi.LongWrite(writer, p); err != nil {
|
||||
log.Printf("telnet longwrite failed: %v", err)
|
||||
}
|
||||
}
|
||||
}(caller.ssh, r)
|
||||
|
||||
var buffer bytes.Buffer
|
||||
var p []byte
|
||||
|
||||
var crlfBuffer = [2]byte{'\r', '\n'}
|
||||
crlf := crlfBuffer[:]
|
||||
|
||||
scanner := bufio.NewScanner(caller.ssh)
|
||||
scanner.Split(scannerSplitFunc)
|
||||
|
||||
for scanner.Scan() {
|
||||
buffer.Write(scanner.Bytes())
|
||||
buffer.Write(crlf)
|
||||
|
||||
p = buffer.Bytes()
|
||||
|
||||
n, err := oi.LongWrite(w, p)
|
||||
if nil != err {
|
||||
break
|
||||
}
|
||||
if expected, actual := int64(len(p)), n; expected != actual {
|
||||
err := fmt.Errorf("transmission problem: tried sending %d bytes, but actually only sent %d bytes", expected, actual)
|
||||
fmt.Fprint(caller.ssh, err.Error())
|
||||
return
|
||||
}
|
||||
buffer.Reset()
|
||||
}
|
||||
|
||||
// Wait a bit to receive data from the server (that we would send to io.Stdout).
|
||||
time.Sleep(3 * time.Millisecond)
|
||||
}
|
||||
|
||||
func scannerSplitFunc(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF {
|
||||
return 0, nil, nil
|
||||
}
|
||||
return bufio.ScanLines(data, atEOF)
|
||||
}
|
||||
|
||||
func telnetHandler(host *Host) ssh.Handler {
|
||||
return func(s ssh.Session) {
|
||||
// FIXME: log session in db
|
||||
//actx := s.Context().Value(authContextKey).(*authContext)
|
||||
caller := bastionTelnetCaller{ssh: s}
|
||||
if err := telnet.DialToAndCall(host.DialAddr(), caller); err != nil {
|
||||
fmt.Fprintf(s, "error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue