diff --git a/backup/remote.md b/backup/remote.md index ba7d4ac..f8108dc 100644 --- a/backup/remote.md +++ b/backup/remote.md @@ -15,7 +15,8 @@ The remote backup system consists of two parts: - A server daemon that receives changes from the backup backend and communicates with a local backup backend to store them. The server side does not need to be running c-lightning, nor have it installed. -The backend URL format is `socket::`. For example `socket:127.0.0.1:1234`. +The backend URL format is `socket::`. For example `socket:127.0.0.1:1234`. To supply a IPv6 +address use the bracketed syntax `socket:[::1]:1234`. To run the server against a local backend use `backup-cli server file://.../ 127.0.0.1:1234`. diff --git a/backup/socketbackend.py b/backup/socketbackend.py index c020a5a..689fbe0 100644 --- a/backup/socketbackend.py +++ b/backup/socketbackend.py @@ -1,21 +1,71 @@ -import json -import logging, socket, struct +from collections import namedtuple +import json, logging, socket, re, struct from typing import Tuple, Iterator -from urllib.parse import urlparse +from urllib.parse import urlparse, parse_qs from backend import Backend, Change from protocol import PacketType, recvall, PKT_CHANGE_TYPES, change_from_packet, packet_from_change, send_packet, recv_packet +SocketURLInfo = namedtuple('SocketURLInfo', ['host', 'port', 'addrtype']) + +class AddrType: + IPv4 = 0 + IPv6 = 1 + NAME = 2 + +def parse_socket_url(destination: str) -> SocketURLInfo: + '''Parse a socket: URL to extract the information contained in it.''' + url = urlparse(destination) + if url.scheme != 'socket': + raise ValueError('Scheme for socket backend must be socket:...') + + if url.path.startswith('['): # bracketed IPv6 address + eidx = url.path.find(']') + if eidx == -1: + raise ValueError('Unterminated bracketed host address.') + host = url.path[1:eidx] + addrtype = AddrType.IPv6 + eidx += 1 + if eidx >= len(url.path) or url.path[eidx] != ':': + raise ValueError('Port number missing.') + eidx += 1 + else: + eidx = url.path.find(':') + if eidx == -1: + raise ValueError('Port number missing.') + host = url.path[0:eidx] + if re.match('\d+\.\d+\.\d+\.\d+$', host): # matches IPv4 address format + addrtype = AddrType.IPv4 + else: + addrtype = AddrType.NAME + eidx += 1 + + try: + port = int(url.path[eidx:]) + except ValueError: + raise ValueError('Invalid port number') + + # parse query parameters + # reject unknown parameters (currently all of them) + qs = parse_qs(url.query) + if len(qs): + raise ValueError('Invalid query string') + + return SocketURLInfo(host=host, port=port, addrtype=addrtype) + class SocketBackend(Backend): def __init__(self, destination: str, create: bool): self.version = None self.prev_version = None self.destination = destination - self.url = urlparse(self.destination) - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - (host, port) = self.url.path.split(':') - logging.info('Initialized socket backend') - self.sock.connect((host, int(port))) + self.url = parse_socket_url(destination) + if self.url.addrtype == AddrType.IPv6: + self.sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + else: # TODO NAME is assumed to be IPv4 for now + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + logging.info('Initialized socket backend, connecting to {}:{} (addrtype {})...'.format( + self.url.host, self.url.port, self.url.addrtype)) + self.sock.connect((self.url.host, self.url.port)) logging.info('Connected to {}'.format(destination)) def _send_packet(self, typ: int, payload: bytes) -> None: diff --git a/backup/test_backup.py b/backup/test_backup.py index ccce5e8..a3afebc 100644 --- a/backup/test_backup.py +++ b/backup/test_backup.py @@ -1,5 +1,6 @@ from backend import Backend from filebackend import FileBackend +import socketbackend from flaky import flaky from pyln.testing.fixtures import * # noqa: F401,F403 from pyln.testing.utils import sync_blockheight @@ -279,3 +280,38 @@ def test_compact(bitcoind, directory, node_factory): l1.rpc.backup_compact() tmp = tempfile.TemporaryDirectory() subprocess.check_call([cli_path, "restore", bdest, tmp.name]) + +def test_parse_socket_url(): + with pytest.raises(ValueError): + # fail: invalid url scheme + socketbackend.parse_socket_url('none') + # fail: no port number + socketbackend.parse_socket_url('socket:127.0.0.1') + socketbackend.parse_socket_url('socket:127.0.0.1:') + # fail: unbracketed IPv6 + socketbackend.parse_socket_url('socket:::1:1234') + # fail: no port number IPv6 + socketbackend.parse_socket_url('socket:[::1]') + socketbackend.parse_socket_url('socket:[::1]:') + # fail: invalid port number + socketbackend.parse_socket_url('socket:127.0.0.1:12bla') + # fail: unrecognized query string key + socketbackend.parse_socket_url('socket:127.0.0.1:1234?dummy=value') + + # IPv4 + s = socketbackend.parse_socket_url('socket:127.0.0.1:1234') + assert(s.host == '127.0.0.1') + assert(s.port == 1234) + assert(s.addrtype == socketbackend.AddrType.IPv4) + + # IPv6 + s = socketbackend.parse_socket_url('socket:[::1]:1235') + assert(s.host == '::1') + assert(s.port == 1235) + assert(s.addrtype == socketbackend.AddrType.IPv6) + + # Hostname + s = socketbackend.parse_socket_url('socket:backup.local:1236') + assert(s.host == 'backup.local') + assert(s.port == 1236) + assert(s.addrtype == socketbackend.AddrType.NAME)