cloudFlared.psm1
# Cloudflared PowerShell Module # Module-scoped variables $script:CloudflaredPath = $null $script:TempTunnelProcs = @{} # Name -> Process $script:PersistentProcs = @{} # Name -> Process function Install-Cloudflared { [CmdletBinding()] param ( [string]$InstallPath = "$HOME\Tools\cloudflared", [switch]$Force ) # Determine architecture $arch = if ([Environment]::Is64BitProcess) { "amd64" } else { "386" } $url = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-$arch.exe" # ensure install dir exists if (-not (Test-Path $InstallPath)) { New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null } $exePath = Join-Path $InstallPath "cloudflared.exe" # only skip download if file exists and -Force was NOT used if ((Test-Path $exePath) -and (-not $Force)) { Write-Host "✅ cloudflared already installed at $exePath" } else { Write-Host "🌐 Downloading cloudflared ($arch) from GitHub..." Invoke-WebRequest -Uri $url -OutFile $exePath Write-Host "✅ Installed cloudflared to $exePath" } # expose path for other cmdlets $script:CloudflaredPath = $exePath } function Get-CloudflaredPath { if (-not $script:CloudflaredPath) { # try to find in PATH or default install $found = Get-Command cloudflared.exe -ErrorAction SilentlyContinue if ($found) { $script:CloudflaredPath = $found.Path } else { $default = "$HOME\Tools\cloudflared\cloudflared.exe" if (Test-Path $default) { $script:CloudflaredPath = $default } else { throw "cloudflared not found. Run Install-Cloudflared first." } } } return $script:CloudflaredPath } function Start-TempTunnel { [CmdletBinding()] param ( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Url, [int]$TimeoutSeconds = 15 ) # Paths $exe = Get-CloudflaredPath $tunnelDir = Join-Path $HOME ".cloudflared\tunnels" if (-not (Test-Path $tunnelDir)) { New-Item -ItemType Directory -Path $tunnelDir | Out-Null } # Generate a unique log-file name per run using ddMMMyyHmmss format (e.g. 07MAY2544330) $timestamp = (Get-Date).ToString("ddMMMyyHmmss").ToUpper() $logFile = Join-Path $tunnelDir "$Name`_$timestamp.log" # Launch cloudflared in a background job, stdout+stderr → one log $job = Start-Job -Name "TempTunnel_$Name" -ScriptBlock { param($exePath, $localUrl, $logPath) & $exePath tunnel --url $localUrl *>&1 | Tee-Object -FilePath $logPath } -ArgumentList $exe, $Url, $logFile Write-Host "✅ Started TempTunnel '$Name' (JobId $($job.Id)). Waiting up to $TimeoutSeconds s for URL…" # Poll full log for trycloudflare URL $publicUrl = $null $sw = [Diagnostics.Stopwatch]::StartNew() while ($sw.Elapsed.TotalSeconds -lt $TimeoutSeconds) { if (Test-Path $logFile) { $text = Get-Content -Path $logFile -Raw -ErrorAction SilentlyContinue if ($text -match '(https?://[^\s]+(?:trycloudflare\.com|cfargotunnel\.com))') { $publicUrl = $Matches[1] break } } Start-Sleep -Milliseconds 500 } $sw.Stop() if ($publicUrl) { Write-Host "🌐 Tunnel URL for '$Name': $publicUrl" } else { Write-Warning "⚠️ No public URL detected in $logFile within $TimeoutSeconds s." } # Save state $script:TempTunnelProcs[$Name] = @{ Job = $job Url = $publicUrl LogPath = $logFile } # Return info [PSCustomObject]@{ Name = $Name JobId = $job.Id Url = $publicUrl Log = $logFile } } function Stop-TempTunnel { [CmdletBinding(DefaultParameterSetName = 'ByName')] param ( [Parameter(Mandatory, ParameterSetName = 'ByName')] [string]$Name, [Parameter(Mandatory, ParameterSetName = 'ById')] [int]$JobId ) # Resolve name from JobId if needed if ($PSCmdlet.ParameterSetName -eq 'ById') { $Name = ($script:TempTunnelProcs.GetEnumerator() | Where-Object { $_.Value.Job.Id -eq $JobId }).Key if (-not $Name) { Write-Warning "No TempTunnel with JobId $JobId found." return } } if (-not $script:TempTunnelProcs.ContainsKey($Name)) { Write-Warning "No TempTunnel named '$Name' is running." return } $info = $script:TempTunnelProcs[$Name] $job = $info.Job if ($job.State -eq 'Running') { Stop-Job -Job $job #-Force } Remove-Job -Job $job #-Force $script:TempTunnelProcs.Remove($Name) Write-Host "🛑 Stopped TempTunnel '$Name' (JobId $($job.Id))." } function New-PersistentTunnel { [CmdletBinding()] param ( # Sub‑domain label you want (e.g. "test3") [Parameter(Mandatory)][string]$Name, # Apex domain already in your Cloudflare account (e.g. "unit259.com") [Parameter(Mandatory)][string]$Domain, # Local port to expose (default 80). Ignored if -Service is supplied. [int]$Port = 80, # Full service URL if you need something other than http://localhost:<Port> [string]$Service, # Create (or overwrite) the CNAME automatically [switch]$AutoRoute, # Where to store config and creds [string]$BasePath = "$HOME\.cloudflared" ) # ------------------------------------------------------------------ # # 1. Compose hostname and service URL # ------------------------------------------------------------------ # $Hostname = "$Name.$Domain" if (-not $Service) { $Service = "http://localhost:$Port" } # ------------------------------------------------------------------ # # 2. Create the tunnel # ------------------------------------------------------------------ # $exe = Get-CloudflaredPath $output = & $exe tunnel create $Name | Out-String if ($output -notmatch 'with id ([0-9a-f-]{36})') { throw "Couldn't parse tunnel ID. Output:`n$output" } $tunnelId = $Matches[1] # Locate the credentials JSON path in the output if ($output -match 'credentials written to\s+([^\r\n]+\.json)') { $origCred = $Matches[1] } else { throw "Couldn't find credentials file in output." } # ------------------------------------------------------------------ # # 3. Move credentials + write config.yml in per‑tunnel folder # ------------------------------------------------------------------ # $configDir = Join-Path $BasePath $Name New-Item -ItemType Directory -Path $configDir -Force | Out-Null $credFile = Join-Path $configDir "$tunnelId.json" Move-Item -Path $origCred -Destination $credFile -Force @" tunnel: $tunnelId credentials-file: $credFile ingress: - hostname: $Hostname service: $Service - service: http_status:404 "@ | Set-Content -Path (Join-Path $configDir 'config.yml') -Encoding UTF8 # ------------------------------------------------------------------ # # 4. Optionally add the CNAME via API # ------------------------------------------------------------------ # if ($AutoRoute) { & $exe tunnel route dns $Name $Hostname } # ------------------------------------------------------------------ # # 5. Summary # ------------------------------------------------------------------ # Write-Host "✅ Created PersistentTunnel '$Name'" Write-Host " • Hostname : $Hostname" Write-Host " • Service : $Service" Write-Host " • Config : $configDir\config.yml" if ($AutoRoute) { Write-Host " • CNAME added/updated in zone $Domain" } else { Write-Host " • Remember to create a CNAME pointing $Hostname → $tunnelId.cfargotunnel.com" } } function Start-PersistentTunnel { [CmdletBinding()] param ( [Parameter(Mandatory)][string]$Name, [string]$LogPath, [switch]$Foreground ) $baseDir = Join-Path $HOME ".cloudflared" $configDir = Join-Path $baseDir $Name $cfgFile = Join-Path $configDir 'config.yml' if (-not (Test-Path $cfgFile)) { throw "config.yml not found: $cfgFile" } if (-not $LogPath) { $LogPath = Join-Path $configDir "$Name.log" } $exe = Get-CloudflaredPath if ($Foreground) { # foreground: run in current console and tee to log & $exe tunnel --config $cfgFile run $Name *>&1 | Tee-Object -FilePath $LogPath } else { # background: PowerShell job executes the same line $job = Start-Job -Name "PersTunnel_$Name" -ScriptBlock { param($exePath, $cfg, $tunnelName, $log) & $exePath tunnel --config $cfg run $tunnelName *>&1 | Tee-Object -FilePath $log } -ArgumentList $exe, $cfgFile, $Name, $LogPath $script:PersistentProcs[$Name] = @{ Job = $job Url = $null LogPath = $LogPath } Write-Host "✅ Started PersistentTunnel '$Name' (JobId $($job.Id)). Log → $LogPath" } } function Stop-PersistentTunnel { [CmdletBinding(DefaultParameterSetName = 'ByName')] param ( [Parameter(Mandatory, ParameterSetName = 'ByName')] [string]$Name, [Parameter(Mandatory, ParameterSetName = 'ById')] [int]$JobId ) # ── Resolve $Name and $info ────────────────────────────────────────── if ($PSCmdlet.ParameterSetName -eq 'ById') { $pair = $script:PersistentProcs.GetEnumerator() | Where-Object { $_.Value.Job.Id -eq $JobId } if (-not $pair) { Write-Warning "No PersistentTunnel with JobId $JobId found." return } $Name = $pair.Key } if (-not $script:PersistentProcs.ContainsKey($Name)) { Write-Warning "No PersistentTunnel named '$Name' is running." return } $info = $script:PersistentProcs[$Name] $job = $info.Job # ── Stop & remove the job ─────────────────────────────────────────── if ($job.State -eq 'Running') { Stop-Job -Job $job } Remove-Job -Job $job # ── Clean up module state ─────────────────────────────────────────── $script:PersistentProcs.Remove($Name) Write-Host "🛑 Stopped PersistentTunnel '$Name' (JobId $($job.Id))." } function Get-TunnelStatus { [CmdletBinding()] param () $rows = @() # ── Temp tunnels ─────────────────────────────────────────────── foreach ($kv in $script:TempTunnelProcs.GetEnumerator()) { $name = $kv.Key $info = $kv.Value $state = $info.Job.State $rows += [PSCustomObject]@{ Name = $name Type = 'Temp' Status = if ($state -eq 'Running') { 'Up' } else { $state } Url = $info.Url Ref = $info.Job.Id Log = $info.LogPath } } # ── Persistent tunnels ───────────────────────────────────────── foreach ($kv in $script:PersistentProcs.GetEnumerator()) { $name = $kv.Key $info = $kv.Value $job = $info.Job $hostname = '' # Build ...\.cloudflared\<tunnelName>\config.yml $cfgPath = Join-Path (Join-Path $HOME '.cloudflared') $name | Join-Path -ChildPath 'config.yml' if (Test-Path $cfgPath) { $hostname = (Get-Content $cfgPath | Select-String -Pattern 'hostname:\s*(\S+)' | Select-Object -First 1).Matches[0].Groups[1].Value } $info.Url = if ($hostname) { "https://$hostname" } else { '' } $status = switch ($job.State) { 'Running' { 'Up' } 'Completed' { 'Down' } default { $job.State } } $rows += [PSCustomObject]@{ Name = $name Type = 'Persistent' Status = $status Url = $info.Url Ref = $job.Id Log = $info.LogPath } } return $rows } function Remove-Tunnel { [CmdletBinding(DefaultParameterSetName = 'ByName')] param ( # Name of the tunnel (sub‑domain label you created with `tunnel create`) [Parameter(Mandatory, ParameterSetName = 'ByName')] [string]$Name, # OR — stop / delete by JobId shown in Get-TunnelStatus / Get-Job [Parameter(Mandatory, ParameterSetName = 'ById')] [int]$JobId, # If supplied, wipe the ~/.cloudflared/<Name> folder afterwards [switch]$PurgeFiles ) # ── Resolve $Name from JobId if necessary ──────────────────────────── if ($PSCmdlet.ParameterSetName -eq 'ById') { $entry = $script:PersistentProcs.GetEnumerator() | Where-Object { $_.Value.Job.Id -eq $JobId } if (-not $entry) { Write-Warning "No persistent tunnel with JobId $JobId found." return } $Name = $entry.Key } # ── Stop any running instance ──────────────────────────────────────── if ($script:PersistentProcs.ContainsKey($Name)) { Stop-PersistentTunnel -Name $Name } elseif ($script:TempTunnelProcs.ContainsKey($Name)) { Stop-TempTunnel -Name $Name } $exe = Get-CloudflaredPath # ── Cleanup lingering connections and delete from account ──────────── & $exe tunnel cleanup $Name | Out-Null & $exe tunnel delete $Name | Out-Null # ── Purge local files if requested ─────────────────────────────────── if ($PurgeFiles) { $folder = Join-Path $HOME ".cloudflared\$Name" if (Test-Path $folder) { Remove-Item -Path $folder -Recurse -Force Write-Host "🧹 Purged local files for '$Name'" } } # ── Clean up module state ──────────────────────────────────────────── $script:PersistentProcs.Remove($Name) $script:TempTunnelProcs.Remove($Name) Write-Host "🗑️ Removed tunnel '$Name'." } function Get-TunnelList { [CmdletBinding()] param() $exe = Get-CloudflaredPath $raw = & $exe tunnel list | Out-String # skip header lines $lines = ($raw -split "`r?`n") | Where-Object { $_ -match '^[0-9a-f-]{36}\s' } foreach ($ln in $lines) { $parts = ($ln -split '\s{2,}') # columns separated by 2+ spaces [PSCustomObject]@{ Id = $parts[0] Name = $parts[1] Created = $parts[2] Connections = $parts[3] } } } function Get-TunnelInfo { [CmdletBinding()] param ([Parameter(Mandatory)][string]$NameOrId) $exe = Get-CloudflaredPath $raw = & $exe tunnel info $NameOrId | Out-String [PSCustomObject]@{ Raw = $raw } } function Open-Tunnel { [CmdletBinding()] param([Parameter(Mandatory)][string]$Name) $status = Get-TunnelStatus | Where-Object Name -eq $Name if (-not $status) { throw "Tunnel '$Name' not found." } if (-not $status.Url) { throw "Tunnel '$Name' has no URL." } Start-Process $status.Url } function Get-CloudflaredVersion { [CmdletBinding()] param() $exe = Get-CloudflaredPath # e.g. “cloudflared version 2025.4.2” $rawLocal = & $exe --version $local = ($rawLocal -replace '.* ([0-9]+\.[0-9]+\.[0-9]+).*', '$1') try { # e.g. “2025.4.2” or “v2025.4.2” $tagRaw = (Invoke-RestMethod ` -Uri 'https://api.github.com/repos/cloudflare/cloudflared/releases/latest' ` -Headers @{ 'User-Agent' = 'PS' }).tag_name $latest = $tagRaw.TrimStart('v') } catch { $latest = 'unknown' } [PSCustomObject] @{ LocalVersion = $local LatestRelease = $latest UpToDate = ($latest -ne 'unknown' -and $local -eq $latest) } } function Update-Cloudflared { [CmdletBinding()] param ( [switch]$Force ) $info = Get-CloudflaredVersion if (-not $Force -and $info.UpToDate) { Write-Host "✅ cloudflared is already up to date ($($info.LocalVersion))." return } Write-Host "⬆️ Updating cloudflared from $($info.LocalVersion) to $($info.LatestRelease)..." Install-Cloudflared -Force # re-check $new = (Get-CloudflaredVersion).LocalVersion Write-Host "✅ Now at $new." } function Test-TunnelConnectivity { [CmdletBinding()] param ( [Parameter(Mandatory)][string]$Name, [int]$TimeoutSeconds = 5 ) $status = Get-TunnelStatus | Where-Object Name -eq $Name if (-not $status -or -not $status.Url) { throw "No running tunnel named '$Name' with a URL." } try { $sw = [System.Diagnostics.Stopwatch]::StartNew() $resp = Invoke-WebRequest -Uri $status.Url -TimeoutSec $TimeoutSeconds -Method Head $sw.Stop() [PSCustomObject]@{ Url = $status.Url Status = $resp.StatusCode Ms = $sw.ElapsedMilliseconds Success = $true } } catch { [PSCustomObject]@{ Url = $status.Url Status = $_.Exception.Message Ms = $null Success = $false } } } Export-ModuleMember ` -Function Install-Cloudflared, Start-TempTunnel, Stop-TempTunnel, ` New-PersistentTunnel, Start-PersistentTunnel, Stop-PersistentTunnel, ` Get-TunnelStatus, Remove-Tunnel, Get-CloudflaredPath, Get-TunnelList, ` Get-TunnelInfo, Open-Tunnel, Get-CloudflaredVersion, Update-Cloudflared, ` Test-TunnelConnectivity |