diff --git a/src/reflector/dial_proxy.cpp b/src/reflector/dial_proxy.cpp index 68eb78f..a251a63 100644 --- a/src/reflector/dial_proxy.cpp +++ b/src/reflector/dial_proxy.cpp @@ -23,8 +23,12 @@ DialProxy::DialProxy(Dispatcher& dispatcher, const Interface& source_if, const I , target_if_{&target_if} , eviction_timer_{dispatcher} {} -std::optional DialProxy::EnsureDiscoveryListener(const IpEndpoint& device) { - return EnsureListener(device, Endpoint::Role::Discovery); +std::optional DialProxy::EnsureDiscoveryListener(const IpEndpoint& device, + std::optional 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 { @@ -53,18 +57,25 @@ void DialProxy::OnInterfaceChanged() noexcept { } std::optional DialProxy::EnsureRestListener(const IpEndpoint& device) { - return EnsureListener(device, Endpoint::Role::Rest); + return EnsureListener(device, Endpoint::Role::Rest, REST_ENDPOINT_GRACE); } -std::optional DialProxy::EnsureListener(const IpEndpoint& device, Endpoint::Role role) { +std::optional 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). @@ -97,7 +108,7 @@ std::optional 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)); @@ -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 diff --git a/src/reflector/dial_proxy.h b/src/reflector/dial_proxy.h index 0898f68..7bf431b 100644 --- a/src/reflector/dial_proxy.h +++ b/src/reflector/dial_proxy.h @@ -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 @@ -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 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 EnsureDiscoveryListener(const IpEndpoint& device, + std::optional 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 @@ -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 }; @@ -175,11 +184,14 @@ class DialProxy : NoMove { [[nodiscard]] std::optional 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 EnsureListener(const IpEndpoint& device, Endpoint::Role role); + [[nodiscard]] std::optional 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 diff --git a/src/reflector/ssdp_message.cpp b/src/reflector/ssdp_message.cpp index 39a1939..f055a28 100644 --- a/src/reflector/ssdp_message.cpp +++ b/src/reflector/ssdp_message.cpp @@ -86,6 +86,55 @@ std::optional ParseMSearchMx(std::span payload) noexce return std::nullopt; // no MX header at all } +std::optional ParseCacheControlMaxAge(std::span payload) noexcept { + const std::string_view text{reinterpret_cast(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 payload) noexcept { const std::string_view text{reinterpret_cast(payload.data()), payload.size()}; return ContainsNoCase(text, DIAL_SERVICE_TYPE); diff --git a/src/reflector/ssdp_message.h b/src/reflector/ssdp_message.h index 742cb0a..0207499 100644 --- a/src/reflector/ssdp_message.h +++ b/src/reflector/ssdp_message.h @@ -36,6 +36,15 @@ inline constexpr uint8_t MSEARCH_MX_DEFAULT = 3; // case-insensitive. [[nodiscard]] std::optional ParseMSearchMx(std::span 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 ParseCacheControlMaxAge(std::span 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. diff --git a/src/reflector/ssdp_reflector.cpp b/src/reflector/ssdp_reflector.cpp index 0a942ce..2c75e94 100644 --- a/src/reflector/ssdp_reflector.cpp +++ b/src/reflector/ssdp_reflector.cpp @@ -305,7 +305,9 @@ std::optional SsdpReflector::RewriteDialLocation(std::spanEnsureDiscoveryListener(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); diff --git a/tests/dial_proxy_test.cpp b/tests/dial_proxy_test.cpp index c4ce2ce..59dff80 100644 --- a/tests/dial_proxy_test.cpp +++ b/tests/dial_proxy_test.cpp @@ -1686,9 +1686,10 @@ TEST_F(DialProxyForwardTest, EvictionReapsIdleOpenButNotRefreshed) { EXPECT_EQ(ConnectionCount(proxy), 1u); } -// 4. Endpoint role grace: an unreferenced Discovery endpoint is reaped after DISCOVERY_ENDPOINT_GRACE; a -// Rest endpoint survives until the longer REST_ENDPOINT_GRACE; a referenced endpoint (an active -// Connection to its device) is NOT reaped even when idle past the grace. +// 4. Endpoint role grace: an unreferenced Rest endpoint is reaped after REST_ENDPOINT_GRACE; an +// unreferenced Discovery endpoint (no advertised max-age) survives until the longer +// DEFAULT_DISCOVERY_GRACE; a referenced endpoint (an active Connection to its device) is NOT reaped +// even when idle past the grace. TEST_F(DialProxyForwardTest, EvictionReapsIdleListenersByRoleGraceButNotReferenced) { auto proxy = MakeProxy(); @@ -1708,21 +1709,74 @@ TEST_F(DialProxyForwardTest, EvictionReapsIdleListenersByRoleGraceButNotReferenc SetEndpointLastActive(proxy, rest_dev, base); SetEndpointLastActive(proxy, referenced_dev, base); // Keep the referencing connection out of the connection reap (its own deadline far ahead). - SetConnDeadline(*FindConnection(proxy, referenced.id), base + std::chrono::hours{1}); + SetConnDeadline(*FindConnection(proxy, referenced.id), base + std::chrono::hours{3}); - // Just past the discovery grace, before the rest grace: only the unreferenced Discovery endpoint goes. - Evict(base + DialProxy::DISCOVERY_ENDPOINT_GRACE + std::chrono::milliseconds{1}); - EXPECT_FALSE(HasEndpoint(proxy, discovery_dev)); // Discovery grace elapsed -> reaped - EXPECT_TRUE(HasEndpoint(proxy, rest_dev)); // Rest grace not yet elapsed -> survives - EXPECT_TRUE(HasEndpoint(proxy, referenced_dev)); // referenced -> survives regardless of grace - - // Past the rest grace too: the unreferenced Rest endpoint goes; the referenced one still survives because - // its Connection keeps it alive (idle past grace but referenced). + // Just past the rest grace, before the discovery grace: only the unreferenced Rest endpoint goes. Evict(base + DialProxy::REST_ENDPOINT_GRACE + std::chrono::milliseconds{1}); EXPECT_FALSE(HasEndpoint(proxy, rest_dev)); // Rest grace elapsed -> reaped + EXPECT_TRUE(HasEndpoint(proxy, discovery_dev)); // Discovery default grace not yet elapsed -> survives + EXPECT_TRUE(HasEndpoint(proxy, referenced_dev)); // referenced -> survives regardless of grace + + // Past the discovery default grace too: the unreferenced Discovery endpoint goes; the referenced one + // still survives because its Connection keeps it alive (idle past grace but referenced). + Evict(base + DialProxy::DEFAULT_DISCOVERY_GRACE + std::chrono::milliseconds{1}); + EXPECT_FALSE(HasEndpoint(proxy, discovery_dev)); // Discovery grace elapsed -> reaped EXPECT_TRUE(HasEndpoint(proxy, referenced_dev)); // still referenced by the live Connection -> survives } +// An advertised max-age (CACHE-CONTROL) sets the Discovery grace, replacing the default: the endpoint +// is evicted once the advertised validity lapses, while an endpoint minted without one still gets +// DEFAULT_DISCOVERY_GRACE. +TEST_F(DialProxyForwardTest, AdvertisedMaxAgeSetsTheDiscoveryGrace) { + auto proxy = MakeProxy(); + const auto advertised_dev = Device(70); + const auto defaulted_dev = Device(71); + ASSERT_TRUE(proxy.EnsureDiscoveryListener(advertised_dev, std::chrono::seconds{60}).has_value()); + ASSERT_TRUE(proxy.EnsureDiscoveryListener(defaulted_dev).has_value()); + + const auto base = std::chrono::steady_clock::now(); + SetEndpointLastActive(proxy, advertised_dev, base); + SetEndpointLastActive(proxy, defaulted_dev, base); + + // Past the advertised 60s, far before the default grace: only the advertised endpoint goes. + Evict(base + std::chrono::seconds{61}); + EXPECT_FALSE(HasEndpoint(proxy, advertised_dev)); + EXPECT_TRUE(HasEndpoint(proxy, defaulted_dev)); +} + +// The advertised validity is attacker-controlled and sets a proxy-slot eviction deadline, so an +// oversized max-age is clamped to MAX_DISCOVERY_GRACE instead of pinning a scarce slot for days. +TEST_F(DialProxyForwardTest, ClampsAnOversizedAdvertisedMaxAge) { + auto proxy = MakeProxy(); + const auto dev = Device(72); + ASSERT_TRUE(proxy.EnsureDiscoveryListener(dev, std::chrono::hours{28}).has_value()); + + const auto base = std::chrono::steady_clock::now(); + SetEndpointLastActive(proxy, dev, base); + + Evict(base + DialProxy::MAX_DISCOVERY_GRACE + std::chrono::seconds{1}); + EXPECT_FALSE(HasEndpoint(proxy, dev)); // evicted at the ceiling, not the advertised 28h +} + +// Each advertisement re-states the device's validity: the new max-age replaces the previous grace in +// both directions, like the device's own cache directive would at any client. +TEST_F(DialProxyForwardTest, AReAdvertisementReplacesTheAdvertisedGrace) { + auto proxy = MakeProxy(); + const auto dev = Device(73); + ASSERT_TRUE(proxy.EnsureDiscoveryListener(dev, std::chrono::seconds{60}).has_value()); + ASSERT_TRUE(proxy.EnsureDiscoveryListener(dev, std::chrono::seconds{600}).has_value()); // extend + + const auto base = std::chrono::steady_clock::now(); + SetEndpointLastActive(proxy, dev, base); + Evict(base + std::chrono::seconds{61}); + EXPECT_TRUE(HasEndpoint(proxy, dev)); // the 600s re-advertisement extended past the initial 60s + + ASSERT_TRUE(proxy.EnsureDiscoveryListener(dev, std::chrono::seconds{30}).has_value()); // shorten + SetEndpointLastActive(proxy, dev, base); + Evict(base + std::chrono::seconds{31}); + EXPECT_FALSE(HasEndpoint(proxy, dev)); // the shorter re-advertisement took effect too +} + // ---- last_active refresh: the two production writes that keep an active endpoint out of the grace reap. // Both are stamped from the LIVE clock (not the Evict `now` seam), so unlike the deadline-based tests above, // these can't set last_active directly -- they bracket each real write with steady_clock::now() and separate @@ -1746,7 +1800,7 @@ TEST_F(DialProxyTest, RediscoveryRefreshesLastActivePastTheOriginalGraceDeadline // Past the original mint's deadline, but well before (original + the 50ms gap)'s deadline: only the // refresh decides whether this endpoint is still alive here. - Evict(after_first + DialProxy::DISCOVERY_ENDPOINT_GRACE + std::chrono::milliseconds{10}); + Evict(after_first + DialProxy::DEFAULT_DISCOVERY_GRACE + std::chrono::milliseconds{10}); EXPECT_TRUE(HasEndpoint(proxy, device)); // survives only because re-advertising refreshed last_active } @@ -1769,7 +1823,7 @@ TEST_F(DialProxyTest, SpuriousAcceptEdgeRefreshesLastActivePastTheOriginalGraceD dispatcher.FireReadable(listener_fd); // no pending client: OnAccept stamps last_active, then Accept() EAGAINs ASSERT_EQ(EndpointActiveConnections(proxy, device), 0u); // confirms nothing was actually accepted - Evict(after_first + DialProxy::DISCOVERY_ENDPOINT_GRACE + std::chrono::milliseconds{10}); + Evict(after_first + DialProxy::DEFAULT_DISCOVERY_GRACE + std::chrono::milliseconds{10}); EXPECT_TRUE(HasEndpoint(proxy, device)); // survives only because the accept edge refreshed last_active } @@ -1814,14 +1868,14 @@ TEST_F(DialProxyForwardTest, EndpointReapedOnlyAfterItsLastConnectionEndsSameSwe SetConnDeadline(*conn, base + std::chrono::hours{1}); // keep the connection out of this first reap // Past grace but still referenced -> endpoint survives, count stays 1. - Evict(base + DialProxy::DISCOVERY_ENDPOINT_GRACE + std::chrono::milliseconds{1}); + Evict(base + DialProxy::DEFAULT_DISCOVERY_GRACE + std::chrono::milliseconds{1}); EXPECT_TRUE(HasEndpoint(proxy, dev)); EXPECT_EQ(EndpointActiveConnections(proxy, dev), 1u); // Expire the connection: in ONE sweep it is reaped (dtor: count 1->0) and the now-unreferenced, past-grace // endpoint is reaped after it. A reordered sweep (endpoints before connections) would keep the endpoint. SetConnDeadline(*conn, base); - Evict(base + DialProxy::DISCOVERY_ENDPOINT_GRACE + std::chrono::milliseconds{1}); + Evict(base + DialProxy::DEFAULT_DISCOVERY_GRACE + std::chrono::milliseconds{1}); EXPECT_EQ(ConnectionCount(proxy), 0u); EXPECT_FALSE(HasEndpoint(proxy, dev)); } @@ -1854,13 +1908,13 @@ TEST_F(DialProxyForwardTest, MultiConnectionEndpointReapedAfterTheLast) { // Reap conn1 only: count 2->1, endpoint still referenced -> kept. SetConnDeadline(*conn1, base); SetConnDeadline(*conn2, base + std::chrono::hours{1}); - Evict(base + DialProxy::DISCOVERY_ENDPOINT_GRACE + std::chrono::milliseconds{1}); + Evict(base + DialProxy::DEFAULT_DISCOVERY_GRACE + std::chrono::milliseconds{1}); EXPECT_EQ(EndpointActiveConnections(proxy, dev), 1u); EXPECT_TRUE(HasEndpoint(proxy, dev)); // Reap conn2: count 1->0, the now-unreferenced past-grace endpoint goes. SetConnDeadline(*conn2, base); - Evict(base + DialProxy::DISCOVERY_ENDPOINT_GRACE + std::chrono::milliseconds{1}); + Evict(base + DialProxy::DEFAULT_DISCOVERY_GRACE + std::chrono::milliseconds{1}); EXPECT_FALSE(HasEndpoint(proxy, dev)); EXPECT_EQ(ConnectionCount(proxy), 0u); diff --git a/tests/ssdp_message_test.cpp b/tests/ssdp_message_test.cpp index d120f87..30fc98d 100644 --- a/tests/ssdp_message_test.cpp +++ b/tests/ssdp_message_test.cpp @@ -108,6 +108,67 @@ TEST(SsdpMessageTest, ParsesMxHeaderNameCaseInsensitively) { EXPECT_EQ(ParseMSearchMx(Bytes("M-SEARCH * HTTP/1.1\r\nmx: 2\r\n\r\n")), 2); } +// --- CACHE-CONTROL max-age (the advertised validity a DIAL listener's grace derives from) --- + +TEST(SsdpMessageTest, ParsesCacheControlMaxAge) { + const auto payload = Bytes( + "NOTIFY * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "CACHE-CONTROL: max-age=1800\r\n" + "NT: upnp:rootdevice\r\n\r\n"); + EXPECT_EQ(ParseCacheControlMaxAge(payload), 1800u); +} + +TEST(SsdpMessageTest, ParsesCacheControlCaseInsensitively) { + // Field name and directive name both fold case. + EXPECT_EQ(ParseCacheControlMaxAge(Bytes("NOTIFY * HTTP/1.1\r\ncache-control: MAX-AGE=60\r\n\r\n")), 60u); +} + +TEST(SsdpMessageTest, PicksMaxAgeFromCommaSeparatedDirectives) { + EXPECT_EQ(ParseCacheControlMaxAge( + Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: no-cache, max-age=90, no-store\r\n\r\n")), 90u); +} + +TEST(SsdpMessageTest, CacheControlNulloptWhenAbsentOrWithoutMaxAge) { + EXPECT_EQ(ParseCacheControlMaxAge(Bytes("NOTIFY * HTTP/1.1\r\nNT: upnp:rootdevice\r\n\r\n")), std::nullopt); + EXPECT_EQ(ParseCacheControlMaxAge(Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: no-cache\r\n\r\n")), std::nullopt); +} + +TEST(SsdpMessageTest, RejectsMaxAgeWithTrailingGarbage) { + // Like MX: the value must be a bare integer; a malformed first max-age invalidates the field + // rather than falling through to a later directive. + EXPECT_EQ(ParseCacheControlMaxAge(Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: max-age=2abc\r\n\r\n")), + std::nullopt); + EXPECT_EQ(ParseCacheControlMaxAge( + Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: max-age=2abc, max-age=90\r\n\r\n")), std::nullopt); +} + +TEST(SsdpMessageTest, RejectsMaxAgeOverflowingUint32) { + EXPECT_EQ(ParseCacheControlMaxAge(Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: max-age=99999999999\r\n\r\n")), + std::nullopt); +} + +TEST(SsdpMessageTest, ToleratesWhitespaceAroundMaxAge) { + EXPECT_EQ(ParseCacheControlMaxAge(Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: max-age=120 \r\n\r\n")), 120u); +} + +TEST(SsdpMessageTest, ToleratesWhitespaceAroundTheMaxAgeEquals) { + // UDA's own header templates and example messages write the directive with spaces around '='. + EXPECT_EQ(ParseCacheControlMaxAge(Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: max-age = 1800\r\n\r\n")), + 1800u); + EXPECT_EQ(ParseCacheControlMaxAge(Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: max-age =60\r\n\r\n")), 60u); + EXPECT_EQ(ParseCacheControlMaxAge(Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: max-age= 90\r\n\r\n")), 90u); +} + +TEST(SsdpMessageTest, DoesNotMistakeALongerDirectiveForMaxAge) { + // "max-agenda" shares the prefix but is a different token: skipped, and the real max-age later in + // the same field still parses. A bare "max-age" with no '=' is a malformed directive -> nullopt. + EXPECT_EQ(ParseCacheControlMaxAge( + Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: max-agenda=5, max-age=70\r\n\r\n")), 70u); + EXPECT_EQ(ParseCacheControlMaxAge(Bytes("NOTIFY * HTTP/1.1\r\nCACHE-CONTROL: max-age\r\n\r\n")), + std::nullopt); +} + // --- DIAL service classification --- TEST(SsdpMessageTest, DetectsTheDialServiceUrn) { diff --git a/tests/ssdp_reflector_test.cpp b/tests/ssdp_reflector_test.cpp index 807def7..75c653e 100644 --- a/tests/ssdp_reflector_test.cpp +++ b/tests/ssdp_reflector_test.cpp @@ -949,6 +949,32 @@ TEST_F(SsdpReflectorTest, DialRewritesPortLessAdvertisementLocation) { EXPECT_NE(text.find("http://127.0.0.1:"), std::string_view::npos) << text; // to the minted listener } +// The advertisement's CACHE-CONTROL max-age flows through the rewrite into the minted listener's +// grace: 45 minutes in — past the 30-minute default validity but within the advertised 3600s — the +// listener still stands, so a re-advertisement reuses it and the rewritten payload is identical. +TEST_F(SsdpReflectorTest, DialAdvertisedMaxAgeExtendsTheListenerGrace) { + auto config = MakeConfig(AddressFamily::IPv4); + config.dial = true; + SsdpReflector reflector{packet_dispatcher, source, target, config}; + ASSERT_TRUE(reflector.IsValid()); + + const auto advertisement = Bytes( + "NOTIFY * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n" + "CACHE-CONTROL: max-age=3600\r\n" + "LOCATION: http://192.168.1.50:8008/dd.xml\r\n" + "NT: urn:dial-multiscreen-org:service:dial:1\r\nNTS: ssdp:alive\r\n\r\n"); + packet_dispatcher.Deliver(target, MakePacket(advertisement, IpAddress::SsdpGroupV4())); + ASSERT_EQ(source.sent.size(), 1u); + const std::string first{AsText(source.sent.back().payload)}; + ASSERT_NE(first.find("http://127.0.0.1:"), std::string::npos) << first; + + packet_dispatcher.dispatcher.FireTimers(std::chrono::steady_clock::now() + std::chrono::minutes{45}); + + packet_dispatcher.Deliver(target, MakePacket(advertisement, IpAddress::SsdpGroupV4())); + ASSERT_EQ(source.sent.size(), 2u); + EXPECT_EQ(AsText(source.sent.back().payload), first); // same listener reused -> same rewrite +} + TEST_F(SsdpReflectorTest, DialRewritesUnicastResponseLocationWhenEnabled) { auto config = MakeConfig(AddressFamily::IPv4); config.dial = true;