Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions src/reflector/dial_proxy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ DialProxy::DialProxy(Dispatcher& dispatcher, const Interface& source_if, const I
, target_if_{&target_if}
, eviction_timer_{dispatcher} {}

std::optional<IpEndpoint> DialProxy::EnsureDiscoveryListener(const IpEndpoint& device) {
return EnsureListener(device, Endpoint::Role::Discovery);
std::optional<IpEndpoint> DialProxy::EnsureDiscoveryListener(const IpEndpoint& device,
std::optional<std::chrono::seconds> advertised_validity) {
const auto grace = advertised_validity
? std::min(*advertised_validity, MAX_DISCOVERY_GRACE)
: DEFAULT_DISCOVERY_GRACE;
return EnsureListener(device, Endpoint::Role::Discovery, grace);
}

void DialProxy::OnInterfaceChanged() noexcept {
Expand Down Expand Up @@ -53,18 +57,25 @@ void DialProxy::OnInterfaceChanged() noexcept {
}

std::optional<IpEndpoint> DialProxy::EnsureRestListener(const IpEndpoint& device) {
return EnsureListener(device, Endpoint::Role::Rest);
return EnsureListener(device, Endpoint::Role::Rest, REST_ENDPOINT_GRACE);
}

std::optional<IpEndpoint> DialProxy::EnsureListener(const IpEndpoint& device, Endpoint::Role role) {
std::optional<IpEndpoint> DialProxy::EnsureListener(const IpEndpoint& device, Endpoint::Role role,
std::chrono::seconds grace) {
const auto now = std::chrono::steady_clock::now();

// Reuse an existing listener for this device: refresh it, promote Discovery -> Rest if asked (a
// device referenced as both roles is pinned to Rest, the longer-lived grace), and hand back its
// authority. The listener's bind address is the source_if address it was minted on.
// device referenced as both roles is pinned to Rest — it serves description fetches and the app's
// REST calls alike), and hand back its authority. The listener's bind address is the source_if
// address it was minted on.
if (const auto it = endpoints_.find(device); it != endpoints_.end()) {
auto& endpoint = it->second;
endpoint.last_active = now;
if (role == Endpoint::Role::Discovery) {
// A fresh advertisement re-states the device's validity, so the new grace replaces the
// old one (shorter or longer) — a Rest touch leaves the advertised validity in force.
endpoint.grace = grace;
}
// Promoting Discovery -> Rest moves the device into the Rest tally (it isn't counted there yet), so
// the Rest cap must gate it here too — otherwise a promotion silently overruns MAX_REST_LISTENERS.
// Over cap: refuse without demoting (close-don't-forward, as a fresh over-cap mint does).
Expand Down Expand Up @@ -97,7 +108,7 @@ std::optional<IpEndpoint> DialProxy::EnsureListener(const IpEndpoint& device, En

// Construct the node in place (Endpoint is NoMove): try_emplace builds it once in the final map node
// — no move follows, so the accept handler we register next binds a stable address.
const auto [it, inserted] = endpoints_.try_emplace(device, device, role, std::move(*listener), now);
const auto [it, inserted] = endpoints_.try_emplace(device, device, role, std::move(*listener), now, grace);
auto& endpoint = it->second;

auto registration = dispatcher_->Register(listener_fd, CreateDelegate<&DialProxy::OnAccept>(this));
Expand Down Expand Up @@ -362,8 +373,7 @@ void DialProxy::EvictExpired(std::chrono::steady_clock::time_point now) noexcept
// reap above already ran the dtors of everything it erased, so the count is current here.
const auto endpoints_reaped = std::erase_if(endpoints_, [now](const auto& entry) {
const auto& endpoint = entry.second;
const auto grace = endpoint.role == Endpoint::Role::Rest ? REST_ENDPOINT_GRACE : DISCOVERY_ENDPOINT_GRACE;
if (now < endpoint.last_active + grace) {
if (now < endpoint.last_active + endpoint.grace) {
return false;
}
return endpoint.active_connections == 0; // reap only if no Connection is still pinned to it
Expand Down
36 changes: 24 additions & 12 deletions src/reflector/dial_proxy.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,19 @@ class DialProxy : NoMove {
// An Open connection with no forwarded byte for this long is reaped (each forwarded byte refreshes its
// idle deadline). Covers the half-open peer that completes the handshake then goes silent.
static constexpr std::chrono::seconds IDLE_TIMEOUT{30};
// An unreferenced Discovery listener idle for this long is evicted. Discovery is a brief sweep then idle,
// so its grace is short — long enough to span a client's retries, short enough to free the listener soon.
static constexpr std::chrono::seconds DISCOVERY_ENDPOINT_GRACE{60};
// An unreferenced Rest listener idle for this long is evicted. Rest is the longer-lived role (a launched
// app keeps polling its REST endpoint), so its grace is longer than the discovery grace.
// An unreferenced Discovery listener is evicted once its advertised validity lapses. The
// advertisement's CACHE-CONTROL max-age sets that validity (and every advertisement refreshes it);
// absent or unparseable, this default applies — the UDA-recommended minimum device validity
// (DIAL's own example advertises max-age=1800).
static constexpr std::chrono::seconds DEFAULT_DISCOVERY_GRACE = std::chrono::minutes{30};
// Ceiling on an advertised max-age: the value is attacker-controlled and sets a proxy-slot
// eviction deadline, so bound how long one advertisement (a spoofed burst included) can pin a
// scarce slot. Well above any real device's re-advertise interval, so a live device still
// refreshes its grace in time.
static constexpr std::chrono::seconds MAX_DISCOVERY_GRACE = std::chrono::hours{2};
// An unreferenced Rest listener idle for this long is evicted. Rest listeners are minted from a
// description response (no advertisement, so no max-age at hand); client activity refreshes them.
static constexpr std::chrono::seconds REST_ENDPOINT_GRACE{300};
static_assert(REST_ENDPOINT_GRACE > DISCOVERY_ENDPOINT_GRACE, "Rest is the longer-lived role");
// The eviction sweep period — how often the reaper runs while either map is non-empty.
static constexpr std::chrono::seconds EVICTION_INTERVAL{5};
// Distinct devices whose REST endpoint we proxy (minted from each description's Application-URL). One per
Expand All @@ -80,8 +86,10 @@ class DialProxy : NoMove {
// Find-or-create a Discovery listener for a device's description endpoint; returns the reflector
// authority (source_if-addr:listener-port) to advertise in the rewritten LOCATION, or nullopt on a
// cap/bind failure (the LOCATION is then injected unchanged — discovery still works via the router).
// Refreshes the endpoint's last_active.
[[nodiscard]] std::optional<IpEndpoint> EnsureDiscoveryListener(const IpEndpoint& device);
// Refreshes the endpoint's last_active and grace: `advertised_validity` is the advertisement's
// CACHE-CONTROL max-age, clamped to MAX_DISCOVERY_GRACE; absent, DEFAULT_DISCOVERY_GRACE applies.
[[nodiscard]] std::optional<IpEndpoint> EnsureDiscoveryListener(const IpEndpoint& device,
std::optional<std::chrono::seconds> advertised_validity = std::nullopt);

// React to a possible source-interface address change. Every listener is bound to source_if's V4
// address as it was at mint time, so once that address changes (or goes away) the listener's
Expand All @@ -106,6 +114,7 @@ class DialProxy : NoMove {
Role role;
TcpSocket listener; // bound to source_if-addr:ephemeral
std::chrono::steady_clock::time_point last_active;
std::chrono::seconds grace; // evicted past last_active + grace (advertised validity for Discovery)
size_t active_connections = 0; // Connections pinned to this device; 0 = reapable, no connection scan
Dispatcher::Registration accept_reg; // declared LAST: dropped first on erase
};
Expand Down Expand Up @@ -175,11 +184,14 @@ class DialProxy : NoMove {
[[nodiscard]] std::optional<IpEndpoint> EnsureRestListener(const IpEndpoint& device);

// Find-or-create the Endpoint for `device`: refresh last_active, promote Discovery -> Rest if `role`
// asks, and return its reflector authority. At first sight: Listen on source_if-addr:ephemeral, read
// the port, emplace the (node-stable) Endpoint, register its accept handler, return the authority.
// Enforces the role's cap (over cap -> nullopt, no listener leaked) and source_if SourceAddress(V4)
// asks, and return its reflector authority. A Discovery call is an advertisement, so it re-states the
// device's validity: `grace` replaces the endpoint's (a Rest touch leaves it — client activity
// refreshes last_active instead). At first sight: Listen on source_if-addr:ephemeral, read the port,
// emplace the (node-stable) Endpoint, register its accept handler, return the authority. Enforces the
// role's cap (over cap -> nullopt, no listener leaked) and source_if SourceAddress(V4)
// (nullopt -> nullopt).
[[nodiscard]] std::optional<IpEndpoint> EnsureListener(const IpEndpoint& device, Endpoint::Role role);
[[nodiscard]] std::optional<IpEndpoint> EnsureListener(const IpEndpoint& device, Endpoint::Role role,
std::chrono::seconds grace);

// The listener accept handler (registered by-fd, so it reverse-looks-up the Endpoint): accept the
// client, egress-pinned-connect to the device, emplace the Connection, register both fds. Drops the
Expand Down
49 changes: 49 additions & 0 deletions src/reflector/ssdp_message.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,55 @@ std::optional<uint8_t> ParseMSearchMx(std::span<const std::byte> payload) noexce
return std::nullopt; // no MX header at all
}

std::optional<uint32_t> ParseCacheControlMaxAge(std::span<const std::byte> payload) noexcept {
const std::string_view text{reinterpret_cast<const char*>(payload.data()), payload.size()};
size_t pos = 0;
while (pos < text.size()) {
const auto end = text.find("\r\n", pos);
const auto line = text.substr(pos, end == std::string_view::npos ? std::string_view::npos : end - pos);
if (StartsWithNoCase(line, "CACHE-CONTROL:")) {
// The first CACHE-CONTROL field decides; its directives are comma-separated and the first
// max-age among them wins. A malformed value invalidates the field (nullopt) rather than
// falling through to a later directive or field.
auto directives = line.substr(14);
while (!directives.empty()) {
const auto comma = directives.find(',');
const auto directive = TrimLeadingSpace(directives.substr(0, comma));
directives = comma == std::string_view::npos ? std::string_view{} : directives.substr(comma + 1);
if (!StartsWithNoCase(directive, "max-age")) {
continue;
}
// OWS around '=' is tolerated: HTTP's cache-directive grammar has none, but UDA's own
// header templates and examples write "max-age = 1800", and devices copy them.
const auto after_name = TrimLeadingSpace(directive.substr(7));
if (!after_name.starts_with('=')) {
if (after_name.data() == directive.data() + 7 && !after_name.empty()) {
continue; // a longer token (e.g. "max-agenda=..."): a different directive
}
return std::nullopt; // a bare or malformed max-age (no '=')
}
const auto value = TrimLeadingSpace(after_name.substr(1));
uint32_t parsed = 0;
const auto* stop = value.data() + value.size();
const auto result = std::from_chars(value.data(), stop, parsed);
// Like ParseMSearchMx: the whole value must be the integer (trailing OWS tolerated) —
// from_chars alone would accept "1800x" as 1800. A value overflowing uint32_t also
// lands here.
if (result.ec == std::errc{} && TrimLeadingSpace({result.ptr, stop}).empty()) {
return parsed;
}
return std::nullopt;
}
return std::nullopt; // CACHE-CONTROL present but carries no max-age directive
}
if (end == std::string_view::npos) {
break;
}
pos = end + 2;
}
return std::nullopt; // no CACHE-CONTROL header at all
}

bool IsDialServiceMessage(std::span<const std::byte> payload) noexcept {
const std::string_view text{reinterpret_cast<const char*>(payload.data()), payload.size()};
return ContainsNoCase(text, DIAL_SERVICE_TYPE);
Expand Down
9 changes: 9 additions & 0 deletions src/reflector/ssdp_message.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ inline constexpr uint8_t MSEARCH_MX_DEFAULT = 3;
// case-insensitive.
[[nodiscard]] std::optional<uint8_t> ParseMSearchMx(std::span<const std::byte> payload) noexcept;

// Parses the CACHE-CONTROL max-age directive (the advertised validity of the announcement, in
// seconds). Walks the CRLF-delimited lines for the first CACHE-CONTROL field (case-insensitive),
// then its comma-separated directives for the first max-age (also case-insensitive; whitespace
// around '=' tolerated — UDA's own examples write "max-age = 1800"). Returns nullopt when the field
// or the directive is absent, or the value is not a bare integer fitting uint32_t — the caller
// applies its own default; bounding the value is also the caller's policy (DialProxy clamps the
// grace it derives).
[[nodiscard]] std::optional<uint32_t> ParseCacheControlMaxAge(std::span<const std::byte> payload) noexcept;

// True if `payload` advertises the DIAL service — its service-type URN (urn:dial-multiscreen-org:service:dial)
// appears anywhere in the message (ST/NT/USN). The SSDP path uses this to decide whether a response's LOCATION
// should be rewritten through the DialProxy; DialProxy itself never parses SSDP.
Expand Down
4 changes: 3 additions & 1 deletion src/reflector/ssdp_reflector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,9 @@ std::optional<std::string> SsdpReflector::RewriteDialLocation(std::span<const st
if (!location) {
return std::nullopt; // a DIAL message with no/unparseable LOCATION: nothing to rewrite
}
const auto reflector_authority = dial_proxy_->EnsureDiscoveryListener(location->endpoint);
const auto max_age = ParseCacheControlMaxAge(payload);
const auto reflector_authority = dial_proxy_->EnsureDiscoveryListener(location->endpoint,
max_age.transform([](uint32_t seconds) { return std::chrono::seconds{seconds}; }));
if (!reflector_authority) {
logger_.Info("DIAL: no listener for device {} (cap/bind); forwarding its LOCATION unchanged",
location->endpoint);
Expand Down
Loading