First commands with basic structure.

- Moves DBManager to common.
This commit is contained in:
Sergi Delgado Segura
2020-04-09 17:45:34 +02:00
parent b5c587b7c5
commit ed8ff228d8
15 changed files with 603 additions and 6 deletions

View File

@@ -278,7 +278,7 @@ class Cryptographer:
return None return None
if not isinstance(sk, PrivateKey): if not isinstance(sk, PrivateKey):
logger.error("The value passed as sk is not a private key (EllipticCurvePrivateKey)") logger.error("The value passed as sk is not a private key (PrivateKey)")
return None return None
try: try:

View File

@@ -1,11 +1,10 @@
import json import json
import plyvel import plyvel
from teos.db_manager import DBManager
from teos import LOG_PREFIX from teos import LOG_PREFIX
from common.logger import Logger from common.logger import Logger
from common.db_manager import DBManager
logger = Logger(actor="AppointmentsDBM", log_name_prefix=LOG_PREFIX) logger = Logger(actor="AppointmentsDBM", log_name_prefix=LOG_PREFIX)

View File

@@ -2,9 +2,9 @@ import json
import plyvel import plyvel
from teos import LOG_PREFIX from teos import LOG_PREFIX
from teos.db_manager import DBManager
from common.logger import Logger from common.logger import Logger
from common.db_manager import DBManager
from common.tools import is_compressed_pk from common.tools import is_compressed_pk
logger = Logger(actor="UsersDBM", log_name_prefix=LOG_PREFIX) logger = Logger(actor="UsersDBM", log_name_prefix=LOG_PREFIX)

View File

@@ -1,5 +1,8 @@
import pytest import pytest
import random import random
from shutil import rmtree
from common.db_manager import DBManager
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
@@ -7,6 +10,17 @@ def prng_seed():
random.seed(0) random.seed(0)
@pytest.fixture(scope="module")
def db_manager():
manager = DBManager("test_db")
# Add last know block for the Responder in the db
yield manager
manager.db.close()
rmtree("test_db")
def get_random_value_hex(nbytes): def get_random_value_hex(nbytes):
pseudo_random_value = random.getrandbits(8 * nbytes) pseudo_random_value = random.getrandbits(8 * nbytes)
prv_hex = "{:x}".format(pseudo_random_value) prv_hex = "{:x}".format(pseudo_random_value)

View File

@@ -2,8 +2,8 @@ import os
import shutil import shutil
import pytest import pytest
from teos.db_manager import DBManager from common.db_manager import DBManager
from test.teos.unit.conftest import get_random_value_hex from test.common.unit.conftest import get_random_value_hex
def open_create_db(db_path): def open_create_db(db_path):

View File

@@ -0,0 +1,74 @@
from common.tools import is_compressed_pk, is_locator
from exceptions import InvalidParameter
def parse_register_arguments(args, default_port):
# Sanity checks
if len(args) == 0:
raise InvalidParameter("missing required parameter: tower_id")
if len(args) > 3:
raise InvalidParameter("too many parameters: got {}, expected 3".format(len(args)))
tower_id = args[0]
if not isinstance(tower_id, str):
raise InvalidParameter("tower id must be a compressed public key (33-byte hex value) " + str(args))
# tower_id is of the form tower_id@[ip][:][port]
if "@" in tower_id:
if len(args) == 1:
tower_id, tower_endpoint = tower_id.split("@")
if not tower_endpoint:
raise InvalidParameter("no tower endpoint was provided")
# Only host was specified
if ":" not in tower_endpoint:
tower_endpoint = "{}:{}".format(tower_endpoint, default_port)
# Colons where specified but no port, defaulting
elif tower_endpoint.endswith(":"):
tower_endpoint = "{}{}".format(tower_endpoint, default_port)
else:
raise InvalidParameter("cannot specify host as both xxx@yyy and separate arguments")
# host was specified, but no port, defaulting
elif len(args) == 2:
tower_endpoint = "{}:{}".format(args[1], default_port)
# host and port specified
elif len(args) == 3:
tower_endpoint = "{}:{}".format(args[1], args[2])
else:
raise InvalidParameter("tower host is missing")
if not is_compressed_pk(tower_id):
raise InvalidParameter("tower id must be a compressed public key (33-byte hex value)")
return tower_id, tower_endpoint
def parse_get_appointment_arguments(args):
# Sanity checks
if len(args) == 0:
raise InvalidParameter("missing required parameter: tower_id")
if len(args) == 1:
raise InvalidParameter("missing required parameter: locator")
if len(args) > 2:
raise InvalidParameter("too many parameters: got {}, expected 2".format(len(args)))
tower_id = args[0]
locator = args[1]
if not is_compressed_pk(tower_id):
raise InvalidParameter("tower id must be a compressed public key (33-byte hex value)")
if not is_locator(locator):
raise InvalidParameter("The provided locator is not valid", locator=locator)
return tower_id, locator

View File

@@ -0,0 +1,37 @@
class BasicException(Exception):
def __init__(self, msg, **kwargs):
self.msg = msg
self.kwargs = kwargs
def __str__(self):
if len(self.kwargs) > 2:
params = "".join("{}={}, ".format(k, v) for k, v in self.kwargs.items())
# Remove the extra 2 characters (space and comma) and add all data to the final message.
message = self.msg + " ({})".format(params[:-2])
else:
message = self.msg
return message
def to_json(self):
response = {"error": self.msg}
response.update(self.kwargs)
return response
class InvalidParameter(BasicException):
"""Raised when a command line parameter is invalid (either missing or wrong)"""
class InvalidKey(BasicException):
"""Raised when there is an error loading the keys"""
class TowerConnectionError(BasicException):
"""Raised when the tower responds with an error"""
class TowerResponseError(BasicException):
"""Raised when the tower responds with an error"""

94
watchtower-plugin/keys.py Normal file
View File

@@ -0,0 +1,94 @@
import os.path
from pathlib import Path
from binascii import hexlify
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from exceptions import InvalidKey
from common.cryptographer import Cryptographer
def save_key(sk, filename):
"""
Saves secret key on disk.
Args:
sk (:obj:`PrivateKey`): a private key file to be saved on disk.
filename (:obj:`str`): the name that will be given to the key file.
"""
der = sk.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
with open(filename, "wb") as der_out:
der_out.write(der)
def generate_keys(data_dir):
"""
Generates a key pair for the client.
Args:
data_dir (:obj:`str`): path to data directory where the keys will be stored.
Returns:
:obj:`tuple`: a tuple containing a ``PrivateKey`` and a ``str`` representing the client sk and compressed
pk respectively.
Raises:
:obj:`FileExistsError`: if the key pair already exists in the given directory.
"""
# Create the output folder it it does not exist (and all the parents if they don't either)
Path(data_dir).mkdir(parents=True, exist_ok=True)
sk_file_name = os.path.join(data_dir, "cli_sk.der")
if os.path.exists(sk_file_name):
raise FileExistsError("The client key pair already exists")
sk = ec.generate_private_key(ec.SECP256K1, default_backend())
save_key(sk, sk_file_name)
compressed_pk = sk.public_key().public_bytes(
encoding=serialization.Encoding.X962, format=serialization.PublicFormat.CompressedPoint
)
return sk, hexlify(compressed_pk).decode("utf-8")
def load_keys(data_dir):
"""
Loads a the client key pair.
Args:
data_dir (:obj:`str`): path to data directory where the keys are stored.
Returns:
:obj:`tuple`: a tuple containing a ``PrivateKey`` and a ``str`` representing the client sk and compressed
pk respectively.
Raises:
:obj:`InvalidKey <cli.exceptions.InvalidKey>`: if any of the keys is invalid or cannot be loaded.
"""
if not isinstance(data_dir, str):
raise ValueError("Invalid data_dir. Please check your settings")
sk_file_path = os.path.join(data_dir, "cli_sk.der")
cli_sk_der = Cryptographer.load_key_file(sk_file_path)
cli_sk = Cryptographer.load_private_key_der(cli_sk_der)
if cli_sk is None:
raise InvalidKey("Client private key is invalid or cannot be parsed")
compressed_cli_pk = Cryptographer.get_compressed_pk(cli_sk.public_key)
if compressed_cli_pk is None:
raise InvalidKey("Client public key cannot be loaded")
return cli_sk, compressed_cli_pk

View File

@@ -0,0 +1,69 @@
import json
import requests
from requests import ConnectionError, ConnectTimeout
from requests.exceptions import MissingSchema, InvalidSchema, InvalidURL
from common import constants
from exceptions import TowerConnectionError, TowerResponseError
def post_request(data, endpoint):
"""
Sends a post request to the tower.
Args:
data (:obj:`dict`): a dictionary containing the data to be posted.
endpoint (:obj:`str`): the endpoint to send the post request.
Returns:
:obj:`dict`: a json-encoded dictionary with the server response if the data can be posted.
Raises:
:obj:`ConnectionError`: if the client cannot connect to the tower.
"""
try:
return requests.post(url=endpoint, json=data, timeout=5)
except ConnectTimeout:
message = "Can't connect to the Eye of Satoshi at {}. Connection timeout".format(endpoint)
except ConnectionError:
message = "Can't connect to the Eye of Satoshi at {}. Tower cannot be reached".format(endpoint)
except (InvalidSchema, MissingSchema, InvalidURL):
message = "Invalid URL. No schema, or invalid schema, found ({})".format(endpoint)
raise TowerConnectionError(message)
def process_post_response(response):
"""
Processes the server response to a post request.
Args:
response (:obj:`requests.models.Response`): a ``Response`` object obtained from the request.
Returns:
:obj:`dict`: a dictionary containing the tower's response data if the response type is
``HTTP_OK``.
Raises:
:obj:`TowerResponseError <cli.exceptions.TowerResponseError>`: if the tower responded with an error, or the
response was invalid.
"""
try:
response_json = response.json()
except (json.JSONDecodeError, AttributeError):
raise TowerResponseError(
"The server returned a non-JSON response", status_code=response.status_code, reason=response.reason
)
if response.status_code not in [constants.HTTP_OK, constants.HTTP_NOT_FOUND]:
raise TowerResponseError(
"The server returned an error", status_code=response.status_code, reason=response.reason, data=response_json
)
return response_json

View File

@@ -0,0 +1,6 @@
pyln-client
requests
coincurve
cryptography
pyzbase32
plyvel

View File

@@ -0,0 +1,3 @@
[teos]
api_port = 9814

View File

@@ -0,0 +1,19 @@
class TowerInfo:
def __init__(self, endpoint, available_slots):
self.endpoint = endpoint
self.available_slots = available_slots
@classmethod
def from_dict(cls, tower_data):
endpoint = tower_data.get("endpoint")
available_slots = tower_data.get("available_slots")
if any(v is None for v in [endpoint, available_slots]):
raise ValueError("Wrong appointment data, some fields are missing")
if available_slots is None:
raise ValueError("Wrong tower data, some fields are missing")
return cls(endpoint, available_slots)
def to_dict(self):
return self.__dict__

View File

@@ -0,0 +1,119 @@
import json
from common.db_manager import DBManager
from common.tools import is_compressed_pk
class TowersDBM(DBManager):
"""
The :class:`TowersDBM` is in charge of interacting with the towers database (``LevelDB``).
Keys and values are stored as bytes in the database but processed as strings by the manager.
Args:
db_path (:obj:`str`): the path (relative or absolute) to the system folder containing the database. A fresh
database will be created if the specified path does not contain one.
Raises:
:obj:`ValueError`: If the provided ``db_path`` is not a string.
:obj:`plyvel.Error`: If the db is currently unavailable (being used by another process).
"""
def __init__(self, db_path, plugin):
if not isinstance(db_path, str):
raise ValueError("db_path must be a valid path/name")
super().__init__(db_path)
self.plugin = plugin
def store_tower_record(self, tower_id, tower_data):
"""
Stores a tower record to the database. ``tower_id`` is used as identifier.
Args:
tower_id (:obj:`str`): a 33-byte hex-encoded string identifying the tower.
tower_data (:obj:`dict`): the tower associated data, as a dictionary.
Returns:
:obj:`bool`: True if the tower record was stored in the database, False otherwise.
"""
if is_compressed_pk(tower_id):
try:
self.create_entry(tower_id, json.dumps(tower_data.to_dict()))
self.plugin.log("Adding tower to Tower's db (id={})".format(tower_id))
return True
except (json.JSONDecodeError, TypeError):
self.plugin.log(
"Could't add tower to db. Wrong tower data format (tower_id={}, tower_data={})".format(
tower_id, tower_data
)
)
return False
else:
self.plugin.log(
"Could't add user to db. Wrong pk format (tower_id={}, tower_data={})".format(tower_id, tower_data)
)
return False
def load_tower_record(self, tower_id):
"""
Loads a tower record from the database using the ``tower_id`` as identifier.
Args:
tower_id (:obj:`str`): a 33-byte hex-encoded string identifying the tower.
Returns:
:obj:`dict`: A dictionary containing the tower data if the ``key`` is found.
Returns ``None`` otherwise.
"""
try:
data = self.load_entry(tower_id)
data = json.loads(data)
except (TypeError, json.decoder.JSONDecodeError):
data = None
return data
def delete_tower_record(self, tower_id):
"""
Deletes a tower record from the database.
Args:
tower_id (:obj:`str`): a 33-byte hex-encoded string identifying the tower.
Returns:
:obj:`bool`: True if the tower was deleted from the database or it was non-existent, False otherwise.
"""
try:
self.delete_entry(tower_id)
self.plugin.log("Deleting tower from Tower's db (id={})".format(tower_id))
return True
except TypeError:
self.plugin.log("Cannot delete user from db, user key has wrong type (id={})".format(tower_id))
return False
def load_all_tower_records(self):
"""
Loads all tower records from the database.
Returns:
:obj:`dict`: A dictionary containing all tower records indexed by ``tower_id``.
Returns an empty dictionary if no data is found.
"""
data = {}
for k, v in self.db.iterator():
# Get uuid and appointment_data from the db
tower_id = k.decode("utf-8")
data[tower_id] = json.loads(v)
return data

163
watchtower-plugin/watchtower.py Executable file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
import os
import plyvel
from pyln.client import Plugin
from common.config_loader import ConfigLoader
from common.cryptographer import Cryptographer
import arg_parser
from tower_info import TowerInfo
from towers_dbm import TowersDBM
from keys import generate_keys, load_keys
from net.http import post_request, process_post_response
from exceptions import TowerConnectionError, TowerResponseError, InvalidParameter
DATA_DIR = os.path.expanduser("~/.teos_cli/")
CONF_FILE_NAME = "teos_cli.conf"
DEFAULT_CONF = {
"DEFAULT_PORT": {"value": 9814, "type": int},
"APPOINTMENTS_FOLDER_NAME": {"value": "appointment_receipts", "type": str, "path": True},
"TOWERS_DB": {"value": "towers", "type": str, "path": True},
"CLI_PRIVATE_KEY": {"value": "cli_sk.der", "type": str, "path": True},
}
plugin = Plugin()
class WTClient:
def __init__(self, sk, user_id, config):
self.sk = sk
self.user_id = user_id
self.db_manager = TowersDBM(config.get("TOWERS_DB"), plugin)
self.towers = {}
self.config = config
# Populate the towers dict with data from the db
for tower_id, tower_info in self.db_manager.load_all_tower_records().items():
self.towers[tower_id] = TowerInfo.from_dict(tower_info)
@plugin.init()
def init(options, configuration, plugin):
try:
plugin.log("Generating a new key pair for the watchtower client. Keys stored at {}".format(DATA_DIR))
cli_sk, compressed_cli_pk = generate_keys(DATA_DIR)
except FileExistsError:
plugin.log("A key file for the watchtower client already exists. Loading it")
cli_sk, compressed_cli_pk = load_keys(DATA_DIR)
plugin.log("Plugin watchtower client initialized")
config_loader = ConfigLoader(DATA_DIR, CONF_FILE_NAME, DEFAULT_CONF, {})
try:
plugin.wt_client = WTClient(cli_sk, compressed_cli_pk, config_loader.build_config())
except plyvel.IOError:
plugin.log("Cannot load towers db. Resource temporarily unavailable")
# TODO: Check how to make the plugin stop
@plugin.method("register", desc="Register your public key with the tower")
def register(plugin, *args):
"""
Registers the user to the tower.
Args:
plugin (:obj:`Plugin`): this plugin.
args (:obj:`list`): a list of arguments. Must contain the tower_id and endpoint.
Accepted input formats:
- tower_id@host:port
- tower_id@host (will default port to DEFAULT_PORT)
- tower_id host port
- tower_id host (will default port to DEFAULT_PORT)
Returns:
:obj:`dict`: a dictionary containing the subscription data.
"""
try:
tower_id, tower_endpoint = arg_parser.parse_register_arguments(
args, plugin.wt_client.config.get("DEFAULT_PORT")
)
# Defaulting to http hosts for now
if not tower_endpoint.startswith("http"):
tower_endpoint = "http://" + tower_endpoint
# Send request to the server.
register_endpoint = "{}/register".format(tower_endpoint)
data = {"public_key": plugin.wt_client.user_id}
plugin.log("Registering in the Eye of Satoshi")
response = process_post_response(post_request(data, register_endpoint))
plugin.log("Registration succeeded. Available slots: {}".format(response.get("available_slots")))
# Save data
tower_info = TowerInfo(tower_endpoint, response.get("available_slots"))
plugin.wt_client.towers[tower_id] = tower_info
plugin.wt_client.db_manager.store_tower_record(tower_id, tower_info)
return response
except (InvalidParameter, TowerConnectionError, TowerResponseError) as e:
plugin.log(str(e), level="error")
return e.to_json()
@plugin.method("getappointment", desc="Gets appointment data from the tower given a locator")
def get_appointment(plugin, *args):
"""
Gets information about an appointment from the tower.
Args:
plugin (:obj:`Plugin`): this plugin.
args (:obj:`list`): a list of arguments. Must contain a single argument, the locator.
Returns:
:obj:`dict`: a dictionary containing the appointment data.
"""
# FIXME: All responses from the tower should be signed. Not using teos_pk atm.
try:
tower_id, locator = arg_parser.parse_get_appointment_arguments(args)
if tower_id not in plugin.wt_client.towers:
raise InvalidParameter("tower_id is not within the registered towers", tower_id=tower_id)
message = "get appointment {}".format(locator)
signature = Cryptographer.sign(message.encode(), plugin.wt_client.sk)
data = {"locator": locator, "signature": signature}
# Send request to the server.
get_appointment_endpoint = "{}/get_appointment".format(plugin.wt_client.towers[tower_id].endpoint)
plugin.log("Requesting appointment from the Eye of Satoshi at {}".format(get_appointment_endpoint))
response = process_post_response(post_request(data, get_appointment_endpoint))
return response
except (InvalidParameter, TowerConnectionError, TowerResponseError) as e:
plugin.log(str(e), level="error")
return e.to_json()
@plugin.hook("commitment_revocation")
def add_appointment(plugin, **kwargs):
commitment_txid = kwargs.get("commitment_txid")
penalty_tx = kwargs.get("penalty_tx")
plugin.log("commitment_txid {}, penalty_tx: {}".format(commitment_txid, penalty_tx))
return {"result": "continue"}
@plugin.method("listtowers")
def list_towers(plugin):
return {k: v.to_dict() for k, v in plugin.wt_client.towers.items()}
plugin.run()