# frozen_string_literal: true # Copyright (c) 2018-2023 Zerocracy, Inc. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the 'Software'), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. require 'open3' require 'slop' require 'shellwords' require 'backtrace' require 'fileutils' require 'zache' require 'concurrent' require 'zold/score' require_relative 'thread_badge' require_relative '../version' require_relative '../age' require_relative '../metronome' require_relative '../thread_pool' require_relative '../wallet' require_relative '../wallets' require_relative '../hungry_wallets' require_relative '../remotes' require_relative '../verbose_thread' require_relative '../node/farmers' require_relative '../node/entrance' require_relative '../node/safe_entrance' require_relative '../node/spread_entrance' require_relative '../node/async_entrance' require_relative '../node/sync_entrance' require_relative '../node/nodup_entrance' require_relative '../node/nospam_entrance' require_relative '../node/pipeline' require_relative '../node/journaled_pipeline' require_relative '../node/front' require_relative '../node/trace' require_relative '../node/farm' require_relative 'pull' require_relative 'push' require_relative 'pay' require_relative 'remote' # NODE command. # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2018 Yegor Bugayenko # License:: MIT module Zold # NODE command class Node prepend ThreadBadge def initialize(wallets:, remotes:, copies:, log: Log::NULL) @remotes = remotes @copies = copies @log = log @wallets = wallets end def run(args = []) opts = Slop.parse(args, help: true, suppress_errors: true) do |o| o.banner = 'Usage: zold node [options]' o.string '--invoice', 'The invoice you want to collect money to or the wallet ID', required: true o.integer '--port', "TCP port to announce in Zold Network (default: #{Remotes::PORT})", default: Remotes::PORT o.integer '--bind-port', "TCP port to listen on, which may differ from --port if you are behind a proxy (default: #{Remotes::PORT})", default: Remotes::PORT o.string '--host', 'Host name (will attempt to auto-detect it, if not specified)' o.integer '--strength', "The strength of the score (default: #{Score::STRENGTH})", default: Score::STRENGTH o.integer '--threads', "How many threads to use for scores finding (default: #{[Concurrent.processor_count / 2, 2].max})", default: [Concurrent.processor_count / 2, 2].max o.bool '--dump-errors', 'Make HTTP front-end errors visible in the log (false by default)', default: false o.bool '--standalone', 'Never communicate with other nodes (mostly for testing)', default: false o.bool '--ignore-score-weakness', 'Ignore score weakness of incoming requests and register those nodes anyway', default: false o.bool '--tolerate-edges', 'Don\'t fail if only "edge" (not "master" ones) nodes accepted/have the wallet', default: false o.integer '--tolerate-quorum', 'The minimum number of nodes required for a successful fetch (default: 4)', default: 4 o.boolean '--nohup', 'Run it in background, rebooting when a higher version is available in the network', default: false o.string '--nohup-command', 'The command to run in server "nohup" mode (default: "gem install zold")', default: 'gem install zold' o.string '--nohup-log', 'The file to log output into (default: zold.log)', default: 'zold.log' o.integer '--nohup-log-truncate', 'The maximum amount of bytes to keep in the file, and truncate it in half if it grows bigger', default: 1024 * 1024 o.string '--halt-code', 'The value of HTTP query parameter "halt," which will cause the front-end immediate termination', default: '' o.integer '--trace-length', 'Maximum length of the trace to keep in memory (default: 4096)', default: 4096 o.string '--save-pid', 'The file to save process ID into right after start (only in NOHUP mode)' o.bool '--never-reboot', 'Don\'t reboot when a new version shows up in the network', default: false o.bool '--routine-immediately', 'Run all routines immediately, without waiting between executions (for testing mostly)', default: false o.bool '--no-cache', 'Skip caching of front JSON pages (will seriously slow down, mostly useful for testing)', default: false o.boolean '--skip-audit', 'Don\'t report audit information to the console every minute', default: false o.boolean '--skip-reconnect', 'Don\'t reconnect to the network every minute (for testing)', default: false o.boolean '--not-hungry', 'Don\'t do hugry pulling of missed nodes (mostly for testing)', default: false o.bool '--allow-spam', 'Don\'t filter the incoming spam via PUT requests (duplicate wallets)', default: false o.bool '--ignore-empty-remotes', 'Don\'t fail if the list of remotes is empty (for testing mostly)', default: false o.bool '--skip-oom', 'Skip Out Of Memory check and never exit, no matter how much RAM is consumed', default: false o.integer '--oom-limit', "Maximum amount of memory we can consume, quit if we take more than that, in Mb (default: #{oom_limit})", default: oom_limit o.integer '--queue-limit', 'The maximum number of wallets to be accepted via PUSH and stored in the queue (default: 256)', default: 256 o.bool '--skip-gc', 'Don\'t run garbage collector and never remove any wallets from the disk', default: false o.integer '--gc-age', 'Maximum time in seconds to keep an empty and unused wallet on the disk', default: 60 * 60 * 24 * 10 o.string '--expose-version', "The version of the software to expose in JSON (default: #{VERSION})", default: VERSION o.string '--private-key', 'The location of RSA private key (default: ~/.ssh/id_rsa)', default: '~/.ssh/id_rsa' o.string '--network', "The name of the network (default: #{Wallet::MAINET})", default: Wallet::MAINET o.integer '--nohup-max-cycles', 'Maximum amount of nohup re-starts (-1 by default, which means forever)', default: -1 o.string '--home', "Home directory (default: #{Dir.pwd})", default: Dir.pwd o.bool '--no-metronome', 'Don\'t run the metronome', default: false o.bool '--disable-push', 'Prohibit all PUSH requests', default: false o.bool '--disable-fetch', 'Prohibit all FETCH requests', default: false o.string '--alias', 'The alias of the node (default: host:port)' o.string '--farmer', 'The name of the farmer, e.g. "plain", "spawn", "fork" (default: "plain")', default: 'plain' o.bool '--help', 'Print instructions' end if opts.help? @log.info(opts.to_s) return end raise '--invoice is mandatory' unless opts['invoice'] if opts['nohup'] if @remotes.all.empty? && !opts['standalone'] && !opts['ignore-empty-remotes'] raise 'There are no remote nodes in the list and you are not running in --standalone mode; the node won\'t connect to the network like that; try to do "zold remote reset" first' end pid = nohup(opts) IO.write(opts['save-pid'], pid) if opts['save-pid'] @log.debug("Process ID #{pid} saved into \"#{opts['save-pid']}\"") @log.info(pid) return end @log = Trace.new(@log, opts['trace-length']) Front.set(:log, @log) Front.set(:logger, @log) Front.set(:trace, @log) Front.set(:nohup_log, opts['nohup-log']) if opts['nohup-log'] Front.set(:protocol, Zold::PROTOCOL) Front.set(:logging, @log.debug?) home = File.expand_path(opts['home']) Front.set(:home, home) @log.info("Time: #{Time.now.utc.iso8601}; CPUs: #{Concurrent.processor_count}") @log.info("Home directory: #{home}") @log.info("Ruby version: #{RUBY_VERSION}/#{RUBY_PLATFORM}") @log.info("Zold gem version: #{Zold::VERSION}") @log.info("Zold protocol version: #{Zold::PROTOCOL}") @log.info("Network ID: #{opts['network']}") @log.info('Front caching is disabled via --no-cache') if opts['no-cache'] host = opts[:host] || ip port = opts[:port] address = "#{host}:#{port}".downcase @log.info("Node location: #{address}") @log.info("Local address: http://127.0.0.1:#{opts['bind-port']}/") @log.info("Remote nodes (#{@remotes.all.count}): \ #{@remotes.all.map { |r| "#{r[:host]}:#{r[:port]}" }.join(', ')}") @log.info("Wallets at: #{@wallets.path}") if opts['standalone'] @remotes = Remotes::Empty.new @log.info('Running in standalone mode! (will never talk to other remotes)') elsif @remotes.exists?(host, port) Remote.new(remotes: @remotes).run(['remote', 'remove', host, port.to_s]) @log.info("Removed current node (#{address}) from list of remotes") end if File.exist?(@copies) FileUtils.rm_rf(@copies) @log.info("Directory #{@copies} deleted") end wts = @wallets if opts['not-hungry'] @log.info('Hungry pulling disabled because of --not-hungry') else hungry = ThreadPool.new('hungry', log: @log) wts = HungryWallets.new(@wallets, @remotes, @copies, hungry, log: @log, network: opts['network']) end Front.set(:zache, Zache.new(dirty: true)) Front.set(:wallets, wts) Front.set(:remotes, @remotes) Front.set(:copies, @copies) Front.set(:address, address) Front.set(:root, home) ledger = File.join(home, 'ledger.csv') Front.set(:ledger, ledger) Front.set(:opts, opts) Front.set(:dump_errors, opts['dump-errors']) Front.set(:port, opts['bind-port']) async_dir = File.join(home, '.zoldata/async-entrance') FileUtils.mkdir_p(async_dir) Front.set(:async_dir, async_dir) journal_dir = File.join(home, '.zoldata/journal') FileUtils.mkdir_p(journal_dir) Front.set(:journal_dir, journal_dir) Front.set(:node_alias, node_alias(opts, address)) entrance = SafeEntrance.new( NoSpamEntrance.new( NoDupEntrance.new( AsyncEntrance.new( SpreadEntrance.new( SyncEntrance.new( Entrance.new( wts, JournaledPipeline.new( Pipeline.new( @remotes, @copies, address, ledger: ledger, network: opts['network'] ), journal_dir ), log: @log ), File.join(home, '.zoldata/sync-entrance'), log: @log ), wts, @remotes, address, log: @log, ignore_score_weakeness: opts['ignore-score-weakness'], tolerate_edges: opts['tolerate-edges'] ), async_dir, log: @log, queue_limit: opts['queue-limit'] ), wts, log: @log ), period: opts['allow-spam'] ? 0 : 60 * 60, log: @log ), network: opts['network'] ) entrance.start do |ent| Front.set(:entrance, ent) farm = Farm.new( invoice(opts), File.join(home, 'farm'), log: @log, farmer: farmer(opts), strength: opts[:strength] ) farm.start(host, opts[:port], threads: opts[:threads]) do |f| Front.set(:farm, f) metronome(f, opts, host, port).start do |metronome| Front.set(:metronome, metronome) @log.info("Starting up the web front at http://#{host}:#{opts[:port]}...") Front.run! @log.info("The web front stopped at http://#{host}:#{opts[:port]}") end end end hungry.kill unless opts['not-hungry'] @log.info('Thanks for helping Zold network!') end private def invoice(opts) invoice = opts['invoice'] unless invoice.include?('@') require_relative 'invoice' invoice = Invoice.new(wallets: @wallets, remotes: @remotes, copies: @copies, log: @log).run( ['invoice', invoice, "--network=#{Shellwords.escape(opts['network'])}"] + ["--tolerate-quorum=#{Shellwords.escape(opts['tolerate-quorum'])}"] + (opts['tolerate-edges'] ? ['--tolerate-edges'] : []) ) end invoice end def node_alias(opts, address) a = opts[:alias] || address unless a.eql?(address) || a =~ /^[A-Za-z0-9]{4,16}$/ raise "Alias should be a 4 to 16 char long alphanumeric string: #{a}" end a end def farmer(opts) case opts['farmer'].downcase.strip when 'plain' @log.debug('"Plain" farmer is used, only one CPU core will be utilized') Farmers::Plain.new when 'fork' @log.debug('"Fork" farmer is used') Farmers::Fork.new(log: @log) when 'spawn' @log.debug('"Spawn" farmer is used') Farmers::Spawn.new(log: @log) else raise "Farmer name is not recognized: #{opts['farmer']}" end end # Returns exit code def exec(cmd, nohup_log) start = Time.now Open3.popen2e({ 'MALLOC_ARENA_MAX' => '2' }, cmd) do |stdin, stdout, thr| nohup_log.print("Started process ##{thr.pid} from process ##{Process.pid}: #{cmd}\n") stdin.close until stdout.eof? begin line = stdout.gets rescue IOError => e line = Backtrace.new(e).to_s end nohup_log.print(line) end nohup_log.print("Nothing else left to read from ##{thr.pid}\n") code = thr.value.to_i nohup_log.print("Exit code of process ##{thr.pid} is #{code}, was alive for #{Age.new(start)}: #{cmd}\n") code end end def nohup(opts) pid = fork do nohup_log = NohupLog.new(opts['nohup-log'], opts['nohup-log-truncate']) Signal.trap('HUP') do nohup_log.print("Received HUP, ignoring...\n") end Signal.trap('TERM') do nohup_log.print("Received TERM, terminating...\n") exit(-1) end myself = File.expand_path($PROGRAM_NAME) args = ARGV.delete_if { |a| a.start_with?('--home') || a == '--nohup' } cycle = 0 loop do begin code = exec("#{myself} #{args.join(' ')}", nohup_log) raise "Exit code is #{code}" if code != 0 exec(opts['nohup-command'], nohup_log) rescue StandardError => e nohup_log.print(Backtrace.new(e).to_s) if cycle < opts['nohup-max-cycles'] nohup_log.print("Let's wait for a minutes, because of the exception...") sleep(60) end end next if opts['nohup-max-cycles'].negative? cycle += 1 if cycle > opts['nohup-max-cycles'] nohup_log.print("There are no more nohup cycles left, after the cycle no.#{cycle}") break end nohup_log.print("Going for nohup cycle no.#{cycle}") end end Process.detach(pid) pid end def metronome(farm, opts, host, port) metronome = Metronome.new(@log) if opts['no-metronome'] @log.info("Metronome hasn't been started because of --no-metronome") return metronome end if opts['skip-gc'] @log.info('Garbage collection is disabled because of --skip-gc') else require_relative 'routines/gc' metronome.add(Routines::Gc.new(opts, @wallets, log: @log)) end if opts['skip-audit'] @log.info('Audit is disabled because of --skip-audit') else require_relative 'routines/audit' metronome.add(Routines::Audit.new(opts, @wallets, log: @log)) end unless opts['standalone'] if opts['skip-reconnect'] @log.info('Reconnect is disabled because of --skip-reconnect') else require_relative 'routines/reconnect' metronome.add(Routines::Reconnect.new(opts, @remotes, farm, log: @log)) end end require_relative 'routines/spread' metronome.add(Routines::Spread.new(opts, @wallets, @remotes, @copies, log: @log)) require_relative 'routines/retire' metronome.add(Routines::Retire.new(opts, log: @log)) if @remotes.master?(host, port) require_relative 'routines/reconcile' metronome.add(Routines::Reconcile.new(opts, @wallets, @remotes, @copies, "#{host}:#{port}", log: @log)) else @log.info('This is not master, no need to reconcile') end @log.info('Metronome started (use --no-metronome to disable it)') metronome end def ip addr = Socket.ip_address_list.detect do |i| i.ipv4? && !i.ipv4_loopback? && !i.ipv4_multicast? && !i.ipv4_private? end raise 'Can\'t detect your IP address, you have to specify it in --host' if addr.nil? addr.ip_address end def oom_limit require 'total' Total::Mem.new.bytes / (1024 * 1024) / 2 rescue Total::CantDetect => e @log.error(e.message) 512 end # Log facility for nohup class NohupLog def initialize(file, max) @file = file raise "Truncation size is too small (#{max}), should be over 10Kb" if max < 10 * 1024 @max = max end def print(data) File.open(@file, 'a') { |f| f.print(data) } return if File.size(@file) < @max temp = Tempfile.new total = copy(@file, temp) unit = File.size(@file) / total tail = total - @max / (2 * unit) copy(temp, @file, tail) File.delete(temp) File.open(@file, 'a') do |f| f.print("The file was truncated, because it was over the quota of #{@max} bytes, \ #{tail} lines left out of #{total}, average line length was #{unit} bytes\n\n") end end def copy(source, target, start = 0) total = 0 File.open(target, 'w') do |t| File.open(source, 'r').each do |line| next unless total >= start t.print(line) total += 1 end end total end end end end