Repository: borgbackup/borg

Issue:
Please enable IPv6 addresses for remote repositories
Hello,

I was trying to specify a remote borg repo as something like that
ssh://user@[ipv6_address_string]:/path/to/repo
(and tried with leaving out the ssh:// in front, as well)

borg then somehow tried to read the ipv6 address as a hostname, and the hostname lookup naturally failed.

ssh supports ipv6 address string, by putting the address into a square bracket.

I have a use case where I want to backup to a remote repository via ssh onto a machine by ipv6. However, I do not want to expose machines IPv6 address to DNS, that's why I would like to use an address literal instead of a dns domain name in the remote repo url.

Regards,
wararjey


Discussion:
I'ld suggest you make a hosts file entry with a hostname for the host's ipv6 address.

Then you don't need to type complicated/long v6 addresses all the time when you work with borg (or ssh) and you do not need to put it into dns either. "Not exposing to DNS" sounds a bit like security by obscurity btw.

Borg's location URL parsing is already quite complex and ipv6 having ":" and "::" as valid address parts likely would complicate it significantly.
Thanks for the hint with the hosts file, I hadn't thought of that.

I wonder if borg could not let ssh do the hostname/ipaddress parsing for the host part of the url, then you'd get that 'feature' for free...

Regards,
wararjey

Resolving patch:
diff --git a/src/borg/helpers.py b/src/borg/helpers.py
--- a/src/borg/helpers.py
+++ b/src/borg/helpers.py
@@ -769,7 +769,7 @@ def bin_to_hex(binary):
 class Location:
     """Object representing a repository / archive location
     """
-    proto = user = host = port = path = archive = None
+    proto = user = _host = port = path = archive = None
 
     # user must not contain "@", ":" or "/".
     # Quoting adduser error message:
@@ -814,7 +814,7 @@ class Location:
     ssh_re = re.compile(r"""
         (?P<proto>ssh)://                                   # ssh://
         """ + optional_user_re + r"""                       # user@  (optional)
-        (?P<host>[^:/]+)(?::(?P<port>\d+))?                 # host or host:port
+        (?P<host>([^:/]+|\[[0-9a-fA-F:.]+\]))(?::(?P<port>\d+))?  # host or host:port or [ipv6] or [ipv6]:port
         """ + abs_path_re + optional_archive_re, re.VERBOSE)  # path or path::archive
 
     file_re = re.compile(r"""
@@ -825,7 +825,7 @@ class Location:
     scp_re = re.compile(r"""
         (
             """ + optional_user_re + r"""                   # user@  (optional)
-            (?P<host>[^:/]+):                               # host: (don't match / in host to disambiguate from file:)
+            (?P<host>([^:/]+|\[[0-9a-fA-F:.]+\])):          # host: (don't match / or [ipv6] in host to disambiguate from file:)
         )?                                                  # user@host: part is optional
         """ + scp_path_re + optional_archive_re, re.VERBOSE)  # path with optional archive
 
@@ -841,7 +841,7 @@ class Location:
     def __init__(self, text=''):
         self.orig = text
         if not self.parse(self.orig):
-            raise ValueError
+            raise ValueError('Location: parse failed: %s' % self.orig)
 
     def parse(self, text):
         text = replace_placeholders(text)
@@ -871,7 +871,7 @@ def normpath_special(p):
         if m:
             self.proto = m.group('proto')
             self.user = m.group('user')
-            self.host = m.group('host')
+            self._host = m.group('host')
             self.port = m.group('port') and int(m.group('port')) or None
             self.path = normpath_special(m.group('path'))
             self.archive = m.group('archive')
@@ -885,10 +885,10 @@ def normpath_special(p):
         m = self.scp_re.match(text)
         if m:
             self.user = m.group('user')
-            self.host = m.group('host')
+            self._host = m.group('host')
             self.path = normpath_special(m.group('path'))
             self.archive = m.group('archive')
-            self.proto = self.host and 'ssh' or 'file'
+            self.proto = self._host and 'ssh' or 'file'
             return True
         return False
 
@@ -912,6 +912,12 @@ def to_key_filename(self):
     def __repr__(self):
         return "Location(%s)" % self
 
+    @property
+    def host(self):
+        # strip square brackets used for IPv6 addrs
+        if self._host is not None:
+            return self._host.lstrip('[').rstrip(']')
+
     def canonical_path(self):
         if self.proto == 'file':
             return self.path
@@ -923,7 +929,7 @@ def canonical_path(self):
             else:
                 path = self.path
             return 'ssh://{}{}{}{}'.format('{}@'.format(self.user) if self.user else '',
-                                           self.host,
+                                           self._host,  # needed for ipv6 addrs
                                            ':{}'.format(self.port) if self.port else '',
                                            path)
 
diff --git a/src/borg/testsuite/helpers.py b/src/borg/testsuite/helpers.py
--- a/src/borg/testsuite/helpers.py
+++ b/src/borg/testsuite/helpers.py
@@ -58,6 +58,30 @@ def test_ssh(self, monkeypatch):
             "Location(proto='ssh', user='user', host='host', port=1234, path='/some/path', archive=None)"
         assert repr(Location('ssh://user@host/some/path')) == \
             "Location(proto='ssh', user='user', host='host', port=None, path='/some/path', archive=None)"
+        assert repr(Location('ssh://user@[::]:1234/some/path::archive')) == \
+            "Location(proto='ssh', user='user', host='::', port=1234, path='/some/path', archive='archive')"
+        assert repr(Location('ssh://user@[::]:1234/some/path')) == \
+            "Location(proto='ssh', user='user', host='::', port=1234, path='/some/path', archive=None)"
+        assert repr(Location('ssh://user@[::]/some/path')) == \
+            "Location(proto='ssh', user='user', host='::', port=None, path='/some/path', archive=None)"
+        assert repr(Location('ssh://user@[2001:db8::]:1234/some/path::archive')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::', port=1234, path='/some/path', archive='archive')"
+        assert repr(Location('ssh://user@[2001:db8::]:1234/some/path')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::', port=1234, path='/some/path', archive=None)"
+        assert repr(Location('ssh://user@[2001:db8::]/some/path')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::', port=None, path='/some/path', archive=None)"
+        assert repr(Location('ssh://user@[2001:db8::c0:ffee]:1234/some/path::archive')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::c0:ffee', port=1234, path='/some/path', archive='archive')"
+        assert repr(Location('ssh://user@[2001:db8::c0:ffee]:1234/some/path')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::c0:ffee', port=1234, path='/some/path', archive=None)"
+        assert repr(Location('ssh://user@[2001:db8::c0:ffee]/some/path')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::c0:ffee', port=None, path='/some/path', archive=None)"
+        assert repr(Location('ssh://user@[2001:db8::192.0.2.1]:1234/some/path::archive')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::192.0.2.1', port=1234, path='/some/path', archive='archive')"
+        assert repr(Location('ssh://user@[2001:db8::192.0.2.1]:1234/some/path')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::192.0.2.1', port=1234, path='/some/path', archive=None)"
+        assert repr(Location('ssh://user@[2001:db8::192.0.2.1]/some/path')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::192.0.2.1', port=None, path='/some/path', archive=None)"
 
     def test_file(self, monkeypatch):
         monkeypatch.delenv('BORG_REPO', raising=False)
@@ -72,6 +96,22 @@ def test_scp(self, monkeypatch):
             "Location(proto='ssh', user='user', host='host', port=None, path='/some/path', archive='archive')"
         assert repr(Location('user@host:/some/path')) == \
             "Location(proto='ssh', user='user', host='host', port=None, path='/some/path', archive=None)"
+        assert repr(Location('user@[::]:/some/path::archive')) == \
+            "Location(proto='ssh', user='user', host='::', port=None, path='/some/path', archive='archive')"
+        assert repr(Location('user@[::]:/some/path')) == \
+            "Location(proto='ssh', user='user', host='::', port=None, path='/some/path', archive=None)"
+        assert repr(Location('user@[2001:db8::]:/some/path::archive')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::', port=None, path='/some/path', archive='archive')"
+        assert repr(Location('user@[2001:db8::]:/some/path')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::', port=None, path='/some/path', archive=None)"
+        assert repr(Location('user@[2001:db8::c0:ffee]:/some/path::archive')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::c0:ffee', port=None, path='/some/path', archive='archive')"
+        assert repr(Location('user@[2001:db8::c0:ffee]:/some/path')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::c0:ffee', port=None, path='/some/path', archive=None)"
+        assert repr(Location('user@[2001:db8::192.0.2.1]:/some/path::archive')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::192.0.2.1', port=None, path='/some/path', archive='archive')"
+        assert repr(Location('user@[2001:db8::192.0.2.1]:/some/path')) == \
+            "Location(proto='ssh', user='user', host='2001:db8::192.0.2.1', port=None, path='/some/path', archive=None)"
 
     def test_smb(self, monkeypatch):
         monkeypatch.delenv('BORG_REPO', raising=False)


Test patch: