├── .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 | --------------------------------------------------------------------------------