diff --git a/grpc/lib/grpc/client/connection/endpoint_resolver.ex b/grpc/lib/grpc/client/connection/endpoint_resolver.ex new file mode 100644 index 00000000..a282006a --- /dev/null +++ b/grpc/lib/grpc/client/connection/endpoint_resolver.ex @@ -0,0 +1,228 @@ +defmodule GRPC.Client.Connection.EndpointResolver do + @moduledoc false + + # Parses and normalises a raw target string into the canonical + # `{norm_target, scheme, cred}` triple consumed by `GRPC.Client.Connection`. + # + # Supported input formats: + # + # * `"https://host:port"` – TLS, implicit :cred injection + # * `"http://host:port"` – plain-text, rejects :cred + # * `"host:port"` – compatibility shorthand → ipv4/ipv6 + # * `"path"` – bare path → unix socket + # * `"dns://…"`, `"ipv4:…"`, `"ipv6:…"`, `"unix:…"`, etc. – passed through + # * `"[::1]:port"` – bracketed IPv6 with port → ipv6 normalised + # * `"::1:port"` – bare IPv6 with port → ipv6 normalised + + @insecure_scheme "http" + @secure_scheme "https" + @default_port 50051 + + @doc """ + Normalises `target` and `cred`, returning `{norm_target, scheme, cred}`. + + - `norm_target` – canonical target string for the resolver (e.g. `"ipv4:1.2.3.4:50051"`, `"ipv6:::1:50051"`) + - `scheme` – `"http"`, `"https"`, or `"unix"` + - `cred` – resolved `%GRPC.Credential{}`, or `nil` for plain-text targets + + ## Examples + + iex> GRPC.Client.Connection.EndpointResolver.normalize("http://example.com:50051", nil) + {"ipv4:example.com:50051", "http", nil} + + iex> cred = %GRPC.Credential{ssl: [verify: :verify_none]} + iex> GRPC.Client.Connection.EndpointResolver.normalize("https://example.com:50051", cred) + {"ipv4:example.com:50051", "https", %GRPC.Credential{ssl: [verify: :verify_none]}} + + iex> GRPC.Client.Connection.EndpointResolver.normalize("localhost:50051", nil) + {"ipv4:localhost:50051", "http", nil} + + iex> GRPC.Client.Connection.EndpointResolver.normalize("[::1]:50051", nil) + {"ipv6:::1:50051", "http", nil} + + """ + @spec normalize(String.t(), GRPC.Credential.t() | nil) :: + {String.t(), String.t(), GRPC.Credential.t() | nil} + def normalize(target, cred) + when is_binary(target) and (is_nil(cred) or is_struct(cred, GRPC.Credential)) do + uri = URI.parse(target) + + cond do + uri.scheme == @secure_scheme and uri.host -> + resolved_cred = cred || default_ssl_option() + prefix = resolver_prefix(uri.host) + {"#{prefix}:#{uri.host}:#{uri.port}", @secure_scheme, resolved_cred} + + uri.scheme == @insecure_scheme and uri.host -> + if cred, + do: raise(ArgumentError, "invalid option for insecure (http) address: :cred") + + prefix = resolver_prefix(uri.host) + {"#{prefix}:#{uri.host}:#{uri.port}", @insecure_scheme, nil} + + # Compatibility mode: "host:port", bare path, or raw IPv6 + uri.scheme in [nil, ""] -> + scheme = if cred, do: @secure_scheme, else: @insecure_scheme + normalize_schemeless(target, scheme, cred) + + # URI.parse misreads "hostname:port" as scheme="hostname", host=nil. + # Detect this: a real resolver scheme always has a host OR uses "://" + # notation. If host is nil and the scheme is not a known gRPC resolver + # prefix, treat it as a schemeless host:port shorthand. + is_nil(uri.host) and uri.scheme not in ["ipv4", "ipv6", "dns", "unix", "xds"] -> + scheme = if cred, do: @secure_scheme, else: @insecure_scheme + normalize_schemeless(target, scheme, cred) + + true -> + scheme = if cred, do: @secure_scheme, else: @insecure_scheme + {target, scheme, cred} + end + end + + @doc """ + Splits a resolved target string (e.g. `"ipv4:1.2.3.4:50051"`) into + `{host, port}`. + + Handles: + - `"host:port"` → `{"host", port}` + - `"scheme:host:port"` → `{"host", port}` + - `"host"` → `{"host", #{@default_port}}` + - `"[::1]:port"` → `{"::1", port}` (bracketed IPv6) + - `"::1:port"` → `{"::1", port}` (bare IPv6, port is the last segment) + + ## Examples + + iex> GRPC.Client.Connection.EndpointResolver.split_host_port("ipv4:127.0.0.1:50051") + {"127.0.0.1", 50051} + + iex> GRPC.Client.Connection.EndpointResolver.split_host_port("localhost:8080") + {"localhost", 8080} + + iex> GRPC.Client.Connection.EndpointResolver.split_host_port("[::1]:50051") + {"::1", 50051} + + iex> GRPC.Client.Connection.EndpointResolver.split_host_port("myhost") + {"myhost", 50051} + + """ + @spec split_host_port(String.t()) :: {String.t(), pos_integer()} + def split_host_port(target) when is_binary(target) do + cond do + String.contains?(target, "[") -> + case Regex.run(~r/\[([^\]]+)\]:(\d+)$/, target) do + [_, addr, port] -> + {addr, String.to_integer(port)} + + _ -> + case Regex.run(~r/\[([^\]]+)\]/, target) do + [_, addr] -> {addr, @default_port} + _ -> {strip_scheme(target), @default_port} + end + end + + target |> String.split(":") |> length() > 2 -> + parts = String.split(target, ":") + + case {parts, Integer.parse(List.last(parts))} do + {[_scheme, host, port_str], {_port, ""}} -> + {host, String.to_integer(port_str)} + + {_, {_port, ""}} -> + port_str = List.last(parts) + addr = parts |> Enum.drop(-1) |> Enum.join(":") + {addr, String.to_integer(port_str)} + + {[_scheme, host], _} -> + {host, @default_port} + + _ -> + {strip_scheme(target), @default_port} + end + + String.contains?(target, ":") -> + [h, p] = String.split(target, ":", parts: 2) + + case Integer.parse(p) do + {port, ""} -> {h, port} + _ -> {p, @default_port} + end + + true -> + {target, @default_port} + end + end + + defp normalize_schemeless(target, scheme, cred) do + cond do + String.starts_with?(target, "[") -> + case Regex.run(~r/^\[([^\]]+)\]:(\d+)$/, target) do + [_, addr, port] -> + {"ipv6:#{addr}:#{port}", scheme, cred} + + _ -> + addr = target |> String.trim_leading("[") |> String.replace("]", "") + {"ipv6:#{addr}", scheme, cred} + end + + String.contains?(target, ":") -> + parts = String.split(target, ":") + + case List.last(parts) do + port_str when byte_size(port_str) > 0 -> + case Integer.parse(port_str) do + {_port, ""} -> + addr = parts |> Enum.drop(-1) |> Enum.join(":") + prefix = resolver_prefix(addr) + {"#{prefix}:#{addr}:#{port_str}", scheme, cred} + + _ -> + prefix = resolver_prefix(target) + {"#{prefix}:#{target}", scheme, cred} + end + + _ -> + prefix = resolver_prefix(target) + {"#{prefix}:#{target}", scheme, cred} + end + + true -> + {"unix://#{target}", "unix", nil} + end + end + + defp resolver_prefix(host) when is_binary(host) do + case :inet.parse_address(String.to_charlist(host)) do + {:ok, {_, _, _, _}} -> "ipv4" + {:ok, {_, _, _, _, _, _, _, _}} -> "ipv6" + {:error, _} -> "ipv4" + end + end + + defp strip_scheme(target) do + case String.split(target, ":", parts: 2) do + [_scheme, rest] -> rest + [bare] -> bare + end + end + + if {:module, CAStore} == Code.ensure_loaded(CAStore) do + defp default_ssl_option do + %GRPC.Credential{ + ssl: [ + verify: :verify_peer, + depth: 99, + cacertfile: CAStore.file_path() + ] + } + end + else + defp default_ssl_option do + raise """ + no GRPC credentials provided. Please either: + + - Pass the `:cred` option to `GRPC.Stub.connect/2,3` + - Add `:castore` to your list of dependencies in `mix.exs` + """ + end + end +end diff --git a/grpc/test/grpc/channel_test.exs b/grpc/test/grpc/channel_test.exs index 6bd9588c..6c5934ef 100644 --- a/grpc/test/grpc/channel_test.exs +++ b/grpc/test/grpc/channel_test.exs @@ -58,7 +58,7 @@ defmodule GRPC.ChannelTest do end test "cred uses https" do - cred = %{ssl: []} + cred = %GRPC.Credential{ssl: []} {:ok, channel} = GRPC.Stub.connect("#{unquote(addr)}:50051", adapter: ClientAdapter, cred: cred) diff --git a/grpc/test/grpc/client/connection/endpoint_resolver_test.exs b/grpc/test/grpc/client/connection/endpoint_resolver_test.exs new file mode 100644 index 00000000..e5b4cf71 --- /dev/null +++ b/grpc/test/grpc/client/connection/endpoint_resolver_test.exs @@ -0,0 +1,250 @@ +defmodule GRPC.Client.Connection.EndpointResolverTest do + use ExUnit.Case, async: true + + alias GRPC.Client.Connection.EndpointResolver + + doctest GRPC.Client.Connection.EndpointResolver + + defp cred(opts \\ [verify: :verify_none]), do: %GRPC.Credential{ssl: opts} + + describe "normalize/2 — https://" do + test "normalises host and port to ipv4 prefix" do + {norm_target, scheme, _cred} = EndpointResolver.normalize("https://example.com:50051", nil) + + assert norm_target == "ipv4:example.com:50051" + assert scheme == "https" + end + + test "injects a GRPC.Credential when none supplied" do + {_target, _scheme, cred} = EndpointResolver.normalize("https://example.com:50051", nil) + + assert %GRPC.Credential{} = cred + end + + test "preserves caller-supplied cred unchanged" do + supplied = cred() + + {_target, _scheme, returned} = + EndpointResolver.normalize("https://example.com:50051", supplied) + + assert returned == supplied + end + + test "uses port from URL" do + {norm_target, _scheme, _cred} = EndpointResolver.normalize("https://example.com:8443", nil) + + assert norm_target == "ipv4:example.com:8443" + end + + test "falls back to URI default port 443 when no port in URL" do + {norm_target, _scheme, _cred} = EndpointResolver.normalize("https://example.com", nil) + + assert norm_target == "ipv4:example.com:443" + end + end + + describe "normalize/2 — http://" do + test "normalises host and port to ipv4 prefix with http scheme" do + {norm_target, scheme, cred} = EndpointResolver.normalize("http://example.com:50051", nil) + + assert norm_target == "ipv4:example.com:50051" + assert scheme == "http" + assert cred == nil + end + + test "uses URI default port 80 when no port in URL" do + {norm_target, _scheme, _cred} = EndpointResolver.normalize("http://example.com", nil) + + assert norm_target == "ipv4:example.com:80" + end + + test "raises ArgumentError when cred is supplied for an http:// target" do + assert_raise ArgumentError, ~r/invalid option for insecure/, fn -> + EndpointResolver.normalize("http://example.com:50051", cred()) + end + end + end + + describe "normalize/2 — schemeless host:port shorthand" do + test "IPv4 address:port becomes ipv4 prefix with http scheme" do + {norm_target, scheme, cred} = EndpointResolver.normalize("127.0.0.1:50051", nil) + + assert norm_target == "ipv4:127.0.0.1:50051" + assert scheme == "http" + assert cred == nil + end + + test "hostname:port is treated as schemeless shorthand" do + {norm_target, scheme, _cred} = EndpointResolver.normalize("localhost:50051", nil) + + assert norm_target == "ipv4:localhost:50051" + assert scheme == "http" + end + + test "supplying cred flips scheme to https" do + {_target, scheme, returned_cred} = EndpointResolver.normalize("127.0.0.1:50051", cred()) + + assert scheme == "https" + assert returned_cred == cred() + end + + test "bare path (no colon) becomes a unix socket and drops cred" do + {norm_target, scheme, cred} = EndpointResolver.normalize("/tmp/my.sock", cred()) + + assert norm_target == "unix:///tmp/my.sock" + assert scheme == "unix" + assert cred == nil + end + end + + describe "normalize/2 — schemeless IPv6" do + test "bracketed IPv6 loopback [::1]:50051" do + {norm_target, scheme, _cred} = EndpointResolver.normalize("[::1]:50051", nil) + + assert norm_target == "ipv6:::1:50051" + assert scheme == "http" + end + + test "bracketed full address [2001:db8::1]:8080" do + {norm_target, _scheme, _cred} = EndpointResolver.normalize("[2001:db8::1]:8080", nil) + + assert norm_target == "ipv6:2001:db8::1:8080" + end + + test "bracketed IPv6 without port — strips brackets, uses default port" do + {norm_target, _scheme, _cred} = EndpointResolver.normalize("[::1]", nil) + + assert norm_target == "ipv6:::1" + end + + test "bare IPv6 loopback ::1:50051 (last segment is port)" do + {norm_target, scheme, _cred} = EndpointResolver.normalize("::1:50051", nil) + + assert norm_target == "ipv6:::1:50051" + assert scheme == "http" + end + + test "bare full address 2001:db8::1:8080" do + {norm_target, _scheme, _cred} = EndpointResolver.normalize("2001:db8::1:8080", nil) + + assert norm_target == "ipv6:2001:db8::1:8080" + end + + test "supplying cred flips scheme to https for bracketed IPv6" do + {_target, scheme, _cred} = EndpointResolver.normalize("[::1]:50051", cred()) + + assert scheme == "https" + end + end + + describe "normalize/2 — passthrough resolver schemes" do + test "dns:// target is passed through unchanged" do + {norm_target, scheme, cred} = + EndpointResolver.normalize("dns://my-service.local:50051", nil) + + assert norm_target == "dns://my-service.local:50051" + assert scheme == "http" + assert cred == nil + end + + test "ipv4: target is passed through unchanged" do + {norm_target, scheme, cred} = EndpointResolver.normalize("ipv4:10.0.0.1:50051", nil) + + assert norm_target == "ipv4:10.0.0.1:50051" + assert scheme == "http" + assert cred == nil + end + + test "ipv6: target is passed through unchanged" do + {norm_target, scheme, cred} = EndpointResolver.normalize("ipv6:[::1]:50051", nil) + + assert norm_target == "ipv6:[::1]:50051" + assert scheme == "http" + assert cred == nil + end + + test "unix:// target is passed through unchanged" do + {norm_target, scheme, cred} = EndpointResolver.normalize("unix:///tmp/my.sock", nil) + + assert norm_target == "unix:///tmp/my.sock" + assert scheme == "http" + assert cred == nil + end + + test "xds:// target is passed through unchanged" do + {norm_target, scheme, cred} = EndpointResolver.normalize("xds:///my-service", nil) + + assert norm_target == "xds:///my-service" + assert scheme == "http" + assert cred == nil + end + + test "supplying cred to a passthrough target returns https and the cred" do + supplied = cred() + + {norm_target, scheme, returned_cred} = + EndpointResolver.normalize("ipv4:10.0.0.1:50051", supplied) + + assert norm_target == "ipv4:10.0.0.1:50051" + assert scheme == "https" + assert returned_cred == supplied + end + end + + describe "split_host_port/1 — standard host:port" do + test "IPv4 address:port" do + assert EndpointResolver.split_host_port("127.0.0.1:50051") == {"127.0.0.1", 50051} + end + + test "hostname:port" do + assert EndpointResolver.split_host_port("localhost:8080") == {"localhost", 8080} + end + + test "bare host with no port uses default 50051" do + assert EndpointResolver.split_host_port("myhost") == {"myhost", 50051} + end + end + + describe "split_host_port/1 — scheme-prefixed targets" do + test "ipv4:host:port strips the scheme prefix" do + assert EndpointResolver.split_host_port("ipv4:10.0.0.1:50051") == {"10.0.0.1", 50051} + end + + test "ipv4:host with no port uses default 50051" do + assert EndpointResolver.split_host_port("ipv4:10.0.0.1") == {"10.0.0.1", 50051} + end + end + + describe "split_host_port/1 — bracketed IPv6" do + test "[::1]:50051" do + assert EndpointResolver.split_host_port("[::1]:50051") == {"::1", 50051} + end + + test "[2001:db8::1]:8080" do + assert EndpointResolver.split_host_port("[2001:db8::1]:8080") == {"2001:db8::1", 8080} + end + + test "ipv6:[::1]:50051 — scheme with bracketed IPv6" do + assert EndpointResolver.split_host_port("ipv6:[::1]:50051") == {"::1", 50051} + end + + test "[::1] with no port uses default 50051" do + assert EndpointResolver.split_host_port("[::1]") == {"::1", 50051} + end + end + + describe "split_host_port/1 — bare IPv6" do + test "::1:50051 — loopback with port" do + assert EndpointResolver.split_host_port("::1:50051") == {"::1", 50051} + end + + test "2001:db8::1:8080 — full address with port" do + assert EndpointResolver.split_host_port("2001:db8::1:8080") == {"2001:db8::1", 8080} + end + + test "::ffff:192.0.2.1:50051 — IPv4-mapped IPv6 with port" do + assert EndpointResolver.split_host_port("::ffff:192.0.2.1:50051") == + {"::ffff:192.0.2.1", 50051} + end + end +end