daemon.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2022 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 os,time,importlib
  22. from subprocess import run,PIPE,CompletedProcess
  23. from collections import namedtuple
  24. from .globalvars import g
  25. from .color import set_vt100
  26. from .util import msg,Msg_r,die
  27. from .flags import *
  28. _dd = namedtuple('daemon_data',['coind_name','coind_version','coind_version_str']) # latest tested version
  29. _nw = namedtuple('coin_networks',['mainnet','testnet','regtest'])
  30. class Daemon(Lockable):
  31. desc = 'daemon'
  32. debug = False
  33. wait = True
  34. use_pidfile = True
  35. pids = ()
  36. use_threads = False
  37. cfg_file = None
  38. new_console_mswin = False
  39. lockfile = None
  40. private_port = None
  41. avail_opts = ()
  42. avail_flags = () # like opts, but can be set or unset after instantiation
  43. _reset_ok = ('debug','wait','pids')
  44. def __init__(self,opts=None,flags=None):
  45. self.platform = g.platform
  46. if self.platform == 'win':
  47. self.use_pidfile = False
  48. self.use_threads = True
  49. self.opt = ClassOpts(self,opts)
  50. self.flag = ClassFlags(self,flags)
  51. def exec_cmd_thread(self,cmd):
  52. import threading
  53. tname = ('exec_cmd','exec_cmd_win_console')[self.platform == 'win' and self.new_console_mswin]
  54. t = threading.Thread(target=getattr(self,tname),args=(cmd,))
  55. t.daemon = True
  56. t.start()
  57. if self.platform == 'win':
  58. Msg_r(' \b') # blocks w/o this...crazy
  59. return True
  60. def exec_cmd_win_console(self,cmd):
  61. from subprocess import Popen,CREATE_NEW_CONSOLE,STARTUPINFO,STARTF_USESHOWWINDOW,SW_HIDE
  62. si = STARTUPINFO(dwFlags=STARTF_USESHOWWINDOW,wShowWindow=SW_HIDE)
  63. p = Popen(cmd,creationflags=CREATE_NEW_CONSOLE,startupinfo=si)
  64. p.wait()
  65. def exec_cmd(self,cmd,is_daemon=False):
  66. out = (PIPE,None)[is_daemon and self.opt.no_daemonize]
  67. try:
  68. cp = run(cmd,check=False,stdout=out,stderr=out)
  69. except Exception as e:
  70. die( 'MMGenCalledProcessError', f'Error starting executable: {type(e).__name__} [Errno {e.errno}]' )
  71. set_vt100()
  72. if self.debug:
  73. print(cp)
  74. return cp
  75. def run_cmd(self,cmd,silent=False,is_daemon=False):
  76. if self.debug:
  77. msg('\n\n')
  78. if self.debug or (is_daemon and not silent):
  79. msg(f'Starting {self.desc} on port {self.bind_port}')
  80. if self.use_threads and is_daemon and not self.opt.no_daemonize:
  81. ret = self.exec_cmd_thread(cmd)
  82. else:
  83. ret = self.exec_cmd(cmd,is_daemon)
  84. if isinstance(ret,CompletedProcess):
  85. if ret.stdout and (self.debug or not silent):
  86. msg(ret.stdout.decode().rstrip())
  87. if ret.stderr and (self.debug or (ret.returncode and not silent)):
  88. msg(ret.stderr.decode().rstrip())
  89. return ret
  90. @property
  91. def pid(self):
  92. if self.use_pidfile:
  93. with open(self.pidfile) as fp:
  94. return fp.read().strip()
  95. elif self.platform == 'win':
  96. """
  97. Assumes only one running instance of given daemon. If multiple daemons are running,
  98. the first PID in the list is returned and self.pids is set to the PID list.
  99. """
  100. ss = f'{self.exec_fn}.exe'
  101. cp = self.run_cmd(['ps','-Wl'],silent=True)
  102. self.pids = ()
  103. # use Windows, not Cygwin, PID
  104. pids = tuple(line.split()[3] for line in cp.stdout.decode().splitlines() if ss in line)
  105. if pids:
  106. if len(pids) > 1:
  107. self.pids = pids
  108. return pids[0]
  109. elif self.platform == 'linux':
  110. ss = ' '.join(self.start_cmd)
  111. cp = self.run_cmd(['pgrep','-f',ss],silent=True)
  112. if cp.stdout:
  113. return cp.stdout.strip().decode()
  114. die(2,f'{ss!r} not found in process list, cannot determine PID')
  115. @property
  116. def bind_port(self):
  117. return self.private_port or self.rpc_port
  118. @property
  119. def state(self):
  120. if self.debug:
  121. msg(f'Testing port {self.bind_port}')
  122. return 'ready' if self.test_socket('localhost',self.bind_port) else 'stopped'
  123. @property
  124. def start_cmds(self):
  125. return [self.start_cmd]
  126. @property
  127. def stop_cmd(self):
  128. return ['kill','-Wf',self.pid] if self.platform == 'win' else ['kill',self.pid]
  129. def cmd(self,action,*args,**kwargs):
  130. return getattr(self,action)(*args,**kwargs)
  131. def do_start(self,silent=False):
  132. if not silent:
  133. msg(f'Starting {self.desc} on port {self.bind_port}')
  134. return self.run_cmd(self.start_cmd,silent=True,is_daemon=True)
  135. def do_stop(self,silent=False):
  136. if not silent:
  137. msg(f'Stopping {self.desc} on port {self.bind_port}')
  138. return self.run_cmd(self.stop_cmd,silent=True)
  139. def cli(self,*cmds,silent=False):
  140. return self.run_cmd(self.cli_cmd(*cmds),silent=silent)
  141. def state_msg(self,extra_text=None):
  142. extra_text = f'{extra_text} ' if extra_text else ''
  143. return '{:{w}} {:10} {}'.format(
  144. f'{self.desc} {extra_text}running',
  145. 'pid N/A' if self.pid is None or self.pids else f'pid {self.pid}',
  146. f'port {self.bind_port}',
  147. w = 52 + len(extra_text) )
  148. def pre_start(self): pass
  149. def start(self,quiet=False,silent=False):
  150. if self.state == 'ready':
  151. if not (quiet or silent):
  152. msg(self.state_msg(extra_text='already'))
  153. return True
  154. self.wait_for_state('stopped')
  155. self.pre_start()
  156. ret = self.do_start(silent=silent)
  157. if self.wait:
  158. self.wait_for_state('ready')
  159. return ret
  160. def stop(self,quiet=False,silent=False):
  161. if self.state == 'ready':
  162. ret = self.do_stop(silent=silent)
  163. if self.pids:
  164. msg('Warning: multiple PIDs [{}] -- we may be stopping the wrong instance'.format(
  165. fmt_list(self.pids,fmt='bare')
  166. ))
  167. if self.wait:
  168. self.wait_for_state('stopped')
  169. return ret
  170. else:
  171. if not (quiet or silent):
  172. msg(f'{self.desc} on port {self.bind_port} not running')
  173. return True
  174. def restart(self,silent=False):
  175. self.stop(silent=silent)
  176. return self.start(silent=silent)
  177. def test_socket(self,host,port,timeout=10):
  178. import socket
  179. try:
  180. socket.create_connection((host,port),timeout=timeout).close()
  181. except:
  182. return False
  183. else:
  184. return True
  185. def wait_for_state(self,req_state):
  186. for i in range(300):
  187. if self.state == req_state:
  188. return True
  189. time.sleep(0.2)
  190. else:
  191. die(2,f'Wait for state {req_state!r} timeout exceeded for {self.desc} (port {self.bind_port})')
  192. class RPCDaemon(Daemon):
  193. avail_opts = ('no_daemonize',)
  194. def __init__(self):
  195. super().__init__()
  196. self.desc = '{} {} {}RPC daemon'.format(
  197. self.rpc_type,
  198. getattr(self.proto.network_names,self.proto.network),
  199. 'test suite ' if self.test_suite else '' )
  200. self._set_ok += ('usr_daemon_args',)
  201. self.usr_daemon_args = []
  202. @property
  203. def start_cmd(self):
  204. return ([self.exec_fn] + self.daemon_args + self.usr_daemon_args)
  205. class CoinDaemon(Daemon):
  206. networks = ('mainnet','testnet','regtest')
  207. cfg_file_hdr = ''
  208. avail_flags = ('keep_cfg_file',)
  209. avail_opts = ('no_daemonize','online')
  210. testnet_dir = None
  211. test_suite_port_shift = 1237
  212. rpc_user = None
  213. rpc_password = None
  214. _cd = namedtuple('coins_data',['coin_name','networks','daemon_ids'])
  215. coins = {
  216. 'BTC': _cd('Bitcoin', networks, ['bitcoin_core']),
  217. 'BCH': _cd('Bitcoin Cash Node', networks, ['bitcoin_cash_node']),
  218. 'LTC': _cd('Litecoin', networks, ['litecoin_core']),
  219. 'XMR': _cd('Monero', ('mainnet','testnet'), ['monero']),
  220. 'ETH': _cd('Ethereum', networks, ['openethereum','geth']
  221. + (['erigon'] if g.enable_erigon else []) ),
  222. 'ETC': _cd('Ethereum Classic', networks, ['parity']),
  223. }
  224. @classmethod
  225. def get_network_ids(cls): # FIXME: gets IDs for _default_ daemon only
  226. from .protocol import CoinProtocol
  227. def gen():
  228. for coin,data in cls.coins.items():
  229. for network in data.networks:
  230. yield CoinProtocol.Base.create_network_id(coin,network)
  231. return list(gen())
  232. def __new__(cls,
  233. network_id = None,
  234. proto = None,
  235. opts = None,
  236. flags = None,
  237. test_suite = False,
  238. port_shift = None,
  239. p2p_port = None,
  240. datadir = None,
  241. daemon_id = None ):
  242. assert network_id or proto, 'CoinDaemon_chk1'
  243. assert not (network_id and proto), 'CoinDaemon_chk2'
  244. if proto:
  245. network_id = proto.network_id
  246. network = proto.network
  247. coin = proto.coin
  248. else:
  249. network_id = network_id.lower()
  250. from .protocol import CoinProtocol,init_proto
  251. proto = init_proto(network_id=network_id)
  252. coin,network = CoinProtocol.Base.parse_network_id(network_id)
  253. coin = coin.upper()
  254. daemon_ids = cls.coins[coin].daemon_ids
  255. daemon_id = daemon_id or g.daemon_id or daemon_ids[0]
  256. if daemon_id not in daemon_ids:
  257. die(1,f'{daemon_id!r}: invalid daemon_id - valid choices: {fmt_list(daemon_ids)}')
  258. me = Daemon.__new__(
  259. getattr(
  260. importlib.import_module(f'mmgen.base_proto.{proto.base_proto.lower()}.daemon'),
  261. daemon_id + '_daemon' ))
  262. assert network in me.networks, f'{network!r}: unsupported network for daemon {daemon_id}'
  263. me.network_id = network_id
  264. me.network = network
  265. me.coin = coin
  266. me.coin_name = cls.coins[coin].coin_name
  267. me.id = daemon_id
  268. me.proto = proto
  269. return me
  270. def __init__(self,
  271. network_id = None,
  272. proto = None,
  273. opts = None,
  274. flags = None,
  275. test_suite = False,
  276. port_shift = None,
  277. p2p_port = None,
  278. datadir = None,
  279. daemon_id = None ):
  280. self.test_suite = test_suite
  281. super().__init__(opts=opts,flags=flags)
  282. self._set_ok += ('shared_args','usr_coind_args')
  283. self.shared_args = []
  284. self.usr_coind_args = []
  285. for k,v in self.daemon_data._asdict().items():
  286. setattr(self,k,v)
  287. self.desc = '{} {} {}daemon'.format(
  288. self.coind_name,
  289. getattr(self.proto.network_names,self.network),
  290. 'test suite ' if test_suite else '' )
  291. # user-set values take precedence
  292. self.datadir = os.path.abspath(datadir or g.daemon_data_dir or self.init_datadir())
  293. self.non_dfl_datadir = bool(datadir or g.daemon_data_dir or test_suite or self.network == 'regtest')
  294. # init_datadir() may have already initialized logdir
  295. self.logdir = os.path.abspath(getattr(self,'logdir',self.datadir))
  296. ps_adj = (port_shift or 0) + (self.test_suite_port_shift if test_suite else 0)
  297. # user-set values take precedence
  298. self.rpc_port = (g.rpc_port or 0) + (port_shift or 0) if g.rpc_port else ps_adj + self.get_rpc_port()
  299. self.p2p_port = (
  300. p2p_port or (
  301. self.get_p2p_port() + ps_adj if self.get_p2p_port() and (test_suite or ps_adj) else None
  302. ) if self.network != 'regtest' else None )
  303. if hasattr(self,'private_ports'):
  304. self.private_port = getattr(self.private_ports,self.network)
  305. # bind_port == self.private_port or self.rpc_port
  306. self.pidfile = '{}/{}-{}-daemon-{}.pid'.format(self.logdir,self.id,self.network,self.bind_port)
  307. self.logfile = '{}/{}-{}-daemon-{}.log'.format(self.logdir,self.id,self.network,self.bind_port)
  308. self.init_subclass()
  309. def init_datadir(self):
  310. if self.test_suite:
  311. return os.path.join('test','daemons',self.network_id)
  312. else:
  313. return os.path.join(*self.datadirs[self.platform])
  314. @property
  315. def network_datadir(self):
  316. return self.datadir
  317. def get_rpc_port(self):
  318. return getattr(self.rpc_ports,self.network)
  319. def get_p2p_port(self):
  320. return None
  321. @property
  322. def start_cmd(self):
  323. return ([self.exec_fn]
  324. + self.coind_args
  325. + self.shared_args
  326. + self.usr_coind_args )
  327. def cli_cmd(self,*cmds):
  328. return ([self.cli_fn]
  329. + self.shared_args
  330. + list(cmds) )
  331. def start(self,*args,**kwargs):
  332. assert self.test_suite or self.network == 'regtest', 'start() restricted to test suite and regtest'
  333. return super().start(*args,**kwargs)
  334. def stop(self,*args,**kwargs):
  335. assert self.test_suite or self.network == 'regtest', 'stop() restricted to test suite and regtest'
  336. return super().stop(*args,**kwargs)
  337. def pre_start(self):
  338. os.makedirs(self.datadir,exist_ok=True)
  339. if self.test_suite or self.network == 'regtest':
  340. if self.cfg_file and not self.flag.keep_cfg_file:
  341. with open(f'{self.datadir}/{self.cfg_file}','w') as fp:
  342. fp.write(self.cfg_file_hdr)
  343. if self.use_pidfile and os.path.exists(self.pidfile):
  344. # Parity overwrites the data in the existing pidfile without zeroing it first, leading
  345. # to interesting consequences when the new PID has fewer digits than the previous one.
  346. os.unlink(self.pidfile)
  347. def remove_datadir(self):
  348. "remove the network's datadir"
  349. assert self.test_suite, 'datadir removal restricted to test suite'
  350. if self.state == 'stopped':
  351. run([
  352. ('rm' if g.platform == 'win' else '/bin/rm'),
  353. '-rf',
  354. self.datadir ])
  355. set_vt100()
  356. else:
  357. msg(f'Cannot remove {self.network_datadir!r} - daemon is not stopped')