daemon.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2024 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 sys,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,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 = sys.platform
  50. if self.platform == 'win32':
  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 == 'win32' 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 == 'win32':
  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,check_retcode=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 OSError as e:
  75. die( 'MMGenCalledProcessError', f'Error starting executable: {type(e).__name__} [Errno {e.errno}]' )
  76. set_vt100()
  77. if check_retcode and cp.returncode:
  78. die(1,str(cp))
  79. if self.debug:
  80. print(cp)
  81. return cp
  82. def run_cmd(self,cmd,silent=False,is_daemon=False,check_retcode=False):
  83. if self.debug:
  84. msg('\n\n')
  85. if self.debug or (is_daemon and not silent):
  86. msg(f'Starting {self.desc} on port {self.bind_port}')
  87. if self.debug:
  88. msg(f'\nExecuting:\n{fmt_list(cmd,fmt="col",indent=" ")}\n')
  89. if self.use_threads and is_daemon and not self.opt.no_daemonize:
  90. ret = self.exec_cmd_thread(cmd)
  91. else:
  92. ret = self.exec_cmd(cmd,is_daemon,check_retcode)
  93. if isinstance(ret,CompletedProcess):
  94. if ret.stdout and (self.debug or not silent):
  95. msg(ret.stdout.decode().rstrip())
  96. if ret.stderr and (self.debug or (ret.returncode and not silent)):
  97. msg(ret.stderr.decode().rstrip())
  98. return ret
  99. @property
  100. def pid(self):
  101. if self.use_pidfile:
  102. with open(self.pidfile) as fp:
  103. return fp.read().strip()
  104. elif self.platform == 'win32':
  105. # Assumes only one running instance of given daemon. If multiple daemons are running,
  106. # the first PID in the list is returned and self.pids is set to the PID list.
  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 in ('linux', 'darwin'):
  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 == 'win32' 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,check_retcode=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. time.sleep(0.3) # race condition
  183. return ret
  184. else:
  185. if not (quiet or silent):
  186. msg(f'{self.desc} on port {self.bind_port} not running')
  187. return True
  188. def restart(self,silent=False):
  189. self.stop(silent=silent)
  190. return self.start(silent=silent)
  191. def test_socket(self,host,port,timeout=10):
  192. import socket
  193. try:
  194. socket.create_connection((host,port),timeout=timeout).close()
  195. except:
  196. return False
  197. else:
  198. return True
  199. def wait_for_state(self,req_state):
  200. for _ in range(300):
  201. if self.state == req_state:
  202. return True
  203. time.sleep(0.2)
  204. else:
  205. die(2,f'Wait for state {req_state!r} timeout exceeded for {self.desc} (port {self.bind_port})')
  206. @classmethod
  207. def get_exec_version_str(cls):
  208. try:
  209. cp = run([cls.exec_fn,cls.version_info_arg],stdout=PIPE,stderr=PIPE,check=True)
  210. except Exception as e:
  211. die(2,f'{e}\nUnable to execute {cls.exec_fn}')
  212. if cp.returncode:
  213. die(2,f'Unable to execute {cls.exec_fn}')
  214. else:
  215. res = cp.stdout.decode().splitlines()
  216. return ( res[0] if len(res) == 1 else [s for s in res if 'ersion' in s][0] ).strip()
  217. class RPCDaemon(Daemon):
  218. avail_opts = ('no_daemonize',)
  219. def __init__(self,cfg,opts=None,flags=None):
  220. super().__init__(cfg,opts=opts,flags=flags)
  221. self.desc = '{} {} {}RPC daemon'.format(
  222. self.rpc_type,
  223. getattr(self.proto.network_names,self.proto.network),
  224. 'test suite ' if self.test_suite else '' )
  225. self._set_ok += ('usr_daemon_args',)
  226. self.usr_daemon_args = []
  227. @property
  228. def start_cmd(self):
  229. return [self.exec_fn] + self.daemon_args + self.usr_daemon_args
  230. class CoinDaemon(Daemon):
  231. networks = ('mainnet','testnet','regtest')
  232. cfg_file_hdr = ''
  233. avail_flags = ('keep_cfg_file',)
  234. avail_opts = ('no_daemonize','online')
  235. testnet_dir = None
  236. test_suite_port_shift = 1237
  237. rpc_user = None
  238. rpc_password = None
  239. _cd = namedtuple('coins_data',['daemon_ids'])
  240. coins = {
  241. 'BTC': _cd(['bitcoin_core']),
  242. 'BCH': _cd(['bitcoin_cash_node']),
  243. 'LTC': _cd(['litecoin_core']),
  244. 'XMR': _cd(['monero']),
  245. 'ETH': _cd(['geth','erigon','openethereum']),
  246. 'ETC': _cd(['parity']),
  247. }
  248. @classmethod
  249. def all_daemon_ids(cls):
  250. return [i for coin in cls.coins for i in cls.coins[coin].daemon_ids]
  251. class warn_blacklisted(oneshot_warning):
  252. color = 'yellow'
  253. message = 'blacklisted daemon: {!r}'
  254. @classmethod
  255. def get_daemon_ids(cls,cfg,coin):
  256. ret = cls.coins[coin].daemon_ids
  257. if 'erigon' in ret and not cfg.enable_erigon:
  258. ret.remove('erigon')
  259. if cfg.blacklisted_daemons:
  260. blacklist = cfg.blacklisted_daemons.split()
  261. def gen():
  262. for daemon_id in ret:
  263. if daemon_id in blacklist:
  264. cls.warn_blacklisted(div=daemon_id,fmt_args=[daemon_id])
  265. else:
  266. yield daemon_id
  267. ret = list(gen())
  268. return ret
  269. @classmethod
  270. def get_daemon(cls,cfg,coin,daemon_id,proto=None):
  271. if proto:
  272. proto_cls = type(proto)
  273. else:
  274. from .protocol import init_proto
  275. proto_cls = init_proto(cfg, coin, return_cls=True)
  276. return getattr(
  277. importlib.import_module(f'mmgen.proto.{proto_cls.base_proto_coin.lower()}.daemon'),
  278. daemon_id+'_daemon' )
  279. @classmethod
  280. def get_network_ids(cls,cfg):
  281. from .protocol import CoinProtocol
  282. def gen():
  283. for coin in cls.coins:
  284. for daemon_id in cls.get_daemon_ids(cfg,coin):
  285. for network in cls.get_daemon( cfg, coin, daemon_id ).networks:
  286. yield CoinProtocol.Base.create_network_id(coin,network)
  287. return remove_dups(list(gen()),quiet=True)
  288. def __new__(cls,
  289. cfg,
  290. network_id = None,
  291. proto = None,
  292. opts = None,
  293. flags = None,
  294. test_suite = False,
  295. port_shift = None,
  296. p2p_port = None,
  297. datadir = None,
  298. daemon_id = None ):
  299. assert network_id or proto, 'CoinDaemon_chk1'
  300. assert not (network_id and proto), 'CoinDaemon_chk2'
  301. if proto:
  302. network_id = proto.network_id
  303. network = proto.network
  304. coin = proto.coin
  305. else:
  306. network_id = network_id.lower()
  307. from .protocol import CoinProtocol,init_proto
  308. proto = init_proto( cfg, network_id=network_id )
  309. coin,network = CoinProtocol.Base.parse_network_id(network_id)
  310. coin = coin.upper()
  311. daemon_ids = cls.get_daemon_ids(cfg,coin)
  312. if not daemon_ids:
  313. die(1,f'No configured daemons for coin {coin}!')
  314. daemon_id = daemon_id or cfg.daemon_id or daemon_ids[0]
  315. if daemon_id not in daemon_ids:
  316. die(1,f'{daemon_id!r}: invalid daemon_id - valid choices: {fmt_list(daemon_ids)}')
  317. me = Daemon.__new__(cls.get_daemon( cfg, None, daemon_id, proto=proto ))
  318. assert network in me.networks, f'{network!r}: unsupported network for daemon {daemon_id}'
  319. me.network_id = network_id
  320. me.network = network
  321. me.coin = coin
  322. me.id = daemon_id
  323. me.proto = proto
  324. return me
  325. def __init__(self,
  326. cfg,
  327. network_id = None,
  328. proto = None,
  329. opts = None,
  330. flags = None,
  331. test_suite = False,
  332. port_shift = None,
  333. p2p_port = None,
  334. datadir = None,
  335. daemon_id = None ):
  336. self.test_suite = test_suite
  337. super().__init__(cfg=cfg,opts=opts,flags=flags)
  338. self._set_ok += ('shared_args','usr_coind_args')
  339. self.shared_args = []
  340. self.usr_coind_args = []
  341. for k,v in self.daemon_data._asdict().items():
  342. setattr(self,k,v)
  343. self.desc = '{} {} {}daemon'.format(
  344. self.coind_name,
  345. getattr(self.proto.network_names,self.network),
  346. 'test suite ' if test_suite else '' )
  347. # user-set values take precedence
  348. self.datadir = os.path.abspath(datadir or cfg.daemon_data_dir or self.init_datadir())
  349. self.non_dfl_datadir = bool(datadir or cfg.daemon_data_dir or test_suite or self.network == 'regtest')
  350. # init_datadir() may have already initialized logdir
  351. self.logdir = os.path.abspath(getattr(self,'logdir',self.datadir))
  352. ps_adj = (port_shift or 0) + (self.test_suite_port_shift if test_suite else 0)
  353. # user-set values take precedence
  354. self.rpc_port = (cfg.rpc_port or 0) + (port_shift or 0) if cfg.rpc_port else ps_adj + self.get_rpc_port()
  355. self.p2p_port = (
  356. p2p_port or (
  357. self.get_p2p_port() + ps_adj if self.get_p2p_port() and (test_suite or ps_adj) else None
  358. ) if self.network != 'regtest' else None )
  359. if hasattr(self,'private_ports'):
  360. self.private_port = getattr(self.private_ports,self.network)
  361. # bind_port == self.private_port or self.rpc_port
  362. self.pidfile = f'{self.logdir}/{self.id}-{self.network}-daemon-{self.bind_port}.pid'
  363. self.logfile = f'{self.logdir}/{self.id}-{self.network}-daemon-{self.bind_port}.log'
  364. self.init_subclass()
  365. def init_datadir(self):
  366. if self.test_suite:
  367. return os.path.join('test','daemons',self.network_id)
  368. else:
  369. return os.path.join(*self.datadirs[self.platform])
  370. @property
  371. def network_datadir(self):
  372. return self.datadir
  373. def get_rpc_port(self):
  374. return getattr(self.rpc_ports,self.network)
  375. def get_p2p_port(self):
  376. return None
  377. @property
  378. def start_cmd(self):
  379. return ([self.exec_fn]
  380. + self.coind_args
  381. + self.shared_args
  382. + self.usr_coind_args )
  383. def cli_cmd(self,*cmds):
  384. return ([self.cli_fn]
  385. + self.shared_args
  386. + list(cmds) )
  387. def start(self,*args,**kwargs):
  388. assert self.test_suite or self.network == 'regtest', 'start() restricted to test suite and regtest'
  389. return super().start(*args,**kwargs)
  390. def stop(self,*args,**kwargs):
  391. assert self.test_suite or self.network == 'regtest', 'stop() restricted to test suite and regtest'
  392. return super().stop(*args,**kwargs)
  393. def pre_start(self):
  394. os.makedirs(self.datadir,exist_ok=True)
  395. if self.test_suite or self.network == 'regtest':
  396. if self.cfg_file and not self.flag.keep_cfg_file:
  397. with open(f'{self.datadir}/{self.cfg_file}','w') as fp:
  398. fp.write(self.cfg_file_hdr)
  399. if self.use_pidfile and os.path.exists(self.pidfile):
  400. # Parity overwrites the data in the existing pidfile without zeroing it first, leading
  401. # to interesting consequences when the new PID has fewer digits than the previous one.
  402. os.unlink(self.pidfile)
  403. def remove_datadir(self):
  404. "remove the network's datadir"
  405. assert self.test_suite, 'datadir removal restricted to test suite'
  406. if self.state == 'stopped':
  407. run([
  408. ('rm' if self.platform == 'win32' else '/bin/rm'),
  409. '-rf',
  410. self.datadir ])
  411. set_vt100()
  412. else:
  413. msg(f'Cannot remove {self.network_datadir!r} - daemon is not stopped')