daemon.py 14 KB

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