Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
aaa8edf
Expose test DERP payload counters
Sniperlyf3 Sep 18, 2026
b504747
Export test DERP metrics endpoint
Sniperlyf3 Sep 18, 2026
7605c4b
Prove direct payload bypasses DERP
Sniperlyf3 Sep 18, 2026
c8ef464
Run direct-path accounting E2E against shared DERP
Sniperlyf3 Sep 18, 2026
fc63c01
Make direct-path assertion explicit
Sniperlyf3 Sep 18, 2026
050036a
Keep shared DERP alive through .NET package checks
Sniperlyf3 Sep 18, 2026
6470b06
Document test DERP metrics export
Sniperlyf3 Sep 18, 2026
5e39763
Use one in-process Tailcat client for agent SSH
Sniperlyf3 Sep 18, 2026
a8fa3fa
Expose live Tailcat path and direct probing
Sniperlyf3 Sep 18, 2026
8b9cb15
Actively probe relayed agent paths
Sniperlyf3 Sep 18, 2026
7ca2abc
Test relayed-to-direct path probing
Sniperlyf3 Sep 18, 2026
9757f8a
Decouple host builds from patched Tailcat API
Sniperlyf3 Sep 18, 2026
839d717
Test optional Tailcat path decoding
Sniperlyf3 Sep 18, 2026
ff07e51
Document pristine Tailcat host-test contract
Sniperlyf3 Sep 18, 2026
38e2372
Use statically checked Tailcat path API
Sniperlyf3 Sep 18, 2026
23e0d50
Remove reflection-only path shape tests
Sniperlyf3 Sep 18, 2026
ffa933a
Patch live Tailcat path API before host compilation
Sniperlyf3 Sep 18, 2026
7e0f59d
Probe Tailcat paths even before status settles
Sniperlyf3 Sep 18, 2026
2eea4c7
Use live disco ping result for path state
Sniperlyf3 Sep 18, 2026
3eb4509
Cover unknown-path direct probing
Sniperlyf3 Sep 18, 2026
49da141
Make E2E helpers tolerate live path notifications
Sniperlyf3 Sep 18, 2026
ce5a084
Correlate SFTP replies past path telemetry
Sniperlyf3 Sep 18, 2026
3855e98
Ignore path telemetry while opening forwards
Sniperlyf3 Sep 18, 2026
8d20e33
Sync merged Go dependency updates
Sniperlyf3 Sep 18, 2026
f6dd488
Sync merged Go dependency checksums
Sniperlyf3 Sep 18, 2026
ca0a62c
Sync setup-go v7 across E2E CI
Sniperlyf3 Sep 18, 2026
8d22b1a
Merge current main into direct-path E2E branch
Sniperlyf3 Sep 18, 2026
6677f7f
cmd/meowshell: register anet interface getter for Android netmon.New
claude Sep 18, 2026
16bbb6b
cmd/meowshell: fix forward_socks race against live path telemetry
claude Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ jobs:
# through a tailcat destination dials via tailcat.Client, the same
# way tailcat's own "forward"/"socks" subcommands do), so go vet/go
# test need that directory to exist before either can even resolve
# imports. A plain, unpatched clone is enough here: the two patches
# below only change Android-specific networking behavior, irrelevant
# to vetting/testing meowshell on this runner's own host platform.
# ./build.sh (further down) later reuses and patches this same
# checkout for the real cross-compiled build.
# imports. Apply the reviewed live-path API patch before compiling
# meowshell: the agent now uses that exact Tailcat Client directly, so
# path reporting is statically type-checked rather than hidden behind
# reflection. ./build.sh later resets this disposable checkout and
# reapplies the full reviewed patch set before release binaries are built.
- name: Fetch tailcat source (for go.mod's replace directive)
env:
SRC_REF: ${{ inputs.tailcat_ref }}
Expand All @@ -47,6 +47,7 @@ jobs:
git clone --depth=1 https://github.com/tailscale/tailcat.git .tailcat-src
git -C .tailcat-src fetch --depth=1 origin "$SRC_REF"
git -C .tailcat-src checkout --detach FETCH_HEAD
git -C .tailcat-src apply "$GITHUB_WORKSPACE/patches/tailcat/live-path-status.patch"

- name: Vet and test meowshell
env:
Expand Down Expand Up @@ -210,7 +211,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v5
with:
go-version: '1.27.1'

Expand Down Expand Up @@ -242,7 +243,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v5
with:
go-version: '1.27.1'

Expand All @@ -258,6 +259,7 @@ jobs:
git clone --depth=1 https://github.com/tailscale/tailcat.git .tailcat-src
git -C .tailcat-src fetch --depth=1 origin "$SRC_REF"
git -C .tailcat-src checkout --detach FETCH_HEAD
git -C .tailcat-src apply "$GITHUB_WORKSPACE/patches/tailcat/live-path-status.patch"

# Some of cmd/meowshell's Windows-specific code (known_hosts DACL/SID
# validation, N9) only builds and runs under GOOS=windows, so it never
Expand Down Expand Up @@ -332,7 +334,7 @@ jobs:
with:
dotnet-version: "8.0.x"

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v5
with:
go-version: '1.27.1'

Expand All @@ -356,6 +358,9 @@ jobs:
dotnet restore dotnet/Meowshell.sln --force --no-cache --nologo
dotnet restore scripts/CommentStripper/CommentStripper.csproj --force --no-cache --nologo

- name: Start a shared local DERP relay for direct-path E2E
run: ./e2e/start-testderp.sh

- name: Test
# The E2E tests in Meowshell.Tests run against these real binaries
# instead of the fake stand-ins the rest of the suite uses; they
Expand Down Expand Up @@ -426,6 +431,10 @@ jobs:
path: nupkg/*
if-no-files-found: error

- name: Stop the shared local DERP relay
if: always()
run: kill "$(cat /tmp/testderp.pid)" 2>/dev/null || true

# Meowshell.Demo: a real, installable app, not just a pass/fail check.
# One button generates a fresh throwaway shell address, one field copies
# it. Published as a single universal APK bundling all four ABIs, the
Expand Down
53 changes: 48 additions & 5 deletions cmd/meowshell/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"flag"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
Expand All @@ -20,6 +21,7 @@ import (
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)

const agentUsage = `meowshell agent -- a persistent, multiplexed SSH connection
Expand All @@ -33,8 +35,9 @@ protocol.go) instead of the one-process-per-operation model "meowshell
connect"/"cp" use. Opening a shell and running a command against the same
host costs one login instead of two.

<destination> is a tailcat address (dialed through tailcat's own bare
client mode, same as "connect"/"cp") or a "[user@]host[:port]" TCP address
<destination> is a tailcat address (dialed by the in-process tailcat client
so the same live WireGuard path can be observed and reused for forwarding)
or a "[user@]host[:port]" TCP address
for a general (non-tailcat) SSH host, verified against a known_hosts file
(--known-hosts) with trust-on-first-use for a host seen for the first time.
--jump chains through one or more intermediate TCP hosts first, each
Expand Down Expand Up @@ -162,6 +165,14 @@ func agentCmd(args []string) error {
if err := session.writeControl(0, connected); err != nil {
return err
}

pathCtx, cancelPath := context.WithCancel(context.Background())
defer cancelPath()
if source := session.tailcatPathSource(); source != nil {
go reportTailcatPath(pathCtx, source, agentPathPollInterval, func(msg controlMessage) error {
return session.writeControl(0, msg)
})
}
return <-frameErrCh
}

Expand Down Expand Up @@ -351,16 +362,32 @@ func (a *agentSession) connect(ctx context.Context, opts connectOptions) error {
user := ""

if last && looksLikeTailcatAddress(hop) {
dial = tailcatDialer(opts.tailcatBin, tailcatClientArgv(opts.key, opts.derpMapURL, opts.verbose, hop, opts.port))
hkCallback = tailcatHostKeyCallback()
tcKey, err := tailcatKeyFromName(opts.key)
if err != nil {
closeClients(chain)
return fmt.Errorf("resolving --key %q for forwarding: %w", opts.key, err)
return fmt.Errorf("resolving --key %q for Tailcat: %w", opts.key, err)
}
tcClient := &tailcat.Client{
Server: tailcat.Addr(hop),
Key: tcKey,
DERPMapURL: opts.derpMapURL,
Logf: logger.Discard,
}
if opts.verbose {
tcClient.Logf = log.Printf
}
dial, err = tailcatClientDialer(tcClient, opts.port)
if err != nil {
closeClients(chain)
return err
}
a.tcAddr = tailcat.Addr(hop)
a.tcKey = tcKey
a.tcDERPMapURL = opts.derpMapURL
a.tcMu.Lock()
a.tcClient = tcClient
a.tcMu.Unlock()
} else {
var hostPort string
user, hostPort = splitUserHost(hop, opts.port)
Expand Down Expand Up @@ -389,6 +416,9 @@ func (a *agentSession) connect(ctx context.Context, opts connectOptions) error {
sc, err := dialSSHClient(ctx, dial, remoteAddr, user, hkCallback, opts.auth)
if err != nil {
closeClients(chain)
if last && looksLikeTailcatAddress(hop) {
a.closeTailcatClient()
}
return fmt.Errorf("connecting to %s: %w", connectTargetForDiagnostics(hop, last && looksLikeTailcatAddress(hop)), err)
}
chain = append(chain, sc)
Expand Down Expand Up @@ -421,12 +451,25 @@ func (a *agentSession) closeHops() {
// after which closing/clearing the client is bounded.
closeClients(a.hops)
a.resetSFTPClient(nil)
a.closeTailcatClient()
}

func (a *agentSession) closeTailcatClient() {
a.tcMu.Lock()
defer a.tcMu.Unlock()
if a.tcClient != nil {
a.tcClient.Close()
a.tcClient = nil
}
a.tcMu.Unlock()
}

func (a *agentSession) tailcatPathSource() *tailcatForwardClient {
a.tcMu.Lock()
defer a.tcMu.Unlock()
if a.tcClient == nil {
return nil
}
return &tailcatForwardClient{cl: a.tcClient}
}

const (
Expand Down
40 changes: 38 additions & 2 deletions cmd/meowshell/agent_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ func expectConnected(t *testing.T, r *bufio.Reader) {
}
}

func expectChannelOpened(t *testing.T, r *bufio.Reader) uint32 {
func expectChannelOpenedMessage(t *testing.T, r *bufio.Reader) (frame, controlMessage) {
t.Helper()
for {
f, err := readFrameWithDeadline(t, r)
Expand All @@ -191,10 +191,46 @@ func expectChannelOpened(t *testing.T, r *bufio.Reader) uint32 {
}
switch msg.Msg {
case "channel_opened":
return f.ChannelID
return f, msg
case "error":
t.Fatalf("agent returned an error opening the channel: %s: %s", msg.Code, msg.Message)
case "path":
// Live path telemetry is an asynchronous connection-level
// notification. It may legally arrive between a request and that
// request's reply, so request/response E2E helpers must not consume
// it as the synchronous response they are waiting for.
continue
}
}
}

func expectChannelOpened(t *testing.T, r *bufio.Reader) uint32 {
t.Helper()
f, _ := expectChannelOpenedMessage(t, r)
return f.ChannelID
}

func expectRequestReply(t *testing.T, r *bufio.Reader, requestID string) controlMessage {
t.Helper()
for {
f, err := readFrameWithDeadline(t, r)
if err != nil {
t.Fatalf("reading reply for request %q: %v", requestID, err)
}
if f.Type != frameTypeControl {
continue
}
var msg controlMessage
if err := json.Unmarshal(f.Payload, &msg); err != nil {
t.Fatalf("decoding control message: %v", err)
}
if msg.Msg == "path" {
continue
}
if msg.RequestID != requestID {
t.Fatalf("reply RequestID = %q, want %q (msg=%+v)", msg.RequestID, requestID, msg)
}
return msg
}
}

Expand Down
36 changes: 31 additions & 5 deletions cmd/meowshell/agent_path.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ type agentPathStatusSource interface {
PathStatus() (agentPathStatus, bool)
}

type agentPathProber interface {
ProbePath(context.Context) (agentPathStatus, bool)
}

type agentPathState struct {
direct bool
via string
Expand Down Expand Up @@ -54,11 +58,7 @@ func reportTailcatPath(

var last agentPathState
haveLast := false
poll := func() bool {
status, ok := source.PathStatus()
if !ok {
return true
}
emitStatus := func(status agentPathStatus) bool {
msg := pathControlMessageFromStatus(status)
state := agentPathState{direct: *msg.Direct, via: msg.Via}
if haveLast && state == last {
Expand All @@ -71,6 +71,32 @@ func reportTailcatPath(
haveLast = true
return true
}
poll := func() bool {
status, ok := source.PathStatus()
if ok {
if !emitStatus(status) {
return false
}
if status.direct {
return true
}
}

// Tailcat's DiscoPing actively nudges the call-me-maybe endpoint
// exchange. Probe while the path is unknown as well as while it is
// relayed: on a freshly started client Status can lag behind the live
// magicsock route, and returning early here used to prevent the very
// probe needed to establish a direct endpoint.
if prober, hasProber := source.(agentPathProber); hasProber {
probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
probed, probeOK := prober.ProbePath(probeCtx)
cancel()
if probeOK {
return emitStatus(probed)
}
}
return true
}

if !poll() {
return
Expand Down
Loading
Loading