commit eb5b09c6df3d19c99f6f6190bdec4a8f6e653d30 Author: Dominik Roth Date: Tue May 21 16:55:57 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7086ea2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +.venv +*.pyc +config.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..9be2902 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Schwarzschild + +A little tool that automatically updates the forwarding table in a fritz.box when a system goes down. diff --git a/example_config.yaml b/example_config.yaml new file mode 100644 index 0000000..5825b8e --- /dev/null +++ b/example_config.yaml @@ -0,0 +1,41 @@ +ip: 192.168.3.1 +user: schwarzschild +pass: +period: 10 +validate_paths_every: 10 +routes: + - name: main + paths: + - name: dodox-Y700-WIRED + ip: 192.168.7.150 + forwards: + - name: haProxy + protocol: TCP + internal: 809 + external: 80 + - name: haProxy_ssl + protocol: TCP + internal: 4439 + external: 443 + - name: dodox-Y700 + ip: 192.168.3.150 + forwards: + - name: haProxy + protocol: TCP + internal: 809 + external: 80 + - name: haProxy_ssl + protocol: TCP + internal: 4439 + external: 443 + - name: ProjectHeimdall + ip: 192.168.3.250 + forwards: + - name: haProxy + protocol: TCP + internal: 80 + external: 80 + - name: haProxy_ssl + protocol: TCP + internal: 443 + external: 443 diff --git a/main.py b/main.py new file mode 100644 index 0000000..87cc5fb --- /dev/null +++ b/main.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 + +import traceback +import sys +import socket +import os +import yaml +import time + +from ping3 import ping + +import fritzconnection.core.exceptions as exp +from fritzconnection.cli import utils +from fritzconnection import FritzConnection +from fritzconnection.core.fritzconnection import ( + FRITZ_IP_ADDRESS, + FRITZ_TCP_PORT, + FRITZ_ENV_USERNAME, + FRITZ_ENV_PASSWORD, +) + +DRY = True + +def readPortmappings(fc): + num = fc.call_action("WANPPPConnection1", "GetPortMappingNumberOfEntries", ) + portMappings = [] + numi = num["NewPortMappingNumberOfEntries"] + for i in range(numi): + portMappings.append(fc.call_action("WANPPPConnection1", "GetGenericPortMappingEntry", NewPortMappingIndex=i)) + return portMappings + +def findPortmapping(fc, extPort, intPort): + portmappings = readPortmappings(fc) + ip = readMyIP() + for m in portmappings: + if m["NewInternalClient"] == ip and m["NewInternalPort"] == int(intPort) and m["NewExternalPort"] == int(extPort): + return m + return None + +def printMappingHeader(): + print('{:<20} {:<8} {:<20} {:<20} {:<15} {:<15} {}\n'.format( + 'Description', 'Protocol', 'Dest. Host', 'Dest. IP', 'Mapping', 'Lease Duration', 'Status')) + +def printMapping(m): + desc = str(m['NewPortMappingDescription']) if m['NewPortMappingDescription'] else '-' + proto = str(m['NewProtocol']) if m['NewProtocol'] else '-' + host = str(m['NewInternalClient']) if m['NewInternalClient'] else '-' + extport = str(m['NewExternalPort']) if m['NewExternalPort'] else "?" + intport = str(m['NewInternalPort']) if m['NewInternalPort'] else "?" + lease = str(m['NewLeaseDuration']) if m['NewLeaseDuration'] else "infinite" + status = 'active' if m['NewEnabled'] else '' + ip = host + mapping = extport + "->" + intport + try: + host = socket.gethostbyaddr(host)[0] + except: + pass + + print(f'{desc:<20} {proto:<8} {host:<20} {ip:<20} {mapping:<15} {lease:<15} {status}') + +def listPortmappings(fc): + print('Fritz Port Forwardings: \n') + printMappingHeader() + + portmappings = readPortmappings(fc) + for m in portmappings: printMapping(m) + print() + +def readMyIP(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + +def addPortMapping(fc, extPort, intPort, name=None, protocol='TCP', active=True): + mapping = { + 'NewRemoteHost': '0.0.0.0', + 'NewExternalPort': extPort, + 'NewProtocol': protocol, + 'NewInternalPort': intPort, + 'NewInternalClient': readMyIP(), + 'NewEnabled': active, + 'NewPortMappingDescription': name, + 'NewLeaseDuration': 0 + } + if DRY: + print('DRY', mapping) + return + fc.call_action("WANPPPConnection1", "AddPortMapping", **mapping ) + +def deletePortMapping(fc, extPort, intPort, protocol='TCP', name=None): + addPortMapping(fc, extPort, intPort, name=name, protocol=protocol, active=False) + +def load_config(): + with open('config.yaml', 'r') as f: + conf = yaml.safe_load(f) + return conf + +def make_con(ip, user, pw): + return FritzConnection(address=ip, user=user, password=pw, use_cache=True) + +def main(): + conf = load_config() + fc = make_con(conf['ip'], conf['user'], conf['pass']) + loop(conf, fc) + +def loop(conf, fc): + period = conf['period'] + active_paths = read_paths(conf, fc) + clock = 0 + info('starting...') + while True: + begin = time.time() + active_paths, clock = cycle(active_paths, clock, conf, fc) + sleep = time.time() - begin + period + if sleep > 0: + time.sleep(sleep) + +def cycle(active_paths, clock, conf, fc): + if clock == -1 or (conf['validate_paths_every'] != 0 and clock >= conf['validate_paths_every']-1): + clock = 0 + real_active_paths = read_paths(conf, fc) + diff = paths_diffs(active_paths, real_active_paths) + if diff: + active_paths = real_active_paths + alert(f'The paths configured in the router are different from what we expect based on our state. Were they changed manually? We will fix this.') + else: + info(f'Router paths validated.') + else: + clock += 1 + new_paths = select_paths(conf, fc) + diff = paths_diffs(active_paths, new_paths) + if diff: + flush_paths(conf, fc, diff) + for route_name in diff: + old_path = active_paths[route_name] + new_path = new_paths[route_name] + alert(f'Route change: Switched from path {old_path} to path {new_path} for route {route_name}') + clock = -1 # force check next round + info(f'Routes ok:') + for route in conf['routes']: + print(f' - {route["name"]} -> {new_paths[route["name"]]}') + return new_paths, clock + +def paths_diffs(old_paths, new_paths, verbose=False): + diffs = {} + for route_name in new_paths: + if route_name not in old_paths or old_paths[route_name] != new_paths[route_name]: + diffs[route_name] = new_paths[route_name] + return diffs + +def select_paths(conf, fc): + path_indices = {} + for route in conf['routes']: + for path in route['paths']: + if check_avail(path): + path_indices[route['name']] = path['name'] + break + else: + # All paths are down + path_indices[route['name']] = None + return path_indices + +def read_paths(conf, fc): + ms = readPortmappings(fc) + path_indices = {} + for route in conf['routes']: + for path in route['paths']: + # Lets see if we have such a path active + fstFwd = path['forwards'][0] + found_map = False + for m in ms: + if m['NewEnabled'] and m['NewInternalClient'] == path['ip'] and m['NewPortMappingDescription'] == fstFwd['name'] and m['NewExternalPort'] == fstFwd['external'] and m['NewInternalPort'] == fstFwd['internal'] and m['NewProtocol'] == fstFwd['protocol'].upper(): + # This path is (at least partially) active + path_indices[route['name']] = path['name'] + found_map = True + break + if found_map: + break + else: + # All paths are down + path_indices[route['name']] = None + alert('We found no active path for route {route["name"]}.') + return path_indices + +def flush_paths(conf, fc, active_paths): + for route in conf['routes']: + if route['name'] not in active_paths: + continue + active_path = active_paths[route['name']] + make_active_path = None + for path in route['paths']: + if path['name'] == active_path: + if not make_active_path == None: + alert(f'Illegal config for {route["name"]}: Has redundant path names.') + make_active_path = path + else: + flush_forwards(path, False, fc) + flush_forwards(make_active_path, True, fc) + if make_active_path==None: + alert(f'No active path for route {route["name"]}') + +def flush_forwards(path, make_active, fc): + for forward in path['forwards']: + addPortMapping(fc, forward['external'], forward['internal'], name=forward['name'], protocol=forward['protocol'], active=make_active) + +def check_avail(path): + ip = path['ip'] + resp_time = ping(ip, timeout=3) + return resp_time!=None + +def alert(txt): + print('[!] '+txt) + +def info(txt): + print('[i] '+txt) + +if __name__=='__main__': + main()