├── .gitignore
├── LICENSE
├── README.md
├── natPMP
├── __init__.py
└── natPMP.py
├── punchVPN.py
├── punchVPN
├── WebConnect.py
├── __init__.py
├── udpKnock.py
└── udpStater.py
├── punchVPNd
└── punchVPNd.py
├── stun
└── __init__.py
└── upnp_igd
└── __init__.py
/.gitignore:
--------------------------------------------------------------------------------
1 | *.py[cod]
2 |
3 | # C extensions
4 | *.so
5 |
6 | # Packages
7 | *.egg
8 | *.egg-info
9 | dist
10 | build
11 | eggs
12 | parts
13 | bin
14 | var
15 | sdist
16 | develop-eggs
17 | .installed.cfg
18 | lib
19 | lib64
20 |
21 | # Installer logs
22 | pip-log.txt
23 |
24 | # Unit test / coverage reports
25 | .coverage
26 | .tox
27 | nosetests.xml
28 |
29 | # Translations
30 | *.mo
31 |
32 | # Mr Developer
33 | .mr.developer.cfg
34 | .project
35 | .pydevproject
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2013 Klaus Heigren, Claus Lensbøl
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | punchVPN
2 | ========
3 |
4 | > Wrapper around openVPN to make p2p VPN with both peers behind nat.
5 |
6 | **punchVPN** aims to create secure tunnels where it is normally not possible
7 | to make tunnes without technical insight. This is done by using a
8 | cobination of UDP hole punching, UPnP-IGD, and NAT-PMP (which is
9 | relatively new)
10 |
11 | All of this is done possible by having a trird party communicating port
12 | numbers and addresses between the two (and for now, only two) clients,
13 | and telling them where and when to connect. This third party is
14 | **punchVPNd**. The third party is also responsible for creating the
15 | symmetric keys shared among the clients.
16 |
17 | Usage:
18 | ------
19 |
20 | ### Server - punchVPNd ###
21 |
22 | The server runs with python2.7 and above and not python3 for now. This is due to
23 | me not figuring out how to get gevent and greenlets run on python3.
24 |
25 | Run the server with:
26 |
27 | python punchVPNd.py
28 |
29 | assuming that python2 is your default python.
30 | The server will listen on http port 8080, and since the keys are being
31 | sent across this channel, you will need to place a reverse proxy in
32 | front of it, such as apache or nginx, with SSL enabled.
33 |
34 | ### Client - punchVPN ###
35 |
36 | The client runs with python3.2 and newer and is run with:
37 |
38 | ./punchVPN.py
39 |
40 | If you do not have a python3 installation this will fail.
41 |
42 | When you have one of the peers connected, that peer will get a token.
43 | Use that token with the '-p' parameter to connect to that peer as
44 | follows:
45 |
46 | ./punchVPN.py -p f3ab
47 |
48 | Other command line parameters are as follows:
49 |
50 | usage: punchVPN.py [-h] [-p PEER] [-a ADDRESS] [--no-vpn] [--no-stun]
51 | [--no-natpmp] [--no-upnpigd] [-v] [-s]
52 |
53 | Client for making p2p VPN connections behind nat
54 |
55 | optional arguments:
56 | -h, --help show this help message and exit
57 | -p PEER, --peer PEER Token of your peer (default: None)
58 | -a ADDRESS, --address ADDRESS
59 | What is the server address? (eg. https://server-
60 | ip:443) (default: http://localhost:8080)
61 | --no-vpn Run with no VPN (for debug) (default: False)
62 | --no-stun Run with no STUN (default: False)
63 | --no-natpmp Run with no nat-PMP (default: False)
64 | --no-upnpigd Run with no UPnP-IGD (default: False)
65 | -v, --verbose Verbose output (default: False)
66 | -s, --silent No output at all (default: False)
67 |
68 | License
69 | =======
70 |
71 | The the included LICENSE file for lincense info
72 |
73 |
74 | Contributing
75 | ============
76 |
77 | Right now, this program is developed as part of a school project.
78 | We would love to have testers but we cannot accept other contributions for
79 | the time being.
80 |
--------------------------------------------------------------------------------
/natPMP/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ['natPMP']
2 | from natPMP.natPMP import natPMP
3 |
--------------------------------------------------------------------------------
/natPMP/natPMP.py:
--------------------------------------------------------------------------------
1 | """
2 | Script for requesting a port redirection via natPMP.
3 | Follows the standart of:
4 | http://tools.ietf.org/html/draft-cheshire-nat-pmp-07
5 | with the exception of hold times. We are not going to wait a little
6 | above 3 minutes to test for natPMP.
7 | """
8 |
9 | import socket
10 | import random
11 | import errno
12 | import os
13 | from subprocess import check_output
14 | from struct import pack, unpack
15 | import logging
16 |
17 | log = logging.getLogger('PunchVPN.nat-pmp')
18 |
19 | class natPMP:
20 |
21 | def __init__(self):
22 | """Make a list to store the mapped ports"""
23 | self.mapped_ports = {}
24 | self.gateway = self.determine_gateway()
25 |
26 | def __del__(self):
27 | self.cleanup()
28 |
29 | def __exit__(self):
30 | self.cleanup()
31 |
32 | def cleanup(self):
33 | if socket and len(self.mapped_ports) > 0:
34 | for mapping in list(self.mapped_ports):
35 | log.info('Delete mapping for port ' + mapping[1])
36 | self.map_external_port(lport=mapping[0], external_port=0, timeout=0)
37 |
38 | def create_payload(self, local_port, external_port, lifetime):
39 | """Create the natPMP payload for opening 'external_port'
40 | (0 means that the GW will choose one randomly) and
41 | redirecting the traffic to client machine.
42 |
43 | int local_port
44 | int external_port (optional)
45 |
46 | return int payload
47 | """
48 |
49 | return pack('>2B3HI', 0, 1, 0, local_port, external_port, lifetime)
50 |
51 | def send_payload(self, s, payload, gateway):
52 | """Encode and send the payload to the gateway of the network
53 |
54 | socket s
55 | int payload
56 | string gateway
57 |
58 | return bool success
59 | """
60 | try:
61 | s.sendto(payload, (gateway, 5351))
62 | success = True
63 | except socket.error as err:
64 | if err.errno != errno.ECONNREFUSED:
65 | raise err
66 | success = False
67 |
68 | return success
69 |
70 | def parse_respons(self, payload):
71 | """Parse the respons from the natPMP device (if any).
72 |
73 | string respons
74 |
75 | return tuple (external_port, lifetime) or False
76 | """
77 |
78 | values = unpack('>2BHI2HI', payload)
79 | if values[2] != 0:
80 | """In this case, we get a status code back and can assume
81 | that we are dealing with a natPMP capable gateway.
82 | If the status code is anything other than 0, setting the
83 | external port failed."""
84 | return False
85 |
86 | # Get lifetime and port using bitmasking
87 | lifetime = values[6]
88 | external_port = values[5]
89 |
90 | return external_port, lifetime
91 |
92 | def determine_gateway(self):
93 | """Determine the gateway of the network as it is
94 | most likely here we will find a natPMP enabled device.
95 |
96 | return string gatweay
97 | """
98 |
99 | if os.name == 'posix':
100 | if os.uname()[0] == 'Linux':
101 | default_gateway = check_output("ip route | awk '/default/ {print $3}'",
102 | shell=True).decode().strip()
103 | elif os.uname()[0] == 'Darwin':
104 | default_gateway = check_output("/usr/sbin/netstat -nr | grep default | awk '{print $2}'",
105 | shell=True).decode().strip()
106 | if not default_gateway.split() == [default_gateway]:
107 | log.info("Two gateways found! - Using the first one "+default_gateway.split()[0])
108 | log.info("If you want to use the other gateway ("+
109 | default_gateway.split()[1]+
110 | "), please unplug or disconnect the network you do not want to use")
111 | default_gateway = default_gateway.split()[0]
112 |
113 | if os.name == 'nt':
114 | """Use WMI for finding the default gateway if we are on windows"""
115 | import wmi
116 | wmi_obj = wmi.WMI()
117 | wmi_sql = "select DefaultIPGateway from Win32_NetworkAdapterConfiguration where IPEnabled=TRUE"
118 | wmi_out = wmi_obj.query( wmi_sql )
119 |
120 | for dev in wmi_out:
121 | default_gateway = dev.DefaultIPGateway[0]
122 |
123 | log.debug('Found gateway ' + default_gateway)
124 |
125 | return default_gateway
126 |
127 | def map_external_port(self, lport=random.randint(1025,65535), external_port=0, timeout=7200):
128 | """Try mapping an external port to an internal port via the natPMP spec
129 | This will also test if the gateway is capable of doing natPMP (timeout based),
130 | and determine the default gateway.
131 |
132 | It is highly recommended that the lport is provided and bound in advance,
133 | as this module will not test if the port is bindable, and will not return the lport.
134 |
135 | If the timeout is set to 0 the mapping will be destroid. In this case the external
136 | port must also be set to 0 and from the draft, is seems that the lport must be the
137 | same as in the time of creation.
138 |
139 | int lport
140 | int external_port
141 | int timeout
142 |
143 | return tuple(external_port, timeout) or False"""
144 |
145 | stimeout = .25
146 |
147 | payload = self.create_payload(lport, external_port, timeout)
148 |
149 | while stimeout < 6:
150 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
151 | s.bind(('',random.randint(1025, 65535)))
152 | s.settimeout(stimeout)
153 |
154 | if self.send_payload(s, payload, self.gateway):
155 | try:
156 | rpayload = s.recvfrom(4096)
157 | except socket.error as err:
158 | if (err.errno and err.errno != errno.ETIMEDOUT) or str(err) != 'timed out':
159 | """For some reason, the timed out error have no errno, although the
160 | errno atrribute is existing (set to None). For this reason, we get this
161 | weird error handeling. It might be a bug in python3.2.3"""
162 | raise err
163 | s = None
164 | else:
165 | if rpayload[1][0] == self.gateway:
166 | if timeout == 0:
167 | del self.mapped_ports[lport]
168 | return True
169 | else:
170 | log.debug('Mapping port ' + respons[0])
171 | respons = self.parse_respons(rpayload[0])
172 | self.mapped_ports[lport] = (lport, respons[0], respons[1])
173 | return respons
174 |
175 | stimeout = stimeout * 2
176 |
177 | log.debug('Gateway '+self.gateway+' does not support nat-pmp')
178 | return False
179 |
180 | def get_external_address(self):
181 | """Use nat-pmp to dertermine the external IPv4 address.
182 |
183 | This method relies on knowing that we have a working nat-pmp gateway, and
184 | should therefore only be run in the event of a successfull map_external_port()."""
185 |
186 | log.debug("Determening external address...")
187 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
188 | s.bind(('',random.randint(1025, 65535)))
189 | s.settimeout(.5)
190 | payload = pack('>2B', 0, 0)
191 |
192 | if self.send_payload(s, payload, self.gateway):
193 | try:
194 | rpayload = s.recvfrom(4096)
195 | except socket.error as err:
196 | if (err.errno and err.errno != errno.ETIMEDOUT) or str(err) != 'timed out':
197 | """For some reason, the timed out error have no errno, although the
198 | errno atrribute is existing (set to None). For this reason, we get this
199 | weird error handeling. It might be a bug in python3.2.3"""
200 | raise err
201 | s = None
202 | log.debug("Determening external address... [FAILED]")
203 | return False
204 | else:
205 | if rpayload[1][0] == self.gateway:
206 | r = unpack('>2BHI4B',rpayload[0])
207 | ip = str(r[4])+'.'+str(r[5])+'.'+str(r[6])+'.'+str(r[7])
208 | log.debug("Determening external address... [SUCCESS]")
209 | log.debug("External address is: "+ip)
210 | return ip
211 |
212 | if __name__ == '__main__':
213 | logging.basicConfig()
214 | log.setLevel(logging.DEBUG)
215 | npmp = natPMP()
216 | outcome = npmp.map_external_port(lport=12345)
217 | print(outcome)
218 | if outcome:
219 | print(npmp.get_external_ip())
220 |
--------------------------------------------------------------------------------
/punchVPN.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | import logging
3 | logging.basicConfig()
4 | log = logging.getLogger("PunchVPN")
5 | import punchVPN
6 | import socket
7 | from random import randint
8 | from punchVPN.udpKnock import udpKnock
9 | from punchVPN.WebConnect import WebConnect
10 | import argparse
11 | from multiprocessing import Process
12 | from time import sleep
13 | import os
14 | from stun import get_ip_info
15 | from natPMP import natPMP
16 | from upnp_igd import upnp_igd
17 | import signal
18 | import stat
19 | from subprocess import call
20 |
21 | PRESERVES_PORT = 1
22 | SEQUENTIAL_PORT = 2
23 | RANDOM_PORT = 3
24 |
25 | port_strings = {
26 | PRESERVES_PORT: "Preserved port allocation",
27 | SEQUENTIAL_PORT: "Sequential port allocation",
28 | RANDOM_PORT: "Random port allocation"}
29 |
30 | def startVPN(lport, raddr, rport, lVPN, rVPN, mode, key):
31 | """Start the VPN client and connect"""
32 | if not args.no_vpn:
33 |
34 | params = {}
35 |
36 | log_matrix = {
37 | logging.DEBUG:9,
38 | logging.INFO:1
39 | }
40 |
41 | try:
42 | loglevel = log_matrix[log.getEffectiveLevel()]
43 | except:
44 | loglevel = None
45 |
46 | if loglevel:
47 | params['--verb'] = loglevel
48 | else:
49 | params['--verb'] = 0
50 |
51 | params['--verb'] = 0
52 |
53 | params.update({
54 | '--secret':key,
55 | '--dev':'tap',
56 | '--ifconfig':'%s 255.255.255.0' % lVPN,
57 | '--comp-lzo':'adaptive',
58 | '--proto':'udp',
59 | '--secret':key
60 | })
61 |
62 | if mode == 'p2p':
63 | params.update({
64 | '--lport':str(lport),
65 | '--rport':str(rport),
66 | '--remote':raddr,
67 | '--ping':30,
68 | '--mode':mode
69 | })
70 | elif mode == 'server':
71 | params.update({
72 | '--port':str(lport),
73 | })
74 | elif mode == 'client':
75 | params.update({
76 | '--remote':raddr,
77 | '--port':str(rport),
78 | '--route-nopull':''
79 | })
80 |
81 | log.debug('Openvpn parameters: %s' % params)
82 |
83 | if os.name == 'posix':
84 | callparms = []
85 | for parm, value in params.items():
86 | callparms.append(parm)
87 | #TODO find another way around subproces.call encapsulating parameters with
88 | # "" if they contain spaces
89 | if parm == '--ifconfig':
90 | callparms += value.split(' ')
91 | else:
92 | callparms.append(str(value))
93 |
94 | call(['openvpn']+callparms)
95 |
96 | def test_stun():
97 | """Get external IP address from stun, and test the connection capabilities"""
98 | log.info("STUN - Testing connection...")
99 | src_port=randint(1025, 65535)
100 | stun = get_ip_info(source_port=src_port)
101 | log.debug(stun)
102 | port_mapping = PRESERVES_PORT if stun[2] == src_port else None
103 |
104 | seq_stun = None
105 | if port_mapping != PRESERVES_PORT:
106 | """Test for sequential port mapping"""
107 | seq_stun = get_ip_info(source_port=src_port+1)
108 | log.debug(seq_stun)
109 | port_mapping = SQUENTIAL_PORT if stun[2] + 1 == seq_stun[2] else RANDOM_PORT
110 |
111 | log.debug("STUN - Port allocation: "+port_strings[port_mapping])
112 | seq_stun = seq_stun or None
113 | ret = (stun, seq_stun), port_mapping, src_port
114 | return ret
115 |
116 | def find_ip(addr):
117 | """Find local ip to hostserver via a tmp socket.
118 |
119 | If the result is a local address, use 8.8.8.8 (google public dns-a) as a
120 | temporary solution. This will most likely only happen during development."""
121 | s_ip = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
122 | s_ip.connect((addr, 1234))
123 | ip = s_ip.getsockname()[0]
124 | s_ip.close()
125 |
126 | # Small hacky for when running a local server, eg. when developing
127 | if ip.startswith('127') or ip == '0.0.0.0':
128 | ip = find_ip('8.8.8.8')
129 | return ip
130 |
131 | def write_key(key):
132 | """Write the key to a file so it can me used to make a secure connection.
133 | To enhance security a bit, the file is firstly opened, written, closed,
134 | chmodded to enhance security a bit, and then the key is written to the file.
135 |
136 | string key
137 |
138 | return string path_to_key"""
139 |
140 | # Register globals
141 | global token
142 |
143 | # Find the OS temp dir
144 | if os.name == 'nt':
145 | path = '%temp%\\'
146 | else:
147 | path = '/tmp/'
148 |
149 | name = 'punchVPN-'+token+'.key'
150 |
151 | # Make the file a bit more secure
152 | f = open(path+name, 'w')
153 | f.write('0')
154 | os.chmod(path+name, stat.S_IREAD | stat.S_IWRITE)
155 | f.close()
156 |
157 | # Write the actual key to the file
158 | f = open(path+name, 'w')
159 | f.write(key)
160 | f.close()
161 |
162 | return path+name
163 |
164 | def gracefull_shutdown(signum, frame):
165 | """Make a gracefull shutdown, and tell the server about it"""
166 | global token
167 |
168 | web = WebConnect(args.address)
169 | log.debug("Closing connection...")
170 | web.post('/disconnect/', {'uuid': token})
171 | exit(1)
172 |
173 | def main():
174 | global token
175 | post_args = {}
176 |
177 | # Register a trap for a gracefull shutdown
178 | signal.signal(signal.SIGINT, gracefull_shutdown)
179 |
180 | """This is our methods for connecting.
181 | At least one of them must return true"""
182 | client_cap = {
183 | 'upnp': False,
184 | 'nat_pmp': False,
185 | 'udp_preserve': False,
186 | 'udp_sequential': False}
187 |
188 | # Choose a random port (stop "early" to be sure we get a port)
189 | lport = randint(1025, 60000)
190 |
191 | # Make the udpKnocker and socket. Get the maybe new lport
192 | knocker = udpKnock(socket.socket(socket.AF_INET, socket.SOCK_DGRAM), lport)
193 | lport = knocker.lport
194 |
195 | # Build default post_args dict, and ready a place for the external IP
196 | post_args = {'lport': lport}
197 | external_address = False
198 |
199 | # Test the natPMP capabilities
200 | if not args.no_natpmp:
201 | log.info("NAT-PMP - Testing for NAT-PMP... ")
202 | npmp = natPMP()
203 | nat_pmp = npmp.map_external_port(lport=lport)
204 | if nat_pmp:
205 | log.info("NAT-PMP - [SUCCESS]")
206 | client_cap['nat_pmp'] = True
207 | post_args['lport'] = nat_pmp[0]
208 | external_address = npmp.get_external_address()
209 | else:
210 | log.info("NAT-PMP - [FAILED]")
211 |
212 | # Test the UPnP-IGD capabilities
213 | if not args.no_upnp and not client_cap['nat_pmp']:
214 | log.info("UPnP-IGD - Testing for UPnP-IDG...")
215 |
216 | # Find IP-Address of local machine
217 | # TODO: Find fix for IPv6-addresses
218 | ip = find_ip(args.address.split(':')[1][2:])
219 |
220 | # Creating the UPnP device checker
221 | upnp = upnp_igd()
222 | if upnp.search() and upnp.AddPortMapping(ip, lport, 'UDP'):
223 | log.info("UPnP-IGD - [SUCCESS]")
224 | external_address = upnp.GetExternalIPAddress()
225 | client_cap['upnp'] = True
226 | else:
227 | log.info("UPnP-IGD - [FAILED]")
228 |
229 | # Get external ip-address and test what NAT type we are behind
230 | if not args.no_stun and not external_address:
231 | stun, port_mapping, stun_port = test_stun()
232 | external_address = stun[0][1]
233 | if port_mapping == PRESERVES_PORT:
234 | client_cap['udp_preserve'] = True
235 | if port_mapping == SEQUENTIAL_PORT:
236 | client_cap['udp_seqential'] = True
237 |
238 | # Connect to the webserver for connection and such
239 | web = WebConnect(args.address)
240 |
241 | # Add client caps and external_address to post_args
242 | post_args['client_cap'] = client_cap
243 | post_args['stun_ip'] = external_address
244 |
245 | # Get token from server
246 | token = web.get('/')['token']
247 | post_args['uuid'] = token
248 | log.info("Token is: "+token)
249 |
250 | if args.peer:
251 | """Connect and tell you want 'token'"""
252 | post_args['token'] = args.peer
253 | respons = web.post('/connect/', post_args)
254 | if respons.get('err'):
255 | log.info("Got error: "+respons['err'])
256 | exit(1)
257 | else:
258 | """Connect and wait for someone to access 'token'"""
259 | respons = web.post('/me/', post_args)
260 | while(True):
261 | peer = input("Enter token of your peer: ")
262 | respons = web.post('/ready/', {'uuid': token, 'token': peer})
263 | if not respons.get('err'):
264 | s = knocker.knock(respons['peer.ip'], int(respons['peer.lport']))
265 | break
266 |
267 | log.debug(respons)
268 | raddr = respons['peer.ip']
269 | rport = respons['peer.lport']
270 | lVPNaddr = respons['me.VPNaddr']
271 | rVPNaddr = respons['peer.VPNaddr']
272 | mode = respons['me.mode']
273 | key = write_key(respons['me.key'])
274 |
275 | # Tell if we are maybe running into trouble
276 | if mode == 'p2p-fallback':
277 | log.info("Running in p2p-fallback mode, may not be able to connect...")
278 | sleep(2)
279 | mode = 'p2p'
280 |
281 | knocker.s.close()
282 | vpn = Process(target=startVPN, args=(lport, raddr, rport, lVPNaddr, rVPNaddr, mode, key))
283 | vpn.start()
284 | vpn.join()
285 |
286 | # Cleanup
287 | os.remove(key)
288 |
289 | if __name__ == '__main__':
290 | """Runner for the script. This will hopefully allow us to compile for windows."""
291 |
292 | # Parse all the command line arguments, hopefully in a sane manner.
293 | parser = argparse.ArgumentParser(prog='punchVPN.py',
294 | formatter_class=argparse.ArgumentDefaultsHelpFormatter,
295 | description='Client for making p2p VPN connections behind nat')
296 | parser.add_argument('-p', '--peer', type=str, default=None, help='Token of your peer')
297 | parser.add_argument('-a', '--address', type=str, default='https://punchserver.xlaus.dk',
298 | help='What is the server address? (eg. https://server-ip:443)')
299 | parser.add_argument('--no-vpn', action='store_true', help='Run with no VPN (for debug)')
300 | parser.add_argument('--no-stun', action='store_true', help='Run with no STUN')
301 | parser.add_argument('--no-natpmp', action='store_true', help='Run with no nat-PMP')
302 | parser.add_argument('--no-upnp', action='store_true', help='Run with no UPnP-IGD')
303 | parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
304 | parser.add_argument('-s', '--silent', action='store_true', help='No output at all')
305 | args = parser.parse_args()
306 |
307 | if not args.silent or args.verbose:
308 | if args.verbose:
309 | log.setLevel(logging.DEBUG)
310 | else:
311 | log.setLevel(logging.INFO)
312 |
313 | # Run the main program, this is where the fun begins
314 | main()
315 |
--------------------------------------------------------------------------------
/punchVPN/WebConnect.py:
--------------------------------------------------------------------------------
1 | import urllib.request
2 | import urllib.parse
3 | import json
4 |
5 | class WebConnect(object):
6 | def __init__(self, host):
7 | """Initialize the object, and setup some basic stuff"""
8 | self.host = host
9 |
10 | def get(self, path):
11 | """GET an URL, and check for status codes"""
12 | return self.request(path)
13 |
14 | def post(self, path, post_data):
15 | """Encode the post parameters given to json, and call self.request with the newly encoded data"""
16 | post_data = {'body': json.dumps(post_data)}
17 | return self.request(path, post_data)
18 |
19 | def request(self, path, data=None):
20 | """Request URL from the server with data if it is present.
21 | If the data is present, urllib uses POST to request the data, if not it uses a GET.
22 | Decode the respons data, and return it."""
23 | if data:
24 | respons = urllib.request.urlopen(self.host+path,urllib.parse.urlencode(data).encode('utf-8'))
25 | else:
26 | respons = urllib.request.urlopen(self.host+path)
27 | content = json.loads(respons.read().decode('UTF-8'))
28 | respons.close()
29 | return content
30 |
--------------------------------------------------------------------------------
/punchVPN/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmol/punchVPN/440f190e002806e1f01397f3ee6d4e2fa2441452/punchVPN/__init__.py
--------------------------------------------------------------------------------
/punchVPN/udpKnock.py:
--------------------------------------------------------------------------------
1 | import socket
2 | import errno
3 |
4 | class udpKnock(object):
5 | def __init__(self, s, lport):
6 | """Try binding the port given as lport. If it fails, try the
7 | lport + 1, and continue to add 1 to lport, untill it succeeds."""
8 | while(1):
9 | try:
10 | s.bind(('', lport))
11 | break
12 | except socket.error as e:
13 | if e.errno != errno.EADDRINUSE:
14 | raise e
15 | lport+=1
16 | self.lport = lport
17 | self.s = s
18 |
19 | def lport(self):
20 | return self.lport
21 |
22 | def knock(self, addr, rport):
23 | """Connect to $addr with lport as local port.
24 | Send some garbage data to $addr to perform the udp knocking."""
25 | self.s.connect((addr, rport))
26 | self.s.sendto(bytes('knock knock', 'UTF-8'), (addr, rport))
27 | return self.s
28 |
--------------------------------------------------------------------------------
/punchVPN/udpStater.py:
--------------------------------------------------------------------------------
1 | class udpStater(object):
2 |
3 | def __init__(self):
4 | """initializer, must be here"""
5 |
6 | def dst_is(self,tst_dst):
7 | """Finds and returnes the first src, dst pair
8 | where dst_ip is first argument"""
9 | f = self.__read_states()
10 | f.pop(0)
11 | for line in f:
12 | src = self.__parse_addr(line.split(" ")[2].split(":"))
13 | dst = self.__parse_addr(line.split(" ")[3].split(":"))
14 |
15 | src = ".".join(src[0].split(".")[::-1]), src[1]
16 |
17 | if dst[0] == tst_dst:
18 | return src, dst
19 |
20 | def __parse_addr(self, addr):
21 | """Translate adress and port from hex to dec"""
22 | ip, port = addr[0], addr[1]
23 | ip_parsed = str(int(ip[0:2], 16))+"."+str(int(ip[2:4], 16))+"."+str(int(ip[4:6], 16))+"."+str(int(ip[6:8], 16))
24 | port_parsed = int(port, 16)
25 | return ip_parsed, port_parsed
26 |
27 | def __read_states(self):
28 | """open /proc/net/udp and split it into a list"""
29 | with open("/proc/net/udp") as f:
30 | return f.readlines()
31 |
--------------------------------------------------------------------------------
/punchVPNd/punchVPNd.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import uuid
3 | from gevent import monkey; monkey.patch_all()
4 | import logging
5 | logging.basicConfig()
6 | log = logging.getLogger("PunchVPNd")
7 | log.setLevel(logging.DEBUG)
8 | from gevent.event import Event
9 | import bottle
10 | from bottle import route, request, static_file, template, run
11 | from time import sleep
12 | import json
13 | from random import randint
14 | from subprocess import check_output
15 |
16 | class Peer(object):
17 | """Peer class for identifying the peers and creating a relation between them."""
18 | def __init__(self, lport):
19 | """Set up peer object"""
20 | self.ip = None
21 | self.lport = lport
22 | self.VPNaddr = None
23 | self.peer = None
24 | self.cap = None
25 | self.mode = None
26 | self.key = None
27 |
28 | peers = {}
29 | new_connect_event = Event()
30 | new_request_event = Event()
31 |
32 | @route('/')
33 | def hello():
34 | """Return 2nd part of a UUID4, for a semi-uniqe token. If token is already in the
35 | peers list, try another one"""
36 | global peers
37 | while(True):
38 | token = str(uuid.uuid4()).split('-')[1]
39 | if not peers.get(token):
40 | break
41 | return json.dumps({'token': token})
42 |
43 | @route('/me/', method='POST')
44 | def me():
45 | """Adds the connecting peer to the peers list and waits for a
46 | peer wating to connect to ealier given UUID.
47 | This method used long polling and relies on the port specified
48 | by the client to be accecible throug whatever method the
49 | client finds usealbe."""
50 |
51 | # Register global vars
52 | global new_request_event
53 | global peers
54 |
55 | # Parse the POST data from JSON to a dict
56 | post_data = json.loads(request.POST.get('body'))
57 |
58 | # Generate keypair for the connection
59 | key = check_output('openvpn --genkey --secret /dev/stdout', shell=True).decode().strip()
60 |
61 | # Create and add object for self (me) to the peers dict
62 | me = Peer(post_data['lport'])
63 | me.ip = post_data.get('stun_ip') or request.environ.get('REMOTE_ADDR')
64 | me.cap = post_data['client_cap']
65 | me.key = key
66 | peers[post_data['uuid']] = me
67 |
68 | # Looping wait for the right client
69 | log.info("Peer '"+post_data['uuid']+"' is waiting")
70 | while(1):
71 | new_request_event.wait()
72 |
73 | # Report back the values of the matching client
74 | if me.peer:
75 | msg = {'status': 'READY'}
76 | msg = json.dumps(msg)
77 | return msg
78 |
79 | @route('/connect/', method='POST')
80 | def connect():
81 | """Tests if the connecting peer has a useable token, and
82 | adds the peer to the peers dict if the token is useable.
83 | Raises event for waiting peers that a new client is
84 | connected and waits for one of the waiting peers
85 | (identified by 'token'), to ready its connection and
86 | report status = ready"""
87 |
88 | # Register global vars
89 | global new_request_event
90 | global new_connect_event
91 | global peers
92 |
93 | # Parse the POST data from JSON to a dict and extract 'token'
94 | post_data = json.loads(request.POST.get('body'))
95 | token = post_data['token']
96 |
97 | # Look for peer identified by 'token' or return error to client
98 | if not peers.has_key(token):
99 | return json.dumps({'err': 'NOT_CONNECTED'})
100 |
101 | # Create and add self (me) to peers dict.
102 | # Sets peer(token).peer to self
103 | me = Peer(post_data['lport'])
104 | me.ip = post_data.get('stun_ip') or request.environ.get('REMOTE_ADDR')
105 | me.cap = post_data['client_cap']
106 |
107 | # Dertermine how we want to connect
108 | if peers[token].cap['upnp'] or peers[token].cap['nat_pmp']:
109 | me.mode = 'client'
110 | peers[token].mode = 'server'
111 | elif me.cap['upnp'] or me.cap['nat_pmp']:
112 | me.mode = 'server'
113 | peers[token].mode = 'client'
114 | elif ((me.cap['udp_preserve'] or me.cap['udp_sequential']) and
115 | (peers[token].cap['udp_preserve'] or peers[token].cap['udp_sequential'])):
116 | me.mode = 'p2p'
117 | peers[token].mode = 'p2p'
118 | else:
119 | # For now, we'll go with trying p2p. Stun could be disabled on the client
120 | me.mode = 'p2p-fallback'
121 | peers[token].mode = 'p2p-fallback'
122 |
123 | peers[post_data['uuid']] = me
124 | peers[token].peer = me
125 |
126 | # Find link local addresses for useing in VPN
127 | c,d = str(randint(1,254)), str(randint(1,253))
128 | me.VPNaddr = "169.254."+c+"."+str(int(d)+1)
129 | peers[token].VPNaddr = "169.254."+c+"."+d
130 |
131 | # Raises the events for peers to wakeup and connect
132 | new_request_event.set()
133 | new_request_event.clear()
134 |
135 | # Looping wait for peer to return a ready connection
136 | log.info("Peer '"+post_data['uuid']+"' requested '"+token+"'")
137 | while(1):
138 | new_connect_event.wait()
139 |
140 | # Delete self and peer from peers dict.
141 | # return connection params
142 | if me.peer:
143 | msg = {'peer.ip': me.peer.ip,
144 | 'peer.lport': me.peer.lport,
145 | 'peer.VPNaddr': me.peer.VPNaddr,
146 | 'me.VPNaddr': me.VPNaddr,
147 | 'me.mode': me.mode,
148 | 'me.key': me.peer.key}
149 | msg = json.dumps(msg)
150 | del peers[post_data['uuid']]
151 | del peers[token]
152 | return msg
153 |
154 | @route('/ready/', method='POST')
155 | def ready():
156 | """Sets the peer of peer to self, and raise the
157 | event for the waiting connections"""
158 |
159 | # Register globals
160 | global new_connect_event
161 | global peers
162 |
163 | # Parse POST data from JSON to dict
164 | post_data = json.loads(request.POST.get('body'))
165 |
166 | # Register me, and set peer of me' peer, to me
167 | # Wow, that feels weird
168 | me = peers[post_data['uuid']]
169 |
170 | if peers.get(post_data['token']) == me.peer:
171 | msg = {'peer.ip': me.peer.ip,
172 | 'peer.lport': me.peer.lport,
173 | 'peer.VPNaddr': me.peer.VPNaddr,
174 | 'me.VPNaddr': me.VPNaddr,
175 | 'me.mode': me.mode,
176 | 'me.key': me.key,
177 | 'status': 'READY'}
178 | msg = json.dumps(msg)
179 | me.peer.peer = me
180 | log.info("Peer '"+post_data['uuid']+"' is ready")
181 |
182 | # Raise events for waiting connections and return ready
183 | new_connect_event.set()
184 | new_connect_event.clear()
185 | return msg
186 | else:
187 | return json.dumps({'err': 'NOT_CONNECTED'})
188 |
189 | @route('/disconnect/', method='POST')
190 | def disconnect():
191 | """Removes peer from peer list in event of a
192 | client side error or trap"""
193 |
194 | # Register globals
195 | global peers
196 |
197 | # Parse POST data from json to dict
198 | post_data = json.loads(request.POST.get('body'))
199 |
200 | # Look for peer to delete, and delete if it exists
201 | if peers.get(post_data['uuid']):
202 | del peers[post_data['uuid']]
203 | return json.dumps({"status": "OK"})
204 | else:
205 | return json.dumps({'err': 'NOT_CONNECTED'})
206 |
207 |
208 | app = bottle.app()
209 |
210 | if __name__ == '__main__':
211 | bottle.debug(True)
212 | bottle.run(app=app, server='gevent', host='0.0.0.0')
213 |
--------------------------------------------------------------------------------
/stun/__init__.py:
--------------------------------------------------------------------------------
1 | #coding=utf-8
2 |
3 | """Slightly modified version of https://github.com/jtriley/pystun, to make it run under Python3"""
4 | __credits__ = "Justin Riley (https://github.com/jtriley) and Gaohawk"
5 | __copyright__ = "Unknown"
6 | __license__ = "MIT"
7 |
8 | """Permission is hereby granted, free of charge, to any person obtaining a copy of this software
9 | and associated documentation files (the "Software"), to deal in the Software without restriction,
10 | including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
11 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
12 | subject to the following conditions:
13 |
14 | The above copyright notice and this permission notice shall be included in all copies or substantial
15 | portions of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
18 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
21 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."""
22 |
23 | import random
24 | import socket
25 | import binascii
26 | import logging
27 |
28 | log = logging.getLogger("PunchVPN.stun")
29 |
30 | def enable_logging():
31 | logging.basicConfig()
32 | log.setLevel(logging.DEBUG)
33 |
34 | stun_servers_list = (
35 | "punchserver.xlaus.dk",
36 | )
37 |
38 | #stun attributes
39 | MappedAddress = '0001'
40 | ResponseAddress = '0002'
41 | ChangeRequest = '0003'
42 | SourceAddress = '0004'
43 | ChangedAddress = '0005'
44 | Username = '0006'
45 | Password = '0007'
46 | MessageIntegrity = '0008'
47 | ErrorCode = '0009'
48 | UnknownAttribute = '000A'
49 | ReflectedFrom = '000B'
50 | XorOnly = '0021'
51 | XorMappedAddress = '8020'
52 | ServerName = '8022'
53 | SecondaryAddress = '8050' # Non standard extention
54 |
55 | #types for a stun message
56 | BindRequestMsg = '0001'
57 | BindResponseMsg = '0101'
58 | BindErrorResponseMsg = '0111'
59 | SharedSecretRequestMsg = '0002'
60 | SharedSecretResponseMsg = '0102'
61 | SharedSecretErrorResponseMsg = '0112'
62 |
63 | dictAttrToVal ={'MappedAddress' : MappedAddress,
64 | 'ResponseAddress' : ResponseAddress,
65 | 'ChangeRequest' : ChangeRequest,
66 | 'SourceAddress' : SourceAddress,
67 | 'ChangedAddress' : ChangedAddress,
68 | 'Username' : Username,
69 | 'Password' : Password,
70 | 'MessageIntegrity': MessageIntegrity,
71 | 'ErrorCode' : ErrorCode,
72 | 'UnknownAttribute': UnknownAttribute,
73 | 'ReflectedFrom' : ReflectedFrom,
74 | 'XorOnly' : XorOnly,
75 | 'XorMappedAddress': XorMappedAddress,
76 | 'ServerName' : ServerName,
77 | 'SecondaryAddress': SecondaryAddress}
78 |
79 | dictMsgTypeToVal = {'BindRequestMsg' :BindRequestMsg,
80 | 'BindResponseMsg' :BindResponseMsg,
81 | 'BindErrorResponseMsg' :BindErrorResponseMsg,
82 | 'SharedSecretRequestMsg' :SharedSecretRequestMsg,
83 | 'SharedSecretResponseMsg' :SharedSecretResponseMsg,
84 | 'SharedSecretErrorResponseMsg':SharedSecretErrorResponseMsg}
85 |
86 | dictValToMsgType = {}
87 |
88 | dictValToAttr = {}
89 |
90 | Blocked = "Blocked"
91 | OpenInternet = "Open Internet"
92 | FullCone = "Full Cone"
93 | SymmetricUDPFirewall = "Symmetric UDP Firewall"
94 | RestricNAT = "Restric NAT"
95 | RestricPortNAT = "Restric Port NAT"
96 | SymmetricNAT = "Symmetric NAT"
97 | ChangedAddressError = "Meet an error, when do Test1 on Changed IP and Port"
98 |
99 |
100 | def _initialize():
101 | items = list(dictAttrToVal.items())
102 | for i in range(len(items)):
103 | dictValToAttr.update({items[i][1]:items[i][0]})
104 | items = list(dictMsgTypeToVal.items())
105 | for i in range(len(items)):
106 | dictValToMsgType.update({items[i][1]:items[i][0]})
107 |
108 |
109 | def gen_tran_id():
110 | a =''
111 | for i in range(32):
112 | a+=random.choice('0123456789ABCDEF')
113 | #return binascii.a2b_hex(a)
114 | return a
115 |
116 |
117 | def stun_test(sock, host, port, source_ip, source_port, send_data=""):
118 | retVal = {'Resp':False,
119 | 'ExternalIP':None,
120 | 'ExternalPort':None,
121 | 'SourceIP':None,
122 | 'SourcePort':None,
123 | 'ChangedIP':None,
124 | 'ChangedPort':None}
125 | str_len = "%#04d" % (len(send_data)/2)
126 | TranID = gen_tran_id()
127 | str_data = ''.join([BindRequestMsg, str_len, TranID, send_data])
128 | data = binascii.a2b_hex(str_data.encode('UTF-8'))
129 | recvCorr = False
130 | while not recvCorr:
131 | recieved = False
132 | count = 3
133 | while not recieved:
134 | log.debug("sendto %s" % str((host, port)))
135 | sock.sendto(data,(host, port))
136 | try:
137 | buf, addr = sock.recvfrom(2048)
138 | log.debug("recvfrom: %s" % str(addr))
139 | recieved = True
140 | except Exception:
141 | recieved = False
142 | if count >0:
143 | count-=1
144 | else:
145 | retVal['Resp'] = False
146 | return retVal
147 | MsgType = binascii.b2a_hex(buf[0:2])
148 | if (dictValToMsgType[MsgType.decode('UTF-8')] == "BindResponseMsg" and
149 | TranID.upper() == binascii.b2a_hex(buf[4:20]).decode('UTF-8').upper()):
150 | recvCorr = True
151 | retVal['Resp'] = True
152 | len_message = int(binascii.b2a_hex(buf[2:4]).decode('UTF-8'), 16)
153 | len_remain = len_message
154 | base = 20
155 | while len_remain:
156 | attr_type = binascii.b2a_hex(buf[base:(base+2)]).decode('UTF-8')
157 | attr_len = int(binascii.b2a_hex(buf[(base+2):(base+4)]).decode('UTF-8'),16)
158 | if attr_type == MappedAddress:
159 | port = int(binascii.b2a_hex(buf[base+6:base+8]).decode('UTF-8'), 16)
160 | ip = "".join([str(int(binascii.b2a_hex(buf[base+8:base+9]).decode('UTF-8'), 16)),'.',
161 | str(int(binascii.b2a_hex(buf[base+9:base+10]).decode('UTF-8'), 16)),'.',
162 | str(int(binascii.b2a_hex(buf[base+10:base+11]).decode('UTF-8'), 16)),'.',
163 | str(int(binascii.b2a_hex(buf[base+11:base+12]).decode('UTF-8'), 16))])
164 | retVal['ExternalIP'] = ip
165 | retVal['ExternalPort'] = port
166 | if attr_type == SourceAddress:
167 | port = int(binascii.b2a_hex(buf[base+6:base+8]).decode('UTF-8'), 16)
168 | ip = "".join([str(int(binascii.b2a_hex(buf[base+8:base+9]).decode('UTF-8'), 16)),'.',
169 | str(int(binascii.b2a_hex(buf[base+9:base+10]).decode('UTF-8'), 16)),'.',
170 | str(int(binascii.b2a_hex(buf[base+10:base+11]).decode('UTF-8'), 16)),'.',
171 | str(int(binascii.b2a_hex(buf[base+11:base+12]).decode('UTF-8'), 16))])
172 | retVal['SourceIP'] = ip
173 | retVal['SourcePort'] = port
174 | if attr_type == ChangedAddress:
175 | port = int(binascii.b2a_hex(buf[base+6:base+8]).decode('UTF-8'), 16)
176 | ip = "".join([str(int(binascii.b2a_hex(buf[base+8:base+9]).decode('UTF-8'), 16)),'.',
177 | str(int(binascii.b2a_hex(buf[base+9:base+10]).decode('UTF-8'), 16)),'.',
178 | str(int(binascii.b2a_hex(buf[base+10:base+11]).decode('UTF-8'), 16)),'.',
179 | str(int(binascii.b2a_hex(buf[base+11:base+12]).decode('UTF-8'), 16))])
180 | retVal['ChangedIP'] = ip
181 | retVal['ChangedPort'] = port
182 | #if attr_type == ServerName:
183 | #serverName = buf[(base+4):(base+4+attr_len)]
184 | base = base + 4 + attr_len
185 | len_remain = len_remain - (4+attr_len)
186 | #s.close()
187 | return retVal
188 |
189 |
190 | def get_nat_type(s, source_ip, source_port, stun_host=None):
191 | _initialize()
192 | port = 3478
193 | log.debug("Do Test1")
194 | resp = False
195 | if stun_host:
196 | ret = stun_test(s, stun_host, port, source_ip, source_port)
197 | resp = ret['Resp']
198 | else:
199 | for host in stun_servers_list:
200 | log.debug('Trying STUN host: %s' % host)
201 | ret = stun_test(s, host, port, source_ip, source_port)
202 | resp = ret['Resp']
203 | if resp:
204 | break
205 | if not resp:
206 | return Blocked, ret
207 | log.debug("Result: %s" % ret)
208 | exIP = ret['ExternalIP']
209 | exPort = ret['ExternalPort']
210 | changedIP = ret['ChangedIP']
211 | changedPort = ret['ChangedPort']
212 | if ret['ExternalIP'] == source_ip:
213 | changeRequest = ''.join([ChangeRequest,'0004',"00000006"])
214 | ret = stun_test(s, host, port, source_ip, source_port, changeRequest)
215 | if ret['Resp']:
216 | typ = OpenInternet
217 | else:
218 | typ = SymmetricUDPFirewall
219 | else:
220 | changeRequest = ''.join([ChangeRequest,'0004',"00000006"])
221 | log.debug("Do Test2")
222 | ret = stun_test(s, host, port, source_ip, source_port, changeRequest)
223 | log.debug("Result: %s" % ret)
224 | if ret['Resp']:
225 | typ = FullCone
226 | else:
227 | log.debug("Do Test1")
228 | ret = stun_test(s, changedIP, changedPort, source_ip, source_port)
229 | log.debug("Result: %s" % ret)
230 | if not ret['Resp']:
231 | typ = ChangedAddressError
232 | else:
233 | if exIP == ret['ExternalIP'] and exPort == ret['ExternalPort']:
234 | changePortRequest = ''.join([ChangeRequest,'0004',"00000002"])
235 | log.debug("Do Test3")
236 | ret = stun_test(s, changedIP, port, source_ip, source_port, changePortRequest)
237 | log.debug("Result: %s" % ret)
238 | if ret['Resp'] == True:
239 | typ = RestricNAT
240 | else:
241 | typ = RestricPortNAT
242 | else:
243 | typ = SymmetricNAT
244 | return typ, ret
245 |
246 |
247 | def get_ip_info(source_ip="0.0.0.0", source_port=54320, stun_host=None):
248 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
249 | s.settimeout(2)
250 | s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
251 | s.bind((source_ip, source_port))
252 | nat_type, nat = get_nat_type(s, source_ip, source_port,
253 | stun_host=stun_host)
254 | external_ip = nat['ExternalIP']
255 | external_port = nat['ExternalPort']
256 | s.close()
257 | return (nat_type, external_ip, external_port)
258 |
259 | def main():
260 | nat_type, external_ip, external_port = get_ip_info()
261 | print("NAT Type:", nat_type)
262 | print("External IP:", external_ip)
263 | print("External Port:", external_port)
264 |
265 | if __name__ == '__main__':
266 | main()
267 |
--------------------------------------------------------------------------------
/upnp_igd/__init__.py:
--------------------------------------------------------------------------------
1 | import socket
2 | from urllib.request import urlopen
3 | import xml.dom.minidom as minidom
4 | from random import randint
5 | import logging
6 |
7 | """ http://upnp.org/specs/gw/UPnP-gw-InternetGatewayDevice-v2-Device.pdf
8 | Simple module for mapping and deleting port-forwards on UPnP-IGD devices
9 | It can search for IGD devices, but it doesn't check if the required device,
10 | service and action are available for creating port-mappings (Assumption is the mother of all f......)
11 | UPnP standard compliance is limited/lacking
12 | Only tested against Linux-IGD
13 | No real error-messages are parsed/supplied, only True and False are returned/provided
14 | """
15 |
16 | log = logging.getLogger('PunchVPN.UPnP-IGD')
17 |
18 | class upnp_igd:
19 | def __init__(self):
20 | # Store for created port-mappings, used for cleaning purposes
21 | self._mapped_ports = {}
22 |
23 | def __del__(self):
24 | #The socket module is not available if this is __main__
25 | if socket:
26 | self.clean()
27 |
28 | def __exit__(self):
29 | #The socket module is not available if this is __main__
30 | if socket:
31 | self.clean()
32 |
33 | def clean(self):
34 | for mapping in list(self._mapped_ports):
35 | self.DeletePortMapping(mapping[0], mapping[1])
36 |
37 |
38 | def search(self):
39 | """Multicast SSDP discover, returns true if we can find an IGD device (_isIGD)"""
40 | log.debug('Searching for IGD devices')
41 | self._XML = []
42 | self._host = None
43 | #Standard multicast address and port for SSDP discover
44 | ip = '239.255.255.250'
45 | port = 1900
46 | searchRequest = 'M-SEARCH * HTTP/1.1\r\n'\
47 | 'HOST:%s:%d\r\n'\
48 | 'ST:upnp:rootdevice\r\n'\
49 | 'MX:2\r\n'\
50 | 'MAN:"ssdp:discover"\r\n\r\n' % (ip, port)
51 |
52 | #Create IPv4 UDP socket
53 | s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
54 | while(1):
55 | try:
56 | s.bind(('', randint(1024, 65535)))
57 | except socket.error:
58 | # This is most lokely a binding error
59 | pass
60 | else:
61 | break
62 |
63 | s.sendto(searchRequest.encode('UTF-8'), (ip, port))
64 | #Don't listen for more than 5 seconds
65 | s.settimeout(5)
66 | while True:
67 | try:
68 | data, sender = s.recvfrom(2048)
69 | if not data:
70 | break
71 | else:
72 | data = data.decode('UTF-8')
73 | if data.startswith('HTTP/1.1 200 OK'):
74 | if self._isIGD(data):
75 | log.debug('IGD device found')
76 | return True
77 | except:
78 | #Most likely a timeout
79 | break
80 | log.debug('No IGD devices found')
81 | return False
82 |
83 | def _isIGD(self, headers):
84 | """ Parse headers returned by self.search(), and see if this is an IGD device """
85 | self._rootXML = None
86 | #Iterate each header
87 | for line in headers.split('\r\n'):
88 | #LOCATION indicates where the device XML is located
89 | if line.startswith('LOCATION:'):
90 | log.debug('Fetching device XML from location specified in response header\n %s' % line)
91 | location = line.split('LOCATION: ')[1]
92 | request = urlopen(location)
93 | xml = request.read().decode('UTF-8')
94 | xmlDom = minidom.parseString(xml)
95 | for dev in xmlDom.getElementsByTagName('device'):
96 | if (dev.getElementsByTagName('deviceType')[0].childNodes[0].data.split(':')[3] ==
97 | 'InternetGatewayDevice'):
98 | self._rootXML = xmlDom
99 | host = line.split('://')[1].split('/')[0].split(':')
100 | self._host = (host[0], int(host[1]))
101 | return True
102 | return False
103 |
104 | def AddPortMapping(self, ip, port, protocol):
105 | """Adds a portmap, requires that an IGD device has been found with self.search()
106 | Linux IGD has a limited status reply (none really) if the syntax is correct, invalid
107 | port numbers etc. are not rejected. Because of this, we just return true if the HTTP
108 | headers status-code is 200
109 | There are no tests to confirm that there actually is a WANIPConn device, with an
110 | WANIPConnection service and an AddPortMapping action
111 | A mapping is stored in self._mapped_ports on success
112 |
113 | Keyword arguments:
114 | ip -- string representation of the clients internal IP
115 | port -- Requested port as integer
116 | protocol -- Requested protocol, use 'TCP' or 'UDP'
117 | """
118 |
119 | log.debug('Mapping protocol %s port %s to host at %s' % (protocol, port, ip))
120 | if not self._host:
121 | return False
122 | response = ''
123 | #Timeconstraints did not allow creating a soap module/parser, UPnP requires NewRemoteHost,
124 | #NewExternalPort, NewProtocol, NewInternalPort, NewInternalClient, NewEnabled,
125 | #NewPortMappingDescription, NewLeaseDuration for this action
126 |
127 | body = ''\
128 | ''\
130 | ''\
131 | ''\
132 | '0'\
133 | '%s'\
134 | '%s'\
135 | ''\
136 | '%s'\
137 | '%s'\
138 | 'upnpigd mapping'\
139 | '1'\
140 | ''\
141 | ''\
142 | '' % (ip, port, protocol, port)
143 |
144 | header = ('POST /upnp/control/WANIPConn1 HTTP/1.1\r\n'\
145 | 'Host:%s\r\n'\
146 | 'Content-Length:%s\r\n'\
147 | 'Content-Type:text/xml\r\n'\
148 | 'SOAPAction:"urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping"\r\n\r\n'
149 | % ((str(self._host[0])+':'+str(self._host[1])), len(body)))
150 |
151 | s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
152 | s.connect(self._host)
153 |
154 | log.debug('sending SOAP request for AddPortMapping\n%s' % body)
155 |
156 | s.send((header+body).encode('UTF-8'))
157 | while True:
158 | data = s.recv(4096)
159 | if not data:
160 | break
161 | else:
162 | response += data.decode('UTF-8')
163 | log.debug('IGD device responded with \n%s' % response)
164 | #Look for the HTML status-code to see if everything is OK. Linux IGD soap-response had no additional
165 | #info, so this was faster to implement
166 | status = response.split('\n', 1)[0].split(' ', 2)[1] == '200'
167 | if status:
168 | log.debug('IGD device acknowledged request')
169 | self._mapped_ports[port, protocol] = True
170 | return status
171 |
172 | def DeletePortMapping(self, port, protocol):
173 | """Deletes a portmap, requires that an IGD device has been found with self.search()
174 | Linux IGD has a limited status reply (none really) if the syntax is correct, invalid port numbers etc.
175 | are not rejected. Because of this, we just return true if the HTTP headers status-code is 200
176 | If a mapping is present in self.__mapped_ports, it will be removed upon successfull deletion
177 |
178 | Keyword arguments:
179 | port -- Requested port as integer
180 | protocol -- Requested protocol, use 'TCP' or 'UDP'
181 | """
182 | log.debug('Removing mapping for protocol %s and port %s' % (protocol, port))
183 | if not self._host:
184 | log.warning('No IGD device known')
185 | return False
186 | response = ''
187 | #Timeconstraints did not allow creating a soap module/parser, UPnP requires NewRemoteHost,
188 | #NewExternalPort and NewProtocol for this action
189 | body = ''\
190 | ''\
192 | ''\
193 | ''\
194 | '%s'\
195 | ''\
196 | '%s'\
197 | ''\
198 | ''\
199 | '' % (port, protocol)
200 |
201 | header = ('POST /upnp/control/WANIPConn1 HTTP/1.1\r\n'\
202 | 'Host:%s\r\n'\
203 | 'Content-Length:%s\r\n'\
204 | 'Content-Type:text/xml\r\n'\
205 | 'SOAPAction:"urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping"\r\n\r\n'
206 | % ((str(self._host[0])+':'+str(self._host[1])), len(body)))
207 |
208 | s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
209 | s.connect(self._host)
210 |
211 | log.debug('sending SOAP request for DeletePortMapping\n%s' % body)
212 |
213 | s.send((header+body).encode('UTF-8'))
214 | while True:
215 | data = s.recv(4096)
216 | if not data:
217 | break
218 | else:
219 | response += data.decode('UTF-8')
220 | log.debug('IGD device responded with \n%s' % response)
221 | #Look for the HTML status-code to see if everything is OK. Linux IGD soap-response had no additional info,
222 | #so this was faster to implement
223 | status = response.split('\n', 1)[0].split(' ', 2)[1] == '200'
224 | if status:
225 | log.debug('IGD device acknowledged request')
226 | #Remove this tnrey from self._mapped_ports if it is present
227 | if status and self._mapped_ports[port, protocol]:
228 | del self._mapped_ports[port, protocol]
229 | return status
230 |
231 | def GetExternalIPAddress(self):
232 | """Asks a found IGD device for it's external ip-address"""
233 |
234 | if not self._host:
235 | return False
236 | log.debug('Requesting external ip-address')
237 | response = ''
238 | #Timeconstraints did not allow creating a soap module/parser, UPnP requires NewRemoteHost, NewExternalPort,
239 | #NewProtocol, NewInternalPort, NewInternalClient, NewEnabled, NewPortMappingDescription,
240 | #NewLeaseDuration for this action
241 |
242 | body = ''\
243 | ''\
245 | ''\
246 | ''\
247 | ''\
248 | ''\
249 | ''
250 |
251 | header = ('POST /upnp/control/WANIPConn1 HTTP/1.1\r\n'\
252 | 'Host:%s\r\n'\
253 | 'Content-Length:%s\r\n'\
254 | 'Content-Type:text/xml\r\n'\
255 | 'SOAPAction:"urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress"\r\n\r\n'
256 | % ((str(self._host[0])+':'+str(self._host[1])), len(body)))
257 |
258 | s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
259 | s.connect(self._host)
260 |
261 | log.debug('Sending SOAP request for GetExternalIPAddress\n%s' % body)
262 |
263 | s.send((header+body).encode('UTF-8'))
264 | while True:
265 | data = s.recv(4096)
266 | if not data:
267 | break
268 | else:
269 | response += data.decode('UTF-8')
270 | log.debug('IGD device responded with \n%s' % response)
271 | #Look for the HTML status-code to see if everything is OK. Linux IGD soap-response had no additional
272 | #info, so this was faster to implement
273 | status = response.split('\n', 1)[0].split(' ', 2)[1] == '200'
274 | if status:
275 | log.debug('IGD device acknowledged request')
276 | soap_reply = response.split('\r\n\r\n')[1]
277 | xmlDom = minidom.parseString(soap_reply)
278 | ip = xmlDom.getElementsByTagName('NewExternalIPAddress')[0].childNodes[0].data
279 | log.debug("IGD external ip-address is %s" % ip)
280 | return ip
281 | return status
282 |
--------------------------------------------------------------------------------