daemon.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2020 The MMGen Project <mmgen@tuta.io>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. daemon.py: Daemon control interface for the MMGen suite
  20. """
  21. import shutil
  22. from subprocess import run,PIPE
  23. from collections import namedtuple
  24. from mmgen.exception import *
  25. from mmgen.common import *
  26. class Daemon(MMGenObject):
  27. debug = False
  28. wait = True
  29. use_pidfile = True
  30. cfg_file = None
  31. new_console_mswin = False
  32. ps_pid_mswin = False
  33. def subclass_init(self): pass
  34. def exec_cmd_thread(self,cmd,check):
  35. import threading
  36. tname = ('exec_cmd','exec_cmd_win_console')[self.platform == 'win' and self.new_console_mswin]
  37. t = threading.Thread(target=getattr(self,tname),args=(cmd,check))
  38. t.daemon = True
  39. t.start()
  40. Msg_r(' \b') # blocks w/o this...crazy
  41. def exec_cmd_win_console(self,cmd,check):
  42. from subprocess import Popen,CREATE_NEW_CONSOLE
  43. p = Popen(cmd,creationflags=CREATE_NEW_CONSOLE)
  44. p.wait()
  45. def exec_cmd(self,cmd,check):
  46. cp = run(cmd,check=False,stdout=PIPE,stderr=PIPE)
  47. if check and cp.returncode != 0:
  48. raise MMGenCalledProcessError(cp)
  49. return cp
  50. def run_cmd(self,cmd,silent=False,check=True,is_daemon=False):
  51. if is_daemon and not silent:
  52. msg('Starting {} {}'.format(self.net_desc,self.desc))
  53. if self.debug:
  54. msg('\nExecuting: {}'.format(' '.join(cmd)))
  55. if self.platform == 'win' and is_daemon:
  56. cp = self.exec_cmd_thread(cmd,check)
  57. else:
  58. cp = self.exec_cmd(cmd,check)
  59. if cp:
  60. out = cp.stdout.decode().rstrip()
  61. err = cp.stderr.decode().rstrip()
  62. if out and (self.debug or not silent):
  63. msg(out)
  64. if err and (self.debug or (cp.returncode and not silent)):
  65. msg(err)
  66. return cp
  67. @property
  68. def pid(self):
  69. if self.ps_pid_mswin and self.platform == 'win':
  70. # TODO: assumes only one running instance of given daemon
  71. cp = self.run_cmd(['ps','-Wl'],silent=True,check=False)
  72. for line in cp.stdout.decode().splitlines():
  73. if self.exec_fn_mswin in line:
  74. return line.split()[3] # use Windows, not Cygwin, PID
  75. die(2,'PID for {!r} not found in ps output'.format(ss))
  76. elif self.use_pidfile:
  77. return open(self.pidfile).read().strip()
  78. else:
  79. return '(unknown)'
  80. def cmd(self,action,*args,**kwargs):
  81. return getattr(self,action)(*args,**kwargs)
  82. def do_start(self,silent=False):
  83. if not silent:
  84. msg('Starting {} {}'.format(self.net_desc,self.desc))
  85. return self.run_cmd(self.start_cmd,silent=True,is_daemon=True)
  86. def do_stop(self,silent=False):
  87. if not silent:
  88. msg('Stopping {} {}'.format(self.net_desc,self.desc))
  89. return self.run_cmd(self.stop_cmd,silent=True)
  90. def cli(self,*cmds,silent=False,check=True):
  91. return self.run_cmd(self.cli_cmd(*cmds),silent=silent,check=check)
  92. def start(self,silent=False):
  93. if self.is_ready:
  94. if not silent:
  95. m = '{} {} already running with pid {}'
  96. msg(m.format(self.net_desc,self.desc,self.pid))
  97. else:
  98. os.makedirs(self.datadir,exist_ok=True)
  99. if self.cfg_file:
  100. open('{}/{}'.format(self.datadir,self.cfg_file),'w').write(self.cfg_file_hdr)
  101. if self.use_pidfile and os.path.exists(self.pidfile):
  102. # Parity just overwrites the data in an existing pidfile, leading to
  103. # interesting consequences.
  104. os.unlink(self.pidfile)
  105. ret = self.do_start(silent=silent)
  106. if self.wait:
  107. self.wait_for_state('ready')
  108. return ret
  109. def stop(self,silent=False):
  110. if self.is_ready:
  111. ret = self.do_stop(silent=silent)
  112. if self.wait:
  113. self.wait_for_state('stopped')
  114. return ret
  115. else:
  116. if not silent:
  117. msg('{} {} not running'.format(self.net_desc,self.desc))
  118. # rm -rf $datadir
  119. def wait_for_state(self,req_state):
  120. for i in range(200):
  121. if self.state == req_state:
  122. return True
  123. time.sleep(0.2)
  124. else:
  125. die(2,'Daemon wait timeout for {} {} exceeded'.format(self.daemon_id.upper(),self.network))
  126. @property
  127. def is_ready(self):
  128. return self.state == 'ready'
  129. @classmethod
  130. def check_implement(cls):
  131. m = 'required method {}() missing in class {}'
  132. for subcls in cls.__subclasses__():
  133. for k in cls.subclasses_must_implement:
  134. assert k in subcls.__dict__, m.format(k,subcls.__name__)
  135. class MoneroWalletDaemon(Daemon):
  136. desc = 'RPC daemon'
  137. net_desc = 'Monero wallet'
  138. daemon_id = 'xmr'
  139. network = 'wallet RPC'
  140. new_console_mswin = True
  141. exec_fn_mswin = 'monero-wallet-rpc.exe'
  142. ps_pid_mswin = True
  143. def __init__(self,wallet_dir,test_suite=False):
  144. self.platform = g.platform
  145. self.wallet_dir = wallet_dir
  146. if test_suite:
  147. self.datadir = os.path.join('test','monero-wallet-rpc')
  148. self.rpc_port = 13142
  149. else:
  150. self.datadir = 'monero-wallet-rpc'
  151. self.rpc_port = 13131
  152. self.daemon_port = CoinDaemon('xmr',test_suite=test_suite).rpc_port
  153. self.pidfile = os.path.join(self.datadir,'monero-wallet-rpc.pid')
  154. self.logfile = os.path.join(self.datadir,'monero-wallet-rpc.log')
  155. if self.platform == 'win':
  156. self.use_pidfile = False
  157. if not g.monero_wallet_rpc_password:
  158. die(1,
  159. 'You must set your Monero wallet RPC password.\n' +
  160. 'This can be done on the command line, with the --monero-wallet-rpc-password\n' +
  161. "option (insecure, not recommended), or by setting 'monero_wallet_rpc_password'\n" +
  162. "in the MMGen config file." )
  163. @property
  164. def start_cmd(self):
  165. cmd = [
  166. 'monero-wallet-rpc',
  167. '--daemon-port={}'.format(self.daemon_port),
  168. '--rpc-bind-port={}'.format(self.rpc_port),
  169. '--wallet-dir='+self.wallet_dir,
  170. '--log-file='+self.logfile,
  171. '--rpc-login={}:{}'.format(g.monero_wallet_rpc_user,g.monero_wallet_rpc_password) ]
  172. if self.platform == 'linux':
  173. cmd += ['--pidfile={}'.format(self.pidfile),'--detach']
  174. return cmd
  175. @property
  176. def state(self):
  177. from mmgen.rpc import MoneroWalletRPCConnection
  178. try:
  179. MoneroWalletRPCConnection(
  180. g.monero_wallet_rpc_host,
  181. self.rpc_port,
  182. g.monero_wallet_rpc_user,
  183. g.monero_wallet_rpc_password).get_version()
  184. return 'ready'
  185. except:
  186. return 'stopped'
  187. @property
  188. def stop_cmd(self):
  189. return ['kill','-Wf',self.pid] if self.platform == 'win' else ['kill',self.pid]
  190. class CoinDaemon(Daemon):
  191. cfg_file_hdr = ''
  192. subclasses_must_implement = ('state','stop_cmd')
  193. network_ids = ('btc','btc_tn','btc_rt','bch','bch_tn','bch_rt','ltc','ltc_tn','ltc_rt','xmr','eth','etc')
  194. cd = namedtuple('daemon_data',
  195. ['coin','cls_pfx','coind_exec','cli_exec','cfg_file','dfl_rpc','dfl_rpc_tn','dfl_rpc_rt'])
  196. daemon_ids = {
  197. 'btc': cd('Bitcoin', 'Bitcoin', 'bitcoind', 'bitcoin-cli', 'bitcoin.conf', 8332,18332,18444),
  198. 'bch': cd('Bcash', 'Bitcoin', 'bitcoind-abc','bitcoin-cli', 'bitcoin.conf', 8442,18442,18553),# MMGen RPC dfls
  199. 'ltc': cd('Litecoin', 'Bitcoin', 'litecoind', 'litecoin-cli','litecoin.conf', 9332,19332,19444),
  200. 'xmr': cd('Monero', 'Monero', 'monerod', 'monerod', 'bitmonero.conf',18081,None,None),
  201. 'eth': cd('Ethereum', 'Ethereum','parity', 'parity', 'parity.conf', 8545,None,None),
  202. 'etc': cd('Ethereum Classic','Ethereum','parity', 'parity', 'parity.conf', 8545,None,None)
  203. }
  204. testnet_arg = []
  205. coind_args = []
  206. cli_args = []
  207. shared_args = []
  208. coind_cmd = []
  209. coin_specific_coind_args = []
  210. coin_specific_cli_args = []
  211. coin_specific_shared_args = []
  212. usr_coind_args = []
  213. usr_cli_args = []
  214. usr_shared_args = []
  215. def __new__(cls,network_id,test_suite=False):
  216. network_id = network_id.lower()
  217. assert network_id in cls.network_ids, '{!r}: invalid network ID'.format(network_id)
  218. if network_id.endswith('_rt'):
  219. network = 'regtest'
  220. daemon_id = network_id[:-3]
  221. elif network_id.endswith('_tn'):
  222. network = 'testnet'
  223. daemon_id = network_id[:-3]
  224. else:
  225. network = 'mainnet'
  226. daemon_id = network_id
  227. me = Daemon.__new__(globals()[cls.daemon_ids[daemon_id].cls_pfx+'Daemon'])
  228. me.network_id = network_id
  229. me.network = network
  230. me.daemon_id = daemon_id
  231. me.desc = 'daemon'
  232. if network == 'regtest':
  233. me.desc = 'regtest daemon'
  234. if test_suite:
  235. rel_datadir = os.path.join('test','data_dir','regtest',daemon_id)
  236. else:
  237. me.datadir = os.path.join(g.data_dir_root,'regtest',daemon_id)
  238. elif test_suite:
  239. me.desc = 'test suite daemon'
  240. rel_datadir = os.path.join('test','daemons',daemon_id)
  241. else:
  242. from mmgen.protocol import CoinProtocol
  243. me.datadir = CoinProtocol(daemon_id,False).daemon_data_dir
  244. if test_suite:
  245. me.datadir = os.path.abspath(os.path.join(os.getcwd(),rel_datadir))
  246. me.port_shift = 1237 if test_suite else 0
  247. me.platform = g.platform
  248. return me
  249. def __init__(self,network_id,test_suite=False):
  250. self.pidfile = '{}/{}-daemon.pid'.format(self.datadir,self.network)
  251. for k in self.daemon_ids[self.daemon_id]._fields:
  252. setattr(self,k,getattr(self.daemon_ids[self.daemon_id],k))
  253. self.rpc_port = {
  254. 'mainnet': self.dfl_rpc,
  255. 'testnet': self.dfl_rpc_tn,
  256. 'regtest': self.dfl_rpc_rt,
  257. }[self.network] + self.port_shift
  258. self.net_desc = '{} {}'.format(self.coin,self.network)
  259. self.subclass_init()
  260. @property
  261. def start_cmd(self):
  262. return ([self.coind_exec]
  263. + self.testnet_arg
  264. + self.coind_args
  265. + self.shared_args
  266. + self.coin_specific_coind_args
  267. + self.coin_specific_shared_args
  268. + self.usr_coind_args
  269. + self.usr_shared_args
  270. + self.coind_cmd )
  271. def cli_cmd(self,*cmds):
  272. return ([self.cli_exec]
  273. + self.testnet_arg
  274. + self.cli_args
  275. + self.shared_args
  276. + self.coin_specific_cli_args
  277. + self.coin_specific_shared_args
  278. + self.usr_cli_args
  279. + self.usr_shared_args
  280. + list(cmds))
  281. class BitcoinDaemon(CoinDaemon):
  282. cfg_file_hdr = '# BitcoinDaemon config file\n'
  283. def subclass_init(self):
  284. if self.platform == 'win' and self.daemon_id == 'bch':
  285. self.use_pidfile = False
  286. if self.network=='testnet':
  287. self.testnet_arg = ['--testnet']
  288. self.shared_args = [
  289. '--datadir={}'.format(self.datadir),
  290. '--rpcport={}'.format(self.rpc_port) ]
  291. self.coind_args = [
  292. '--listen=0',
  293. '--keypool=1',
  294. '--rpcallowip=127.0.0.1',
  295. '--rpcbind=127.0.0.1:{}'.format(self.rpc_port) ]
  296. if self.use_pidfile:
  297. self.coind_args += ['--pid='+self.pidfile]
  298. if self.platform == 'linux':
  299. self.coind_args += ['--daemon']
  300. if self.daemon_id == 'bch':
  301. self.coin_specific_coind_args = ['--usecashaddr=0']
  302. elif self.daemon_id == 'ltc':
  303. self.coin_specific_coind_args = ['--mempoolreplacement=1']
  304. @property
  305. def state(self):
  306. cp = self.cli('getblockcount',silent=True,check=False)
  307. err = cp.stderr.decode()
  308. if ("error: couldn't connect" in err
  309. or "error: Could not connect" in err
  310. or "does not exist" in err ):
  311. return 'stopped'
  312. elif cp.returncode == 0:
  313. return 'ready'
  314. else:
  315. return 'busy'
  316. @property
  317. def stop_cmd(self):
  318. return self.cli_cmd('stop')
  319. class MoneroDaemon(CoinDaemon):
  320. exec_fn_mswin = 'monerod.exe'
  321. ps_pid_mswin = True
  322. new_console_mswin = True
  323. def subclass_init(self):
  324. if self.platform == 'win':
  325. self.use_pidfile = False
  326. @property
  327. def shared_args(self):
  328. return ['--zmq-rpc-bind-port={}'.format(self.rpc_port+1),'--rpc-bind-port={}'.format(self.rpc_port)]
  329. @property
  330. def coind_args(self):
  331. cmd = [
  332. '--bg-mining-enable',
  333. '--data-dir={}'.format(self.datadir),
  334. '--offline' ]
  335. if self.platform == 'linux':
  336. cmd += ['--pidfile={}'.format(self.pidfile),'--detach']
  337. return cmd
  338. @property
  339. def state(self):
  340. cp = self.run_cmd(
  341. [self.coind_exec]
  342. + self.shared_args
  343. + ['status'],
  344. silent=True,
  345. check=False )
  346. return 'stopped' if 'Error:' in cp.stdout.decode() else 'ready'
  347. @property
  348. def stop_cmd(self):
  349. if self.platform == 'win':
  350. return ['kill','-Wf',self.pid]
  351. else:
  352. return [self.coind_exec] + self.shared_args + ['exit']
  353. class EthereumDaemon(CoinDaemon):
  354. exec_fn_mswin = 'parity.exe'
  355. ps_pid_mswin = True
  356. def subclass_init(self):
  357. # defaults:
  358. # linux: $HOME/.local/share/io.parity.ethereum/chains/DevelopmentChain
  359. # win: $LOCALAPPDATA/Parity/Ethereum/chains/DevelopmentChain
  360. self.chaindir = os.path.join(self.datadir,'devchain')
  361. shutil.rmtree(self.chaindir,ignore_errors=True)
  362. @property
  363. def coind_cmd(self):
  364. return ['daemon',self.pidfile] if self.platform == 'linux' else []
  365. @property
  366. def coind_args(self):
  367. return ['--ports-shift={}'.format(self.port_shift),
  368. '--base-path={}'.format(self.chaindir),
  369. '--config=dev',
  370. '--log-file={}'.format(os.path.join(self.datadir,'parity.log')) ]
  371. @property
  372. def state(self):
  373. from mmgen.rpc import EthereumRPCConnection
  374. try:
  375. conn = EthereumRPCConnection('localhost',self.rpc_port,socket_timeout=0.2)
  376. except:
  377. return 'stopped'
  378. ret = conn.eth_chainId(on_fail='return')
  379. return ('stopped','ready')[ret == '0x11']
  380. @property
  381. def stop_cmd(self):
  382. return ['kill','-Wf',self.pid] if self.platform == 'win' else ['kill',self.pid]
  383. CoinDaemon.check_implement()