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