Class: Dash::Configuration::Proxy

Inherits:
Object
  • Object
show all
Includes:
Validation
Defined in:
lib/dash/configuration/proxy.rb

Direct Known Subclasses

Loadbalancer

Defined Under Namespace

Classes: Acme, Boot, Run

Constant Summary collapse

DEFAULT_LOG_REQUEST_HEADERS =
[ "Cache-Control", "Last-Modified", "User-Agent" ]
CONTAINER_NAME =
"dash-proxy"
LOADBALANCER_CONTAINER_NAME =
"dash-loadbalancer"
CLIENT_CA_FILENAME =
"client-ca.pem"
IMAGE_TITLE =

The image label the prune filters key on, set by dash-proxy's publish workflow.

"dash-proxy"
LOADBALANCER_IMAGE_TITLE =
"dash-loadbalancer"
NETWORK =
"dash"
CONFIG_VOLUME =
"dash-proxy-config"
LOADBALANCER_CONFIG_VOLUME =
"dash-loadbalancer-config"
LEGACY_CONTAINER_NAME =

Pre-rename names, kept only so the stage-3c migration can find what a host already has: the container to replace, the volume to copy, the network to bridge, and the image label to keep pruning by. Nothing constructs a resource with these — stage 3d deletes them.

"kamal-proxy"
LEGACY_LOADBALANCER_CONTAINER_NAME =
"kamal-loadbalancer"
LEGACY_HOLDER_CONTAINER_NAME =
"kamal-proxy-net"
LEGACY_NETWORK =
"kamal"
LEGACY_RENAME_MARKER =

Written into the run directory once a host is verifiably past the 3c rename, so the bridge costs it nothing but a test -f on every deploy after. Deleted in stage 3d.

".legacy-renamed"
LEGACY_CONFIG_VOLUME =
"kamal-proxy-config"
LEGACY_LOADBALANCER_CONFIG_VOLUME =
"kamal-loadbalancer-config"
LEGACY_IMAGE_TITLE =
"kamal-proxy"
LEGACY_LOADBALANCER_IMAGE_TITLE =
"kamal-loadbalancer"
DEFAULT_COMPRESSION_ENCODINGS =

What compress: true offers. dash-proxy has no "on" state without an explicit list - --compress is the list - so the shorthand has to pick. Best ratio first, matching the proxy's own default ordering; the client's Accept-Encoding q-values still outrank this preference.

%w[ zstd br gzip ].freeze
SUPPORTED_COMPRESSION_ENCODINGS =
%w[ gzip br zstd ].freeze
COMPRESSION_ENCODING_ALIASES =

dash-proxy maps brotli onto the br token that travels in Content-Encoding.

{ "brotli" => "br" }.freeze
DEPLOY_OPTION_DISPOSITIONS =

The layering contract. When the fork's load balancer fronts the per-host proxies, every deploy option lives at exactly one layer — or at both, on purpose. Nothing is allowed to be undecided: #deploy_options refuses to emit a key that has no disposition here, and test/proxy_layering_test.rb fails the build if a new option is added without one.

:edge    — only where clients connect. Stripped from the per-app deploy,
         applied by the load balancer.
:per_app — only next to the app. Applied per-app, stripped from the
         load balancer.
:both    — each layer genuinely has its own copy of the concern.

Without load balancing the single proxy is every layer at once and the whole surface applies to it.

{
  # --- Edge: TLS terminates where the handshake happens, and dash-proxy
  # gates TLSRedirect on TLSEnabled, so the whole family travels together.
  host: :edge,
  tls: :edge,
  "tls-staging": :edge,
  "tls-certificate-path": :edge,
  "tls-private-key-path": :edge,
  "tls-redirect": :edge,
  "tls-domains-source": :edge,
  "tls-domains-interval": :edge,
  "tls-domains-batch-size": :edge,
  "tls-on-demand-url": :edge,
  "tls-client-ca-path": :edge,

  # --- Edge: the load balancer is the only proxy that ever sees the real
  # client address — an allow list on a per-app proxy would refuse every
  # request (its peer is the LB) and one limiter would count the whole
  # fleet as a single client.
  "allow-ip": :edge,
  "deny-ip": :edge,
  "deny-user-agent": :edge,
  "trusted-proxy": :edge,
  "client-ip-header": :edge,
  "rate-limit": :edge,
  "rate-limit-burst": :edge,
  "rate-limit-exempt": :edge,

  # --- Edge: dash-proxy deletes the Authorization header once a service
  # enforces basic auth, so an inner proxy would 401 the credential-less
  # request the load balancer forwards. Credentials belong at the edge only.
  "basic-auth": :edge,

  # --- Edge: both layers used to pin with the same cookie name but separate
  # HMAC keys, so the inner proxy clobbered the edge pin every other request.
  # Only the edge pin can stick.
  "session-affinity": :edge,
  "session-affinity-cookie": :edge,

  # --- Edge: redirectURLIfNeeded consults r.TLS only, so behind the LB a
  # per-app redirect emits http:// Locations to HTTPS clients. The dynamic
  # redirect map answers where clients connect, for the same reason.
  "canonical-host": :edge,
  redirect: :edge,
  "redirects-source": :edge,
  "redirects-interval": :edge,

  # --- Edge: one response cache, at the edge — two layers of cache would
  # double the storage and let the inner cache serve entries the edge
  # already invalidated. The store it writes into is proxy-wide (proxy/run).
  cache: :edge,
  "cache-max-ttl": :edge,
  "cache-max-body": :edge,
  "cache-max-variants": :edge,
  "cache-vary-header": :edge,
  "cache-vary-cookie": :edge,
  "cache-allow-set-cookie": :edge,

  # --- Edge: splitting reads from writes is a fleet-level routing decision;
  # per-app proxies each front a single host and have nothing to split.
  "read-target": :edge,
  "read-target-websockets": :edge,
  "writer-affinity-timeout": :edge,

  # --- Per-app: applied next to the app, exactly once. The LB forwards to
  # the per-host proxies, so running these at both layers would add a header
  # twice or run a rewrite over its own output.
  "set-request-header": :per_app,
  "add-request-header": :per_app,
  "remove-request-header": :per_app,
  "set-response-header": :per_app,
  "add-response-header": :per_app,
  "remove-response-header": :per_app,
  rewrite: :per_app,
  "intercept-errors": :per_app,

  # --- Per-app: sleep stops and starts app containers through the docker
  # socket — the LB has neither the socket nor the containers, and its
  # targets are host addresses, so a sleep flag there fails the deploy.
  "sleep-after": :per_app,
  "wake-timeout": :per_app,
  "sleep-container": :per_app,

  # --- Per-app: compress once, next to the app. Double-running was only
  # safe by accident of the Content-Encoding guard.
  compress: :per_app,
  "compress-content-type": :per_app,
  "compress-min-length": :per_app,

  # --- Both, deliberately: each layer has a real connection pool to its own
  # targets (LB -> per-host proxies, per-host proxy -> app containers), so
  # pool tuning and request deadlines apply to each hop.
  "target-timeout": :both,
  "target-max-conns": :both,
  "target-max-idle-conns": :both,
  "target-idle-conn-timeout": :both,
  "target-dial-timeout": :both,
  "target-disable-keep-alives": :both,
  "target-try-duration": :both,
  "target-try-interval": :both,
  "path-timeout": :both,
  "request-timeout": :both,
  "path-request-timeout": :both,
  "deploy-timeout": :both,
  "drain-timeout": :both,

  # --- Both: each layer health-checks its own targets, buffers its own
  # connections, routes its own paths and writes its own logs.
  "health-check-interval": :both,
  "health-check-timeout": :both,
  "health-check-path": :both,
  "health-check-port": :both,
  "health-check-host": :both,
  "buffer-requests": :both,
  "buffer-responses": :both,
  "buffer-memory": :both,
  "max-request-body": :both,
  "max-response-body": :both,
  "path-prefix": :both,
  "strip-path-prefix": :both,
  "forward-headers": :both,
  "log-request-header": :both,
  "log-response-header": :both,
  "error-pages": :both,
  "exclude-metrics-path": :both
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Validation

#validate!, #validation_yml

Constructor Details

#initialize(config:, proxy_config:, role_name: nil, secrets:, context: "proxy", load_balanced: true) ⇒ Proxy

load_balanced: false marks a registration the fork's load balancer can never front - accessories, whose targets it does not collect. Such a proxy keeps its own host/TLS/basic-auth instead of deferring them to the edge.



203
204
205
206
207
208
209
210
211
212
# File 'lib/dash/configuration/proxy.rb', line 203

def initialize(config:, proxy_config:, role_name: nil, secrets:, context: "proxy", load_balanced: true)
  @config = config
  @proxy_config = proxy_config
  @proxy_config = {} if @proxy_config.nil?
  @role_name = role_name
  @load_balanced = load_balanced
  @secrets = secrets
  validate! @proxy_config, with: Dash::Configuration::Validator::Proxy, context: context
  @run = Dash::Configuration::Proxy::Run.new(config, run_config: @proxy_config["run"], context: "#{context}/run") if @proxy_config && @proxy_config["run"].present?
end

Instance Attribute Details

#config ⇒ Object (readonly)

Returns the value of attribute config.



198
199
200
# File 'lib/dash/configuration/proxy.rb', line 198

def config
  @config
end

#proxy_config ⇒ Object (readonly)

Returns the value of attribute proxy_config.



198
199
200
# File 'lib/dash/configuration/proxy.rb', line 198

def proxy_config
  @proxy_config
end

#role_name ⇒ Object (readonly)

Returns the value of attribute role_name.



198
199
200
# File 'lib/dash/configuration/proxy.rb', line 198

def role_name
  @role_name
end

#run ⇒ Object (readonly)

Returns the value of attribute run.



198
199
200
# File 'lib/dash/configuration/proxy.rb', line 198

def run
  @run
end

#secrets ⇒ Object (readonly)

Returns the value of attribute secrets.



198
199
200
# File 'lib/dash/configuration/proxy.rb', line 198

def secrets
  @secrets
end

Class Method Details

.disposition(key) ⇒ Object

Refusing beats guessing: a deploy option nobody placed would silently land on both layers, which is how session affinity broke in the only topology where it matters.



189
190
191
192
193
194
# File 'lib/dash/configuration/proxy.rb', line 189

def self.disposition(key)
  DEPLOY_OPTION_DISPOSITIONS.fetch(key) do
    raise Dash::ConfigurationError,
      "proxy deploy option --#{key} has no layering disposition - add it to Dash::Configuration::Proxy::DEPLOY_OPTION_DISPOSITIONS"
  end
end

Instance Method Details

#all_deploy_options ⇒ Object

The full option surface before the layering contract is applied — what a single proxy (no load balancer) deploys with. Public so the layering canary can enumerate every key the gem emits.



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
# File 'lib/dash/configuration/proxy.rb', line 351

def all_deploy_options
  {
    host: hosts,
    tls: ssl? ? true : nil,
    "tls-staging": proxy_config["ssl_staging"] ? true : nil,
    "tls-certificate-path": container_tls_cert,
    "tls-private-key-path": container_tls_key,
    "deploy-timeout": seconds_duration(config.deploy_timeout),
    "drain-timeout": seconds_duration(config.drain_timeout),
    "health-check-interval": seconds_duration(proxy_config.dig("healthcheck", "interval")),
    "health-check-timeout": seconds_duration(proxy_config.dig("healthcheck", "timeout")),
    "health-check-path": healthcheck_path,
    "health-check-port": proxy_config.dig("healthcheck", "port"),
    "health-check-host": proxy_config.dig("healthcheck", "host"),
    "target-timeout": seconds_duration(proxy_config["response_timeout"]),
    "read-target": proxy_config.dig("read_routing", "targets").presence,
    "read-target-websockets": proxy_config.dig("read_routing", "websockets") ? true : nil,
    "writer-affinity-timeout": seconds_duration(proxy_config.dig("read_routing", "writer_affinity_timeout")),
    "path-timeout": path_timeout_args("path_response_timeouts"),
    "request-timeout": seconds_duration(proxy_config["request_timeout"]),
    "path-request-timeout": path_timeout_args("path_request_timeouts"),
    "buffer-requests": proxy_config.fetch("buffering", { "requests": true }).fetch("requests", true),
    "buffer-responses": proxy_config.fetch("buffering", { "responses": true }).fetch("responses", true),
    "buffer-memory": proxy_config.dig("buffering", "memory"),
    "max-request-body": proxy_config.dig("buffering", "max_request_body"),
    "max-response-body": proxy_config.dig("buffering", "max_response_body"),
    "path-prefix": path_prefixes,
    "strip-path-prefix": proxy_config.dig("strip_path_prefix"),
    "forward-headers": proxy_config.dig("forward_headers"),
    "tls-redirect": proxy_config.dig("ssl_redirect"),
    "basic-auth": basic_auth_credential,
    "log-request-header": proxy_config.dig("logging", "request_headers") || DEFAULT_LOG_REQUEST_HEADERS,
    "log-response-header": proxy_config.dig("logging", "response_headers"),
    "error-pages": error_pages,
    # A deploy flag despite reading like metrics configuration: where the
    # metrics are served and who may read them are proxy-wide and live under
    # proxy/run, but which of *this service's* paths are counted is per service.
    "exclude-metrics-path": proxy_config["exclude_metrics_paths"].presence
  }.merge(ssl_domains_options).merge(tls_options).merge(cache_options).merge(compress_options)
    .merge(access_control_options).merge(traffic_options).merge(lifecycle_options)
    .merge(target_options).compact
end

#app_port ⇒ Object



214
215
216
# File 'lib/dash/configuration/proxy.rb', line 214

def app_port
  proxy_config.fetch("app_port", 80)
end

#certificate_pem_content ⇒ Object



263
264
265
266
267
# File 'lib/dash/configuration/proxy.rb', line 263

def certificate_pem_content
  ssl = proxy_config["ssl"]
  return nil unless ssl.is_a?(Hash)
  secrets[ssl["certificate_pem"]]
end

#client_ca? ⇒ Boolean

Returns:

  • (Boolean)


311
312
313
# File 'lib/dash/configuration/proxy.rb', line 311

def client_ca?
  client_ca_pem.present?
end

#client_ca_pem ⇒ Object

The name of a secret in .dash/secrets holding the CA bundle client certificates must chain to - mirroring ssl.certificate_pem, not a local file path. Kamal uploads the content into the app's TLS directory, which the proxy container already mounts, and hands the proxy the path it sees there.



307
308
309
# File 'lib/dash/configuration/proxy.rb', line 307

def client_ca_pem
  ssl_config["client_ca_pem"]
end

#client_ca_pem_content ⇒ Object

Resolved at upload time, not config time, so dash app logs and friends work on machines without the secret. A blank secret raises like basic_auth.password_secret - silently deploying without the client CA would turn mTLS off.



319
320
321
322
323
324
325
# File 'lib/dash/configuration/proxy.rb', line 319

def client_ca_pem_content
  secrets[client_ca_pem].tap do |content|
    if content.blank?
      raise Dash::ConfigurationError, "proxy/ssl: client_ca_pem secret '#{client_ca_pem}' is empty"
    end
  end
end

#container_client_ca ⇒ Object



331
332
333
# File 'lib/dash/configuration/proxy.rb', line 331

def container_client_ca
  tls_file_path(config.proxy_boot.tls_container_directory, CLIENT_CA_FILENAME) if client_ca?
end

#container_tls_cert ⇒ Object



283
284
285
# File 'lib/dash/configuration/proxy.rb', line 283

def container_tls_cert
  tls_path(config.proxy_boot.tls_container_directory, "cert.pem")
end

#container_tls_key ⇒ Object



287
288
289
# File 'lib/dash/configuration/proxy.rb', line 287

def container_tls_key
  tls_path(config.proxy_boot.tls_container_directory, "key.pem") if custom_ssl_certificate?
end

#custom_ssl_certificate? ⇒ Boolean

Returns:

  • (Boolean)


257
258
259
260
261
# File 'lib/dash/configuration/proxy.rb', line 257

def custom_ssl_certificate?
  ssl = proxy_config["ssl"]
  return false unless ssl.is_a?(Hash)
  ssl["certificate_pem"].present? && ssl["private_key_pem"].present?
end

#deploy_command_args(target:) ⇒ Object



394
395
396
# File 'lib/dash/configuration/proxy.rb', line 394

def deploy_command_args(target:)
  optionize ({ target: "#{target}:#{app_port}" }).merge(deploy_options), with: "="
end

#deploy_options ⇒ Object



344
345
346
# File 'lib/dash/configuration/proxy.rb', line 344

def deploy_options
  all_deploy_options.select { |key, _| retained_dispositions.include?(self.class.disposition(key)) }
end

#effective_loadbalancer ⇒ Object



243
244
245
246
247
248
249
250
251
# File 'lib/dash/configuration/proxy.rb', line 243

def effective_loadbalancer
  return nil unless load_balanced?
  return false if loadbalancer == false
  return primary_role_first_host if loadbalancer == true
  return loadbalancer if loadbalancer.present?
  return primary_role_first_host if auto_load_balanced_primary_role?

  nil
end

#healthcheck_path ⇒ Object

Nil when unset: the default lives in dash-proxy, not here.



340
341
342
# File 'lib/dash/configuration/proxy.rb', line 340

def healthcheck_path
  proxy_config.dig("healthcheck", "path")
end

#host_client_ca ⇒ Object



327
328
329
# File 'lib/dash/configuration/proxy.rb', line 327

def host_client_ca
  tls_file_path(config.proxy_boot.tls_directory, CLIENT_CA_FILENAME) if client_ca?
end

#host_tls_cert ⇒ Object



275
276
277
# File 'lib/dash/configuration/proxy.rb', line 275

def host_tls_cert
  tls_path(config.proxy_boot.tls_directory, "cert.pem")
end

#host_tls_key ⇒ Object



279
280
281
# File 'lib/dash/configuration/proxy.rb', line 279

def host_tls_key
  tls_path(config.proxy_boot.tls_directory, "key.pem")
end

#hosts ⇒ Object



222
223
224
# File 'lib/dash/configuration/proxy.rb', line 222

def hosts
  proxy_config["hosts"] || proxy_config["host"]&.split(",") || []
end

#load_balanced? ⇒ Boolean

Returns:

  • (Boolean)


239
240
241
# File 'lib/dash/configuration/proxy.rb', line 239

def load_balanced?
  @load_balanced
end

#load_balancing? ⇒ Boolean

Returns:

  • (Boolean)


235
236
237
# File 'lib/dash/configuration/proxy.rb', line 235

def load_balancing?
  effective_loadbalancer.present?
end

#loadbalancer ⇒ Object



226
227
228
# File 'lib/dash/configuration/proxy.rb', line 226

def loadbalancer
  proxy_config["loadbalancer"]
end

#loadbalancer_on_proxy_host? ⇒ Boolean

Returns:

  • (Boolean)


253
254
255
# File 'lib/dash/configuration/proxy.rb', line 253

def loadbalancer_on_proxy_host?
  load_balancing? && config.proxy_hosts.include?(effective_loadbalancer)
end

#merge(other) ⇒ Object



426
427
428
# File 'lib/dash/configuration/proxy.rb', line 426

def merge(other)
  self.class.new config: config, proxy_config: other.proxy_config.deep_merge(proxy_config), role_name: role_name, secrets: secrets, load_balanced: load_balanced?
end

#on_demand_url ⇒ Object



298
299
300
# File 'lib/dash/configuration/proxy.rb', line 298

def on_demand_url
  ssl_config["on_demand_url"]
end

#path_prefixes ⇒ Object



335
336
337
# File 'lib/dash/configuration/proxy.rb', line 335

def path_prefixes
  proxy_config["path_prefixes"] || proxy_config["path_prefix"]&.split(",") || []
end

#private_key_pem_content ⇒ Object



269
270
271
272
273
# File 'lib/dash/configuration/proxy.rb', line 269

def private_key_pem_content
  ssl = proxy_config["ssl"]
  return nil unless ssl.is_a?(Hash)
  secrets[ssl["private_key_pem"]]
end

#reboot_on_deploy? ⇒ Boolean

Root-level proxy setting only; ignored inside role-specific proxy blocks.

Returns:

  • (Boolean)


231
232
233
# File 'lib/dash/configuration/proxy.rb', line 231

def reboot_on_deploy?
  proxy_config.fetch("reboot_on_deploy", true)
end

#rollout_deploy_command_args(target:) ⇒ Object



407
408
409
# File 'lib/dash/configuration/proxy.rb', line 407

def rollout_deploy_command_args(target:)
  optionize ({ target: "#{target}:#{app_port}" }).merge(rollout_deploy_options), with: "="
end

#rollout_deploy_options ⇒ Object

dash-proxy rollout deploy only accepts the target and the timeouts - the service already exists, so it keeps the host, TLS, buffering and logging options of the live deploy.



400
401
402
403
404
405
# File 'lib/dash/configuration/proxy.rb', line 400

def rollout_deploy_options
  {
    "deploy-timeout": seconds_duration(config.deploy_timeout),
    "drain-timeout": seconds_duration(config.drain_timeout)
  }.compact
end

#rollout_set_command_args(percent: nil, list: nil) ⇒ Object



411
412
413
# File 'lib/dash/configuration/proxy.rb', line 411

def rollout_set_command_args(percent: nil, list: nil)
  optionize({ percent: percent, list: list }.compact, with: "=")
end

#ssl? ⇒ Boolean

Returns:

  • (Boolean)


218
219
220
# File 'lib/dash/configuration/proxy.rb', line 218

def ssl?
  proxy_config.fetch("ssl", false)
end

#ssl_config ⇒ Object

Everything TLS lives in the one ssl hash - certificate material, on-demand issuance and the mTLS client CA. One naming family instead of a separate tls: block.



294
295
296
# File 'lib/dash/configuration/proxy.rb', line 294

def ssl_config
  proxy_config["ssl"].is_a?(Hash) ? proxy_config["ssl"] : {}
end

#stop_command_args(**options) ⇒ Object



422
423
424
# File 'lib/dash/configuration/proxy.rb', line 422

def stop_command_args(**options)
  optionize stop_options(**options), with: "="
end

#stop_options(drain_timeout: nil, message: nil) ⇒ Object



415
416
417
418
419
420
# File 'lib/dash/configuration/proxy.rb', line 415

def stop_options(drain_timeout: nil, message: nil)
  {
    "drain-timeout": seconds_duration(drain_timeout),
    message: message
  }.compact
end