Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a --local flag #20

Merged
merged 41 commits into from
Jan 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
fe83508
wip: --local
caarlos0 Jan 25, 2022
ee16c12
fix: auth et al
caarlos0 Jan 25, 2022
a151c42
wip: logs
caarlos0 Jan 25, 2022
c092ff3
wip: --local
caarlos0 Jan 26, 2022
44a3245
refactor: var names
caarlos0 Jan 26, 2022
ba04244
fix: stop app in the background
caarlos0 Jan 26, 2022
94afc84
fix: lint issues
caarlos0 Jan 26, 2022
6bff381
fix: sigwinch on windows
caarlos0 Jan 26, 2022
2d5ca61
fix: lint
caarlos0 Jan 26, 2022
fd5df54
refactor: move code around
caarlos0 Jan 26, 2022
06fa3cc
feat: parse identity files
caarlos0 Jan 26, 2022
174af49
fix: lint
caarlos0 Jan 26, 2022
fc0ce45
fix: ask for key pwd
caarlos0 Jan 26, 2022
dd6553c
fix: use auth agent
caarlos0 Jan 26, 2022
a8b8fa1
fix: auth
caarlos0 Jan 26, 2022
692f6fb
fix: lint issues
caarlos0 Jan 26, 2022
f0e8e0b
fix: quit tea before handing off
caarlos0 Jan 26, 2022
f7a07a7
fix: password input
caarlos0 Jan 26, 2022
903ea7b
chore: godoc
caarlos0 Jan 26, 2022
9bc1b13
fix: complexity
caarlos0 Jan 26, 2022
caccbe6
chore: godo
caarlos0 Jan 26, 2022
34561bb
fix: linting issues
caarlos0 Jan 26, 2022
ab07270
fix: prefer agent when available
caarlos0 Jan 27, 2022
94d143a
Merge branch 'main' into local
caarlos0 Jan 28, 2022
ef5da03
fix: expand include path
caarlos0 Jan 28, 2022
09cb53c
docs: readme
caarlos0 Jan 28, 2022
8a4da09
Merge remote-tracking branch 'origin/main' into local
caarlos0 Jan 30, 2022
19a20fe
feat: forward agent
caarlos0 Jan 30, 2022
69b365e
refactor: less reapeated code
caarlos0 Jan 30, 2022
36118a0
feat: forward agent
caarlos0 Jan 30, 2022
2ac93c1
Merge branch 'main' into local
caarlos0 Jan 31, 2022
3d0b169
feat: allow multiple identities
caarlos0 Jan 31, 2022
7b64b61
test: user keys
caarlos0 Jan 31, 2022
b0a3d6c
fix: lint issues
caarlos0 Jan 31, 2022
2beb250
test: fixed tests on windows
caarlos0 Jan 31, 2022
f93a8be
fix: go mod tidy
caarlos0 Jan 31, 2022
e61fe8d
test: more tests
caarlos0 Jan 31, 2022
e11c575
chore: debug windows
caarlos0 Jan 31, 2022
115ee22
chore: refresh
caarlos0 Jan 31, 2022
7c09718
test: debug windows
caarlos0 Jan 31, 2022
abb9da3
fix: windows
caarlos0 Jan 31, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
wishlist.yaml
.wishlist
dist
wishlist.log
cover.txt
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@ You can also use the `wishlist` CLI to list and connect to servers in your `~/.s

### CLI

#### Remote

If you just want a directory of existing servers, you can use the `wishlist` CLI and a YAML config file. You can also just run it without any arguments to list the servers in your `~/.ssh/config`.
Check the [example config file](/_example/config.yaml) file as well as `wishlist --help` for details.

#### Local

If you want to explore your own local servers, you can simply run `wishlist --local` to be presented with a the UI filled with your `~/.ssh/config` hosts.

### Library

Wishlist is also available as a library which allows you to start several apps within the same process.
Expand Down
192 changes: 13 additions & 179 deletions client.go
Original file line number Diff line number Diff line change
@@ -1,89 +1,35 @@
package wishlist

import (
"errors"
"fmt"
"io"
"log"
"net"
"os"

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

func resetPty(w io.Writer) {
fmt.Fprint(w, termenv.CSI+termenv.ExitAltScreenSeq)
fmt.Fprint(w, termenv.CSI+termenv.ResetSeq+"m")
fmt.Fprintf(w, termenv.CSI+termenv.EraseDisplaySeq, 2) // nolint:gomnd
// SSHClient is a SSH client.
type SSHClient interface {
Connect(e *Endpoint) error
}

func mustConnect(s ssh.Session, e *Endpoint, stdin io.Reader) {
if err := connect(s, e, stdin); err != nil {
fmt.Fprintf(s, "wishlist: %s\n\r", err.Error())
_ = s.Exit(1)
return // unreachable
}
fmt.Fprintf(s, "wishlist: closed connection to %q (%s)\n\r", e.Name, e.Address)
_ = s.Exit(0)
}

func connect(prev ssh.Session, e *Endpoint, stdin io.Reader) error {
resetPty(prev)

method, closers, err := authMethod(prev)
defer closers.close()
if err != nil {
return fmt.Errorf("failed to find an auth method: %w", err)
}

conf := &gossh.ClientConfig{
User: firstNonEmpty(e.User, prev.User()),
HostKeyCallback: hostKeyCallback(e, ".wishlist/known_hosts"),
Auth: []gossh.AuthMethod{method},
}

func createSession(conf *gossh.ClientConfig, e *Endpoint) (*gossh.Session, *gossh.Client, closers, error) {
var cl closers
conn, err := gossh.Dial("tcp", e.Address, conf)
if err != nil {
return fmt.Errorf("connection failed: %w", err)
return nil, nil, cl, fmt.Errorf("connection failed: %w", err)
}
defer func() {
if err := conn.Close(); err != nil {
log.Println("failed to close conn:", err)
}
}()

cl = append(cl, conn.Close)

session, err := conn.NewSession()
if err != nil {
return fmt.Errorf("failed to open session: %w", err)
}

log.Printf("%s connect to %q, %s -> %s", prev.User(), e.Name, prev.RemoteAddr().String(), conn.RemoteAddr().String())
defer func() {
if err := session.Close(); err != nil && err != io.EOF {
log.Println("failed to close session:", err)
}
}()

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

pty, winch, _ := prev.Pty()
w := pty.Window
if err := session.RequestPty(pty.Term, w.Height, w.Width, nil); err != nil {
return fmt.Errorf("failed to request pty: %w", err)
return nil, conn, cl, fmt.Errorf("failed to open session: %w", err)
}
cl = append(cl, session.Close)
return session, conn, cl, nil
}

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

go notifyWindowChanges(session, done, winch)

func shellAndWait(session *gossh.Session) error {
if err := session.Shell(); err != nil {
return fmt.Errorf("failed to start shell: %w", err)
}
Expand All @@ -94,24 +40,6 @@ func connect(prev ssh.Session, e *Endpoint, stdin io.Reader) error {
return nil
}

func notifyWindowChanges(session *gossh.Session, done <-chan bool, winch <-chan ssh.Window) {
for {
select {
case <-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() {
Expand All @@ -122,67 +50,6 @@ func (c closers) close() {
}
}

// authMethod returns an auth method.
//
// it first tries to use ssh-agent, if that's not available, it creates and uses a new key pair.
func authMethod(s ssh.Session) (gossh.AuthMethod, closers, error) {
method, closers, err := tryAuthAgent(s)
if err != nil {
return method, closers, err
}
if method != nil {
return method, closers, nil
}

method, err = tryNewKey()
return method, closers, err
}

// tryAuthAgent will try to use an ssh-agent to authenticate.
func tryAuthAgent(s ssh.Session) (gossh.AuthMethod, closers, error) {
_, _ = s.SendRequest("auth-agent-req@openssh.com", true, nil)

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

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

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

fmt.Fprintf(s.Stderr(), "wishlist: ssh agent not available\n\r")
return nil, nil, nil
}

// tryNewKey will create a .wishlist/client_ed25519 keypair if one does not exist.
// It will return an auth method that uses the keypair if it exist or is successfully created.
func tryNewKey() (gossh.AuthMethod, error) {
key, err := keygen.New(".wishlist", "client", nil, keygen.Ed25519)
if err != nil {
return nil, err // nolint:wrapcheck
}

signer, err := gossh.ParsePrivateKey(key.PrivateKeyPEM)
if err != nil {
return nil, err // nolint:wrapcheck
}

if key.IsKeyPairExists() {
return gossh.PublicKeys(signer), nil
}

return gossh.PublicKeys(signer), key.WriteKeys()
}

func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
Expand All @@ -191,36 +58,3 @@ func firstNonEmpty(ss ...string) string {
}
return ""
}

// hostKeyCallback returns a callback that will be used to verify the host key.
//
// it creates a file in the given path, and uses that to verify hosts and keys.
// if the host does not exist there, it adds it so its available next time, as plain old `ssh` does.
func hostKeyCallback(e *Endpoint, path string) gossh.HostKeyCallback {
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
kh, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) // nolint:gomnd
if err != nil {
return fmt.Errorf("failed to open known_hosts: %w", err)
}
defer func() { _ = kh.Close() }()

callback, err := knownhosts.New(kh.Name())
if err != nil {
return fmt.Errorf("failed to check known_hosts: %w", err)
}

if err := callback(hostname, remote, key); err != nil {
var kerr *knownhosts.KeyError
if errors.As(err, &kerr) {
if len(kerr.Want) > 0 {
return fmt.Errorf("possible man-in-the-middle attack: %w", err)
}
// if want is empty, it means the host was not in the known_hosts file, so lets add it there.
fmt.Fprintln(kh, knownhosts.Line([]string{e.Address}, key))
return nil
}
return fmt.Errorf("failed to check known_hosts: %w", err)
}
return nil
}
}