├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── config.ini ├── moneriote ├── __init__.py ├── dns │ ├── __init__.py │ ├── cloudflare.py │ └── transip.py ├── main.py ├── moneriote.py ├── rpc.py └── utils.py ├── requirements.txt └── setup.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | moneriote.egg-info 3 | *.pyc 4 | .vscode/settings.json 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2018 Monero ecosystem 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Moneriote-python 2 | 3 | moneriote-python is a Python script to maintain DNS records of monero nodes with their RPC port open. It actively scans 4 | the Monero network through `monerod` and manages DNS records. 5 | 6 | An example is the remote node service [node.moneroworld.com](https://moneroworld.com/): 7 | 8 | ``` 9 | dig A node.moneroworld.com 10 | 11 | ;; ANSWER SECTION: 12 | node.moneroworld.com. 3600 IN CNAME opennode.xmr-tw.org. 13 | opennode.xmr-tw.org. 300 IN A 69.94.198.240 14 | opennode.xmr-tw.org. 300 IN A 91.121.57.211 15 | opennode.xmr-tw.org. 300 IN A 139.59.59.176 16 | opennode.xmr-tw.org. 300 IN A 139.99.195.96 17 | opennode.xmr-tw.org. 300 IN A 212.83.130.45 18 | ``` 19 | 20 | Supports the following DNS providers: [Cloudflare](https://www.cloudflare.com/), [TransIP](https://transip.nl) 21 | 22 | 23 | Screenshot 24 | ---- 25 | 26 | ![](https://i.imgur.com/VeKZnEX.png) 27 | 28 | ### Requirements 29 | 30 | 1. Python >= 3.5 31 | 2. Domain name 32 | 3. A running and fully synced Monero daemon 33 | 34 | Installation 35 | ---- 36 | 37 | ```bash 38 | git clone python-moneriote 39 | cd python-moneriote 40 | virtualenv -p /usr/bin/python3 venv 41 | source venv/bin/activate 42 | python setup.py develop 43 | ``` 44 | 45 | Moneriote is now installed inside the virtualenv. 46 | 47 | 48 | Usage 49 | ---- 50 | 51 | ``` 52 | Usage: moneriote [OPTIONS] 53 | 54 | Options: 55 | --monerod-path TEXT Path to the monero daemon executable (monerod). [default: monerod] 56 | --monerod-address TEXT Monero daemon address. [default: 127.0.0.1] 57 | --monerod-port INTEGER Monero daemon port. [default: 18081] 58 | --monerod-auth TEXT Monero daemon auth as 'user:pass'. Will be passed to monerod as `--rpc-login` argument. 59 | --blockheight-discovery TEXT Available options: 'monerod', 'xmrchain', 'moneroblocks'. When set to 'compare', it will use all methods and pick the highest 60 | blockheight. [default: compare] 61 | --dns-provider TEXT The DNS provider/plugin to use. [default: cloudflare] 62 | --domain TEXT The domain name without the subdomain. 'example.com'. 63 | --subdomain TEXT The subdomain name. [default: node] 64 | --api-key TEXT DNS API key. 65 | --api-email TEXT DNS email address or username. 66 | --max-records INTEGER Maximum number of DNS records to add. [default: 5] 67 | --loop-interval INTEGER Update loop interval. [default: 600] 68 | --scan-interval INTEGER Interval at which to mass-scan RPC nodes. [default: 3600] 69 | --concurrent_scans INTEGER The amount of servers to scan at once. [default: 20] 70 | --ban-list TEXT Enable ban-list if list path is provided. One IP address per line. 71 | --from-config TEXT Load configuration from ini file. 72 | --help Show this message and exit. 73 | ``` 74 | 75 | Example 76 | ---- 77 | 78 | Easiest is to run in `screen` or `tmux`. If you really care about uptime and 79 | want to babysit the process, write some configuration for `supervisord` or `systemd`. 80 | 81 | ``` 82 | moneriote --monerod-path "/home/xmr/monero-gui-v0.12.3.0/monerod" 83 | --blockheight-discovery "compare" 84 | --dns-provider "cloudflare" 85 | --domain "example.com" 86 | --subdomain "node" 87 | --api-key "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 88 | --api-email "example@bla.com" 89 | --max-records 5 90 | --ban-list "/home/xmr/block.txt" 91 | ``` 92 | 93 | Flags 94 | ---- 95 | 96 | #### `--monerod-path` 97 | 98 | The full path to the monerod executable. On windows this ends on .exe. 99 | 100 | ##### `--monerod-address` 101 | 102 | Default: `127.0.0.1` 103 | 104 | The address on which your local monerod is listening 105 | 106 | ##### `--monerod-port` 107 | 108 | Default: `18081` 109 | 110 | The port on which your local monerod is listening 111 | 112 | ##### `--monerod-auth` 113 | 114 | The authentication string to use when contacting monerod. 115 | 116 | ##### `--blockheight-discovery` 117 | 118 | Default: `compare` 119 | 120 | Available options: `monerod`, `xmrchain`, `moneroblocks`. When set to `compare`, 121 | it will use all methods and pick the highest blockheight. 122 | 123 | `xmrchain` and `moneroblocks` are both Monero explorer websites that expose an API. 124 | 125 | ##### `--dns-provider` 126 | 127 | Available DNS providers: `cloudflare`, `transip`. 128 | 129 | If your DNS provider is not included but does provide an API for adding/removing records, 130 | you can code a custom implementation of `DnsProvider`. See [moneriote/dns/](tree/master/moneriote/dns/) 131 | 132 | #### `--domain` 133 | 134 | The domain name without the subdomain, example: `example.com` 135 | 136 | #### `--subdomain` 137 | 138 | The subdomain name, example: `node` 139 | 140 | The full domain would become `node.example.com` 141 | 142 | #### `--api-key` 143 | 144 | The key required by your DNS provider for API access. 145 | 146 | #### `--api-email` 147 | 148 | The email required by your DNS provider for API access. This flag could also serve for an username, depending 149 | on `DnsProvider`. 150 | 151 | #### `--max-records` 152 | 153 | Default: `5` 154 | 155 | The maximum amount of records to add. 156 | 157 | #### `--loop-interval` 158 | 159 | Default: `600` 160 | 161 | Shuffle/randomize the records every `X` seconds. Default is 10 minutes. 162 | 163 | #### `--scan-interval` 164 | 165 | Default: `3600` 166 | 167 | Ask monerod for new peers and mass-scan them, every `X` seconds. Default is 1 hour. 168 | 169 | #### `--concurrent_scans` 170 | 171 | Default: `20` 172 | 173 | The amount of servers to scan at once. 174 | 175 | #### `--ban-list` 176 | 177 | Enable ban-list if list file path is provided. One IP address per line. 178 | 179 | #### `--from-config` 180 | 181 | Alternatively, configuration can be passed via `config.ini`. 182 | 183 | Development 184 | ---- 185 | 186 | Additional DNS provider(s) can be implemented by inheriting from `moneriote.dns.DnsProvider()`. Your custom 187 | class must implement the following methods. 188 | 189 | #### `def get_records(self)` 190 | 191 | Must return a list of nodes (`moneriote.rpc.RpcNodeList`). 192 | 193 | #### `def add_record(self, node: RpcNode)` 194 | 195 | Adds the A record to the subdomain 196 | 197 | #### `def delete_record(self, node: RpcNode):` 198 | 199 | Removes the A record from the subdomain. 200 | 201 | ## History 202 | 203 | - Originally developed as a bash script in [Gingeropolous/moneriote](https://github.com/Gingeropolous/moneriote). 204 | - Improved and rewritten in Python by [connorw600/moneriote](https://github.com/connorw600/moneriote/tree/opennodes-python) 205 | - Improved by [Lafudoci/moneriote](https://github.com/Lafudoci/moneriote/tree/opennodes-python) 206 | - Rewritten by [skftn](https://github.com/skftn) 207 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [MoneroDaemon] 2 | path = monerod.exe 3 | address = 127.0.0.1 4 | port = 18081 5 | auth = 6 | height_discovery_method = compare 7 | 8 | [DNS] 9 | provider = cloudflare 10 | domain_name = example.com 11 | subdomain_name = node 12 | api_key = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 13 | api_email = xxxx 14 | max_records = 5 15 | 16 | [BanList] 17 | ban_list_path = -------------------------------------------------------------------------------- /moneriote/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from multiprocessing import freeze_support 4 | 5 | freeze_support() 6 | 7 | PATH_CACHE = os.path.join(tempfile.gettempdir(), 'moneriote-cache.json') 8 | CONFIG = {} 9 | -------------------------------------------------------------------------------- /moneriote/dns/__init__.py: -------------------------------------------------------------------------------- 1 | from moneriote.rpc import RpcNode 2 | 3 | 4 | class DnsProvider(object): 5 | def __init__(self, **kwargs): 6 | self.domain_name = kwargs['domain_name'] 7 | self.subdomain_name = kwargs.get('subdomain_name', 'node') 8 | self.api_key = kwargs['api_key'] 9 | self.api_email = kwargs['api_email'] 10 | self.max_records = kwargs.get('max_records', 5) 11 | self.headers = {} 12 | 13 | @property 14 | def fulldomain_name(self): 15 | return '%s.%s' % (self.subdomain_name, self.domain_name) 16 | 17 | def get_records(self): 18 | raise NotImplementedError() 19 | 20 | def add_record(self, node: RpcNode): 21 | raise NotImplementedError() 22 | 23 | def delete_record(self, node: RpcNode): 24 | raise NotImplementedError() 25 | -------------------------------------------------------------------------------- /moneriote/dns/cloudflare.py: -------------------------------------------------------------------------------- 1 | import time 2 | from moneriote.dns import DnsProvider 3 | from moneriote.rpc import RpcNode, RpcNodeList 4 | from moneriote.utils import log_err, log_msg, random_user_agent, make_json_request 5 | 6 | 7 | class Cloudflare(DnsProvider): 8 | def __init__(self, **kwargs): 9 | super(Cloudflare, self).__init__(**kwargs) 10 | 11 | self.headers = { 12 | 'Content-Type': 'application/json', 13 | 'X-Auth-Email': kwargs['api_email'], 14 | 'X-Auth-Key': kwargs['api_key'], 15 | 'User-Agent': random_user_agent() 16 | } 17 | self.api_base = 'https://api.cloudflare.com/client/v4/zones' 18 | self.zone_id = None 19 | 20 | # zone_id is required and will be detected via Cloudflare API 21 | if not self.zone_id: 22 | log_msg('Determining zone_id; looking for \'%s\'' % self.domain_name) 23 | result = make_json_request(url=self.api_base, headers=self.headers) 24 | try: 25 | zones = result.get('result') 26 | self.zone_id = next(zone.get('id') for zone in zones if zone.get('name') == self.domain_name) 27 | except StopIteration: 28 | log_err('could not determine zone_id. Is your Cloudflare domain correct?', fatal=True) 29 | log_msg('Cloudflare zone_id \'%s\' matched to \'%s\'' % (self.zone_id, self.domain_name)) 30 | 31 | def get_records(self): 32 | max_retries = 5 33 | nodes = RpcNodeList() 34 | log_msg('Fetching existing record(s) (%s.%s)' % (self.subdomain_name, self.domain_name)) 35 | 36 | retries = 0 37 | while (True): 38 | try: 39 | result = make_json_request('%s/%s/dns_records/?type=A&name=%s.%s' % ( 40 | self.api_base, self.zone_id, 41 | self.subdomain_name, self.domain_name), headers=self.headers) 42 | records = result.get('result') 43 | 44 | # filter on A records / subdomain 45 | for record in records: 46 | if record.get('type') != 'A' or record.get('name') != self.fulldomain_name: 47 | continue 48 | 49 | node = RpcNode(address=record.get('content'), uid=record.get('id')) 50 | nodes.append(node) 51 | log_msg('> A %s %s' % (record.get('name'), record.get('content'))) 52 | return nodes 53 | 54 | except Exception as ex: 55 | log_err("Cloudflare record fetching failed: %s" % (str(ex))) 56 | retries += 1 57 | time.sleep(1) 58 | if retries > max_retries: 59 | return None 60 | 61 | 62 | def add_record(self, node: RpcNode): 63 | log_msg('Record insertion: %s' % node.address) 64 | 65 | try: 66 | url = '%s/%s/dns_records' % (self.api_base, self.zone_id) 67 | make_json_request(url=url, method='POST', verbose = False, headers=self.headers, json={ 68 | 'name': self.subdomain_name, 69 | 'content': node.address, 70 | 'type': 'A', 71 | 'ttl': 120 72 | }) 73 | except Exception as ex: 74 | log_err("Cloudflare record (%s) insertion failed: %s" % (node.address, str(ex))) 75 | 76 | def delete_record(self, node: RpcNode): 77 | # Delete DNS Record 78 | log_msg('Cloudflare record deletion: %s' % node.address) 79 | 80 | try: 81 | url = '%s/%s/dns_records/%s' % (self.api_base, self.zone_id, node.uid) 82 | data = make_json_request(url=url, method='DELETE', verbose = False, headers=self.headers) 83 | assert data.get('success') is True 84 | return data.get('result') 85 | except Exception as ex: 86 | log_err("Record (%s) deletion failed: %s" % (node.address, str(ex))) 87 | -------------------------------------------------------------------------------- /moneriote/dns/transip.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import time 4 | import uuid 5 | from collections import OrderedDict 6 | 7 | import rsa 8 | from suds.client import Client as SudsClient 9 | from suds.sudsobject import Object as SudsObject 10 | from suds.xsd.doctor import Import, ImportDoctor 11 | 12 | try: 13 | from urllib.parse import urlencode, quote_plus 14 | except ImportError: 15 | from urllib import urlencode, quote_plus 16 | 17 | try: 18 | import suds_requests 19 | except ImportError: 20 | suds_requests = None 21 | 22 | try: 23 | from Crypto.Hash import SHA512 24 | from Crypto.Signature import PKCS1_v1_5 25 | from Crypto.PublicKey import RSA 26 | 27 | HAS_PYCRYPTO = True 28 | except ImportError: 29 | HAS_PYCRYPTO = False 30 | 31 | from moneriote.rpc import RpcNode, RpcNodeList 32 | from moneriote.dns import DnsProvider 33 | 34 | URI_TEMPLATE = 'https://{}/wsdl/?service={}' 35 | 36 | MODE_RO = 'readonly' 37 | MODE_RW = 'readwrite' 38 | 39 | 40 | def convert_value(value): 41 | """ 42 | None and boolean values are not accepted by the Transip API. 43 | This method converts 44 | - None and False to an empty string, 45 | - True to 1 46 | """ 47 | if isinstance(value, bool): 48 | return 1 if value else '' 49 | 50 | if not value: 51 | return '' 52 | 53 | return value 54 | 55 | 56 | class TransIPDnsEntry(SudsObject): 57 | def __init__(self, name, expire, record_type, content): 58 | super(TransIPDnsEntry, self).__init__() 59 | self.name = name 60 | self.expire = expire 61 | self.type = record_type 62 | self.content = content 63 | 64 | def __eq__(self, other): 65 | if not hasattr(self, 'name') or not hasattr(self, 'type') or not hasattr(self, 'content'): 66 | return False 67 | return self.name == other.name and self.type == other.type and self.content == other.content 68 | 69 | def __repr__(self): 70 | return '%s %s %s %d' % (self.name, self.type, self.content, self.expire) 71 | 72 | 73 | class TransIP(DnsProvider): 74 | """modified https://github.com/benkonrath/transip-api""" 75 | def __init__(self, **kwargs): 76 | super(TransIP, self).__init__(**kwargs) 77 | self.service_name = 'DomainService' 78 | self.login = kwargs['api_email'] 79 | self.private_key_file = kwargs['api_key'] 80 | self.endpoint = 'api.transip.nl' 81 | self.url = URI_TEMPLATE.format(self.endpoint, self.service_name) 82 | 83 | imp = Import('http://schemas.xmlsoap.org/soap/encoding/') 84 | doc = ImportDoctor(imp) 85 | 86 | suds_kwargs = dict() 87 | if suds_requests: 88 | suds_kwargs['transport'] = suds_requests.RequestsTransport() 89 | 90 | self.soap_client = SudsClient(self.url, doctor=doc, **suds_kwargs) 91 | 92 | def _sign(self, message): 93 | """ Uses the decrypted private key to sign the message. """ 94 | if os.path.exists(self.private_key_file): 95 | with open(self.private_key_file) as private_key: 96 | keydata = private_key.read() 97 | 98 | if HAS_PYCRYPTO: 99 | rsa_key = RSA.importKey(keydata) 100 | rsa_ = PKCS1_v1_5.new(rsa_key) 101 | sha512_hash_ = SHA512.new() 102 | sha512_hash_.update(message.encode('utf-8')) 103 | signature = rsa_.sign(sha512_hash_) 104 | else: 105 | privkey = rsa.PrivateKey.load_pkcs1(keydata) 106 | signature = rsa.sign( 107 | message.encode('utf-8'), privkey, 'SHA-512' 108 | ) 109 | 110 | signature = base64.b64encode(signature) 111 | signature = quote_plus(signature) 112 | 113 | return signature 114 | else: 115 | raise RuntimeError('The private key does not exist.') 116 | 117 | def _build_signature_message(self, service_name, method_name, 118 | timestamp, nonce, additional=None): 119 | """ 120 | Builds the message that should be signed. This message contains 121 | specific information about the request in a specific order. 122 | """ 123 | if additional is None: 124 | additional = [] 125 | 126 | sign = OrderedDict() 127 | # Add all additional parameters first 128 | for index, value in enumerate(additional): 129 | if isinstance(value, list): 130 | for entryindex, entryvalue in enumerate(value): 131 | if not isinstance(entryvalue, SudsObject): 132 | continue 133 | 134 | for objectkey, objectvalue in entryvalue: 135 | objectvalue = convert_value(objectvalue) 136 | sign[str(index) + '[' + str(entryindex) + '][' + objectkey + ']'] = objectvalue 137 | elif isinstance(value, SudsObject): 138 | for entryindex, entryvalue in value: 139 | key = str(index) + '[' + str(entryindex) + ']' 140 | sign[key] = convert_value(entryvalue) 141 | else: 142 | sign[index] = convert_value(value) 143 | sign['__method'] = method_name 144 | sign['__service'] = service_name 145 | sign['__hostname'] = self.endpoint 146 | sign['__timestamp'] = timestamp 147 | sign['__nonce'] = nonce 148 | 149 | return urlencode(sign) \ 150 | .replace('%5B', '[') \ 151 | .replace('%5D', ']') \ 152 | .replace('+', '%20') \ 153 | .replace('%7E', '~') # Comply with RFC3989. This replacement is also in TransIP's sample PHP library. 154 | 155 | def update_cookie(self, cookies): 156 | """ Updates the cookie for the upcoming call to the API. """ 157 | temp = [] 158 | for k, val in cookies.items(): 159 | temp.append("%s=%s" % (k, val)) 160 | 161 | cookiestring = ';'.join(temp) 162 | self.soap_client.set_options(headers={'Cookie': cookiestring}) 163 | 164 | def build_cookie(self, method, mode, parameters=None): 165 | """ 166 | Build a cookie for the request. 167 | Keyword arguments: 168 | method -- the method to be called on the service. 169 | mode -- Read-only (MODE_RO) or read-write (MODE_RW) 170 | """ 171 | timestamp = int(time.time()) 172 | nonce = str(uuid.uuid4())[:32] 173 | 174 | message_to_sign = self._build_signature_message( 175 | service_name=self.service_name, 176 | method_name=method, 177 | timestamp=timestamp, 178 | nonce=nonce, 179 | additional=parameters 180 | ) 181 | 182 | signature = self._sign(message_to_sign) 183 | 184 | cookies = { 185 | "nonce": nonce, 186 | "timestamp": timestamp, 187 | "mode": mode, 188 | "clientVersion": '0.4.1', 189 | "login": self.login, 190 | "signature": signature 191 | } 192 | 193 | return cookies 194 | 195 | def _simple_request(self, method, *args, **kwargs): 196 | cookie = self.build_cookie(mode=kwargs.get('mode', MODE_RO), method=method, parameters=args) 197 | self.update_cookie(cookie) 198 | return getattr(self.soap_client.service, method)(*args) 199 | 200 | def _rpcnode_to_entry(self, node: RpcNode): 201 | return TransIPDnsEntry(**{ 202 | 'name': node.kwargs.get('name', self.subdomain_name), 203 | 'expire': node.kwargs.get('expire', 60), 204 | 'record_type': node.kwargs.get('type', 'A'), 205 | 'content': node.address 206 | }) 207 | 208 | def get_records(self, all_records=False): 209 | nodes = RpcNodeList() 210 | cookie = self.build_cookie(mode=MODE_RO, method='getInfo', parameters=[self.domain_name]) 211 | self.update_cookie(cookie) 212 | 213 | result = self.soap_client.service.getInfo(self.domain_name) 214 | for dnsentry in result.dnsEntries: 215 | if dnsentry.__class__.__name__ != 'DnsEntry': 216 | continue 217 | if dnsentry.type != 'A' and not all_records: 218 | continue 219 | if dnsentry.name != self.subdomain_name and not all_records: 220 | continue 221 | nodes.append(RpcNode( 222 | address=dnsentry.content, type=dnsentry.type, name=dnsentry.name, expire=dnsentry.expire)) 223 | return nodes 224 | 225 | def add_record(self, node: RpcNode): 226 | records = [self._rpcnode_to_entry(_node) for _node in self.get_records(all_records=True)] 227 | records.append(self._rpcnode_to_entry(node)) 228 | return self._simple_request('setDnsEntries', self.domain_name, records, mode=MODE_RW) 229 | 230 | def delete_record(self, node: RpcNode): 231 | records = [self._rpcnode_to_entry(_node) for _node in self.get_records(all_records=True) \ 232 | if _node.address != node.address] 233 | return self._simple_request('setDnsEntries', self.domain_name, records, mode=MODE_RW) 234 | -------------------------------------------------------------------------------- /moneriote/main.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from time import sleep 3 | 4 | import click 5 | click_option = functools.partial(click.option, show_default=True) 6 | 7 | 8 | @click.command(context_settings=dict(max_content_width=160)) 9 | @click_option('--monerod-path', default='monerod', help="Path to the monero daemon executable (monerod).") 10 | @click_option('--monerod-address', default='127.0.0.1', help="Monero daemon address.") 11 | @click_option('--monerod-port', default=18081, help="Monero daemon port.") 12 | @click_option('--monerod-auth', help="Monero daemon auth as 'user:pass'. Will be passed to monerod as " 13 | "`--rpc-login` argument.") 14 | @click_option('--blockheight-discovery', default='compare', 15 | help="Available options: 'monerod', 'xmrchain', 'moneroblocks'. When set to 'compare', " 16 | "it will use all methods and pick the highest blockheight.") 17 | @click_option('--dns-provider', default="cloudflare", help="The DNS provider/plugin to use.") 18 | @click_option('--domain', help="The domain name without the subdomain. 'example.com'.") 19 | @click_option('--subdomain', default="node", help="The subdomain name.") 20 | @click_option('--api-key', help="DNS API key.") 21 | @click_option('--api-email', help="DNS email address or username.") 22 | @click_option('--max-records', default=5, help='Maximum number of DNS records to add.') 23 | @click_option('--loop-interval', default=180, help='Loop interval for quickcheck nodes in cache and DNS records update.') 24 | @click_option('--scan-interval', default=1800, help='Interval at which to mass-scan RPC nodes.') 25 | @click_option('--concurrent_scans', default=20, help='The amount of servers to scan at once.') 26 | @click_option('--ban-list', help='Enable ban-list if list path is provided.') 27 | @click_option('--from-config', help='Load configuration from ini file.') 28 | def cli(monerod_path, monerod_address, monerod_port, monerod_auth, blockheight_discovery, 29 | dns_provider, domain, subdomain, api_key, api_email, max_records, loop_interval, 30 | concurrent_scans, scan_interval, ban_list, from_config): 31 | from moneriote import CONFIG 32 | from moneriote.moneriote import Moneriote 33 | from moneriote.utils import log_err, log_msg, banner, parse_ini 34 | 35 | banner() 36 | 37 | if from_config: 38 | md, dns, ban = parse_ini(from_config) 39 | monerod_path = md['path'] 40 | monerod_address = md['address'] 41 | monerod_auth = md['auth'] 42 | monerod_port = md['port'] 43 | api_email = dns['api_email'] 44 | api_key = dns['api_key'] 45 | domain = dns['domain_name'] 46 | subdomain = dns['subdomain_name'] 47 | max_records = int(dns['max_records']) 48 | dns_provider = dns['provider'] 49 | ban_list = ban['ban_list_path'] 50 | 51 | if not api_email: 52 | log_err('Parameter api_email is required', fatal=True) 53 | 54 | if not api_key: 55 | log_err('Parameter api_key is required', fatal=True) 56 | 57 | if not domain: 58 | log_err('Parametre domain is required', fatal=True) 59 | 60 | CONFIG['concurrent_scans'] = concurrent_scans 61 | CONFIG['scan_interval'] = scan_interval 62 | 63 | if dns_provider == 'cloudflare': 64 | from moneriote.dns.cloudflare import Cloudflare 65 | dns_provider = Cloudflare( 66 | domain_name=domain, 67 | subdomain_name=subdomain, 68 | api_key=api_key, 69 | api_email=api_email, 70 | max_records=max_records) 71 | elif dns_provider == 'transip': 72 | from moneriote.dns.transip import TransIP 73 | dns_provider = TransIP( 74 | api_email=api_email, 75 | api_key=api_key, 76 | subdomain_name=subdomain, 77 | domain_name=domain, 78 | max_records=max_records) 79 | else: 80 | log_err("Unknown DNS provider \'%s\'" % dns_provider, fatal=True) 81 | 82 | mon = Moneriote(dns_provider=dns_provider, 83 | md_path=monerod_path, 84 | md_address=monerod_address, 85 | md_port=monerod_port, 86 | md_auth=monerod_auth, 87 | md_height_discovery_method=blockheight_discovery, 88 | ban_list_path=ban_list) 89 | 90 | while True: 91 | mon.main() 92 | log_msg('Sleeping for %d seconds' % loop_interval) 93 | sleep(loop_interval) 94 | -------------------------------------------------------------------------------- /moneriote/moneriote.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import re 3 | import os 4 | import subprocess 5 | import time 6 | from functools import partial 7 | from multiprocessing import Pool 8 | from subprocess import Popen 9 | from datetime import datetime 10 | 11 | from moneriote import PATH_CACHE, CONFIG 12 | from moneriote.dns import DnsProvider 13 | from moneriote.rpc import RpcNode, RpcNodeList 14 | from moneriote.utils import log_msg, log_err, make_json_request, banner, parse_ban_list 15 | 16 | 17 | if sys.version_info[0] != 3 or sys.version_info[1] < 3.5: 18 | log_err("please run with python >= 3.5", fatal=True) 19 | 20 | try: 21 | import requests 22 | except ImportError: 23 | log_err("please install requests: pip install requests", fatal=True) 24 | 25 | 26 | class Moneriote: 27 | def __init__(self, dns_provider: DnsProvider, md_address: str = '127.0.0.1', md_port: int = 18081, 28 | md_auth: str = 'not:used', md_path: str = 'monerod.exe', 29 | md_height_discovery_method: str = 'xmrchain', ban_list_path: str = ''): 30 | self.dns_provider = dns_provider 31 | 32 | self.md_path = md_path 33 | self.md_daemon_addr = md_address 34 | self.md_daemon_port = md_port 35 | self.md_daemon_auth = md_auth 36 | if md_height_discovery_method not in ['xmrchain', 'monerod', 'compare', 'moneroblocks']: 37 | log_err('bad height_discovery_method option', fatal=True) 38 | self.md_height_discovery_method = md_height_discovery_method 39 | 40 | # default Monero RPC port 41 | self._m_rpc_port = 18089 42 | self._blockchain_height = None 43 | 44 | self.last_mass_scan_time = 0 45 | 46 | if not os.path.isfile(PATH_CACHE): 47 | log_msg("Auto creating \'%s\'" % PATH_CACHE) 48 | f = open(PATH_CACHE, 'a') 49 | f.write('[]') 50 | f.close() 51 | 52 | if ban_list_path != '': 53 | ban_list = parse_ban_list(ban_list_path) 54 | log_msg('Load %d nodes from %s'%(len(ban_list), ban_list_path)) 55 | self.ban_list = ban_list 56 | else: 57 | self.ban_list = [] 58 | 59 | self.monerod_check() 60 | 61 | def main(self): 62 | # get & set the current blockheight 63 | height = self.monerod_get_height(method=self.md_height_discovery_method) 64 | if not height or not isinstance(height, int): 65 | log_err("Unable to fetch the current blockchain height") 66 | return 67 | self._blockchain_height = height 68 | 69 | nodes = RpcNodeList() 70 | nodes += RpcNodeList.cache_read(PATH_CACHE) # from `cached_nodes.json` 71 | if nodes: 72 | nodes = self.scan(nodes, remove_invalid=True) 73 | 74 | now = time.time() 75 | this_round_uptime = now - self.last_mass_scan_time 76 | 77 | if len(nodes.nodes) <= self.dns_provider.max_records or this_round_uptime > CONFIG['scan_interval']: 78 | peers = self.monerod_get_peers() # from monerod 79 | nodes += self.scan(peers, remove_invalid=True) 80 | self.last_mass_scan_time = now 81 | 82 | if len(nodes.nodes) > 0: 83 | nodes.cache_write() 84 | 85 | nodes.shuffle() 86 | 87 | inserts = nodes.nodes[:self.dns_provider.max_records] 88 | insert_ips = [] 89 | for node in inserts: 90 | insert_ips.append(node.address) 91 | 92 | dns_nodes = self.dns_provider.get_records() 93 | 94 | if dns_nodes != None: 95 | # insert new records 96 | for node in inserts: 97 | if node.address not in dns_nodes: 98 | self.dns_provider.add_record(node) 99 | 100 | # remove old records 101 | for node in dns_nodes: 102 | if node.address not in insert_ips: 103 | self.dns_provider.delete_record(node) 104 | else: 105 | log_err('Could not fetch DNS records, skipping this update.') 106 | 107 | else: 108 | log_err('Could not get any valid node, skipping this update.') 109 | 110 | def scan(self, nodes: RpcNodeList, remove_invalid=False): 111 | """ 112 | Start threads checking known nodes to see if they're alive. 113 | :param nodes: 114 | :param remove_invalid: only return valid nodes when set to True 115 | :return: valid nodes 116 | """ 117 | if len(nodes) == 0: 118 | return nodes 119 | 120 | if len(self.ban_list) > 0: 121 | filtered_nodes = RpcNodeList() 122 | for node in nodes: 123 | if node.address in self.ban_list: 124 | log_msg('Ban %s'%node.address) 125 | else: 126 | filtered_nodes.append(node) 127 | nodes = filtered_nodes 128 | 129 | now = datetime.now() 130 | log_msg('Scanning %d node(s) on port %d. This can take several minutes. Let it run.' % ( 131 | len(nodes), self._m_rpc_port)) 132 | 133 | pool = Pool(processes=CONFIG['concurrent_scans']) 134 | nodes = RpcNodeList.from_list(pool.map(partial(RpcNode.is_valid, self._blockchain_height), nodes)) 135 | pool.close() 136 | pool.join() 137 | 138 | log_msg('Scanning %d node(s) done after %d seconds, found %d valid' % ( 139 | len(nodes), (datetime.now() - now).total_seconds(), len(nodes.valid(valid=True)))) 140 | 141 | if remove_invalid: 142 | nodes = nodes.valid(valid=True) 143 | 144 | return nodes 145 | 146 | def monerod_check(self): 147 | url = 'http://%s:%d' % (self.md_daemon_addr, self.md_daemon_port) 148 | 149 | try: 150 | resp = requests.get(url, timeout=2) 151 | assert resp.status_code in [401, 403, 404] 152 | assert resp.headers.get('Server', '').startswith('Epee') 153 | return True 154 | except Exception as ex: 155 | log_err("monerod not reachable: %s" % url, fatal=True) 156 | 157 | def monerod_get_height(self, method='compare'): 158 | """ 159 | Gets the current top block on the chain 160 | :param method: 'monerod' will use only monerod to fetch the height. 161 | 'xmrchain' will only use xmrchain. 'both' will query both and compare. 162 | :return: 163 | """ 164 | data = {} 165 | xmrchain_height = 0 166 | max_retries = 5 167 | 168 | if method == ['compare', 'monerod']: 169 | output = self._daemon_command(cmd="print_height") 170 | if isinstance(output, str) and output.startswith('Error') or not output: 171 | log_err("monerod output: %s" % output) 172 | elif isinstance(output, str): 173 | data['md_height'] = int(re.sub('[^0-9]', '', output.splitlines()[1])) 174 | log_msg('monerod height is %d' % data['md_height']) 175 | if method == 'monerod': 176 | return data['md_height'] 177 | 178 | if method in ['compare', 'moneroblocks']: 179 | retries = 0 180 | while True: 181 | if retries > max_retries: 182 | break 183 | try: 184 | blob = make_json_request('https://moneroblocks.info/api/get_stats/', timeout=5, verify=True) 185 | data['moneroblocks'] = blob.get('height') 186 | break 187 | except Exception as ex: 188 | log_msg('Fetching moneroblocks JSON has failed. Retrying.') 189 | retries += 1 190 | time.sleep(1) 191 | 192 | if method in ['compare', 'xmrchain']: 193 | retries = 0 194 | while True: 195 | if retries > max_retries: 196 | break 197 | try: 198 | blob = make_json_request('https://xmrchain.net/api/networkinfo', timeout=5, verify=True) 199 | assert blob.get('status') == 'success' 200 | data['xmrchain_height'] = blob.get('data', {}).get('height') 201 | assert isinstance(data['xmrchain_height'], int) 202 | log_msg('xmrchain height is %d' % data['xmrchain_height']) 203 | if method == 'xmrchain': 204 | return data['xmrchain_height'] 205 | break 206 | except Exception as ex: 207 | log_msg('Fetching xmrchain JSON has failed. Retrying.') 208 | retries += 1 209 | time.sleep(1) 210 | continue 211 | 212 | if data: 213 | return max(data.values()) 214 | log_err('Unable to obtain blockheight.') 215 | 216 | def monerod_get_peers(self): 217 | """Gets the last known peers from monerod""" 218 | nodes = RpcNodeList() 219 | output = self._daemon_command("print_pl") 220 | if not output: 221 | return nodes 222 | 223 | regex = r"(gray|white)\s+(\w+)\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})" 224 | matches = re.finditer(regex, output) 225 | 226 | for i, match in enumerate(matches): 227 | if match.group(1) != 'white': 228 | continue 229 | 230 | address = match.group(3) 231 | nodes.append(RpcNode(address=address)) 232 | 233 | log_msg('Got peers from RPC: %d node(s)' % len(nodes)) 234 | return nodes 235 | 236 | def _daemon_command(self, cmd: str): 237 | if not os.path.exists(self.md_path): 238 | log_err("monerod not found in path \'%s\'" % self.md_path) 239 | return 240 | 241 | log_msg("Spawning daemon; executing command \'%s\'" % cmd) 242 | 243 | # build proc args 244 | args = [ 245 | '--rpc-bind-ip', self.md_daemon_addr, 246 | '--rpc-bind-port', str(self.md_daemon_port), 247 | ] 248 | if self.md_daemon_auth: 249 | args.extend(['--rpc-login', self.md_daemon_auth]) 250 | args.append(cmd) 251 | 252 | try: 253 | process = Popen([self.md_path, *args], 254 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT, 255 | universal_newlines=True, bufsize=1) 256 | output, err = process.communicate(timeout=10) 257 | if not output: 258 | log_err("No output from monerod") 259 | return output 260 | except Exception as ex: 261 | log_err('Could not spawn \'%s %s\': %s' % ( 262 | self.md_path, ' '.join(args), str(ex) 263 | )) 264 | finally: 265 | # cleanup 266 | process.kill() 267 | -------------------------------------------------------------------------------- /moneriote/rpc.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import random 3 | import json 4 | 5 | from dateutil.parser import parse as dateutil_parse 6 | 7 | from moneriote import PATH_CACHE, CONFIG 8 | from moneriote.utils import log_msg, log_err, make_json_request 9 | 10 | 11 | class RpcNodeList: 12 | def __init__(self): 13 | self.nodes = [] 14 | self._addresses = [] 15 | 16 | @classmethod 17 | def from_list(cls, nodes): 18 | self = cls() 19 | for node in nodes: 20 | self.append(node) 21 | return self 22 | 23 | def append(self, node): 24 | if node.address not in self._addresses: 25 | self.nodes.append(node) 26 | self._addresses.append(node.address) 27 | 28 | def valid(self, valid=True): 29 | return RpcNodeList.from_list([ 30 | node for node in self.nodes if node.valid is valid]) 31 | 32 | def valid_cf(self, valid=True): 33 | return RpcNodeList.from_list([ 34 | node for node in self.nodes if node.valid is valid and isinstance(node.cf_id, str)]) 35 | 36 | def shuffle(self): 37 | random.shuffle(self.nodes) 38 | 39 | def __iter__(self): 40 | return iter(self.nodes) 41 | 42 | def __contains__(self, address): 43 | return address in self._addresses 44 | 45 | def __add__(self, inp): 46 | if isinstance(inp, RpcNodeList): 47 | for node in inp: 48 | self.append(node) 49 | elif isinstance(inp, RpcNode): 50 | self.append(inp) 51 | return self 52 | 53 | def __len__(self): 54 | return len(self.nodes) 55 | 56 | def cache_write(self): 57 | """Writes a cache file of valid nodes""" 58 | now = datetime.now() 59 | data = [] 60 | 61 | for node in self.nodes: 62 | if node.valid: 63 | data.append({'address': node.address, 64 | 'port': node.port, 65 | 'dt': node.dt}) 66 | try: 67 | f = open(PATH_CACHE, 'w') 68 | f.write(json.dumps(data, indent=4)) 69 | f.close() 70 | except Exception as ex: 71 | log_err('Writing \'%s\' failed' % PATH_CACHE) 72 | raise 73 | log_msg('Written \'%s\' with %d nodes' % (PATH_CACHE, len(data))) 74 | 75 | @staticmethod 76 | def cache_read(path): 77 | """ 78 | Reads nodes from the nodes cache file. 79 | :return: List of RpcNode objects 80 | """ 81 | log_msg('Reading \'%s\'' % path) 82 | 83 | try: 84 | f = open(path, 'r') 85 | blob = json.loads(f.read()) 86 | f.close() 87 | except Exception as ex: 88 | log_err('Reading \'%s\' failed' % path) 89 | return RpcNodeList() 90 | 91 | if not isinstance(blob, list): 92 | return RpcNodeList() 93 | 94 | nodes = RpcNodeList() 95 | for node in blob: 96 | dt = dateutil_parse(node['dt']) 97 | if 'address' in node: 98 | nodes.append(RpcNode(**node)) 99 | 100 | log_msg('Loaded %d nodes from \'%s\'' % (len(nodes), path)) 101 | return nodes 102 | 103 | 104 | class RpcNode: 105 | def __init__(self, address: str, uid=None, port=18089, dt= '', **kwargs): 106 | """ 107 | :param address: ip 108 | :param uid: record uid as per DNS provider 109 | """ 110 | self.address = address 111 | self.port = port 112 | self.uid = uid 113 | self._acceptableBlockOffset = 3 114 | self.valid = False 115 | self.dt = dt 116 | self.kwargs = kwargs 117 | 118 | @staticmethod 119 | def is_valid(current_blockheight, obj): 120 | now = datetime.now() 121 | # Scans the current node to see if the RPC port is available and is within the accepted range 122 | url = 'http://%s:%d/' % (obj.address, obj.port) 123 | url = '%s%s' % (url, 'getheight') 124 | 125 | if len(obj.dt) == 0: 126 | obj.dt = now.strftime('%Y-%m-%d %H:%M:%S') 127 | 128 | try: 129 | blob = make_json_request(url, verbose=False, timeout=2) 130 | if not blob: 131 | raise Exception() 132 | except Exception as ex: 133 | return obj 134 | 135 | if not isinstance(blob.get('height', ''), int): 136 | return obj 137 | 138 | height = blob.get('height') 139 | diff = current_blockheight - height 140 | 141 | # Check if the node we're checking is up to date (with a little buffer) 142 | if diff <= obj._acceptableBlockOffset: 143 | obj.valid = True 144 | return obj 145 | -------------------------------------------------------------------------------- /moneriote/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import configparser 3 | import sys 4 | import random 5 | from datetime import datetime 6 | 7 | import requests 8 | 9 | 10 | def banner(): 11 | header = """ 12 | \033[92m• ▌ ▄ ·. ▐ ▄ ▄▄▄ .▄▄▄ ▪ ▄▄▄▄▄▄▄▄ . 13 | ·██ ▐███▪▪ •█▌▐█▀▄.▀·▀▄ █·██ ▪ •██ ▀▄.▀· 14 | ▐█ ▌▐▌▐█· ▄█▀▄ ▐█▐▐▌▐▀▀▪▄▐▀▀▄ ▐█· ▄█▀▄ ▐█.▪▐▀▀▪▄ 15 | ██ ██▌▐█▌▐█▌.▐▌██▐█▌▐█▄▄▌▐█•█▌▐█▌▐█▌.▐▌ ▐█▌·▐█▄▄▌ 16 | ▀▀ █▪▀▀▀ ▀█▄▀▪▀▀ █▪ ▀▀▀ .▀ ▀▀▀▀ ▀█▄▀▪ ▀▀▀ ▀▀▀ 17 | 18 | @skftn @Lafudoci @gingeropolous @connorw600 19 | \033[0m 20 | """.strip() 21 | print(header) 22 | 23 | 24 | def log_err(msg, fatal=False): 25 | now = datetime.now() 26 | print('\033[91m[%s]\033[0m %s' % (now.strftime("%Y-%m-%d %H:%M"), msg)) 27 | 28 | if fatal: 29 | sys.exit() 30 | 31 | 32 | def log_msg(msg): 33 | now = datetime.now() 34 | print('\033[92m[%s]\033[0m %s' % (now.strftime("%Y-%m-%d %H:%M"), msg)) 35 | 36 | 37 | def random_user_agent(): 38 | return random.choice([ 39 | 'Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:24.0) Gecko/20100101 Firefox/24.0', 40 | 'Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)', 41 | 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1', 42 | 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0', 43 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36', 44 | 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:46.0) Gecko/20100101 Firefox/46.0', 45 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/601.7.7 (KHTML, like Gecko) Version/9.1.2 Safari/601.7.7', 46 | 'Mozilla/5.0 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko' 47 | ]) 48 | 49 | 50 | def make_json_request(url, headers=None, method='GET', verbose=True, **kwargs): 51 | if verbose: 52 | log_msg("%s: %s" % (method, url)) 53 | 54 | kwargs.setdefault('verify', True) 55 | kwargs.setdefault('timeout', 5) 56 | kwargs.setdefault('headers', { 57 | 'User-Agent': random_user_agent() 58 | }) 59 | 60 | if headers: 61 | kwargs['headers'] = headers 62 | 63 | try: 64 | _method = getattr(requests, method.lower()) 65 | if not _method: 66 | raise Exception("Unknown method \'%s\'" % method) 67 | except Exception as ex: 68 | if verbose: 69 | log_err(str(ex)) 70 | raise 71 | 72 | try: 73 | resp = _method(url=url, **kwargs) 74 | resp.raise_for_status() 75 | return resp.json() 76 | except Exception as ex: 77 | if verbose: 78 | log_err("Error (%s): %s" % (url, str(ex))) 79 | 80 | 81 | def parse_ini(fn): 82 | if not os.path.isfile(fn): 83 | log_err("%s missing" % fn, fatal=True) 84 | 85 | config = configparser.ConfigParser() 86 | config.read(fn) 87 | 88 | def try_cast(val): 89 | if val.isdigit(): 90 | return int(val) 91 | if val.lower() in ['true', 'false']: 92 | return bool(val) 93 | return val 94 | 95 | md = {k: try_cast(v) for k, v in config._sections.get('MoneroDaemon', {}).items()} 96 | dns = {k: try_cast(v) for k, v in config._sections.get('DNS', {}).items()} 97 | ban = {k: try_cast(v) for k, v in config._sections.get('BanList', {}).items()} 98 | return md, dns, ban 99 | 100 | 101 | def parse_ban_list(path): 102 | if not os.path.isfile(path): 103 | log_err("%s missing" % path, fatal=True) 104 | ban_list = [] 105 | with open(os.path.join(path), 'r') as f: 106 | for line in f: 107 | ban_list.append(line.strip()) 108 | return ban_list -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.20.0 2 | python-dateutil 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | 5 | version = '0.2.1' 6 | README = os.path.join(os.path.dirname(__file__), 'README.md') 7 | long_description = open(README).read() 8 | setup( 9 | name='moneriote', 10 | version=version, 11 | description='Python scripts to maintain Monero open-nodes DNS records', 12 | long_description=long_description, 13 | long_description_content_type='text/markdown', 14 | classifiers=[ 15 | 'Development Status :: 4 - Beta', 16 | 'Environment :: Web Environment', 17 | 'Intended Audience :: Developers', 18 | 'License :: WTFPL', 19 | 'Operating System :: OS Independent', 20 | 'Topic :: Software Development :: Libraries :: Python Modules', 21 | 'Topic :: Utilities', 22 | 'Programming Language :: Python' 23 | ], 24 | keywords='monero', 25 | author='', 26 | author_email='', 27 | url='', 28 | install_requires=[ 29 | 'requests', 30 | 'click', 31 | 'python-dateutil' 32 | ], 33 | entry_points=''' 34 | [console_scripts] 35 | moneriote=moneriote.main:cli 36 | ''', 37 | setup_requires=['setuptools>=38.6.0'], 38 | download_url= 39 | '', 40 | packages=find_packages(), 41 | include_package_data=True, 42 | ) 43 | --------------------------------------------------------------------------------