Skip to content

Commit

Permalink
feat: proxy connections (charmbracelet#1)
Browse files Browse the repository at this point in the history
* wip

Signed-off-by: Carlos A Becker <[email protected]>

* wip

Signed-off-by: Carlos A Becker <[email protected]>

* wip

Signed-off-by: Carlos A Becker <[email protected]>

* wip: proxy

Signed-off-by: Carlos A Becker <[email protected]>

* fix: res

Signed-off-by: Carlos A Becker <[email protected]>

* fix: stdin leak

Signed-off-by: Carlos A Becker <[email protected]>

* feat: improvements

Signed-off-by: Carlos A Becker <[email protected]>

* fix: improve example

Signed-off-by: Carlos A Becker <[email protected]>

* fix: connect on the cmd instead

Signed-off-by: Carlos A Becker <[email protected]>

* fix: notify win changes

Signed-off-by: Carlos A Becker <[email protected]>

* chore: improv example

Signed-off-by: Carlos A Becker <[email protected]>

* refactor: noop model

Signed-off-by: Carlos A Becker <[email protected]>

* refactor: improve client

Signed-off-by: Carlos A Becker <[email protected]>

* fix: fail on used addr

Signed-off-by: Carlos A Becker <[email protected]>

* refactor: move code around

Signed-off-by: Carlos A Becker <[email protected]>

* wip

Signed-off-by: Carlos A Becker <[email protected]>

* refactor: improve code a bit

* simplify

Signed-off-by: Carlos A Becker <[email protected]>

* fix: use keygen

Signed-off-by: Carlos A Becker <[email protected]>

* chore: organizing

Signed-off-by: Carlos A Becker <[email protected]>

* feat: allow to ssh -t directly into an app

Signed-off-by: Carlos A Becker <[email protected]>

* fix: log

Signed-off-by: Carlos A Becker <[email protected]>

* feat: agent forwarding

Signed-off-by: Carlos A Becker <[email protected]>

* docs: improve

Signed-off-by: Carlos A Becker <[email protected]>

* wip

Signed-off-by: Carlos A Becker <[email protected]>

* feat: allow to use custom users

Signed-off-by: Carlos A Becker <[email protected]>

* wip

Signed-off-by: Carlos A Becker <[email protected]>

* feat: handoff

Signed-off-by: Carlos A Becker <[email protected]>
  • Loading branch information
caarlos0 authored Jan 6, 2022
1 parent 263fd91 commit 5d5464d
Show file tree
Hide file tree
Showing 11 changed files with 503 additions and 183 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,24 @@ It can be used to start multiple SSH apps within a single package, and provide a
You can also list apps provided elsewhere.

You can also use the `wishlist` CLI to just start a listing of external SSH apps based on a JSON config file.

## Auth

* if ssh agent forwarding is available, it will be used
* otherwise, each session will create a new ed25519 key and use it, in which case your app will be to allow access to any public key
* password auth is not supported

### Example agent forwarding

```sh
eval (ssh-agent)
ssh-add -k # adds all your pubkeys
ssh-add -l # should list the added keys

ssh \
-o 'ForwardAgent=yes' \ # forwards the agent
-o 'UserKnownHostsFile=/dev/null' \ # do not add to ~/.ssh/known_hosts, optional
-p 2222 \ # port
foo.bar \ # host
-t list # optional, app name
```
48 changes: 23 additions & 25 deletions _example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ package main

import (
"fmt"
"log"

"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/accesscontrol"
"github.com/charmbracelet/wish/activeterm"
bm "github.com/charmbracelet/wish/bubbletea"
lm "github.com/charmbracelet/wish/logging"
Expand All @@ -22,16 +22,25 @@ func main() {
return wish.NewServer(
wish.WithAddress(e.Address),
wish.WithMiddleware(
bm.Middleware(e.Handler),
lm.Middleware(),
accesscontrol.Middleware(),
activeterm.Middleware(),
append(
e.Middlewares,
lm.Middleware(),
activeterm.Middleware(),
)...,
),
)
},
Endpoints: []*wishlist.Endpoint{
{
Name: "foo bar",
Name: "example",
Middlewares: []wish.Middleware{
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return initialModel(), nil
}),
},
},
{
Name: "foobar",
Address: "some.other.server:2222",
},
{
Expand All @@ -40,21 +49,14 @@ func main() {
{
Address: "entries without names are ignored",
},
{
Name: "example app",
Handler: func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return initialModel(), []tea.ProgramOption{}
},
},
},
}); err != nil {
panic(err)
log.Fatalln(err)
}
}

type model struct {
spinner spinner.Model
quitting bool
spinner spinner.Model
}

func initialModel() model {
Expand All @@ -70,24 +72,20 @@ func (m model) Init() tea.Cmd {
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
log.Println("keypress:", msg)
switch msg.String() {
case "q", "esc", "ctrl+c":
m.quitting = true
return m, tea.Quit
default:
return m, nil
}
default:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.WindowSizeMsg:
log.Println("window size:", msg)
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}

func (m model) View() string {
str := fmt.Sprintf("\n\n %s Loading forever...press q to quit\n\n", m.spinner.View())
if m.quitting {
return str + "\n"
}
return str
}
190 changes: 190 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package wishlist

import (
"fmt"
"io"
"log"
"net"

"github.com/charmbracelet/keygen"
"github.com/gliderlabs/ssh"
"github.com/muesli/termenv"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)

func resetPty(w io.Writer) {
fmt.Fprint(w, termenv.CSI+termenv.ExitAltScreenSeq)
fmt.Fprint(w, termenv.CSI+termenv.ResetSeq+"m")
}

func MustConnect(s ssh.Session, e *Endpoint) {
if err := connect(s, e); err != nil {
fmt.Fprintln(s, err.Error())
s.Exit(1)
return //unreachable
}
fmt.Fprintf(s, "Closed connection to %q (%s)\n", e.Name, e.Address)
s.Exit(0)
}

func connect(prev ssh.Session, e *Endpoint) error {
resetPty(prev)
defer resetPty(prev)

methods, closers, err := authMethods(prev)
defer closers.close()
if err != nil {
return err
}

conf := &gossh.ClientConfig{
User: firstNonEmpty(e.User, prev.User()),
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
Auth: methods,
}

conn, err := gossh.Dial("tcp", e.Address, conf)
if err != nil {
return err
}
defer func() {
if err := conn.Close(); err != nil {
log.Println("failed to close conn:", err)
}
}()

session, err := conn.NewSession()
if err != nil {
return err
}

defer func() {
if err := session.Close(); err != nil {
log.Println("failed to close session:", err)
}
}()

session.Stdout = prev
session.Stderr = prev.Stderr()
session.Stdin = prev

pty, winch, _ := prev.Pty()
w := pty.Window
if err := session.RequestPty(pty.Term, w.Height, w.Width, nil); err != nil {
return err
}

done := make(chan bool, 1)
defer func() { done <- true }()

go notifyWindowChanges(session, done, winch)

// Non blocking:
// - session.Shell()
// - session.Start()
//
// Blocking:
// - session.Run()
// - session.Output()
// - session.CombinedOutput()
// - session.Wait()
//
if err := session.Shell(); err != nil {
return err
}

return session.Wait()
}

func notifyWindowChanges(session *gossh.Session, done <-chan bool, winch <-chan ssh.Window) {
for {
select {
case <-done:
log.Println("winch done")
return
case w := <-winch:
if w.Height == 0 && w.Width == 0 {
// this only happens if the session is already dead, make sure there are no leftovers
return
}
if err := session.WindowChange(w.Height, w.Width); err != nil {
log.Println("failed to notify window change", err)
return
}
}
}
}

type closers []func() error

func (c closers) close() {
for _, closer := range c {
if err := closer(); err != nil {
log.Println("failed to close:", err)
}
}
}

func authMethods(s ssh.Session) ([]gossh.AuthMethod, closers, error) {
var authMethods []gossh.AuthMethod
methods, closers, err := tryAuthAgent(s)
if err != nil {
return methods, closers, err
}
if methods != nil {
authMethods = append(authMethods, methods...)
}

methods, err = tryNewKey()
if err != nil {
return methods, closers, err
}
return append(authMethods, methods...), closers, nil
}

func tryAuthAgent(s ssh.Session) ([]gossh.AuthMethod, closers, error) {
ok, err := s.SendRequest("[email protected]", true, nil)
log.Println("agent forward:", ok, err, ssh.AgentRequested(s))

if ssh.AgentRequested(s) {
l, err := ssh.NewAgentListener()
if err != nil {
return nil, nil, err
}
go ssh.ForwardAgentConnections(l, s)

conn, err := net.Dial(l.Addr().Network(), l.Addr().String())
if err != nil {
return nil, closers{l.Close}, err
}

return []gossh.AuthMethod{
gossh.PublicKeysCallback(agent.NewClient(conn).Signers),
}, closers{l.Close, conn.Close}, nil
}

return nil, nil, nil
}

func tryNewKey() ([]gossh.AuthMethod, error) {
key, err := keygen.New("", "", nil, keygen.Ed25519)
if err != nil {
return nil, err
}

signer, err := gossh.ParsePrivateKey(key.PrivateKeyPEM)
if err != nil {
return nil, err
}
return []gossh.AuthMethod{gossh.PublicKeys(signer)}, nil
}

func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
16 changes: 11 additions & 5 deletions cmd/wishlist/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/accesscontrol"
"github.com/charmbracelet/wish/activeterm"
bm "github.com/charmbracelet/wish/bubbletea"
lm "github.com/charmbracelet/wish/logging"
"github.com/charmbracelet/wishlist"
"github.com/gliderlabs/ssh"
Expand All @@ -29,14 +28,21 @@ func main() {
log.Fatalln(err)
}

names := []string{"list"}
for _, e := range config.Endpoints {
names = append(names, e.Name)
}

config.Factory = func(e wishlist.Endpoint) (*ssh.Server, error) {
return wish.NewServer(
wish.WithAddress(e.Address),
wish.WithMiddleware(
bm.Middleware(e.Handler),
lm.Middleware(),
accesscontrol.Middleware(),
activeterm.Middleware(),
append(
e.Middlewares,
lm.Middleware(),
accesscontrol.Middleware(names...),
activeterm.Middleware(),
)...,
),
)
}
Expand Down
37 changes: 37 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package wishlist

import (
"github.com/charmbracelet/wish"
"github.com/gliderlabs/ssh"
)

const HandoffContextKey = "handoff-to"

// Endpoint represents an endpoint to list.
// If it has a Handler, wishlist will start an SSH server on the given address.
type Endpoint struct {
Name string `yaml:"name"`
Address string `yaml:"address"`
User string `yaml:"user"`
Middlewares []wish.Middleware `yaml:"-"`
}

// Returns true if the endpoint is valid.
func (e Endpoint) Valid() bool {
return e.Name != "" && (len(e.Middlewares) > 0 || e.Address != "")
}

// ShouldListen returns true if we should start a server for this endpoint.
func (e Endpoint) ShouldListen() bool {
return len(e.Middlewares) > 0
}

// Config represents the wishlist configuration.
type Config struct {
Listen string `yaml:"listen"`
Port int64 `yaml:"port"`
Endpoints []*Endpoint `yaml:"endpoints"`
Factory func(Endpoint) (*ssh.Server, error) `yaml:"-"`

lastPort int64
}
2 changes: 2 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package wishlist provides a library and binary to list and connect to SSH apps.
package wishlist
Loading

0 comments on commit 5d5464d

Please sign in to comment.