mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-09 14:55:08 +02:00
Compare commits
6 Commits
v0.5.2-rc1
...
v0.5.2-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
| 01665b6e3a | |||
| 1898a99789 | |||
| 3eefda9205 | |||
| a6ba9a8227 | |||
| 7055444c4b | |||
| 4bfc0e25cb |
@@ -78,17 +78,36 @@ def _iter_channel_objects(channels_obj: Any) -> Iterator[Any]:
|
||||
|
||||
|
||||
def _primary_channel_name() -> str | None:
|
||||
"""Return the name to use for the primary channel when available."""
|
||||
"""Return the fallback name to use for the primary channel when needed."""
|
||||
|
||||
preset = getattr(config, "MODEM_PRESET", None)
|
||||
if isinstance(preset, str) and preset.strip():
|
||||
return preset
|
||||
return preset.strip()
|
||||
env_name = os.environ.get("CHANNEL", "").strip()
|
||||
if env_name:
|
||||
return env_name
|
||||
return None
|
||||
|
||||
|
||||
def _extract_channel_name(settings_obj: Any) -> str | None:
|
||||
"""Normalise the configured channel name extracted from ``settings_obj``."""
|
||||
|
||||
if settings_obj is None:
|
||||
return None
|
||||
|
||||
if isinstance(settings_obj, dict):
|
||||
candidate = settings_obj.get("name")
|
||||
else:
|
||||
candidate = getattr(settings_obj, "name", None)
|
||||
|
||||
if isinstance(candidate, str):
|
||||
candidate = candidate.strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_role(role: Any) -> int | None:
|
||||
"""Convert a channel role descriptor into an integer value."""
|
||||
|
||||
@@ -122,27 +141,23 @@ def _channel_tuple(channel_obj: Any) -> tuple[int, str] | None:
|
||||
role_value = _normalize_role(getattr(channel_obj, "role", None))
|
||||
if role_value == _ROLE_PRIMARY:
|
||||
channel_index = 0
|
||||
channel_name = _primary_channel_name()
|
||||
channel_name = _extract_channel_name(getattr(channel_obj, "settings", None))
|
||||
if channel_name is None:
|
||||
channel_name = _primary_channel_name()
|
||||
elif role_value == _ROLE_SECONDARY:
|
||||
raw_index = getattr(channel_obj, "index", None)
|
||||
try:
|
||||
channel_index = int(raw_index)
|
||||
except Exception:
|
||||
channel_index = None
|
||||
settings = getattr(channel_obj, "settings", None)
|
||||
channel_name = getattr(settings, "name", None) if settings else None
|
||||
channel_name = _extract_channel_name(getattr(channel_obj, "settings", None))
|
||||
else:
|
||||
return None
|
||||
|
||||
if not isinstance(channel_index, int):
|
||||
return None
|
||||
|
||||
if isinstance(channel_name, str):
|
||||
channel_name = channel_name.strip()
|
||||
else:
|
||||
channel_name = None
|
||||
|
||||
if not channel_name:
|
||||
if not isinstance(channel_name, str) or not channel_name:
|
||||
return None
|
||||
|
||||
return channel_index, channel_name
|
||||
|
||||
@@ -72,11 +72,25 @@ def _post_json(
|
||||
return
|
||||
url = f"{instance}{path}"
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
# Add full headers to avoid Cloudflare blocks on instances behind cloudflare proxy
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Origin": f"{instance}",
|
||||
"Referer": f"{instance}",
|
||||
}
|
||||
if api_token:
|
||||
req.add_header("Authorization", f"Bearer {api_token}")
|
||||
headers["Authorization"] = f"Bearer {api_token}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
resp.read()
|
||||
|
||||
+31
-4
@@ -407,11 +407,14 @@ def test_capture_channels_from_interface_records_metadata(mesh_module, capsys):
|
||||
mesh = mesh_module
|
||||
|
||||
mesh.config.MODEM_PRESET = "MediumFast"
|
||||
mesh.channels._reset_channel_cache()
|
||||
|
||||
class DummyInterface:
|
||||
def __init__(self) -> None:
|
||||
self.wait_calls = 0
|
||||
primary = SimpleNamespace(role=1, settings=SimpleNamespace())
|
||||
primary = SimpleNamespace(
|
||||
role=1, settings=SimpleNamespace(name=" radioamator ")
|
||||
)
|
||||
secondary = SimpleNamespace(
|
||||
role="SECONDARY",
|
||||
index="7",
|
||||
@@ -428,19 +431,20 @@ def test_capture_channels_from_interface_records_metadata(mesh_module, capsys):
|
||||
log_output = capsys.readouterr().out
|
||||
|
||||
assert iface.wait_calls == 1
|
||||
assert mesh.channels.channel_mappings() == ((0, "MediumFast"), (7, "TestChannel"))
|
||||
assert mesh.channels.channel_mappings() == ((0, "radioamator"), (7, "TestChannel"))
|
||||
assert mesh.channels.channel_name(7) == "TestChannel"
|
||||
assert "Captured channel metadata" in log_output
|
||||
assert "channels=((0, 'MediumFast'), (7, 'TestChannel'))" in log_output
|
||||
assert "channels=((0, 'radioamator'), (7, 'TestChannel'))" in log_output
|
||||
|
||||
mesh.channels.capture_from_interface(SimpleNamespace(localNode=None))
|
||||
assert mesh.channels.channel_mappings() == ((0, "MediumFast"), (7, "TestChannel"))
|
||||
assert mesh.channels.channel_mappings() == ((0, "radioamator"), (7, "TestChannel"))
|
||||
|
||||
|
||||
def test_capture_channels_primary_falls_back_to_env(mesh_module, monkeypatch, capsys):
|
||||
mesh = mesh_module
|
||||
|
||||
mesh.config.MODEM_PRESET = None
|
||||
mesh.channels._reset_channel_cache()
|
||||
monkeypatch.setenv("CHANNEL", "FallbackName")
|
||||
|
||||
class DummyInterface:
|
||||
@@ -461,6 +465,29 @@ def test_capture_channels_primary_falls_back_to_env(mesh_module, monkeypatch, ca
|
||||
assert "FallbackName" in log_output
|
||||
|
||||
|
||||
def test_capture_channels_primary_falls_back_to_preset(mesh_module, capsys):
|
||||
mesh = mesh_module
|
||||
|
||||
mesh.config.MODEM_PRESET = " MediumFast "
|
||||
mesh.channels._reset_channel_cache()
|
||||
|
||||
class DummyInterface:
|
||||
def __init__(self) -> None:
|
||||
self.localNode = SimpleNamespace(
|
||||
channels=[SimpleNamespace(role="PRIMARY", settings=SimpleNamespace())]
|
||||
)
|
||||
|
||||
def waitForConfig(self) -> None: # noqa: D401 - matches interface contract
|
||||
return None
|
||||
|
||||
mesh.channels.capture_from_interface(DummyInterface())
|
||||
log_output = capsys.readouterr().out
|
||||
|
||||
assert mesh.channels.channel_mappings() == ((0, "MediumFast"),)
|
||||
assert mesh.channels.channel_name(0) == "MediumFast"
|
||||
assert "MediumFast" in log_output
|
||||
|
||||
|
||||
def test_create_default_interface_falls_back_to_tcp(mesh_module, monkeypatch):
|
||||
mesh = mesh_module
|
||||
attempts = []
|
||||
|
||||
@@ -273,12 +273,17 @@ module PotatoMesh
|
||||
thread
|
||||
end
|
||||
|
||||
# Launch a background thread responsible for the first federation broadcast.
|
||||
#
|
||||
# @return [Thread, nil] the thread handling the initial announcement.
|
||||
def start_initial_federation_announcement!
|
||||
existing = settings.respond_to?(:initial_federation_thread) ? settings.initial_federation_thread : nil
|
||||
return existing if existing&.alive?
|
||||
|
||||
thread = Thread.new do
|
||||
begin
|
||||
delay = PotatoMesh::Config.initial_federation_delay_seconds
|
||||
Kernel.sleep(delay) if delay.positive?
|
||||
announce_instance_to_all_domains
|
||||
rescue StandardError => e
|
||||
warn_log(
|
||||
@@ -445,12 +450,34 @@ module PotatoMesh
|
||||
# @param db [SQLite3::Database] open database connection used for writes.
|
||||
# @param domain [String] remote domain to crawl for federation records.
|
||||
# @param visited [Set<String>] domains processed during this crawl.
|
||||
# @param per_response_limit [Integer, nil] maximum entries processed per response.
|
||||
# @param overall_limit [Integer, nil] maximum unique domains visited.
|
||||
# @return [Set<String>] updated set of visited domains.
|
||||
def ingest_known_instances_from!(db, domain, visited: nil)
|
||||
def ingest_known_instances_from!(
|
||||
db,
|
||||
domain,
|
||||
visited: nil,
|
||||
per_response_limit: nil,
|
||||
overall_limit: nil
|
||||
)
|
||||
sanitized = sanitize_instance_domain(domain)
|
||||
return visited || Set.new unless sanitized
|
||||
|
||||
visited ||= Set.new
|
||||
|
||||
overall_limit ||= PotatoMesh::Config.federation_max_domains_per_crawl
|
||||
per_response_limit ||= PotatoMesh::Config.federation_max_instances_per_response
|
||||
|
||||
if overall_limit && overall_limit.positive? && visited.size >= overall_limit
|
||||
debug_log(
|
||||
"Skipped remote instance crawl due to crawl limit",
|
||||
context: "federation.instances",
|
||||
domain: sanitized,
|
||||
limit: overall_limit,
|
||||
)
|
||||
return visited
|
||||
end
|
||||
|
||||
return visited if visited.include?(sanitized)
|
||||
|
||||
visited << sanitized
|
||||
@@ -466,7 +493,29 @@ module PotatoMesh
|
||||
return visited
|
||||
end
|
||||
|
||||
processed_entries = 0
|
||||
payload.each do |entry|
|
||||
if per_response_limit && per_response_limit.positive? && processed_entries >= per_response_limit
|
||||
debug_log(
|
||||
"Skipped remote instance entry due to response limit",
|
||||
context: "federation.instances",
|
||||
domain: sanitized,
|
||||
limit: per_response_limit,
|
||||
)
|
||||
break
|
||||
end
|
||||
|
||||
if overall_limit && overall_limit.positive? && visited.size >= overall_limit
|
||||
debug_log(
|
||||
"Skipped remote instance entry due to crawl limit",
|
||||
context: "federation.instances",
|
||||
domain: sanitized,
|
||||
limit: overall_limit,
|
||||
)
|
||||
break
|
||||
end
|
||||
|
||||
processed_entries += 1
|
||||
attributes, signature, reason = remote_instance_attributes_from_payload(entry)
|
||||
unless attributes && signature
|
||||
warn_log(
|
||||
@@ -523,7 +572,13 @@ module PotatoMesh
|
||||
|
||||
begin
|
||||
upsert_instance_record(db, attributes, signature)
|
||||
ingest_known_instances_from!(db, attributes[:domain], visited: visited)
|
||||
ingest_known_instances_from!(
|
||||
db,
|
||||
attributes[:domain],
|
||||
visited: visited,
|
||||
per_response_limit: per_response_limit,
|
||||
overall_limit: overall_limit,
|
||||
)
|
||||
rescue ArgumentError => e
|
||||
warn_log(
|
||||
"Failed to persist remote instance",
|
||||
@@ -538,12 +593,52 @@ module PotatoMesh
|
||||
visited
|
||||
end
|
||||
|
||||
# Resolve the host component of a remote URI and ensure the destination is
|
||||
# safe for federation HTTP requests.
|
||||
#
|
||||
# The method performs a DNS lookup using Addrinfo to capture every
|
||||
# available address for the supplied URI host. The resulting addresses are
|
||||
# converted to {IPAddr} objects for consistent inspection via
|
||||
# {restricted_ip_address?}. When all resolved addresses fall within
|
||||
# restricted ranges, the method raises an ArgumentError so callers can
|
||||
# abort the federation request before contacting the remote endpoint.
|
||||
#
|
||||
# @param uri [URI::Generic] remote endpoint candidate.
|
||||
# @return [Array<IPAddr>] list of resolved, unrestricted IP addresses.
|
||||
# @raise [ArgumentError] when +uri.host+ is blank or resolves solely to
|
||||
# restricted addresses.
|
||||
def resolve_remote_ip_addresses(uri)
|
||||
host = uri&.host
|
||||
raise ArgumentError, "URI missing host" unless host
|
||||
|
||||
addrinfo_records = Addrinfo.getaddrinfo(host, nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM)
|
||||
addresses = addrinfo_records.filter_map do |addr|
|
||||
begin
|
||||
IPAddr.new(addr.ip_address)
|
||||
rescue IPAddr::InvalidAddressError
|
||||
nil
|
||||
end
|
||||
end
|
||||
unique_addresses = addresses.uniq { |ip| [ip.family, ip.to_s] }
|
||||
unrestricted_addresses = unique_addresses.reject { |ip| restricted_ip_address?(ip) }
|
||||
|
||||
if unique_addresses.any? && unrestricted_addresses.empty?
|
||||
raise ArgumentError, "restricted domain"
|
||||
end
|
||||
|
||||
unrestricted_addresses
|
||||
end
|
||||
|
||||
# Build an HTTP client configured for communication with a remote instance.
|
||||
#
|
||||
# @param uri [URI::Generic] target URI describing the remote endpoint.
|
||||
# @return [Net::HTTP] HTTP client ready to execute the request.
|
||||
def build_remote_http_client(uri)
|
||||
remote_addresses = resolve_remote_ip_addresses(uri)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
if http.respond_to?(:ipaddr=) && remote_addresses.any?
|
||||
http.ipaddr = remote_addresses.first.to_s
|
||||
end
|
||||
http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout
|
||||
http.read_timeout = PotatoMesh::Config.remote_instance_read_timeout
|
||||
http.use_ssl = uri.scheme == "https"
|
||||
|
||||
@@ -195,24 +195,31 @@ module PotatoMesh
|
||||
[json_output, signature]
|
||||
end
|
||||
|
||||
# Regenerate the well-known document when the on-disk copy is stale.
|
||||
# Regenerate the well-known document when it is stale or when the existing
|
||||
# content no longer matches the current instance configuration.
|
||||
#
|
||||
# @return [void]
|
||||
def refresh_well_known_document_if_stale
|
||||
FileUtils.mkdir_p(well_known_directory)
|
||||
path = well_known_file_path
|
||||
now = Time.now
|
||||
json_output, signature = build_well_known_document
|
||||
expected_contents = json_output.end_with?("\n") ? json_output : "#{json_output}\n"
|
||||
|
||||
needs_update = true
|
||||
if File.exist?(path)
|
||||
current_contents = File.binread(path)
|
||||
mtime = File.mtime(path)
|
||||
if (now - mtime) < PotatoMesh::Config.well_known_refresh_interval
|
||||
return
|
||||
if current_contents == expected_contents &&
|
||||
(now - mtime) < PotatoMesh::Config.well_known_refresh_interval
|
||||
needs_update = false
|
||||
end
|
||||
end
|
||||
|
||||
json_output, signature = build_well_known_document
|
||||
return unless needs_update
|
||||
|
||||
File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |file|
|
||||
file.write(json_output)
|
||||
file.write("\n") unless json_output.end_with?("\n")
|
||||
file.write(expected_contents)
|
||||
end
|
||||
|
||||
debug_log(
|
||||
|
||||
@@ -173,6 +173,22 @@ module PotatoMesh
|
||||
halt 400, { error: "restricted domain" }.to_json
|
||||
end
|
||||
|
||||
begin
|
||||
resolve_remote_ip_addresses(URI.parse("https://#{attributes[:domain]}"))
|
||||
rescue ArgumentError => e
|
||||
warn_log(
|
||||
"Instance registration rejected",
|
||||
context: "ingest.register",
|
||||
domain: attributes[:domain],
|
||||
reason: "restricted domain",
|
||||
error_message: e.message,
|
||||
)
|
||||
halt 400, { error: "restricted domain" }.to_json
|
||||
rescue SocketError
|
||||
# DNS lookups that fail to resolve are handled later when the
|
||||
# registration flow attempts to contact the remote instance.
|
||||
end
|
||||
|
||||
well_known, well_known_meta = fetch_instance_json(attributes[:domain], "/.well-known/potato-mesh")
|
||||
unless well_known
|
||||
details_list = Array(well_known_meta).map(&:to_s)
|
||||
@@ -225,7 +241,12 @@ module PotatoMesh
|
||||
|
||||
db = open_database
|
||||
upsert_instance_record(db, attributes, signature)
|
||||
ingest_known_instances_from!(db, attributes[:domain])
|
||||
ingest_known_instances_from!(
|
||||
db,
|
||||
attributes[:domain],
|
||||
per_response_limit: PotatoMesh::Config.federation_max_instances_per_response,
|
||||
overall_limit: PotatoMesh::Config.federation_max_domains_per_crawl,
|
||||
)
|
||||
debug_log(
|
||||
"Registered remote instance",
|
||||
context: "ingest.register",
|
||||
|
||||
@@ -34,6 +34,9 @@ module PotatoMesh
|
||||
DEFAULT_MAX_DISTANCE_KM = 42.0
|
||||
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT = 5
|
||||
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT = 12
|
||||
DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE = 64
|
||||
DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL = 256
|
||||
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2
|
||||
|
||||
# Resolve the absolute path to the web application root directory.
|
||||
#
|
||||
@@ -285,6 +288,26 @@ module PotatoMesh
|
||||
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT
|
||||
end
|
||||
|
||||
# Limit the number of remote instances processed from a single response.
|
||||
#
|
||||
# @return [Integer] maximum entries processed per /api/instances payload.
|
||||
def federation_max_instances_per_response
|
||||
fetch_positive_integer(
|
||||
"FEDERATION_MAX_INSTANCES_PER_RESPONSE",
|
||||
DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE,
|
||||
)
|
||||
end
|
||||
|
||||
# Limit the total number of distinct domains crawled during one ingestion.
|
||||
#
|
||||
# @return [Integer] maximum unique domains visited per crawl.
|
||||
def federation_max_domains_per_crawl
|
||||
fetch_positive_integer(
|
||||
"FEDERATION_MAX_DOMAINS_PER_CRAWL",
|
||||
DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL,
|
||||
)
|
||||
end
|
||||
|
||||
# Maximum acceptable age for remote node data.
|
||||
#
|
||||
# @return [Integer] seconds before remote nodes are considered stale.
|
||||
@@ -313,6 +336,16 @@ module PotatoMesh
|
||||
8 * 60 * 60
|
||||
end
|
||||
|
||||
# Determine the grace period before sending the initial federation announcement.
|
||||
#
|
||||
# @return [Integer] seconds to wait before the first broadcast cycle.
|
||||
def initial_federation_delay_seconds
|
||||
fetch_positive_integer(
|
||||
"INITIAL_FEDERATION_DELAY_SECONDS",
|
||||
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS,
|
||||
)
|
||||
end
|
||||
|
||||
# Retrieve the configured site name for presentation.
|
||||
#
|
||||
# @return [String] human friendly site label.
|
||||
@@ -424,6 +457,27 @@ module PotatoMesh
|
||||
trimmed.empty? ? default : trimmed
|
||||
end
|
||||
|
||||
# Fetch and validate integer based configuration flags.
|
||||
#
|
||||
# @param key [String] environment variable to read.
|
||||
# @param default [Integer] fallback value when unset or invalid.
|
||||
# @return [Integer] positive integer sourced from configuration.
|
||||
def fetch_positive_integer(key, default)
|
||||
value = ENV[key]
|
||||
return default if value.nil?
|
||||
|
||||
trimmed = value.strip
|
||||
return default if trimmed.empty?
|
||||
|
||||
begin
|
||||
parsed = Integer(trimmed, 10)
|
||||
rescue ArgumentError
|
||||
return default
|
||||
end
|
||||
|
||||
parsed.positive? ? parsed : default
|
||||
end
|
||||
|
||||
# Resolve the effective XDG directory honoring environment overrides.
|
||||
#
|
||||
# @param env_key [String] name of the environment variable to inspect.
|
||||
|
||||
+44
-1
@@ -20,6 +20,7 @@ require "json"
|
||||
require "time"
|
||||
require "base64"
|
||||
require "uri"
|
||||
require "socket"
|
||||
|
||||
RSpec.describe "Potato Mesh Sinatra app" do
|
||||
let(:app) { Sinatra::Application }
|
||||
@@ -248,7 +249,10 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
|
||||
it "stores and clears the initial federation thread" do
|
||||
allow(app).to receive(:announce_instance_to_all_domains)
|
||||
delay = 3
|
||||
allow(PotatoMesh::Config).to receive(:initial_federation_delay_seconds).and_return(delay)
|
||||
expect(Kernel).to receive(:sleep).with(delay)
|
||||
expect(app).to receive(:announce_instance_to_all_domains)
|
||||
allow(Thread).to receive(:new) do |&block|
|
||||
dummy_thread.block = block
|
||||
dummy_thread
|
||||
@@ -1255,6 +1259,45 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects registrations when DNS resolves to restricted addresses" do
|
||||
restricted_addrinfo = Addrinfo.ip("127.0.0.1")
|
||||
allow(Addrinfo).to receive(:getaddrinfo).and_return([restricted_addrinfo])
|
||||
|
||||
warning_calls = []
|
||||
allow_any_instance_of(Sinatra::Application).to receive(:warn_log).and_wrap_original do |method, *args, **kwargs|
|
||||
warning_calls << [args, kwargs]
|
||||
method.call(*args, **kwargs)
|
||||
end
|
||||
|
||||
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do
|
||||
raise "fetch_instance_json should not be called for restricted domains"
|
||||
end
|
||||
|
||||
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(400)
|
||||
expect(JSON.parse(last_response.body)).to eq("error" => "restricted domain")
|
||||
|
||||
expect(warning_calls).to include(
|
||||
[
|
||||
["Instance registration rejected"],
|
||||
hash_including(
|
||||
context: "ingest.register",
|
||||
domain: domain,
|
||||
reason: "restricted domain",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
stored = db.get_first_value(
|
||||
"SELECT COUNT(*) FROM instances WHERE id = ?",
|
||||
[instance_attributes[:id]],
|
||||
)
|
||||
expect(stored).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts bracketed IPv6 domains" do
|
||||
ipv6_domain = "[2001:db8::1]"
|
||||
ipv6_attributes = instance_attributes.merge(domain: ipv6_domain)
|
||||
|
||||
@@ -169,6 +169,54 @@ RSpec.describe PotatoMesh::Config do
|
||||
end
|
||||
end
|
||||
|
||||
describe ".federation_max_instances_per_response" do
|
||||
it "returns the baked-in response limit when unset" do
|
||||
within_env("FEDERATION_MAX_INSTANCES_PER_RESPONSE" => nil) do
|
||||
expect(described_class.federation_max_instances_per_response).to eq(
|
||||
PotatoMesh::Config::DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts positive overrides" do
|
||||
within_env("FEDERATION_MAX_INSTANCES_PER_RESPONSE" => "7") do
|
||||
expect(described_class.federation_max_instances_per_response).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects non-positive overrides" do
|
||||
within_env("FEDERATION_MAX_INSTANCES_PER_RESPONSE" => "0") do
|
||||
expect(described_class.federation_max_instances_per_response).to eq(
|
||||
PotatoMesh::Config::DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".federation_max_domains_per_crawl" do
|
||||
it "returns the baked-in crawl limit when unset" do
|
||||
within_env("FEDERATION_MAX_DOMAINS_PER_CRAWL" => nil) do
|
||||
expect(described_class.federation_max_domains_per_crawl).to eq(
|
||||
PotatoMesh::Config::DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts positive overrides" do
|
||||
within_env("FEDERATION_MAX_DOMAINS_PER_CRAWL" => "11") do
|
||||
expect(described_class.federation_max_domains_per_crawl).to eq(11)
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects invalid overrides" do
|
||||
within_env("FEDERATION_MAX_DOMAINS_PER_CRAWL" => "-5") do
|
||||
expect(described_class.federation_max_domains_per_crawl).to eq(
|
||||
PotatoMesh::Config::DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".db_path" do
|
||||
it "returns the default path inside the data directory" do
|
||||
expect(described_class.db_path).to eq(described_class.default_db_path)
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
require "spec_helper"
|
||||
require "net/http"
|
||||
require "openssl"
|
||||
require "set"
|
||||
require "uri"
|
||||
require "socket"
|
||||
|
||||
RSpec.describe PotatoMesh::App::Federation do
|
||||
subject(:federation_helpers) do
|
||||
@@ -128,10 +130,12 @@ RSpec.describe PotatoMesh::App::Federation do
|
||||
describe ".build_remote_http_client" do
|
||||
let(:connect_timeout) { 5 }
|
||||
let(:read_timeout) { 12 }
|
||||
let(:public_addrinfo) { Addrinfo.ip("203.0.113.5") }
|
||||
|
||||
before do
|
||||
allow(PotatoMesh::Config).to receive(:remote_instance_http_timeout).and_return(connect_timeout)
|
||||
allow(PotatoMesh::Config).to receive(:remote_instance_read_timeout).and_return(read_timeout)
|
||||
allow(Addrinfo).to receive(:getaddrinfo).and_return([public_addrinfo])
|
||||
end
|
||||
|
||||
it "configures SSL settings for HTTPS endpoints" do
|
||||
@@ -175,6 +179,123 @@ RSpec.describe PotatoMesh::App::Federation do
|
||||
expect(http.cert_store).to be_nil
|
||||
expect(http.verify_callback).to be_nil
|
||||
end
|
||||
|
||||
it "rejects URIs that resolve exclusively to restricted addresses" do
|
||||
uri = URI.parse("https://loopback.mesh/api")
|
||||
allow(Addrinfo).to receive(:getaddrinfo).and_return([Addrinfo.ip("127.0.0.1")])
|
||||
|
||||
expect do
|
||||
federation_helpers.build_remote_http_client(uri)
|
||||
end.to raise_error(ArgumentError, "restricted domain")
|
||||
end
|
||||
|
||||
it "binds the HTTP client to the first unrestricted address" do
|
||||
uri = URI.parse("https://remote.example.com/api")
|
||||
allow(Addrinfo).to receive(:getaddrinfo).and_return([
|
||||
Addrinfo.ip("127.0.0.1"),
|
||||
public_addrinfo,
|
||||
Addrinfo.ip("10.0.0.3"),
|
||||
])
|
||||
|
||||
http = federation_helpers.build_remote_http_client(uri)
|
||||
|
||||
if http.respond_to?(:ipaddr)
|
||||
expect(http.ipaddr).to eq("203.0.113.5")
|
||||
else
|
||||
skip "Net::HTTP#ipaddr accessor unavailable"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".ingest_known_instances_from!" do
|
||||
let(:db) { double(:db) }
|
||||
let(:seed_domain) { "seed.mesh" }
|
||||
let(:payload_entries) do
|
||||
Array.new(3) do |index|
|
||||
{
|
||||
"id" => "remote-#{index}",
|
||||
"domain" => "ally-#{index}.mesh",
|
||||
"pubkey" => "ignored-pubkey-#{index}",
|
||||
"signature" => "ignored-signature-#{index}",
|
||||
}
|
||||
end
|
||||
end
|
||||
let(:attributes_list) do
|
||||
payload_entries.map do |entry|
|
||||
{
|
||||
id: entry["id"],
|
||||
domain: entry["domain"],
|
||||
pubkey: entry["pubkey"],
|
||||
name: nil,
|
||||
version: nil,
|
||||
channel: nil,
|
||||
frequency: nil,
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
last_update_time: nil,
|
||||
is_private: false,
|
||||
}
|
||||
end
|
||||
end
|
||||
let(:node_payload) do
|
||||
Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
|
||||
{ "node_id" => "node-#{index}", "last_heard" => Time.now.to_i - index }
|
||||
end
|
||||
end
|
||||
let(:response_map) do
|
||||
mapping = { [seed_domain, "/api/instances"] => [payload_entries, :instances] }
|
||||
attributes_list.each do |attributes|
|
||||
mapping[[attributes[:domain], "/api/nodes"]] = [node_payload, :nodes]
|
||||
mapping[[attributes[:domain], "/api/instances"]] = [[], :instances]
|
||||
end
|
||||
mapping
|
||||
end
|
||||
|
||||
before do
|
||||
allow(federation_helpers).to receive(:fetch_instance_json) do |host, path|
|
||||
response_map.fetch([host, path]) { [nil, []] }
|
||||
end
|
||||
allow(federation_helpers).to receive(:verify_instance_signature).and_return(true)
|
||||
allow(federation_helpers).to receive(:validate_remote_nodes).and_return([true, nil])
|
||||
payload_entries.each_with_index do |entry, index|
|
||||
allow(federation_helpers).to receive(:remote_instance_attributes_from_payload).with(entry).and_return([attributes_list[index], "signature-#{index}", nil])
|
||||
end
|
||||
end
|
||||
|
||||
it "stops processing once the per-response limit is exceeded" do
|
||||
processed_domains = []
|
||||
allow(federation_helpers).to receive(:upsert_instance_record) do |_db, attrs, _signature|
|
||||
processed_domains << attrs[:domain]
|
||||
end
|
||||
allow(PotatoMesh::Config).to receive(:federation_max_instances_per_response).and_return(2)
|
||||
allow(PotatoMesh::Config).to receive(:federation_max_domains_per_crawl).and_return(10)
|
||||
|
||||
visited = federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
||||
|
||||
expect(processed_domains).to eq([
|
||||
attributes_list[0][:domain],
|
||||
attributes_list[1][:domain],
|
||||
])
|
||||
expect(visited).to include(seed_domain, attributes_list[0][:domain], attributes_list[1][:domain])
|
||||
expect(visited).not_to include(attributes_list[2][:domain])
|
||||
expect(federation_helpers.debug_messages).to include(a_string_including("response limit"))
|
||||
end
|
||||
|
||||
it "halts recursion once the crawl limit would be exceeded" do
|
||||
processed_domains = []
|
||||
allow(federation_helpers).to receive(:upsert_instance_record) do |_db, attrs, _signature|
|
||||
processed_domains << attrs[:domain]
|
||||
end
|
||||
allow(PotatoMesh::Config).to receive(:federation_max_instances_per_response).and_return(5)
|
||||
allow(PotatoMesh::Config).to receive(:federation_max_domains_per_crawl).and_return(2)
|
||||
|
||||
visited = federation_helpers.ingest_known_instances_from!(db, seed_domain)
|
||||
|
||||
expect(processed_domains).to eq([attributes_list.first[:domain]])
|
||||
expect(visited).to include(seed_domain, attributes_list.first[:domain])
|
||||
expect(visited).not_to include(attributes_list[1][:domain], attributes_list[2][:domain])
|
||||
expect(federation_helpers.debug_messages).to include(a_string_including("crawl limit"))
|
||||
end
|
||||
end
|
||||
|
||||
describe ".perform_instance_http_request" do
|
||||
@@ -220,6 +341,15 @@ RSpec.describe PotatoMesh::App::Federation do
|
||||
federation_helpers.send(:perform_instance_http_request, uri)
|
||||
end.to raise_error(PotatoMesh::App::InstanceFetchError, "Net::ReadTimeout")
|
||||
end
|
||||
|
||||
it "wraps restricted address resolution failures" do
|
||||
allow(federation_helpers).to receive(:build_remote_http_client).and_call_original
|
||||
allow(Addrinfo).to receive(:getaddrinfo).and_return([Addrinfo.ip("127.0.0.1")])
|
||||
|
||||
expect do
|
||||
federation_helpers.send(:perform_instance_http_request, uri)
|
||||
end.to raise_error(PotatoMesh::App::InstanceFetchError, "ArgumentError: restricted domain")
|
||||
end
|
||||
end
|
||||
|
||||
describe ".announce_instance_to_domain" do
|
||||
|
||||
@@ -64,4 +64,59 @@ RSpec.describe PotatoMesh::App::Identity do
|
||||
allow(PotatoMesh::Config).to receive(:legacy_keyfile_candidates).and_call_original
|
||||
end
|
||||
end
|
||||
|
||||
describe ".refresh_well_known_document_if_stale" do
|
||||
let(:storage_dir) { Dir.mktmpdir }
|
||||
let(:well_known_path) do
|
||||
File.join(storage_dir, File.basename(PotatoMesh::Config.well_known_relative_path))
|
||||
end
|
||||
|
||||
before do
|
||||
allow(PotatoMesh::Config).to receive(:well_known_storage_root).and_return(storage_dir)
|
||||
allow(PotatoMesh::Config).to receive(:well_known_relative_path).and_return(".well-known/potato-mesh")
|
||||
allow(PotatoMesh::Config).to receive(:well_known_refresh_interval).and_return(86_400)
|
||||
allow(PotatoMesh::Sanitizer).to receive(:sanitized_site_name).and_return("Test Instance")
|
||||
allow(PotatoMesh::Sanitizer).to receive(:sanitize_instance_domain).and_return("example.com")
|
||||
end
|
||||
|
||||
after do
|
||||
FileUtils.remove_entry(storage_dir)
|
||||
allow(PotatoMesh::Config).to receive(:well_known_storage_root).and_call_original
|
||||
allow(PotatoMesh::Config).to receive(:well_known_relative_path).and_call_original
|
||||
allow(PotatoMesh::Config).to receive(:well_known_refresh_interval).and_call_original
|
||||
allow(PotatoMesh::Sanitizer).to receive(:sanitized_site_name).and_call_original
|
||||
allow(PotatoMesh::Sanitizer).to receive(:sanitize_instance_domain).and_call_original
|
||||
end
|
||||
|
||||
it "writes a well-known document when none exists" do
|
||||
PotatoMesh::Application.refresh_well_known_document_if_stale
|
||||
|
||||
expect(File.exist?(well_known_path)).to be(true)
|
||||
document = JSON.parse(File.read(well_known_path))
|
||||
expect(document.fetch("version")).to eq(PotatoMesh::Application::APP_VERSION)
|
||||
expect(document.fetch("domain")).to eq("example.com")
|
||||
end
|
||||
|
||||
it "rewrites the document when configuration values change" do
|
||||
PotatoMesh::Application.refresh_well_known_document_if_stale
|
||||
original_contents = File.binread(well_known_path)
|
||||
|
||||
stub_const("PotatoMesh::Application::APP_VERSION", "9.9.9-test")
|
||||
PotatoMesh::Application.refresh_well_known_document_if_stale
|
||||
|
||||
rewritten_contents = File.binread(well_known_path)
|
||||
expect(rewritten_contents).not_to eq(original_contents)
|
||||
document = JSON.parse(rewritten_contents)
|
||||
expect(document.fetch("version")).to eq("9.9.9-test")
|
||||
end
|
||||
|
||||
it "does not rewrite when content is current and within the refresh interval" do
|
||||
PotatoMesh::Application.refresh_well_known_document_if_stale
|
||||
original_contents = File.binread(well_known_path)
|
||||
|
||||
PotatoMesh::Application.refresh_well_known_document_if_stale
|
||||
|
||||
expect(File.binread(well_known_path)).to eq(original_contents)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user