Skip to content

Commit 8e2e17f

Browse files
garethsblo-simon
andauthored
Fix service name generation to enforce RFC 6763 length limit (#485)
* 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 * Generate a slightly more compact hash string * Clarify that the hash suffix isn't guaranteed to be unique * Remove unnecessary #include Co-authored-by: Simon Lo <simon.lo@sony.com> --------- Co-authored-by: Simon Lo <simon.lo@sony.com>
1 parent 20cb0c5 commit 8e2e17f

4 files changed

Lines changed: 255 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: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,19 @@ namespace nmos
330330
return utility::us2s(nmos::fields::service_name_prefix(settings)) + "_" + service_api(service);
331331
}
332332

333+
// generate a hash string (slightly more compact and possibly slightly more memorable than hex)
334+
inline std::string hash_string(const std::string& s, size_t len = 5)
335+
{
336+
auto hash = std::hash<std::string>{}(s);
337+
// vowels (and vowel-ish digits) are omitted from the set of available characters
338+
// to reduce the chances of "bad words" being formed
339+
static const char alphanums[] = "bcdfghjklmnpqrstvwxz2456789";
340+
static const size_t base = sizeof(alphanums) - 1;
341+
std::string result(len, ' ');
342+
for (auto& c : result) { c = alphanums[hash % base]; hash /= base; }
343+
return result;
344+
}
345+
333346
inline std::set<nmos::api_version> service_versions(const nmos::service_type& service, const nmos::settings& settings)
334347
{
335348
// the System API is defined by IS-09 (having been originally specified in JT-NM TR-1001-1:2018 Annex A)
@@ -341,11 +354,33 @@ namespace nmos
341354
}
342355
}
343356

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

351386
// 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: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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 a 5-character alphanumeric 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+
const auto suffix = name.substr(last_dash + 1);
132+
BST_CHECK_EQUAL(5, suffix.size());
133+
BST_CHECK(suffix.end() == std::find_if(suffix.begin(), suffix.end(), [](char c)
134+
{
135+
return !std::isdigit(static_cast<unsigned char>(c)) && !std::islower(static_cast<unsigned char>(c));
136+
}));
137+
}
138+
139+
////////////////////////////////////////////////////////////////////////////////////////////
140+
BST_TEST_CASE(testServiceNameExactly63Bytes)
141+
{
142+
using web::json::value_of;
143+
144+
// a name that is exactly 63 bytes should not be truncated
145+
// "nmos-cpp_node_" = 14 chars, "_3212" = 5 chars, so host needs to be 63 - 14 - 5 = 44 chars
146+
// after dot replacement: e.g. a host name with no dots that is 44 chars
147+
const std::string host_44(44, 'x');
148+
const nmos::settings settings = value_of({
149+
{ nmos::fields::host_name, utility::s2us(host_44) },
150+
{ nmos::experimental::fields::href_mode, 1 }
151+
});
152+
153+
const auto name = nmos::experimental::service_name(nmos::service_types::node, settings);
154+
BST_CHECK_EQUAL(63, name.size());
155+
BST_CHECK_EQUAL("nmos-cpp_node_" + host_44 + "_3212", name);
156+
}
157+
158+
////////////////////////////////////////////////////////////////////////////////////////////
159+
BST_TEST_CASE(testServiceNameOneOver63Bytes)
160+
{
161+
using web::json::value_of;
162+
163+
// a name that is 64 bytes should be truncated
164+
const std::string host_45(45, 'x');
165+
const nmos::settings settings = value_of({
166+
{ nmos::fields::host_name, utility::s2us(host_45) },
167+
{ nmos::experimental::fields::href_mode, 1 }
168+
});
169+
170+
const auto name = nmos::experimental::service_name(nmos::service_types::node, settings);
171+
BST_REQUIRE(name.size() <= 63);
172+
}
173+
174+
////////////////////////////////////////////////////////////////////////////////////////////
175+
BST_TEST_CASE(testServiceNameTruncationDistinct)
176+
{
177+
using web::json::value_of;
178+
179+
// settings that produce distinct over-long names before truncation usually result in
180+
// distinct names because the hash suffix is based on the full untruncated name
181+
const auto make_settings = [](int port)
182+
{
183+
return value_of({
184+
{ nmos::fields::host_name, U("a-host-name-that-is-itself-valid-but-already-63-characters-long.example.com") },
185+
{ nmos::experimental::fields::href_mode, 1 },
186+
{ nmos::fields::node_port, port }
187+
});
188+
};
189+
190+
const auto name1 = nmos::experimental::service_name(nmos::service_types::node, make_settings(3212));
191+
const auto name2 = nmos::experimental::service_name(nmos::service_types::node, make_settings(3213));
192+
193+
BST_CHECK(name1 != name2);
194+
}
195+
196+
////////////////////////////////////////////////////////////////////////////////////////////
197+
BST_TEST_CASE(testServiceNameTruncationExample)
198+
{
199+
using web::json::value_of;
200+
201+
const nmos::settings settings = value_of({
202+
{ nmos::fields::host_name, U("a-host-name-that-is-itself-valid-but-already-63-characters-long.xz6zx.example.com") },
203+
{ nmos::experimental::fields::href_mode, 1 }
204+
});
205+
206+
const auto name = nmos::experimental::service_name(nmos::service_types::node, settings);
207+
BST_REQUIRE_EQUAL("nmos-cpp_node_a-host-name-that-is-itself-valid-but-alread-", name.substr(0, name.size() - 5));
208+
#ifdef __GLIBCXX__
209+
BST_CHECK_EQUAL("cr4ck", name.substr(name.size() - 5));
210+
// ...e4y8q.example.com produces the same hash suffix with this std::hash implementation
211+
// but anything could happen on another platform...
212+
#endif
213+
}

0 commit comments

Comments
 (0)