From 2240be1f2d3ad59032b877c438947b4bac6c6489 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sat, 11 Oct 2025 18:22:50 +0200 Subject: [PATCH] Improve instance domain detection logic (#278) --- web/app.rb | 124 +++++++++++++++++++++++++++++++++++++++++-- web/spec/app_spec.rb | 54 +++++++++++-------- 2 files changed, 152 insertions(+), 26 deletions(-) diff --git a/web/app.rb b/web/app.rb index a04c2bc..7138f0c 100644 --- a/web/app.rb +++ b/web/app.rb @@ -122,7 +122,13 @@ def determine_instance_domain reverse = reverse_dns_domain return [reverse, :reverse_dns] if reverse - [nil, :unknown] + public_ip = discover_public_ip_address + return [public_ip, :public_ip] if public_ip + + protected_ip = discover_protected_ip_address + return [protected_ip, :protected_ip] if protected_ip + + [discover_local_ip_address, :local_ip] end # Attempt to resolve a hostname via reverse DNS for the current machine. @@ -156,6 +162,112 @@ def reverse_dns_domain nil end +# Locate a globally routable IP address exposed on the current host. +# +# @return [String, nil] +def discover_public_ip_address + address = ip_address_candidates.find { |candidate| public_ip_address?(candidate) } + address&.ip_address +end + +# Locate a private IP address (e.g. RFC1918 or unique local) exposed on the current host. +# +# @return [String, nil] +def discover_protected_ip_address + address = ip_address_candidates.find { |candidate| protected_ip_address?(candidate) } + address&.ip_address +end + +# Enumerate IP address candidates exposed on the local machine. +# +# @return [Array] +def ip_address_candidates + Socket.ip_address_list.select { |addr| addr.respond_to?(:ip?) && addr.ip? } +end + +# Determine whether the supplied address is globally routable. +# +# @param addr [Addrinfo] +# @return [Boolean] +def public_ip_address?(addr) + ip = ipaddr_from(addr) + return false unless ip + return false if loopback_address?(addr, ip) + return false if link_local_address?(addr, ip) + return false if private_address?(addr, ip) + return false if unspecified_address?(ip) + + true +end + +# Determine whether the supplied address is suitable for protected network exposure. +# +# @param addr [Addrinfo] +# @return [Boolean] +def protected_ip_address?(addr) + ip = ipaddr_from(addr) + return false unless ip + return false if loopback_address?(addr, ip) + return false if link_local_address?(addr, ip) + + private_address?(addr, ip) +end + +# Convert an Addrinfo into an IPAddr instance for further inspection. +# +# @param addr [Addrinfo] +# @return [IPAddr, nil] +def ipaddr_from(addr) + ip = addr.ip_address + return nil if ip.nil? || ip.empty? + + IPAddr.new(ip) +rescue IPAddr::InvalidAddressError + nil +end + +# Determine whether the address is a loopback interface. +# +# @param addr [Addrinfo] +# @param ip [IPAddr] +# @return [Boolean] +def loopback_address?(addr, ip) + (addr.respond_to?(:ipv4_loopback?) && addr.ipv4_loopback?) || + (addr.respond_to?(:ipv6_loopback?) && addr.ipv6_loopback?) || + ip.loopback? +end + +# Determine whether the address resides within a link-local range. +# +# @param addr [Addrinfo] +# @param ip [IPAddr] +# @return [Boolean] +def link_local_address?(addr, ip) + (addr.respond_to?(:ipv6_linklocal?) && addr.ipv6_linklocal?) || + (ip.respond_to?(:link_local?) && ip.link_local?) +end + +# Determine whether the address is private (RFC1918 or unique local). +# +# @param addr [Addrinfo] +# @param ip [IPAddr] +# @return [Boolean] +def private_address?(addr, ip) + if addr.respond_to?(:ipv4?) && addr.ipv4? && addr.respond_to?(:ipv4_private?) + addr.ipv4_private? + else + ip.private? + end +end + +# Determine whether the address is the unspecified (all zeros) address. +# +# @param ip [IPAddr] +# @return [Boolean] +def unspecified_address?(ip) + (ip.ipv4? || ip.ipv6?) && ip.to_i.zero? +end + # Determine the current application version using ``git describe`` when # available. # @@ -467,8 +579,14 @@ def log_instance_domain_resolution "Instance domain configured from INSTANCE_DOMAIN environment variable: #{INSTANCE_DOMAIN.inspect}" when :reverse_dns "Instance domain resolved via reverse DNS lookup: #{INSTANCE_DOMAIN.inspect}" + when :public_ip + "Instance domain resolved using public IP address: #{INSTANCE_DOMAIN.inspect}" + when :protected_ip + "Instance domain resolved using protected network IP address: #{INSTANCE_DOMAIN.inspect}" + when :local_ip + "Instance domain defaulted to local IP address: #{INSTANCE_DOMAIN.inspect}" else - "Instance domain could not be determined from the environment or reverse DNS." + "Instance domain could not be determined from the environment or local network." end debug_log(message) @@ -509,7 +627,7 @@ end # # @return [String] def discover_local_ip_address - candidates = Socket.ip_address_list.select { |addr| addr.respond_to?(:ip?) && addr.ip? } + candidates = ip_address_candidates ipv4 = candidates.find do |addr| addr.respond_to?(:ipv4?) && addr.ipv4? && !(addr.respond_to?(:ipv4_loopback?) && addr.ipv4_loopback?) diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 0db0eaf..60b9bd4 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -273,39 +273,47 @@ RSpec.describe "Potato Mesh Sinatra app" do end it "falls back to reverse DNS when available" do - address = instance_double( - Addrinfo, - ip?: true, - ipv4_loopback?: false, - ipv6_loopback?: false, - ipv6_linklocal?: false, - ip_address: "203.0.113.10", - ) + address = Addrinfo.ip("203.0.113.10") allow(Socket).to receive(:ip_address_list).and_return([address]) - allow(Resolv).to receive(:getname).with("203.0.113.10").and_return("federation.example.com") + allow(Resolv).to receive(:getname).with("203.0.113.10").and_return("chara.htznr.fault.dev") domain, source = determine_instance_domain - expect(domain).to eq("federation.example.com") + expect(domain).to eq("chara.htznr.fault.dev") expect(source).to eq(:reverse_dns) end - it "returns nil when no sources provide a domain" do - address = instance_double( - Addrinfo, - ip?: true, - ipv4_loopback?: false, - ipv6_loopback?: false, - ipv6_linklocal?: false, - ip_address: "198.51.100.12", - ) - allow(Socket).to receive(:ip_address_list).and_return([address]) - allow(Resolv).to receive(:getname).with("198.51.100.12").and_raise(Resolv::ResolvError) + it "falls back to a public IP address when reverse DNS is unavailable" do + public_address = Addrinfo.ip("203.0.113.20") + allow(Socket).to receive(:ip_address_list).and_return([public_address]) + allow(Resolv).to receive(:getname).and_raise(Resolv::ResolvError) domain, source = determine_instance_domain - expect(domain).to be_nil - expect(source).to eq(:unknown) + expect(domain).to eq("203.0.113.20") + expect(source).to eq(:public_ip) + end + + it "falls back to a protected IP address when only private networks exist" do + private_address = Addrinfo.ip("10.0.0.5") + allow(Socket).to receive(:ip_address_list).and_return([private_address]) + allow(Resolv).to receive(:getname).and_raise(Resolv::ResolvError) + + domain, source = determine_instance_domain + + expect(domain).to eq("10.0.0.5") + expect(source).to eq(:protected_ip) + end + + it "falls back to a local IP address when no other sources are available" do + loopback_address = Addrinfo.ip("127.0.0.1") + allow(Socket).to receive(:ip_address_list).and_return([loopback_address]) + allow(Resolv).to receive(:getname).and_raise(Resolv::ResolvError) + + domain, source = determine_instance_domain + + expect(domain).to eq("127.0.0.1") + expect(source).to eq(:local_ip) end end