171 lines
6.2 KiB
GDScript
171 lines
6.2 KiB
GDScript
@tool
|
|
class_name McpAllowHosts
|
|
extends RefCounted
|
|
|
|
## Client-side helpers for the `--allow-host` LAN opt-in (#507, server core
|
|
## in #421). Pure static functions only — no EditorSettings, no sockets —
|
|
## so the settings-value → launch-args plumbing and the manual-command LAN
|
|
## URL builder are deterministically testable without a live editor.
|
|
##
|
|
## Accepted syntax mirrors the server's `parse_allow_hosts`
|
|
## (src/godot_ai/transport/origin_guard.py): each token is a bare IP
|
|
## (IPv4 or IPv6) or a CIDR, comma-separated. Host bits set on a CIDR are
|
|
## tolerated server-side (`strict=False`), so we only validate the IP part
|
|
## and the prefix length here — anything else fails loudly at server
|
|
## startup, which the dock-side validation exists to pre-empt.
|
|
|
|
|
|
## Canonicalize a comma-separated allow-host value: whitespace-stripped,
|
|
## deduplicated, sorted. Returns "" for a value with no usable tokens so
|
|
## callers can skip appending `--allow-host` entirely (keeps spawns
|
|
## compatible with pre-#421 servers — same contract as
|
|
## `ClientConfigurator.excluded_domains()`).
|
|
static func normalize(raw: String) -> String:
|
|
var parts := PackedStringArray()
|
|
for p in raw.split(","):
|
|
var t := p.strip_edges()
|
|
if not t.is_empty() and parts.find(t) == -1:
|
|
parts.append(t)
|
|
parts.sort()
|
|
return ",".join(parts)
|
|
|
|
|
|
## Whether a single token is a bare IP or a CIDR the server will accept.
|
|
static func token_is_valid(token: String) -> bool:
|
|
var t := token.strip_edges()
|
|
if t.is_empty():
|
|
return false
|
|
var ip := t
|
|
var prefix := 0
|
|
## Track slash presence separately from the prefix value: reusing -1 as
|
|
## the "no slash" sentinel let an explicit negative prefix like
|
|
## "10.0.0.0/-1" validate (is_valid_int accepts "-1"). CodeRabbit review.
|
|
var has_prefix := false
|
|
var slash := t.find("/")
|
|
if slash != -1:
|
|
ip = t.substr(0, slash)
|
|
var prefix_text := t.substr(slash + 1)
|
|
## Explicit signs are rejected by the server's parse_allow_hosts
|
|
## (ipaddress refuses "10.0.0.0/+8"), but is_valid_int accepts
|
|
## them — keep the mirror honest.
|
|
if prefix_text.is_empty() or prefix_text.begins_with("+") or prefix_text.begins_with("-"):
|
|
return false
|
|
if not prefix_text.is_valid_int():
|
|
return false
|
|
prefix = int(prefix_text)
|
|
has_prefix = true
|
|
if not ip.is_valid_ip_address():
|
|
return false
|
|
var max_prefix := 128 if ip.contains(":") else 32
|
|
return not has_prefix or (prefix >= 0 and prefix <= max_prefix)
|
|
|
|
|
|
## Every token in `raw` that fails `token_is_valid` — the dock surfaces
|
|
## these inline so a typo is caught before it aborts the server spawn.
|
|
static func invalid_tokens(raw: String) -> PackedStringArray:
|
|
var bad := PackedStringArray()
|
|
for p in raw.split(","):
|
|
var t := p.strip_edges()
|
|
if t.is_empty():
|
|
continue
|
|
if not token_is_valid(t) and bad.find(t) == -1:
|
|
bad.append(t)
|
|
return bad
|
|
|
|
|
|
## True when the allowlist names at least one non-loopback range — i.e.
|
|
## the server is actually reachable off this machine and the manual
|
|
## command should surface a LAN URL.
|
|
static func is_lan_allowlist_active(value: String) -> bool:
|
|
for p in value.split(","):
|
|
var t := p.strip_edges()
|
|
if t.is_empty():
|
|
continue
|
|
var ip := t.substr(0, t.find("/")) if t.contains("/") else t
|
|
if not _is_loopback(ip):
|
|
return true
|
|
return false
|
|
|
|
|
|
## Choose the LAN address to show in the manual command from the
|
|
## machine's local addresses (caller passes `IP.get_local_addresses()`
|
|
## so this stays pure). Loopback and link-local addresses are dropped;
|
|
## the first private-range IPv4 wins, then any remaining IPv4, then
|
|
## anything left (IPv6). Returns `{"address": String, "ambiguous": bool}`
|
|
## — `ambiguous` flags multiple viable candidates so the note can tell
|
|
## the user to pick the interface on their trusted network.
|
|
static func pick_lan_address(addresses: PackedStringArray) -> Dictionary:
|
|
var candidates := PackedStringArray()
|
|
for a in addresses:
|
|
var addr := String(a).strip_edges()
|
|
if addr.is_empty() or _is_loopback(addr) or _is_link_local(addr):
|
|
continue
|
|
candidates.append(addr)
|
|
if candidates.is_empty():
|
|
return {"address": "", "ambiguous": false}
|
|
var chosen := ""
|
|
for addr in candidates:
|
|
if _is_private_ipv4(addr):
|
|
chosen = addr
|
|
break
|
|
if chosen.is_empty():
|
|
for addr in candidates:
|
|
if addr.contains("."):
|
|
chosen = addr
|
|
break
|
|
if chosen.is_empty():
|
|
chosen = candidates[0]
|
|
return {"address": chosen, "ambiguous": candidates.size() > 1}
|
|
|
|
|
|
## Informational LAN-URL note appended to the manual command when the
|
|
## allowlist is active (#507). Never changes what gets WRITTEN to client
|
|
## configs — loopback stays the write target; this is copy-paste help for
|
|
## pointing a remote agent at the right address.
|
|
static func lan_url_note(allow_hosts_value: String, addresses: PackedStringArray, http_port: int) -> String:
|
|
if not is_lan_allowlist_active(allow_hosts_value):
|
|
return ""
|
|
var pick := pick_lan_address(addresses)
|
|
var addr := String(pick.get("address", ""))
|
|
if addr.is_empty():
|
|
return (
|
|
"LAN access is enabled (--allow-host %s), but no LAN address was detected on this machine."
|
|
% allow_hosts_value
|
|
)
|
|
var host := "[%s]" % addr if addr.contains(":") else addr
|
|
var note := (
|
|
"LAN access is enabled (--allow-host %s). Remote agents on the allowed network can use: http://%s:%d/mcp"
|
|
% [allow_hosts_value, host, http_port]
|
|
)
|
|
if bool(pick.get("ambiguous", false)):
|
|
note += "\n(multiple network interfaces detected — pick the address on the network you allowed)"
|
|
return note
|
|
|
|
|
|
static func _is_loopback(addr: String) -> bool:
|
|
var a := addr.to_lower()
|
|
return a.begins_with("127.") or a == "::1" or a == "localhost"
|
|
|
|
|
|
static func _is_link_local(addr: String) -> bool:
|
|
var a := addr.to_lower()
|
|
if a.begins_with("169.254."):
|
|
return true
|
|
## IPv6 link-local is fe80::/10 — the whole fe80-febf first hextet, not
|
|
## just literal "fe80" (Copilot review on #507's PR: fea0::... etc. must
|
|
## also be excluded from LAN-URL candidates).
|
|
if a.length() >= 4 and a.begins_with("fe") and a[2] in "89ab":
|
|
return true
|
|
return false
|
|
|
|
|
|
static func _is_private_ipv4(addr: String) -> bool:
|
|
if not addr.contains("."):
|
|
return false
|
|
if addr.begins_with("10.") or addr.begins_with("192.168."):
|
|
return true
|
|
if addr.begins_with("172."):
|
|
var second := int(addr.get_slice(".", 1))
|
|
return second >= 16 and second <= 31
|
|
return false
|