daemon.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  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 .exception import *
  25. from .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. lockfile = None
  34. avail_flags = ()
  35. def __init__(self):
  36. self._flags = []
  37. def subclass_init(self): pass
  38. def exec_cmd_thread(self,cmd,check):
  39. import threading
  40. tname = ('exec_cmd','exec_cmd_win_console')[self.platform == 'win' and self.new_console_mswin]
  41. t = threading.Thread(target=getattr(self,tname),args=(cmd,check))
  42. t.daemon = True
  43. t.start()
  44. Msg_r(' \b') # blocks w/o this...crazy
  45. def exec_cmd_win_console(self,cmd,check):
  46. from subprocess import Popen,CREATE_NEW_CONSOLE,STARTUPINFO,STARTF_USESHOWWINDOW,SW_HIDE
  47. si = STARTUPINFO(dwFlags=STARTF_USESHOWWINDOW,wShowWindow=SW_HIDE)
  48. p = Popen(cmd,creationflags=CREATE_NEW_CONSOLE,startupinfo=si)
  49. p.wait()
  50. def exec_cmd(self,cmd,check):
  51. cp = run(cmd,check=False,stdout=PIPE,stderr=PIPE)
  52. if self.debug:
  53. print(cp)
  54. if check and cp.returncode != 0:
  55. raise MMGenCalledProcessError(cp)
  56. return cp
  57. def run_cmd(self,cmd,silent=False,check=True,is_daemon=False):
  58. if is_daemon and not silent:
  59. msg('Starting {} {}'.format(self.net_desc,self.desc))
  60. if self.debug:
  61. msg('\nExecuting: {}'.format(' '.join(cmd)))
  62. if self.platform == 'win' and is_daemon:
  63. cp = self.exec_cmd_thread(cmd,check)
  64. else:
  65. cp = self.exec_cmd(cmd,check)
  66. if cp:
  67. out = cp.stdout.decode().rstrip()
  68. err = cp.stderr.decode().rstrip()
  69. if out and (self.debug or not silent):
  70. msg(out)
  71. if err and (self.debug or (cp.returncode and not silent)):
  72. msg(err)
  73. return cp
  74. @property
  75. def pid(self):
  76. if self.ps_pid_mswin and self.platform == 'win':
  77. # TODO: assumes only one running instance of given daemon
  78. cp = self.run_cmd(['ps','-Wl'],silent=True,check=False)
  79. for line in cp.stdout.decode().splitlines():
  80. if self.exec_fn_mswin in line:
  81. return line.split()[3] # use Windows, not Cygwin, PID
  82. die(2,'PID for {!r} not found in ps output'.format(ss))
  83. elif self.use_pidfile:
  84. return open(self.pidfile).read().strip()
  85. else:
  86. return '(unknown)'
  87. def cmd(self,action,*args,**kwargs):
  88. return getattr(self,action)(*args,**kwargs)
  89. def do_start(self,silent=False):
  90. if not silent:
  91. msg('Starting {} {}'.format(self.net_desc,self.desc))
  92. return self.run_cmd(self.start_cmd,silent=True,is_daemon=True)
  93. def do_stop(self,silent=False):
  94. if not silent:
  95. msg('Stopping {} {}'.format(self.net_desc,self.desc))
  96. return self.run_cmd(self.stop_cmd,silent=True)
  97. def cli(self,*cmds,silent=False,check=True):
  98. return self.run_cmd(self.cli_cmd(*cmds),silent=silent,check=check)
  99. def start(self,silent=False):
  100. if self.state == 'ready':
  101. if not silent:
  102. m = '{} {} already running with pid {}'
  103. msg(m.format(self.net_desc,self.desc,self.pid))
  104. return
  105. self.wait_for_state('stopped')
  106. os.makedirs(self.datadir,exist_ok=True)
  107. if self.cfg_file and not 'keep_cfg_file' in self.flags:
  108. open('{}/{}'.format(self.datadir,self.cfg_file),'w').write(self.cfg_file_hdr)
  109. if self.use_pidfile and os.path.exists(self.pidfile):
  110. # OpenEthereum just overwrites the data in the existing pidfile without zeroing it first,
  111. # leading to interesting consequences.
  112. os.unlink(self.pidfile)
  113. for i in range(20):
  114. try: ret = self.do_start(silent=silent)
  115. except FileNotFoundError as e:
  116. die(e.errno,e.strerror)
  117. except: pass
  118. else: break
  119. time.sleep(1)
  120. else:
  121. die(2,'Unable to start daemon')
  122. if self.wait:
  123. self.wait_for_state('ready')
  124. return ret
  125. def stop(self,silent=False):
  126. if self.state == 'ready':
  127. ret = self.do_stop(silent=silent)
  128. if self.wait:
  129. self.wait_for_state('stopped')
  130. return ret
  131. else:
  132. if not silent:
  133. msg('{} {} not running'.format(self.net_desc,self.desc))
  134. def restart(self,silent=False):
  135. self.stop(silent=silent)
  136. return self.start(silent=silent)
  137. def test_socket(self,host,port,timeout=10):
  138. import socket
  139. try: socket.create_connection((host,port),timeout=timeout).close()
  140. except: return False
  141. else: return True
  142. def wait_for_state(self,req_state):
  143. for i in range(300):
  144. if self.state == req_state:
  145. return True
  146. time.sleep(0.2)
  147. else:
  148. m = 'Wait for state {!r} timeout exceeded for daemon {} {} (port {})'
  149. die(2,m.format(req_state,self.daemon_id.upper(),self.network,self.rpc_port))
  150. @classmethod
  151. def check_implement(cls):
  152. m = 'required method {}() missing in class {}'
  153. for subcls in cls.__subclasses__():
  154. for k in cls.subclasses_must_implement:
  155. assert k in subcls.__dict__, m.format(k,subcls.__name__)
  156. @property
  157. def flags(self):
  158. return self._flags
  159. def add_flag(self,val):
  160. if val not in self.avail_flags:
  161. m = '{!r}: unrecognized flag (available options: {})'
  162. die(1,m.format(val,self.avail_flags))
  163. if val in self._flags:
  164. die(1,'Flag {!r} already set'.format(val))
  165. self._flags.append(val)
  166. def remove_flag(self,val):
  167. if val not in self.avail_flags:
  168. m = '{!r}: unrecognized flag (available options: {})'
  169. die(1,m.format(val,self.avail_flags))
  170. if val not in self._flags:
  171. die(1,'Flag {!r} not set, so cannot be removed'.format(val))
  172. self._flags.remove(val)
  173. def remove_datadir(self):
  174. if self.state == 'stopped':
  175. run(['/bin/rm','-rf',self.datadir])
  176. else:
  177. msg(f'Cannot remove {self.datadir!r} - daemon is not stopped')
  178. class MoneroWalletDaemon(Daemon):
  179. desc = 'RPC daemon'
  180. net_desc = 'Monero wallet'
  181. daemon_id = 'xmr'
  182. network = 'wallet RPC'
  183. new_console_mswin = True
  184. exec_fn_mswin = 'monero-wallet-rpc.exe'
  185. ps_pid_mswin = True
  186. def __init__(self,wallet_dir,test_suite=False,host=None,user=None,passwd=None):
  187. super().__init__()
  188. self.platform = g.platform
  189. self.wallet_dir = wallet_dir
  190. if test_suite:
  191. self.datadir = os.path.join('test','monero-wallet-rpc')
  192. self.rpc_port = 13142
  193. else:
  194. self.datadir = 'monero-wallet-rpc'
  195. self.rpc_port = 13131
  196. self.daemon_port = CoinDaemon('xmr',test_suite=test_suite).rpc_port
  197. self.pidfile = os.path.join(self.datadir,'monero-wallet-rpc.pid')
  198. self.logfile = os.path.join(self.datadir,'monero-wallet-rpc.log')
  199. if self.platform == 'win':
  200. self.use_pidfile = False
  201. self.host = host or g.monero_wallet_rpc_host
  202. self.user = user or g.monero_wallet_rpc_user
  203. self.passwd = passwd or g.monero_wallet_rpc_password
  204. assert self.host
  205. assert self.user
  206. if not self.passwd:
  207. die(1,
  208. 'You must set your Monero wallet RPC password.\n' +
  209. 'This can be done on the command line, with the --monero-wallet-rpc-password\n' +
  210. "option (insecure, not recommended), or by setting 'monero_wallet_rpc_password'\n" +
  211. "in the MMGen config file." )
  212. @property
  213. def start_cmd(self):
  214. cmd = [
  215. 'monero-wallet-rpc',
  216. '--daemon-port={}'.format(self.daemon_port),
  217. '--rpc-bind-port={}'.format(self.rpc_port),
  218. '--wallet-dir='+self.wallet_dir,
  219. '--log-file='+self.logfile,
  220. '--rpc-login={}:{}'.format(self.user,self.passwd) ]
  221. if self.platform == 'linux':
  222. cmd += ['--pidfile={}'.format(self.pidfile)]
  223. cmd += [] if 'no_daemonize' in self.flags else ['--detach']
  224. return cmd
  225. @property
  226. def state(self):
  227. return 'ready' if self.test_socket('localhost',self.rpc_port) else 'stopped'
  228. if not self.test_socket(self.host,self.rpc_port):
  229. return 'stopped'
  230. from .rpc import MoneroWalletRPCClient
  231. try:
  232. MoneroWalletRPCClient(
  233. self.host,
  234. self.rpc_port,
  235. self.user,
  236. self.passwd).call('get_version')
  237. return 'ready'
  238. except:
  239. return 'stopped'
  240. @property
  241. def stop_cmd(self):
  242. return ['kill','-Wf',self.pid] if self.platform == 'win' else ['kill',self.pid]
  243. class CoinDaemon(Daemon):
  244. cfg_file_hdr = ''
  245. subclasses_must_implement = ('state','stop_cmd')
  246. avail_flags = ('no_daemonize','keep_cfg_file')
  247. network_ids = ('btc','btc_tn','btc_rt','bch','bch_tn','bch_rt','ltc','ltc_tn','ltc_rt','xmr','eth','etc')
  248. cd = namedtuple('daemon_data',
  249. ['coin','cls_pfx','coind_exec','cli_exec','cfg_file','testnet_dir','dfl_rpc','dfl_rpc_tn','dfl_rpc_rt'])
  250. daemon_ids = { # for BCH we use non-standard RPC ports
  251. 'btc': cd('Bitcoin', 'Bitcoin', 'bitcoind', 'bitcoin-cli', 'bitcoin.conf', 'testnet3',8332,18332,18444),
  252. 'bch': cd('Bcash', 'Bitcoin', 'bitcoind-abc','bitcoin-cli', 'bitcoin.conf', 'testnet3',8442,18442,18553),
  253. 'ltc': cd('Litecoin', 'Bitcoin', 'litecoind', 'litecoin-cli','litecoin.conf','testnet4',9332,19332,19444),
  254. 'xmr': cd('Monero', 'Monero', 'monerod', 'monerod', 'bitmonero.conf',None, 18081,None,None),
  255. 'eth': cd('Ethereum', 'Ethereum','openethereum','openethereum','parity.conf', None, 8545, 8545,8545),
  256. 'etc': cd('Ethereum Classic','Ethereum','openethereum','openethereum','parity.conf', None, 8545, 8545,8545)
  257. }
  258. def __new__(cls,network_id=None,test_suite=False,flags=None,proto=None):
  259. assert network_id or proto, 'CoinDaemon_chk1'
  260. assert not (network_id and proto), 'CoinDaemon_chk2'
  261. if proto:
  262. network_id = proto.network_id
  263. network = proto.network
  264. daemon_id = proto.coin.lower()
  265. else:
  266. network_id = network_id.lower()
  267. assert network_id in cls.network_ids, '{!r}: invalid network ID'.format(network_id)
  268. from mmgen.protocol import CoinProtocol
  269. daemon_id,network = CoinProtocol.Base.parse_network_id(network_id)
  270. me = Daemon.__new__(globals()[cls.daemon_ids[daemon_id].cls_pfx+'Daemon'])
  271. me.network_id = network_id
  272. me.network = network
  273. me.daemon_id = daemon_id
  274. me.desc = 'daemon'
  275. if network == 'regtest':
  276. me.desc = 'regtest daemon'
  277. if test_suite:
  278. rel_datadir = os.path.join(
  279. 'test',
  280. 'data_dir{}'.format('-α' if g.debug_utf8 else ''),
  281. 'regtest',
  282. daemon_id )
  283. else:
  284. datadir = os.path.join(g.data_dir_root,'regtest',daemon_id)
  285. elif test_suite:
  286. me.desc = 'test suite daemon'
  287. rel_datadir = os.path.join('test','daemons',daemon_id)
  288. else:
  289. datadir = me.dfl_datadir
  290. if test_suite:
  291. datadir = os.path.join(os.getcwd(),rel_datadir)
  292. if g.daemon_data_dir: # user-set value overrides everything else
  293. datadir = g.daemon_data_dir
  294. me.datadir = os.path.abspath(datadir)
  295. me.data_subdir = (lambda x: x if network == 'testnet' and x else '')(me.daemon_ids[daemon_id].testnet_dir)
  296. me.port_shift = 1237 if test_suite else 0
  297. me.platform = g.platform
  298. return me
  299. def __init__(self,network_id=None,test_suite=False,flags=None,proto=None):
  300. super().__init__()
  301. self.testnet_arg = []
  302. self.daemonize_args = []
  303. self.cli_args = []
  304. self.coind_cmd = []
  305. self.coin_specific_coind_args = []
  306. self.coin_specific_cli_args = []
  307. self.coin_specific_shared_args = []
  308. self.usr_coind_args = []
  309. self.usr_cli_args = []
  310. self.usr_shared_args = []
  311. if flags:
  312. if type(flags) not in (list,tuple):
  313. m = '{!r}: illegal value for flags (must be list or tuple)'
  314. die(1,m.format(flags))
  315. for flag in flags:
  316. self.add_flag(flag)
  317. self.pidfile = '{}/{}-daemon.pid'.format(self.datadir,self.network)
  318. for k in self.daemon_ids[self.daemon_id]._fields:
  319. setattr(self,k,getattr(self.daemon_ids[self.daemon_id],k))
  320. self.rpc_port = {
  321. 'mainnet': self.dfl_rpc,
  322. 'testnet': self.dfl_rpc_tn,
  323. 'regtest': self.dfl_rpc_rt,
  324. }[self.network] + self.port_shift
  325. if g.rpc_port: # user-set value overrides everything else
  326. self.rpc_port = g.rpc_port
  327. self.net_desc = '{} {}'.format(self.coin,self.network)
  328. self.subclass_init()
  329. @property
  330. def dfl_datadir(self):
  331. if g.platform == 'linux':
  332. path_data = {
  333. 'btc': ['.bitcoin'],
  334. 'bch': ['.bitcoin-abc'],
  335. 'ltc': ['.litecoin'],
  336. 'xmr': ['.bitmonero'],
  337. 'eth': ['.local','share','io.parity.ethereum'],
  338. 'etc': ['.local','share','io.parity.ethereum'],
  339. }
  340. return os.path.join( *([g.home_dir] + path_data[self.daemon_id]) )
  341. elif g.platform == 'win':
  342. path_data = {
  343. 'btc': [os.getenv('APPDATA'),'Bitcoin'],
  344. 'bch': [os.getenv('APPDATA'),'Bitcoin_ABC'],
  345. 'ltc': [os.getenv('APPDATA'),'Litecoin'],
  346. 'xmr': ['/','c','ProgramData','bitmonero'],
  347. 'eth': [g.home_dir,'.local','share','io.parity.ethereum'],
  348. 'etc': [g.home_dir,'.local','share','io.parity.ethereum'],
  349. }
  350. return os.path.join(*path_data[self.daemon_id])
  351. else:
  352. raise ValueError(f'{g.platform}: unrecognized platform')
  353. @property
  354. def start_cmd(self):
  355. return ([self.coind_exec]
  356. + self.testnet_arg
  357. + self.coind_args
  358. + self.shared_args
  359. + self.coin_specific_coind_args
  360. + self.coin_specific_shared_args
  361. + self.usr_coind_args
  362. + self.usr_shared_args
  363. + self.daemonize_args
  364. + self.coind_cmd )
  365. def cli_cmd(self,*cmds):
  366. return ([self.cli_exec]
  367. + self.testnet_arg
  368. + self.cli_args
  369. + self.shared_args
  370. + self.coin_specific_cli_args
  371. + self.coin_specific_shared_args
  372. + self.usr_cli_args
  373. + self.usr_shared_args
  374. + list(cmds))
  375. class BitcoinDaemon(CoinDaemon):
  376. cfg_file_hdr = '# BitcoinDaemon config file\n'
  377. def subclass_init(self):
  378. if self.platform == 'win' and self.daemon_id == 'bch':
  379. self.use_pidfile = False
  380. if self.network == 'testnet':
  381. self.testnet_arg = ['--testnet']
  382. self.shared_args = [
  383. '--datadir={}'.format(self.datadir),
  384. '--rpcport={}'.format(self.rpc_port) ]
  385. self.coind_args = [
  386. '--listen=0',
  387. '--keypool=1',
  388. '--rpcallowip=127.0.0.1',
  389. '--rpcbind=127.0.0.1:{}'.format(self.rpc_port) ]
  390. if self.use_pidfile:
  391. self.coind_args += ['--pid='+self.pidfile]
  392. if self.platform == 'linux' and not 'no_daemonize' in self.flags:
  393. self.daemonize_args = ['--daemon']
  394. if self.daemon_id == 'btc':
  395. if self.network == 'regtest':
  396. self.coin_specific_coind_args = ['--fallbackfee=0.0002']
  397. elif self.daemon_id == 'bch':
  398. self.coin_specific_coind_args = ['--usecashaddr=0']
  399. elif self.daemon_id == 'ltc':
  400. self.coin_specific_coind_args = ['--mempoolreplacement=1','--txindex=1']
  401. if self.network == 'testnet':
  402. self.lockfile = os.path.join(self.datadir,self.testnet_dir,'.cookie')
  403. elif self.network == 'mainnet':
  404. self.lockfile = os.path.join(self.datadir,'.cookie')
  405. @property
  406. def state(self):
  407. cp = self.cli('getblockcount',silent=True,check=False)
  408. err = cp.stderr.decode()
  409. if ("error: couldn't connect" in err
  410. or "error: Could not connect" in err
  411. or "does not exist" in err ):
  412. # regtest has no cookie file, so test will always fail
  413. if self.lockfile and os.path.exists(self.lockfile):
  414. ret = 'busy'
  415. else:
  416. ret = 'stopped'
  417. elif cp.returncode == 0:
  418. ret = 'ready'
  419. else:
  420. ret = 'busy'
  421. if self.debug:
  422. print(f'State: {ret!r}')
  423. return ret
  424. @property
  425. def stop_cmd(self):
  426. return self.cli_cmd('stop')
  427. class MoneroDaemon(CoinDaemon):
  428. exec_fn_mswin = 'monerod.exe'
  429. ps_pid_mswin = True
  430. new_console_mswin = True
  431. host = 'localhost' # FIXME
  432. def subclass_init(self):
  433. if self.platform == 'win':
  434. self.use_pidfile = False
  435. @property
  436. def shared_args(self):
  437. return ['--zmq-rpc-bind-port={}'.format(self.rpc_port+1),'--rpc-bind-port={}'.format(self.rpc_port)]
  438. @property
  439. def coind_args(self):
  440. cmd = [
  441. '--bg-mining-enable',
  442. '--data-dir={}'.format(self.datadir),
  443. '--offline' ]
  444. if self.platform == 'linux':
  445. cmd += ['--pidfile={}'.format(self.pidfile)]
  446. cmd += [] if 'no_daemonize' in self.flags else ['--detach']
  447. return cmd
  448. @property
  449. def state(self):
  450. if not self.test_socket(self.host,self.rpc_port):
  451. return 'stopped'
  452. cp = self.run_cmd(
  453. [self.coind_exec]
  454. + self.shared_args
  455. + ['status'],
  456. silent=True,
  457. check=False )
  458. return 'stopped' if 'Error:' in cp.stdout.decode() else 'ready'
  459. @property
  460. def stop_cmd(self):
  461. if self.platform == 'win':
  462. return ['kill','-Wf',self.pid]
  463. else:
  464. return [self.coind_exec] + self.shared_args + ['exit']
  465. class EthereumDaemon(CoinDaemon):
  466. exec_fn_mswin = 'openethereum.exe'
  467. ps_pid_mswin = True
  468. def subclass_init(self):
  469. self.shared_args = []
  470. # defaults:
  471. # linux: $HOME/.local/share/io.parity.ethereum/chains/DevelopmentChain
  472. # win: $LOCALAPPDATA/Parity/Ethereum/chains/DevelopmentChain
  473. self.chaindir = os.path.join(self.datadir,'devchain')
  474. shutil.rmtree(self.chaindir,ignore_errors=True)
  475. if self.platform == 'linux' and not 'no_daemonize' in self.flags:
  476. self.daemonize_args = ['daemon',self.pidfile]
  477. @property
  478. def coind_args(self):
  479. return ['--ports-shift={}'.format(self.port_shift),
  480. '--base-path={}'.format(self.chaindir),
  481. '--config=dev',
  482. '--log-file={}'.format(os.path.join(self.datadir,'openethereum.log')) ]
  483. @property
  484. def state(self):
  485. return 'ready' if self.test_socket('localhost',self.rpc_port) else 'stopped'
  486. # the following code does not work
  487. async def do():
  488. ret = await self.rpc.call('eth_chainId')
  489. return ('stopped','ready')[ret == '0x11']
  490. try:
  491. return run_session(do()) # socket exception is not propagated
  492. except:# SocketError:
  493. return 'stopped'
  494. @property
  495. def stop_cmd(self):
  496. return ['kill','-Wf',self.pid] if self.platform == 'win' else ['kill',self.pid]
  497. CoinDaemon.check_implement()