SSDP server discovery draft

Mon, 02 Jun 2025 08:38:59 -0500

author
Tuomo Valkonen <tuomov@iki.fi>
date
Mon, 02 Jun 2025 08:38:59 -0500
changeset 148
ff975e768112
parent 147
c42d69c44170
child 149
4d5cb76f86a2

SSDP server discovery

.gitignore file | annotate | diff | comparison | revisions
.hgignore file | annotate | diff | comparison | revisions
README.md file | annotate | diff | comparison | revisions
announce/borg-announce.py file | annotate | diff | comparison | revisions
announce/borg-announce.service file | annotate | diff | comparison | revisions
announce/borg-announce.user.service file | annotate | diff | comparison | revisions
borgend/backup.py file | annotate | diff | comparison | revisions
borgend/config.py file | annotate | diff | comparison | revisions
borgend/instance.py file | annotate | diff | comparison | revisions
borgend/repository.py file | annotate | diff | comparison | revisions
borgend/ssdp_discover.py file | annotate | diff | comparison | revisions
borgend/ui.py file | annotate | diff | comparison | revisions
config.example.yaml file | annotate | diff | comparison | revisions
pyproject.toml file | annotate | diff | comparison | revisions
run.py file | annotate | diff | comparison | revisions
setup.py file | annotate | diff | comparison | revisions
--- /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"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/run.py	Mon Jun 02 08:38:59 2025 -0500
@@ -0,0 +1,3 @@
+from .borgend.__main__ import main
+
+main()
--- a/setup.py	Sun Dec 25 13:26:18 2022 +0200
+++ b/setup.py	Mon Jun 02 08:38:59 2025 -0500
@@ -14,6 +14,7 @@
     #setup_requires=['py2app'],
     install_requires=[
         "rumps",
+        "ssdpy",
         "keyring",
         "PyYAML",
         "xdg;platform_system!='darwin'",

mercurial