From 4fecdfe76e8f5693d4e551d5f01765721864cfad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Mei=C3=9Fner?= <35730763+c8m6@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:57:14 +0200 Subject: [PATCH 1/3] feat: add optional TCP/UDP relay companion --- .dockerignore | 1 + Dockerfile | 5 ++ README.md | 62 +++++++++++++ bin/acl-relay | 11 +++ bin/acl-with-relay | 56 ++++++++++++ script/relay_fixture.rb | 84 ++++++++++++++++++ script/relay_smoke.rb | 165 +++++++++++++++++++++++++++++++++++ script/validate-image | 13 +++ spec/transport_relay_spec.rb | 70 +++++++++++++++ support/transport_relay.rb | 110 +++++++++++++++++++++++ 10 files changed, 577 insertions(+) create mode 100755 bin/acl-relay create mode 100755 bin/acl-with-relay create mode 100644 script/relay_fixture.rb create mode 100644 script/relay_smoke.rb create mode 100644 spec/transport_relay_spec.rb create mode 100644 support/transport_relay.rb diff --git a/.dockerignore b/.dockerignore index 8846679..3b79ac4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,4 @@ !NOTICE !lib/** !bin/** +!support/** diff --git a/Dockerfile b/Dockerfile index 8660731..070241d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,10 +20,15 @@ LABEL org.opencontainers.image.version=$APP_VERSION \ ENV BUNDLE_PATH=/usr/local/bundle BUNDLE_WITHOUT=development:test BUNDLE_DEPLOYMENT=1 ACL_CONFIG=/config/config.yaml WORKDIR /app RUN groupadd --gid 10001 acl && useradd --uid 10001 --gid acl --no-create-home acl +RUN apt-get update && apt-get install -y --no-install-recommends libcap2-bin \ + && cp /usr/local/bin/ruby /usr/local/bin/acl-relay-ruby \ + && setcap cap_net_bind_service=ep /usr/local/bin/acl-relay-ruby \ + && apt-get purge -y libcap2-bin && rm -rf /var/lib/apt/lists/* COPY --from=dependencies /usr/local/bundle /usr/local/bundle COPY --chmod=644 Gemfile Gemfile.lock LICENSE NOTICE ./ COPY --chmod=755 lib ./lib COPY --chmod=755 bin ./bin +COPY --chmod=755 support ./support USER 10001:10001 EXPOSE 8080 STOPSIGNAL SIGTERM diff --git a/README.md b/README.md index 84786d2..5f3634a 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,68 @@ Build args `APP_VERSION`, `APP_REVISION`, `APP_BUILD_TIME` (UTC ISO 8601) and source. Optional BuildKit secret `proxy_ca` supplies a CA bundle during dependency installation for environments with an HTTPS proxy; it is not copied into layers. +## Optional TCP/UDP companion + +`ruby bin/acl-with-relay` runs the HTTP application together with an optional, +opaque IPv4 TCP/UDP relay. This is useful when a client expects an HTTP API and +another protocol at the same address, such as an HTTP API plus a DNS endpoint. +The HTTP application, its mapping configuration, and the default container +command are unchanged. No protocol parsing or vendor-specific behavior is added +to the HTTP engine. + +The companion uses these environment variables: + +| Variable | Meaning | Default | +| --- | --- | --- | +| `ACL_RELAY_UPSTREAM_HOST` | Fixed upstream IPv4 address or hostname; required | None | +| `ACL_RELAY_UPSTREAM_PORT` | Upstream TCP and UDP port | `53` | +| `ACL_RELAY_LISTEN_PORT` | Local TCP and UDP port, bound on all IPv4 interfaces | `53` | + +Both ports must be decimal integers between 1 and 65535. The upstream is trusted +deployment configuration; client packets cannot choose a different destination. +Each UDP exchange uses its own connected upstream socket, so concurrent replies +cannot be delivered to another client. TCP streams and UDP payloads pass through +without modification. Exchanges/connections are limited to five seconds, with +at most 32 simultaneous TCP/UDP clients in total. Failed or excess exchanges are +closed/dropped without logging their contents. There are no retries, caching, +record creation, or name-resolution semantics in the relay: a DNS upstream must +itself provide the required records and CNAME resolution for A/AAAA requests. + +The supervisor starts both child processes and stops the other if either exits. +SIGTERM/SIGINT stop both children; shutdown has a five-second grace period before +remaining children are killed. An invalid companion configuration or a failed +listener causes the combined command to fail, rather than leaving HTTP running +on its own. + +The container still runs as UID/GID `10001:10001`. For a listen port below 1024, +only the relay child uses a separate copy of the Ruby interpreter with the Linux +file capability `CAP_NET_BIND_SERVICE`; the HTTP child uses the ordinary +interpreter. The supervisor uses the base image's `setpriv` to clear inherited +and ambient capabilities for HTTP and enable `no-new-privileges` on that child. +Such a deployment must retain `NET_BIND_SERVICE` in the container's +capability bounding set and allow file capabilities (`no-new-privileges` must +not be enabled). For a listen port of 1024 or higher, both children use ordinary +Ruby and work with all capabilities dropped and `no-new-privileges` enabled. +No root user or network-administration capability is required. + +For example, on an existing isolated test network with the synthetic upstream +name `resolver.example.test`: + +```sh +docker run --rm --network test-services \ + --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \ + --cap-drop ALL --cap-add NET_BIND_SERVICE \ + -e ACL_RELAY_UPSTREAM_HOST=resolver.example.test \ + --mount "type=bind,src=$PWD/config/config.yaml,dst=/config/config.yaml,readonly" \ + api-compatibility-layer:development ruby bin/acl-with-relay +``` + +The container then serves HTTP on its configured port and forwards TCP/UDP port +53 to the configured upstream. Limit access using the test network; the relay +does not add authentication. The image validation checks both port 53 and an +unprivileged port, including an HTTP write followed by CNAME/address lookups, +concurrent UDP clients, TCP framing, and signal cleanup. + ## Security Configuration is trusted and determines all destinations and mappings. Restrict diff --git a/bin/acl-relay b/bin/acl-relay new file mode 100755 index 0000000..1dea53d --- /dev/null +++ b/bin/acl-relay @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../support/transport_relay' + +begin + TransportRelay.new.run +rescue KeyError, ArgumentError, SystemCallError, SocketError => e + warn "Relay configuration/startup error: #{e.message}" + exit 1 +end diff --git a/bin/acl-with-relay b/bin/acl-with-relay new file mode 100755 index 0000000..643cc05 --- /dev/null +++ b/bin/acl-with-relay @@ -0,0 +1,56 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'rbconfig' +require 'timeout' +require_relative '../support/transport_relay' + +children = [] +stopping = false + +terminate = lambda do + children.each do |pid| + Process.kill('TERM', pid) + rescue Errno::ESRCH + # A child may have exited before its sibling. + end +end + +begin + relay = TransportRelay.new # Validate before starting either service. + relay_ruby = '/usr/local/bin/acl-relay-ruby' + relay_ruby = RbConfig.ruby unless relay.privileged_port? && File.executable?(relay_ruby) + %w[INT TERM].each do |signal| + Signal.trap(signal) do + stopping = true + terminate.call + end + end + children << Process.spawn(relay_ruby, File.expand_path('acl-relay', __dir__)) + http_command = [RbConfig.ruby, File.expand_path('acl', __dir__)] + if File.executable?('/usr/bin/setpriv') + http_command.unshift('/usr/bin/setpriv', '--no-new-privs', '--inh-caps=-all', '--ambient-caps=-all') + end + children << Process.spawn(*http_command) + terminate.call if stopping + pid, status = Process.wait2 + children.delete(pid) + exit(stopping ? 0 : [status.exitstatus || 1, 1].max) +rescue KeyError, ArgumentError, SystemCallError, SocketError => e + warn "Companion configuration/startup error: #{e.message}" + exit 1 +ensure + terminate.call + children.each do |pid| + Timeout.timeout(5) { Process.wait(pid) } + rescue Timeout::Error + begin + Process.kill('KILL', pid) + Process.wait(pid) + rescue Errno::ESRCH, Errno::ECHILD + # The child may have exited as the shutdown deadline elapsed. + end + rescue Errno::ESRCH, Errno::ECHILD + # Already reaped or exited during shutdown. + end +end diff --git a/script/relay_fixture.rb b/script/relay_fixture.rb new file mode 100644 index 0000000..41342d5 --- /dev/null +++ b/script/relay_fixture.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Synthetic HTTP backend and DNS server, reachable only on this process's loopback. +class SharedEndpointFixture + attr_reader :dns_port, :http_port + + def initialize + @aliases = {} + @mutex = Mutex.new + @tcp = TCPServer.new('127.0.0.1', 0) + @dns_port = @tcp.local_address.ip_port + @udp = UDPSocket.new + @udp.bind('127.0.0.1', @dns_port) + @threads = [Thread.new { serve_udp }, Thread.new { serve_tcp }] + start_http + end + + def stop + @http&.shutdown + @threads.each { |thread| thread.kill.join } + @tcp.close + @udp.close + end + + private + + def start_http + ready = Queue.new + @http = WEBrick::HTTPServer.new(Port: 0, BindAddress: '127.0.0.1', AccessLog: [], + Logger: WEBrick::Log.new(File::NULL), StartCallback: -> { ready << true }) + @http.mount_proc('/entries') do |request, response| + data = JSON.parse(request.body) + @mutex.synchronize { @aliases[data.fetch('name')] = data.fetch('target') } + response.status = 201 + response.body = JSON.generate(data) + end + @threads << Thread.new { @http.start } + ready.pop + @http_port = @http.config[:Port] + end + + def serve_udp + loop do + bytes, sender = @udp.recvfrom(65_535) + @udp.send(answer(bytes), 0, sender[3], sender[1]) + end + end + + def serve_tcp + loop do + client = @tcp.accept + begin + length = client.read(2).unpack1('n') + bytes = answer(client.read(length)) + client.write([bytes.bytesize].pack('n') + bytes) + ensure + client.close + end + end + end + + def answer(bytes) + request = Resolv::DNS::Message.decode(bytes) + response = Resolv::DNS::Message.new(request.id) + response.qr = 1 + response.aa = 1 + request.question.each do |name, type| + response.add_question(name, type) + add_answer(response, name, type) + end + response.encode + end + + def add_answer(response, name, type) + target = @mutex.synchronize { @aliases[name.to_s] } + if target + response.add_answer(name, 60, Resolv::DNS::Resource::IN::CNAME.new(Resolv::DNS::Name.create(target))) + name = target + end + return unless type == Resolv::DNS::Resource::IN::A && name.to_s == 'ingress.example.test' + + response.add_answer(name, 60, Resolv::DNS::Resource::IN::A.new('192.0.2.10')) + end +end diff --git a/script/relay_smoke.rb b/script/relay_smoke.rb new file mode 100644 index 0000000..e236b90 --- /dev/null +++ b/script/relay_smoke.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'bundler/setup' +require 'json' +require 'net/http' +require 'resolv' +require 'socket' +require 'tempfile' +require 'timeout' +require 'webrick' +require 'yaml' + +require_relative 'relay_fixture' + +class RelaySmoke + def initialize + @fixture = SharedEndpointFixture.new + @port = TCPServer.open('127.0.0.1', 0) { |socket| socket.local_address.ip_port } + @relay_port = Integer(ENV.fetch('ACL_RELAY_SMOKE_PORT', '1053')) + end + + def run + Tempfile.create(['relay-smoke', '.yaml']) do |file| + file.write(YAML.dump(configuration)) + file.flush + %w[TERM INT].each { |signal| verify_process(file.path, signal) } + end + puts 'Relay smoke passed: shared HTTP/DNS address, CNAME write/read, UDP/TCP, concurrent clients, TERM/INT.' + ensure + @fixture.stop + end + + private + + def configuration + { + 'version' => 1, 'listen' => { 'address' => '127.0.0.1', 'port' => @port }, + 'backends' => { 'inventory' => { 'base_url' => "http://127.0.0.1:#{@fixture.http_port}" } }, + 'routes' => [{ 'method' => 'GET', 'path' => '/ready', 'response' => { 'body' => { 'ok' => true } } }, + { 'method' => 'POST', 'path' => '/aliases', + 'extract' => { 'entry' => { 'from' => 'body', 'pointer' => '' } }, + 'backend' => { 'name' => 'inventory', 'method' => 'POST', 'path' => '/entries', + 'body' => '{{values.entry}}' } }] + } + end + + def environment(path) + { 'ACL_CONFIG' => path, 'ACL_RELAY_UPSTREAM_HOST' => '127.0.0.1', + 'ACL_RELAY_UPSTREAM_PORT' => @fixture.dns_port.to_s, 'ACL_RELAY_LISTEN_PORT' => @relay_port.to_s } + end + + def verify_process(path, signal) + pid = Process.spawn(environment(path), 'ruby', 'bin/acl-with-relay', out: File::NULL, err: File::NULL) + wait_ready + children = File.read("/proc/#{pid}/task/#{pid}/children").split.map(&:to_i) + verify_capabilities(children) if ENV['ACL_RELAY_SMOKE_CAPABILITIES'] == '1' + verify_exchange + verify_concurrent_queries + Process.kill(signal, pid) + _, status = Timeout.timeout(10) { Process.wait2(pid) } + raise "unclean SIG#{signal} shutdown" unless status.success? + raise 'companion left a child process running' if children.any? { |child| alive?(child) } + ensure + cleanup(pid) + end + + def cleanup(pid) + Process.kill('TERM', pid) + Timeout.timeout(10) { Process.wait(pid) } + rescue Timeout::Error + Process.kill('KILL', pid) + Process.wait(pid) + rescue Errno::ESRCH, Errno::ECHILD + # Already reaped after clean shutdown. + end + + def verify_capabilities(children) + statuses = children.map { |pid| File.read("/proc/#{pid}/status") } + capabilities = statuses.map { |status| status[/^CapEff:\s*(\h+)/, 1].to_i(16) } + raise 'only the relay may hold NET_BIND_SERVICE' unless capabilities.sort == [0, 0x400] + + http = statuses.find { |status| status[/^CapEff:\s*(\h+)/, 1].to_i(16).zero? } + raise 'HTTP may not gain privileges' unless http[/^NoNewPrivs:\s*(\d+)/, 1] == '1' + raise 'a child changed user' unless statuses.all? { |status| status[/^Uid:\s*(\d+)/, 1].to_i == Process.uid } + end + + def alive?(pid) + Process.kill(0, pid) + true + rescue Errno::ESRCH + false + end + + def wait_ready + Timeout.timeout(10) do + loop do + return if http('GET', '/ready').code == '200' && udp_query('ingress.example.test').answer.any? + rescue IOError, SystemCallError, Timeout::Error + sleep 0.05 + end + end + end + + def http(method, path, body = nil) + client = Net::HTTP.new('127.0.0.1', @port, nil) + client.open_timeout = 1 + client.read_timeout = 2 + request = Net::HTTPGenericRequest.new(method, !body.nil?, true, path, 'Content-Type' => 'application/json') + request.body = JSON.generate(body) if body + client.request(request) + end + + def verify_exchange + response = http('POST', '/aliases', { 'name' => 'alias.example.test', 'target' => 'ingress.example.test' }) + raise 'HTTP backend write failed' unless response.code == '201' + + dns = Resolv::DNS.new(nameserver_port: [['127.0.0.1', @relay_port]], timeouts: 2) + raise 'CNAME address resolution failed' unless dns.getaddress('alias.example.test').to_s == '192.0.2.10' + + cname = dns.getresource('alias.example.test', Resolv::DNS::Resource::IN::CNAME) + raise 'CNAME record lost' unless cname.name.to_s == 'ingress.example.test' + + verify_tcp + ensure + dns&.close + end + + def query(name, id) + Resolv::DNS::Message.new(id).tap { |message| message.add_question(name, Resolv::DNS::Resource::IN::A) } + end + + def udp_query(name, id = 1234) + socket = UDPSocket.new + socket.connect('127.0.0.1', @relay_port) + socket.send(query(name, id).encode, 0) + Timeout.timeout(2) { Resolv::DNS::Message.decode(socket.recv(65_535)) } + ensure + socket&.close + end + + def verify_tcp + socket = TCPSocket.new('127.0.0.1', @relay_port) + payload = query('alias.example.test', 4321).encode + socket.write([payload.bytesize].pack('n')) + socket.write(payload[0, 3]) + socket.write(payload[3..]) + socket.close_write + response = Timeout.timeout(2) { Resolv::DNS::Message.decode(socket.read(socket.read(2).unpack1('n'))) } + raise 'TCP DNS response corrupted' unless response.id == 4321 && response.answer.size == 2 + ensure + socket&.close + end + + def verify_concurrent_queries + threads = 12.times.map do |id| + Thread.new do + response = udp_query('alias.example.test', id) + raise 'UDP responses crossed between clients' unless response.id == id && response.answer.size == 2 + end + end + threads.each(&:value) + end +end + +RelaySmoke.new.run diff --git a/script/validate-image b/script/validate-image index ca2d28f..bcbbbcf 100755 --- a/script/validate-image +++ b/script/validate-image @@ -11,3 +11,16 @@ docker run --rm --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \ --cap-drop ALL --security-opt no-new-privileges \ --mount "type=bind,src=$root/script/image_smoke.rb,dst=/checks/image_smoke.rb,readonly" \ "$image" ruby /checks/image_smoke.rb +for port in 1053 53; do + options=(--cap-drop ALL) + if [ "$port" = 53 ]; then + options+=(--cap-add NET_BIND_SERVICE -e ACL_RELAY_SMOKE_CAPABILITIES=1) + else + options+=(--security-opt no-new-privileges) + fi + docker run --rm --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \ + "${options[@]}" -e "ACL_RELAY_SMOKE_PORT=$port" \ + --mount "type=bind,src=$root/script/relay_smoke.rb,dst=/checks/relay_smoke.rb,readonly" \ + --mount "type=bind,src=$root/script/relay_fixture.rb,dst=/checks/relay_fixture.rb,readonly" \ + "$image" ruby /checks/relay_smoke.rb +done diff --git a/spec/transport_relay_spec.rb b/spec/transport_relay_spec.rb new file mode 100644 index 0000000..785396f --- /dev/null +++ b/spec/transport_relay_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require_relative '../support/transport_relay' +require 'open3' +require 'rbconfig' + +RSpec.describe TransportRelay do + let(:settings) { { 'ACL_RELAY_UPSTREAM_HOST' => 'upstream.example.test' } } + + it 'requires an explicit upstream' do + expect { described_class.new({}) }.to raise_error(KeyError) + end + + it 'accepts hostnames and IPv4 loopback addresses' do + expect(described_class.new(settings)).to be_privileged_port + expect(described_class.new(settings.merge('ACL_RELAY_UPSTREAM_HOST' => '127.0.0.1'))).to be_privileged_port + end + + it 'does not request a privileged interpreter for a high listen port' do + expect(described_class.new(settings.merge('ACL_RELAY_LISTEN_PORT' => '1053'))).not_to be_privileged_port + end + + ['', '-upstream', 'http://upstream.example.test', 'upstream,option', "upstream\n"].each do |host| + it "rejects invalid upstream #{host.inspect}" do + expect { described_class.new(settings.merge('ACL_RELAY_UPSTREAM_HOST' => host)) }.to raise_error(ArgumentError) + end + end + + %w[ACL_RELAY_LISTEN_PORT ACL_RELAY_UPSTREAM_PORT].each do |key| + %w[0 65536 -1 invalid 53,option].each do |value| + it "rejects #{key}=#{value}" do + expect { described_class.new(settings.merge(key => value)) }.to raise_error(ArgumentError) + end + end + end + + it 'fails the companion before HTTP startup if its configuration is incomplete' do + output, status = Open3.capture2e({ 'ACL_RELAY_UPSTREAM_HOST' => nil, 'RUBYOPT' => nil }, + RbConfig.ruby, 'bin/acl-with-relay') + expect(status.exitstatus).to eq(1) + expect(output).to include('Companion configuration/startup error') + end + + it 'relays HTTP writes and DNS reads over UDP/TCP and shuts down both children' do + port = TCPServer.open('127.0.0.1', 0) { |server| server.local_address.ip_port } + output, status = Open3.capture2e({ 'ACL_RELAY_SMOKE_PORT' => port.to_s }, RbConfig.ruby, 'script/relay_smoke.rb') + expect(status.success?).to be(true), output + expect(output).to include('Relay smoke passed') + end + + it 'stops HTTP if the UDP listener cannot start' do + occupied = UDPSocket.new + occupied.bind('0.0.0.0', 0) + relay_port = occupied.local_address.ip_port + http_port = TCPServer.open('127.0.0.1', 0) { |server| server.local_address.ip_port } + data = configuration.merge('listen' => { 'address' => '127.0.0.1', 'port' => http_port }) + Tempfile.create(['relay-failure', '.yaml']) do |file| + file.write(YAML.dump(data)) + file.flush + environment = { 'ACL_CONFIG' => file.path, 'ACL_RELAY_UPSTREAM_HOST' => '127.0.0.1', 'RUBYOPT' => nil, + 'ACL_RELAY_LISTEN_PORT' => relay_port.to_s } + output, status = Timeout.timeout(10) { Open3.capture2e(environment, RbConfig.ruby, 'bin/acl-with-relay') } + expect(status.success?).to be(false) + expect(output).to include('Relay configuration/startup error') + expect { TCPSocket.new('127.0.0.1', http_port) }.to raise_error(Errno::ECONNREFUSED) + end + ensure + occupied&.close + end +end diff --git a/support/transport_relay.rb b/support/transport_relay.rb new file mode 100644 index 0000000..45e47f3 --- /dev/null +++ b/support/transport_relay.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'socket' +require 'timeout' + +# Optional container companion; the HTTP application never loads this file. +class TransportRelay + TIMEOUT = 5 + MAX_CLIENTS = 32 + MAX_PACKET = 65_535 + + def initialize(environment = ENV) + @host = environment.fetch('ACL_RELAY_UPSTREAM_HOST') + unless @host.match?(/\A[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\z/) + raise ArgumentError, 'ACL_RELAY_UPSTREAM_HOST must be an IPv4 address or hostname' + end + + @port = port(environment.fetch('ACL_RELAY_LISTEN_PORT', '53')) + @upstream_port = port(environment.fetch('ACL_RELAY_UPSTREAM_PORT', '53')) + @slots = SizedQueue.new(MAX_CLIENTS) + MAX_CLIENTS.times { @slots << true } + @workers = [] + @mutex = Mutex.new + @stopping = false + end + + def privileged_port? + @port < 1024 + end + + def run + @tcp = TCPServer.new('0.0.0.0', @port) + @udp = UDPSocket.new + @udp.bind('0.0.0.0', @port) + %w[INT TERM].each { |signal| Signal.trap(signal) { @stopping = true } } + until @stopping + IO.select([@tcp, @udp], nil, nil, 0.25)&.first&.each do |listener| + listener == @tcp ? accept_tcp : accept_udp + end + end + ensure + @tcp&.close + @udp&.close + @mutex.synchronize { @workers.dup }.each { |thread| thread.kill.join } + end + + private + + def port(value) + Integer(value, 10).tap do |number| + raise ArgumentError, 'relay ports must be between 1 and 65535' unless (1..65_535).cover?(number) + end + end + + def accept_tcp + client = @tcp.accept_nonblock(exception: false) + return if client == :wait_readable + + client.close unless worker { relay_tcp(client) } + end + + def accept_udp + packet = @udp.recvfrom_nonblock(MAX_PACKET, exception: false) + return if packet == :wait_readable + + payload, sender = packet + worker { relay_udp(payload, sender) } + end + + def worker(&) + @slots.pop(true) + @mutex.synchronize do + @workers << Thread.new do + Timeout.timeout(TIMEOUT, &) + rescue IOError, SystemCallError, SocketError, Timeout::Error + # Drop a failed exchange without logging client traffic. + ensure + @slots << true + @mutex.synchronize { @workers.delete(Thread.current) } + end + end + true + rescue ThreadError + false + end + + def relay_udp(payload, sender) + upstream = UDPSocket.new + upstream.connect(@host, @upstream_port) + upstream.send(payload, 0) + @udp.send(upstream.recv(MAX_PACKET), 0, sender[3], sender[1]) + ensure + upstream&.close + end + + def relay_tcp(client) + upstream = Socket.tcp(@host, @upstream_port, connect_timeout: TIMEOUT) + upload = Thread.new do + IO.copy_stream(client, upstream) + upstream.close_write + rescue IOError, SystemCallError + # The peer may already have closed its side of the connection. + end + IO.copy_stream(upstream, client) + ensure + upload&.kill&.join + upstream&.close + client.close + end +end From 1df2981bd155f29f8048da24531ef27e9890030c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Mei=C3=9Fner?= <35730763+c8m6@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:24:44 +0200 Subject: [PATCH 2/3] ci: validate relay-enabled container on every pull request --- .github/workflows/ci.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5025ef5..5403331 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,22 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + - uses: ruby/setup-ruby@e81a8fa391b11a595c1b7ed84177cc0fe02faf83 # v1 with: ruby-version: '3.4' bundler-cache: true - run: bundle exec rspec - run: bundle exec rubocop --force-exclusion - run: bundle exec bundler-audit check --update + + image-smoke: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Build validation image + run: docker build --tag api-compatibility-layer:pr-validation . + - name: Validate HTTP and TCP/UDP relay image + run: bash script/validate-image api-compatibility-layer:pr-validation From c359027eaa0a15fdb6f53c87f857b9c6c07149a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Mei=C3=9Fner?= <35730763+c8m6@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:25:23 +0200 Subject: [PATCH 3/3] docs: clarify relay capability restrictions and CI coverage --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5f3634a..3a7aefe 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,15 @@ not be enabled). For a listen port of 1024 or higher, both children use ordinary Ruby and work with all capabilities dropped and `no-new-privileges` enabled. No root user or network-administration capability is required. +Security note: the file capability is granted to a general-purpose Ruby +interpreter copy, not exclusively to relay operations. Limit executable code +and filesystem write access within the container. Use a read-only root +filesystem and restrict inbound and outbound networking. Privileged-port mode +requires the NET_BIND_SERVICE bounding capability and cannot run with +no-new-privileges (Kubernetes allowPrivilegeEscalation: false). On OKD, verify +SCC compatibility before deployment. Prefer ports >= 1024 with all capabilities +dropped and no-new-privileges when possible. + For example, on an existing isolated test network with the synthetic upstream name `resolver.example.test`: @@ -482,8 +491,9 @@ Conventional Commits. ## GitHub, releases and Docker Hub -PRs and pushes to `main` run RSpec, RuboCop and bundler-audit on Ruby 3.4 with -Bundler caching; obsolete runs are cancelled. The separate CodeQL workflow runs +PRs and pushes to `main` run RSpec, RuboCop and bundler-audit on Ruby 3.4, +and build and validate the Docker image (including relay and capability tests) +in a separate job. Obsolete runs are cancelled. The separate CodeQL workflow runs on `main` pushes and manual dispatch, with Ruby and `build-mode: none`. Private repositories need the applicable GitHub Code Security entitlement to run CodeQL; workflow configuration does not enable or purchase that service.