You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
344 lines
13 KiB
344 lines
13 KiB
#!/usr/bin/env python3 |
|
|
|
""" |
|
A rudimentary implementation of a server, allowing POST of proposals |
|
in base64 format, with POW attached required, |
|
and GET of all current proposals, for SNICKER. |
|
Serves only over Tor onion service. |
|
For persistent onion services, specify public port, local port and |
|
hidden service directory: |
|
|
|
`python snicker-server.py 80 7080 /my/hiddenservicedir` |
|
|
|
... and (a) make sure these settings match those in your Tor config, |
|
and also (b) note that the hidden service hostname may not be displayed |
|
if the running user, understandably, do not have permissions to read that |
|
directory. |
|
|
|
If you only want an ephemeral onion service, for testing, just run without |
|
arguments: |
|
|
|
`python snicker-server.py` |
|
|
|
""" |
|
|
|
from twisted.internet import reactor |
|
from twisted.internet.defer import Deferred |
|
from twisted.web.server import Site |
|
from twisted.web.resource import Resource |
|
from twisted.internet.endpoints import TCP4ClientEndpoint, UNIXClientEndpoint, serverFromString |
|
import txtorcon |
|
import sys |
|
import base64 |
|
import json |
|
import sqlite3 |
|
import threading |
|
from io import BytesIO |
|
from jmbase import jmprint, hextobin, verify_pow |
|
from jmclient import process_shutdown, jm_single, load_program_config, check_and_start_tor |
|
from jmclient.configure import get_log |
|
|
|
# Note: this is actually a duplication of the |
|
# string in jmbitcoin.secp256k1_ecies, but this is deliberate, |
|
# as we want this tool to have no dependency on jmbitcoin. |
|
ECIES_MAGIC_BYTES = b'BIE1' |
|
|
|
log = get_log() |
|
|
|
database_file_name = "proposals.db" |
|
database_table_name = "proposals" |
|
|
|
class SNICKERServer(Resource): |
|
# rudimentary: flat file, TODO location of file |
|
DATABASE = "snicker-proposals.txt" |
|
|
|
def __init__(self): |
|
self.dblock = threading.Lock() |
|
self.conn = sqlite3.connect(database_file_name, check_same_thread=False) |
|
# TODO: ? |
|
#con.row_factory = dict_factory |
|
|
|
self.cursor = self.conn.cursor() |
|
try: |
|
self.dblock.acquire(True) |
|
# note the pubkey is *NOT* a primary key, by |
|
# design; we need to be able to create multiple |
|
# proposals against one key. |
|
self.cursor.execute("CREATE TABLE IF NOT EXISTS {}(" |
|
"pubkey TEXT NOT NULL, proposal TEXT NOT NULL, " |
|
"unique (pubkey, proposal));".format(database_table_name)) |
|
finally: |
|
self.dblock.release() |
|
|
|
# initial PoW setting; todo, change this: |
|
self.set_pow_target_bits(8) |
|
self.nonce_length = 10 |
|
super().__init__() |
|
|
|
isLeaf = True |
|
|
|
def set_pow_target_bits(self, nbits): |
|
self.nbits = nbits |
|
|
|
def get_pow_target_bits(self): |
|
return self.nbits |
|
|
|
def return_error(self, request, error_meaning, |
|
error_code="unavailable", http_code=400): |
|
""" |
|
We return, to the sender, stringified json in the body as per the above. |
|
""" |
|
request.setResponseCode(http_code) |
|
request.setHeader(b"content-type", b"text/html; charset=utf-8") |
|
log.debug("Returning an error: " + str( |
|
error_code) + ": " + str(error_meaning)) |
|
return json.dumps({"errorCode": error_code, |
|
"message": error_meaning}).encode("utf-8") |
|
|
|
def render_GET(self, request): |
|
"""GET request to "/" retrieves the entire current data set. |
|
GET "/target" retrieves the current nbits target for PoW. |
|
It's intended that proposers request the target in real time |
|
before each submission, so that the server can dynamically update |
|
it at any time. |
|
""" |
|
log.debug("GET request, path: {}".format(request.path)) |
|
if request.path == b"/target": |
|
return self.serve_pow_target(request) |
|
if request.path != b"/": |
|
return self.return_error(request, "Invalid request path", |
|
"invalid-request-path") |
|
proposals = self.get_all_current_proposals() |
|
request.setHeader(b"content-length", |
|
("%d" % len(proposals)).encode("ascii")) |
|
return proposals.encode("ascii") |
|
|
|
def serve_pow_target(self, request): |
|
targetbits = ("%d" % self.nbits).encode("ascii") |
|
request.setHeader(b"content-length", |
|
("%d" % len(targetbits)).encode("ascii")) |
|
return targetbits |
|
|
|
def render_POST(self, request): |
|
""" An individual proposal may be submitted in base64, with key |
|
appended after newline separator in hex. |
|
""" |
|
log.debug("The server got this POST request: ") |
|
# unfortunately the twisted Request object is not |
|
# easily serialized: |
|
log.debug(request) |
|
log.debug(request.method) |
|
log.debug(request.uri) |
|
log.debug(request.args) |
|
sender_parameters = request.args |
|
log.debug(request.path) |
|
# defer logging of raw request content: |
|
proposals = request.content |
|
if not isinstance(proposals, BytesIO): |
|
return self.return_error(request, "Invalid request format", |
|
"invalid-request-format") |
|
proposals = proposals.read() |
|
# for now, only allowing proposals of form "base64ciphertext,hexkey", |
|
#newline separated: |
|
proposals = proposals.split(b"\n") |
|
log.debug("Client send proposal list of length: " + str( |
|
len(proposals))) |
|
accepted_proposals = [] |
|
for proposal in proposals: |
|
if len(proposal) == 0: |
|
continue |
|
try: |
|
encryptedtx, key, nonce = proposal.split(b",") |
|
bin_key = hextobin(key.decode('utf-8')) |
|
bin_nonce = hextobin(nonce.decode('utf-8')) |
|
base64.b64decode(encryptedtx) |
|
except: |
|
log.warn("This proposal was not accepted: " + proposal.decode( |
|
"utf-8")) |
|
# give up immediately in case of format error: |
|
return self.return_error(request, "Invalid request format", |
|
"invalid-request-format") |
|
if not verify_pow(proposal, nbits=self.nbits, truncate=32): |
|
return self.return_error(request, "Insufficient PoW", |
|
"insufficient proof of work") |
|
accepted_proposals.append((key, encryptedtx)) |
|
|
|
# the proposals are valid format-wise; add them to the database |
|
for p in accepted_proposals: |
|
# note we will ignore errors here and continue; |
|
# warning will be shown in logs from called fn. |
|
self.add_proposal(p) |
|
content = "{} proposals accepted".format(len(accepted_proposals)) |
|
request.setHeader(b"content-length", ("%d" % len(content)).encode( |
|
"ascii")) |
|
return content.encode("ascii") |
|
|
|
def add_proposal(self, p): |
|
proposal_to_add = tuple(x.decode("utf-8") for x in p) |
|
try: |
|
self.cursor.execute('INSERT INTO {} VALUES(?, ?);'.format( |
|
database_table_name),proposal_to_add) |
|
except sqlite3.Error as e: |
|
log.warn("Error inserting data into table: {}".format( |
|
" ".join(e.args))) |
|
return False |
|
self.conn.commit() |
|
return True |
|
|
|
def dbquery(self, querystr, params, return_results=False): |
|
try: |
|
self.dblock.acquire(True) |
|
if return_results: |
|
return self.cursor.execute( |
|
querystr, params).fetchall() |
|
self.cursor.execute(querystr, params) |
|
finally: |
|
self.dblock.release() |
|
|
|
def get_all_keys(self): |
|
rows = self.dbquery('SELECT DISTINCT pubkey FROM {};'.format( |
|
database_table_name), (), True) |
|
if not rows: |
|
return [] |
|
return list([x[0] for x in rows]) |
|
|
|
@classmethod |
|
def db_row_to_proposal_string(cls, row): |
|
assert len(row) == 2 |
|
key, proposal = row |
|
return proposal + "," + key |
|
|
|
def get_all_current_proposals(self): |
|
rows = self.dbquery('SELECT * from {};'.format( |
|
database_table_name), (), True) |
|
return "\n".join([self.db_row_to_proposal_string(x) for x in rows]) |
|
|
|
def get_proposals_for_key(self, key): |
|
rows = self.dbquery('SELECT proposal FROM {} WHERE pubkey=?'.format( |
|
database_table_name), (key,), True) |
|
if not rows: |
|
return [] |
|
return rows |
|
|
|
class SNICKERServerManager(object): |
|
|
|
def __init__(self, port, local_port=None, |
|
hsdir=None, |
|
control_port=9051, |
|
uri_created_callback=None, |
|
info_callback=None, |
|
shutdown_callback=None): |
|
# port is the *public* port, default 80 |
|
# if local_port is None, we follow the process |
|
# to create an ephemeral hidden service. |
|
# if local_port is a valid port, we start the |
|
# hidden service configured at directory hsdir. |
|
# In the latter case, note the patch described at |
|
# https://github.com/meejah/txtorcon/issues/347 is required. |
|
self.port = port |
|
self.local_port = local_port |
|
if self.local_port is not None: |
|
assert hsdir is not None |
|
self.hsdir = hsdir |
|
self.control_port = control_port |
|
if not uri_created_callback: |
|
self.uri_created_callback = self.default_info_callback |
|
else: |
|
self.uri_created_callback = uri_created_callback |
|
if not info_callback: |
|
self.info_callback = self.default_info_callback |
|
else: |
|
self.info_callback = info_callback |
|
|
|
self.shutdown_callback =shutdown_callback |
|
|
|
def default_info_callback(self, msg): |
|
jmprint(msg) |
|
|
|
def start_snicker_server_and_tor(self): |
|
""" Packages the startup of the receiver side. |
|
""" |
|
self.server = SNICKERServer() |
|
self.site = Site(self.server) |
|
self.site.displayTracebacks = False |
|
jmprint("Attempting to start onion service on port: " + str( |
|
self.port) + " ...") |
|
self.start_tor() |
|
|
|
def setup_failed(self, arg): |
|
errmsg = "Setup failed: " + str(arg) |
|
log.error(errmsg) |
|
self.info_callback(errmsg) |
|
process_shutdown() |
|
|
|
def create_onion_ep(self, t): |
|
if self.local_port: |
|
endpointString = "onion:{}:controlPort={}:localPort={}:hiddenServiceDir={}".format( |
|
self.port, self.control_port,self.local_port, self.hsdir) |
|
return serverFromString(reactor, endpointString) |
|
else: |
|
# ephemeral onion: |
|
self.tor_connection = t |
|
return t.create_onion_endpoint(self.port, version=3) |
|
|
|
def onion_listen(self, onion_ep): |
|
return onion_ep.listen(self.site) |
|
|
|
def print_host(self, ep): |
|
""" Callback fired once the HS is available; |
|
receiver user needs a BIP21 URI to pass to |
|
the sender: |
|
""" |
|
self.info_callback("Your hidden service is available: ") |
|
# Note that ep,getHost().onion_port must return the same |
|
# port as we chose in self.port; if not there is an error. |
|
assert ep.getHost().onion_port == self.port |
|
self.uri_created_callback(str(ep.getHost().onion_uri)) |
|
|
|
def start_tor(self): |
|
""" This function executes the workflow |
|
of starting the hidden service. |
|
""" |
|
if not self.local_port: |
|
control_host = jm_single().config.get("PAYJOIN", "tor_control_host") |
|
control_port = int(jm_single().config.get("PAYJOIN", "tor_control_port")) |
|
if str(control_host).startswith('unix:'): |
|
control_endpoint = UNIXClientEndpoint(reactor, control_host[5:]) |
|
else: |
|
control_endpoint = TCP4ClientEndpoint(reactor, control_host, control_port) |
|
d = txtorcon.connect(reactor, control_endpoint) |
|
d.addCallback(self.create_onion_ep) |
|
d.addErrback(self.setup_failed) |
|
else: |
|
d = Deferred() |
|
d.callback(None) |
|
d.addCallback(self.create_onion_ep) |
|
# TODO: add errbacks to the next two calls in |
|
# the chain: |
|
d.addCallback(self.onion_listen) |
|
d.addCallback(self.print_host) |
|
|
|
def shutdown(self): |
|
self.tor_connection.protocol.transport.loseConnection() |
|
process_shutdown(self.mode) |
|
self.info_callback("Hidden service shutdown complete") |
|
if self.shutdown_callback: |
|
self.shutdown_callback() |
|
|
|
def snicker_server_start(port, local_port=None, hsdir=None): |
|
ssm = SNICKERServerManager(port, local_port=local_port, hsdir=hsdir) |
|
ssm.start_snicker_server_and_tor() |
|
|
|
if __name__ == "__main__": |
|
load_program_config(bs="no-blockchain") |
|
check_and_start_tor() |
|
# in testing, we can optionally use ephemeral; |
|
# in testing or prod we can use persistent: |
|
if len(sys.argv) < 2: |
|
snicker_server_start(80) |
|
else: |
|
port = int(sys.argv[1]) |
|
local_port = int(sys.argv[2]) |
|
hsdir = sys.argv[3] |
|
snicker_server_start(port, local_port, hsdir) |
|
reactor.run()
|
|
|