diff --git a/web/lib/potato_mesh/application/networking.rb b/web/lib/potato_mesh/application/networking.rb index 879f64f..1aa2679 100644 --- a/web/lib/potato_mesh/application/networking.rb +++ b/web/lib/potato_mesh/application/networking.rb @@ -39,13 +39,11 @@ module PotatoMesh raise "INSTANCE_DOMAIN URL must include a hostname: #{candidate.inspect}" end - candidate = hostname + ip_host = ipv6_literal?(hostname) + candidate_host = ip_host ? "[#{ip_host}]" : hostname + candidate = candidate_host port = uri.port - if port && (!uri.respond_to?(:default_port) || uri.default_port.nil? || port != uri.default_port) - candidate = "#{candidate}:#{port}" - elsif port && uri.to_s.match?(/:\d+/) - candidate = "#{candidate}:#{port}" - end + candidate = "#{candidate_host}:#{port}" if port_required?(uri, trimmed) end sanitized = sanitize_instance_domain(candidate) @@ -53,7 +51,7 @@ module PotatoMesh raise "INSTANCE_DOMAIN must be a bare hostname (optionally with a port) without schemes or paths: #{raw.inspect}" end - sanitized.downcase + ensure_ipv6_instance_domain(sanitized).downcase end def determine_instance_domain @@ -200,6 +198,73 @@ module PotatoMesh false end + + # Normalize IPv6 instance domains so that they remain bracketed and URI-compatible. + # + # @param domain [String] sanitized hostname optionally including a port suffix. + # @return [String] domain with IPv6 literals wrapped in brackets when necessary. + def ensure_ipv6_instance_domain(domain) + bracketed_match = domain.match(/\A\[(?[^\]]+)\](?::(?\d+))?\z/) + if bracketed_match + host = bracketed_match[:host] + port = bracketed_match[:port] + ipv6 = ipv6_literal?(host) + if ipv6 + return "[#{ipv6}]#{port ? ":#{port}" : ""}" + end + + return domain + end + + host_candidate = domain + port_candidate = nil + split_host, separator, split_port = domain.rpartition(":") + if !separator.empty? && split_port.match?(/\A\d+\z/) && !split_host.empty? && !split_host.end_with?(":") + host_candidate = split_host + port_candidate = split_port + end + + if port_candidate + ipv6_host = ipv6_literal?(host_candidate) + return "[#{ipv6_host}]:#{port_candidate}" if ipv6_host + + host_candidate = domain + port_candidate = nil + end + + ipv6 = ipv6_literal?(host_candidate) + return "[#{ipv6}]" if ipv6 + + domain + end + + # Parse an IPv6 literal and return its canonical representation when valid. + # + # @param candidate [String] potential IPv6 literal. + # @return [String, nil] normalized IPv6 literal or nil when the candidate is not IPv6. + def ipv6_literal?(candidate) + IPAddr.new(candidate).yield_self do |ip| + return ip.ipv6? ? ip.to_s : nil + end + rescue IPAddr::InvalidAddressError + nil + end + + # Determine whether a URI's port should be included in the canonicalized domain. + # + # @param uri [URI::Generic] parsed URI for the instance domain. + # @param raw [String] original sanitized input string. + # @return [Boolean] true when the port must be preserved. + def port_required?(uri, raw) + port = uri.port + return false unless port + + return true unless uri.respond_to?(:default_port) && uri.default_port && port == uri.default_port + + raw_port_fragment = ":#{port}" + sanitized_raw = raw.strip + sanitized_raw.end_with?(raw_port_fragment) + end end end end diff --git a/web/spec/networking_spec.rb b/web/spec/networking_spec.rb new file mode 100644 index 0000000..f571655 --- /dev/null +++ b/web/spec/networking_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe PotatoMesh::Application do + describe ".canonicalize_configured_instance_domain" do + subject(:canonicalize) { described_class.canonicalize_configured_instance_domain(input) } + + context "with an IPv6 URL" do + let(:input) { "http://[::1]" } + + it "retains brackets around the literal" do + expect(canonicalize).to eq("[::1]") + end + end + + context "with an IPv6 URL including a non-default port" do + let(:input) { "http://[::1]:8080" } + + it "keeps the literal bracketed and appends the port" do + expect(canonicalize).to eq("[::1]:8080") + end + end + + context "with a bare IPv6 literal" do + let(:input) { "::1" } + + it "wraps the literal in brackets" do + expect(canonicalize).to eq("[::1]") + end + end + + context "with a bare IPv6 literal and port" do + let(:input) { "::1:9000" } + + it "wraps the literal in brackets and preserves the port" do + expect(canonicalize).to eq("[::1]:9000") + end + end + + context "with an IPv4 literal" do + let(:input) { "http://127.0.0.1" } + + it "returns the literal without brackets" do + expect(canonicalize).to eq("127.0.0.1") + end + end + end +end