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 }