Skip to content

Client

TrueNASClient and TrueNASHost are the same class. "Client" is the friendlier name at a call site; the class is documented under Host, where its hostctl half (run, path, capabilities, last_selection) lives.

pytruenas.TrueNASClient(config=None, credentials=None, *, client=None, **options)

Bases: PosixHost, Generic[ApiVersion]

A TrueNAS middleware host: POSIX semantics over composed transports.

Everything generic -- run, path, spawn, info, connect, close, shell, capabilities, last_selection -- is inherited from :class:hostctl.host.PosixHost, which selects between the providers assembled below. What this class adds is only the part no other host has: the middleware JSON-RPC websocket and the API surface built on it.

Construct it from a connection string, a :class:TrueNASConfig, or nothing at all (the local middleware socket)::

TrueNASHost("wss://nas")
TrueNASHost("nas", credentials="1-...", executor=["ssh"])
TrueNASHost(TrueNASConfig.from_target("wss://nas"))
TrueNASHost()

A string accepts every form :class:~pytruenas.TrueNASClient does and takes the same keyword options as :meth:TrueNASConfig.from_target.

Provider order depends on the target, and is overridable per selector with executor=/path=:

  • local -- hostctl's stock local pair, and nothing else. Reaching this same machine over SSH, a PTY, or the filesystem API would be slower and strictly less capable.
  • remote -- ssh then webshell for commands, sftp then tnasws for paths. SSH leads on capability; the websocket legs follow. This reproduces :class:~pytruenas.fs.truenas.TruenasPath's hand-rolled fallback through hostctl's selector, which additionally records a redacted trace of what was tried (host.last_selection).
Source code in src/pytruenas/host.py
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
def __init__(
    self,
    config: "TrueNASConfig | str | None" = None,
    credentials: object = None,
    *,
    client: object = None,
    **options: object,
) -> None:
    # `TrueNASClient(target, creds)` passed credentials positionally, and
    # that is the single most common call in the wild -- accept it.
    if credentials is not None:
        if "credentials" in options:
            raise TypeError("credentials given both positionally and by keyword")
        options["credentials"] = credentials
    # A connection string is the common case, so accept it directly rather
    # than making every caller reach for TrueNASConfig.from_target first.
    # hostctl's own `Host("uri")` shortcut cannot help here: its metaclass
    # only intercepts when `cls is Host`, so a subclass falls straight
    # through to normal construction.
    #
    # Keywords are split by destination: everything TrueNASConfig accepts
    # builds the config, and the rest (`info=`, `initializer=`, ...) goes
    # on to SystemHost. Splitting rather than guessing keeps a typo an
    # error from whichever layer owns the name.
    if config is None or isinstance(config, str):
        config_options = {
            key: options.pop(key) for key in list(options) if key in _CONFIG_OPTIONS
        }
        # Anything left that SystemHost does not take is a typo. Catch it
        # here rather than letting it reach SystemHost, which would raise a
        # TypeError naming an internal class -- unhelpful for a caller who
        # wrote `passwrd=` and needs to be told *that*.
        unknown = sorted(set(options) - _HOST_OPTIONS)
        if unknown:
            raise ValueError(
                f"unknown credential argument: {unknown[0]!r} "
                f"(configuration options: {', '.join(sorted(_CONFIG_OPTIONS))})"
            )
        config = TrueNASConfig.from_target(
            config, **_ty.cast(_ty.Any, config_options)
        )
    elif any(key in _CONFIG_OPTIONS for key in options):
        unexpected = ", ".join(
            sorted(key for key in options if key in _CONFIG_OPTIONS)
        )
        raise TypeError(
            "configuration options may not be combined with an existing "
            f"TrueNASConfig; pass them to from_target instead: {unexpected}"
        )
    self._config = config
    #: The live JSON-RPC connection, opened on first `.conn` access.
    self._conn: "_connection.TrueNASWSConnection | None" = None
    self.logger = _resolve_logger(config.logger, config.name)
    # `client=` is accepted and ignored: the host *is* the client now.
    # Kept so existing callers (and tests that injected a stand-in) do not
    # break on an unexpected keyword.
    del client

    executors, paths = self._build_providers(config)
    super().__init__(
        config,
        executor_providers=executors,
        path_providers=paths,
        **_ty.cast(_ty.Any, options),
    )

api cached property

The root API namespace (host.api.<namespace>.<method>(...)).

Parameterise the host to type it: TrueNASHost[Current]("nas").api completes exactly as the old TrueNASClient[Current] did.

client property

Deprecated alias for self.

The host is the client -- they were two objects forwarding halves of their surface to each other, which is now one class. Kept so host.client.api and similar keep working.

config_type = TrueNASConfig class-attribute instance-attribute

conn property

The live JSON-RPC connection; opens on first access.

Logs in first when autologin is set (the default) and there is no live connection. Reconnects if the previous one closed.

logger = _resolve_logger(config.logger, config.name) instance-attribute

name property

This host's short label -- see :attr:TrueNASConfig.name.

ssh property

The underlying asyncssh connection (requires the ssh extra).

For the rare caller that needs the raw connection -- port forwarding, an SFTP client of its own. Ordinary command and path work should go through :meth:run and :meth:path, which pick a transport rather than assuming this one exists.

sslverify property

TLS verification for this host -- see :attr:TrueNASConfig.sslverify.

Read from the config rather than stored, so every transport -- the JSON-RPC connection, the REST calls, and the web shell -- answers from the one value the caller set, with no second copy to drift.

websocket property

Former name of :attr:conn, kept because it is public API.

Defined as a property that reads self.conn rather than as websocket = conn: the latter makes two independent class attributes, so patching or overriding one would silently leave the other pointing at the original.

close()

Close the transports, then the websocket.

Order matters: the tnasws path provider talks over the websocket, so it must be torn down before the connection it depends on.

Source code in src/pytruenas/host.py
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
def close(self) -> None:
    """Close the transports, then the websocket.

    Order matters: the ``tnasws`` path provider talks over the websocket,
    so it must be torn down before the connection it depends on.
    """
    try:
        super().close()
    finally:
        conn, self._conn = self._conn, None
        if conn is not None:
            try:
                conn.close()
            except Exception:
                # close() must be safe to call repeatedly and must not mask
                # an error raised by the provider teardown above.
                pass

download(method, *args, filename=None, buffered=False, wait=True, **kwargs)

Call method for a download link and fetch it over HTTP(S).

Source code in src/pytruenas/host.py
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
def download(
    self,
    method: str,
    *args,
    filename: "str | None" = None,
    buffered=False,
    wait=True,
    **kwargs,
):
    """Call ``method`` for a download link and fetch it over HTTP(S)."""
    jobid, link = self.api.core.download(
        method, args, filename or "download", buffered, **kwargs
    )
    target = self._http_target(link)

    if wait:
        if buffered:
            self.api.core.job_wait(jobid, job=True, _timeout=None)
        resp = _req.get(target.uri, verify=self._config.sslverify)
        resp.raise_for_status()
        return resp.content
    return jobid

dump_api()

Run middlewared --dump-api on the target and parse the JSON.

Source code in src/pytruenas/host.py
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
def dump_api(self):
    """Run ``middlewared --dump-api`` on the target and parse the JSON."""
    import json

    from .models.apidump import Api

    api: Api = json.loads(
        self.run("middlewared --dump-api", capture_output=True).stdout
    )
    return api

install_sshcreds(name=None, private_key=None)

Provision an SSH keypair and wire it into this host's SSH transport.

Generates (or reuses) a SSH_KEY_PAIR keychain credential, installs the public half on root's authorized_keys, and stores the private half on :attr:TrueNASConfig.ssh as a real :class:hostctl.host.SshConfig -- with client_keys as an actual field rather than the "client_keys|root" string that the pre-hostctl client packed into a username.

Adding an SSH transport changes what this host can do, so the providers are rebuilt: a host that had no executor at all (remote, no SSH) gains one, and paths gain the richer SFTP leg.

Does not require the optional ssh extra: provisioning runs entirely over the middleware API and opens no SSH connection. The one exception is passing private_key= for a key the host does not already know, where the public half has to be derived locally (see :func:_public_key). Using the SSH transport this configures still needs the extra, as it always did.

Source code in src/pytruenas/host.py
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
def install_sshcreds(
    self, name: "str | None" = None, private_key: "str | None" = None
):
    """Provision an SSH keypair and wire it into this host's SSH transport.

    Generates (or reuses) a ``SSH_KEY_PAIR`` keychain credential, installs
    the public half on root's ``authorized_keys``, and stores the private
    half on :attr:`TrueNASConfig.ssh` as a real
    :class:`hostctl.host.SshConfig` -- with ``client_keys`` as an actual
    field rather than the ``"client_keys|root"`` string that the pre-hostctl
    client packed into a username.

    Adding an SSH transport changes what this host can do, so the providers
    are rebuilt: a host that had no executor at all (remote, no SSH) gains
    one, and paths gain the richer SFTP leg.

    Does **not** require the optional ``ssh`` extra: provisioning runs
    entirely over the middleware API and opens no SSH connection. The one
    exception is passing ``private_key=`` for a key the host does not
    already know, where the public half has to be derived locally (see
    :func:`_public_key`). *Using* the SSH transport this configures still
    needs the extra, as it always did.
    """
    name = name or "pytruenas"
    keypair = self.api.keychaincredential._get(type="SSH_KEY_PAIR", name=name)
    # The middleware hands back BOTH halves on the paths it generates or
    # stores, so the public key is usually already known -- see `_public_key`
    # for why that matters.
    pubkey: "str | None" = None
    if not keypair and not private_key:
        generated = self.api.keychaincredential.generate_ssh_key_pair()
        private_key = generated["private_key"]
        pubkey = generated.get("public_key")
    elif not private_key:
        attributes = keypair["attributes"]
        private_key = attributes["private_key"]
        pubkey = attributes.get("public_key")

    pubkey = (pubkey or "").strip() or _public_key(_ty.cast(str, private_key))
    keypair = self.api.keychaincredential._upsert(
        ("name", "type"),
        type="SSH_KEY_PAIR",
        name=name,
        attributes={"private_key": private_key, "public_key": pubkey},
    )
    root = self.api.user._get(username="root")
    authorized = (root.get("sshpubkey") or "").splitlines()
    if pubkey not in authorized:
        authorized.append(pubkey)
        # `("username",)` -- a one-item SEQUENCE, not the bare string. A
        # bare `str` selector is read as a record *id* (see
        # `DbAction.execute`); the tuple says "match on this field name",
        # which is what selecting root by username means.
        self.api.user._upsert(
            ("username",), username="root", sshpubkey="\n".join(authorized)
        )

    private_key = _ty.cast(str, keypair["attributes"]["private_key"])

    from hostctl.host import SshConfig

    # A local target has no host to SSH *to*, and needs none -- commands
    # already run here. The keypair is still provisioned (it is installed
    # on root's authorized_keys, so other machines can use it), but there
    # is no leg to wire it into.
    if self._config.is_local:
        return private_key

    existing = self._config.ssh
    if existing is None:
        self._config.ssh = SshConfig(
            host=self._config.host,
            username="root",
            client_keys=[private_key.encode()],
        )
    elif not existing.password and not existing.client_keys:
        existing.client_keys = [private_key.encode()]
    else:
        # Explicit credentials win: a caller who configured their own auth
        # is not silently overridden by a provisioning call.
        return private_key

    executors, paths = self._build_providers(self._config)
    from hostctl.provider import ProviderSelector

    self._executor_selector = ProviderSelector(executors)
    self._path_selector = ProviderSelector(paths)
    return private_key

login(creds=None, *, login_ex=False, login_options=None, otp_provider=None)

Open a fresh connection and authenticate.

By default uses the legacy auth.login/login_with_* path. Pass login_ex=True for the modern mechanism, which supports 2FA via an OTP_REQUIRED continuation: the OTP comes from the credential's own otp_token if set, else from otp_provider(). A credential with no login_ex form (e.g. local-socket auth) falls back automatically.

Source code in src/pytruenas/host.py
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
def login(
    self,
    creds: "_auth.Credentials | None" = None,
    *,
    login_ex: bool = False,
    login_options: "dict | None" = None,
    otp_provider: "_ty.Callable[[], str] | None" = None,
):
    """Open a fresh connection and authenticate.

    By default uses the legacy ``auth.login``/``login_with_*`` path. Pass
    ``login_ex=True`` for the modern mechanism, which supports 2FA via an
    ``OTP_REQUIRED`` continuation: the OTP comes from the credential's own
    ``otp_token`` if set, else from ``otp_provider()``. A credential with no
    login_ex form (e.g. local-socket auth) falls back automatically.
    """
    if self._conn and not self._conn._closed.is_set():
        try:
            self._conn.close()
        except Exception:
            pass
    self._conn = self._openwss()
    creds = creds or _ty.cast(_auth.Credentials, self._config.credentials)
    if login_ex:
        return creds.login_ex(
            _ty.cast(_ty.Any, self),
            login_options=login_options,
            otp_provider=otp_provider,
        )
    creds.login(_ty.cast(_ty.Any, self))

logout()

End the current session (auth.logout).

Source code in src/pytruenas/host.py
989
990
991
def logout(self) -> None:
    """End the current session (``auth.logout``)."""
    self.api.auth.logout()

me()

The current session's authenticated user (auth.me).

Source code in src/pytruenas/host.py
985
986
987
def me(self) -> dict:
    """The current session's authenticated user (``auth.me``)."""
    return _ty.cast(dict, self.api.auth.me())

ping()

Round-trip the middleware (core.ping -> "pong").

Source code in src/pytruenas/host.py
993
994
995
def ping(self) -> str:
    """Round-trip the middleware (``core.ping`` -> ``"pong"``)."""
    return _ty.cast(str, self.api.core.ping())

subscribe(event, callback=None, *, maxsize=_connection.DEFAULT_EVENT_QUEUE_SIZE)

Subscribe to a middleware event; return a Subscription.

host.subscribe("alert.list") is shorthand for host.api.alert.list.subscribe(). A subscription is bound to the current websocket and does not survive a reconnect -- the events() iterator ending is that signal.

Source code in src/pytruenas/host.py
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
def subscribe(
    self,
    event: str,
    callback: "_ty.Callable[..., object] | None" = None,
    *,
    maxsize: int = _connection.DEFAULT_EVENT_QUEUE_SIZE,
):
    """Subscribe to a middleware event; return a ``Subscription``.

    ``host.subscribe("alert.list")`` is shorthand for
    ``host.api.alert.list.subscribe()``. A subscription is bound to the
    current websocket and does **not** survive a reconnect -- the
    ``events()`` iterator ending is that signal.
    """
    return self.conn.subscribe(event, callback, maxsize=maxsize)

upload(file, method, *params, token=None, wait=True, **kwargs)

Upload file via /_upload, then call method with it.

Source code in src/pytruenas/host.py
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
def upload(
    self, file: "str | bytes", method: str, *params, token=None, wait=True, **kwargs
):
    """Upload ``file`` via ``/_upload``, then call ``method`` with it."""
    target = self._http_target("/_upload")
    data = {"method": method, "params": params}
    if isinstance(file, str):
        file = file.encode()

    if not token:
        token = self.api.auth.generate_token(5, {}, False, **kwargs)

    resp = _req.post(
        target.uri,
        headers={"Authorization": f"Token {token}"},
        verify=self._config.sslverify,
        files={"data": _js.dumps(data).encode(), "file": file},
    )
    jobid = resp.json()["job_id"]
    if wait:
        self.api.core.job_wait(jobid, job=True, _timeout=None)
    return jobid