1 module natop.upnp; 2 3 import natop.network; 4 import natop.exceptions; 5 6 import std.random : uniform; 7 import std.datetime; 8 import std.exception; 9 import std.algorithm; 10 import std.conv; 11 import std.string; 12 13 14 import vibe.core.core; 15 import vibe.core.net; 16 import vibe.core.log; 17 import vibe.stream.operations; 18 import vibe.http.common; 19 import vibe.inet.message; 20 import vibe.utils.memory; 21 import vibe.http.client; 22 import vibe.stream.memory; 23 24 import vibe.data.xml; 25 26 private string g_userAgent = "NAT-Opener/1.0.0"; 27 28 void setUserAgent(string ua) { 29 g_userAgent = ua; 30 } 31 32 class UPNP : Router { 33 private: 34 string m_baseURL; 35 UDPConnection m_udp; 36 IPRoute m_iproute; 37 XmlNode m_device; 38 39 public: 40 @property string id() { 41 return getXmlNodeValue("UDN").replace("uuid:", "").strip(); 42 43 } 44 45 @property bool hasDevice() const { 46 return m_device !is null; 47 } 48 49 this(IPRoute iproute) { 50 m_iproute = iproute; 51 bool has_error; 52 int retries; 53 do { 54 55 has_error = false; 56 ushort port = uniform(2000,65000).to!ushort; 57 try { 58 m_udp = listenUDP(port, iproute.destination.toAddressString()); 59 } 60 catch (Exception e) { 61 logError("Could not bind to port [%d]: %s", port, e.msg); 62 has_error = true; 63 } 64 enforce(++retries < 100); 65 } while (has_error); 66 retries = 0; 67 while(!m_udp.canBroadcast || has_error) { 68 has_error = false; 69 try { 70 m_udp.canBroadcast = true; 71 } catch (Exception e) { 72 has_error = true; 73 sleep(1.seconds); 74 } 75 enforce (++retries < 10); 76 } 77 m_udp.connect("239.255.255.250", 1900); 78 } 79 80 ~this() { 81 try m_udp.close(); catch (Exception e) { 82 logError("Error in UPNP Dtor: %s", e.msg); 83 } 84 } 85 86 void discover() { 87 string query = "M-SEARCH * HTTP/1.1\r\n" 88 "HOST: 239.255.255.250:1900\r\n" 89 "ST: upnp:rootdevice\r\n" 90 "MAN: ssdp:discover\r\n" 91 "MX: 1\r\n\r\n"; 92 ubyte[] data = new ubyte[](4096); 93 auto start = Clock.currTime(UTC()); 94 95 while(Clock.currTime(UTC()) - start < 5.seconds) { 96 m_udp.send(cast(ubyte[])query); 97 NetworkAddress peer; 98 // wait for packet up to 1 sec 99 ubyte[] bytes = m_udp.recv(1.seconds, data, &peer); 100 101 static if (LOG) logInfo("Discover response received from %s: %s", peer.toString(), cast(string)bytes); 102 103 if (peer.toAddressString() != m_iproute.gateway.toAddressString()) { 104 static if (LOG) logInfo("Got unmatched peer: %s != %s", peer.toAddressString(), m_iproute.gateway.toAddressString()); 105 continue; 106 } 107 if (peer.port == 0) { 108 static if (LOG) logInfo("Got zero port"); 109 continue; 110 } 111 112 // process packet 113 if (handlePacket(bytes)) 114 break; 115 } 116 } 117 118 void deleteMapping(ushort external_port, bool is_tcp = true) { 119 string soap_action = "DeletePortMapping"; 120 string query = format(`<?xml version="1.0"?> 121 <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" 122 s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"> 123 <s:Body><u:%s xmlns:u="%s"> 124 <NewExternalPort>%u</NewExternalPort> 125 <NewProtocol>%s</NewProtocol> 126 </u:%s></s:Body></s:Envelope>`, 127 soap_action, serviceType, external_port, 128 (is_tcp ? "TCP":"UDP"), soap_action); 129 130 131 string response_data; 132 requestHTTP(controlURL, (scope HTTPClientRequest req) { 133 req.method = HTTPMethod.POST; 134 req.httpVersion = HTTPVersion.HTTP_1_0; 135 req.headers.remove("Accept-Encoding"); 136 req.headers.remove("Connection"); 137 req.headers["Soapaction"] = "\"" ~ serviceType ~ "#" ~ soap_action ~ "\""; 138 req.writeBody(query, "text/xml; charset=\"utf-8\""); 139 }, (scope HTTPClientResponse res) { 140 enforce(res.statusCode == 200, "Mapping failed: " ~ res.statusPhrase); 141 response_data = cast(string)res.bodyReader.readAll(); 142 static if (LOG) logInfo("Tried to delete mapping, got: %s", response_data); 143 }); 144 validateMappingResponse(readDocument(response_data)); 145 } 146 147 void createMapping(ushort local_port, ushort external_port, bool is_tcp = true) { 148 static if (LOG) logInfo("Creating mapping"); 149 string soap_action = "AddPortMapping"; 150 string query = format(`<?xml version="1.0"?> 151 <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" 152 s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"> 153 <s:Body><u:%s xmlns:u="%s"> 154 <NewRemoteHost></NewRemoteHost> 155 <NewExternalPort>%u</NewExternalPort> 156 <NewProtocol>%s</NewProtocol> 157 <NewInternalPort>%u</NewInternalPort> 158 <NewInternalClient>%s</NewInternalClient> 159 <NewEnabled>1</NewEnabled> 160 <NewPortMappingDescription>%s</NewPortMappingDescription> 161 <NewLeaseDuration>0</NewLeaseDuration> 162 </u:%s></s:Body></s:Envelope>`, soap_action, serviceType, external_port, 163 (is_tcp ? "TCP":"UDP"), 164 local_port, m_iproute.destination.toAddressString(), g_userAgent, soap_action); 165 166 string response_data; 167 static if (LOG) logInfo("Control URL: %s", controlURL); 168 static if (LOG) logInfo("Query: %s", query); 169 requestHTTP(controlURL, (scope HTTPClientRequest req) { 170 req.method = HTTPMethod.POST; 171 req.httpVersion = HTTPVersion.HTTP_1_0; 172 req.headers.remove("Accept-Encoding"); 173 req.headers.remove("Connection"); 174 req.headers["Soapaction"] = "\"" ~ serviceType ~ "#" ~ soap_action ~ "\""; 175 req.writeBody(query, "text/xml; charset=\"utf-8\""); 176 }, (scope HTTPClientResponse res) { 177 enforce(res.statusCode == 200, "Mapping failed: " ~ res.statusPhrase); 178 response_data = cast(string)res.bodyReader.readAll(); 179 static if (LOG) logInfo("Tried to create mapping, got: %s", response_data); 180 }); 181 validateMappingResponse(readDocument(response_data)); 182 } 183 private: 184 185 @property string serviceType() { 186 return getXmlNodeValue("serviceType"); 187 } 188 189 @property string controlURL() { 190 string ctl_url = getXmlNodeValue("controlURL"); 191 enforce(ctl_url.length > 0, "No Control URL Found"); 192 if (ctl_url[0] == '/') 193 return m_baseURL ~ ctl_url; 194 return ctl_url; 195 } 196 197 string getXmlNodeValue(string key) { 198 XmlNode[] matches = m_device.parseXPath("//" ~ key); 199 enforce(matches.length > 0, "No " ~ key ~ " found for Device"); 200 return matches[0].getCData(); 201 } 202 bool handlePacket(ubyte[] bytes) { 203 204 auto bytes_stream = new MemoryStream(bytes, false); 205 string stln = cast(string)readLine(bytes_stream, 4096, "\r\n", defaultAllocator()); 206 auto httpVersion = parseHTTPVersion(stln); 207 enforce(stln.startsWith(" ")); 208 stln = stln[1 .. $]; 209 auto statusCode = parse!int(stln); 210 211 if (statusCode != 200) 212 return false; 213 214 string statusPhrase; 215 if( stln.length > 0 ){ 216 enforce(stln.startsWith(" ")); 217 stln = stln[1 .. $]; 218 statusPhrase = stln; 219 } 220 221 InetHeaderMap headers; 222 // read headers until an empty line is hit 223 parseRFC5322Header(bytes_stream, headers, 4096, defaultAllocator(), false); 224 225 if (headers.get("ST").indexOf("rootdevice", CaseSensitive.no) == -1) 226 return false; 227 if (headers.get("USN").indexOf("rootdevice", CaseSensitive.no) == -1) 228 return false; 229 XmlNode xml_doc; 230 URL base_url = URL.parse(headers.get("Location")); 231 m_baseURL = format("%s://%s:%d", base_url.schema, base_url.host, base_url.port); 232 requestHTTP(headers.get("Location"), (scope HTTPClientRequest req) { 233 req.httpVersion = HTTPVersion.HTTP_1_0; 234 }, (scope HTTPClientResponse res) { 235 auto response_text = cast(string) res.bodyReader().readAll(); 236 xml_doc = readDocument(response_text); 237 }); 238 //if (xml_doc) static if (LOG) logInfo("Got document: %s", xml_doc.toPrettyString()); 239 240 m_device = findInternetDevice(xml_doc); 241 if (m_device) static if (LOG) logInfo("Got device: %s", m_device.toPrettyString()); 242 return m_device !is null; 243 244 } 245 246 XmlNode findInternetDevice(XmlNode xml_doc) { 247 XmlNode[] list = xml_doc.parseXPath("//device"); 248 foreach (i, XmlNode el; list) { 249 XmlNode[] service_types = el.parseXPath("/serviceList/service/serviceType"); 250 if (service_types.length > 0) { 251 string service_type = service_types[0].getCData(); 252 static if (LOG) logInfo("Service type: %s", service_type); 253 if (service_type.indexOf("WANIPConnection", CaseSensitive.no) != -1 || service_type.indexOf("WANPPPConnection", CaseSensitive.no) != -1) 254 return el; 255 } 256 } 257 return null; 258 } 259 260 261 void validateMappingResponse(XmlNode response_xml) { 262 XmlNode[] xml_error_code = response_xml.parseXPath("//errorCode"); 263 if (xml_error_code.length == 0) return; 264 265 int error_code = xml_error_code[0].getCData().to!int; 266 // handle errors 267 268 switch (error_code) 269 { 270 case 725: 271 // permanent leases only 272 throw new NATOPException("Lease durations unsupported in the router"); 273 case 718: 274 case 727: 275 // conflict in mapping 276 throw new PortConflictException(getUPNPError(727)); 277 case 716: 278 // cannot be wildcard on external port, use random 279 throw new NATOPException("Wildcard ports unsupported in the router"); 280 default: 281 string error_msg = getUPNPError(error_code); 282 283 XmlNode[] xml_error_msg = response_xml.parseXPath("//errorDescription"); 284 if (xml_error_msg.length > 0) 285 error_msg = xml_error_msg[0].getCData(); 286 287 throw new NATOPException(format("Got Error %d: %s", error_code, error_msg)); 288 } 289 290 } 291 292 } 293 294 struct UPNPErrorCode 295 { 296 int code; 297 string msg; 298 } 299 300 package: 301 302 UPNPErrorCode[] g_errorCodes = [ 303 {402, "Invalid Arguments"}, 304 {501, "Action Failed"}, 305 {714, "The specified value does not exist in the array"}, 306 {715, "The source IP address cannot be wild-carded"}, 307 {716, "The external port cannot be wild-carded"}, 308 {718, "The port mapping entry specified conflicts with a mapping assigned previously to another client"}, 309 {724, "Internal and External port values must be the same"}, 310 {725, "The NAT implementation only supports permanent lease times on port mappings"}, 311 {726, "RemoteHost must be a wildcard and cannot be a specific IP address or DNS name"}, 312 {727, "ExternalPort must be a wildcard and cannot be a specific port "} 313 ]; 314 315 string getUPNPError(int code) { 316 foreach (error; g_errorCodes) { 317 if (error.code == code) 318 return error.msg; 319 } 320 return ""; 321 }