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.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
#!/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."
|
||||
Reference in New Issue
Block a user