banshee is under active development: collectors will keep changing, and a version you publish will likely need adjustments. For stable use, wait for version 1.0.0.

Services

banshee/services-collection/services/borg/ssh/

archives

For each borg repository a server backs up to, whether it reads and the age of its newest archive, by SSH, without taking a lock.

  • services
  • backup
  • borg
  • borgbackup
  • archives
  • repository
  • ssh

by banshee1 installation2 versions

Keen 1.0.0 · 117 lines
// The borg repositories a server backs up to, by SSH and borg on that server: for each repository, whether
// borg reads it, and the age of its newest archive. A backup that failed leaves no archive: the age grows, and
// says it. Each repository is given by its location and by the file, on the server, that holds its passphrase:
// borg reads that file itself, through BORG_PASSCOMMAND set to "cat <file>", which it runs without a shell. No
// secret leaves the server, none is written on a command line. borg runs with --bypass-lock, so that it never
// writes a lock in the repository nor waits for a backup running.
need('protocol:ssh');
$repositories = variable('borg:repositories', 'multiline', array(
    'label' => 'Repositories',
    'help'  => 'One repository per line: its location, a space, and the path, on the server, of the file that holds its passphrase, for instance ssh://backup@nas.example/srv/borg/www /etc/borg/nas.passphrase.',
));

per('repository', 'Repositories');
output('status', 'text', array('label' => 'Repository read', 'per' => 'repository'));
output('age', 'number', array('label' => 'Age of the newest archive', 'unit' => 'h', 'per' => 'repository'));

watch.expect('unreadable', 'Repository unreadable or empty', 'status', array('critical' => array('ok')));
watch.above('old', 'Newest archive too old', 'age', array('warning' => 30, 'critical' => 54));
watch.missing('missing', 'Repository missing', 'status', array('enabled' => false));

// The seconds from 1 January 2000 to a date and time, all in the same local time: two of them subtract into
// a duration, off by an hour at most across a change of summer time.
function local_seconds($year, $month, $day, $hour, $minute, $second) {
    $before = array(0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334);
    $days = $before[$month - 1] + $day - 1;
    if ($month > 2 && $year % 4 == 0 && ($year % 100 != 0 || $year % 400 == 0)) {
        $days = $days + 1;
    }
    for ($y = 2000; $y < $year; $y = $y + 1) {
        $days = $days + 365;
        if ($y % 4 == 0 && ($y % 100 != 0 || $y % 400 == 0)) {
            $days = $days + 1;
        }
    }
    return $days * 86400 + $hour * 3600 + $minute * 60 + $second;
}

// The seconds since 1 January 1970 UTC of a time written 2026-10-26T01:00:03.123456+01:00 (or Z, or +0100);
// '' when the text is not such a time.
function epoch_of($time) {
    $m = text.match($time, '^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?(Z|([+-])([0-9]{2}):?([0-9]{2}))$');
    if (list.count($m) == 0) {
        return '';
    }
    // 946684800: the seconds from 1970 to 2000. Each part is turned into a number, so that it compares as one.
    $seconds = local_seconds($m[1] + 0, $m[2] + 0, $m[3] + 0, $m[4] + 0, $m[5] + 0, $m[6] + 0) + 946684800;
    if ($m[8] != 'Z') {
        $shift = $m[10] * 3600 + $m[11] * 60;
        if ($m[9] == '+') {
            $seconds = $seconds - $shift;
        } else {
            $seconds = $seconds + $shift;
        }
    }
    return $seconds;
}

// borg writes its times in the local time of the server, without their offset: the server's offset now, +0100,
// is put on them. An archive made before a change of summer time is then off by an hour.
$zone = ssh.run('date +%z');
$offset = text.trim($zone['output']);
if (list.count(text.match($offset, '^[+-][0-9]{4}$')) == 0) {
    fail('misconfigured', 'date +%z did not give the offset of the server', $zone['output'] . $zone['errors']);
}

// A repository is named by its location.
$now = time.now();
$seen = array();
foreach (text.lines($repositories) as $line) {
    $fields = text.words($line);
    if (list.count($fields) != 2) {
        fail('misconfigured', 'Each line of the repositories must give a location and a passphrase file', $line);
    }
    $name = $fields[0];
    // The passphrase file is written into BORG_PASSCOMMAND, which the shell of the server reads: it may only be
    // an absolute path of letters, digits and . _ / -.
    if (list.count(text.match($fields[1], '^/[A-Za-z0-9_./-]+$')) == 0) {
        fail('misconfigured', 'A passphrase file must be an absolute path of letters, digits and . _ / -', $fields[1]);
    }
    if (list.has($seen, $name)) {
        continue;
    }
    $seen[$name] = true;

    // The newest archive: {"archives": [{"name": "www-2026-10-26", "id": "…", "start": "2026-10-26T01:00:03.000000"}],
    // "repository": {…}, "encryption": {…}}. The location goes as an argument, quoted apart.
    $read = ssh.run('BORG_PASSCOMMAND=\'cat ' . $fields[1] . '\' borg list --json --bypass-lock --last 1',
        arguments=array($name));

    // Exit 1 is a warning, and the list is still given. borg 1.4 exits 52 for a wrong passphrase and 13 for a
    // repository that does not exist; the older ones exit 2, and say it.
    if ($read['exit'] != 0 && $read['exit'] != 1) {
        $status = 'unreadable';
        if ($read['exit'] == 52 || text.contains($read['errors'], 'passphrase')) {
            $status = 'wrong-passphrase';
        } elseif ($read['exit'] == 13 || text.contains($read['errors'], 'does not exist')) {
            $status = 'not-found';
        }
        value('status', $status, $name);
        continue;
    }

    $newest = '';
    $list = json.read(text.trim($read['output']));
    foreach (list.get($list, 'archives', array()) as $archive) {
        $time = epoch_of(list.get($archive, 'start', '') . $offset);
        if ($time != '' && ($newest == '' || $time > $newest)) {
            $newest = $time;
        }
    }
    if ($newest == '') {
        value('status', 'empty', $name);
        continue;
    }
    value('status', 'ok', $name);
    value('age', number.round(($now - $newest) / 3600, 1), $name);
}