#!/usr/bin/env pwsh # scripts/gen-bookmarks.ps1 # # Regenerate bookmarks-domains.html and bookmarks-ports.html from the live # Caddyfile and stacks/*/docker-compose.yml. Run after editing either, then # commit the regenerated HTMLs alongside the source change. # # ./scripts/gen-bookmarks.ps1 # ./scripts/gen-bookmarks.ps1 -Hostname myhost [CmdletBinding()] param( [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, [string]$Hostname = 'valhalla' ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Format-HtmlEscape([string]$s) { if ($null -eq $s) { return '' } return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"') } # Split a text blob into LF-terminated lines (handles CRLF inputs). function Get-LfLines([string]$path) { $raw = Get-Content -Raw -Path $path -Encoding UTF8 return ($raw -replace "`r", '') -split "`n" } # ------------------------------------------------------------------------- # Caddyfile -> [{Host, Section, Internal, Upstream}, ...] # ------------------------------------------------------------------------- function Get-CaddySites([string]$caddyFile) { $lines = Get-LfLines $caddyFile $sites = [System.Collections.Generic.List[object]]::new() $currentSection = 'Other' $i = 0 while ($i -lt $lines.Count) { $trim = $lines[$i].Trim() # Section banner: `# ===`, then `# NAME`, then `# ===` # NOTE: capture the name BEFORE running the second -match, since # PowerShell's -match overwrites $matches with each call. if ($trim -match '^#\s+=+\s*$' -and $i + 2 -lt $lines.Count) { $next = $lines[$i + 1].Trim() $after = $lines[$i + 2].Trim() if ($next -match '^#\s+(.+?)\s*$') { $name = $matches[1] if ($after -match '^#\s+=+\s*$') { $currentSection = ($name -replace '\s*[-—]\s*(public|internal only)\s*$','').Trim() $i += 3 continue } } } # Site header at column 0: ` {` if ($trim -match '^([a-zA-Z0-9][^{}]*)\{\s*$') { $hostList = $matches[1].Trim() $hosts = $hostList -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^[a-zA-Z0-9.-]+$' -and $_ -match '\.' } # Walk to matching brace; capture markers. $depth = 1 $internal = $false $upstream = $null $j = $i + 1 while ($j -lt $lines.Count -and $depth -gt 0) { $bl = $lines[$j].Trim() $depth += ([regex]::Matches($bl,'\{')).Count $depth -= ([regex]::Matches($bl,'\}')).Count if ($bl -match 'import\s+internal_only') { $internal = $true } if (-not $upstream -and $bl -match 'reverse_proxy\s+(\S+)') { $upstream = $matches[1] } if (-not $upstream -and $bl -match 'file_server') { $upstream = '(file_server)' } $j++ } foreach ($h in $hosts) { $sites.Add([pscustomobject]@{ Host = $h Section = $currentSection Internal = $internal Upstream = $upstream }) } $i = $j continue } $i++ } return ,$sites } # ------------------------------------------------------------------------- # stacks/*/docker-compose.yml -> [{Stack, Service, ContainerName, Ports[]}, ...] # ------------------------------------------------------------------------- function Get-StackServices([string]$stacksDir) { $result = [System.Collections.Generic.List[object]]::new() Get-ChildItem -Path $stacksDir -Directory | Sort-Object Name | ForEach-Object { $stackName = $_.Name $composeFile = Join-Path $_.FullName 'docker-compose.yml' if (-not (Test-Path $composeFile)) { return } $lines = Get-LfLines $composeFile $state = 'TOP' # TOP | SERVICES $service = $null $services = [ordered]@{} $inPorts = $false foreach ($raw in $lines) { $line = $raw -replace '\s+$','' if ($line -match '^\s*#') { continue } if ($line -eq '') { $inPorts = $false; continue } # Top-level key at column 0 if ($line -match '^([a-zA-Z_][a-zA-Z0-9_-]*):\s*$') { $state = if ($matches[1] -eq 'services') { 'SERVICES' } else { 'TOP' } $service = $null $inPorts = $false continue } if ($state -ne 'SERVICES') { continue } # Service header at indent 2 if ($line -match '^ ([a-zA-Z0-9_-]+):\s*$') { $service = $matches[1] $services[$service] = [pscustomobject]@{ Name = $service ContainerName = $service Ports = [System.Collections.Generic.List[object]]::new() } $inPorts = $false continue } if (-not $service) { continue } # Property at indent 4 if ($line -match '^ container_name:\s*(\S+)\s*$') { $services[$service].ContainerName = $matches[1] $inPorts = $false continue } if ($line -match '^ ports:\s*$') { $inPorts = $true; continue } if ($line -match '^ [a-zA-Z_][a-zA-Z0-9_-]*:') { $inPorts = $false; continue } # Port entry at indent 6 if ($inPorts -and $line -match '^ -\s*["'']?(\d+):(\d+)(/(tcp|udp))?["'']?\s*$') { $services[$service].Ports.Add([pscustomobject]@{ Host = [int]$matches[1] Container = [int]$matches[2] Proto = if ($matches[4]) { $matches[4] } else { 'tcp' } }) continue } } foreach ($svc in $services.Values) { if ($svc.Ports.Count -gt 0) { $result.Add([pscustomobject]@{ Stack = $stackName Service = $svc.Name ContainerName = $svc.ContainerName Ports = $svc.Ports }) } } } return ,$result } # ------------------------------------------------------------------------- # Netscape bookmark HTML renderers # ------------------------------------------------------------------------- function New-DomainsHtml($sites) { $out = [System.Collections.Generic.List[string]]::new() $out.Add('') $out.Add('') $out.Add('Homelab - Domains (via Caddy)') $out.Add('

Homelab - Domains (via Caddy)

') $out.Add('

') $out.Add('

Homelab - Domains

') $out.Add('

') # Group by section, preserving first-seen order. $groups = [ordered]@{} foreach ($s in $sites) { if (-not $groups.Contains($s.Section)) { $groups[$s.Section] = [System.Collections.Generic.List[object]]::new() } $groups[$s.Section].Add($s) } foreach ($section in $groups.Keys) { $items = $groups[$section] $publicCount = @($items | Where-Object { -not $_.Internal }).Count $internalCount = @($items | Where-Object { $_.Internal }).Count $suffix = if ($publicCount -eq 0) { ' (internal)' } elseif ($internalCount -eq 0) { ' (public)' } else { '' } $heading = Format-HtmlEscape "$section$suffix" $out.Add('') $out.Add("

$heading

") $out.Add('

') foreach ($s in $items) { $label = $s.Host -replace '\.ginnoir\.com$','' if ($s.Internal -and $publicCount -gt 0) { $label = "$label [internal]" } $href = Format-HtmlEscape "https://$($s.Host)" $text = Format-HtmlEscape $label $out.Add("

$text") } $out.Add('

') } $out.Add('') $out.Add('

') $out.Add('

') return ($out -join "`n") + "`n" } function New-PortsHtml($services, [string]$hostname) { $out = [System.Collections.Generic.List[string]]::new() $out.Add('') $out.Add('') $out.Add("Homelab - Backdoor Ports (direct to $hostname)") $out.Add("

Homelab - Backdoor Ports (direct to $hostname)

") $out.Add('

') $out.Add('

Homelab - Backdoor Ports

') $out.Add('

') $byStack = [ordered]@{} foreach ($svc in $services) { if (-not $byStack.Contains($svc.Stack)) { $byStack[$svc.Stack] = [System.Collections.Generic.List[object]]::new() } $byStack[$svc.Stack].Add($svc) } $ti = (Get-Culture).TextInfo foreach ($stack in $byStack.Keys) { $items = $byStack[$stack] $heading = Format-HtmlEscape ($ti.ToTitleCase($stack)) $out.Add('') $out.Add("

$heading

") $out.Add('

') foreach ($svc in $items) { # Collapse same host-port across protocols (TCP+UDP -> one entry # labelled TCP/UDP). Browsers can't open UDP anyway. $byHostPort = @($svc.Ports | Group-Object -Property Host) $multi = $byHostPort.Count -gt 1 foreach ($g in ($byHostPort | Sort-Object { [int]$_.Name })) { $group = @($g.Group) $p = $group[0] $protos = ($group | ForEach-Object { $_.Proto } | Sort-Object -Unique) -join '/' $scheme = if ($p.Container -eq 22) { 'ssh' } elseif ($p.Container -eq 443) { 'https' } else { 'http' } $userPrefix = if ($scheme -eq 'ssh') { 'git@' } else { '' } $href = Format-HtmlEscape ("{0}://{1}{2}:{3}" -f $scheme, $userPrefix, $hostname, $p.Host) $portTag = if ($multi -or $p.Container -ne $p.Host) { " :$($p.Host)" } else { '' } $protoTag = if ($protos -ne 'tcp') { " ($($protos.ToUpper()))" } else { '' } $text = Format-HtmlEscape "$($svc.ContainerName)$portTag$protoTag" $out.Add("

$text") } } $out.Add('

') } $out.Add('') $out.Add('

') $out.Add('

') return ($out -join "`n") + "`n" } # ------------------------------------------------------------------------- # Main # ------------------------------------------------------------------------- $caddyFile = Join-Path $RepoRoot 'Caddyfile' $stacksDir = Join-Path $RepoRoot 'stacks' $domainsOut = Join-Path $RepoRoot 'bookmarks-domains.html' $portsOut = Join-Path $RepoRoot 'bookmarks-ports.html' Write-Host "Parsing Caddyfile..." $sites = Get-CaddySites $caddyFile Write-Host " found $($sites.Count) site(s)" Write-Host "Parsing stacks..." $services = Get-StackServices $stacksDir $portCount = 0 foreach ($s in $services) { $portCount += $s.Ports.Count } Write-Host " found $($services.Count) service(s) with $portCount published port(s)" $utf8NoBom = [System.Text.UTF8Encoding]::new($false) Write-Host "Writing $domainsOut" [System.IO.File]::WriteAllText($domainsOut, (New-DomainsHtml $sites), $utf8NoBom) Write-Host "Writing $portsOut" [System.IO.File]::WriteAllText($portsOut, (New-PortsHtml $services $Hostname), $utf8NoBom) Write-Host "Done."