Files
once-campfire/test/test_helpers/push_service_test_helper.rb
T
Marcello CostagliolaandClaude Opus 5.5 53520d4444 Reuse push connections pinned to the address the guard just approved
Since push delivery was pinned to the IP resolved and guarded for it,
every push opens a new TCP and TLS connection: Net::HTTP::Persistent
looks the host up itself and can't be pinned. The handshake is one or
two extra round trips for every push.

WebPush::Connections keeps the pinned connections open for 30 seconds
and hands one out again only to a delivery whose own, fresh resolution
returned the same address for the same host. Net::HTTP only ever
reconnects to that address, so no request goes to an address the guard
didn't just approve. A connection the push service closed while idle is
replaced before the push is written; once it's written, a dropped
connection raises ConnectionLost instead of sending the push twice or
invalidating the subscription. A delivery without a resolved IP is no
longer sent at all, and net-http-persistent goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 18:24:41 +02:00

122 lines
4.6 KiB
Ruby

module PushServiceTestHelper
# A push service on 127.0.0.1 for delivery tests: TLS with a certificate for HOST from a test CA that the
# process trusts, HTTP/1.1 keep-alive. HOST doesn't resolve, so a delivery that looked it up instead of
# using its pinned address would fail.
HOST = "push.test"
IP = "127.0.0.1"
class Server
attr_reader :port
# status: the answer to every request.
# hang_up_after_response: :fin or :close_notify to hang up after each answer, as an idle timeout would.
# drop_request: ->(number) whether to hang up after reading that request, without answering it.
# certificate: ->(connection) the name its certificate is for, HOST unless told otherwise.
def initialize(status: "201 Created", hang_up_after_response: nil, drop_request: ->(_) { false }, certificate: ->(_) { HOST })
@status, @hang_up_after_response, @drop_request, @certificate = status, hang_up_after_response, drop_request, certificate
@connections, @requests, @closed = 0, [], Queue.new
@mutex = Mutex.new
@listener = TCPServer.new(IP, 0)
@port = @listener.addr[1]
@thread = Thread.new { loop { serve(@listener.accept) } rescue IOError }
end
def connections = @mutex.synchronize { @connections }
def requests = @mutex.synchronize { @requests.dup }
# Whether a connection to it closed, waiting for that up to the given seconds.
def hung_up?(within: 5)
!!@closed.pop(timeout: within)
end
def stop
@listener.close
@thread.join
end
private
def serve(socket)
connection = @mutex.synchronize { @connections += 1 }
Thread.new do
tls = OpenSSL::SSL::SSLSocket.new(socket, PushServiceTestHelper.context_for(@certificate.(connection))).tap(&:accept)
while (request_line = tls.gets("\r\n"))
length = 0
while (header = tls.gets("\r\n")) != "\r\n"
length = header.split(":", 2).last.to_i if header.downcase.start_with?("content-length:")
end
tls.read(length)
number = @mutex.synchronize { @requests << request_line.split[1]; @requests.size }
break if @drop_request.(number)
tls.write "HTTP/1.1 #{@status}\r\nContent-Length: 0\r\n\r\n"
if @hang_up_after_response
tls.close if @hang_up_after_response == :close_notify
break
end
end
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
ensure
socket.close
@closed << true
end
end
end
class << self
def context_for(name)
@contexts ||= {}
@contexts[name] ||= OpenSSL::SSL::SSLContext.new.tap do |context|
context.key = OpenSSL::PKey::EC.generate("prime256v1")
context.cert = sign(subject: "CN=#{name}", key: context.key, ca: ca, ca_key: ca_key) do |extensions|
[ extensions.create_extension("subjectAltName", "DNS:#{name}") ]
end
end
end
private
def ca_key
@ca_key ||= OpenSSL::PKey::EC.generate("prime256v1")
end
def ca
@ca ||= sign(subject: "CN=Push service test CA", key: ca_key, ca_key: ca_key) do |extensions|
[ extensions.create_extension("basicConstraints", "CA:TRUE", true), extensions.create_extension("keyUsage", "keyCertSign", true) ]
end.tap { OpenSSL::SSL::SSLContext::DEFAULT_CERT_STORE.add_cert(it) }
end
def sign(subject:, key:, ca_key:, ca: nil)
OpenSSL::X509::Certificate.new.tap do |certificate|
certificate.version, certificate.serial = 2, SecureRandom.random_number(1 << 64)
certificate.subject = OpenSSL::X509::Name.parse(subject)
certificate.issuer = ca ? ca.subject : certificate.subject
certificate.public_key = key
certificate.not_before, certificate.not_after = 1.minute.ago, 1.hour.from_now
extensions = OpenSSL::X509::ExtensionFactory.new(ca || certificate, certificate)
yield(extensions).each { certificate.add_extension(it) }
certificate.sign(ca_key, "SHA256")
end
end
end
private
def with_push_service(**options)
WebMock.disable!
server = Server.new(**options)
yield server
ensure
server&.stop
WebMock.enable!
end
# Built as WebPush::PersistentRequest builds it for a delivery.
def pinned_connection(server, ip = IP)
Net::HTTP.new(HOST, server.port, nil).tap do |http|
http.ipaddr = ip
http.use_ssl = true
end
end
def push_request(path = "/push")
Net::HTTP::Post.new(path).tap { it.body = "payload" }
end
end