Meowshell 0.1.94
dotnet add package Meowshell --version 0.1.94
NuGet\Install-Package Meowshell -Version 0.1.94
<PackageReference Include="Meowshell" Version="0.1.94" />
<PackageVersion Include="Meowshell" Version="0.1.94" />
<PackageReference Include="Meowshell" />
paket add Meowshell --version 0.1.94
#r "nuget: Meowshell, 0.1.94"
#:package Meowshell@0.1.94
#addin nuget:?package=Meowshell&version=0.1.94
#tool nuget:?package=Meowshell&version=0.1.94
Meowshell (.NET library)
A complete C# wrapper around tailcat: a real remote shell, a SOCKS5 proxy, port forwarding, and one-shot key/file operations — Android, Linux, or Windows — over Tailscale's data plane, without its control plane. Start with the shell server below; the full surface covers the rest.
Targets net8.0 (plus a Android-specific build for the net10.0-android36.0
target framework), with no dependencies beyond the packaged binaries below.
dotnet add package Meowshell
That alone is enough: Meowshell.Runtime.{linux,windows,android} — whichever
matches what you're building — comes along as a transitive dependency, and
MeowshellOptions.Create resolves the right paths for the platform it was
compiled for. No Context, no path, no platform check of your own.
var options = MeowshellOptions.Create(TimeSpan.FromMinutes(5)) with
{
AuthorizedKeys = "alice@github",
AllowClientKeys = "nodekey:...",
};
await using var server = await MeowshellServer.StartAsync(options);
Console.WriteLine($"connect with: tailcat ssh {server.Address}");
await server.Completed; // or let the deadline fire, or dispose early
The other machine connects with plain tailcat — tailcat ssh <address> —
and needs an ssh binary on its PATH, since tailcat's client shells out to
the system one. meowshell itself is server-side only; don't install it on
the client.
On Android, the manifest must opt into extraction, or the binaries stay inside the APK and never exist on disk to execute:
<application android:extractNativeLibs="true" />
Meowshell fails your build with a clear error if the corresponding MSBuild
property (<AndroidExtractNativeLibraries>true</AndroidExtractNativeLibraries>)
isn't set on an Android-targeting project — this is a build-time check, not
a runtime surprise.
Supplying a key at runtime
To debug a fleet device, generate its key yourself and keep the address:
tailcat genkey --key=device-0001 # prints the address; keep it in your records
Hand the key to the device only when you want a session — nothing has to come back off the device, since you already know how to reach it:
await using var server = await MeowshellServer.StartAsync(
MeowshellOptions.Create(TimeSpan.FromMinutes(10)) with
{
PrivateKeyJson = await FetchSessionKeyAsync(), // from your backend
AllowClientKeys = "nodekey:<your client key>",
AuthorizedKeys = "you@github",
});
// server.Address is the address you already know; no handoff needed
PrivateKeyJson is piped to meowshell --key-stdin, staged on an unlinked
file descriptor, and never exists as a named file. Neither end needs an open
port — both dial out to a DERP relay, so this works from behind CGNAT.
Set AllowClientKeys to your own client public key (tailcat printpub), so
a leaked device key alone doesn't grant a shell — a caller needs your client
key too.
The full surface
Meowshell is a complete C# wrapper around tailcat's CLI, not just the
shell server above:
| Type | Wraps | Use it for |
|---|---|---|
MeowshellServer |
meowshell serve |
An interactive shell, an SFTP file service, or a forced command for one session — combinable (a shell plus file access), or standalone. |
MeowshellSocksProxy |
meowshell socks |
A local SOCKS5 proxy that dials out through a tailcat server. |
MeowshellPortForward |
meowshell forward |
One or more local TCP ports forwarded to a tailcat server. |
TailcatClient |
tailcat genkey / parse / resolve / printpub / ping / ls / ssh / cp |
One-shot key management, address inspection, connectivity checks, file listing and transfer, and (not on Android) a console-attached ssh session. All work on Android too, ssh included via TailcatSshSession below. |
TailcatSshSession |
meowshell connect |
A native (no system ssh/sftp binary, on any platform) interactive pseudo-terminal session, driven programmatically — raw Output/WriteAsync, not a console. The one to use anywhere there's no real console to inherit (an Android app, most of all). |
Why the long-lived ones go through meowshell
MeowshellServer, MeowshellSocksProxy, and MeowshellPortForward all
launch meowshell rather than tailcat directly — even a SOCKS proxy or a
port forward, which don't need meowshell's shell-environment fix at all.
The reason is orphan protection: before it execs tailcat, meowshell arms a
parent-death signal (Linux and Android) or assigns itself to a kill-on-close
job object (Windows), so if the host process dies without stopping the
listener first — a crash, an OOM kill, a force-stop — the OS tears it down
too, instead of leaving it running as an orphan. That risk exists on any
platform, but it's most worth guarding against on Android, where the OS
kills app processes far more readily — backgrounding, memory pressure — than
it does on a desktop or a server. TailcatClient's operations skip all of
this and call tailcat directly: they're one-shot, so there's nothing left
running afterward to orphan.
Every option
BinaryDirectory/Naming/HomeDirectory/DerpMapUrl/Verbose (and, on
the three listeners, GracePeriod) are declared once, on TailcatOptions
and TailcatListenerOptions, and inherited by all four options types below
— not duplicated per type.
MeowshellOptions (for MeowshellServer) — HomeDirectory and
WorkDirectory are required; everything else has a default.
| Option | Default | What it does |
|---|---|---|
Lifetime |
5 minutes | How long the server may live before it shuts itself down. |
AuthorizedKeys |
— | SSH key sources allowed to log in (paths, literal keys, or user@github). Mutually exclusive with InsecureNoAuth. |
InsecureNoAuth |
false |
Serve a shell to anyone holding the address, with no SSH auth — pair with AllowClientKeys. |
AllowClientKeys |
— | Comma-separated tailcat client keys allowed to connect. |
EphemeralKey |
true |
Generate a throwaway key so the address dies with the process, instead of reusing a saved one. Ignored when PrivateKeyJson is set. |
PrivateKeyJson |
— | A *.private.json's contents, piped in on stdin rather than stored on disk. |
StartTimeout |
30s | How long to wait for the server to publish its address. |
GracePeriod |
3s | How long SIGTERM gets before SIGKILL. |
DerpMapUrl |
tailcat's own default | A self-hosted, JSON-encoded DERP map to use instead. |
Verbose |
false |
tailcat's own --verbose. |
FullAddress |
false |
Embed the DERP server's info in the address, so a client can connect without fetching a DERP map. |
Psk |
true |
Include a WireGuard pre-shared key in the address. Only disable it for tailcat clients v0.5.0 and earlier. |
Files |
— | A directory to serve over SFTP, with an optional :ro/:rw/:wo/:wo+ suffix. Combinable with AuthorizedKeys/InsecureNoAuth to also serve a shell — but not together with ForcedCommand, which would leave the ssh service serving nothing but that one command. |
ForcedCommand |
none (login shell) | Run this command for every session instead of a shell, OpenSSH-ForceCommand-style. The command sees TAILCAT_PEER_KEY, TAILCAT_REMOTE_ADDR, TAILCAT_LOCAL_ADDR. |
AuthorizedKeys/InsecureNoAuth, Files, and ForcedCommand combine
freely except for that one case above — set none of the three and
StartAsync throws, since there'd be nothing to serve.
MeowshellSocksOptions (for MeowshellSocksProxy) — HomeDirectory required.
| Option | Default | What it does |
|---|---|---|
Listen |
tailcat's own default | [address]:port to listen on; a bare port means localhost, a bare address an OS-assigned port. |
ClientKey |
tailcat's saved default | tailcat client key name or path. |
DerpMapUrl |
tailcat's own default | Same as MeowshellOptions.DerpMapUrl. |
Verbose |
false |
Same as MeowshellOptions.Verbose. |
GracePeriod |
3s | Same as MeowshellOptions.GracePeriod. |
MeowshellPortForwardOptions (for MeowshellPortForward) —
HomeDirectory, Address, and Mappings (at least one) required.
| Option | Default | What it does |
|---|---|---|
Mappings |
— | A bare port, local:remote, or local:remote-ip:remote-port (the server must be an exit node). Local port 0 asks the OS for a free port. |
Bind |
127.0.0.1 |
Local address for a mapping that only names a port. |
ClientKey |
tailcat's saved default | Same as MeowshellSocksOptions.ClientKey. |
DerpMapUrl |
tailcat's own default | Same as MeowshellOptions.DerpMapUrl. |
Verbose |
false |
Same as MeowshellOptions.Verbose. |
GracePeriod |
3s | Same as MeowshellOptions.GracePeriod. |
TailcatClientOptions (shared by every TailcatClient call) — HomeDirectory required.
| Option | Default | What it does |
|---|---|---|
DerpMapUrl |
tailcat's own default | Same as MeowshellOptions.DerpMapUrl. |
Verbose |
false |
Same as MeowshellOptions.Verbose. |
Timeout |
30s | How long to wait for the command to finish before killing it. |
TailcatClient methods: GenerateKeyAsync, DeleteKeyAsync, ListKeysAsync
(tailcat genkey's three modes), ParseAsync (returns a typed
TailcatParsedAddress, not raw JSON), ResolveAsync (returns a
TailcatAddress), PrintPubAsync, PingAsync (returns a
TailcatPingResult — its Pong is the parsed "pong in ... via ..." line
when tailcat printed one, and Success doesn't throw on its own, since
e.g. --until-direct timing out is meaningful information, not an error),
ListFilesAsync (tailcat ls, pure Go SFTP — no ssh/sftp binary
involved — returns typed TailcatFileEntry records, not raw text), SshAsync,
and CpAsync. GenerateKeyAsync takes a TailcatKeyOptions —
Name required:
| Option | Default | What it does |
|---|---|---|
Client |
false |
Generate a client identity key (no DERP region) for an --allow list, instead of a server key. |
Force |
false |
Overwrite an existing key of the same name. |
Region |
auto (nearest by latency, chosen fresh each server start) |
A DERP region ID/code/substring, or comma-separated custom hostnames. |
FixedRegion |
false |
Discover the nearest region once, now, and bake it into the key. |
EmbedDerpMap |
false |
Embed the DERP map nodes in the address (implies FixedRegion unless Region names one). |
Psk |
true |
Same as MeowshellOptions.Psk. |
Remote paths
CpAsync and ListFilesAsync take TailcatPath instead of a
hand-built "tc-addr:path" string, so there's no scp-style text to get
subtly wrong (a missing colon, sources and target swapped):
// Upload a local file to a directory a server offers read-write:
await TailcatClient.CpAsync(options,
source: "photo.jpg", // a plain string is always local
target: TailcatPath.Remote(address, "photos/photo.jpg"));
// Download, and list what's there first:
var entries = await TailcatClient.ListFilesAsync(options, TailcatPath.Remote(address, "photos"));
await TailcatClient.CpAsync(options, TailcatPath.Remote(address, "photos/photo.jpg"), "local-copy.jpg");
TailcatPath.Local(path) (or a bare string, which converts implicitly),
TailcatPath.Remote(address, path), and TailcatPath.RemoteHost(dnsName, path)
(for a server named by a DNS name with a "tailcat=" TXT record, instead of
a literal address) cover every case tailcat's own cp/ls accept. CpAsync
also takes a multi-source overload (IReadOnlyList<TailcatPath> sources)
for copying several local files to one remote directory in a single call,
and throws ArgumentException up front if nothing in the call is remote,
or if the sources and target don't all name the same server -- the same
rule tailcat itself enforces, just reported before a process ever runs.
Interactive sessions without a console
SshAsync is the simplest option when there's a real console to hand
tailcat's own ssh client — it inherits the caller's stdio directly, the
same as running ssh yourself. That doesn't work at all in a process with
no console of its own (an Android app, most notably), and doesn't fit a
GUI app that wants to render the session in its own terminal widget rather
than a real OS console either way.
TailcatSshSession is for both of those: native (no system ssh
client involved, on any platform), giving you raw bytes instead of a
console.
await using var session = await TailcatSshSession.ConnectAsync(options, address);
await session.WriteAsync("ls -la\n"u8.ToArray());
var buffer = new byte[4096];
int n;
while ((n = await session.Output.ReadAsync(buffer)) > 0)
{
// Feed buffer[..n] to your own terminal renderer, or just Console.Write it.
Console.Write(Encoding.UTF8.GetString(buffer, 0, n));
}
An interactive shell (the default, no command argument) gets a
pseudo-terminal, sized by columns/rows at connect time — there's no
live resize once the session is running. Pass command to run something
other than a shell, still with a pseudo-terminal unless you set
requestPty: false. Completed faults with a TailcatException if the
process dies unexpectedly, the same as the three listener types; call
StopAsync (or dispose the session) to end it deliberately.
Errors
Everything that can go wrong at the process level -- a non-zero exit, or a
zero exit with output that doesn't match the shape this library parses --
comes back as one type, TailcatException, carrying ExitCode and
Diagnostics (tailcat's own captured stderr, or a description of the
unexpected output). Its Message already includes Diagnostics, so
catching it is normally enough to know what went wrong, with no need to
subscribe to a Log event or inspect a process yourself:
try
{
await using var server = await MeowshellServer.StartAsync(options);
await server.Completed;
}
catch (TailcatException ex)
{
Console.WriteLine($"tailcat failed ({ex.ExitCode}): {ex.Diagnostics}");
}
MeowshellServer, MeowshellSocksProxy, and MeowshellPortForward all
capture their process's stderr internally for this, whether or not
anything is subscribed to Log. Their Completed task reflects it too:
it completes successfully after a StopAsync call or the deadline, but
faults with a TailcatException if the process dies on its own first --
a crash, an OOM kill -- so awaiting Completed is enough to notice and
diagnose that without polling.
What's not available on Android
TailcatClient.SshAsync shells out to a system ssh client, which a
typical Android app sandbox doesn't provide. It exists in the API on every
platform — same class, same signature, full IntelliSense — but throws
PlatformNotSupportedException specifically when running on Android,
naming the alternative: TailcatSshSession for a programmatic session
there (or anywhere else you don't want SshAsync taking over your own
console), MeowshellServer for shell access as the server side instead.
Everything else works on Android too, unchanged in the API: CpAsync uses
the system scp everywhere else, but on Android routes through
meowshell's own native SFTP cp instead — same TailcatPath arguments,
same TailcatResult, no platform check needed in your own code.
ListFilesAsync and TailcatSshSession never depended on a system binary
anywhere to begin with. Files/ForcedCommand on MeowshellServer also
work the same on Android as anywhere else.
Packages
| Package | Contents |
|---|---|
Meowshell |
Everything above: MeowshellServer, MeowshellSocksProxy, MeowshellPortForward, TailcatClient, BinaryLocator. |
Meowshell.Runtime.linux |
tailcat/meowshell for linux-x64, linux-arm64, linux-arm, linux-x86. |
Meowshell.Runtime.windows |
tailcat.exe/meowshell.exe for win-x64, win-arm64. |
Meowshell.Runtime.android |
libtailcat.so/libmeowshell.so for android-arm64, android-arm, android-x64, android-x86. |
Each runtime package lays its binaries out under runtimes/<rid>/native/,
NuGet's own convention for native assets, so a plain BinaryLocator.Locate
finds them with no configuration. Set BinaryDirectory on
MeowshellOptions, or the MEOWSHELL_BINARIES environment variable, to
point at a different location instead.
What MeowshellServer handles, and what it cannot
It sets TAILCAT_BIN (meowshell looks for a sibling literally named
tailcat, which doesn't exist under Android's native library directory
where everything is lib*.so), sets a writable HOME (tailcat aborts a
session when user.Current fails, which on Android means whenever HOME is
unset), reads the address from a handoff file rather than scraping stdout,
and stops with SIGTERM before SIGKILL. It defaults to a fresh ephemeral key
per call — without that, tailcat would reuse a saved default key if one
exists, silently turning a throwaway address into a permanent one.
The deadline itself is enforced in-process, so it only fires while your app is alive and running its own code. If the host process is killed outright instead — a crash, an OOM kill, a force-stop — the OS closes the gap for you; see why the long-lived ones go through meowshell above for how.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-android36.0 is compatible. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0-android36.0
- Meowshell.Runtime.android (>= 0.1.94)
-
net8.0
- Meowshell.Runtime.linux (>= 0.1.94)
- Meowshell.Runtime.windows (>= 0.1.94)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.