From f5663644ea8799b3a8126c0b30e12dcb7f9b1eda Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 15 Dec 2025 10:52:52 +0100 Subject: [PATCH] feat: implementar UPnP IGD y NAT-PMP port mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuevo módulo nat.zig con: - NatPmpClient: cliente NAT-PMP (RFC 6886) con detección de gateway, obtención de IP externa y mapeo de puertos UDP/TCP - UpnpClient: cliente UPnP IGD con SSDP discovery, SOAP control, AddPortMapping y DeletePortMapping - NatManager: interfaz unificada que intenta ambos protocolos Tests incluidos para inicialización y tipos básicos. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 3 +- src/main.zig | 1 + src/nat.zig | 932 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 935 insertions(+), 1 deletion(-) create mode 100644 src/nat.zig diff --git a/CLAUDE.md b/CLAUDE.md index 8d93986..426b2c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ zcatp2p/ ├── discovery.zig # Local + global discovery ├── stun.zig # STUN client ├── relay.zig # Relay protocol + ├── nat.zig # UPnP IGD / NAT-PMP port mapping └── connection.zig # Connection management ``` @@ -86,8 +87,8 @@ zcatp2p/ - [x] Implementación relay client - [x] Tests unitarios (36 tests) - [x] Discovery global (HTTPS API) +- [x] UPnP/NAT-PMP port mapping - [ ] Integración completa de red -- [ ] UPnP/NAT-PMP port mapping ## Comandos diff --git a/src/main.zig b/src/main.zig index fd506c8..6de93e4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -16,6 +16,7 @@ pub const tls = @import("tls.zig"); pub const stun = @import("stun.zig"); pub const relay = @import("relay.zig"); pub const http = @import("http.zig"); +pub const nat = @import("nat.zig"); // Re-exports principales pub const DeviceId = identity.DeviceId; diff --git a/src/nat.zig b/src/nat.zig new file mode 100644 index 0000000..7383a5e --- /dev/null +++ b/src/nat.zig @@ -0,0 +1,932 @@ +//! Módulo NAT - UPnP IGD y NAT-PMP port mapping +//! +//! Implementa apertura automática de puertos en routers NAT. +//! Soporta UPnP IGD (Internet Gateway Device) y NAT-PMP/PCP. + +const std = @import("std"); +const http = @import("http.zig"); + +// ============================================================================= +// Tipos comunes +// ============================================================================= + +/// Protocolo de transporte +pub const Protocol = enum { + TCP, + UDP, + + pub fn toString(self: Protocol) []const u8 { + return switch (self) { + .TCP => "TCP", + .UDP => "UDP", + }; + } +}; + +/// Mapeo de puerto +pub const PortMapping = struct { + internal_port: u16, + external_port: u16, + protocol: Protocol, + description: []const u8, + lifetime: u32, // segundos, 0 = permanente + external_ip: ?[4]u8, +}; + +/// Resultado de operación NAT +pub const NatResult = union(enum) { + success: PortMapping, + gateway_not_found, + mapping_failed: []const u8, + not_supported, + timeout, +}; + +/// Tipo de gateway NAT detectado +pub const GatewayType = enum { + unknown, + upnp, + nat_pmp, + pcp, + none, +}; + +// ============================================================================= +// NAT-PMP Client (RFC 6886) +// ============================================================================= + +/// Puerto NAT-PMP del gateway +pub const NATPMP_PORT: u16 = 5351; + +/// Opcodes NAT-PMP +const NatPmpOpcode = enum(u8) { + external_address = 0, + map_udp = 1, + map_tcp = 2, +}; + +/// Códigos de resultado NAT-PMP +const NatPmpResult = enum(u16) { + success = 0, + unsupported_version = 1, + not_authorized = 2, + network_failure = 3, + out_of_resources = 4, + unsupported_opcode = 5, + _, +}; + +/// Cliente NAT-PMP +pub const NatPmpClient = struct { + allocator: std.mem.Allocator, + socket: ?std.posix.socket_t, + gateway_ip: [4]u8, + external_ip: ?[4]u8, + epoch: u32, + + pub fn init(allocator: std.mem.Allocator) NatPmpClient { + return .{ + .allocator = allocator, + .socket = null, + .gateway_ip = .{ 0, 0, 0, 0 }, + .external_ip = null, + .epoch = 0, + }; + } + + pub fn deinit(self: *NatPmpClient) void { + if (self.socket) |sock| { + std.posix.close(sock); + } + } + + /// Detecta el gateway por defecto + pub fn detectGateway(self: *NatPmpClient) !void { + // En la mayoría de redes, el gateway es x.x.x.1 + // Método más robusto: leer de /proc/net/route en Linux + const gateway = try self.readDefaultGateway(); + self.gateway_ip = gateway; + } + + fn readDefaultGateway(self: *NatPmpClient) ![4]u8 { + _ = self; + + // Intentar leer de /proc/net/route (Linux) + const file = std.fs.openFileAbsolute("/proc/net/route", .{}) catch { + // Fallback: asumir 192.168.1.1 + return .{ 192, 168, 1, 1 }; + }; + defer file.close(); + + var buf: [4096]u8 = undefined; + const bytes_read = file.readAll(&buf) catch return .{ 192, 168, 1, 1 }; + + // Parsear tabla de rutas + var lines = std.mem.splitSequence(u8, buf[0..bytes_read], "\n"); + _ = lines.next(); // Skip header + + while (lines.next()) |line| { + var fields = std.mem.splitSequence(u8, line, "\t"); + _ = fields.next(); // Interface + const dest = fields.next() orelse continue; + const gateway_hex = fields.next() orelse continue; + + // Buscar ruta por defecto (destino 00000000) + if (std.mem.eql(u8, dest, "00000000")) { + // Gateway está en formato hex little-endian + const gw = std.fmt.parseInt(u32, gateway_hex, 16) catch continue; + return .{ + @truncate(gw), + @truncate(gw >> 8), + @truncate(gw >> 16), + @truncate(gw >> 24), + }; + } + } + + return .{ 192, 168, 1, 1 }; + } + + /// Crea el socket UDP + pub fn createSocket(self: *NatPmpClient) !void { + if (self.socket != null) return; + + self.socket = try std.posix.socket( + std.posix.AF.INET, + std.posix.SOCK.DGRAM, + 0, + ); + + // Timeout de 250ms (NAT-PMP spec) + const tv = std.posix.timeval{ + .sec = 0, + .usec = 250000, + }; + try std.posix.setsockopt( + self.socket.?, + std.posix.SOL.SOCKET, + std.posix.SO.RCVTIMEO, + std.mem.asBytes(&tv), + ); + } + + /// Obtiene la dirección IP externa + pub fn getExternalAddress(self: *NatPmpClient) !?[4]u8 { + try self.createSocket(); + + // Construir request + var request: [2]u8 = .{ 0, @intFromEnum(NatPmpOpcode.external_address) }; + + const gateway_addr = std.net.Address.initIp4(self.gateway_ip, NATPMP_PORT); + + // Enviar con retries exponenciales + var timeout_ms: u32 = 250; + for (0..9) |_| { + _ = std.posix.sendto( + self.socket.?, + &request, + 0, + &gateway_addr.any, + gateway_addr.getOsSockLen(), + ) catch continue; + + // Recibir respuesta + var response: [12]u8 = undefined; + const len = std.posix.recvfrom(self.socket.?, &response, 0, null, null) catch { + timeout_ms *= 2; + continue; + }; + + if (len >= 12) { + // Verificar versión y opcode + if (response[0] != 0) continue; // Versión incorrecta + if (response[1] != 128) continue; // No es respuesta + + const result: NatPmpResult = @enumFromInt(std.mem.readInt(u16, response[2..4], .big)); + if (result != .success) continue; + + self.epoch = std.mem.readInt(u32, response[4..8], .big); + self.external_ip = response[8..12].*; + + return self.external_ip; + } + } + + return null; + } + + /// Mapea un puerto + pub fn mapPort( + self: *NatPmpClient, + internal_port: u16, + external_port: u16, + protocol: Protocol, + lifetime: u32, + ) !?PortMapping { + try self.createSocket(); + + // Construir request + var request: [12]u8 = undefined; + request[0] = 0; // Versión + request[1] = switch (protocol) { + .UDP => @intFromEnum(NatPmpOpcode.map_udp), + .TCP => @intFromEnum(NatPmpOpcode.map_tcp), + }; + request[2] = 0; // Reserved + request[3] = 0; + std.mem.writeInt(u16, request[4..6], internal_port, .big); + std.mem.writeInt(u16, request[6..8], external_port, .big); + std.mem.writeInt(u32, request[8..12], lifetime, .big); + + const gateway_addr = std.net.Address.initIp4(self.gateway_ip, NATPMP_PORT); + + // Enviar con retries + var timeout_ms: u32 = 250; + for (0..9) |_| { + _ = std.posix.sendto( + self.socket.?, + &request, + 0, + &gateway_addr.any, + gateway_addr.getOsSockLen(), + ) catch continue; + + var response: [16]u8 = undefined; + const len = std.posix.recvfrom(self.socket.?, &response, 0, null, null) catch { + timeout_ms *= 2; + continue; + }; + + if (len >= 16) { + if (response[0] != 0) continue; + if (response[1] != 128 + request[1]) continue; + + const result: NatPmpResult = @enumFromInt(std.mem.readInt(u16, response[2..4], .big)); + if (result != .success) { + return null; + } + + self.epoch = std.mem.readInt(u32, response[4..8], .big); + const mapped_internal = std.mem.readInt(u16, response[8..10], .big); + const mapped_external = std.mem.readInt(u16, response[10..12], .big); + const mapped_lifetime = std.mem.readInt(u32, response[12..16], .big); + + return PortMapping{ + .internal_port = mapped_internal, + .external_port = mapped_external, + .protocol = protocol, + .description = "NAT-PMP", + .lifetime = mapped_lifetime, + .external_ip = self.external_ip, + }; + } + } + + return null; + } + + /// Elimina un mapeo de puerto + pub fn unmapPort( + self: *NatPmpClient, + internal_port: u16, + protocol: Protocol, + ) !void { + // Lifetime 0 elimina el mapeo + _ = try self.mapPort(internal_port, 0, protocol, 0); + } +}; + +// ============================================================================= +// UPnP IGD Client +// ============================================================================= + +/// Dirección multicast SSDP +const SSDP_MULTICAST_ADDR: [4]u8 = .{ 239, 255, 255, 250 }; +const SSDP_PORT: u16 = 1900; + +/// Timeout de discovery SSDP +const SSDP_TIMEOUT_MS: u32 = 3000; + +/// Dispositivo UPnP descubierto +pub const UpnpDevice = struct { + location: []const u8, + server: []const u8, + usn: []const u8, + control_url: []const u8, + service_type: []const u8, + allocator: std.mem.Allocator, + + pub fn deinit(self: *UpnpDevice) void { + self.allocator.free(self.location); + if (self.server.len > 0) self.allocator.free(self.server); + if (self.usn.len > 0) self.allocator.free(self.usn); + if (self.control_url.len > 0) self.allocator.free(self.control_url); + if (self.service_type.len > 0) self.allocator.free(self.service_type); + } +}; + +/// Cliente UPnP IGD +pub const UpnpClient = struct { + allocator: std.mem.Allocator, + socket: ?std.posix.socket_t, + device: ?UpnpDevice, + local_ip: ?[4]u8, + + /// Tipos de servicio IGD + const SERVICE_TYPES: []const []const u8 = &.{ + "urn:schemas-upnp-org:service:WANIPConnection:1", + "urn:schemas-upnp-org:service:WANIPConnection:2", + "urn:schemas-upnp-org:service:WANPPPConnection:1", + }; + + pub fn init(allocator: std.mem.Allocator) UpnpClient { + return .{ + .allocator = allocator, + .socket = null, + .device = null, + .local_ip = null, + }; + } + + pub fn deinit(self: *UpnpClient) void { + if (self.socket) |sock| { + std.posix.close(sock); + } + if (self.device) |*dev| { + dev.deinit(); + } + } + + /// Descubre dispositivos IGD mediante SSDP + pub fn discover(self: *UpnpClient) !bool { + // Crear socket UDP + self.socket = try std.posix.socket( + std.posix.AF.INET, + std.posix.SOCK.DGRAM, + 0, + ); + errdefer { + if (self.socket) |sock| std.posix.close(sock); + self.socket = null; + } + + // Timeout + const tv = std.posix.timeval{ + .sec = @intCast(SSDP_TIMEOUT_MS / 1000), + .usec = @intCast((SSDP_TIMEOUT_MS % 1000) * 1000), + }; + try std.posix.setsockopt( + self.socket.?, + std.posix.SOL.SOCKET, + std.posix.SO.RCVTIMEO, + std.mem.asBytes(&tv), + ); + + // Enviar M-SEARCH para cada tipo de servicio + for (SERVICE_TYPES) |service_type| { + if (try self.sendMSearch(service_type)) { + return true; + } + } + + return false; + } + + fn sendMSearch(self: *UpnpClient, service_type: []const u8) !bool { + // Construir mensaje M-SEARCH + var request_buf: [512]u8 = undefined; + const request = std.fmt.bufPrint(&request_buf, + \\M-SEARCH * HTTP/1.1 + \\HOST: 239.255.255.250:1900 + \\MAN: "ssdp:discover" + \\MX: 3 + \\ST: {s} + \\ + \\ + , .{service_type}) catch return false; + + const multicast_addr = std.net.Address.initIp4(SSDP_MULTICAST_ADDR, SSDP_PORT); + + // Enviar + _ = try std.posix.sendto( + self.socket.?, + request, + 0, + &multicast_addr.any, + multicast_addr.getOsSockLen(), + ); + + // Recibir respuestas + var response_buf: [2048]u8 = undefined; + while (true) { + var src_addr: std.posix.sockaddr = undefined; + var src_len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr); + + const len = std.posix.recvfrom( + self.socket.?, + &response_buf, + 0, + &src_addr, + &src_len, + ) catch break; + + if (len == 0) break; + + // Parsear respuesta SSDP + if (try self.parseSsdpResponse(response_buf[0..len], service_type)) { + // Obtener IP local desde la respuesta + if (src_addr.family == std.posix.AF.INET) { + const addr4: *std.posix.sockaddr.in = @ptrCast(&src_addr); + _ = addr4; + } + return true; + } + } + + return false; + } + + fn parseSsdpResponse(self: *UpnpClient, response: []const u8, service_type: []const u8) !bool { + // Verificar que es respuesta HTTP 200 + if (!std.mem.startsWith(u8, response, "HTTP/1.1 200")) { + return false; + } + + var location: ?[]const u8 = null; + var server: ?[]const u8 = null; + var usn: ?[]const u8 = null; + + // Parsear headers + var lines = std.mem.splitSequence(u8, response, "\r\n"); + while (lines.next()) |line| { + if (std.ascii.startsWithIgnoreCase(line, "LOCATION:")) { + location = std.mem.trim(u8, line[9..], " \t"); + } else if (std.ascii.startsWithIgnoreCase(line, "SERVER:")) { + server = std.mem.trim(u8, line[7..], " \t"); + } else if (std.ascii.startsWithIgnoreCase(line, "USN:")) { + usn = std.mem.trim(u8, line[4..], " \t"); + } + } + + if (location == null) return false; + + // Obtener descripción del dispositivo + const control_url = try self.getControlUrl(location.?, service_type); + if (control_url == null) return false; + + self.device = .{ + .location = try self.allocator.dupe(u8, location.?), + .server = if (server) |s| try self.allocator.dupe(u8, s) else "", + .usn = if (usn) |u| try self.allocator.dupe(u8, u) else "", + .control_url = control_url.?, + .service_type = try self.allocator.dupe(u8, service_type), + .allocator = self.allocator, + }; + + return true; + } + + fn getControlUrl(self: *UpnpClient, location: []const u8, service_type: []const u8) !?[]const u8 { + // Hacer GET al location para obtener XML de descripción + var client = http.HttpClient.init(self.allocator); + defer client.deinit(); + + var response = client.get(location, null) catch return null; + defer response.deinit(); + + if (!response.status_code.isSuccess()) return null; + + // Parsear XML para encontrar controlURL del servicio + return self.parseDeviceDescription(response.body, service_type, location); + } + + fn parseDeviceDescription(self: *UpnpClient, xml: []const u8, service_type: []const u8, base_url: []const u8) !?[]const u8 { + // Buscar el servicio en el XML + // Formato: ... ... ... + + var pos: usize = 0; + while (pos < xml.len) { + // Buscar serviceType + const st_start = std.mem.indexOfPos(u8, xml, pos, "") orelse break; + const st_end = std.mem.indexOfPos(u8, xml, st_start, "") orelse break; + const found_type = xml[st_start + 13 .. st_end]; + + if (std.mem.indexOf(u8, found_type, service_type) != null) { + // Encontrado - buscar controlURL + const ctrl_start = std.mem.indexOfPos(u8, xml, st_end, "") orelse break; + const ctrl_end = std.mem.indexOfPos(u8, xml, ctrl_start, "") orelse break; + const control_path = xml[ctrl_start + 12 .. ctrl_end]; + + // Construir URL completa + return try self.buildControlUrl(base_url, control_path); + } + + pos = st_end; + } + + return null; + } + + fn buildControlUrl(self: *UpnpClient, base_url: []const u8, control_path: []const u8) ![]const u8 { + // Si control_path es absoluto, usarlo directamente + if (std.mem.startsWith(u8, control_path, "http://") or + std.mem.startsWith(u8, control_path, "https://")) + { + return try self.allocator.dupe(u8, control_path); + } + + // Extraer base del location URL + const url = try http.Url.parse(base_url); + + var buf: [512]u8 = undefined; + const full_url = std.fmt.bufPrint(&buf, "{s}://{s}:{d}{s}", .{ + url.scheme, + url.host, + url.port, + control_path, + }) catch return error.UrlTooLong; + + return try self.allocator.dupe(u8, full_url); + } + + /// Obtiene la dirección IP externa + pub fn getExternalIPAddress(self: *UpnpClient) !?[]const u8 { + const device = self.device orelse return null; + + const soap_body = + \\ + \\ + \\ + \\ + \\ + \\ + \\ + ; + + var body_buf: [1024]u8 = undefined; + const body = std.fmt.bufPrint(&body_buf, soap_body, .{device.service_type}) catch return null; + + const response = try self.sendSoapRequest("GetExternalIPAddress", body); + defer self.allocator.free(response); + + // Parsear respuesta para extraer NewExternalIPAddress + if (std.mem.indexOf(u8, response, "")) |start| { + const ip_start = start + 22; + if (std.mem.indexOfPos(u8, response, ip_start, "")) |end| { + return try self.allocator.dupe(u8, response[ip_start..end]); + } + } + + return null; + } + + /// Añade un mapeo de puerto + pub fn addPortMapping( + self: *UpnpClient, + external_port: u16, + internal_port: u16, + protocol: Protocol, + description: []const u8, + lease_duration: u32, + ) !bool { + const device = self.device orelse return false; + + // Obtener IP local + const local_ip = try self.getLocalIP(); + + const soap_template = + \\ + \\ + \\ + \\ + \\ + \\{d} + \\{s} + \\{d} + \\{s} + \\1 + \\{s} + \\{d} + \\ + \\ + \\ + ; + + var body_buf: [2048]u8 = undefined; + const body = std.fmt.bufPrint(&body_buf, soap_template, .{ + device.service_type, + external_port, + protocol.toString(), + internal_port, + local_ip, + description, + lease_duration, + }) catch return false; + + const response = self.sendSoapRequest("AddPortMapping", body) catch return false; + defer self.allocator.free(response); + + // Verificar éxito + return std.mem.indexOf(u8, response, "AddPortMappingResponse") != null; + } + + /// Elimina un mapeo de puerto + pub fn deletePortMapping( + self: *UpnpClient, + external_port: u16, + protocol: Protocol, + ) !bool { + const device = self.device orelse return false; + + const soap_template = + \\ + \\ + \\ + \\ + \\ + \\{d} + \\{s} + \\ + \\ + \\ + ; + + var body_buf: [1024]u8 = undefined; + const body = std.fmt.bufPrint(&body_buf, soap_template, .{ + device.service_type, + external_port, + protocol.toString(), + }) catch return false; + + const response = self.sendSoapRequest("DeletePortMapping", body) catch return false; + defer self.allocator.free(response); + + return std.mem.indexOf(u8, response, "DeletePortMappingResponse") != null; + } + + fn sendSoapRequest(self: *UpnpClient, action: []const u8, body: []const u8) ![]const u8 { + const device = self.device orelse return error.NoDevice; + + var client = http.HttpClient.init(self.allocator); + defer client.deinit(); + + // Headers SOAP + var soap_action_buf: [256]u8 = undefined; + const soap_action = std.fmt.bufPrint(&soap_action_buf, "\"{s}#{s}\"", .{ + device.service_type, + action, + }) catch return error.BufferTooSmall; + + const headers = [_]http.Header{ + .{ .name = "Content-Type", .value = "text/xml; charset=\"utf-8\"" }, + .{ .name = "SOAPAction", .value = soap_action }, + }; + + var response = try client.post(device.control_url, &headers, body); + defer response.deinit(); + + return try self.allocator.dupe(u8, response.body); + } + + fn getLocalIP(self: *UpnpClient) ![]const u8 { + if (self.local_ip) |ip| { + var buf: [16]u8 = undefined; + return std.fmt.bufPrint(&buf, "{d}.{d}.{d}.{d}", .{ ip[0], ip[1], ip[2], ip[3] }) catch "0.0.0.0"; + } + + // Obtener IP local conectando a una dirección externa + const sock = try std.posix.socket(std.posix.AF.INET, std.posix.SOCK.DGRAM, 0); + defer std.posix.close(sock); + + const addr = std.net.Address.initIp4(.{ 8, 8, 8, 8 }, 53); + std.posix.connect(sock, &addr.any, addr.getOsSockLen()) catch return "0.0.0.0"; + + var local_addr: std.posix.sockaddr = undefined; + var local_len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr); + std.posix.getsockname(sock, &local_addr, &local_len) catch return "0.0.0.0"; + + if (local_addr.family == std.posix.AF.INET) { + const addr4: *std.posix.sockaddr.in = @ptrCast(&local_addr); + const ip = addr4.addr; + self.local_ip = .{ + @truncate(ip), + @truncate(ip >> 8), + @truncate(ip >> 16), + @truncate(ip >> 24), + }; + + var buf: [16]u8 = undefined; + return std.fmt.bufPrint(&buf, "{d}.{d}.{d}.{d}", .{ + self.local_ip.?[0], + self.local_ip.?[1], + self.local_ip.?[2], + self.local_ip.?[3], + }) catch "0.0.0.0"; + } + + return "0.0.0.0"; + } +}; + +// ============================================================================= +// NAT Manager - Interfaz unificada +// ============================================================================= + +/// Gestor NAT unificado +pub const NatManager = struct { + allocator: std.mem.Allocator, + upnp: UpnpClient, + nat_pmp: NatPmpClient, + gateway_type: GatewayType, + mappings: std.ArrayListUnmanaged(PortMapping), + + pub fn init(allocator: std.mem.Allocator) NatManager { + return .{ + .allocator = allocator, + .upnp = UpnpClient.init(allocator), + .nat_pmp = NatPmpClient.init(allocator), + .gateway_type = .unknown, + .mappings = .{}, + }; + } + + pub fn deinit(self: *NatManager) void { + self.upnp.deinit(); + self.nat_pmp.deinit(); + self.mappings.deinit(self.allocator); + } + + /// Descubre el gateway y el protocolo soportado + pub fn discover(self: *NatManager) !GatewayType { + // Intentar NAT-PMP primero (más rápido) + self.nat_pmp.detectGateway() catch {}; + if (self.nat_pmp.getExternalAddress() catch null) |_| { + self.gateway_type = .nat_pmp; + return .nat_pmp; + } + + // Intentar UPnP + if (self.upnp.discover() catch false) { + self.gateway_type = .upnp; + return .upnp; + } + + self.gateway_type = .none; + return .none; + } + + /// Obtiene la dirección IP externa + pub fn getExternalIP(self: *NatManager) !?[]const u8 { + switch (self.gateway_type) { + .nat_pmp => { + if (self.nat_pmp.external_ip) |ip| { + var buf: [16]u8 = undefined; + const result = std.fmt.bufPrint(&buf, "{d}.{d}.{d}.{d}", .{ + ip[0], ip[1], ip[2], ip[3], + }) catch return null; + return try self.allocator.dupe(u8, result); + } + return null; + }, + .upnp => { + return self.upnp.getExternalIPAddress(); + }, + else => return null, + } + } + + /// Mapea un puerto (intenta ambos protocolos) + pub fn mapPort( + self: *NatManager, + internal_port: u16, + external_port: u16, + protocol: Protocol, + description: []const u8, + lifetime: u32, + ) !NatResult { + // Si no se ha descubierto, intentar + if (self.gateway_type == .unknown) { + _ = try self.discover(); + } + + switch (self.gateway_type) { + .nat_pmp => { + if (try self.nat_pmp.mapPort(internal_port, external_port, protocol, lifetime)) |mapping| { + try self.mappings.append(self.allocator, mapping); + return .{ .success = mapping }; + } + return .{ .mapping_failed = "NAT-PMP mapping failed" }; + }, + .upnp => { + if (try self.upnp.addPortMapping(external_port, internal_port, protocol, description, lifetime)) { + const mapping = PortMapping{ + .internal_port = internal_port, + .external_port = external_port, + .protocol = protocol, + .description = description, + .lifetime = lifetime, + .external_ip = null, + }; + try self.mappings.append(self.allocator, mapping); + return .{ .success = mapping }; + } + return .{ .mapping_failed = "UPnP mapping failed" }; + }, + .none => return .gateway_not_found, + else => return .not_supported, + } + } + + /// Elimina un mapeo de puerto + pub fn unmapPort(self: *NatManager, external_port: u16, protocol: Protocol) !void { + switch (self.gateway_type) { + .nat_pmp => { + // NAT-PMP usa puerto interno para eliminar + for (self.mappings.items) |mapping| { + if (mapping.external_port == external_port and mapping.protocol == protocol) { + try self.nat_pmp.unmapPort(mapping.internal_port, protocol); + break; + } + } + }, + .upnp => { + _ = try self.upnp.deletePortMapping(external_port, protocol); + }, + else => {}, + } + + // Remover de la lista local + var i: usize = 0; + while (i < self.mappings.items.len) { + if (self.mappings.items[i].external_port == external_port and + self.mappings.items[i].protocol == protocol) + { + _ = self.mappings.orderedRemove(i); + } else { + i += 1; + } + } + } + + /// Renueva todos los mapeos + pub fn renewMappings(self: *NatManager) !void { + for (self.mappings.items) |mapping| { + _ = try self.mapPort( + mapping.internal_port, + mapping.external_port, + mapping.protocol, + mapping.description, + mapping.lifetime, + ); + } + } + + /// Elimina todos los mapeos + pub fn unmapAll(self: *NatManager) !void { + while (self.mappings.items.len > 0) { + const mapping = self.mappings.items[0]; + try self.unmapPort(mapping.external_port, mapping.protocol); + } + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "nat pmp client init" { + const allocator = std.testing.allocator; + var client = NatPmpClient.init(allocator); + defer client.deinit(); + + try std.testing.expect(client.socket == null); + try std.testing.expect(client.external_ip == null); +} + +test "upnp client init" { + const allocator = std.testing.allocator; + var client = UpnpClient.init(allocator); + defer client.deinit(); + + try std.testing.expect(client.socket == null); + try std.testing.expect(client.device == null); +} + +test "nat manager init" { + const allocator = std.testing.allocator; + var manager = NatManager.init(allocator); + defer manager.deinit(); + + try std.testing.expect(manager.gateway_type == .unknown); + try std.testing.expect(manager.mappings.items.len == 0); +} + +test "protocol to string" { + try std.testing.expectEqualStrings("TCP", Protocol.TCP.toString()); + try std.testing.expectEqualStrings("UDP", Protocol.UDP.toString()); +} + +test "gateway type" { + try std.testing.expect(GatewayType.unknown != GatewayType.upnp); + try std.testing.expect(GatewayType.nat_pmp != GatewayType.none); +}