Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions spec/sparoid_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/client-cli.cr
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 26 additions & 3 deletions src/client.cr
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require "log"
require "socket"
require "random/secure"
require "openssl/cipher"
Expand All @@ -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", "")
Expand Down Expand Up @@ -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
Expand Down
Loading