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