Class: Portless::Proxy

Inherits:
Object
  • Object
show all
Defined in:
lib/portless/proxy.rb

Overview

The reverse proxy daemon (async-http: HTTP/1.1 + TLS + WebSockets; HTTP/2 in phase 2). Routes by Host to a backend 127.0.0.1: from the route store — exact match, then wildcard .name — adds X-Forwarded-, stamps the X-Portless-Rb health header, guards against proxy loops, and re-reads routes.json per request so new apps appear without a restart. A sibling :80 listener 302-redirects to HTTPS. Mirrors portless's proxy.ts.

Constant Summary collapse

HOP_HEADER =
"x-rb-portless-hops"
HOP_BY_HOP =
%w[connection keep-alive proxy-authenticate proxy-authorization
te trailers transfer-encoding upgrade host].freeze

Instance Method Summary collapse

Constructor Details

#initialize(port:, tls: true, route_store: RouteStore.new, certs: Certs.new) ⇒ Proxy

Returns a new instance of Proxy.



23
24
25
26
27
28
29
30
# File 'lib/portless/proxy.rb', line 23

def initialize(port:, tls: true, route_store: RouteStore.new, certs: Certs.new)
  @port = port
  @tls = tls
  @route_store = route_store
  @certs = certs
  @clients = {}
  @host_contexts = {}
end

Instance Method Details

#call(request) ⇒ Object

The reverse-proxy app: resolve the request's host to a backend, forward it, stamp the health header. Public so it can be mounted in a test reactor (Async::HTTP::Server.for(endpoint, &proxy.method(:call))).



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/portless/proxy.rb', line 63

def call(request)
  host = request_host(request)
  route = route_for(host)
  return error(404, "No app is registered for #{host}.") unless route

  hops = request.headers[HOP_HEADER].to_a.first.to_i
  return error(508, "Proxy loop detected for #{host}.") if hops >= Constants::MAX_PROXY_HOPS

  response = client_for(route.port).call(build_forward(request, host, hops))
  # The h1 backend accepts a WebSocket with 101 Switching Protocols, but
  # HTTP/2 forbids 1xx finals — an extended-CONNECT success is a plain 2xx.
  response.status = 200 if response.status == 101 && request.version == "HTTP/2"
  response.headers.add(Constants::HEALTH_HEADER, VERSION)
  response
rescue StandardError => e
  error(502, "Backend for #{host} is not responding (#{e.class}).")
end

#route_for(host) ⇒ Object

Exact host match, then wildcard fallback so *.name.localhost all reach the single app registered as name.localhost.



53
54
55
56
57
58
# File 'lib/portless/proxy.rb', line 53

def route_for(host)
  host = host.to_s.split(":").first.to_s.downcase
  routes = @route_store.routes
  routes.find { |r| r.hostname == host } ||
    routes.find { |r| host.end_with?(".#{r.hostname}") }
end

#runObject



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/portless/proxy.rb', line 32

def run
  State.ensure_dir!
  # A second daemon on the same port would overwrite the live one's marker
  # files, then wipe them on its own EADDRINUSE crash — leaving the survivor
  # unstoppable by `proxy stop`. Refuse before touching any state.
  raise Error, "a proxy is already running on :#{@port}" if Health.proxy_running?(@port)

  @certs.ensure_ca! if @tls
  write_markers
  install_signal_handlers

  Async do
    make_server(listen_endpoint).run
    start_redirect_listener if @tls && @port != Constants::HTTP_PORT
  end
ensure
  cleanup
end