commit 9c278f49b6113fd77e2e2919ce8a1fc6bc83b135 Author: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Thu Oct 22 20:59:13 2026 +0200 git init diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..353512b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto + +# Laufen auf dem Pi: CRLF wuerde Shell-Skript und systemd-Unit brechen +*.sh text eol=lf +*.service text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# Batch-Dateien unter Windows immer mit CRLF +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddd582a --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Enthalten das Tunnel-Secret +client/config.ps1 +server/restrictions.yaml + +# Installationsspezifisch +server/.env + +# Wird beim ersten Start von GitHub geladen +client/bin/ + +# Laufzeitdaten +client/logs/ +client/chrome-profile/ + +# Interne Notizen (Heim-IP, Domain, NPM-Details) +notes/ + +# Editor und Windows +.vscode/ +.idea/ +Thumbs.db +desktop.ini +*.bak diff --git a/README.md b/README.md new file mode 100644 index 0000000..16b6c9e --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# Tunnler + +[English](#english) | [Deutsch](#deutsch) + +--- + +## English + +> **Disclaimer:** This tool was vibe coded with Claude Code — architecture and structure were planned together with AI, and all implementation was done through prompts. Not a single line was written by hand. + +Routes only Chrome and Codex through a wstunnel server on a home Raspberry Pi, even behind a corporate proxy that blocks sites. + +Unlike a web proxy, the tunnel passes raw TCP connections. TLS ends at the real server, so Google and ChatGPT logins work. + +### Features + +- Local proxy on `127.0.0.1:1080`, used only by a dedicated Chrome profile and Codex +- Tunnel as WebSocket over `wss://` on port 443, optionally through a corporate proxy +- Access only via a secret URL path +- Server allows only TCP 80/443 to public destinations, the home network is blocked +- Client needs no admin rights and runs on Windows PowerShell 5.1 + +### Installation + +Prerequisites: + +- Raspberry Pi with Docker and Nginx Proxy Manager, own domain with SSL certificate +- Windows 10/11 with Google Chrome + +Step-by-step guide for server and client (German): [docs/installation.md](docs/installation.md) + +### Usage + +Double-click `client\Chrome-Tunnel.cmd`. The tunnel starts hidden in the background, then a separate Chrome window opens that runs entirely through the Pi. + +Run `client\PATH-eintragen.cmd` once. Afterwards type `codexx` instead of `codex` in any project folder, for example in the VS Code terminal. Arguments are passed through. + +`Tunnel-Start.cmd` starts only the tunnel. Then set `127.0.0.1:1080` as HTTP proxy in any program. `Tunnel-Status.cmd` shows the public IP through the tunnel. `Tunnel-Stop.cmd` stops the tunnel. + +### Configuration + +All client values live in `client\config.ps1` (copy from `config.example.ps1`). + +| Variable | Meaning | Default | +|---|---|---| +| `$TunnelSecret` | Secret path, identical to the server | empty | +| `$TunnelServer` | Server address | `wss://deine-domain.de:443` | +| `$LocalProxy` | Local proxy for Chrome and Codex | `127.0.0.1:1080` | +| `$UpstreamProxy` | Corporate proxy, empty = direct | empty | +| `$UpstreamProxyUser` / `$UpstreamProxyPassword` | Corporate proxy login | empty | +| `$SkipTlsVerify` | Disable certificate check, only if the proxy breaks TLS | `$false` | + +After a change, run `client\Tunnel-Stop.cmd` and start the tunnel again. + +### Known blockers + +- On a corporate network the tunnel times out without `$UpstreamProxy`. See [corporate proxy](docs/installation.md#teil-3--einsatz-hinter-einem-firmen-proxy). +- The first start downloads `wstunnel.exe` from GitHub. Without access to github.com, place it in `client\bin\` manually. +- Private addresses such as `192.168.x.x` are blocked through the tunnel. The client log then shows `Invalid status code: 400`. Add them to the program's proxy exceptions. +- `client\config.ps1` and `server/restrictions.yaml` contain the secret. Do not share or commit them. + +### Further documentation + +- [docs/installation.md](docs/installation.md) — installation, corporate proxy, rotating the secret, updating, troubleshooting (German) +- [wstunnel](https://github.com/erebe/wstunnel) — the tunnel software used + +--- + +## Deutsch + +> **Hinweis:** Dieses Tool wurde mit Claude Code vibe coded — Architektur und Struktur wurden gemeinsam mit KI geplant, die gesamte Implementierung erfolgte per Prompt. Keine einzige Zeile wurde von Hand geschrieben. + +Leitet nur Chrome und Codex über einen wstunnel-Server auf dem Raspberry Pi zu Hause ins Internet, auch hinter einem Firmen-Proxy, der Seiten sperrt. + +Anders als ein Web-Proxy reicht der Tunnel reine TCP-Verbindungen durch. TLS endet beim echten Server, daher funktionieren Google- und ChatGPT-Logins. + +### Funktionen + +- Lokaler Proxy auf `127.0.0.1:1080`, den nur ein eigenes Chrome-Profil und Codex nutzen +- Tunnel als WebSocket über `wss://` auf Port 443, optional durch einen Firmen-Proxy +- Zugang nur mit geheimem URL-Pfad +- Server erlaubt nur TCP 80/443 zu öffentlichen Zielen, das Heimnetz ist gesperrt +- Client ohne Admin-Rechte, läuft mit Windows PowerShell 5.1 + +### Installation + +Voraussetzungen: + +- Raspberry Pi mit Docker und Nginx Proxy Manager, eigene Domain mit SSL-Zertifikat +- Windows 10/11 mit Google Chrome + +Schritt-für-Schritt-Anleitung für Server und Client: [docs/installation.md](docs/installation.md) + +### Nutzung + +`client\Chrome-Tunnel.cmd` doppelklicken. Der Tunnel startet unsichtbar im Hintergrund, danach öffnet sich ein eigenes Chrome-Fenster, das komplett über den Pi läuft. + +Einmal `client\PATH-eintragen.cmd` ausführen. Danach in jedem Projektordner `codexx` statt `codex` eingeben, zum Beispiel im VS-Code-Terminal. Argumente werden durchgereicht. + +`Tunnel-Start.cmd` startet nur den Tunnel. Danach `127.0.0.1:1080` in beliebigen Programmen als HTTP-Proxy eintragen. `Tunnel-Status.cmd` zeigt die öffentliche IP über den Tunnel. `Tunnel-Stop.cmd` beendet den Tunnel. + +### Konfiguration + +Alle Client-Werte stehen in `client\config.ps1` (Vorlage: `config.example.ps1`). + +| Variable | Bedeutung | Standard | +|---|---|---| +| `$TunnelSecret` | Geheimer Pfad, identisch mit dem Server | leer | +| `$TunnelServer` | Adresse des Servers | `wss://deine-domain.de:443` | +| `$LocalProxy` | Lokaler Proxy für Chrome und Codex | `127.0.0.1:1080` | +| `$UpstreamProxy` | Firmen-Proxy, leer = direkt | leer | +| `$UpstreamProxyUser` / `$UpstreamProxyPassword` | Anmeldung am Firmen-Proxy | leer | +| `$SkipTlsVerify` | Zertifikatsprüfung abschalten, nur bei TLS-Aufbruch | `$false` | + +Nach einer Änderung `client\Tunnel-Stop.cmd` ausführen und den Tunnel neu starten. + +### Bekannte Stolpersteine + +- Im Firmennetz läuft der Tunnel ohne `$UpstreamProxy` in einen Timeout. Siehe [Einsatz hinter einem Firmen-Proxy](docs/installation.md#teil-3--einsatz-hinter-einem-firmen-proxy). +- Der erste Start lädt `wstunnel.exe` von GitHub. Ohne Zugriff auf github.com die Datei von Hand nach `client\bin\` legen. +- Private Adressen wie `192.168.x.x` sind über den Tunnel gesperrt. Das Client-Log zeigt dann `Invalid status code: 400`. Solche Adressen im Programm als Proxy-Ausnahme eintragen. +- `client\config.ps1` und `server/restrictions.yaml` enthalten das Secret. Nicht weitergeben und nicht committen. + +### Weitere Dokumentation + +- [docs/installation.md](docs/installation.md) — Installation, Firmen-Proxy, Secret tauschen, Aktualisieren, Fehlerbehebung +- [wstunnel](https://github.com/erebe/wstunnel) — verwendete Tunnel-Software diff --git a/client/Chrome-Tunnel.cmd b/client/Chrome-Tunnel.cmd new file mode 100644 index 0000000..0e9f5cd --- /dev/null +++ b/client/Chrome-Tunnel.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\chrome.ps1" +if errorlevel 1 pause diff --git a/client/PATH-eintragen.cmd b/client/PATH-eintragen.cmd new file mode 100644 index 0000000..6f14eec --- /dev/null +++ b/client/PATH-eintragen.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\path.ps1" +pause diff --git a/client/Tunnel-Start.cmd b/client/Tunnel-Start.cmd new file mode 100644 index 0000000..e8f631e --- /dev/null +++ b/client/Tunnel-Start.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\tunnel.ps1" start +pause diff --git a/client/Tunnel-Status.cmd b/client/Tunnel-Status.cmd new file mode 100644 index 0000000..45cea3d --- /dev/null +++ b/client/Tunnel-Status.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\tunnel.ps1" status +pause diff --git a/client/Tunnel-Stop.cmd b/client/Tunnel-Stop.cmd new file mode 100644 index 0000000..96465fd --- /dev/null +++ b/client/Tunnel-Stop.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\tunnel.ps1" stop +pause diff --git a/client/codexx.cmd b/client/codexx.cmd new file mode 100644 index 0000000..085193c --- /dev/null +++ b/client/codexx.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\codex.ps1" %* diff --git a/client/config.example.ps1 b/client/config.example.ps1 new file mode 100644 index 0000000..c66130e --- /dev/null +++ b/client/config.example.ps1 @@ -0,0 +1,19 @@ +# Als config.ps1 speichern und ausfuellen. +# config.ps1 enthaelt das Tunnel-Secret: nicht weitergeben, nicht committen. + +# Geheimer Pfad, identisch mit server/restrictions.yaml und der NPM-Location +$TunnelSecret = '' +$TunnelServer = 'wss://deine-domain.de:443' + +# Lokaler Proxy, auf den Chrome und Codex zeigen +$LocalProxy = '127.0.0.1:1080' + +# Firmen-Proxy aus der PAC-Datei, z. B. 'http://proxy.firma.de:8080'. +# Leer = direkte Verbindung (zu Hause, Hotspot). +$UpstreamProxy = '' +$UpstreamProxyUser = '' +$UpstreamProxyPassword = '' + +# Nur auf $true setzen, wenn der Firmen-Proxy TLS aufbricht +# (Zertifikat der eigenen Domain im Browser nicht von Let's Encrypt). +$SkipTlsVerify = $false diff --git a/client/scripts/chrome.ps1 b/client/scripts/chrome.ps1 new file mode 100644 index 0000000..21fdd35 --- /dev/null +++ b/client/scripts/chrome.ps1 @@ -0,0 +1,19 @@ +# Startet den Tunnel (falls noetig) und ein eigenes Chrome-Profil, das komplett +# ueber den Tunnel laeuft. Normales Chrome und alle anderen Programme bleiben direkt. +. "$PSScriptRoot\common.ps1" + +Start-Tunnel + +$chrome = @( + "$env:ProgramFiles\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe", + "$env:LOCALAPPDATA\Google\Chrome\Application\chrome.exe" +) | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $chrome) { throw "chrome.exe nicht gefunden." } + +$profileDir = Join-Path $ClientDir 'chrome-profile' +Start-Process -FilePath $chrome -ArgumentList @( + "--proxy-server=$ProxyUrl", + "--user-data-dir=`"$profileDir`"", + '--no-first-run' +) diff --git a/client/scripts/codex.ps1 b/client/scripts/codex.ps1 new file mode 100644 index 0000000..747da50 --- /dev/null +++ b/client/scripts/codex.ps1 @@ -0,0 +1,17 @@ +# Startet den Tunnel (falls noetig) und Codex mit Proxy-Variablen nur fuer diesen Aufruf. +# Alle Argumente werden an codex durchgereicht. +. "$PSScriptRoot\common.ps1" + +Start-Tunnel + +$names = 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY' +$saved = @{} +foreach ($n in $names) { $saved[$n] = [Environment]::GetEnvironmentVariable($n, 'Process') } +try { + $env:HTTPS_PROXY = $ProxyUrl + $env:HTTP_PROXY = $ProxyUrl + $env:NO_PROXY = 'localhost,127.0.0.1' + & codex @args +} finally { + foreach ($n in $names) { [Environment]::SetEnvironmentVariable($n, $saved[$n], 'Process') } +} diff --git a/client/scripts/common.ps1 b/client/scripts/common.ps1 new file mode 100644 index 0000000..0b6f10f --- /dev/null +++ b/client/scripts/common.ps1 @@ -0,0 +1,108 @@ +# Gemeinsame Funktionen fuer die Tunnel-Skripte. Wird per Dot-Sourcing geladen. +$ErrorActionPreference = 'Stop' + +$ClientDir = Split-Path $PSScriptRoot -Parent +$ConfigPath = Join-Path $ClientDir 'config.ps1' +if (-not (Test-Path $ConfigPath)) { + throw "config.ps1 fehlt. config.example.ps1 nach config.ps1 kopieren und ausfuellen." +} +. $ConfigPath + +$WstunnelVersion = '11.1.0' # passend zum Server-Image in server/docker-compose.yml +$Exe = Join-Path $ClientDir 'bin\wstunnel.exe' +$LogDir = Join-Path $ClientDir 'logs' +$ProxyUrl = "http://$LocalProxy" + +function Get-TunnelArgs([string]$LogLevel) { + $tunnelArgs = @('client', '-L', "http://$LocalProxy", '-c', '2', '--log-lvl', $LogLevel) + if (-not $SkipTlsVerify) { $tunnelArgs += '--tls-verify-certificate' } + if ($UpstreamProxy) { $tunnelArgs += @('--http-proxy', $UpstreamProxy) } + if ($UpstreamProxyUser) { $tunnelArgs += @('--http-proxy-login', $UpstreamProxyUser) } + $tunnelArgs += $TunnelServer + return $tunnelArgs +} + +# Secret und Proxy-Passwort gehen per Umgebung an wstunnel, damit sie nicht in +# der Kommandozeile stehen (Prozesslisten, EDR-Protokolle). HTTP_PROXY wird +# entfernt, weil wstunnel es sonst als Upstream-Proxy uebernimmt. NO_COLOR liest +# wstunnel ebenfalls und akzeptiert nur true/false (ein gesetztes NO_COLOR=1 bricht den Start). +function Invoke-WithTunnelEnv([scriptblock]$Action) { + $names = 'WSTUNNEL_HTTP_UPGRADE_PATH_PREFIX', 'WSTUNNEL_HTTP_PROXY_PASSWORD', 'HTTP_PROXY', 'NO_COLOR' + $saved = @{} + foreach ($n in $names) { $saved[$n] = [Environment]::GetEnvironmentVariable($n, 'Process') } + try { + $env:NO_COLOR = 'true' + $env:WSTUNNEL_HTTP_UPGRADE_PATH_PREFIX = $TunnelSecret + if ($UpstreamProxyPassword) { $env:WSTUNNEL_HTTP_PROXY_PASSWORD = $UpstreamProxyPassword } + Remove-Item Env:HTTP_PROXY -ErrorAction SilentlyContinue + & $Action + } finally { + foreach ($n in $names) { [Environment]::SetEnvironmentVariable($n, $saved[$n], 'Process') } + } +} + +function Test-TunnelListening { + $parts = $LocalProxy.Split(':') + $client = New-Object System.Net.Sockets.TcpClient + try { $client.Connect($parts[0], [int]$parts[1]); return $true } + catch { return $false } + finally { $client.Close() } +} + +function Get-TunnelProcess { + Get-Process wstunnel -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $Exe } +} + +# wstunnel.exe liegt nicht im Repo. Beim ersten Start wird das offizielle Release +# geladen und gegen die checksums.txt des Releases geprueft. +function Install-Wstunnel { + if (Test-Path $Exe) { return } + $base = "https://github.com/erebe/wstunnel/releases/download/v$WstunnelVersion" + $archiveName = "wstunnel_${WstunnelVersion}_windows_amd64.tar.gz" + $tmp = Join-Path ([IO.Path]::GetTempPath()) "tunnler-$([guid]::NewGuid())" + New-Item -ItemType Directory -Force $tmp | Out-Null + $oldProgress = $ProgressPreference + try { + Write-Host "Lade wstunnel $WstunnelVersion ..." + $ProgressPreference = 'SilentlyContinue' # sonst ist Invoke-WebRequest in PS 5.1 sehr langsam + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $archive = Join-Path $tmp $archiveName + $sums = Join-Path $tmp 'checksums.txt' + Invoke-WebRequest "$base/$archiveName" -OutFile $archive -UseBasicParsing + Invoke-WebRequest "$base/checksums.txt" -OutFile $sums -UseBasicParsing + $line = Get-Content $sums | Where-Object { $_ -match "\s$([regex]::Escape($archiveName))$" } + if (-not $line) { throw "$archiveName fehlt in checksums.txt." } + if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne ($line -split '\s+')[0]) { + throw "Pruefsumme von $archiveName stimmt nicht. Download verworfen." + } + & "$env:SystemRoot\System32\tar.exe" -xzf $archive -C $tmp wstunnel.exe + if ($LASTEXITCODE -ne 0) { throw "Entpacken von $archiveName fehlgeschlagen." } + New-Item -ItemType Directory -Force (Split-Path $Exe) | Out-Null + Move-Item (Join-Path $tmp 'wstunnel.exe') $Exe + } finally { + $ProgressPreference = $oldProgress + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } +} + +# Startet den Client unsichtbar im Hintergrund, falls er noch nicht laeuft. +function Start-Tunnel { + if (-not $TunnelSecret) { throw "TunnelSecret in config.ps1 ist leer." } + if (Test-TunnelListening) { return } + Install-Wstunnel + New-Item -ItemType Directory -Force $LogDir | Out-Null + $log = Join-Path $LogDir 'wstunnel.log' + Invoke-WithTunnelEnv { + Start-Process -FilePath $Exe -ArgumentList (Get-TunnelArgs 'WARN') -WindowStyle Hidden ` + -RedirectStandardOutput $log -RedirectStandardError (Join-Path $LogDir 'wstunnel.err.log') + } + for ($i = 0; $i -lt 20; $i++) { + if (Test-TunnelListening) { return } + Start-Sleep -Milliseconds 250 + } + throw "Tunnel-Client lauscht nicht auf $LocalProxy. Details: $log" +} + +function Stop-Tunnel { + Get-TunnelProcess | Stop-Process -Force +} diff --git a/client/scripts/path.ps1 b/client/scripts/path.ps1 new file mode 100644 index 0000000..37938c3 --- /dev/null +++ b/client/scripts/path.ps1 @@ -0,0 +1,25 @@ +# Traegt den Client-Ordner in den Benutzer-PATH ein, damit 'codexx' in jedem Terminal geht. +# Der PATH wird roh aus der Registry gelesen, damit Eintraege wie %USERPROFILE%\... nicht +# beim Zurueckschreiben fest aufgeloest werden. +$ErrorActionPreference = 'Stop' +$ClientDir = Split-Path $PSScriptRoot -Parent + +$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) +try { + $raw = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + if (($raw -split ';' | ForEach-Object { $_.TrimEnd('\') }) -contains $ClientDir.TrimEnd('\')) { + Write-Host "$ClientDir steht schon im PATH." + return + } + $key.SetValue('Path', (($raw.TrimEnd(';') + ";$ClientDir").TrimStart(';')), 'ExpandString') +} finally { + $key.Close() +} + +# Explorer ueber die Aenderung informieren, sonst sehen neu gestartete Programme sie erst nach einer Neuanmeldung +Add-Type -Namespace Tunnler -Name Native -MemberDefinition '[DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr h, uint m, UIntPtr w, string l, uint f, uint t, out UIntPtr r);' +$result = [UIntPtr]::Zero +[Tunnler.Native]::SendMessageTimeout([IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) | Out-Null + +Write-Host "$ClientDir in den PATH eingetragen." +Write-Host "VS Code und offene Terminals komplett neu starten, dann 'codexx' statt 'codex' eingeben." diff --git a/client/scripts/tunnel.ps1 b/client/scripts/tunnel.ps1 new file mode 100644 index 0000000..cdecd40 --- /dev/null +++ b/client/scripts/tunnel.ps1 @@ -0,0 +1,31 @@ +# Tunnel-Client verwalten. +# tunnel.ps1 start Client im Hintergrund starten, Proxy fuer beliebige Programme +# tunnel.ps1 status laeuft der Client, und welche IP sieht das Internet? +# tunnel.ps1 stop Client beenden +# tunnel.ps1 run Client im Vordergrund mit ausfuehrlichem Log (Fehlersuche) +param([ValidateSet('start', 'status', 'stop', 'run')][string]$Action = 'status') +. "$PSScriptRoot\common.ps1" + +switch ($Action) { + 'start' { + Start-Tunnel + Write-Host "Tunnel laeuft. In den gewuenschten Programmen als HTTP-Proxy eintragen: $LocalProxy" + Write-Host "Beenden mit Tunnel-Stop.cmd." + } + 'status' { + if (-not (Test-TunnelListening)) { Write-Host "Tunnel laeuft nicht."; break } + Write-Host "Tunnel lauscht auf $LocalProxy." + $ip = & curl.exe -s -m 15 -x $ProxyUrl https://api.ipify.org + if ($LASTEXITCODE -eq 0) { Write-Host "Oeffentliche IP ueber den Tunnel: $ip" } + else { Write-Host "Keine Verbindung durch den Tunnel. Log: $(Join-Path $LogDir 'wstunnel.log')" } + } + 'stop' { + Stop-Tunnel + Write-Host "Tunnel beendet." + } + 'run' { + Stop-Tunnel + Install-Wstunnel + Invoke-WithTunnelEnv { & $Exe @(Get-TunnelArgs 'INFO') } + } +} diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..c29ecbf --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,340 @@ +# Installation + +Schritt-für-Schritt-Einrichtung von Server (Raspberry Pi) und Client (Windows). +Alle Befehle sind zum Kopieren gedacht. Platzhalter in `GROSSBUCHSTABEN` +ersetzen. + +Dauer: etwa 20 Minuten. + +--- + +## Voraussetzungen + +**Server** + +- Raspberry Pi oder anderer Linux-Rechner mit Docker und Docker Compose + (`docker compose version` funktioniert) +- [Nginx Proxy Manager](https://nginxproxymanager.com/) läuft dort in Docker +- Eine Domain zeigt auf deinen Internetanschluss. Im Router ist Port 443 an den + Pi weitergeleitet. +- Im Nginx Proxy Manager gibt es für diese Domain einen Proxy Host mit gültigem + SSL-Zertifikat. Ein neues Zertifikat bekommst du im Proxy Host über den Tab + **SSL** → *Request a new SSL Certificate*. +- SSH-Zugang zum Pi mit einem Benutzer, der `sudo` darf + +**Client** + +- Windows 10 oder 11 mit Google Chrome +- Dieses Projekt als Ordner auf dem PC + +--- + +## Teil 1 — Server + +### 1.1 Dateien auf den Pi kopieren + +In PowerShell im Projektordner (dort, wo `README.md` liegt): + +```powershell +$Pi = 'BENUTZER@PI-ADRESSE' +ssh $Pi 'mkdir -p ~/wstunnel && chmod 700 ~/wstunnel' +scp server\docker-compose.yml server\restrictions.example.yaml server\firewall\tunnler-firewall.sh server\firewall\tunnler-firewall.service "${Pi}:wstunnel/" +``` + +Danach am Pi anmelden. Alle folgenden Befehle in Teil 1 laufen dort: + +```powershell +ssh $Pi +``` + +### 1.2 Netz des Proxy Managers ermitteln + +Der Tunnel-Container kommt in dasselbe Docker-Netz wie der Nginx Proxy Manager +und bekommt dort eine feste IP-Adresse. + +```bash +cd ~/wstunnel +NPM_CONTAINER=$(docker ps --format '{{.Names}} {{.Image}}' | awk '/nginx-proxy-manager/ {print $1; exit}') +NPM_NETWORK=$(docker inspect "$NPM_CONTAINER" --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' | head -n 1) +SUBNET=$(docker network inspect "$NPM_NETWORK" --format '{{(index .IPAM.Config 0).Subnet}}') +TUNNEL_IP=$(python3 -c 'import ipaddress, sys; print(ipaddress.ip_network(sys.argv[1])[-6])' "$SUBNET") +printf 'NPM_NETWORK=%s\nTUNNEL_IP=%s\n' "$NPM_NETWORK" "$TUNNEL_IP" > .env +echo "Proxy Manager: $NPM_CONTAINER | Netz: $NPM_NETWORK ($SUBNET) | Tunnel-IP: $TUNNEL_IP" +``` + +Die letzte Zeile muss einen Container, ein Netz und eine IP nennen, zum +Beispiel: + +``` +Proxy Manager: nginx-app-1 | Netz: nginx_default (172.20.0.0/16) | Tunnel-IP: 172.20.255.250 +``` + +Ist ein Feld leer oder heißt das Netz `bridge`, siehe +[Fehlerbehebung](#fehlerbehebung). + +### 1.3 Secret erzeugen + +Das Secret ist das Passwort des Tunnels. Es wird Teil des URL-Pfads. + +```bash +cd ~/wstunnel +SECRET=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32) +sed "s/__SECRET__/$SECRET/" restrictions.example.yaml > restrictions.yaml +chmod 644 restrictions.yaml +echo "Secret: $SECRET" +``` + +Das Secret in einem Passwort-Manager speichern. Es wird in Schritt 1.6 und 2.2 +gebraucht. + +### 1.4 Tunnel-Server starten + +```bash +cd ~/wstunnel +docker compose up -d +docker logs wstunnel --tail 5 +``` + +Docker lädt das Image `ghcr.io/erebe/wstunnel` beim ersten Start selbst +herunter. Die Version steht in `docker-compose.yml`. In der Log-Ausgabe muss +folgende Zeile stehen: + +``` +Starting wstunnel server listening on 0.0.0.0:8090 +``` + +### 1.5 Firewall einrichten + +Die Regeln sperren für den Tunnel-Container alle privaten Netze. Über den Tunnel +ist damit weder das Heimnetz noch der Pi selbst erreichbar. + +```bash +cd ~/wstunnel +. ./.env +echo "TUNNEL_IP=$TUNNEL_IP" | sudo tee /etc/default/tunnler-firewall +sudo install -m 755 tunnler-firewall.sh /usr/local/sbin/ +sudo install -m 644 tunnler-firewall.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now tunnler-firewall +sudo iptables -S TUNNLER | head -n 3 +``` + +Die Ausgabe beginnt mit `-N TUNNLER`. Die Regeln werden bei jedem Neustart +automatisch wieder gesetzt. + +### 1.6 Nginx Proxy Manager konfigurieren + +Die Werte für die Oberfläche anzeigen: + +```bash +cd ~/wstunnel +. ./.env +SECRET=$(sed -n 's/.*PathPrefix "^\([A-Za-z0-9]*\)\$".*/\1/p' restrictions.yaml) +echo "Location: /$SECRET/" +echo "Forward: http $TUNNEL_IP 8090" +``` + +Im Nginx Proxy Manager **Hosts → Proxy Hosts** öffnen und beim Proxy Host der +Domain auf **Edit** klicken: + +1. Tab **Details**: **Websockets Support** einschalten. +2. Tab **Custom Locations** → **Add location**: + + | Feld | Wert | + |---|---| + | Location | `/SECRET/` aus der Ausgabe oben, mit beiden Schrägstrichen | + | Scheme | `http` | + | Forward Hostname / IP | Tunnel-IP aus der Ausgabe oben, ohne Schrägstrich | + | Forward Port | `8090` | + +3. Beim Zahnrad der Location dieses Feld füllen: + + ```nginx + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + ``` + +4. **Save**. Der Proxy Host muss danach als *Online* angezeigt werden. + +Die bestehende Website unter `/` bleibt unverändert. + +--- + +## Teil 2 — Client + +### 2.1 Ordner ablegen + +In PowerShell im Projektordner: + +```powershell +New-Item -ItemType Directory -Force C:\Tools | Out-Null +Copy-Item -Recurse client C:\Tools\tunnler +``` + +Ein anderer Zielordner geht genauso. Admin-Rechte sind nicht nötig. +`wstunnel.exe` lädt der erste Start von GitHub nach `bin\` und prüft dabei die +SHA-256-Prüfsumme. + +### 2.2 Konfiguration anlegen + +```powershell +cd C:\Tools\tunnler +Copy-Item config.example.ps1 config.ps1 +notepad config.ps1 +``` + +In `config.ps1` zwei Werte eintragen und speichern: + +```powershell +$TunnelSecret = 'SECRET' +$TunnelServer = 'wss://DOMAIN:443' +``` + +### 2.3 Server-Weg prüfen + +```powershell +curl.exe -s -o NUL -w "%{http_code}`n" https://DOMAIN/SECRET/events +``` + +| Ergebnis | Bedeutung | +|---|---| +| `400` | Richtig: Die Anfrage erreicht den Tunnel-Server | +| `404` | Die Custom Location fehlt oder ist falsch geschrieben (1.6) | +| `502` | Der Container läuft nicht, oder die Forward-IP ist falsch (1.4, 1.6) | + +### 2.4 Tunnel testen + +`Chrome-Tunnel.cmd` doppelklicken. Es öffnet sich ein eigenes Chrome-Fenster. +Danach in PowerShell: + +```powershell +cd C:\Tools\tunnler +.\Tunnel-Status.cmd +``` + +Die angezeigte IP muss die IP des Internetanschlusses zu Hause sein. Für einen +echten Test den PC mit einem Handy-Hotspot verbinden: Die IP darf dann nicht die +des Hotspots sein. + +### 2.5 `codexx` einrichten + +`PATH-eintragen.cmd` doppelklicken. Der Client-Ordner landet im Benutzer-PATH, +Admin-Rechte sind nicht nötig. Danach VS Code und alle Terminals komplett neu +starten. In jedem Projektordner startet `codexx` statt `codex` Codex über den +Tunnel. + +Nach dem Verschieben des Client-Ordners den alten Eintrag unter +*Umgebungsvariablen für dieses Konto bearbeiten* → `Path` löschen und +`PATH-eintragen.cmd` erneut ausführen. + +--- + +## Teil 3 — Einsatz hinter einem Firmen-Proxy + +Netze mit erzwungenem Proxy lassen keine direkte Verbindung zu. Der Tunnel muss +dort durch den Proxy. + +### 3.1 Proxy-Adresse herausfinden + +```powershell +(Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings").AutoConfigURL +``` + +Die angezeigte URL im Browser öffnen. In der PAC-Datei nach einer Zeile wie +`PROXY proxy.firma.de:8080` suchen. + +### 3.2 Proxy eintragen + +In `config.ps1`: + +```powershell +$UpstreamProxy = 'http://PROXY-HOST:PROXY-PORT' +``` + +Verlangt der Proxy eine Anmeldung, zusätzlich `$UpstreamProxyUser` und +`$UpstreamProxyPassword` setzen. Danach `Tunnel-Stop.cmd` ausführen und +`Chrome-Tunnel.cmd` neu starten. + +### 3.3 Wenn der Proxy TLS aufbricht + +In Edge `https://DOMAIN` öffnen, auf das Schloss klicken und das Zertifikat +ansehen. Ist der Aussteller nicht *Let's Encrypt*, bricht der Proxy die +Verschlüsselung auf. Der Tunnel scheitert dann an der Zertifikatsprüfung. Nur +in diesem Fall in `config.ps1` setzen: + +```powershell +$SkipTlsVerify = $true +``` + +Der Proxy sieht dann das Secret. Nach dem Einsatz das Secret tauschen. + +--- + +## Secret tauschen + +Auf dem Pi: + +```bash +cd ~/wstunnel +OLD=$(sed -n 's/.*PathPrefix "^\([A-Za-z0-9]*\)\$".*/\1/p' restrictions.yaml) +NEW=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32) +sed -i "s/$OLD/$NEW/" restrictions.yaml +echo "Neues Secret: $NEW" +``` + +Der Server lädt die Datei automatisch neu. Danach: + +1. Im Nginx Proxy Manager die Custom Location auf `/NEUES-SECRET/` ändern und + speichern. +2. Auf jedem PC in `config.ps1` `$TunnelSecret` ersetzen, dann `Tunnel-Stop.cmd` + ausführen. + +--- + +## Aktualisieren + +In `~/wstunnel/docker-compose.yml` die Image-Version erhöhen +([Releases](https://github.com/erebe/wstunnel/releases)), dann: + +```bash +cd ~/wstunnel +docker compose pull +docker compose up -d +``` + +Den Client auf dieselbe Version bringen: In `client\scripts\common.ps1` +`$WstunnelVersion` anpassen, `Tunnel-Stop.cmd` ausführen und +`client\bin\wstunnel.exe` löschen. Der nächste Start lädt die neue Version. + +--- + +## Deinstallieren + +1. Im Nginx Proxy Manager die Custom Location entfernen. +2. Auf dem Pi: + + ```bash + cd ~/wstunnel && docker compose down + sudo systemctl disable --now tunnler-firewall + sudo rm /etc/systemd/system/tunnler-firewall.service /usr/local/sbin/tunnler-firewall.sh /etc/default/tunnler-firewall + sudo systemctl daemon-reload + rm -r ~/wstunnel + ``` + +3. Auf dem PC den Client-Ordner löschen. Es wurde nichts installiert. + +--- + +## Fehlerbehebung + +| Symptom | Lösung | +|---|---| +| 1.2: `Proxy Manager:` ist leer | Der Container heißt anders. `docker ps` ausführen und den Namen von Hand setzen: `NPM_CONTAINER=NAME`, dann ab der zweiten Zeile wiederholen. | +| 1.2: Netz heißt `bridge` | Im Standardnetz sind keine festen IPs möglich. Den Proxy Manager in ein eigenes Netz verschieben, zum Beispiel per Docker Compose. | +| 1.4: `Address already in use` / `invalid address` | Die Tunnel-IP ist belegt oder liegt außerhalb des Subnetzes. In `~/wstunnel/.env` eine andere freie IP aus dem Subnetz eintragen und 1.4 bis 1.6 wiederholen. | +| 1.6: Proxy Host *Offline* | Die Location in NPM löschen und Schreibweise prüfen. Im Feld *Forward Hostname* darf kein Pfad stehen. | +| Client: `Tunnel-Client lauscht nicht` | `logs\wstunnel.err.log` im Client-Ordner lesen. Ein anderes Programm nutzt eventuell Port 1080; dann `$LocalProxy` in `config.ps1` ändern. | +| Client verbindet nicht, Timeout | Im Firmennetz fehlt `$UpstreamProxy` (3.2). Sonst Portweiterleitung und DNS der Domain prüfen. | +| Seite lädt nicht, Client-Log zeigt `400` | Das Ziel ist gesperrt (nur Ports 80/443, keine privaten Adressen). Den Grund zeigt `docker logs wstunnel` auf dem Pi in der Zeile `Rejecting …`. | +| Codex nutzt den Tunnel nicht | Codex mit `codexx` starten, nicht direkt mit `codex`. Fehlt der Befehl, `PATH-eintragen.cmd` ausführen (2.5). | diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..2eaa4fa --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,33 @@ +services: + wstunnel: + image: ghcr.io/erebe/wstunnel:v11.1.0 + container_name: wstunnel + restart: unless-stopped + networks: + npm: # nur fuer den Proxy Manager erreichbar, kein ports: + ipv4_address: ${TUNNEL_IP:-172.20.250.250} # feste IP: NPM haengt nicht von Docker-DNS ab + command: + - /home/app/wstunnel # Image startet ueber dumb-init, Binaerpfad noetig + - server + - --restrict-config=/home/app/restrictions.yaml + - --dns-resolver=dns://1.1.1.1 # oeffentliches DNS: lokale Namen sind nicht aufloesbar + - --dns-resolver=dns://9.9.9.9 + - --dns-resolver-prefer-ipv4 + - ws://0.0.0.0:8090 # NPM terminiert TLS, intern reicht ws + volumes: + - ./restrictions.yaml:/home/app/restrictions.yaml:ro + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + mem_limit: 64m + logging: + driver: json-file + options: + max-size: 1m + max-file: "3" + +# Werte aus .env (siehe docs/installation.md), Standard = bestehende Installation +networks: + npm: + external: true + name: ${NPM_NETWORK:-nginx_default} diff --git a/server/firewall/tunnler-firewall.service b/server/firewall/tunnler-firewall.service new file mode 100644 index 0000000..a9dadaa --- /dev/null +++ b/server/firewall/tunnler-firewall.service @@ -0,0 +1,14 @@ +[Unit] +Description=tunnler: wstunnel-Container vom Heimnetz trennen +After=docker.service +PartOf=docker.service + +[Service] +Type=oneshot +EnvironmentFile=-/etc/default/tunnler-firewall +RemainAfterExit=yes +ExecStart=/usr/local/sbin/tunnler-firewall.sh +ExecStop=/usr/local/sbin/tunnler-firewall.sh --remove + +[Install] +WantedBy=multi-user.target docker.service diff --git a/server/firewall/tunnler-firewall.sh b/server/firewall/tunnler-firewall.sh new file mode 100644 index 0000000..b2f3136 --- /dev/null +++ b/server/firewall/tunnler-firewall.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Trennt den wstunnel-Container (feste IP, TUNNEL_IP aus /etc/default/tunnler-firewall) vom Heimnetz +# und vom Pi selbst. Hostnamen, die per oeffentlichem DNS auf private IPs zeigen +# (z. B. 192-168-0-1.nip.io), kann wstunnel selbst nicht abfangen. +# tunnler-firewall.sh Regeln setzen (idempotent) +# tunnler-firewall.sh --remove Regeln entfernen +set -e +SRC=${TUNNEL_IP:-172.20.250.250} + +for chain in DOCKER-USER INPUT; do + while iptables -D "$chain" -s "$SRC" -j TUNNLER 2>/dev/null; do :; done +done +if [ "$1" = "--remove" ]; then + iptables -F TUNNLER 2>/dev/null || true + iptables -X TUNNLER 2>/dev/null || true + exit 0 +fi + +iptables -N TUNNLER 2>/dev/null || iptables -F TUNNLER +iptables -A TUNNLER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN +for net in 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16; do + iptables -A TUNNLER -d "$net" -j DROP +done + +iptables -N DOCKER-USER 2>/dev/null || true +iptables -I DOCKER-USER -s "$SRC" -j TUNNLER +iptables -I INPUT -s "$SRC" -j TUNNLER diff --git a/server/restrictions.example.yaml b/server/restrictions.example.yaml new file mode 100644 index 0000000..79308cf --- /dev/null +++ b/server/restrictions.example.yaml @@ -0,0 +1,99 @@ +# wstunnel-Server: Zugriffsregeln (wird bei Aenderung automatisch neu geladen). +# Vorlage: Platzhalter im PathPrefix durch das Secret ersetzen, als restrictions.yaml speichern. +# +# Wirkung: +# - Nur Clients mit dem geheimen Pfad-Praefix werden akzeptiert. +# - Nur Forward-Tunnel (keine Reverse-Tunnel), nur TCP, nur Ziel-Port 80/443. +# - Hostnamen: mindestens ein Punkt, TLD aus Buchstaben, nie *.localhost. +# Lokale Namen (pi.hole, *.home, Container-Namen) loest der Server nicht auf, +# weil er per --dns-resolver nur oeffentliches DNS fragt (siehe docker-compose.yml). +# - IP-Adressen als Ziel: nur oeffentliche IPv4-Bereiche (privat, Loopback, +# Link-Local, CGNAT, Multicast ausgeschlossen) und globales IPv6. +restrictions: + - name: "tunnler" + description: "Forward-Tunnel mit Secret, TCP 80/443, keine privaten Ziele" + match: + - !PathPrefix "^__SECRET__$" + allow: + - !Tunnel + protocol: + - Tcp + port: + - 80 + - 443 + host: '(?i)^([a-z0-9_-]+\.)+([a-z]{2,8}|[a-z]{10,63}|[a-km-z][a-z]{8}|l[a-np-z][a-z]{7}|lo[a-bd-z][a-z]{6}|loc[b-z][a-z]{5}|loca[a-km-z][a-z]{4}|local[a-gi-z][a-z]{3}|localh[a-np-z][a-z]{2}|localho[a-rt-z][a-z]|localhos[a-su-z]|xn--[a-z0-9-]+)$' + cidr: + - 1.0.0.0/8 + - 2.0.0.0/7 + - 4.0.0.0/6 + - 8.0.0.0/7 + - 11.0.0.0/8 + - 12.0.0.0/6 + - 16.0.0.0/4 + - 32.0.0.0/3 + - 64.0.0.0/3 + - 96.0.0.0/6 + - 100.0.0.0/10 + - 100.128.0.0/9 + - 101.0.0.0/8 + - 102.0.0.0/7 + - 104.0.0.0/5 + - 112.0.0.0/5 + - 120.0.0.0/6 + - 124.0.0.0/7 + - 126.0.0.0/8 + - 128.0.0.0/3 + - 160.0.0.0/5 + - 168.0.0.0/8 + - 169.0.0.0/9 + - 169.128.0.0/10 + - 169.192.0.0/11 + - 169.224.0.0/12 + - 169.240.0.0/13 + - 169.248.0.0/14 + - 169.252.0.0/15 + - 169.255.0.0/16 + - 170.0.0.0/7 + - 172.0.0.0/12 + - 172.32.0.0/11 + - 172.64.0.0/10 + - 172.128.0.0/9 + - 173.0.0.0/8 + - 174.0.0.0/7 + - 176.0.0.0/4 + - 192.0.1.0/24 + - 192.0.2.0/23 + - 192.0.4.0/22 + - 192.0.8.0/21 + - 192.0.16.0/20 + - 192.0.32.0/19 + - 192.0.64.0/18 + - 192.0.128.0/17 + - 192.1.0.0/16 + - 192.2.0.0/15 + - 192.4.0.0/14 + - 192.8.0.0/13 + - 192.16.0.0/12 + - 192.32.0.0/11 + - 192.64.0.0/10 + - 192.128.0.0/11 + - 192.160.0.0/13 + - 192.169.0.0/16 + - 192.170.0.0/15 + - 192.172.0.0/14 + - 192.176.0.0/12 + - 192.192.0.0/10 + - 193.0.0.0/8 + - 194.0.0.0/7 + - 196.0.0.0/7 + - 198.0.0.0/12 + - 198.16.0.0/15 + - 198.20.0.0/14 + - 198.24.0.0/13 + - 198.32.0.0/11 + - 198.64.0.0/10 + - 198.128.0.0/9 + - 199.0.0.0/8 + - 200.0.0.0/5 + - 208.0.0.0/4 + - 2000::/3