Class: Hyperion::AdminListener

Inherits:
Object
  • Object
show all
Defined in:
lib/hyperion/admin_listener.rb

Overview

Sibling HTTP listener for admin endpoints (RFC A8). When the operator sets ‘admin.listener_port`, Hyperion spawns a small dedicated server on `127.0.0.1:<port>` that handles ONLY `/-/quit` and `/-/metrics` (Prometheus exposition). The application listener is unchanged —admin paths can stay mounted in-app simultaneously, depending on whether `AdminMiddleware` is wrapped.

**Why a sibling listener, not just middleware?** Three failure modes AdminMiddleware can’t escape on its own:

1. Misordered `Rack::Builder` middleware can disable admin (a
   `use` of a custom 404 middleware in front of Hyperion's wrap).
2. Request-headers-logging middleware (`Rack::CommonLogger` derivs,
   OpenTelemetry HTTP instrumentation, app-level header dumpers)
   logs the `X-Hyperion-Admin-Token` value to access logs. The
   sibling listener's path never goes through that pipeline.
3. Operators who don't want to manually 404 `/-/*` at the edge
   proxy can simply not expose this port.

**Defence-in-depth, not a replacement for network isolation.** The bearer token still gates every request. Operators MUST keep this port on a private interface (default ‘127.0.0.1`) or behind an authenticating reverse proxy. Same `secure_match?` logic as AdminMiddleware.

**Implementation note.** Single accept thread, no Rack pipeline. We parse the request line + Authorization header by hand because:

* The two endpoints are trivial (drain via SIGTERM; render
  pre-formatted Prometheus text).
* Pulling in a full Rack stack inside Hyperion to serve two
  endpoints would re-introduce the misordering footgun (#1 above).
* The bytes per response are tiny — encryption / chunked encoding
  / keep-alive aren't needed.

Returns 202 + ‘“status”:“draining”` on quit, 200 + Prometheus text on metrics, 401 on bearer mismatch, 404 on anything else.

Constant Summary collapse

PATH_QUIT =
'/-/quit'
PATH_METRICS =
'/-/metrics'
METRICS_CONTENT_TYPE =
'text/plain; version=0.0.4; charset=utf-8'
JSON_CONTENT_TYPE =
'application/json'
UNAUTHORIZED_BODY =
%({"error":"unauthorized"}\n)
NOT_FOUND_BODY =
%({"error":"not_found"}\n)
DRAINING_BODY =
%({"status":"draining"}\n)
SIGNAL_FAILED =
%({"error":"signal_failed"}\n)

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(host:, port:, token:, runtime: nil, signal_target: nil) ⇒ AdminListener

Returns a new instance of AdminListener.

Raises:

  • (ArgumentError)


58
59
60
61
62
63
64
65
66
67
# File 'lib/hyperion/admin_listener.rb', line 58

def initialize(host:, port:, token:, runtime: nil, signal_target: nil)
  raise ArgumentError, 'admin listener token must be a non-empty String' if token.nil? || token.to_s.empty?

  @host          = host
  @port          = port
  @token         = token.to_s
  @runtime       = runtime || Hyperion::Runtime.default
  @signal_target = signal_target
  @stopped       = false
end

Instance Attribute Details

#hostObject (readonly)

Returns the value of attribute host.



56
57
58
# File 'lib/hyperion/admin_listener.rb', line 56

def host
  @host
end

#portObject (readonly)

Returns the value of attribute port.



56
57
58
# File 'lib/hyperion/admin_listener.rb', line 56

def port
  @port
end

Instance Method Details

#startObject

Bind + spawn the accept thread. Returns self so callers can chain ‘.start.join` or just hold the reference for `#stop`.



71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/hyperion/admin_listener.rb', line 71

def start
  @server = ::TCPServer.new(@host, @port)
  # Honour port: 0 (let kernel pick) — the test suite uses this so
  # multiple AdminListeners can coexist without port conflicts.
  @port = @server.addr[1]

  @thread = Thread.new { accept_loop }
  @thread.report_on_exception = false
  @runtime.logger.info do
    { message: 'admin listener started', host: @host, port: @port,
      paths: [PATH_QUIT, PATH_METRICS] }
  end
  self
end

#stopObject



86
87
88
89
90
91
92
93
# File 'lib/hyperion/admin_listener.rb', line 86

def stop
  @stopped = true
  @server&.close
  @thread&.join(5)
  nil
rescue StandardError
  nil
end