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. For a program run with no shell layer, passhostctl.Exec("/bin/ls", "-l"); a path object anywhere else is an ordinary value that stringifies, so one command in a sequence cannot silently become the executable. capture_outputmay beTrue(both streams),"stdout","stderr", orFalse.input=feeds stdin (str or bytes); a file-likestdin=is drained.cwd=,timeout=,encoding=/errors=behave as withsubprocess.run.checkandcapture_outputdo not: both default toTruehere, so a non-zero exit raisesCalledProcessErrorunless you passcheck=False, and output is captured rather than inherited.env=adds to the target's environment rather than replacing it, on every transport (a local target included):env={"A": "1"}still leavesPATHset.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. Each entry is a
hostctl SelectionStep record (.provider, .chosen, .availability,
.reason):
client.run("uptime")
[step.provider for step in client.last_selection if step.chosen]
# ['ssh']
When a command is served by a provider ranked below one that is unavailable —
the web shell because SSH refused the connection or its host key is not
trusted — the client logs one WARNING per skipped provider, on its own
logger: [nas] run() fell back from ssh to webshell: ssh: <reason>. A path
operation falling back from SFTP to the filesystem API logs the same way
(path() fell back from sftp to tnasws: ...).
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: SSH has real channels, while the web
shell drives an interactive terminal. It behaves like any other executor for
capture_output, stdout=/stderr=, input=, text/encoding, check and
timeout, with these differences:
- Output is exact. The command and its input are sent base64-encoded and its
output is framed by markers the shell prints — hostctl's
PosixConsoleProfileprotocol — so nothing typed is interpreted by the terminal and nothing the terminal draws (the echo, the prompt, the login banner) reaches your result. Captured.stdoutis the program's own bytes. - Uncaptured stdout is streamed as it arrives to its target (default
sys.stdout.buffer), as the web shell delivers it — a long-running command reports progress live. - stderr is captured separately in any POSIX login shell. It is written to
the remote side and delivered when the command finishes, so a stderr
target receives it at the end rather than live.
stderr=subprocess.STDOUTmerges the two as they are produced. -
stdin is always a file, never the terminal.
input=(bytes or str) and a readablestdin=object — read to EOF first — are delivered through a temporary file; with neither, stdin is/dev/null, so a command that prompts gets EOF instead of hanging. Streaming stdin incrementally is not supported: bytes typed into a terminal that the program does not read would be run by the shell as the next command.stdin=subprocess.PIPEwithoutinput=raisesValueError; a file descriptor is read to EOF like a stream.client.run("cat", input="hello\n", encoding="utf-8") client.run("cat", stdin=open("big.bin", "rb")) # read in full, then sent -
timeout=Nonewaits indefinitely; on a timeout the command is interrupted, the session is closed, andsubprocess.TimeoutExpiredcarries the partial output (andorphaned=False). - A connection that drops once the command line was typed raises
ConnectionError(the command may have run, so the host does not retry it elsewhere); a web shell that cannot connect at all lets the host try the next provider. A command that exits the shell (exit 3) ends the session the same way, and the next call reconnects.
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.