Mon, 02 Jun 2025 08:38:59 -0500
SSDP server discovery
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.gitignore Mon Jun 02 08:38:59 2025 -0500 @@ -0,0 +1,1 @@ +.hgignore \ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.hgignore Mon Jun 02 08:38:59 2025 -0500 @@ -0,0 +1,8 @@ +syntax: glob +build/ +*.pyc +__pycache__ +.eggs/ +dist/ +*.egg-info +*.orig
--- a/README.md Sun Dec 25 13:26:18 2022 +0200 +++ b/README.md Mon Jun 02 08:38:59 2025 -0500 @@ -21,6 +21,8 @@ remote `ssh` location, Borgend will also retry the backup at set shorter intervals. + * Can discover, e.g., home servers using SSDP/UPnP + The lead author is Tuomo Valkonen (<tuomov@iki.fi>). [Borg Backup]: https://www.borgbackup.org/ @@ -37,6 +39,7 @@ This will also install some additional Python libraries ([keyring](https://pypi.python.org/pypi/keyring), [pyyaml](http://pyyaml.org/), +[ssdpy](https://pypi.org/project/ssdpy/), [rumps](https://github.com/jaredks/rumps), and, if not on MacOS, [xdg](https://pypi.python.org/pypi/xdg/3.0.0)). Now you can start borgend with @@ -96,3 +99,18 @@ then create a non-standalone application. While not easily transferrable between different machines, it will still help with keychain permissions. +## SSDP + +Borgend can discover local servers using SDSP/UPnP. The server should have +a corresponding annoucement service running. Relevant Python script and +systemd setup files can be found under `announce/`. The `--uuid` parameter +of the script should match the `ssdp_uuid` configuration setting of the +repository, while `ssdp_path` should indicate the path within the server. +The announce script can also communicate user ID on the server, as set with +`--user`. + +For some additional security, `borgend` itself does not query the service +with the `uuid`, but merely with the service type `borg`. It will only attempt +to connect if a server with the correct UUID answers to its SSDP query. + +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/announce/borg-announce.py Mon Jun 02 08:38:59 2025 -0500 @@ -0,0 +1,35 @@ +#!/usr/bin/python3 +import argparse +from ssdpy import SSDPServer +import socket + +parser=argparse.ArgumentParser( + description="BorgBackup SSDP/UPnP announcer", + formatter_class=argparse.RawDescriptionHelpFormatter) + +parser.add_argument( + '--uuid', + dest='uuid', + required=True, + help='UUID of borgbackup instance') + +parser.add_argument( + '--user', + dest='user', + required=False, + help='Borgbackup user (for SSH login)') + +def main(): + args=parser.parse_args() + + # Find our own IP + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + my_ip = s.getsockname()[0] + s.close() + location = "ssh://{}{}/".format(args.user + "@" if args.user else "", my_ip) + + server = SSDPServer(usn = args.uuid, device_type = "borg", location = location) + server.serve_forever() + +main()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/announce/borg-announce.service Mon Jun 02 08:38:59 2025 -0500 @@ -0,0 +1,13 @@ +[Unit] +Description=BorgBackup announcer +Wants=network.target +After=network.target + +[Service] +User=borg +Restart=always +RestartSec=10 +ExecStart=/usr/local/bin/borg-announce.py --user=borg --uuid=borg@home.c5fad77dbff6157eba56597e9c404631 + +[Install] +WantedBy=multi-user.target
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/announce/borg-announce.user.service Mon Jun 02 08:38:59 2025 -0500 @@ -0,0 +1,12 @@ +[Unit] +Description=BorgBackup announcer +Wants=network.target +After=network.target + +[Service] +Restart=always +RestartSec=10 +ExecStart=/usr/local/bin/borg-announce.py --user=borg --uuid=borg@home.c5fad77dbff6157eba56597e9c404631 + +[Install] +WantedBy=default.target
--- a/borgend/backup.py Sun Dec 25 13:26:18 2022 +0200 +++ b/borgend/backup.py Mon Jun 02 08:38:59 2025 -0500 @@ -18,6 +18,7 @@ from .instance import BorgInstance from .scheduler import TerminableThread from .exprotect import protect_noreturn +from .ssdp_discover import discover_repository_provider _logger=logging.getLogger(__name__) @@ -33,7 +34,8 @@ PAUSED=1 SCHEDULED=2 QUEUED=3 - ACTIVE=4 + DISCOVERY=4 + ACTIVE=5 def __str__(self): return _statestring[self] @@ -65,6 +67,7 @@ State.PAUSED: 'paused', State.SCHEDULED: 'scheduled', State.QUEUED: 'queued', + State.DISCOVERY: 'discovery', State.ACTIVE: 'active' } @@ -177,7 +180,7 @@ return thistime, True -_prune_progress_re=re.compile(".*\(([0-9]+)/([0-9]+)\)$") +_prune_progress_re=re.compile(r".*\(([0-9]+)/([0-9]+)\)$") # Borg gives very little progress info in easy form, so try to extrat it def check_prune_status(msg): res=_prune_progress_re.match(msg) @@ -482,19 +485,39 @@ +self.repository.borg_parameters +self.borg_parameters) + if self.repository.location is None: + assert(self.repository.ssdp_uuid is not None) + assert(self.repository.ssdp_path is not None) + ssdp_uuid = self.repository.ssdp_uuid + ssdp_path = self.repository.ssdp_path + self.logger.debug('Starting SSDP discovery of service {}'.format(ssdp_uuid)) + self.__update_status(State.DISCOVERY) + #self._cond.release() + location = discover_repository_provider(ssdp_uuid) + #self._cond.acquire() + #self.__update_status(State.IDLE) # TODO: probably should update to old status + if location is None: + self.current_operation.add_error(Errors.OFFLINE) + self.logger.warn("Failed to discover repository provider {}".format(ssdp_uuid)) + else: + self.logger.debug("Found repository provider {} at {}".format(ssdp_uuid, location)) + self.__launch_with_location(op, params, "{}/{}".format(location, ssdp_path)) + else: + self.__launch_with_location(op, params, self.repository.location) + + def __launch_with_location(self, op, params, location): if op.type==Operation.CREATE: - archive="%s::%s%s" % (self.repository.location, + archive="%s::%s%s" % (location, self.archive_prefix, self.archive_template) self.__do_launch(op, archive, params.common, params.create, self.paths) elif op.type==Operation.PRUNE: - self.__do_launch(op, self.repository.location, params.common, + self.__do_launch(op, location, params.common, [{'glob-archives': self.archive_prefix + '*'}] + params.prune) - elif op.type==Operation.LIST: - self.__do_launch(op, self.repository.location, params.common, + self.__do_launch(op, location, params.common, [{'glob-archives': self.archive_prefix + '*'}]) else: raise NotImplementedError("Invalid operation '%s'" % str(op.type)) @@ -677,7 +700,7 @@ def __next_operation_list(self): reason='initial' - # Unless manual backup has been chosen (backup_interval<=0), perform + # Unless manual backup has been chosen (backup_interval<=0), perform # repository listing if no previous create operation known, or if we # just pruned the repository if self.backup_interval<=0: @@ -806,4 +829,3 @@ self.logger.debug('Resume signalled') self._pause=False self._cond.notify() -
--- a/borgend/config.py Sun Dec 25 13:26:18 2022 +0200 +++ b/borgend/config.py Mon Jun 02 08:38:59 2025 -0500 @@ -52,33 +52,33 @@ def error(x): raise AssertionError(x) -def check_field(cfg, field, descr, loc, default, check): +def check_field(cfg, field, descr, loc, default, check, *, is_optional = False): if field in cfg: tmp=cfg[field] if not check(tmp): error("%s is of invalid type for %s" % (field, loc)) return tmp else: - if default is not None: + if default is not None or is_optional: return default else: error("%s is not configured for %s" % (field, loc)) -def check_bool(cfg, field, descr, loc, default=None): +def check_bool(cfg, field, descr, loc, default=None, **kwargs): return check_field(cfg, field, descr, loc, default, - lambda x: isinstance(x, bool)) + lambda x: isinstance(x, bool), **kwargs) -def check_string(cfg, field, descr, loc, default=None): +def check_string(cfg, field, descr, loc, default=None, **kwargs): return check_field(cfg, field, descr, loc, default, - lambda x: isinstance(x, str)) + lambda x: isinstance(x, str), **kwargs) -def check_dict(cfg, field, descr, loc, default=None): +def check_dict(cfg, field, descr, loc, default=None, **kwargs): return check_field(cfg, field, descr, loc, default, - lambda x: isinstance(x, dict)) + lambda x: isinstance(x, dict), **kwargs) -def check_list(cfg, field, descr, loc, default=None): +def check_list(cfg, field, descr, loc, default=None, **kwargs): return check_field(cfg, field, descr, loc, default, - lambda x: isinstance(x, list)) + lambda x: isinstance(x, list), **kwargs) def is_list_of(x, chk): if x is None: @@ -175,4 +175,3 @@ defaults['borg'], check_string) borg_parameters=BorgParameters.from_config(settings['borg'], "top-level") -
--- a/borgend/instance.py Sun Dec 25 13:26:18 2022 +0200 +++ b/borgend/instance.py Mon Jun 02 08:38:59 2025 -0500 @@ -63,7 +63,7 @@ return (cmd+arglistify(self.op_params) +[self.archive_or_repository]+self.paths) - def launch(self, passphrase=None): + def launch(self, passphrase=None, *, relocated_ok = False): cmd=self.construct_cmdline() logger.info('Launching ' + str(cmd)) @@ -73,6 +73,8 @@ # close stdin. env=os.environ.copy() env['BORG_PASSPHRASE']=passphrase or '' + if relocated_ok: + env['BORG_RELOCATED_REPO_ACCESS_IS_OK'] = 'yes' # Workaround: if launched is a standalone app created with py2app, # borg will fail unless Python environment is reset. @@ -157,4 +159,3 @@ def has_terminated(self): return not self.proc or (self.proc.poll() is not None) -
--- a/borgend/repository.py Sun Dec 25 13:26:18 2022 +0200 +++ b/borgend/repository.py Mon Jun 02 08:38:59 2025 -0500 @@ -109,8 +109,17 @@ self.logger=logger.getChild(self.repository_name) + self.ssdp_uuid=config.check_string(cfg, 'ssdp_uuid', + 'SSDP service UUID', loc, + is_optional = True) + + self.ssdp_path=config.check_string(cfg, 'ssdp_path', + 'Path within SSDP service', loc, + is_optional = self.ssdp_uuid is None) + self.location=config.check_string(cfg, 'location', - 'Target repository location', loc) + 'Target repository location', loc, + is_optional = self.ssdp_uuid is not None) self.borg_parameters=config.BorgParameters.from_config(cfg, loc) @@ -149,6 +158,9 @@ self.__passphrase=None return self.__passphrase + def is_ssdp(self): + return self.location is None and self.ssdp_uuid is not None + def launch_borg_instance(self, inst): try: self.logger.debug('launch_borg_instance: entering _cond') @@ -159,12 +171,10 @@ self.logger.error('Aborting operation due to failure to obtain passphrase') raise err else: - inst.launch(passphrase=passphrase) + inst.launch(passphrase=passphrase, relocated_ok = self.is_ssdp()) def find_repository(name): if name in repositories: return repositories[name] else: return None - -
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/borgend/ssdp_discover.py Mon Jun 02 08:38:59 2025 -0500 @@ -0,0 +1,11 @@ +from ssdpy import SSDPClient + +def discover_repository_provider(uuid): + # TODO: this should be initialised only once + client = SSDPClient() + devices = client.m_search("borg") + for device in devices: + if device["usn"] == uuid: + return device["location"] + + return None
--- a/borgend/ui.py Sun Dec 25 13:26:18 2022 +0200 +++ b/borgend/ui.py Mon Jun 02 08:38:59 2025 -0500 @@ -28,6 +28,7 @@ backup.State.PAUSED: 'B‖', backup.State.SCHEDULED: 'B.', backup.State.QUEUED: 'B:', + backup.State.DISCOVERY: 'B¡', backup.State.ACTIVE: 'B!', } @@ -159,6 +160,8 @@ elif status.state==backup.State.QUEUED: info=add_info(info, "queued %s" % status.type) + elif status.state==backup.State.DISCOVERY: + info=add_info(info, "discovering") elif status.state==backup.State.ACTIVE: # Operation running progress='' @@ -232,9 +235,9 @@ def __rebuild_menu(self, update): if not update: self.itemlist=[] - + itemlist=self.itemlist - + state=(backup.State.INACTIVE, backup.Errors.OK) refresh_time=None all_paused=True @@ -251,7 +254,7 @@ itemstate=-1 elif this_state[0]==backup.State.SCHEDULED or this_state[0]==backup.State.QUEUED: itemstate=1 - + if update: item=itemlist[index] if item.title!=title: @@ -262,7 +265,7 @@ item=rumps.MenuItem(title, callback=cbm) item.state=itemstate itemlist.append(item) - + state=combine_state(state, this_state) all_paused=(all_paused and this_state[0]==backup.State.PAUSED) @@ -292,20 +295,20 @@ if not settings['no_quit_menu_entry']: menu_quit=rumps.MenuItem("Quit...", callback=lambda _: self.quit()) itemlist.append(menu_quit) - + return itemlist, state, refresh_time def build_menu_and_timer(self, update=False): logger.debug('Rebuilding menu') itemlist, state, refresh_time=self.__rebuild_menu(update) title=trayname(state) - + if update and self.title!=title: logger.debug("set title %s" % title) self.title=title self.refresh_timer.stop() - + if self.updated_recently: self.updated_recently=False if self.refresh_timer.interval>refresh_interval: @@ -338,7 +341,7 @@ def __refresh_callback(self, timer): with self.lock: self.build_menu_and_timer(True) - + @protect_noreturn def __status_callback(self, index, status, errorlog=None): logger.debug("Tray status callback") @@ -432,4 +435,3 @@ @rumps.notifications def notification_center(_): showlog() -
--- a/config.example.yaml Sun Dec 25 13:26:18 2022 +0200 +++ b/config.example.yaml Mon Jun 02 08:38:59 2025 -0500 @@ -40,11 +40,16 @@ location: /Volumes/backup1/borg keychain_account: borg-backup@mylaptop + - name: homeserver + ssdp_uuid: borg@home.c5fad77dbff6157eba56597e9c404631 + ssdp_path: /mnt/backup/borg + keychain_account: borg-backup@mylaptop + # Backups: configure here which files should be backed up, how frequently, and to # which repositires. backups: - # 1. Most files in $HOME to ssh://myserver.invalid - - name: Home to 'myserver' + # 1. Most files in $HOME to homeserver, discovered by SSDP + - name: Home to 'homeserver' # Scheduling mode: dreamtime/realtime/manual # Dreamtime scheduling discounts system sleep periods. scheduling: dreamtime @@ -55,7 +60,7 @@ # Prune interval: once every two weeks prune_interval: 1209600 # Repository and archive configuration - repository: myserver + repository: homeserver archive_prefix: 'all@mylaptop-' archive_template: '{now:%Y-%m-%d_%H:%M:%S}' # Files to include @@ -105,5 +110,3 @@ - pattern: "+ $HOME/Library/Mobile Documents/" - pattern: "- $HOME/Library/" - pattern: "- $HOME/.config/borg/security/" - -
--- a/pyproject.toml Sun Dec 25 13:26:18 2022 +0200 +++ b/pyproject.toml Mon Jun 02 08:38:59 2025 -0500 @@ -1,3 +1,3 @@ [build-system] -requires = ["setuptools", "wheel", "py2app"] +requires = ["setuptools", "wheel", "py2app", "ssdpy"] build-backend = "setuptools.build_meta"