Class: Portless::Proxy
- Inherits:
-
Object
- Object
- Portless::Proxy
- 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:
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
-
#call(request) ⇒ Object
The reverse-proxy app: resolve the request's host to a backend, forward it, stamp the health header.
-
#initialize(port:, tls: true, lan: false, route_store: RouteStore.new, certs: Certs.new) ⇒ Proxy
constructor
A new instance of Proxy.
-
#listen_hosts ⇒ Object
Loopback-only unless LAN mode was asked for: on 0.0.0.0 every registered dev app is reachable from the LAN/VPN by default (upstream shipped the same change as a security fix).
-
#route_for(host) ⇒ Object
Exact host match, then a route's public share hostname (tailscale/ngrok — requests forwarded by the tunnel keep the *.ts.net authority, upstream issue #297), then wildcard fallback so *.name.localhost all reach the single app registered as name.localhost.
- #run ⇒ Object
Constructor Details
#initialize(port:, tls: true, lan: false, route_store: RouteStore.new, certs: Certs.new) ⇒ Proxy
Returns a new instance of Proxy.
23 24 25 26 27 28 29 30 31 |
# File 'lib/portless/proxy.rb', line 23 def initialize(port:, tls: true, lan: false, route_store: RouteStore.new, certs: Certs.new) @port = port @tls = tls @lan = lan @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))).
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/portless/proxy.rb', line 77 def call(request) host = request_host(request) route = route_for(host) return not_found(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 = with_backend_timeout do client_for(route.port).call(build_forward(request, host, hops)) end if response.status == 101 && request.version == "HTTP/2" # The h1 backend accepts a WebSocket with 101 Switching Protocols, but # HTTP/2 forbids 1xx finals — an extended-CONNECT success is a plain 2xx, # and the h1 handshake headers are meaningless (and illegal) on h2. response.status = 200 strip_hop_headers(response) response.headers.delete("sec-websocket-accept") elsif response.status != 101 # Hop-by-hop headers are single-hop by definition; relayed into an h2 # stream a backend's `Connection: close` aborts the whole header block, # so the client sees a bare 200 with no headers and no body. strip_hop_headers(response) end response.headers.add(Constants::HEALTH_HEADER, VERSION) response rescue Async::TimeoutError error(504, "Backend for #{host} accepted the connection but never answered.") rescue StandardError => e error(502, "Backend for #{host} is not responding (#{e.class}).") end |
#listen_hosts ⇒ Object
Loopback-only unless LAN mode was asked for: on 0.0.0.0 every registered
dev app is reachable from the LAN/VPN by default (upstream shipped the
same change as a security fix). *.localhost resolves to ::1 too, so an
IPv6-loopback sibling always listens (best-effort — its bind failure
surfaces as a logged task error, never fatal).
57 58 59 |
# File 'lib/portless/proxy.rb', line 57 def listen_hosts (@lan ? [ "0.0.0.0" ] : [ "127.0.0.1" ]) + [ "[::1]" ] end |
#route_for(host) ⇒ Object
Exact host match, then a route's public share hostname (tailscale/ngrok — requests forwarded by the tunnel keep the *.ts.net authority, upstream issue #297), then wildcard fallback so *.name.localhost all reach the single app registered as name.localhost.
65 66 67 68 69 70 71 72 |
# File 'lib/portless/proxy.rb', line 65 def route_for(host) = host.to_s.downcase.delete_suffix(":443") host = .split(":").first.to_s routes = @route_store.routes routes.find { |r| r.hostname == host } || routes.find { |r| share_match?(r, , host) } || routes.find { |r| host.end_with?(".#{r.hostname}") } end |
#run ⇒ Object
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'lib/portless/proxy.rb', line 33 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 listen_hosts.each { |host| make_server(endpoint_for(host)).run } start_redirect_listener if @tls && @port != Constants::HTTP_PORT end ensure cleanup end |