librats 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,635 @@
1
+ /**
2
+ * @file upnp.cpp
3
+ * @brief UPnP IGD port mapping client implementation
4
+ */
5
+
6
+ #include "upnp.h"
7
+ #include "socket.h"
8
+ #include "logger.h"
9
+
10
+ #include <cstring>
11
+ #include <cstdio>
12
+ #include <cstdlib>
13
+ #include <algorithm>
14
+ #include <cctype>
15
+ #include <random>
16
+ #include <sstream>
17
+
18
+ #define LOG_UPNP_DEBUG(message) LOG_DEBUG("upnp", message)
19
+ #define LOG_UPNP_INFO(message) LOG_INFO("upnp", message)
20
+ #define LOG_UPNP_WARN(message) LOG_WARN("upnp", message)
21
+ #define LOG_UPNP_ERROR(message) LOG_ERROR("upnp", message)
22
+
23
+ namespace librats {
24
+
25
+ namespace {
26
+
27
+ // Service types we know how to drive, in preference order.
28
+ const char* kWantedServices[] = {
29
+ "urn:schemas-upnp-org:service:WANIPConnection:2",
30
+ "urn:schemas-upnp-org:service:WANIPConnection:1",
31
+ "urn:schemas-upnp-org:service:WANPPPConnection:1",
32
+ };
33
+
34
+ std::string to_lower(std::string s) {
35
+ std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
36
+ return s;
37
+ }
38
+
39
+ // Case-insensitive search for `needle` in `haystack` starting at `from`.
40
+ size_t ifind(const std::string& haystack, const std::string& needle, size_t from = 0) {
41
+ auto it = std::search(haystack.begin() + from, haystack.end(), needle.begin(), needle.end(),
42
+ [](char a, char b) { return std::tolower((unsigned char)a) == std::tolower((unsigned char)b); });
43
+ if (it == haystack.end()) return std::string::npos;
44
+ return static_cast<size_t>(it - haystack.begin());
45
+ }
46
+
47
+ // Determine which local IPv4 address the OS would use to reach `dest_ip`.
48
+ std::string local_ip_for_destination(const std::string& dest_ip, uint16_t dest_port) {
49
+ socket_t s = ::socket(AF_INET, SOCK_DGRAM, 0);
50
+ if (!is_valid_socket(s)) return "";
51
+ struct sockaddr_in dest;
52
+ std::memset(&dest, 0, sizeof(dest));
53
+ dest.sin_family = AF_INET;
54
+ dest.sin_port = htons(dest_port);
55
+ inet_pton(AF_INET, dest_ip.c_str(), &dest.sin_addr);
56
+
57
+ std::string result;
58
+ if (::connect(s, reinterpret_cast<struct sockaddr*>(&dest), sizeof(dest)) == 0) {
59
+ struct sockaddr_in local;
60
+ socklen_t len = sizeof(local);
61
+ if (::getsockname(s, reinterpret_cast<struct sockaddr*>(&local), &len) == 0) {
62
+ char ip[INET_ADDRSTRLEN];
63
+ if (inet_ntop(AF_INET, &local.sin_addr, ip, sizeof(ip))) {
64
+ result = ip;
65
+ }
66
+ }
67
+ }
68
+ close_socket(s);
69
+ return result;
70
+ }
71
+
72
+ // Perform a blocking HTTP/1.1 request (Connection: close) and return the body.
73
+ // `extra_headers` must each end with CRLF. Returns false on transport failure.
74
+ bool http_request(const std::string& host, uint16_t port, const std::string& method,
75
+ const std::string& path, const std::string& extra_headers,
76
+ const std::string& body, int& status_code, std::string& response_body) {
77
+ socket_t sock = create_tcp_client(host, port, 10000);
78
+ if (!is_valid_socket(sock)) {
79
+ return false;
80
+ }
81
+
82
+ std::ostringstream req;
83
+ req << method << " " << path << " HTTP/1.1\r\n"
84
+ << "Host: " << host << ":" << port << "\r\n"
85
+ << "Connection: close\r\n"
86
+ << extra_headers;
87
+ if (!body.empty()) {
88
+ req << "Content-Length: " << body.size() << "\r\n";
89
+ }
90
+ req << "\r\n" << body;
91
+
92
+ if (send_tcp_string(sock, req.str()) < 0) {
93
+ close_socket(sock);
94
+ return false;
95
+ }
96
+
97
+ std::string raw;
98
+ while (true) {
99
+ auto chunk = receive_tcp_data(sock, 4096);
100
+ if (chunk.empty()) break;
101
+ raw.append(reinterpret_cast<const char*>(chunk.data()), chunk.size());
102
+ if (raw.size() > 256 * 1024) break; // sanity cap for IGD descriptions
103
+ }
104
+ close_socket(sock);
105
+
106
+ if (raw.empty()) return false;
107
+
108
+ // Parse status line
109
+ status_code = 0;
110
+ size_t sp = raw.find(' ');
111
+ if (sp != std::string::npos) {
112
+ status_code = std::atoi(raw.substr(sp + 1, 4).c_str());
113
+ }
114
+
115
+ size_t header_end = raw.find("\r\n\r\n");
116
+ if (header_end == std::string::npos) {
117
+ response_body = "";
118
+ } else {
119
+ response_body = raw.substr(header_end + 4);
120
+ }
121
+ return true;
122
+ }
123
+
124
+ } // anonymous namespace
125
+
126
+ namespace upnp_detail {
127
+
128
+ std::string extract_xml_tag(const std::string& xml, const std::string& tag, size_t from) {
129
+ std::string open = "<" + tag;
130
+ size_t s = ifind(xml, open, from);
131
+ if (s == std::string::npos) return "";
132
+ size_t gt = xml.find('>', s);
133
+ if (gt == std::string::npos) return "";
134
+ std::string close = "</" + tag + ">";
135
+ size_t e = ifind(xml, close, gt + 1);
136
+ if (e == std::string::npos) return "";
137
+ std::string value = xml.substr(gt + 1, e - gt - 1);
138
+ // trim whitespace
139
+ size_t b = value.find_first_not_of(" \t\r\n");
140
+ size_t en = value.find_last_not_of(" \t\r\n");
141
+ if (b == std::string::npos) return "";
142
+ return value.substr(b, en - b + 1);
143
+ }
144
+
145
+ bool parse_http_url(const std::string& url, std::string& host, uint16_t& port, std::string& path) {
146
+ std::string lower = to_lower(url);
147
+ const std::string prefix = "http://";
148
+ if (lower.compare(0, prefix.size(), prefix) != 0) return false;
149
+ size_t host_start = prefix.size();
150
+ size_t path_start = url.find('/', host_start);
151
+ std::string authority = (path_start == std::string::npos)
152
+ ? url.substr(host_start)
153
+ : url.substr(host_start, path_start - host_start);
154
+ path = (path_start == std::string::npos) ? "/" : url.substr(path_start);
155
+ size_t colon = authority.find(':');
156
+ if (colon == std::string::npos) {
157
+ host = authority;
158
+ port = 80;
159
+ } else {
160
+ host = authority.substr(0, colon);
161
+ port = static_cast<uint16_t>(std::atoi(authority.substr(colon + 1).c_str()));
162
+ if (port == 0) port = 80;
163
+ }
164
+ return !host.empty();
165
+ }
166
+
167
+ std::string resolve_control_url(std::string control_url, std::string url_base,
168
+ const std::string& desc_host, uint16_t desc_port) {
169
+ if (control_url.empty()) return "";
170
+
171
+ // Already absolute.
172
+ if (to_lower(control_url).compare(0, 7, "http://") == 0) {
173
+ return control_url;
174
+ }
175
+
176
+ // Ensure a leading slash so we can append to an authority.
177
+ if (control_url.front() != '/') control_url = "/" + control_url;
178
+
179
+ if (!url_base.empty()) {
180
+ // <URLBase> is typically http://host:port[/]; drop a trailing slash so we
181
+ // don't produce a doubled "//" when joining with the rooted control path.
182
+ if (url_base.back() == '/') url_base.pop_back();
183
+ return url_base + control_url;
184
+ }
185
+
186
+ // Fall back to the host the description itself was fetched from.
187
+ return "http://" + desc_host + ":" + std::to_string(desc_port) + control_url;
188
+ }
189
+
190
+ } // namespace upnp_detail
191
+
192
+ UpnpClient::UpnpClient() = default;
193
+
194
+ UpnpClient::~UpnpClient() {
195
+ stop();
196
+ }
197
+
198
+ void UpnpClient::add_mapping(PortMapProtocol protocol, uint16_t internal_port, uint16_t external_port,
199
+ const std::string& description) {
200
+ std::lock_guard<std::mutex> lock(mutex_);
201
+ for (const auto& m : mappings_) {
202
+ if (m.protocol == protocol && m.internal_port == internal_port) {
203
+ return;
204
+ }
205
+ }
206
+ Mapping m;
207
+ m.protocol = protocol;
208
+ m.internal_port = internal_port;
209
+ m.external_port = external_port == 0 ? internal_port : external_port;
210
+ m.description = description;
211
+ mappings_.push_back(m);
212
+ LOG_UPNP_DEBUG("Registered mapping " << to_string(protocol) << " internal=" << internal_port
213
+ << " external=" << m.external_port);
214
+ // Wake the worker to install the new mapping immediately. wake_worker_ must be
215
+ // set under cv_mutex_ (the mutex the worker waits on) so the notification can't
216
+ // be lost in the gap between the worker evaluating its predicate and blocking.
217
+ if (running_.load()) {
218
+ {
219
+ std::lock_guard<std::mutex> lk(cv_mutex_);
220
+ wake_worker_ = true;
221
+ }
222
+ cv_.notify_all();
223
+ }
224
+ }
225
+
226
+ std::string UpnpClient::external_ip() const {
227
+ std::lock_guard<std::mutex> lock(mutex_);
228
+ return external_ip_;
229
+ }
230
+
231
+ bool UpnpClient::start() {
232
+ if (running_.exchange(true)) {
233
+ return false;
234
+ }
235
+ stop_requested_.store(false);
236
+ worker_ = std::thread(&UpnpClient::worker_loop, this);
237
+ return true;
238
+ }
239
+
240
+ void UpnpClient::stop() {
241
+ // Guard idempotency on stop_requested_, NOT running_: the worker clears
242
+ // running_ itself when discovery fails (see worker_loop), so gating the join
243
+ // on running_ would skip it and leave the thread joinable — destroying it
244
+ // then calls std::terminate ("terminate called without an active exception").
245
+ if (stop_requested_.exchange(true)) {
246
+ return;
247
+ }
248
+ {
249
+ // Take cv_mutex_ before notifying so a worker about to sleep can't miss the
250
+ // stop request (same lost-wakeup hazard as add_mapping).
251
+ std::lock_guard<std::mutex> lk(cv_mutex_);
252
+ wake_worker_ = true;
253
+ }
254
+ cv_.notify_all();
255
+ wakeup_.signal(); // unblock an in-flight SSDP receive so the join is immediate
256
+ if (worker_.joinable()) {
257
+ worker_.join();
258
+ }
259
+ running_.store(false);
260
+ }
261
+
262
+ void UpnpClient::notify(const Mapping& m, bool success, const std::string& error) {
263
+ if (!callback_) return;
264
+ PortMapResult r;
265
+ r.transport = PortMapTransport::UPnP;
266
+ r.protocol = m.protocol;
267
+ r.success = success;
268
+ r.internal_port = m.internal_port;
269
+ r.external_port = m.external_port;
270
+ {
271
+ std::lock_guard<std::mutex> lock(mutex_);
272
+ r.external_ip = external_ip_;
273
+ }
274
+ r.error = error;
275
+ callback_(r);
276
+ }
277
+
278
+ bool UpnpClient::discover_device(Device& out) {
279
+ socket_t sock = create_udp_socket(0, "", AddressFamily::IPv4);
280
+ if (!is_valid_socket(sock)) {
281
+ LOG_UPNP_ERROR("Failed to create SSDP socket");
282
+ return false;
283
+ }
284
+
285
+ // SSDP M-SEARCH. MX=2 asks devices to spread responses over up to 2 seconds.
286
+ // Search for several targets: IGD v1/v2 directly, and the generic root-device
287
+ // target (some routers only answer the latter). We then validate the WAN
288
+ // service from each device description, so over-broad replies are harmless.
289
+ static const char* kSearchTargets[] = {
290
+ "urn:schemas-upnp-org:device:InternetGatewayDevice:1",
291
+ "urn:schemas-upnp-org:device:InternetGatewayDevice:2",
292
+ "upnp:rootdevice",
293
+ };
294
+ std::vector<std::vector<uint8_t>> payloads;
295
+ for (const char* st : kSearchTargets) {
296
+ std::string msearch =
297
+ "M-SEARCH * HTTP/1.1\r\n"
298
+ "HOST: 239.255.255.250:1900\r\n"
299
+ "MAN: \"ssdp:discover\"\r\n"
300
+ "MX: 2\r\n"
301
+ "ST: " + std::string(st) + "\r\n"
302
+ "\r\n";
303
+ payloads.emplace_back(msearch.begin(), msearch.end());
304
+ }
305
+
306
+ bool found = false;
307
+ std::vector<std::string> tried; // device descriptions already fetched this run
308
+ // Send a few bursts (UDP is lossy) and harvest responses for a few seconds.
309
+ for (int attempt = 0; attempt < 3 && !found && !stop_requested_.load(); ++attempt) {
310
+ for (const auto& payload : payloads) {
311
+ send_udp_data(sock, payload, SSDP_MULTICAST_ADDR, SSDP_PORT, AddressFamily::IPv4);
312
+ }
313
+
314
+ auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
315
+ while (std::chrono::steady_clock::now() < deadline && !stop_requested_.load()) {
316
+ Peer from;
317
+ auto resp = receive_udp_data(sock, 2048, from, 1000, wakeup_.fd());
318
+ if (resp.empty()) continue;
319
+
320
+ std::string text(reinterpret_cast<const char*>(resp.data()), resp.size());
321
+ size_t loc = ifind(text, "location:");
322
+ if (loc == std::string::npos) continue;
323
+ size_t value_start = loc + std::string("location:").size();
324
+ size_t line_end = text.find("\r\n", value_start);
325
+ std::string location = text.substr(value_start, line_end - value_start);
326
+ // trim
327
+ size_t b = location.find_first_not_of(" \t");
328
+ size_t e = location.find_last_not_of(" \t\r\n");
329
+ if (b == std::string::npos) continue;
330
+ location = location.substr(b, e - b + 1);
331
+
332
+ // The broadened search may surface non-IGD root devices and duplicate
333
+ // replies; fetch each unique description at most once.
334
+ if (std::find(tried.begin(), tried.end(), location) != tried.end()) continue;
335
+ tried.push_back(location);
336
+
337
+ std::string local_ip = local_ip_for_destination(from.ip, from.port);
338
+ LOG_UPNP_DEBUG("SSDP reply from " << from.ip << " location=" << location);
339
+ if (fetch_description(location, local_ip, out)) {
340
+ found = true;
341
+ break;
342
+ }
343
+ }
344
+ }
345
+
346
+ close_socket(sock);
347
+ return found;
348
+ }
349
+
350
+ bool UpnpClient::fetch_description(const std::string& location, const std::string& local_ip, Device& out) {
351
+ std::string host, path;
352
+ uint16_t port = 0;
353
+ if (!upnp_detail::parse_http_url(location, host, port, path)) {
354
+ return false;
355
+ }
356
+
357
+ int status = 0;
358
+ std::string body;
359
+ if (!http_request(host, port, "GET", path, "", "", status, body) || status != 200) {
360
+ LOG_UPNP_DEBUG("Failed to fetch device description from " << location << " (status " << status << ")");
361
+ return false;
362
+ }
363
+
364
+ // Find a usable WAN connection service and its control URL.
365
+ std::string service_type, control_url;
366
+ size_t pos = 0;
367
+ while (true) {
368
+ size_t svc_start = ifind(body, "<service", pos);
369
+ if (svc_start == std::string::npos) break;
370
+ size_t svc_end = ifind(body, "</service>", svc_start);
371
+ if (svc_end == std::string::npos) break;
372
+ std::string segment = body.substr(svc_start, svc_end - svc_start);
373
+ std::string type = upnp_detail::extract_xml_tag(segment, "serviceType");
374
+ for (const char* wanted : kWantedServices) {
375
+ if (to_lower(type) == to_lower(wanted)) {
376
+ std::string ctrl = upnp_detail::extract_xml_tag(segment, "controlURL");
377
+ if (!ctrl.empty()) {
378
+ service_type = type;
379
+ control_url = ctrl;
380
+ }
381
+ break;
382
+ }
383
+ }
384
+ if (!service_type.empty()) break;
385
+ pos = svc_end + 1;
386
+ }
387
+
388
+ if (service_type.empty() || control_url.empty()) {
389
+ LOG_UPNP_DEBUG("No WAN connection service found at " << location);
390
+ return false;
391
+ }
392
+
393
+ // Resolve control URL (absolute, root-relative or relative to <URLBase>).
394
+ std::string control_absolute = upnp_detail::resolve_control_url(
395
+ control_url, upnp_detail::extract_xml_tag(body, "URLBase"), host, port);
396
+
397
+ std::string c_host, c_path;
398
+ uint16_t c_port = 0;
399
+ if (!upnp_detail::parse_http_url(control_absolute, c_host, c_port, c_path)) {
400
+ return false;
401
+ }
402
+
403
+ out.control_url = control_absolute;
404
+ out.service_type = service_type;
405
+ out.control_host = c_host;
406
+ out.control_port = c_port;
407
+ out.control_path = c_path;
408
+ out.local_ip = !local_ip.empty() ? local_ip : local_ip_for_destination(c_host, c_port);
409
+
410
+ LOG_UPNP_INFO("Found UPnP IGD: service=" << service_type << " control=" << control_absolute
411
+ << " localIP=" << out.local_ip);
412
+ return out.valid();
413
+ }
414
+
415
+ bool UpnpClient::soap_action(const Device& dev, const std::string& action,
416
+ const std::string& body_args, std::string& response_body,
417
+ int* upnp_error) {
418
+ if (upnp_error) *upnp_error = 0;
419
+
420
+ std::ostringstream soap;
421
+ soap << "<?xml version=\"1.0\"?>\r\n"
422
+ << "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" "
423
+ << "s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">"
424
+ << "<s:Body><u:" << action << " xmlns:u=\"" << dev.service_type << "\">"
425
+ << body_args
426
+ << "</u:" << action << "></s:Body></s:Envelope>";
427
+ std::string body = soap.str();
428
+
429
+ std::ostringstream headers;
430
+ headers << "Content-Type: text/xml; charset=\"utf-8\"\r\n"
431
+ << "SOAPAction: \"" << dev.service_type << "#" << action << "\"\r\n";
432
+
433
+ int status = 0;
434
+ if (!http_request(dev.control_host, dev.control_port, "POST", dev.control_path,
435
+ headers.str(), body, status, response_body)) {
436
+ return false;
437
+ }
438
+
439
+ // UPnP faults are carried in a SOAP body that typically rides on HTTP 500, but
440
+ // some routers answer 200 with a fault too. Parse the body regardless of status.
441
+ std::string err_code = upnp_detail::extract_xml_tag(response_body, "errorCode");
442
+ if (!err_code.empty()) {
443
+ int code = std::atoi(err_code.c_str());
444
+ if (upnp_error) *upnp_error = code;
445
+ LOG_UPNP_DEBUG("SOAP " << action << " returned status " << status
446
+ << " upnp errorCode " << code);
447
+ return false;
448
+ }
449
+
450
+ if (status != 200) {
451
+ LOG_UPNP_DEBUG("SOAP " << action << " returned status " << status);
452
+ return false;
453
+ }
454
+ return true;
455
+ }
456
+
457
+ bool UpnpClient::add_port_mapping(const Device& dev, Mapping& m) {
458
+ // A few routers reject the requested external port (718 conflict / 501 action
459
+ // failed) or only accept permanent leases (725). Mirror libtorrent: retry with
460
+ // a fresh random external port on conflict, and drop to a permanent lease on
461
+ // 725, instead of giving up on the first error.
462
+ static constexpr int kMaxAttempts = 6;
463
+ int last_error = 0;
464
+
465
+ for (int attempt = 0; attempt < kMaxAttempts && !stop_requested_.load(); ++attempt) {
466
+ const uint32_t lease = permanent_lease_only_ ? 0 : lease_duration_;
467
+
468
+ std::ostringstream args;
469
+ args << "<NewRemoteHost></NewRemoteHost>"
470
+ << "<NewExternalPort>" << m.external_port << "</NewExternalPort>"
471
+ << "<NewProtocol>" << to_string(m.protocol) << "</NewProtocol>"
472
+ << "<NewInternalPort>" << m.internal_port << "</NewInternalPort>"
473
+ << "<NewInternalClient>" << dev.local_ip << "</NewInternalClient>"
474
+ << "<NewEnabled>1</NewEnabled>"
475
+ << "<NewPortMappingDescription>" << m.description << "</NewPortMappingDescription>"
476
+ << "<NewLeaseDuration>" << lease << "</NewLeaseDuration>";
477
+
478
+ std::string resp;
479
+ int upnp_error = 0;
480
+ if (soap_action(dev, "AddPortMapping", args.str(), resp, &upnp_error)) {
481
+ m.active = true;
482
+ uint32_t refresh = lease > 0 ? lease / 2 : 1800;
483
+ if (refresh < 60) refresh = 60;
484
+ m.expires = std::chrono::steady_clock::now() + std::chrono::seconds(refresh);
485
+
486
+ LOG_UPNP_INFO("UPnP mapped " << to_string(m.protocol) << " external " << m.external_port
487
+ << " -> " << dev.local_ip << ":" << m.internal_port
488
+ << " (lease " << lease << "s)");
489
+ notify(m, true, "");
490
+ return true;
491
+ }
492
+
493
+ last_error = upnp_error;
494
+
495
+ if (upnp_error == 725 && !permanent_lease_only_) {
496
+ // IGD only supports permanent leases: switch and retry immediately.
497
+ LOG_UPNP_DEBUG("IGD supports permanent leases only; retrying without a lease");
498
+ permanent_lease_only_ = true;
499
+ continue;
500
+ }
501
+
502
+ if (upnp_error == 718 || upnp_error == 501) {
503
+ // External port conflicts with an existing mapping (some routers report
504
+ // 501 Action Failed instead): pick another port and retry.
505
+ static std::mt19937 rng(std::random_device{}());
506
+ std::uniform_int_distribution<int> dist(49152, 65535);
507
+ uint16_t new_port = static_cast<uint16_t>(dist(rng));
508
+ LOG_UPNP_DEBUG("External port " << m.external_port << " conflicts (error " << upnp_error
509
+ << "); retrying with " << new_port);
510
+ m.external_port = new_port;
511
+ continue;
512
+ }
513
+
514
+ // Any other error is not retryable.
515
+ break;
516
+ }
517
+
518
+ std::string err = "AddPortMapping failed";
519
+ if (last_error) err += " (UPnP error " + std::to_string(last_error) + ")";
520
+ LOG_UPNP_WARN("UPnP " << err << " for " << to_string(m.protocol) << " port " << m.internal_port);
521
+ notify(m, false, err);
522
+ return false;
523
+ }
524
+
525
+ bool UpnpClient::delete_port_mapping(const Device& dev, const Mapping& m) {
526
+ std::ostringstream args;
527
+ args << "<NewRemoteHost></NewRemoteHost>"
528
+ << "<NewExternalPort>" << m.external_port << "</NewExternalPort>"
529
+ << "<NewProtocol>" << to_string(m.protocol) << "</NewProtocol>";
530
+ std::string resp;
531
+ bool ok = soap_action(dev, "DeletePortMapping", args.str(), resp);
532
+ if (ok) {
533
+ LOG_UPNP_INFO("UPnP removed mapping " << to_string(m.protocol) << " external " << m.external_port);
534
+ }
535
+ return ok;
536
+ }
537
+
538
+ bool UpnpClient::query_external_ip(const Device& dev) {
539
+ std::string resp;
540
+ if (!soap_action(dev, "GetExternalIPAddress", "", resp)) {
541
+ return false;
542
+ }
543
+ std::string ip = upnp_detail::extract_xml_tag(resp, "NewExternalIPAddress");
544
+ if (!ip.empty()) {
545
+ std::lock_guard<std::mutex> lock(mutex_);
546
+ external_ip_ = ip;
547
+ LOG_UPNP_INFO("UPnP external IP: " << ip);
548
+ return true;
549
+ }
550
+ return false;
551
+ }
552
+
553
+ void UpnpClient::remove_all_mappings(const Device& dev) {
554
+ std::vector<Mapping> snapshot;
555
+ {
556
+ std::lock_guard<std::mutex> lock(mutex_);
557
+ snapshot = mappings_;
558
+ }
559
+ for (const auto& m : snapshot) {
560
+ if (m.active) {
561
+ delete_port_mapping(dev, m);
562
+ }
563
+ }
564
+ }
565
+
566
+ void UpnpClient::worker_loop() {
567
+ LOG_UPNP_DEBUG("UPnP worker started");
568
+
569
+ Device dev;
570
+ if (!discover_device(dev)) {
571
+ LOG_UPNP_WARN("UPnP disabled: no Internet Gateway Device found");
572
+ running_.store(false);
573
+ return;
574
+ }
575
+
576
+ {
577
+ std::lock_guard<std::mutex> lock(mutex_);
578
+ device_ = dev;
579
+ device_found_ = true;
580
+ }
581
+
582
+ query_external_ip(dev);
583
+
584
+ while (!stop_requested_.load()) {
585
+ auto now = std::chrono::steady_clock::now();
586
+
587
+ // Install / refresh mappings that are due.
588
+ std::vector<size_t> indices;
589
+ {
590
+ std::lock_guard<std::mutex> lock(mutex_);
591
+ for (size_t i = 0; i < mappings_.size(); ++i) {
592
+ if (!mappings_[i].active || mappings_[i].expires <= now) {
593
+ indices.push_back(i);
594
+ }
595
+ }
596
+ }
597
+ for (size_t idx : indices) {
598
+ if (stop_requested_.load()) break;
599
+ Mapping local;
600
+ {
601
+ std::lock_guard<std::mutex> lock(mutex_);
602
+ if (idx >= mappings_.size()) continue;
603
+ local = mappings_[idx];
604
+ }
605
+ bool ok = add_port_mapping(dev, local);
606
+ std::lock_guard<std::mutex> lock(mutex_);
607
+ if (idx < mappings_.size() && ok) {
608
+ mappings_[idx].active = local.active;
609
+ mappings_[idx].expires = local.expires;
610
+ mappings_[idx].external_port = local.external_port;
611
+ }
612
+ }
613
+
614
+ // Sleep until the soonest refresh (default: lease/2).
615
+ auto next_wake = std::chrono::steady_clock::now() +
616
+ std::chrono::seconds(lease_duration_ > 0 ? lease_duration_ / 2 : 1800);
617
+ {
618
+ std::lock_guard<std::mutex> lock(mutex_);
619
+ for (const auto& m : mappings_) {
620
+ if (m.active && m.expires < next_wake) {
621
+ next_wake = m.expires;
622
+ }
623
+ }
624
+ }
625
+
626
+ std::unique_lock<std::mutex> lk(cv_mutex_);
627
+ cv_.wait_until(lk, next_wake, [this] { return stop_requested_.load() || wake_worker_; });
628
+ wake_worker_ = false;
629
+ }
630
+
631
+ remove_all_mappings(dev);
632
+ LOG_UPNP_DEBUG("UPnP worker stopped");
633
+ }
634
+
635
+ } // namespace librats