diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a97dc24..8c7f12a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,30 @@ jobs: with: go-version: stable + # cmd/meowshell's go.mod replaces github.com/tailscale/tailcat with a + # local ./.tailcat-src checkout (see tailcatdial.go's doc comment for + # why meowshell imports tailcat's own package at all: forwarding + # through a tailcat destination dials via tailcat.Client, the same + # way tailcat's own "forward"/"socks" subcommands do), so go vet/go + # test need that directory to exist before either can even resolve + # imports. A plain, unpatched clone is enough here: the two patches + # below only change Android-specific networking behavior, irrelevant + # to vetting/testing meowshell on this runner's own host platform. + # ./build.sh (further down) later reuses and patches this same + # checkout for the real cross-compiled build. + - name: Fetch tailcat source (for go.mod's replace directive) + env: + SRC_REF: ${{ inputs.tailcat_ref || 'main' }} + run: | + git clone --depth=1 https://github.com/tailscale/tailcat.git .tailcat-src + git -C .tailcat-src fetch --depth=1 origin "$SRC_REF" + git -C .tailcat-src checkout --detach FETCH_HEAD + - name: Vet and test meowshell + env: + # tailcat's go.mod asks for a newer Go than setup-go's stable may + # be; let the toolchain fetch it rather than pinning a version here. + GOTOOLCHAIN: auto run: | go vet ./... go test ./... diff --git a/README.md b/README.md index 48c16c0..734c452 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,11 @@ wrapper around tailcat: an SFTP file service and forced-command sessions alongside the shell, a SOCKS5 proxy, TCP port forwarding, native interactive sessions and file transfer as a *client* too (no system `ssh`/`scp` needed, so this also works from inside an Android app), and one-shot operations for -key management, address inspection, and connectivity checks. See +key management, address inspection, and connectivity checks. A persistent +`MeowshellAgentConnection` goes further still: one login multiplexing a +shell, the full SFTP verb set, and port forwarding together, and the only +one of these that also reaches a general (non-tailcat) SSH host, with real +host-key verification and password/certificate/Keystore-backed auth. See [`dotnet/README.md`](dotnet/README.md) for the full surface, every option, and the one thing (a console-attached `ssh` client, as opposed to a programmatic session) an Android app sandbox can't run. diff --git a/cmd/meowshell/agent.go b/cmd/meowshell/agent.go new file mode 100644 index 0000000..0afb899 --- /dev/null +++ b/cmd/meowshell/agent.go @@ -0,0 +1,868 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/pkg/sftp" + "github.com/tailscale/tailcat" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "tailscale.com/types/key" +) + +const agentUsage = `meowshell agent -- a persistent, multiplexed SSH connection + +USAGE + meowshell agent [flags] + +Dials once and keeps the connection open, multiplexing shell and exec +channels over it via a framed control protocol on stdin/stdout (see +protocol.go) instead of the one-process-per-operation model "meowshell +connect"/"cp" use. Opening a shell and running a command against the same +host costs one login instead of two. + + is a tailcat address (dialed through tailcat's own bare +client mode, same as "connect"/"cp") or a "[user@]host[:port]" TCP address +for a general (non-tailcat) SSH host, verified against a known_hosts file +(--known-hosts) with trust-on-first-use for a host seen for the first time. +--jump chains through one or more intermediate TCP hosts first, each +verified the same way, before reaching . + +Driven by dotnet/Meowshell's MeowshellAgentConnection -- not meant to be +typed at directly. + + meowshell agent + meowshell agent user@bastion.example.com:2222 + meowshell agent --jump=user@bastion.example.com 10.0.0.5 +` + +// stringList collects a repeatable flag's values in the order given. +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ",") } +func (s *stringList) Set(v string) error { *s = append(*s, v); return nil } + +// agentCmd implements "meowshell agent": see agentUsage. +func agentCmd(args []string) error { + fs := flag.NewFlagSet("agent", flag.ExitOnError) + key := fs.String("key", "", "tailcat client key name or path") + tailcatBin := fs.String("tailcat", "", "path to the tailcat binary") + derpMapURL := fs.String("derpmap-url", "", "URL of the JSON DERP map to resolve a DERP region from, instead of tailcat's default. Passed to tailcat's own --derpmap-url") + verbose := fs.Bool("verbose", false, "passed to tailcat's own --verbose") + port := fs.String("p", "22", "port number of the destination's SSH service") + knownHosts := fs.String("known-hosts", "", "known_hosts file for TCP-transport host-key verification (default: $HOME/.meowshell/known_hosts)") + var jumps stringList + fs.Var(&jumps, "jump", "an intermediate TCP SSH host to tunnel through first ([user@]host[:port]); repeatable, in order, closest-to-here first") + fs.Usage = func() { fmt.Fprint(os.Stderr, agentUsage); fs.PrintDefaults() } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("agent needs exactly one destination") + } + dest := fs.Arg(0) + + // The tailcat binary is only needed at all when the final hop is a + // tailcat address -- --jump hops are always TCP (a bastion is a real + // SSH host, not something reachable through tailcat's own transport). + var bin string + if looksLikeTailcatAddress(dest) { + b, err := findTailcat(*tailcatBin) + if err != nil { + return err + } + bin = b + } + + khPath := *knownHosts + if khPath == "" { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + khPath = filepath.Join(home, ".meowshell", "known_hosts") + } + + session := newAgentSession(os.Stdin, os.Stdout) + + // The client's auth material (configure) has to be read synchronously, + // before serveFrames starts consuming stdin on its own goroutine -- + // it's the one message with nowhere else to come from, since no + // prompt has been raised yet for serveFrames' usual + // prompt_response dispatch to deliver it through. + cfg, err := session.readConfigure() + if err != nil { + session.writeError(0, errProtocolError, err) + return err + } + auth, err := session.buildAuthMethods(cfg) + if err != nil { + session.writeError(0, errAuthFailed, err) + return err + } + + frameErrCh := make(chan error, 1) + go func() { frameErrCh <- session.serveFrames() }() + + err = session.connect(context.Background(), connectOptions{ + destination: dest, + jumps: jumps, + tailcatBin: bin, + key: *key, + derpMapURL: *derpMapURL, + verbose: *verbose, + port: *port, + knownHostsPath: khPath, + proxyURL: cfg.ProxyURL, + auth: auth, + }) + if err != nil { + session.writeError(0, classifyConnectError(err), err) + return err + } + defer session.closeHops() + session.startKeepalive() + + if cfg.AgentForwarding && session.agentForwardSock != "" { + if err := agent.ForwardToRemote(session.client(), session.agentForwardSock); err != nil { + session.writeError(0, errUnknown, fmt.Errorf("setting up agent forwarding: %w", err)) + } else { + session.agentForwardingReady = true + } + } + + if err := session.writeControl(0, controlMessage{Msg: "connected"}); err != nil { + return err + } + return <-frameErrCh +} + +// classifyConnectError maps a connection-establishment failure to the +// typed error code a client can branch on. Coarse today (string-matched +// auth failures included) -- the full typed-error surface lands with the +// rest of phase 3, but a connection failure is common enough (a wrong +// password, an unreachable host) to be worth classifying now rather than +// leaving every one of them as errUnknown. +func classifyConnectError(err error) errorCode { + var hkChanged *hostKeyChangedError + if errors.As(err, &hkChanged) { + return errHostKeyChanged + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return errTimeout + } + var opErr *net.OpError + if errors.As(err, &opErr) { + return errNetworkUnreachable + } + if strings.Contains(err.Error(), "unable to authenticate") { + return errAuthFailed + } + return errUnknown +} + +// connectOptions is agentSession.connect's parameter set: everything about +// where to dial and how, gathered up so connect itself reads as the hop +// loop it actually is rather than a wall of individual arguments. +type connectOptions struct { + destination string + jumps []string + tailcatBin string + key string + derpMapURL string + verbose bool + port string + knownHostsPath string + proxyURL string // SOCKS5 or HTTP CONNECT proxy for the first TCP hop only; see proxyDialer. From the configure message, not a flag -- proxy credentials never belong on this process's own argv. + auth []ssh.AuthMethod // from buildAuthMethods; every hop dials with the same auth list +} + +// agentChannel is one multiplexed shell/exec session, SFTP transfer, or +// port-forward listener, live for as long as its own operation runs. +// Exactly one of session, sftpFile, or listener is set, identifying which +// kind this is; the others are that kind's own extra state. +type agentChannel struct { + // shell/exec + session *ssh.Session + stdin io.WriteCloser + + // sftp_upload/sftp_download (agentsftp.go) + sftpFile *sftp.File + ctx context.Context + cancel context.CancelFunc + isUpload bool + uploadPath string + uploadPreserve bool + uploadMode uint32 + uploadModTime int64 + + // forward_local/forward_remote/forward_socks (forwarding.go): closing + // the listener stops accepting new forwarded connections; connections + // already in flight finish or fail on their own. + listener net.Listener +} + +// agentSession serves the control protocol for one agent connection: reads +// frames from in, dispatches them, and writes replies/channel data to out. +// Every write to out goes through outMu, since channels' output pumps and +// the main read loop can all be writing concurrently. +// +// The SSH connection itself is built by connect, which runs on the same +// goroutine as agentCmd's caller while serveFrames already runs on its own +// -- necessarily concurrent, since a host-key or auth prompt raised deep +// inside connect's dial needs the very same stdin serveFrames is reading +// to receive its answer. scPtr is what makes that safe to publish across +// goroutines: nil until connect succeeds, then set once. +type agentSession struct { + scPtr atomic.Pointer[ssh.Client] + hops []*ssh.Client // every hop's client, including the final one scPtr points at; closed in reverse on shutdown + + in io.Reader + out io.Writer + + outMu sync.Mutex + + chansMu sync.Mutex + chans map[uint32]*agentChannel + nextID atomic.Uint32 + + sftpMu sync.Mutex + sftpClient *sftp.Client // lazily opened by sftpClientFor (agentsftp.go), shared across every ls/stat/.../upload/download + + promptsMu sync.Mutex + prompts map[string]chan controlMessage + nextPromptID atomic.Uint64 + + // Auth-related state buildAuthMethods/agentCmd populate before connect + // runs and later reads: agentForwardSock is the local ssh-agent socket + // (if one was found and configure didn't disable it) ForwardToRemote + // needs; agentForwardingReady is set once that forwarding is actually + // wired up on the connected client, gating whether openChannel asks + // for it per session. + agentForwardSock string + agentForwardingReady bool + + // Set by connect when the final hop is a tailcat address (never for a + // --jump hop or a general SSH host) -- what forwardClient needs to + // build a native tailcat.Client for forward_local/forward_socks. + tcAddr tailcat.Addr + tcKey key.NodePrivate + tcDERPMapURL string + + tcMu sync.Mutex + tcClient *tailcat.Client // lazily built by forwardClient; most connections never open a forward at all +} + +func newAgentSession(in io.Reader, out io.Writer) *agentSession { + return &agentSession{ + in: in, + out: out, + chans: make(map[uint32]*agentChannel), + prompts: make(map[string]chan controlMessage), + } +} + +func (a *agentSession) client() *ssh.Client { return a.scPtr.Load() } + +// forwardClient returns the Dial-shaped client forward_local/forward_socks +// should dial through: the existing *ssh.Client for a general SSH host +// (SSH direct-tcpip -- unavailable against tailcat's own embedded SSH +// service, see forwarding.go's doc comment), or, when the destination is a +// tailcat address, a native tailcat.Client instead, since forwarding +// through a tailcat server was never an SSH feature there in the first +// place (see tailcat's own "forward"/"socks" subcommands). Lazily built +// and cached: most connections never open a forward at all, and building +// one starts a real WireGuard session. +func (a *agentSession) forwardClient() interface { + Dial(network, addr string) (net.Conn, error) +} { + if a.tcAddr == "" { + return a.client() + } + a.tcMu.Lock() + defer a.tcMu.Unlock() + if a.tcClient == nil { + a.tcClient = &tailcat.Client{ + Server: a.tcAddr, + Key: a.tcKey, + DERPMapURL: a.tcDERPMapURL, + } + } + return &tailcatForwardClient{cl: a.tcClient} +} + +// readConfigure reads the client's mandatory first message, synchronously, +// before serveFrames starts consuming a.in on its own goroutine -- see the +// comment at its one call site in agentCmd for why that ordering matters. +func (a *agentSession) readConfigure() (controlMessage, error) { + f, err := readFrame(a.in) + if err != nil { + return controlMessage{}, fmt.Errorf("reading configure: %w", err) + } + if f.Type != frameTypeControl { + return controlMessage{}, fmt.Errorf("expected a configure control frame, got a data frame") + } + var msg controlMessage + if err := json.Unmarshal(f.Payload, &msg); err != nil { + return controlMessage{}, fmt.Errorf("decoding configure: %w", err) + } + if msg.Msg != "configure" { + return controlMessage{}, fmt.Errorf("expected %q as the first message, got %q", "configure", msg.Msg) + } + return msg, nil +} + +// connect dials destination (and any --jump hops before it), completing +// the SSH handshake for each hop in turn, and publishes the final client +// via scPtr once every hop succeeds. Only the last hop may be a tailcat +// address; --jump hops are always TCP, verified with a real host-key +// callback (see hostkeys.go) since there is no WireGuard-authenticated +// peer to lean on for them the way there is for tailcat's own transport. +func (a *agentSession) connect(ctx context.Context, opts connectOptions) error { + hops := append(append([]string{}, opts.jumps...), opts.destination) + var chain []*ssh.Client + var current *ssh.Client + + for i, hop := range hops { + last := i == len(hops)-1 + var dial dialer + var hkCallback ssh.HostKeyCallback + remoteAddr := "tailcat" + user := "" + + if last && looksLikeTailcatAddress(hop) { + dial = tailcatDialer(opts.tailcatBin, tailcatClientArgv(opts.key, opts.derpMapURL, opts.verbose, hop, opts.port)) + hkCallback = tailcatHostKeyCallback() + tcKey, err := tailcatKeyFromName(opts.key) + if err != nil { + closeClients(chain) + return fmt.Errorf("resolving --key %q for forwarding: %w", opts.key, err) + } + a.tcAddr = tailcat.Addr(hop) + a.tcKey = tcKey + a.tcDERPMapURL = opts.derpMapURL + } else { + var hostPort string + user, hostPort = splitUserHost(hop, opts.port) + remoteAddr = hostPort + switch { + case current != nil: + dial = jumpDialer(current, hostPort) + case opts.proxyURL != "": + pd, err := proxyDialer(opts.proxyURL, hostPort) + if err != nil { + closeClients(chain) + return err + } + dial = pd + default: + dial = tcpDialer(hostPort) + } + cb, err := tcpHostKeyCallback(opts.knownHostsPath, a.promptHostKey) + if err != nil { + closeClients(chain) + return err + } + hkCallback = cb + } + + sc, err := dialSSHClient(ctx, dial, remoteAddr, user, hkCallback, opts.auth) + if err != nil { + closeClients(chain) + return fmt.Errorf("connecting to %s: %w", hop, err) + } + chain = append(chain, sc) + current = sc + } + + a.hops = chain + a.scPtr.Store(current) + return nil +} + +func closeClients(clients []*ssh.Client) { + for i := len(clients) - 1; i >= 0; i-- { + clients[i].Close() + } +} + +func (a *agentSession) closeHops() { + if a.sftpClient != nil { + a.sftpClient.Close() + } + a.tcMu.Lock() + if a.tcClient != nil { + a.tcClient.Close() + } + a.tcMu.Unlock() + closeClients(a.hops) +} + +const ( + keepaliveInterval = 30 * time.Second + keepaliveTimeout = 15 * time.Second +) + +// startKeepalive sends an OpenSSH-style keepalive request on an interval +// for as long as the connection lives, so a mobile carrier's NAT binding +// (or any other idle-connection timeout along the path) doesn't silently +// drop a connection nothing has sent traffic on in a while. A request that +// doesn't get answered within keepaliveTimeout is treated as a dead peer: +// reported to the client and the connection torn down, rather than left to +// hang indefinitely. +func (a *agentSession) startKeepalive() { + go func() { + ticker := time.NewTicker(keepaliveInterval) + defer ticker.Stop() + for range ticker.C { + client := a.client() + if client == nil { + return + } + result := make(chan error, 1) + go func() { + _, _, err := client.SendRequest("keepalive@openssh.com", true, nil) + result <- err + }() + select { + case err := <-result: + if err != nil { + a.reportConnectionLost(fmt.Errorf("keepalive: %w", err)) + return + } + case <-time.After(keepaliveTimeout): + a.reportConnectionLost(fmt.Errorf("keepalive: no response within %s", keepaliveTimeout)) + return + } + } + }() +} + +// reportConnectionLost tells the client the connection is dead and closes +// it, so anything still blocked on it (a channel read, a pending SFTP +// request) unblocks instead of hanging on a peer that will never answer. +func (a *agentSession) reportConnectionLost(err error) { + a.writeError(0, errConnectionLost, err) + if client := a.client(); client != nil { + client.Close() + } +} + +// promptHostKey is the hostKeyPrompter connect passes to tcpHostKeyCallback: +// a TOFU decision round-tripped to the client over the control channel. +func (a *agentSession) promptHostKey(hostname string, remote net.Addr, key ssh.PublicKey) (bool, error) { + resp, err := a.prompt(controlMessage{ + PromptKind: "host_key", + Remote: hostname, + Fingerprint: fingerprintSHA256(key), + }) + if err != nil { + return false, err + } + if resp.Cancelled { + return false, fmt.Errorf("host key prompt for %s was cancelled", hostname) + } + return resp.Accept, nil +} + +// prompt sends a prompt_request and blocks for the client's matching +// prompt_response, delivered by serveFrames (running concurrently on its +// own goroutine -- see the agentSession doc comment on why that matters). +func (a *agentSession) prompt(msg controlMessage) (controlMessage, error) { + id := fmt.Sprintf("p%d", a.nextPromptID.Add(1)) + msg.Msg = "prompt_request" + msg.RequestID = id + + ch := make(chan controlMessage, 1) + a.promptsMu.Lock() + a.prompts[id] = ch + a.promptsMu.Unlock() + defer func() { + a.promptsMu.Lock() + delete(a.prompts, id) + a.promptsMu.Unlock() + }() + + if err := a.writeControl(0, msg); err != nil { + return controlMessage{}, err + } + resp, ok := <-ch + if !ok { + return controlMessage{}, fmt.Errorf("connection closed while waiting for a prompt response") + } + return resp, nil +} + +func (a *agentSession) writeControl(channelID uint32, msg controlMessage) error { + body, err := json.Marshal(msg) + if err != nil { + return err + } + a.outMu.Lock() + defer a.outMu.Unlock() + return writeFrame(a.out, frame{Type: frameTypeControl, ChannelID: channelID, Payload: body}) +} + +func (a *agentSession) writeData(channelID uint32, stream byte, p []byte) error { + payload := make([]byte, 1+len(p)) + payload[0] = stream + copy(payload[1:], p) + a.outMu.Lock() + defer a.outMu.Unlock() + return writeFrame(a.out, frame{Type: frameTypeData, ChannelID: channelID, Payload: payload}) +} + +func (a *agentSession) writeError(channelID uint32, code errorCode, err error) error { + return a.writeControl(channelID, controlMessage{Msg: "error", Code: code, Message: err.Error()}) +} + +// serveFrames reads frames from a.in until the client closes its end (a +// clean shutdown, reported as nil) or a frame-level protocol error makes +// the stream unrecoverable. Runs for the whole process lifetime, starting +// before connect even dials -- see the agentSession doc comment. +func (a *agentSession) serveFrames() error { + defer a.closeAllChannels() + defer a.closeAllPrompts() + for { + f, err := readFrame(a.in) + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return fmt.Errorf("reading control frame: %w", err) + } + switch f.Type { + case frameTypeControl: + a.handleControl(f.ChannelID, f.Payload) + case frameTypeData: + a.handleData(f.ChannelID, f.Payload) + default: + a.writeError(f.ChannelID, errProtocolError, fmt.Errorf("unknown frame type %d", f.Type)) + } + } +} + +func (a *agentSession) handleControl(channelID uint32, payload []byte) { + var msg controlMessage + if err := json.Unmarshal(payload, &msg); err != nil { + a.writeError(channelID, errProtocolError, err) + return + } + if msg.Msg == "prompt_response" { + a.deliverPromptResponse(msg) + return + } + if a.client() == nil { + a.writeError(channelID, errProtocolError, fmt.Errorf("message %q sent before the connection was ready", msg.Msg)) + return + } + switch msg.Msg { + case "open_channel": + a.openChannel(msg) + case "resize": + a.resize(channelID, msg) + case "close_channel": + a.closeChannel(channelID) + case "sftp_op": + a.sftpOp(msg) + default: + a.writeError(channelID, errProtocolError, fmt.Errorf("unknown message %q", msg.Msg)) + } +} + +func (a *agentSession) deliverPromptResponse(msg controlMessage) { + a.promptsMu.Lock() + ch := a.prompts[msg.RequestID] + a.promptsMu.Unlock() + if ch != nil { + ch <- msg + } +} + +// handleData writes an incoming data frame to the target channel: a +// shell/exec channel's remote stdin, or an sftp_upload channel's remote +// file. There is no stream tag to interpret here (unlike an outgoing data +// frame): everything the client sends is keystrokes/command input or +// upload bytes, never something split across two streams. +func (a *agentSession) handleData(channelID uint32, payload []byte) { + ch := a.channel(channelID) + if ch == nil { + return // channel already closed; nothing left to write to + } + switch { + case ch.stdin != nil: + ch.stdin.Write(payload) + case ch.sftpFile != nil: + if _, err := ch.sftpFile.Write(payload); err != nil { + a.writeError(channelID, classifySFTPError(err), err) + } + } +} + +func (a *agentSession) channel(id uint32) *agentChannel { + a.chansMu.Lock() + defer a.chansMu.Unlock() + return a.chans[id] +} + +// openChannel dispatches an open_channel request by kind: "shell"/"exec" +// here (an SSH session), "sftp_upload"/"sftp_download" to agentsftp.go, +// and "forward_local"/"forward_remote"/"forward_socks" to forwarding.go. +func (a *agentSession) openChannel(msg controlMessage) { + switch msg.Kind { + case "sftp_upload", "sftp_download": + a.openSFTPChannel(msg) + return + case "forward_local", "forward_remote", "forward_socks": + a.openForwardChannel(msg) + return + } + a.openShellChannel(msg) +} + +// openShellChannel opens a new SSH session on the shared client for a +// shell (no command) or exec (a command) request, wires its +// stdin/stdout/stderr into the framed protocol, and starts it running. +// wantPty mirrors connect.go's own default (a pseudo-terminal unless the +// request is a command that explicitly declined one). +func (a *agentSession) openShellChannel(msg controlMessage) { + session, err := a.client().NewSession() + if err != nil { + a.writeError(0, errUnknown, fmt.Errorf("opening session: %w", err)) + return + } + + wantPty := msg.Kind == "shell" + if msg.Pty != nil { + wantPty = *msg.Pty + } + if wantPty { + cols, rows := msg.Cols, msg.Rows + if cols <= 0 { + cols = 80 + } + if rows <= 0 { + rows = 24 + } + term := msg.Term + if term == "" { + term = "xterm-256color" + } + if err := session.RequestPty(term, rows, cols, ssh.TerminalModes{}); err != nil { + session.Close() + a.writeError(0, errUnknown, fmt.Errorf("requesting a pseudo-terminal: %w", err)) + return + } + } + + stdin, err := session.StdinPipe() + if err != nil { + session.Close() + a.writeError(0, errUnknown, fmt.Errorf("opening remote stdin: %w", err)) + return + } + stdout, err := session.StdoutPipe() + if err != nil { + session.Close() + a.writeError(0, errUnknown, fmt.Errorf("opening remote stdout: %w", err)) + return + } + stderr, err := session.StderrPipe() + if err != nil { + session.Close() + a.writeError(0, errUnknown, fmt.Errorf("opening remote stderr: %w", err)) + return + } + + id := a.nextID.Add(1) + ch := &agentChannel{session: session, stdin: stdin} + a.chansMu.Lock() + a.chans[id] = ch + a.chansMu.Unlock() + + // channel_opened must reach the client before anything else naming this + // id can: a fast remote command (nothing unusual -- a local test server + // hits this every time on loopback) can produce output and even exit + // before this function would otherwise get around to announcing the id + // at all, so the client needs it in hand before Start/Shell runs, not + // after -- otherwise pumpToClient below could write data (or + // waitChannel an exit_status) for an id the client hasn't been told + // about yet. + if err := a.writeControl(id, controlMessage{Msg: "channel_opened"}); err != nil { + a.removeChannel(id) + session.Close() + return + } + + if a.agentForwardingReady { + if err := agent.RequestAgentForwarding(session); err != nil { + a.writeError(id, errUnknown, fmt.Errorf("requesting agent forwarding: %w", err)) + } + } + + switch msg.Kind { + case "exec": + // Plain space-join, no quoting -- exactly what a real ssh client + // sends too (connect.go documents this same choice in detail). + err = session.Start(strings.Join(msg.Command, " ")) + default: + err = session.Shell() + } + if err != nil { + a.removeChannel(id) + session.Close() + a.writeError(id, errUnknown, fmt.Errorf("starting session: %w", err)) + return + } + + // session.Wait (in waitChannel) is not synchronized with StdoutPipe/ + // StderrPipe's own internal buffering -- golang.org/x/crypto/ssh can + // report the exit-status request before a pumpToClient goroutine has + // been scheduled to drain the last of what's sitting in a pipe ahead + // of it, which reordered exit_status before its own channel's last + // data frame in practice (caught by a fake SSH server fast enough to + // expose it: pumpSFTPDownload has no such race, since it IS the + // reader driving its own completion signal). wg makes waitChannel + // block until both pumps have reached EOF -- which, for a well-behaved + // remote, only happens once the channel itself is done sending data -- + // before it ever calls Wait, so every data frame is written first. + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); a.pumpToClient(id, streamStdout, stdout) }() + go func() { defer wg.Done(); a.pumpToClient(id, streamStderr, stderr) }() + go a.waitChannel(id, ch, &wg) +} + +// pumpToClient relays one of a channel's remote output streams to the +// client as data frames until the pipe closes (the session ending, or the +// channel being closed out from under it). +func (a *agentSession) pumpToClient(id uint32, stream byte, r io.Reader) { + buf := make([]byte, 32*1024) + for { + n, err := r.Read(buf) + if n > 0 { + if werr := a.writeData(id, stream, buf[:n]); werr != nil { + return + } + } + if err != nil { + return + } + } +} + +// waitChannel reports the channel's outcome as a typed exit_status once the +// remote session ends, replacing connect.go's os.Exit(status) (fine for a +// one-shot process, but this one serves many channels and must never exit +// the whole agent over one of them) with a structured field the client can +// inspect. Waits for wg (both output pumps having reached EOF) first -- see +// the ordering comment at this function's one call site. +func (a *agentSession) waitChannel(id uint32, ch *agentChannel, wg *sync.WaitGroup) { + wg.Wait() + err := ch.session.Wait() + exitCode := 0 + var exitErr *ssh.ExitError + switch { + case err == nil: + exitCode = 0 + case errors.As(err, &exitErr): + exitCode = exitErr.ExitStatus() + default: + a.writeError(id, errConnectionLost, err) + a.removeChannel(id) + return + } + a.writeControl(id, controlMessage{Msg: "exit_status", ExitCode: exitCode}) + a.removeChannel(id) +} + +func (a *agentSession) resize(channelID uint32, msg controlMessage) { + ch := a.channel(channelID) + if ch == nil || ch.session == nil { + return // not a shell/exec channel (or already closed); resize is meaningless for the others + } + if msg.Cols <= 0 || msg.Rows <= 0 { + a.writeError(channelID, errProtocolError, fmt.Errorf("resize needs positive cols/rows, got %dx%d", msg.Cols, msg.Rows)) + return + } + if err := ch.session.WindowChange(msg.Rows, msg.Cols); err != nil { + a.writeError(channelID, errUnknown, fmt.Errorf("resizing: %w", err)) + } +} + +// closeChannel ends channelID's operation, however that channel kind ends +// one: a shell/exec session simply closes; an sftp_upload finalizes (see +// finalizeUpload -- close_channel is its "no more bytes coming" signal, +// not just cleanup); an in-progress sftp_download's context is cancelled, +// letting pumpSFTPDownload report and clean up on its own; a forward's +// listener closes, ending new connections (ones already in flight finish +// or fail on their own). +func (a *agentSession) closeChannel(channelID uint32) { + ch := a.removeChannel(channelID) + if ch == nil { + return + } + switch { + case ch.session != nil: + ch.session.Close() + case ch.sftpFile != nil && ch.isUpload: + a.finalizeUpload(channelID, ch) + case ch.sftpFile != nil: + if ch.cancel != nil { + ch.cancel() + } + case ch.listener != nil: + ch.listener.Close() + } +} + +func (a *agentSession) removeChannel(id uint32) *agentChannel { + a.chansMu.Lock() + defer a.chansMu.Unlock() + ch := a.chans[id] + delete(a.chans, id) + return ch +} + +func (a *agentSession) closeAllChannels() { + a.chansMu.Lock() + chans := a.chans + a.chans = make(map[uint32]*agentChannel) + a.chansMu.Unlock() + for _, ch := range chans { + switch { + case ch.session != nil: + ch.session.Close() + case ch.sftpFile != nil: + if ch.cancel != nil { + ch.cancel() + } + ch.sftpFile.Close() + case ch.listener != nil: + ch.listener.Close() + } + } +} + +func (a *agentSession) closeAllPrompts() { + a.promptsMu.Lock() + prompts := a.prompts + a.prompts = make(map[string]chan controlMessage) + a.promptsMu.Unlock() + for _, ch := range prompts { + close(ch) + } +} diff --git a/cmd/meowshell/agent_auth_e2e_test.go b/cmd/meowshell/agent_auth_e2e_test.go new file mode 100644 index 0000000..a23bb1c --- /dev/null +++ b/cmd/meowshell/agent_auth_e2e_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "net" + "os" + "path/filepath" + "testing" + + "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") + if err != nil { + t.Fatal(err) + } + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatal(err) + } + cfg := &ssh.ServerConfig{} + configure(cfg) + cfg.AddHostKey(signer) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go serveTestSSHConn(conn, cfg, echoCommandHandler) + } + }() + t.Cleanup(func() { ln.Close() }) + 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) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatal(err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + 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) + msg := decodeControl(t, f) + if msg.Msg != "prompt_request" || msg.PromptKind != "host_key" { + t.Fatalf("message = %+v, want a host_key prompt_request", msg) + } + send(t, stdin, 0, controlMessage{Msg: "prompt_response", RequestID: msg.RequestID, Accept: true}) +} + +func TestAgentPasswordAuth(t *testing.T) { + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + + addr, _ := startAuthTestSSHServer(t, func(cfg *ssh.ServerConfig) { + cfg.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if string(password) == "correct-horse" { + return nil, nil + } + return nil, errors.New("wrong password") + } + }) + knownHosts := filepath.Join(t.TempDir(), "known_hosts") + + t.Run("correct password authenticates", func(t *testing.T) { + cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) + defer stopAgent(t, cmd, stdin) + + acceptHostKeyPrompt(t, stdin, out) + + f := mustReadFrame(t, out) + msg := decodeControl(t, f) + if msg.Msg != "prompt_request" || msg.PromptKind != "password" { + t.Fatalf("message = %+v, want a password prompt_request", msg) + } + send(t, stdin, 0, controlMessage{Msg: "prompt_response", RequestID: msg.RequestID, Answer: "correct-horse"}) + + expectConnected(t, out) + }) + + t.Run("wrong password is refused as a typed auth failure", func(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" { + t.Fatalf("message = %+v, want a password prompt_request", msg) + } + send(t, stdin, 0, controlMessage{Msg: "prompt_response", RequestID: msg.RequestID, Answer: "wrong"}) + + f = mustReadFrame(t, out) + msg = decodeControl(t, f) + if msg.Msg != "error" || msg.Code != errAuthFailed { + t.Fatalf("message = %+v, want an error with code %q", msg, errAuthFailed) + } + }) +} + +func TestAgentSuppliedPrivateKeyAuth(t *testing.T) { + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + privatePEM, publicKey := newTestKeyPair(t) + + addr, _ := startAuthTestSSHServer(t, func(cfg *ssh.ServerConfig) { + cfg.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if bytes.Equal(key.Marshal(), publicKey.Marshal()) { + return nil, nil + } + return nil, errors.New("unknown public key") + } + }) + knownHosts := filepath.Join(t.TempDir(), "known_hosts") + + t.Run("the matching key authenticates without any prompt beyond TOFU", func(t *testing.T) { + cmd, stdin, out := startAgentConfigured(t, meowshellBin, knownHosts, "testuser@"+addr, + controlMessage{Msg: "configure", Keys: [][]byte{privatePEM}}) + defer stopAgent(t, cmd, stdin) + + acceptHostKeyPrompt(t, stdin, out) + expectConnected(t, out) + }) + + t.Run("a non-matching key is refused", func(t *testing.T) { + wrongPEM, _ := newTestKeyPair(t) + cmd, stdin, out := startAgentConfigured(t, meowshellBin, knownHosts, "testuser@"+addr, + 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 { + t.Fatalf("message = %+v, want an error with code %q", msg, errAuthFailed) + } + }) +} diff --git a/cmd/meowshell/agent_e2e_test.go b/cmd/meowshell/agent_e2e_test.go new file mode 100644 index 0000000..7643c3f --- /dev/null +++ b/cmd/meowshell/agent_e2e_test.go @@ -0,0 +1,326 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "testing" + "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") + + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("TS_DEBUG_TAILCAT_LOCAL_DERP", "1") + + addr := startE2EServer(t, tailcatBin, meowshellBin, home) + + cmd := exec.Command(meowshellBin, "agent", "--tailcat="+tailcatBin, addr) + cmd.Env = append(os.Environ(), "TAILCAT_BIN="+tailcatBin) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatalf("starting meowshell agent: %v", err) + } + t.Cleanup(func() { + stdin.Close() + cmd.Wait() + if t.Failed() { + t.Logf("agent stderr:\n%s", stderr.String()) + } + }) + + out := bufio.NewReader(stdout) + send(t, stdin, 0, controlMessage{Msg: "configure"}) + expectConnected(t, out) + + t.Run("exec channel runs a command and reports exit status", func(t *testing.T) { + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "exec", Command: []string{"echo", "hello-from-agent-e2e"}}) + id := expectChannelOpened(t, out) + + got := readUntilExit(t, out, id) + if !bytes.Contains(got, []byte("hello-from-agent-e2e")) { + t.Errorf("exec output = %q, want it to contain the echoed marker", got) + } + }) + + 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) + + exitCode := readExitOnly(t, out, id) + if exitCode != 42 { + t.Errorf("exit code = %d, want 42", exitCode) + } + }) + + t.Run("shell channel accepts input, resizes, and exits on stdin close", func(t *testing.T) { + pty := true + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "shell", Pty: &pty, Cols: 80, Rows: 24}) + id := expectChannelOpened(t, out) + + send(t, stdin, id, controlMessage{Msg: "resize", Cols: 120, Rows: 40}) + + mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: []byte("echo shell-marker-e2e\n")}) + + got := readUntil(t, out, id, "shell-marker-e2e", 20*time.Second) + if !bytes.Contains(got, []byte("shell-marker-e2e")) { + t.Errorf("shell output = %q, want it to contain the echoed marker", got) + } + + mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: []byte("exit\n")}) + readUntilExit(t, out, id) + }) +} + +// findE2EBinary locates a real binary for the e2e test to drive: an +// explicit env var override, falling back to dist/ 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 != "" { + return p + } + p := filepath.Join("..", "..", "dist", distName) + if _, err := os.Stat(p); err != nil { + t.Skipf("no %s (looked for $%s and %s); build.sh must run first", distName, envVar, p) + } + abs, err := filepath.Abs(p) + if err != nil { + t.Fatal(err) + } + 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") + cmd := exec.Command(meowshellBin, "serve", "--insecure-no-auth", "--tailcat="+tailcatBin) + cmd.Env = append(os.Environ(), + "TAILCAT_BIN="+tailcatBin, + "TAILCAT_ADDR_FILE="+addrFile, + "HOME="+home, + "TS_DEBUG_TAILCAT_LOCAL_DERP=1", + ) + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Start(); err != nil { + t.Fatalf("starting meowshell serve: %v", err) + } + t.Cleanup(func() { + cmd.Process.Kill() + cmd.Wait() + if t.Failed() { + t.Logf("server log:\n%s", out.String()) + } + }) + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(addrFile); err == nil && len(data) > 0 { + return string(bytes.TrimSpace(data)) + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("server never published an address; log:\n%s", out.String()) + return "" +} + +func send(t *testing.T, w io.Writer, channelID uint32, msg controlMessage) { + t.Helper() + body, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + mustWriteFrame(t, w, frame{Type: frameTypeControl, ChannelID: channelID, Payload: body}) +} + +func mustWriteFrame(t *testing.T, w io.Writer, f frame) { + t.Helper() + if err := writeFrame(w, f); err != nil { + t.Fatalf("writeFrame: %v", err) + } +} + +// 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) + if err != nil { + t.Fatalf("reading the connection handshake: %v", err) + } + var msg controlMessage + if err := json.Unmarshal(f.Payload, &msg); err != nil { + t.Fatalf("decoding the connection handshake: %v", err) + } + switch msg.Msg { + case "connected": + return + case "error": + t.Fatalf("agent failed to connect: %s: %s", msg.Code, msg.Message) + default: + t.Fatalf("unexpected first message %q, want \"connected\"", msg.Msg) + } +} + +// 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 { + f, err := readFrameWithDeadline(t, r) + if err != nil { + t.Fatalf("reading channel_opened: %v", err) + } + if f.Type != frameTypeControl { + continue + } + var msg controlMessage + if err := json.Unmarshal(f.Payload, &msg); err != nil { + t.Fatalf("decoding control message: %v", err) + } + switch msg.Msg { + case "channel_opened": + return f.ChannelID + case "error": + t.Fatalf("agent returned an error opening the channel: %s: %s", msg.Code, msg.Message) + } + } +} + +// 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 + for { + f, err := readFrameWithDeadline(t, r) + if err != nil { + t.Fatalf("reading channel %d: %v", id, err) + } + if f.ChannelID != id { + continue + } + switch f.Type { + case frameTypeData: + buf.Write(f.Payload[1:]) // drop the stream tag + case frameTypeControl: + var msg controlMessage + if err := json.Unmarshal(f.Payload, &msg); err != nil { + t.Fatalf("decoding control message: %v", err) + } + switch msg.Msg { + case "exit_status": + return buf.Bytes() + case "error": + t.Fatalf("channel %d errored: %s: %s", id, msg.Code, msg.Message) + } + } + } +} + +// 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 { + f, err := readFrameWithDeadline(t, r) + if err != nil { + t.Fatalf("reading channel %d: %v", id, err) + } + if f.ChannelID != id || f.Type != frameTypeControl { + continue + } + var msg controlMessage + if err := json.Unmarshal(f.Payload, &msg); err != nil { + t.Fatalf("decoding control message: %v", err) + } + if msg.Msg == "exit_status" { + return msg.ExitCode + } + if msg.Msg == "error" { + t.Fatalf("channel %d errored: %s: %s", id, msg.Code, msg.Message) + } + } +} + +// 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) + var buf bytes.Buffer + for time.Now().Before(deadline) { + f, err := readFrameWithDeadline(t, r) + if err != nil { + t.Fatalf("reading channel %d: %v", id, err) + } + if f.ChannelID != id || f.Type != frameTypeData { + continue + } + buf.Write(f.Payload[1:]) + if bytes.Contains(buf.Bytes(), []byte(want)) { + return buf.Bytes() + } + } + t.Fatalf("timed out waiting for %q on channel %d; got %q", want, id, buf.Bytes()) + 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 { + f frame + err error + } + ch := make(chan result, 1) + go func() { + f, err := readFrame(r) + ch <- result{f, err} + }() + select { + case res := <-ch: + return res.f, res.err + case <-time.After(30 * time.Second): + return frame{}, fmt.Errorf("timed out reading a frame") + } +} diff --git a/cmd/meowshell/agent_forward_e2e_test.go b/cmd/meowshell/agent_forward_e2e_test.go new file mode 100644 index 0000000..7220990 --- /dev/null +++ b/cmd/meowshell/agent_forward_e2e_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + "io" + "net" + "path/filepath" + "testing" + "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") + + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer backendLn.Close() + const backendReply = "hello from the forwarded backend" + go func() { + for { + c, err := backendLn.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + io.WriteString(c, backendReply) + }() + } + }() + + addr, _, _ := startTestSSHServer(t, echoCommandHandler) // the SSH server whose Dial reaches backendLn + knownHosts := filepath.Join(t.TempDir(), "known_hosts") + + cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) + defer stopAgent(t, cmd, stdin) + acceptHostKeyPrompt(t, stdin, out) + expectConnected(t, out) + + send(t, stdin, 0, controlMessage{ + Msg: "open_channel", Kind: "forward_local", + ListenAddr: "127.0.0.1:0", RemoteAddr: backendLn.Addr().String(), + }) + f := mustReadFrame(t, out) + opened := decodeControl(t, f) + if opened.Msg != "channel_opened" || opened.BoundAddr == "" { + t.Fatalf("channel_opened = %+v, want a non-empty BoundAddr", opened) + } + + conn, err := net.DialTimeout("tcp", opened.BoundAddr, 5*time.Second) + if err != nil { + t.Fatalf("dialing the forwarded local listener: %v", err) + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + got, err := io.ReadAll(conn) + if err != nil { + t.Fatalf("reading through the forward: %v", err) + } + if string(got) != backendReply { + t.Errorf("got %q through the forward, want %q", got, backendReply) + } +} + +// 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") + + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer backendLn.Close() + const backendReply = "hello via socks" + go func() { + for { + c, err := backendLn.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + io.WriteString(c, backendReply) + }() + } + }() + + addr, _, _ := startTestSSHServer(t, echoCommandHandler) + knownHosts := filepath.Join(t.TempDir(), "known_hosts") + + cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) + defer stopAgent(t, cmd, stdin) + acceptHostKeyPrompt(t, stdin, out) + expectConnected(t, out) + + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "forward_socks", ListenAddr: "127.0.0.1:0"}) + f := mustReadFrame(t, out) + opened := decodeControl(t, f) + if opened.Msg != "channel_opened" || opened.BoundAddr == "" { + t.Fatalf("channel_opened = %+v, want a non-empty BoundAddr", opened) + } + + conn, err := net.DialTimeout("tcp", opened.BoundAddr, 5*time.Second) + if err != nil { + t.Fatalf("dialing the SOCKS listener: %v", err) + } + defer conn.Close() + got, err := socks5Connect(conn, backendLn.Addr().String()) + if err != nil { + t.Fatalf("SOCKS5 CONNECT: %v", err) + } + if got != backendReply { + t.Errorf("got %q through SOCKS, want %q", got, backendReply) + } +} + +// 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 + } + reply := make([]byte, 2) + if _, err := io.ReadFull(conn, reply); err != nil { + return "", err + } + if reply[0] != 0x05 || reply[1] != 0x00 { + return "", fmt.Errorf("unexpected method-selection reply %v", reply) + } + + host, portStr, err := net.SplitHostPort(target) + if err != nil { + return "", err + } + var port int + fmt.Sscanf(portStr, "%d", &port) + + req := []byte{0x05, 0x01, 0x00, 0x03, byte(len(host))} + req = append(req, host...) + req = append(req, byte(port>>8), byte(port)) + if _, err := conn.Write(req); err != nil { + return "", err + } + respHdr := make([]byte, 4) + if _, err := io.ReadFull(conn, respHdr); err != nil { + return "", err + } + if respHdr[1] != 0x00 { + return "", fmt.Errorf("SOCKS5 CONNECT failed, reply code %d", respHdr[1]) + } + switch respHdr[3] { + case 0x01: + io.CopyN(io.Discard, conn, 4+2) + case 0x03: + lenBuf := make([]byte, 1) + io.ReadFull(conn, lenBuf) + io.CopyN(io.Discard, conn, int64(lenBuf[0])+2) + case 0x04: + io.CopyN(io.Discard, conn, 16+2) + } + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + got, err := io.ReadAll(conn) + return string(got), err +} diff --git a/cmd/meowshell/agent_security_e2e_test.go b/cmd/meowshell/agent_security_e2e_test.go new file mode 100644 index 0000000..2025444 --- /dev/null +++ b/cmd/meowshell/agent_security_e2e_test.go @@ -0,0 +1,357 @@ +package main + +import ( + "fmt" + "io" + "net" + "os" + "path/filepath" + "testing" + "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") + + addr, _, _ := startTestSSHServer(t, echoCommandHandler) + knownHosts := filepath.Join(t.TempDir(), "known_hosts") + + t.Run("0.0.0.0 is refused by default", func(t *testing.T) { + cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) + defer stopAgent(t, cmd, stdin) + acceptHostKeyPrompt(t, stdin, out) + 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"}) + f := mustReadFrame(t, out) + msg := decodeControl(t, f) + if msg.Msg != "error" { + t.Fatalf("binding 0.0.0.0 without opt-in = %+v, want an error", msg) + } + }) + + 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}) + f := mustReadFrame(t, out) + msg := decodeControl(t, f) + if msg.Msg != "channel_opened" { + t.Fatalf("binding 0.0.0.0 with opt-in = %+v, want channel_opened", msg) + } + }) + + t.Run("127.0.0.1 and localhost succeed without opt-in", func(t *testing.T) { + for _, listenAddr := range []string{"127.0.0.1:0", "localhost:0"} { + cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) + expectConnected(t, out) + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "forward_local", ListenAddr: listenAddr, RemoteAddr: "127.0.0.1:1"}) + f := mustReadFrame(t, out) + msg := decodeControl(t, f) + if msg.Msg != "channel_opened" { + t.Errorf("binding %s without opt-in = %+v, want channel_opened", listenAddr, msg) + } + stopAgent(t, cmd, stdin) + } + }) +} + +// 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") + } + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer backendLn.Close() + const reply = "hello over a unix socket forward" + go func() { + c, err := backendLn.Accept() + if err != nil { + return + } + defer c.Close() + io.WriteString(c, reply) + }() + + addr, _, _ := startTestSSHServer(t, echoCommandHandler) + knownHosts := filepath.Join(t.TempDir(), "known_hosts") + cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) + defer stopAgent(t, cmd, stdin) + acceptHostKeyPrompt(t, stdin, out) + expectConnected(t, out) + + socketPath := filepath.Join(t.TempDir(), "forward.sock") + send(t, stdin, 0, controlMessage{ + Msg: "open_channel", Kind: "forward_local", + ListenNetwork: "unix", ListenAddr: socketPath, + RemoteAddr: backendLn.Addr().String(), + }) + f := mustReadFrame(t, out) + opened := decodeControl(t, f) + if opened.Msg != "channel_opened" { + t.Fatalf("open_channel(unix) = %+v", opened) + } + + info, err := os.Stat(socketPath) + if err != nil { + t.Fatalf("stat socket: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("socket permissions = %o, want 0600", got) + } + + conn, err := net.DialTimeout("unix", socketPath, e2eDialTimeout) + if err != nil { + t.Fatalf("dialing the unix socket forward: %v", err) + } + defer conn.Close() + got, err := io.ReadAll(conn) + if err != nil { + t.Fatalf("reading through the forward: %v", err) + } + if string(got) != reply { + t.Errorf("got %q through the unix socket forward, want %q", got, reply) + } +} + +// 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") + + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer backendLn.Close() + const reply = "hello via authenticated socks" + go func() { + for { + c, err := backendLn.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + io.WriteString(c, reply) + }() + } + }() + + addr, _, _ := startTestSSHServer(t, echoCommandHandler) + knownHosts := filepath.Join(t.TempDir(), "known_hosts") + cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) + defer stopAgent(t, cmd, stdin) + acceptHostKeyPrompt(t, stdin, out) + expectConnected(t, out) + + send(t, stdin, 0, controlMessage{ + Msg: "open_channel", Kind: "forward_socks", ListenAddr: "127.0.0.1:0", + SocksUsername: "app", SocksPassword: "s3cret-token", + }) + f := mustReadFrame(t, out) + opened := decodeControl(t, f) + if opened.Msg != "channel_opened" { + t.Fatalf("open_channel(forward_socks) = %+v", opened) + } + + t.Run("wrong credentials are refused", func(t *testing.T) { + conn, err := net.DialTimeout("tcp", opened.BoundAddr, e2eDialTimeout) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := socks5Auth(conn, "app", "wrong-token"); err == nil { + t.Error("wrong SOCKS credentials were accepted") + } + }) + + t.Run("no credentials at all are refused", func(t *testing.T) { + conn, err := net.DialTimeout("tcp", opened.BoundAddr, e2eDialTimeout) + if err != nil { + 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) + } + replyHdr := make([]byte, 2) + if _, err := io.ReadFull(conn, replyHdr); err != nil { + t.Fatal(err) + } + if replyHdr[1] != 0xFF { + t.Errorf("method selection reply = %v, want no acceptable method (0xFF)", replyHdr) + } + }) + + t.Run("correct credentials reach the backend", func(t *testing.T) { + conn, err := net.DialTimeout("tcp", opened.BoundAddr, e2eDialTimeout) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + got, err := socks5AuthAndConnect(conn, "app", "s3cret-token", backendLn.Addr().String()) + if err != nil { + t.Fatalf("authenticated SOCKS5 CONNECT: %v", err) + } + if got != reply { + t.Errorf("got %q, want %q", got, reply) + } + }) +} + +// 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) + + proxyLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer proxyLn.Close() + dialedThroughProxy := make(chan struct{}, 1) + go func() { + for { + c, err := proxyLn.Accept() + if err != nil { + return + } + go serveHTTPConnectProxy(t, c, addr, dialedThroughProxy) + } + }() + + knownHosts := filepath.Join(t.TempDir(), "known_hosts") + proxyURL := "http://" + proxyLn.Addr().String() + cmd, stdin, out := startAgentConfigured(t, meowshellBin, knownHosts, "testuser@"+addr, + controlMessage{Msg: "configure", ProxyURL: proxyURL}) + defer stopAgent(t, cmd, stdin) + acceptHostKeyPrompt(t, stdin, out) + expectConnected(t, out) + + select { + case <-dialedThroughProxy: + default: + t.Error("connection never went through the configured proxy") + } +} + +// 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) + n, err := conn.Read(buf) + if err != nil || n == 0 { + return + } + select { + case dialed <- struct{}{}: + default: + } + backend, err := net.DialTimeout("tcp", wantTarget, e2eDialTimeout) + if err != nil { + conn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + return + } + defer backend.Close() + conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + done := make(chan struct{}, 2) + go func() { io.Copy(backend, conn); done <- struct{}{} }() + go func() { io.Copy(conn, backend); done <- struct{}{} }() + <-done +} + +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 + } + methodReply := make([]byte, 2) + if _, err := io.ReadFull(conn, methodReply); err != nil { + return false, err + } + if methodReply[1] != 0x02 { + return false, fmt.Errorf("server didn't select username/password auth: %v", methodReply) + } + req := []byte{0x01, byte(len(username))} + req = append(req, username...) + req = append(req, byte(len(password))) + req = append(req, password...) + if _, err := conn.Write(req); err != nil { + return false, err + } + authReply := make([]byte, 2) + if _, err := io.ReadFull(conn, authReply); err != nil { + return false, err + } + if authReply[1] != 0x00 { + return false, fmt.Errorf("authentication failed, status %d", authReply[1]) + } + 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 + } + host, portStr, err := net.SplitHostPort(target) + if err != nil { + return "", err + } + var port int + fmt.Sscanf(portStr, "%d", &port) + req := []byte{0x05, 0x01, 0x00, 0x03, byte(len(host))} + req = append(req, host...) + req = append(req, byte(port>>8), byte(port)) + if _, err := conn.Write(req); err != nil { + return "", err + } + respHdr := make([]byte, 4) + if _, err := io.ReadFull(conn, respHdr); err != nil { + return "", err + } + if respHdr[1] != 0x00 { + return "", fmt.Errorf("SOCKS5 CONNECT failed, reply code %d", respHdr[1]) + } + switch respHdr[3] { + case 0x01: + io.CopyN(io.Discard, conn, 4+2) + case 0x03: + lenBuf := make([]byte, 1) + io.ReadFull(conn, lenBuf) + io.CopyN(io.Discard, conn, int64(lenBuf[0])+2) + case 0x04: + io.CopyN(io.Discard, conn, 16+2) + } + got, err := io.ReadAll(conn) + return string(got), err +} diff --git a/cmd/meowshell/agent_sftp_e2e_test.go b/cmd/meowshell/agent_sftp_e2e_test.go new file mode 100644 index 0000000..a739366 --- /dev/null +++ b/cmd/meowshell/agent_sftp_e2e_test.go @@ -0,0 +1,261 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "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") + + home := t.TempDir() + 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 + 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() }) + expectConnected(t, out) + + t.Run("mkdir, then ls sees it", func(t *testing.T) { + sftpOp(t, stdin, out, controlMessage{Op: "mkdir", Path: "adir"}) + resp := sftpOp(t, stdin, out, controlMessage{Op: "ls", Path: "."}) + if !hasEntry(resp.Entries, "adir", true) { + t.Errorf("ls after mkdir = %+v, want a directory entry named \"adir\"", resp.Entries) + } + }) + + t.Run("upload, stat, chmod, rename, download, remove", func(t *testing.T) { + uploadContent := []byte("hello from the sftp e2e test\n") + uploadViaAgent(t, stdin, out, "adir/file.txt", uploadContent, false, 0, 0) + + stat := sftpOp(t, stdin, out, controlMessage{Op: "stat", Path: "adir/file.txt"}) + if len(stat.Entries) != 1 || stat.Entries[0].Size != int64(len(uploadContent)) { + t.Fatalf("stat = %+v, want one entry of size %d", stat.Entries, len(uploadContent)) + } + + sftpOp(t, stdin, out, controlMessage{Op: "chmod", Path: "adir/file.txt", Mode: 0o640}) + stat = sftpOp(t, stdin, out, controlMessage{Op: "stat", Path: "adir/file.txt"}) + if got := stat.Entries[0].Mode & 0o777; got != 0o640 { + t.Errorf("mode after chmod = %o, want 0640", got) + } + + sftpOp(t, stdin, out, controlMessage{Op: "rename", Path: "adir/file.txt", NewPath: "adir/renamed.txt"}) + + got := downloadViaAgent(t, stdin, out, "adir/renamed.txt") + if !bytes.Equal(got, uploadContent) { + t.Errorf("downloaded content = %q, want %q", got, uploadContent) + } + + sftpOp(t, stdin, out, controlMessage{Op: "remove", Path: "adir/renamed.txt"}) + if _, err := os.Stat(filepath.Join(served, "adir", "renamed.txt")); err == nil { + t.Error("file still exists on disk after remove") + } + }) + + t.Run("upload with preserve carries mode and mtime", func(t *testing.T) { + mtime := time.Now().Add(-48 * time.Hour).Truncate(time.Second) + uploadViaAgent(t, stdin, out, "adir/preserved.txt", []byte("x"), true, 0o600, mtime.Unix()) + + stat := sftpOp(t, stdin, out, controlMessage{Op: "stat", Path: "adir/preserved.txt"}) + if len(stat.Entries) != 1 { + t.Fatalf("stat = %+v", stat.Entries) + } + if got := stat.Entries[0].Mode & 0o777; got != 0o600 { + t.Errorf("preserved mode = %o, want 0600", got) + } + if got := stat.Entries[0].ModTime; got != mtime.Unix() { + t.Errorf("preserved mtime = %d, want %d", got, mtime.Unix()) + } + }) + + t.Run("symlink and readlink", func(t *testing.T) { + sftpOp(t, stdin, out, controlMessage{Op: "symlink", Path: "adir/link", Target: "preserved.txt"}) + resp := sftpOp(t, stdin, out, controlMessage{Op: "readlink", Path: "adir/link"}) + if resp.Target != "preserved.txt" { + t.Errorf("readlink = %q, want %q", resp.Target, "preserved.txt") + } + }) + + t.Run("realpath resolves relative to the served root", func(t *testing.T) { + resp := sftpOp(t, stdin, out, controlMessage{Op: "realpath", Path: "adir/preserved.txt"}) + if resp.Path == "" || resp.Path == "adir/preserved.txt" { + t.Errorf("realpath = %q, want an absolute path", resp.Path) + } + }) + + t.Run("rmdir on a non-empty directory fails, then remove+rmdir succeeds", func(t *testing.T) { + if err := trySFTPOp(t, stdin, out, controlMessage{Op: "rmdir", Path: "adir"}); err == "" { + t.Error("rmdir on a non-empty directory did not error") + } + sftpOp(t, stdin, out, controlMessage{Op: "remove", Path: "adir/preserved.txt"}) + sftpOp(t, stdin, out, controlMessage{Op: "remove", Path: "adir/link"}) + sftpOp(t, stdin, out, controlMessage{Op: "rmdir", Path: "adir"}) + }) + + t.Run("stat on a missing file is a typed not_found error", func(t *testing.T) { + if err := trySFTPOp(t, stdin, out, controlMessage{Op: "stat", Path: "does-not-exist"}); err != errNotFound { + t.Errorf("stat on a missing file: code = %v, want %v", err, errNotFound) + } + }) + + 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 + uploadViaAgent(t, stdin, out, "big.bin", payload, false, 0, 0) + + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "sftp_download", Path: "big.bin"}) + f := mustReadFrame(t, out) + opened := decodeControl(t, f) + if opened.Msg != "channel_opened" || opened.Size != int64(len(payload)) { + t.Fatalf("channel_opened = %+v, want Size %d", opened, len(payload)) + } + id := f.ChannelID + + var got bytes.Buffer + sawProgress := false + for { + f := mustReadFrame(t, out) + if f.ChannelID != id { + continue + } + if f.Type == frameTypeData { + got.Write(f.Payload[1:]) + continue + } + msg := decodeControl(t, f) + switch msg.Msg { + case "progress": + sawProgress = true + case "exit_status": + goto done + case "error": + t.Fatalf("download errored: %+v", msg) + } + } + done: + if !bytes.Equal(got.Bytes(), payload) { + t.Errorf("downloaded %d bytes, want %d matching bytes", got.Len(), len(payload)) + } + if !sawProgress { + t.Error("never saw a progress message for a 100KB download") + } + }) +} + +func startE2EFilesServer(t *testing.T, tailcatBin, meowshellBin, home, served string) string { + t.Helper() + addrFile := filepath.Join(home, "addr") + cmd := exec.Command(meowshellBin, "serve", "--insecure-no-auth", "--files="+served+":rw", "--tailcat="+tailcatBin) + cmd.Env = append(os.Environ(), + "TAILCAT_BIN="+tailcatBin, + "TAILCAT_ADDR_FILE="+addrFile, + "HOME="+home, + "TS_DEBUG_TAILCAT_LOCAL_DERP=1", + ) + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Start(); err != nil { + t.Fatalf("starting meowshell serve: %v", err) + } + t.Cleanup(func() { + cmd.Process.Kill() + cmd.Wait() + if t.Failed() { + t.Logf("server log:\n%s", out.String()) + } + }) + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(addrFile); err == nil && len(data) > 0 { + return string(bytes.TrimSpace(data)) + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("server never published an address; log:\n%s", out.String()) + 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 { + t.Helper() + req.Msg = "sftp_op" + req.RequestID = fmt.Sprintf("op%d", time.Now().UnixNano()) + send(t, stdin, 0, req) + f := mustReadFrame(t, out) + msg := decodeControl(t, f) + if msg.RequestID != req.RequestID { + t.Fatalf("sftp_op %s: reply RequestID = %q, want %q (msg=%+v)", req.Op, msg.RequestID, req.RequestID, msg) + } + if msg.Msg == "error" { + t.Fatalf("sftp_op %s %s failed: %s: %s", req.Op, req.Path, msg.Code, msg.Message) + } + 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 { + t.Helper() + req.Msg = "sftp_op" + req.RequestID = fmt.Sprintf("op%d", time.Now().UnixNano()) + send(t, stdin, 0, req) + f := mustReadFrame(t, out) + msg := decodeControl(t, f) + if msg.Msg == "error" { + return msg.Code + } + return "" +} + +func hasEntry(entries []sftpEntry, name string, isDir bool) bool { + for _, e := range entries { + if e.Name == name && e.IsDir == isDir { + return true + } + } + return false +} + +func uploadViaAgent(t *testing.T, stdin interface { + Write([]byte) (int, error) +}, out *bufio.Reader, path string, content []byte, preserve bool, mode uint32, modTime int64) { + t.Helper() + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "sftp_upload", Path: path, Preserve: preserve, Mode: mode, ModTime: modTime}) + id := expectChannelOpened(t, out) + mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: content}) + send(t, stdin, id, controlMessage{Msg: "close_channel"}) + readUntilExit(t, out, id) +} + +func downloadViaAgent(t *testing.T, stdin interface { + Write([]byte) (int, error) +}, out *bufio.Reader, path string) []byte { + t.Helper() + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "sftp_download", Path: path}) + id := expectChannelOpened(t, out) + return readUntilExit(t, out, id) +} diff --git a/cmd/meowshell/agent_tailcat_forward_e2e_test.go b/cmd/meowshell/agent_tailcat_forward_e2e_test.go new file mode 100644 index 0000000..e7d69f4 --- /dev/null +++ b/cmd/meowshell/agent_tailcat_forward_e2e_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "bufio" + "bytes" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "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") + + home := t.TempDir() + 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) + } + defer backendLn.Close() + const backendReply = "hello from the tailcat-forwarded backend" + go func() { + for { + c, err := backendLn.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + io.WriteString(c, backendReply) + }() + } + }() + _, backendPort, err := net.SplitHostPort(backendLn.Addr().String()) + if err != nil { + t.Fatal(err) + } + + cmd := exec.Command(meowshellBin, "agent", "--tailcat="+tailcatBin, addr) + cmd.Env = append(os.Environ(), "TAILCAT_BIN="+tailcatBin, "HOME="+home, "XDG_CONFIG_HOME="+filepath.Join(home, "config")) + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatalf("starting meowshell agent: %v", err) + } + t.Cleanup(func() { + stdin.Close() + cmd.Wait() + if t.Failed() { + t.Logf("agent stderr:\n%s", stderr.String()) + } + }) + + out := bufio.NewReader(stdout) + send(t, stdin, 0, controlMessage{Msg: "configure"}) + expectConnected(t, out) + + t.Run("forward_local", func(t *testing.T) { + send(t, stdin, 0, controlMessage{ + Msg: "open_channel", Kind: "forward_local", + ListenAddr: "127.0.0.1:0", RemoteAddr: "localhost:" + backendPort, + }) + f := mustReadFrame(t, out) + opened := decodeControl(t, f) + if opened.Msg != "channel_opened" || opened.BoundAddr == "" { + t.Fatalf("channel_opened = %+v, want a non-empty BoundAddr", opened) + } + + conn, err := net.DialTimeout("tcp", opened.BoundAddr, 10*time.Second) + if err != nil { + t.Fatalf("dialing the forwarded local listener: %v", err) + } + defer conn.Close() + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + got, err := io.ReadAll(conn) + if err != nil { + t.Fatalf("reading through the forward: %v", err) + } + if string(got) != backendReply { + t.Errorf("got %q through the tailcat-destination forward, want %q", got, backendReply) + } + }) + + t.Run("forward_socks", func(t *testing.T) { + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "forward_socks", ListenAddr: "127.0.0.1:0"}) + f := mustReadFrame(t, out) + opened := decodeControl(t, f) + if opened.Msg != "channel_opened" || opened.BoundAddr == "" { + t.Fatalf("channel_opened = %+v, want a non-empty BoundAddr", opened) + } + + conn, err := net.DialTimeout("tcp", opened.BoundAddr, 10*time.Second) + if err != nil { + t.Fatalf("dialing the SOCKS listener: %v", err) + } + defer conn.Close() + got, err := socks5Connect(conn, "localhost:"+backendPort) + if err != nil { + t.Fatalf("SOCKS5 CONNECT: %v", err) + } + if got != backendReply { + t.Errorf("got %q through SOCKS against a tailcat destination, want %q", got, backendReply) + } + }) +} + +// 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") + cmd := exec.Command(meowshellBin, "serve", "--insecure-no-auth", "--exit-node", "--tailcat="+tailcatBin) + cmd.Env = append(os.Environ(), + "TAILCAT_BIN="+tailcatBin, + "TAILCAT_ADDR_FILE="+addrFile, + "HOME="+home, + "TS_DEBUG_TAILCAT_LOCAL_DERP=1", + ) + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Start(); err != nil { + t.Fatalf("starting tailcat serve: %v", err) + } + t.Cleanup(func() { + cmd.Process.Kill() + cmd.Wait() + if t.Failed() { + t.Logf("server log:\n%s", out.String()) + } + }) + + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(addrFile); err == nil && len(data) > 0 { + return string(bytes.TrimSpace(data)) + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("server never published an address; log:\n%s", out.String()) + return "" +} diff --git a/cmd/meowshell/agent_tcp_e2e_test.go b/cmd/meowshell/agent_tcp_e2e_test.go new file mode 100644 index 0000000..ef63cba --- /dev/null +++ b/cmd/meowshell/agent_tcp_e2e_test.go @@ -0,0 +1,300 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + + "golang.org/x/crypto/ssh" +) + +// TestAgentTCPEndToEnd drives "meowshell agent" against a real (if +// minimal) SSH server over plain TCP -- no tailcat involved at all -- to +// prove the general-SSH-host path: TCP dialing, TOFU host-key prompting +// round-tripped over the control channel, and the same known_hosts file +// trusting the same key silently on a second connection. +func TestAgentTCPEndToEnd(t *testing.T) { + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + + addr, hostKey1, stopServer1 := startTestSSHServer(t, echoCommandHandler) + knownHosts := filepath.Join(t.TempDir(), "known_hosts") + + t.Run("first connection prompts for TOFU and the client accepts", func(t *testing.T) { + cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) + defer stopAgent(t, cmd, stdin) + + f := mustReadFrame(t, out) + msg := decodeControl(t, f) + if msg.Msg != "prompt_request" || msg.PromptKind != "host_key" { + t.Fatalf("first message = %+v, want a host_key prompt_request", msg) + } + wantFP := fingerprintSHA256(hostKey1.PublicKey()) + if msg.Fingerprint != wantFP { + t.Errorf("prompted fingerprint = %q, want %q", msg.Fingerprint, wantFP) + } + send(t, stdin, 0, controlMessage{Msg: "prompt_response", RequestID: msg.RequestID, Accept: true}) + + expectConnected(t, out) + + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "exec", Command: []string{"ping"}}) + id := expectChannelOpened(t, out) + got := readUntilExit(t, out, id) + if !bytes.Contains(got, []byte("ping")) { + t.Errorf("exec output = %q, want it to contain the echoed command", got) + } + }) + + if _, err := os.Stat(knownHosts); err != nil { + t.Fatalf("known_hosts was never written: %v", err) + } + + t.Run("second connection reuses the trusted key without prompting", func(t *testing.T) { + cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) + defer stopAgent(t, cmd, stdin) + + // No prompt this time: the very first message must be "connected". + expectConnected(t, out) + }) + + t.Run("a changed host key is refused, never silently prompted past", func(t *testing.T) { + port := addrPort(t, addr) + stopServer1() // free the port before the second server rebinds it + _, hostKey2, _ := startTestSSHServerOnPort(t, port, echoCommandHandler) + if bytes.Equal(hostKey1.PublicKey().Marshal(), hostKey2.PublicKey().Marshal()) { + t.Fatal("the second server's host key is identical to the first; the test proves nothing") + } + + cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) + defer stopAgent(t, cmd, stdin) + + f := mustReadFrame(t, out) + msg := decodeControl(t, f) + if msg.Msg != "error" || msg.Code != errHostKeyChanged { + t.Fatalf("connecting with a changed host key = %+v, want an error with code %q", msg, errHostKeyChanged) + } + }) +} + +func startAgent(t *testing.T, meowshellBin, knownHosts, dest string) (*exec.Cmd, *os.File, *bufio.Reader) { + t.Helper() + return startAgentConfigured(t, meowshellBin, knownHosts, dest, controlMessage{Msg: "configure"}) +} + +// startAgentConfigured is startAgent, sending configureMsg (which must set +// Msg: "configure") as the mandatory first message instead of an empty +// one -- for a test that needs to supply auth material (Keys, +// KeystoreKeyIDs, DisableAgent, ...). +func startAgentConfigured(t *testing.T, meowshellBin, knownHosts, dest string, configureMsg controlMessage) (*exec.Cmd, *os.File, *bufio.Reader) { + t.Helper() + cmd := exec.Command(meowshellBin, "agent", "--known-hosts="+knownHosts, dest) + stdinR, stdinW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + cmd.Stdin = stdinR + cmd.Stdout = stdoutW + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatalf("starting meowshell agent: %v", err) + } + stdinR.Close() + stdoutW.Close() + t.Cleanup(func() { + if t.Failed() { + t.Logf("agent stderr:\n%s", stderr.String()) + } + }) + send(t, stdinW, 0, configureMsg) + return cmd, stdinW, bufio.NewReader(stdoutR) +} + +func stopAgent(t *testing.T, cmd *exec.Cmd, stdin *os.File) { + t.Helper() + stdin.Close() + cmd.Wait() +} + +func mustReadFrame(t *testing.T, r *bufio.Reader) frame { + t.Helper() + f, err := readFrameWithDeadline(t, r) + if err != nil { + t.Fatalf("reading a frame: %v", err) + } + return f +} + +func decodeControl(t *testing.T, f frame) controlMessage { + t.Helper() + var msg controlMessage + if err := json.Unmarshal(f.Payload, &msg); err != nil { + t.Fatalf("decoding control message: %v", err) + } + return msg +} + +func addrPort(t *testing.T, hostPort string) string { + t.Helper() + _, port, err := net.SplitHostPort(hostPort) + if err != nil { + t.Fatal(err) + } + return port +} + +// echoCommandHandler is a fake SSH server's exec handler: it writes the +// requested command line back to the channel and exits 0, just enough to +// prove a channel round trip happened without needing a real shell. +func echoCommandHandler(ch ssh.Channel, command string) { + fmt.Fprintf(ch, "%s\n", command) + ch.SendRequest("exit-status", false, ssh.Marshal(&struct{ Status uint32 }{0})) +} + +// startTestSSHServer starts a minimal SSH server on an OS-assigned port, +// accepting any client (NoClientAuth -- auth methods aren't this test's +// concern) and running handleExec for every "exec" request it receives. +// Returns the server's address, host key, and a stop function (also +// registered as t.Cleanup, but exposed for a test that needs the port +// freed before it ends, e.g. to rebind it for a "changed host key" case). +func startTestSSHServer(t *testing.T, handleExec func(ssh.Channel, string)) (addr string, key ssh.Signer, stop func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + _, port, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + signer, stop := serveTestSSH(t, ln, handleExec) + return "127.0.0.1:" + port, signer, stop +} + +// startTestSSHServerOnPort is startTestSSHServer for a specific, already +// chosen port -- used to simulate a changed host key answering at the same +// address a prior test server used. +func startTestSSHServerOnPort(t *testing.T, port string, handleExec func(ssh.Channel, string)) (addr string, key ssh.Signer, stop func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:"+port) + if err != nil { + t.Fatal(err) + } + signer, stop := serveTestSSH(t, ln, handleExec) + return "127.0.0.1:" + port, signer, stop +} + +func serveTestSSH(t *testing.T, ln net.Listener, handleExec func(ssh.Channel, string)) (ssh.Signer, func()) { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatal(err) + } + config := &ssh.ServerConfig{NoClientAuth: true} + config.AddHostKey(signer) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go serveTestSSHConn(conn, config, handleExec) + } + }() + stop := func() { ln.Close() } + t.Cleanup(stop) + return signer, stop +} + +func serveTestSSHConn(conn net.Conn, config *ssh.ServerConfig, handleExec func(ssh.Channel, string)) { + sc, chans, reqs, err := ssh.NewServerConn(conn, config) + if err != nil { + return + } + defer sc.Close() + go ssh.DiscardRequests(reqs) + for newCh := range chans { + if newCh.ChannelType() == "direct-tcpip" { + go serveTestSSHDirectTCPIP(newCh) + continue + } + if newCh.ChannelType() != "session" { + newCh.Reject(ssh.UnknownChannelType, "unsupported") + continue + } + ch, requests, err := newCh.Accept() + if err != nil { + continue + } + go func() { + defer ch.Close() + for req := range requests { + switch req.Type { + case "exec": + var payload struct{ Command string } + ssh.Unmarshal(req.Payload, &payload) + req.Reply(true, nil) + handleExec(ch, payload.Command) + return + case "pty-req", "shell", "window-change": + if req.WantReply { + req.Reply(true, nil) + } + default: + if req.WantReply { + req.Reply(false, nil) + } + } + } + }() + } +} + +// serveTestSSHDirectTCPIP answers a "direct-tcpip" channel request -- +// what ssh.Client.Dial sends server-side -- by dialing the requested +// address locally and piping bytes both ways, the same shape a real +// sshd's own forwarding support has. Without this, meowshell agent's own +// forward_local/forward_socks channels (which both go through +// client.Dial) have nothing on the server end to actually reach a +// backend through. +func serveTestSSHDirectTCPIP(newCh ssh.NewChannel) { + var payload struct { + DestAddr string + DestPort uint32 + OriginAddr string + OriginPort uint32 + } + if err := ssh.Unmarshal(newCh.ExtraData(), &payload); err != nil { + newCh.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request") + return + } + target := net.JoinHostPort(payload.DestAddr, fmt.Sprint(payload.DestPort)) + remote, err := net.Dial("tcp", target) + if err != nil { + newCh.Reject(ssh.ConnectionFailed, err.Error()) + return + } + ch, requests, err := newCh.Accept() + if err != nil { + remote.Close() + return + } + go ssh.DiscardRequests(requests) + proxyForwardedConn(ch, func() (net.Conn, error) { return remote, nil }) +} diff --git a/cmd/meowshell/agentauth.go b/cmd/meowshell/agentauth.go new file mode 100644 index 0000000..dafd57a --- /dev/null +++ b/cmd/meowshell/agentauth.go @@ -0,0 +1,182 @@ +package main + +import ( + "errors" + "fmt" + "io" + "net" + "os" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// buildAuthMethods turns a configure message into the ordered +// []ssh.AuthMethod dialSSHClient offers: every public-key-capable signer +// (the local ssh-agent, keys supplied over the control channel, and +// Keystore-backed keys) combined into one ssh.PublicKeys method, so +// golang.org/x/crypto/ssh tries each in turn within a single auth round, +// then keyboard-interactive, then password -- the package's own +// partial-success negotiation handles the rest. Against tailcat's own +// service this is mostly unused machinery (it only ever asks for +// public-key or nothing at all -- see hostkeys.go's tailcat comment for +// the same point about host keys) but it's what makes a general TCP SSH +// host, or a tailcat --ssh-authorized-keys service from an Android app +// with no ssh-agent, actually reachable. +func (a *agentSession) buildAuthMethods(cfg controlMessage) ([]ssh.AuthMethod, error) { + var signers []ssh.Signer + + if !cfg.DisableAgent { + if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" { + if conn, err := net.Dial("unix", sock); err == nil { + if s, err := agent.NewClient(conn).Signers(); err == nil { + signers = append(signers, s...) + } + a.agentForwardSock = sock + } + } + } + + for i, keyBytes := range cfg.Keys { + signer, err := a.parseKeyMaybePrompting(keyBytes) + if err != nil { + return nil, fmt.Errorf("parsing supplied key %d: %w", i, err) + } + if i < len(cfg.Certificates) && len(cfg.Certificates[i]) > 0 { + pub, err := ssh.ParsePublicKey(cfg.Certificates[i]) + if err != nil { + return nil, fmt.Errorf("parsing supplied certificate %d: %w", i, err) + } + cert, ok := pub.(*ssh.Certificate) + if !ok { + return nil, fmt.Errorf("supplied certificate %d is not an SSH certificate", i) + } + if signer, err = ssh.NewCertSigner(cert, signer); err != nil { + return nil, fmt.Errorf("pairing certificate %d with its key: %w", i, err) + } + } + signers = append(signers, signer) + } + + for i, keyID := range cfg.KeystoreKeyIDs { + if i >= len(cfg.KeystorePublicKeys) { + return nil, fmt.Errorf("keystore key %q has no matching public key", keyID) + } + pub, err := ssh.ParsePublicKey(cfg.KeystorePublicKeys[i]) + if err != nil { + return nil, fmt.Errorf("parsing keystore public key %q: %w", keyID, err) + } + signers = append(signers, &keystoreSigner{session: a, keyID: keyID, pub: pub}) + } + + var methods []ssh.AuthMethod + if len(signers) > 0 { + methods = append(methods, ssh.PublicKeys(signers...)) + } + methods = append(methods, + ssh.KeyboardInteractive(a.keyboardInteractive), + ssh.PasswordCallback(a.promptPassword), + ) + return methods, nil +} + +// parseKeyMaybePrompting parses a private key blob, round-tripping a +// passphrase prompt (up to a few attempts, the same allowance a real ssh +// client gives a typo'd passphrase) when the key turns out to be +// encrypted. keyBytes are never written to disk anywhere in this path -- +// they arrive over the control channel and live only in process memory. +func (a *agentSession) parseKeyMaybePrompting(keyBytes []byte) (ssh.Signer, error) { + signer, err := ssh.ParsePrivateKey(keyBytes) + if err == nil { + return signer, nil + } + var missing *ssh.PassphraseMissingError + if !errors.As(err, &missing) { + return nil, err + } + const maxAttempts = 3 + for attempt := 0; attempt < maxAttempts; attempt++ { + resp, perr := a.prompt(controlMessage{PromptKind: "passphrase"}) + if perr != nil { + return nil, perr + } + if resp.Cancelled { + return nil, fmt.Errorf("passphrase prompt cancelled") + } + if signer, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(resp.Answer)); err == nil { + return signer, nil + } + } + return nil, fmt.Errorf("could not decrypt key after %d attempts: %w", maxAttempts, err) +} + +// promptPassword is an ssh.PasswordCallback: one prompt, one answer. +func (a *agentSession) promptPassword() (string, error) { + resp, err := a.prompt(controlMessage{PromptKind: "password"}) + if err != nil { + return "", err + } + if resp.Cancelled { + return "", fmt.Errorf("password prompt cancelled") + } + return resp.Answer, nil +} + +// keyboardInteractive is an ssh.KeyboardInteractiveChallenge: the actual +// OTP/PAM path for a general SSH host (tailcat's own service never sends +// this challenge -- confirmed against its server source, see hostkeys.go +// and the project plan -- but the plumbing is shared and harmless against +// it). May be invoked more than once per connection attempt; each call is +// its own prompt round trip. +func (a *agentSession) keyboardInteractive(name, instruction string, questions []string, echos []bool) ([]string, error) { + resp, err := a.prompt(controlMessage{ + PromptKind: "keyboard_interactive", + Remote: name, + Instruction: instruction, + Questions: questions, + Echos: echos, + }) + if err != nil { + return nil, err + } + if resp.Cancelled { + return nil, fmt.Errorf("keyboard-interactive prompt cancelled") + } + return resp.Answers, nil +} + +// keystoreSigner is an ssh.Signer backed entirely by a client-side +// callback: the private key never reaches this process at all, only its +// public half (supplied in the configure message) and, per signature, a +// blob signed elsewhere -- Android Keystore hardware being the motivating +// case. Sign blocks on the same prompt round trip host-key/password/etc. +// prompts use, just carrying binary key material instead of typed text. +// +// This implements the plain ssh.Signer interface rather than +// AlgorithmSigner, so it always signs with the key's default algorithm +// (fine for ed25519/ecdsa, which only have one); an RSA Keystore key +// talking to a server that insists on rsa-sha2-256/512 specifically would +// need AlgorithmSigner support this pass doesn't add. +type keystoreSigner struct { + session *agentSession + keyID string + pub ssh.PublicKey +} + +func (s *keystoreSigner) PublicKey() ssh.PublicKey { return s.pub } + +func (s *keystoreSigner) Sign(_ io.Reader, data []byte) (*ssh.Signature, error) { + resp, err := s.session.prompt(controlMessage{ + PromptKind: "sign", + KeyID: s.keyID, + Algorithm: s.pub.Type(), + SignData: data, + }) + if err != nil { + return nil, err + } + if resp.Cancelled || len(resp.Signature) == 0 { + return nil, fmt.Errorf("keystore signing for key %q was refused", s.keyID) + } + return &ssh.Signature{Format: s.pub.Type(), Blob: resp.Signature}, nil +} diff --git a/cmd/meowshell/agentauth_test.go b/cmd/meowshell/agentauth_test.go new file mode 100644 index 0000000..bfdf0fa --- /dev/null +++ b/cmd/meowshell/agentauth_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "encoding/pem" + "io" + "testing" + + "golang.org/x/crypto/ssh" +) + +// testAuthSession wires an agentSession to a pair of pipes so a test can +// play the client side of the control protocol directly, in-process -- +// no subprocess or real SSH server needed to exercise the prompt +// round-trip logic in agentauth.go on its own. +func testAuthSession(t *testing.T) (session *agentSession, fromAgent io.Reader, toAgent io.Writer) { + t.Helper() + agentIn, clientOut := io.Pipe() + clientIn, agentOut := io.Pipe() + session = newAgentSession(agentIn, agentOut) + go session.serveFrames() + t.Cleanup(func() { clientOut.Close() }) + return session, clientIn, clientOut +} + +func readControlFrame(t *testing.T, r io.Reader) (frame, controlMessage) { + t.Helper() + f, err := readFrame(r) + if err != nil { + t.Fatalf("readFrame: %v", err) + } + var msg controlMessage + if err := json.Unmarshal(f.Payload, &msg); err != nil { + t.Fatalf("decoding control message: %v", err) + } + return f, msg +} + +func TestParseKeyMaybePromptingRetriesOnWrongPassphrase(t *testing.T) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + block, err := ssh.MarshalPrivateKeyWithPassphrase(priv, "", []byte("correct-passphrase")) + if err != nil { + t.Fatal(err) + } + keyBytes := pem.EncodeToMemory(block) + + session, fromAgent, toAgent := testAuthSession(t) + + answers := []string{"wrong-once", "correct-passphrase"} + done := make(chan error, 1) + go func() { + for range answers { + _, msg := readControlFrame(t, fromAgent) + if msg.Msg != "prompt_request" || msg.PromptKind != "passphrase" { + done <- nil // let the main goroutine's assertion below report the mismatch + return + } + answer := answers[0] + answers = answers[1:] + body, _ := json.Marshal(controlMessage{Msg: "prompt_response", RequestID: msg.RequestID, Answer: answer}) + writeFrame(toAgent, frame{Type: frameTypeControl, ChannelID: 0, Payload: body}) + } + done <- nil + }() + + signer, err := session.parseKeyMaybePrompting(keyBytes) + <-done + if err != nil { + t.Fatalf("parseKeyMaybePrompting: %v", err) + } + if signer.PublicKey().Type() != ssh.KeyAlgoED25519 { + t.Errorf("signer key type = %q, want %q", signer.PublicKey().Type(), ssh.KeyAlgoED25519) + } +} + +func TestKeystoreSignerRoundTrips(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + + session, fromAgent, toAgent := testAuthSession(t) + signer := &keystoreSigner{session: session, keyID: "keystore-key-1", pub: sshPub} + + wantSig := []byte("fake-signature-bytes") + go func() { + _, msg := readControlFrame(t, fromAgent) + if msg.Msg != "prompt_request" || msg.PromptKind != "sign" || msg.KeyID != "keystore-key-1" { + t.Errorf("sign prompt = %+v, want kind=sign key_id=keystore-key-1", msg) + return + } + body, _ := json.Marshal(controlMessage{Msg: "prompt_response", RequestID: msg.RequestID, Signature: wantSig}) + writeFrame(toAgent, frame{Type: frameTypeControl, ChannelID: 0, Payload: body}) + }() + + sig, err := signer.Sign(nil, []byte("data to sign")) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if string(sig.Blob) != string(wantSig) { + t.Errorf("signature = %q, want %q", sig.Blob, wantSig) + } +} + +func TestKeystoreSignerPropagatesRefusal(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + + session, fromAgent, toAgent := testAuthSession(t) + signer := &keystoreSigner{session: session, keyID: "keystore-key-1", pub: sshPub} + + go func() { + _, msg := readControlFrame(t, fromAgent) + body, _ := json.Marshal(controlMessage{Msg: "prompt_response", RequestID: msg.RequestID, Cancelled: true}) + writeFrame(toAgent, frame{Type: frameTypeControl, ChannelID: 0, Payload: body}) + }() + + if _, err := signer.Sign(nil, []byte("data")); err == nil { + t.Fatal("Sign with a cancelled response did not error") + } +} diff --git a/cmd/meowshell/agentsftp.go b/cmd/meowshell/agentsftp.go new file mode 100644 index 0000000..6f656fa --- /dev/null +++ b/cmd/meowshell/agentsftp.go @@ -0,0 +1,258 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/pkg/sftp" +) + +// sftpClientFor lazily opens the one *sftp.Client this connection shares +// across every ls/stat/mkdir/.../upload/download request -- pkg/sftp +// already multiplexes concurrent requests over its single SSH subsystem +// channel internally, so there is no need for more than one here. +func (a *agentSession) sftpClientFor() (*sftp.Client, error) { + a.sftpMu.Lock() + defer a.sftpMu.Unlock() + if a.sftpClient != nil { + return a.sftpClient, nil + } + sf, err := sftp.NewClient(a.client()) + if err != nil { + return nil, fmt.Errorf("opening SFTP session: %w", err) + } + a.sftpClient = sf + return sf, nil +} + +// classifySFTPError maps an SFTP failure to a typed error code. pkg/sftp +// documents its client methods as returning errors satisfying the +// standard os.IsNotExist/os.IsPermission predicates for the common +// not-found/denied cases (wrapping the wire-level SSH_FX_* status), which +// is enough to cover what a caller is actually likely to branch on without +// this needing to unpack pkg/sftp's *sftp.StatusError codes by hand. +func classifySFTPError(err error) errorCode { + switch { + case errors.Is(err, os.ErrNotExist): + return errNotFound + case errors.Is(err, os.ErrPermission): + return errPermissionDenied + default: + return errUnknown + } +} + +// sftpOp answers one metadata request/response op -- see protocol.go's +// controlMessage doc for the field meanings and agentUsage-adjacent +// sftpUsage-style listing of supported Op values. +func (a *agentSession) sftpOp(msg controlMessage) { + sf, err := a.sftpClientFor() + if err != nil { + a.writeControl(0, controlMessage{Msg: "error", RequestID: msg.RequestID, Code: errUnknown, Message: err.Error()}) + return + } + + resp := controlMessage{Msg: "sftp_result", RequestID: msg.RequestID} + switch msg.Op { + case "ls": + entries, lsErr := sf.ReadDir(msg.Path) + if lsErr != nil { + err = lsErr + break + } + resp.Entries = make([]sftpEntry, len(entries)) + for i, fi := range entries { + resp.Entries[i] = fileInfoToEntry(fi) + } + case "stat", "lstat": + var fi os.FileInfo + if msg.Op == "stat" { + fi, err = sf.Stat(msg.Path) + } else { + fi, err = sf.Lstat(msg.Path) + } + if err == nil { + resp.Entries = []sftpEntry{fileInfoToEntry(fi)} + } + case "mkdir": + err = sf.Mkdir(msg.Path) + case "mkdir_all": + err = sf.MkdirAll(msg.Path) + case "rmdir": + err = sf.RemoveDirectory(msg.Path) + case "remove": + err = sf.Remove(msg.Path) + case "rename": + err = sf.Rename(msg.Path, msg.NewPath) + case "chmod": + err = sf.Chmod(msg.Path, os.FileMode(msg.Mode)) + case "chown": + err = sf.Chown(msg.Path, msg.UID, msg.GID) + case "symlink": + err = sf.Symlink(msg.Target, msg.Path) + case "readlink": + resp.Target, err = sf.ReadLink(msg.Path) + case "truncate": + err = sf.Truncate(msg.Path, msg.Size) + case "realpath": + resp.Path, err = sf.RealPath(msg.Path) + default: + a.writeControl(0, controlMessage{Msg: "error", RequestID: msg.RequestID, Code: errProtocolError, Message: fmt.Sprintf("unknown sftp op %q", msg.Op)}) + return + } + if err != nil { + a.writeControl(0, controlMessage{Msg: "error", RequestID: msg.RequestID, Code: classifySFTPError(err), Message: err.Error()}) + return + } + a.writeControl(0, resp) +} + +func fileInfoToEntry(fi os.FileInfo) sftpEntry { + return sftpEntry{ + Name: fi.Name(), + Size: fi.Size(), + Mode: uint32(fi.Mode()), + ModTime: fi.ModTime().Unix(), + IsDir: fi.IsDir(), + } +} + +// openSFTPChannel opens an upload or download channel: a file transfer, +// unlike sftpOp's fast metadata request/response, gets its own channel ID +// so its bytes can ride the same data-frame machinery shell/exec channels +// use, with progress and cancellation alongside. +func (a *agentSession) openSFTPChannel(msg controlMessage) { + sf, err := a.sftpClientFor() + if err != nil { + a.writeError(0, errUnknown, fmt.Errorf("opening SFTP: %w", err)) + return + } + ctx, cancel := context.WithCancel(context.Background()) + + switch msg.Kind { + case "sftp_upload": + f, err := sf.Create(msg.Path) + if err != nil { + cancel() + a.writeError(0, classifySFTPError(err), fmt.Errorf("creating %s: %w", msg.Path, err)) + return + } + id := a.nextID.Add(1) + ch := &agentChannel{ + sftpFile: f, ctx: ctx, cancel: cancel, isUpload: true, + uploadPath: msg.Path, uploadPreserve: msg.Preserve, uploadMode: msg.Mode, uploadModTime: msg.ModTime, + } + a.chansMu.Lock() + a.chans[id] = ch + a.chansMu.Unlock() + a.writeControl(id, controlMessage{Msg: "channel_opened"}) + + case "sftp_download": + fi, err := sf.Stat(msg.Path) + if err != nil { + cancel() + a.writeError(0, classifySFTPError(err), fmt.Errorf("stat %s: %w", msg.Path, err)) + return + } + f, err := sf.Open(msg.Path) + if err != nil { + cancel() + a.writeError(0, classifySFTPError(err), fmt.Errorf("opening %s: %w", msg.Path, err)) + return + } + id := a.nextID.Add(1) + ch := &agentChannel{sftpFile: f, ctx: ctx, cancel: cancel} + a.chansMu.Lock() + a.chans[id] = ch + a.chansMu.Unlock() + a.writeControl(id, controlMessage{Msg: "channel_opened", Size: fi.Size()}) + go a.pumpSFTPDownload(id, f, ctx) + + default: + cancel() + a.writeError(0, errProtocolError, fmt.Errorf("unknown open_channel kind %q", msg.Kind)) + } +} + +// sftpProgressInterval throttles progress messages -- frequent enough for +// a UI progress bar to feel live, rare enough not to flood the control +// channel on a fast local transfer. +const sftpProgressInterval = 200 * time.Millisecond + +// pumpSFTPDownload streams a remote file to the client as data frames, +// reporting progress and ending in exit_status (success) or error +// (cancelled, or a real read failure) -- the same three-way outcome +// waitChannel reports for a shell/exec channel, just driven by file reads +// instead of session.Wait. +func (a *agentSession) pumpSFTPDownload(id uint32, f *sftp.File, ctx context.Context) { + defer f.Close() + defer a.removeChannel(id) + + buf := make([]byte, 32*1024) + var done int64 + lastProgress := time.Now() + for { + select { + case <-ctx.Done(): + a.writeError(id, errCancelled, fmt.Errorf("download cancelled")) + return + default: + } + n, err := f.Read(buf) + if n > 0 { + if werr := a.writeData(id, streamStdout, buf[:n]); werr != nil { + return + } + done += int64(n) + if time.Since(lastProgress) >= sftpProgressInterval { + a.writeControl(id, controlMessage{Msg: "progress", BytesDone: done}) + lastProgress = time.Now() + } + } + if err != nil { + if errors.Is(err, io.EOF) { + a.writeControl(id, controlMessage{Msg: "progress", BytesDone: done}) + a.writeControl(id, controlMessage{Msg: "exit_status", ExitCode: 0}) + } else { + a.writeError(id, classifySFTPError(err), err) + } + return + } + } +} + +// finalizeUpload closes an sftp_upload channel's remote file and applies +// Preserve (mode + mtime) if requested, reporting the outcome as +// exit_status. close_channel is how the client signals "no more bytes +// coming" for an upload -- this is where a transfer is actually considered +// done, not just where its resources get cleaned up. +func (a *agentSession) finalizeUpload(channelID uint32, ch *agentChannel) { + if ch.cancel != nil { + defer ch.cancel() + } + if err := ch.sftpFile.Close(); err != nil { + a.writeError(channelID, classifySFTPError(err), err) + return + } + if ch.uploadPreserve { + if sf, err := a.sftpClientFor(); err == nil { + // Best-effort: neither failure should turn an upload that + // otherwise completed into a reported failure, but the client + // should still hear about it. + if err := sf.Chmod(ch.uploadPath, os.FileMode(ch.uploadMode)); err != nil { + a.writeError(channelID, errUnknown, fmt.Errorf("preserving mode: %w", err)) + } + if ch.uploadModTime != 0 { + mt := time.Unix(ch.uploadModTime, 0) + if err := sf.Chtimes(ch.uploadPath, mt, mt); err != nil { + a.writeError(channelID, errUnknown, fmt.Errorf("preserving mtime: %w", err)) + } + } + } + } + a.writeControl(channelID, controlMessage{Msg: "exit_status", ExitCode: 0}) +} diff --git a/cmd/meowshell/connect.go b/cmd/meowshell/connect.go index 68ff08a..073038c 100644 --- a/cmd/meowshell/connect.go +++ b/cmd/meowshell/connect.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "flag" "fmt" @@ -69,7 +70,8 @@ func connect(args []string) error { if err != nil { return err } - sc, err := dialSSHClient(bin, tailcatClientArgv(*key, *derpMapURL, *verbose, addr, *port)) + dial, remoteAddr, hkCallback := tailcatSSHDialer(bin, tailcatClientArgv(*key, *derpMapURL, *verbose, addr, *port)) + sc, err := dialSSHClient(context.Background(), dial, remoteAddr, "", hkCallback, sshAgentAuthMethods()) if err != nil { return err } diff --git a/cmd/meowshell/forwarding.go b/cmd/meowshell/forwarding.go new file mode 100644 index 0000000..9f44167 --- /dev/null +++ b/cmd/meowshell/forwarding.go @@ -0,0 +1,423 @@ +package main + +import ( + "crypto/subtle" + "encoding/binary" + "fmt" + "io" + "net" + "os" +) + +// openForwardChannel opens one of the three generic forwarding modes, +// each as its own listener living inside this agent process for as long +// as its channel stays open (unlike a shell/exec/SFTP channel, none of +// these carry their own bytes over the framed protocol at all -- once a +// forwarded connection is accepted, its bytes flow directly between a +// local net.Conn and the far end's own dial, entirely inside this +// process). +// +// forward_local and forward_socks dial out through forwardClient, which +// picks the right mechanism for the destination this agent connected to: +// SSH direct-tcpip (client.Dial) for a general SSH host, or a native +// tailcat.Client (tailcatdial.go) for a tailcat address -- tailcat's own +// embedded SSH service registers no "direct-tcpip" channel handler at all +// (confirmed against tailcat_ssh.go's source), so SSH-level forwarding +// was never available against it; forwarding through a tailcat server +// instead uses the same mechanism tailcat's own "forward"/"socks" +// subcommands do. forward_remote is SSH-only: it asks the *far end* to +// open a listener (client.Listen / tcpip-forward), a feature only an SSH +// server can offer, and tailcat's own embedded one doesn't (no +// "tcpip-forward" global request handler either) -- opening one against a +// tailcat destination still succeeds (nothing about accepting a +// connection touches the remote yet) but every connection it accepts is +// simply refused rather than reported as an error over the control +// channel, a known, minor gap (see proxyForwardedConn) rather than +// something a caller can currently distinguish from "the backend simply +// refused." +// +// forward_local and forward_socks create a *local* listener, which needs +// its own access control: see resolveLocalListener. forward_remote does +// not -- when it does work (a general SSH host), its "listener" is +// virtual, implemented entirely over the SSH wire protocol with no local +// socket of any kind, so nothing else on this machine can reach it that +// way. +func (a *agentSession) openForwardChannel(msg controlMessage) { + switch msg.Kind { + case "forward_local": + a.openLocalForward(msg) + case "forward_remote": + a.openRemoteForward(msg) + case "forward_socks": + a.openSOCKSForward(msg) + default: + a.writeError(0, errProtocolError, fmt.Errorf("unknown open_channel kind %q", msg.Kind)) + } +} + +// resolveLocalListener builds the local listener a forward_local/ +// forward_socks request asks for. Two shapes: +// +// - ListenNetwork "unix": ListenAddr is a filesystem path. The +// recommended choice -- a Unix socket is protected by ordinary file +// permissions (restricted to 0600 here, regardless of umask), so only +// this process's own user can connect to it. Critically, on Android +// that means only *this app* can reach it: unlike a TCP socket on +// 127.0.0.1, which most platforms (Android included) let any other +// local process connect to, a Unix socket under the app's own private +// files directory is off-limits to every other app on the device. +// - ListenNetwork "" or "tcp": ListenAddr is a "host:port". Restricted +// to loopback (127.0.0.0/8, ::1, or "localhost") unless +// AllowNonLoopbackBind is set, so a caller can't expose a forward or +// SOCKS proxy to the whole LAN by accident -- opting into a wider +// bind is a deliberate act, not a default. +func resolveLocalListener(msg controlMessage) (net.Listener, error) { + switch msg.ListenNetwork { + case "", "tcp": + if !msg.AllowNonLoopbackBind && !isLoopbackListenAddr(msg.ListenAddr) { + return nil, fmt.Errorf("refusing to bind %q: not a loopback address (set allow_non_loopback_bind to allow a wider bind deliberately)", msg.ListenAddr) + } + return net.Listen("tcp", msg.ListenAddr) + case "unix": + return listenUnix(msg.ListenAddr) + default: + return nil, fmt.Errorf("unknown listen_network %q (want \"tcp\" or \"unix\")", msg.ListenNetwork) + } +} + +// isLoopbackListenAddr reports whether addr ("host:port", or ":port" for +// an OS-assigned port on every interface) names only loopback interfaces. +// An empty host means "all interfaces" in net.Listen and is never +// loopback, matching that same meaning here. +func isLoopbackListenAddr(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil || host == "" { + return false + } + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// listenUnix binds a Unix domain socket at path, clearing a stale socket +// a crashed previous instance may have left behind first (a fresh Listen +// otherwise fails with "address already in use"), and restricting the +// resulting file to this process's own user -- a fresh AF_UNIX socket's +// permissions otherwise just follow the umask, which can be far more +// permissive than that. Go's net.UnixListener removes the socket file on +// Close on its own, so no matching cleanup is needed at that end. +func listenUnix(path string) (net.Listener, error) { + if path == "" { + return nil, fmt.Errorf("a unix listen_network needs a non-empty socket path") + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("removing stale socket %s: %w", path, err) + } + ln, err := net.Listen("unix", path) + if err != nil { + return nil, err + } + if err := os.Chmod(path, 0o600); err != nil { + ln.Close() + return nil, fmt.Errorf("restricting socket permissions: %w", err) + } + return ln, nil +} + +// openLocalForward implements "-L": the agent listens (see +// resolveLocalListener), and forwards each accepted connection to +// RemoteAddr through forwardClient, the same as OpenSSH's -L against a +// general SSH host, or through tailcat's own client-side dial against a +// tailcat destination. +func (a *agentSession) openLocalForward(msg controlMessage) { + ln, err := resolveLocalListener(msg) + if err != nil { + a.writeError(0, errUnknown, err) + return + } + client := a.forwardClient() + id := a.registerForward(ln) + a.writeControl(id, controlMessage{Msg: "channel_opened", BoundAddr: ln.Addr().String()}) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return // listener closed (close_channel), or a real accept failure either way ends this forward + } + go proxyForwardedConn(conn, func() (net.Conn, error) { + return client.Dial("tcp", msg.RemoteAddr) + }) + } + }() +} + +// openRemoteForward implements "-R": the agent asks the remote server to +// listen on ListenAddr (client.Listen, SSH's tcpip-forward), and for each +// connection the remote side accepts, dials RemoteAddr locally -- a +// resource on this process's own machine, made reachable from the far +// end of the connection. Always goes through a.client() (SSH), unlike +// openLocalForward/openSOCKSForward: there's no forwardClient equivalent +// here, since tailcat has no "ask the server to listen and forward back" +// feature of its own to fall back to -- against a tailcat destination, +// client.Listen simply gets refused the same way it always has (tailcat's +// embedded SSH service registers no "tcpip-forward" request handler +// either), surfaced here as a real error. No local listener of any kind +// is created here (see this file's top-level doc comment), so +// resolveLocalListener/UDS/loopback restriction don't apply. +func (a *agentSession) openRemoteForward(msg controlMessage) { + client := a.client() + ln, err := client.Listen("tcp", msg.ListenAddr) + if err != nil { + a.writeError(0, errUnknown, fmt.Errorf("asking the remote to listen on %s: %w", msg.ListenAddr, err)) + return + } + id := a.registerForward(ln) + a.writeControl(id, controlMessage{Msg: "channel_opened", BoundAddr: ln.Addr().String()}) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go proxyForwardedConn(conn, func() (net.Conn, error) { + return net.Dial("tcp", msg.RemoteAddr) + }) + } + }() +} + +// openSOCKSForward implements "-D": the agent runs a minimal SOCKS5 +// server on the listener resolveLocalListener builds, gated by +// SocksUsername/SocksPassword when either is set (RFC 1929), dialing each +// requested destination through forwardClient. +func (a *agentSession) openSOCKSForward(msg controlMessage) { + ln, err := resolveLocalListener(msg) + if err != nil { + a.writeError(0, errUnknown, err) + return + } + client := a.forwardClient() + id := a.registerForward(ln) + a.writeControl(id, controlMessage{Msg: "channel_opened", BoundAddr: ln.Addr().String()}) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go serveSOCKS5(conn, client, msg.SocksUsername, msg.SocksPassword) + } + }() +} + +// registerForward allocates a channel ID for a forward's listener, +// storing it as an agentChannel so close_channel can find and stop it the +// same way it closes any other channel kind. +func (a *agentSession) registerForward(ln net.Listener) uint32 { + id := a.nextID.Add(1) + a.chansMu.Lock() + a.chans[id] = &agentChannel{listener: ln} + a.chansMu.Unlock() + return id +} + +// proxyForwardedConn copies bytes in both directions between conn and +// whatever dial returns, closing both once either side is done -- the +// same shape ssh(1)'s own -L/-R handling uses internally. conn only needs +// to be an io.ReadWriteCloser (not the fuller net.Conn every caller here +// happens to have): an ssh.Channel satisfies it too, which is what a +// direct-tcpip channel on the *serving* end of a connection is (see the +// test helper that exercises this against a real one). +func proxyForwardedConn(conn io.ReadWriteCloser, dial func() (net.Conn, error)) { + defer conn.Close() + remote, err := dial() + if err != nil { + return + } + defer remote.Close() + + done := make(chan struct{}, 2) + go func() { io.Copy(remote, conn); done <- struct{}{} }() + go func() { io.Copy(conn, remote); done <- struct{}{} }() + <-done +} + +// serveSOCKS5 speaks just enough of RFC 1928 to handle one CONNECT +// request, then hands the connection to proxyForwardedConn like any other +// forwarded one. Anything else -- BIND, UDP ASSOCIATE -- gets rejected; a +// SOCKS client asking for either is not this feature's use case (routing +// an app's outbound TCP through the SSH connection). When username or +// password is non-empty, RFC 1929 username/password auth is required +// (the only method offered besides "none"); a client that doesn't +// support it, or doesn't present this exact pair, never reaches the +// CONNECT stage at all. +func serveSOCKS5(conn net.Conn, client interface { + Dial(network, addr string) (net.Conn, error) +}, username, password string) { + defer func() { + // A protocol violation or unsupported request closes conn without + // forwarding it on; proxyForwardedConn (the success path) takes + // over closing conn itself once control reaches it below. + if r := recover(); r != nil { + conn.Close() + } + }() + + hdr := make([]byte, 2) + if _, err := io.ReadFull(conn, hdr); err != nil || hdr[0] != 0x05 { + conn.Close() + return + } + methods := make([]byte, hdr[1]) + if _, err := io.ReadFull(conn, methods); err != nil { + conn.Close() + return + } + + requireAuth := username != "" || password != "" + const ( + methodNone = 0x00 + methodUserPass = 0x02 + methodNoneUsage = methodNone + ) + selected := byte(methodNoneUsage) + if requireAuth { + if !containsByte(methods, methodUserPass) { + conn.Write([]byte{0x05, 0xFF}) // no acceptable method + conn.Close() + return + } + selected = methodUserPass + } + if _, err := conn.Write([]byte{0x05, selected}); err != nil { + conn.Close() + return + } + if requireAuth && !authenticateSOCKS5(conn, username, password) { + conn.Close() + return + } + + req := make([]byte, 4) + if _, err := io.ReadFull(conn, req); err != nil { + conn.Close() + return + } + const cmdConnect = 0x01 + if req[0] != 0x05 || req[1] != cmdConnect { + writeSOCKS5Reply(conn, 0x07) // command not supported + conn.Close() + return + } + + var host string + switch req[3] { + case 0x01: // IPv4 + addr := make([]byte, 4) + if _, err := io.ReadFull(conn, addr); err != nil { + conn.Close() + return + } + host = net.IP(addr).String() + case 0x03: // domain name + lenBuf := make([]byte, 1) + if _, err := io.ReadFull(conn, lenBuf); err != nil { + conn.Close() + return + } + name := make([]byte, lenBuf[0]) + if _, err := io.ReadFull(conn, name); err != nil { + conn.Close() + return + } + host = string(name) + case 0x04: // IPv6 + addr := make([]byte, 16) + if _, err := io.ReadFull(conn, addr); err != nil { + conn.Close() + return + } + host = net.IP(addr).String() + default: + writeSOCKS5Reply(conn, 0x08) // address type not supported + conn.Close() + return + } + portBuf := make([]byte, 2) + if _, err := io.ReadFull(conn, portBuf); err != nil { + conn.Close() + return + } + port := binary.BigEndian.Uint16(portBuf) + target := net.JoinHostPort(host, fmt.Sprint(port)) + + remote, err := client.Dial("tcp", target) + if err != nil { + writeSOCKS5Reply(conn, 0x05) // connection refused + conn.Close() + return + } + if err := writeSOCKS5Reply(conn, 0x00); err != nil { // succeeded + conn.Close() + remote.Close() + return + } + proxyForwardedConn(conn, func() (net.Conn, error) { return remote, nil }) +} + +// authenticateSOCKS5 reads one RFC 1929 username/password negotiation +// message and replies with its status byte, reporting whether the +// presented credentials matched. Comparisons are constant-time so a +// client can't learn anything about a wrong token's length or contents +// from response timing. +func authenticateSOCKS5(conn net.Conn, wantUsername, wantPassword string) bool { + hdr := make([]byte, 2) + if _, err := io.ReadFull(conn, hdr); err != nil || hdr[0] != 0x01 { + return false + } + uname := make([]byte, hdr[1]) + if _, err := io.ReadFull(conn, uname); err != nil { + return false + } + plenBuf := make([]byte, 1) + if _, err := io.ReadFull(conn, plenBuf); err != nil { + return false + } + passwd := make([]byte, plenBuf[0]) + if _, err := io.ReadFull(conn, passwd); err != nil { + return false + } + + ok := subtle.ConstantTimeCompare(uname, []byte(wantUsername)) == 1 && + subtle.ConstantTimeCompare(passwd, []byte(wantPassword)) == 1 + status := byte(0x01) + if ok { + status = 0x00 + } + if _, err := conn.Write([]byte{0x01, status}); err != nil { + return false + } + return ok +} + +func containsByte(b []byte, v byte) bool { + for _, x := range b { + if x == v { + return true + } + } + return false +} + +// writeSOCKS5Reply writes a reply with a fixed 0.0.0.0:0 bound address -- +// this proxy never actually binds a local address on the target's behalf, +// and no SOCKS client this is meant to serve inspects that field. +func writeSOCKS5Reply(conn net.Conn, code byte) error { + _, err := conn.Write([]byte{0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return err +} diff --git a/cmd/meowshell/hostkeys.go b/cmd/meowshell/hostkeys.go new file mode 100644 index 0000000..c846dc2 --- /dev/null +++ b/cmd/meowshell/hostkeys.go @@ -0,0 +1,114 @@ +package main + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +// tailcatHostKeyCallback is the tailcat-transport HostKeyCallback: always +// insecure-ignore, and correctly so, not a gap to "fix" later -- a tailcat +// address's embedded node key is what WireGuard already authenticates the +// peer with, so there is no separate host identity left for an SSH host key +// to vouch for on top of it. Real verification (tcpHostKeyCallback below) +// only has meaning once the transport is raw TCP, with no such peer +// authentication underneath it. +func tailcatHostKeyCallback() ssh.HostKeyCallback { + return ssh.InsecureIgnoreHostKey() +} + +// hostKeyChangedError distinguishes "the host key changed" (a possible +// MITM, never auto-prompted-past) from "the host key is merely unknown" (a +// normal first connection, fine to prompt for). Both start life as a +// *knownhosts.KeyError from the same callback; only this wrapping on the +// "Want non-empty" case carries that distinction out to the caller, which +// maps it to the errHostKeyChanged typed error code. +type hostKeyChangedError struct { + hostname string + err error +} + +func (e *hostKeyChangedError) Error() string { + return fmt.Sprintf("host key for %s has changed: %v", e.hostname, e.err) +} +func (e *hostKeyChangedError) Unwrap() error { return e.err } + +// hostKeyPrompter asks whether to trust a host key the local known_hosts +// store has no entry for yet (TOFU), returning the caller's decision. The +// agent's implementation round-trips this over the control protocol as a +// prompt_request/prompt_response pair (see agent.go's promptHostKey). +type hostKeyPrompter func(hostname string, remote net.Addr, key ssh.PublicKey) (accept bool, err error) + +// tcpHostKeyCallback builds a real ssh.HostKeyCallback for TCP transport, +// backed by a known_hosts file in the standard OpenSSH line format +// (golang.org/x/crypto/ssh/knownhosts) rather than a meowshell-specific one +// -- interoperable, and inspectable/editable with ordinary tools. An +// unknown key calls prompt and, if accepted, is appended to the file; a +// key that contradicts an existing entry is never offered to prompt at +// all -- it comes back as *hostKeyChangedError, a hard stop. +func tcpHostKeyCallback(knownHostsPath string, prompt hostKeyPrompter) (ssh.HostKeyCallback, error) { + if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0o700); err != nil { + return nil, fmt.Errorf("creating known_hosts directory: %w", err) + } + f, err := os.OpenFile(knownHostsPath, os.O_CREATE|os.O_APPEND, 0o600) + if err != nil { + return nil, fmt.Errorf("creating known_hosts file: %w", err) + } + f.Close() + + verify, err := knownhosts.New(knownHostsPath) + if err != nil { + return nil, fmt.Errorf("loading known_hosts: %w", err) + } + + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + err := verify(hostname, remote, key) + if err == nil { + return nil + } + var keyErr *knownhosts.KeyError + if !errors.As(err, &keyErr) { + return err // some other failure (a malformed store, an I/O error): not ours to prompt past + } + if len(keyErr.Want) > 0 { + return &hostKeyChangedError{hostname: hostname, err: keyErr} + } + + accept, perr := prompt(hostname, remote, key) + if perr != nil { + return perr + } + if !accept { + return fmt.Errorf("host key for %s rejected", hostname) + } + return appendKnownHost(knownHostsPath, hostname, key) + }, nil +} + +func appendKnownHost(knownHostsPath, hostname string, key ssh.PublicKey) error { + f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("recording accepted host key: %w", err) + } + defer f.Close() + line := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key) + if _, err := fmt.Fprintln(f, line); err != nil { + return fmt.Errorf("recording accepted host key: %w", err) + } + return nil +} + +// fingerprintSHA256 formats key the way OpenSSH itself does (ssh-keygen -lf, +// a modern sshd's login log), so a fingerprint shown to a user matches what +// they'd see confirming the same key anywhere else. +func fingerprintSHA256(key ssh.PublicKey) string { + sum := sha256.Sum256(key.Marshal()) + return "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:]) +} diff --git a/cmd/meowshell/main.go b/cmd/meowshell/main.go index 59bc1f4..f52d38a 100644 --- a/cmd/meowshell/main.go +++ b/cmd/meowshell/main.go @@ -58,6 +58,7 @@ const usage = `meowshell -- an interactive shell over a tailcat address USAGE meowshell serve [flags] [-- [args...]] meowshell connect [flags] [command [args...]] + meowshell agent [flags] meowshell socks [flags] meowshell forward [flags] [ ...] meowshell cp [flags] ... @@ -76,6 +77,11 @@ to one tailcat client key: meowshell serve --insecure-no-auth --allow=nodekey:abc... +Also let "tailcat forward"/"tailcat socks" clients reach any port this +machine can dial, not just the ports above (see --exit-node's own help): + + meowshell serve --insecure-no-auth --exit-node --allow=nodekey:abc... + Connect to one: meowshell connect @@ -116,6 +122,8 @@ func main() { err = serve(os.Args[2:]) case "connect": err = connect(os.Args[2:]) + case "agent": + err = agentCmd(os.Args[2:]) case "socks": err = socks(os.Args[2:]) case "forward": @@ -152,6 +160,7 @@ func serve(args []string) error { fullAddress := fs.Bool("full-address", false, "print a longer tailcat address with embedded DERP server info, so clients can connect without a DERP map fetch. Passed to tailcat's own --full-address") psk := fs.Bool("psk", true, "include a WireGuard pre-shared key in the tailcat address (recommended; disabling weakens security). Passed to tailcat's own --psk") files := fs.String("files", "", "directory to serve to SFTP clients (scp, sftp), with an optional :ro (read-only, the default), :rw, :wo (flat write-only drop box), or :wo+ (recursive write-only drop box) suffix. Can be combined with --authorized-keys/--insecure-no-auth to also serve a shell. Passed to tailcat's own --files") + exitNode := fs.Bool("exit-node", false, "let a client's \"tailcat forward\"/\"tailcat socks\" (or an agent connection's own forward_local/forward_socks) reach any port this machine can dial, not just this server's own served ports -- tailcat's own \"exit-node\" service, without which forwarding to an arbitrary port is refused outright (a real, protocol-level requirement of tailcat's own OnTCP gate, not something this flag works around). Combine with --allow to restrict who gets that reach.") fs.Usage = func() { fmt.Fprint(os.Stderr, usage); fs.PrintDefaults() } if err := fs.Parse(rest); err != nil { return err @@ -161,8 +170,8 @@ func serve(args []string) error { switch { case *authKeys != "" && *noAuth: return fmt.Errorf("--authorized-keys and --insecure-no-auth are mutually exclusive") - case !hasSSH && *files == "" && len(command) == 0: - return fmt.Errorf("choose what to serve: --authorized-keys=, --insecure-no-auth, --files=, or a command after --") + case !hasSSH && *files == "" && !*exitNode && len(command) == 0: + return fmt.Errorf("choose what to serve: --authorized-keys=, --insecure-no-auth, --files=, --exit-node, or a command after --") case *files != "" && hasSSH && len(command) > 0: return fmt.Errorf("--files cannot be combined with a forced command on the ssh/no-auth-ssh service, which would allow nothing but that command") } @@ -197,6 +206,9 @@ func serve(args []string) error { if *files != "" && *allow == "" { fmt.Fprintln(os.Stderr, "# warning: --files without --allow serves files to anyone who learns this address") } + if *exitNode && *allow == "" { + fmt.Fprintln(os.Stderr, "# warning: --exit-node without --allow lets anyone who learns this address reach any port this machine can dial") + } argv := []string{bin, "serve"} if *allow != "" { @@ -223,12 +235,19 @@ func serve(args []string) error { if *files != "" { argv = append(argv, "--files="+*files) } + var services []string if hasSSH { service := "ssh" if *noAuth { service = "no-auth-ssh" } - argv = append(argv, service) + services = append(services, service) + } + if *exitNode { + services = append(services, "exit-node") + } + if len(services) > 0 { + argv = append(argv, strings.Join(services, ",")) } if len(command) > 0 { argv = append(argv, "--") diff --git a/cmd/meowshell/main_test.go b/cmd/meowshell/main_test.go index e182f28..2156308 100644 --- a/cmd/meowshell/main_test.go +++ b/cmd/meowshell/main_test.go @@ -149,6 +149,16 @@ func TestServeArgv(t *testing.T) { args: []string{"--tailcat=" + tailcat, "--", "echo", "hi"}, want: []string{tailcat, "serve", "--", "echo", "hi"}, }, + { + name: "exit-node alone, no ssh service token", + args: []string{"--exit-node", "--tailcat=" + tailcat}, + want: []string{tailcat, "serve", "exit-node"}, + }, + { + name: "exit-node combined with no-auth-ssh joins into one comma-separated service token", + args: []string{"--insecure-no-auth", "--exit-node", "--tailcat=" + tailcat}, + want: []string{tailcat, "serve", "no-auth-ssh,exit-node"}, + }, } for _, c := range cases { @@ -184,6 +194,21 @@ func TestConnectRejectsConflictingPtyFlags(t *testing.T) { } } +// agent's argv building (tailcatClientArgv) is the same shared helper +// connect and cp use, covered by TestTailcatClientArgv in cp_test.go. The +// multiplexed session itself is covered by the protocol-level tests in +// protocol_test.go plus dotnet/Meowshell.Tests' end-to-end coverage, the +// same split connect's own session behavior uses above. + +func TestAgentRequiresExactlyOneAddress(t *testing.T) { + cases := [][]string{nil, {"tcaddr", "extra"}} + for _, args := range cases { + if err := agentCmd(args); err == nil { + t.Errorf("agentCmd(%v) did not error", args) + } + } +} + func TestSocksArgv(t *testing.T) { tailcat := writeFakeTailcat(t) diff --git a/cmd/meowshell/protocol.go b/cmd/meowshell/protocol.go new file mode 100644 index 0000000..3acc083 --- /dev/null +++ b/cmd/meowshell/protocol.go @@ -0,0 +1,269 @@ +package main + +import ( + "encoding/binary" + "fmt" + "io" +) + +// The agent control protocol multiplexes everything -- control messages and +// channel data alike -- over one stdin/stdout pair as a stream of frames, +// the same way SSH itself multiplexes channels over one TCP connection, one +// level up. Framing: a 4-byte big-endian length prefix (covering everything +// that follows, not itself), then a 1-byte frame type, a 4-byte big-endian +// channel ID (0 for a connection-level message with no channel yet), and a +// payload -- JSON for a control frame, raw bytes for a data frame. Keeping +// data frames binary (not JSON/base64) matters for PTY and file-transfer +// throughput; control messages stay JSON because they're rare, small, and +// worth keeping easy to log and debug. +const ( + frameTypeControl byte = 0 + frameTypeData byte = 1 +) + +// Data frame payloads carry one stream-tag byte ahead of the raw bytes, so +// stdout and stderr from the same exec channel can be told apart without a +// second channel ID per stream. +const ( + streamStdout byte = 0 + streamStderr byte = 1 +) + +// maxFrameLength caps how much a single length prefix can claim, so a +// corrupt or hostile stream can't force an unbounded allocation before +// readFrame even knows whether the rest of the frame will arrive. +const maxFrameLength = 64 << 20 // 64MiB, well past any single PTY/SFTP chunk this protocol sends + +const frameHeaderLength = 5 // type (1) + channel ID (4), counted in the length prefix + +type frame struct { + Type byte + ChannelID uint32 + Payload []byte +} + +// writeFrame writes f to w in one call where possible: bufio.Writer.Write +// (the only writer this is used with) does not interleave with a +// concurrent Write, so a single combined buffer is what keeps concurrent +// callers from tearing frames into each other on the wire. +func writeFrame(w io.Writer, f frame) error { + buf := make([]byte, 4+frameHeaderLength+len(f.Payload)) + binary.BigEndian.PutUint32(buf[0:4], uint32(frameHeaderLength+len(f.Payload))) + buf[4] = f.Type + binary.BigEndian.PutUint32(buf[5:9], f.ChannelID) + copy(buf[9:], f.Payload) + _, err := w.Write(buf) + return err +} + +func readFrame(r io.Reader) (frame, error) { + var lenBuf [4]byte + if _, err := io.ReadFull(r, lenBuf[:]); err != nil { + return frame{}, err + } + n := binary.BigEndian.Uint32(lenBuf[:]) + if n < frameHeaderLength { + return frame{}, fmt.Errorf("frame length %d shorter than the header alone", n) + } + if n > maxFrameLength { + return frame{}, fmt.Errorf("frame length %d exceeds the %d limit", n, maxFrameLength) + } + body := make([]byte, n) + if _, err := io.ReadFull(r, body); err != nil { + return frame{}, err + } + return frame{ + Type: body[0], + ChannelID: binary.BigEndian.Uint32(body[1:5]), + Payload: body[5:], + }, nil +} + +// errorCode identifies why an operation failed, so a caller (the .NET side, +// ultimately an app's UI) can branch on what happened instead of pattern +// matching scraped diagnostic text. HostKeyChanged in particular needs to +// be easy to single out for a hard-stop warning, never silently retried. +type errorCode string + +const ( + errAuthFailed errorCode = "auth_failed" + errHostKeyUnknown errorCode = "host_key_unknown" + errHostKeyChanged errorCode = "host_key_changed" + errNetworkUnreachable errorCode = "network_unreachable" + errTimeout errorCode = "timeout" + errConnectionLost errorCode = "connection_lost" + errProtocolError errorCode = "protocol_error" + errCancelled errorCode = "cancelled" + errPermissionDenied errorCode = "permission_denied" + errNotFound errorCode = "not_found" + errUnknown errorCode = "unknown" +) + +// controlMessage is the JSON payload of a control frame. One flat, +// mostly-omitempty struct rather than a message-specific type per Msg +// value: the message set is small and every field is self-explanatory from +// its name, so a discriminated union of Go types would add ceremony +// (marshalling/unmarshalling boilerplate per type) without making any +// message easier to read on the wire or in a log. +// +// There is no ChannelID field here: the enclosing frame's ChannelID is the +// channel a message is about, for every message type including +// channel_opened (the frame carries the newly assigned ID; open_channel +// itself is sent on channel 0, since the client has no ID yet to send it +// on). +type controlMessage struct { + Msg string `json:"msg"` + + // open_channel (client -> agent) + Kind string `json:"kind,omitempty"` // "shell" or "exec" + Command []string `json:"command,omitempty"` // exec only + Pty *bool `json:"pty,omitempty"` // nil means "shell default true, exec default false" + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` + Term string `json:"term,omitempty"` + + // resize (client -> agent, shell channels only) reuses Cols/Rows above + + // exit_status (agent -> client) + ExitCode int `json:"exit_code,omitempty"` + + // error (agent -> client); the frame's channel ID is 0 for a + // connection-level failure, or the channel the error belongs to + Code errorCode `json:"code,omitempty"` + Message string `json:"message,omitempty"` + + // prompt_request (agent -> client) / prompt_response (client -> agent), + // always on channel 0: a round trip the agent needs answered before it + // can go on, mid-dial (host-key TOFU) or mid-auth (password, + // keyboard-interactive, a passphrase). RequestID pairs a response to + // its request, since more than one can be outstanding in principle + // (a jump chain prompting for each hop) even though today's callers + // only ever have one in flight at a time. + RequestID string `json:"request_id,omitempty"` + // PromptKind: "host_key", "password", "keyboard_interactive", or + // "passphrase". + PromptKind string `json:"prompt_kind,omitempty"` + Remote string `json:"remote,omitempty"` // host:port (or "tailcat") the prompt is about + Fingerprint string `json:"fingerprint,omitempty"` // host_key: SHA256:... of the offered key + Prompt string `json:"prompt,omitempty"` // password/passphrase: label to show + Instruction string `json:"instruction,omitempty"` // keyboard_interactive + Questions []string `json:"questions,omitempty"` // keyboard_interactive + Echos []bool `json:"echos,omitempty"` // keyboard_interactive: whether each answer may be shown as typed + + // prompt_response fields + Accept bool `json:"accept,omitempty"` // host_key + Answer string `json:"answer,omitempty"` // password/passphrase + Answers []string `json:"answers,omitempty"` // keyboard_interactive + Cancelled bool `json:"cancelled,omitempty"` // the user declined to answer at all + + // PromptKind "sign": a Keystore-backed key's Sign, round-tripped the + // same way a password or passphrase prompt is (see agentauth.go's + // keystoreSigner) rather than as a separate message pair -- it is one + // more request/response the client answers, just with binary key + // material instead of typed text. KeyID and Algorithm identify which + // key and which signature format the agent's SSH negotiation asked + // for; []byte fields are base64 on the wire, encoding/json's default + // for a byte slice. + KeyID string `json:"key_id,omitempty"` + Algorithm string `json:"algorithm,omitempty"` + SignData []byte `json:"sign_data,omitempty"` + Signature []byte `json:"signature,omitempty"` + + // configure (client -> agent, mandatory, always the first message on + // the connection, before anything else including a prompt_response -- + // no prompt exists yet to answer at that point). Every public-key + // signer this connection may offer, gathered up front rather than + // fetched on demand, since SSH tries public-key auth as one method + // covering every key at once (see agentauth.go's buildAuthMethods). + DisableAgent bool `json:"disable_agent,omitempty"` // skip the local ssh-agent even if one is running + + // Keys are private key blobs (any format ssh.ParsePrivateKey accepts); + // Certificates, index-paired with Keys, are OpenSSH certificate public + // keys to sign with instead of the bare key at the same index -- a + // shorter Certificates than Keys leaves the extra keys uncertified. + Keys [][]byte `json:"keys,omitempty"` + Certificates [][]byte `json:"certificates,omitempty"` + + // KeystoreKeyIDs/KeystorePublicKeys are index-paired: a public key the + // agent can offer without ever holding the private half, signing + // through a "sign" prompt instead (Android Keystore's own use case). + KeystoreKeyIDs []string `json:"keystore_key_ids,omitempty"` + KeystorePublicKeys [][]byte `json:"keystore_public_keys,omitempty"` + + AgentForwarding bool `json:"agent_forwarding,omitempty"` // forward the local ssh-agent (if any) to the remote, once connected + + // ProxyURL is a SOCKS5 or HTTP CONNECT proxy ("scheme://[user:pass@]host:port") + // for the first TCP hop of connect -- part of configure rather than a + // CLI flag specifically so proxy credentials never end up on this + // process's own command line (readable via /proc//cmdline by + // anything sharing enough local privilege), the same reasoning Keys + // above already gets right. + ProxyURL string `json:"proxy_url,omitempty"` + + // sftp_op (client -> agent, request/response, no channel -- paired by + // RequestID above, reused here for the same "which reply is this" + // purpose it serves for prompts) / sftp_result (agent -> client). + // Op selects the operation: ls, stat, lstat, mkdir, mkdir_all, rmdir, + // remove, rename, chmod, chown, symlink, readlink, truncate, realpath. + // Which of the fields below apply depends on Op; see agentsftp.go. + Op string `json:"op,omitempty"` + Path string `json:"path,omitempty"` + NewPath string `json:"new_path,omitempty"` // rename's destination + Mode uint32 `json:"mode,omitempty"` // chmod, or an upload's local mode to preserve remotely + UID int `json:"uid,omitempty"` + GID int `json:"gid,omitempty"` + Target string `json:"target,omitempty"` // symlink's target, or readlink's result + Size int64 `json:"size,omitempty"` // truncate's target size, or (on channel_opened) a download's total size + ModTime int64 `json:"mod_time,omitempty"` // unix seconds; an upload's mtime to preserve remotely + + // open_channel (sftp_upload/sftp_download) reuses Path above for the + // remote file and Preserve for whether to carry mode+mtime across; + // progress (agent -> client, sftp_download) reuses the channel's own + // frame ChannelID, so BytesDone/BytesTotal are its only fields. + Preserve bool `json:"preserve,omitempty"` + BytesDone int64 `json:"bytes_done,omitempty"` + + Entries []sftpEntry `json:"entries,omitempty"` // sftp_result for ls/stat/lstat + + // open_channel (forward_local/forward_remote/forward_socks) / its + // channel_opened reply; see forwarding.go. + ListenAddr string `json:"listen_addr,omitempty"` // forward_local/forward_socks: where the agent listens; forward_remote: where the *remote* server listens + RemoteAddr string `json:"remote_addr,omitempty"` // forward_local/forward_remote: the far end each accepted connection is forwarded to + BoundAddr string `json:"bound_addr,omitempty"` // channel_opened: the actual bound listen address (useful when a port of 0 asked for an OS-assigned one) + + // ListenNetwork selects what ListenAddr means for forward_local/ + // forward_socks: "tcp" (the default when empty) or "unix", in which + // case ListenAddr is a filesystem path -- the recommended local + // endpoint, since a Unix socket under the caller's own private + // directory is enforced by filesystem permissions, unlike a TCP + // socket on 127.0.0.1, which most platforms (Android very much + // included) let any other local process connect to regardless of + // which app owns it. + ListenNetwork string `json:"listen_network,omitempty"` + + // AllowNonLoopbackBind must be set true to bind a "tcp" listener to + // anything other than loopback (127.0.0.0/8, ::1, or "localhost") -- + // otherwise open_channel fails outright rather than silently exposing + // a forward or SOCKS proxy to the LAN. Meaningless (ignored) for + // ListenNetwork "unix". + AllowNonLoopbackBind bool `json:"allow_non_loopback_bind,omitempty"` + + // SocksUsername/SocksPassword (forward_socks only) turn on RFC 1929 + // username/password SOCKS5 auth: a client of the proxy must present + // this exact pair before anything gets relayed. Leaving both empty + // serves the proxy with no authentication at all (SOCKS5's classic + // behavior, and still fine on a "unix" listener, since filesystem + // permissions are already doing the access control there). + SocksUsername string `json:"socks_username,omitempty"` + SocksPassword string `json:"socks_password,omitempty"` +} + +// sftpEntry is one directory entry or a single file's metadata, the +// sftp_result payload for the "ls"/"stat"/"lstat" ops. +type sftpEntry struct { + Name string `json:"name"` + Size int64 `json:"size"` + Mode uint32 `json:"mode"` + ModTime int64 `json:"mod_time"` // unix seconds + IsDir bool `json:"is_dir"` +} diff --git a/cmd/meowshell/protocol_test.go b/cmd/meowshell/protocol_test.go new file mode 100644 index 0000000..a824497 --- /dev/null +++ b/cmd/meowshell/protocol_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" +) + +func TestFrameRoundTrip(t *testing.T) { + cases := []frame{ + {Type: frameTypeControl, ChannelID: 0, Payload: []byte(`{"msg":"open_channel"}`)}, + {Type: frameTypeData, ChannelID: 7, Payload: append([]byte{streamStdout}, "hello\n"...)}, + {Type: frameTypeControl, ChannelID: 42, Payload: nil}, + } + for _, c := range cases { + var buf bytes.Buffer + if err := writeFrame(&buf, c); err != nil { + t.Fatalf("writeFrame(%+v) = %v", c, err) + } + got, err := readFrame(&buf) + if err != nil { + t.Fatalf("readFrame after writing %+v = %v", c, err) + } + if got.Type != c.Type || got.ChannelID != c.ChannelID || !bytes.Equal(got.Payload, c.Payload) { + t.Errorf("round trip of %+v = %+v", c, got) + } + } +} + +func TestReadFrameMultipleInSequence(t *testing.T) { + var buf bytes.Buffer + writeFrame(&buf, frame{Type: frameTypeControl, ChannelID: 1, Payload: []byte("a")}) + writeFrame(&buf, frame{Type: frameTypeData, ChannelID: 2, Payload: []byte("b")}) + + f1, err := readFrame(&buf) + if err != nil || f1.ChannelID != 1 { + t.Fatalf("first frame = %+v, %v", f1, err) + } + f2, err := readFrame(&buf) + if err != nil || f2.ChannelID != 2 { + t.Fatalf("second frame = %+v, %v", f2, err) + } + if _, err := readFrame(&buf); err != io.EOF { + t.Fatalf("readFrame at end of stream = %v, want io.EOF", err) + } +} + +func TestReadFrameRejectsOversizedLength(t *testing.T) { + r := strings.NewReader(string([]byte{0xff, 0xff, 0xff, 0xff})) + if _, err := readFrame(r); err == nil { + t.Fatal("readFrame with a length far past maxFrameLength did not error") + } +} + +func TestReadFrameRejectsLengthShorterThanHeader(t *testing.T) { + r := strings.NewReader(string([]byte{0, 0, 0, 2})) + if _, err := readFrame(r); err == nil { + t.Fatal("readFrame with a length shorter than the header did not error") + } +} + +func TestControlMessageJSONShape(t *testing.T) { + pty := true + msg := controlMessage{Msg: "open_channel", Kind: "exec", Command: []string{"ls", "-la"}, Pty: &pty, Cols: 80, Rows: 24} + body, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + var got controlMessage + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + if got.Msg != msg.Msg || got.Kind != msg.Kind || got.Cols != msg.Cols || got.Rows != msg.Rows { + t.Errorf("round trip = %+v, want %+v", got, msg) + } + if got.Pty == nil || *got.Pty != true { + t.Errorf("Pty round trip = %v, want true", got.Pty) + } +} diff --git a/cmd/meowshell/sftp.go b/cmd/meowshell/sftp.go index b8d2190..d0a7625 100644 --- a/cmd/meowshell/sftp.go +++ b/cmd/meowshell/sftp.go @@ -1,11 +1,11 @@ package main import ( + "context" "errors" "fmt" "io" "net" - "os" "os/exec" "strings" "time" @@ -73,37 +73,26 @@ type pipeAddr struct{} func (pipeAddr) Network() string { return "tailcat" } func (pipeAddr) String() string { return "tailcat" } -// dialSSHClient starts tailcat's own bare client mode as a subprocess (argv -// from tailcatClientArgv) and speaks SSH directly over its stdin/stdout -- -// no system ssh binary involved, which is what makes this work in an -// Android app sandbox (and piped into from anywhere else with no real -// terminal attached at all). A "no-auth-ssh" or "files" service accepts -// SSH's "none" auth method (always tried first) on tailcat's own -// WireGuard-peer trust alone, the same trust tailcat's own native "ls" -// subcommand relies on -- but a plain "ssh" service configured with -// --ssh-authorized-keys requires real SSH public-key auth on top of that, -// which sshAgentAuthMethods offers from the local ssh-agent when one is -// running (matching what a real ssh client does automatically). Closing -// the returned client also tears down the subprocess. -func dialSSHClient(tailcatBin string, argv []string) (*ssh.Client, error) { - cmd := exec.Command(tailcatBin, argv...) - cmd.Stderr = os.Stderr - stdin, err := cmd.StdinPipe() +// dialSSHClient dials through dial (tailcatDialer, tcpDialer, or +// jumpDialer -- see transport.go) and speaks SSH directly over the +// resulting net.Conn: no system ssh binary involved anywhere in this call, +// which is what makes it work in an Android app sandbox (and piped into +// from anywhere else with no real terminal attached at all). remoteAddr is +// passed to the handshake only for hostKeyCallback to key its lookups on +// ("tailcat" is fine there -- see hostkeys.go for why tailcat transport +// trusts the peer through WireGuard instead of a host key at all). Closing +// the returned client also closes whatever dial returned (for the tailcat +// dialer, that tears down its subprocess too). +func dialSSHClient(ctx context.Context, dial dialer, remoteAddr, user string, hostKeyCallback ssh.HostKeyCallback, auth []ssh.AuthMethod) (*ssh.Client, error) { + conn, err := dial(ctx) if err != nil { return nil, err } - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - if err := cmd.Start(); err != nil { - return nil, err - } - conn := &pipeConn{cmd: cmd, stdout: stdout, stdin: stdin} - - sshConn, chans, reqs, err := ssh.NewClientConn(conn, "tailcat", &ssh.ClientConfig{ - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Auth: sshAgentAuthMethods(), + sshConn, chans, reqs, err := ssh.NewClientConn(conn, remoteAddr, &ssh.ClientConfig{ + User: user, + HostKeyCallback: hostKeyCallback, + Auth: auth, + Timeout: sshHandshakeTimeout, }) if err != nil { conn.Close() @@ -112,9 +101,26 @@ func dialSSHClient(tailcatBin string, argv []string) (*ssh.Client, error) { return ssh.NewClient(sshConn, chans, reqs), nil } -// dialSFTP is dialSSHClient plus opening the SFTP subsystem on top. +// sshHandshakeTimeout bounds ssh.NewClientConn itself, on top of whatever +// timeout ctx already puts on the dial step -- a TCP host that accepts the +// connection but never completes (or never finishes) the SSH handshake +// would otherwise hang dialSSHClient forever, since golang.org/x/crypto/ssh +// has no context-based cancellation of its own. +const sshHandshakeTimeout = 20 * time.Second + +// tailcatSSHDialer builds the tailcat-transport dialer + host-key callback +// pair dialSSHClient needs: the shared shape connect.go, cp.go, and +// agent.go all still use today, while agent.go alone also offers the TCP +// alternative in transport.go/hostkeys.go. +func tailcatSSHDialer(tailcatBin string, argv []string) (dialer, string, ssh.HostKeyCallback) { + return tailcatDialer(tailcatBin, argv), "tailcat", tailcatHostKeyCallback() +} + +// dialSFTP is dialSSHClient plus opening the SFTP subsystem on top, for +// connect.go and cp.go's still tailcat-only use. func dialSFTP(tailcatBin string, argv []string) (*sftp.Client, io.Closer, error) { - sc, err := dialSSHClient(tailcatBin, argv) + dial, remoteAddr, hkCallback := tailcatSSHDialer(tailcatBin, argv) + sc, err := dialSSHClient(context.Background(), dial, remoteAddr, "", hkCallback, sshAgentAuthMethods()) if err != nil { return nil, nil, err } diff --git a/cmd/meowshell/tailcatdial.go b/cmd/meowshell/tailcatdial.go new file mode 100644 index 0000000..13701e9 --- /dev/null +++ b/cmd/meowshell/tailcatdial.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/netip" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/tailscale/tailcat" + "tailscale.com/types/key" +) + +// tailcatForwardClient adapts a *tailcat.Client to the small Dial-shaped +// interface forwarding.go's openLocalForward/openSOCKSForward already take +// (the same one a.client(), the *ssh.Client, satisfies for a general SSH +// host) -- so forward_local/forward_socks can reuse that exact code +// unmodified against a tailcat destination too, dialing through tailcat's +// own client-side WireGuard connection instead of an SSH direct-tcpip +// channel, which tailcat's own embedded SSH service never implements (see +// forwarding.go's package doc comment). This mirrors what tailcat's own +// "forward"/"socks" subcommands do (cmd/tailcat/forward.go's +// forwardListener, cmd/tailcat/tailcat.go's dialSOCKSTarget) -- forwarding +// through a tailcat server was never an SSH feature there either. +type tailcatForwardClient struct { + cl *tailcat.Client +} + +// Dial implements forwarding.go's client interface. network is always +// "tcp" here (forward_local/forward_socks never ask for anything else, +// unlike tailcat's own SOCKS5 UDP ASSOCIATE support, which this doesn't +// need). addr is a "host:port": a target on the tailcat server's own +// loopback dials that port on the server itself (DialTCPPort), matching a +// bare-port mapping in "tailcat forward"'s own syntax; anything else is +// routed through the server acting as an exit node (DialTCP), matching a +// "local:remote-ip:remote-port" mapping there -- just always spelled as a +// full host:port here, since that's this protocol's own RemoteAddr/SOCKS +// CONNECT shape rather than a mapping string to parse. +func (c *tailcatForwardClient) Dial(network, addr string) (net.Conn, error) { + if network != "tcp" { + return nil, fmt.Errorf("tailcat forwarding only supports tcp, not %q", network) + } + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("invalid forward target %q: %w", addr, err) + } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid forward target port %q: %w", portStr, err) + } + // A bounded context, not context.Background(): a destination the + // server refuses (no OnTCP/OnTCPForward handler for it -- e.g. no + // --exit-node and this isn't one of its already-served ports) can + // leave UserDial/DialContextTCP hanging rather than returning a + // prompt error, since gVisor's netstack has no obligation to surface + // a refusal as fast as a real RST would; on the client's own first + // use this also covers standing up its WireGuard session. Same + // timeout tcpDialer/dialHTTPConnectProxy already use for a real TCP + // dial elsewhere in this file's siblings. + ctx, cancel := context.WithTimeout(context.Background(), tcpDialTimeout) + defer cancel() + if host == "" || host == "localhost" || host == "127.0.0.1" || host == "::1" { + return c.cl.DialTCPPort(ctx, uint16(port)) + } + ip, err := netip.ParseAddr(host) + if err != nil { + // Prefer an IPv4 result, same as tailcat's own classifySOCKSAddr: + // it rides the exit node's NAT64 mapping, and the server may not + // have IPv6 connectivity of its own at all. + ips, lookupErr := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if lookupErr != nil { + return nil, fmt.Errorf("resolving forward target host %q: %w", host, lookupErr) + } + if len(ips) == 0 { + return nil, fmt.Errorf("no addresses found for forward target host %q", host) + } + ip = ips[0] + for _, a := range ips { + if a.Unmap().Is4() { + ip = a + break + } + } + } + return c.cl.DialTCP(ctx, netip.AddrPortFrom(ip.Unmap(), uint16(port))) +} + +// tailcatKeyFromName resolves a "--key" value the same way tailcat's own +// CLI does (cmd/tailcat's clientKey/keyPath -- not importable from here, +// since they live in a main package and this one needs its own copy): an +// empty or "new" value means a fresh ephemeral node identity; anything +// containing a path separator is a literal path to a key file; anything +// else is a name under tailcat's own on-disk key directory, what +// "tailcat genkey" itself writes to. +func tailcatKeyFromName(name string) (key.NodePrivate, error) { + if name == "" || name == "new" { + return key.NewNode(), nil + } + path := name + if !strings.ContainsAny(name, `/\`) { + confDir, err := os.UserConfigDir() + if err != nil { + return key.NodePrivate{}, err + } + path = filepath.Join(confDir, "tailcat", "keys", name+".private.json") + } + j, err := os.ReadFile(path) + if err != nil { + return key.NodePrivate{}, err + } + var conf tailcat.PrivateKey + if err := json.Unmarshal(j, &conf); err != nil { + return key.NodePrivate{}, fmt.Errorf("parsing %s: %w", path, err) + } + return conf.Private, nil +} diff --git a/cmd/meowshell/transport.go b/cmd/meowshell/transport.go new file mode 100644 index 0000000..861b9d2 --- /dev/null +++ b/cmd/meowshell/transport.go @@ -0,0 +1,209 @@ +package main + +import ( + "bufio" + "context" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "time" + + "golang.org/x/net/proxy" +) + +// dialer opens the net.Conn a *ssh.Client handshake runs over -- the one +// seam tailcat and raw TCP transport share (see dialSSHClient in sftp.go). +// A dialer owns the lifetime of whatever it returns: closing the *ssh.Client +// built on top closes the net.Conn, which for the tailcat dialer also tears +// down the subprocess underneath it (pipeConn.Close waits for it to exit). +type dialer func(ctx context.Context) (net.Conn, error) + +// tailcatDialer runs tailcat's own bare client mode as a subprocess (argv +// from tailcatClientArgv) and adapts its stdin/stdout to net.Conn via +// pipeConn -- what makes this whole seam work inside an Android app sandbox +// in the first place: no raw socket of its own, just a child process's +// pipes. Deliberately plain exec.Command, not CommandContext: ctx here only +// ever bounds how long the dial itself may take (see dialSSHClient), and +// must not reach into the subprocess's lifetime once dialing succeeds -- +// the subprocess IS the connection for as long as the connection lives. +func tailcatDialer(tailcatBin string, argv []string) dialer { + return func(ctx context.Context) (net.Conn, error) { + cmd := exec.Command(tailcatBin, argv...) + cmd.Stderr = os.Stderr + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + return &pipeConn{cmd: cmd, stdout: stdout, stdin: stdin}, nil + } +} + +const tcpDialTimeout = 15 * time.Second + +// tcpDialer dials hostPort directly over TCP, for a destination that isn't +// a tailcat address (a bastion, a plain VPS). Unlike the tailcat dialer, +// there is no WireGuard-authenticated peer on the other end, so whatever +// calls dialSSHClient with this must also pass a real HostKeyCallback -- +// never ssh.InsecureIgnoreHostKey, which is only correct for tailcat's own +// transport (see hostkeys.go). +func tcpDialer(hostPort string) dialer { + return func(ctx context.Context) (net.Conn, error) { + d := net.Dialer{Timeout: tcpDialTimeout} + return d.DialContext(ctx, "tcp", hostPort) + } +} + +// jumpDialer dials hostPort through an already-established SSH client +// (client.Dial), for each hop of a --jump chain after the first: the prior +// hop's own SSH connection carries the TCP stream to the next hop, rather +// than this process opening a second direct connection to it. via must +// outlive the returned dialer's use -- the caller keeps every hop's +// *ssh.Client alive for the life of the overall connection, closing them in +// reverse order when it ends. +func jumpDialer(via jumpClient, hostPort string) dialer { + return func(ctx context.Context) (net.Conn, error) { + return via.Dial("tcp", hostPort) + } +} + +// jumpClient is the one *ssh.Client method jumpDialer needs, broken out so +// tests can fake it without a real SSH server to dial through. +type jumpClient interface { + Dial(network, addr string) (net.Conn, error) +} + +// splitUserHost splits a "[user@]host[:port]" destination, defaulting port +// when host doesn't already carry one. host may be a bracketed IPv6 +// literal ("[::1]:2222" or "[::1]"). +func splitUserHost(dest, defaultPort string) (user, hostPort string) { + if at := strings.LastIndex(dest, "@"); at >= 0 { + user, dest = dest[:at], dest[at+1:] + } + if _, _, err := net.SplitHostPort(dest); err == nil { + return user, dest + } + // dest carries no port. JoinHostPort re-brackets a literal that + // contains ":" on its own, so a bracketed literal's brackets need + // stripping first or it comes out double-bracketed ("[[::1]]:22"). + host := strings.TrimSuffix(strings.TrimPrefix(dest, "["), "]") + return user, net.JoinHostPort(host, defaultPort) +} + +// proxyDialer wraps hostPort's dial to go through an upstream SOCKS5 or +// HTTP CONNECT proxy (proxyURL: "socks5://[user:pass@]host:port" or +// "http://[user:pass@]host:port"), for a corporate network whose only path +// out is through one. Only meaningful for the first hop: --jump hops after +// it go through jumpDialer instead (an already-established SSH client's +// own Dial, which needs no further proxying to reach the network beyond +// it), and tailcat transport does not do a raw internet TCP dial of its +// own in the first place. +func proxyDialer(proxyURL, hostPort string) (dialer, error) { + u, err := url.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("invalid --proxy %q: %w", proxyURL, err) + } + switch u.Scheme { + case "socks5", "socks5h": + d, err := proxy.SOCKS5("tcp", u.Host, proxyAuthFromURL(u), proxy.Direct) + if err != nil { + return nil, fmt.Errorf("configuring SOCKS5 proxy %q: %w", proxyURL, err) + } + return func(ctx context.Context) (net.Conn, error) { + if cd, ok := d.(proxy.ContextDialer); ok { + return cd.DialContext(ctx, "tcp", hostPort) + } + return d.Dial("tcp", hostPort) + }, nil + case "http", "https": + return func(ctx context.Context) (net.Conn, error) { + return dialHTTPConnectProxy(ctx, u, hostPort) + }, nil + default: + return nil, fmt.Errorf("unsupported --proxy scheme %q (want socks5 or http)", u.Scheme) + } +} + +func proxyAuthFromURL(u *url.URL) *proxy.Auth { + if u.User == nil { + return nil + } + pass, _ := u.User.Password() + return &proxy.Auth{User: u.User.Username(), Password: pass} +} + +// dialHTTPConnectProxy speaks the one HTTP request this needs by hand +// (net/http has no client-side CONNECT tunneling helper of its own): send +// CONNECT hostPort, read back the proxy's response line, and hand the raw +// TCP connection on once it answers 200 -- from that point on it's a plain +// byte pipe to hostPort, exactly like any other dialer here. The response +// is read through a bufio.Reader wrapped back into the returned conn +// (bufConn below): on a fast local proxy, the tunnel's first bytes can +// already be sitting in the same TCP segment as the status line, and a +// bufio.Reader that read them off the wire while parsing the response +// would otherwise strand them -- invisible to a caller reading conn +// directly afterwards. +func dialHTTPConnectProxy(ctx context.Context, proxyURL *url.URL, hostPort string) (net.Conn, error) { + d := net.Dialer{Timeout: tcpDialTimeout} + conn, err := d.DialContext(ctx, "tcp", proxyURL.Host) + if err != nil { + return nil, err + } + var authHeader string + if proxyURL.User != nil { + pass, _ := proxyURL.User.Password() + auth := base64.StdEncoding.EncodeToString([]byte(proxyURL.User.Username() + ":" + pass)) + authHeader = "Proxy-Authorization: Basic " + auth + "\r\n" + } + if _, err := fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n%s\r\n", hostPort, hostPort, authHeader); err != nil { + conn.Close() + return nil, err + } + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, &http.Request{Method: "CONNECT"}) + if err != nil { + conn.Close() + return nil, err + } + if resp.StatusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("HTTP CONNECT proxy %s refused: %s", proxyURL.Host, resp.Status) + } + return &bufConn{Conn: conn, r: br}, nil +} + +// bufConn is a net.Conn whose Read is satisfied from r first -- see +// dialHTTPConnectProxy above for why that matters here. +type bufConn struct { + net.Conn + r *bufio.Reader +} + +func (c *bufConn) Read(p []byte) (int, error) { return c.r.Read(p) } + +// looksLikeTailcatAddress reports whether dest is syntactically a tailcat +// address: tailcat's own parseWire requires a "tc" prefix followed by +// base64.RawURLEncoding, and checking exactly that (without also pulling in +// the CBOR decode that only tailcat itself needs to actually use one) is +// enough to tell a tailcat address apart from a "[user@]host[:port]" TCP +// destination -- the base64url alphabet contains neither "@", ":", nor the +// "." a bare hostname or dotted IPv4 address needs. +func looksLikeTailcatAddress(dest string) bool { + rest, ok := strings.CutPrefix(dest, "tc") + if !ok || rest == "" { + return false + } + _, err := base64.RawURLEncoding.DecodeString(rest) + return err == nil +} diff --git a/cmd/meowshell/transport_test.go b/cmd/meowshell/transport_test.go new file mode 100644 index 0000000..37b72cf --- /dev/null +++ b/cmd/meowshell/transport_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "net" + "net/http" + "net/url" + "testing" + "time" +) + +func mustParseURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return u +} + +func TestLooksLikeTailcatAddress(t *testing.T) { + cases := []struct { + dest string + want bool + }{ + // A real address as tailcat itself would publish one (captured + // from a local test server run), not a hand-truncated fake -- + // base64.RawURLEncoding is picky about length, so this needs to + // be a genuine, complete encoding to prove the happy path. + {"tcpGFwWCCCAiC8CWRmU8Bh0If_O_VgzekQvOSa1sJo-6FEOuZSXGFrWCCOgRnXVZlBMOhYT2IA-bDVKrvHkvoCwZSFA5ZtmwzpJmFxWCC1_Zamsrq9_iP73WYNbE6NfssVj2moLObKm-IqlLlHzGFygaFhToGmYWhhVGE0aTEyNy4wLjAuMWE2ZG5vbmVhcxmlQ2FkGaPRYXj1", true}, + {"tc", false}, // no payload at all + {"tcp://example.com", false}, // "://" is not valid base64url + {"example.com:2222", false}, // a plain TCP host:port + {"user@example.com", false}, // a plain TCP user@host + {"10.0.0.1:22", false}, // a bare IPv4:port + {"tailscale-node.example", false}, // a bare hostname, no "tc" prefix + } + for _, c := range cases { + if got := looksLikeTailcatAddress(c.dest); got != c.want { + t.Errorf("looksLikeTailcatAddress(%q) = %v, want %v", c.dest, got, c.want) + } + } +} + +func TestSplitUserHost(t *testing.T) { + cases := []struct { + dest string + wantUser, want string + }{ + {"example.com", "", "example.com:22"}, + {"example.com:2222", "", "example.com:2222"}, + {"alice@example.com", "alice", "example.com:22"}, + {"alice@example.com:2222", "alice", "example.com:2222"}, + {"[::1]", "", "[::1]:22"}, + {"[::1]:2222", "", "[::1]:2222"}, + {"alice@[::1]:2222", "alice", "[::1]:2222"}, + } + for _, c := range cases { + user, hostPort := splitUserHost(c.dest, "22") + if user != c.wantUser || hostPort != c.want { + t.Errorf("splitUserHost(%q) = (%q, %q), want (%q, %q)", c.dest, user, hostPort, c.wantUser, c.want) + } + } +} + +// TestDialHTTPConnectProxy drives dialHTTPConnectProxy against a minimal +// fake HTTP CONNECT proxy, proving --proxy=http://... actually tunnels +// bytes rather than just parsing a URL. +func TestDialHTTPConnectProxy(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + const backendReply = "hello through the tunnel" + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + req, err := http.ReadRequest(bufio.NewReader(conn)) + if err != nil || req.Method != "CONNECT" { + return + } + fmt.Fprintf(conn, "HTTP/1.1 200 Connection Established\r\n\r\n") + // From here on, the proxy is just a pipe: write something only the + // far end of the tunnel could have -- the test can't easily stand + // up a real second hop, so this stands in for it directly. + conn.Write([]byte(backendReply)) + }() + + proxyURL := mustParseURL(t, "http://"+ln.Addr().String()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, err := dialHTTPConnectProxy(ctx, proxyURL, "backend.example:22") + if err != nil { + t.Fatalf("dialHTTPConnectProxy: %v", err) + } + defer conn.Close() + + buf := make([]byte, len(backendReply)) + if _, err := readFull(conn, buf); err != nil { + t.Fatalf("reading through the tunnel: %v", err) + } + if string(buf) != backendReply { + t.Errorf("got %q through the tunnel, want %q", buf, backendReply) + } +} + +func TestDialHTTPConnectProxyRejectsNon200(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + http.ReadRequest(bufio.NewReader(conn)) + fmt.Fprintf(conn, "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n") + }() + + proxyURL := mustParseURL(t, "http://"+ln.Addr().String()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := dialHTTPConnectProxy(ctx, proxyURL, "backend.example:22"); err == nil { + t.Fatal("dialHTTPConnectProxy against a 407 response did not error") + } +} + +func readFull(conn net.Conn, buf []byte) (int, error) { + total := 0 + for total < len(buf) { + n, err := conn.Read(buf[total:]) + total += n + if err != nil { + return total, err + } + } + return total, nil +} diff --git a/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs b/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs new file mode 100644 index 0000000..506a8c1 --- /dev/null +++ b/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs @@ -0,0 +1,492 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; +using Meowshell; + +namespace Meowshell.Tests; + +/// +/// Runs against a real "meowshell +/// agent" subprocess and a real tailcat server -- the check that the C# +/// wire client actually speaks the same framed protocol the Go side +/// (cmd/meowshell/agent.go, proven independently by its own Go-level E2E +/// tests) implements, not just that both sides individually parse their +/// own fixtures correctly. +/// +/// Skipped (each test returns immediately) when the real binaries are not +/// available, e.g. a local "dotnet test" run without a "dist" build. Needs +/// real network unless TS_DEBUG_TAILCAT_LOCAL_DERP=1 is set in the test +/// process's own environment (inherited by every child process this +/// spawns) for the hermetic local-DERP mode tailcat itself provides. +/// +public sealed class MeowshellAgentConnectionE2ETests : IDisposable +{ + private static readonly Regex AddressPattern = new(@"\btc[A-Za-z0-9_-]{10,}", RegexOptions.Compiled); + private static string Redact(string text) => AddressPattern.Replace(text, "tc"); + private static void Mask(string value) + { + if (!string.IsNullOrEmpty(value)) Console.WriteLine("::add-mask::" + value); + } + + private const string TailcatEnvVar = "DOTNET_E2E_TAILCAT_BIN"; + private const string MeowshellEnvVar = "DOTNET_E2E_MEOWSHELL_BIN"; + + private readonly string _dir = Directory.CreateTempSubdirectory("agent-connection-e2e-").FullName; + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + /// Same layout as TailcatClientE2ETests.FindRealBinaries(). Returns null (skip) if either binary is unavailable. + private (string binDir, string tailcatPath)? FindRealBinaries() + { + var tailcatSrc = Environment.GetEnvironmentVariable(TailcatEnvVar); + var meowshellSrc = Environment.GetEnvironmentVariable(MeowshellEnvVar); + if (string.IsNullOrEmpty(tailcatSrc) || string.IsNullOrEmpty(meowshellSrc) + || !File.Exists(tailcatSrc) || !File.Exists(meowshellSrc)) + { + return null; + } + + var bin = Path.Combine(_dir, "bin"); + Directory.CreateDirectory(bin); + var naming = BinaryNaming.ForCurrentPlatform(); + var tailcatDst = Path.Combine(bin, naming.FileName("tailcat")); + var meowshellDst = Path.Combine(bin, naming.FileName("meowshell")); + File.Copy(tailcatSrc, tailcatDst); + File.Copy(meowshellSrc, meowshellDst); + if (!OperatingSystem.IsWindows()) + { + const UnixFileMode exec = + UnixFileMode.UserRead | UnixFileMode.UserExecute | UnixFileMode.UserWrite; + File.SetUnixFileMode(tailcatDst, exec); + File.SetUnixFileMode(meowshellDst, exec); + } + return (bin, tailcatDst); + } + + private TailcatClientOptions ClientOptions(string bin) => new() + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "home"), + Timeout = TimeSpan.FromSeconds(30), + }; + + [Fact] + public async Task ExecChannelRunsACommandAndReportsARealExitCode() + { + var real = FindRealBinaries(); + if (real is null) return; // see FindRealBinaries() + var (bin, _) = real.Value; + + await using var server = await MeowshellServer.StartAsync(new MeowshellOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "server-home"), + WorkDirectory = Path.Combine(_dir, "server-work"), + InsecureNoAuth = true, + Lifetime = TimeSpan.FromMinutes(2), + StartTimeout = TimeSpan.FromSeconds(30), + }); + Mask(server.Address); + + await using var connection = await MeowshellAgentConnection.ConnectAsync(ClientOptions(bin), server.Address); + + var marker = $"agent-exec-e2e-{Guid.NewGuid():N}"; + await using (var ok = await connection.OpenExecAsync(["echo", marker])) + { + var output = await new StreamReader(ok.Output).ReadToEndAsync(); + Assert.Contains(marker, output); + Assert.Equal(0, await ok.Completed); + } + + await using var failing = await connection.OpenExecAsync(["sh", "-c", "'exit 42'"]); + Assert.Equal(42, await failing.Completed); + } + + [Fact] + public async Task ShellChannelAcceptsInputAndResizesLive() + { + var real = FindRealBinaries(); + if (real is null) return; // see FindRealBinaries() + var (bin, _) = real.Value; + + await using var server = await MeowshellServer.StartAsync(new MeowshellOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "server-home"), + WorkDirectory = Path.Combine(_dir, "server-work"), + InsecureNoAuth = true, + Lifetime = TimeSpan.FromMinutes(2), + StartTimeout = TimeSpan.FromSeconds(30), + }); + Mask(server.Address); + + await using var connection = await MeowshellAgentConnection.ConnectAsync(ClientOptions(bin), server.Address); + await using var shell = await connection.OpenShellAsync(columns: 80, rows: 24); + + await shell.ResizeAsync(120, 40); + + var marker = $"agent-shell-e2e-{Guid.NewGuid():N}"; + await shell.WriteAsync(Encoding.UTF8.GetBytes($"echo {marker}\n")); + await shell.WriteAsync(Encoding.UTF8.GetBytes("exit\n")); + + var output = await ReadUntilAsync(shell.Output, marker, TimeSpan.FromSeconds(30)); + Assert.Contains(marker, output); + await shell.Completed; + } + + [Fact] + public async Task SftpVerbsAndTransfersRoundTrip() + { + var real = FindRealBinaries(); + if (real is null) return; // see FindRealBinaries() + var (bin, _) = real.Value; + + var served = Path.Combine(_dir, "served"); + Directory.CreateDirectory(served); + await using var server = await MeowshellServer.StartAsync(new MeowshellOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "server-home"), + WorkDirectory = Path.Combine(_dir, "server-work"), + InsecureNoAuth = true, + Files = served + ":rw", + Lifetime = TimeSpan.FromMinutes(2), + StartTimeout = TimeSpan.FromSeconds(30), + }); + Mask(server.Address); + + await using var connection = await MeowshellAgentConnection.ConnectAsync(ClientOptions(bin), server.Address); + + await connection.MkdirAsync("uploads"); + var entries = await connection.ListFilesAsync("."); + Assert.Contains(entries, e => e.Name == "uploads" && e.IsDirectory); + + var localUpload = Path.Combine(_dir, "upload.txt"); + var content = $"agent-sftp-e2e-{Guid.NewGuid():N}"; + await File.WriteAllTextAsync(localUpload, content); + + var progressReports = new List(); + await connection.UploadAsync(localUpload, "uploads/file.txt", progress: new Progress(progressReports.Add)); + Assert.NotEmpty(progressReports); + + var stat = await connection.StatAsync("uploads/file.txt"); + Assert.Equal(content.Length, stat.Size); + + var localDownload = Path.Combine(_dir, "downloaded.txt"); + await connection.DownloadAsync("uploads/file.txt", localDownload); + Assert.Equal(content, await File.ReadAllTextAsync(localDownload)); + + await connection.RenameAsync("uploads/file.txt", "uploads/renamed.txt"); + await connection.RemoveAsync("uploads/renamed.txt"); + await connection.RemoveDirectoryAsync("uploads"); + + Assert.False(Directory.Exists(Path.Combine(served, "uploads"))); + } + + /// + /// -L/-D forwarding against a tailcat destination: forwardClient + /// (tailcatdial.go) dials through a native tailcat.Client instead of + /// an SSH direct-tcpip channel there, since tailcat's own embedded SSH + /// service never implements the latter (see forwarding.go's doc + /// comment) -- the same mechanism tailcat's own "forward"/"socks" + /// subcommands use. That dial is still gated by the destination + /// server's own tailcat.Server.OnTCP: without + /// it refuses anything but the server's own already-served ports, so + /// this starts the server with it set and checks actual bytes cross + /// the forward to an arbitrary backend -- not just that the listener + /// opens (a real Go-level daemon test already proves the underlying + /// feature: cmd/meowshell/agent_tailcat_forward_e2e_test.go). + /// + [Fact] + public async Task LocalForwardReachesAnArbitraryBackendOnAnExitNodeServer() + { + var real = FindRealBinaries(); + if (real is null) return; // see FindRealBinaries() + var (bin, _) = real.Value; + + await using var server = await MeowshellServer.StartAsync(new MeowshellOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "server-home"), + WorkDirectory = Path.Combine(_dir, "server-work"), + InsecureNoAuth = true, + AllowExitNode = true, + Lifetime = TimeSpan.FromMinutes(2), + StartTimeout = TimeSpan.FromSeconds(30), + }); + Mask(server.Address); + + await using var connection = await MeowshellAgentConnection.ConnectAsync(ClientOptions(bin), server.Address); + + using var backend = new TcpListener(IPAddress.Loopback, 0); + backend.Start(); + const string backendReply = "hello from the exit-node-forwarded backend"; + _ = Task.Run(async () => + { + while (true) + { + TcpClient client; + try { client = await backend.AcceptTcpClientAsync(); } + catch { return; } + _ = Task.Run(async () => + { + using (client) + await client.GetStream().WriteAsync(Encoding.UTF8.GetBytes(backendReply)); + }); + } + }); + + await using var forward = await connection.OpenLocalForwardAsync( + "127.0.0.1:0", $"127.0.0.1:{((IPEndPoint)backend.LocalEndpoint).Port}"); + Assert.NotEmpty(forward.BoundAddress); + + var boundEndpoint = IPEndPoint.Parse(forward.BoundAddress); + using var socket = new TcpClient(); + await socket.ConnectAsync(boundEndpoint); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var buffer = new byte[256]; + var total = 0; + int n; + while (total < buffer.Length && (n = await socket.GetStream().ReadAsync(buffer.AsMemory(total), cts.Token)) > 0) + total += n; + Assert.Equal(backendReply, Encoding.UTF8.GetString(buffer, 0, total)); + } + + /// + /// The Go-side loopback-default restriction (resolveLocalListener in + /// forwarding.go): binding anything other than loopback fails outright + /// unless explicitly opted into. Checked here at the C# call site -- + /// listenAddress reaches the agent process and is rejected before any + /// SSH channel is even attempted, so this doesn't depend on tailcat's + /// own (nonexistent) forwarding support. + /// + [Fact] + public async Task LocalForwardRejectsNonLoopbackBindUnlessAllowed() + { + var real = FindRealBinaries(); + if (real is null) return; // see FindRealBinaries() + var (bin, _) = real.Value; + + await using var server = await MeowshellServer.StartAsync(new MeowshellOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "server-home"), + WorkDirectory = Path.Combine(_dir, "server-work"), + InsecureNoAuth = true, + Lifetime = TimeSpan.FromMinutes(2), + StartTimeout = TimeSpan.FromSeconds(30), + }); + Mask(server.Address); + + await using var connection = await MeowshellAgentConnection.ConnectAsync(ClientOptions(bin), server.Address); + + var ex = await Assert.ThrowsAsync(() => + connection.OpenLocalForwardAsync("0.0.0.0:0", "127.0.0.1:1")); + Assert.Contains("loopback", ex.Message, StringComparison.OrdinalIgnoreCase); + + // The opt-in makes the identical bind succeed (the listener opens; + // whether tailcat itself would ever accept a forwarded connection + // is the separate, already-covered concern above). + await using var forward = await connection.OpenLocalForwardAsync("0.0.0.0:0", "127.0.0.1:1", allowNonLoopbackBind: true); + Assert.NotEmpty(forward.BoundAddress); + } + + /// + /// A Unix-domain-socket forward: the recommended local endpoint over a + /// TCP loopback socket, since filesystem permissions on the socket + /// path -- not merely "which port" -- are what restrict access. + /// + [Fact] + public async Task LocalForwardOnUnixSocketCreatesA0600Socket() + { + if (OperatingSystem.IsWindows()) return; // no AF_UNIX story to check here + var real = FindRealBinaries(); + if (real is null) return; // see FindRealBinaries() + var (bin, _) = real.Value; + + await using var server = await MeowshellServer.StartAsync(new MeowshellOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "server-home"), + WorkDirectory = Path.Combine(_dir, "server-work"), + InsecureNoAuth = true, + Lifetime = TimeSpan.FromMinutes(2), + StartTimeout = TimeSpan.FromSeconds(30), + }); + Mask(server.Address); + + await using var connection = await MeowshellAgentConnection.ConnectAsync(ClientOptions(bin), server.Address); + + var socketPath = Path.Combine(_dir, "forward.sock"); + await using var forward = await connection.OpenLocalForwardOnUnixSocketAsync(socketPath, "127.0.0.1:1"); + Assert.Equal(socketPath, forward.BoundAddress); + Assert.True(File.Exists(socketPath)); + var mode = File.GetUnixFileMode(socketPath); + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode & (UnixFileMode)0b111_111_111); + } + + /// + /// forward_socks with auth: by default OpenSocksForwardAsync generates + /// a random SOCKS5 username/password and the proxy enforces it via + /// RFC 1929 subnegotiation -- proven here entirely at the SOCKS + /// handshake layer (no CONNECT is ever attempted), so it doesn't + /// depend on tailcat's own forwarding support either. + /// + [Fact] + public async Task SocksForwardEnforcesAutoGeneratedToken() + { + var real = FindRealBinaries(); + if (real is null) return; // see FindRealBinaries() + var (bin, _) = real.Value; + + await using var server = await MeowshellServer.StartAsync(new MeowshellOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "server-home"), + WorkDirectory = Path.Combine(_dir, "server-work"), + InsecureNoAuth = true, + Lifetime = TimeSpan.FromMinutes(2), + StartTimeout = TimeSpan.FromSeconds(30), + }); + Mask(server.Address); + + await using var connection = await MeowshellAgentConnection.ConnectAsync(ClientOptions(bin), server.Address); + + await using var forward = await connection.OpenSocksForwardAsync("127.0.0.1:0"); + Assert.False(string.IsNullOrEmpty(forward.SocksUsername)); + Assert.False(string.IsNullOrEmpty(forward.SocksPassword)); + + var boundEndpoint = IPEndPoint.Parse(forward.BoundAddress); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + using (var noAuthClient = new Socket(SocketType.Stream, ProtocolType.Tcp)) + { + await noAuthClient.ConnectAsync(boundEndpoint, cts.Token); + var method = await Socks5GreetAsync(noAuthClient, [0x00], cts.Token); // only offers "no auth" + Assert.Equal(0xFF, method); // server requires auth: no acceptable method + } + + using (var wrongCreds = new Socket(SocketType.Stream, ProtocolType.Tcp)) + { + await wrongCreds.ConnectAsync(boundEndpoint, cts.Token); + Assert.Equal(0x02, await Socks5GreetAsync(wrongCreds, [0x02], cts.Token)); + var status = await Socks5AuthAsync(wrongCreds, forward.SocksUsername!, "not-the-right-password", cts.Token); + Assert.NotEqual(0x00, status); + } + + using (var rightCreds = new Socket(SocketType.Stream, ProtocolType.Tcp)) + { + await rightCreds.ConnectAsync(boundEndpoint, cts.Token); + Assert.Equal(0x02, await Socks5GreetAsync(rightCreds, [0x02], cts.Token)); + var status = await Socks5AuthAsync(rightCreds, forward.SocksUsername!, forward.SocksPassword!, cts.Token); + Assert.Equal(0x00, status); + } + } + + /// + /// Fix 4's real target: HandleData used to fire-and-forget into each + /// channel's sink (`_ = sink.OnDataAsync(...)`), which under + /// backpressure could leave two overlapping WriteAsync calls in + /// flight on the same Pipe -- undefined behavior. A remote command + /// producing several times the Pipe's default 64KiB threshold in one + /// channel is exactly the condition that used to be able to trigger + /// it; this checks the bytes come through complete and byte-for-byte + /// correct rather than merely "didn't throw" (the exception, when it + /// happened at all, was itself an intermittent Pipe invariant + /// violation, not a reliable repro on its own). + /// + [Fact] + public async Task ExecChannelDeliversLargeOutputIntactUnderBackpressure() + { + var real = FindRealBinaries(); + if (real is null) return; // see FindRealBinaries() + var (bin, _) = real.Value; + + await using var server = await MeowshellServer.StartAsync(new MeowshellOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "server-home"), + WorkDirectory = Path.Combine(_dir, "server-work"), + InsecureNoAuth = true, + Lifetime = TimeSpan.FromMinutes(2), + StartTimeout = TimeSpan.FromSeconds(30), + }); + Mask(server.Address); + + await using var connection = await MeowshellAgentConnection.ConnectAsync(ClientOptions(bin), server.Address); + + const int totalBytes = 512 * 1024; // several multiples of the Pipe's 64KiB PauseWriterThreshold + // One array element, not ["sh", "-c", ...]: OpenExecAsync joins + // elements with spaces and the exec request already runs through + // the remote's own shell (see agent.go's session.Start), so an + // explicit "sh -c" prefix here double-wraps it -- the inner "sh -c" + // then only takes "head" as its script and the rest as positional + // params, leaving a bare `head` blocked forever reading this + // channel's own (never-written, never-closed) stdin. + await using var exec = await connection.OpenExecAsync([$"head -c {totalBytes} /dev/zero | tr '\\0' 'A'"]); + + using var ms = new MemoryStream(); + await exec.Output.CopyToAsync(ms); + Assert.Equal(0, await exec.Completed); + + var received = ms.ToArray(); + Assert.Equal(totalBytes, received.Length); + Assert.All(received, b => Assert.Equal((byte)'A', b)); + } + + private static async Task Socks5GreetAsync(Socket socket, byte[] methods, CancellationToken cancellationToken) + { + var greeting = new byte[2 + methods.Length]; + greeting[0] = 0x05; + greeting[1] = (byte)methods.Length; + methods.CopyTo(greeting, 2); + await socket.SendAsync(greeting, cancellationToken); + var resp = new byte[2]; + await ReadExactAsync(socket, resp, cancellationToken); + Assert.Equal(0x05, resp[0]); + return resp[1]; + } + + private static async Task Socks5AuthAsync(Socket socket, string username, string password, CancellationToken cancellationToken) + { + var userBytes = Encoding.UTF8.GetBytes(username); + var passBytes = Encoding.UTF8.GetBytes(password); + var req = new byte[3 + userBytes.Length + passBytes.Length]; + req[0] = 0x01; + req[1] = (byte)userBytes.Length; + userBytes.CopyTo(req, 2); + req[2 + userBytes.Length] = (byte)passBytes.Length; + passBytes.CopyTo(req, 3 + userBytes.Length); + await socket.SendAsync(req, cancellationToken); + var resp = new byte[2]; + await ReadExactAsync(socket, resp, cancellationToken); + return resp[1]; + } + + private static async Task ReadExactAsync(Socket socket, byte[] buffer, CancellationToken cancellationToken) + { + var total = 0; + while (total < buffer.Length) + { + var n = await socket.ReceiveAsync(buffer.AsMemory(total), cancellationToken); + if (n == 0) throw new IOException("socket closed before the expected reply arrived"); + total += n; + } + } + + /// Reads from stream until has appeared or elapses, returning everything read so far either way. + private static async Task ReadUntilAsync(Stream stream, string marker, TimeSpan timeout) + { + var buffer = new byte[4096]; + var text = new StringBuilder(); + using var cts = new CancellationTokenSource(timeout); + while (!text.ToString().Contains(marker)) + { + var read = await stream.ReadAsync(buffer, cts.Token); + if (read == 0) break; + text.Append(Encoding.UTF8.GetString(buffer, 0, read)); + } + return text.ToString(); + } +} diff --git a/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs b/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs index 22e264d..fcd8c27 100644 --- a/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs +++ b/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Net; +using System.Net.Sockets; using Meowshell; namespace Meowshell.Tests; @@ -111,4 +113,93 @@ public async Task APortForwardStartsItsLocalListenerAndStopsCleanly() await forward.StopAsync(); Assert.True(forward.Completed.IsCompletedSuccessfully); } + + /// + /// The actual fix, not just "the listener comes up": without + /// , a server refuses to + /// forward to any port it isn't already otherwise serving (tailcat's + /// own OnTCP gate sends a RST) -- true of "tailcat forward" today, and + /// would stay true regardless of which client API asks. With it set, + /// against a real + /// should actually move bytes to an + /// arbitrary local backend, not just open its own listener. + /// + [Fact] + public async Task APortForwardWithAllowExitNodeReachesAnArbitraryBackend() + { + var real = RealBinaries(); + if (real is null) return; // see RealBinaries() + var (bin, _) = real.Value; + + using var backend = new TcpListener(IPAddress.Loopback, 0); + backend.Start(); + const string backendReply = "hello from the exit-node-forwarded backend"; + _ = Task.Run(async () => + { + while (true) + { + TcpClient client; + try { client = await backend.AcceptTcpClientAsync(); } + catch { return; } + _ = Task.Run(async () => + { + using (client) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(backendReply); + await client.GetStream().WriteAsync(bytes); + } + }); + } + }); + var backendPort = ((IPEndPoint)backend.LocalEndpoint).Port; + + await using var server = await MeowshellServer.StartAsync(new MeowshellOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "server-home"), + WorkDirectory = Path.Combine(_dir, "server-work"), + InsecureNoAuth = true, + AllowExitNode = true, + Lifetime = TimeSpan.FromMinutes(2), + StartTimeout = TimeSpan.FromSeconds(30), + }); + + await using var forward = await MeowshellPortForward.StartAsync(new MeowshellPortForwardOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "forward-home"), + Address = server.Address, + Mappings = [$"0:{backendPort}"], + }); + + // MeowshellPortForward's own Log event is the only way to learn the + // OS-assigned local port (see forwarding.go's own doc comment on + // the mapping syntax) -- it logs "forwarding -> ..." once + // the listener is up. + var boundAddressFound = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + forward.Log += line => + { + var marker = "forwarding "; + var at = line.IndexOf(marker, StringComparison.Ordinal); + if (at < 0) return; + var rest = line[(at + marker.Length)..]; + var end = rest.IndexOf(' '); + if (end > 0) boundAddressFound.TrySetResult(rest[..end]); + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var registration = cts.Token.Register(() => boundAddressFound.TrySetCanceled()); + var boundAddress = await boundAddressFound.Task; + + using var socket = new TcpClient(); + await socket.ConnectAsync(IPEndPoint.Parse(boundAddress), cts.Token); + using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var buffer = new byte[256]; + var total = 0; + int n; + while (total < buffer.Length && (n = await socket.GetStream().ReadAsync(buffer.AsMemory(total), readCts.Token)) > 0) + total += n; + var got = System.Text.Encoding.UTF8.GetString(buffer, 0, total); + Assert.Equal(backendReply, got); + } } diff --git a/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs b/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs index 5dcf59c..eb33072 100644 --- a/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs +++ b/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs @@ -142,7 +142,11 @@ public async Task ParseThrowsOnAGenuinelyInvalidAddress() /// -- and it's the case ConnectAsync's fail-fast timeout race exists /// for: the failure has to surface as a thrown exception from /// ConnectAsync itself, not just an eventually-faulted Completed the - /// caller happened to never await. + /// caller happened to never await. TailcatSshSession is built on + /// MeowshellAgentConnection, whose failures report a typed + /// MeowshellErrorCode rather than a process exit code (the agent + /// process itself need not have exited nonzero at all for a connect + /// attempt to fail at the protocol level) -- ExitCode is always 0 here. /// [Fact] public async Task SshSessionConnectAsyncThrowsOnAGenuinelyInvalidAddress() @@ -153,7 +157,7 @@ public async Task SshSessionConnectAsyncThrowsOnAGenuinelyInvalidAddress() var ex = await Assert.ThrowsAsync( () => TailcatSshSession.ConnectAsync(ClientOptions(bin), "tcnotarealaddress")); - Assert.NotEqual(0, ex.ExitCode); + Assert.NotEqual(MeowshellErrorCode.None, ex.Code); } /// diff --git a/dotnet/Meowshell.Tests/TailcatClientTests.cs b/dotnet/Meowshell.Tests/TailcatClientTests.cs index 5fa0f0f..5de085f 100644 --- a/dotnet/Meowshell.Tests/TailcatClientTests.cs +++ b/dotnet/Meowshell.Tests/TailcatClientTests.cs @@ -51,6 +51,32 @@ public sealed class TailcatClientTests : IDisposable }, argsFile); } + /// Same idea as , but for GetEnvironmentAsync, which runs "meowshell" directly rather than "tailcat". + private TailcatClientOptions FakeMeowshell(string script) + { + var bin = Path.Combine(_dir, "bin"); + Directory.CreateDirectory(bin); + var naming = BinaryNaming.ForCurrentPlatform(); + var meowshell = Path.Combine(bin, naming.FileName("meowshell")); + File.WriteAllText(meowshell, "#!/bin/bash\n" + script); + var tailcat = Path.Combine(bin, naming.FileName("tailcat")); + File.WriteAllText(tailcat, "#!/bin/bash\ntrue\n"); + if (!OperatingSystem.IsWindows()) + { + const UnixFileMode exec = UnixFileMode.UserRead | UnixFileMode.UserExecute | UnixFileMode.UserWrite; + File.SetUnixFileMode(meowshell, exec); + File.SetUnixFileMode(tailcat, exec); + } + + return new TailcatClientOptions + { + BinaryDirectory = bin, + HomeDirectory = Path.Combine(_dir, "home"), + Naming = naming, + Timeout = TimeSpan.FromSeconds(10), + }; + } + [Fact] public async Task GenerateKeyReturnsTheLastLineOfOutput() { @@ -536,4 +562,43 @@ await TailcatClient.ResolveAsync( ["--derpmap-url=https://derp.example/map.json", "--verbose", "resolve", "tcADDR"], File.ReadAllLines(argsFile)); } + + [Fact] + public async Task GetEnvironmentParsesAFullMeowshellEnvReport() + { + // Shape captured from cmd/meowshell/main.go's printEnv(). + var options = FakeMeowshell( + "printf 'shell /bin/bash\\nhome /home/e2e\\nuser e2e\\npath /usr/bin:/bin\\nterm xterm-256color\\nlang en_US.UTF-8\\ntailcat /opt/bin/tailcat\\n'\n"); + + var env = await TailcatClient.GetEnvironmentAsync(options); + + Assert.Equal("/bin/bash", env.Shell); + Assert.Equal("/home/e2e", env.Home); + Assert.Equal("e2e", env.User); + Assert.Equal("/usr/bin:/bin", env.Path); + Assert.Equal("xterm-256color", env.Term); + Assert.Equal("en_US.UTF-8", env.Lang); + Assert.Equal("/opt/bin/tailcat", env.TailcatBinaryPath); + Assert.Empty(env.Warnings); + } + + [Fact] + public async Task GetEnvironmentSurfacesWarningsAndAMissingTailcatBinary() + { + var options = FakeMeowshell( + "printf 'shell /bin/sh\\nhome /home/e2e\\nuser e2e\\npath /usr/bin\\nterm \\nlang \\ntailcat NOT FOUND (exec: \"tailcat\": executable file not found in $PATH)\\nwarning: $SHELL not set, falling back to /bin/sh\\n'\n"); + + var env = await TailcatClient.GetEnvironmentAsync(options); + + Assert.Null(env.TailcatBinaryPath); + Assert.Equal(["$SHELL not set, falling back to /bin/sh"], env.Warnings); + } + + [Fact] + public async Task GetEnvironmentThrowsOnFailure() + { + var options = FakeMeowshell("echo 'boom' >&2\nexit 1\n"); + var ex = await Assert.ThrowsAsync(() => TailcatClient.GetEnvironmentAsync(options)); + Assert.Equal(1, ex.ExitCode); + } } diff --git a/dotnet/Meowshell/Meowshell.csproj b/dotnet/Meowshell/Meowshell.csproj index c27cb72..f135811 100644 --- a/dotnet/Meowshell/Meowshell.csproj +++ b/dotnet/Meowshell/Meowshell.csproj @@ -32,6 +32,15 @@ Pack="true" PackagePath="buildTransitive/net10.0-android36.0/" /> + + + + +