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:
ginnoir
2026-06-04 19:20:43 -05:00
parent 98b3fe4fd8
commit 1934ace8d1
3 changed files with 496 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Homelab - Domains (via Caddy)</TITLE>
<H1>Homelab - Domains (via Caddy)</H1>
<DL><p>
<DT><H3>Homelab - Domains</H3>
<DL><p>
<DT><H3>FOUNDRY VTT (public)</H3>
<DL><p>
<DT><A HREF="https://foundry.ginnoir.com">foundry</A>
<DT><A HREF="https://foundry2.ginnoir.com">foundry2</A>
</DL><p>
<DT><H3>TABLETOP TOOLS (public)</H3>
<DL><p>
<DT><A HREF="https://5etools.ginnoir.com">5etools</A>
<DT><A HREF="https://pf2e.ginnoir.com">pf2e</A>
</DL><p>
<DT><H3>FILE STORAGE (public)</H3>
<DL><p>
<DT><A HREF="https://files.ginnoir.com">files</A>
</DL><p>
<DT><H3>STATIC SITES (public)</H3>
<DL><p>
<DT><A HREF="https://ffttsystems.ginnoir.com">ffttsystems</A>
</DL><p>
<DT><H3>MEDIA REQUESTS (public)</H3>
<DL><p>
<DT><A HREF="https://requests.ginnoir.com">requests</A>
</DL><p>
<DT><H3>MEDIA MANAGEMENT (internal)</H3>
<DL><p>
<DT><A HREF="https://sonarr.ginnoir.com">sonarr</A>
<DT><A HREF="https://radarr.ginnoir.com">radarr</A>
<DT><A HREF="https://bazarr.ginnoir.com">bazarr</A>
<DT><A HREF="https://prowlarr.ginnoir.com">prowlarr</A>
<DT><A HREF="https://tautulli.ginnoir.com">tautulli</A>
</DL><p>
<DT><H3>DOWNLOAD CLIENTS (internal)</H3>
<DL><p>
<DT><A HREF="https://qbittorrent.ginnoir.com">qbittorrent</A>
<DT><A HREF="https://deluge.ginnoir.com">deluge</A>
<DT><A HREF="https://nzbget.ginnoir.com">nzbget</A>
<DT><A HREF="https://whisparr.ginnoir.com">whisparr</A>
<DT><A HREF="https://stash.ginnoir.com">stash</A>
</DL><p>
<DT><H3>NOTIFICATIONS &amp; RSS (public)</H3>
<DL><p>
<DT><A HREF="https://ntfy.ginnoir.com">ntfy</A>
<DT><A HREF="https://freshrss.ginnoir.com">freshrss</A>
</DL><p>
<DT><H3>RESUME / PORTFOLIO (public)</H3>
<DL><p>
<DT><A HREF="https://resume.ginnoir.com">resume</A>
<DT><A HREF="https://j-costa.com">j-costa.com</A>
<DT><A HREF="https://storage.j-costa.com">storage.j-costa.com</A>
</DL><p>
<DT><H3>MINIO CONSOLE (internal)</H3>
<DL><p>
<DT><A HREF="https://minio.ginnoir.com">minio</A>
</DL><p>
<DT><H3>FAMAPP &amp; AUTH (public)</H3>
<DL><p>
<DT><A HREF="https://fam.ginnoir.com">fam</A>
<DT><A HREF="https://auth.ginnoir.com">auth</A>
<DT><A HREF="https://dev.ginnoir.com">dev</A>
</DL><p>
<DT><H3>MANAGEMENT (internal)</H3>
<DL><p>
<DT><A HREF="https://portainer.ginnoir.com">portainer</A>
<DT><A HREF="https://uptime.ginnoir.com">uptime</A>
<DT><A HREF="https://homarr.ginnoir.com">homarr</A>
<DT><A HREF="https://router.ginnoir.com">router</A>
</DL><p>
<DT><H3>DEV STACK (internal)</H3>
<DL><p>
<DT><A HREF="https://gitea.ginnoir.com">gitea</A>
<DT><A HREF="https://code.ginnoir.com">code</A>
<DT><A HREF="https://registry.ginnoir.com">registry</A>
<DT><A HREF="https://dbx.ginnoir.com">dbx</A>
<DT><A HREF="https://vault.ginnoir.com">vault</A>
<DT><A HREF="https://docs.ginnoir.com">docs</A>
<DT><A HREF="https://plane.ginnoir.com">plane</A>
</DL><p>
</DL><p>
</DL><p>
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Homelab - Backdoor Ports (direct to valhalla)</TITLE>
<H1>Homelab - Backdoor Ports (direct to valhalla)</H1>
<DL><p>
<DT><H3>Homelab - Backdoor Ports</H3>
<DL><p>
<DT><H3>Authentik</H3>
<DL><p>
<DT><A HREF="http://valhalla:9200">authentik_server :9200</A>
</DL><p>
<DT><H3>Dev</H3>
<DL><p>
<DT><A HREF="ssh://git@valhalla:2222">gitea :2222</A>
<DT><A HREF="http://valhalla:3030">gitea :3030</A>
<DT><A HREF="http://valhalla:8443">code_server</A>
<DT><A HREF="http://valhalla:5000">registry</A>
<DT><A HREF="http://valhalla:4224">dbx</A>
<DT><A HREF="http://valhalla:6875">bookstack :6875</A>
</DL><p>
<DT><H3>Famapp</H3>
<DL><p>
<DT><A HREF="http://valhalla:3010">famapp :3010</A>
<DT><A HREF="http://valhalla:9002">minio_famapp :9002</A>
<DT><A HREF="http://valhalla:9003">minio_famapp :9003</A>
</DL><p>
<DT><H3>Foundry</H3>
<DL><p>
<DT><A HREF="http://valhalla:30000">foundry</A>
<DT><A HREF="http://valhalla:30001">foundry2 :30001</A>
<DT><A HREF="http://valhalla:9009">5etools :9009</A>
</DL><p>
<DT><H3>Media</H3>
<DL><p>
<DT><A HREF="http://valhalla:8112">deluge</A>
<DT><A HREF="http://valhalla:6789">nzbget</A>
<DT><A HREF="http://valhalla:8989">sonarr</A>
<DT><A HREF="http://valhalla:7878">radarr</A>
<DT><A HREF="http://valhalla:6767">bazarr</A>
<DT><A HREF="http://valhalla:9696">prowlarr</A>
<DT><A HREF="http://valhalla:5055">overseerr</A>
<DT><A HREF="http://valhalla:8181">tautulli</A>
<DT><A HREF="http://valhalla:3232">qbittorrent :3232</A>
<DT><A HREF="http://valhalla:6881">qbittorrent :6881 (TCP/UDP)</A>
<DT><A HREF="http://valhalla:6969">whisparr</A>
<DT><A HREF="http://valhalla:6971">stash :6971</A>
</DL><p>
<DT><H3>Monitoring</H3>
<DL><p>
<DT><A HREF="http://valhalla:3001">uptime_kuma</A>
<DT><A HREF="http://valhalla:7575">homarr</A>
</DL><p>
<DT><H3>Notify</H3>
<DL><p>
<DT><A HREF="http://valhalla:8000">ntfy :8000</A>
<DT><A HREF="http://valhalla:9999">freshrss :9999</A>
</DL><p>
<DT><H3>Owncloud</H3>
<DL><p>
<DT><A HREF="http://valhalla:8080">owncloud_server</A>
</DL><p>
<DT><H3>Proxy</H3>
<DL><p>
<DT><A HREF="http://valhalla:80">caddy :80</A>
<DT><A HREF="https://valhalla:443">caddy :443</A>
</DL><p>
<DT><H3>Remote</H3>
<DL><p>
<DT><A HREF="http://valhalla:21117">hbbr</A>
<DT><A HREF="http://valhalla:21115">hbbs :21115</A>
<DT><A HREF="http://valhalla:21116">hbbs :21116 (TCP/UDP)</A>
</DL><p>
<DT><H3>Resume</H3>
<DL><p>
<DT><A HREF="http://valhalla:9000">minio_resume :9000</A>
<DT><A HREF="http://valhalla:9004">minio_resume :9004</A>
<DT><A HREF="http://valhalla:3000">resume</A>
</DL><p>
</DL><p>
</DL><p>
+305
View File
@@ -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('&','&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."