diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 9030ddf..95f6614 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -142,3 +142,43 @@ describe Sparoid::Server do s.try &.close end end + +describe Sparoid::Client do + describe ".process_send_results" do + it "raises SendError when every send failed" do + ip1 = Socket::IPAddress.new("1.1.1.1", 1) + ip2 = Socket::IPAddress.new("2.2.2.2", 2) + results = [ + {ip1, Exception.new("no route").as(Exception?)}, + {ip2, Exception.new("network down").as(Exception?)}, + ] + ex = expect_raises(Sparoid::Client::SendError) do + Sparoid::Client.process_send_results("example.com", results) + end + ex.message.to_s.should contain("example.com") + ex.message.to_s.should contain("1.1.1.1:1: no route") + ex.message.to_s.should contain("2.2.2.2:2: network down") + end + + it "returns partial-failure errors when at least one send succeeded" do + ip1 = Socket::IPAddress.new("1.1.1.1", 1) + ip2 = Socket::IPAddress.new("2.2.2.2", 2) + err = Exception.new("oops") + results = [ + {ip1, nil.as(Exception?)}, + {ip2, err.as(Exception?)}, + ] + Sparoid::Client.process_send_results("example.com", results).should eq [{ip2, err}] + end + + it "returns nothing when all sends succeeded" do + ip = Socket::IPAddress.new("1.1.1.1", 1) + results = [{ip, nil.as(Exception?)}] + Sparoid::Client.process_send_results("example.com", results).should be_empty + end + + it "returns nothing when there were no addresses" do + Sparoid::Client.process_send_results("example.com", [] of {Socket::IPAddress, Exception?}).should be_empty + end + end +end diff --git a/src/client-cli.cr b/src/client-cli.cr index 338ebb6..17b45cf 100644 --- a/src/client-cli.cr +++ b/src/client-cli.cr @@ -1,7 +1,11 @@ +require "log" require "option_parser" require "./client" require "./version" +# Route logs to STDERR (in :connect mode STDOUT is the unix-domain socket used for FD passing). +Log.setup_from_env(default_level: :info, backend: Log::IOBackend.new(STDERR)) + subcommand = :none host = "0.0.0.0" port = 8484 diff --git a/src/client.cr b/src/client.cr index 6147c9d..e525745 100644 --- a/src/client.cr +++ b/src/client.cr @@ -1,3 +1,4 @@ +require "log" require "socket" require "random/secure" require "openssl/cipher" @@ -11,6 +12,10 @@ require "wait_group" module Sparoid class Client + Log = ::Log.for(self) + + class SendError < Exception; end + def self.new(config_path = "~/.sparoid.ini") key = ENV.fetch("SPAROID_KEY", "") hmac_key = ENV.fetch("SPAROID_HMAC_KEY", "") @@ -70,25 +75,43 @@ module Sparoid exit 1 # only if all connects fails end - # Send to all resolved IPs for the hostname + # Send to all resolved IPs for the hostname. + # Per-address failures are logged as warnings if at least one address succeeded. + # If every address fails, raises SendError. private def self.udp_send(host, port, key : String, hmac_key : String, public_ip : String? = nil) : Array(String) host_addresses = Socket::Addrinfo.udp(host, port) - host_addresses.each do |addrinfo| + results = host_addresses.map do |addrinfo| packages = generate_messages(addrinfo.ip_address, public_ip).map { |message| generate_package(key, hmac_key, message) } socket = UDPSocket.new(addrinfo.family) + error = nil.as(Exception?) begin packages.each do |data| socket.send data, to: addrinfo.ip_address end rescue ex - STDERR << "Sparoid error sending " << ex.inspect << "\n" + error = ex ensure socket.close end + {addrinfo.ip_address, error} + end + process_send_results(host, results).each do |ip, ex| + Log.warn { "skip #{host} (#{ip}): #{ex.message}" } end host_addresses.map &.ip_address.address end + # Decide whether per-address send failures are partial (warn) or total (raise). + # Returns the per-address errors to warn about. Raises SendError when every send failed. + def self.process_send_results(host : String, results : Array({Socket::IPAddress, Exception?})) : Array({Socket::IPAddress, Exception}) + errors = results.compact_map { |ip, err| err.try { |e| {ip, e} } } + if !results.empty? && errors.size == results.size + details = errors.map { |ip, ex| "#{ip}: #{ex.message}" }.join("; ") + raise SendError.new("failed to send to any address for #{host}: #{details}") + end + errors + end + private def self.encrypt(key, hmac_key, data) : Bytes cipher = OpenSSL::Cipher.new("aes-256-cbc") cipher.encrypt