Compare commits

...

6 Commits

Author SHA1 Message Date
l5y 01665b6e3a Fixes POST request 403 errors on instances behind Cloudflare proxy (#368)
* Add full headers to ingestor POST requests to avoid CF bans

* run black

* Guard Authorization header when token absent

---------

Co-authored-by: varna9000 <milen@aeroisk.com>
2025-10-16 22:29:04 +02:00
l5y 1898a99789 Delay initial federation announcements (#366) 2025-10-16 21:50:43 +02:00
l5y 3eefda9205 Ensure well-known document stays in sync (#365) 2025-10-16 21:43:11 +02:00
l5y a6ba9a8227 Guard federation DNS resolution against restricted networks (#362)
* Guard federation DNS resolution against restricted networks

* Pin federation HTTP clients to vetted IPs
2025-10-16 21:15:34 +02:00
l5y 7055444c4b Add federation ingestion limits and tests (#364) 2025-10-16 21:15:18 +02:00
l5y 4bfc0e25cb Prefer reported primary channel names (#363) 2025-10-16 20:35:24 +02:00
11 changed files with 538 additions and 29 deletions
+26 -11
View File
@@ -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
+18 -4
View File
@@ -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
View File
@@ -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 = []
+97 -2
View File
@@ -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"
+13 -6
View File
@@ -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",
+54
View File
@@ -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
View File
@@ -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)
+48
View File
@@ -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)
+130
View File
@@ -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
+55
View File
@@ -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