class Webhook::Delivery < ApplicationRecord USER_AGENT = "fizzy/1.0.0 Webhook" ENDPOINT_TIMEOUT = 7.seconds DNS_RESOLUTION_TIMEOUT = 2 PRIVATE_IP_RANGES = [ # IPv4 mapped to IPv6 IPAddr.new("::ffff:0:0/96"), # Link-local (DHCP and router stuff) IPAddr.new("169.254.0.0/16"), IPAddr.new("fe80::/10") ].freeze belongs_to :webhook belongs_to :event encrypts :request, :response store :request, coder: JSON store :response, coder: JSON scope :chronologically, -> { order created_at: :asc, id: :desc } enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending def deliver_later Webhook::DeliveryJob.perform_later(self) end def deliver in_progress! self.request[:headers] = headers self.response = perform_request completed! save! rescue errored! raise end def succeeded? completed? && response[:error].blank? && response[:code].between?(200, 299) end private def perform_request if private_uri? { error: :private_uri } else response = http.request( Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload } ) { code: response.code.to_i, headers: response.to_hash } end rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError { error: :dns_lookup_failed } rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT { error: :connection_timeout } rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET { error: :destination_unreachable } rescue OpenSSL::SSL::SSLError { error: :failed_tls } end def private_uri? ip_addresses = [] Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns| dns.each_address(uri.host) do |ip_address| ip_addresses << IPAddr.new(ip_address) end end ip_addresses.any? do |ip| ip.private? || ip.loopback? || PRIVATE_IP_RANGES.any? { |range| range.include?(ip) } end end def uri @uri ||= URI(webhook.url) end def http Net::HTTP.new(uri.host, uri.port).tap do |http| http.use_ssl = (uri.scheme == "https") http.open_timeout = ENDPOINT_TIMEOUT http.read_timeout = ENDPOINT_TIMEOUT end end def headers { "User-Agent" => USER_AGENT, "Content-Type" => "application/json", "X-Webhook-Signature" => signature, "X-Webhook-Timestamp" => Time.current.utc.iso8601 } end def signature OpenSSL::HMAC.hexdigest("SHA256", webhook.signing_secret, payload) end def payload { test: :test }.to_json end end