Line data Source code
1 : /*
2 : * Copyright (C) 2004-2026 Savoir-faire Linux Inc.
3 : *
4 : * This program is free software: you can redistribute it and/or modify
5 : * it under the terms of the GNU General Public License as published by
6 : * the Free Software Foundation, either version 3 of the License, or
7 : * (at your option) any later version.
8 : *
9 : * This program is distributed in the hope that it will be useful,
10 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 : * GNU General Public License for more details.
13 : *
14 : * You should have received a copy of the GNU General Public License
15 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 : */
17 :
18 : #ifdef HAVE_CONFIG_H
19 : #include "config.h"
20 : #endif
21 : #include "namedirectory.h"
22 :
23 : #include "logger.h"
24 : #include "string_utils.h"
25 : #include "fileutils.h"
26 : #include "base64.h"
27 :
28 : #include <asio.hpp>
29 :
30 : #include "manager.h"
31 : #include <opendht/crypto.h>
32 : #include <opendht/utils.h>
33 : #include <opendht/http.h>
34 : #include <opendht/logger.h>
35 : #include <opendht/thread_pool.h>
36 :
37 : #include <cstddef>
38 : #include <msgpack.hpp>
39 : #include "json_utils.h"
40 :
41 : /* for Visual Studio */
42 :
43 : #include <sstream>
44 : #include <regex>
45 : #include <fstream>
46 :
47 : namespace jami {
48 :
49 : constexpr const char* const QUERY_NAME {"/name/"};
50 : constexpr const char* const QUERY_ADDR {"/addr/"};
51 : constexpr auto CACHE_DIRECTORY {"namecache"sv};
52 : constexpr const char DEFAULT_SERVER_HOST[] = "https://ns.jami.net";
53 :
54 : constexpr std::string_view HEX_PREFIX = "0x"sv;
55 : constexpr std::chrono::seconds SAVE_INTERVAL {5};
56 :
57 : /*
58 : * Parser for URIs. ( protocol ) ( username ) ( hostname )
59 : * - Requires "@" if a username is present (e.g., "user@domain.com").
60 : * - Allows common URL-safe special characters in usernames and domains.
61 : *
62 : * Regex breakdown:
63 : * 1. `([a-zA-Z]+:(?://)?)?` → Optional scheme ("http://", "ftp://").
64 : * 2. `(?:([^\s@]{1,64})@)?` → Optional username (max 64 chars, Unicode allowed).
65 : * 3. `([^\s@]+)` → Domain or standalone name (Unicode allowed, no spaces or "@").
66 : */
67 : const std::regex URI_VALIDATOR {R"(^([a-zA-Z]+:(?://)?)?(?:([\w\-.~%!$&'()*+,;=]{1,64}|[^\s@]{1,64})@)?([^\s@]+)$)"};
68 :
69 : constexpr size_t MAX_RESPONSE_SIZE {1024ul * 1024};
70 :
71 : using Request = dht::http::Request;
72 :
73 : void
74 1 : toLower(std::string& string)
75 : {
76 1 : std::transform(string.begin(), string.end(), string.begin(), ::tolower);
77 1 : }
78 :
79 : NameDirectory&
80 0 : NameDirectory::instance()
81 : {
82 0 : return instance(DEFAULT_SERVER_HOST);
83 : }
84 :
85 : void
86 3 : NameDirectory::lookupUri(std::string_view uri, const std::string& default_server, LookupCallback cb)
87 : {
88 3 : const std::string& default_ns = default_server.empty() ? DEFAULT_SERVER_HOST : default_server;
89 3 : std::svmatch pieces_match;
90 3 : if (std::regex_match(uri, pieces_match, URI_VALIDATOR)) {
91 3 : if (pieces_match.size() == 4) {
92 3 : if (pieces_match[2].length() == 0)
93 3 : instance(default_ns).lookupName(pieces_match[3], std::move(cb));
94 : else
95 0 : instance(pieces_match[3].str()).lookupName(pieces_match[2], std::move(cb));
96 3 : return;
97 : }
98 : }
99 0 : JAMI_ERROR("Unable to parse URI: {}", uri);
100 0 : cb("", "", Response::invalidResponse);
101 6 : }
102 :
103 30 : NameDirectory::NameDirectory(const std::string& serverUrl, std::shared_ptr<dht::Logger> l)
104 30 : : serverUrl_(serverUrl)
105 30 : , logger_(std::move(l))
106 30 : , httpContext_(Manager::instance().ioContext())
107 60 : , saveTask_(*httpContext_)
108 : {
109 30 : if (!serverUrl_.empty() && serverUrl_.back() == '/')
110 0 : serverUrl_.pop_back();
111 30 : resolver_ = std::make_shared<dht::http::Resolver>(*httpContext_, serverUrl, logger_);
112 30 : cachePath_ = fileutils::get_cache_dir() / CACHE_DIRECTORY / resolver_->get_url().host;
113 30 : }
114 :
115 : std::shared_ptr<dht::http::Resolver>
116 1017 : NameDirectory::resolver()
117 : {
118 1017 : std::lock_guard lk(resolverMtx_);
119 1017 : if (!resolver_)
120 0 : resolver_ = std::make_shared<dht::http::Resolver>(*httpContext_, serverUrl_, logger_);
121 2034 : return resolver_;
122 1017 : }
123 :
124 : void
125 0 : NameDirectory::dropResolver(const std::shared_ptr<dht::http::Resolver>& failed)
126 : {
127 0 : std::lock_guard lk(resolverMtx_);
128 0 : if (resolver_ && resolver_ == failed) {
129 0 : resolver_.reset();
130 0 : JAMI_WARNING("[NameDirectory] No HTTP response from {}; resolving the host again", serverUrl_);
131 : }
132 0 : }
133 :
134 30 : NameDirectory::~NameDirectory()
135 : {
136 30 : decltype(requests_) requests;
137 : {
138 30 : std::lock_guard lk(requestsMtx_);
139 30 : requests = std::move(requests_);
140 30 : }
141 30 : for (auto& req : requests)
142 0 : req->cancel();
143 30 : }
144 :
145 : void
146 30 : NameDirectory::load()
147 : {
148 30 : loadCache();
149 30 : }
150 :
151 : std::string
152 10 : canonicalName(const std::string& url)
153 : {
154 10 : std::string name = url;
155 10 : std::transform(name.begin(), name.end(), name.begin(), ::tolower);
156 10 : if (name.find("://") == std::string::npos)
157 0 : name = "https://" + name;
158 10 : return name;
159 0 : }
160 :
161 : NameDirectory&
162 1036 : NameDirectory::instance(const std::string& serverUrl, std::shared_ptr<dht::Logger> l)
163 : {
164 2062 : const std::string& s = serverUrl.empty() ? DEFAULT_SERVER_HOST : canonicalName(serverUrl);
165 : static std::mutex instanceMtx {};
166 :
167 1036 : std::lock_guard lock(instanceMtx);
168 1036 : static std::map<std::string, NameDirectory> instances {};
169 1036 : auto it = instances.find(s);
170 1036 : if (it != instances.end())
171 1006 : return it->second;
172 30 : auto r = instances.emplace(std::piecewise_construct, std::forward_as_tuple(s), std::forward_as_tuple(s, l));
173 30 : if (r.second)
174 30 : r.first->second.load();
175 30 : return r.first->second;
176 1036 : }
177 :
178 : void
179 1017 : NameDirectory::setHeaderFields(Request& request)
180 : {
181 1017 : request.set_header_field(restinio::http_field_t::user_agent,
182 2034 : fmt::format("Jami ({}/{})", jami::platform(), jami::arch()));
183 2034 : request.set_header_field(restinio::http_field_t::accept, "*/*");
184 1017 : request.set_header_field(restinio::http_field_t::content_type, "application/json");
185 1017 : }
186 :
187 : void
188 1014 : NameDirectory::lookupAddress(const std::string& addr, LookupCallback cb)
189 : {
190 1014 : auto cacheResult = nameCache(addr);
191 1014 : if (not cacheResult.first.empty()) {
192 1 : cb(cacheResult.first, cacheResult.second, Response::found);
193 1 : return;
194 : }
195 1013 : auto res = resolver();
196 1013 : auto request = std::make_shared<Request>(*httpContext_, res, serverUrl_ + QUERY_ADDR + addr);
197 : try {
198 1013 : request->set_method(restinio::http_method_get());
199 1013 : setHeaderFields(*request);
200 1013 : request->add_on_done_callback([this, res, cb, addr](const dht::http::Response& response) {
201 1013 : if (response.status_code == 0)
202 0 : dropResolver(res);
203 1013 : if (response.status_code > 400 && response.status_code < 500) {
204 1011 : auto cacheResult = nameCache(addr);
205 1011 : if (not cacheResult.first.empty())
206 1 : cb(cacheResult.first, cacheResult.second, Response::found);
207 : else
208 5050 : cb("", "", Response::notFound);
209 1013 : } else if (response.status_code == 400)
210 5 : cb("", "", Response::invalidResponse);
211 1 : else if (response.status_code != 200) {
212 0 : JAMI_ERROR("Address lookup for {} on {} failed with code={}", addr, serverUrl_, response.status_code);
213 0 : cb("", "", Response::error);
214 : } else {
215 : try {
216 1 : Json::Value json;
217 1 : if (!json::parse(response.body, json)) {
218 0 : cb("", "", Response::error);
219 0 : return;
220 : }
221 1 : auto name = json["name"].asString();
222 1 : if (name.empty()) {
223 0 : cb(name, addr, Response::notFound);
224 0 : return;
225 : }
226 1 : JAMI_DEBUG("Found name for {}: {}", addr, name);
227 : {
228 1 : std::lock_guard l(cacheLock_);
229 1 : addrCache_.emplace(name, std::pair(name, addr));
230 1 : nameCache_.emplace(addr, std::pair(name, addr));
231 1 : scheduleCacheSave();
232 1 : }
233 1 : cb(name, addr, Response::found);
234 1 : } catch (const std::exception& e) {
235 0 : JAMI_ERROR("Error when performing address lookup: {}", e.what());
236 0 : cb("", "", Response::error);
237 0 : }
238 : }
239 1013 : std::lock_guard lk(requestsMtx_);
240 1013 : if (auto req = response.request.lock())
241 1013 : requests_.erase(req);
242 1013 : });
243 : {
244 1013 : std::lock_guard lk(requestsMtx_);
245 1013 : requests_.emplace(request);
246 1013 : }
247 1013 : request->send();
248 0 : } catch (const std::exception& e) {
249 0 : JAMI_ERROR("Error when performing address lookup: {}", e.what());
250 : {
251 0 : std::lock_guard lk(requestsMtx_);
252 0 : requests_.erase(request);
253 0 : }
254 : // The request will never complete, so answer here: callers must always get a reply.
255 0 : cb("", "", Response::error);
256 0 : }
257 1014 : }
258 :
259 : bool
260 0 : NameDirectory::verify(const std::string& name, const dht::crypto::PublicKey& pk, const std::string& signature)
261 : {
262 0 : return pk.checkSignature(std::vector<uint8_t>(name.begin(), name.end()), base64::decode(signature));
263 : }
264 :
265 : void
266 3 : NameDirectory::lookupName(const std::string& name, LookupCallback cb)
267 : {
268 3 : auto cacheResult = addrCache(name);
269 3 : if (not cacheResult.first.empty()) {
270 0 : cb(cacheResult.first, cacheResult.second, Response::found);
271 0 : return;
272 : }
273 3 : auto encodedName = urlEncode(name);
274 3 : auto res = resolver();
275 3 : auto request = std::make_shared<Request>(*httpContext_, res, serverUrl_ + QUERY_NAME + encodedName);
276 : try {
277 3 : request->set_method(restinio::http_method_get());
278 3 : setHeaderFields(*request);
279 3 : request->add_on_done_callback([this, res, name, cb](const dht::http::Response& response) {
280 3 : if (response.status_code == 0)
281 0 : dropResolver(res);
282 3 : if (response.status_code > 400 && response.status_code < 500)
283 5 : cb("", "", Response::notFound);
284 2 : else if (response.status_code == 400)
285 5 : cb("", "", Response::invalidResponse);
286 1 : else if (response.status_code < 200 || response.status_code > 299) {
287 0 : JAMI_ERROR("Name lookup for {} on {} failed with code={}", name, serverUrl_, response.status_code);
288 0 : cb("", "", Response::error);
289 : } else {
290 : try {
291 1 : Json::Value json;
292 1 : if (!json::parse(response.body, json)) {
293 0 : cb("", "", Response::error);
294 0 : return;
295 : }
296 1 : auto nameResult = json["name"].asString();
297 1 : auto addr = json["addr"].asString();
298 1 : auto publickey = json["publickey"].asString();
299 1 : auto signature = json["signature"].asString();
300 :
301 1 : if (starts_with(addr, HEX_PREFIX))
302 0 : addr = addr.substr(HEX_PREFIX.size());
303 1 : if (addr.empty()) {
304 0 : cb("", "", Response::notFound);
305 0 : return;
306 : }
307 1 : if (not publickey.empty() and not signature.empty()) {
308 : try {
309 0 : auto pk = dht::crypto::PublicKey(base64::decode(publickey));
310 0 : if (pk.getId().toString() != addr or not verify(nameResult, pk, signature)) {
311 0 : cb("", "", Response::invalidResponse);
312 0 : return;
313 : }
314 0 : } catch (const std::exception& e) {
315 0 : cb("", "", Response::invalidResponse);
316 0 : return;
317 0 : }
318 : }
319 1 : JAMI_DEBUG("Found address for {}: {}", name, addr);
320 : {
321 1 : std::lock_guard l(cacheLock_);
322 1 : addrCache_.emplace(name, std::pair(nameResult, addr));
323 1 : addrCache_.emplace(nameResult, std::pair(nameResult, addr));
324 1 : nameCache_.emplace(addr, std::pair(nameResult, addr));
325 1 : scheduleCacheSave();
326 1 : }
327 1 : cb(nameResult, addr, Response::found);
328 1 : } catch (const std::exception& e) {
329 0 : JAMI_ERROR("Error when performing name lookup: {}", e.what());
330 0 : cb("", "", Response::error);
331 0 : }
332 : }
333 3 : std::lock_guard lk(requestsMtx_);
334 3 : if (auto req = response.request.lock())
335 3 : requests_.erase(req);
336 3 : });
337 : {
338 3 : std::lock_guard lk(requestsMtx_);
339 3 : requests_.emplace(request);
340 3 : }
341 3 : request->send();
342 0 : } catch (const std::exception& e) {
343 0 : JAMI_ERROR("Name lookup for {} failed: {}", name, e.what());
344 : {
345 0 : std::lock_guard lk(requestsMtx_);
346 0 : requests_.erase(request);
347 0 : }
348 : // The request will never complete, so answer here: callers must always get a reply.
349 0 : cb("", "", Response::error);
350 0 : }
351 3 : }
352 :
353 : using Blob = std::vector<uint8_t>;
354 : void
355 1 : NameDirectory::registerName(const std::string& addr,
356 : const std::string& n,
357 : const std::string& owner,
358 : RegistrationCallback cb,
359 : const std::string& signedname,
360 : const std::string& publickey)
361 : {
362 1 : std::string name {n};
363 1 : toLower(name);
364 1 : auto cacheResult = addrCache(name);
365 1 : if (not cacheResult.first.empty()) {
366 0 : if (cacheResult.second == addr)
367 0 : cb(RegistrationResponse::success, name);
368 : else
369 0 : cb(RegistrationResponse::alreadyTaken, name);
370 0 : return;
371 : }
372 : {
373 1 : std::lock_guard l(cacheLock_);
374 1 : if (not pendingRegistrations_.emplace(addr, name).second) {
375 0 : JAMI_WARNING("RegisterName: already registering name {} {}", addr, name);
376 0 : cb(RegistrationResponse::error, name);
377 0 : return;
378 : }
379 1 : }
380 : std::string body = fmt::format("{{\"addr\":\"{}\",\"owner\":\"{}\",\"signature\":\"{}\",\"publickey\":\"{}\"}}",
381 : addr,
382 : owner,
383 : signedname,
384 2 : base64::encode(publickey));
385 :
386 1 : auto encodedName = urlEncode(name);
387 1 : auto res = resolver();
388 1 : auto request = std::make_shared<Request>(*httpContext_, res, serverUrl_ + QUERY_NAME + encodedName);
389 : try {
390 1 : request->set_method(restinio::http_method_post());
391 1 : setHeaderFields(*request);
392 1 : request->set_body(body);
393 :
394 1 : JAMI_WARNING("RegisterName: sending request {} {}", addr, name);
395 :
396 1 : request->add_on_done_callback([this, res, name, addr, cb = std::move(cb)](const dht::http::Response& response) {
397 1 : if (response.status_code == 0)
398 0 : dropResolver(res);
399 : {
400 1 : std::lock_guard l(cacheLock_);
401 1 : pendingRegistrations_.erase(name);
402 1 : }
403 1 : if (response.status_code == 400) {
404 0 : cb(RegistrationResponse::incompleteRequest, name);
405 0 : JAMI_ERROR("RegistrationResponse::incompleteRequest");
406 1 : } else if (response.status_code == 401) {
407 0 : cb(RegistrationResponse::signatureVerificationFailed, name);
408 0 : JAMI_ERROR("RegistrationResponse::signatureVerificationFailed");
409 1 : } else if (response.status_code == 403) {
410 0 : cb(RegistrationResponse::alreadyTaken, name);
411 0 : JAMI_ERROR("RegistrationResponse::alreadyTaken");
412 1 : } else if (response.status_code == 409) {
413 0 : cb(RegistrationResponse::alreadyTaken, name);
414 0 : JAMI_ERROR("RegistrationResponse::alreadyTaken");
415 1 : } else if (response.status_code > 400 && response.status_code < 500) {
416 0 : cb(RegistrationResponse::alreadyTaken, name);
417 0 : JAMI_ERROR("RegistrationResponse::alreadyTaken");
418 1 : } else if (response.status_code < 200 || response.status_code > 299) {
419 0 : cb(RegistrationResponse::error, name);
420 0 : JAMI_ERROR("RegistrationResponse::error");
421 : } else {
422 1 : Json::Value json;
423 1 : std::string err;
424 1 : Json::CharReaderBuilder rbuilder;
425 :
426 1 : auto reader = std::unique_ptr<Json::CharReader>(rbuilder.newCharReader());
427 1 : if (!reader->parse(response.body.data(), response.body.data() + response.body.size(), &json, &err)) {
428 0 : cb(RegistrationResponse::error, name);
429 0 : return;
430 : }
431 1 : auto success = json["success"].asBool();
432 1 : JAMI_DEBUG("Got reply for registration of {} {}: {}", name, addr, success ? "success" : "failure");
433 1 : if (success) {
434 1 : std::lock_guard l(cacheLock_);
435 1 : addrCache_.emplace(name, std::pair(name, addr));
436 1 : nameCache_.emplace(addr, std::pair(name, addr));
437 1 : }
438 1 : cb(success ? RegistrationResponse::success : RegistrationResponse::error, name);
439 1 : }
440 1 : std::lock_guard lk(requestsMtx_);
441 1 : if (auto req = response.request.lock())
442 1 : requests_.erase(req);
443 1 : });
444 : {
445 1 : std::lock_guard lk(requestsMtx_);
446 1 : requests_.emplace(request);
447 1 : }
448 1 : request->send();
449 0 : } catch (const std::exception& e) {
450 0 : JAMI_ERROR("Error when performing name registration: {}", e.what());
451 0 : cb(RegistrationResponse::error, name);
452 : {
453 0 : std::lock_guard l(cacheLock_);
454 0 : pendingRegistrations_.erase(name);
455 0 : }
456 0 : std::lock_guard lk(requestsMtx_);
457 0 : if (request)
458 0 : requests_.erase(request);
459 0 : }
460 1 : }
461 :
462 : void
463 2 : NameDirectory::scheduleCacheSave()
464 : {
465 2 : saveTask_.expires_after(SAVE_INTERVAL);
466 2 : saveTask_.async_wait([this](const asio::error_code& ec) {
467 1 : if (ec)
468 0 : return;
469 1 : saveCache();
470 : });
471 2 : }
472 :
473 : void
474 1 : NameDirectory::saveCache()
475 : {
476 1 : dhtnet::fileutils::recursive_mkdir(fileutils::get_cache_dir() / CACHE_DIRECTORY);
477 1 : std::lock_guard lock(dhtnet::fileutils::getFileLock(cachePath_));
478 1 : std::ofstream file(cachePath_, std::ios::trunc | std::ios::binary);
479 1 : if (!file.is_open()) {
480 0 : JAMI_ERROR("Unable to save cache to {}", cachePath_);
481 0 : return;
482 : }
483 : {
484 1 : std::lock_guard l(cacheLock_);
485 1 : msgpack::pack(file, nameCache_);
486 1 : }
487 1 : JAMI_DEBUG("Saved {:d} name-address mapping(s) to {}", nameCache_.size(), cachePath_);
488 1 : }
489 :
490 : void
491 30 : NameDirectory::loadCache()
492 : {
493 30 : msgpack::unpacker pac;
494 :
495 : // read file
496 : {
497 30 : std::lock_guard lock(dhtnet::fileutils::getFileLock(cachePath_));
498 30 : std::ifstream file(cachePath_);
499 30 : if (!file.is_open()) {
500 30 : JAMI_DEBUG("Unable to load {}", cachePath_);
501 30 : return;
502 : }
503 0 : std::string line;
504 0 : while (std::getline(file, line)) {
505 0 : pac.reserve_buffer(line.size());
506 0 : memcpy(pac.buffer(), line.data(), line.size());
507 0 : pac.buffer_consumed(line.size());
508 : }
509 60 : }
510 :
511 : try {
512 : // load values
513 0 : std::lock_guard l(cacheLock_);
514 0 : msgpack::object_handle oh;
515 0 : if (pac.next(oh))
516 0 : oh.get().convert(nameCache_);
517 0 : for (const auto& m : nameCache_)
518 0 : addrCache_.emplace(m.second.second, m.second);
519 0 : } catch (const msgpack::parse_error& e) {
520 0 : JAMI_ERROR("Error when parsing msgpack object: {}", e.what());
521 0 : } catch (const std::bad_cast& e) {
522 0 : JAMI_ERROR("Error when loading cache: {}", e.what());
523 0 : }
524 :
525 0 : JAMI_DEBUG("Loaded {:d} name-address mapping(s) from cache", nameCache_.size());
526 30 : }
527 :
528 : } // namespace jami
|