summaryrefslogtreecommitdiff
path: root/hosts/vidhar/borg
diff options
context:
space:
mode:
Diffstat (limited to 'hosts/vidhar/borg')
-rw-r--r--hosts/vidhar/borg/append.borgbase26
-rwxr-xr-xhosts/vidhar/borg/copy.py216
-rw-r--r--hosts/vidhar/borg/default.nix82
-rw-r--r--hosts/vidhar/borg/yggdrasil.borgkey26
4 files changed, 347 insertions, 3 deletions
diff --git a/hosts/vidhar/borg/append.borgbase b/hosts/vidhar/borg/append.borgbase
new file mode 100644
index 00000000..d7091871
--- /dev/null
+++ b/hosts/vidhar/borg/append.borgbase
@@ -0,0 +1,26 @@
1{
2 "data": "ENC[AES256_GCM,data:+TKVqaaf8MYqzLgJ4DFYg6CaGmrSkNlGZiK3PNLusVwZhxAk19Xd/ouFEpI4+Gsecah9kyDZgMRM7x4bXz6RhhR9cEyPbm9lIYQz6OGoqW/xMIui1+DwCIfrtByXZ1SQKLz4052+fUwQvGgdG2/i9twF1SPUUA0NxNKyqjVfZpAVia8aJkl7Zu74UzjUPAqaORYAoZcZRVcUDH1cUQpDMzUza3SsDv9/FbnG/ZIify8sKVXaNsNjmuKQ1WEshcSLNcQQzvQIM0jCwpN8KE9C4mj0ANf3JGehoP39ticvzZl2iOCfBwNuL4feWUepBXuRxtn4nbv8OxmQkdJrqR4VwDzgbP3XzpI5zraLIMH093BIQSLbuNXPOHVDh4QxpWrj5+IZYq9ZjUHw3zCg66AGFZRMKPs11N9RVnxp/LpZRMRSYNhzh12ik7YVqyGt32MkDXrSdGFw96ae1PsTSe5V1rY1yevnznwY6OtXj20cneT/CMGFyj9DkcZ6ClT5KDYP1xPwR2D1A56cBDgvPeNPfhPWhRHoYLU90eHkF16N9zLrSBBhKtoNUUbB9K46hiSWaTwdQVWNR2ZLafR3,iv:6eDgcQuPi9Z5tVbr1BEVzCRu5IZC6hYfZtXfuwIKaZQ=,tag:wdYKzdGkaKOv3jHd6TzA9w==,type:str]",
3 "sops": {
4 "kms": null,
5 "gcp_kms": null,
6 "azure_kv": null,
7 "hc_vault": null,
8 "age": null,
9 "lastmodified": "2022-02-17T10:32:27Z",
10 "mac": "ENC[AES256_GCM,data:7i7zPalH3J/VtNGzHrECMHt02WTGOuT5KL+HQT6b5zLCpYyTTBit/HM4xW4hONxnEaEgPpkUr0cTNvsGrrKE0zyj784xLDASvaBcTinkmNvkBEVsB9ACPG3G5YmexvYC9Z3tVFjfLPdQeXgy5nAm6Q8TMANgp+xIQTnc0IGVjS8=,iv:u3UL8XrR6UKEDOWgMa4nzEYl8vmguZmcANmby2kgxWM=,tag:2k7+DKVTtmI7ldk/ktXzbQ==,type:str]",
11 "pgp": [
12 {
13 "created_at": "2022-02-17T10:32:26Z",
14 "enc": "-----BEGIN PGP MESSAGE-----\n\nhF4DbYDvGI0HDr0SAQdArVlDVy8NhAS3QNPoul2iE05B1Su1r8fnmFm4k4+ORX4w\nddhRrT+TAkbPM0Zl1nDyazqJKWlq2DXZ8DZ6qEFAS0bYN0x/QiazIqH4NfWWwa0N\n0l4BunqQtbQNkv0qjqBmkhDnlVNainmEPv4ChXrJc1z6HXYdvv4CkfzwdvEfr5yO\ndpHERmg5O5mX29BnM6LHk6CdFtJS1jN3qZy3oa0KEECoZm8Ak2hlBw/PeEGk3eBM\n=SHc3\n-----END PGP MESSAGE-----\n",
15 "fp": "A1C7C95E6CAF0A965CB47277BCF50A89C1B1F362"
16 },
17 {
18 "created_at": "2022-02-17T10:32:26Z",
19 "enc": "-----BEGIN PGP MESSAGE-----\n\nhF4DXxoViZlp6dISAQdApIVC5MIiWio4yRZ0hIVWntk2Xk7LKHPwJP2pw73SgREw\nCJZDCT91QEMPDwobh8eq4R8vook9fUJ0t+M5TUN5IwjSwSdmwiQ+mpArmyStIi4W\n0l4BiEt5Giar+H8V62bBiN38EbqHF4jN7jfjt6QrU4Nr/kP7DokI26TvXPqOtOOh\nTGOhiTvroHu4zRGMZKqq/IDE9FLA1SXRU3rXmHtVjz2U2Zmnj0Hj9iR+bZmy+TuY\n=ugF2\n-----END PGP MESSAGE-----\n",
20 "fp": "30D3453B8CD02FE2A3E7C78C0FB536FB87AE8F51"
21 }
22 ],
23 "unencrypted_suffix": "_unencrypted",
24 "version": "3.7.1"
25 }
26} \ No newline at end of file
diff --git a/hosts/vidhar/borg/copy.py b/hosts/vidhar/borg/copy.py
new file mode 100755
index 00000000..b99e301a
--- /dev/null
+++ b/hosts/vidhar/borg/copy.py
@@ -0,0 +1,216 @@
1#!@python@/bin/python
2
3import json
4import os
5import subprocess
6import re
7import sys
8from sys import stderr
9from humanize import naturalsize
10
11from tempfile import TemporaryDirectory
12
13from datetime import (datetime, timedelta)
14from dateutil.tz import (tzlocal, tzutc)
15import dateutil.parser
16import argparse
17
18from tqdm import tqdm
19
20from xdg import xdg_runtime_dir
21import pathlib
22
23import unshare
24from time import sleep
25
26from halo import Halo
27
28
29parser = argparse.ArgumentParser()
30parser.add_argument('source', metavar='REPO_OR_ARCHIVE')
31parser.add_argument('target', metavar='REPO_OR_ARCHIVE')
32args = parser.parse_args()
33
34def read_repo(path):
35 with Halo(text=f'Listing {path}', spinner='arc') as sp:
36 res = None
37 with subprocess.Popen(['borg', 'list', '--info', '--lock-wait', '120', '--json', path], stdout=subprocess.PIPE) as proc:
38 res = json.load(proc.stdout)['archives']
39 sp.succeed(f'{len(res)} archives in {path}')
40 return res
41
42class ToSync:
43 def __iter__(self):
44 return self
45
46 def __next__(self):
47 while True:
48 try:
49 src = read_repo(args.source)
50 dst = read_repo(args.target)
51 for entry in src:
52 if entry['name'] not in {dst_entry['name'] for dst_entry in dst} and not entry['name'].endswith('.checkpoint'):
53 return entry
54 raise StopIteration
55 except (subprocess.CalledProcessError, json.decoder.JSONDecodeError) as err:
56 print(err, file=stderr)
57 continue
58
59def copy_archive(src_repo_path, dst_repo_path, entry):
60 cache_suffix = None
61 with Halo(text=f'Determine archive parameters', spinner='arc') as sp:
62 match = re.compile('^(.*)-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.(checkpoint|recreate)(\.[0-9]+)?)?').fullmatch(entry['name'])
63 if match:
64 repo_id = None
65 with subprocess.Popen(['borg', 'info', '--info', '--lock-wait', '120', '--json', src_repo_path], stdout=subprocess.PIPE) as proc:
66 repo_id = json.load(proc.stdout)['repository']['id']
67 if repo_id:
68 cache_suffix = f'{repo_id}_{match.group(1)}'
69 sp.succeed(f'Will process {entry["name"]} ({dateutil.parser.isoparse(entry["start"])}, cache_suffix={cache_suffix})')
70 with TemporaryDirectory(prefix=f'borg-mount_{entry["name"]}_') as tmpdir:
71 child = os.fork()
72 if child == 0:
73 # print('unshare/chroot', file=stderr)
74 unshare.unshare(unshare.CLONE_NEWNS)
75 subprocess.run(['mount', '--make-rprivate', '/'], check=True)
76 chroot = pathlib.Path(tmpdir) / 'chroot'
77 upper = pathlib.Path(tmpdir) / 'upper'
78 work = pathlib.Path(tmpdir) / 'work'
79 for path in [chroot,upper,work]:
80 path.mkdir()
81 subprocess.run(['mount', '-t', 'overlay', 'overlay', '-o', f'lowerdir=/,upperdir={upper},workdir={work}', chroot], check=True)
82 bindMounts = ['nix', 'run', 'proc', 'dev', 'sys', pathlib.Path(os.path.expanduser('~')).relative_to('/')]
83 if not ":" in src_repo_path:
84 bindMounts.append(pathlib.Path(src_repo_path).relative_to('/'))
85 if 'SSH_AUTH_SOCK' in os.environ:
86 bindMounts.append(pathlib.Path(os.environ['SSH_AUTH_SOCK']).parent.relative_to('/'))
87 for bindMount in bindMounts:
88 (chroot / bindMount).mkdir(parents=True,exist_ok=True)
89 subprocess.run(['mount', '--bind', pathlib.Path('/') / bindMount, chroot / bindMount], check=True)
90 os.chroot(chroot)
91 os.chdir('/')
92 dir = pathlib.Path('/borg')
93 dir.mkdir(parents=True,exist_ok=True)
94 with Halo(text=f'Determine size', spinner='arc') as sp:
95 total_size = None
96 total_files = None
97 with subprocess.Popen(['borg', 'info', '--info', '--json', '--lock-wait', '120', f'{src_repo_path}::{entry["name"]}'], stdout=subprocess.PIPE, text=True) as proc:
98 stats = json.load(proc.stdout)['archives'][0]['stats']
99 total_size = stats['original_size']
100 total_files = stats['nfiles']
101 sp.succeed(f'{total_files} files, {naturalsize(total_size, binary=True)}')
102 # print(f'Mounting to {dir}', file=stderr)
103 with subprocess.Popen(['borg', 'mount', '--foreground', '--progress', '--lock-wait', '120', f'{src_repo_path}::{entry["name"]}', dir]) as mount_proc:
104 with Halo(text='Waiting for mount', spinner='arc') as sp:
105 wait_start = datetime.now()
106 while True:
107 ret = subprocess.run(['mountpoint', '-q', dir])
108 if ret.returncode == 0:
109 break
110 elif datetime.now() - wait_start > timedelta(minutes=10):
111 ret.check_returncode()
112 sleep(0.1)
113 sp.succeed('Mounted')
114 while True:
115 try:
116 with tqdm(total=total_size, unit_scale=True, unit_divisor=1024, unit='B', smoothing=0.01, disable=None, dynamic_ncols=True, maxinterval=0.5, miniters=1) as progress:
117 seen = 0
118 env = os.environ.copy()
119 create_args = ['borg',
120 'create',
121 '--lock-wait=120',
122 '--one-file-system',
123 '--compression=auto,zstd,10',
124 '--chunker-params=10,23,16,4095',
125 '--files-cache=ctime,size',
126 '--show-rc',
127 # '--remote-ratelimit=20480',
128 '--log-json',
129 '--progress',
130 '--list',
131 '--filter=AMEi-x?',
132 '--stats'
133 ]
134 archive_time = datetime.strptime(entry["time"], "%Y-%m-%dT%H:%M:%S.%f").replace(tzinfo=tzlocal()).astimezone(tzutc())
135 create_args += [f'--timestamp={archive_time.strftime("%Y-%m-%dT%H:%M:%S")}']
136 if cache_suffix:
137 env['BORG_FILES_CACHE_SUFFIX'] = cache_suffix
138 else:
139 create_args += ['--files-cache=disabled']
140 create_args += [f'{dst_repo_path}::{entry["name"]}', '.']
141 with subprocess.Popen(create_args, cwd=dir, stdin=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, env=env) as proc:
142 last_list = None
143 last_list_time = None
144 for line in proc.stderr:
145 try:
146 json_line = json.loads(line)
147 except json.decoder.JSONDecodeError:
148 tqdm.write(line)
149 continue
150
151 t = ''
152 if 'time' in json_line:
153 ts = datetime.fromtimestamp(json_line['time']).replace(tzinfo=tzlocal())
154 t = f'{ts.isoformat(timespec="minutes")} '
155 if json_line['type'] == 'archive_progress':
156 if last_list_time is None or ((datetime.now() - last_list_time) // timedelta(seconds=3)) % 2 == 1:
157 if 'path' in json_line and json_line['path']:
158 progress.set_description(f'… {json_line["path"]}', refresh=False)
159 else:
160 progress.set_description(None, refresh=False)
161 elif last_list is not None:
162 progress.set_description(last_list, refresh=False)
163 progress.set_postfix(compressed=naturalsize(json_line['compressed_size'], binary=True), deduplicated=naturalsize(json_line['deduplicated_size'], binary=True), nfiles=f'{json_line["nfiles"]}/{total_files}', refresh=False)
164 progress.update(json_line["original_size"] - seen)
165 seen = json_line["original_size"]
166 elif json_line['type'] == 'file_status':
167 # tqdm.write(t + f'{json_line["status"]} {json_line["path"]}')
168 last_list = f'{json_line["status"]} {json_line["path"]}'
169 last_list_time = datetime.now()
170 progress.set_description(last_list, refresh=False)
171 elif (json_line['type'] == 'log_message' or json_line['type'] == 'progress_message' or json_line['type'] == 'progress_percent') and ('message' in json_line or 'msgid' in json_line):
172 if 'message' in json_line:
173 tqdm.write(t + json_line['message'])
174 elif 'msgid' in json_line:
175 tqdm.write(t + json_line['msgid'])
176 else:
177 tqdm.write(t + line)
178 progress.set_description(None)
179 if proc.wait() != 0:
180 continue
181 except subprocess.CalledProcessError as err:
182 print(err, file=stderr)
183 continue
184 else:
185 break
186 mount_proc.terminate()
187 os._exit(0)
188 else:
189 while True:
190 waitpid, waitret = os.wait()
191 if waitret != 0:
192 sys.exit(waitret)
193 if waitpid == child:
194 break
195
196def main():
197 if "::" in args.source:
198 (src_repo_path, _, src_archive) = args.source.partition("::")
199 entry = None
200 for candidate_entry in read_repo(src_repo_path):
201 if entry['name'] != src_archive:
202 continue
203 entry = candidate_entry
204 break
205
206 if entry is None:
207 print("Did not find archive", file=stderr)
208 os.exit(1)
209
210 copy_archive(src_repo_path, args.target, entry)
211 else:
212 for entry in ToSync():
213 copy_archive(args.source, args.target, entry)
214
215if __name__ == "__main__":
216 sys.exit(main())
diff --git a/hosts/vidhar/borg/default.nix b/hosts/vidhar/borg/default.nix
index ee5856c9..65c309da 100644
--- a/hosts/vidhar/borg/default.nix
+++ b/hosts/vidhar/borg/default.nix
@@ -1,15 +1,72 @@
1{ pkgs, lib, ... }: 1{ config, pkgs, lib, ... }:
2 2
3with lib; 3with lib;
4 4
5{ 5let
6 copyService = { repo, repoEscaped }: let
7 serviceName = "copy-borg@${repoEscaped}";
8 sshConfig = pkgs.writeText "config" ''
9 Host yggdrasil.borgbase
10 HostName nx69hpl8.repo.borgbase.com
11 User nx69hpl8
12 IdentityFile /run/credentials/${serviceName}.service/ssh-identity
13 IdentitiesOnly yes
14
15 BatchMode yes
16 ServerAliveInterval 10
17 ServerAliveCountMax 30
18 '';
19 in nameValuePair serviceName {
20 serviceConfig = {
21 Type = "oneshot";
22 ExecStart = "${copyBorg}/bin/copy ${escapeShellArg repo} yggdrasil.borgbase:repo";
23 User = "borg";
24 Group = "borg";
25 StateDirectory = "borg";
26 Environment = [
27 "BORG_RSH=\"${pkgs.openssh}/bin/ssh -F ${sshConfig}\""
28 "BORG_CACHE_DIR=/var/lib/borg/cache"
29 "BORG_SECURITY_DIR=/var/lib/borg/security"
30 "BORG_KEYS_DIR=/var/lib/borg/keys"
31 "BORG_KEY_FILE=/run/credentials/${serviceName}.service/keyfile"
32 ];
33 LoadCredential = [
34 "ssh-identity:${config.sops.secrets."append.borgbase".path}"
35 "keyfile:${config.sops.secrets."yggdrasil.borgkey".path}"
36 ];
37 };
38 };
39
40 copyBorg = pkgs.stdenv.mkDerivation rec {
41 name = "copy";
42 src = ./copy.py;
43
44 phases = ["buildPhase" "checkPhase" "installPhase"];
45
46 python = pkgs.python39.withPackages (ps: with ps; [humanize tqdm dateutil xdg python-unshare halo]);
47
48 buildPhase = ''
49 substituteAll $src copy
50 '';
51
52 doCheck = true;
53 checkPhase = ''
54 ${python}/bin/python -m py_compile copy
55 '';
56
57 installPhase = ''
58 install -m 0755 -D -t $out/bin \
59 copy
60 '';
61 };
62in {
6 config = { 63 config = {
7 services.borgbackup.repos.jotnar = { 64 services.borgbackup.repos.jotnar = {
8 path = "/srv/backup/borg/jotnar"; 65 path = "/srv/backup/borg/jotnar";
9 authorizedKeysAppendOnly = let 66 authorizedKeysAppendOnly = let
10 dir = ./jotnar; 67 dir = ./jotnar;
11 toAuthKey = fname: ftype: if ftype != "regular" || !(hasSuffix ".pub" fname) then null else builtins.readFile (dir + "/${fname}"); 68 toAuthKey = fname: ftype: if ftype != "regular" || !(hasSuffix ".pub" fname) then null else builtins.readFile (dir + "/${fname}");
12 in filter (v: v != null) (lib.mapAttrsToList toAuthKey (builtins.readDir dir)); 69 in filter (v: v != null) (mapAttrsToList toAuthKey (builtins.readDir dir));
13 }; 70 };
14 71
15 boot.postBootCommands = mkBefore '' 72 boot.postBootCommands = mkBefore ''
@@ -25,5 +82,24 @@ with lib;
25 82
26 Match All 83 Match All
27 ''; 84 '';
85
86 sops.secrets."append.borgbase" = {
87 format = "binary";
88 sopsFile = ./append.borgbase;
89 };
90 sops.secrets."yggdrasil.borgkey" = {
91 format = "binary";
92 sopsFile = ./yggdrasil.borgkey;
93 };
94
95 systemd.services = listToAttrs (map copyService [{ repo = "/srv/backup/borg/jotnar"; repoEscaped = "srv-backup-borg-jotnar"; }]);
96
97 # systemd.timers."copy-borg@srv-backup-borg-jotnar" = {
98 # wantedBy = ["multi-user.target"];
99
100 # timerConfig = {
101 # OnCalendar = "*-*-* 00/4:00:00 Europe/Berlin";
102 # };
103 # };
28 }; 104 };
29} 105}
diff --git a/hosts/vidhar/borg/yggdrasil.borgkey b/hosts/vidhar/borg/yggdrasil.borgkey
new file mode 100644
index 00000000..3540792b
--- /dev/null
+++ b/hosts/vidhar/borg/yggdrasil.borgkey
@@ -0,0 +1,26 @@
1{
2 "data": "ENC[AES256_GCM,data:/iyvIA5kkXfeyOb7tVakXHI0UN36GQCB7iyPmC9TsTecYWWo9FM4H3jYI10s7NV0ljysnV9IlPqjGGRIbZJ5M9uZluXOmir5uxPZXO7mn6ZWTPKux6OWkWzLOzgebXBx/wqt3y7EwvPg4wKlIC4+xN5Qyn1YT0RuviMNSB7yVRFn5J6OsE3fVssGw5KB7cw3169JhNpNn8LR6zR/3kai5A7FeFxgjvehDwi/t/gIMjWMsfMgdimILVLyTmE1Q2CKHYIZH69/V3/lzThoxNz3EGgj+XTs116DTZaE0nRadvbUfT+mh0CMV/1RKhfaRZ5qbLWDJdihxcKse4f/k0264upzcnwnbDVz3CYgzTaby9vcMra1/Heb6s0lEV8dyXQUiaBsO6CYnTsNC0ogmwIqINZfGiJOQLBIk4ja5dj60UcO9uKYXr3ZkGYuaVowpWOmgpAfy3PE2eLIj1ZTKAE2Llw2vQFUG9X4i9YI2EHjXCnars88Uoa16Hb6/zbypMh3IKw/Yor/3CKkTIdtiuGZ10/09qlIHnfVjy1tL9lPS92vvoDPA2o1z0utrd8qEvsvS4JiZvz9x3JWUKZN2/DQedarzg53FiFhuvSc4BRtGD5Dqo/2kHGDzzL1BmBVrBfiYtzQmMaXmyOe4WvnK/umujVP7LwhrQheER0Jsg8atQmiPGp4fUQEpo9TPuHx0DRnpEpfVSVXqROGF6Q3KiV492hUGb0JISgfUFpN2ZhvhQS2lgaBGnwfb1CJGgkSKhMQIrvWWVqvwHYE90vIGNZLnsSKedHXxy+jbJqyU4uCKVaRRdXOBVtcFOeNekQO3Ayl0YQTZqi7U174h/6oS2ECePUsKWyYXGiQoNKjyd+edG//IIIFvBgjqBeMmsdzGlxxJE47gMRefSXwk8qk/QZG320uiJtz+G0jVpUUrGwL4ujPXwi1Nj099Oi0Q3SJJ1qvZy4kekvFaoyRlTmXHziupyWKhgnTPj6DCWqI03ORvB5m4y5eWdg90yuj+FyT3FSuIJrbbk8r8HaiirCXEfDE/oTK2A4OCfxph/ebi+WrDQYEXNSCbAMmVQAhSINs,iv:VPlUsquBSSxWcQFQG9Eb/kN/VCRp1ULs2Mixe/M9dNo=,tag:D8isf1CanHzXSNSJ8EK+5g==,type:str]",
3 "sops": {
4 "kms": null,
5 "gcp_kms": null,
6 "azure_kv": null,
7 "hc_vault": null,
8 "age": null,
9 "lastmodified": "2022-02-17T11:07:24Z",
10 "mac": "ENC[AES256_GCM,data:5dmDZTT0+xwtUMLRHxQ8O8pviyzZOtcZXufdRkpbQrCImhk1B4eSm2gaT8GavJYswu3I/Z7Yt6BNeiKkccf/PXWAFsOn7L6R2B52X5TdgUD49HXiLcu9V5Sy2/YDqlCcC1IpxwylilxypP1ht/M19VdPl/vFClQTwsQcwpBujtE=,iv:u90ozqlzOnvp0ly/x1hZAnR67XPo5pWGSvPSbzI5eA4=,tag:WKc64wNitiU/x0Baugky9w==,type:str]",
11 "pgp": [
12 {
13 "created_at": "2022-02-17T11:07:24Z",
14 "enc": "-----BEGIN PGP MESSAGE-----\n\nhF4DbYDvGI0HDr0SAQdAnvb/5Kls/HsLN0dWxkew1E9ppPKI3IDS8fHUun+emnYw\nNJ4XjE2VbM2sdPaAsdeEtmONof8r8k0EEmvV8YFb2iH4EvuwB/LE3sb5Ldjp2QHm\n0l4BS/e7YzESnua/yHA26caeRaqBBbD8mXpKjTaA40v9mbOkpcQpqqP62WO1ox6J\nXLBuV7O1gGjaoWfN/xjkzB2PVsAs5WeTBelMQc0M0/RmlPgOQmOD19SWQop+4npR\n=qLw1\n-----END PGP MESSAGE-----\n",
15 "fp": "A1C7C95E6CAF0A965CB47277BCF50A89C1B1F362"
16 },
17 {
18 "created_at": "2022-02-17T11:07:24Z",
19 "enc": "-----BEGIN PGP MESSAGE-----\n\nhF4DXxoViZlp6dISAQdACICrWK61VYsHz08d5cUN4S0zOsmas6/KMs9Eok2+hyAw\n5H5cWdUMtKXCG70Cws3pP9Xq0fRrAQ4ta+HBd38w+fDhm/y4HQPcdv7T7ekcEMHH\n0l4BDO10UfkHAiVrhp5jbpdolkH/0uOb90tZPvN1RGJkDoyJjqp5XTn13c9kfsFg\ni5txaJPTp7XvIBiLLwhmb2z3a1XCDjd1qS2hiaD9c7+fxcanU5a9QwlT5ANnzm/X\n=/xps\n-----END PGP MESSAGE-----\n",
20 "fp": "30D3453B8CD02FE2A3E7C78C0FB536FB87AE8F51"
21 }
22 ],
23 "unencrypted_suffix": "_unencrypted",
24 "version": "3.7.1"
25 }
26} \ No newline at end of file