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.
306 lines
12 KiB
PowerShell
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('&','&').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: `<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."
|