Running commands
The middleware's JSON-RPC API has no generic command-exec method, so
client.run(...) reaches the target over whichever transport is actually
available. Rather than a fixed local-or-SSH branch, the client composes a list
of executors and picks the first usable one:
| target | executors, in order |
|---|---|
| the NAS you are running on | local |
| remote, SSH configured | ssh, then webshell |
| remote, no SSH | webshell |
result = client.run("zpool status", capture_output="stdout", encoding="utf-8")
print(result.stdout)
run() returns a subprocess.CompletedProcess. Highlights:
- Multiple positional commands are joined by the shell's separator. A command
given as a
list/tupleis shell-quoted piece by piece; a leadingpathlib.PurePathmarks a direct executable whose trailing values are argv. capture_outputmay beTrue(both streams),"stdout","stderr", orFalse.input=feeds stdin (str or bytes); a file-likestdin=is drained.cwd=,env=,check=,timeout=,encoding=/errors=behave as withsubprocess.run.executable=overrides the shell.
client.run("cat > /tmp/x", input="hello\n", encoding="utf-8")
Which transport ran it
client.last_selection records what was tried and why, with credentials
redacted — useful when a command took an unexpected route:
client.run("uptime")
[t["provider"] for t in client.last_selection if t["chosen"]]
# ['ssh']
client.capabilities reports whether run is available at all, so a
caller can check up front instead of discovering it mid-command.
The web shell
A remote host reachable on the API port but not on 22 — NAT without a
forwarded port, a firewall allowing only 443, an appliance behind a reverse
proxy — has no SSH to fall back on. For those, run() uses /websocket/shell,
the same PTY the web UI's Shell page drives.
It is ranked below SSH deliberately, because a PTY is a single terminal stream rather than a pair of clean channels. Most of that is worked around, but not all of it:
-
stdout and stderr are separated when the shell allows it. A PTY has one stream, so the command is wrapped to fence its stderr in terminal escape markers, which are split back out into
.stderr. This needs a shell with process substitution — bash, zsh, or ksh. The login shell is read fromauth.me(); under anything else (sh,dash) the streams stay merged, everything arrives onstdout, and.stderrisNone. Merged output is always correct, just less informative.The wrapper is only applied when you ask for the distinction — a plain
capture_output=Truecosts nothing extra. -
Input works, in two shapes.
# Known up front -- delivered as a here-document. Prefer this. client.run("cat", input="hello\n", encoding="utf-8") # Still being produced -- pumped in the background. client.run("cat", stdin=open("big.bin", "rb"))input=is reliable;stdin=races the terminal's echo of the command line and is mitigated by a short delay rather than fully cured. A file descriptor (subprocess.PIPE) is rejected — there is no PTY fd to attach one to. -
Output you do not capture is streamed as it arrives, in raw bytes, to
stdout(defaultsys.stdout.buffer) — so a long-running command reports progress instead of going silent, and colour and other escape sequences survive. Anything you do capture is cleaned text. -
Commands must be single-line. An embedded newline submits a partial line to the terminal and desynchronises the session, so it is rejected rather than silently mangled. A here-document is the exception, since its newlines are the document's own — which is how
input=is delivered. - The exit status is recovered from the terminal stream, and terminal escape sequences are stripped from the captured output.
Choosing the transport yourself
executor= and path= override the defaults, naming providers in preference
order. Each takes a single name or a sequence:
from pytruenas.host import TrueNASConfig
# SSH only -- never fall back to the web shell
TrueNASConfig.from_target("wss://nas", executor=["ssh"], path=["sftp"])
# Prefer the web shell over SSH, for whatever reason
TrueNASConfig.from_target("wss://nas", executor=["webshell", "ssh"])
# Force the websocket filesystem leg on a local target (not offered by default)
TrueNASConfig.from_target(None, path=["local", "tnasws"])
# Refuse to run commands at all
TrueNASConfig.from_target("wss://nas", executor=[])
Valid names are local, ssh, and webshell for executors, and local,
sftp, and tnasws for paths. An unknown name raises rather than quietly
composing a host with no provider, and asking for ssh/sftp without an
SshConfig raises too. Omit them (the default) to decide from the target.
Note
SSH execution and the SFTP leg of client.path(...) need the ssh extra:
pip install pytruenas[ssh]. The web shell does not — it runs over the
same websocket connection as the API.