diff --git a/README.md b/README.md index 1ce3cf0..0c22689 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/_example/main.go b/_example/main.go index bd9afc2..1cbfc7d 100644 --- a/_example/main.go +++ b/_example/main.go @@ -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" @@ -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", }, { @@ -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 { @@ -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 } diff --git a/client.go b/client.go new file mode 100644 index 0000000..6bf2253 --- /dev/null +++ b/client.go @@ -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("auth-agent-req@openssh.com", 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 "" +} diff --git a/cmd/wishlist/main.go b/cmd/wishlist/main.go index a9e0d25..ee18444 100644 --- a/cmd/wishlist/main.go +++ b/cmd/wishlist/main.go @@ -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" @@ -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(), + )..., ), ) } diff --git a/config.go b/config.go new file mode 100644 index 0000000..7788b26 --- /dev/null +++ b/config.go @@ -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 +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..14c80a4 --- /dev/null +++ b/doc.go @@ -0,0 +1,2 @@ +// Package wishlist provides a library and binary to list and connect to SSH apps. +package wishlist diff --git a/go.mod b/go.mod index 21f0783..5a108a5 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,22 @@ go 1.16 require ( github.com/charmbracelet/bubbles v0.9.0 github.com/charmbracelet/bubbletea v0.19.2 + github.com/charmbracelet/keygen v0.1.2 github.com/charmbracelet/lipgloss v0.4.0 github.com/charmbracelet/wish v0.1.1 github.com/gliderlabs/ssh v0.3.3 github.com/hashicorp/go-multierror v1.1.1 + github.com/muesli/termenv v0.9.0 + golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) + +// replace github.com/charmbracelet/wish => ../wish + +// replace github.com/gliderlabs/ssh => ../../forks/ssh + +replace github.com/gliderlabs/ssh => ../../forks/ssh + +replace github.com/charmbracelet/wish => ../wish + +replace github.com/charmbracelet/bubbletea => ../bubbletea diff --git a/main.go b/main.go deleted file mode 100644 index a9dc10b..0000000 --- a/main.go +++ /dev/null @@ -1,151 +0,0 @@ -package wishlist - -import ( - "fmt" - "log" - "os" - "os/signal" - "sync/atomic" - "syscall" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - bm "github.com/charmbracelet/wish/bubbletea" - "github.com/gliderlabs/ssh" - "github.com/hashicorp/go-multierror" -) - -// 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 `json:"name"` - Address string `json:"address"` - Handler bm.BubbleTeaHandler `json:"-"` -} - -// Returns true if the endpoint is valid. -func (e Endpoint) Valid() bool { - return e.Name != "" && (e.Handler != nil || e.Address != "") -} - -// ShouldListen returns true if we should start a server for this endpoint. -func (e Endpoint) ShouldListen() bool { - return e.Handler != nil -} - -// Config represents the wishlist configuration. -type Config struct { - Listen string `json:"listen"` - Port int64 `json:"port"` - Endpoints []*Endpoint `json:"endpoints"` - Factory func(Endpoint) (*ssh.Server, error) `json:"-"` - - lastPort int64 -} - -// Serve servers the list for the given config. -func Serve(config *Config) error { - var closes []func() error - done := make(chan os.Signal, 1) - signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - for _, endpoint := range append([]*Endpoint{ - { - Name: "listing", - Address: toAddress(config.Listen, config.Port), - Handler: func(s ssh.Session) (tea.Model, []tea.ProgramOption) { - return newListing(config.Endpoints), []tea.ProgramOption{tea.WithAltScreen()} - }, - }, - }, config.Endpoints...) { - if !endpoint.Valid() || !endpoint.ShouldListen() { - continue - } - - if endpoint.Address == "" { - endpoint.Address = toAddress(config.Listen, atomic.AddInt64(&config.lastPort, 1)) - } - - close, err := listen(config, *endpoint) - if close != nil { - closes = append(closes, close) - } - if err != nil { - if err2 := closeAll(closes); err2 != nil { - return multierror.Append(err, err2) - } - return err - } - } - <-done - log.Print("Stopping SSH servers") - return closeAll(closes) -} - -func listen(config *Config, endpoint Endpoint) (func() error, error) { - s, err := config.Factory(endpoint) - if err != nil { - return nil, err - } - log.Printf("Starting SSH server for %s on ssh://%s", endpoint.Name, endpoint.Address) - go s.ListenAndServe() - return s.Close, nil -} - -func closeAll(closes []func() error) error { - var result error - for _, close := range closes { - if err := close(); err != nil { - result = multierror.Append(result, err) - } - } - return result -} - -var docStyle = lipgloss.NewStyle().Margin(1, 2) - -func newListing(endpoints []*Endpoint) tea.Model { - var items []list.Item - for _, endpoint := range endpoints { - if endpoint.Valid() { - items = append(items, endpoint) - } - } - l := list.NewModel(items, list.NewDefaultDelegate(), 0, 0) - l.Title = "Directory Listing" - return model{l, endpoints} -} - -type model struct { - list list.Model - endpoints []*Endpoint -} - -func (i *Endpoint) Title() string { return i.Name } -func (i *Endpoint) Description() string { return fmt.Sprintf("ssh://%s", i.Address) } -func (i *Endpoint) FilterValue() string { return i.Name } - -func (m model) Init() tea.Cmd { - return nil -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - top, right, bottom, left := docStyle.GetMargin() - m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) - } - - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd -} - -func (m model) View() string { - return docStyle.Render(m.list.View()) -} - -func toAddress(listen string, port int64) string { - return fmt.Sprintf("%s:%d", listen, port) -} diff --git a/server.go b/server.go new file mode 100644 index 0000000..ffb0367 --- /dev/null +++ b/server.go @@ -0,0 +1,121 @@ +package wishlist + +import ( + "fmt" + "log" + "net" + "os" + "os/signal" + "sync/atomic" + "syscall" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/wish" + bm "github.com/charmbracelet/wish/bubbletea" + "github.com/gliderlabs/ssh" + "github.com/hashicorp/go-multierror" +) + +// handles ssh host -t appname +func cmdMiddleware(endpoints []*Endpoint) wish.Middleware { + return func(h ssh.Handler) ssh.Handler { + return func(s ssh.Session) { + if cmd := s.Command(); len(cmd) == 1 && cmd[0] != "list" { + for _, e := range endpoints { + if e.Name == cmd[0] { + MustConnect(s, e) + } + } + fmt.Fprintln(s.Stderr(), "command not found:", cmd) + return + } + h(s) + } + } +} + +// handles handoff to another app +func handoffMiddleware(h ssh.Handler) ssh.Handler { + return func(s ssh.Session) { + if cte := s.Context().Value(HandoffContextKey); cte != nil { + s.Context().SetValue(bm.QuitAppContextKey, true) + MustConnect(s, cte.(*Endpoint)) + } + } +} + +// Serve servers the list for the given config. +func Serve(config *Config) error { + var closes []func() error + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + config.lastPort = config.Port + for _, endpoint := range append([]*Endpoint{ + { + Name: "list", + Address: toAddress(config.Listen, config.Port), + Middlewares: []wish.Middleware{ + handoffMiddleware, + bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { + return newListing(config.Endpoints, s), nil + }), + cmdMiddleware(config.Endpoints), + }, + }, + }, config.Endpoints...) { + if !endpoint.Valid() || !endpoint.ShouldListen() { + continue + } + + if endpoint.Address == "" { + endpoint.Address = toAddress(config.Listen, atomic.AddInt64(&config.lastPort, 1)) + } + + close, err := listenAndServe(config, *endpoint) + if close != nil { + closes = append(closes, close) + } + if err != nil { + if err2 := closeAll(closes); err2 != nil { + return multierror.Append(err, err2) + } + return err + } + } + <-done + log.Print("Stopping SSH servers") + return closeAll(closes) +} + +func listenAndServe(config *Config, endpoint Endpoint) (func() error, error) { + s, err := config.Factory(endpoint) + if err != nil { + return nil, err + } + log.Printf("Starting SSH server for %s on ssh://%s", endpoint.Name, endpoint.Address) + ln, err := net.Listen("tcp", endpoint.Address) + if err != nil { + return nil, err + } + go func() { + if err := s.Serve(ln); err != nil { + log.Println("SSH server error:", err) + } + }() + return s.Close, nil +} + +func closeAll(closes []func() error) error { + var result error + for _, close := range closes { + if err := close(); err != nil { + result = multierror.Append(result, err) + } + } + return result +} + +func toAddress(listen string, port int64) string { + return fmt.Sprintf("%s:%d", listen, port) +} diff --git a/wishlist.go b/wishlist.go new file mode 100644 index 0000000..eaa66ef --- /dev/null +++ b/wishlist.go @@ -0,0 +1,75 @@ +package wishlist + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/gliderlabs/ssh" +) + +var docStyle = lipgloss.NewStyle().Margin(1, 2) + +var enter = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("Enter", "Connect"), +) + +func newListing(endpoints []*Endpoint, s ssh.Session) tea.Model { + var items []list.Item + for _, endpoint := range endpoints { + if endpoint.Valid() { + items = append(items, endpoint) + } + } + l := list.NewModel(items, list.NewDefaultDelegate(), 0, 0) + l.Title = "Directory Listing" + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{enter} + } + return listModel{ + list: l, + endpoints: endpoints, + session: s, + } +} + +func (i *Endpoint) Title() string { return i.Name } +func (i *Endpoint) Description() string { return fmt.Sprintf("ssh://%s", i.Address) } +func (i *Endpoint) FilterValue() string { return i.Name } + +type listModel struct { + list list.Model + endpoints []*Endpoint + session ssh.Session +} + +func (m listModel) Init() tea.Cmd { + return nil +} + +func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if key.Matches(msg, enter) { + m.session.Context().SetValue( + HandoffContextKey, + m.list.SelectedItem().(*Endpoint), + ) + return m, tea.Quit + } + case tea.WindowSizeMsg: + top, right, bottom, left := docStyle.GetMargin() + m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m listModel) View() string { + return docStyle.Render(m.list.View()) +} diff --git a/wishlist.yaml b/wishlist.yaml index 85499b9..a3c6c72 100644 --- a/wishlist.yaml +++ b/wishlist.yaml @@ -1,9 +1,17 @@ -listen: 127.0.0.1 +listen: 0.0.0.0 port: 2222 endpoints: - name: confetti address: ssh.caarlos0.dev:2222 - name: fireworks address: ssh.caarlos0.dev:2223 -- name: party parrot +- name: party address: ssh.caarlos0.dev:2225 +- name: pc + address: darkstar.local:22 +- name: pi3 + address: pi3.local:22 + user: ubuntu +- name: pi4 + address: pi4.local:22 + user: ubuntu