├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── prestic ├── __init__.py └── prestic.py ├── restic-icon.png ├── screenshot.png └── setup.py /.gitattributes: -------------------------------------------------------------------------------- 1 | prestic/prestic.py export-subst 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .vscode/ 3 | build/ 4 | dist/ 5 | *.egg-info/ 6 | test-repo/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alex Duchesne 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prestic 2 | Prestic is a profile manager and task scheduler for [restic](https://restic.net/). It works on all 3 | operating systems supported by restic but GUI and keyring functionality may vary by platform. 4 | 5 | ![Screenshot](https://github.com/ducalex/prestic/raw/master/screenshot.png) 6 | 7 | 8 | # Installation 9 | 10 | Python 3.6+ and [pip](https://pip.pypa.io/en/stable/installing/) are required. Then: 11 | - `pip install http://github.com/ducalex/prestic/tarball/master#egg=prestic` 12 | 13 | _Note: On Ubuntu you need to [add ~/.local/bin to your path](https://bugs.launchpad.net/ubuntu/+source/bash/+bug/1588562) 14 | if needed and run `sudo apt install gir1.2-appindicator3-0.1` for the gui to work._ 15 | 16 | _Note: If you prefer you can also directly download `prestic.py` and put it somewhere in your PATH 17 | (it is standalone)._ 18 | 19 | 20 | ### Start Prestic on login 21 | - Windows: Put a link to `prestic-gui.exe` in your `Startup` folder (run `where prestic-gui` to locate it if needed) 22 | - Linux: Add command `prestic --gui` to your startup applications 23 | 24 | 25 | # Usage 26 | - Run profile-defined default command: `prestic -p profilename` 27 | - Run any restic command on profile: `prestic -p profilename snapshots` 28 | - Start gui and scheduler: `prestic --gui` 29 | - Start scheduler only: `prestic --service` 30 | 31 | ## Keyring 32 | The keyring allows you to let your operating system store repository passwords encrypted in your 33 | user profile. This is the best password method if it is available to you. 34 | 35 | To use, add `password-keyring = ` to your prestic profile, where `` can be anything you 36 | want to identify that password. Then to set a password run the following command: 37 | `prestic --keyring set `. 38 | 39 | 40 | # Configuration file 41 | Configuration is stored in $HOME/.prestic/config.ini. The file consists of profile blocks. You can use a 42 | single block or split in multiple blocks through inheritance. For example one profile could contain 43 | the repository configuration and then another one inherits from it and adds the backup command. 44 | 45 | Lists can span multiple lines, as long as they are indented deeper than the first line of the value. 46 | 47 | ````ini 48 | # default is the profile used when no -p is given (it is optional) 49 | [default] 50 | inherit = my-profile # A single inherit can be used as an alias 51 | 52 | [my-profile] 53 | # (string) human-redable description: 54 | description = 55 | # (list) inherit options from other profiles 56 | inherit = 57 | # (string) Run this profile periodically (will do nothing if command not set) 58 | # Format is: `daily at 23:59` or `monthly at 23:59` or `mon,tue,wed at 23:59`. Hourly is also possible: `daily at *:30` 59 | schedule = 60 | # (bool) controls non-essential notifications (errors are always shown) 61 | notifications = on 62 | # (string) sets cpu priority (idle, low, normal, high) 63 | cpu-priority = 64 | # (string) sets disk io priority (idle, low, normal, high) 65 | io-priority = 66 | # (int) Time to wait and retry if the repository is locked (seconds) 67 | wait-for-lock = 68 | 69 | # (string) repository uri 70 | repository = sftp:user@domain:folder 71 | # (string) repository password (plain text) 72 | password = 73 | # (string) repository password (retrieve from file) 74 | password-file = 75 | # (string) repository password (retrieve from command) 76 | password-command = 77 | # (string) repository password (retrieve from OS keyring/locker) 78 | password-keyring = 79 | # (int) limits downloads to a maximum rate in KiB/s 80 | limit-download = 81 | # (int) limits uploads to a maximum rate in KiB/s 82 | limit-upload = 83 | # (string) path to restic executable (you may add global flags too) 84 | executable = restic 85 | # (string|list) default restic command to execute (if none provided): 86 | command = 87 | # (list) restic arguments for default command 88 | args = 89 | # (int) be verbose (specify level 0-3) 90 | verbose = 91 | # (regex) ignore lines matching this expression when writing log files 92 | log-filter = ^unchanged\s/ 93 | # (string) set the cache directory 94 | cache-dir = 95 | 96 | # (string) environment variables can be set: 97 | env.AWS_ACCESS_KEY_ID = VALUE 98 | env.AWS_SECRET_ACCESS_KEY = VALUE 99 | 100 | # (string) other flags can be set: 101 | flag.json = true 102 | flag.new-restic-flag = value 103 | 104 | ```` 105 | 106 | ### Simple configuration example 107 | ````ini 108 | [my-repo] 109 | description = USB Storage 110 | repository = /media/backup 111 | password-keyring = my-repo 112 | 113 | [my-backup] 114 | description = Backup to USB Storage 115 | inherit = my-repo 116 | schedule = daily at 12:00 117 | command = backup 118 | args = 119 | /home/user/folder1 120 | /home/user/folder2 121 | --iexclude="*.lock" 122 | 123 | # Where the my-backup profile will run daily at 12:00 124 | # You can also issue manual commands: 125 | # prestic -p my-backup 126 | # prestic -p my-repo list snapshots 127 | # prestic -p my-backup list snapshots # this overrides my-backup's command/args but not global-flags 128 | ```` 129 | -------------------------------------------------------------------------------- /prestic/__init__.py: -------------------------------------------------------------------------------- 1 | from .prestic import * 2 | -------------------------------------------------------------------------------- /prestic/prestic.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ Prestic is a profile manager and task scheduler for restic """ 3 | 4 | import logging 5 | import os 6 | import shlex 7 | import sys 8 | import time 9 | import mimetypes 10 | import json 11 | import urllib.parse 12 | import re 13 | from argparse import ArgumentParser 14 | from configparser import ConfigParser 15 | from copy import deepcopy 16 | from datetime import datetime, timedelta 17 | from getpass import getpass 18 | from http.server import BaseHTTPRequestHandler 19 | from io import StringIO, BytesIO 20 | from math import floor, ceil 21 | from pathlib import Path, PurePosixPath 22 | from subprocess import Popen, PIPE, STDOUT 23 | from socketserver import TCPServer 24 | from threading import Thread 25 | 26 | try: 27 | from base64 import b64decode 28 | from PIL import Image 29 | import pystray 30 | except: 31 | pystray = None 32 | 33 | try: 34 | import keyring 35 | except: 36 | keyring = None 37 | 38 | 39 | PROG_NAME = "prestic" 40 | PROG_ICON = ( 41 | b"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgBAMAAACBVGfHAAAAKlBMVEU3My+7UVNMOTPDsF5mRD5sXkuunIJvUUOOdFrQzsvx22rJs5yVhlz19vZPK" 42 | b"bxAAAAACnRSTlMB+yr6kmH65b/0S/q8VwAAAWpJREFUKM+Vkb9Lw0AUx4+jha6Z7BhKCtlPHUIW01Q6lpiAzoVb0g6FapYMrW20f4AUhGaKotwfEJ" 43 | b"Czi4XEH3GzQpH7X7xrmypuPnj33vvA3b33fQD83yCUV8ESoeI4wDbrIrXHBqcN6RJ0JSmAFjxIetVAlSQJcJeU8SFCens0yIHpJwjt41F3A8pm4mL" 44 | b"s4tQcrEDZUF0qLJYVAXYMcMKWvGSsDxQPmA0ZhtdskjwswwVQLFBROUjZNOt8vi+AzXsqNYthzKZ+Zzn7ADbvsWgUXhh79CljVxVHTFFXk3BCcTz7" 45 | b"6tl1MZen6ekb/zX1tehUXPG0KPMT2s4QufP4o4XekUb0ecZr5JiyUKKqEYKyV0JuZLjWiAOic7/diFZEvMg0Et3LG6CtAdndADhEJOIAPeVCqy2EW" 46 | b"lyhfg5KlLoU07i5XcXFSqBnIOdESTDG43mwBfAscKzqcO9nfbWAn8fGL3D+Z8E1K0+/AZb2itxu6ZQTAAAAAElFTkSuQmCC" 47 | ) 48 | PROG_BUILD = "35492c7" 49 | 50 | if sys.platform == "win32" and Path(os.getenv("APPDATA")).exists(): 51 | PROG_HOME = Path(os.getenv("APPDATA")).joinpath(PROG_NAME) 52 | else: 53 | PROG_HOME = Path.home().joinpath("." + PROG_NAME) 54 | 55 | 56 | class Profile: 57 | _options = [ 58 | # (key, datatype, remap, default) 59 | ("inherit", "list", None, []), 60 | ("description", "str", None, "no description"), 61 | ("repository", "str", "flag.repo", None), 62 | ("password", "str", "env.RESTIC_PASSWORD", None), 63 | ("password-command", "str", "env.RESTIC_PASSWORD_COMMAND", None), 64 | ("password-file", "str", "env.RESTIC_PASSWORD_FILE", None), 65 | ("password-keyring", "str", None, None), 66 | ("executable", "list", None, ["restic"]), 67 | ("command", "list", None, []), 68 | ("args", "list", None, []), 69 | ("schedule", "str", None, None), 70 | ("notifications", "bool", None, True), 71 | ("wait-for-lock", "str", None, None), 72 | ("cpu-priority", "str", None, None), 73 | ("io-priority", "str", None, None), 74 | ("limit-upload", "size", None, None), 75 | ("limit-download", "size", None, None), 76 | ("verbose", "int", "flag.verbose", None), 77 | ("option", "list", "flag.option", None), 78 | ("cache-dir", "str", "env.RESTIC_CACHE_DIR", None), 79 | ] 80 | # Break down _options for easier access 81 | _keymap = {key: remap or key for key, datatype, remap, default in _options} 82 | _types = {remap or key: datatype for key, datatype, remap, default in _options} 83 | _defaults = {remap or key: default for key, datatype, remap, default in _options} 84 | 85 | def __init__(self, name, properties={}): 86 | self._properties = {"name": name} 87 | self._parents = [] 88 | self.last_run = None 89 | self.next_run = None 90 | 91 | for key in properties: 92 | self[key] = properties[key] 93 | 94 | def __getattr__(self, name): 95 | return self[name] 96 | 97 | def __getitem__(self, key): 98 | key = self._keymap.get(key, key) 99 | return self._properties.get(key, self._defaults.get(key)) 100 | 101 | def __setitem__(self, key, value): 102 | key = self._keymap.get(key, key) 103 | datatype = self._types.get(key) 104 | if datatype == "list": 105 | self._properties[key] = shlex.split(value) if type(value) is str else list(value) 106 | elif datatype == "bool": 107 | self._properties[key] = value in [True, "true", "on", "yes", "1"] 108 | elif datatype == "size": # if unit is not specified, KB is assumed. 109 | self._properties[key] = int(value) if str(value).isnumeric() else (parse_size(value) / 1024) 110 | else: # if datatype == "str": 111 | self._properties[key] = str(value) 112 | if key == "schedule": 113 | self.next_run = self.find_next_run() 114 | 115 | def is_defined(self, key): 116 | return self._keymap.get(key, key) in self._properties 117 | 118 | def inherit(self, profile): 119 | for key, value in profile._properties.items(): 120 | if not self.is_defined(key) and profile.is_defined(key): 121 | self[key] = deepcopy(value) 122 | self._parents.append([profile.name, profile._parents]) 123 | 124 | def find_next_run(self, from_time=None): 125 | if self.schedule: 126 | weekdays = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] 127 | 128 | from_time = (from_time or datetime.now()) + timedelta(minutes=1) 129 | next_run = from_time.replace(hour=0, minute=0, second=0) 130 | 131 | m_days = set(range(1, 32)) 132 | w_days = set() 133 | 134 | for part in self.schedule.lower().replace(",", " ").split(): 135 | if part == "monthly": 136 | m_days = {1} 137 | elif part == "weekly": 138 | w_days = {0} 139 | elif part == "daily": 140 | w_days = {0, 1, 2, 3, 4, 5, 6} 141 | elif part == "hourly": 142 | next_run = next_run.replace(hour=int(from_time.hour + 1), minute=int(0)) 143 | elif part[0:3] in weekdays: 144 | w_days.add(weekdays.index(part[0:3])) 145 | elif len(part.split(":")) == 2: 146 | hour, minute = part.split(":") 147 | hour = from_time.hour + 1 if hour == "*" else int(hour) 148 | next_run = next_run.replace(hour=int(hour), minute=int(minute)) 149 | 150 | for i in range(from_time.weekday(), from_time.weekday() + 32): 151 | if next_run.day in m_days and ((i % 7) in w_days or not w_days): 152 | if next_run >= from_time: 153 | return next_run 154 | next_run += timedelta(days=1) 155 | 156 | return None 157 | 158 | def set_last_run(self, last_run=None): 159 | self.last_run = last_run or datetime.now() 160 | self.next_run = self.find_next_run(self.last_run) 161 | 162 | def get_command(self, cmd_args=[]): 163 | args = [*self["executable"]] 164 | env = {} 165 | 166 | for key in self._properties: # and defaults? 167 | if key.startswith("env."): 168 | env[key[4:]] = self[key] 169 | elif key.startswith("flag."): 170 | values = self[key] if type(self[key]) is list else [self[key]] 171 | for val in values: 172 | if type(val) is bool and val: 173 | args += [f"--{key[5:]}"] 174 | elif type(val) is str: 175 | args += [f"--{key[5:]}={val}"] if val.isalnum() else [f"--{key[5:]}", val] 176 | 177 | if self["password-keyring"]: 178 | username = shlex.quote(self["password-keyring"]) 179 | python = shlex.quote(sys.executable) 180 | env["RESTIC_PASSWORD_COMMAND"] = f"{python} -m keyring get {PROG_NAME} {username}" 181 | if not keyring: 182 | logging.warning(f"keyring module missing, required by profile {self.name}") 183 | 184 | if self["limit-upload"]: 185 | args += [f"--limit-upload={self['limit-upload']}"] 186 | if self["limit-download"]: 187 | args += [f"--limit-download={self['limit-download']}"] 188 | if self["limit-upload"] or self["limit-download"]: 189 | env["RCLONE_BWLIMIT"] = f"{self['limit-upload'] or 'off'}:{self['limit-download'] or 'off'}" 190 | 191 | # Ignore default command if any argument was given 192 | if cmd_args: 193 | args += cmd_args 194 | elif self.command: 195 | args += self.command 196 | args += self.args 197 | 198 | return env, args 199 | 200 | def run(self, cmd_args=[], text_output=True, stdout=None, stderr=None): 201 | env, args = self.get_command(cmd_args) 202 | 203 | p_args = {"args": args, "env": {**os.environ, **env}, "stdout": stdout, "stderr": stderr} 204 | 205 | if text_output: 206 | p_args["universal_newlines"] = True 207 | p_args["encoding"] = "utf-8" 208 | p_args["errors"] = "replace" 209 | p_args["bufsize"] = 1 210 | 211 | if sys.platform == "win32": 212 | cpu_priorities = {"idle": 0x0040, "low": 0x4000, "normal": 0x0020, "high": 0x0080} 213 | p_args["creationflags"] = cpu_priorities.get(self["cpu-priority"], 0) 214 | # do not create a window/console if we capture ALL output 215 | if stdout != None or stderr != None: 216 | p_args["creationflags"] |= 0x08000000 # CREATE_NO_WINDOW 217 | 218 | self.last_run = datetime.now() 219 | self.next_run = None # Disable scheduling while running 220 | 221 | logging.info(f"running: {' '.join(shlex.quote(s) for s in args)}\n") 222 | return Popen(**p_args) 223 | 224 | 225 | class BaseHandler: 226 | def __init__(self, config_file=None): 227 | self.config_file = config_file or Path(PROG_HOME, "config.ini") 228 | self.config_file.parent.mkdir(exist_ok=True) 229 | self.running = False 230 | self.load_config() 231 | 232 | def load_config(self): 233 | config = ConfigParser() 234 | status = ConfigParser() 235 | config.optionxform = lambda x: str(x) if x.startswith("env.") else x.lower() 236 | 237 | if config.read(self.config_file): 238 | logging.info(f"configuration loaded from file {self.config_file}") 239 | elif config.read(Path(__file__).parent.joinpath("prestic.ini")): 240 | logging.info(f"configuration loaded from file prestic.ini") 241 | 242 | status.read(Path(PROG_HOME, "status.ini")) 243 | 244 | self.profiles = { 245 | "default": Profile("default"), 246 | **{k: Profile(k, dict(config[k])) for k in config.sections()}, 247 | } 248 | WebRequestHandler.profiles = self.profiles # Temp hack :() 249 | 250 | # Process profile inheritance 251 | inherits = True 252 | while inherits: 253 | inherits = False 254 | for name, profile in self.profiles.items(): 255 | if not profile["inherit"]: 256 | continue 257 | 258 | inherits = True 259 | 260 | parent_name = profile["inherit"][0] 261 | parent = self.profiles.get(parent_name) 262 | 263 | if not parent: 264 | exit(f"[error] profile {name} inherits non-existing parent {parent_name}") 265 | elif parent_name == profile.name: 266 | exit(f"[error] profile {name} cannot inherit from itself") 267 | elif parent["inherit"]: 268 | continue 269 | 270 | profile.inherit(parent) 271 | profile["inherit"].pop(0) 272 | 273 | self.tasks = [t for t in self.profiles.values() if t.command and t.repository] 274 | self.state = status 275 | 276 | # Setup task status 277 | for task in self.tasks: 278 | try: 279 | self.save_state(task.name, {"started": 0, "pid": 0}, False) 280 | task.set_last_run(datetime.fromtimestamp(status[task.name].getfloat("last_run"))) 281 | # Do not try to catch up if the task was supposed to run less than one day ago 282 | # and is supposed to run again today 283 | if task.next_run > datetime.now() - timedelta( 284 | days=1 285 | ) and task.find_next_run() < datetime.now() + timedelta(hours=12): 286 | task.next_run = task.find_next_run() 287 | except: 288 | pass 289 | 290 | def save_state(self, section, values, write=True): 291 | if not self.state.has_section(section): 292 | self.state.add_section(section) 293 | self.state[section].update({k: str(v) for k, v in values.items()}) 294 | if write: 295 | with Path(PROG_HOME, "status.ini").open("w") as fp: 296 | self.state.write(fp) 297 | 298 | def dump_profiles(self): 299 | print(f"\nAvailable profiles:") 300 | for name, profile in self.profiles.items(): 301 | if profile.repository: 302 | print(f" > {name} ({profile.description}) [{profile.repository}] {' '.join(profile.command)}") 303 | 304 | def run(self, profile=None, args=[]): 305 | logging.info(f"running {args} on {profile}!") 306 | self.running = True 307 | 308 | def stop(self): 309 | logging.info("shutting down...") 310 | self.running = False 311 | 312 | 313 | class ServiceHandler(BaseHandler): 314 | """Run in service mode (task scheduler) and output to log files""" 315 | 316 | def set_status(self, message, busy=False): 317 | if self.gui and message != self.status: 318 | self.gui.title = "Prestic backup manager\n" + (message or "idle") 319 | icon = self.icons["busy" if busy else "norm"] 320 | if self.gui.icon is not icon: 321 | self.gui.icon = icon 322 | # This can cause issues if the menu is currently open but there is no way to know if it is... 323 | self.gui.update_menu() 324 | if message != self.status: 325 | logging.info(f"status: {message}") 326 | self.status = message 327 | 328 | def notify(self, message, title=None): 329 | if self.gui and self.gui.HAS_NOTIFICATION: 330 | self.gui.notify(message, f"{PROG_NAME}: {title}" if title else PROG_NAME) 331 | time.sleep(5) # 0.5s needed for stability, rest to give time for reading 332 | 333 | def proc_scheduler(self): 334 | # TO DO: Handle missed tasks more gracefully (ie computer sleep). We shouldn't run a 335 | # missed task if its next schedule is soon anyway (like we do in load_config) 336 | logging.info("scheduler running with %d tasks" % len(self.tasks)) 337 | for task in self.tasks: 338 | logging.info(f" > {task.name} will next run {time_diff(task.next_run)}") 339 | 340 | while self.running: 341 | next_task = None 342 | sleep_time = 60 343 | try: 344 | for task in self.tasks: 345 | if not task.next_run: 346 | continue 347 | elif task.next_run <= datetime.now(): 348 | self.run_task(task) 349 | if not next_task: 350 | next_task = task 351 | elif task.next_run and next_task.next_run and task.next_run < next_task.next_run: 352 | next_task = task 353 | 354 | if next_task: 355 | sleep_time = max(0, (next_task.next_run - datetime.now()).total_seconds()) 356 | self.set_status(f"{next_task.name} will run {time_diff(next_task.next_run)}") 357 | else: 358 | self.set_status(f"no scheduled task") 359 | 360 | except Exception as e: 361 | logging.error(f"service_loop crashed: {type(e).__name__} '{e}'") 362 | self.notify(str(e), f"Unhandled exception: {type(e).__name__}") 363 | # raise e 364 | 365 | time.sleep(min(sleep_time, 10)) 366 | 367 | def proc_webui(self): 368 | time.sleep(1) # Wait for the gui to come up so we can show errors 369 | try: 370 | # TO DO: Stop the web server after a period of inactivity (to release memory but also for security) 371 | self.webui_server = TCPServer(self.webui_listen, WebRequestHandler) 372 | self.webui_listen = self.webui_server.server_address # In case we use automatic assignment 373 | self.webui_token = "" 374 | self.webui_url = f"http://{self.webui_listen[0]}:{self.webui_listen[1]}/?token={self.webui_token}" 375 | logging.info(f"webui running at {self.webui_url}") 376 | self.webui_server.serve_forever() 377 | except Exception as e: 378 | logging.error(f"webui error: {type(e).__name__} '{e}'") 379 | self.notify(str(e), "Webui couldn't start") 380 | 381 | def run_task(self, task): 382 | try: 383 | log_file = Path(PROG_HOME, "logs", f"{time.strftime('%Y.%m.%d_%H.%M')}-{task.name}.txt") 384 | log_file.parent.mkdir(parents=True, exist_ok=True) 385 | log_fd = log_file.open("w", encoding="utf-8", errors="replace") 386 | except: 387 | log_file = Path("-") 388 | log_fd = None 389 | 390 | if "backup" in task.command: # and task.verbose < 2: 391 | log_filter = re.compile("^unchanged\s/") 392 | else: 393 | log_filter = None 394 | 395 | self.save_state(task.name, {"started": time.time(), "log_file": log_file.name}) 396 | self.set_status(f"running task {task.name}", True) 397 | if task["notifications"]: 398 | self.notify(f"Running task {task.name}") 399 | 400 | def task_log(line): 401 | if log_fd: 402 | log_fd.write(f"[{datetime.now()}] {line}\n") 403 | log_fd.flush() 404 | else: 405 | logging.info(f"[task_log] {line}") 406 | 407 | def try_run(cmd_args=[]): 408 | proc = task.run(cmd_args, stdout=PIPE, stderr=STDOUT) 409 | output = [] 410 | 411 | self.save_state(task.name, {"pid": proc.pid}) 412 | 413 | task_log(f"Repository: {task.repository}") 414 | task_log(f"Command line: {' '.join(shlex.quote(s) for s in proc.args)}") 415 | task_log(f"Restic output:\n ") 416 | 417 | for line in proc.stdout: 418 | line = line.rstrip("\r\n") 419 | if not log_filter or not log_filter.match(line): 420 | output.append(line) 421 | task_log(line) 422 | 423 | ret = proc.wait() 424 | 425 | task_log(f" \nRestic exit code: {ret}\n ") 426 | 427 | return output, ret 428 | 429 | output, ret = try_run() 430 | 431 | # This naive method could be a problem, we should check the lock time ourselves 432 | # see https://github.com/restic/restic/pull/2391 433 | if ret == 1 and "remove stale locks" in output[-1]: 434 | logging.warning("task failed because of a stale lock. attempting unlock...") 435 | if try_run(["unlock"])[1] == 0: 436 | output, ret = try_run() 437 | 438 | task.set_last_run() 439 | if log_fd: 440 | log_fd.close() 441 | 442 | if ret == 0: 443 | status_txt = f"task {task.name} finished successfully." 444 | elif ret == 3 and "backup" in task.command: 445 | status_txt = f"task {task.name} finished with some warnings..." 446 | else: 447 | status_txt = f"task {task.name} FAILED with exit code: {ret} !" 448 | if log_file.exists(): 449 | os_open_url(Path(PROG_HOME, "logs", log_file)) 450 | 451 | self.save_state(task.name, {"last_run": time.time(), "exit_code": ret, "pid": 0}) 452 | self.set_status(status_txt) 453 | if task["notifications"] or ret != 0: 454 | self.notify(("\n".join(output[-4:]))[-220:].strip(), status_txt) 455 | 456 | def run(self, profile, args=[]): 457 | self.webui_listen = ("127.0.0.1", 8711) # 0 458 | self.webui_server = None 459 | self.webui_url = None 460 | self.running = True 461 | self.status = None 462 | self.gui = None 463 | 464 | self.save_state("__prestic__", {"pid": os.getpid()}) 465 | self.set_status("service started") 466 | 467 | Thread(target=self.proc_scheduler, name="scheduler").start() 468 | Thread(target=self.proc_webui, name="webui").start() 469 | 470 | try: 471 | icon = Image.open(BytesIO(b64decode(PROG_ICON))).convert("RGBA") 472 | self.icons = { 473 | "norm": icon, 474 | "busy": Image.alpha_composite(Image.new("RGBA", icon.size, (255, 0, 255, 255)), icon), 475 | "fail": Image.alpha_composite(Image.new("RGBA", icon.size, (255, 0, 0, 255)), icon), 476 | } 477 | 478 | def make_cb(fn, arg): # Binds fn to arg 479 | return lambda: fn(arg) 480 | 481 | def on_run_now_click(task): 482 | if task["notifications"]: 483 | self.notify(f"{task.name} will run next") 484 | task.next_run = datetime.now() 485 | 486 | def on_log_click(task): 487 | if log_file := self.state[task.name].get("log_file", ""): 488 | os_open_url(Path(PROG_HOME, "logs", log_file)) 489 | 490 | def tasks_menu(): 491 | for task in self.tasks: 492 | task_menu = pystray.Menu( 493 | pystray.MenuItem(task.description, lambda: 1), 494 | pystray.Menu.SEPARATOR, 495 | pystray.MenuItem(f"Next run: {time_diff(task.next_run)}", lambda: 1), 496 | pystray.MenuItem( 497 | f"Last run: {time_diff(task.last_run)}", make_cb(on_log_click, task) 498 | ), 499 | pystray.Menu.SEPARATOR, 500 | pystray.MenuItem("Run Now", make_cb(on_run_now_click, task)), 501 | ) 502 | yield pystray.MenuItem(task.name, task_menu) 503 | 504 | self.gui = pystray.Icon( 505 | name=PROG_NAME, 506 | icon=icon, 507 | menu=pystray.Menu( 508 | pystray.MenuItem("Tasks", pystray.Menu(tasks_menu)), 509 | pystray.MenuItem("Open web interface", lambda: os_open_url(self.webui_url)), 510 | pystray.MenuItem("Open prestic folder", lambda: os_open_url(PROG_HOME)), 511 | pystray.MenuItem("Reload config", lambda: self.load_config()), 512 | pystray.MenuItem("Quit", lambda: self.stop()), 513 | ), 514 | ) 515 | self.gui.run() 516 | except Exception as e: 517 | logging.warning("pystray (gui) couldn't be initialized...") 518 | logging.warning(f"proc_gui error: {type(e).__name__} '{e}'") 519 | 520 | while self.running: 521 | time.sleep(60) 522 | 523 | def stop(self, rc=0): 524 | logging.info("shutting down...") 525 | try: 526 | self.running = False 527 | if self.gui: 528 | self.gui.visible = False 529 | if self.webui_server: 530 | self.webui_server.shutdown() 531 | finally: 532 | os._exit(rc) 533 | 534 | 535 | class KeyringHandler(BaseHandler): 536 | """Keyring manager (basically a `keyring` clone)""" 537 | 538 | def run(self, profile, args=[]): 539 | if len(args) != 2 or args[0] not in ["get", "set", "del"]: 540 | exit("Usage: get|set|del username") 541 | try: 542 | if args[0] == "get": 543 | ret = keyring.get_password(PROG_NAME, args[1]) 544 | if ret is None: 545 | exit("Error: Not found") 546 | print(ret, end="") 547 | elif args[0] == "set": 548 | keyring.set_password(PROG_NAME, args[1], getpass()) 549 | print("OK") 550 | elif args[0] == "del": 551 | keyring.delete_password(PROG_NAME, args[1]) 552 | print("OK") 553 | except Exception as e: 554 | exit(f"Error: {repr(e)}") 555 | 556 | 557 | class CommandHandler(BaseHandler): 558 | """Run a single command and output to stdout""" 559 | 560 | def run(self, profile_name, args=[]): 561 | profile = self.profiles.get(profile_name) 562 | if profile: 563 | logging.info(f"profile: {profile.name} ({profile.description})") 564 | try: 565 | exit(profile.run(args).wait()) 566 | except OSError as e: 567 | logging.error(f"unable to start restic: {e}") 568 | else: 569 | logging.error(f"profile {profile_name} does not exist") 570 | self.dump_profiles() 571 | exit(-1) 572 | 573 | 574 | class WebRequestHandler(BaseHTTPRequestHandler): 575 | """Handler""" 576 | 577 | template = """ 578 | 579 | 580 | 592 | 593 | %s 594 | 595 | """ 596 | 597 | profiles = {} 598 | snapshots_cache = {} 599 | 600 | def gen_table(self, rows, header=None): 601 | content = "" 602 | if header: 603 | content += "" 604 | content += "" 605 | for row in rows: 606 | content += "" 607 | content += "
" + ("".join(header)) + "
" + ("".join(row)) + "
" 608 | return content 609 | 610 | def route_home(self): 611 | table = [] 612 | repos = [] 613 | for p in self.profiles.values(): 614 | if p["repository"] and p["repository"] not in repos: 615 | table.append([p["name"], p["description"], p["repository"], f"snapshots"]) 616 | repos.append(p["repository"]) 617 | header = f"

Service status: Idle

" 618 | return (200, header + self.gen_table(table, ["Name", "Description", "Repository", "Actions"])) 619 | 620 | def route_snapshots(self, profile_name): 621 | if not (profile := self.profiles.get(profile_name)): 622 | return (404, "Profile not found") 623 | if not (snapshots := json.load(profile.run(["snapshots", "--json"], stdout=PIPE).stdout)): 624 | return (404, "No snapshot found") 625 | prev_id = None 626 | table = [] 627 | for s in sorted(snapshots, key=lambda x: x["time"]): 628 | links = f"browse | diff" 629 | table.append([s["short_id"], format_date(s["time"]), str(s["hostname"]), str(s["tags"]), str(s["paths"]), links]) 630 | prev_id = s["short_id"] 631 | table.reverse() 632 | return (200, self.gen_table(table, ["ID", "Time", "Host", "Tags", "Paths", "Actions"])) 633 | 634 | def route_diff(self, profile_name, snapshot1, snapshot2): 635 | if not (profile := self.profiles.get(profile_name)): 636 | return (404, "Profile not found") 637 | proc = profile.run(["diff", snapshot1, snapshot2], stdout=PIPE, text_output=False) 638 | return (200, proc.stdout, "text/plain") 639 | 640 | def route_download(self, profile_name, snapshot_id, browse_path): 641 | if not (profile := self.profiles.get(profile_name)): 642 | return (404, "Profile not found") 643 | proc = profile.run(["dump", snapshot_id, browse_path], stdout=PIPE, text_output=False) 644 | return (200, proc.stdout, mimetypes.guess_type(browse_path)) 645 | 646 | def route_browse(self, profile_name, snapshot_id, browse_path=None): 647 | if not (profile := self.profiles.get(profile_name)): 648 | return (404, "Profile not found") 649 | 650 | path = str(PurePosixPath(browse_path or "/")).strip("/") 651 | 652 | if snapshot_id not in self.snapshots_cache: 653 | self.snapshots_cache[snapshot_id] = files = {} 654 | for line in profile.run(["ls", "--json", snapshot_id], stdout=PIPE).stdout: 655 | f = json.loads(line) 656 | if f and f["struct_type"] == "node": 657 | parent_path = str(PurePosixPath(f["path"]).parent).strip("/") 658 | node_path = str(PurePosixPath(f["path"])).strip("/") 659 | if f["type"] == "dir" and node_path not in files: 660 | files[node_path] = [] 661 | if parent_path not in files: 662 | files[parent_path] = [] 663 | files[parent_path].append([f["name"], f["type"], f["size"] if "size" in f else "", f["mtime"]]) 664 | 665 | if path not in self.snapshots_cache[snapshot_id]: 666 | return (404, "Path not found") 667 | 668 | files = sorted(self.snapshots_cache[snapshot_id][path], key=lambda x: x[1]) 669 | table = [] 670 | 671 | if len(browse_path) > 0: 672 | table.append([f"..", "", "", ""]) 673 | 674 | for name, type, size, mtime in files: 675 | dl_url = f"/{profile.name}/download/{snapshot_id}/{path}/{name}" 676 | nav_url = f"/{profile.name}/browse/{snapshot_id}/{path}/{name}" if type == 'dir' else dl_url 677 | table.append([f"{name}", str(size), format_date(mtime), f"Download"]) 678 | 679 | return (200, self.gen_table(table, ["Name", "Size", "Date modified", "Download"])) 680 | 681 | def respond(self, code, content, content_type="text/html; charset=utf-8"): 682 | self.send_response(code) 683 | self.send_header("Content-type", content_type) 684 | self.end_headers() 685 | if type(content) is str: 686 | segments = [] 687 | path = urllib.parse.unquote(self.path.split("?")[0]) 688 | path = PurePosixPath(path) 689 | while str(path) != "/": 690 | segments.append(f"{path.name}") 691 | path = path.parent 692 | segments.append("Home") 693 | content = f"

{' / '.join(reversed(segments))}

" + content 694 | self.wfile.write((self.template % content).encode("utf-8")) 695 | elif type(content) is bytes: 696 | self.wfile.write(content) 697 | else: 698 | while data := content.read(64 * 1024): 699 | self.wfile.write(data) 700 | 701 | def do_GET(self): 702 | routes = [ 703 | (r"^([^/]+)/diff/([a-z0-9]{6,})\.\.([a-z0-9]{6,})$", self.route_diff), # Browse files in snapshot 704 | (r"^([^/]+)/download/([a-z0-9]{6,})(/.*)$", self.route_download), # Download path in snapshot 705 | (r"^([^/]+)/browse/([a-z0-9]{6,})(|/.*)$", self.route_browse), # Browse files in snapshot 706 | (r"^([^/]+)$", self.route_snapshots), # list snapshots 707 | (r"^$", self.route_home), # list profiles 708 | ] 709 | 710 | path = urllib.parse.unquote(self.path.split("?")[0]) 711 | path = path.strip("/") 712 | 713 | for route_path, route_action in routes: 714 | if m := re.match(route_path, path): 715 | self.respond(*route_action(*m.groups())) 716 | return 717 | self.respond(404, "Not found") 718 | 719 | 720 | def time_diff(time, from_time=None): 721 | if not time: 722 | return "never" 723 | from_time = from_time or datetime.now() 724 | time_diff = (time - from_time).total_seconds() 725 | days = floor(abs(time_diff) / 86400) 726 | hours = floor(abs(time_diff) / 3600) % 24 727 | minutes = floor(abs(time_diff) / 60) % 60 728 | suffix = "from now" if time_diff > 0 else "ago" 729 | if abs(time_diff) < 60: 730 | return "just now" 731 | return f"{days}d {hours}h {minutes}m {suffix}" 732 | 733 | 734 | def os_open_url(path): 735 | if sys.platform == "win32": 736 | Popen(["start", str(path)], shell=True).wait() 737 | elif sys.platform == "darwin": 738 | Popen(["open", str(path)], shell=True).wait() 739 | else: 740 | Popen(["xdg-open", str(path)]).wait() 741 | 742 | 743 | def format_date(dt): 744 | if type(dt) is str: 745 | dt = re.sub(r"\.[0-9]{3,}", "", dt) # Python doesn't like variable ms precision 746 | dt = datetime.fromisoformat(dt) 747 | return str(dt) 748 | 749 | 750 | def parse_size(size): 751 | if m := re.match(f"^\s*([\d\.]+)\s*([BKMGTP])B?$", f"{size}".upper()): 752 | return int(float(m.group(1)) * (2 ** (10 * "BKMGTP".index(m.group(2))))) 753 | elif m := re.match(f"^\s*([\d]+)\s*$", f"{size}"): 754 | return int(m.group(1)) 755 | return 0 756 | 757 | 758 | def main(argv=None): 759 | parser = ArgumentParser(description="Prestic Backup Manager (for restic)") 760 | parser.add_argument("-c", "--config", default=None, help="config file") 761 | parser.add_argument("-p", "--profile", default="default", help="profile to use") 762 | parser.add_argument("--service", const=True, action="store_const", help="start service") 763 | parser.add_argument("--keyring", const=True, action="store_const", help="keyring management") 764 | parser.add_argument("command", nargs="...", help="restic command to run...") 765 | args = parser.parse_args(argv) 766 | 767 | logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO) 768 | 769 | if args.service: 770 | handler = ServiceHandler(args.config) 771 | elif args.keyring: 772 | handler = KeyringHandler(args.config) 773 | else: 774 | handler = CommandHandler(args.config) 775 | 776 | try: 777 | handler.run(args.profile, args.command) 778 | except KeyboardInterrupt: 779 | handler.stop() 780 | 781 | 782 | def gui(): 783 | # Fixes some issues when invoked by pythonw.exe (but we should use .prestic/stderr.txt I suppose) 784 | sys.stdout = sys.stdout or open(os.devnull, "w") 785 | sys.stderr = sys.stderr or open(os.devnull, "w") 786 | main([*sys.argv[1:], "--service"]) 787 | 788 | 789 | if __name__ == "__main__": 790 | main() 791 | -------------------------------------------------------------------------------- /restic-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ducalex/prestic/35492c7d6ef67a67c729f283feeb39a42ec42c68/restic-icon.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ducalex/prestic/35492c7d6ef67a67c729f283feeb39a42ec42c68/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup( 4 | name="prestic", 5 | version="0.0.2", 6 | license="MIT", 7 | author="Alex Duchesne", 8 | author_email="alex@alexou.net", 9 | description="Prestic is a profile manager and task scheduler for restic", 10 | url="https://github.com/ducalex/prestic", 11 | packages=["prestic"], 12 | classifiers=[ 13 | "Programming Language :: Python :: 3", 14 | "License :: OSI Approved :: MIT License", 15 | "Operating System :: OS Independent", 16 | ], 17 | python_requires=">=3.8", 18 | install_requires=[ 19 | "pystray", 20 | "keyring" 21 | ], 22 | entry_points={ 23 | "console_scripts": [ 24 | "prestic=prestic:main", 25 | ], 26 | "gui_scripts": [ 27 | "prestic-gui=prestic:gui", 28 | ], 29 | }, 30 | ) 31 | --------------------------------------------------------------------------------