Source code for saltext.uci_ubus.proxy.ubus_jsonrpc

"""
Salt proxy module for OpenWrt devices via ubus JSON-RPC.

Connects to the device's uhttpd JSON-RPC endpoint and provides
OpenWrt configuration management through the ubus API.

.. code-block:: yaml

    # /srv/salt/pillar/router.sls
    proxy:
      proxytype: uci_ubus_jsonrpc
      host: 10.35.24.1
      password: secret
      # username: salt-agent      (default)
      # scheme: https             (auto-detected by default: tries plain
      # port: 443                  HTTP on 80 first, falls back to HTTPS on
      #                            443 — set both explicitly to skip probing,
      #                            e.g. for a device behind Caddy/ACME)
      # verify_ssl: false         (default)
      # timeout: 30               (default, HTTP request timeout)
      # session_timeout: 300      (default, rpcd session lifetime)
      # rpcd_timeout: 300         (default, rpcd ubus invoke timeout)
"""

import logging
import time
import urllib.error

from saltext.uci_ubus._internal.rpc import UbusRpcClient

log = logging.getLogger(__name__)

__virtualname__ = "uci_ubus_jsonrpc"
__proxyenabled__ = ["uci_ubus_jsonrpc"]

DETAILS = {}

# rpcd session timeout: how long the login session lives. Staged UCI
# changes are scoped to the session and are lost when it expires.
DEFAULT_SESSION_TIMEOUT = 300

# rpcd ubus invoke timeout: how long rpcd waits for a ubus call to
# return (rpcd.@rpcd[0].timeout UCI setting). Calls like uci apply
# trigger service reloads and can take a while on slow devices.
DEFAULT_RPCD_TIMEOUT = 300


def __virtual__():
    return __virtualname__


[docs] def init(opts): """Create JSON-RPC client from proxy pillar and authenticate.""" DETAILS.clear() proxy_conf = opts["proxy"] for key in ("host", "password"): if key not in proxy_conf: raise ValueError(f"uci_ubus_jsonrpc: required pillar key '{key}' is missing") session_timeout = proxy_conf.get("session_timeout", DEFAULT_SESSION_TIMEOUT) rpcd_timeout = proxy_conf.get("rpcd_timeout", DEFAULT_RPCD_TIMEOUT) client = UbusRpcClient( host=proxy_conf["host"], username=proxy_conf.get("username", "salt-agent"), password=proxy_conf["password"], # scheme/port default to None (auto-detect: plain HTTP on 80 first, # falling back to HTTPS on 443 — see UbusRpcClient). Set both # explicitly in pillar only to skip probing for a nonstandard setup. port=proxy_conf.get("port"), scheme=proxy_conf.get("scheme"), verify_ssl=proxy_conf.get("verify_ssl", False), # Set when a reverse proxy in front of the device (e.g. Caddy with a # per-domain ACME cert) routes by a specific name that doesn't match # `host` — needed for both TLS SNI and the HTTP Host header. server_name=proxy_conf.get("server_name"), timeout=proxy_conf.get("timeout", 30), session_timeout=session_timeout, ) client.login() if client.session_timeout < session_timeout: log.warning( "rpcd granted session timeout of %ds (requested %ds). Staged UCI changes may be lost between state runs.", client.session_timeout, session_timeout, ) _ensure_rpcd_timeout(client, rpcd_timeout) DETAILS["client"] = client DETAILS["grains_cache"] = _fetch_grains(client) DETAILS["initialized"] = True log.info("uci_ubus_jsonrpc proxy initialized for %s", proxy_conf["host"])
[docs] def alive(opts): # pylint: disable=unused-argument """Return True if the proxy was initialized and no transport error has occurred. This is a flag-based check (no network I/O). Transport errors in ``call()`` or ``ping()`` flip ``initialized`` to False so that Salt triggers ``init()`` on the next cycle. """ return DETAILS.get("initialized", False)
[docs] def ping(): """Return True if the device responds to a system.board call.""" try: DETAILS["client"].call("system", "board") return True except (urllib.error.URLError, TimeoutError, OSError) as exc: log.warning("Transport error during ping: %s", exc) DETAILS["initialized"] = False return False except Exception: # pylint: disable=broad-exception-caught return False
[docs] def shutdown(opts): # pylint: disable=unused-argument """Clean up proxy state.""" DETAILS.clear() log.info("uci_ubus_jsonrpc proxy shut down")
[docs] def grains(): """Return cached device grains.""" return DETAILS.get("grains_cache", {})
[docs] def grains_refresh(): """Re-fetch grains from the device.""" if "client" in DETAILS: DETAILS["grains_cache"] = _fetch_grains(DETAILS["client"]) return grains()
[docs] def call(ubus_object, ubus_method, params=None): """Forward a ubus call through the proxy's RPC client.""" try: return DETAILS["client"].call(ubus_object, ubus_method, params) except (urllib.error.URLError, TimeoutError, OSError) as exc: log.warning("Transport error during ubus call %s.%s: %s", ubus_object, ubus_method, exc) DETAILS["initialized"] = False raise
def _ensure_rpcd_timeout(client, desired): """Ensure rpcd's ubus invoke timeout is at least ``desired`` seconds. The ``rpcd.@rpcd[0].timeout`` UCI setting controls how long rpcd waits for a ubus call to complete. Operations like ``uci apply`` trigger service reloads that can exceed the default 30s on slow devices. This reads the current value and bumps it if needed, then reloads rpcd and re-authenticates. """ try: rpcd_conf = client.call("uci", "get", {"config": "rpcd", "type": "rpcd"}) except Exception: # pylint: disable=broad-exception-caught log.debug("Could not read rpcd config, skipping invoke timeout check") return # Find the first rpcd-type section values = rpcd_conf.get("values", {}) section_name = None current_timeout = None for name, data in values.items(): if data.get(".type") == "rpcd": section_name = name try: current_timeout = int(data.get("timeout", 30)) except (ValueError, TypeError): current_timeout = 30 break if section_name is None or current_timeout is None: return if current_timeout >= desired: return log.info( "rpcd invoke timeout is %ds (need %ds), updating", current_timeout, desired, ) try: client.call( "uci", "set", { "config": "rpcd", "section": section_name, "values": {"timeout": str(desired)}, }, ) client.call("uci", "commit", {"config": "rpcd"}) client.call("uci", "reload_config") except Exception: # pylint: disable=broad-exception-caught # reload_config restarts rpcd, which kills our connection pass time.sleep(2) client.login() log.info("rpcd invoke timeout updated to %ds", desired) def _fetch_grains(client): """Build grains dict from system.board and system.info responses.""" grains_data = {} try: board = client.call("system", "board") if board: release = board.get("release", {}) grains_data["os"] = release.get("distribution", "OpenWrt") grains_data["os_family"] = "OpenWrt" grains_data["osrelease"] = release.get("version", "") grains_data["oscodename"] = release.get("revision", "") grains_data["osfullname"] = release.get("description", "") grains_data["kernel"] = board.get("kernel", "") grains_data["kernelrelease"] = board.get("kernel", "") grains_data["model"] = board.get("model", "") grains_data["board_name"] = board.get("board_name", "") if board.get("system"): grains_data["cpuarch"] = board["system"] hostname = board.get("hostname", "") if hostname: grains_data["host"] = hostname grains_data["nodename"] = hostname grains_data["fqdn"] = hostname if "." in hostname: parts = hostname.split(".", 1) grains_data["domain"] = parts[1] except Exception as exc: # pylint: disable=broad-exception-caught log.warning("Failed to fetch system.board grains: %s", exc) try: info = client.call("system", "info") if info: grains_data["mem_total"] = info.get("memory", {}).get("total", 0) // 1024 grains_data["uptime"] = info.get("uptime", 0) except Exception as exc: # pylint: disable=broad-exception-caught log.warning("Failed to fetch system.info grains: %s", exc) return grains_data