diff --git a/app.py b/app.py index c7cb2ec..d1dda92 100644 --- a/app.py +++ b/app.py @@ -61,6 +61,8 @@ DEFAULT_CONFIG = { # WiFi 'wifi_ssid': '', 'wifi_password': '', 'ap_ssid': 'PiCopy', 'ap_password': 'PiCopy,', + # WireGuard + 'wireguard_auto': False, } # ── Persistenter Kopierstatus ─────────────────────────────────────────────── @@ -268,6 +270,146 @@ def wifi_monitor(): time.sleep(30) +# ── WireGuard VPN ───────────────────────────────────────────────────────────── + +WG_CONF = Path('/etc/wireguard/picopy.conf') +WG_IFACE = 'picopy' + +def wg_is_installed(): + return shutil.which('wg-quick') is not None + + +wg_state = { + 'connected': False, + 'ip': '', + 'peer': '', + 'error': None, + 'has_config': False, + 'installed': False, + 'pkg_running': False, + 'pkg_action': '', + 'pkg_error': None, +} +wg_lock = threading.Lock() + + +def wg_update_state(): + inst = wg_is_installed() + has_conf = WG_CONF.exists() + if not inst: + with wg_lock: + wg_state.update(installed=False, connected=False, ip='', peer='', + has_config=has_conf) + return + r = subprocess.run(['wg', 'show', WG_IFACE], + capture_output=True, text=True, timeout=5) + if r.returncode != 0: + with wg_lock: + wg_state.update(installed=True, connected=False, ip='', peer='', + has_config=has_conf) + return + ip_r = subprocess.run(['ip', '-4', 'addr', 'show', WG_IFACE], + capture_output=True, text=True, timeout=5) + ip = '' + for line in ip_r.stdout.splitlines(): + if line.strip().startswith('inet '): + ip = line.strip().split()[1].split('/')[0] + break + peer = '' + for line in r.stdout.splitlines(): + if line.startswith('peer:'): + peer = line.split(':', 1)[-1].strip() + break + with wg_lock: + wg_state.update(installed=True, connected=True, ip=ip, peer=peer, + error=None, has_config=has_conf) + + +def wg_connect(): + if not WG_CONF.exists(): + with wg_lock: + wg_state['error'] = 'Keine Konfiguration vorhanden' + return False + r = subprocess.run(['wg-quick', 'up', WG_IFACE], + capture_output=True, text=True, timeout=30) + if r.returncode == 0: + time.sleep(1) + wg_update_state() + log.info('WireGuard verbunden') + return True + err = (r.stderr.strip().splitlines()[-1] + if r.stderr.strip() else 'Unbekannter Fehler') + with wg_lock: + wg_state.update(connected=False, error=err) + log.error(f'WireGuard Fehler: {err}') + return False + + +def wg_disconnect(): + r = subprocess.run(['wg-quick', 'down', WG_IFACE], + capture_output=True, text=True, timeout=15) + with wg_lock: + wg_state.update(connected=False, ip='', peer='', error=None) + log.info('WireGuard getrennt') + return r.returncode == 0 + + +def _wg_apt(action: str, packages: list): + """Führt apt-get install/remove aus und aktualisiert pkg_state.""" + with wg_lock: + if wg_state['pkg_running']: + return + wg_state.update(pkg_running=True, pkg_action=action, pkg_error=None) + try: + cmd = ['apt-get', action, '-y'] + packages + r = subprocess.run(cmd, capture_output=True, text=True, timeout=300, + env={**os.environ, 'DEBIAN_FRONTEND': 'noninteractive'}) + if r.returncode != 0: + err = (r.stderr.strip().splitlines()[-1] + if r.stderr.strip() else f'apt-get {action} fehlgeschlagen') + log.error(f'WireGuard apt {action}: {err}') + with wg_lock: + wg_state['pkg_error'] = err + else: + log.info(f'WireGuard apt {action} abgeschlossen') + except Exception as e: + with wg_lock: + wg_state['pkg_error'] = str(e) + finally: + with wg_lock: + wg_state['pkg_running'] = False + wg_state['pkg_action'] = '' + wg_update_state() + + +def wg_install(): + _wg_apt('install', ['wireguard', 'wireguard-tools']) + + +def wg_uninstall(): + wg_disconnect() + _wg_apt('remove', ['wireguard', 'wireguard-tools']) + + +def wg_save_config(content: str): + try: + WG_CONF.parent.mkdir(parents=True, exist_ok=True) + WG_CONF.write_text(content, encoding='utf-8') + WG_CONF.chmod(0o600) + return True, '' + except Exception as e: + return False, str(e) + + +def wg_monitor(): + while True: + try: + wg_update_state() + except Exception: + pass + time.sleep(10) + + # ── USB Geräteerkennung ─────────────────────────────────────────────────────── def usb_port_of(dev_name): @@ -774,7 +916,9 @@ def r_status(): cs = dict(copy_state) with wifi_lock: ws = dict(wifi_state) - return jsonify(copy=cs, wifi=ws) + with wg_lock: + wgs = dict(wg_state) + return jsonify(copy=cs, wifi=ws, vpn=wgs) @app.route('/api/copy/start', methods=['POST']) def r_start(): @@ -860,6 +1004,63 @@ def r_wifi_status(): return jsonify(dict(wifi_state)) +# ── WireGuard Routes ───────────────────────────────────────────────────────── + +@app.route('/api/wireguard/config', methods=['GET', 'POST']) +def r_wg_config(): + if request.method == 'POST': + data = request.get_json(force=True) + content = data.get('content', '') + if not content.strip(): + return jsonify(error='Konfiguration ist leer'), 400 + ok, err = wg_save_config(content) + if not ok: + return jsonify(error=err), 500 + auto = data.get('auto') + if auto is not None: + c = load_cfg() + c['wireguard_auto'] = bool(auto) + save_cfg(c) + with wg_lock: + wg_state['has_config'] = True + return jsonify(ok=True) + if WG_CONF.exists(): + content = WG_CONF.read_text(encoding='utf-8') + masked = re.sub(r'(PrivateKey\s*=\s*)(.+)', r'\1****', content) + return jsonify(exists=True, config=masked) + return jsonify(exists=False, config='') + + +@app.route('/api/wireguard/connect', methods=['POST']) +def r_wg_connect(): + threading.Thread(target=wg_connect, daemon=True).start() + return jsonify(ok=True, msg='Verbindungsversuch gestartet') + + +@app.route('/api/wireguard/disconnect', methods=['POST']) +def r_wg_disconnect(): + ok = wg_disconnect() + return jsonify(ok=ok) + + +@app.route('/api/wireguard/install', methods=['POST']) +def r_wg_install(): + with wg_lock: + if wg_state['pkg_running']: + return jsonify(error='Bereits aktiv'), 400 + threading.Thread(target=wg_install, daemon=True).start() + return jsonify(ok=True) + + +@app.route('/api/wireguard/uninstall', methods=['POST']) +def r_wg_uninstall(): + with wg_lock: + if wg_state['pkg_running']: + return jsonify(error='Bereits aktiv'), 400 + threading.Thread(target=wg_uninstall, daemon=True).start() + return jsonify(ok=True) + + # ── Upload Routes ────────────────────────────────────────────────────────────── @app.route('/api/upload/targets', methods=['GET']) @@ -1332,6 +1533,9 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys .sec{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--sub);padding:.1rem 0;margin:.7rem 0 .5rem;display:flex;align-items:center;gap:.5rem} .sec::after{content:'';flex:1;height:1px;background:var(--brd)} .empty{color:var(--sub);font-size:.85rem;padding:.3rem 0} + +/* ── WireGuard VPN ── */ +.wdot.vpn{background:var(--pur);box-shadow:0 0 6px var(--pur)}
@@ -1350,6 +1554,11 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys Verbinde… +