Skip to content

Commit 92e8f69

Browse files
committed
Fix service name generation to enforce RFC 6763 length limit
Truncate mDNS service instance names exceeding 63 bytes with a hash suffix to ensure RFC 6763 compliance, since Avahi does not automatically truncate like mDNSResponder. Also use underscore rather than colon as the host/port separator, and declare service_name in the header. Add unit tests for service_name. Made-with: Cursor
1 parent 20cb0c5 commit 92e8f69

4 files changed

Lines changed: 236 additions & 3 deletions

File tree

Development/cmake/NmosCppTest.cmake

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ set(NMOS_CPP_TEST_NMOS_TEST_SOURCES
5555
nmos/test/json_validator_test.cpp
5656
nmos/test/jwt_generator_test.cpp
5757
nmos/test/jwt_validation_test.cpp
58+
nmos/test/mdns_test.cpp
5859
nmos/test/paging_utils_test.cpp
5960
nmos/test/query_api_test.cpp
6061
nmos/test/sdp_test_utils.cpp

Development/nmos/mdns.cpp

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#include "nmos/mdns.h"
22

3+
#include <functional>
4+
#include <iomanip>
35
#include <boost/algorithm/string/erase.hpp>
46
#include <boost/algorithm/string/join.hpp>
57
#include <boost/algorithm/string/predicate.hpp>
@@ -330,6 +332,13 @@ namespace nmos
330332
return utility::us2s(nmos::fields::service_name_prefix(settings)) + "_" + service_api(service);
331333
}
332334

335+
inline std::string hash_string(const std::string& s)
336+
{
337+
std::ostringstream os;
338+
os << std::hex << std::setfill('0') << std::setw(8) << (std::hash<std::string>{}(s) & 0xFFFFFFFF);
339+
return os.str();
340+
}
341+
333342
inline std::set<nmos::api_version> service_versions(const nmos::service_type& service, const nmos::settings& settings)
334343
{
335344
// the System API is defined by IS-09 (having been originally specified in JT-NM TR-1001-1:2018 Annex A)
@@ -341,11 +350,33 @@ namespace nmos
341350
}
342351
}
343352

353+
// generate a stable unique name for the specified service, based on the service type, host and port
344354
std::string service_name(const nmos::service_type& service, const nmos::settings& settings)
345355
{
346-
// this just serves as an example of a possible service naming strategy
347-
// replacing '.' with '-', since although '.' is legal in service names, some DNS-SD implementations just don't like it
348-
return boost::algorithm::replace_all_copy(details::service_base_name(service, settings) + "_" + utility::us2s(nmos::get_host(settings)) + ":" + utility::us2s(utility::ostringstreamed(details::service_port(service, settings))), ".", "-");
356+
// replace '.' with '-', since although '.' is legal in service names, some DNS-SD implementations just don't like it
357+
const auto name = boost::algorithm::replace_all_copy(details::service_base_name(service, settings) + "_" + utility::us2s(nmos::get_host(settings)) + "_" + utility::us2s(utility::ostringstreamed(details::service_port(service, settings))), ".", "-");
358+
359+
// RFC 6763 Section 4.1.1 specifies that instance names must not exceed 63 bytes
360+
// see https://tools.ietf.org/html/rfc6763#section-4.1.1
361+
const size_t max_length = 63;
362+
if (name.size() <= max_length) return name;
363+
364+
// truncate over-long names, because Avahi does not automatically truncate like mDNSResponder does,
365+
// and include a unique suffix based on the full name, to reduce collisions
366+
367+
// RFC 6763 explains that DNS-SD names are intended to be user-visible, "short and descriptive",
368+
// and avoid "incomprehensible hexadecimal strings"; because this is likely to result in collisions,
369+
// RFC 6762 defines a conflict resolution mechanism designed to keep names human-readable,
370+
// implemented by Avahi ("<name> #2") and mDNSResponder ("<name> (2)"), and recommends the newly
371+
// chosen name is recorded in persistent storage so that the service will use the same name when it
372+
// restarts...
373+
// because NMOS service names are unlikely to be presented to the user, maximizing the likelihood
374+
// of a unique name without the need for persistent storage is higher priority than no hex strings!
375+
376+
const auto suffix = details::hash_string(name);
377+
const auto max_prefix_length = max_length - 1 - suffix.size();
378+
const auto prefix_length = name.find_last_not_of("-_", max_prefix_length - 1) + 1;
379+
return name.substr(0, prefix_length) + "-" + suffix;
349380
}
350381

351382
// helper function for registering addresses when the host name is explicitly configured

Development/nmos/mdns.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ namespace nmos
148148

149149
namespace experimental
150150
{
151+
// generate a stable unique name for the specified service, based on the service type, host and port
152+
std::string service_name(const nmos::service_type& service, const nmos::settings& settings);
153+
151154
// helper function for registering addresses when the host name is explicitly configured
152155
void register_addresses(mdns::service_advertiser& advertiser, const nmos::settings& settings);
153156

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// The first "test" is of course whether the header compiles standalone
2+
#include "nmos/mdns.h"
3+
4+
#include "bst/test/test.h"
5+
#include "cpprest/basic_utils.h"
6+
7+
////////////////////////////////////////////////////////////////////////////////////////////
8+
BST_TEST_CASE(testServiceNameFormat)
9+
{
10+
using web::json::value_of;
11+
12+
// with default settings, service_name should be "<prefix>_<api>_<host>_<port>"
13+
// with dots replaced by dashes
14+
const nmos::settings settings;
15+
16+
BST_CHECK_EQUAL("nmos-cpp_node_127-0-0-1_3212", nmos::experimental::service_name(nmos::service_types::node, settings));
17+
BST_CHECK_EQUAL("nmos-cpp_query_127-0-0-1_3211", nmos::experimental::service_name(nmos::service_types::query, settings));
18+
BST_CHECK_EQUAL("nmos-cpp_registration_127-0-0-1_3210", nmos::experimental::service_name(nmos::service_types::registration, settings));
19+
BST_CHECK_EQUAL("nmos-cpp_system_127-0-0-1_10641", nmos::experimental::service_name(nmos::service_types::system, settings));
20+
BST_CHECK_EQUAL("nmos-cpp_auth_127-0-0-1_443", nmos::experimental::service_name(nmos::service_types::authorization, settings));
21+
}
22+
23+
////////////////////////////////////////////////////////////////////////////////////////////
24+
BST_TEST_CASE(testServiceNameCustomPrefix)
25+
{
26+
using web::json::value_of;
27+
28+
// dots in prefix should be replaced with dashes
29+
const nmos::settings settings = value_of({
30+
{ nmos::fields::service_name_prefix, U("my.prefix") }
31+
});
32+
33+
BST_CHECK_EQUAL("my-prefix_node_127-0-0-1_3212", nmos::experimental::service_name(nmos::service_types::node, settings));
34+
}
35+
36+
////////////////////////////////////////////////////////////////////////////////////////////
37+
BST_TEST_CASE(testServiceNameDotReplacement)
38+
{
39+
using web::json::value_of;
40+
41+
// dots in host name should be replaced with dashes
42+
const nmos::settings settings = value_of({
43+
{ nmos::fields::host_name, U("my-server.example.com") },
44+
{ nmos::experimental::fields::href_mode, 1 }
45+
});
46+
47+
BST_CHECK_EQUAL("nmos-cpp_node_my-server-example-com_3212", nmos::experimental::service_name(nmos::service_types::node, settings));
48+
}
49+
50+
////////////////////////////////////////////////////////////////////////////////////////////
51+
BST_TEST_CASE(testServiceNameCustomPort)
52+
{
53+
using web::json::value_of;
54+
55+
const nmos::settings settings = value_of({
56+
{ nmos::fields::node_port, 8080 }
57+
});
58+
59+
BST_CHECK_EQUAL("nmos-cpp_node_127-0-0-1_8080", nmos::experimental::service_name(nmos::service_types::node, settings));
60+
}
61+
62+
////////////////////////////////////////////////////////////////////////////////////////////
63+
BST_TEST_CASE(testServiceNameMaxLength)
64+
{
65+
using web::json::value_of;
66+
67+
// RFC 6763 Section 4.1.1 specifies instance names must not exceed 63 bytes
68+
const size_t max_length = 63;
69+
70+
// a name that fits within 63 bytes should pass through unchanged
71+
{
72+
const nmos::settings settings;
73+
const auto name = nmos::experimental::service_name(nmos::service_types::node, settings);
74+
BST_REQUIRE(name.size() <= max_length);
75+
// no hash suffix
76+
BST_CHECK_EQUAL("nmos-cpp_node_127-0-0-1_3212", name);
77+
}
78+
79+
// a name that would exceed 63 bytes should be truncated with a hash suffix
80+
{
81+
const nmos::settings settings = value_of({
82+
{ nmos::fields::host_name, U("a-host-name-that-is-itself-valid-but-already-63-characters-long.example.com") },
83+
{ nmos::experimental::fields::href_mode, 1 }
84+
});
85+
86+
const auto name = nmos::experimental::service_name(nmos::service_types::registration, settings);
87+
BST_REQUIRE(name.size() <= max_length);
88+
}
89+
}
90+
91+
////////////////////////////////////////////////////////////////////////////////////////////
92+
BST_TEST_CASE(testServiceNameTruncationStable)
93+
{
94+
using web::json::value_of;
95+
96+
// the truncated name should be stable (consistent) even across runs of the same program
97+
// but that's hard to test (and actually, std::hash is only required to produce the same
98+
// result for the same input within a single execution of a program; this allows salted
99+
// hashes that prevent collision denial-of-service attacks, which would be unfortunate
100+
// for this use case, but common standard library implementations don't do that...)
101+
102+
const nmos::settings settings = value_of({
103+
{ nmos::fields::host_name, U("a-host-name-that-is-itself-valid-but-already-63-characters-long.example.com") },
104+
{ nmos::experimental::fields::href_mode, 1 }
105+
});
106+
107+
const auto name1 = nmos::experimental::service_name(nmos::service_types::registration, settings);
108+
const auto name2 = nmos::experimental::service_name(nmos::service_types::registration, settings);
109+
BST_CHECK_EQUAL(name1, name2);
110+
}
111+
112+
////////////////////////////////////////////////////////////////////////////////////////////
113+
BST_TEST_CASE(testServiceNameTruncationSuffix)
114+
{
115+
using web::json::value_of;
116+
117+
const size_t max_length = 63;
118+
119+
// the truncated name should end with a dash followed by an 8-character hex hash
120+
const nmos::settings settings = value_of({
121+
{ nmos::fields::host_name, U("a-host-name-that-is-itself-valid-but-already-63-characters-long.example.com") },
122+
{ nmos::experimental::fields::href_mode, 1 }
123+
});
124+
125+
const auto name = nmos::experimental::service_name(nmos::service_types::registration, settings);
126+
BST_REQUIRE(name.size() <= max_length);
127+
128+
const auto last_dash = name.rfind('-');
129+
BST_REQUIRE(std::string::npos != last_dash);
130+
131+
// the suffix after the last dash should be exactly 8 hex characters
132+
const auto suffix = name.substr(last_dash + 1);
133+
BST_CHECK_EQUAL(8, suffix.size());
134+
BST_CHECK(suffix.end() == std::find_if(suffix.begin(), suffix.end(), [](char c)
135+
{
136+
return !std::isxdigit(static_cast<unsigned char>(c));
137+
}));
138+
}
139+
140+
////////////////////////////////////////////////////////////////////////////////////////////
141+
BST_TEST_CASE(testServiceNameExactly63Bytes)
142+
{
143+
using web::json::value_of;
144+
145+
// a name that is exactly 63 bytes should not be truncated
146+
// "nmos-cpp_node_" = 14 chars, "_3212" = 5 chars, so host needs to be 63 - 14 - 5 = 44 chars
147+
// after dot replacement: e.g. a host name with no dots that is 44 chars
148+
const std::string host_44(44, 'x');
149+
const nmos::settings settings = value_of({
150+
{ nmos::fields::host_name, utility::s2us(host_44) },
151+
{ nmos::experimental::fields::href_mode, 1 }
152+
});
153+
154+
const auto name = nmos::experimental::service_name(nmos::service_types::node, settings);
155+
BST_CHECK_EQUAL(63, name.size());
156+
BST_CHECK_EQUAL("nmos-cpp_node_" + host_44 + "_3212", name);
157+
}
158+
159+
////////////////////////////////////////////////////////////////////////////////////////////
160+
BST_TEST_CASE(testServiceNameOneOver63Bytes)
161+
{
162+
using web::json::value_of;
163+
164+
// a name that is 64 bytes should be truncated
165+
const std::string host_45(45, 'x');
166+
const nmos::settings settings = value_of({
167+
{ nmos::fields::host_name, utility::s2us(host_45) },
168+
{ nmos::experimental::fields::href_mode, 1 }
169+
});
170+
171+
const auto name = nmos::experimental::service_name(nmos::service_types::node, settings);
172+
BST_REQUIRE(name.size() <= 63);
173+
}
174+
175+
////////////////////////////////////////////////////////////////////////////////////////////
176+
BST_TEST_CASE(testServiceNameTruncationDistinct)
177+
{
178+
using web::json::value_of;
179+
180+
// even when truncated, names that differ only in the port (which is past the truncation point)
181+
// should produce distinct names due to the hash suffix being based on the full untruncated name
182+
const auto make_settings = [](int port)
183+
{
184+
return value_of({
185+
{ nmos::fields::host_name, U("a-host-name-that-is-itself-valid-but-already-63-characters-long.example.com") },
186+
{ nmos::experimental::fields::href_mode, 1 },
187+
{ nmos::fields::node_port, port }
188+
});
189+
};
190+
191+
const auto name1 = nmos::experimental::service_name(nmos::service_types::node, make_settings(3212));
192+
const auto name2 = nmos::experimental::service_name(nmos::service_types::node, make_settings(3213));
193+
const auto name3 = nmos::experimental::service_name(nmos::service_types::node, make_settings(3214));
194+
195+
BST_CHECK(name1 != name2);
196+
BST_CHECK(name1 != name3);
197+
BST_CHECK(name2 != name3);
198+
}

0 commit comments

Comments
 (0)