Module: Lich::Common::BindHostResolver

Defined in:
documented/common/bind_host_resolver.rb

Overview

Resolves keyword bind hosts (+tailscale+, lan, any) into concrete local addresses so users never have to discover their own interface IPs. Non-keyword hosts pass through unchanged, gaining only an advisory warning when they look reachable from untrusted networks.

This is the single bind-token vocabulary for every listener Lich opens -- the frontend socket, the --game proxy, and the detachable client all route their --bind-address / host tokens through here. Each of those is an unauthenticated plaintext socket that can drive the game session, so every resolution away from loopback carries a warning for the caller to surface to the user.

Examples:

Resolve the machine's Tailscale address

Lich::Common::BindHostResolver.resolve('tailscale').host #=> "100.101.102.103"

Since:

  • 5.18.0

Defined Under Namespace

Classes: Resolution

Constant Summary collapse

Error =

Raised when a keyword host cannot be resolved to a usable address.

Since:

  • 5.18.0

Class.new(StandardError)
TAILSCALE_RANGE =

Tailscale assigns every node an address from the CGNAT range.

Since:

  • 5.18.0

IPAddr.new('100.64.0.0/10').freeze
PRIVATE_RANGES =

RFC1918 ranges in preference order for lan: household routers hand out 192.168/16 and 10/8, while Docker bridges, WSL adapters, and VM host-only interfaces typically squat on 172.16/12.

Since:

  • 5.18.0

[
  IPAddr.new('192.168.0.0/16').freeze,
  IPAddr.new('10.0.0.0/8').freeze,
  IPAddr.new('172.16.0.0/12').freeze
].freeze
ANY_WARNING =

Since:

  • 5.18.0

"binding 0.0.0.0 exposes Lich's unauthenticated listen sockets " \
'on every network this machine is connected to; anyone who ' \
'can reach them can control the session'

Class Method Summary collapse

Class Method Details

.default_route_addressObject

Learns the source address the OS would pick for outbound traffic by connect()ing a UDP socket to a public IP - no packet is actually sent. This selects the interface holding the default route, which is the address other LAN devices can reach, unlike a naive first-interface scan that may land on a Docker bridge or VM adapter.

Since:

  • 5.18.0



122
123
124
125
126
127
128
129
# File 'documented/common/bind_host_resolver.rb', line 122

def self.default_route_address
  UDPSocket.open do |socket|
    socket.connect('8.8.8.8', 53)
    socket.addr[3]
  end
rescue StandardError
  nil
end

.ipv4_addresses(address_list) ⇒ Array<String>

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Extracts and deduplicates IPv4 addresses from a list of network interfaces.

Parameters:

  • address_list (Array<Addrinfo>)

    local addresses

Returns:

  • (Array<String>)

    unique IPv4 address strings

Since:

  • 5.18.0



161
162
163
# File 'documented/common/bind_host_resolver.rb', line 161

def self.ipv4_addresses(address_list)
  address_list.select(&:ipv4?).map(&:ip_address).uniq
end

.private_address?(address) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Checks whether an address is in a private (RFC1918 or IPv6 ULA) range.

Parameters:

  • address (String)

    an IP address

Returns:

  • (Boolean)

    true if the address is private, false otherwise or if unparseable

Since:

  • 5.18.0



170
171
172
173
174
# File 'documented/common/bind_host_resolver.rb', line 170

def self.private_address?(address)
  IPAddr.new(address).private?
rescue IPAddr::InvalidAddressError
  false
end

.resolve(token, address_list: Socket.ip_address_list, route_probe: method(:default_route_address)) ⇒ Resolution

Resolves a bind host token to a concrete address.

Parameters:

  • token (String)

    tailscale, lan, any (case-insensitive), an IP address, or a hostname

  • address_list (Array<Addrinfo>) (defaults to: Socket.ip_address_list)

    local addresses (injectable for tests)

  • route_probe (#call) (defaults to: method(:default_route_address))

    returns the default-route source address or nil

Returns:

  • (Resolution)

    the bindable host and an optional user-facing warning

Raises:

  • (Error)

    when a keyword host has no matching local address

Since:

  • 5.18.0



57
58
59
60
61
62
63
64
65
66
67
68
# File 'documented/common/bind_host_resolver.rb', line 57

def self.resolve(token, address_list: Socket.ip_address_list, route_probe: method(:default_route_address))
  case token.to_s.downcase
  when 'tailscale'
    resolve_tailscale(address_list)
  when 'lan'
    resolve_lan(address_list, route_probe)
  when 'any'
    Resolution.new(host: '0.0.0.0', warning: ANY_WARNING)
  else
    Resolution.new(host: token, warning: warning_for_explicit(token))
  end
end

.resolve_lan(address_list, route_probe) ⇒ Resolution

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Finds the machine's LAN address suitable for local-network binding.

Probes the default route to identify the preferred interface, then searches for a private IPv4 address in RFC1918 ranges (192.168/16, 10/8, 172.16/12) in preference order. Returns with a warning about network exposure.

Parameters:

  • address_list (Array<Addrinfo>)

    local addresses to search

  • route_probe (#call)

    callable returning the default-route source address or nil

Returns:

  • (Resolution)

    a private IPv4 address with a user-facing security warning

Raises:

  • (Error)

    if no private IPv4 address is found

Since:

  • 5.18.0



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'documented/common/bind_host_resolver.rb', line 96

def self.resolve_lan(address_list, route_probe)
  candidates = ipv4_addresses(address_list).select { |ip| private_address?(ip) }
  probed = begin
    route_probe.call
  rescue StandardError
    nil
  end
  address = if probed && candidates.include?(probed)
              probed
            else
              PRIVATE_RANGES.lazy.map { |range| candidates.find { |ip| range.include?(ip) } }.find(&:itself)
            end
  raise Error, 'no private (LAN) IPv4 address found on this machine' unless address

  Resolution.new(
    host: address,
    warning: "Lich's listen sockets are unauthenticated; anyone on your network " \
             "can control this session via #{address}. Prefer the tailscale keyword if you use Tailscale"
  )
end

.resolve_tailscale(address_list) ⇒ Resolution

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Finds the machine's Tailscale address from the local interface list.

Parameters:

  • address_list (Array<Addrinfo>)

    local addresses to search

Returns:

  • (Resolution)

    the Tailscale address with no warning

Raises:

  • (Error)

    if no address in the 100.64.0.0/10 range is found

Since:

  • 5.18.0



76
77
78
79
80
81
82
83
# File 'documented/common/bind_host_resolver.rb', line 76

def self.resolve_tailscale(address_list)
  address = ipv4_addresses(address_list).find { |ip| TAILSCALE_RANGE.include?(ip) }
  unless address
    raise Error, "Tailscale doesn't appear to be running on this machine " \
                 '(no 100.64.0.0/10 address found); start Tailscale or use the lan keyword instead'
  end
  Resolution.new(host: address, warning: nil)
end

.warning_for_explicit(token) ⇒ String?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Checks whether an explicitly provided bind address is safe or warns the user.

Returns a warning string if the address looks reachable from untrusted networks. Loopback, private, and Tailscale addresses pass through with no warning. Non-IP hostnames return nil (cannot judge without resolution).

Parameters:

  • token (String)

    an IP address or hostname

Returns:

  • (String, nil)

    a warning message, or nil if safe

Since:

  • 5.18.0



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'documented/common/bind_host_resolver.rb', line 140

def self.warning_for_explicit(token)
  ip = IPAddr.new(token.to_s)
  if ip.ipv4?
    return ANY_WARNING if ip == IPAddr.new('0.0.0.0')
    return nil if ip.loopback? || ip.private? || TAILSCALE_RANGE.include?(ip)
  else
    return ANY_WARNING if ip == IPAddr.new('::')
    return nil if ip.loopback? || ip.private? || ip.link_local?
  end
  "#{token} is not a private address; Lich's unauthenticated listen sockets " \
    'may be reachable from untrusted networks'
rescue IPAddr::InvalidAddressError
  # A hostname - nothing to judge without resolving it here.
  nil
end