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… +
@@ -1618,6 +1827,72 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys + +
+
+
+ WireGuard VPN + +
+
+ + + + + + + + + + +
+
+
+
@@ -1957,7 +2232,44 @@ function fmtSpd(bps){ // ── Poll ────────────────────────────────────────────────────────────────────── async function poll(){ try{ - const {copy:c,wifi:w}=await api('/status'); + const {copy:c,wifi:w,vpn:v}=await api('/status'); + // VPN Topbar + Card + if(v){ + const vp=$('vpn-pill'),vdot=$('vpn-dot'),vl=$('vpn-label'),vi=$('vpn-ip'); + const ni=$('wg-not-installed'),pp=$('wg-pkg-progress'),ui=$('wg-installed-ui'); + if(v.pkg_running){ + ni.style.display='none'; pp.style.display='block'; ui.style.display='none'; + const act=v.pkg_action==='remove'?'Deinstalliere':'Installiere'; + $('wg-pkg-title').textContent=act+' WireGuard…'; + $('wg-pkg-icon').textContent='⏳'; + $('wg-status-sub').textContent=act+'…'; + vp.style.display='none'; + } else if(!v.installed){ + ni.style.display='block'; pp.style.display='none'; ui.style.display='none'; + $('wg-status-sub').textContent='Nicht installiert'; + vp.style.display='none'; + if(v.pkg_error) flash('wg-flash','err',v.pkg_error); + } else { + ni.style.display='none'; pp.style.display='none'; ui.style.display='block'; + const wgd=$('wg-dot'),wgl=$('wg-label'),wgdet=$('wg-detail'); + const bc=$('wg-btn-connect'),bd=$('wg-btn-disconnect'); + if(v.connected){ + vp.style.display='flex'; vdot.className='wdot c'; + vl.textContent='VPN aktiv'; vi.textContent=v.ip||''; + wgd.className='wdot c'; wgl.textContent='Verbunden'; + wgdet.textContent=v.ip?(v.ip+(v.peer?' · peer …'+v.peer.slice(-8):'')):''; + bc.style.display='none'; bd.style.display=''; bd.disabled=false; + $('wg-status-sub').textContent=v.ip||''; + } else { + vp.style.display=v.has_config?'flex':'none'; + vdot.className='wdot d'; vl.textContent='VPN'; vi.textContent=''; + wgd.className='wdot d'; wgl.textContent='Getrennt'; + wgdet.textContent=v.error||''; + bc.style.display=v.has_config?'':'none'; bc.disabled=false; bd.style.display='none'; + $('wg-status-sub').textContent=v.has_config?'Konfiguriert':'Nicht konfiguriert'; + } + } + } // WiFi const wd=$('wdot'),wl=$('wifi-label'),wi=$('wifi-ip'); if(w.mode==='client'){wd.className='wdot c';wl.textContent=w.ssid||'Verbunden';wi.textContent=w.ip||'';} @@ -2120,6 +2432,49 @@ async function rebootDevice() { }, 10000); } +// ── WireGuard VPN ───────────────────────────────────────────────────────────── +async function wgInstall(){ + if(!confirm('wireguard + wireguard-tools jetzt per apt-get installieren?\n\nDauer: ca. 30–90 Sekunden.'))return; + flash('wg-flash','ok','Starte Installation…'); + const r=await api('/wireguard/install','POST'); + if(r.error) flash('wg-flash','err',r.error); +} +async function wgUninstall(){ + if(!confirm('WireGuard wirklich deinstallieren?\n\nDer aktive VPN-Tunnel wird vorher getrennt.\nDie Konfigurationsdatei bleibt erhalten.'))return; + flash('wg-flash','ok','Deinstalliere…'); + const r=await api('/wireguard/uninstall','POST'); + if(r.error) flash('wg-flash','err',r.error); +} +async function wgConnect(){ + $('wg-btn-connect').disabled=true; + flash('wg-flash','ok','Verbinde VPN…'); + await api('/wireguard/connect','POST'); +} +async function wgDisconnect(){ + $('wg-btn-disconnect').disabled=true; + flash('wg-flash','ok','Trenne VPN…'); + const r=await api('/wireguard/disconnect','POST'); + if(!r.ok) flash('wg-flash','err','Trennen fehlgeschlagen'); +} +async function wgSaveConfig(){ + const content=$('wg-config').value.trim(); + if(!content){flash('wg-flash','err','Konfiguration ist leer');return;} + if(!content.includes('[Interface]')){flash('wg-flash','err','Ungültige Konfiguration – [Interface] fehlt');return;} + const auto=$('wg-auto').checked; + flash('wg-flash','ok','Speichere…'); + const r=await api('/wireguard/config','POST',{content,auto}); + if(r.error){flash('wg-flash','err',r.error);return;} + flash('wg-flash','ok','✓ Konfiguration gespeichert'); +} +async function loadWgConfig(){ + try{ + const r=await api('/wireguard/config'); + if(r.exists && r.config) $('wg-config').value=r.config; + const c=await api('/config'); + $('wg-auto').checked=!!c.wireguard_auto; + }catch(e){} +} + function flash(id,cls,msg){ const el=$(id); el.className='flash '+cls; el.textContent=msg; el.style.display='block'; if(cls==='ok') setTimeout(()=>el.style.display='none',3500); @@ -2129,6 +2484,7 @@ function flash(id,cls,msg){ await loadCfg(); await refreshDevices(); await loadUTs(); + await loadWgConfig(); expl.load(''); setInterval(poll,1500); setInterval(refreshDevices,8000); @@ -2144,8 +2500,12 @@ function flash(id,cls,msg){ if __name__ == '__main__': cleanup_stale_mounts() load_state() + wg_update_state() threading.Thread(target=usb_monitor, daemon=True).start() threading.Thread(target=wifi_monitor, daemon=True).start() + threading.Thread(target=wg_monitor, daemon=True).start() threading.Thread(target=update_check_loop, daemon=True).start() + if load_cfg().get('wireguard_auto') and WG_CONF.exists(): + threading.Thread(target=wg_connect, daemon=True).start() log.info(f'PiCopy v{VERSION} läuft auf http://0.0.0.0:8080') app.run(host='0.0.0.0', port=8080, debug=False, use_reloader=False) diff --git a/version.txt b/version.txt index 337a6a8..66c4c22 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.8 \ No newline at end of file +1.0.9