diff --git a/cmd/meowshell/agent.go b/cmd/meowshell/agent.go index 0afb899..9ad16af 100644 --- a/cmd/meowshell/agent.go +++ b/cmd/meowshell/agent.go @@ -48,13 +48,11 @@ typed at directly. 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") @@ -74,9 +72,6 @@ func agentCmd(args []string) error { } 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) @@ -97,11 +92,6 @@ func agentCmd(args []string) error { 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) @@ -149,12 +139,6 @@ func agentCmd(args []string) error { 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) { @@ -174,9 +158,6 @@ func classifyConnectError(err error) errorCode { 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 @@ -186,20 +167,14 @@ type connectOptions struct { 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 + proxyURL string + auth []ssh.AuthMethod } -// 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 @@ -209,26 +184,12 @@ type agentChannel struct { 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 + hops []*ssh.Client in io.Reader out io.Writer @@ -240,30 +201,21 @@ type agentSession struct { nextID atomic.Uint32 sftpMu sync.Mutex - sftpClient *sftp.Client // lazily opened by sftpClientFor (agentsftp.go), shared across every ls/stat/.../upload/download + sftpClient *sftp.Client 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 + tcClient *tailcat.Client } func newAgentSession(in io.Reader, out io.Writer) *agentSession { @@ -277,15 +229,6 @@ func newAgentSession(in io.Reader, out io.Writer) *agentSession { 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) } { @@ -304,9 +247,6 @@ func (a *agentSession) forwardClient() interface { 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 { @@ -325,12 +265,6 @@ func (a *agentSession) readConfigure() (controlMessage, error) { 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 @@ -416,13 +350,6 @@ const ( 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) @@ -451,9 +378,6 @@ func (a *agentSession) startKeepalive() { }() } -// 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 { @@ -461,8 +385,6 @@ func (a *agentSession) reportConnectionLost(err error) { } } -// 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", @@ -478,9 +400,6 @@ func (a *agentSession) promptHostKey(hostname string, remote net.Addr, key ssh.P 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" @@ -529,10 +448,6 @@ func (a *agentSession) writeError(channelID uint32, code errorCode, err error) e 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() @@ -592,15 +507,10 @@ func (a *agentSession) deliverPromptResponse(msg controlMessage) { } } -// 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 + return } switch { case ch.stdin != nil: @@ -618,9 +528,6 @@ func (a *agentSession) channel(id uint32) *agentChannel { 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": @@ -633,11 +540,6 @@ func (a *agentSession) openChannel(msg controlMessage) { 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 { @@ -693,14 +595,6 @@ func (a *agentSession) openShellChannel(msg controlMessage) { 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() @@ -715,8 +609,7 @@ func (a *agentSession) openShellChannel(msg controlMessage) { 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() @@ -728,17 +621,6 @@ func (a *agentSession) openShellChannel(msg controlMessage) { 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) }() @@ -746,9 +628,6 @@ func (a *agentSession) openShellChannel(msg controlMessage) { 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 { @@ -764,12 +643,6 @@ func (a *agentSession) pumpToClient(id uint32, stream byte, r io.Reader) { } } -// 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() @@ -792,7 +665,7 @@ func (a *agentSession) waitChannel(id uint32, ch *agentChannel, wg *sync.WaitGro 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 + return } 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)) @@ -803,13 +676,6 @@ func (a *agentSession) resize(channelID uint32, msg controlMessage) { } } -// 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 { diff --git a/cmd/meowshell/agent_auth_e2e_test.go b/cmd/meowshell/agent_auth_e2e_test.go index a23bb1c..6834172 100644 --- a/cmd/meowshell/agent_auth_e2e_test.go +++ b/cmd/meowshell/agent_auth_e2e_test.go @@ -16,11 +16,6 @@ import ( "golang.org/x/crypto/ssh" ) -// startAuthTestSSHServer is startTestSSHServer with the auth requirement -// left to the caller (configure sets a Password/PublicKey/KeyboardInteractive -// callback on cfg before it's used), for tests that need meowshell agent's -// new auth methods actually exercised end to end rather than the -// NoClientAuth free pass startTestSSHServer gives every other test here. func startAuthTestSSHServer(t *testing.T, configure func(cfg *ssh.ServerConfig)) (addr string, hostKey ssh.Signer) { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -52,10 +47,6 @@ func startAuthTestSSHServer(t *testing.T, configure func(cfg *ssh.ServerConfig)) return ln.Addr().String(), signer } -// newTestKeyPair generates an ed25519 key pair and returns the private key -// as an unencrypted PKCS#8 PEM block (what ssh.ParsePrivateKey accepts) and -// its ssh.PublicKey, for tests that need real key bytes to hand a -// "configure" message the way an app would. func newTestKeyPair(t *testing.T) (privatePEM []byte, public ssh.PublicKey) { t.Helper() pub, priv, err := ed25519.GenerateKey(rand.Reader) @@ -74,9 +65,6 @@ func newTestKeyPair(t *testing.T) (privatePEM []byte, public ssh.PublicKey) { return pemBytes, sshPub } -// acceptHostKeyPrompt reads and accepts the TOFU host-key prompt every -// first connection to a fresh known_hosts file raises, so an auth-focused -// test doesn't have to special-case it inline. func acceptHostKeyPrompt(t *testing.T, stdin *os.File, out *bufio.Reader) { t.Helper() f := mustReadFrame(t, out) @@ -120,9 +108,6 @@ func TestAgentPasswordAuth(t *testing.T) { cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) defer stopAgent(t, cmd, stdin) - // The host key is already trusted from the previous subtest's - // connection to the same address+known_hosts -- no TOFU prompt - // this time, straight to the password prompt. f := mustReadFrame(t, out) msg := decodeControl(t, f) if msg.Msg != "prompt_request" || msg.PromptKind != "password" { @@ -167,8 +152,6 @@ func TestAgentSuppliedPrivateKeyAuth(t *testing.T) { controlMessage{Msg: "configure", Keys: [][]byte{wrongPEM}}) defer stopAgent(t, cmd, stdin) - // Host key already trusted from the previous subtest; straight to - // the auth failure this time. f := mustReadFrame(t, out) msg := decodeControl(t, f) if msg.Msg != "error" || msg.Code != errAuthFailed { diff --git a/cmd/meowshell/agent_e2e_test.go b/cmd/meowshell/agent_e2e_test.go index 7643c3f..c879eeb 100644 --- a/cmd/meowshell/agent_e2e_test.go +++ b/cmd/meowshell/agent_e2e_test.go @@ -13,17 +13,6 @@ import ( "time" ) -// TestAgentEndToEnd drives "meowshell agent" as a real subprocess against a -// real tailcat server (hermetic: TS_DEBUG_TAILCAT_LOCAL_DERP replaces the -// public DERP/STUN infrastructure with an in-process one, so this needs no -// network access), speaking the framed control protocol exactly as -// dotnet/Meowshell's MeowshellAgentConnection eventually will. It proves -// the multiplexing daemon model end to end: one process, one handshake, -// two channels (an exec and a resized shell) opened on it in turn. -// -// Needs real tailcat/meowshell binaries built for this platform; skips -// itself when they aren't found rather than failing the whole package -// (dist/ is a local/CI build product, not checked in). func TestAgentEndToEnd(t *testing.T) { tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") @@ -72,9 +61,6 @@ func TestAgentEndToEnd(t *testing.T) { }) t.Run("exec channel with a nonzero exit reports it as a structured value", func(t *testing.T) { - // Quoted, not bare: Command elements are joined with plain spaces - // (agent.go documents this, matching connect.go and a real ssh - // client), so "exit 42" must survive as one argument to sh -c. send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "exec", Command: []string{"sh", "-c", "'exit 42'"}}) id := expectChannelOpened(t, out) @@ -103,9 +89,6 @@ func TestAgentEndToEnd(t *testing.T) { }) } -// findE2EBinary locates a real binary for the e2e test to drive: an -// explicit env var override, falling back to dist/ 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 != "" { @@ -122,9 +105,6 @@ func findE2EBinary(t *testing.T, envVar, distName string) string { return abs } -// startE2EServer starts an unauthenticated meowshell server over a -// hermetic local DERP relay and returns the address it publishes, the same -// setup e2e/host-e2e.sh uses against real binaries. func startE2EServer(t *testing.T, tailcatBin, meowshellBin, home string) string { t.Helper() addrFile := filepath.Join(home, "addr") @@ -175,10 +155,6 @@ func mustWriteFrame(t *testing.T, w io.Writer, f frame) { } } -// expectConnected reads the connection-level handshake message the agent -// sends once it has finished dialing (and any host-key/auth prompting -// along the way): "connected" on success, or an "error" that fails the -// test outright, since nothing after this point can succeed either. func expectConnected(t *testing.T, r *bufio.Reader) { t.Helper() f, err := readFrameWithDeadline(t, r) @@ -199,8 +175,6 @@ func expectConnected(t *testing.T, r *bufio.Reader) { } } -// expectChannelOpened reads frames until channel_opened, failing the test -// on an error frame or a control message it didn't expect. func expectChannelOpened(t *testing.T, r *bufio.Reader) uint32 { t.Helper() for { @@ -224,8 +198,6 @@ func expectChannelOpened(t *testing.T, r *bufio.Reader) uint32 { } } -// readUntilExit collects data frames for id until its exit_status arrives, -// returning the concatenated stdout+stderr bytes seen along the way. func readUntilExit(t *testing.T, r *bufio.Reader, id uint32) []byte { t.Helper() var buf bytes.Buffer @@ -239,7 +211,7 @@ func readUntilExit(t *testing.T, r *bufio.Reader, id uint32) []byte { } switch f.Type { case frameTypeData: - buf.Write(f.Payload[1:]) // drop the stream tag + buf.Write(f.Payload[1:]) case frameTypeControl: var msg controlMessage if err := json.Unmarshal(f.Payload, &msg); err != nil { @@ -255,8 +227,6 @@ func readUntilExit(t *testing.T, r *bufio.Reader, id uint32) []byte { } } -// readExitOnly is readUntilExit's counterpart when only the exit code -// matters to the caller. func readExitOnly(t *testing.T, r *bufio.Reader, id uint32) int { t.Helper() for { @@ -280,8 +250,6 @@ func readExitOnly(t *testing.T, r *bufio.Reader, id uint32) int { } } -// readUntil collects data frames for id until want appears in them or -// timeout elapses. func readUntil(t *testing.T, r *bufio.Reader, id uint32, want string, timeout time.Duration) []byte { t.Helper() deadline := time.Now().Add(timeout) @@ -303,9 +271,6 @@ func readUntil(t *testing.T, r *bufio.Reader, id uint32, want string, timeout ti return nil } -// readFrameWithDeadline wraps readFrame with an overall per-call budget, so -// a protocol bug hangs the one subtest that hit it instead of the whole -// test binary. func readFrameWithDeadline(t *testing.T, r *bufio.Reader) (frame, error) { t.Helper() type result struct { diff --git a/cmd/meowshell/agent_forward_e2e_test.go b/cmd/meowshell/agent_forward_e2e_test.go index 7220990..b78214f 100644 --- a/cmd/meowshell/agent_forward_e2e_test.go +++ b/cmd/meowshell/agent_forward_e2e_test.go @@ -9,9 +9,6 @@ import ( "time" ) -// TestAgentLocalForwardEndToEnd drives "-L"-style forwarding: the agent -// listens locally and forwards each accepted connection through the SSH -// client to a target the test's own fake SSH server dials back out to. func TestAgentLocalForwardEndToEnd(t *testing.T) { meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") @@ -34,7 +31,7 @@ func TestAgentLocalForwardEndToEnd(t *testing.T) { } }() - addr, _, _ := startTestSSHServer(t, echoCommandHandler) // the SSH server whose Dial reaches backendLn + addr, _, _ := startTestSSHServer(t, echoCommandHandler) knownHosts := filepath.Join(t.TempDir(), "known_hosts") cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) @@ -67,10 +64,6 @@ func TestAgentLocalForwardEndToEnd(t *testing.T) { } } -// TestAgentSOCKSForwardEndToEnd drives "-D": a SOCKS5 client (net.Dialer -// speaking the protocol by hand, since the standard library has no SOCKS5 -// client of its own either) connects through the agent's SOCKS listener -// to a backend the SSH server's own Dial reaches. func TestAgentSOCKSForwardEndToEnd(t *testing.T) { meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") @@ -122,8 +115,6 @@ func TestAgentSOCKSForwardEndToEnd(t *testing.T) { } } -// socks5Connect speaks just enough SOCKS5 client-side to CONNECT to -// target through conn and read back whatever the far end sends. func socks5Connect(conn net.Conn, target string) (string, error) { if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { return "", err diff --git a/cmd/meowshell/agent_security_e2e_test.go b/cmd/meowshell/agent_security_e2e_test.go index 2025444..34a1ceb 100644 --- a/cmd/meowshell/agent_security_e2e_test.go +++ b/cmd/meowshell/agent_security_e2e_test.go @@ -10,9 +10,6 @@ import ( "time" ) -// TestAgentRejectsNonLoopbackBindByDefault proves the fix for the -// unrestricted-bind finding: a forward_local asking to listen on a -// non-loopback address is refused unless AllowNonLoopbackBind is set. func TestAgentRejectsNonLoopbackBindByDefault(t *testing.T) { meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") @@ -36,9 +33,7 @@ func TestAgentRejectsNonLoopbackBindByDefault(t *testing.T) { t.Run("0.0.0.0 succeeds with AllowNonLoopbackBind", func(t *testing.T) { cmd, stdin, out := startAgent(t, meowshellBin, knownHosts, "testuser@"+addr) defer stopAgent(t, cmd, stdin) - // known_hosts already trusts this server from the first subtest, - // so no host-key prompt this time -- but "connected" still comes - // first, same as any other fresh connection. + expectConnected(t, out) send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "forward_local", ListenAddr: "0.0.0.0:0", RemoteAddr: "127.0.0.1:1", AllowNonLoopbackBind: true}) @@ -64,9 +59,6 @@ func TestAgentRejectsNonLoopbackBindByDefault(t *testing.T) { }) } -// TestAgentUnixSocketForward proves the UDS fix: a forward_local with -// listen_network "unix" listens on a filesystem-permission-protected -// socket, chmod'd 0600 regardless of umask, and actually relays bytes. func TestAgentUnixSocketForward(t *testing.T) { if os.PathSeparator == '\\' { t.Skip("unix domain sockets aren't this test's concern on Windows") @@ -129,9 +121,6 @@ func TestAgentUnixSocketForward(t *testing.T) { } } -// TestAgentSocksAuthToken proves the SOCKS5 auth fix: a proxy opened with -// SocksUsername/SocksPassword refuses a client presenting the wrong -// credentials (or none), and serves one presenting the right pair. func TestAgentSocksAuthToken(t *testing.T) { meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") @@ -188,8 +177,7 @@ func TestAgentSocksAuthToken(t *testing.T) { t.Fatal(err) } defer conn.Close() - // Offer only "no auth"; the server requires user/pass and must - // reject the method-selection outright (0xFF), not fall back. + if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { t.Fatal(err) } @@ -218,10 +206,6 @@ func TestAgentSocksAuthToken(t *testing.T) { }) } -// TestAgentConfigureCarriesProxyURL proves the argv fix: the agent -// connects through a proxy configured via the "configure" message -// (never a CLI flag, so it never lands in this process's own argv/ -// /proc/pid/cmdline) exactly as it did when --proxy was still a flag. func TestAgentConfigureCarriesProxyURL(t *testing.T) { meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") addr, _, _ := startTestSSHServer(t, echoCommandHandler) @@ -257,10 +241,6 @@ func TestAgentConfigureCarriesProxyURL(t *testing.T) { } } -// serveHTTPConnectProxy answers exactly one CONNECT request by dialing -// wantTarget itself (ignoring whatever the client asked for, since this -// test only cares whether the agent used the proxy at all) and signals -// dialed once it has. func serveHTTPConnectProxy(t *testing.T, conn net.Conn, wantTarget string, dialed chan<- struct{}) { defer conn.Close() buf := make([]byte, 4096) @@ -287,8 +267,6 @@ func serveHTTPConnectProxy(t *testing.T, conn net.Conn, wantTarget string, diale const e2eDialTimeout = 5 * time.Second -// socks5Auth performs the greeting + username/password subnegotiation -// only, for a test that expects it to fail. func socks5Auth(conn net.Conn, username, password string) (bool, error) { if _, err := conn.Write([]byte{0x05, 0x01, 0x02}); err != nil { return false, err @@ -317,8 +295,6 @@ func socks5Auth(conn net.Conn, username, password string) (bool, error) { return true, nil } -// socks5AuthAndConnect is socks5Auth plus a CONNECT request, returning -// whatever the far end sends back. func socks5AuthAndConnect(conn net.Conn, username, password, target string) (string, error) { if ok, err := socks5Auth(conn, username, password); err != nil || !ok { return "", err diff --git a/cmd/meowshell/agent_sftp_e2e_test.go b/cmd/meowshell/agent_sftp_e2e_test.go index a739366..9406c50 100644 --- a/cmd/meowshell/agent_sftp_e2e_test.go +++ b/cmd/meowshell/agent_sftp_e2e_test.go @@ -11,9 +11,6 @@ import ( "time" ) -// TestAgentSFTPEndToEnd drives meowshell agent's SFTP surface (verbs -// beyond meowshell cp's plain upload/download/ls) against a real tailcat -// server, over the local-DERP hermetic setup agent_e2e_test.go uses. func TestAgentSFTPEndToEnd(t *testing.T) { tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") @@ -22,14 +19,9 @@ func TestAgentSFTPEndToEnd(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) t.Setenv("TS_DEBUG_TAILCAT_LOCAL_DERP", "1") - served := t.TempDir() // meowshell serve --files serves this directory + served := t.TempDir() addr := startE2EFilesServer(t, tailcatBin, meowshellBin, home, served) - // startAgent (agent_tcp_e2e_test.go) doesn't set an explicit Env for - // the subprocess, so it inherits the test process's own -- this is - // what gets TAILCAT_BIN to it for a tailcat-address destination - // (--known-hosts is harmless but unused on this path: tailcat - // transport never builds a TCP host-key callback). t.Setenv("TAILCAT_BIN", tailcatBin) _, stdin, out := startAgent(t, meowshellBin, filepath.Join(t.TempDir(), "known_hosts"), addr) t.Cleanup(func() { stdin.Close() }) @@ -118,7 +110,7 @@ func TestAgentSFTPEndToEnd(t *testing.T) { }) t.Run("download reports progress and a real total size", func(t *testing.T) { - payload := bytes.Repeat([]byte("0123456789"), 10_000) // 100KB, big enough to cross the progress interval at least once + payload := bytes.Repeat([]byte("0123456789"), 10_000) uploadViaAgent(t, stdin, out, "big.bin", payload, false, 0, 0) send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "sftp_download", Path: "big.bin"}) @@ -194,8 +186,6 @@ func startE2EFilesServer(t *testing.T, tailcatBin, meowshellBin, home, served st return "" } -// sftpOp sends one sftp_op request and returns its sftp_result, failing -// the test on an error response. func sftpOp(t *testing.T, stdin interface { Write([]byte) (int, error) }, out *bufio.Reader, req controlMessage) controlMessage { @@ -214,8 +204,6 @@ func sftpOp(t *testing.T, stdin interface { return msg } -// trySFTPOp is sftpOp for a call the test expects might fail: it returns -// the error code (or "" on success) instead of failing the test itself. func trySFTPOp(t *testing.T, stdin interface { Write([]byte) (int, error) }, out *bufio.Reader, req controlMessage) errorCode { diff --git a/cmd/meowshell/agent_tailcat_forward_e2e_test.go b/cmd/meowshell/agent_tailcat_forward_e2e_test.go index e7d69f4..58aab63 100644 --- a/cmd/meowshell/agent_tailcat_forward_e2e_test.go +++ b/cmd/meowshell/agent_tailcat_forward_e2e_test.go @@ -12,16 +12,6 @@ import ( "time" ) -// TestAgentForwardsThroughTailcatDestination proves the fix for the daemon's -// biggest forwarding gap: forward_local/forward_socks against a *tailcat* -// destination used to open successfully (the listener bound fine) but drop -// every accepted connection, since tailcat's own embedded SSH service never -// implements SSH-level forwarding (see forwarding.go's doc comment). Now -// forwardClient (tailcatdial.go) picks a native tailcat.Client instead of -// the SSH client for that case, dialing the same way tailcat's own -// "forward"/"socks" subcommands do. This drives both forward_local and -// forward_socks against a real (hermetic, local-DERP) tailcat server and -// checks actual bytes flow end to end -- not just that the channel opens. func TestAgentForwardsThroughTailcatDestination(t *testing.T) { tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") @@ -30,20 +20,8 @@ func TestAgentForwardsThroughTailcatDestination(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) t.Setenv("TS_DEBUG_TAILCAT_LOCAL_DERP", "1") - // Forwarding to an arbitrary port needs the server started as an exit - // node (tailcat.Server.OnTCP only forwards a bare-port dial to its own - // services otherwise -- see cmd/tailcat/tailcat.go's own OnTCP, which - // serves 22 and any --files/--ssh-authorized-keys ports but sends a RST - // for anything else unless "exit-node" is one of its served services). - // meowshell serve's own --exit-node flag (main.go) requests exactly - // that -- unlike startE2EServer (used by the general daemon E2E tests), - // which starts a plain "no-auth-ssh" server with no forwarding at all. addr := startE2EServerWithExitNode(t, tailcatBin, meowshellBin, home) - // Stands in for "a service running on the server": from the tailcat - // server's own point of view this is its own loopback, since the test - // server and this backend both run as this one test process's own - // child/local listeners on the same machine. backendLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) @@ -143,11 +121,6 @@ func TestAgentForwardsThroughTailcatDestination(t *testing.T) { }) } -// startE2EServerWithExitNode starts "meowshell serve --insecure-no-auth -// --exit-node" (so an agent can still connect at all, and its OnTCP -// handler also forwards any port, not just the ones its other services -// already listen on -- see this test's own comment at its call site) over -// a hermetic local DERP relay. func startE2EServerWithExitNode(t *testing.T, tailcatBin, meowshellBin, home string) string { t.Helper() addrFile := filepath.Join(home, "addr") diff --git a/cmd/meowshell/agent_tcp_e2e_test.go b/cmd/meowshell/agent_tcp_e2e_test.go index ef63cba..0af3913 100644 --- a/cmd/meowshell/agent_tcp_e2e_test.go +++ b/cmd/meowshell/agent_tcp_e2e_test.go @@ -16,11 +16,6 @@ import ( "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") @@ -60,13 +55,12 @@ func TestAgentTCPEndToEnd(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 + stopServer1() _, 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") @@ -88,10 +82,6 @@ func startAgent(t *testing.T, meowshellBin, knownHosts, dest string) (*exec.Cmd, 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) @@ -154,20 +144,11 @@ func addrPort(t *testing.T, hostPort string) string { 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") @@ -182,9 +163,6 @@ func startTestSSHServer(t *testing.T, handleExec func(ssh.Channel, string)) (add 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) @@ -266,13 +244,6 @@ func serveTestSSHConn(conn net.Conn, config *ssh.ServerConfig, handleExec func(s } } -// 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 diff --git a/cmd/meowshell/agentauth.go b/cmd/meowshell/agentauth.go index dafd57a..9621181 100644 --- a/cmd/meowshell/agentauth.go +++ b/cmd/meowshell/agentauth.go @@ -11,18 +11,6 @@ import ( "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 @@ -80,11 +68,6 @@ func (a *agentSession) buildAuthMethods(cfg controlMessage) ([]ssh.AuthMethod, e 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 { @@ -110,7 +93,6 @@ func (a *agentSession) parseKeyMaybePrompting(keyBytes []byte) (ssh.Signer, erro 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 { @@ -122,12 +104,6 @@ func (a *agentSession) promptPassword() (string, error) { 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", @@ -145,18 +121,6 @@ func (a *agentSession) keyboardInteractive(name, instruction string, questions [ 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 diff --git a/cmd/meowshell/agentauth_test.go b/cmd/meowshell/agentauth_test.go index bfdf0fa..592806e 100644 --- a/cmd/meowshell/agentauth_test.go +++ b/cmd/meowshell/agentauth_test.go @@ -11,10 +11,6 @@ import ( "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() @@ -57,7 +53,7 @@ func TestParseKeyMaybePromptingRetriesOnWrongPassphrase(t *testing.T) { 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 + done <- nil return } answer := answers[0] diff --git a/cmd/meowshell/agentsftp.go b/cmd/meowshell/agentsftp.go index 6f656fa..74e5e71 100644 --- a/cmd/meowshell/agentsftp.go +++ b/cmd/meowshell/agentsftp.go @@ -11,10 +11,6 @@ import ( "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() @@ -29,12 +25,6 @@ func (a *agentSession) sftpClientFor() (*sftp.Client, error) { 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): @@ -46,9 +36,6 @@ func classifySFTPError(err error) errorCode { } } -// 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 { @@ -121,10 +108,6 @@ func fileInfoToEntry(fi os.FileInfo) sftpEntry { } } -// 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 { @@ -178,16 +161,8 @@ func (a *agentSession) openSFTPChannel(msg controlMessage) { } } -// 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) @@ -225,11 +200,6 @@ func (a *agentSession) pumpSFTPDownload(id uint32, f *sftp.File, ctx context.Con } } -// 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() @@ -240,9 +210,6 @@ func (a *agentSession) finalizeUpload(channelID uint32, ch *agentChannel) { } 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)) } diff --git a/cmd/meowshell/connect.go b/cmd/meowshell/connect.go index 073038c..0b0ce1c 100644 --- a/cmd/meowshell/connect.go +++ b/cmd/meowshell/connect.go @@ -34,13 +34,6 @@ pseudo-terminal too. meowshell connect -t top ` -// connect implements "meowshell connect": an SFTP-cp-style native -// replacement for what used to shell out to "tailcat ssh" (a system ssh -// client). Requests a pseudo-terminal for an interactive shell by default, -// forwards the local terminal into raw mode when there is one (a real CLI -// user's own terminal; a no-op when stdin is a pipe, as it is for anything -// driving this as a subprocess), and reports the remote exit status as its -// own. func connect(args []string) error { fs2 := flag.NewFlagSet("connect", flag.ExitOnError) key := fs2.String("key", "", "tailcat client key name or path") @@ -110,12 +103,6 @@ func connect(args []string) error { } } - // A real CLI user's own terminal needs raw mode so keystrokes reach the - // remote session immediately, unprocessed by the local tty driver (the - // same reason ssh(1) does this). A no-op -- IsTerminal false -- when - // stdin is a pipe, which is how everything else drives this: nothing - // local to put in raw mode, and nothing here otherwise touches stdin - // framing, so piped bytes reach the session exactly as written. if term.IsTerminal(stdinFd) { if state, err := term.MakeRaw(stdinFd); err == nil { defer term.Restore(stdinFd, state) @@ -127,12 +114,6 @@ func connect(args []string) error { session.Stderr = os.Stderr if len(command) > 0 { - // Plain space-join, no quoting: the SSH exec request is one string - // regardless, and this is exactly what a real ssh client sends too - // (try "ssh host echo 'a b'" against any real sshd -- it receives - // "echo a b", not "echo 'a b'"). Quoting is the caller's own job - // when a single argument needs to survive as one word remotely, - // same as it always was. err = session.Run(strings.Join(command, " ")) } else if err = session.Shell(); err == nil { err = session.Wait() diff --git a/cmd/meowshell/cp.go b/cmd/meowshell/cp.go index 13baebf..fa61bd5 100644 --- a/cmd/meowshell/cp.go +++ b/cmd/meowshell/cp.go @@ -36,8 +36,6 @@ Copy a directory tree to a directory the server offers read-write: meowshell cp -r ./photos :photos ` -// cp implements the "meowshell cp" subcommand: an SFTP-native counterpart -// to "tailcat cp" that never shells out to a system ssh/scp client. func cp(args []string) error { fs2 := flag.NewFlagSet("cp", flag.ExitOnError) recursive := fs2.Bool("r", false, "recursively copy directories") @@ -91,8 +89,6 @@ func cp(args []string) error { return nil } -// copyOne copies one source to target, in whichever direction the two -// arguments' remoteness implies. func copyOne(sf *sftp.Client, src, target string, recursive, preserve, multiSource bool) error { _, srcPath, srcRemote := splitRemoteArg(src) _, dstPath, dstRemote := splitRemoteArg(target) @@ -237,13 +233,6 @@ func downloadFile(sf *sftp.Client, remotePath, localPath string, fi os.FileInfo, return os.Chmod(localPath, fi.Mode().Perm()) } -// filepathRelFromSlash returns target's path relative to base, as a local, -// OS-separated path. Both are SFTP paths (always "/"-separated), and -// sf.Walk(base) guarantees every path it yields is base itself or nested -// under it, so no ".." case exists to handle -- but when base is "." -// (a bare "tc-addr:" root), the walker's paths already come back clean and -// unprefixed, unlike a named root's "root/child" paths, so both shapes -// need handling here. func filepathRelFromSlash(base, target string) (string, error) { base, target = path.Clean(base), path.Clean(target) if target == base { diff --git a/cmd/meowshell/cp_test.go b/cmd/meowshell/cp_test.go index fcbd8cb..e585ee7 100644 --- a/cmd/meowshell/cp_test.go +++ b/cmd/meowshell/cp_test.go @@ -6,9 +6,6 @@ import ( "testing" ) -// Same cases as tailcat's own cmd/tailcat/cp_test.go TestSplitRemoteArg: -// splitRemoteArg is a verbatim copy of tailcat's, so the same address -// syntax must be accepted or rejected identically by both. func TestSplitRemoteArg(t *testing.T) { for _, tt := range []struct { arg string @@ -88,8 +85,6 @@ func TestFilepathRelFromSlash(t *testing.T) { } } -// TestCPUsageErrors verifies cp's argument validation, which happens -// before any subprocess or SFTP connection. func TestCPUsageErrors(t *testing.T) { for _, tt := range []struct { name string diff --git a/cmd/meowshell/env.go b/cmd/meowshell/env.go index d670152..b0cb5a9 100644 --- a/cmd/meowshell/env.go +++ b/cmd/meowshell/env.go @@ -7,10 +7,9 @@ import ( "strings" ) -// Env is the shell environment meowshell resolves for a session. type Env struct { - Shell string // absolute path of the real shell to exec - Home string // an existing, ideally writable, home directory + Shell string + Home string User string Path string Lang string @@ -19,11 +18,9 @@ type Env struct { Warnings []string } -// resolver locates files and directories. It is a struct so tests can -// substitute a fake filesystem rather than depend on the host's layout. type resolver struct { getenv func(string) string - isFile func(string) bool // exists and is executable + isFile func(string) bool isDir func(string) bool writable func(string) bool mkdirAll func(string) error @@ -65,7 +62,6 @@ func newResolver() *resolver { } } -// termuxPrefix returns Termux's install prefix, if this looks like Termux. func (r *resolver) termuxPrefix() string { if p := r.getenv("PREFIX"); p != "" && r.isDir(filepath.Join(p, "bin")) { return p @@ -77,13 +73,8 @@ func (r *resolver) termuxPrefix() string { return "" } -// shell picks the most capable interactive shell available. Shells with -// completion and prompt colouring are preferred over plain sh, which on -// Android is mksh and gives a bare "$" with no completion. func (r *resolver) shell() (string, []string) { if r.goos == "windows" { - // tailcat chooses PowerShell from the registry there and never - // reads SHELL, so there is nothing for meowshell to resolve. return "", nil } var warns []string @@ -107,7 +98,7 @@ func (r *resolver) shell() (string, []string) { "/bin/bash", "/usr/bin/bash", "/bin/zsh", "/usr/bin/zsh", "/system/bin/bash", - "/system/bin/sh", // Android: mksh + "/system/bin/sh", "/bin/sh", ) for _, c := range cands { @@ -118,21 +109,15 @@ func (r *resolver) shell() (string, []string) { return "/system/bin/sh", append(warns, "found no usable shell; falling back to /system/bin/sh") } -// path builds a PATH from the directories that actually exist. tailcat -// hardcodes /usr/local/bin:/usr/bin:/bin, none of which exist on Android. func (r *resolver) path() string { if r.goos == "windows" { - // tailcat gives the session the server's own environment on - // Windows, so PATH is already whatever the operator has. return r.getenv("PATH") } var dirs []string if p := r.termuxPrefix(); p != "" { dirs = append(dirs, filepath.Join(p, "bin")) } - // Android's own directories come first so PATH names them canonically: - // /bin is a symlink to /system/bin there, and listing the symlink first - // would leave PATH pointing at the same directory under two names. + dirs = append(dirs, "/system/bin", "/system/xbin", "/vendor/bin", "/product/bin", @@ -149,8 +134,7 @@ func (r *resolver) path() string { if !r.isDir(d) { continue } - // Deduplicate by target, not by name, so a symlinked alias of a - // directory already on PATH is dropped. + if real := r.realpath(d); !seen[real] { seen[real] = true out = append(out, d) @@ -159,9 +143,6 @@ func (r *resolver) path() string { return strings.Join(out, ":") } -// home returns a home directory that exists. tailcat passes this to -// user.Current via $HOME, and on Android an unset $HOME makes -// user.Current fail, which kills the session before the shell starts. func (r *resolver) home() (string, []string) { if r.goos == "windows" { if h := r.getenv("USERPROFILE"); h != "" { @@ -191,7 +172,6 @@ func (r *resolver) home() (string, []string) { } cands = append(cands, "/data/local/tmp/meowshell", filepath.Join(os.TempDir(), "meowshell")) - // Prefer a writable directory; remember the first merely-existing one. var readOnly string for _, c := range cands { p, ok := try(c) @@ -224,7 +204,6 @@ func (r *resolver) user() string { return "unknown" } -// Resolve works out the environment a session should run with. func (r *resolver) Resolve() Env { sh, warns := r.shell() home, hw := r.home() diff --git a/cmd/meowshell/env_test.go b/cmd/meowshell/env_test.go index 47c3d53..94b0213 100644 --- a/cmd/meowshell/env_test.go +++ b/cmd/meowshell/env_test.go @@ -7,8 +7,6 @@ import ( "testing" ) -// fakeFS builds a resolver backed by a fixed set of paths, so these tests -// describe Android layouts this machine does not have. func fakeFS(goos string, env map[string]string, files, dirs []string) *resolver { set := func(ss []string) map[string]bool { m := make(map[string]bool, len(ss)) @@ -26,7 +24,7 @@ func fakeFS(goos string, env map[string]string, files, dirs []string) *resolver mkdirAll: func(p string) error { d[p] = true; return nil }, realpath: func(p string) string { if p == "/bin" && d["/system/bin"] { - return "/system/bin" // as on Android + return "/system/bin" } return p }, @@ -47,7 +45,6 @@ func TestShellPrefersTermuxBash(t *testing.T) { } func TestShellFallsBackToAndroidSh(t *testing.T) { - // A bare Android device: no Termux, no /bin/sh. r := fakeFS("android", nil, []string{"/system/bin/sh"}, nil) got, _ := r.shell() if want := "/system/bin/sh"; got != want { @@ -68,8 +65,6 @@ func TestShellRejectsBadOverride(t *testing.T) { } func TestPathOmitsDirsThatDoNotExist(t *testing.T) { - // The bug this whole shim exists for: tailcat would hand the session - // /usr/local/bin:/usr/bin:/bin, none of which are on Android. r := fakeFS("android", map[string]string{"PREFIX": termuxUsr}, nil, []string{termuxUsr + "/bin", "/system/bin", "/system/xbin"}) got := r.path() @@ -95,8 +90,6 @@ func TestPathPutsTermuxFirst(t *testing.T) { } func TestHomeCreatedWhenUnset(t *testing.T) { - // $HOME unset is the case that makes tailcat's user.Current fail and - // kills the session, so meowshell must always produce one. r := fakeFS("android", nil, nil, nil) got, _ := r.home() if got == "" { @@ -139,8 +132,6 @@ func TestResolveFillsTermAndLang(t *testing.T) { } func TestResolveKeepsClientTerm(t *testing.T) { - // TERM is one of the few things tailcat forwards from the client; - // the shim must not stomp it. r := fakeFS("android", map[string]string{"TERM": "screen-256color"}, []string{"/system/bin/sh"}, nil) if got := r.Resolve().Term; got != "screen-256color" { @@ -149,8 +140,6 @@ func TestResolveKeepsClientTerm(t *testing.T) { } func TestPathDropsSymlinkedDuplicates(t *testing.T) { - // /bin is a symlink to /system/bin on Android, so PATH must not name - // the same directory twice. r := fakeFS("android", nil, nil, []string{"/system/bin", "/bin"}) got := r.path() if got != "/system/bin" { @@ -159,9 +148,6 @@ func TestPathDropsSymlinkedDuplicates(t *testing.T) { } func TestSetEnvReplacesRatherThanShadowing(t *testing.T) { - // adb shell already exports SHELL=/bin/sh. Appending an override is - // silently ignored, because Go keeps the first mention of a key -- so - // tailcat would start /bin/sh and never run the shim. got := setEnv( []string{"SHELL=/bin/sh", "PATH=/keep/me", "HOME=/old"}, [][2]string{{"SHELL", "/path/to/meowshell"}, {"HOME", "/new"}}, @@ -198,8 +184,6 @@ func TestValidateKeyAcceptsARealKey(t *testing.T) { } func TestValidateKeyRejectsJunk(t *testing.T) { - // A bad pipe should fail here, with a clear message, rather than as an - // opaque error out of tailcat after the exec. for name, in := range map[string]string{ "not json": "hello", "empty": "", @@ -213,9 +197,6 @@ func TestValidateKeyRejectsJunk(t *testing.T) { } func TestShimDetectionAcceptsAnyFlag(t *testing.T) { - // tailcat runs "$SHELL -l" or "$SHELL -c " today. Were it to use - // another flag, treating that as a subcommand would hand the session - // usage text instead of a shell. for _, args := range [][]string{{"-l"}, {"-c", "echo hi"}, {"--login"}, {"-lc", "x"}} { if !isShimInvocation(args) { t.Errorf("isShimInvocation(%q) = false, want true", args) @@ -229,8 +210,6 @@ func TestShimDetectionAcceptsAnyFlag(t *testing.T) { } func TestFindTailcatDoesNotRequireAnExecuteBitOnWindows(t *testing.T) { - // Windows files carry no execute bit. Requiring one rejected every - // candidate, so meowshell could not find tailcat there at all. dir := t.TempDir() bin := filepath.Join(dir, "tailcat.exe") if err := os.WriteFile(bin, []byte("stub"), 0o644); err != nil { @@ -253,9 +232,6 @@ func TestFindTailcatDoesNotRequireAnExecuteBitOnWindows(t *testing.T) { } func TestResolveOnWindowsUsesWindowsNotions(t *testing.T) { - // Without a Windows branch the resolver reported "found no usable - // shell; falling back to /system/bin/sh" and handed back an Android - // home, which is where the staged key was ending up. r := fakeFS("windows", map[string]string{ "USERPROFILE": `C:\Users\someone`, "PATH": `C:\Windows\system32;C:\Windows`, diff --git a/cmd/meowshell/exec_unix.go b/cmd/meowshell/exec_unix.go index 68bb85c..c7d31d0 100644 --- a/cmd/meowshell/exec_unix.go +++ b/cmd/meowshell/exec_unix.go @@ -8,18 +8,7 @@ import ( "syscall" ) -// runTailcat replaces this process with tailcat, so the caller keeps the -// same PID: whoever launched meowshell holds a handle to the server itself, -// with no supervising process in between. func runTailcat(bin string, argv, environ []string) error { - // PR_SET_PDEATHSIG asks the kernel to SIGKILL this process when its - // parent dies, and survives the exec below since tailcat carries no - // setuid/setgid bit or file capabilities. Without it, a parent that - // dies without stopping the server first -- a crash, an OOM kill, a - // force-stop -- leaves tailcat running as an orphan with no one left - // to enforce a caller's lifetime deadline. Best-effort: a restricted - // environment that refuses this still gets a working session, just - // without the crash backstop. if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGKILL), 0); errno != 0 { fmt.Fprintf(os.Stderr, "# warning: could not arm the parent-death signal: %v\n", errno) } diff --git a/cmd/meowshell/exec_windows.go b/cmd/meowshell/exec_windows.go index 6225598..5ec0db0 100644 --- a/cmd/meowshell/exec_windows.go +++ b/cmd/meowshell/exec_windows.go @@ -7,22 +7,8 @@ import ( "time" ) -// stagedKeyGracePeriod is how long runTailcat waits after starting tailcat -// before removing a key staged on disk (see keystage_windows.go). tailcat -// reads --key with a single synchronous file read right at the start of -// serve -- process creation, flag parsing and that read, with no I/O wait -// in between -- so this only needs to outlast that by a comfortable margin, -// not the session. Killing meowshell (unlike sending it a signal on Unix) -// gives it no chance to run any code at all, so the removal can't be -// deferred until the child exits: it has to happen this way, shortly after -// start, for an abrupt kill to not leave the key on disk for the life of a -// multi-minute session. const stagedKeyGracePeriod = time.Second -// runTailcat runs tailcat as a child and exits with its status. Windows has -// no exec, so unlike Unix there is a supervising process; killing meowshell -// leaves the child running, so callers should terminate the whole process -// tree. func runTailcat(bin string, argv, environ []string) error { cmd := exec.Command(bin, argv[1:]...) cmd.Env = environ diff --git a/cmd/meowshell/forwarding.go b/cmd/meowshell/forwarding.go index 9f44167..b8435f6 100644 --- a/cmd/meowshell/forwarding.go +++ b/cmd/meowshell/forwarding.go @@ -9,39 +9,6 @@ import ( "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": @@ -55,22 +22,6 @@ func (a *agentSession) openForwardChannel(msg controlMessage) { } } -// 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": @@ -85,10 +36,6 @@ func resolveLocalListener(msg controlMessage) (net.Listener, error) { } } -// 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 == "" { @@ -101,13 +48,6 @@ func isLoopbackListenAddr(addr string) bool { 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") @@ -126,11 +66,6 @@ func listenUnix(path string) (net.Listener, error) { 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 { @@ -145,7 +80,7 @@ func (a *agentSession) openLocalForward(msg controlMessage) { for { conn, err := ln.Accept() if err != nil { - return // listener closed (close_channel), or a real accept failure either way ends this forward + return } go proxyForwardedConn(conn, func() (net.Conn, error) { return client.Dial("tcp", msg.RemoteAddr) @@ -154,19 +89,6 @@ func (a *agentSession) openLocalForward(msg controlMessage) { }() } -// 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) @@ -190,10 +112,6 @@ func (a *agentSession) openRemoteForward(msg controlMessage) { }() } -// 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 { @@ -215,9 +133,6 @@ func (a *agentSession) openSOCKSForward(msg controlMessage) { }() } -// 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() @@ -226,13 +141,6 @@ func (a *agentSession) registerForward(ln net.Listener) uint32 { 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() @@ -247,22 +155,10 @@ func proxyForwardedConn(conn io.ReadWriteCloser, dial func() (net.Conn, error)) <-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() } @@ -288,7 +184,7 @@ func serveSOCKS5(conn net.Conn, client interface { selected := byte(methodNoneUsage) if requireAuth { if !containsByte(methods, methodUserPass) { - conn.Write([]byte{0x05, 0xFF}) // no acceptable method + conn.Write([]byte{0x05, 0xFF}) conn.Close() return } @@ -310,21 +206,21 @@ func serveSOCKS5(conn net.Conn, client interface { } const cmdConnect = 0x01 if req[0] != 0x05 || req[1] != cmdConnect { - writeSOCKS5Reply(conn, 0x07) // command not supported + writeSOCKS5Reply(conn, 0x07) conn.Close() return } var host string switch req[3] { - case 0x01: // IPv4 + case 0x01: addr := make([]byte, 4) if _, err := io.ReadFull(conn, addr); err != nil { conn.Close() return } host = net.IP(addr).String() - case 0x03: // domain name + case 0x03: lenBuf := make([]byte, 1) if _, err := io.ReadFull(conn, lenBuf); err != nil { conn.Close() @@ -336,7 +232,7 @@ func serveSOCKS5(conn net.Conn, client interface { return } host = string(name) - case 0x04: // IPv6 + case 0x04: addr := make([]byte, 16) if _, err := io.ReadFull(conn, addr); err != nil { conn.Close() @@ -344,7 +240,7 @@ func serveSOCKS5(conn net.Conn, client interface { } host = net.IP(addr).String() default: - writeSOCKS5Reply(conn, 0x08) // address type not supported + writeSOCKS5Reply(conn, 0x08) conn.Close() return } @@ -358,11 +254,11 @@ func serveSOCKS5(conn net.Conn, client interface { remote, err := client.Dial("tcp", target) if err != nil { - writeSOCKS5Reply(conn, 0x05) // connection refused + writeSOCKS5Reply(conn, 0x05) conn.Close() return } - if err := writeSOCKS5Reply(conn, 0x00); err != nil { // succeeded + if err := writeSOCKS5Reply(conn, 0x00); err != nil { conn.Close() remote.Close() return @@ -370,11 +266,6 @@ func serveSOCKS5(conn net.Conn, client interface { 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 { @@ -414,9 +305,6 @@ func containsByte(b []byte, v byte) bool { 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 index c846dc2..aed1eee 100644 --- a/cmd/meowshell/hostkeys.go +++ b/cmd/meowshell/hostkeys.go @@ -13,23 +13,10 @@ import ( "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 @@ -40,19 +27,8 @@ func (e *hostKeyChangedError) Error() string { } 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) @@ -75,7 +51,7 @@ func tcpHostKeyCallback(knownHostsPath string, prompt hostKeyPrompter) (ssh.Host } 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 + return err } if len(keyErr.Want) > 0 { return &hostKeyChangedError{hostname: hostname, err: keyErr} @@ -105,9 +81,6 @@ func appendKnownHost(knownHostsPath, hostname string, key ssh.PublicKey) error { 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/keystage_unix.go b/cmd/meowshell/keystage_unix.go index 7fb49b9..2e3b872 100644 --- a/cmd/meowshell/keystage_unix.go +++ b/cmd/meowshell/keystage_unix.go @@ -8,26 +8,14 @@ import ( "syscall" ) -// stagedKey holds the descriptor the staged key lives on. It is a package -// variable so the *os.File is never garbage collected: os.File has a -// finalizer that closes the descriptor, which would pull the key out from -// under tailcat before it reads it. var stagedKey *os.File -// stageKey writes the key where tailcat can read it without it ever -// existing under a name. -// -// The bytes go into a temp file that is unlinked immediately, so the only -// remaining reference is the open descriptor. serve execs tailcat rather -// than forking it, so the process keeps its PID and the descriptor path -// still resolves in the new image. tailcat accepts it because it treats any -// --key containing a slash as a path and simply reads it. func stageKey(dir string, data []byte) (string, error) { f, err := os.CreateTemp(dir, "meowshell-key-*") if err != nil { return "", fmt.Errorf("staging key: %w", err) } - // Unlink before writing: from here on nothing can open it by name. + if err := os.Remove(f.Name()); err != nil { f.Close() return "", fmt.Errorf("unlinking staged key: %w", err) @@ -36,7 +24,7 @@ func stageKey(dir string, data []byte) (string, error) { f.Close() return "", fmt.Errorf("writing staged key: %w", err) } - // Go opens files close-on-exec; this descriptor has to survive the exec. + if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, f.Fd(), syscall.F_SETFD, 0); errno != 0 { f.Close() return "", fmt.Errorf("clearing close-on-exec on staged key: %w", errno) @@ -46,14 +34,11 @@ func stageKey(dir string, data []byte) (string, error) { return fdPath(f.Fd()), nil } -// fdPath names an open descriptor as a path the child can open. func fdPath(fd uintptr) string { if runtimeGOOS == "linux" || runtimeGOOS == "android" { return fmt.Sprintf("/proc/self/fd/%d", fd) } - return fmt.Sprintf("/dev/fd/%d", fd) // darwin, and BSDs with fdescfs + return fmt.Sprintf("/dev/fd/%d", fd) } -// cleanupStagedKey is a no-op here: the file was unlinked at creation, so it -// disappears when the process exits. func cleanupStagedKey() {} diff --git a/cmd/meowshell/keystage_windows.go b/cmd/meowshell/keystage_windows.go index 83faea8..079ed8b 100644 --- a/cmd/meowshell/keystage_windows.go +++ b/cmd/meowshell/keystage_windows.go @@ -11,12 +11,6 @@ var ( stagedKeyPath string ) -// stageKey writes the key to a file for tailcat to read. -// -// Windows has neither unlink-while-open nor a path for an inherited -// descriptor, so unlike Unix the key does briefly exist as a named file. It -// is created with an exclusive handle in a per-user temp directory; see -// runTailcat for when it gets removed. func stageKey(dir string, data []byte) (string, error) { f, err := os.CreateTemp(dir, "meowshell-key-*") if err != nil { @@ -37,10 +31,6 @@ func stageKey(dir string, data []byte) (string, error) { return f.Name(), nil } -// cleanupStagedKey removes the staged key file, if any. It is safe to call -// more than once, and safe to call concurrently with itself: runTailcat -// calls it both from a timer and after the child exits, and only the first -// call is expected to find anything to remove. func cleanupStagedKey() { stagedKeyMu.Lock() path := stagedKeyPath diff --git a/cmd/meowshell/main.go b/cmd/meowshell/main.go index f52d38a..ce1b3ac 100644 --- a/cmd/meowshell/main.go +++ b/cmd/meowshell/main.go @@ -1,36 +1,3 @@ -// meowshell wraps the tailcat binary to serve a proper interactive shell -// over a tailcat address: a real PTY with completion, colours, job control -// and window resizing. -// -// tailcat's built-in ssh service already allocates a PTY, applies the -// client's termios modes and forwards SIGWINCH. What it does not do is -// survive Android: it derives the session's PATH from a hardcoded -// /usr/local/bin:/usr/bin:/bin, falls back to /bin/sh for the login shell, -// and aborts the session outright when user.Current fails, which on Android -// happens whenever $HOME is unset. -// -// meowshell fixes that from both ends. Before starting the server it -// exports a HOME, USER and SHELL that tailcat can read. It then passes -// itself as $SHELL, so tailcat launches meowshell rather than the real -// shell; invoked that way (with -l or -c) meowshell repairs PATH, TERM and -// LANG in the session's own environment and execs the real shell. -// -// serve/socks/forward run tailcat through runTailcat, which also arms a -// parent-death watchdog (exec_unix.go, exec_windows.go): each is a -// long-lived listener that would otherwise survive an orphaning host -// process indefinitely. -// -// cp and connect solve a related Android problem each: tailcat's own -// cp/ssh shell out to a system scp/ssh client, which an app sandbox does -// not provide (tailcat's ls has no such dependency -- it already speaks -// SFTP directly in-process, and works on Android unchanged). Both speak -// SSH themselves instead (sftp.go, connect.go), routed through tailcat's -// own bare client mode as a subprocess rather than a system ssh/scp -// binary, so file transfer and interactive sessions both work there too -- -// connect's session is plain stdin/stdout either way, so it works exactly -// as well piped into from another process (an Android app driving it as a -// child process, with no real terminal anywhere in the picture) as it does -// from a real terminal. package main import ( @@ -49,8 +16,6 @@ import ( var runtimeGOOS = runtime.GOOS -// runTailcatFn is the seam tests replace to capture the argv a subcommand -// built instead of actually launching a process. var runTailcatFn = runTailcat const usage = `meowshell -- an interactive shell over a tailcat address @@ -254,9 +219,6 @@ func serve(args []string) error { argv = append(argv, command...) } - // tailcat reads SHELL for the login shell, and HOME/USER through - // user.Current. Setting SHELL to this binary is what gets the shim - // above run for each session. vars := [][2]string{} if shimSupported { vars = append(vars, @@ -269,13 +231,6 @@ func serve(args []string) error { return runTailcatFn(bin, argv, setEnv(os.Environ(), vars)) } -// splitForcedCommand splits args on the first literal "--", returning the -// flags before it and the command after it. tailcat's own serve subcommand -// takes this to mean "run this command instead of a shell, for every -// session" (or, without ssh/no-auth-ssh, a standalone exec service); passed -// through unexamined. Go's flag package would otherwise consume a leading -// "--" itself while still parsing flags, dropping the marker tailcat needs -// to see. func splitForcedCommand(args []string) (rest, command []string) { i := slices.Index(args, "--") if i < 0 { @@ -284,8 +239,6 @@ func splitForcedCommand(args []string) (rest, command []string) { return args[:i], args[i+1:] } -// validateKey rejects input that is not a tailcat private key, so a bad -// pipe fails here rather than as a confusing error out of tailcat. func validateKey(data []byte) error { var k struct { Private string @@ -299,11 +252,6 @@ func validateKey(data []byte) error { return nil } -// stagingDir picks a writable directory to stage the key in. -// -// os.TempDir is the portable answer: it honours TMPDIR on unix and TEMP or -// TMP on Windows. home is only a fallback for the rare case os.TempDir -// returns nothing at all. func stagingDir(home string) string { if d := os.TempDir(); d != "" { return d @@ -311,8 +259,6 @@ func stagingDir(home string) string { return home } -// keyFromStdin reads a tailcat private key from stdin and returns a path -// tailcat can read it from. func keyFromStdin(dir string) (string, error) { data, err := io.ReadAll(io.LimitReader(os.Stdin, 1<<16)) if err != nil { @@ -324,14 +270,6 @@ func keyFromStdin(dir string) (string, error) { return stageKey(dir, data) } -// setEnv returns environ with each given variable set, replacing any entry -// already there. -// -// Appending would not do: Go keeps the first mention of a key and clears -// later duplicates, so an appended override of a variable the caller already -// exported is silently ignored -- e.g. adb shell exports SHELL=/bin/sh, so -// an appended SHELL would never reach tailcat, which starts the shim only -// when SHELL points at it. func setEnv(environ []string, vars [][2]string) []string { replacing := make(map[string]bool, len(vars)) for _, v := range vars { @@ -350,9 +288,6 @@ func setEnv(environ []string, vars [][2]string) []string { return out } -// socks runs "tailcat socks" through runTailcat rather than execing it -// directly, so its parent-death watchdog (see exec_unix.go/exec_windows.go) -// covers this long-lived proxy the same way it covers serve. func socks(args []string) error { fs := flag.NewFlagSet("socks", flag.ExitOnError) key := fs.String("key", "", "tailcat client key name or path") @@ -387,9 +322,6 @@ func socks(args []string) error { return runTailcatFn(bin, argv, os.Environ()) } -// forward runs "tailcat forward" through runTailcat for the same reason -// socks does: it is a long-lived local listener, so it gets the same -// parent-death watchdog serve does. func forward(args []string) error { fs := flag.NewFlagSet("forward", flag.ExitOnError) key := fs.String("key", "", "tailcat client key name or path") @@ -442,8 +374,6 @@ func printEnv() error { return nil } -// findTailcat locates the tailcat binary: an explicit path, then -// $TAILCAT_BIN, then alongside this executable, then $PATH. func findTailcat(explicit string) (string, error) { var tried []string check := func(p string) (string, bool) { @@ -455,8 +385,7 @@ func findTailcat(explicit string) (string, error) { if err != nil || fi.IsDir() { return "", false } - // Windows has no execute bit; requiring one rejects every file - // there, so meowshell would never find tailcat at all. + if runtimeGOOS != "windows" && fi.Mode()&0o111 == 0 { return "", false } diff --git a/cmd/meowshell/main_test.go b/cmd/meowshell/main_test.go index 2156308..8394db4 100644 --- a/cmd/meowshell/main_test.go +++ b/cmd/meowshell/main_test.go @@ -8,9 +8,6 @@ import ( "testing" ) -// writeFakeTailcat creates an executable file findTailcat will accept, so -// serve/connect/socks/forward can run to the point of building an argv -// without a real tailcat binary. func writeFakeTailcat(t *testing.T) string { t.Helper() dir := t.TempDir() @@ -25,8 +22,6 @@ func writeFakeTailcat(t *testing.T) string { return p } -// captureRunTailcat replaces runTailcatFn for the test's duration, recording -// the bin/argv a subcommand built instead of actually launching anything. func captureRunTailcat(t *testing.T) *capturedRun { t.Helper() captured := &capturedRun{} @@ -174,15 +169,7 @@ func TestServeArgv(t *testing.T) { } } -// connect's argv building (tailcatClientArgv) is covered by -// TestTailcatClientArgv in cp_test.go, shared with cp. The session itself -// (dialing, pty allocation, a remote command, exit status) has no local -// server to dial in this package, so it's covered end-to-end against a -// real one by dotnet/Meowshell.Tests' TailcatSshSession tests instead. - func TestConnectRequiresAnAddress(t *testing.T) { - // Fails at flag/argument validation, before findTailcat or any dial -- - // no fake binary needed. if err := connect(nil); err == nil { t.Fatal("connect with no address did not error") } @@ -194,12 +181,6 @@ 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 { @@ -257,7 +238,7 @@ func TestForwardRequiresAnAddressAndAMapping(t *testing.T) { cases := [][]string{ nil, {"--tailcat=" + tailcat}, - {"--tailcat=" + tailcat, "tcaddr"}, // address with no mapping + {"--tailcat=" + tailcat, "tcaddr"}, } for _, args := range cases { if err := forward(args); err == nil { diff --git a/cmd/meowshell/protocol.go b/cmd/meowshell/protocol.go index 3acc083..2b032c4 100644 --- a/cmd/meowshell/protocol.go +++ b/cmd/meowshell/protocol.go @@ -6,35 +6,19 @@ import ( "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 maxFrameLength = 64 << 20 -const frameHeaderLength = 5 // type (1) + channel ID (4), counted in the length prefix +const frameHeaderLength = 5 type frame struct { Type byte @@ -42,10 +26,6 @@ type frame struct { 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))) @@ -79,10 +59,6 @@ func readFrame(r io.Reader) (frame, error) { }, 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 ( @@ -99,171 +75,84 @@ const ( 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" + Kind string `json:"kind,omitempty"` + Command []string `json:"command,omitempty"` + Pty *bool `json:"pty,omitempty"` 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 string `json:"prompt_kind,omitempty"` + Remote string `json:"remote,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Prompt string `json:"prompt,omitempty"` + Instruction string `json:"instruction,omitempty"` + Questions []string `json:"questions,omitempty"` + Echos []bool `json:"echos,omitempty"` + + Accept bool `json:"accept,omitempty"` + Answer string `json:"answer,omitempty"` + Answers []string `json:"answers,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` - // 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 + DisableAgent bool `json:"disable_agent,omitempty"` - // 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 + AgentForwarding bool `json:"agent_forwarding,omitempty"` - // 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 + NewPath string `json:"new_path,omitempty"` + Mode uint32 `json:"mode,omitempty"` 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 + Target string `json:"target,omitempty"` + Size int64 `json:"size,omitempty"` + ModTime int64 `json:"mod_time,omitempty"` - // 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 + Entries []sftpEntry `json:"entries,omitempty"` - // 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) + ListenAddr string `json:"listen_addr,omitempty"` + RemoteAddr string `json:"remote_addr,omitempty"` + BoundAddr string `json:"bound_addr,omitempty"` - // 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 + ModTime int64 `json:"mod_time"` IsDir bool `json:"is_dir"` } diff --git a/cmd/meowshell/sftp.go b/cmd/meowshell/sftp.go index d0a7625..4afa632 100644 --- a/cmd/meowshell/sftp.go +++ b/cmd/meowshell/sftp.go @@ -14,11 +14,6 @@ import ( "golang.org/x/crypto/ssh" ) -// tailcatClientArgv builds the argv for tailcat's own bare client mode -// ("tailcat [flags] "), the same netcat-over-tailcat bridge -// OpenSSH's ProxyCommand drives for tailcat's own ssh/cp subcommands -- -// just built as a real argv slice here instead of a shell-quoted string, -// since dialSFTP execs it directly rather than handing it to a shell. func tailcatClientArgv(key, derpMapURL string, verbose bool, addr, port string) []string { var argv []string if key != "" { @@ -33,11 +28,6 @@ func tailcatClientArgv(key, derpMapURL string, verbose bool, addr, port string) return append(argv, addr, port) } -// pipeConn adapts a child process's stdin/stdout to net.Conn, the shape -// golang.org/x/crypto/ssh needs to drive a handshake. Deadlines are -// unsupported (no-ops): the child process enforces its own timeouts (the -// meow ping's fixed 10s handshake deadline among them), and the pipe has no -// deeper OS-level timeout mechanism to hook into. type pipeConn struct { cmd *exec.Cmd stdout io.ReadCloser @@ -51,8 +41,7 @@ func (c *pipeConn) Close() error { c.stdin.Close() c.stdout.Close() err := c.cmd.Wait() - // Closing stdin/stdout above makes tailcat exit on its own; that shows - // up here as a plain nonzero exit, not a real failure to report. + var exitErr *exec.ExitError if errors.As(err, &exitErr) { return nil @@ -73,16 +62,6 @@ type pipeAddr struct{} func (pipeAddr) Network() string { return "tailcat" } func (pipeAddr) String() string { return "tailcat" } -// 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 { @@ -101,23 +80,12 @@ func dialSSHClient(ctx context.Context, dial dialer, remoteAddr, user string, ho return ssh.NewClient(sshConn, chans, reqs), nil } -// 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) { dial, remoteAddr, hkCallback := tailcatSSHDialer(tailcatBin, argv) sc, err := dialSSHClient(context.Background(), dial, remoteAddr, "", hkCallback, sshAgentAuthMethods()) @@ -139,13 +107,6 @@ type closerFunc func() error func (f closerFunc) Close() error { return f() } -// splitRemoteArg splits an scp-style remote argument "host:path", where -// host is a tailcat address or a DNS name with a "tailcat=" TXT record. ok -// reports whether arg is remote: it has a colon that isn't preceded by a -// path separator, and the part before the colon is longer than one -// character (so a Windows drive path like "C:\foo" stays local). Mirrors -// tailcat's own cp.go splitRemoteArg exactly, so the same address syntax -// works identically whether cp runs through tailcat or through meowshell. func splitRemoteArg(arg string) (host, path string, ok bool) { i := strings.Index(arg, ":") if i <= 1 { diff --git a/cmd/meowshell/shim_unix.go b/cmd/meowshell/shim_unix.go index eafbfe4..6be0d9a 100644 --- a/cmd/meowshell/shim_unix.go +++ b/cmd/meowshell/shim_unix.go @@ -9,19 +9,8 @@ import ( "syscall" ) -// shimSupported reports whether tailcat can be made to run meowshell as the -// session's login shell. On Unix it reads $SHELL; on Windows it picks -// PowerShell itself and inherits the environment wholesale, so there is -// nothing for a shim to fix. const shimSupported = true -// isShimInvocation reports whether these arguments come from tailcat -// starting the session's login shell rather than from a person. -// -// tailcat runs "$SHELL -l", or "$SHELL -c " for a remote command. -// Any flag-like first argument counts: were tailcat to use a different flag, -// treating it as a subcommand would hand the session usage text instead of a -// shell. func isShimInvocation(args []string) bool { if len(args) == 0 { return false @@ -33,15 +22,11 @@ func isShimInvocation(args []string) bool { return strings.HasPrefix(args[0], "-") } -// runAsShell is the shell shim. tailcat hands the session a fixed -// environment (SHELL, USER, HOME, PATH, plus TERM/LANG/LC_* from the -// client), so this is the only place PATH can be corrected before the real -// shell starts. func runAsShell(args []string) error { env := newResolver().Resolve() os.Setenv("PATH", env.Path) - os.Setenv("SHELL", env.Shell) // the real shell, not meowshell + os.Setenv("SHELL", env.Shell) if os.Getenv("TERM") == "" { os.Setenv("TERM", env.Term) } @@ -57,7 +42,5 @@ func runAsShell(args []string) error { } } - // Pass the arguments through so the real shell still does its own login - // processing and sources the user's rc files. return syscall.Exec(env.Shell, append([]string{env.Shell}, args...), os.Environ()) } diff --git a/cmd/meowshell/shim_windows.go b/cmd/meowshell/shim_windows.go index abc4d29..4047e10 100644 --- a/cmd/meowshell/shim_windows.go +++ b/cmd/meowshell/shim_windows.go @@ -2,10 +2,6 @@ package main import "errors" -// shimSupported is false on Windows: tailcat builds the session's -// environment by inheriting the server's wholesale and chooses PowerShell -// from the registry, never consulting $SHELL. There is no broken PATH to -// repair and no way to interpose, so meowshell is a launcher only. const shimSupported = false func isShimInvocation([]string) bool { return false } diff --git a/cmd/meowshell/sshagent_unix.go b/cmd/meowshell/sshagent_unix.go index bffd431..c47ea4b 100644 --- a/cmd/meowshell/sshagent_unix.go +++ b/cmd/meowshell/sshagent_unix.go @@ -10,14 +10,6 @@ import ( "golang.org/x/crypto/ssh/agent" ) -// sshAgentAuthMethods returns an SSH public-key auth method backed by the -// local ssh-agent (via $SSH_AUTH_SOCK), for a server that requires public-key -// auth -- an "ssh" service configured with --ssh-authorized-keys, as opposed -// to a "no-auth-ssh" one, which accepts SSH's "none" method (always tried -// first, before anything in ClientConfig.Auth) on tailcat's own WireGuard-peer -// trust alone. Returns nil if no agent is running: the handshake still -// succeeds against a no-auth-ssh service either way, and simply has no -// public key to offer if the server asks for one. func sshAgentAuthMethods() []ssh.AuthMethod { sock := os.Getenv("SSH_AUTH_SOCK") if sock == "" { diff --git a/cmd/meowshell/sshagent_windows.go b/cmd/meowshell/sshagent_windows.go index 744b087..85b6b2a 100644 --- a/cmd/meowshell/sshagent_windows.go +++ b/cmd/meowshell/sshagent_windows.go @@ -4,9 +4,4 @@ package main import "golang.org/x/crypto/ssh" -// sshAgentAuthMethods: no Windows ssh-agent (named pipe) support yet. A -// no-auth-ssh service still works fine without it -- SSH's "none" method is -// always tried first, before anything here -- but an "ssh" service requiring -// --ssh-authorized-keys has no public key to offer on Windows until this -// grows one. func sshAgentAuthMethods() []ssh.AuthMethod { return nil } diff --git a/cmd/meowshell/tailcatdial.go b/cmd/meowshell/tailcatdial.go index 13701e9..ceca714 100644 --- a/cmd/meowshell/tailcatdial.go +++ b/cmd/meowshell/tailcatdial.go @@ -15,31 +15,10 @@ import ( "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) @@ -52,15 +31,7 @@ func (c *tailcatForwardClient) Dial(network, addr string) (net.Conn, error) { 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" { @@ -68,9 +39,6 @@ func (c *tailcatForwardClient) Dial(network, addr string) (net.Conn, error) { } 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) @@ -89,13 +57,6 @@ func (c *tailcatForwardClient) Dial(network, addr string) (net.Conn, error) { 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 diff --git a/cmd/meowshell/transport.go b/cmd/meowshell/transport.go index 861b9d2..c01d58c 100644 --- a/cmd/meowshell/transport.go +++ b/cmd/meowshell/transport.go @@ -16,21 +16,8 @@ import ( "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...) @@ -52,12 +39,6 @@ func tailcatDialer(tailcatBin string, argv []string) dialer { 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} @@ -65,28 +46,16 @@ func tcpDialer(hostPort string) dialer { } } -// 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:] @@ -94,21 +63,11 @@ func splitUserHost(dest, defaultPort string) (user, hostPort string) { 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 { @@ -143,17 +102,6 @@ func proxyAuthFromURL(u *url.URL) *proxy.Auth { 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) @@ -183,8 +131,6 @@ func dialHTTPConnectProxy(ctx context.Context, proxyURL *url.URL, hostPort strin 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 @@ -192,13 +138,6 @@ type bufConn struct { 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 == "" { diff --git a/cmd/meowshell/transport_test.go b/cmd/meowshell/transport_test.go index 37b72cf..92992eb 100644 --- a/cmd/meowshell/transport_test.go +++ b/cmd/meowshell/transport_test.go @@ -25,17 +25,13 @@ func TestLooksLikeTailcatAddress(t *testing.T) { 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 + {"tc", false}, + {"tcp://example.com", false}, + {"example.com:2222", false}, + {"user@example.com", false}, + {"10.0.0.1:22", false}, + {"tailscale-node.example", false}, } for _, c := range cases { if got := looksLikeTailcatAddress(c.dest); got != c.want { @@ -65,9 +61,6 @@ func TestSplitUserHost(t *testing.T) { } } -// 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 { @@ -87,9 +80,7 @@ func TestDialHTTPConnectProxy(t *testing.T) { 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)) }() diff --git a/dotnet/Meowshell.AndroidProbe/MainActivity.cs b/dotnet/Meowshell.AndroidProbe/MainActivity.cs index 8d1a90b..6613207 100644 --- a/dotnet/Meowshell.AndroidProbe/MainActivity.cs +++ b/dotnet/Meowshell.AndroidProbe/MainActivity.cs @@ -5,30 +5,10 @@ using Android.Widget; using Meowshell; -// Denied by default; without it tailcat cannot reach the network at all, -// which would fail this probe for a reason that has nothing to do with -// whether the binaries were found. [assembly: Android.App.UsesPermission(Android.Manifest.Permission.Internet)] namespace Meowshell.AndroidProbe; -/// -/// Runs once on launch: calls MeowshellOptions.Create exactly as any -/// consumer would, with no path of any kind supplied by this probe, and -/// starts a real MeowshellServer -- proving both that this app's own -/// build extracted the packaged binaries where Create expects them, and -/// that Create's Android branch (compiled into Meowshell only for the -/// android target framework, see MeowshellServer.cs) actually finds them. -/// Reports the result to logcat under the tag "MeowshellProbe", which -/// dotnet/android-probe-e2e.sh polls for. -/// -/// Deliberately does not stop the server once PROBE_PASS is reported: -/// android-probe-e2e.sh then dials in from a host tailcat and runs real -/// commands, which is the only thing that proves a session actually -/// works inside a real installed app's sandbox, rather than just that -/// Start() returned an address. The server's own Lifetime is what tears -/// it down; the emulator itself is torn down right after regardless. -/// [Activity(Label = "Meowshell Probe", MainLauncher = true, Exported = true)] public sealed class MainActivity : Activity { @@ -46,8 +26,6 @@ private async Task RunProbeAsync(TextView status) { try { - // No path, no Context, no platform check: exactly what a real - // consumer writes, on any platform. var options = MeowshellOptions.Create(TimeSpan.FromMinutes(3)) with { InsecureNoAuth = true, @@ -56,9 +34,6 @@ private async Task RunProbeAsync(TextView status) Log.Info(Tag, $"PROBE_START nativeLibraryDir={options.BinaryDirectory}"); - // onLog, not server.Log: StartAsync never hands the instance - // back when it throws, which is exactly the case that needs - // tailcat's own stderr the most. await using var server = await MeowshellServer.StartAsync( options, onLog: line => Log.Info(Tag, $"tailcat: {line}")); if (string.IsNullOrWhiteSpace(server.Address)) @@ -66,20 +41,12 @@ private async Task RunProbeAsync(TextView status) throw new InvalidOperationException("StartAsync returned an empty address"); } - // Run (and log their own PROBE_*_PASS/PROBE_*_FAIL) before - // PROBE_PASS below: android-probe-e2e.sh's polling loop exits as - // soon as it sees PROBE_PASS, so those markers have to already - // be in logcat by then, not still pending on a fire-and-forget - // task. await RunCpProbeAsync(options); await RunSshSessionProbeAsync(options, server.Address); Log.Info(Tag, $"PROBE_PASS address_len={server.Address.Length}"); status.Text = "PROBE_PASS"; - // Stay up for android-probe-e2e.sh's host round-trip; see the - // class doc comment. Completed resolves once the Lifetime - // deadline (or an early failure) tears the server down. await server.Completed; } catch (Exception ex) @@ -89,17 +56,6 @@ private async Task RunProbeAsync(TextView status) } } - /// - /// A second, fully self-contained round trip, independent of the shell - /// server above: starts its own files-only server and pulls a file back - /// from it via TailcatClient.CpAsync. This is the only way to prove - /// CpAsync's Android branch (meowshell's own "cp", speaking SFTP - /// directly, never the system scp this sandbox has no room for) - /// actually works under a real installed app's exec constraints, not - /// just adb shell's much looser ones. Non-fatal: a failure here is - /// logged and reported, but does not stop the shell probe above from - /// staying up for android-probe-e2e.sh's own host round-trip. - /// private async Task RunCpProbeAsync(MeowshellOptions shellOptions) { try @@ -144,16 +100,6 @@ private async Task RunCpProbeAsync(MeowshellOptions shellOptions) } } - /// - /// A third, independent round trip: opens an interactive pseudo-terminal - /// session against the shell server already running above and drives it - /// entirely through TailcatSshSession's Output/WriteAsync, exactly as an - /// app with no real console of its own would (Android has none). This is - /// the only way to prove TailcatSshSession's Android branch (meowshell's - /// own "connect", speaking SSH directly, never the system ssh this - /// sandbox has no room for) actually works under a real installed app's - /// exec constraints. Non-fatal, like RunCpProbeAsync above. - /// private async Task RunSshSessionProbeAsync(MeowshellOptions shellOptions, string address) { try diff --git a/dotnet/Meowshell.Demo/MainActivity.cs b/dotnet/Meowshell.Demo/MainActivity.cs index 7ac2996..75a0c5c 100644 --- a/dotnet/Meowshell.Demo/MainActivity.cs +++ b/dotnet/Meowshell.Demo/MainActivity.cs @@ -5,26 +5,13 @@ using Android.Widget; using Meowshell; -// Denied by default; without it tailcat cannot reach the network at all. [assembly: Android.App.UsesPermission(Android.Manifest.Permission.Internet)] namespace Meowshell.Demo; -/// -/// A real, installable demo: one button generates a fresh throwaway shell -/// address, one field lets you copy it. Tap "Regenerate" again and the old -/// address stops working immediately -- a new server, a new ephemeral key, -/// a new address. -/// -/// Built the same way Meowshell.AndroidProbe is (MeowshellOptions.Create, -/// no path or platform check of any kind) but kept running and interactive, -/// since the point here is a person actually using it, not a pass/fail check. -/// [Activity(Label = "Meowshell Demo", MainLauncher = true, Exported = true)] public sealed class MainActivity : Activity { - // Long enough that nobody using the app hits it by surprise; Regenerate - // starts a fresh server (and so a fresh deadline) at any time regardless. private static readonly TimeSpan ServerLifetime = TimeSpan.FromHours(4); private TextView _addressField = null!; @@ -80,17 +67,11 @@ private async Task RegenerateAsync() _server = null; if (old is not null) { - // The old address must stop working before a new one is handed - // out, not after: otherwise both would be live at once. await old.DisposeAsync(); } try { - // No path, no Context, no platform check: exactly what a real - // consumer writes, on any platform. EphemeralKey defaults to - // true, so this alone is what makes "Regenerate" regenerate -- - // a fresh key, and so a fresh address, every call. var options = MeowshellOptions.Create(ServerLifetime) with { InsecureNoAuth = true, diff --git a/dotnet/Meowshell.PackageTests/PackageConsumptionTests.cs b/dotnet/Meowshell.PackageTests/PackageConsumptionTests.cs index 3d83830..b1c6e0d 100644 --- a/dotnet/Meowshell.PackageTests/PackageConsumptionTests.cs +++ b/dotnet/Meowshell.PackageTests/PackageConsumptionTests.cs @@ -4,38 +4,13 @@ namespace Meowshell.PackageTests; -/// -/// Consumes Meowshell the way a real app would: as a package, not as -/// this repo's source, and with only that one package referenced (see the -/// csproj). Meowshell.Runtime.linux reaches this project only as a -/// transitive dependency, so this proves two things at once: that adding -/// just Meowshell is enough to end up with the right binaries on disk -/// (nothing else here ever adds a runtime package explicitly), and that -/// once there, MeowshellServer finds and runs them on its own, through -/// BinaryLocator's search of the package's runtimes/<rid>/native -/// layout. Meowshell.Tests cannot prove either: it references the -/// library by ProjectReference and hands MeowshellServer a directory it -/// built itself, so a broken package layout or a missing dependency would -/// both pass there and only surface once a consumer actually installed -/// the package. -/// public sealed class PackageConsumptionTests : IDisposable { private readonly string _dir = Directory.CreateTempSubdirectory("tailcat-pkgtest-").FullName; - // tailcat client error output (e.g. a failed connection) commonly - // echoes the target address back; with InsecureNoAuth that address - // alone is a live credential, and the server here is still running - // when this could fire, so it must never reach a CI log verbatim. private static readonly Regex AddressPattern = new(@"\btc[A-Za-z0-9_-]{10,}", RegexOptions.Compiled); private static string Redact(string text) => AddressPattern.Replace(text, "tc"); - // Best-effort second layer alongside Redact() above: "::add-mask::" is a - // GitHub Actions runner command, not a .NET/xunit feature, so there is no - // guarantee dotnet test's captured console output is scanned for it the - // way a shell step's stdout is. Redact() is what actually keeps the - // address out of a failure message; this just registers it too, in case - // it helps. private static void Mask(string value) { if (!string.IsNullOrEmpty(value)) Console.WriteLine("::add-mask::" + value); @@ -57,9 +32,6 @@ public void TheRuntimePackageIsFoundWithoutBeingToldWhereItIs() [Fact] public async Task ARealSessionRunsUsingOnlyThePackagesOwnDiscovery() { - // BinaryDirectory is left unset: StartAsync must locate the - // binaries itself, exactly as it would for a real consumer that - // never sets it either. var options = new MeowshellOptions { HomeDirectory = Path.Combine(_dir, "home"), diff --git a/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs b/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs index 506a8c1..ebf7e7d 100644 --- a/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs +++ b/dotnet/Meowshell.Tests/MeowshellAgentConnectionE2ETests.cs @@ -6,20 +6,6 @@ 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); @@ -36,7 +22,6 @@ private static void Mask(string value) 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); @@ -75,7 +60,7 @@ private static void Mask(string value) public async Task ExecChannelRunsACommandAndReportsARealExitCode() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; await using var server = await MeowshellServer.StartAsync(new MeowshellOptions @@ -107,7 +92,7 @@ public async Task ExecChannelRunsACommandAndReportsARealExitCode() public async Task ShellChannelAcceptsInputAndResizesLive() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; await using var server = await MeowshellServer.StartAsync(new MeowshellOptions @@ -139,7 +124,7 @@ public async Task ShellChannelAcceptsInputAndResizesLive() public async Task SftpVerbsAndTransfersRoundTrip() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; var served = Path.Combine(_dir, "served"); @@ -184,25 +169,11 @@ public async Task SftpVerbsAndTransfersRoundTrip() 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() + if (real is null) return; var (bin, _) = real.Value; await using var server = await MeowshellServer.StartAsync(new MeowshellOptions @@ -253,19 +224,11 @@ public async Task LocalForwardReachesAnArbitraryBackendOnAnExitNodeServer() 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() + if (real is null) return; var (bin, _) = real.Value; await using var server = await MeowshellServer.StartAsync(new MeowshellOptions @@ -285,24 +248,16 @@ public async Task LocalForwardRejectsNonLoopbackBindUnlessAllowed() 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 + if (OperatingSystem.IsWindows()) return; var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; await using var server = await MeowshellServer.StartAsync(new MeowshellOptions @@ -326,18 +281,11 @@ public async Task LocalForwardOnUnixSocketCreatesA0600Socket() 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() + if (real is null) return; var (bin, _) = real.Value; await using var server = await MeowshellServer.StartAsync(new MeowshellOptions @@ -363,8 +311,8 @@ public async Task SocksForwardEnforcesAutoGeneratedToken() 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 + var method = await Socks5GreetAsync(noAuthClient, [0x00], cts.Token); + Assert.Equal(0xFF, method); } using (var wrongCreds = new Socket(SocketType.Stream, ProtocolType.Tcp)) @@ -384,23 +332,11 @@ public async Task SocksForwardEnforcesAutoGeneratedToken() } } - /// - /// 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() + if (real is null) return; var (bin, _) = real.Value; await using var server = await MeowshellServer.StartAsync(new MeowshellOptions @@ -416,14 +352,8 @@ public async Task ExecChannelDeliversLargeOutputIntactUnderBackpressure() 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. + const int totalBytes = 512 * 1024; + await using var exec = await connection.OpenExecAsync([$"head -c {totalBytes} /dev/zero | tr '\\0' 'A'"]); using var ms = new MemoryStream(); @@ -475,7 +405,6 @@ private static async Task ReadExactAsync(Socket socket, byte[] buffer, Cancellat } } - /// 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]; diff --git a/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs b/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs index fcd8c27..0e6f2f1 100644 --- a/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs +++ b/dotnet/Meowshell.Tests/MeowshellListenersE2ETests.cs @@ -5,15 +5,6 @@ namespace Meowshell.Tests; -/// -/// Runs and -/// against the real tailcat and meowshell binaries built by build.sh -- the -/// .NET counterpart to for the two -/// other long-lived listeners meowshell wraps. -/// -/// Skipped (each test returns immediately) when the real binaries are not -/// available, e.g. a local "dotnet test" run without a "dist" build. -/// public sealed class MeowshellListenersE2ETests : IDisposable { private const string TailcatEnvVar = "DOTNET_E2E_TAILCAT_BIN"; @@ -23,9 +14,6 @@ public sealed class MeowshellListenersE2ETests : IDisposable public void Dispose() => Directory.Delete(_dir, recursive: true); - /// Same layout as MeowshellServerE2ETests.RealBinaries(): copies the - /// real binaries into a directory named per the current platform's - /// convention, executable. Returns null (skip) if either is unavailable. private (string binDir, string tailcatPath)? RealBinaries() { var tailcatSrc = Environment.GetEnvironmentVariable(TailcatEnvVar); @@ -57,7 +45,7 @@ public sealed class MeowshellListenersE2ETests : IDisposable public async Task ASocksProxyStaysUpUntilStopped() { var real = RealBinaries(); - if (real is null) return; // see RealBinaries() + if (real is null) return; var (bin, _) = real.Value; await using var proxy = await MeowshellSocksProxy.StartAsync(new MeowshellSocksOptions @@ -77,12 +65,9 @@ public async Task ASocksProxyStaysUpUntilStopped() public async Task APortForwardStartsItsLocalListenerAndStopsCleanly() { var real = RealBinaries(); - if (real is null) return; // see RealBinaries() + if (real is null) return; var (bin, tailcatPath) = real.Value; - // forward validates its argument up front, so it needs a - // syntactically real address -- nothing has to be listening at the - // target for the local listener itself to come up. var configDir = Path.Combine(_dir, "keyconfig"); Directory.CreateDirectory(configDir); var genkeyPsi = new ProcessStartInfo(tailcatPath) @@ -114,21 +99,11 @@ public async Task APortForwardStartsItsLocalListenerAndStopsCleanly() 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() + if (real is null) return; var (bin, _) = real.Value; using var backend = new TcpListener(IPAddress.Loopback, 0); @@ -172,10 +147,6 @@ public async Task APortForwardWithAllowExitNodeReachesAnArbitraryBackend() 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 => { diff --git a/dotnet/Meowshell.Tests/MeowshellPortForwardTests.cs b/dotnet/Meowshell.Tests/MeowshellPortForwardTests.cs index 4c3ceb4..846db24 100644 --- a/dotnet/Meowshell.Tests/MeowshellPortForwardTests.cs +++ b/dotnet/Meowshell.Tests/MeowshellPortForwardTests.cs @@ -2,17 +2,12 @@ namespace Meowshell.Tests; -/// -/// Exercises MeowshellPortForward against a stand-in for meowshell, the -/// same way does for MeowshellServer. -/// public sealed class MeowshellPortForwardTests : IDisposable { private readonly string _dir = Directory.CreateTempSubdirectory("meowshell-forward-test-").FullName; public void Dispose() => Directory.Delete(_dir, recursive: true); - /// Writes stand-in binaries whose "meowshell" records its own argv, one element per line, then runs (default: stay up). private (MeowshellPortForwardOptions options, string argsFile) Fake(string script = "exec sleep 300\n") { var bin = Path.Combine(_dir, "bin"); @@ -80,7 +75,7 @@ public async Task StaysUpUntilStoppedAndIsIdempotent() Assert.False(forward.Completed.IsCompleted, "forward exited on its own instead of staying up as a listener"); await forward.StopAsync(); - await forward.StopAsync(); // must not throw + await forward.StopAsync(); Assert.True(forward.Completed.IsCompletedSuccessfully); await forward.DisposeAsync(); } diff --git a/dotnet/Meowshell.Tests/MeowshellServerE2ETests.cs b/dotnet/Meowshell.Tests/MeowshellServerE2ETests.cs index 6abad2b..0c0e35e 100644 --- a/dotnet/Meowshell.Tests/MeowshellServerE2ETests.cs +++ b/dotnet/Meowshell.Tests/MeowshellServerE2ETests.cs @@ -4,32 +4,11 @@ namespace Meowshell.Tests; -/// -/// Runs MeowshellServer against the real tailcat and meowshell binaries -/// built by build.sh, with a real tailcat client on the other end -- the one -/// thing the fake-binary tests in cannot -/// cover, and the .NET counterpart to e2e/host-e2e.sh. -/// -/// Skipped (each test returns immediately) when the real binaries are not -/// available, e.g. a local "dotnet test" run without a "dist" build. The CI -/// "dotnet" job always sets and -/// , so there these tests actually run. -/// public sealed class MeowshellServerE2ETests : IDisposable { - // tailcat's own diagnostics include the address it just published; with - // InsecureNoAuth that address alone is a live credential, so it must - // never reach a CI log verbatim (public repo; a failing assertion's - // message here becomes part of the "dotnet test" job's captured output). private static readonly Regex AddressPattern = new(@"\btc[A-Za-z0-9_-]{10,}", RegexOptions.Compiled); private static string Redact(string text) => AddressPattern.Replace(text, "tc"); - // Best-effort second layer alongside Redact() above: "::add-mask::" is a - // GitHub Actions runner command, not a .NET/xunit feature, so there is no - // guarantee dotnet test's captured console output is scanned for it the - // way a shell step's stdout is. Redact() is what actually keeps the - // address out of a failure message; this just registers it too, in case - // it helps. private static void Mask(string value) { if (!string.IsNullOrEmpty(value)) Console.WriteLine("::add-mask::" + value); @@ -42,13 +21,6 @@ private static void Mask(string value) public void Dispose() => Directory.Delete(_dir, recursive: true); - /// - /// Copies the real binaries named by and - /// into a directory laid out the way - /// MeowshellServer expects: named per the current platform's convention, - /// executable. Returns null if either variable is unset or names a - /// missing file, which the caller treats as "nothing to test against". - /// private (string binDir, string tailcatPath)? RealBinaries() { var tailcatSrc = Environment.GetEnvironmentVariable(TailcatEnvVar); @@ -99,20 +71,12 @@ private static void Mask(string value) return (p.ExitCode, (await stdoutTask).Trim(), (await stderrTask).Trim()); } - /// - /// The node key an address carries, the same way e2e/host-e2e.sh - /// compares identities: a server picks a DERP region at startup and - /// embeds it, so the address it publishes is not byte-identical to the - /// one genkey printed. - /// private static async Task IdentityAsync(string tailcatPath, string address) { var (exitCode, stdout, _) = await RunAsync(tailcatPath, "parse", address); Assert.Equal(0, exitCode); var match = Regex.Match(stdout, "\"ServerPublic\":\\s*\"([^\"]+)\""); - // "parse"'s own JSON echoes the address back, which may still be a - // live, connectable server at this point in the test -- redact it - // the same as everywhere else, not just the ServerPublic identity. + Assert.True(match.Success, $"no ServerPublic in: {Redact(stdout)}"); return match.Groups[1].Value; } @@ -121,12 +85,9 @@ private static async Task IdentityAsync(string tailcatPath, string addre public async Task ARealClientCanRunACommandOverTheAddress() { var real = RealBinaries(); - if (real is null) return; // see RealBinaries() + if (real is null) return; var (bin, tailcatPath) = real.Value; - // Construct MeowshellOptions directly rather than through Create(): - // this test needs a specific directory of real downloaded binaries, - // not the auto-discovery a real consumer gets for free. var options = new MeowshellOptions { BinaryDirectory = bin, @@ -159,12 +120,9 @@ public async Task ARealClientCanRunACommandOverTheAddress() public async Task APrivateKeyDeliveredAtRuntimeCarriesTheProvisionedIdentity() { var real = RealBinaries(); - if (real is null) return; // see RealBinaries() + if (real is null) return; var (bin, tailcatPath) = real.Value; - // Provision a key on the host, exactly as a backend handing out a - // per-session key would -- with its own config directory, entirely - // separate from wherever MeowshellServer runs the session. var configDir = Path.Combine(_dir, "keyconfig"); Directory.CreateDirectory(configDir); var genkeyPsi = new ProcessStartInfo(tailcatPath) diff --git a/dotnet/Meowshell.Tests/MeowshellServerTests.cs b/dotnet/Meowshell.Tests/MeowshellServerTests.cs index 728d8a7..7fe9d6b 100644 --- a/dotnet/Meowshell.Tests/MeowshellServerTests.cs +++ b/dotnet/Meowshell.Tests/MeowshellServerTests.cs @@ -3,11 +3,6 @@ namespace Meowshell.Tests; -/// -/// Exercises the lifecycle against a stand-in for meowshell, so the process -/// handling, address handoff and shutdown are covered without an Android -/// device in the loop. -/// public sealed class MeowshellServerTests : IDisposable { private const string FakeAddress = "tcTESTADDRESS000000000000"; @@ -15,7 +10,6 @@ public sealed class MeowshellServerTests : IDisposable public void Dispose() => Directory.Delete(_dir, recursive: true); - /// Writes stand-in binaries; the script body decides what "meowshell" does. private MeowshellOptions Fake(string script, TimeSpan? lifetime = null) { var bin = Path.Combine(_dir, "bin"); @@ -33,7 +27,7 @@ private MeowshellOptions Fake(string script, TimeSpan? lifetime = null) HomeDirectory = Path.Combine(_dir, "home"), WorkDirectory = Path.Combine(_dir, "work"), InsecureNoAuth = true, - Naming = BinaryNaming.Android, // the stand-ins are named lib*.so + Naming = BinaryNaming.Android, Lifetime = lifetime ?? TimeSpan.FromMinutes(5), StartTimeout = TimeSpan.FromSeconds(10), GracePeriod = TimeSpan.FromSeconds(2), @@ -43,7 +37,6 @@ private MeowshellOptions Fake(string script, TimeSpan? lifetime = null) private const string PublishesAddress = $"printf '%s' '{FakeAddress}' > \"$TAILCAT_ADDR_FILE\"\nexec sleep 300\n"; - /// A fake that records its own argv, one element per line, before publishing an address. private (MeowshellOptions options, string argsFile) FakeCapturingArgv() { var argsFile = Path.Combine(_dir, "args-" + Guid.NewGuid().ToString("N")); @@ -104,7 +97,7 @@ public async Task StopAsync_TerminatesTheServerAndIsIdempotent() { var server = await MeowshellServer.StartAsync(Fake(PublishesAddress)); await server.StopAsync(); - await server.StopAsync(); // must not throw + await server.StopAsync(); Assert.True(server.Completed.IsCompleted); await server.DisposeAsync(); } @@ -129,7 +122,7 @@ public async Task CompletedSucceedsWhenStopAsyncInitiatedTheExit() { var server = await MeowshellServer.StartAsync(Fake(PublishesAddress)); await server.StopAsync(); - await server.Completed; // must not throw + await server.Completed; await server.DisposeAsync(); } @@ -146,8 +139,6 @@ public async Task TheDeadlineShutsTheServerDownOnItsOwn() [Fact] public async Task AGracefulStopIsAttemptedBeforeKilling() { - // The stand-in traps SIGTERM and records it, so a SIGKILL-only stop - // would leave the marker absent. var marker = Path.Combine(_dir, "sigterm"); var script = $"trap 'printf caught > {marker}; exit 0' TERM\n" + @@ -162,7 +153,6 @@ public async Task AGracefulStopIsAttemptedBeforeKilling() [Fact] public async Task APrivateKeyIsPipedInOnStdin() { - // The key must reach the process without being written anywhere. var seen = Path.Combine(_dir, "stdin-key"); var script = $"cat > {seen}\n" + @@ -179,9 +169,6 @@ public async Task APrivateKeyIsPipedInOnStdin() [Fact] public async Task TheServerIsToldWhereTailcatIs() { - // meowshell looks for a sibling named "tailcat"; under an Android - // native library directory everything is lib*.so, so the path has to - // be passed explicitly. var seen = Path.Combine(_dir, "env"); var script = $"printf '%s\\n' \"$TAILCAT_BIN\" \"$HOME\" > {seen}\n" + @@ -297,8 +284,6 @@ public async Task ForcedCommandAloneRequiresNoAuthenticationMode() [Fact] public void BinaryNamingFollowsThePlatformConvention() { - // Android only unpacks lib*.so into the native library directory, - // which is the one place an app may execute from. Assert.Equal("libtailcat.so", BinaryNaming.Android.FileName("tailcat")); Assert.Equal("tailcat.exe", BinaryNaming.Windows.FileName("tailcat")); Assert.Equal("tailcat", BinaryNaming.Plain.FileName("tailcat")); @@ -307,13 +292,6 @@ public void BinaryNamingFollowsThePlatformConvention() [Fact] public void CreateLeavesBinaryDiscoveryToBinaryLocatorOnNonAndroidPlatforms() { - // Create's Android branch is compiled into Meowshell only for - // the android target framework (see the #if ANDROID in - // MeowshellServer.cs), which this plain net8.0 test assembly does - // not build; that branch is exercised only by the on-device probe - // in Meowshell.AndroidProbe. This covers the other one: no - // BinaryDirectory set, so StartAsync falls back to BinaryLocator, - // exactly as a real desktop or server consumer gets for free. var host = MeowshellOptions.Create(TimeSpan.FromMinutes(1)); Assert.Null(host.BinaryDirectory); Assert.Equal(BinaryNaming.ForCurrentPlatform(), host.Naming); @@ -344,8 +322,7 @@ public void TheRuntimeIdentifierNamesAnOsAndArchitecture() public void TheSearchPathCoversBothPublishLayouts() { var path = BinaryLocator.SearchPath("/app").ToList(); - // A RID-specific publish flattens native assets beside the assembly; - // a RID-agnostic build keeps the package's runtimes/ layout. + Assert.Contains("/app", path); Assert.Contains(Path.Combine("/app", "runtimes", BinaryLocator.RuntimeIdentifier, "native"), path); } @@ -366,8 +343,6 @@ public void LocateFindsBinariesInThePackageLayout() [Fact] public void LocateIgnoresAnIncompleteDirectory() { - // Only one of the pair present must not count: the failure would - // otherwise surface much later, as a missing-file error at start. var app = Path.Combine(_dir, "half"); Directory.CreateDirectory(app); var naming = BinaryNaming.ForCurrentPlatform(); @@ -379,8 +354,6 @@ public void LocateIgnoresAnIncompleteDirectory() [Fact] public async Task ABinaryWithoutTheExecutableBitIsMadeRunnable() { - // NuGet restore does not reliably carry the executable bit, so a - // package-delivered binary can arrive unrunnable. if (OperatingSystem.IsWindows()) return; var opts = Fake(PublishesAddress); var shell = Path.Combine(opts.BinaryDirectory!, opts.Naming.FileName("meowshell")); diff --git a/dotnet/Meowshell.Tests/MeowshellSocksProxyTests.cs b/dotnet/Meowshell.Tests/MeowshellSocksProxyTests.cs index 5e6ff6b..e3d2e70 100644 --- a/dotnet/Meowshell.Tests/MeowshellSocksProxyTests.cs +++ b/dotnet/Meowshell.Tests/MeowshellSocksProxyTests.cs @@ -2,17 +2,12 @@ namespace Meowshell.Tests; -/// -/// Exercises MeowshellSocksProxy against a stand-in for meowshell, the same -/// way does for MeowshellServer. -/// public sealed class MeowshellSocksProxyTests : IDisposable { private readonly string _dir = Directory.CreateTempSubdirectory("meowshell-socks-test-").FullName; public void Dispose() => Directory.Delete(_dir, recursive: true); - /// Writes stand-in binaries whose "meowshell" records its own argv, one element per line, then runs (default: stay up). private (MeowshellSocksOptions options, string argsFile) Fake(string script = "exec sleep 300\n") { var bin = Path.Combine(_dir, "bin"); @@ -46,9 +41,6 @@ public async Task AllFlagsArePassedThrough() Verbose = true, }); - // StartAsync returns as soon as the process is created, with no - // handoff file to wait on the way MeowshellServer has -- give the - // fake's own write a moment to land. for (var i = 0; i < 100 && !File.Exists(argsFile); i++) await Task.Delay(50); @@ -69,7 +61,7 @@ public async Task StaysUpUntilStoppedAndIsIdempotent() Assert.False(proxy.Completed.IsCompleted, "the proxy exited on its own instead of staying up as a listener"); await proxy.StopAsync(); - await proxy.StopAsync(); // must not throw + await proxy.StopAsync(); Assert.True(proxy.Completed.IsCompletedSuccessfully); await proxy.DisposeAsync(); } diff --git a/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs b/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs index eb33072..5239c61 100644 --- a/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs +++ b/dotnet/Meowshell.Tests/TailcatClientE2ETests.cs @@ -5,23 +5,8 @@ namespace Meowshell.Tests; -/// -/// Runs against the real tailcat binary built by -/// build.sh, feeding its actual output through this library's real parsing -/// code -- the check that a future tailcat release changing its output -/// shape breaks this suite, not just the recorded fixtures in -/// . Genkey/parse/printpub need no network -/// (a numeric --region skips the DERP map fetch); resolve/ping/ls need a -/// live server and real network, so they also cover the same ground as -/// from the client side. -/// -/// Skipped (each test returns immediately) when the real binaries are not -/// available, e.g. a local "dotnet test" run without a "dist" build. -/// public sealed class TailcatClientE2ETests : IDisposable { - // Same reasoning as MeowshellServerE2ETests: a tailcat address is a live - // credential, so it must never reach a CI log verbatim. 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) @@ -36,7 +21,6 @@ private static void Mask(string value) public void Dispose() => Directory.Delete(_dir, recursive: true); - /// Same layout as MeowshellServerE2ETests.RealBinaries(). Returns null (skip) if either binary is unavailable. private (string binDir, string tailcatPath)? FindRealBinaries() { var tailcatSrc = Environment.GetEnvironmentVariable(TailcatEnvVar); @@ -75,11 +59,10 @@ private static void Mask(string value) public async Task GenerateKeyAndParseRoundTripAgainstTheRealBinary() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; var options = ClientOptions(bin); - // A numeric region needs no DERP map fetch, so this needs no network. var address = await TailcatClient.GenerateKeyAsync(options, new TailcatKeyOptions { Name = "e2e-server-key", @@ -102,7 +85,7 @@ public async Task GenerateKeyAndParseRoundTripAgainstTheRealBinary() public async Task GenerateKeyForAClientReturnsAPublicKey() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; var pub = await TailcatClient.GenerateKeyAsync(ClientOptions(bin), new TailcatKeyOptions @@ -117,7 +100,7 @@ public async Task GenerateKeyForAClientReturnsAPublicKey() public async Task PrintPubReturnsAPublicKeyWithNoSavedKey() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; var pub = await TailcatClient.PrintPubAsync(ClientOptions(bin)); @@ -128,7 +111,7 @@ public async Task PrintPubReturnsAPublicKeyWithNoSavedKey() public async Task ParseThrowsOnAGenuinelyInvalidAddress() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; var ex = await Assert.ThrowsAsync( @@ -136,23 +119,11 @@ public async Task ParseThrowsOnAGenuinelyInvalidAddress() Assert.NotEqual(0, ex.ExitCode); } - /// - /// A malformed address fails address parsing before any network call, - /// so unlike most tests here this needs no live server or DERP access - /// -- 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. 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() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; var ex = await Assert.ThrowsAsync( @@ -160,20 +131,11 @@ public async Task SshSessionConnectAsyncThrowsOnAGenuinelyInvalidAddress() Assert.NotEqual(MeowshellErrorCode.None, ex.Code); } - /// - /// Starts a real server (real binaries, real network -- the default - /// tailcat.dev DERP map) and exercises resolve/ping/ls against it - /// through TailcatClient, the client-side counterpart to - /// MeowshellServerE2ETests. Needs real network the same way that class - /// does: skips no differently than the other tests here when the - /// binaries are missing, but will fail rather than skip if network - /// access to tailcat.dev is blocked (as in this sandbox; CI has it). - /// [Fact] public async Task ResolvePingAndLsAgainstARealRunningServer() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; var served = Path.Combine(_dir, "served"); @@ -219,18 +181,11 @@ public async Task ResolvePingAndLsAgainstARealRunningServer() await server.StopAsync(); } - /// - /// Uploads a file to a real server's writable "files" share, confirms - /// it landed via ListFilesAsync, then downloads it back to a different - /// local path and checks the bytes round-tripped exactly -- CpAsync and - /// TailcatPath exercised against the real system scp, not a stand-in. - /// Needs real network the same way does. - /// [Fact] public async Task CpUploadsAndDownloadsAFileAgainstARealServer() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; var served = Path.Combine(_dir, "served-rw"); @@ -271,19 +226,11 @@ public async Task CpUploadsAndDownloadsAFileAgainstARealServer() await server.StopAsync(); } - /// - /// Opens an interactive pseudo-terminal session against a real server - /// and drives it entirely through TailcatSshSession's Output/WriteAsync - /// -- the same shape an Android app with no real console would use -- - /// confirming a real shell prompt appears, a command's output comes - /// back, and exiting ends the session cleanly. Needs real network the - /// same way does. - /// [Fact] public async Task SshSessionRunsAnInteractiveShellAgainstARealServer() { var real = FindRealBinaries(); - if (real is null) return; // see FindRealBinaries() + if (real is null) return; var (bin, _) = real.Value; var serverOptions = new MeowshellOptions @@ -311,7 +258,6 @@ public async Task SshSessionRunsAnInteractiveShellAgainstARealServer() await session.Completed; } - /// 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]; diff --git a/dotnet/Meowshell.Tests/TailcatClientTests.cs b/dotnet/Meowshell.Tests/TailcatClientTests.cs index 5de085f..307daa3 100644 --- a/dotnet/Meowshell.Tests/TailcatClientTests.cs +++ b/dotnet/Meowshell.Tests/TailcatClientTests.cs @@ -2,29 +2,12 @@ namespace Meowshell.Tests; -/// -/// Exercises TailcatClient against a stand-in for the bare tailcat binary -- -/// unlike MeowshellServer/MeowshellSocksProxy/MeowshellPortForward, these -/// calls never go through meowshell, so the fake here plays "tailcat" -/// directly (CpAsync goes through meowshell too, but only on Android; this -/// non-Android test process always takes its system-scp path). The -/// PlatformNotSupportedException SshAsync raises on Android isn't covered -/// here: OperatingSystem.IsAndroid() reflects the real runtime, not -/// something this net8.0 test process can fake. -/// -/// The parsing tests below feed real output captured from the actual -/// tailcat binary (built from tailscale/tailcat, run against a hermetic -/// local DERP+STUN server -- see tailscale.com/tstest/integration), not -/// guessed strings, so a future tailcat release changing its output shape -/// fails these loudly rather than silently producing wrong typed results. -/// public sealed class TailcatClientTests : IDisposable { private readonly string _dir = Directory.CreateTempSubdirectory("tailcat-client-test-").FullName; public void Dispose() => Directory.Delete(_dir, recursive: true); - /// Writes a stand-in "tailcat" running , recording its own argv, one element per line, plus a trivial "meowshell" (unused, but MeowshellBinaries.Locate requires both to exist). private (TailcatClientOptions options, string argsFile) Fake(string script) { var bin = Path.Combine(_dir, "bin"); @@ -51,7 +34,6 @@ 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"); @@ -115,9 +97,6 @@ public async Task GenerateKeyThrowsOnFailure() [Fact] public async Task GenerateKeyThrowsOnUnexpectedOutputShape() { - // A zero exit but output that isn't a tailcat address at all -- - // this should never happen against a real binary, but must not be - // silently trusted if it does. var (options, _) = Fake("echo not-an-address\n"); var ex = await Assert.ThrowsAsync(() => TailcatClient.GenerateKeyAsync(options, new TailcatKeyOptions { Name = "key" })); @@ -142,8 +121,6 @@ public async Task ListKeysSplitsOutputIntoLines() Assert.Equal(["genkey", "--list"], File.ReadAllLines(argsFile)); } - // Captured from `tailcat parse ` for a short address with no - // embedded region (just a RegionID reference). private const string ParseJsonShort = """ { "ServerPublic": "nodekey:8d927fef23cb84f285a936d650be0d73820f5a613062ed2ffe1922bfe77cf55d", @@ -153,9 +130,6 @@ public async Task ListKeysSplitsOutputIntoLines() } """; - // Captured from `tailcat parse `, where - // came from `tailcat resolve` -- a "full address" embedding its DERP - // node directly instead of referencing a region by ID. private const string ParseJsonEmbeddedRegion = """ { "ServerPublic": "nodekey:2aeee97e7151c4189254361d2d1ba08553c99b8993881e9375e2f1adb9de7f59", @@ -265,7 +239,6 @@ public async Task PrintPubThrowsOnUnexpectedOutputShape() [Fact] public async Task PingParsesADirectPong() { - // Captured from `tailcat ping ` against a hermetic local server. var (options, argsFile) = Fake("printf 'pong in 580µs via 127.0.0.1:45437\\n'\n"); var result = await TailcatClient.PingAsync(options, "tcADDR"); @@ -280,7 +253,6 @@ public async Task PingParsesADirectPong() [Fact] public async Task PingParsesADerpRelayedPong() { - // Captured from `tailcat ping ` when the connection fell back to DERP. var (options, _) = Fake("printf 'pong in 680µs via DERP(test)\\n'\n"); var result = await TailcatClient.PingAsync(options, "tcADDR"); @@ -306,8 +278,6 @@ public async Task PingParsesCompoundGoDurations(string duration, int expectedMil [Fact] public async Task PingDoesNotThrowOnANonZeroExit() { - // --until-direct timing out is a meaningful, non-exceptional result, - // and can still have printed relayed pongs before giving up. var (options, argsFile) = Fake("echo 'pong in 42ms via DERP(sfo)'\nexit 1\n"); var result = await TailcatClient.PingAsync(options, "tcADDR", untilDirect: true, timeout: TimeSpan.FromSeconds(5)); @@ -331,8 +301,6 @@ public async Task PingLeavesPongNullWhenNothingMatched() [Fact] public async Task ListFilesParsesAShortListing() { - // Captured from `tailcat ls ` against a directory containing - // one file and one subdirectory. var (options, argsFile) = Fake("printf 'hello.txt\\nsubdir/\\n'\n"); var entries = await TailcatClient.ListFilesAsync(options, "tcADDR", longListing: false); @@ -348,7 +316,6 @@ public async Task ListFilesParsesAShortListing() [Fact] public async Task ListFilesParsesALongListing() { - // Captured from `tailcat ls -l ` against the same directory. var (options, argsFile) = Fake( "printf -- '-rw-r--r-- 3 Sep 9 10:56 hello.txt\\ndrwxr-xr-x 4096 Sep 9 10:56 subdir/\\n'\n"); var entries = await TailcatClient.ListFilesAsync(options, "tcADDR", longListing: true); @@ -380,8 +347,6 @@ public async Task ListFilesParsesALongListing() [Fact] public async Task ListFilesParsesALongListingOfASingleFileTarget() { - // Captured from `tailcat ls -l :hello.txt` (a file, not a - // directory): one entry, no directory traversal. var (options, _) = Fake("printf -- '-rw-r--r-- 3 Sep 9 10:56 hello.txt\\n'\n"); var entries = await TailcatClient.ListFilesAsync(options, "tcADDR:hello.txt", longListing: true); var entry = Assert.Single(entries); @@ -392,10 +357,6 @@ public async Task ListFilesParsesALongListingOfASingleFileTarget() [Fact] public async Task ListFilesParsesAnOlderEntryWithAYearInsteadOfATime() { - // tailcat prints a year instead of a time of day for anything - // modified more than 180 days ago -- not reachable from a fresh - // hermetic test server, so this line is built from the verified - // "%s %12d %s %s" format (tailcat's ls.go) rather than captured live. var (options, _) = Fake("printf -- '-rw-r--r-- 512 Jan 15 2019 old.txt\\n'\n"); var entries = await TailcatClient.ListFilesAsync(options, "tcADDR", longListing: true); var entry = Assert.Single(entries); @@ -534,8 +495,6 @@ await Assert.ThrowsAsync( [Fact] public async Task CpDoesNotThrowOnANonZeroExit() { - // Like SshAsync, an arbitrary scp/remote-command exit code isn't - // "tailcat itself failed" -- it's returned, not thrown. var (options, _) = Fake("echo 'scp: no such file or directory' >&2\nexit 1\n"); var result = await TailcatClient.CpAsync( options, TailcatPath.Local("missing.txt"), TailcatPath.Remote(new TailcatAddress("tcADDR"))); @@ -566,7 +525,6 @@ await TailcatClient.ResolveAsync( [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"); diff --git a/dotnet/Meowshell/BinaryLocator.cs b/dotnet/Meowshell/BinaryLocator.cs index 9fcebfa..66649e5 100644 --- a/dotnet/Meowshell/BinaryLocator.cs +++ b/dotnet/Meowshell/BinaryLocator.cs @@ -2,26 +2,13 @@ namespace Meowshell; -/// -/// Finds the meowshell and tailcat executables that ship alongside an -/// application. -/// -/// -/// The runtime packages lay binaries out the way NuGet expects native assets, -/// under runtimes/<rid>/native/. Depending on how an app is -/// published those either stay in that layout beside the assembly or are -/// flattened into the output directory, and on Android the platform unpacks -/// them into its own native library directory instead. All three are searched. -/// +/// Finds the meowshell and tailcat executables that ship alongside an application. public static class BinaryLocator { /// Environment variable naming a directory to search first. public const string DirectoryVariable = "MEOWSHELL_BINARIES"; - /// - /// The RID whose native assets this process would use, e.g. - /// linux-arm64 or android-arm64. - /// + /// The RID whose native assets this process would use, e.g. linux-arm64 or android-arm64. public static string RuntimeIdentifier { get @@ -43,11 +30,7 @@ public static string RuntimeIdentifier } } - /// - /// Directories to search, most specific first. Exposed so a caller can - /// report what was tried when nothing is found. - /// - /// Where the application was loaded from. + /// Directories to search, most specific first. Exposed so a caller can report what was tried when nothing is found. public static IEnumerable SearchPath(string baseDirectory) { var explicitDir = Environment.GetEnvironmentVariable(DirectoryVariable); @@ -55,18 +38,13 @@ public static IEnumerable SearchPath(string baseDirectory) { yield return explicitDir; } - // A RID-specific publish flattens native assets next to the assembly. + yield return baseDirectory; - // A RID-agnostic build keeps the package layout. + yield return Path.Combine(baseDirectory, "runtimes", RuntimeIdentifier, "native"); } - /// - /// Returns the first directory on the search path holding both binaries, - /// or null if neither is complete. - /// - /// How the binaries are named on this platform. - /// Defaults to the application's base directory. + /// Returns the first directory on the search path holding both binaries, or null if neither is complete. public static string? Locate(BinaryNaming naming, string? baseDirectory = null) { baseDirectory ??= AppContext.BaseDirectory; diff --git a/dotnet/Meowshell/GoDuration.cs b/dotnet/Meowshell/GoDuration.cs index 83f03f0..16faebb 100644 --- a/dotnet/Meowshell/GoDuration.cs +++ b/dotnet/Meowshell/GoDuration.cs @@ -4,15 +4,6 @@ namespace Meowshell; -/// -/// Parses Go's time.Duration.String() format: an optional sign, -/// then either one fractional unit for a sub-second duration (e.g. -/// "580µs", "12.3ms") or hours/minutes/seconds run together (e.g. -/// "1h2m3s", "2m0.5s") -- the format tailcat itself prints latencies in -/// (see tailcat ping's output). Every unit's value is summed, so -/// this parses any string in the format regardless of which units Go -/// chose to include, without reproducing Go's own formatting rules. -/// internal static class GoDuration { private static readonly Regex Token = new( @@ -34,7 +25,7 @@ public static bool TryParse(string s, out TimeSpan result) while (pos < trimmed.Length) { var m = Token.Match(trimmed, pos); - if (!m.Success || m.Index != pos) return false; // a gap is not a clean duration string + if (!m.Success || m.Index != pos) return false; matchedAny = true; pos = m.Index + m.Length; var value = double.Parse(m.Groups["num"].Value, CultureInfo.InvariantCulture); diff --git a/dotnet/Meowshell/JobObject.cs b/dotnet/Meowshell/JobObject.cs index 3756890..a3f51b0 100644 --- a/dotnet/Meowshell/JobObject.cs +++ b/dotnet/Meowshell/JobObject.cs @@ -6,17 +6,6 @@ namespace Meowshell; -/// -/// A Windows job object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the OS kills -/// every process assigned to it as soon as this handle closes, including -/// when the owning process itself crashes and the kernel closes its handles -/// for it. Windows has no exec(), so meowshell stays as a separate parent of -/// tailcat rather than becoming it; if the host process dies before -/// MeowshellServer's own shutdown code runs, this is what stops tailcat -/// surviving as an orphan. Assigning meowshell to the job is enough -- -/// tailcat, started later as meowshell's child, joins the same job by -/// Windows' default nesting behavior. -/// [SupportedOSPlatform("windows")] internal sealed class JobObject : IDisposable { @@ -74,13 +63,6 @@ private static extern bool SetInformationJobObject( private JobObject(SafeFileHandle handle) => _handle = handle; - /// - /// Creates a kill-on-close job object and assigns - /// to it. Returns null if the OS refuses (e.g. the process already - /// belongs to a job that forbids further nesting) -- the deadline and - /// the orderly stop/kill path still apply either way; this is only a - /// backstop for a crash. - /// public static JobObject? Wrap(Process process) { var handle = CreateJobObjectW(0, null); diff --git a/dotnet/Meowshell/MeowshellAgentConnection.cs b/dotnet/Meowshell/MeowshellAgentConnection.cs index ca46495..12fb8f5 100644 --- a/dotnet/Meowshell/MeowshellAgentConnection.cs +++ b/dotnet/Meowshell/MeowshellAgentConnection.cs @@ -5,13 +5,7 @@ namespace Meowshell; -/// -/// Auth material and options for the connection's mandatory "configure" -/// message -- everything -/// hands the agent before it dials anything. Leave everything unset for a -/// connection that only ever needs the local ssh-agent or no auth at all -/// (tailcat's own "no-auth-ssh"/"ssh" services never ask for more). -/// +/// Auth material and options for the connection's mandatory "configure" message. Leave everything unset for a connection that only ever needs the local ssh-agent or no auth at all. public sealed record MeowshellAgentConfigureOptions { /// Skip the local ssh-agent even if one is running. @@ -34,29 +28,32 @@ public sealed record MeowshellAgentConfigureOptions } /// A host-key prompt: an unrecognized key on a TCP-transport connection, needing a trust-on-first-use decision. +/// The remote host being connected to. +/// The host key's fingerprint. public sealed record MeowshellHostKeyPrompt(string Remote, string Fingerprint); /// A keyboard-interactive challenge, RFC 4256 style: zero or more questions, each independently maskable. +/// The challenge's name. +/// Free-text instructions to show the user. +/// The questions to ask, in order. +/// Whether each corresponding question's answer should be shown as typed. public sealed record MeowshellKeyboardInteractivePrompt(string Name, string Instruction, IReadOnlyList Questions, IReadOnlyList Echos); /// A request to sign with a Keystore-backed key -- see . +/// Which key to sign with. +/// The signature algorithm requested. +/// The bytes to sign. public sealed record MeowshellSignRequest(string KeyId, string Algorithm, byte[] Data); /// One directory entry or a single file's metadata, from an SFTP ls/stat/lstat. +/// The entry's name. +/// The size in bytes. +/// The raw permission bits. +/// The modification time. +/// Whether the entry is a directory. public sealed record MeowshellSftpEntry(string Name, long Size, uint Mode, DateTimeOffset ModifiedAt, bool IsDirectory); -/// -/// A persistent, multiplexed connection to a tailcat address or a general -/// SSH host, driving "meowshell agent" as a long-lived subprocess instead -/// of the one-process-per-operation model -/// and 's cp/ls calls use. Open a shell, run a -/// command, browse files, and forward a port all over the same login -- -/// each just opens another channel on this one connection. -/// -/// This only carries bytes and structured events: interpreting a shell's -/// output (ANSI/VT100 escapes, an actual terminal widget) is entirely the -/// caller's own responsibility, same as . -/// +/// A persistent, multiplexed connection to a tailcat address or a general SSH host, driving "meowshell agent" as a long-lived subprocess. Open a shell, run a command, browse files, and forward a port all over the same login. public sealed class MeowshellAgentConnection : IAsyncDisposable { private readonly Process _process; @@ -98,13 +95,7 @@ private MeowshellAgentConnection(Process process) _readLoop = Task.Run(RunReadLoopAsync); } - /// - /// Dials -- a tailcat address, or a - /// "[user@]host[:port]" TCP address for a general SSH host -- and - /// completes the SSH handshake, including any host-key/auth prompts - /// it needs along the way (subscribe to this connection's prompt - /// events before awaiting the result, since they can fire mid-call). - /// + /// Dials -- a tailcat address, or a "[user@]host[:port]" TCP address -- and completes the SSH handshake, including any host-key/auth prompts along the way. /// Where the binaries live and how to reach the destination. /// A tailcat address, or a "[user@]host[:port]" TCP address. /// Auth material and options; omit for local-ssh-agent-or-nothing. @@ -161,7 +152,7 @@ public static async Task ConnectAsync( await connection.DisposeAsync().ConfigureAwait(false); throw new TailcatException("meowshell agent did not connect in time", 0, connection._diagnostics.Tail(), MeowshellErrorCode.Timeout); } - await connection._connected.Task.ConfigureAwait(false); // observes/rethrows a connect failure + await connection._connected.Task.ConfigureAwait(false); return connection; } @@ -175,21 +166,16 @@ private Task SendConfigureAsync(MeowshellAgentConfigureOptions configure, string KeystoreKeyIds = configure.KeystoreKeyIds?.ToArray(), KeystorePublicKeys = ToJagged(configure.KeystorePublicKeys), AgentForwarding = configure.ForwardLocalAgent, - // Part of configure rather than the process argv specifically so - // proxy credentials never end up readable via /proc//cmdline - // by anything sharing enough local privilege -- matches Keys etc. above. ProxyUrl = proxyUrl, }, cancellationToken); private static byte[][]? ToJagged(IReadOnlyList? list) => list is null ? null : list.ToArray(); - // ---- Channels: shell/exec ---- - /// Opens an interactive shell, with a pseudo-terminal unless is set false. public Task OpenShellAsync(int columns = 80, int rows = 24, string? term = null, bool? pty = null, CancellationToken cancellationToken = default) => OpenShellChannelAsync(new AgentMessage { Msg = "open_channel", Kind = "shell", Pty = pty, Cols = columns, Rows = rows, Term = term }, cancellationToken); - /// Runs non-interactively (or with a pseudo-terminal if is true). Elements are joined with plain spaces, same as a real ssh client's trailing command line -- no quoting is added. + /// Runs non-interactively (or with a pseudo-terminal if is true). Elements are joined with plain spaces -- no quoting is added. public Task OpenExecAsync(IReadOnlyList command, bool? pty = null, int columns = 80, int rows = 24, string? term = null, CancellationToken cancellationToken = default) => OpenShellChannelAsync(new AgentMessage { Msg = "open_channel", Kind = "exec", Command = command.ToArray(), Pty = pty, Cols = columns, Rows = rows, Term = term }, cancellationToken); @@ -200,8 +186,6 @@ private Task OpenShellChannelAsync(AgentMessage requ return (channel, (IAgentChannelSink)channel); }, cancellationToken); - // ---- SFTP: metadata ops ---- - /// Lists a directory's entries. public async Task> ListFilesAsync(string path, CancellationToken cancellationToken = default) { @@ -281,8 +265,6 @@ private async Task SftpRequestAsync(AgentMessage request, Cancella } } - // ---- SFTP: transfers ---- - /// Uploads a local file, optionally reporting progress and preserving its mode/mtime remotely. public async Task UploadAsync(string localPath, string remotePath, bool preserve = false, IProgress? progress = null, CancellationToken cancellationToken = default) { @@ -318,8 +300,6 @@ public async Task UploadAsync(string localPath, string remotePath, bool preserve } } - // C# has no octal literal syntax; grouped-by-3 binary spells out the - // same rwxrwxrwx bits an octal 0o644 would, unlike an opaque decimal 420. private const int DefaultUnixFileMode = 0b110_100_100; private static int GetUnixMode(string path) @@ -365,56 +345,26 @@ public async Task DownloadAsync(string remotePath, string localPath, bool preser } } - // ---- Port forwarding ---- - - /// - /// "-L": listens locally, forwarding each connection to - /// through the SSH client. A ":0" port in gets an - /// OS-assigned one -- read it back from . - /// Refuses to bind anything other than loopback unless - /// is true -- a loopback TCP socket is still reachable by any other local process/app - /// (very much including on Android), so widening the bind is an explicit, deliberate opt-in - /// rather than the default. Prefer where a - /// filesystem path is usable: only the caller's own process can reach a 0600 Unix socket. - /// + /// "-L": listens locally, forwarding each connection to through the SSH client. A ":0" port in gets an OS-assigned one. Refuses to bind anything other than loopback unless is true. public Task OpenLocalForwardAsync(string listenAddress, string remoteAddress, bool allowNonLoopbackBind = false, CancellationToken cancellationToken = default) => OpenForwardAsync("forward_local", listenAddress, remoteAddress, listenNetwork: null, allowNonLoopbackBind, socksUsername: null, socksPassword: null, cancellationToken); - /// - /// "-L" over a Unix domain socket at instead of a TCP port -- - /// the recommended local endpoint whenever the caller (e.g. an Android app) can hand the path - /// to whatever will connect to it, since filesystem permissions (the agent creates it 0600) - /// restrict access to this process, unlike a TCP socket on 127.0.0.1. - /// + /// "-L" over a Unix domain socket at instead of a TCP port -- the recommended local endpoint whenever the caller can hand the path to whatever will connect to it. public Task OpenLocalForwardOnUnixSocketAsync(string socketPath, string remoteAddress, CancellationToken cancellationToken = default) => OpenForwardAsync("forward_local", socketPath, remoteAddress, listenNetwork: "unix", allowNonLoopbackBind: false, socksUsername: null, socksPassword: null, cancellationToken); - /// "-R": asks the remote to listen on , forwarding each connection it accepts to on this machine. This is a virtual listener on the SSH wire, not a local socket, so no Unix-socket or bind-address concern applies here. + /// "-R": asks the remote to listen on , forwarding each connection it accepts to on this machine. public Task OpenRemoteForwardAsync(string listenAddress, string localAddress, CancellationToken cancellationToken = default) => OpenForwardAsync("forward_remote", listenAddress, localAddress, listenNetwork: null, allowNonLoopbackBind: false, socksUsername: null, socksPassword: null, cancellationToken); - /// - /// "-D": runs a local SOCKS5 proxy on , routing outbound - /// connections through the SSH client. By default this requires RFC 1929 SOCKS5 - /// username/password auth with a random token generated for you -- read it back from - /// / - /// and configure your SOCKS client with it, since a loopback SOCKS proxy with no auth is - /// reachable by any other local process/app. Pass false for - /// the classic unauthenticated behavior, or your own / - /// instead of an auto-generated pair. - /// + /// "-D": runs a local SOCKS5 proxy on . By default requires RFC 1929 SOCKS5 auth with a random token -- read it back from /. public Task OpenSocksForwardAsync(string listenAddress, bool requireAuth = true, string? socksUsername = null, string? socksPassword = null, bool allowNonLoopbackBind = false, CancellationToken cancellationToken = default) { (socksUsername, socksPassword) = ResolveSocksAuth(requireAuth, socksUsername, socksPassword); return OpenForwardAsync("forward_socks", listenAddress, remoteAddress: null, listenNetwork: null, allowNonLoopbackBind, socksUsername, socksPassword, cancellationToken); } - /// - /// "-D" over a Unix domain socket at instead of a TCP port. - /// Filesystem permissions on the socket already restrict who can reach it, so - /// defaults to false here (unlike the TCP overload) -- - /// pass true (or supply your own credentials) to layer SOCKS5 auth on top anyway. - /// + /// "-D" over a Unix domain socket at instead of a TCP port. defaults to false here, unlike the TCP overload. public Task OpenSocksForwardOnUnixSocketAsync(string socketPath, bool requireAuth = false, string? socksUsername = null, string? socksPassword = null, CancellationToken cancellationToken = default) { (socksUsername, socksPassword) = ResolveSocksAuth(requireAuth, socksUsername, socksPassword); @@ -450,29 +400,6 @@ private Task OpenForwardAsync(string kind, string listenAddres internal Task CloseForwardAsync(uint id, CancellationToken cancellationToken) => WriteControlAsync(id, new AgentMessage { Msg = "close_channel" }, cancellationToken); - // ---- Channel open plumbing ---- - - /// - /// Opens a channel: sends open_channel and waits for channel_opened, - /// building the caller's result (and, for anything that needs one, its - /// channel sink) from inside -- called - /// synchronously from the read loop's own handling of channel_opened, - /// on the read-loop thread, rather than after this method's await - /// resumes on some arbitrary thread-pool thread later. That ordering - /// is load-bearing, not just tidy: the agent can send a channel's - /// first data frame immediately after channel_opened, with no gap at - /// all, and a sink registered even slightly late would silently drop - /// it (caught for real: a small enough SFTP download could complete - /// -- every data frame and exit_status -- before a naively-registered- - /// after-the-await sink ever got into _channels). - /// - /// Only one open_channel round trip runs at a time (_openChannelLock): - /// the wire protocol carries no request ID pairing open_channel to its - /// channel_opened reply, so a second concurrent request would have no - /// reliable way to tell which reply is its own. This only serializes - /// the moment of opening a channel, never how many can be open (and - /// multiplexed) at once. - /// private async Task OpenChannelAsync(AgentMessage request, Func makeResult, CancellationToken cancellationToken) { await _openChannelLock.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -507,8 +434,6 @@ private async Task OpenChannelAsync(AgentMessage request, Func? _pendingOpenSuccess; private Action? _pendingOpenFailure; - // ---- Wire I/O ---- - internal async Task WriteControlAsync(uint channelId, AgentMessage message, CancellationToken cancellationToken) { await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -549,13 +474,6 @@ private async Task RunReadLoopAsync() } } - /// - /// Registered sinks are always an - /// (see ), so this OnDataAsync call - /// always completes synchronously (it only enqueues) -- discarding - /// its result here is safe, not a reintroduction of the fire-and- - /// forget race the pump exists to fix. - /// private void HandleData(uint channelId, byte[] payload) { if (payload.Length == 0) return; @@ -588,7 +506,7 @@ private void HandleControl(uint channelId, AgentMessage msg) case "error": if (msg.RequestId is not null && _pendingRequests.TryRemove(msg.RequestId, out var errorTcs)) { - errorTcs.TrySetResult(msg); // SftpRequestAsync turns an "error" Msg into a thrown exception itself + errorTcs.TrySetResult(msg); return; } if (channelId == 0) @@ -598,15 +516,6 @@ private void HandleControl(uint channelId, AgentMessage msg) _connected.TrySetException(AgentError(msg, "connecting")); return; } - // A channel-0 error after connect is either this - // connection's one in-flight open_channel attempt - // failing (the common case -- see openShellChannel/ - // openSFTPChannel/openForwardChannel on the Go side, - // which all report a pre-channel-ID failure this way), - // or the connection itself dying (a keepalive timeout, - // always reported as ConnectionLost) -- the two are - // only told apart by the code, since both share - // channel 0 on the wire. if (MeowshellErrorCodeExtensions.Parse(msg.Code) != MeowshellErrorCode.ConnectionLost && _pendingOpenFailure is { } failure) { failure(AgentError(msg, "opening a channel")); @@ -668,7 +577,7 @@ private async Task HandlePromptAsync(AgentMessage msg) { response.Cancelled = true; } - try { await WriteControlAsync(0, response, CancellationToken.None).ConfigureAwait(false); } catch { /* connection already gone */ } + try { await WriteControlAsync(0, response, CancellationToken.None).ConfigureAwait(false); } catch { } } private static TailcatException AgentError(AgentMessage msg, string doingWhat) => @@ -691,12 +600,12 @@ public async Task StopAsync() _stopped = true; if (!_process.HasExited) { - try { _process.StandardInput.Close(); } catch { /* already gone */ } + try { _process.StandardInput.Close(); } catch { } using var grace = new CancellationTokenSource(StopGracePeriod); try { await _process.WaitForExitAsync(grace.Token).ConfigureAwait(false); } catch (OperationCanceledException) { MeowshellProcessControl.TryKill(_process); } } - try { await _readLoop.ConfigureAwait(false); } catch { /* already reported via FaultEverything */ } + try { await _readLoop.ConfigureAwait(false); } catch { } } /// Ends the connection and releases everything it holds. @@ -709,7 +618,6 @@ public async ValueTask DisposeAsync() } } -/// What a registered channel needs to receive its own frames -- internal, implemented by and the private sink types transfers/forwards use. internal interface IAgentChannelSink { Task OnDataAsync(byte stream, ReadOnlyMemory data); @@ -717,23 +625,6 @@ internal interface IAgentChannelSink void OnFault(Exception ex); } -/// -/// Wraps a channel's real sink so every frame reaches it strictly in -/// order, one at a time. The read loop used to fire-and-forget straight -/// into the sink (_ = sink.OnDataAsync(...)): under backpressure -/// (a 's default -/// PauseWriterThreshold) that could leave two overlapping WriteAsync -/// calls in flight on the same Pipe at once -- undefined behavior for a -/// single-writer type. This queues both data frames and the terminal -/// control message (exit_status/error -- the only ones any sink ever -/// receives) and drains them on a dedicated background task, so -/// OnDataAsync calls for one channel are always sequential and the -/// terminal OnControl always arrives after every data frame queued -/// ahead of it. The shared read loop itself never blocks on a slow -/// sink: TryWrite on an unbounded channel always succeeds immediately, -/// so a backed-up queue only delays that one channel's own delivery, -/// never any other channel's. -/// internal sealed class AgentChannelDataPump : IAgentChannelSink { private readonly record struct QueueItem(bool IsData, byte Stream, ReadOnlyMemory Data, AgentMessage? Control); @@ -755,11 +646,6 @@ private async Task RunAsync() { if (item.IsData) { - // A write failure here reaches the caller through the - // sink's own OnControl("error")/OnFault path, not - // through this fire-and-forget-from-the-queue's- - // perspective call -- nothing more useful to do with - // the exception at this point. try { await _inner.OnDataAsync(item.Stream, item.Data).ConfigureAwait(false); } catch { } } @@ -790,7 +676,6 @@ public void OnFault(Exception ex) } } -/// A sink that ignores data/control traffic for its channel -- forward listeners, whose only meaningful message (channel_opened) is consumed before this is even registered. internal sealed class AgentIgnoreSink : IAgentChannelSink { public Task OnDataAsync(byte stream, ReadOnlyMemory data) => Task.CompletedTask; @@ -798,7 +683,6 @@ public void OnControl(AgentMessage msg) { } public void OnFault(Exception ex) { } } -/// An sftp_upload channel's sink: no data ever arrives from the agent, just the eventual exit_status/error that finalizes the transfer. internal sealed class AgentRequestResponseSink(TaskCompletionSource completion) : IAgentChannelSink { public Task OnDataAsync(byte stream, ReadOnlyMemory data) => Task.CompletedTask; @@ -812,7 +696,6 @@ public void OnControl(AgentMessage msg) public void OnFault(Exception ex) => completion.TrySetException(ex); } -/// An sftp_download channel's sink: streams data into a pipe the caller reads from, tracking total size (from channel_opened) and completion. internal sealed class AgentDownloadSink : IAgentChannelSink { private readonly Pipe _pipe = new(); @@ -879,7 +762,7 @@ internal MeowshellAgentShellChannel(MeowshellAgentConnection connection, uint id public Task WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) => _connection.SendDataAsync(_id, data, cancellationToken); - /// Resizes the pseudo-terminal, taking effect immediately -- unlike , this can be called any time after the channel opens, not just once at connect time. + /// Resizes the pseudo-terminal, taking effect immediately -- unlike , this can be called any time after the channel opens. public Task ResizeAsync(int columns, int rows, CancellationToken cancellationToken = default) => _connection.WriteControlAsync(_id, new AgentMessage { Msg = "resize", Cols = columns, Rows = rows }, cancellationToken); @@ -921,7 +804,7 @@ void IAgentChannelSink.OnFault(Exception ex) /// Ends the channel. public async ValueTask DisposeAsync() { - try { await CloseAsync().ConfigureAwait(false); } catch { /* connection already gone */ } + try { await CloseAsync().ConfigureAwait(false); } catch { } } } @@ -943,7 +826,7 @@ internal MeowshellForward(MeowshellAgentConnection connection, uint id, string b /// The actual bound listen address -- resolved by the OS when the request asked for port 0. public string BoundAddress { get; } - /// The SOCKS5 username a client must present to use this proxy, when opened via / with auth enabled. Null for a forward with no auth, or for a forward_local/forward_remote channel. + /// The SOCKS5 username a client must present to use this proxy, when opened with auth enabled. Null for a forward with no auth, or for a forward_local/forward_remote channel. public string? SocksUsername { get; } /// The SOCKS5 password paired with . diff --git a/dotnet/Meowshell/MeowshellAgentProtocol.cs b/dotnet/Meowshell/MeowshellAgentProtocol.cs index 465c5b9..5c1e473 100644 --- a/dotnet/Meowshell/MeowshellAgentProtocol.cs +++ b/dotnet/Meowshell/MeowshellAgentProtocol.cs @@ -4,13 +4,6 @@ namespace Meowshell; -/// -/// The framed control protocol "meowshell agent" speaks on its -/// stdin/stdout, mirroring cmd/meowshell/protocol.go exactly (frame -/// layout, message field names, and JSON casing all have to match its Go -/// counterpart byte for byte). Internal: -/// is the public surface built on top of it. -/// internal static class MeowshellAgentProtocol { public const byte FrameTypeControl = 0; @@ -19,7 +12,7 @@ internal static class MeowshellAgentProtocol public const byte StreamStdout = 0; public const byte StreamStderr = 1; - private const int FrameHeaderLength = 5; // type (1) + channel id (4), counted in the length prefix + private const int FrameHeaderLength = 5; private const int MaxFrameLength = 64 << 20; public static readonly JsonSerializerOptions JsonOptions = new() @@ -28,7 +21,6 @@ internal static class MeowshellAgentProtocol DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, }; - /// One frame: a control message (JSON payload) or a data chunk (raw bytes, tagged with a stream byte for stdout/stderr). public readonly record struct AgentFrame(byte Type, uint ChannelId, byte[] Payload); public static async Task WriteFrameAsync(Stream stream, AgentFrame frame, CancellationToken cancellationToken) @@ -47,21 +39,9 @@ public static Task WriteControlAsync(Stream stream, uint channelId, AgentMessage return WriteFrameAsync(stream, new AgentFrame(FrameTypeControl, channelId, body), cancellationToken); } - /// - /// Writes a client-to-agent data frame: raw bytes, no stream-tag byte. - /// The tag only exists on the agent-to-client direction (stdout vs - /// stderr for a shell/exec channel; see / - /// ) -- everything the client sends is - /// keystrokes/command input or upload bytes, never something split - /// across two streams, matching Go's own agent.go handleData exactly. - /// public static Task WriteDataAsync(Stream stream, uint channelId, ReadOnlyMemory data, CancellationToken cancellationToken) => WriteFrameAsync(stream, new AgentFrame(FrameTypeData, channelId, data.ToArray()), cancellationToken); - /// - /// Reads one frame, or returns null at a clean EOF (the agent process - /// closed its stdout, e.g. after the connection ended). - /// public static async Task ReadFrameAsync(Stream stream, CancellationToken cancellationToken) { var lenBuf = new byte[4]; @@ -82,7 +62,6 @@ public static Task WriteDataAsync(Stream stream, uint channelId, ReadOnlyMemory< return new AgentFrame(body[0], ReadUInt32BigEndian(body.AsSpan(1, 4)), payload); } - /// Reads exactly buf.Length bytes, or returns false if the stream ends before the first byte of this read (a clean EOF between frames). private static async Task ReadFullAsync(Stream stream, byte[] buf, CancellationToken cancellationToken) { var total = 0; @@ -111,18 +90,10 @@ private static uint ReadUInt32BigEndian(ReadOnlySpan src) => ((uint)src[0] << 24) | ((uint)src[1] << 16) | ((uint)src[2] << 8) | src[3]; } -/// -/// The JSON payload of a control frame -- one flat class mirroring Go's -/// controlMessage field for field (see protocol.go's own doc comment -/// for why it's one flat shape rather than a type per message). Internal: -/// and its channel/prompt types are -/// the public API built on top of this. -/// internal sealed class AgentMessage { public string Msg { get; set; } = ""; - // open_channel public string? Kind { get; set; } public string[]? Command { get; set; } public bool? Pty { get; set; } @@ -130,14 +101,11 @@ internal sealed class AgentMessage public int Rows { get; set; } public string? Term { get; set; } - // exit_status public int ExitCode { get; set; } - // error public string? Code { get; set; } public string? Message { get; set; } - // prompt_request / prompt_response public string? RequestId { get; set; } public string? PromptKind { get; set; } public string? Remote { get; set; } @@ -151,13 +119,11 @@ internal sealed class AgentMessage public string[]? Answers { get; set; } public bool Cancelled { get; set; } - // sign prompt public string? KeyId { get; set; } public string? Algorithm { get; set; } public byte[]? SignData { get; set; } public byte[]? Signature { get; set; } - // configure public bool DisableAgent { get; set; } public byte[][]? Keys { get; set; } public byte[][]? Certificates { get; set; } @@ -165,10 +131,8 @@ internal sealed class AgentMessage public byte[][]? KeystorePublicKeys { get; set; } public bool AgentForwarding { get; set; } - /// SOCKS5/HTTP CONNECT proxy for the first TCP hop, part of configure so it never lands on the agent process's own argv. public string? ProxyUrl { get; set; } - // sftp_op / sftp_result public string? Op { get; set; } public string? Path { get; set; } public string? NewPath { get; set; } @@ -182,23 +146,18 @@ internal sealed class AgentMessage public long BytesDone { get; set; } public AgentSftpEntry[]? Entries { get; set; } - // forwarding public string? ListenAddr { get; set; } public string? RemoteAddr { get; set; } public string? BoundAddr { get; set; } - /// "tcp" (default, when null/empty) or "unix" -- selects what ListenAddr means for forward_local/forward_socks. public string? ListenNetwork { get; set; } - /// Must be set true to bind a "tcp" listener to anything other than loopback; ignored for ListenNetwork "unix". public bool AllowNonLoopbackBind { get; set; } - /// forward_socks only: RFC 1929 username/password SOCKS5 auth. Both empty means no auth. public string? SocksUsername { get; set; } public string? SocksPassword { get; set; } } -/// One directory entry or a single file's metadata -- an sftp_op "ls"/"stat"/"lstat" result, mirroring Go's sftpEntry. internal sealed class AgentSftpEntry { public string Name { get; set; } = ""; diff --git a/dotnet/Meowshell/MeowshellBinaries.cs b/dotnet/Meowshell/MeowshellBinaries.cs index 7322c8d..bf4e20e 100644 --- a/dotnet/Meowshell/MeowshellBinaries.cs +++ b/dotnet/Meowshell/MeowshellBinaries.cs @@ -2,17 +2,8 @@ namespace Meowshell; -/// -/// Locating and preparing the meowshell and tailcat binaries, shared by -/// every wrapper that spawns one of them. -/// internal static class MeowshellBinaries { - /// - /// Resolves the meowshell and tailcat binary paths and makes sure both - /// are executable. - /// - /// No binaries were found, or one of the two is missing from the resolved directory. public static (string Meowshell, string Tailcat) Locate(string? binaryDirectory, BinaryNaming naming) { var binaries = binaryDirectory ?? BinaryLocator.Locate(naming); @@ -36,13 +27,6 @@ public static (string Meowshell, string Tailcat) Locate(string? binaryDirectory, return (meowshell, tailcat); } - /// - /// Makes sure a binary can be executed. NuGet restore does not reliably - /// carry the executable bit onto Unix filesystems, so a package-delivered - /// binary can arrive unrunnable; Android unpacks its own and needs - /// nothing. Failures here are ignored: if the bit really cannot be set, - /// starting the process reports it far better than guessing would. - /// private static void EnsureExecutable(string path) { if (OperatingSystem.IsWindows()) return; @@ -58,7 +42,6 @@ private static void EnsureExecutable(string path) } catch (Exception e) when (e is IOException or UnauthorizedAccessException or PlatformNotSupportedException) { - // Left to the process start to report. } } } diff --git a/dotnet/Meowshell/MeowshellErrorCode.cs b/dotnet/Meowshell/MeowshellErrorCode.cs index 70bcac1..b424991 100644 --- a/dotnet/Meowshell/MeowshellErrorCode.cs +++ b/dotnet/Meowshell/MeowshellErrorCode.cs @@ -2,48 +2,31 @@ namespace Meowshell; -/// -/// Why an agent operation failed, mirroring Go's errorCode -/// (cmd/meowshell/protocol.go) -- lets a caller branch on what happened -/// instead of pattern-matching scraped diagnostic text. -/// in particular should drive a hard-stop -/// warning UI, never a silent retry. -/// +/// Why an agent operation failed, mirroring Go's errorCode (cmd/meowshell/protocol.go). public enum MeowshellErrorCode { - /// No error code was given -- a failure that didn't come from the agent protocol at all (the process itself crashed or exited unexpectedly). + /// No error code was given -- a failure that didn't come from the agent protocol at all. None = 0, - - /// Authentication was refused (a wrong password/passphrase, a key the server doesn't accept, ...). + /// Authentication was refused. AuthFailed, - - /// A TCP-transport connection's host key has no known_hosts entry yet -- surfaced only if a handler rejected it or none was subscribed. + /// A TCP-transport connection's host key has no known_hosts entry yet. HostKeyUnknown, - - /// A TCP-transport connection's host key does not match the one on file -- a possible MITM. Never auto-retried; treat this as a hard stop. + /// A TCP-transport connection's host key does not match the one on file -- a possible MITM, never auto-retried. HostKeyChanged, - /// The destination (or a --jump hop) could not be reached over the network. NetworkUnreachable, - /// The operation did not complete within its allotted time. Timeout, - - /// The connection died after being established (a keepalive went unanswered, the process exited). + /// The connection died after being established. ConnectionLost, - - /// The agent and this client disagreed about the wire protocol -- a bug, not a remote-side failure. + /// The agent and this client disagreed about the wire protocol. ProtocolError, - /// The operation was cancelled, locally or by the user declining a prompt. Cancelled, - /// The remote refused the operation for lack of permission. PermissionDenied, - /// The remote path does not exist. NotFound, - /// A failure the agent reported without (or with an unrecognized) more specific code. Unknown, } diff --git a/dotnet/Meowshell/MeowshellPortForward.cs b/dotnet/Meowshell/MeowshellPortForward.cs index 2afb370..5aafbce 100644 --- a/dotnet/Meowshell/MeowshellPortForward.cs +++ b/dotnet/Meowshell/MeowshellPortForward.cs @@ -9,49 +9,25 @@ public sealed record MeowshellPortForwardOptions : TailcatListenerOptions /// The tailcat address to forward to. public required string Address { get; init; } - /// - /// At least one port mapping: a bare port (same local and remote port), - /// local:remote, or local:remote-ip:remote-port (the - /// server must be running as an exit node). A local port of 0 asks the - /// OS for a free port; each listener prints its address once it is - /// listening. - /// + /// At least one port mapping: a bare port, local:remote, or local:remote-ip:remote-port (the server must be an exit node). A local port of 0 asks the OS for a free port. public required IReadOnlyList Mappings { get; init; } - /// - /// Listen address, used as the local address for a mapping that only - /// specifies a port. Empty means tailcat's own default (127.0.0.1). - /// Passed to tailcat's own --bind. - /// + /// Listen address, used as the local address for a mapping that only specifies a port. Empty means tailcat's own default (127.0.0.1). Passed to tailcat's own --bind. public string? Bind { get; init; } /// tailcat client key name or path (see 'tailcat genkey'). public string? ClientKey { get; init; } } -/// -/// Forwards local TCP ports to a tailcat server, until stopped. A -/// long-lived local listener, so -- like -- -/// it goes through meowshell rather than a bare tailcat, to inherit the -/// same crash backstop (Windows job object here; PR_SET_PDEATHSIG is armed -/// inside meowshell on Unix, before it execs tailcat). -/// +/// Forwards local TCP ports to a tailcat server, until stopped. public sealed class MeowshellPortForward : IAsyncDisposable { private readonly TailcatListener _listener; - /// - /// Completes when the process has exited. Succeeds after a - /// call; faults with a - /// if the process dies on its own first. - /// + /// Completes when the process has exited. Succeeds after a call; faults with a if the process dies on its own first. public Task Completed => _listener.Completed; - /// - /// Diagnostic output from tailcat, including each listener's bound - /// address once it is listening (most useful for a mapping that asked - /// for an OS-assigned port). Raised on a background thread. - /// + /// Diagnostic output from tailcat, including each listener's bound address once it is listening. Raised on a background thread. public event Action? Log { add => _listener.Log += value; diff --git a/dotnet/Meowshell/MeowshellProcessControl.cs b/dotnet/Meowshell/MeowshellProcessControl.cs index 9269f37..3be8c3a 100644 --- a/dotnet/Meowshell/MeowshellProcessControl.cs +++ b/dotnet/Meowshell/MeowshellProcessControl.cs @@ -4,23 +4,10 @@ namespace Meowshell; -/// -/// The stop/kill mechanics shared by every long-lived meowshell-spawned -/// process (, , -/// ): request a graceful stop, then kill -/// outright if that does not land in time. -/// internal static class MeowshellProcessControl { - // errno ETXTBSY: Linux briefly refuses to exec a file that was just - // written, until the kernel (or a filesystem scanner that reopened it) - // finishes releasing its own handle. Only ever observed against a - // binary copied into place moments earlier -- an already-installed - // executable never hits this -- so a short bounded retry clears it - // without masking a real failure to start. private const int ETXTBSY = 26; - /// Starts , retrying briefly on ETXTBSY. public static void Start(Process process) { for (var attempt = 1; ; attempt++) @@ -37,11 +24,6 @@ public static void Start(Process process) } } - /// - /// Asks the process to stop. Unix gets SIGTERM so tailcat can close the - /// tunnel; Windows has no equivalent signal, so there the process is - /// killed outright, which still tears sessions down but less tidily. - /// public static void RequestStop(Process process, Func kill, int sigterm) { if (OperatingSystem.IsWindows()) @@ -52,14 +34,8 @@ public static void RequestStop(Process process, Func kill, int si kill(process.Id, sigterm); } - /// - /// Kills the tree, not just the process. On Windows meowshell stays as - /// a parent of tailcat, so killing it alone would orphan the server; on - /// Unix the exec means there is only one process, and asking for the - /// tree is harmless. - /// public static void TryKill(Process p) { - try { if (!p.HasExited) p.Kill(entireProcessTree: true); } catch { /* already gone */ } + try { if (!p.HasExited) p.Kill(entireProcessTree: true); } catch { } } } diff --git a/dotnet/Meowshell/MeowshellServer.cs b/dotnet/Meowshell/MeowshellServer.cs index 68ee512..7259436 100644 --- a/dotnet/Meowshell/MeowshellServer.cs +++ b/dotnet/Meowshell/MeowshellServer.cs @@ -38,119 +38,45 @@ public sealed record MeowshellOptions : TailcatListenerOptions /// How long the server may live before it is shut down. public TimeSpan Lifetime { get; init; } = TimeSpan.FromMinutes(5); - /// - /// SSH public key sources permitted to log in: authorized_keys paths, - /// literal key lines, or names like "alice@github". Mutually exclusive - /// with . - /// + /// SSH public key sources permitted to log in: authorized_keys paths, literal key lines, or names like "alice@github". Mutually exclusive with . public string? AuthorizedKeys { get; init; } - /// - /// Serve a shell to anyone holding the address, with no SSH auth. The - /// address is then the only credential, so pair it with - /// . - /// + /// Serve a shell to anyone holding the address, with no SSH auth. The address is then the only credential, so pair it with . public bool InsecureNoAuth { get; init; } /// Comma-separated tailcat client node keys allowed to connect. public string? AllowClientKeys { get; init; } - /// - /// Generate a throwaway key so the address dies with the process. Leave - /// true: with a saved "default" key present, tailcat would silently reuse - /// a stable address instead. Ignored when is - /// set. - /// + /// Generate a throwaway key so the address dies with the process. Leave true. Ignored when is set. public bool EphemeralKey { get; init; } = true; - /// - /// Contents of a tailcat *.private.json, supplied at runtime rather than - /// stored on the device. It is piped to meowshell on stdin and staged on - /// an unlinked descriptor, so it never exists as a named file. Use this - /// when your backend hands out a per-session key whose address you - /// already hold. - /// + /// Contents of a tailcat *.private.json, supplied at runtime rather than stored on the device. Piped to meowshell on stdin, never exists as a named file. public string? PrivateKeyJson { get; init; } /// How long to wait for the server to publish its address. public TimeSpan StartTimeout { get; init; } = TimeSpan.FromSeconds(30); - /// - /// Embed the DERP server's own info in the published address instead of - /// a region reference, so a client can connect without first fetching a - /// DERP map. Passed to tailcat's own --full-address. - /// + /// Embed the DERP server's own info in the published address instead of a region reference. Passed to tailcat's own --full-address. public bool FullAddress { get; init; } - /// - /// Include a WireGuard pre-shared key in the address (recommended). - /// Disabling it only shortens the address and trades away security, for - /// compatibility with tailcat clients v0.5.0 and earlier. Passed to - /// tailcat's own --psk. - /// + /// Include a WireGuard pre-shared key in the address (recommended). Passed to tailcat's own --psk. public bool Psk { get; init; } = true; - /// - /// 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. Combinable with or - /// to also serve a shell, but not with - /// on either of those, which would allow - /// nothing but that command. Passed to tailcat's own --files. - /// + /// Directory to serve to SFTP clients, with an optional :ro/:rw/:wo/:wo+ suffix. Passed to tailcat's own --files. public string? Files { get; init; } - /// - /// Let a client's / - /// (or a 's own forward_local/forward_socks - /// channels) reach any port this machine can dial, not just the SSH/files - /// ports above -- tailcat's own "exit-node" service. Without it, tailcat's - /// protocol-level access gate refuses a forward to any port this server - /// wasn't already otherwise serving, no matter which client API asks. - /// Pair with to restrict who gets that - /// reach. Passed to meowshell's own --exit-node. - /// + /// Let a client's forward/SOCKS channels reach any port this machine can dial, not just the SSH/files ports above -- tailcat's own "exit-node" service. Passed to meowshell's own --exit-node. public bool AllowExitNode { get; init; } - /// - /// Run this command for every session instead of a login shell, like - /// OpenSSH's ForceCommand: the client gets no shell, no client-chosen - /// command, and no SFTP subsystem. The command sees the peer's node key - /// in TAILCAT_PEER_KEY (in 's - /// format), plus TAILCAT_REMOTE_ADDR and - /// TAILCAT_LOCAL_ADDR. Passed to tailcat's own serve ... -- - /// <command>. Empty runs a normal login shell. - /// + /// Run this command for every session instead of a login shell, like OpenSSH's ForceCommand. Empty runs a normal login shell. public IReadOnlyList ForcedCommand { get; init; } = []; - /// - /// The one entry point: works unchanged on Android, Windows, and Linux, - /// with no platform code, and no paths, of your own. Add just this - /// package -- it carries the right native binaries for wherever you're - /// building, see -- and: - /// - /// var options = MeowshellOptions.Create(TimeSpan.FromMinutes(5)) with - /// { - /// InsecureNoAuth = true, // or AuthorizedKeys = "..."; - /// }; - /// await using var server = await MeowshellServer.StartAsync(options); - /// - /// Which platform's directories and binary layout apply is resolved at - /// build time, from which target framework compiled this method into - /// your app -- an Android build and a desktop build of the same call - /// never carry both, so there is nothing to detect at runtime and - /// nothing to get wrong by picking the wrong overload. - /// + /// The one entry point: works unchanged on Android, Windows, and Linux, with no platform code of your own. /// - /// The one thing this cannot reach into your app to set for you: an - /// Android app targeting API 29+ may only execute a file from - /// ApplicationInfo.NativeLibraryDir, and only has one there if the OS - /// extracted it at install time, which requires your own app to set - /// <AndroidExtractNativeLibraries>true</AndroidExtractNativeLibraries> - /// (or the equivalent android:extractNativeLibs="true" manifest - /// attribute) -- a referenced library cannot set that on your manifest - /// for you. + /// An Android app targeting API 29+ may only execute a file from ApplicationInfo.NativeLibraryDir, and only has + /// one there if the OS extracted it at install time, which requires your own app to set + /// <AndroidExtractNativeLibraries>true</AndroidExtractNativeLibraries> -- a referenced + /// library cannot set that on your manifest for you. /// /// How long the server may live before it shuts itself down. public static MeowshellOptions Create(TimeSpan lifetime) @@ -175,11 +101,7 @@ public static MeowshellOptions Create(TimeSpan lifetime) } } -/// -/// Runs a tailcat shell server for a bounded period and shuts it down -/// afterwards. Start it, hand to whoever is connecting, -/// and dispose when done; the deadline fires on its own if you do not. -/// +/// Runs a tailcat shell server for a bounded period and shuts it down afterwards. public sealed class MeowshellServer : IAsyncDisposable { private readonly MeowshellOptions _options; @@ -197,13 +119,7 @@ public sealed class MeowshellServer : IAsyncDisposable public TimeSpan Remaining => ExpiresAt - DateTimeOffset.UtcNow is { Ticks: > 0 } t ? t : TimeSpan.Zero; - /// - /// Completes when the server process has exited. Succeeds after a - /// call or the deadline; faults with a - /// if the process dies on its own first - /// (a crash, an OOM kill), so awaiting this is enough to notice and - /// diagnose that without polling. - /// + /// Completes when the server process has exited. Succeeds after a call or the deadline; faults with a if the process dies on its own first. public Task Completed => _listener.Completed; /// Diagnostic output from tailcat. Raised on a background thread. @@ -221,25 +137,11 @@ private MeowshellServer(MeowshellOptions options, TailcatListener listener, stri ExpiresAt = DateTimeOffset.UtcNow + options.Lifetime; } - /// - /// Starts the server and returns once it has published an address. - /// + /// Starts the server and returns once it has published an address. /// Where the binaries live and how the session is configured. /// Abandons the start; the process is cleaned up. - /// - /// Diagnostic output from tailcat, called as it arrives. Unlike the - /// event on the instance this method returns, this - /// also fires when StartAsync itself throws: tailcat's own stderr is - /// usually the actual reason it exited before publishing an address, - /// and the instance carrying is never handed back to - /// the caller on that path, so without this there is nothing to attach - /// a subscriber to. - /// - /// - /// Both authentication modes were set, nothing was chosen to serve, or - /// was combined with a forced - /// command on the ssh/no-auth-ssh service. - /// + /// Diagnostic output from tailcat, called as it arrives -- also fires when StartAsync itself throws, unlike the event. + /// Both authentication modes were set, nothing was chosen to serve, or was combined with a forced command on the ssh/no-auth-ssh service. /// A native binary is missing. /// tailcat exited before publishing an address. /// No address appeared within . @@ -314,11 +216,7 @@ public static async Task StartAsync( psi.ArgumentList.Add(token); } - // meowshell looks for a sibling file literally named "tailcat"; under - // NativeLibraryDir everything is lib*.so, so point it at the binary. psi.Environment["TAILCAT_BIN"] = tailcat; - // tailcat aborts a session when user.Current fails, which on Android - // happens whenever HOME is unset. psi.Environment["HOME"] = options.HomeDirectory; psi.Environment["TMPDIR"] = options.WorkDirectory; psi.Environment["TAILCAT_ADDR_FILE"] = addressFile; @@ -333,8 +231,6 @@ public static async Task StartAsync( if (options.PrivateKeyJson is not null) { - // meowshell reads the whole key before exec'ing tailcat, so - // the pipe has to be closed for it to proceed. await process.StandardInput.WriteAsync(options.PrivateKeyJson) .ConfigureAwait(false); process.StandardInput.Close(); @@ -354,10 +250,6 @@ await process.StandardInput.WriteAsync(options.PrivateKeyJson) } } - /// - /// tailcat writes its address to TAILCAT_ADDR_FILE once it is listening, - /// which is more robust than parsing stdout. - /// private async Task WaitForAddressAsync(CancellationToken cancellationToken) { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -392,13 +284,10 @@ private void StartDeadline() => _ = Task.Run(async () => await Task.Delay(_options.Lifetime, _deadline.Token).ConfigureAwait(false); await StopAsync().ConfigureAwait(false); } - catch (OperationCanceledException) { /* stopped early */ } + catch (OperationCanceledException) { } }); - /// - /// Shuts the server down: SIGTERM, then SIGKILL if it does not go quietly. - /// Safe to call repeatedly. - /// + /// Shuts the server down: SIGTERM, then SIGKILL if it does not go quietly. Safe to call repeatedly. public async Task StopAsync() { try @@ -408,7 +297,7 @@ public async Task StopAsync() } finally { - try { if (File.Exists(_addressFile)) File.Delete(_addressFile); } catch { /* best effort */ } + try { if (File.Exists(_addressFile)) File.Delete(_addressFile); } catch { } } } diff --git a/dotnet/Meowshell/MeowshellSocksProxy.cs b/dotnet/Meowshell/MeowshellSocksProxy.cs index 7aef476..bbf4fe8 100644 --- a/dotnet/Meowshell/MeowshellSocksProxy.cs +++ b/dotnet/Meowshell/MeowshellSocksProxy.cs @@ -6,34 +6,19 @@ namespace Meowshell; /// Configuration for a . public sealed record MeowshellSocksOptions : TailcatListenerOptions { - /// - /// SOCKS5 proxy listen [address]:port; a bare port means - /// localhost, a bare address means an OS-assigned port. Empty lets - /// tailcat pick its own default. Passed to tailcat's own - /// --listen. - /// + /// SOCKS5 proxy listen [address]:port; a bare port means localhost, a bare address means an OS-assigned port. Empty lets tailcat pick its own default. Passed to tailcat's own --listen. public string? Listen { get; init; } /// tailcat client key name or path (see 'tailcat genkey'). public string? ClientKey { get; init; } } -/// -/// Runs a SOCKS5 proxy that dials tailcat servers, until stopped. A -/// long-lived local listener, so -- like -- -/// it goes through meowshell rather than a bare tailcat, to inherit the -/// same crash backstop (Windows job object here; PR_SET_PDEATHSIG is armed -/// inside meowshell on Unix, before it execs tailcat). -/// +/// Runs a SOCKS5 proxy that dials tailcat servers, until stopped. public sealed class MeowshellSocksProxy : IAsyncDisposable { private readonly TailcatListener _listener; - /// - /// Completes when the process has exited. Succeeds after a - /// call; faults with a - /// if the process dies on its own first. - /// + /// Completes when the process has exited. Succeeds after a call; faults with a if the process dies on its own first. public Task Completed => _listener.Completed; /// Diagnostic output from tailcat. Raised on a background thread. diff --git a/dotnet/Meowshell/TailcatAddress.cs b/dotnet/Meowshell/TailcatAddress.cs index f76d03d..8a648de 100644 --- a/dotnet/Meowshell/TailcatAddress.cs +++ b/dotnet/Meowshell/TailcatAddress.cs @@ -2,14 +2,7 @@ namespace Meowshell; -/// -/// A tailcat address: opaque, base64-encoded, and always starting with -/// "tc". Wrapping it catches a stray empty string or a copy-paste mistake -/// at the API boundary, and reads better than a bare string at -/// every call site that specifically needs an address rather than any -/// text. Implicitly convertible to and from string, so existing -/// code passing a plain address string keeps working. -/// +/// A tailcat address: opaque, base64-encoded, and always starting with "tc". Implicitly convertible to and from string. public readonly record struct TailcatAddress { private readonly string _value; diff --git a/dotnet/Meowshell/TailcatClient.cs b/dotnet/Meowshell/TailcatClient.cs index 4dc5222..7c9931b 100644 --- a/dotnet/Meowshell/TailcatClient.cs +++ b/dotnet/Meowshell/TailcatClient.cs @@ -25,10 +25,7 @@ public sealed record TailcatClientOptions : TailcatOptions /// Configuration for . public sealed record TailcatKeyOptions { - /// - /// Key name (written to $CONFIG/tailcat/keys/<name>.private.json) - /// or a path, if it contains a slash. - /// + /// Key name (written to $CONFIG/tailcat/keys/<name>.private.json) or a path, if it contains a slash. public required string Name { get; init; } /// Generate a client identity key (no DERP region), for use in an --allow list, instead of a server key. @@ -37,11 +34,7 @@ public sealed record TailcatKeyOptions /// Overwrite an existing key of the same name. public bool Force { get; init; } - /// - /// Region ID, code, or substring, or one or more comma-separated - /// hostnames to use custom DERP server(s). "auto" (the default) picks - /// one by latency at each server startup. - /// + /// Region ID, code, or substring, or one or more comma-separated hostnames to use custom DERP server(s). "auto" (the default) picks one by latency at each server startup. public string? Region { get; init; } /// Discover the nearest DERP region once, now, and bake it into the key and address. @@ -54,14 +47,7 @@ public sealed record TailcatKeyOptions public bool Psk { get; init; } = true; } -/// -/// One-shot tailcat operations that call the bare tailcat binary -/// directly, not meowshell: none of these spawn a shell (the reason -/// meowshell exists) or run for long enough to risk being orphaned by a -/// crashed host (the reason , -/// and -/// go through it). -/// +/// One-shot tailcat operations that call the bare tailcat binary directly, not meowshell. public static class TailcatClient { private static ProcessStartInfo Prepare(TailcatClientOptions options) @@ -103,7 +89,7 @@ private static async Task RunAsync(ProcessStartInfo psi, TimeSpan } catch (OperationCanceledException) { - try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { /* already gone */ } + try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { } throw new TimeoutException($"{commandForTimeoutMessage} did not finish within {timeout}"); } return new TailcatResult( @@ -112,18 +98,13 @@ private static async Task RunAsync(ProcessStartInfo psi, TimeSpan (await stderrTask.ConfigureAwait(false)).Trim()); } - /// Builds the exception for a non-zero exit, carrying tailcat's own stderr. private static TailcatException Failure(string command, TailcatResult result) => new($"tailcat {command} failed", result.ExitCode, result.Stderr); - /// Builds the exception for output that doesn't match the shape this method expects, despite a zero exit. private static TailcatException UnexpectedOutput(string command, string detail) => new($"unexpected output from tailcat {command}", exitCode: 0, detail); - /// - /// Generates a key and returns its tailcat address (or, for a - /// key, its public key). - /// + /// Generates a key and returns its tailcat address (or, for a key, its public key). /// tailcat exited non-zero, or printed something other than the expected address/public key. public static async Task GenerateKeyAsync(TailcatClientOptions options, TailcatKeyOptions key) { @@ -137,9 +118,7 @@ public static async Task GenerateKeyAsync(TailcatClientOptions options, var result = await RunAsync(options, [.. args]).ConfigureAwait(false); if (!result.Success) throw Failure("genkey", result); - // genkey's last line of stdout is the address (earlier lines can - // include a "# wrote file to ..." notice); client keys print only - // the public key, on their own single line. + var lines = result.Stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (lines.Length == 0) throw UnexpectedOutput("genkey", "printed nothing"); @@ -210,32 +189,22 @@ public static async Task PrintPubAsync(TailcatClientOptions options, str return result.Stdout; } - /// - /// Pings a server, reporting whether the pong arrived via DERP or a - /// direct path. Does not throw on a non-zero exit (e.g. - /// timing out without going direct) -- - /// check . - /// carries the most recent parsed pong either way, since a timed-out - /// attempt can still have printed - /// several relayed pongs before giving up. - /// + /// Pings a server, reporting whether the pong arrived via DERP or a direct path. Does not throw on a non-zero exit -- check . public static async Task PingAsync( TailcatClientOptions options, string address, bool untilDirect = false, TimeSpan? timeout = null) { var args = new List { "ping" }; if (untilDirect) args.Add("--until-direct"); - // Go's duration flag parser wants "5s", not TimeSpan's default - // "00:00:05"; a plain number of seconds with an "s" suffix is - // always valid for it, fractional or not. + if (timeout is { } t) args.Add($"--timeout={t.TotalSeconds.ToString(CultureInfo.InvariantCulture)}s"); args.Add(address); var result = await RunAsync(options, [.. args]).ConfigureAwait(false); return TailcatPingResult.From(result); } - /// Lists files on a tailcat server (a "files" service, or the home directory of an ssh/no-auth-ssh one), over SFTP directly -- no ssh or sftp binary is involved. + /// Lists files on a tailcat server, over SFTP directly -- no ssh or sftp binary is involved. /// Where the binaries live and how to reach the server. - /// A remote path: or , with an optional path under the served/home directory. + /// A remote path: or . /// Include permissions, size, and modification time. /// is a local path. /// tailcat exited non-zero, or a line didn't match the expected shape. @@ -266,13 +235,8 @@ public static async Task> ListFilesAsync( return TailcatFileEntry.ParseAll(result.Stdout, longListing); } - /// - /// Connects the system ssh client through a tailcat server. - /// - /// - /// Running on Android: this shells out to a system ssh binary, which an app sandbox does not provide. - /// Use or meowshell connect for shell access there instead. - /// + /// Connects the system ssh client through a tailcat server. + /// Running on Android: this shells out to a system ssh binary, which an app sandbox does not provide. public static Task SshAsync( TailcatClientOptions options, string destination, IReadOnlyList? command = null, string? port = null) { @@ -289,9 +253,7 @@ public static Task SshAsync( return RunAsync(options, [.. args]); } - /// - /// Copies one source to . - /// + /// Copies one source to . /// Where the binaries live and how to reach the server. /// The file or directory to copy: to upload, or / to download. /// Where to copy it to: local for a download, remote for an upload. @@ -304,23 +266,14 @@ public static Task CpAsync( bool recursive = false, bool preserve = false, string? port = null) => CpAsync(options, [source], target, recursive, preserve, port); - /// - /// Copies one or more sources to -- the multi-source form of - /// , for copying - /// several local files to one remote directory in a single call. Uses the system scp everywhere except - /// Android, where an app sandbox provides no such binary; there it speaks SFTP directly instead, through - /// meowshell's own "cp" subcommand (routed over tailcat's own client mode, not a system ssh/scp client). - /// + /// Copies one or more sources to -- the multi-source form of . /// Where the binaries live and how to reach the server. /// The files or directories to copy. /// Where to copy them to. /// Recursively copy directories. /// Preserve modification times and modes. /// The server's SSH (file service) port, when it isn't 22. - /// - /// is empty; none of or is - /// remote (there would be no tailcat server to route the copy through); or they don't all name the same server. - /// + /// is empty; none of or is remote; or they don't all name the same server. public static Task CpAsync( TailcatClientOptions options, IReadOnlyList sources, TailcatPath target, bool recursive = false, bool preserve = false, string? port = null) @@ -357,12 +310,7 @@ public static Task CpAsync( : RunAsync(options, ["cp", .. cpArgs]); } - /// - /// meowshell's own resolved environment for a session: shell/home/user/path/term/lang and where it found - /// the tailcat binary, exactly as it would hand them to a real / - /// session. Useful for diagnosing a broken environment (an Android app sandbox, adb shell, a stripped - /// container) up front, rather than from a session that fails mysteriously once it's already running. - /// + /// meowshell's own resolved environment for a session, exactly as it would hand it to a real / session. /// Where the binaries live. /// meowshell exited non-zero. public static async Task GetEnvironmentAsync(TailcatClientOptions options) @@ -384,11 +332,6 @@ public static async Task GetEnvironmentAsync(TailcatClientOp return TailcatEnvironment.Parse(result.Stdout); } - /// - /// Android counterpart to the system-scp path above: runs meowshell's own "cp" subcommand, which speaks SFTP - /// directly (routed over tailcat's own bare client mode, not a system ssh/scp binary an app sandbox lacks) - /// instead of shelling out to scp. - /// private static Task RunMeowshellCpAsync(TailcatClientOptions options, IReadOnlyList cpArgs) { var (meowshell, tailcat) = MeowshellBinaries.Locate(options.BinaryDirectory, options.Naming); @@ -406,8 +349,7 @@ private static Task RunMeowshellCpAsync(TailcatClientOptions opti if (options.Verbose) psi.ArgumentList.Add("--verbose"); foreach (var a in cpArgs) psi.ArgumentList.Add(a); - // meowshell looks for a sibling file literally named "tailcat"; under - // NativeLibraryDir everything is lib*.so, so point it at the binary. + psi.Environment["TAILCAT_BIN"] = tailcat; psi.Environment["HOME"] = options.HomeDirectory; return RunAsync(psi, options.Timeout, "meowshell cp " + string.Join(' ', cpArgs)); diff --git a/dotnet/Meowshell/TailcatDiagnostics.cs b/dotnet/Meowshell/TailcatDiagnostics.cs index df136f4..2ffb78e 100644 --- a/dotnet/Meowshell/TailcatDiagnostics.cs +++ b/dotnet/Meowshell/TailcatDiagnostics.cs @@ -2,14 +2,6 @@ namespace Meowshell; -/// -/// A bounded tail of a process's stderr, kept regardless of whether -/// anything is subscribed to its Log event -- so that when -/// , , or -/// exits unexpectedly, there is -/// something to put in the resulting -/// besides a bare exit code. -/// internal sealed class TailcatDiagnostics { private const int MaxLines = 50; @@ -26,7 +18,6 @@ public void Add(string? line) } } - /// The captured lines, oldest first, joined with newlines. public string Tail() { lock (_lock) return string.Join('\n', _lines); diff --git a/dotnet/Meowshell/TailcatEnvironment.cs b/dotnet/Meowshell/TailcatEnvironment.cs index e01757f..7caf784 100644 --- a/dotnet/Meowshell/TailcatEnvironment.cs +++ b/dotnet/Meowshell/TailcatEnvironment.cs @@ -3,19 +3,14 @@ namespace Meowshell; -/// -/// meowshell's own resolved environment for a session -- the shell/home/user/path/term/lang -/// it would hand a real session, and where it found the tailcat binary. Mirrors meowshell env, -/// useful for diagnosing a broken environment (an app sandbox, adb shell, a stripped-down -/// container) before starting a real session rather than after one fails mysteriously. -/// -/// The resolved login shell (fixed up on Android/adb shell, where $SHELL is often wrong or missing). +/// meowshell's own resolved environment for a session -- the shell/home/user/path/term/lang it would hand a real session, and where it found the tailcat binary. Mirrors meowshell env. +/// The resolved login shell. /// The resolved home directory. /// The resolved username. /// The resolved PATH. /// The resolved TERM. /// The resolved LANG. -/// Where meowshell found the tailcat binary, or null if it couldn't (see and the thrown exception's diagnostics for why, when this is null). +/// Where meowshell found the tailcat binary, or null if it couldn't. /// Resolver warnings, e.g. about a shell that doesn't exist or a broken environment variable. public sealed record TailcatEnvironment( string Shell, string Home, string User, string Path, string Term, string Lang, diff --git a/dotnet/Meowshell/TailcatException.cs b/dotnet/Meowshell/TailcatException.cs index 8f133d3..d708df9 100644 --- a/dotnet/Meowshell/TailcatException.cs +++ b/dotnet/Meowshell/TailcatException.cs @@ -2,38 +2,16 @@ namespace Meowshell; -/// -/// tailcat (or meowshell) did not behave as expected: it exited with a -/// non-zero code, exited unexpectedly while something was supposed to keep -/// running, or produced output that doesn't match the shape this library -/// parses. carries tailcat's own explanation -- -/// its captured stderr, or a description of the unexpected output -- so -/// catching this one exception is normally enough to know what went wrong, -/// with no need to subscribe to a Log event or inspect a process -/// directly. -/// +/// tailcat (or meowshell) did not behave as expected -- a non-zero exit, an unexpected exit, or output that doesn't parse. carries tailcat's own explanation. public sealed class TailcatException : Exception { - /// - /// The process's exit code, or 0 if it exited successfully but its - /// output didn't parse as expected. - /// + /// The process's exit code, or 0 if it exited successfully but its output didn't parse as expected. public int ExitCode { get; } - /// - /// tailcat's own explanation: captured stderr (possibly just the tail - /// of it, for a long-lived process), or a description of the - /// unexpected output when is 0. - /// + /// tailcat's own explanation: captured stderr, or a description of the unexpected output when is 0. public string Diagnostics { get; } - /// - /// The typed reason this failed, when one is known -- always set for a - /// failure that came back over 's - /// control protocol, for one that - /// didn't (a plain nonzero process exit, for one of this library's - /// other, non-agent APIs). - /// + /// The typed reason this failed, when one is known -- always set for a failure, otherwise. public MeowshellErrorCode Code { get; } /// Builds a message combining a short summary with the captured diagnostics. diff --git a/dotnet/Meowshell/TailcatFileEntry.cs b/dotnet/Meowshell/TailcatFileEntry.cs index c89a886..d384370 100644 --- a/dotnet/Meowshell/TailcatFileEntry.cs +++ b/dotnet/Meowshell/TailcatFileEntry.cs @@ -4,30 +4,13 @@ namespace Meowshell; -/// -/// One entry from tailcat ls. , , -/// and are only present -/// for a long listing (longListing: true); a short listing carries -/// only the name. -/// +/// One entry from tailcat ls. , , and are only present for a long listing (longListing: true); a short listing carries only the name. /// The entry's name, without a trailing slash even for a directory. /// Whether the entry is a directory. /// The raw permission string, e.g. "-rw-r--r--" or "drwxr-xr-x". /// The size in bytes. -/// -/// The modification time exactly as tailcat printed it: "Mon _2 15:04" for -/// a file modified within the last 180 days, else "Mon _2 2006" -- the -/// same ambiguous, locale-independent format classic Unix ls -l -/// uses, carrying either a time-of-day or a year but never both. -/// -/// -/// A best-effort reconstruction of : when it -/// carries a year, midnight on that date; when it carries a time of day, -/// that time on the most recent matching date not in the future. Treat -/// this as informational, not a precise instant -- the missing half -/// (time-of-day or year) is a guess, and tailcat's clock may be in any -/// time zone, which nothing here reveals. -/// +/// The modification time exactly as tailcat printed it: "Mon _2 15:04" for a file modified within the last 180 days, else "Mon _2 2006". +/// A best-effort reconstruction of . Treat this as informational, not a precise instant. public sealed record TailcatFileEntry( string Name, bool IsDirectory, @@ -36,25 +19,10 @@ public sealed record TailcatFileEntry( string? ModifiedRaw, DateTime? ModifiedAt) { - // " ", matching - // printEntry's "%s %12d %s %s\n" in tailcat's own ls.go, where the - // third %s is itself "Mon _2 15:04" or "Mon _2 2006" -- three - // whitespace-separated tokens. Matched by runs of non-whitespace and - // whitespace rather than fixed column widths, since %12d only - // guarantees a *minimum* width. private static readonly Regex LongFormat = new( @"^(?\S+)\s+(?\d+)\s+(?[A-Za-z]{3})\s+(?\d{1,2})\s+(?\d{1,2}:\d{2}|\d{4})\s+(?.+)$", RegexOptions.Compiled); - /// - /// Parses tailcat ls's stdout. - /// must match whether -l was passed: a short listing's lines - /// are only ever a bare name (a file name can itself contain - /// whitespace, so a short line is never tokenized), while a long - /// listing's lines are tokenized to separate the fixed fields from a - /// name that may still contain spaces. - /// - /// A line didn't match the expected long-listing shape. internal static IReadOnlyList ParseAll(string stdout, bool longListing) { var lines = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries); @@ -107,8 +75,6 @@ private static (string Name, bool IsDirectory) SplitTrailingSlash(string name) = { if (!TimeOnly.TryParseExact(timeOrYear, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var time)) return null; - // No year given: assume the most recent one that isn't in the - // future, mirroring classic "ls -l" parsing. var now = DateTime.UtcNow; for (var year = now.Year; year >= now.Year - 1; year--) { diff --git a/dotnet/Meowshell/TailcatListener.cs b/dotnet/Meowshell/TailcatListener.cs index 7577b6e..d1dc0b9 100644 --- a/dotnet/Meowshell/TailcatListener.cs +++ b/dotnet/Meowshell/TailcatListener.cs @@ -4,14 +4,6 @@ namespace Meowshell; -/// -/// The process lifecycle shared by every long-lived meowshell-spawned -/// listener (, , -/// ): captures stderr regardless of -/// whether anything is listening, completes or faults -/// depending on whether the exit was requested, and stops the process -/// (SIGTERM, then SIGKILL if that doesn't land in time). -/// internal sealed class TailcatListener : IAsyncDisposable { private const int SIGTERM = 15; @@ -27,17 +19,10 @@ internal sealed class TailcatListener : IAsyncDisposable private JobObject? _job; private bool _stopped; - /// The underlying process, for a caller that needs it directly (MeowshellServer polls it for an early exit before it has an address to report). public Process Process { get; } - /// - /// Completes when the process has exited. Succeeds after a - /// call; faults with a - /// if the process dies on its own first (a crash, an OOM kill). - /// public Task Completed => _exited.Task; - /// Diagnostic output from tailcat. Raised on a background thread. public event Action? Log; private TailcatListener(Process process, TimeSpan gracePeriod) @@ -46,14 +31,6 @@ private TailcatListener(Process process, TimeSpan gracePeriod) _gracePeriod = gracePeriod; } - /// - /// Starts (already configured with a - /// that redirects stdout/stderr) and - /// wires up output capture and the crash-fault - /// semantics. On Windows, also assigns the process to a kill-on-close - /// job object, so it doesn't outlive a crashed host even if - /// is never called. - /// public static TailcatListener Start(Process process, TimeSpan gracePeriod, Action? onLog) { process.EnableRaisingEvents = true; @@ -66,11 +43,6 @@ public static TailcatListener Start(Process process, TimeSpan gracePeriod, Actio process.Exited += async (_, _) => { - // Exited can fire before the async reads behind - // BeginErrorReadLine finish delivering the last lines; - // WaitForExitAsync (unlike the Exited event itself) is - // documented to synchronize with that, so _diagnostics is - // complete by the time this reads it. await process.WaitForExitAsync().ConfigureAwait(false); if (listener._stopped) listener._exited.TrySetResult(); else listener._exited.TrySetException(new TailcatException( @@ -89,13 +61,6 @@ public static TailcatListener Start(Process process, TimeSpan gracePeriod, Actio return listener; } - /// - /// Throws a if the process has already - /// exited, first synchronizing with the diagnostics stream the same - /// way the fault path does -- for a caller - /// (MeowshellServer) polling for an early exit before it considers - /// itself started. - /// public async Task ThrowIfExitedAsync(string summary, CancellationToken cancellationToken = default) { if (!Process.HasExited) return; @@ -103,7 +68,6 @@ public async Task ThrowIfExitedAsync(string summary, CancellationToken cancellat throw new TailcatException(summary, Process.ExitCode, _diagnostics.Tail()); } - /// Stops the process: SIGTERM, then SIGKILL if it does not go quietly. Safe to call repeatedly. public async Task StopAsync() { await _stopLock.WaitAsync().ConfigureAwait(false); @@ -133,7 +97,6 @@ public async Task StopAsync() } } - /// Stops the process and releases everything it holds. public async ValueTask DisposeAsync() { await StopAsync().ConfigureAwait(false); diff --git a/dotnet/Meowshell/TailcatOptions.cs b/dotnet/Meowshell/TailcatOptions.cs index 7c2bbb5..ed1d95d 100644 --- a/dotnet/Meowshell/TailcatOptions.cs +++ b/dotnet/Meowshell/TailcatOptions.cs @@ -2,48 +2,26 @@ namespace Meowshell; -/// -/// Configuration shared by everything in this library that reaches a -/// tailcat server: where the binaries live, and how to dial out. -/// +/// Configuration shared by everything in this library that reaches a tailcat server: where the binaries live, and how to dial out. public abstract record TailcatOptions { - /// - /// Directory holding the meowshell and tailcat binaries. Leave null to - /// search for the ones shipped by a runtime package; see - /// . On Android this must be - /// ApplicationInfo.NativeLibraryDir, because for apps targeting API 29+ - /// it is the only location an app may execute a file from. - /// + /// Directory holding the meowshell and tailcat binaries. Leave null to search for the ones shipped by a runtime package; see . On Android this must be ApplicationInfo.NativeLibraryDir. public string? BinaryDirectory { get; init; } - /// - /// How the binaries are named in . Defaults - /// to the convention for the running platform: lib*.so on Android, - /// because only files named that way are unpacked into the native library - /// directory; *.exe on Windows; a bare name elsewhere. - /// + /// How the binaries are named in . Defaults to the convention for the running platform. public BinaryNaming Naming { get; init; } = BinaryNaming.ForCurrentPlatform(); /// A writable HOME. Use the app's FilesDir. public required string HomeDirectory { get; init; } - /// - /// URL of a self-hosted, JSON-encoded DERP map to use instead of - /// tailcat's default (https://tailcat.dev/derpmap.json). Passed - /// to tailcat's own --derpmap-url. - /// + /// URL of a self-hosted, JSON-encoded DERP map to use instead of tailcat's default. Passed to tailcat's own --derpmap-url. public string? DerpMapUrl { get; init; } /// Passed to tailcat's own --verbose. public bool Verbose { get; init; } } -/// -/// Configuration shared by every long-lived listener this library wraps -/// (, , -/// ), on top of . -/// +/// Configuration shared by every long-lived listener this library wraps, on top of . public abstract record TailcatListenerOptions : TailcatOptions { /// How long SIGTERM gets before SIGKILL. diff --git a/dotnet/Meowshell/TailcatParsedAddress.cs b/dotnet/Meowshell/TailcatParsedAddress.cs index b8fba87..6a849ba 100644 --- a/dotnet/Meowshell/TailcatParsedAddress.cs +++ b/dotnet/Meowshell/TailcatParsedAddress.cs @@ -3,21 +3,10 @@ namespace Meowshell; -/// -/// The fields a tailcat address actually carries, as decoded by -/// tailcat parse. Mirrors tailcat's own wire format one field at a -/// time (see tailscale/tailcat's wire.go) -- property names match its -/// JSON exactly (a few carry an explicit JsonPropertyName where -/// tailcat's own casing, e.g. "RegionID", isn't idiomatic C#), so this -/// deserializes directly with no custom converter. A field is null/empty -/// exactly when the address doesn't carry it: a short address (the common -/// case) has no ; a full address (tailcat -/// resolve's output, or one generated with --full-address) -/// embeds instead. -/// +/// The fields a tailcat address actually carries, as decoded by tailcat parse. A field is null/empty exactly when the address doesn't carry it. /// The server's public node key, e.g. "nodekey:...". /// The server's public discovery key, e.g. "discokey:...", when the address carries one. -/// The WireGuard pre-shared key, e.g. "psk:...", when the address carries one (addresses from current servers always do, unless generated with --psk=false). +/// The WireGuard pre-shared key, e.g. "psk:...", when the address carries one. /// DERP relay details embedded directly in the address, when present, instead of referencing a region by . /// The DERP region ID this address's server registers at, when the address references one by ID rather than embedding it. public sealed record TailcatParsedAddress( @@ -40,14 +29,14 @@ public sealed record TailcatDerpRegion( /// One DERP relay node, as embedded in a full tailcat address. /// The node's short name within its region, when present. -/// The node's region ID, when it differs from its region's own (a frontend node), or is present at all. +/// The node's region ID, when it differs from its region's own, or is present at all. /// The hostname (or, for a self-hosted relay, an IP literal) clients dial. /// The expected TLS certificate name, when it differs from . /// An IPv4 literal to dial directly, skipping DNS, when present. /// An IPv6 literal to dial directly, skipping DNS, when present. /// The node's STUN port, when it differs from the default. /// The node's DERP (HTTPS) port, when it differs from the default (443). -/// Whether the node accepts a plaintext or self-signed connection -- only ever true for a local test relay, never a real deployment. +/// Whether the node accepts a plaintext or self-signed connection -- only ever true for a local test relay. public sealed record TailcatDerpNode( string? Name, [property: JsonPropertyName("RegionID")] long RegionId, diff --git a/dotnet/Meowshell/TailcatPath.cs b/dotnet/Meowshell/TailcatPath.cs index 9a383dc..ab9e552 100644 --- a/dotnet/Meowshell/TailcatPath.cs +++ b/dotnet/Meowshell/TailcatPath.cs @@ -2,14 +2,7 @@ namespace Meowshell; -/// -/// A path for -/// and : either a -/// local filesystem path, or a path on a tailcat server. Building the -/// scp-style "<tc-addr>:path" text by hand is easy to get subtly -/// wrong (forgetting the colon, swapping source and target); constructing -/// one of these instead makes that string, and only that string. -/// +/// A path for and : either a local filesystem path, or a path on a tailcat server. public sealed record TailcatPath { /// The local filesystem path, when this is a local path. @@ -18,15 +11,7 @@ public sealed record TailcatPath /// The server's tailcat address, when this is a remote path named by one. public TailcatAddress? Address { get; } - /// - /// The path on the server, relative to its served directory (a - /// "files" service) or home directory (an ssh/no-auth-ssh one). Null - /// means the server's default: the served/home directory itself for - /// , - /// or "keep the source's own name" for a - /// - /// target. - /// + /// The path on the server, relative to its served directory or home directory. Null means the server's default. public string? RemotePath { get; } private readonly string? _remoteHost; @@ -54,7 +39,6 @@ private TailcatPath(string? localPath, TailcatAddress? address, string? remoteHo /// A local path. Lets a plain string be passed anywhere a is expected. public static implicit operator TailcatPath(string localPath) => Local(localPath); - /// The server this path names (its address as text, or its DNS name), for grouping every remote path in one call by which server they target. Null for a local path. internal string? Server => Address?.ToString() ?? _remoteHost; /// The scp-style argument tailcat expects: "server:path" for a remote path, the bare path for a local one. diff --git a/dotnet/Meowshell/TailcatPingResult.cs b/dotnet/Meowshell/TailcatPingResult.cs index 46577f9..f86167c 100644 --- a/dotnet/Meowshell/TailcatPingResult.cs +++ b/dotnet/Meowshell/TailcatPingResult.cs @@ -9,13 +9,7 @@ namespace Meowshell; /// The direct endpoint (e.g. "1.2.3.4:5678") when , else the DERP region code or ID. public sealed record TailcatPong(TimeSpan Latency, bool Direct, string Via); -/// -/// The result of . -/// is populated from the last "pong in ... via ..." line tailcat printed, -/// if any -- present even when is false, since -/// --until-direct can print several relayed pongs before giving up -/// on ever going direct. -/// +/// The result of . is populated from the last "pong in ... via ..." line tailcat printed, if any. /// The raw process result: exit code and full stdout/stderr. /// The most recent parsed pong, or null if tailcat printed none. public sealed record TailcatPingResult(TailcatResult Result, TailcatPong? Pong) diff --git a/dotnet/Meowshell/TailcatSshSession.cs b/dotnet/Meowshell/TailcatSshSession.cs index bd55e49..31b5694 100644 --- a/dotnet/Meowshell/TailcatSshSession.cs +++ b/dotnet/Meowshell/TailcatSshSession.cs @@ -2,31 +2,7 @@ namespace Meowshell; -/// -/// An interactive session over a tailcat address, with a pseudo-terminal by -/// default -- native (no system ssh client involved, on any platform) -/// unlike , which shells out to one and -/// inherits the caller's own console. That makes SshAsync the simpler -/// choice for a CLI app that already has a real console to hand it, and -/// this the one for anywhere there is no such console at all (an Android -/// app being the main case) or that wants programmatic control over the -/// session instead. -/// -/// A thin wrapper around its own dedicated -/// and one shell/exec channel on it -- one login, one process, exactly as -/// before, just built on the same daemon -/// uses directly instead of "meowshell connect"'s separate one-shot -/// implementation. Reach for itself -/// instead when a shell, file transfer, and a forward all need to share one -/// login, or live resize matters (this session's pseudo-terminal size is -/// fixed for its life, set at ). -/// -/// This only carries bytes: read for whatever the -/// remote pseudo-terminal renders and write keystrokes with -/// . Interpreting that output (ANSI/VT100 escape -/// sequences, an actual terminal widget) is entirely the caller's own -/// responsibility. -/// +/// An interactive session over a tailcat address, with a pseudo-terminal by default -- native (no system ssh client involved) unlike . A thin wrapper around its own dedicated and one shell/exec channel on it. public sealed class TailcatSshSession : IAsyncDisposable { private readonly MeowshellAgentConnection _connection; @@ -35,18 +11,10 @@ public sealed class TailcatSshSession : IAsyncDisposable private readonly SemaphoreSlim _stopLock = new(1, 1); private bool _stopped; - /// - /// Raw bytes the remote pseudo-terminal produced. Do not read this - /// concurrently from more than one place. - /// + /// Raw bytes the remote pseudo-terminal produced. Do not read this concurrently from more than one place. public Stream Output => _channel.Output; - /// - /// Resolves when the session ends: successfully after - /// , or when a session running a command (not an - /// interactive shell) finishes on its own. Faults with a - /// if the connection is lost unexpectedly. - /// + /// Resolves when the session ends: successfully after , or when a session running a command finishes on its own. Faults with a if the connection is lost unexpectedly. public Task Completed => _exited.Task; /// Diagnostic output from meowshell/tailcat (connection setup, errors). Raised on a background thread. @@ -69,34 +37,23 @@ private async Task ObserveCompletionAsync() } catch (Exception ex) { - // A fault here is also the normal shape of a deliberate - // StopAsync (closing the connection faults every channel on - // it -- see MeowshellAgentConnection.FaultEverything), not - // just an unexpected connection loss; _stopped tells them - // apart the same way the old process-per-session - // implementation's Exited handler used it. if (_stopped) _exited.TrySetResult(); else _exited.TrySetException(ex); } } - /// - /// Opens a session against : an - /// interactive pseudo-terminal shell by default, or - /// instead if given (still with a - /// pseudo-terminal, unless is set false). - /// + /// Opens a session against : an interactive pseudo-terminal shell by default, or instead if given. /// Where the binaries live and how to reach the server. /// A tailcat address, or a DNS name carrying a "tailcat=" TXT record. - /// Run this instead of an interactive shell, e.g. ["ls", "-la"]. Elements are joined with plain spaces, the same as a real ssh client sends a trailing command line -- no quoting is added, so an element that must survive as one word remotely (e.g. ["sh", "-c", "exit 42"]'s last element) needs its own quotes if it contains spaces. + /// Run this instead of an interactive shell, e.g. ["ls", "-la"]. Elements are joined with plain spaces -- no quoting is added. /// Pseudo-terminal width. /// Pseudo-terminal height. - /// Whether to allocate a pseudo-terminal. Defaults to true for an interactive shell (no ); pass true explicitly to also get one for a command. + /// Whether to allocate a pseudo-terminal. Defaults to true for an interactive shell. /// TERM to request for the pseudo-terminal. Defaults to "xterm-256color". /// The server's SSH service port, when it isn't 22. /// Called for each line of meowshell/tailcat's own diagnostic output, in addition to . /// Cancels waiting for the connection to settle; does not cancel or stop the session itself once returned. - /// The session failed to connect within (a bad address, a handshake failure, a lost connection). + /// The session failed to connect within . public static async Task ConnectAsync( TailcatClientOptions options, string destination, IReadOnlyList? command = null, int columns = 80, int rows = 24, bool? requestPty = null, string? term = null, string? port = null, @@ -124,11 +81,7 @@ public static async Task ConnectAsync( public Task WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) => _channel.WriteAsync(data, cancellationToken); - /// - /// Ends the session: closes the channel (like Ctrl+D, letting a shell - /// exit on its own), then tears down its dedicated connection. Safe to - /// call repeatedly. - /// + /// Ends the session: closes the channel (like Ctrl+D, letting a shell exit on its own), then tears down its dedicated connection. Safe to call repeatedly. public async Task StopAsync() { await _stopLock.WaitAsync().ConfigureAwait(false); @@ -137,7 +90,7 @@ public async Task StopAsync() if (_stopped) return; _stopped = true; - try { await _channel.DisposeAsync().ConfigureAwait(false); } catch { /* connection already gone */ } + try { await _channel.DisposeAsync().ConfigureAwait(false); } catch { } await _connection.StopAsync().ConfigureAwait(false); _exited.TrySetResult(); } diff --git a/dotnet/README.md b/dotnet/README.md index 172b78e..094ed1d 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -268,11 +268,15 @@ process dies unexpectedly, the same as the three listener types; call ### A persistent, multiplexed connection -`TailcatSshSession`, `CpAsync`, and `ListFilesAsync` each spawn their own -process and their own full handshake — fine for one thing at a time, but -opening a shell *and* browsing files against the same host means two -logins. **`MeowshellAgentConnection`** dials once and keeps the connection -open, multiplexing every operation over it as its own channel: +`CpAsync` and `ListFilesAsync` each spawn their own bare-`tailcat` process +and their own full handshake — fine for one thing at a time, but opening a +shell *and* browsing files against the same host means two logins. +`TailcatSshSession` is already built on the daemon underneath (see +[above](#interactive-sessions-without-a-console)), but still opens its own +dedicated connection per session rather than sharing one with anything +else. **`MeowshellAgentConnection`** is what actually shares one: dial +once and keep the connection open, multiplexing every operation over it +as its own channel: ```csharp await using var connection = await MeowshellAgentConnection.ConnectAsync(options, address); diff --git a/scripts/CommentStripper/CommentStripper.csproj b/scripts/CommentStripper/CommentStripper.csproj new file mode 100644 index 0000000..707e6f5 --- /dev/null +++ b/scripts/CommentStripper/CommentStripper.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/scripts/CommentStripper/Program.cs b/scripts/CommentStripper/Program.cs new file mode 100644 index 0000000..8eaa7d0 --- /dev/null +++ b/scripts/CommentStripper/Program.cs @@ -0,0 +1,138 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +var dryRun = args.Contains("--dry-run"); +var root = args.FirstOrDefault(a => !a.StartsWith("--")) ?? "."; + +var files = Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) + .Where(f => !PathHasSegment(f, "bin") && !PathHasSegment(f, "obj") && !PathHasSegment(f, ".git")) + .OrderBy(f => f, StringComparer.Ordinal) + .ToList(); + +var changed = 0; +foreach (var path in files) +{ + var original = await File.ReadAllTextAsync(path); + var tree = CSharpSyntaxTree.ParseText(original, path: path); + var root2 = (CompilationUnitSyntax)await tree.GetRootAsync(); + var stripped = (CompilationUnitSyntax)new CommentStripper().Visit(root2)!; + var result = CollapseBraceBlankLines(stripped.ToFullString()); + + if (result == original) continue; + changed++; + if (dryRun) + { + Console.WriteLine(path); + continue; + } + await File.WriteAllTextAsync(path, result); +} +Console.Error.WriteLine($"{changed}/{files.Count} files had comments stripped"); + +static bool PathHasSegment(string path, string segment) => + path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains(segment); + +// Drops a blank line left directly inside a brace pair -- right after the +// line that opens it, or right before the line that closes it -- the shape +// a removed comment that used to be the first or last line of a block +// leaves behind. The trivia-level Filter pass has no way to see this: an +// empty line at the very start of a block is legitimate C# either way, so +// nothing there removes it on its own. +static string CollapseBraceBlankLines(string src) +{ + var lines = src.Split('\n'); + var outLines = new List(lines.Length); + for (var i = 0; i < lines.Length; i++) + { + var cur = lines[i]; + if (cur.TrimEnd(' ', '\t').EndsWith('{') && i + 1 < lines.Length && lines[i + 1].Trim() == "") + { + outLines.Add(cur); + i++; + continue; + } + if (cur.Trim() == "" && i + 1 < lines.Length && lines[i + 1].TrimStart().StartsWith('}')) + { + continue; + } + outLines.Add(cur); + } + return string.Join('\n', outLines); +} + +// Removes every comment/doc-comment trivia node (line, block, and /// or /** */ +// documentation comments alike -- each is its own SyntaxTriviaList entry, so +// filtering by kind catches multi-line and doc comments the same way as +// single-line ones) while leaving string/char literal content untouched, since +// Roslyn's own parser already separated trivia from token/literal content +// correctly. Three passes over each token's trivia: +// +// 1. Drop every comment-kind trivia node outright. A /// or /** */ doc +// comment's own trivia text bundles its trailing newline (a plain // or +// /* */ comment does not -- that newline is always a separate +// EndOfLineTrivia entry that survives this pass untouched), so dropping a +// doc comment can leave two whitespace runs directly adjacent with no +// newline between them. +// 2. Collapse whitespace: a WhitespaceTrivia entry immediately followed by +// another WhitespaceTrivia or an EndOfLineTrivia is dead -- either +// superseded by a later indent run (exactly the adjacency pass 1 can +// produce) or trailing indentation on what's now a blank line -- so only +// the whitespace immediately preceding real content survives. +// 3. Cap each contiguous run of EndOfLineTrivia at 1, so a removed +// multi-line comment block collapses to at most one blank line rather +// than a matching run of them. The newline terminating the *previous* +// real line already lives in that line's own last token's trailing +// trivia (Roslyn convention: trailing trivia runs up through the first +// EndOfLineTrivia, at most one), so this list's own leading trivia only +// ever needs to contribute the newline for one further, intentional +// blank line -- capping it at 1 here, not 2, is what keeps the combined +// result (this list appended after the previous token's trailing +// trivia) at exactly one blank line instead of two. +sealed class CommentStripper : CSharpSyntaxRewriter +{ + public override SyntaxToken VisitToken(SyntaxToken token) => + token.WithLeadingTrivia(Filter(token.LeadingTrivia)).WithTrailingTrivia(Filter(token.TrailingTrivia)); + + private static readonly SyntaxKind[] CommentKinds = + [ + SyntaxKind.SingleLineCommentTrivia, + SyntaxKind.MultiLineCommentTrivia, + SyntaxKind.SingleLineDocumentationCommentTrivia, + SyntaxKind.MultiLineDocumentationCommentTrivia, + ]; + + private static SyntaxTriviaList Filter(SyntaxTriviaList trivia) + { + var noComments = trivia.Where(t => !CommentKinds.Contains(t.Kind())).ToList(); + + var noDeadWhitespace = new List(); + for (var i = 0; i < noComments.Count; i++) + { + var t = noComments[i]; + if (t.IsKind(SyntaxKind.WhitespaceTrivia) && i + 1 < noComments.Count) + { + var next = noComments[i + 1]; + if (next.IsKind(SyntaxKind.WhitespaceTrivia) || next.IsKind(SyntaxKind.EndOfLineTrivia)) continue; + } + noDeadWhitespace.Add(t); + } + + var capped = new List(); + var consecutiveNewlines = 0; + foreach (var t in noDeadWhitespace) + { + if (t.IsKind(SyntaxKind.EndOfLineTrivia)) + { + consecutiveNewlines++; + if (consecutiveNewlines > 1) continue; + } + else + { + consecutiveNewlines = 0; + } + capped.Add(t); + } + return SyntaxFactory.TriviaList(capped); + } +} diff --git a/scripts/stripcomments/main.go b/scripts/stripcomments/main.go new file mode 100644 index 0000000..9e0f0d5 --- /dev/null +++ b/scripts/stripcomments/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "flag" + "fmt" + "go/ast" + "go/build/constraint" + "go/format" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" +) + +func main() { + dryRun := flag.Bool("dry-run", false, "print files that would change instead of writing them") + root := flag.String("root", ".", "root directory to walk for .go files") + flag.Parse() + + var files []string + err := filepath.WalkDir(*root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + name := d.Name() + if name == ".tailcat-src" || name == "dist" || name == ".git" { + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(path, ".go") { + files = append(files, path) + } + return nil + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + changed := 0 + for _, path := range files { + out, wasChanged, err := stripFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", path, err) + os.Exit(1) + } + if !wasChanged { + continue + } + changed++ + if *dryRun { + fmt.Println(path) + continue + } + if err := os.WriteFile(path, out, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", path, err) + os.Exit(1) + } + } + fmt.Fprintf(os.Stderr, "%d/%d files had comments stripped\n", changed, len(files)) +} + +// stripFile parses path without attaching comments to the AST, then reprints +// it gofmt-formatted. Since comments were never part of the parsed tree, the +// printed output has none -- string/rune literals are handled correctly by +// the parser's own tokenizer, so nothing inside a string is ever touched. +// +// Build-constraint lines (//go:build, and the legacy // +build) are the one +// exception: syntactically they're ordinary line comments, but the Go +// toolchain reads them to decide which files even compile for a given +// GOOS/GOARCH, so dropping one silently breaks the build instead of just +// losing prose -- a real bug this tool shipped with once already, caught +// only by CI actually building for a second platform. extractBuildTags scans +// the original source's leading comment lines (constraints are only ever +// valid before the package clause) and whatever it finds is spliced back +// into the stripped output. +func stripFile(path string) (out []byte, changed bool, err error) { + original, err := os.ReadFile(path) + if err != nil { + return nil, false, err + } + tags := extractBuildTags(original) + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, original, parser.SkipObjectResolution) + if err != nil { + return nil, false, err + } + file.Comments = []*ast.CommentGroup{} + + var buf strings.Builder + if err := format.Node(&buf, fset, file); err != nil { + return nil, false, err + } + out = []byte(collapseBraceBlankLines(buf.String())) + if len(tags) > 0 { + out = append([]byte(strings.Join(tags, "\n")+"\n\n"), out...) + } + return out, string(out) != string(original), nil +} + +// collapseBraceBlankLines drops a blank line left directly inside a brace +// pair -- right after the line that opens it, or right before the line that +// closes it -- the shape a removed comment that used to be the first or +// last line of a block leaves behind. gofmt itself has no opinion on these +// (a blank line is valid either place), so nothing upstream removes them. +func collapseBraceBlankLines(src string) string { + lines := strings.Split(src, "\n") + out := make([]string, 0, len(lines)) + for i := 0; i < len(lines); i++ { + cur := lines[i] + if strings.HasSuffix(strings.TrimRight(cur, " \t"), "{") && i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "" { + out = append(out, cur) + i++ + continue + } + if strings.TrimSpace(cur) == "" && i+1 < len(lines) { + next := strings.TrimSpace(lines[i+1]) + if next == "}" || strings.HasPrefix(next, "}") { + continue + } + } + out = append(out, cur) + } + return strings.Join(out, "\n") +} + +func extractBuildTags(src []byte) []string { + var tags []string + for _, raw := range strings.Split(string(src), "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + if !strings.HasPrefix(line, "//") { + break // constraints only ever precede the package clause + } + if constraint.IsGoBuild(line) || constraint.IsPlusBuild(line) { + tags = append(tags, line) + } + } + return tags +}