Files
homelabstack/scripts/gen-bookmarks.ps1
T
ginnoir 1934ace8d1 feat(scripts): add gen-bookmarks.ps1 + checked-in bookmark lists
scripts/gen-bookmarks.ps1 parses Caddyfile (site blocks grouped by
banner comments) and stacks/*/docker-compose.yml (services with
published ports) into two Netscape-format HTML bookmark files importable
by any browser. Run after editing Caddyfile or stack compose; commit the
regenerated HTMLs alongside the source change.
2026-06-04 19:20:43 -05:00

306 lines
12 KiB
PowerShell

#!/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('&','&amp;').Replace('<','&lt;').Replace('>','&gt;').Replace('"','&quot;')
}
# 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: `<hosts> {`
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('<!DOCTYPE NETSCAPE-Bookmark-file-1>')
$out.Add('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">')
$out.Add('<TITLE>Homelab - Domains (via Caddy)</TITLE>')
$out.Add('<H1>Homelab - Domains (via Caddy)</H1>')
$out.Add('<DL><p>')
$out.Add(' <DT><H3>Homelab - Domains</H3>')
$out.Add(' <DL><p>')
# 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(" <DT><H3>$heading</H3>")
$out.Add(' <DL><p>')
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(" <DT><A HREF=`"$href`">$text</A>")
}
$out.Add(' </DL><p>')
}
$out.Add('')
$out.Add(' </DL><p>')
$out.Add('</DL><p>')
return ($out -join "`n") + "`n"
}
function New-PortsHtml($services, [string]$hostname) {
$out = [System.Collections.Generic.List[string]]::new()
$out.Add('<!DOCTYPE NETSCAPE-Bookmark-file-1>')
$out.Add('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">')
$out.Add("<TITLE>Homelab - Backdoor Ports (direct to $hostname)</TITLE>")
$out.Add("<H1>Homelab - Backdoor Ports (direct to $hostname)</H1>")
$out.Add('<DL><p>')
$out.Add(' <DT><H3>Homelab - Backdoor Ports</H3>')
$out.Add(' <DL><p>')
$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(" <DT><H3>$heading</H3>")
$out.Add(' <DL><p>')
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(" <DT><A HREF=`"$href`">$text</A>")
}
}
$out.Add(' </DL><p>')
}
$out.Add('')
$out.Add(' </DL><p>')
$out.Add('</DL><p>')
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."