Remote

The scrippy-remote module executes commands on remote hosts and transfers files, over SSH/SFTP, FTP/FTPS and CIFS.

Table of content

Extending disk_watch.py

disk_watch.py checks the filesystems of the host it runs on, and keeps its reports in a local directory nobody else can see. With scrippy-remote, version 1.2.0:

  • checks the filesystems of remote hosts over SSH,
  • synchronizes its report directory with a central server over SFTP: New reports are sent, reports deleted locally are deleted remotely,
  • purges the reports older than a retention period, locally and therefore remotely.

The configuration file

Add the SSH settings to /home/luiggi/.local/share/scrippy/conf/disk_watch.conf. The [publish] section is optional: Without it, the reports are neither purged nor published. Its retention key is optional too (default: 30 days).

[remote]
user = scrippy
key = /home/luiggi/.ssh/scrippy_rsa

[publish]
host = backup
dir = /srv/reports/disk_watch
retention = 30

The scrippy user must exist on the remote hosts, with the public key matching /home/luiggi/.ssh/scrippy_rsa in its ~/.ssh/authorized_keys file. The remote hosts must be known: Connect once with the ssh command to add them to ~/.ssh/known_hosts.

The code

Starting from the version 1.1.0 of disk_watch.py, the header declares the new configuration keys and the --host option:

    @conf:remote|user|str|false
    @conf:remote|key|str|false
    @args:host|str|Remote hosts to check over SSH (default: local host)|+||||false|false

The module is imported, after scrippy_core, and the name pattern of the report files is defined:

import shlex
import scrippy_core
from scrippy_core import logger, ScrippyCoreError
from scrippy_template import template
from scrippy_remote import ScrippyRemoteError
from scrippy_remote.ssh import Ssh
from scrippy_remote.local import list_local_dir, delete_local_file
REPORT_PATTERN = r"^disk_watch_\d{8}_\d{6}\.(txt|csv|html)$"

get_usage() is renamed get_local_usage(), and the new get_remote_usage() function runs df -P on a remote host through an open Ssh connection:

def get_remote_usage(ssh, mount_point):
  logger.debug(f"[+] Checking {ssh.host}:{mount_point}")
  result = ssh.exec(command=f"df -P {shlex.quote(mount_point)}", exit_on_error=False)
  if result["exit_code"] != 0:
    raise OSError(" ".join(result["stderr"]) or f"df exit code {result['exit_code']}")
  # Filesystem 1024-blocks Used Available Capacity Mounted on
  fields = result["stdout"][-1].split()
  return round(int(fields[2]) * 100 / int(fields[1]), 1)

check() receives the function to use to get the usage of a mount point, and a prefix for the names of the mount points:

def check(mount_points, warning, critical, get_usage=get_local_usage, prefix=""):
  results = []
  for mount_point in mount_points:
    name = f"{prefix}{mount_point}"
    try:
      usage = get_usage(mount_point)
    except OSError as err:
      logger.error(f" '-> {name}: {err.strerror or err}")
      results.append((name, None, UNKNOWN))
      continue
    if usage >= critical:
      logger.error(f" '-> {name}: {usage}% [CRITICAL]")
      status = CRITICAL
    elif usage >= warning:
      logger.warning(f" '-> {name}: {usage}% [WARNING]")
      status = WARNING
    else:
      logger.info(f" '-> {name}: {usage}% [OK]")
      status = OK
    results.append((name, usage, status))
  return results

The new check_remote_hosts() function opens an SSH connection to each host and checks its mount points. An unreachable host does not stop the script: Its mount points get the UNKNOWN status:

def check_remote_hosts(hosts, mount_points, warning, critical, config):
  results = []
  for host in hosts:
    logger.info(f"[+] Checking remote host: {host}")
    try:
      with Ssh(username=config.get("remote", "user"),
               host=host,
               key=config.get("remote", "key"),
               missing_host_key_policy="reject") as ssh:
        results += check(mount_points, warning, critical,
                         get_usage=lambda mount_point: get_remote_usage(ssh, mount_point),
                         prefix=f"{host}:")
    except ScrippyRemoteError as err:
      logger.error(f" '-> {host}: {err}")
      results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
  return results

The new purge_reports() function deletes the local reports older than the retention period. The date of a report is part of its name (disk_watch_20261008_191314.csv), so comparing names is enough:

def purge_reports(report_dir, retention):
  """Deletes the local reports older than ``retention`` days (the report date is part of its name)."""
  limit = f"disk_watch_{datetime.datetime.now() - datetime.timedelta(days=retention):%Y%m%d_%H%M%S}"
  logger.info(f"[+] Purging reports older than {retention} days")
  for name in list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN):
    if name < limit:
      logger.info(f" '-> Deleting {name}")
      delete_local_file(local_file=os.path.join(report_dir, name))

The new sync_reports() function makes the remote directory a copy of the local one. It compares the list of the local reports with the list of the remote reports, then sends the missing ones and deletes the ones that no longer exist locally. Only the files matching REPORT_PATTERN are considered on both sides: Other files of the remote directory are left untouched.

def sync_reports(report_dir, config):
  """Synchronizes the local reports to the publication host: Missing reports are sent, deleted reports are deleted."""
  host = config.get("publish", "host")
  remote_dir = f"{config.get('publish', 'dir')}/{socket.gethostname()}"
  logger.info(f"[+] Synchronizing reports to {host}:{remote_dir}")
  with Ssh(username=config.get("remote", "user"),
           host=host,
           key=config.get("remote", "key"),
           missing_host_key_policy="reject") as ssh:
    ssh.create_remote_dir(remote_dir=remote_dir, create_dirs=True)
    local_reports = set(list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN))
    remote_reports = set(ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=REPORT_PATTERN))
    for name in sorted(local_reports - remote_reports):
      logger.info(f" '-> Sending {name}")
      ssh.put_file(local_file=os.path.join(report_dir, name), remote_dir=remote_dir)
    for name in sorted(remote_reports - local_reports):
      logger.info(f" '-> Deleting {name}")
      ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
    logger.info(f" '-> {len(local_reports - remote_reports)} sent, {len(remote_reports - local_reports)} deleted, {len(local_reports)} published")

write_report() now returns the path of the written report, and main() uses the new functions:

    mount_points = get_mount_points(config, args)
    if args.host:
      results = check_remote_hosts(args.host, mount_points, warning, critical, config)
    else:
      results = check(mount_points, warning, critical)
    if args.report:
      report_format = args.format or "text"
      content = render_report(results, report_format, warning, critical, _context.env["templatedir"])
      report = write_report(content, report_format, _context.workspace_path, _context.env["reportdir"])
      if config.has("publish", "host"):
        retention = config.get("publish", "retention", "int") if config.has("publish", "retention") else 30
        purge_reports(os.path.dirname(report), retention)
        sync_reports(os.path.dirname(report), config)

Note: Comparing the lists of names sends each report only once, unlike remote_mirror() which sends every file at each call. See Synchronizing files over SFTP for other strategies.

Complete script
#!/usr/bin/env python3
"""
--------------------------------------------------------------------------------
  @author         : Luiggi Vercotti
  @date           : 2026-10-08
  @version        : 1.2.0
  @description    : Monitor filesystem usage

--------------------------------------------------------------------------------
  Update:
  0.1.0  2026-10-08 - Luiggi Vercotti - cre: Script creation
  0.2.0  2026-10-08 - Luiggi Vercotti - evo: Several mount points, log levels
  0.3.0  2026-10-08 - Luiggi Vercotti - evo: Monitoring exit status
  0.4.0  2026-10-08 - Luiggi Vercotti - evo: Configuration file
  0.5.0  2026-10-08 - Luiggi Vercotti - evo: Command line options
  0.6.0  2026-10-08 - Luiggi Vercotti - evo: Alerting API token
  0.7.0  2026-10-08 - Luiggi Vercotti - evo: Report file
  0.8.0  2026-10-08 - Luiggi Vercotti - evo: Error handling
  0.9.0  2026-10-08 - Luiggi Vercotti - evo: Access control
  1.0.0  2026-10-08 - Luiggi Vercotti - evo: Concurrent executions
  1.1.0  2026-10-08 - Luiggi Vercotti - evo: Report templates
  1.2.0  2026-10-08 - Luiggi Vercotti - evo: Remote hosts, reports synchronization

--------------------------------------------------------------------------------
  List of authorized users or group:
    @group:ops

--------------------------------------------------------------------------------
  Concurrent executions:
    @max_instance: 1
    @exit_on_wait: true

--------------------------------------------------------------------------------
  List of mandatory configuration parameters:
    @conf:filesystems|mount_points|str|false
    @conf:thresholds|warning|int|false
    @conf:thresholds|critical|int|false
    @conf:alerting|token|str|true
    @conf:remote|user|str|false
    @conf:remote|key|str|false

--------------------------------------------------------------------------------
  List of execution options and argument parameters:
    @args:host|str|Remote hosts to check over SSH (default: local host)|+||||false|false
    @args:mount-point|str|Mount points to check (default: from configuration)|+||exclude||false|false
    @args:exclude|str|Mount points to skip|+||||false|false
    @args:warning|int|Warning threshold in percent (default: from configuration)|1||||false|false
    @args:critical|int|Critical threshold in percent (default: from configuration)|1||||false|false
    @args:token|str|Alerting API token (default: from configuration)|1||||false|true
    @args:report|bool|Write a report into the report directory||false|||false|false
    @args:format|choice|Report format (default: text)|1|text,csv,html||report|false|false

--------------------------------------------------------------------------------
  Functioning:
  ---------------
    Checks the usage of each mount point against a warning and a critical
    threshold and exits with a monitoring compatible exit status:
      0: OK, 1: WARNING, 2: CRITICAL, 3: UNKNOWN (a mount point can not be checked)

    Configuration:
      - filesystems::mount_points: Comma separated list of mount points
      - thresholds::warning: Warning threshold in percent
      - thresholds::critical: Critical threshold in percent
      - alerting::token: Alerting API token (secret)
      - remote::user: SSH user name on the remote hosts
      - remote::key: SSH private key file
      - publish::host, publish::dir: Optional. Remote host and directory where
        the report directory is synchronized over SFTP (same user and key as
        remote hosts), in a subdirectory named after the local host
      - publish::retention: Optional. Number of days the reports are kept
        locally and on the publication host (default: 30)

    Templates:
      - <templatedir>/disk_watch/report.<format>.j2
"""
# ------------------------------------------------------------------------------
#  Initialization of the environment
# ------------------------------------------------------------------------------
import os
import sys
import shutil
import datetime
import shlex
import socket
import scrippy_core
from scrippy_core import logger, ScrippyCoreError
from scrippy_template import template
from scrippy_remote import ScrippyRemoteError
from scrippy_remote.ssh import Ssh
from scrippy_remote.local import list_local_dir, delete_local_file

OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3
STATUS = {OK: "OK", WARNING: "WARNING", CRITICAL: "CRITICAL", UNKNOWN: "UNKNOWN"}
EXTENSIONS = {"text": "txt", "csv": "csv", "html": "html"}
REPORT_PATTERN = r"^disk_watch_\d{8}_\d{6}\.(txt|csv|html)$"

# ------------------------------------------------------------------------------
#  Definition of functions and classes
# ------------------------------------------------------------------------------
def get_mount_points(config, args):
  if args.mount_point:
    return args.mount_point
  mount_points = [mp.strip() for mp in config.get("filesystems", "mount_points").split(",")]
  return [mp for mp in mount_points if mp not in (args.exclude or [])]

def get_thresholds(config, args):
  warning = args.warning if args.warning is not None else config.get("thresholds", "warning", "int")
  critical = args.critical if args.critical is not None else config.get("thresholds", "critical", "int")
  if not 0 &lt; warning &lt; critical &lt;= 100:
    raise ScrippyCoreError(f"Invalid thresholds: warning={warning}, critical={critical}")
  return warning, critical

def get_local_usage(mount_point):
  logger.debug(f"[+] Checking {mount_point}")
  usage = shutil.disk_usage(mount_point)
  return round(usage.used * 100 / usage.total, 1)

def get_remote_usage(ssh, mount_point):
  logger.debug(f"[+] Checking {ssh.host}:{mount_point}")
  result = ssh.exec(command=f"df -P {shlex.quote(mount_point)}", exit_on_error=False)
  if result["exit_code"] != 0:
    raise OSError(" ".join(result["stderr"]) or f"df exit code {result['exit_code']}")
  # Filesystem 1024-blocks Used Available Capacity Mounted on
  fields = result["stdout"][-1].split()
  return round(int(fields[2]) * 100 / int(fields[1]), 1)

def check(mount_points, warning, critical, get_usage=get_local_usage, prefix=""):
  results = []
  for mount_point in mount_points:
    name = f"{prefix}{mount_point}"
    try:
      usage = get_usage(mount_point)
    except OSError as err:
      logger.error(f" '-&gt; {name}: {err.strerror or err}")
      results.append((name, None, UNKNOWN))
      continue
    if usage &gt;= critical:
      logger.error(f" '-&gt; {name}: {usage}% [CRITICAL]")
      status = CRITICAL
    elif usage &gt;= warning:
      logger.warning(f" '-&gt; {name}: {usage}% [WARNING]")
      status = WARNING
    else:
      logger.info(f" '-&gt; {name}: {usage}% [OK]")
      status = OK
    results.append((name, usage, status))
  return results

def check_remote_hosts(hosts, mount_points, warning, critical, config):
  results = []
  for host in hosts:
    logger.info(f"[+] Checking remote host: {host}")
    try:
      with Ssh(username=config.get("remote", "user"),
               host=host,
               key=config.get("remote", "key"),
               missing_host_key_policy="reject") as ssh:
        results += check(mount_points, warning, critical,
                         get_usage=lambda mount_point: get_remote_usage(ssh, mount_point),
                         prefix=f"{host}:")
    except ScrippyRemoteError as err:
      logger.error(f" '-&gt; {host}: {err}")
      results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
  return results

def purge_reports(report_dir, retention):
  """Deletes the local reports older than ``retention`` days (the report date is part of its name)."""
  limit = f"disk_watch_{datetime.datetime.now() - datetime.timedelta(days=retention):%Y%m%d_%H%M%S}"
  logger.info(f"[+] Purging reports older than {retention} days")
  for name in list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN):
    if name &lt; limit:
      logger.info(f" '-&gt; Deleting {name}")
      delete_local_file(local_file=os.path.join(report_dir, name))

def sync_reports(report_dir, config):
  """Synchronizes the local reports to the publication host: Missing reports are sent, deleted reports are deleted."""
  host = config.get("publish", "host")
  remote_dir = f"{config.get('publish', 'dir')}/{socket.gethostname()}"
  logger.info(f"[+] Synchronizing reports to {host}:{remote_dir}")
  with Ssh(username=config.get("remote", "user"),
           host=host,
           key=config.get("remote", "key"),
           missing_host_key_policy="reject") as ssh:
    ssh.create_remote_dir(remote_dir=remote_dir, create_dirs=True)
    local_reports = set(list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN))
    remote_reports = set(ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=REPORT_PATTERN))
    for name in sorted(local_reports - remote_reports):
      logger.info(f" '-&gt; Sending {name}")
      ssh.put_file(local_file=os.path.join(report_dir, name), remote_dir=remote_dir)
    for name in sorted(remote_reports - local_reports):
      logger.info(f" '-&gt; Deleting {name}")
      ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
    logger.info(f" '-&gt; {len(local_reports - remote_reports)} sent, {len(remote_reports - local_reports)} deleted, {len(local_reports)} published")

def render_report(results, report_format, warning, critical, templatedir):
  params = {"hostname": socket.gethostname(),
            "date": f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
            "warning": warning,
            "critical": critical,
            "status": STATUS[max(status for _, _, status in results)],
            "results": [{"mount_point": mount_point, "usage": usage, "status": STATUS[status]}
                        for mount_point, usage, status in results]}
  renderer = template.Renderer(os.path.join(templatedir, "disk_watch"),
                               f"report.{EXTENSIONS[report_format]}.j2")
  return renderer.render(params)

def write_report(content, report_format, workspace_path, reportdir):
  name = f"disk_watch_{datetime.datetime.now():%Y%m%d_%H%M%S}.{EXTENSIONS[report_format]}"
  tmp_report = os.path.join(workspace_path, name)
  logger.info(f"[+] Writing report: {tmp_report}")
  with open(tmp_report, mode="w", encoding="utf-8") as report:
    report.write(content)
  report_dir = os.path.join(reportdir, "disk_watch")
  os.makedirs(report_dir, exist_ok=True)
  logger.info(f"[+] Publishing report into {report_dir}")
  return shutil.move(tmp_report, os.path.join(report_dir, name))

# ------------------------------------------------------------------------------
#  Main processing
# ------------------------------------------------------------------------------
def main():
  with scrippy_core.ScriptContext(retention=100) as _context:
    config = _context.config
    args = _context.args
    token = args.token or config.get("alerting", "token")
    logger.debug(f"[+] Alerting API token: {token}")
    warning, critical = get_thresholds(config, args)
    logger.log_section("disk_watch", f"Thresholds: warning={warning}% critical={critical}%")
    mount_points = get_mount_points(config, args)
    if args.host:
      results = check_remote_hosts(args.host, mount_points, warning, critical, config)
    else:
      results = check(mount_points, warning, critical)
    if args.report:
      report_format = args.format or "text"
      content = render_report(results, report_format, warning, critical, _context.env["templatedir"])
      report = write_report(content, report_format, _context.workspace_path, _context.env["reportdir"])
      if config.has("publish", "host"):
        retention = config.get("publish", "retention", "int") if config.has("publish", "retention") else 30
        purge_reports(os.path.dirname(report), retention)
        sync_reports(os.path.dirname(report), config)
    status = max(status for _, _, status in results)
    logger.info(f"[+] Status: {STATUS[status]}")
    if status != OK:
      sys.exit(status)

# ------------------------------------------------------------------------------
#  Entry point
# ------------------------------------------------------------------------------
if __name__ == '__main__':
  main()

The result

./disk_watch.py --host srv1 srv2 nowhere --mount-point / /var
[...]
[20261008 19:25:01] [INFO    ] [+] Checking remote host: srv1
[20261008 19:25:01] [INFO    ]  '-> srv1:/: 34.9% [OK]
[20261008 19:25:01] [INFO    ]  '-> srv1:/var: 40.1% [OK]
[20261008 19:25:01] [INFO    ] [+] Checking remote host: srv2
[20261008 19:25:01] [INFO    ]  '-> srv2:/: 50.1% [OK]
[20261008 19:25:01] [WARNING ]  '-> srv2:/var: 85.3% [WARNING]
[20261008 19:25:01] [INFO    ] [+] Checking remote host: nowhere
[20261008 19:25:01] [CRITICAL] [SshConnectionError] gaierror: [Errno -2] Name or service not known
[20261008 19:25:01] [ERROR   ]  '-> nowhere: [SshConnectionError] gaierror: [Errno -2] Name or service not known
[20261008 19:25:01] [INFO    ] [+] Status: UNKNOWN
[...]

With --report, the reports are purged and synchronized:

./disk_watch.py --host srv1 --report --format html
[...]
[20261008 20:53:45] [INFO    ] [+] Purging reports older than 30 days
[20261008 20:53:45] [INFO    ]  '-> Deleting disk_watch_20260824_080000.txt
[20261008 20:53:45] [INFO    ] [+] Synchronizing reports to backup:/srv/reports/disk_watch/srv.flying.circus
[20261008 20:53:45] [INFO    ]  '-> Sending disk_watch_20261006_080000.csv
[20261008 20:53:45] [INFO    ]  '-> Sending disk_watch_20261008_205345.html
[20261008 20:53:45] [INFO    ]  '-> Deleting disk_watch_20261005_080000.txt
[20261008 20:53:45] [INFO    ]  '-> 2 sent, 1 deleted, 2 published
[...]

The next execution only sends its own report:

[20261008 20:53:46] [INFO    ]  '-> Sending disk_watch_20261008_205346.txt
[20261008 20:53:46] [INFO    ]  '-> 1 sent, 0 deleted, 3 published

With --debug, scrippy-remote logs the connections, the commands and their output:

[20261008 19:25:12] [DEBUG   ] Connecting to srv1:22
[20261008 19:25:12] [DEBUG   ] Running command: df -P /
[20261008 19:25:12] [DEBUG   ] Filesystem 1024-blocks Used Available Capacity Mounted on
[20261008 19:25:12] [DEBUG   ] /dev/sda1 20511312 7158448 13352864 35% /

Installation

pip install scrippy-remote

scrippy-remote requires Python 3.11 or later, paramiko and pysmb, installed automatically.

Overview

scrippy-remote provides one class per protocol:

Class Module Protocol
Ssh scrippy_remote.ssh Remote command execution and file transfer over SSH/SFTP
Ftp scrippy_remote.ftp File transfer over FTP, FTPS (implicit TLS) or FTPES (explicit TLS)
Cifs scrippy_remote.cifs File transfer within a CIFS/Samba shared folder

Each class is used as a context manager: The connection is opened when entering the with block and closed when leaving it.

The three classes share the same file and directory operations. In addition, Ssh executes commands with exec() and Cifs reads and writes remote files with open().

from scrippy_remote.ftp import Ftp
from scrippy_remote.ssh import Ssh

with Ftp(host="ftp.flying.circus", username="luiggi", password="dead_parrot") as ftp:
  ftp.get_file(remote_file="sketches/parrot.txt", local_dir=workspace_path)

with Ssh(username="luiggi", host="srv.flying.circus", key="/home/luiggi/.ssh/id_rsa") as ssh:
  ssh.put_file(local_file=os.path.join(workspace_path, "parrot.txt"),
               remote_dir="/home/luiggi/sketches",
               create_dirs=True)
  ssh.exec(command="chmod 640 /home/luiggi/sketches/parrot.txt")

Tip: Download files into the workspace of the script: They are automatically deleted at the end of the execution.

Error handling

All errors raise a scrippy_remote.ScrippyRemoteError. Its message starts with the error category, i.e: [SshTransferError]:

Category Raised by
SshConnectionError, FtpConnectionError, CifsConnectionError Connection and authentication
SshExecError Ssh.exec()
SshTransferError, FtpTransferError, CifsTransferError get_file(), put_file(), Cifs.open()
SshRemoteOSError, FtpRemoteOSError, CifsRemoteOSError Remote directory listing, creation and deletion
SshArgumentError, FtpArgumentError, CifsArgumentError Invalid argument
LocalOSError, LocalArgumentError Local operations

The error is logged as a CRITICAL message by the module before being raised, then handled by the execution context like any other error, or by your script.

The exit_on_error argument

Most methods accept an exit_on_error argument, True by default. When set to False, errors are only logged as warnings and the method goes on: A mirror skips the faulty file, a recursive deletion skips the faulty entry, exec() returns the result of the failed command.

Connection errors, argument errors and Cifs.open() errors are always raised.

from scrippy_remote import ScrippyRemoteError

try:
  with Ssh(username="luiggi", host="srv.flying.circus", key="/home/luiggi/.ssh/id_rsa") as ssh:
    ssh.delete_remote_dir(remote_dir="/home/luiggi/dead", recursive=True)
except ScrippyRemoteError as err:
  logger.error(f"[+] Cleanup failed: {err}")

SSH and SFTP

from scrippy_remote.ssh import Ssh

Ssh(username, host, port=22, password=None, key=None, missing_host_key_policy="warn",
    allow_agent=True, look_for_keys=True, timeout=60)
Parameter Description Default
username The user name
host The remote host
port The TCP port 22
password The password. When key is provided, the passphrase of the key None
key The private key file None
missing_host_key_policy What to do when the remote host is not in ~/.ssh/known_hosts: auto (accept), warn (accept with a warning) or reject (refuse) warn
allow_agent Use the SSH agent True
look_for_keys Search for private keys in ~/.ssh/ True
timeout Waiting time in seconds for the connection and the SFTP operations. Commands run with exec() are not limited 60
  • Hosts whose key changed are always rejected. Use missing_host_key_policy="reject" to also refuse unknown hosts, which is recommended in production.
  • A lost connection is automatically reopened by the next operation.
  • Remote paths are used as is: An absolute path starts from the root, a relative path from the home directory.
  • ssh.client is the underlying paramiko.SSHClient and ssh.sftp the paramiko.SFTPClient, for operations not provided by Ssh.

Tip: Store the password or the key passphrase in the configuration file, declared as secret.

Executing commands

result = ssh.exec(command, exit_on_error=True)

Runs command on the remote host and returns a dictionary:

Key Content
exit_code The exit status of the command, -1 when none was received
stdout The list of the lines of the standard output, stripped, empty lines dropped
stderr The list of the lines of the standard error, stripped, empty lines dropped

The standard output lines are logged in DEBUG level, the standard error lines in ERROR level, as they are received.

A non-zero exit status raises a SshExecError containing the standard error. With exit_on_error=False, it is logged as a warning and the result is returned:

result = ssh.exec(command="systemctl is-active nginx", exit_on_error=False)
if result["exit_code"] != 0:
  logger.warning("[+] nginx is not running")

Warning: The command is run by the remote shell: Quote the values you insert into it, i.e. with shlex.quote() as disk_watch.py does.

FTP and FTPS

from scrippy_remote.ftp import Ftp

Ftp(host, username, password, port=None, tls=True, explicit=True, ssl_verify=True,
    ssl_version=None, timeout=60)
Parameter Description Default
host The remote host
username The user name, anonymous for an anonymous connection
password The password
port The TCP port 990 for implicit TLS, 21 otherwise
tls Encrypt the connection and the transfers. When False, credentials and data are sent in clear text True
explicit True: Explicit TLS (FTPES). False: Implicit TLS (FTPS) True
ssl_verify Verify the server certificate. When False, a warning is logged at each connection True
ssl_version The only TLS version allowed: TLSv1, TLSv1_1, TLSv1_2 or TLSv1_3 The highest version supported by both sides
timeout Waiting time in seconds 60
  • TLS is enabled by default: Use tls=False for a plain FTP server.
  • Transfers use the passive mode.
  • Remote paths are used as is: An absolute path starts from the server root, a relative path from the login directory.
  • list_remote_dir() requires the server to return Unix style listings (ls -l format), which is the case of most servers. Symbolic links are not listed.
with Ftp(host="ftp.flying.circus", username="anonymous", password="", tls=False) as ftp:
  ftp.get_file(remote_file="pub/parrot.txt", local_dir=workspace_path)

CIFS

from scrippy_remote.cifs import Cifs

Cifs(host, shared_folder, username, password, port=445, use_ntlm_v2=True,
     is_direct_tcp=True, timeout=60)
Parameter Description Default
host The remote host
shared_folder The shared folder
username The user name
password The password
port The TCP port 445
use_ntlm_v2 Use NTLMv2 authentication True
is_direct_tcp Use direct TCP (port 445) instead of NetBIOS (port 139) True
timeout Waiting time in seconds 60
  • Remote paths are relative to the shared folder and use / as separator. The shared folder root is /.
  • cifs.client is the underlying pysmb SMBConnection.

Note: CIFS support is functional but not guaranteed over time.

Reading and writing remote files

cifs.open(remote_file, mode)

Opens a remote file in binary mode, for reading (mode="r") or writing (mode="w"), as a context manager. In read mode, the whole file is downloaded when opening it. In write mode, the content is sent when leaving the with block.

with Cifs(host="srv.flying.circus", shared_folder="storage",
          username="luiggi", password="dead_parrot") as cifs:
  with cifs.open(remote_file="luiggi/inquisition.txt", mode="w") as w_file:
    w_file.write(b"Nobody expects the Spanish inquisition")
  with cifs.open(remote_file="luiggi/inquisition.txt", mode="r") as r_file:
    content = r_file.read()

File and directory operations

The following methods are provided by Ssh, Ftp and Cifs, with the same arguments. All of them accept exit_on_error (see Error handling).

Method Description
get_file(remote_file, local_dir, create_dirs=False) Retrieves remote_file into local_dir
put_file(local_file, remote_dir, create_dirs=False) Sends local_file into remote_dir
local_mirror(remote_dir, local_dir) Copies the remote directory tree into local_dir
remote_mirror(local_dir, remote_dir) Copies the local directory tree into remote_dir
list_remote_dir(remote_dir, file_type="f", pattern=".*") Lists the files or directories of remote_dir
create_remote_dir(remote_dir, create_dirs=False) Creates remote_dir
delete_remote_file(remote_file) Deletes remote_file
delete_remote_dir(remote_dir, recursive=False) Deletes remote_dir and its files

get_file()

The file is stored in local_dir, which must exist. With create_dirs=True, the remote file path is reproduced below local_dir, created if needed: /data/parrot.txt is stored as <local_dir>/data/parrot.txt. On failure, no partial local copy is left.

put_file()

The file is stored in remote_dir. With create_dirs=True, the missing directories of remote_dir are created.

local_mirror() and remote_mirror()

The content of the source directory is reproduced as is in the destination directory, empty directories included: <source>/a/b.txt is stored as <destination>/a/b.txt. The destination directory is created if needed.

ssh.local_mirror(remote_dir="/var/www/site", local_dir=os.path.join(workspace_path, "site"))

See Mirrors for their exact behavior.

list_remote_dir()

Returns the names (not full paths) of the entries of remote_dir matching pattern, a regular expression. file_type is f for files (default) or d for directories. It is not recursive.

logs = ssh.list_remote_dir(remote_dir="/var/log/nginx", pattern=r".*\.log$")

create_remote_dir()

The parent directory must exist and remote_dir must not. With create_dirs=True, the missing parent directories are created and an existing remote_dir is accepted, as with mkdir -p.

delete_remote_file() and delete_remote_dir()

delete_remote_dir() deletes the directory and its files. With recursive=False (default), it fails if the directory contains sub directories.

Synchronizing files over SFTP

scrippy-remote does not provide a ready-made synchronization tool such as rsync: It provides the building blocks to write the synchronization that fits each need, with a full control over what is transferred, deleted and logged. This section describes these building blocks and gives tested recipes.

All the examples use an open Ssh connection named ssh. The same methods are available with Ftp and Cifs.

Mirrors

local_mirror() and remote_mirror() copy a whole directory tree in one call:

local_mirror(remote_dir, local_dir) remote_mirror(local_dir, remote_dir)
Direction Remote host to local host Local host to remote host
Destination directory Created if needed Created if needed, with its missing parents
Sub directories Copied recursively, empty ones included Copied recursively, empty ones included
Existing files Overwritten Overwritten
Files only present in the destination Kept Kept
Symbolic links Followed: The target is copied Followed: The target is copied
Errors Stop at the first error, or skipped with exit_on_error=False Stop at the first error, or skipped with exit_on_error=False

A mirror is a copy, not a synchronization:

  • Every file is transferred at each call, whether it changed or not. For a large tree copied often, compare the file lists to transfer only what is needed (see Incremental download).
  • Nothing is ever deleted from the destination. To remove the files deleted from the source, compare the file lists (see the sync_reports() function of disk_watch.py), or copy into an empty directory, such as a new subdirectory of the workspace.
  • File permissions and dates are not preserved.

Comparing file lists

list_remote_dir() and list_local_dir() return file names, not paths, filtered by the same regular expressions: Their results can be compared directly with Python sets.

remote_files = set(ssh.list_remote_dir(remote_dir="/var/log/app", file_type="f", pattern=r"^app-\d{8}\.log$"))
local_files = set(list_local_dir(local_dir=archive_dir, file_type="f", pattern=r"^app-\d{8}\.log$"))
to_download = remote_files - local_files
to_delete = local_files - remote_files

Note: Comparing names does not detect a file whose content changed. This is the right choice for files that are written once and never modified, such as reports, logs or exports with a date in their name. For files that may change, transfer them again, i.e. with a mirror.

Tip: Anchor the patterns (^...$) and make them as specific as possible: A synchronization deletes the files that match the pattern on one side only.

Recipes

The recipes below are functions of a complete Scrippy script, given at the end of this section.

Backup of a remote directory

The remote directory is copied into the workspace, then archived into the report directory. The copy is deleted with the workspace at the end of the execution:

def backup_directory(ssh, remote_dir, workspace_path, reportdir):
  """Copies a remote directory tree and archives it into the report directory."""
  name = f"{ssh.host}_{os.path.basename(remote_dir)}_{datetime.date.today():%Y%m%d}"
  local_dir = os.path.join(workspace_path, name)
  ssh.local_mirror(remote_dir=remote_dir, local_dir=local_dir)
  archive = shutil.make_archive(os.path.join(reportdir, name), "gztar", root_dir=workspace_path, base_dir=name)
  logger.info(f"[+] Backup: {archive}")
  return archive
backup_directory(ssh, "/etc/nginx", _context.workspace_path, _context.env["reportdir"])
[INFO    ] [+] Backup: /home/luiggi/.local/share/scrippy/reports/srv1_nginx_20261008.tar.gz

Deployment of a local directory

The local directory is copied to the remote host, then a command adjusts the permissions, which are not preserved by the copy:

def deploy_directory(ssh, local_dir, remote_dir):
  """Copies a local directory tree to the remote host, then restricts its permissions."""
  ssh.remote_mirror(local_dir=local_dir, remote_dir=remote_dir)
  ssh.exec(command=f"chmod -R go-w {remote_dir}")
deploy_directory(ssh, os.path.join(_context.env["datadir"], "site"), "/var/www/site")

Warning: The files removed from the local directory remain on the remote host. To deploy an exact copy, deploy into a new directory (i.e: /var/www/site-<version>), then switch a symbolic link with ssh.exec().

Incremental download

Only the remote files that are not present locally are retrieved: Running it again transfers nothing.

def download_new_files(ssh, remote_dir, local_dir, pattern):
  """Retrieves the remote files matching the pattern that are not yet in the local directory."""
  remote_files = ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern)
  local_files = list_local_dir(local_dir=local_dir, file_type="f", pattern=pattern)
  new_files = sorted(set(remote_files) - set(local_files))
  for name in new_files:
    ssh.get_file(remote_file=f"{remote_dir}/{name}", local_dir=local_dir)
  logger.info(f"[+] {len(new_files)} new file(s) downloaded")
  return new_files
[INFO    ] [+] 4 new file(s) downloaded
[INFO    ] [+] 0 new file(s) downloaded

Collecting files

The remote files are retrieved, then deleted from the remote host, i.e. to process the exports dropped by another application. A file is only deleted once successfully retrieved: A failed file stays on the remote host and is collected by the next execution.

def collect_files(ssh, remote_dir, local_dir, pattern):
  """Moves the remote files matching the pattern to the local directory: A file is deleted once retrieved."""
  collected = []
  for name in ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern):
    try:
      ssh.get_file(remote_file=f"{remote_dir}/{name}", local_dir=local_dir)
    except ScrippyRemoteError as err:
      # The file stays on the remote host and will be collected by the next execution
      logger.error(f" '-> {name}: {err}")
      continue
    ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
    collected.append(name)
  logger.info(f"[+] {len(collected)} file(s) collected")
  return collected
[INFO    ] [+] 2 file(s) collected

Purging remote files

def purge_remote_files(ssh, remote_dir, pattern):
  """Deletes the remote files matching the pattern, i.e. old logs."""
  for name in ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern):
    ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
purge_remote_files(ssh, "/var/log/app", r"^app-202609\d{2}\.log$")

To delete a whole directory tree, use ssh.delete_remote_dir(remote_dir, recursive=True).

Copying what can be copied

With exit_on_error=False, a mirror skips the files it can not transfer, logs them as warnings and goes on:

def mirror_what_can_be(ssh, remote_dir, local_dir):
  """Copies a remote directory tree, skipping the files that can not be read."""
  ssh.local_mirror(remote_dir=remote_dir, local_dir=local_dir, exit_on_error=False)
[WARNING ] [SshTransferError] PermissionError: [Errno 13] Permission denied

Without exit_on_error=False, the same mirror stops at the unreadable file with a ScrippyRemoteError.

The complete recipes script

sftp_recipes.py
#!/usr/bin/env python3
"""
--------------------------------------------------------------------------------
  @author         : Luiggi Vercotti
  @date           : 2026-10-08
  @version        : 1.0.0
  @description    : SFTP file manipulation recipes

--------------------------------------------------------------------------------
  Update:
  1.0.0  2026-10-08 - Luiggi Vercotti - cre: Script creation

--------------------------------------------------------------------------------
  List of mandatory configuration parameters:
    @conf:remote|user|str|false
    @conf:remote|key|str|false

--------------------------------------------------------------------------------
  List of execution options and argument parameters:
    @args:host|str|Remote host|1||||true|false

--------------------------------------------------------------------------------
  Functioning:
  ---------------
    Runs the SFTP recipes of the scrippy-remote documentation against a host.
"""
import os
import shutil
import datetime
import scrippy_core
from scrippy_core import logger
from scrippy_remote import ScrippyRemoteError
from scrippy_remote.ssh import Ssh
from scrippy_remote.local import list_local_dir

def backup_directory(ssh, remote_dir, workspace_path, reportdir):
  """Copies a remote directory tree and archives it into the report directory."""
  name = f"{ssh.host}_{os.path.basename(remote_dir)}_{datetime.date.today():%Y%m%d}"
  local_dir = os.path.join(workspace_path, name)
  ssh.local_mirror(remote_dir=remote_dir, local_dir=local_dir)
  archive = shutil.make_archive(os.path.join(reportdir, name), "gztar", root_dir=workspace_path, base_dir=name)
  logger.info(f"[+] Backup: {archive}")
  return archive

def deploy_directory(ssh, local_dir, remote_dir):
  """Copies a local directory tree to the remote host, then restricts its permissions."""
  ssh.remote_mirror(local_dir=local_dir, remote_dir=remote_dir)
  ssh.exec(command=f"chmod -R go-w {remote_dir}")

def download_new_files(ssh, remote_dir, local_dir, pattern):
  """Retrieves the remote files matching the pattern that are not yet in the local directory."""
  remote_files = ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern)
  local_files = list_local_dir(local_dir=local_dir, file_type="f", pattern=pattern)
  new_files = sorted(set(remote_files) - set(local_files))
  for name in new_files:
    ssh.get_file(remote_file=f"{remote_dir}/{name}", local_dir=local_dir)
  logger.info(f"[+] {len(new_files)} new file(s) downloaded")
  return new_files

def collect_files(ssh, remote_dir, local_dir, pattern):
  """Moves the remote files matching the pattern to the local directory: A file is deleted once retrieved."""
  collected = []
  for name in ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern):
    try:
      ssh.get_file(remote_file=f"{remote_dir}/{name}", local_dir=local_dir)
    except ScrippyRemoteError as err:
      # The file stays on the remote host and will be collected by the next execution
      logger.error(f" '-&gt; {name}: {err}")
      continue
    ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
    collected.append(name)
  logger.info(f"[+] {len(collected)} file(s) collected")
  return collected

def purge_remote_files(ssh, remote_dir, pattern):
  """Deletes the remote files matching the pattern, i.e. old logs."""
  for name in ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern):
    ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")

def mirror_what_can_be(ssh, remote_dir, local_dir):
  """Copies a remote directory tree, skipping the files that can not be read."""
  ssh.local_mirror(remote_dir=remote_dir, local_dir=local_dir, exit_on_error=False)

def main():
  with scrippy_core.ScriptContext() as _context:
    config = _context.config
    workspace_path = _context.workspace_path
    with Ssh(username=config.get("remote", "user"),
             host=_context.args.host,
             key=config.get("remote", "key"),
             missing_host_key_policy="reject") as ssh:
      backup_directory(ssh, "/etc/nginx", workspace_path, _context.env["reportdir"])
      site = os.path.join(_context.env["datadir"], "site")
      deploy_directory(ssh, site, "/var/www/site")
      logs = os.path.join(workspace_path, "logs")
      os.makedirs(logs)
      download_new_files(ssh, "/var/log/app", logs, r"^app-\d{8}\.log$")
      download_new_files(ssh, "/var/log/app", logs, r"^app-\d{8}\.log$")
      incoming = os.path.join(workspace_path, "incoming")
      os.makedirs(incoming)
      collect_files(ssh, "/data/outgoing", incoming, r".*\.csv$")
      purge_remote_files(ssh, "/var/log/app", r"^app-202609\d{2}\.log$")
      mirror_what_can_be(ssh, "/etc/nginx", os.path.join(workspace_path, "nginx"))
      logger.info(f"[+] Remote logs: {ssh.list_remote_dir(remote_dir='/var/log/app')}")

if __name__ == '__main__':
  main()

Local operations

The scrippy_remote.local module provides the same operations on the local host, without any connection. They are also available as methods of Ssh, Ftp and Cifs.

Function Description
list_local_dir(local_dir, file_type="f", pattern=".*") Lists the files or directories of local_dir, as list_remote_dir()
create_local_dir(local_dir) Creates local_dir and its missing parents
delete_local_file(local_file) Deletes local_file
delete_local_dir(local_dir, recursive=False) Deletes local_dir and its files, as delete_remote_dir()
from scrippy_remote.local import list_local_dir

reports = list_local_dir(local_dir=reportdir, pattern=r"disk_watch_.*\.html$")