Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 8 additions & 142 deletions cmd/meowshell/agent.go

Large diffs are not rendered by default.

17 changes: 0 additions & 17 deletions cmd/meowshell/agent_auth_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,6 @@ import (
"golang.org/x/crypto/ssh"
)

// startAuthTestSSHServer is startTestSSHServer with the auth requirement
// left to the caller (configure sets a Password/PublicKey/KeyboardInteractive
// callback on cfg before it's used), for tests that need meowshell agent's
// new auth methods actually exercised end to end rather than the
// NoClientAuth free pass startTestSSHServer gives every other test here.
func startAuthTestSSHServer(t *testing.T, configure func(cfg *ssh.ServerConfig)) (addr string, hostKey ssh.Signer) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
Expand Down Expand Up @@ -52,10 +47,6 @@ func startAuthTestSSHServer(t *testing.T, configure func(cfg *ssh.ServerConfig))
return ln.Addr().String(), signer
}

// newTestKeyPair generates an ed25519 key pair and returns the private key
// as an unencrypted PKCS#8 PEM block (what ssh.ParsePrivateKey accepts) and
// its ssh.PublicKey, for tests that need real key bytes to hand a
// "configure" message the way an app would.
func newTestKeyPair(t *testing.T) (privatePEM []byte, public ssh.PublicKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
Expand All @@ -74,9 +65,6 @@ func newTestKeyPair(t *testing.T) (privatePEM []byte, public ssh.PublicKey) {
return pemBytes, sshPub
}

// acceptHostKeyPrompt reads and accepts the TOFU host-key prompt every
// first connection to a fresh known_hosts file raises, so an auth-focused
// test doesn't have to special-case it inline.
func acceptHostKeyPrompt(t *testing.T, stdin *os.File, out *bufio.Reader) {
t.Helper()
f := mustReadFrame(t, out)
Expand Down Expand Up @@ -120,9 +108,6 @@ func TestAgentPasswordAuth(t *testing.T) {
cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr)
defer stopAgent(t, cmd, stdin)

// The host key is already trusted from the previous subtest's
// connection to the same address+known_hosts -- no TOFU prompt
// this time, straight to the password prompt.
f := mustReadFrame(t, out)
msg := decodeControl(t, f)
if msg.Msg != "prompt_request" || msg.PromptKind != "password" {
Expand Down Expand Up @@ -167,8 +152,6 @@ func TestAgentSuppliedPrivateKeyAuth(t *testing.T) {
controlMessage{Msg: "configure", Keys: [][]byte{wrongPEM}})
defer stopAgent(t, cmd, stdin)

// Host key already trusted from the previous subtest; straight to
// the auth failure this time.
f := mustReadFrame(t, out)
msg := decodeControl(t, f)
if msg.Msg != "error" || msg.Code != errAuthFailed {
Expand Down
37 changes: 1 addition & 36 deletions cmd/meowshell/agent_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,6 @@ import (
"time"
)

// TestAgentEndToEnd drives "meowshell agent" as a real subprocess against a
// real tailcat server (hermetic: TS_DEBUG_TAILCAT_LOCAL_DERP replaces the
// public DERP/STUN infrastructure with an in-process one, so this needs no
// network access), speaking the framed control protocol exactly as
// dotnet/Meowshell's MeowshellAgentConnection eventually will. It proves
// the multiplexing daemon model end to end: one process, one handshake,
// two channels (an exec and a resized shell) opened on it in turn.
//
// Needs real tailcat/meowshell binaries built for this platform; skips
// itself when they aren't found rather than failing the whole package
// (dist/ is a local/CI build product, not checked in).
func TestAgentEndToEnd(t *testing.T) {
tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64")
meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64")
Expand Down Expand Up @@ -72,9 +61,6 @@ func TestAgentEndToEnd(t *testing.T) {
})

t.Run("exec channel with a nonzero exit reports it as a structured value", func(t *testing.T) {
// Quoted, not bare: Command elements are joined with plain spaces
// (agent.go documents this, matching connect.go and a real ssh
// client), so "exit 42" must survive as one argument to sh -c.
send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "exec", Command: []string{"sh", "-c", "'exit 42'"}})
id := expectChannelOpened(t, out)

Expand Down Expand Up @@ -103,9 +89,6 @@ func TestAgentEndToEnd(t *testing.T) {
})
}

// findE2EBinary locates a real binary for the e2e test to drive: an
// explicit env var override, falling back to dist/<name> relative to the
// repo root (build.sh's own output layout).
func findE2EBinary(t *testing.T, envVar, distName string) string {
t.Helper()
if p := os.Getenv(envVar); p != "" {
Expand All @@ -122,9 +105,6 @@ func findE2EBinary(t *testing.T, envVar, distName string) string {
return abs
}

// startE2EServer starts an unauthenticated meowshell server over a
// hermetic local DERP relay and returns the address it publishes, the same
// setup e2e/host-e2e.sh uses against real binaries.
func startE2EServer(t *testing.T, tailcatBin, meowshellBin, home string) string {
t.Helper()
addrFile := filepath.Join(home, "addr")
Expand Down Expand Up @@ -175,10 +155,6 @@ func mustWriteFrame(t *testing.T, w io.Writer, f frame) {
}
}

// expectConnected reads the connection-level handshake message the agent
// sends once it has finished dialing (and any host-key/auth prompting
// along the way): "connected" on success, or an "error" that fails the
// test outright, since nothing after this point can succeed either.
func expectConnected(t *testing.T, r *bufio.Reader) {
t.Helper()
f, err := readFrameWithDeadline(t, r)
Expand All @@ -199,8 +175,6 @@ func expectConnected(t *testing.T, r *bufio.Reader) {
}
}

// expectChannelOpened reads frames until channel_opened, failing the test
// on an error frame or a control message it didn't expect.
func expectChannelOpened(t *testing.T, r *bufio.Reader) uint32 {
t.Helper()
for {
Expand All @@ -224,8 +198,6 @@ func expectChannelOpened(t *testing.T, r *bufio.Reader) uint32 {
}
}

// readUntilExit collects data frames for id until its exit_status arrives,
// returning the concatenated stdout+stderr bytes seen along the way.
func readUntilExit(t *testing.T, r *bufio.Reader, id uint32) []byte {
t.Helper()
var buf bytes.Buffer
Expand All @@ -239,7 +211,7 @@ func readUntilExit(t *testing.T, r *bufio.Reader, id uint32) []byte {
}
switch f.Type {
case frameTypeData:
buf.Write(f.Payload[1:]) // drop the stream tag
buf.Write(f.Payload[1:])
case frameTypeControl:
var msg controlMessage
if err := json.Unmarshal(f.Payload, &msg); err != nil {
Expand All @@ -255,8 +227,6 @@ func readUntilExit(t *testing.T, r *bufio.Reader, id uint32) []byte {
}
}

// readExitOnly is readUntilExit's counterpart when only the exit code
// matters to the caller.
func readExitOnly(t *testing.T, r *bufio.Reader, id uint32) int {
t.Helper()
for {
Expand All @@ -280,8 +250,6 @@ func readExitOnly(t *testing.T, r *bufio.Reader, id uint32) int {
}
}

// readUntil collects data frames for id until want appears in them or
// timeout elapses.
func readUntil(t *testing.T, r *bufio.Reader, id uint32, want string, timeout time.Duration) []byte {
t.Helper()
deadline := time.Now().Add(timeout)
Expand All @@ -303,9 +271,6 @@ func readUntil(t *testing.T, r *bufio.Reader, id uint32, want string, timeout ti
return nil
}

// readFrameWithDeadline wraps readFrame with an overall per-call budget, so
// a protocol bug hangs the one subtest that hit it instead of the whole
// test binary.
func readFrameWithDeadline(t *testing.T, r *bufio.Reader) (frame, error) {
t.Helper()
type result struct {
Expand Down
11 changes: 1 addition & 10 deletions cmd/meowshell/agent_forward_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ import (
"time"
)

// TestAgentLocalForwardEndToEnd drives "-L"-style forwarding: the agent
// listens locally and forwards each accepted connection through the SSH
// client to a target the test's own fake SSH server dials back out to.
func TestAgentLocalForwardEndToEnd(t *testing.T) {
meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64")

Expand All @@ -34,7 +31,7 @@ func TestAgentLocalForwardEndToEnd(t *testing.T) {
}
}()

addr, _, _ := startTestSSHServer(t, echoCommandHandler) // the SSH server whose Dial reaches backendLn
addr, _, _ := startTestSSHServer(t, echoCommandHandler)
knownHosts := filepath.Join(t.TempDir(), "known_hosts")

cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr)
Expand Down Expand Up @@ -67,10 +64,6 @@ func TestAgentLocalForwardEndToEnd(t *testing.T) {
}
}

// TestAgentSOCKSForwardEndToEnd drives "-D": a SOCKS5 client (net.Dialer
// speaking the protocol by hand, since the standard library has no SOCKS5
// client of its own either) connects through the agent's SOCKS listener
// to a backend the SSH server's own Dial reaches.
func TestAgentSOCKSForwardEndToEnd(t *testing.T) {
meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64")

Expand Down Expand Up @@ -122,8 +115,6 @@ func TestAgentSOCKSForwardEndToEnd(t *testing.T) {
}
}

// socks5Connect speaks just enough SOCKS5 client-side to CONNECT to
// target through conn and read back whatever the far end sends.
func socks5Connect(conn net.Conn, target string) (string, error) {
if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil {
return "", err
Expand Down
28 changes: 2 additions & 26 deletions cmd/meowshell/agent_security_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ import (
"time"
)

// TestAgentRejectsNonLoopbackBindByDefault proves the fix for the
// unrestricted-bind finding: a forward_local asking to listen on a
// non-loopback address is refused unless AllowNonLoopbackBind is set.
func TestAgentRejectsNonLoopbackBindByDefault(t *testing.T) {
meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64")

Expand All @@ -36,9 +33,7 @@ func TestAgentRejectsNonLoopbackBindByDefault(t *testing.T) {
t.Run("0.0.0.0 succeeds with AllowNonLoopbackBind", func(t *testing.T) {
cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr)
defer stopAgent(t, cmd, stdin)
// known_hosts already trusts this server from the first subtest,
// so no host-key prompt this time -- but "connected" still comes
// first, same as any other fresh connection.

expectConnected(t, out)

send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "forward_local", ListenAddr: "0.0.0.0:0", RemoteAddr: "127.0.0.1:1", AllowNonLoopbackBind: true})
Expand All @@ -64,9 +59,6 @@ func TestAgentRejectsNonLoopbackBindByDefault(t *testing.T) {
})
}

// TestAgentUnixSocketForward proves the UDS fix: a forward_local with
// listen_network "unix" listens on a filesystem-permission-protected
// socket, chmod'd 0600 regardless of umask, and actually relays bytes.
func TestAgentUnixSocketForward(t *testing.T) {
if os.PathSeparator == '\\' {
t.Skip("unix domain sockets aren't this test's concern on Windows")
Expand Down Expand Up @@ -129,9 +121,6 @@ func TestAgentUnixSocketForward(t *testing.T) {
}
}

// TestAgentSocksAuthToken proves the SOCKS5 auth fix: a proxy opened with
// SocksUsername/SocksPassword refuses a client presenting the wrong
// credentials (or none), and serves one presenting the right pair.
func TestAgentSocksAuthToken(t *testing.T) {
meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64")

Expand Down Expand Up @@ -188,8 +177,7 @@ func TestAgentSocksAuthToken(t *testing.T) {
t.Fatal(err)
}
defer conn.Close()
// Offer only "no auth"; the server requires user/pass and must
// reject the method-selection outright (0xFF), not fall back.

if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -218,10 +206,6 @@ func TestAgentSocksAuthToken(t *testing.T) {
})
}

// TestAgentConfigureCarriesProxyURL proves the argv fix: the agent
// connects through a proxy configured via the "configure" message
// (never a CLI flag, so it never lands in this process's own argv/
// /proc/pid/cmdline) exactly as it did when --proxy was still a flag.
func TestAgentConfigureCarriesProxyURL(t *testing.T) {
meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64")
addr, _, _ := startTestSSHServer(t, echoCommandHandler)
Expand Down Expand Up @@ -257,10 +241,6 @@ func TestAgentConfigureCarriesProxyURL(t *testing.T) {
}
}

// serveHTTPConnectProxy answers exactly one CONNECT request by dialing
// wantTarget itself (ignoring whatever the client asked for, since this
// test only cares whether the agent used the proxy at all) and signals
// dialed once it has.
func serveHTTPConnectProxy(t *testing.T, conn net.Conn, wantTarget string, dialed chan<- struct{}) {
defer conn.Close()
buf := make([]byte, 4096)
Expand All @@ -287,8 +267,6 @@ func serveHTTPConnectProxy(t *testing.T, conn net.Conn, wantTarget string, diale

const e2eDialTimeout = 5 * time.Second

// socks5Auth performs the greeting + username/password subnegotiation
// only, for a test that expects it to fail.
func socks5Auth(conn net.Conn, username, password string) (bool, error) {
if _, err := conn.Write([]byte{0x05, 0x01, 0x02}); err != nil {
return false, err
Expand Down Expand Up @@ -317,8 +295,6 @@ func socks5Auth(conn net.Conn, username, password string) (bool, error) {
return true, nil
}

// socks5AuthAndConnect is socks5Auth plus a CONNECT request, returning
// whatever the far end sends back.
func socks5AuthAndConnect(conn net.Conn, username, password, target string) (string, error) {
if ok, err := socks5Auth(conn, username, password); err != nil || !ok {
return "", err
Expand Down
16 changes: 2 additions & 14 deletions cmd/meowshell/agent_sftp_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ import (
"time"
)

// TestAgentSFTPEndToEnd drives meowshell agent's SFTP surface (verbs
// beyond meowshell cp's plain upload/download/ls) against a real tailcat
// server, over the local-DERP hermetic setup agent_e2e_test.go uses.
func TestAgentSFTPEndToEnd(t *testing.T) {
tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64")
meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64")
Expand All @@ -22,14 +19,9 @@ func TestAgentSFTPEndToEnd(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
t.Setenv("TS_DEBUG_TAILCAT_LOCAL_DERP", "1")

served := t.TempDir() // meowshell serve --files serves this directory
served := t.TempDir()
addr := startE2EFilesServer(t, tailcatBin, meowshellBin, home, served)

// startAgent (agent_tcp_e2e_test.go) doesn't set an explicit Env for
// the subprocess, so it inherits the test process's own -- this is
// what gets TAILCAT_BIN to it for a tailcat-address destination
// (--known-hosts is harmless but unused on this path: tailcat
// transport never builds a TCP host-key callback).
t.Setenv("TAILCAT_BIN", tailcatBin)
_, stdin, out := startAgent(t, meowshellBin, filepath.Join(t.TempDir(), "known_hosts"), addr)
t.Cleanup(func() { stdin.Close() })
Expand Down Expand Up @@ -118,7 +110,7 @@ func TestAgentSFTPEndToEnd(t *testing.T) {
})

t.Run("download reports progress and a real total size", func(t *testing.T) {
payload := bytes.Repeat([]byte("0123456789"), 10_000) // 100KB, big enough to cross the progress interval at least once
payload := bytes.Repeat([]byte("0123456789"), 10_000)
uploadViaAgent(t, stdin, out, "big.bin", payload, false, 0, 0)

send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "sftp_download", Path: "big.bin"})
Expand Down Expand Up @@ -194,8 +186,6 @@ func startE2EFilesServer(t *testing.T, tailcatBin, meowshellBin, home, served st
return ""
}

// sftpOp sends one sftp_op request and returns its sftp_result, failing
// the test on an error response.
func sftpOp(t *testing.T, stdin interface {
Write([]byte) (int, error)
}, out *bufio.Reader, req controlMessage) controlMessage {
Expand All @@ -214,8 +204,6 @@ func sftpOp(t *testing.T, stdin interface {
return msg
}

// trySFTPOp is sftpOp for a call the test expects might fail: it returns
// the error code (or "" on success) instead of failing the test itself.
func trySFTPOp(t *testing.T, stdin interface {
Write([]byte) (int, error)
}, out *bufio.Reader, req controlMessage) errorCode {
Expand Down
27 changes: 0 additions & 27 deletions cmd/meowshell/agent_tailcat_forward_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,6 @@ import (
"time"
)

// TestAgentForwardsThroughTailcatDestination proves the fix for the daemon's
// biggest forwarding gap: forward_local/forward_socks against a *tailcat*
// destination used to open successfully (the listener bound fine) but drop
// every accepted connection, since tailcat's own embedded SSH service never
// implements SSH-level forwarding (see forwarding.go's doc comment). Now
// forwardClient (tailcatdial.go) picks a native tailcat.Client instead of
// the SSH client for that case, dialing the same way tailcat's own
// "forward"/"socks" subcommands do. This drives both forward_local and
// forward_socks against a real (hermetic, local-DERP) tailcat server and
// checks actual bytes flow end to end -- not just that the channel opens.
func TestAgentForwardsThroughTailcatDestination(t *testing.T) {
tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64")
meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64")
Expand All @@ -30,20 +20,8 @@ func TestAgentForwardsThroughTailcatDestination(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
t.Setenv("TS_DEBUG_TAILCAT_LOCAL_DERP", "1")

// Forwarding to an arbitrary port needs the server started as an exit
// node (tailcat.Server.OnTCP only forwards a bare-port dial to its own
// services otherwise -- see cmd/tailcat/tailcat.go's own OnTCP, which
// serves 22 and any --files/--ssh-authorized-keys ports but sends a RST
// for anything else unless "exit-node" is one of its served services).
// meowshell serve's own --exit-node flag (main.go) requests exactly
// that -- unlike startE2EServer (used by the general daemon E2E tests),
// which starts a plain "no-auth-ssh" server with no forwarding at all.
addr := startE2EServerWithExitNode(t, tailcatBin, meowshellBin, home)

// Stands in for "a service running on the server": from the tailcat
// server's own point of view this is its own loopback, since the test
// server and this backend both run as this one test process's own
// child/local listeners on the same machine.
backendLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -143,11 +121,6 @@ func TestAgentForwardsThroughTailcatDestination(t *testing.T) {
})
}

// startE2EServerWithExitNode starts "meowshell serve --insecure-no-auth
// --exit-node" (so an agent can still connect at all, and its OnTCP
// handler also forwards any port, not just the ones its other services
// already listen on -- see this test's own comment at its call site) over
// a hermetic local DERP relay.
func startE2EServerWithExitNode(t *testing.T, tailcatBin, meowshellBin, home string) string {
t.Helper()
addrFile := filepath.Join(home, "addr")
Expand Down
Loading
Loading