# Richtet den SSH-Tunnel zum Server als HINTERGRUNDAUFGABE ein. # # LÄUFT AUF DEM HEIMRECHNER, nicht auf dem Server. # # pwsh -File tunnel-einrichten.ps1 # pwsh -File tunnel-einrichten.ps1 -Benutzer Florian -LokalerPort 13390 # # WOZU. Seit dem 13.08.2026 ist RDP am Server gesperrt (Hintergrund: # docs/vorfall-2026-08-12-gitea.md). Hinein geht es nur noch durch einen # SSH-Tunnel mit Schlüssel. Von Hand hieße das: vor jeder Sitzung ein # PowerShell-Fenster öffnen, einen Befehl tippen, das Fenster offen lassen. # # Das hält niemand durch, und was niemand durchhält, wird umgangen -- am Ende # steht dann wieder ein offener Port. Deshalb wird der Tunnel hier zu etwas, # das man nicht sieht: eine Aufgabe, die beim Anmelden startet, sich bei # Verbindungsabbruch selbst neu aufbaut und kein Fenster zeigt. # # Danach verbindet man sich wie immer -- nur nicht mehr auf die Server-Adresse, # sondern auf localhost mit dem hier eingestellten Port. # # WAS ES BRAUCHT: einen SSH-Schlüssel, dessen öffentlicher Teil auf dem Server # hinterlegt ist. Fehlt einer, legt dieses Skript ihn an und zeigt, was zu # schicken ist. [CmdletBinding()] param( [string]$Server = '88.218.224.10', # KEIN Standard aus $env:USERNAME. Der lokale Windows-Name und das Konto auf # dem Server sind oft verschieden -- hier hiess der eine "az319" und der # andere "Darkster". Das Skript lief dann sauber durch und der Tunnel kam # trotzdem nicht, mit einer Fehlermeldung, die auf den Schluessel zeigte # statt auf den Namen. Lieber einmal fragen als einmal in die Irre fuehren. [string]$Benutzer, # Der Port auf DIESEM Rechner. Nicht 3389 nehmen: Windows lauscht dort # selbst, und dann landet die Verbindung auf dem eigenen PC statt im Tunnel # -- mit der irreführenden Meldung "Konsolensitzung bereits aktiv". [int]$LokalerPort = 13389, [int]$ZielPort = 3389, [string]$AufgabenName = 'SSH-Tunnel zum Rootserver' ) $ErrorActionPreference = 'Stop' if (-not $Benutzer) { Write-Host "Wie heisst dein Konto AUF DEM SERVER? (nicht der Name hier am PC)" -ForegroundColor Yellow $Benutzer = (Read-Host ' Benutzername').Trim() if (-not $Benutzer) { Write-Host ' Ohne Benutzernamen geht es nicht.' -ForegroundColor Red; exit 1 } } function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan } function Gut($t) { Write-Host " $t" -ForegroundColor Green } function Warnung($t) { Write-Host " $t" -ForegroundColor Yellow } # -- 1. Ist überhaupt ein SSH-Client da? --------------------------------------- Schritt 'Voraussetzungen prüfen' $ssh = (Get-Command ssh.exe -ErrorAction SilentlyContinue).Source if (-not $ssh) { Write-Host ' Kein ssh.exe gefunden. Unter Windows 10/11 nachinstallieren mit:' -ForegroundColor Red Write-Host ' Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0' -ForegroundColor Red exit 1 } Gut "ssh.exe: $ssh" # -- 2. Schlüssel --------------------------------------------------------------- $schluessel = Join-Path $env:USERPROFILE '.ssh\id_ed25519' if (-not (Test-Path $schluessel)) { Schritt 'Es gibt noch keinen Schlüssel -- einen anlegen' New-Item -ItemType Directory -Force -Path (Split-Path $schluessel) | Out-Null # Ohne Passphrase, damit die Hintergrundaufgabe ohne Rückfrage startet. Der # Schutz liegt dann darin, dass die Datei nur diesem Windows-Konto gehört. # Wer eine Passphrase will, nimmt sie -- muss den Schlüssel dann aber in den # ssh-agent legen, sonst hängt die Aufgabe an der Abfrage. & ssh-keygen -t ed25519 -N '""' -f $schluessel -C "$Benutzer-$env:COMPUTERNAME" | Out-Null Gut 'Schlüssel angelegt.' Write-Host '' Write-Host ' ===> DIESE ZEILE muss auf dem Server hinterlegt werden:' -ForegroundColor Yellow Write-Host '' Write-Host (" " + (Get-Content "$schluessel.pub")) -ForegroundColor White Write-Host '' Write-Host ' Schick sie demjenigen, der den Server betreut. Erst danach' -ForegroundColor Yellow Write-Host ' funktioniert der Tunnel -- das Skript richtet ihn trotzdem' -ForegroundColor Yellow Write-Host ' schon ein.' -ForegroundColor Yellow Write-Host '' } else { Gut "Schlüssel vorhanden: $schluessel" } # -- 3. Ist der lokale Port frei? ---------------------------------------------- # # Vorher prüfen und nicht hinterher rätseln: ist er belegt, bindet ssh nicht, # und die RDP-Verbindung landet bei dem Programm, das dort schon lauscht. Schritt "Lokalen Port $LokalerPort prüfen" $belegt = Get-NetTCPConnection -State Listen -LocalPort $LokalerPort -ErrorAction SilentlyContinue if ($belegt) { $wer = (Get-Process -Id $belegt[0].OwningProcess -ErrorAction SilentlyContinue).ProcessName Write-Host " Port $LokalerPort ist belegt (von $wer). Nimm einen anderen:" -ForegroundColor Red Write-Host " pwsh -File tunnel-einrichten.ps1 -LokalerPort $($LokalerPort + 1)" -ForegroundColor Red exit 1 } Gut 'frei' # -- 4. Das Tunnelskript ablegen ------------------------------------------------ # # Eine Schleife und kein einzelner Aufruf: bei Zwangstrennung, Standby oder # kurzem Leitungsaussetzer endet ssh, und ohne Schleife wäre der Tunnel dann # weg -- ohne dass es jemand merkt, bis die nächste Verbindung scheitert. $ordner = Join-Path $env:LOCALAPPDATA 'SSH-Tunnel' New-Item -ItemType Directory -Force -Path $ordner | Out-Null $skript = Join-Path $ordner 'tunnel.ps1' @" # Erzeugt von tunnel-einrichten.ps1 -- von Hand ändern lohnt nicht, # lieber das Einrichtungsskript neu laufen lassen. # # PROTOKOLL, weil es kein Fenster mehr gibt: die Aufgabe laeuft ausserhalb der # Sitzung, also sieht niemand mehr, was ssh sagt. Ohne diese Datei waere ein # abgelehnter Schluessel oder ein besetzter Port unsichtbar -- der Tunnel # stuende einfach nicht, und niemand wuesste warum. `$log = Join-Path `$PSScriptRoot 'tunnel.log' if ((Test-Path `$log) -and (Get-Item `$log).Length -gt 1MB) { Remove-Item `$log -Force } while (`$true) { ("[" + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + "] verbinde ...") | Add-Content `$log # -N: nur weiterleiten, keine Shell. # ExitOnForwardFailure: lieber sofort scheitern und neu versuchen, als # scheinbar zu laufen, während die Weiterleitung gar nicht steht. # ServerAliveInterval: erkennt tote Leitungen, statt still einzufrieren. & '$ssh' -N `` -o ExitOnForwardFailure=yes `` -o ServerAliveInterval=30 -o ServerAliveCountMax=3 `` -o StrictHostKeyChecking=accept-new `` -L ${LokalerPort}:localhost:${ZielPort} `` $Benutzer@$Server 2>&1 | Add-Content `$log ("[" + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + "] beendet, neuer Versuch in 5 s") | Add-Content `$log Start-Sleep -Seconds 5 } "@ | Set-Content -Path $skript -Encoding UTF8 Gut "Tunnelskript: $skript" # -- 5. Als Aufgabe beim Anmelden ---------------------------------------------- Schritt 'Als Hintergrundaufgabe eintragen' Unregister-ScheduledTask -TaskName $AufgabenName -Confirm:$false -ErrorAction SilentlyContinue $pwsh = (Get-Command pwsh.exe -ErrorAction SilentlyContinue).Source if (-not $pwsh) { $pwsh = (Get-Command powershell.exe).Source } # EIN VBS-STARTER ALS ZWISCHENSCHICHT, und der Umweg hat zwei Gruende. # # 1. "-WindowStyle Hidden" versteckt ein Konsolenprogramm nur halb. Die Aufgabe # lief interaktiv in der Anmeldesitzung, bekam dort ein Fenster -- und wer # es zumacht, kappt den Tunnel, ohne zu wissen was er tut. # 2. Der naheliegende Ausweg, die Aufgabe per S4U ausserhalb der Sitzung laufen # zu lassen, braucht erhoehte Rechte. In einem gewoehnlichen Fenster # scheitert das Eintragen mit "Zugriff verweigert" -- und ein Werkzeug, das # Administratorrechte verlangt, benutzt am Ende niemand. # # wscript.exe startet mit Fenstermodus 0, und das heisst wirklich unsichtbar. # Es braucht keine besonderen Rechte, weil nichts Besonderes passiert. $starter = Join-Path $ordner 'tunnel.vbs' @" ' Erzeugt von tunnel-einrichten.ps1. Startet den Tunnel OHNE Fenster. ' Die 0 im Run-Aufruf ist der ganze Trick: Fenstermodus "unsichtbar". Set sh = CreateObject("WScript.Shell") sh.Run """$pwsh"" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ""$skript""", 0, False "@ | Set-Content -Path $starter -Encoding ASCII $aktion = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\wscript.exe" -Argument "`"$starter`"" $ausloeser = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME" # Interactive und NICHT S4U: S4U braucht Administratorrechte zum Eintragen. # Das Fenster verhindert der VBS-Starter, nicht der Anmeldetyp. $prinzipal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited $optionen = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -ExecutionTimeLimit ([TimeSpan]::Zero) Register-ScheduledTask -TaskName $AufgabenName -Action $aktion -Trigger $ausloeser ` -Principal $prinzipal -Settings $optionen ` -Description "Haelt den SSH-Tunnel zu $Server offen. RDP verbindet sich danach auf localhost:$LokalerPort." | Out-Null Gut 'eingetragen (startet künftig bei jeder Anmeldung)' # -- 6. Jetzt starten und nachsehen --------------------------------------------- Schritt 'Starten und nachmessen' Start-ScheduledTask -TaskName $AufgabenName $bis = (Get-Date).AddSeconds(25) do { Start-Sleep -Seconds 2 $steht = Get-NetTCPConnection -State Listen -LocalPort $LokalerPort -ErrorAction SilentlyContinue } while (-not $steht -and (Get-Date) -lt $bis) Write-Host '' if ($steht) { Gut "Der Tunnel steht: localhost:$LokalerPort -> ${Server}:$ZielPort" Write-Host '' Write-Host " In der Fernwartung ab jetzt eintragen:" -ForegroundColor White Write-Host " Hostname : localhost" -ForegroundColor White Write-Host " Port : $LokalerPort" -ForegroundColor White Write-Host '' Write-Host " Nicht mehr auf $Server verbinden -- das ist gesperrt." -ForegroundColor DarkGray Write-Host '' Write-Host ' Es gibt jetzt KEIN Fenster mehr. Nachsehen kann man so:' -ForegroundColor DarkGray Write-Host " Get-NetTCPConnection -LocalPort $LokalerPort -State Listen" -ForegroundColor DarkGray Write-Host " Get-Content '$(Join-Path $ordner 'tunnel.log')' -Tail 20" -ForegroundColor DarkGray } else { Warnung "Der Tunnel steht noch nicht auf Port $LokalerPort." Write-Host '' # NACHSEHEN STATT RATEN. Die erste Fassung behauptete hier "der Schluessel # fehlt auf dem Server" -- und lag falsch: es war der Benutzername. Eine # geratene Ursache schickt den Suchenden in die falsche Richtung, und das # kostet mehr Zeit als gar kein Hinweis. Schritt 'Nachsehen, woran es liegt' $antwort = & $ssh -o BatchMode=yes -o ConnectTimeout=10 ` -o StrictHostKeyChecking=accept-new "$Benutzer@$Server" 'exit' 2>&1 | Out-String if ($antwort -match 'Permission denied') { Warnung 'Der Server nimmt den Schluessel nicht an. Zwei Ursachen sind haeufig:' Warnung " - Der Benutzername stimmt nicht. Du hast '$Benutzer' benutzt --" Warnung ' ist das dein Konto AUF DEM SERVER, nicht das hier am PC?' Warnung ' - Der oeffentliche Schluessel ist dort noch nicht hinterlegt:' Write-Host '' Write-Host (" " + (Get-Content "$schluessel.pub")) -ForegroundColor White } elseif ($antwort -match 'Could not resolve|timed out|refused|unreachable') { Warnung "Der Server ist nicht erreichbar: $($antwort.Trim())" } elseif ($antwort.Trim()) { Warnung "ssh sagt: $($antwort.Trim())" } else { Warnung 'ssh selbst kommt durch -- dann klemmt es an der Weiterleitung.' Warnung "Steht auf dem SERVER etwas auf Port $ZielPort und laesst er" Warnung 'Weiterleitungen zu (AllowTcpForwarding)?' } }