├── .github └── workflows │ └── readme.yml ├── .gitignore ├── Class ├── .gitignore ├── base.py ├── bird.py ├── cli.py ├── diag.py ├── latency.py ├── network.py ├── templator.py └── wireguard.py ├── LICENSE ├── README.md ├── api.py ├── cli.py ├── configs ├── .gitignore ├── nginx.certbot.example ├── nginx.example ├── wgmesh-bird.service ├── wgmesh-diag.service ├── wgmesh-pipe.service ├── wgmesh-rotate.service └── wgmesh.service ├── cron ├── bird.py ├── diag.py ├── rotate.py └── smoke.py ├── deinstall.sh ├── docs ├── amnezia.md ├── api.md ├── cli.md ├── cron.md ├── folders.md ├── ipt_xor.md ├── logs.md ├── peering.md ├── troubleshooting.md └── wgobfs.md ├── install.sh ├── links └── .gitignore ├── logs └── .gitignore ├── reinstall.sh └── tools ├── .gitignore ├── amnezia.sh ├── clean.py ├── machine-id.py ├── patch.py ├── status.py ├── update.py ├── wgobfs.sh └── xor.sh /.github/workflows/readme.yml: -------------------------------------------------------------------------------- 1 | name: Update Readme 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_templates: 10 | name: "Update Readme" 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: "Fetching Repository Contents" 14 | uses: actions/checkout@master 15 | 16 | - name: Update README.md 17 | run: | 18 | sed -i -e 's/experimental/master/g' README.md 19 | sed -i -e 's/experimental/master/g' install.sh 20 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 21 | git config user.name "github-actions[bot]" 22 | git commit -am "Updated README" 23 | 24 | - name: Push changes 25 | env: 26 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 27 | run: | 28 | git push https://Ne00n:${GH_TOKEN}@github.com/Ne00n/wg-mesh.git master -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | token 2 | tokens.json 3 | test.py 4 | test.sh 5 | pipe -------------------------------------------------------------------------------- /Class/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /Class/base.py: -------------------------------------------------------------------------------- 1 | import subprocess, requests, netaddr, shutil, time, json, re, os 2 | from ipaddress import ip_network 3 | 4 | class Base: 5 | 6 | def cmd(self,cmd,timeout=None): 7 | try: 8 | p = subprocess.run(cmd, stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, timeout=timeout) 9 | return [p.stdout.decode('utf-8'),p.stderr.decode('utf-8')] 10 | except: 11 | return ["",""] 12 | 13 | def sameNetwork(self,origin,target): 14 | o = ip_network(origin, strict = False).network_address 15 | t = ip_network(target, strict = False).network_address 16 | return o == t 17 | 18 | def getRemote(self,config,subnetPrefixSplitted): 19 | parsed = re.findall(f'(({subnetPrefixSplitted[0]}\.{subnetPrefixSplitted[1]}\.[0-9]+\.)([0-9]+)\/31)',config, re.MULTILINE)[0] 20 | lastOctet = int(parsed[2]) 21 | return parsed,f"{parsed[1]}{lastOctet-1}" if self.sameNetwork(f"{parsed[1]}{lastOctet-1}",parsed[0]) else f"{parsed[1]}{lastOctet+1}" 22 | 23 | def readJson(self,file): 24 | if os.path.isfile(file): 25 | try: 26 | with open(file) as handle: return json.loads(handle.read()) 27 | except Exception as e: 28 | return {} 29 | else: 30 | return {} 31 | 32 | def readFile(self,file): 33 | if os.path.isfile(file): 34 | try: 35 | with open(file, 'r') as file: return file.read() 36 | except Exception as e: 37 | return "" 38 | else: 39 | return "" 40 | 41 | def saveFile(self,data,path): 42 | #Prevent file corruption 43 | total, used, free = shutil.disk_usage("/") 44 | usagePercent = (used / total) * 100 45 | if usagePercent >= 98: return False 46 | try: 47 | with open(path, 'w') as file: file.write(data) 48 | except Exception as e: 49 | return False 50 | return True 51 | 52 | def saveJson(self,data,path): 53 | #Prevent file corruption 54 | total, used, free = shutil.disk_usage("/") 55 | usagePercent = (used / total) * 100 56 | if usagePercent >= 98: return False 57 | try: 58 | with open(path, 'w') as f: json.dump(data, f, indent=4) 59 | except Exception as e: 60 | return False 61 | return True 62 | 63 | def getRoutes(self,subnetPrefixSplitted=[10,0]): 64 | routes = self.cmd("birdc show route")[0] 65 | return re.findall(f"({subnetPrefixSplitted[0]}\.{subnetPrefixSplitted[1]}\.[0-9]+\.0\/30)",routes, re.MULTILINE) 66 | 67 | def getBirdLinks(self,configs,prefix="pipe",subnetPrefixSplitted=[10,0]): 68 | return re.findall(f"({prefix}[A-Za-z0-9]+): longest[index]: longest[index] = len(entry) 148 | for i, row in enumerate(list): 149 | elements = row.split("\t") 150 | for index, entry in enumerate(elements): 151 | if len(entry) < longest[index]: 152 | diff = longest[index] - len(entry) 153 | while len(entry) < longest[index]: 154 | entry += " " 155 | response += f"{entry}" if response.endswith("\n") or response == "" else f" {entry}" 156 | if i < len(list) -1: response += "\n" 157 | return response 158 | 159 | def notify(self,server,title,message,priority=5): 160 | payload = {'title':title, 'message':message, 'priority':priority} 161 | req = self.call(server,payload,"POST") 162 | if req: return True -------------------------------------------------------------------------------- /Class/bird.py: -------------------------------------------------------------------------------- 1 | import random, time, json, re, os 2 | from Class.templator import Templator 3 | from Class.wireguard import Wireguard 4 | from Class.base import Base 5 | 6 | class Bird(Base): 7 | Templator = Templator() 8 | 9 | def __init__(self,path,logger): 10 | self.config = self.readJson(f'{path}/configs/config.json') 11 | self.subnetPrefixSplitted = self.config['subnet'].split(".") 12 | self.prefix = self.config['prefix'] 13 | self.wg = Wireguard(path) 14 | self.logger = logger 15 | self.path = path 16 | 17 | def getLatency(self,targets): 18 | ips = [] 19 | for row in targets: ips.append(row['target']) 20 | latency = self.fping(ips,5) 21 | if not latency: 22 | self.logger.warning("No pingable links found.") 23 | return False 24 | for entry,row in latency.items(): 25 | row = row[2:] #drop the first 2 pings 26 | row.sort() 27 | for data in list(targets): 28 | for entry,row in latency.items(): 29 | if entry == data['target']: 30 | if len(row) < 5: self.logger.warning(f"Expected 5 pings, got {len(row)} from {data['target']}, possible Packetloss") 31 | data['cost'] = self.getAvrg(row,False) 32 | if data['cost'] == 65535: self.logger.warning(f"Cannot reach {data['nic']} {data['target']}") 33 | break 34 | #apparently fping 4.2 and 5.0 result in different outputs, neat, so we keep this 35 | elif data['target'] not in latency and not "latency" in data: 36 | self.logger.warning(f"Cannot reach {data['nic']} {data['target']}") 37 | data['cost'] = 65535 38 | break 39 | if (len(targets) != len(latency)): self.logger.warning("Targets do not match expected responses.") 40 | return targets 41 | 42 | def getIPerf(self,targets): 43 | random.shuffle(targets) 44 | todo = [] 45 | #we try to iperf a link 5 times 46 | for i in range(5): 47 | for row in targets: 48 | #skip already benchmarked links 49 | if 'cost' in row and row['cost'] != 20000: continue 50 | #benchmark 51 | self.logger.info(f"Running IPerf to {row['target']} on {row['nic']}") 52 | speed = int(self.iperf(row['target'])) 53 | self.logger.info(f"{speed}Mbit's for {row['target']}") 54 | if speed == 0: 55 | #if we fail to run the iperf, put on list 56 | todo.append(row['target']) 57 | row['cost'] = 20000 58 | time.sleep(random.randint(2,10)) 59 | else: 60 | if row['target'] in todo: todo.remove(row['target']) 61 | row['cost'] = 20000 - speed 62 | #when list is empty, exit 63 | if not todo: break 64 | return targets 65 | 66 | def genTargets(self,links): 67 | result,peers = [],[] 68 | for link in links: 69 | nic,ip,lastByte = link[0],link[2],link[3] 70 | origin = ip+lastByte 71 | #Client or Server roll the dice or rather not, so we ping the correct ip 72 | target = self.resolve(f"{ip}{int(lastByte)+1}",origin,31) 73 | if target == True: 74 | targetIP = f"{ip}{int(lastByte)+1}" 75 | else: 76 | targetIP = f"{ip}{int(lastByte)-1}" 77 | if "Peer" in nic: peers.append({'nic':nic,'target':targetIP,'origin':origin}) 78 | result.append({'nic':nic,'target':targetIP,'origin':origin}) 79 | return result,peers 80 | 81 | def bird(self,override=False,skipIperf=False): 82 | #check if bird is running 83 | bird = self.cmd("systemctl status bird")[0] 84 | if not "running" in bird and override == False: 85 | self.logger.warning("bird not running") 86 | return False 87 | self.logger.info("Collecting Network data") 88 | configs = self.cmd('ip addr show')[0] 89 | links = re.findall(f"(({self.prefix})[A-Za-z0-9]+): = 200 or int(self.config['id']) >= 200) and (data['cost'] + 10000) < 65535: data['cost'] += 10000 106 | elif self.config['operationMode'] == 2: 107 | self.logger.info("IPerf messurement") 108 | latencyData = self.getIPerf(nodes) 109 | latencyDataNoGroup = latencyData 110 | latencyData = self.wg.groupByArea(latencyData) 111 | self.logger.info("Generating config") 112 | bird = self.Templator.genBird(latencyData,peers,self.config) 113 | if bird == "": 114 | self.logger.warning("No bird config generated") 115 | return False 116 | self.logger.info("Writing config") 117 | self.saveFile(bird,'/etc/bird/bird.conf') 118 | self.logger.info("Reloading bird") 119 | self.cmd("sudo systemctl reload bird") 120 | return latencyDataNoGroup,peers 121 | 122 | def mesh(self): 123 | #check if bird is running 124 | bird = self.cmd("systemctl status bird")[0] 125 | if not "running" in bird: 126 | self.logger.warning("bird not running") 127 | return False 128 | #wait for bird to fully bootstrap 129 | oldTargets,counter = [],0 130 | self.logger.info("Waiting for bird routes") 131 | for run in range(30): 132 | targets = self.getRoutes(self.subnetPrefixSplitted) 133 | self.logger.debug(f"Run {run}/30, Counter {counter}, Got {targets} as targets") 134 | if oldTargets != targets: 135 | oldTargets = targets 136 | counter = 0 137 | else: 138 | counter += 1 139 | if counter == 8: break 140 | time.sleep(5) 141 | #when targets empty, abort 142 | if not targets: 143 | self.logger.warning("bird returned no routes, did you setup bird?") 144 | return False 145 | #vxlan fuckn magic 146 | vxlan = self.cmd("bridge fdb show dev vxlan1 | grep dst")[0] 147 | for target in targets: 148 | ip = target.replace("0/30","1") 149 | splitted = ip.split(".") 150 | if not ip in vxlan: 151 | self.cmd(f"sudo bridge fdb append 00:00:00:00:00:00 dev vxlan1 dst {ip}") 152 | self.cmd(f"sudo bridge fdb append 00:00:00:00:00:00 dev vxlan1v6 dst fd10:0:{splitted[2]}::1") 153 | #To prevent creating connections to new nodes joined afterwards, save state 154 | if os.path.isfile(f"{self.path}/configs/state.json"): 155 | self.logger.debug("state.json already exist, skipping") 156 | else: 157 | #fetch network interfaces and parse 158 | configs = self.cmd('ip addr show')[0] 159 | links = self.getBirdLinks(configs,self.prefix,self.subnetPrefixSplitted) 160 | localIP = f"{'.'.join(self.config['subnet'].split('.')[:2])}.{self.config['id']}.1" 161 | if not links: 162 | self.logger.warning("No wireguard interfaces found") 163 | return False 164 | #remove local machine from list 165 | for ip in list(targets): 166 | if self.resolve(localIP,ip.replace("/30",""),30): 167 | targets.remove(ip) 168 | #run against existing links 169 | for ip in list(targets): 170 | for link in links: 171 | if self.resolve(link[1],ip.replace("/30",""),24): 172 | #multiple links in the same subnet 173 | if ip in targets: targets.remove(ip) 174 | #run against local link names 175 | for ip in list(targets): 176 | for link in links: 177 | splitted = ip.split(".") 178 | if f"pipe{splitted[2]}" in link[0]: 179 | #multiple links in the same subnet 180 | if ip in targets: targets.remove(ip) 181 | self.logger.info(f"Possible targets {targets}") 182 | #wireguard 183 | self.logger.info("meshing") 184 | results = {} 185 | for target in targets: 186 | targetSplit = target.split(".") 187 | #reserve 10.0.200+ for clients, don't mesh 188 | if int(targetSplit[2]) >= 200: continue 189 | dest = target.replace(".0/30",".1") 190 | #no token needed but external IP for the client 191 | self.logger.info(f"Setting up link to {dest}") 192 | status = self.wg.connect(f"http://{dest}:{self.config['listenPort']}") 193 | if status['v4'] or status['v6']: 194 | results[target] = True 195 | self.logger.info(f"Link established to http://{dest}:{self.config['listenPort']}") 196 | else: 197 | results[target] = False 198 | self.logger.warning(f"Failed to setup link to http://{dest}:{self.config['listenPort']}") 199 | self.logger.info("saving state.json") 200 | with open(f"{self.path}/configs/state.json", 'w') as f: json.dump(results, f ,indent=4) -------------------------------------------------------------------------------- /Class/cli.py: -------------------------------------------------------------------------------- 1 | from logging.handlers import RotatingFileHandler 2 | from Class.wireguard import Wireguard 3 | from Class.templator import Templator 4 | from Class.base import Base 5 | from Class.bird import Bird 6 | import subprocess, logging, time, sys, os 7 | 8 | class CLI(Base): 9 | 10 | def __init__(self,path): 11 | self.path = path 12 | self.templator = Templator() 13 | self.wg = Wireguard(path,True) 14 | 15 | def init(self,id,listen): 16 | self.wg.init(id,listen) 17 | 18 | def used(self): 19 | self.wg = Wireguard(self.path) 20 | self.wg.used() 21 | 22 | def bender(self): 23 | self.wg = Wireguard(self.path) 24 | self.wg.bender() 25 | 26 | def connect(self,dest,token,linkType="default",port=51820,network=""): 27 | self.wg = Wireguard(self.path) 28 | status = self.wg.connect(dest,token,linkType,port,network) 29 | if self.wg.getInitial: 30 | if not status['v4'] and not status['v6']: 31 | print(f"Initial link wasn't setup.") 32 | return 33 | print("Waiting for meshing to complete.") 34 | for i in range(1, 300): 35 | if os.path.isfile(f"{self.path}/configs/state.json"): return 36 | time.sleep(1) 37 | print("Meshing seems to have failed.") 38 | 39 | def proximity(self,cutoff=0): 40 | self.wg = Wireguard(self.path) 41 | self.wg.proximity(cutoff) 42 | 43 | def disconnect(self,links=[],force=False): 44 | self.wg = Wireguard(self.path) 45 | self.wg.disconnect(links,force) 46 | 47 | def links(self,state): 48 | files = os.listdir(f'{self.path}/links/') 49 | for file in list(files): 50 | if not file.endswith(".sh"): files.remove(file) 51 | for file in files: 52 | subprocess.run(f"bash {self.path}/links/{file} {state}",shell=True) 53 | 54 | def update(self): 55 | subprocess.run("cd; git pull",shell=True) 56 | 57 | def clean(self,ignoreJSON,ignoreEndpoint): 58 | self.wg = Wireguard(self.path) 59 | self.wg.clean(ignoreJSON,ignoreEndpoint) 60 | 61 | def migrate(self): 62 | self.wg = Wireguard(self.path,False,True) 63 | self.wg.updateConfig() 64 | 65 | def geo(self): 66 | headers = {"Origin":"https://ip-api.com"} 67 | geoDataRaw = self.call("https://demo.ip-api.com/json/?fields=66842623",{},"GET",headers) 68 | if geoDataRaw: 69 | geoData = geoDataRaw.json() 70 | config = self.readJson(f'{self.path}/configs/config.json') 71 | if not "geo" in config: config['geo'] = {} 72 | config['geo']['countryCode'] = geoData['countryCode'] 73 | config['geo']['continent'] = geoData['continent'] 74 | config['geo']['country'] = geoData['country'] 75 | config['geo']['city'] = geoData['city'] 76 | print(f"Updated geodata {config['geo']}") 77 | self.saveJson(config,f"{self.path}/configs/config.json") 78 | 79 | def recover(self): 80 | stream_handler = logging.StreamHandler() 81 | stream_handler.setLevel(logging.DEBUG) 82 | logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',datefmt='%d.%m.%Y %H:%M:%S',level=logging.DEBUG,handlers=[RotatingFileHandler(maxBytes=10000000,backupCount=5,filename=f"{self.path}/logs/recovery.log"),stream_handler]) 83 | logger = logging.getLogger() 84 | self.bird = Bird(self.path,logger) 85 | self.bird.bird(True) 86 | 87 | def token(self): 88 | tokens = self.readJson(f"{self.path}/tokens.json") 89 | if tokens: 90 | print(f"Connect: {', '.join(tokens['connect'])}") 91 | print(f"Peer: {', '.join(tokens['peer'])}") 92 | else: 93 | print("Unable to load the tokens.json") 94 | 95 | def status(self): 96 | print("--- Services ----") 97 | proc = self.cmd("systemctl status bird")[0] 98 | birdRunning = "Bird2 is not running." if not "running" in proc else "Bird2 is running." 99 | proc = self.cmd("systemctl status wgmesh")[0] 100 | wgmeshRunning = "wgmesh is not running." if not "running" in proc else "wgmesh is running." 101 | proc = self.cmd("systemctl status wgmesh-bird")[0] 102 | wgmeshBirdRunning = "wgmesh-bird is not running." if not "running" in proc else "wgmesh-bird is running." 103 | print(f"{birdRunning}\t{wgmeshRunning}\t{wgmeshBirdRunning}") 104 | print("--- Wireguard ---") 105 | network = self.readJson(f"{self.path}/configs/network.json") 106 | if not network: 107 | print("Unable to load network.json") 108 | return 109 | print("Destination\tStatus\tPacketloss\tJitter") 110 | jittar,loss,online,offline = 0,0,0,0 111 | for dest,data in network.items(): 112 | hasLoss,hasJitter = "No","No" 113 | if dest == "updated": continue 114 | if data['packetloss']: 115 | hasLoss = "Yes" 116 | loss += 1 117 | if data['jitter']: 118 | hasJitter = "Yes" 119 | jittar += 1 120 | if data['state']: 121 | state = "Online" 122 | online += 1 123 | else: 124 | state = "Offline" 125 | offline += 1 126 | print(f"{dest}\t{state}\t{hasLoss}\t\t{hasJitter}") 127 | print(f"{len(network) -1}\t\t{online}/{offline}\t{loss}\t\t{jittar}") 128 | 129 | def disable(self,option): 130 | config = self.readJson(f"{self.path}/configs/config.json") 131 | if not config: 132 | print("Unable to load config.json") 133 | return 134 | if "mesh" in option: 135 | self.wg.saveJson({},f"{self.path}/configs/state.json") 136 | elif "ospfv2" in option: 137 | config['bird']['ospfv2'] = False 138 | elif "ospfv3" in option: 139 | config['bird']['ospfv3'] = False 140 | elif "client" in option: 141 | config['bird']['client'] = False 142 | elif "notifications" in option: 143 | config['notifications']['enabled'] = False 144 | elif "wgobfs" in option: 145 | if "wgobfs" in config['linkTypes']: config['linkTypes'].remove("wgobfs") 146 | elif "ipt_xor" in option: 147 | if "ipt_xor" in config['linkTypes']: config['linkTypes'].remove("ipt_xor") 148 | elif "amneziawg" in option: 149 | if "amneziawg" in config['linkTypes']: config['linkTypes'].remove("amneziawg") 150 | else: 151 | print("Valid options: mesh, ospfv2, ospfv3, wgobfs, ipt_xor, amneziawg, client, notifications") 152 | return 153 | response = self.saveJson(config,f"{self.path}/configs/config.json") 154 | if not response: 155 | print("Failed to save config.json") 156 | return 157 | print("You should reload the services to apply any config changes") 158 | 159 | def enable(self,option): 160 | config = self.readJson(f"{self.path}/configs/config.json") 161 | if not config: 162 | print("Unable to load config.json") 163 | return 164 | if "mesh" in option: 165 | if os.path.isfile(f"{self.path}/configs/state.json"): os.remove(f"{self.path}/configs/state.json") 166 | elif "ospfv2" in option: 167 | config['bird']['ospfv2'] = True 168 | elif "ospfv3" in option: 169 | config['bird']['ospfv3'] = True 170 | elif "client" in option: 171 | config['bird']['client'] = True 172 | elif "notifications" in option: 173 | config['notifications']['enabled'] = True 174 | elif "wgobfs" in option: 175 | if not "wgobfs" in config['linkTypes']: config['linkTypes'].append("wgobfs") 176 | print("You still need to install wgobfs with: bash /opt/wg-mesh/tools/wgobfs.sh") 177 | elif "ipt_xor" in option: 178 | if not "ipt_xor" in config['linkTypes']: config['linkTypes'].append("ipt_xor") 179 | print("You still need to install ipt_xor with: bash /opt/wg-mesh/tools/xor.sh") 180 | elif "amneziawg" in option: 181 | if not "amneziawg" in config['linkTypes']: config['linkTypes'].append("amneziawg") 182 | print("You still need to install amneziawg with: bash /opt/wg-mesh/tools/amnezia.sh") 183 | else: 184 | print("Valid options: mesh, ospfv2, ospfv3, wgobfs, ipt_xor, amneziawg, client, notifications") 185 | return 186 | response = self.saveJson(config,f"{self.path}/configs/config.json") 187 | if not response: 188 | print("Failed to save config.json") 189 | return 190 | print("You should reload the services to apply any config changes") 191 | 192 | def setOption(self,options): 193 | validOptions = ["area","prefix","defaultLinkType","basePort","tick","reloadInterval","reloadPercentage","operationMode","vxlanOffset","subnet","subnetVXLAN","subnetLinkLocal","AllowedPeers","gotifyUp","gotifyDown","gotifyError",'gotifyDiag'] 194 | if len(sys.argv) == 0: 195 | print(f"Valid options: {', '.join(validOptions)}") 196 | else: 197 | key, value = options 198 | if key in validOptions: 199 | config = self.readJson(f"{self.path}/configs/config.json") 200 | if not config: 201 | print(f"Unable to read config.json") 202 | return 203 | if key == "basePort" or key == "vxlanOffset" or key == "operationMode": 204 | config[key] = int(value) 205 | elif key == "area" or key == "tick" or key == "reloadInterval" or key == "reloadPercentage": 206 | config['bird'][key] = int(value) 207 | elif key == "gotifyUp" or key == "gotifyDown" or key == "gotifyError" or key == "gotifyDiag": 208 | config['notifications'][key] = value 209 | elif key == "AllowedPeers": 210 | if value in config['AllowedPeers']: 211 | config['AllowedPeers'].remove(value) 212 | else: 213 | config['AllowedPeers'].append(value) 214 | else: 215 | config[key] = value 216 | response = self.saveJson(config,f"{self.path}/configs/config.json") 217 | if not response: 218 | print("Failed to save config.json") 219 | return 220 | print("You should reload the services to apply any config changes") 221 | if key == "subnet" or key == "subnetVXLAN": 222 | print("Reconfiguring dummy") 223 | self.wg = Wireguard(self.path) 224 | self.wg.reconfigureDummy() 225 | else: 226 | print(f"Valid options: {', '.join(validOptions)}") 227 | 228 | def cost(self,link,cost=0): 229 | self.wg.setCost(link,cost) -------------------------------------------------------------------------------- /Class/diag.py: -------------------------------------------------------------------------------- 1 | import random, time, json, re, os 2 | from Class.wireguard import Wireguard 3 | from Class.base import Base 4 | 5 | class Diag(Base): 6 | 7 | def __init__(self,path,logger): 8 | self.logger = logger 9 | self.wg = Wireguard(path) 10 | self.path = path 11 | self.diagnostic = self.readJson(f"{self.path}/configs/diagnostic.json") 12 | self.network = self.readJson(f"{self.path}/configs/network.json") 13 | self.config = self.readJson(f'{self.path}/configs/config.json') 14 | self.subnetPrefixSplitted = self.config['subnet'].split(".") 15 | 16 | def run(self): 17 | notifications = self.config['notifications'] 18 | self.logger.info("Starting diagnostic") 19 | if not os.path.isfile(f"{self.path}/configs/state.json"): 20 | self.logger.warning("state.json does not exist") 21 | return False 22 | targets = self.getRoutes() 23 | if not targets: 24 | self.logger.warning("bird returned no routes, did you setup bird?") 25 | return False 26 | links = self.wg.getLinks() 27 | self.logger.info(f"Checking {len(links)} Links") 28 | offline,online = self.wg.checkLinks(links) 29 | self.logger.info(f"Found {len(offline)} dead link(s)") 30 | for link in offline: 31 | count, data, current = 0, links[link], int(time.time()) 32 | if not "endpoint" in data['config']: continue 33 | linkConfig = self.readJson(f'{self.path}/links/{data["filename"]}.json') 34 | #have to check the linkType, currently no logic for different link types so we just skip them for now 35 | if "linkType" in linkConfig and linkConfig['linkType'] != "default": 36 | self.logger.warning(f"{link} has non default linkType, skipping") 37 | continue 38 | parsed, remote = self.getRemote(data['config'],self.subnetPrefixSplitted) 39 | self.logger.info(f"Found dead link {link} ({remote})") 40 | if not remote in self.diagnostic: self.diagnostic[remote] = {"cooldown":0,"retries":0} 41 | if self.diagnostic[remote]['cooldown'] > current: 42 | self.logger.info(f"Skipping {link} due to cooldown") 43 | continue 44 | self.diagnostic[remote]['cooldown'] = current + random.randint(43200,57600) 45 | self.diagnostic[remote]['retries'] += 1 46 | if not remote in self.network: 47 | self.logger.warning(f"{link} no data in network.json, skipping") 48 | continue 49 | for event,row in list(self.network[remote]['packetloss'].items()): 50 | if int(event) > int(time.time()) and row['peak'] == 4: count += 1 51 | if count < 20: 52 | self.logger.info(f"{link} got {count}, 20 are needed for confirmation") 53 | continue 54 | endpoint = f"{parsed[1]}1" 55 | pings = self.fping([endpoint],3,True) 56 | if not pings[endpoint]: 57 | self.logger.info(f"Unable to reach endpoint {link} ({endpoint})") 58 | continue 59 | self.logger.info(f"Dead link confirmed {link} ({remote})") 60 | if notifications['enabled']: self.wg.notify(self.config['notifications']['gotifyDiag'],f"{link} disconnecting",f"Node {self.config['id']} disconnecting {link}") 61 | self.logger.info(f"Disconnecting {link}") 62 | status = self.wg.disconnect([link]) 63 | if not status[link]: 64 | self.logger.warning(f"Failed to disconnect {link} ({remote})") 65 | if notifications['enabled']: self.wg.notify(self.config['notifications']['gotifyDiag'],f"{link} disconnect failure",f"Node {self.config['id']} failed to disconnect {link}") 66 | continue 67 | time.sleep(3) 68 | self.logger.info(f"Reconnecting {link}") 69 | port = random.randint(1024, 50000) 70 | status = self.wg.connect(f"http://{endpoint}:8080","dummy","",port) 71 | if status['v4'] or status['v6']: 72 | self.logger.info(f"Reconnected {link} ({remote}) with Port {port}") 73 | if notifications['enabled']: self.wg.notify(self.config['notifications']['gotifyDiag'],f"{link} reconnected",f"Node {self.config['id']} reconnected {link}") 74 | else: 75 | self.logger.info(f"Could not reconnect {link} ({remote})") 76 | if notifications['enabled']: self.wg.notify(self.config['notifications']['gotifyDiag'],f"{link} reconnect failure",f"Node {self.config['id']} failed to reconnect {link}") 77 | self.logger.info(f"Loop done") 78 | self.saveJson(self.diagnostic,f"{self.path}/configs/diagnostic.json") 79 | return True -------------------------------------------------------------------------------- /Class/latency.py: -------------------------------------------------------------------------------- 1 | import subprocess, requests, json, copy, time, sys, re, os 2 | from Class.wireguard import Wireguard 3 | from Class.templator import Templator 4 | from datetime import datetime 5 | from threading import Thread 6 | from Class.base import Base 7 | from random import randint 8 | 9 | class Latency(Base): 10 | Templator = Templator() 11 | 12 | def __init__(self,path,logger): 13 | self.wg = Wireguard(path) 14 | self.latencyData = {} 15 | self.logger = logger 16 | self.linkState = {} 17 | self.path = path 18 | self.noWait = 0 19 | self.lastReload = int(time.time()) + 600 20 | self.currentLinks = self.wg.getLinks(False) 21 | self.config = self.readJson(f'{path}/configs/config.json') 22 | self.subnetPrefixSplitted = self.config['subnet'].split(".") 23 | self.network = self.readJson(f"{path}/configs/network.json") 24 | if not self.network: self.network = {"created":int(time.time()),"updated":0} 25 | 26 | def checkJitter(self,row,avrg): 27 | grace = 20 28 | for entry in row: 29 | if entry[0] == "timed out": continue 30 | if float(entry[0]) > avrg + grace: return True,round(float(entry[0]) - (avrg + grace),2) 31 | return False,0 32 | 33 | def reloadPeacemaker(self,nic,ongoing,eventCount,latency,old): 34 | #needs to be ongoing 35 | if not ongoing: return False 36 | #ignore links dead or nearly dead links 37 | if latency > 20000 and float(old) > 20000: return False 38 | #ignore any negative changes 39 | if latency <= float(old): return False 40 | diff = int(latency - float(old)) 41 | percentage = round((abs(float(old) - latency) / latency) * 100.0,1) 42 | #needs to be higher than 15% (default) 43 | self.logger.debug(f"{nic} Current percentage: {percentage}%, needed {self.config['bird']['reloadPercentage']}% (current {latency}, earlier {old}, diff {diff})") 44 | if percentage < self.config['bird']['reloadPercentage']: return False 45 | return True 46 | 47 | def countEvents(self,entry,eventType): 48 | eventCount,eventScore = 0,0 49 | for event,details in list(self.network[entry][eventType].items()): 50 | if int(event) > int(time.time()): 51 | eventCount += 1 52 | eventScore += details['peak'] 53 | #delete events after 120 minutes 54 | elif (int(time.time()) - 7200) > int(event): 55 | del self.network[entry][eventType][event] 56 | return eventCount,round(eventScore,1) 57 | 58 | def getOldLatencyData(self,target): 59 | for node in self.latencyDataState: 60 | if target == node['target']: return node 61 | 62 | def getLatency(self,config,pings=4): 63 | targets = [] 64 | for row in config: targets.append(row['target']) 65 | latency = self.fping(targets,pings,True) 66 | if not latency: 67 | self.logger.warning("No pingable links found.") 68 | return False 69 | total,ongoingLoss,ongoingJitter,self.reload,self.noWait,peers = 0,0,0,0,0,[] 70 | for node in list(config): 71 | for entry,row in latency.items(): 72 | if entry == node['target']: 73 | peers.append(entry) 74 | #get old latencyData before reload, so we have a better reference 75 | oldLatencyData = self.getOldLatencyData(node['target']) 76 | old = oldLatencyData['cost'] 77 | #get average 78 | node['cost'] = current = self.getAvrg(row,False) 79 | if node['nic'] in self.linkState: node['cost'] += self.linkState[node['nic']]['cost'] 80 | if entry not in self.network: self.network[entry] = {"packetloss":{},"jitter":{},"outages":0,"state":1} 81 | #Packetloss 82 | hasLoss,peakLoss = len(row) < pings -1,(pings -1) - len(row) 83 | if hasLoss: 84 | #keep packet loss events for 30 minutes 85 | self.network[entry]['packetloss'][int(time.time()) + randint(1700,2100)] = {"peak":peakLoss,"latency":current} 86 | self.logger.info(f"{node['nic']} ({entry}) Packetloss detected got {len(row)} of {pings -1}") 87 | 88 | eventCount,eventScore = self.countEvents(entry,'packetloss') 89 | #multiply by 10 otherwise small package loss may not result in routing changes 90 | eventScore = (eventScore * eventCount) * 10 91 | if eventCount > 0: 92 | node['cost'] += eventScore 93 | self.logger.debug(f"Loss {node['nic']} ({entry}) Weight: {old}, Latency: {current}, Modified: {node['cost']}, Score: {eventScore}, Count: {eventCount}") 94 | if self.reloadPeacemaker(node['nic'],hasLoss,eventCount,node['cost'],old): 95 | self.logger.debug(f"{node['nic']} ({entry}) Triggering Packetloss reload") 96 | self.reload += 1 97 | self.noWait += 1 98 | ongoingLoss += 1 99 | 100 | #Jitter 101 | hasJitter,peakJitter = self.checkJitter(row,self.getAvrg(row)) 102 | if hasJitter: 103 | #keep jitter events for 30 minutes 104 | self.network[entry]['jitter'][int(time.time()) + randint(1700,2100)] = {"peak":peakJitter,"latency":current} 105 | self.logger.info(f"{node['nic']} ({entry}) High Jitter dectected") 106 | 107 | eventCount,eventScore = self.countEvents(entry,'jitter') 108 | if eventCount > 0: 109 | node['cost'] += eventScore 110 | self.logger.debug(f"Jitter {node['nic']} ({entry}) Weight: {old}, Latency: {current}, Modified: {node['cost']}, Score: {eventScore}, Count: {eventCount}") 111 | if self.reloadPeacemaker(node['nic'],hasJitter,eventCount,node['cost'],old): 112 | self.logger.debug(f"{node['nic']} ({entry}) Triggering Jitter reload") 113 | self.reload += 1 114 | ongoingJitter += 1 115 | 116 | total += 1 117 | #if within 200-255 range (client) adjust base cost/weight to avoid transit 118 | linkID = re.findall(f"{self.config['prefix']}.*?([0-9]+)",node['nic'], re.MULTILINE)[0] 119 | if (int(linkID) >= 200 or int(self.config['id']) >= 200) and (node['cost'] + 10000) < 65535: node['cost'] += 10000 120 | #make sure its always int 121 | node['cost'] = int(node['cost']) 122 | #make sure we stay below max int 123 | if node['cost'] > 65535: node['cost'] = 65535 124 | #make sure we always stay over zero 125 | #in case of a typo and you connect to itself, it may cause a weight to be measured at zero 126 | if node['cost'] < 0: node['cost'] = 1 127 | 128 | #clear out old peers 129 | for entry in list(self.network): 130 | if entry not in peers: del self.network[entry] 131 | self.logger.info(f"Total {total}, Jitter {ongoingJitter}, Packetloss {ongoingLoss}") 132 | self.network['updated'] = int(time.time()) 133 | return config 134 | 135 | def run(self,runs,messages=[]): 136 | #Check if bird is running 137 | self.logger.debug("Checking bird status") 138 | bird = self.cmd("systemctl status bird")[0] 139 | if not "running" in bird: 140 | self.logger.warning("bird not running") 141 | return -1 142 | if self.config['operationMode'] == 0: 143 | self.logger.info("Running latency") 144 | self.logger.debug("Processing messages") 145 | for rawMessage in messages: 146 | message = json.loads(rawMessage) 147 | self.logger.info(f"{message['link']} set cost to {message['cost']}") 148 | self.linkState[message['link']]['cost'] = message['cost'] 149 | #reset lastReload to trigger a reload, otherwise we have to wait up to 10 minutes 150 | self.lastReload = int(time.time()) 151 | self.logger.debug("Running fping") 152 | latencyData = self.getLatency(self.latencyData,5) 153 | if not latencyData: 154 | self.logger.warning("Nothing todo") 155 | else: 156 | #save in memory so we don't have to read the config file again 157 | self.notifications(latencyData) 158 | self.latencyData = latencyData 159 | latencyData = self.wg.groupByArea(latencyData) 160 | birdConfig = self.Templator.genBird(latencyData,self.peers,self.config) 161 | #write 162 | self.saveFile(birdConfig,'/etc/bird/bird.conf') 163 | #reload bird with updates only every 10 minutes or if reload is greater than 1 164 | if int(time.time()) > self.lastReload or self.reload > 0: 165 | #keep a copy with the current values in the bird config 166 | self.latencyDataState = copy.deepcopy(self.latencyData) 167 | #reload 168 | self.logger.info("Reloading bird") 169 | self.cmd('sudo systemctl reload bird') 170 | self.lastReload = int(time.time()) + self.config['bird']['reloadInterval'] 171 | else: 172 | self.logger.debug(f"{datetime.now().minute} not in window.") 173 | #however save any packetloss or jitter detected 174 | self.saveJson(self.network,f"{self.path}/configs/network.json") 175 | time.sleep(5) 176 | else: 177 | time.sleep(10) 178 | return self.noWait 179 | 180 | def setLatencyData(self,latencyData,peers): 181 | #fill linkState 182 | for data in latencyData: 183 | if not data['nic'] in self.linkState: self.linkState[data['nic']] = {"state":1,"cost":0,"outages":0} 184 | #copy dicts 185 | self.latencyData = copy.deepcopy(latencyData) 186 | self.latencyDataState = copy.deepcopy(latencyData) 187 | self.peers = peers 188 | 189 | def sendMessage(self,status,row): 190 | linkOnDisk = self.currentLinks[f"{row['nic']}.sh"] 191 | mtr = ["..."] 192 | if status == 0: 193 | if linkOnDisk['remotePublic']: 194 | targetIP = linkOnDisk['remotePublic'] 195 | targetIP = targetIP.replace("[","").replace("]","") 196 | mtr = self.cmd(f'mtr {targetIP} --report --report-cycles 3 --no-dns') 197 | else: 198 | mtr = ["No public ip available for mtr",""] 199 | notifications = self.config['notifications'] 200 | if status: 201 | self.notify(notifications['gotifyUp'],f"Node {self.config['id']}: {row['nic']} is up",f"{row['nic']} has been down {self.linkState[row['nic']]['outages']} times") 202 | else: 203 | self.notify(notifications['gotifyDown'],f"Node {self.config['id']}: {row['nic']} is down ({self.linkState[row['nic']]['outages']})",f"{mtr[0]}") 204 | 205 | def notifications(self,latencyData): 206 | for index,row in enumerate(latencyData): 207 | nic = row['nic'] 208 | if not self.linkState[nic]['state'] and row['cost'] != 65535: 209 | self.linkState[row['nic']]['state'] = 1 210 | self.network[row['target']]['state'] = 1 211 | self.logger.warning(f"Link {row['nic']} is up") 212 | notifications = self.config['notifications'] 213 | if notifications['enabled']: 214 | sendMessage = Thread(target=self.sendMessage, args=([1,row])) 215 | sendMessage.start() 216 | elif self.linkState[nic]['state'] and row['cost'] == 65535: 217 | self.linkState[row['nic']]['state'] = 0 218 | self.network[row['target']]['state'] = 0 219 | self.linkState[row['nic']]['outages'] += 1 220 | self.network[row['target']]['outages'] += 1 221 | self.logger.warning(f"Link {row['nic']} is down") 222 | notifications = self.config['notifications'] 223 | if notifications['enabled']: 224 | sendMessage = Thread(target=self.sendMessage, args=([0,row])) 225 | sendMessage.start() -------------------------------------------------------------------------------- /Class/network.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | 3 | class Network: 4 | 5 | def __init__(self,config): 6 | self.config = config 7 | self.subnetPrefix = ".".join(self.config['subnet'].split(".")[:2]) 8 | 9 | def getNodeSubnet(self): 10 | if self.config['subnet'].startswith("10."): 11 | return f"{self.subnetPrefix}.{self.config['id']}.0/23" 12 | else: 13 | return f"{self.subnetPrefix}.{self.config['id']}.0/24" 14 | 15 | def getNodeSubnetv6(self): 16 | return f"{self.config['subnetLinkLocal']}{self.config['id']}::/112" 17 | 18 | def getPeerSubnets(self): 19 | nodeSubnet = self.getNodeSubnet() 20 | network = ipaddress.ip_network(nodeSubnet) 21 | subnets = list(network.subnets(new_prefix=31)) 22 | subnets = subnets[2:] 23 | return subnets 24 | 25 | def getPeerSubnetsv6(self): 26 | nodeSubnet = self.getNodeSubnetv6() 27 | network = ipaddress.ip_network(nodeSubnet) 28 | return list(network.subnets(new_prefix=127)) 29 | 30 | def getHost(self,freeSubnet,suffix="31"): 31 | peerSubnet = ipaddress.ip_network(freeSubnet) 32 | return f"{list(peerSubnet.hosts())[1]}/{suffix}" -------------------------------------------------------------------------------- /Class/templator.py: -------------------------------------------------------------------------------- 1 | import ipaddress, time 2 | 3 | class Templator: 4 | 5 | def genServer(self,interface,config,payload,freeSubnet,freeSubnetv6,serverPort,wgobfsSharedKey=""): 6 | clientPublicKey,linkType,prefix,area,connectivity = payload['clientPublicKey'],payload['linkType'],payload['prefix'],payload['area'],payload['connectivity'] 7 | wgobfs,mtu = "",1412 if "v6" in interface else 1420 8 | wgPrefix = "awg" if linkType == "amneziawg" else "wg" 9 | wgProtocol = "amneziawg" if linkType == "amneziawg" else "wireguard" 10 | if linkType == "wgobfs": wgobfs += f"sudo iptables -t mangle -I INPUT -p udp -m udp --dport {serverPort} -j WGOBFS --key {wgobfsSharedKey} --unobfs;\n" 11 | if linkType == "wgobfs": wgobfs += f"sudo iptables -t mangle -I OUTPUT -p udp -m udp --sport {serverPort} -j WGOBFS --key {wgobfsSharedKey} --obfs;\n" 12 | if linkType == "ipt_xor" and not "v6" in interface: wgobfs += f'sudo iptables -t mangle -I OUTPUT -p udp -d {connectivity["ipv4"]} -j XOR --keys "{wgobfsSharedKey}";\n' 13 | if linkType == "ipt_xor" and not "v6" in interface: wgobfs += f'sudo iptables -t mangle -I INPUT -p udp -s {connectivity["ipv4"]} -j XOR --keys "{wgobfsSharedKey}";\n' 14 | wgobfsReverse = wgobfs.replace("mangle -I","mangle -D") 15 | template = f'''#!/bin/bash 16 | #Area {area} 17 | if [ "$1" == "up" ]; then 18 | {wgobfs} 19 | sudo ip link add dev {interface} type {wgProtocol} 20 | sudo ip address add dev {interface} {freeSubnet} 21 | sudo ip -6 address add dev {interface} {freeSubnetv6} 22 | sudo {wgPrefix} set {interface} listen-port {serverPort} private-key /opt/wg-mesh/links/{interface}.key peer {clientPublicKey} preshared-key /opt/wg-mesh/links/{interface}.pre allowed-ips 0.0.0.0/0,::0/0 23 | sudo ip link set {interface} mtu {mtu} 24 | sudo ip link set up dev {interface} 25 | else 26 | {wgobfsReverse} 27 | sudo ip link delete dev {interface} 28 | fi''' 29 | return template 30 | 31 | def genClient(self,interface,config,resp,serverIPExternal,linkType="default",prefix="10.0",peerPrefix="172.31"): 32 | serverID,freeSubnet,freeSubnetv6,serverPort,serverPublicKey,wgobfsSharedKey = resp['id'],resp['freeSubnet'],resp['freeSubnetv6'],resp['freePort'],resp['publicKeyServer'],resp['wgobfsSharedKey'] 33 | wgobfs,mtu = "",1412 if "v6" in interface else 1420 34 | wgPrefix = "awg" if linkType == "amneziawg" else "wg" 35 | wgProtocol = "amneziawg" if linkType == "amneziawg" else "wireguard" 36 | if linkType == "wgobfs": wgobfs += f"sudo iptables -t mangle -I INPUT -p udp -m udp --sport {serverPort} -j WGOBFS --key {wgobfsSharedKey} --unobfs;\n" 37 | if linkType == "wgobfs": wgobfs += f"sudo iptables -t mangle -I OUTPUT -p udp -m udp --dport {serverPort} -j WGOBFS --key {wgobfsSharedKey} --obfs;\n" 38 | if linkType == "ipt_xor" and not "v6" in interface: wgobfs += f'sudo iptables -t mangle -I OUTPUT -p udp -d {serverIPExternal} -j XOR --keys "{wgobfsSharedKey}";\n' 39 | if linkType == "ipt_xor" and not "v6" in interface: wgobfs += f'sudo iptables -t mangle -I INPUT -p udp -s {serverIPExternal} -j XOR --keys "{wgobfsSharedKey}";\n' 40 | wgobfsReverse = wgobfs.replace("mangle -I","mangle -D") 41 | template = f'''#!/bin/bash 42 | #Area {config['bird']["area"]} 43 | if [ "$1" == "up" ]; then 44 | {wgobfs} 45 | sudo ip link add dev {interface} type {wgProtocol} 46 | sudo ip address add dev {interface} {freeSubnet} 47 | sudo ip -6 address add dev {interface} {freeSubnetv6} 48 | sudo {wgPrefix} set {interface} private-key /opt/wg-mesh/links/{interface}.key peer {serverPublicKey} preshared-key /opt/wg-mesh/links/{interface}.pre allowed-ips 0.0.0.0/0,::0/0 endpoint {serverIPExternal}:{serverPort} 49 | sudo ip link set {interface} mtu {mtu} 50 | sudo ip link set up dev {interface} 51 | else 52 | {wgobfsReverse} 53 | sudo ip link delete dev {interface} 54 | fi''' 55 | return template 56 | 57 | def getNodeVXLAN(self,config): 58 | vxlanSubnet = ipaddress.ip_network(config['subnetVXLAN']) 59 | for index,host in enumerate(list(vxlanSubnet.hosts()),start=1): 60 | #if /23 is used, override 0 to 1 since the VXLAN can't use a zero, 1 can't be used anyway since blocked by collision detection 61 | if index == int(config['id']) or (int(config['id']) == 0 and index == 1): return f"{str(host)}/24" 62 | 63 | def genDummy(self,config,connectivity): 64 | serverID = int(config['id']) 65 | serverID += config['vxlanOffset'] 66 | vxlanID = config['subnetVXLAN'].split(".")[2] 67 | prefix = ".".join(config['subnet'].split(".")[:2]) 68 | masquerade = "" 69 | if connectivity['ipv4']: masquerade += "sudo iptables -t nat -A POSTROUTING -o $(ip route show default | awk '/default/ {{print $5}}' | tail -1) -j MASQUERADE;\n" 70 | if connectivity['ipv6']: masquerade += "sudo ip6tables -t nat -A POSTROUTING -o $(ip -6 route show default | awk '/default/ {{print $5}}' | tail -1) -j MASQUERADE;\n" 71 | masqueradeReverse = masquerade.replace("-A POSTROUTING","-D POSTROUTING") 72 | template = f'''#!/bin/bash 73 | if [ "$1" == "up" ]; then 74 | {masquerade} 75 | sudo ip addr add {prefix}.{serverID}.1/30 dev lo; 76 | sudo ip -6 addr add fd10:0:{serverID}::1/48 dev lo; 77 | sudo ip link add vxlan1 type vxlan id 1 dstport 1789 local {prefix}.{serverID}.1; 78 | sudo ip -6 link add vxlan1v6 type vxlan id 2 dstport 1790 local fd10:0:{serverID}::1; 79 | sudo ip link set vxlan1 up; sudo ip -6 link set vxlan1v6 up; 80 | sudo ip addr add {self.getNodeVXLAN(config)} dev vxlan1; 81 | sudo ip -6 addr add fd10:{vxlanID}::{serverID}/64 dev vxlan1v6; 82 | else 83 | {masqueradeReverse} 84 | sudo ip addr del {prefix}.{serverID}.1/30 dev lo; 85 | sudo ip -6 addr del fd10:0:{serverID}::1/48 dev lo; 86 | sudo ip link delete vxlan1; sudo ip -6 link delete vxlan1v6; 87 | fi''' 88 | return template 89 | 90 | def genBGPPeer(self,subnetPrefix,peer): 91 | export = f"{subnetPrefix}.0.0/16" 92 | return ''' 93 | protocol bgp '''+peer["nic"]+''' { 94 | ipv4 { 95 | preference 175; 96 | import all; 97 | export where net ~ [ '''+export+''' ]; 98 | }; 99 | local as '''+"".join(peer["origin"].split(".")[2:])+'''; 100 | neighbor '''+peer["target"]+''' as '''+"".join(peer["target"].split(".")[2:])+'''; 101 | } 102 | ''' 103 | 104 | def genInterfaceOSPF(self,data,ospfType=2): 105 | nic = data['nic'] 106 | template = f'\n\t\tinterface "{nic}" {{' 107 | template += '\n\t\t\tstub;' if "Peer" in data ['nic'] else '\n\t\t\ttype ptmp;' 108 | if ospfType == 2 and not "Peer" in data['nic']: template += f"\n\t\t\tneighbors {{ {data['target']}; }};" 109 | template += f"\n\t\t\tcost {data['cost']};\n\t\t}};" 110 | return template 111 | 112 | def genBird(self,latency,peers,config): 113 | isRouter = "yes" if config['bird']['client'] else "no" 114 | subnetPrefix = ".".join(config['subnet'].split(".")[:2]) 115 | routerID = f"{subnetPrefix}.{config['id']}.1" 116 | logLevels = f"{config['bird']['loglevel']}" 117 | template = f'log "/etc/bird/bird.log" {logLevels};\nrouter id {routerID}; #generated {int(time.time())}' 118 | template += "\n\nprotocol device {\n\tscan time 10;\n}\n" 119 | 120 | localPTP = "" 121 | for area,latencyData in latency.items(): 122 | for data in latencyData: 123 | if localPTP != "": 124 | localPTP += "," 125 | localPTP += data['target']+"/32-" 126 | 127 | template += f"\nfunction avoid_local_ptp() {{\n\t### Avoid fucking around with direct peers\n\treturn net ~ [ {localPTP} ];\n}}" 128 | template += '\n\nprotocol direct {\n\tipv4;\n\tipv6;\n\tinterface "lo";\n\tinterface "tunnel*";\n}' 129 | template += f'\n\nprotocol static {{\n\tipv4;\n\troute {subnetPrefix}.0.0/16 unreachable;\n\tinclude "static.conf";\n\n}}' 130 | template += '\ninclude "bgp.conf";' 131 | 132 | #BGP Peers 133 | for peer in peers: 134 | template += self.genBGPPeer(subnetPrefix,peer) 135 | 136 | template += "\nprotocol kernel {\n\tipv4 {\n\t\texport filter { " 137 | template += f"\n\t\t\tkrt_prefsrc = {routerID};" 138 | template += "\n\t\t\tif avoid_local_ptp() then reject;\n\t\t\taccept;\n\t\t};\n\t};\n}" 139 | template += "\n\nprotocol kernel {\n\tipv6 { export all; };\n}" 140 | 141 | if config['bird']['ospfv2']: 142 | template += "\n\nfilter export_OSPF {\n\tif source ~ [ RTS_DEVICE ] then accept;" 143 | for peerSubnet in config['AllowedPeers']: 144 | template += f"\n\tif net ~ [ {peerSubnet} ] then accept;" 145 | template += "\n\treject;\n}" 146 | template += f"\n\nprotocol ospf {{\n\ttick {config['bird']['tick']};\n\tgraceful restart yes;\n\tstub router {isRouter};" 147 | template += f"\n\tipv4 {{\n\t\timport all;\n\t\texport filter export_OSPF;\n\t}};" 148 | for area,latencyData in latency.items(): 149 | template += f"\n\tarea {area} {{" 150 | for data in latencyData: 151 | template += self.genInterfaceOSPF(data) 152 | template += "\n\t};" 153 | template += "\n}" 154 | 155 | if config['bird']['ospfv3']: 156 | template += f"\n\nfilter export_OSPFv3 {{\n\tif (net.len > 48) then reject;\n\tif source ~ [ RTS_DEVICE, RTS_STATIC ] then accept;\n\treject;\n}}" 157 | template += f"\n\nprotocol ospf v3 {{\n\ttick {config['bird']['tick']};\n\tgraceful restart yes;\n\tstub router {isRouter};" 158 | template += f"\n\tipv6 {{\n\t\texport filter export_OSPFv3;\n\t}};" 159 | for area,latencyData in latency.items(): 160 | template += f"\n\tarea {area} {{" 161 | for data in latencyData: 162 | template += self.genInterfaceOSPF(data,3) 163 | template += "\n\t};" 164 | template += "\n}\n" 165 | 166 | return template 167 | -------------------------------------------------------------------------------- /Class/wireguard.py: -------------------------------------------------------------------------------- 1 | import urllib.request, ipaddress, requests, random, string, json, time, re, os 2 | from Class.templator import Templator 3 | from Class.network import Network 4 | from Class.base import Base 5 | 6 | class Wireguard(Base): 7 | Templator = Templator() 8 | 9 | def __init__(self,path,skip=False,onlyConfig=False): 10 | self.path = path 11 | self.isInitial = False 12 | if skip: return 13 | if not os.path.isfile(f"{self.path}/configs/config.json"): exit("Config missing") 14 | self.config = self.readJson(f'{self.path}/configs/config.json') 15 | if onlyConfig: return 16 | self.Network = Network(self.config) 17 | self.prefix = self.config['prefix'] 18 | self.subnetPrefix = ".".join(self.config['subnet'].split(".")[:2]) 19 | self.subnetPrefixSplitted = self.config['subnet'].split(".") 20 | self.subnetPeerPrefix = ".".join(self.config['subnetPeer'].split(".")[:2]) 21 | self.subnetPeerPrefixSplitted = self.config['subnetPeer'].split(".") 22 | 23 | def updateConfig(self): 24 | reconfigureDummy = False 25 | if not "defaultLinkType" in self.config: self.config['defaultLinkType'] = "default" 26 | if not "listenPort" in self.config: self.config['listenPort'] = 8080 27 | if not "operationMode" in self.config: self.config['operationMode'] = 0 28 | if not "vxlanOffset" in self.config: self.config['vxlanOffset'] = 0 29 | if not "subnet" in self.config: self.config['subnet'] = "10.0.0.0/16" 30 | if not "subnetPeer" in self.config: self.config['subnetPeer'] = "172.31.0.0/16" 31 | if not "subnetVXLAN" in self.config: 32 | self.config['subnetVXLAN'] = "10.0.251.0/24" 33 | reconfigureDummy = True 34 | if not "subnetLinkLocal" in self.config: self.config['subnetLinkLocal'] = "fe82:" 35 | if not "AllowedPeers" in self.config: self.config['AllowedPeers'] = [] 36 | if not "linkTypes" in self.config: self.config['linkTypes'] = ["default"] 37 | if not os.path.isfile("/etc/bird/static.conf"): self.cmd('touch /etc/bird/static.conf') 38 | if not os.path.isfile("/etc/bird/bgp.conf"): self.cmd('touch /etc/bird/bgp.conf') 39 | if not "bird" in self.config: self.config['bird'] = {} 40 | if not "ospfv2" in self.config['bird']: self.config['bird']['ospfv2'] = True 41 | if not "ospfv3" in self.config['bird']: self.config['bird']['ospfv3'] = True 42 | if not "area" in self.config['bird']: self.config['bird']['area'] = 0 43 | if not "tick" in self.config['bird']: self.config['bird']['tick'] = 1 44 | if not "client" in self.config['bird']: self.config['bird']['client'] = False 45 | if not "loglevel" in self.config['bird']: self.config['bird']['loglevel'] = "{ warning, fatal}" 46 | if not "reloadInterval" in self.config['bird']: self.config['bird']['reloadInterval'] = 600 47 | if not "reloadPercentage" in self.config['bird']: self.config['bird']['reloadPercentage'] = 15 48 | if not "notifications" in self.config: self.config['notifications'] = {"enabled":False,"gotifyUp":"","gotifyDown":"","gotifyError":"","gotifyDiag":""} 49 | if not "gotifyDiag" in self.config['notifications']: self.config['notifications']['gotifyDiag'] = "" 50 | self.saveJson(self.config,f"{self.path}/configs/config.json") 51 | if reconfigureDummy: self.reconfigureDummy() 52 | 53 | def genKeys(self): 54 | keys = self.cmd('key=$(wg genkey) && echo $key && echo $key | wg pubkey')[0] 55 | privateKeyServer, publicKeyServer = keys.splitlines() 56 | return privateKeyServer, publicKeyServer 57 | 58 | def genPreShared(self): 59 | return self.cmd('wg genpsk')[0] 60 | 61 | def getConfig(self): 62 | return self.config 63 | 64 | def getPublic(self,private): 65 | return self.cmd(f'echo {private} | wg pubkey')[0].rstrip() 66 | 67 | def loadConfigs(self,files): 68 | configs = [] 69 | for config in files: configs.append(self.readFile(f'{self.path}/links/{config}')) 70 | return configs 71 | 72 | def getConfigs(self,abort=True): 73 | files = os.listdir(f'{self.path}/links/') 74 | for file in list(files): 75 | if not file.endswith(".sh"): files.remove(file) 76 | if not files and abort: exit(f"No {self.prefix} configs found") 77 | return files 78 | 79 | def fetch(self,url): 80 | try: 81 | request = urllib.request.urlopen(url, timeout=3) 82 | if (request.getcode() != 200): 83 | print(f"Failed to fetch {url}") 84 | return 85 | except: 86 | return 87 | return request.read().decode('utf-8').strip() 88 | 89 | def getIP(self,config): 90 | for key,ip in config['connectivity'].items(): 91 | if ip is not None: return ip 92 | 93 | def init(self,id,listen): 94 | if os.path.isfile(f"{self.path}/config.json"): exit("Config already exists") 95 | print("Getting external IPv4 and IPv6") 96 | ipv4 = self.fetch("https://checkip.amazonaws.com") 97 | ipv6 = self.fetch("https://api6.ipify.org/") 98 | print(f"Got {ipv4} and {ipv6}") 99 | #config 100 | print("Generating config.json") 101 | connectivity = {"ipv4":ipv4,"ipv6":ipv6} 102 | config = {"listen":listen,"listenPort":8080,"basePort":51820,"operationMode":0,"vxlanOffset":0,"subnet":"10.0.0.0/16","subnetPeer":"172.31.0.0/16", 103 | "subnetVXLAN":"10.0.251.0/24","subnetLinkLocal":"fe82:","AllowedPeers":[],"prefix":"pipe","id":int(id),"linkTypes":["default"],"defaultLinkType":"default","connectivity":connectivity, 104 | "bird":{"ospfv2":True,"ospfv3":True,"area":0,"tick":1,"client":False,"loglevel":"{ warning, fatal}","reloadInterval":600,"reloadPercentage":15},"notifications":{"enabled":False,"gotifyUp":"","gotifyDown":"","gotifyError":"","gotifyDiag":""}} 105 | response = self.saveJson(config,f"{self.path}/configs/config.json") 106 | if not response: exit("Unable to save config.json") 107 | #load configs 108 | self.prefix = "pipe" 109 | configs = self.getConfigs(False) 110 | #dummy 111 | if not "dummy.sh" in configs: 112 | dummyConfig = self.Templator.genDummy(config,connectivity) 113 | self.saveFile(dummyConfig,f"{self.path}/links/dummy.sh") 114 | self.setInterface("dummy","up") 115 | 116 | def reconfigureDummy(self): 117 | self.setInterface("dummy","down") 118 | self.cleanInterface("dummy",False) 119 | dummyConfig = self.Templator.genDummy(self.config,self.config['connectivity']) 120 | self.saveFile(dummyConfig,f"{self.path}/links/dummy.sh") 121 | self.setInterface("dummy","up") 122 | 123 | def findLowest(self,min,list): 124 | for i in range(min,min + 400): 125 | if i not in list and i % 2 == 0: return i 126 | 127 | def minimal(self,files,port=51820): 128 | ports,usedSubnets,usedSubnetsv6,freeSubnet = [],[],[],"" 129 | if port == 0: port = random.randint(1500, 55000) 130 | for file in files: 131 | config = self.readFile(f"{self.path}/links/{file}") 132 | configPort = re.findall(f"listen-port\s([0-9]+)",config, re.MULTILINE) 133 | configIP = re.findall(f"ip address add dev.*?([0-9.]+\/31)",config, re.MULTILINE) 134 | configIPv6 = re.findall(f"ip -6 address add dev.*?([a-zA-Z0-9:]+\/127)",config,re.MULTILINE) 135 | #Clients are ignored since they use a different subnet 136 | if not configPort: continue 137 | ports.append(int(configPort[0])) 138 | usedSubnets.append(configIP[0]) 139 | usedSubnetsv6.append(configIPv6[0]) 140 | freePort = self.findLowest(port,ports) 141 | try: 142 | #Get available subnets 143 | peerSubnets = self.Network.getPeerSubnets() 144 | peerSubnetsv6 = self.Network.getPeerSubnetsv6() 145 | #Convert to network objects 146 | usedSubnets = {ipaddress.ip_network(subnet) for subnet in usedSubnets} 147 | usedSubnetsv6 = {ipaddress.ip_network(subnet) for subnet in usedSubnetsv6} 148 | #Find usable subnets 149 | freeSubnets = set(peerSubnets) - usedSubnets 150 | freeSubnetsv6 = set(peerSubnetsv6) - usedSubnetsv6 151 | for subnet in sorted(freeSubnets, key=lambda x: int(x.network_address)): 152 | freeSubnet = str(subnet) 153 | break 154 | for subnet in sorted(freeSubnetsv6, key=lambda x: int(x.network_address)): 155 | freeSubnetv6 = str(subnet) 156 | break 157 | return freeSubnet,freeSubnetv6,freePort 158 | except: 159 | return "","",0 160 | 161 | def getInterface(self,id,type="",network=""): 162 | return f"{self.prefix}{network}{id}{type}" 163 | 164 | def filterInterface(self,interface): 165 | return interface.replace(".sh","") 166 | 167 | def getInterfaceRemote(self,interface,network=""): 168 | v6 = "v6" if "v6" in interface else "" 169 | return f"{self.prefix}{network}{self.config['id']}{v6}" 170 | 171 | def setInterface(self,file,state): 172 | self.cmd(f'bash {self.path}/links/{file}.sh {state}') 173 | 174 | def cleanInterface(self,interface,deleteKey=True): 175 | os.remove(f"{self.path}/links/{interface}.sh") 176 | if deleteKey: 177 | os.remove(f"{self.path}/links/{interface}.key") 178 | if os.path.isfile(f"{self.path}/links/{interface}.pre"): os.remove(f"{self.path}/links/{interface}.pre") 179 | if os.path.isfile(f"{self.path}/links/{interface}.json"): os.remove(f"{self.path}/links/{interface}.json") 180 | 181 | def removeInterface(self,interface): 182 | self.setInterface(interface,"down") 183 | self.cleanInterface(interface) 184 | 185 | def clean(self,ignoreJSON,ignoreEndpoint): 186 | links = self.getLinks(True,ignoreJSON) 187 | offline,online = self.checkLinks(links) 188 | for link in offline: 189 | data = links[link] 190 | parsed, remote = self.getRemote(data['config'],self.subnetPrefixSplitted) 191 | print(f"Found dead link {link} ({remote})") 192 | pings = self.fping([data['vxlan']],3,True) 193 | if ignoreEndpoint or not pings or not pings[data['vxlan']]: 194 | print(f"Unable to reach endpoint {link} ({data['vxlan']})") 195 | print(f"Removing {link} ({data['vxlan']})") 196 | interface = self.filterInterface(link) 197 | self.removeInterface(interface) 198 | else: 199 | print(f"Endpoint {data['vxlan']} still up, ignoring.") 200 | 201 | def getFilename(self,links,remote): 202 | for filename, row in links.items(): 203 | if row['remote'] == remote: return filename 204 | 205 | def filesToLinks(self,files,useJSON=True): 206 | links = {} 207 | for findex, filename in enumerate(files): 208 | if not filename.endswith(".sh") or filename == "dummy.sh": continue 209 | config = self.readFile(f"{self.path}/links/{filename}") 210 | if not config: 211 | print(f"{filename} is empty!") 212 | continue 213 | link = filename.replace(".sh","") 214 | linkConfig = self.readJson(f"{self.path}/links/{link}.json") 215 | subnetPrefix,subnetPrefixSplitted = self.subnetSwitch(filename) 216 | if linkConfig and useJSON: 217 | remotePublic = linkConfig['remotePublic'] 218 | destination = linkConfig['remote'] 219 | else: 220 | remotePublic = "" 221 | destination = "" 222 | #grab wg server ip from client wg config 223 | if "endpoint" in config: 224 | remotePublic = re.findall(f'endpoint\s(.*):',config, re.MULTILINE)[0] 225 | destination = re.findall(f'({subnetPrefixSplitted[0]}\.{subnetPrefixSplitted[1]}\.[0-9]+\.)',config, re.MULTILINE) 226 | if not destination: 227 | print(f"Ignoring {filename}") 228 | continue 229 | destination = f"{destination[0]}1" 230 | elif "Peer" in filename: 231 | peerIP = re.findall("Peer\s([0-9.]+)",config, re.MULTILINE) 232 | if not peerIP: 233 | print(f"Unable to figure out peer for {filename}") 234 | continue 235 | destination = peerIP[0] 236 | elif "listen-port" in config: 237 | #grab ID from filename 238 | linkID = re.findall(f"{self.prefix}.*?([0-9]+)",filename, re.MULTILINE)[0] 239 | destination = f"{subnetPrefix}.{linkID}.1" 240 | #get remote endpoint 241 | local, remote = self.getRemote(config,subnetPrefixSplitted) 242 | #grab publickey 243 | publicKey = re.findall(f"peer\s([A-Za-z0-9/.=+]+)",config,re.MULTILINE)[0] 244 | #grab area 245 | area = re.findall(f"Area\s([0-9]+)",config,re.MULTILINE) 246 | area = int(area[0]) if area else 0 247 | links[filename] = {"filename":filename,"vxlan":destination,"local":local,"remote":remote,'remotePublic':remotePublic,'publicKey':publicKey,"area":area,"config":config} 248 | return links 249 | 250 | def AskProtocol(self,dest,token=""): 251 | #ask remote about available protocols 252 | req = self.call(f'{dest}/connectivity',{"token":token}) 253 | if req == False: return False 254 | if req.status_code != 200: 255 | print("Failed to request connectivity info") 256 | return False 257 | data = req.json() 258 | return data 259 | 260 | def subnetSwitch(self,network=""): 261 | if "Peer" in network: 262 | return self.subnetPeerPrefix,self.subnetPeerPrefixSplitted 263 | else: 264 | return self.subnetPrefix,self.subnetPrefixSplitted 265 | 266 | def connect(self,dest,token="",linkType="",port=51820,network=""): 267 | print(f"Connecting to {dest}") 268 | #generate new key pair 269 | clientPrivateKey, clientPublicKey = self.genKeys() 270 | #initial check 271 | configs = self.cmd('ip addr show')[0] 272 | subnetPrefix,subnetPrefixSplitted = self.subnetSwitch(network) 273 | links = self.getBirdLinks(configs,self.prefix,subnetPrefixSplitted) 274 | self.isInitial = False if links else True 275 | status = {"v4":False,"v6":False} 276 | #ask remote about available protocols 277 | data = self.AskProtocol(dest,token) 278 | if not data: return status 279 | #start with the protocol which is available 280 | if data['connectivity']['ipv4'] and self.config['connectivity']['ipv4']: isv6 = False 281 | elif data['connectivity']['ipv6'] and self.config['connectivity']['ipv6']: isv6 = True 282 | #if neither of these are available, leave it 283 | else: return status 284 | #linkType 285 | if linkType == "": 286 | if self.config['defaultLinkType'] in data['linkTypes']: 287 | linkType = self.config['defaultLinkType'] 288 | else: 289 | linkType = "default" 290 | for run in range(2): 291 | #call destination 292 | payload = {"clientPublicKey":clientPublicKey,"id":self.config['id'],"token":token, 293 | "ipv6":isv6,"initial":self.isInitial,"linkType":linkType,"area":self.config['bird']['area'],"prefix":subnetPrefix,"network":network,"connectivity":self.config['connectivity']} 294 | if port != 51820: payload["port"] = port 295 | req = self.call(f'{dest}/connect',payload) 296 | if req == False: return status 297 | if req.status_code == 412: 298 | print(f"Link already exists to {dest}") 299 | elif req.status_code == 200: 300 | resp = req.json() 301 | #check if v6 or v4 302 | interfaceType = "v6" if isv6 else "" 303 | connectivity = f"[{resp['connectivity']['ipv6']}]" if isv6 else resp['connectivity']['ipv4'] 304 | #interface 305 | interface = self.getInterface(resp['id'],interfaceType,network) 306 | #generate config 307 | clientConfig = self.Templator.genClient(interface,self.config,resp,connectivity,linkType,subnetPrefix,data['subnetPrefix']) 308 | print(f"Creating & Starting {interface}") 309 | self.saveFile(clientPrivateKey,f"{self.path}/links/{interface}.key") 310 | self.saveFile(resp['preSharedKey'],f"{self.path}/links/{interface}.pre") 311 | self.saveFile(clientConfig,f"{self.path}/links/{interface}.sh") 312 | linkConfig = {'remote':f"{data['subnetPrefix']}.{resp['id']}.1",'remotePublic':connectivity.replace("[","").replace("]",""),"linkType":linkType} 313 | self.saveJson(linkConfig,f"{self.path}/links/{interface}.json") 314 | self.setInterface(interface,"up") 315 | status["v6" if isv6 else "v4"] = True 316 | else: 317 | print(f"Failed to connect to {dest}") 318 | print(f"Got {req.text} as response") 319 | return status 320 | #before we try to setup a v4 in v6 wg, we check if booth hosts have IPv6 connectivity 321 | if not self.config['connectivity']['ipv6'] or not data['connectivity']['ipv6']: break 322 | if not self.config['connectivity']['ipv4'] or not data['connectivity']['ipv4']: break 323 | #second run going to be IPv6 if available 324 | isv6 = True 325 | return status 326 | 327 | def updateLink(self,link,data): 328 | config = self.readFile(f"{self.path}/links/{link}.sh") 329 | if 'port' in data: config = re.sub(f"listen-port ([0-9]+)", f"listen-port {data['port']}", config, 0, re.MULTILINE) 330 | if 'xorKey' in data: 331 | xorKey = data['xorKey'] 332 | config = re.sub(f'--keys."(.*?)"', f'--keys "{xorKey}"', config, 0, re.MULTILINE) 333 | if 'cost' in data: self.setCost(link,data['cost']) 334 | self.saveFile(config,f"{self.path}/links/{link}.sh") 335 | 336 | def getUsedIDs(self): 337 | targets = self.getRoutes(self.subnetPrefixSplitted) 338 | parsed = re.findall(f"([0-9]+).0\/30",", ".join(targets), re.MULTILINE) 339 | parsed.sort(key = int) 340 | return parsed 341 | 342 | def bender(self): 343 | print("Getting Routes") 344 | parsed = self.getUsedIDs() 345 | print("Route Bender nodes.json") 346 | for id in parsed: print(f'"{self.subnetPrefix}.252.{id}",') 347 | 348 | def used(self): 349 | print("Getting Routes") 350 | parsed = self.getUsedIDs() 351 | print("Already used ID's") 352 | print(parsed) 353 | 354 | def proximity(self,cutoff=0): 355 | fpingTargets, existing = [],[] 356 | links = self.getLinks() 357 | for link,details in links.items(): existing.append(details['vxlan']) 358 | print("Getting Routes") 359 | targets = self.getRoutes(self.subnetPrefixSplitted) 360 | print("Getting Connection info") 361 | ips = {} 362 | local = f"{self.subnetPrefix}.{self.config['id']}.1" 363 | for target in targets: 364 | target = target.replace("0/30","1") 365 | if target == local: 366 | print(f"Skipping {target} since local.") 367 | continue 368 | resp = self.AskProtocol(f'http://{target}:{self.config["listenPort"]}','') 369 | if not resp: continue 370 | ips[resp['connectivity']['ipv4']] = target 371 | ips[resp['connectivity']['ipv6']] = target 372 | for ip in ips: 373 | if ip != None: fpingTargets.append(ip) 374 | print("Getting Latency") 375 | fping = self.fping(fpingTargets,10) 376 | latencyData = {} 377 | print("Parsing Results") 378 | for ip in fping: latencyData[ip] = self.getAvrg(fping[ip]) 379 | latencyData = {k: latencyData[k] for k in sorted(latencyData, key=latencyData.get)} 380 | terminate, result = [], [] 381 | result.append("Target\tIP address\tConnected\tLatency") 382 | result.append("-------\t-------\t-------\t-------") 383 | for ip,latency in latencyData.items(): 384 | if latency > float(cutoff): terminate.append(ips[ip]) 385 | result.append(f"{ips[ip]}\t{ip}\t{bool(ips[ip] in existing)}\t{latency}ms") 386 | result = self.formatTable(result) 387 | if cutoff == 0: 388 | print(result) 389 | return True 390 | for ip,latency in latencyData.items(): 391 | if latency > float(cutoff): continue 392 | targetSplit = ips[ip].split(".") 393 | #reserve 10.0.200+ for clients, don't mesh 394 | if int(targetSplit[2]) >= 200: continue 395 | if ips[ip] in existing: continue 396 | self.connect(f"http://{ips[ip]}:{self.config['listenPort']}") 397 | for link,details in links.items(): 398 | if not details['vxlan'] in terminate: continue 399 | self.disconnect([link]) 400 | 401 | def getFiles(self): 402 | files = os.listdir(f"{self.path}/links/") 403 | return [x for x in files if self.filter(x)] 404 | 405 | def getLinks(self,shouldExit=True,useJSON=True): 406 | links = self.filesToLinks(self.getFiles(),useJSON) 407 | if not links and shouldExit: exit("No links found.") 408 | return links 409 | 410 | def groupByArea(self,latencyData): 411 | results = {} 412 | wgLinks = self.getLinks() 413 | for data in latencyData: 414 | if not f"{data['nic']}.sh" in wgLinks: continue 415 | current = wgLinks[f"{data['nic']}.sh"] 416 | if not current['area'] in results: results[current['area']] = [] 417 | results[current['area']].append(data) 418 | return results 419 | 420 | def checkLinks(self,links): 421 | #fping 422 | fping = "fping -c2" 423 | for filename,row in links.items(): fping += f" {row['remote']}" 424 | results = self.cmd(fping)[1].splitlines() 425 | online,offline = [],[] 426 | #categorizing results 427 | for row in results: 428 | if "xmt/rcv/%loss" in row: 429 | ip = re.findall(f'([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)',row, re.MULTILINE)[0] 430 | filename = self.getFilename(links,ip) 431 | offline.append(filename) if "100%" in row else online.append(filename) 432 | return offline,online 433 | 434 | def disconnect(self,links=[],force=False): 435 | currentLinks, status = self.getLinks(),{} 436 | for index, link in enumerate(links): 437 | if not link.endswith(".sh"): links[index] += ".sh" 438 | if not links: 439 | print("Checking Links") 440 | offline,online = self.checkLinks(currentLinks) 441 | #shutdown the links that are offline first 442 | if offline: print(f"Found offline links, disconnecting them first. {offline}") 443 | targets = offline + online 444 | else: 445 | targets = links 446 | print("Disconnecting") 447 | for filename in targets: 448 | #if a specific link is given filter out 449 | if links and filename not in links: continue 450 | interfaceRemote = self.getInterfaceRemote(filename) 451 | #call destination 452 | if not filename in currentLinks: 453 | print(f"Unable to find link {filename}") 454 | status[filename] = False 455 | continue 456 | data = currentLinks[filename] 457 | print(f'Calling http://{data["vxlan"]}:{self.config["listenPort"]}/disconnect') 458 | req = self.call(f'http://{data["vxlan"]}:{self.config["listenPort"]}/disconnect',{"publicKeyServer":data['publicKey'],"interface":interfaceRemote}) 459 | if req == False and force == False: 460 | status[filename] = False 461 | continue 462 | if force or req.status_code == 200: 463 | interface = self.filterInterface(filename) 464 | self.removeInterface(interface) 465 | status[filename] = True 466 | else: 467 | print(f"Got {req.status_code} with {req.text} aborting") 468 | status[filename] = False 469 | #load configs 470 | configs = self.getConfigs(False) 471 | #get all links 472 | files = os.listdir(f"{self.path}/links/") 473 | #check for dummy and .gitignore 474 | if "dummy.sh" in files: files.remove("dummy.sh") 475 | if ".gitignore" in files: files.remove(".gitignore") 476 | #clear state.json if no links left 477 | if os.path.isfile(f"{self.path}/configs/state.json") and not files: 478 | print("state.json has been reset!") 479 | os.remove(f"{self.path}/configs/state.json") 480 | return status 481 | 482 | def setCost(self,link,cost=0): 483 | if os.path.isfile(f"{self.path}/links/{link}.sh"): 484 | if not os.path.exists(f"{self.path}/pipe"): 485 | print("Pipe not found, did you start wgmesh-bird?") 486 | return 487 | with open(f"{self.path}/pipe", 'w') as f: f.write(json.dumps({"link":link,"cost":cost})) 488 | return True 489 | else: 490 | print(f"Unable to find file: {self.path}/links/{link}.sh") 491 | 492 | def getConfig(self): 493 | return self.config 494 | 495 | def getInitial(self): 496 | return self.isInitial -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ne00n 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wg-mesh 2 | ## Work in Progress 3 | 4 | **Idea**
5 | - Build a [wireguard](https://www.wireguard.com/) mesh vpn in [tinc](https://www.tinc-vpn.org/) style 6 | - Simple CLI to add and manage nodes 7 | - Replacement for https://github.com/Ne00n/pipe-builder-3000/ 8 | - Simple Token auth 9 | - Decentralized 10 | 11 | **Software**
12 | - python3 13 | - wireguard (VPN) 14 | - bird2 (Routing, OSPF) 15 | 16 | **Network**
17 | - By default 10.0.x.x/16.
18 | - 10.0.id.1 Node /30
19 | - 10.0.id.4-255 peers /31
20 | - 10.0.251.1-255 vxlan /32
21 | 22 | **Features**
23 | - [x] automatic mesh buildup when node has joined 24 | - [x] join nodes via cli 25 | - [x] disconnect nodes via cli 26 | - [x] VXLAN 27 | - [x] Dualstack and/or Singlestack (Transport) 28 | - [x] Dualstack (within the VPN Network) 29 | - [x] Autostart Wireguard links on boot 30 | - [x] Active Latency optimisation 31 | - [x] Packet loss detection & rerouting 32 | - [x] High Jitter detection & rerouting 33 | - [x] Support for wgobfs, ipt_xor and AmneziaWG 34 | - [x] Push notifications via gotify 35 | 36 | **Requirements**
37 | - Debian or Ubuntu 38 | - Python 3.9 or higher 39 | - Kernel 5.4+ (wg kernel module, no user space support) 40 | 41 | Keep in mind that some containers such as OVZ or LXC, depending on kernel version and host configuration have issues with bird and/or wireguard.
42 | 43 | **Example 2 nodes**
44 | The ID needs to be unique, otherwise it will result in collisions.
45 | Keep in mind, ID's 200 and higher are reserved for clients, they won't get meshed.
46 | 47 | Public is used to expose the API to all interfaces, by default it listens only local on 10.0.id.1.
48 | Use Public only for testing! since everything is transmitted unencrypted, otherwise use a reverse proxy with TLS.
49 | 50 | Depending on what Subnet you are using, you either have to increment the ID's by 2 (10.) or by 1 (192/172.)
51 | If 10.0.x.x/16 is used (default), a /23 is reserved per node, hence you have to increment it by 2.
52 | ``` 53 | #Install wg-mesh and initialize the first node 54 | curl -so- https://raw.githubusercontent.com/Ne00n/wg-mesh/master/install.sh | bash -s -- init 0 public 55 | #Install wg-mesh and initialize the second node 56 | curl -so- https://raw.githubusercontent.com/Ne00n/wg-mesh/master/install.sh | bash -s -- init 2 57 | ``` 58 | Grab the Token from Node 0
59 | ``` 60 | wgmesh token 61 | ``` 62 | Connect Node 2 to Node 0 63 | ``` 64 | wgmesh connect http://:8080 65 | ``` 66 | After connecting successfully, a dummy.sh will be created, which assigns a 10.0.nodeID.0/30 to lo.
67 | This will be picked up by bird, so on booth nodes on 10.0.0.1 and 10.0.2.1 should be reachable after bird ran.
68 | Regarding NAT or in general behind Firewalls, the "connector" is always a Client, the endpoint the Server.
69 | 70 | **Example 2+ nodes**
71 | ``` 72 | #Install wg-mesh and initialize the first node 73 | curl -so- https://raw.githubusercontent.com/Ne00n/wg-mesh/master/install.sh | bash -s -- init 0 public 74 | #Install wg-mesh and initialize the second node 75 | curl -so- https://raw.githubusercontent.com/Ne00n/wg-mesh/master/install.sh | bash -s -- init 2 76 | #Install wg-mesh and initialize the third node 77 | curl -so- https://raw.githubusercontent.com/Ne00n/wg-mesh/master/install.sh | bash -s -- init 4 78 | ``` 79 | Grab the Token from Node 0 with 80 | ``` 81 | wgmesh token 82 | ``` 83 | Connect Node 2 to Node 0 84 | ``` 85 | wgmesh connect http://:8080 86 | ``` 87 | Before you connect the 3rd node, make sure Node 2 already has fully connected.
88 | Connect Node 4 to Node 0 89 | ``` 90 | wgmesh connect http://:8080 91 | ``` 92 | Wait for bird to pickup all routes + mesh buildup.
93 | You can check it with
94 | ``` 95 | birdc show route 96 | #and/or 97 | cat /opt/wg-mesh/configs/state.json 98 | ``` 99 | All 3 nodes should be reachable under 10.0.nodeID.1
100 | 101 | **Removal** 102 | ``` 103 | wgmesh down && bash /opt/wg-mesh/deinstall.sh 104 | ``` 105 | 106 | **Updating** 107 | ``` 108 | wgmesh update && wgmesh migrate && systemctl restart wgmesh && systemctl restart wgmesh-bird 109 | ``` 110 | 111 | **Limitations**
112 | Connecting multiple nodes at once, without waiting for the other node to finish, will result in double links.
113 | By default, when a new node joins, it checks which connections it does not have, which with a new node would be everything.
114 | 115 | Additional, bird2, by default, takes 30s to distribute the routes, there will be also a delay.
116 | In total roughtly 60s, depending on the network size, to avoid this issue.
117 | 118 | Depending on network conditions, bird will be reloaded, every 5 minutes or as short as every 20 seconds.
119 | This will drop long lived TCP connections. 120 | 121 | **Known Issues**
122 | - A client that does not have a direct connection to a newly added server, is stuck with a old outdated vxlan configuration.
123 | This can be "fixed" by reloading wgmesh-bird.
-------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | import ipaddress, threading, socket, random, logging, string, secrets, json, time, os, re 2 | from bottle import HTTPResponse, route, run, request, template 3 | from logging.handlers import RotatingFileHandler 4 | from Class.wireguard import Wireguard 5 | from Class.templator import Templator 6 | from threading import Thread 7 | from random import randint 8 | from pathlib import Path 9 | 10 | connectMutex = threading.Lock() 11 | updateMutex = threading.Lock() 12 | folder = os.path.dirname(os.path.realpath(__file__)) 13 | #wireguard 14 | wg = Wireguard(folder) 15 | config = wg.getConfig() 16 | #pull subnetPrefix 17 | subnetPrefix = ".".join(config['subnet'].split(".")[:2]) 18 | #templator 19 | templator = Templator() 20 | #logging 21 | level = "info" 22 | levels = {'critical': logging.CRITICAL,'error': logging.ERROR,'warning': logging.WARNING,'info': logging.INFO,'debug': logging.DEBUG} 23 | stream_handler = logging.StreamHandler() 24 | stream_handler.setLevel(levels[level]) 25 | logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',datefmt='%d.%m.%Y %H:%M:%S',level=levels[level],handlers=[RotatingFileHandler(maxBytes=10000000,backupCount=5,filename=f"{folder}/logs/api.log"),stream_handler]) 26 | blocklist = {} 27 | #token 28 | tokens = {"connect":[],"peer":[]} 29 | for i in range(3): 30 | token = phrase = ''.join(random.choices(string.ascii_uppercase + string.digits, k=18)) 31 | logging.info(f"Adding connect token {token}") 32 | tokens['connect'].append(token) 33 | for i in range(3): 34 | token = phrase = ''.join(random.choices(string.ascii_uppercase + string.digits, k=18)) 35 | logging.info(f"Adding peer token {token}") 36 | tokens['peer'].append(token) 37 | try: 38 | wg.saveJson(tokens,f"{folder}/tokens.json") 39 | except: 40 | logging.warning("Failed to write token file") 41 | 42 | def validateToken(payload): 43 | if not "token" in payload: return False 44 | token = re.findall(r"^([A-Za-z0-9/.=+]{18,60})$",payload['token'],re.MULTILINE | re.DOTALL) 45 | if not token: return False 46 | if "network" in payload and payload["network"] == "Peer": 47 | if payload['token'] not in tokens['peer']: return False 48 | else: 49 | if payload['token'] not in tokens['connect']: return False 50 | return True 51 | 52 | def block(requestIP,check=False): 53 | if check and requestIP not in blocklist: 54 | return False 55 | elif not requestIP in blocklist: 56 | blocklist[requestIP] = int(time.time()) + randint(120,300) 57 | elif time.time() > blocklist[requestIP]: 58 | del blocklist[requestIP] 59 | else: 60 | return True 61 | 62 | def validateID(id): 63 | result = re.findall(r"^[0-9]{1,4}$",str(id),re.MULTILINE | re.DOTALL) 64 | if not result: return False 65 | return True 66 | 67 | def validatePort(port): 68 | result = re.findall(r"^[0-9]{4,5}$",str(port),re.MULTILINE | re.DOTALL) 69 | if not result: return False 70 | return True 71 | 72 | def validateNetwork(network): 73 | result = re.findall(r"^[A-Za-z]{3,6}$",network,re.MULTILINE | re.DOTALL) 74 | if not result: return False 75 | return True 76 | 77 | def validateLinkType(linkType): 78 | linkTypes = ["default","wgobfs","ipt_xor","amneziawg"] 79 | if linkType in linkTypes: return True 80 | return False 81 | 82 | def validatePrefix(prefix): 83 | result = re.findall(r"^[0-9.]{4,6}$",prefix,re.MULTILINE | re.DOTALL) 84 | if not result: return False 85 | return True 86 | 87 | def validateConnectivity(connectivity): 88 | if "ipv4" not in connectivity or "ipv6" not in connectivity: return False 89 | try: 90 | if connectivity['ipv4']: 91 | ip_obj = ipaddress.ip_address(connectivity['ipv4']) 92 | if connectivity['ipv6']: 93 | ip_obj = ipaddress.ip_address(connectivity['ipv6']) 94 | except ValueError: 95 | return False 96 | return True 97 | 98 | def terminateLink(folder,interface,wait=True): 99 | wg = Wireguard(folder) 100 | if wait: time.sleep(2) 101 | wg.setInterface(interface,"down") 102 | wg.cleanInterface(interface) 103 | return 104 | 105 | def getReqIP(): 106 | reqIP = request.environ.get('HTTP_X_REAL_IP') or request.environ.get('REMOTE_ADDR') 107 | logging.debug(f"{reqIP} connecting") 108 | if ipaddress.ip_address(reqIP).version == 6 and ipaddress.IPv6Address(reqIP).ipv4_mapped: return ipaddress.IPv6Address(reqIP).ipv4_mapped 109 | return reqIP 110 | 111 | def getInternal(requestIP): 112 | try: 113 | return ipaddress.ip_address(requestIP) in ipaddress.ip_network(config['subnet']) 114 | except: 115 | return False 116 | 117 | @route('/connectivity',method='POST') 118 | def index(): 119 | requestIP = getReqIP() 120 | isInternal = getInternal(requestIP) 121 | payload = json.load(request.body) 122 | #check blocklist 123 | if block(requestIP,check=True): 124 | logging.info(f"{requestIP} in blocklist") 125 | return HTTPResponse(status=403, body="IP Blocked") 126 | #validate token 127 | if not isInternal and not validateToken(payload): 128 | logging.info(f"Invalid Token from {requestIP}") 129 | block(requestIP) 130 | return HTTPResponse(status=401, body="Invalid Token") 131 | geo = config['geo'] if "geo" in config else {} 132 | return HTTPResponse(status=200, body={'connectivity':config['connectivity'],'geo':geo,'linkTypes':config['linkTypes'],'subnetPrefix':subnetPrefix}) 133 | 134 | @route('/connect', method='POST') 135 | def index(): 136 | requestIP = getReqIP() 137 | isInternal = getInternal(requestIP) 138 | payload = json.load(request.body) 139 | #check blocklist 140 | if block(requestIP,check=True): 141 | logging.info(f"{requestIP} in blocklist") 142 | return HTTPResponse(status=403, body="IP Blocked") 143 | #validate token 144 | if not isInternal and not validateToken(payload): 145 | logging.info(f"Invalid Token from {requestIP}") 146 | block(requestIP) 147 | return HTTPResponse(status=401, body="Invalid Token") 148 | #validate id 149 | if not 'id' in payload or not validateID(payload['id']): 150 | logging.info(f"Invalid ID from {requestIP}") 151 | return HTTPResponse(status=400, body="Invalid ID") 152 | #validate port 153 | if "port" in payload and not validatePort(payload['port']): 154 | logging.info(f"Invalid Port from {requestIP}") 155 | return HTTPResponse(status=400, body="Invalid Port") 156 | #validate prefix 157 | if "prefix" in payload and not validatePrefix(payload['prefix']): 158 | logging.info(f"Invalid Prefix from {requestIP}") 159 | return HTTPResponse(status=400, body="Invalid Prefix") 160 | #validate network 161 | if "network" in payload and payload['network'] != "" and not validateNetwork(payload['network']): 162 | logging.info(f"Invalid Network from {requestIP}") 163 | return HTTPResponse(status=400, body="Invalid Network") 164 | #validate linkType 165 | if "linkType" in payload and not validateLinkType(payload['linkType']): 166 | logging.info(f"Invalid linkType from {requestIP}") 167 | return HTTPResponse(status=400, body="Invalid linkType") 168 | #validate area 169 | if "area" in payload and not validateID(payload['area']): 170 | logging.info(f"Invalid Area from {requestIP}") 171 | return HTTPResponse(status=400, body="Invalid Area") 172 | #validate connectivity 173 | if "connectivity" in payload and not validateConnectivity(payload['connectivity']): 174 | logging.info(f"Invalid connectivity data from {requestIP}") 175 | return HTTPResponse(status=400, body="Invalid connectivity data") 176 | #prevent local connects 177 | if payload['id'] == config['id']: 178 | logging.info(f"Invalid connection from {requestIP}") 179 | return HTTPResponse(status=400,body="Are you trying to connect to yourself?!") 180 | #defaults 181 | if not "connectivity" in payload: payload['connectivity'] = {"ipv4":"","ipv6":""} 182 | if not "linkType" in payload: payload['linkType'] = "default" 183 | if not "network" in payload: payload['network'] = "" 184 | if not "initial" in payload: payload['initial'] = False 185 | if not "prefix" in payload: payload['prefix'] = f"{subnetPrefix}" 186 | if not "area" in payload: payload['area'] = 0 187 | payload['basePort'] = config['basePort'] if not "port" in payload else payload['port'] 188 | if not "ipv6" in payload: payload['ipv6'] = False 189 | #initial 190 | if payload['initial']: 191 | routes = wg.cmd("birdc show route")[0] 192 | subnetPrefixSplitted = payload['prefix'].split(".") 193 | targets = re.findall(f"({subnetPrefixSplitted[0]}\.{subnetPrefixSplitted[1]}\.[0-9]+\.0\/30)",routes, re.MULTILINE) 194 | if f"{payload['prefix']}.{payload['id']}.0/30" in targets or (payload['prefix'] == "10.0" and f"{payload['prefix']}.{int(payload['id'])+1}.0/30" in targets): 195 | logging.info(f"ID Collision from {requestIP}") 196 | return HTTPResponse(status=416, body="Collision") 197 | #generate interface name 198 | interfaceType = "v6" if payload['ipv6'] else "" 199 | interface = wg.getInterface(payload['id'],interfaceType,payload['network']) 200 | #check if interface exists 201 | if os.path.isfile(f"{folder}/links/{interface}.sh") or os.path.isfile(f"{folder}/links/{interface}Serv.sh"): 202 | logging.info(f"Link already exists, {requestIP}") 203 | return HTTPResponse(status=412, body="Link already exists") 204 | #block any other requests to prevent issues regarding port and ip assignment 205 | connectMutex.acquire() 206 | #generate new key pair 207 | privateKeyServer, publicKeyServer = wg.genKeys() 208 | preSharedKey = wg.genPreShared() 209 | wgobfsSharedKey = secrets.token_urlsafe(24) 210 | #load configs 211 | configs = wg.getConfigs(False) 212 | freeSubnet,freeSubnetv6,freePort = wg.minimal(configs,payload['basePort']) 213 | if not freeSubnet or not freeSubnetv6: 214 | connectMutex.release() 215 | logging.info(f"Unable to allocate subnet for wireguard link, {requestIP}") 216 | return HTTPResponse(status=500, body="Unable to allocate subnet for wireguard link.") 217 | #generate wireguard config 218 | serverConfig = templator.genServer(interface,config,payload,freeSubnet,freeSubnetv6,freePort,wgobfsSharedKey) 219 | #save 220 | logging.debug(f"Creating wireguard link {interface}") 221 | wg.saveFile(privateKeyServer,f"{folder}/links/{interface}.key") 222 | wg.saveFile(preSharedKey,f"{folder}/links/{interface}.pre") 223 | wg.saveFile(serverConfig,f"{folder}/links/{interface}.sh") 224 | remotePublic = payload['connectivity']['ipv6'] if "v6" in interface else payload['connectivity']['ipv4'] 225 | linkConfig = {'remote':f"{payload['prefix']}.{payload['id']}.1",'remotePublic':remotePublic.replace("[","").replace("]",""),"linkType":payload['linkType']} 226 | wg.saveJson(linkConfig,f"{folder}/links/{interface}.json") 227 | logging.debug(f"{interface} up") 228 | wg.setInterface(interface,"up") 229 | #check for dummy 230 | if not "dummy" in configs: 231 | logging.debug(f"Creating dummy") 232 | dummyConfig = templator.genDummy(config,config['connectivity']) 233 | wg.saveFile(dummyConfig,f"{folder}/links/dummy.sh") 234 | logging.debug(f"dummy up") 235 | wg.setInterface("dummy","up") 236 | connectMutex.release() 237 | logging.info(f"{interface} created for {requestIP}") 238 | return HTTPResponse(status=200, body={"publicKeyServer":publicKeyServer,'preSharedKey':preSharedKey,'wgobfsSharedKey':wgobfsSharedKey,'id':config['id'] 239 | ,'freeSubnet':wg.Network.getHost(freeSubnet),"freeSubnetv6":wg.Network.getHost(freeSubnetv6,"127"),'freePort':freePort,'connectivity':config['connectivity']}) 240 | 241 | @route('/update', method='PATCH') 242 | def index(): 243 | requestIP = getReqIP() 244 | payload = json.load(request.body) 245 | #check blocklist 246 | if block(requestIP,check=True): 247 | logging.info(f"{requestIP} in blocklist") 248 | return HTTPResponse(status=403, body="IP Blocked") 249 | #validate interface name 250 | interface = re.findall(r"^[A-Za-z0-9]{3,50}$",payload['interface'], re.MULTILINE) 251 | if not interface: 252 | logging.info(f"Invalid interface name from {requestIP}") 253 | return HTTPResponse(status=400, body="Invalid link name") 254 | #check if interface exists 255 | if not os.path.isfile(f"{folder}/links/{payload['interface']}.sh"): 256 | logging.info(f"Invalid link from {requestIP}") 257 | return HTTPResponse(status=400, body="invalid link") 258 | #read private key 259 | with open(f"{folder}/links/{payload['interface']}.key", 'r') as file: privateKeyServer = file.read() 260 | #get public key from private key 261 | publicKeyServer = wg.getPublic(privateKeyServer) 262 | #check if they match 263 | if payload['publicKeyServer'] != publicKeyServer: 264 | logging.info(f"Invalid public key from {requestIP}") 265 | block(requestIP) 266 | return HTTPResponse(status=400, body="invalid public key") 267 | #update 268 | wg.setInterface(payload['interface'],"down") 269 | #since cost adjustments go through a pipe, there needs to be a mutex 270 | if "cost" in payload: updateMutex.acquire() 271 | logging.info(f"{payload['interface']} updating link") 272 | wg.updateLink(payload['interface'],payload) 273 | wg.setInterface(payload['interface'],"up") 274 | #the pipe is fetched every 100ms, make sure we wait until the data is fetched 275 | if "cost" in payload: 276 | time.sleep(0.1) 277 | updateMutex.release() 278 | return HTTPResponse(status=200, body="link updated") 279 | 280 | @route('/disconnect', method='POST') 281 | def index(): 282 | requestIP = getReqIP() 283 | payload = json.load(request.body) 284 | #check blocklist 285 | if block(requestIP,check=True): 286 | logging.info(f"{requestIP} in blocklist") 287 | return HTTPResponse(status=403, body="IP Blocked") 288 | #validate interface name 289 | interface = re.findall(r"^[A-Za-z0-9]{3,50}$",payload['interface'], re.MULTILINE) 290 | if not interface: 291 | logging.info(f"Invalid interface name from {requestIP}") 292 | return HTTPResponse(status=400, body="Invalid link name") 293 | #support older versions that are using Serv 294 | if os.path.isfile(f"{folder}/links/{payload['interface']}Serv.sh"): payload['interface'] = f"{payload['interface']}Serv" 295 | #check if interface exists 296 | if not os.path.isfile(f"{folder}/links/{payload['interface']}.sh"): 297 | logging.info(f"Invalid link from {requestIP}") 298 | return HTTPResponse(status=400, body="invalid link") 299 | #read private key 300 | with open(f"{folder}/links/{payload['interface']}.key", 'r') as file: privateKeyServer = file.read() 301 | #get public key from private key 302 | publicKeyServer = wg.getPublic(privateKeyServer) 303 | #check if they match 304 | if payload['publicKeyServer'] != publicKeyServer: 305 | logging.info(f"Invalid public key from {requestIP}") 306 | block(requestIP) 307 | return HTTPResponse(status=400, body="invalid public key") 308 | #terminate the link 309 | if "wait" in payload and payload['wait'] == False: 310 | terminateLink(folder,payload['interface'],False) 311 | logging.info(f"{payload['interface']} terminated") 312 | else: 313 | termination = Thread(target=terminateLink, args=([folder,payload['interface']])) 314 | termination.start() 315 | logging.info(f"{payload['interface']} started termination thread") 316 | return HTTPResponse(status=200, body="link terminated") 317 | 318 | listen = '::' if config['listen'] == "public" else f"{subnetPrefix}.{config['id']}.1" 319 | run(host=listen, port=config['listenPort'], server='paste') -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from Class.cli import CLI 4 | import sys, os 5 | 6 | options = "init , status, used, bender, migrate, recover, connect/peer , disconnect, up, down, clean, proximity, token, disable, enable, set, cost" 7 | #path 8 | path = os.path.dirname(os.path.realpath(__file__)) 9 | cli = CLI(path) 10 | 11 | if len(sys.argv) == 1: 12 | print(options) 13 | elif sys.argv[1] == "init": 14 | state = sys.argv[3] if len(sys.argv) > 3 else "local" 15 | cli.init(sys.argv[2],state) 16 | elif sys.argv[1] == "used": 17 | cli.used() 18 | elif sys.argv[1] == "status": 19 | cli.status() 20 | elif sys.argv[1] == "bender": 21 | cli.bender() 22 | elif sys.argv[1] == "connect" or sys.argv[1] == "peer": 23 | if len(sys.argv) <= 2: exit("URL is missing.") 24 | token = "dummy" if len(sys.argv) <= 3 else sys.argv[3] 25 | linkType = "" if len(sys.argv) <= 4 else sys.argv[4] 26 | port = 51820 if len(sys.argv) <= 5 else sys.argv[5] 27 | network = "Peer" if sys.argv[1] == "peer" else "" 28 | cli.connect(sys.argv[2],token,linkType,port,network) 29 | elif sys.argv[1] == "proximity": 30 | cutoff = sys.argv[2] if len(sys.argv) == 3 else 0 31 | cli.proximity(cutoff) 32 | elif sys.argv[1] == "disconnect": 33 | force,links = False,[] 34 | sys.argv = sys.argv[2:] 35 | for param in sys.argv: 36 | if param.lower() == "force": force = True 37 | if param.lower() != "force": links.append(param) 38 | cli.disconnect(links,force) 39 | elif sys.argv[1] == "up" or sys.argv[1] == "down": 40 | cli.links(sys.argv[1]) 41 | elif sys.argv[1] == "clean": 42 | ignoreJSON = False if len(sys.argv) <= 2 else True 43 | ignoreEndpoint = False if len(sys.argv) <= 3 else True 44 | cli.clean(ignoreJSON,ignoreEndpoint) 45 | elif sys.argv[1] == "migrate": 46 | cli.migrate() 47 | elif sys.argv[1] == "recover": 48 | cli.recover() 49 | elif sys.argv[1] == "token": 50 | cli.token() 51 | elif sys.argv[1] == "update": 52 | cli.update() 53 | elif sys.argv[1] == "geo": 54 | cli.geo() 55 | elif sys.argv[1] == "disable": 56 | sys.argv = sys.argv[2:] 57 | cli.disable(sys.argv) 58 | elif sys.argv[1] == "enable": 59 | sys.argv = sys.argv[2:] 60 | cli.enable(sys.argv) 61 | elif sys.argv[1] == "set": 62 | sys.argv = sys.argv[2:] 63 | cli.setOption(sys.argv) 64 | elif sys.argv[1] == "cost": 65 | if len(sys.argv) <= 2: exit("link missing") 66 | cost = None if len(sys.argv) <= 3 else int(sys.argv[3]) 67 | cli.cost(sys.argv[2],cost) 68 | else: 69 | print(options) -------------------------------------------------------------------------------- /configs/.gitignore: -------------------------------------------------------------------------------- 1 | config.json -------------------------------------------------------------------------------- /configs/nginx.certbot.example: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name wg.domain.com; 5 | server_tokens off; 6 | 7 | location '/.well-known/acme-challenge' { 8 | default_type "text/plain"; 9 | root /var/www/html/acme-challange; 10 | autoindex on; 11 | } 12 | 13 | location / { 14 | proxy_set_header X-Real-IP $remote_addr; 15 | proxy_set_header X-Forwarded-Host $host; 16 | proxy_set_header X-Forwarded-Server $host; 17 | proxy_pass http://10.0.1.1:8080; 18 | } 19 | } -------------------------------------------------------------------------------- /configs/nginx.example: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name wg.domain.com; 5 | server_tokens off; 6 | 7 | location '/.well-known/acme-challenge' { 8 | default_type "text/plain"; 9 | root /var/www/html/acme-challange; 10 | autoindex on; 11 | } 12 | 13 | location / { 14 | return 301 https://wg.domain.com$request_uri; 15 | } 16 | 17 | } 18 | 19 | server { 20 | listen [::]:443 ssl http2 ipv6only=off; 21 | server_tokens off; 22 | 23 | ssl_certificate /etc/letsencrypt/live/wg.domain.com/fullchain.pem; 24 | ssl_certificate_key /etc/letsencrypt/live/wg.domain.com/privkey.pem; 25 | 26 | server_name wg.domain.com; 27 | 28 | location / { 29 | proxy_set_header X-Real-IP $remote_addr; 30 | proxy_set_header X-Forwarded-Host $host; 31 | proxy_set_header X-Forwarded-Server $host; 32 | proxy_pass http://10.0.1.1:8080; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /configs/wgmesh-bird.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=wgmesh-bird service 3 | Wants=network-online.target wgmesh-pipe.service bird.service 4 | After=network-online.target wgmesh-pipe.service bird.service 5 | [Service] 6 | User=wg-mesh 7 | Group=wg-mesh 8 | Type=simple 9 | Restart=on-failure 10 | StandardOutput=null 11 | Environment=PYTHONUNBUFFERED=1 12 | WorkingDirectory=/opt/wg-mesh/cron 13 | ExecStart=/usr/bin/python3 bird.py -u 14 | [Install] 15 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /configs/wgmesh-diag.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=wgmesh-diag service 3 | Wants=network-online.target wgmesh-pipe.service 4 | After=network-online.target wgmesh-pipe.service 5 | [Service] 6 | User=wg-mesh 7 | Group=wg-mesh 8 | Type=simple 9 | Restart=on-failure 10 | StandardOutput=null 11 | WorkingDirectory=/opt/wg-mesh 12 | WorkingDirectory=/opt/wg-mesh/cron 13 | ExecStart=/usr/bin/python3 diag.py 14 | [Install] 15 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /configs/wgmesh-pipe.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=wgmesh-pipe service 3 | Wants=network-online.target 4 | After=network-online.target 5 | [Service] 6 | User=wg-mesh 7 | Group=wg-mesh 8 | WorkingDirectory=/opt/wg-mesh/ 9 | ExecStart=/usr/bin/python3 cli.py up 10 | RemainAfterExit=true 11 | Type=oneshot 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /configs/wgmesh-rotate.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=wgmesh-rotate service 3 | Wants=network-online.target wgmesh-pipe.service bird.service 4 | After=network-online.target wgmesh-pipe.service bird.service 5 | [Service] 6 | User=wg-mesh 7 | Group=wg-mesh 8 | Type=notify 9 | TimeoutSec=300 10 | Restart=on-failure 11 | StandardOutput=null 12 | Environment=PYTHONUNBUFFERED=1 13 | WorkingDirectory=/opt/wg-mesh/cron 14 | ExecStart=/usr/bin/python3 rotate.py 15 | [Install] 16 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /configs/wgmesh.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=wgmesh service 3 | Wants=network-online.target wgmesh-pipe.service 4 | After=network-online.target wgmesh-pipe.service 5 | [Service] 6 | User=wg-mesh 7 | Group=wg-mesh 8 | Type=simple 9 | Restart=on-failure 10 | StandardOutput=null 11 | WorkingDirectory=/opt/wg-mesh 12 | ExecStart=/usr/bin/python3 api.py 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /cron/bird.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import logging, threading, shutil, queue, time, sys, os 3 | sys.path.append("..") # Adds higher directory to python modules path. 4 | from logging.handlers import RotatingFileHandler 5 | from Class.latency import Latency 6 | from Class.bird import Bird 7 | 8 | path = os.path.dirname(os.path.realpath(__file__)) 9 | path = path.replace("/cron","") 10 | 11 | #logging 12 | level = "info" 13 | levels = {'critical': logging.CRITICAL,'error': logging.ERROR,'warning': logging.WARNING,'info': logging.INFO,'debug': logging.DEBUG} 14 | stream_handler = logging.StreamHandler() 15 | stream_handler.setLevel(levels[level]) 16 | logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',datefmt='%d.%m.%Y %H:%M:%S',level=levels[level],handlers=[RotatingFileHandler(maxBytes=10000000,backupCount=5,filename=f"{path}/logs/network.log"),stream_handler]) 17 | logger = logging.getLogger() 18 | 19 | latency = Latency(path,logger) 20 | bird = Bird(path,logger) 21 | 22 | def readPipe(messagesQueue,last=""): 23 | if not os.path.exists(f"{path}/pipe"): 24 | print(f"Creating pipe {path}/pipe") 25 | os.mkfifo(f"{path}/pipe") 26 | while True: 27 | with open(f'{path}/pipe', 'r') as f: 28 | time.sleep(0.1) 29 | data = f.read() 30 | if data and data != last: 31 | messagesQueue.put(data) 32 | last = data 33 | 34 | messagesQueue = queue.Queue() 35 | pipeThread = threading.Thread(target=readPipe, args=(messagesQueue,)) 36 | pipeThread.start() 37 | 38 | pathToLinks,links = f'{path}/links/',[] 39 | 40 | skip,skipUntil = 0,0 41 | restartCooldown = regenCooldown = int(time.time()) + 1800 42 | 43 | total, used, free = shutil.disk_usage("/") 44 | usagePercent = (used / total) * 100 45 | logger.info(f"Current disk space usage {round(usagePercent,1)}%") 46 | if usagePercent > 90: logger.warning("If you hit 98%, wg-mesh will stop writing any files.") 47 | 48 | while True: 49 | for runs in range(6): 50 | currentLinks = os.listdir(pathToLinks) 51 | #filter out specific links 52 | currentLinks = [x for x in currentLinks if bird.filter(x)] 53 | if links != currentLinks: 54 | logger.info(f"Found difference in files, triggering reload") 55 | difference = list(set(links) - set(currentLinks)) 56 | logger.info(f"Difference {difference}") 57 | #hold until bird reports success 58 | if bird.bird(skipIperf=True): 59 | bird.mesh() 60 | latencyData,peers = bird.bird() 61 | latency.setLatencyData(latencyData,peers) 62 | links = currentLinks 63 | logger.info(f"Ready") 64 | #every 30s 65 | run = [0,3] 66 | if runs in run: 67 | if links: 68 | logger.debug("Grabbing messages") 69 | messages = [] 70 | while not messagesQueue.empty(): messages.append(messagesQueue.get()) 71 | skip = latency.run(runs,messages) 72 | if skip > 0: 73 | skipUntil = time.time() + 60 74 | logger.info(f"Skipping 10s wait for 60s") 75 | elif skip == -1 and int(time.time()) > restartCooldown: 76 | logger.info(f"Triggering bird restart") 77 | os.system("sudo systemctl restart bird") 78 | restartCooldown = int(time.time()) + 1800 79 | elif skip == -2 and int(time.time()) > regenCooldown: 80 | logger.info(f"Triggering bird config regenerate") 81 | links.append("dummy") 82 | regenCooldown = int(time.time()) + 1800 83 | else: 84 | if skipUntil < time.time(): time.sleep(10) -------------------------------------------------------------------------------- /cron/diag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import logging, random, signal, time, sys, os 3 | sys.path.append("..") # Adds higher directory to python modules path. 4 | from logging.handlers import RotatingFileHandler 5 | from Class.diag import Diag 6 | import systemd.daemon 7 | 8 | path = os.path.dirname(os.path.realpath(__file__)) 9 | path = path.replace("/cron","") 10 | 11 | #logging 12 | level = "info" 13 | levels = {'critical': logging.CRITICAL,'error': logging.ERROR,'warning': logging.WARNING,'info': logging.INFO,'debug': logging.DEBUG} 14 | stream_handler = logging.StreamHandler() 15 | stream_handler.setLevel(levels[level]) 16 | logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',datefmt='%d.%m.%Y %H:%M:%S',level=levels[level],handlers=[RotatingFileHandler(maxBytes=10000000,backupCount=5,filename=f"{path}/logs/diagnostic.log"),stream_handler]) 17 | logger = logging.getLogger() 18 | 19 | shutdown = False 20 | def gracefulExit(signal_number,stack_frame): 21 | systemd.daemon.notify('STOPPING=1') 22 | logger.info(f"Stopping") 23 | global shutdown 24 | shutdown = True 25 | 26 | signal.signal(signal.SIGINT, gracefulExit) 27 | signal.signal(signal.SIGTERM, gracefulExit) 28 | systemd.daemon.notify('READY=1') 29 | logger.info(f"Ready") 30 | 31 | diag = Diag(path,logger) 32 | waitUntil = 0 33 | while not shutdown: 34 | currentTime = int(time.time()) 35 | if currentTime > waitUntil: 36 | #check for lock file 37 | if os.path.isfile(f"{path}/cron/lock"): 38 | time.sleep(10) 39 | continue 40 | #we need a lock file, since rotate and diag could conflict with each other 41 | open(f"{path}/cron/lock",'w').close() 42 | diag.run() 43 | #clear lock file 44 | os.unlink(f"{path}/cron/lock") 45 | waitUntil = currentTime + random.randint(3600,7200) 46 | else: 47 | time.sleep(10) -------------------------------------------------------------------------------- /cron/rotate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import logging, secrets, signal, random, time, sys, os 3 | sys.path.append("..") # Adds higher directory to python modules path. 4 | from logging.handlers import RotatingFileHandler 5 | from Class.wireguard import Wireguard 6 | import systemd.daemon 7 | 8 | path = os.path.dirname(os.path.realpath(__file__)) 9 | path = path.replace("/cron","") 10 | 11 | #logging 12 | level = "info" 13 | levels = {'critical': logging.CRITICAL,'error': logging.ERROR,'warning': logging.WARNING,'info': logging.INFO,'debug': logging.DEBUG} 14 | stream_handler = logging.StreamHandler() 15 | stream_handler.setLevel(levels[level]) 16 | logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',datefmt='%d.%m.%Y %H:%M:%S',level=levels[level],handlers=[RotatingFileHandler(maxBytes=10000000,backupCount=5,filename=f"{path}/logs/rotate.log"),stream_handler]) 17 | logger = logging.getLogger() 18 | 19 | wg = Wireguard(path) 20 | config = wg.getConfig() 21 | rotate = wg.readJson(f"{path}/configs/rotate.json") 22 | notifications = config['notifications'] 23 | 24 | targetInterface = "" 25 | if len(sys.argv) == 2: targetInterface = sys.argv[1] 26 | 27 | def setRemoteCost(cost=0): 28 | return wg.call(f'http://{data["vxlan"]}:{config["listenPort"]}/update',{"cost":cost,"publicKeyServer":data['publicKey'],"interface":interfaceRemote},'PATCH') 29 | 30 | shutdown = False 31 | def gracefulExit(signal_number,stack_frame): 32 | systemd.daemon.notify('STOPPING=1') 33 | logger.info(f"Stopping") 34 | global shutdown 35 | shutdown = True 36 | 37 | signal.signal(signal.SIGINT, gracefulExit) 38 | signal.signal(signal.SIGTERM, gracefulExit) 39 | systemd.daemon.notify('READY=1') 40 | logger.info(f"Ready") 41 | 42 | waitUntil = 0 43 | while not shutdown: 44 | currentTime = int(time.time()) 45 | if currentTime > waitUntil: 46 | #we need a lock file, since rotate and diag could conflict with each other 47 | if os.path.isfile(f"{path}/cron/lock"): 48 | time.sleep(60) 49 | continue 50 | open(f"{path}/cron/lock",'w').close() 51 | links = wg.getLinks() 52 | for link, data in links.items(): 53 | link = wg.filterInterface(link) 54 | if targetInterface and link != targetInterface: continue 55 | if "XOR" in data['config'] and "endpoint" in data['config']: 56 | if not link in rotate: rotate[link] = {"cooldown":0} 57 | if rotate[link]['cooldown'] > int(time.time()): 58 | logger.info(f"Skipping {link} due to cooldown") 59 | continue 60 | #rotate every 5 to 7 hours 61 | rotate[link]['cooldown'] = int(time.time()) + random.randint(18000,25200) 62 | logger.info(f"{link} swapping xor keys") 63 | interfaceRemote = wg.getInterfaceRemote(link) 64 | logger.info(f"{link} increasing remote cost") 65 | req = setRemoteCost(5000) 66 | if not req: 67 | logger.warning(f"{link} Failed to increase remote cost") 68 | if notifications['enabled']: wg.notify(config['notifications']['gotifyError'],f"{link} xor exchange error",f"Node {config['id']} Failed to increase remote cost") 69 | continue 70 | logger.info(f"{link} increasing local cost") 71 | result = wg.setCost(link,5000) 72 | if not result: 73 | logger.warning(f"Failed to increase local cost") 74 | if notifications['enabled']: wg.notify(config['notifications']['gotifyError'],f"{link} xor exchange error",f"Node {config['id']} Failed to increase local cost") 75 | req = setRemoteCost(0) 76 | if not req: logger.warning(f"{link} Failed to remove remote cost") 77 | continue 78 | logger.info(f"{link} waiting 60s for cost to apply") 79 | time.sleep(60) 80 | logger.info(f"{link} shutting link down") 81 | wg.setInterface(link,"down") 82 | logger.info(f"{link} updating remote xor keys") 83 | xorKey = secrets.token_urlsafe(24) 84 | req = wg.call(f'http://{data["vxlan"]}:{config["listenPort"]}/update',{"xorKey":xorKey,"publicKeyServer":data['publicKey'],"interface":interfaceRemote},'PATCH') 85 | if not req: 86 | logger.warning(f"{link} Failed to update remote xor keys") 87 | if notifications['enabled']: wg.notify(config['notifications']['gotifyError'],f"{link} xor exchange error",f"Node {config['id']} Failed to update remote xor keys") 88 | logger.info(f"{link} restoring link state") 89 | wg.setCost(link,0) 90 | wg.setInterface(link,"up") 91 | setRemoteCost(0) 92 | logger.info(f"{link} restored link state") 93 | continue 94 | logger.info(f"{link} updating local xor keys") 95 | wg.updateLink(link,{'xorKey':xorKey}) 96 | logger.info(f"{link} starting link") 97 | wg.setInterface(link,"up") 98 | logger.info(f"{link} removing remote cost") 99 | req = setRemoteCost(0) 100 | if not req: 101 | logger.warning(f"{link} Failed to remove remote cost") 102 | if notifications['enabled']: wg.notify(config['notifications']['gotifyError'],f"{link} xor exchange error",f"Node {config['id']} Failed to remove remote cost") 103 | logger.info(f"{link} removing local cost") 104 | result = wg.setCost(link,0) 105 | if not result: 106 | logger.warning(f"{link} Failed to remove local cost") 107 | if notifications['enabled']: wg.notify(config['notifications']['gotifyError'],f"{link} xor exchange error",f"Node {config['id']} Failed to remove local cost") 108 | logger.info(f"{link} Testing connectivity") 109 | time.sleep(2) 110 | latency = wg.fping([data['remote']],5,True) 111 | if not latency: 112 | logger.warning(f"{link} Unable to verify connectivity") 113 | if notifications['enabled']: wg.notify(config['notifications']['gotifyError'],f"{link} xor exchange error",f"Node {config['id']} Unable to verify connectivity") 114 | logger.info(f"{link} done swapping xor keys") 115 | #run every hour 116 | waitUntil = currentTime + 3600 117 | wg.saveJson(rotate,f"{path}/configs/rotate.json") 118 | os.unlink(f"{path}/cron/lock") 119 | else: 120 | time.sleep(10) -------------------------------------------------------------------------------- /cron/smoke.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import requests, time, sys, os, re 3 | sys.path.append("..") # Adds higher directory to python modules path. 4 | from Class.base import Base 5 | from Class.wireguard import Wireguard 6 | 7 | path = os.path.dirname(os.path.realpath(__file__)) 8 | path = path.replace("/cron","") 9 | 10 | base = Base() 11 | wireguard = Wireguard(path) 12 | 13 | print("Getting Routes") 14 | routes = base.cmd("birdc show route")[0] 15 | targets = re.findall(f"(10\.0\.[0-9]+\.0\/30)",routes, re.MULTILINE) 16 | print("Getting Connection info") 17 | data = {} 18 | 19 | for index, target in enumerate(targets): 20 | target = target.replace("0/30","1") 21 | print(f"Getting {index} of {len(targets) -1}") 22 | resp = wireguard.AskProtocol(f'http://{target}:8080','') 23 | if not resp: continue 24 | if not "geo" in resp: 25 | print(f"No geo from {target}") 26 | continue 27 | if not resp: 28 | print(f"No response from {target}") 29 | continue 30 | data[target] = resp 31 | 32 | sortedData = dict(sorted(data.items(), key=lambda item: item[1]['geo']['country'])) 33 | 34 | build = {} 35 | for target,data in sortedData.items(): 36 | if not data['geo']['continent'] in build: build[data['geo']['continent']] = {} 37 | if not data['geo']['city'] in build[data['geo']['continent']]: build[data['geo']['continent']][data['geo']['city']] = [] 38 | build[data['geo']['continent']][data['geo']['city']].append([target,data['geo']['countryCode']]) 39 | 40 | smokeping = """ 41 | 42 | *** Targets *** 43 | 44 | probe = FPing 45 | 46 | menu = Top 47 | title = Network Latency Grapher 48 | remark = Welcome to the SmokePing website of xxx Company. Here you will learn all about the latency of our network. 49 | 50 | """ 51 | 52 | for continent,details in build.items(): 53 | continent = continent.replace(" ","") 54 | smokeping += f""" 55 | 56 | + {continent} 57 | menu = {continent} 58 | title = {continent} 59 | 60 | """ 61 | for city, nodes in details.items(): 62 | for node in nodes: 63 | id = str(node[0].split(".")[2:3][0]).zfill(3) 64 | smokeping += f""" 65 | ++ {node[1]}{id} 66 | 67 | menu = {node[1]}{id} | {city} 68 | title = {node[1]}{id} | {city} 69 | host = {node[0]} 70 | alerts = startloss,someloss,bigloss,rttdetect,hostdown,lossdetect 71 | """ 72 | 73 | base.saveFile(smokeping,"/etc/smokeping/config.d/Targets") -------------------------------------------------------------------------------- /deinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [[ $(id -u) -ne 0 ]] ; then echo "Please run as root" ; exit 1 ; fi 3 | systemctl disable wgmesh && systemctl stop wgmesh 4 | rm /etc/systemd/system/wgmesh.service 5 | systemctl disable wgmesh-bird && systemctl stop wgmesh-bird 6 | rm /etc/systemd/system/wgmesh-bird.service 7 | systemctl disable wgmesh-rotate && systemctl stop wgmesh-rotate 8 | rm /etc/systemd/system/wgmesh-rotate.service 9 | systemctl disable wgmesh-diag && systemctl stop wgmesh-diag 10 | rm /etc/systemd/system/wgmesh-diag.service 11 | systemctl disable wgmesh-pipe 12 | rm /etc/systemd/system/wgmesh-pipe.service 13 | wgmesh down 14 | userdel -r wg-mesh 15 | rm /etc/sysctl.d/wg-mesh.conf 16 | sysctl --system 17 | rm /etc/sudoers.d/wg-mesh 18 | rm /usr/local/bin/wgmesh -------------------------------------------------------------------------------- /docs/amnezia.md: -------------------------------------------------------------------------------- 1 | ## amneziawg 2 | 3 | This is currently untested however supported technically. 4 | 5 | Install amneziawg with 6 | ``` 7 | bash /opt/wg-mesh/tools/amnezia.sh 8 | ``` 9 | To enable amneziawg connections run.
10 | ``` 11 | #add amneziawg to linkTypes 12 | wgmesh enable amneziawg 13 | #To override the defaultLinkType, if you want to prefer amneziawg over normal wg. 14 | wgmesh set defaultLinkType amneziawg 15 | systemctl restart wgmesh 16 | ``` 17 | 18 | If the remote has amneziawg not in linkeTypes, default will be used.
-------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | Currently the webservice / API is exposed at ::8080, without TLS, use a reverse proxy for TLS
4 | You can find an example config file for nginx in configs/ 5 | 6 | Internal requests from 10.0.0.0/8 don't need a token (connectivity, connect and update).
7 | - /connectivity needs a valid token, otherwise will refuse to provide connectivity info
8 | - /connect needs a valid token, otherwise the service will refuse to setup a wg link
9 | - /update needs a valid wg public key and link name, otherwise it will not update the wg link
10 | - /disconnect needs a valid wg public key and link name, otherwise will refuse to disconnect a specific link
-------------------------------------------------------------------------------- /docs/cli.md: -------------------------------------------------------------------------------- 1 | # cli 2 | 3 | ## Init 4 | 5 | For a quick test, you can make it listen public, however all data including wg keys are transmitted unencrypted! 6 | ``` 7 | curl -so- https://raw.githubusercontent.com/Ne00n/wg-mesh/experimental/install.sh | bash -s -- init 1 public 8 | ``` 9 | 10 | Otherwise always without public 11 | ``` 12 | curl -so- https://raw.githubusercontent.com/Ne00n/wg-mesh/experimental/install.sh | bash -s -- init 1 13 | ``` 14 | ## Connect / Peer 15 | 16 | Connect externally 17 | ``` 18 | wgmesh connect https://mahdomain.net:443 mahtoken 19 | ``` 20 | 21 | Connect internally 22 | ``` 23 | wgmesh connect http://10.0.1.1:8080 24 | ``` 25 | 26 | Connect with specific preferences (linkType , port) 27 | ``` 28 | wgmesh connect http://10.0.1.1:8080 dummy wgobfs 5555 29 | ``` 30 | 31 | If the linkType is not available or the port is already used, it will be ignored. 32 | 33 | ## Disconnect 34 | 35 | To disconnect all links on a Node 36 | ``` 37 | wgmesh disconnect 38 | #disconnect all links despite untable to reach API endpoint 39 | wgmesh disconnect force 40 | #disconnect a specific link e.g pipe250, pipe250v6 41 | wgmesh disconnect pipe250 42 | #disconnect a specific link with force 43 | wgmesh disconnect pipe250 force 44 | ``` 45 | 46 | ## Clean 47 | 48 | Removes all dead links that don't ping
49 | Be careful, you could remove links to a server that just has an outage. 50 | ``` 51 | wgmesh clean 52 | ``` 53 | 54 | ## Shutdown/Startup 55 | 56 | You can shutdown all links with 57 | ``` 58 | wgmesh down 59 | ``` 60 | Or start them all up 61 | ``` 62 | wgmesh up && systemctl restart wgmesh 63 | ``` 64 | 65 | ## Enable / Disable 66 | 67 | To enable/disable settings
68 | To view all possible commands just run enable or disable without any parameters 69 | 70 | ``` 71 | wgmesh enable/disable ospfv3 72 | ``` 73 | 74 | ## Set 75 | 76 | To set specific settings such as defaultLinkType
77 | To view all possible commands just run set without any parameters 78 | 79 | ``` 80 | wgmesh set defaultLinkType wgobfs 81 | ``` 82 | 83 | **Examples** 84 | Use a random wireguard port for each link 85 | ``` 86 | wgmesh set basePort 0 && systemctl restart wgmesh-bird && systemctl restart wgmesh 87 | #or a different e.g 88 | wgmesh set basePort 5000 && systemctl restart wgmesh-bird && systemctl restart wgmesh 89 | ``` 90 | 91 | Disable automatic meshing, apply this before connecting 92 | ``` 93 | wgmesh disable mesh && systemctl restart wgmesh-bird 94 | ``` 95 | 96 | ## Used 97 | 98 | Prints out the used id's 99 | ``` 100 | wgmesh used 101 | ``` 102 | 103 | ## Bender 104 | 105 | Prints out a config for the route bender 4000 106 | ``` 107 | wgmesh bender 108 | ``` 109 | 110 | ## Proximity 111 | 112 | Prints out nodes sorted by latency 113 | ``` 114 | wgmesh proximity 115 | ``` 116 | 117 | Proxmity can also cutoff based on latency 118 | ``` 119 | wgmesh proximity 200 120 | ``` 121 | 122 | ## Migrate 123 | 124 | Will migrate any config changes 125 | ``` 126 | wgmesh migrate 127 | ``` 128 | 129 | ## Recover 130 | 131 | In case your bird config is fucked beyond repair.
132 | Don't forget to restart bird after this. 133 | ``` 134 | wgmesh recover 135 | ``` 136 | 137 | ## Token 138 | 139 | Prints out the tokens, you can also find them in logs/ or in the tokens.json file 140 | ``` 141 | wgmesh token 142 | ``` 143 | 144 | ## Cost 145 | 146 | You can increase a link cost manually by hand 147 | 148 | ``` 149 | wgmesh cost pipe5 5000 150 | ``` -------------------------------------------------------------------------------- /docs/cron.md: -------------------------------------------------------------------------------- 1 | # Cron 2 | 3 | ## bird 4 | 5 | Responsible for generating the bird config and watching for any config changes to rebuild that config.
6 | Also keeps an eye on links if they die, have packet loss 7 | 8 | Associated systemd service: wgmesh-bird 9 | 10 | ## rotate 11 | 12 | If ipt_xor is used, the cronjob is used, to swap the keys a few times per day.
13 | It increases the link cost at first, to offload any traffic before the link is shutdown and reconfigured. 14 | 15 | Includes systemd support, to avoid the process getting killed while swapping out the keys.
16 | Associated systemd service: wgmesh-rotate 17 | 18 | ## smoke 19 | 20 | Used to generate a smokeping file, so you don't have to do that by hand.
21 | Should be run as root. -------------------------------------------------------------------------------- /docs/folders.md: -------------------------------------------------------------------------------- 1 | # Folders 2 | 3 | ## Class 4 | 5 | Contains all class files for wg-mesh 6 | 7 | ## configs 8 | 9 | Contains the main config file plus some systemd template files and an nginx example file for the reverse proxy 10 | 11 | ## cron 12 | 13 | Contains the cronjobs, see cron.md 14 | 15 | ## docs 16 | 17 | Well yea 18 | 19 | ## links 20 | 21 | Oh boy, basically it has all the configuration files for the links.
22 | Including private keys, preshared keys, also the bash files for each link, which you can toggle individually. 23 | 24 | Like this, to bring the link up. 25 | ``` 26 | bash pipe5.sh up 27 | ``` 28 | 29 | Or to shut it down. 30 | ``` 31 | bash pipe5.sh 32 | ``` 33 | 34 | You can also modify these, they won't be overwritten as long you don't use /update api call, which can update the port and other stuff. 35 | 36 | ## logs 37 | 38 | Well see logs.md 39 | 40 | ## tools 41 | 42 | Contains some useful tools, such as install scripts for ipt_xor, wgobfs and amneziawg.
43 | Additionally, you find an update script, that just runs through the network and updates the wg-mesh version on all nodes. 44 | 45 | Clean basically does the same just cleans up dead links on all nodes, same for machine-id which flags up duplicate mac addresses, see throubleshooting.md.
46 | Patch does modify the bird systemd file to give it a higher priority, you can apply it, you don't have to, it does noticably at least on smokeping reduce the internal latency by a fraction of a millisecond.
47 | Status just prints out the status of the wgmesh-bird deamon. -------------------------------------------------------------------------------- /docs/ipt_xor.md: -------------------------------------------------------------------------------- 1 | ## ipt_xor 2 | 3 | Github repository: https://github.com/faicker/ipt_xor
4 | 5 | Install ipt_xor with 6 | ``` 7 | bash /opt/wg-mesh/tools/xor.sh 8 | ``` 9 | To enable ipt_xor connections run.
10 | ``` 11 | #add ipt_xor to linkTypes 12 | wgmesh ipt_xor wgobfs 13 | #To override the defaultLinkType, if you want to prefer ipt_xor over normal wg. 14 | wgmesh set defaultLinkType ipt_xor 15 | systemctl restart wgmesh 16 | ``` 17 | 18 | If the remote has ipt_xor not in linkeTypes, default will be used.
-------------------------------------------------------------------------------- /docs/logs.md: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | ## api.log 4 | 5 | Logging for the api, logs any connect, disconnect and update requests 6 | 7 | ## network.log 8 | 9 | Logging for the bird cronjob, including all network events 10 | 11 | ## rotate.log 12 | 13 | Logging for the rotate cronjob -------------------------------------------------------------------------------- /docs/peering.md: -------------------------------------------------------------------------------- 1 | ## Peering 2 | 3 | Peering works like Connect, its technically the same.
4 | For example.
5 | 6 | ``` 7 | wgmesh peer https://myendpoint.com:443 peertoken 8 | ``` 9 | 10 | A BGP session will be setup automatically on booth ends.
11 | 12 | However, since filters are used, you have to specify which prefixes should be imported.
13 | You can simply do this with.
14 | ``` 15 | wgmesh set AllowedPeers 10.1.0.0/16 16 | ``` 17 | You can add multiple subnets and you can remove them the same way you added them.
18 | Don't forget to restart the services.
-------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | ## Troubleshooting 2 | 3 | - You can check the logs/
4 | - wg-mesh is very slow
5 | sudo requires a resolvable hostname 6 | - wg-mesh is not meshing
7 | bird2 needs to be running / hidepid can block said access to check if bird is running.
8 | - sudo is asking for authentication
9 | reinstall sudo, likely old config file (debian 10)
10 | - RTNETLINK answers: Address already in use
11 | Can also mean the Port wg tries to listen, is already in use. Check your existing wg links.
12 | - packetloss and/or higher latency inside the wg-mesh network but not on the uplink/network itself 13 | wireguard needs cpu time, check the load on the machine and check if you see any CPU steal.
14 | This will likely explain what you see for example on Smokeping, you can try to reduce the links to lower the cpu usage.
15 | - wg-mesh doesn't seem to detect any wireguard links
16 | Without wg kernel module these wireguard tunnels won't work for example on OpenVZ
17 | - duplicate vxlan mac address / vxlan mac flapping (dropped connections/packet loss)
18 | If you are using a virtual machine, check your machine-id if they are the same.
19 | You can check it with or tools/machine-id.py
20 | ``` 21 | cat /etc/machine-id 22 | ``` 23 | Which can be easily fixed by running.
24 | ``` 25 | rm -f /etc/machine-id && rm -f /var/lib/dbus/machine-id 26 | dbus-uuidgen --ensure && systemd-machine-id-setup 27 | reboot 28 | ``` -------------------------------------------------------------------------------- /docs/wgobfs.md: -------------------------------------------------------------------------------- 1 | ## wgobfs 2 | 3 | Github repository: https://github.com/infinet/xt_wgobfs
4 | 5 | Install wgbofs with 6 | ``` 7 | bash /opt/wg-mesh/tools/wgobfs.sh 8 | ``` 9 | To enable wgobfs connections run.
10 | ``` 11 | #add wgobfs to linkTypes 12 | wgmesh enable wgobfs 13 | #To override the defaultLinkType, if you want to prefer wgobfs over normal wg. 14 | wgmesh set defaultLinkType wgobfs 15 | systemctl restart wgmesh 16 | ``` 17 | 18 | If the remote has wgbofs not in linkeTypes, default will be used.
-------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | if [[ $(id -u) -ne 0 ]] ; then echo "Please run as root" ; exit 1 ; fi 4 | apt-get install wireguard iptables bird2 sudo python3 python3-netaddr python3-paste python3-systemd python3-bottle python3-requests python3-pip fping mtr-tiny vnstat git -y 5 | cd /opt/ 6 | #git 7 | git clone https://github.com/Ne00n/wg-mesh.git 8 | cd wg-mesh 9 | git checkout master 10 | useradd wg-mesh -r -d /opt/wg-mesh -s /bin/bash 11 | #run init 12 | ./cli.py $@ 13 | chown -R wg-mesh:wg-mesh /opt/wg-mesh/ 14 | #add wgmesh to /usr/local/bin 15 | cat <>/usr/local/bin/wgmesh 16 | #!/bin/bash 17 | if [[ $(id -u) -ne 0 ]] ; then echo "Please run as root" ; exit 1 ; fi 18 | su wg-mesh <> /etc/sudoers.d/wg-mesh 25 | echo "wg-mesh ALL=(ALL) NOPASSWD: /usr/sbin/ip*" >> /etc/sudoers.d/wg-mesh 26 | echo "wg-mesh ALL=(ALL) NOPASSWD: /usr/sbin/iptables*" >> /etc/sudoers.d/wg-mesh 27 | echo "wg-mesh ALL=(ALL) NOPASSWD: /usr/sbin/ip6tables*" >> /etc/sudoers.d/wg-mesh 28 | echo "wg-mesh ALL=(ALL) NOPASSWD: /sbin/bridge fdb append *" >> /etc/sudoers.d/wg-mesh 29 | echo "wg-mesh ALL=(ALL) NOPASSWD: /usr/sbin/bridge fdb append *" >> /etc/sudoers.d/wg-mesh 30 | echo "wg-mesh ALL=(ALL) NOPASSWD: /usr/bin/wg set*" >> /etc/sudoers.d/wg-mesh 31 | #bird permissions 32 | echo "wg-mesh ALL=(ALL) NOPASSWD: /bin/systemctl reload bird" >> /etc/sudoers.d/wg-mesh 33 | echo "wg-mesh ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload bird" >> /etc/sudoers.d/wg-mesh 34 | echo "wg-mesh ALL=(ALL) NOPASSWD: /bin/systemctl restart bird" >> /etc/sudoers.d/wg-mesh 35 | echo "wg-mesh ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart bird" >> /etc/sudoers.d/wg-mesh 36 | usermod -a -G bird wg-mesh 37 | touch /etc/bird/static.conf 38 | chown bird:bird /etc/bird/static.conf 39 | touch /etc/bird/bgp.conf 40 | chown bird:bird /etc/bird/bgp.conf 41 | chmod -R 770 /etc/bird/ 42 | #sysctl 43 | echo "net.ipv4.ip_forward=1" >> /etc/sysctl.d/wg-mesh.conf 44 | echo "net.ipv4.conf.all.rp_filter=0" >> /etc/sysctl.d/wg-mesh.conf 45 | echo "net.ipv4.conf.default.rp_filter=0" >> /etc/sysctl.d/wg-mesh.conf 46 | echo "net.core.default_qdisc=fq " >> /etc/sysctl.d/wg-mesh.conf 47 | echo "net.ipv4.tcp_congestion_control=bbr" >> /etc/sysctl.d/wg-mesh.conf 48 | echo "net.ipv6.conf.all.forwarding=1" >> /etc/sysctl.d/wg-mesh.conf 49 | sysctl --system 50 | #systemd wg-mesh service 51 | cp /opt/wg-mesh/configs/wgmesh.service /etc/systemd/system/wgmesh.service 52 | systemctl enable wgmesh && systemctl start wgmesh 53 | #systemd bird service 54 | cp /opt/wg-mesh/configs/wgmesh-bird.service /etc/systemd/system/wgmesh-bird.service 55 | systemctl enable wgmesh-bird && systemctl start wgmesh-bird 56 | #systemd pipe service 57 | cp /opt/wg-mesh/configs/wgmesh-pipe.service /etc/systemd/system/wgmesh-pipe.service 58 | systemctl enable wgmesh-pipe 59 | #systemd roate service 60 | cp /opt/wg-mesh/configs/wgmesh-rotate.service /etc/systemd/system/wgmesh-rotate.service 61 | systemctl enable wgmesh-rotate && systemctl start wgmesh-rotate -------------------------------------------------------------------------------- /links/.gitignore: -------------------------------------------------------------------------------- 1 | *.sh 2 | *.json 3 | *.pre -------------------------------------------------------------------------------- /logs/.gitignore: -------------------------------------------------------------------------------- 1 | *.log -------------------------------------------------------------------------------- /reinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | if [[ $(id -u) -ne 0 ]] ; then echo "Please run as root" ; exit 1 ; fi 4 | apt-get install wireguard iptables bird2 sudo python3 python3-netaddr python3-systemd python3-paste python3-bottle python3-requests python3-pip fping mtr-tiny vnstat git -y -------------------------------------------------------------------------------- /tools/.gitignore: -------------------------------------------------------------------------------- 1 | targets.txt 2 | test* 3 | debug* -------------------------------------------------------------------------------- /tools/amnezia.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | apt install -y software-properties-common python3-launchpadlib gnupg2 linux-headers-$(uname -r) 4 | apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 57290828 5 | echo "deb https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" | sudo tee -a /etc/apt/sources.list 6 | echo "deb-src https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" | sudo tee -a /etc/apt/sources.list 7 | apt-get update 8 | apt-get install -y amneziawg 9 | echo "wg-mesh ALL=(ALL) NOPASSWD: /usr/bin/awg set*" >> /etc/sudoers.d/wg-mesh -------------------------------------------------------------------------------- /tools/clean.py: -------------------------------------------------------------------------------- 1 | import socket, sys 2 | sys.path.append("..") # Adds higher directory to python modules path. 3 | 4 | from Class.base import Base 5 | B = Base() 6 | 7 | for i in range(0,250): 8 | #check if SSH is reachable 9 | print("Checking,"f"10.0.{i}.1") 10 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 11 | s.settimeout(2) 12 | try: s.connect((f"10.0.{i}.1", 22)) 13 | except: continue 14 | #continue 15 | print("Cleaning",f"10.0.{i}.1") 16 | resp = B.cmd(f"""ssh root@10.0.{i}.1 <> /etc/modules 15 | -------------------------------------------------------------------------------- /tools/xor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | cd 4 | apt-get update 5 | apt-get install linux-headers-$(uname -r) git make autoconf automake libtool libxtables-dev pkg-config -y 6 | if [ ! -d "ipt_xor" ] ; then 7 | git clone https://github.com/faicker/ipt_xor 8 | #https://github.com/Ne00n/ipt_xor 9 | cd ipt_xor 10 | else 11 | cd "ipt_xor" 12 | git pull 13 | fi 14 | cd userspace 15 | make libxt_XOR.so 16 | cp libxt_XOR.so /usr/lib/x86_64-linux-gnu/xtables/ 17 | cd .. 18 | cd kernel 19 | make 20 | currentKernel=$(uname -r) 21 | cp xt_XOR.ko /lib/modules/$currentKernel/kernel/ 22 | depmod -a 23 | update-alternatives --set iptables /usr/sbin/iptables-legacy --------------------------------------------------------------------------------