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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
!NOTICE
!lib/**
!bin/**
!support/**
14 changes: 13 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 74 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,77 @@ 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.

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`:

```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
Expand Down Expand Up @@ -420,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.
Expand Down
11 changes: 11 additions & 0 deletions bin/acl-relay
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions bin/acl-with-relay
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions script/relay_fixture.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading