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, 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))).



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
108
109
110
111
112
113
114
115
116
# File 'lib/portless/proxy.rb', line 82

def call(request)
  host = request_host(request)
  # Only consulted in LAN mode: with a loopback-only bind there is no
  # off-machine client, and a stricter default there could 404 local dev if
  # the peer address were ever unreadable.
  lan_client = @lan && !loopback_client?(request)
  route = route_for(host, lan_client: lan_client)
  return not_found(host, lan_client: lan_client) 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_hostsObject

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, lan_client: false) ⇒ 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.

lan_client: restricts the search to routes that opted into LAN serving (run --lan). LAN mode opens one socket for the whole daemon, so without this every app you happen to be running would answer the whole Wi-Fi.



69
70
71
72
73
74
75
76
77
# File 'lib/portless/proxy.rb', line 69

def route_for(host, lan_client: false)
  authority = host.to_s.downcase.delete_suffix(":443")
  host = authority.split(":").first.to_s
  routes = @route_store.routes
  routes = routes.select(&:lan?) if lan_client
  routes.find { |r| r.hostname == host } ||
    routes.find { |r| share_match?(r, authority, host) } ||
    routes.find { |r| host.end_with?(".#{r.hostname}") }
end

#runObject



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