import json import re import logging from random import shuffle from datetime import datetime, timedelta import geoip2.database import arrow import requests from flask import Flask, request, redirect, jsonify from flask import render_template, flash from urllib.parse import urlparse, urlencode from xmrnodes.helpers import determine_crypto, is_onion, make_request from xmrnodes.helpers import retrieve_peers, rw_cache, get_highest_block, check_zmq from xmrnodes.forms import SubmitNode from xmrnodes.models import Node, HealthCheck, Peer from xmrnodes import config logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) app = Flask(__name__) app.config.from_envvar("FLASK_SECRETS") app.secret_key = app.config["SECRET_KEY"] HEALTHY_BLOCK_DIFF = 100 # idc to config this. hardcode is fine. @app.route("/", methods=["GET", "POST"]) def index(): form = SubmitNode() nettype = request.args.get("nettype", "mainnet") crypto = request.args.get("crypto", "monero") onion = request.args.get("onion", False) show_all = "true" == request.args.get("all", "false") web_compatible = request.args.get("web_compatible", False) highest_block = get_highest_block(nettype, crypto) healthy_block = highest_block - HEALTHY_BLOCK_DIFF nodes = Node.select().where( Node.validated == True, Node.nettype == nettype, Node.crypto == crypto ) if web_compatible: nodes = nodes.where(Node.web_compatible == True) nodes_all = nodes.count() nodes_unhealthy = nodes.where( (Node.available == False) | (Node.last_height < healthy_block) ).count() if not show_all: nodes = nodes.where( Node.available == True, Node.last_height > healthy_block ) nodes = nodes.order_by( Node.datetime_entered.desc() ) if onion: nodes = nodes.where(Node.is_tor == True) nodes = [n for n in nodes] shuffle(nodes) return render_template( "index.html", nodes=nodes, nodes_all=nodes_all, nodes_unhealthy=nodes_unhealthy, nettype=nettype, crypto=crypto, form=form, web_compatible=web_compatible ) @app.route("/nodes.json") def nodes_json(): nodes = Node.select().where( Node.validated==True ).where( Node.nettype=="mainnet" ) xmr_nodes = [n for n in nodes if n.crypto == "monero"] wow_nodes = [n for n in nodes if n.crypto == "wownero"] return jsonify({ "monero": { "clear": [n.url for n in xmr_nodes if n.is_tor == False], "onion": [n.url for n in xmr_nodes if n.is_tor == True], "web_compatible": [n.url for n in xmr_nodes if n.web_compatible == True], }, "wownero": { "clear": [n.url for n in wow_nodes if n.is_tor == False], "onion": [n.url for n in wow_nodes if n.is_tor == True] } }) @app.route("/wow_nodes.json") def wow_nodes_json(): nodes = Node.select().where( Node.validated==True ).where( Node.nettype=="mainnet" ).where( Node.crypto=="wownero" ) nodes = [n for n in nodes] return jsonify({ "clear": [n.url for n in nodes if n.is_tor == False], "onion": [n.url for n in nodes if n.is_tor == True] }) @app.route("/map") def map(): return render_template( "map.html", peers=rw_cache('map_peers'), source_node=config.NODE_HOST ) @app.route("/resources") def resources(): return render_template("resources.html") @app.route("/add", methods=["GET", "POST"]) def add(): if request.method == "POST": url = request.form.get("node_url") regex = re.compile( r"^(?:http)s?://" # http:// or https:// r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|" #domain... r"localhost|" #localhost... r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # ...or ip r"(?::\d+)?" # optional port r"(?:/?|[/?]\S+)$", re.IGNORECASE ) re_match = re.match(regex, url) if re_match is None: flash("This doesn\"t look like a valid URL") else: _url = urlparse(url) url = f"{_url.scheme}://{_url.netloc}".lower() if Node.select().where(Node.url == url).exists(): flash("This node is already in the database.") else: flash("Seems like a valid node URL. Added to the database and will check soon.") node = Node(url=url) node.save() return redirect("/") def cleanup_health_checks(): diff = datetime.now() - timedelta(hours=24) checks = HealthCheck.select().where(HealthCheck.datetime <= diff) for check in checks: print("Deleting check", check.id) check.delete_instance() @app.cli.command("zmq") def get_zmq(): # nodes = Node.select().where(Node.validated == True) # for node in nodes: # check_zmq(node.url) check_zmq('http://monero.10z.com.ar:18089') @app.cli.command("check") def check(): cleanup_health_checks() nodes = Node.select().where(Node.validated == True) for node in nodes: now = datetime.utcnow() hc = HealthCheck(node=node) logging.info(f"Attempting to check {node.url}") try: r = make_request(node.url) assert "status" in r.json() assert "offline" in r.json() assert "height" in r.json() has_cors = 'Access-Control-Allow-Origin' in r.headers is_ssl = node.url.startswith('https://') if r.json()["status"] == "OK": node.web_compatible = has_cors and is_ssl node.last_height = r.json()["height"] hc.health = True highest_block = get_highest_block(node.nettype, node.crypto) healthy_block = highest_block - HEALTHY_BLOCK_DIFF if r.json()["height"] < healthy_block: node.available = False logging.info("unhealthy") else: node.available = True logging.info("success") else: raise except: logging.info("fail") node.datetime_failed = now node.available = False hc.health = False finally: node.datetime_checked = now node.save() hc.save() @app.cli.command("get_peers") def get_peers(): all_peers = [] print('[+] Preparing to crawl Monero p2p network') print(f'[.] Retrieving initial peers from {config.NODE_HOST}:{config.NODE_PORT}') initial_peers = retrieve_peers(config.NODE_HOST, config.NODE_PORT) with geoip2.database.Reader('./data/GeoLite2-City.mmdb') as reader: for peer in initial_peers: if peer not in all_peers: all_peers.append(peer) _url = urlparse(peer) url = f"{_url.scheme}://{_url.netloc}".lower() if not Peer.select().where(Peer.url == peer).exists(): response = reader.city(_url.hostname) p = Peer( url=peer, country=response.country.name, city=response.city.name, postal=response.postal.code, lat=response.location.latitude, lon=response.location.longitude, ) p.save() print(f'{peer} - saving new peer') else: p = Peer.select().where(Peer.url == peer).first() p.datetime = datetime.now() p.save() try: print(f'[.] Retrieving crawled peers from {_url.netloc}') new_peers = retrieve_peers(_url.hostname, _url.port) for peer in new_peers: if peer not in all_peers: all_peers.append(peer) _url = urlparse(peer) url = f"{_url.scheme}://{_url.netloc}".lower() if not Peer.select().where(Peer.url == peer).exists(): response = reader.city(_url.hostname) p = Peer( url=peer, country=response.country.name, city=response.city.name, postal=response.postal.code, lat=response.location.latitude, lon=response.location.longitude, ) p.save() print(f'{peer} - saving new peer') else: p = Peer.select().where(Peer.url == peer).first() p.datetime = datetime.now() p.save() except: pass print(f'[+] Found {len(all_peers)} peers from {config.NODE_HOST}:{config.NODE_PORT}') print('[+] Deleting old Monero p2p peers') for p in Peer.select(): if p.hours_elapsed() > 24: print(f'[.] Deleting {p.url}') p.delete_instance() rw_cache('map_peers', list(Peer.select().execute())) @app.cli.command("validate") def validate(): nodes = Node.select().where(Node.validated == False) for node in nodes: now = datetime.utcnow() logging.info(f"Attempting to validate {node.url}") try: r = make_request(node.url) assert "height" in r.json() assert "nettype" in r.json() has_cors = 'Access-Control-Allow-Origin' in r.headers is_ssl = node.url.startswith('https://') nettype = r.json()["nettype"] crypto = determine_crypto(node.url) logging.info("success") if nettype in ["mainnet", "stagenet", "testnet"]: node.nettype = nettype node.available = True node.validated = True node.web_compatible = has_cors and is_ssl node.last_height = r.json()["height"] node.datetime_checked = now node.crypto = crypto node.is_tor = is_onion(node.url) node.save() else: logging.info("unexpected nettype") except requests.exceptions.ConnectTimeout: logging.info("connection timed out") node.delete_instance() except requests.exceptions.SSLError: logging.info("invalid certificate") node.delete_instance() except requests.exceptions.ConnectionError: logging.info("connection error") node.delete_instance() except requests.exceptions.HTTPError: logging.info("http error, 4xx or 5xx") node.delete_instance() except Exception as e: logging.info("failed for reasons unknown") node.delete_instance() @app.cli.command("export") def export(): all_nodes = [] export_dir = f"{config.DATA_DIR}/export.txt" nodes = Node.select().where(Node.validated == True) for node in nodes: logging.info(f"Adding {node.url}") all_nodes.append(node.url) with open(export_dir, "w") as f: f.write("\n".join(all_nodes)) logging.info(f"{nodes.count()} nodes written to {export_dir}") @app.cli.command("import") def import_(): all_nodes = [] export_dir = f"{config.DATA_DIR}/export.txt" with open(export_dir, "r") as f: for url in f.readlines(): try: n = url.rstrip().lower() logging.info(f"Adding {n}") node = Node(url=n) node.save() all_nodes.append(n) except: pass logging.info(f"{len(all_nodes)} node urls imported and ready to be validated") @app.template_filter("humanize") def humanize(d): t = arrow.get(d, "UTC") return t.humanize() @app.template_filter("hours_elapsed") def hours_elapsed(d): now = datetime.utcnow() diff = now - d return diff.total_seconds() / 60 / 60 @app.template_filter("pop_arg") def trim_arg(all_args, arg_to_trim): d = all_args.to_dict() d.pop(arg_to_trim) return urlencode(d) if __name__ == "__main__": app.run()