From 1934ace8d1bba872a2871687957e52fb2b41269e Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 4 Jun 2026 19:20:43 -0500 Subject: [PATCH] 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. --- bookmarks-domains.html | 99 +++++++++++++ bookmarks-ports.html | 92 ++++++++++++ scripts/gen-bookmarks.ps1 | 305 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 bookmarks-domains.html create mode 100644 bookmarks-ports.html create mode 100644 scripts/gen-bookmarks.ps1 diff --git a/bookmarks-domains.html b/bookmarks-domains.html new file mode 100644 index 0000000..af34af7 --- /dev/null +++ b/bookmarks-domains.html @@ -0,0 +1,99 @@ + + +Homelab - Domains (via Caddy) +

Homelab - Domains (via Caddy)

+

+

Homelab - Domains

+

+ +

FOUNDRY VTT (public)

+

+

foundry +
foundry2 +

+ +

TABLETOP TOOLS (public)

+

+

5etools +
pf2e +

+ +

FILE STORAGE (public)

+

+

files +

+ +

STATIC SITES (public)

+

+

ffttsystems +

+ +

MEDIA REQUESTS (public)

+

+

requests +

+ +

MEDIA MANAGEMENT (internal)

+

+

sonarr +
radarr +
bazarr +
prowlarr +
tautulli +

+ +

DOWNLOAD CLIENTS (internal)

+

+

qbittorrent +
deluge +
nzbget +
whisparr +
stash +

+ +

NOTIFICATIONS & RSS (public)

+

+

ntfy +
freshrss +

+ +

RESUME / PORTFOLIO (public)

+

+

resume +
j-costa.com +
storage.j-costa.com +

+ +

MINIO CONSOLE (internal)

+

+

minio +

+ +

FAMAPP & AUTH (public)

+

+

fam +
auth +
dev +

+ +

MANAGEMENT (internal)

+

+

portainer +
uptime +
homarr +
router +

+ +

DEV STACK (internal)

+

+

gitea +
code +
registry +
dbx +
vault +
docs +
plane +

+ +

+

diff --git a/bookmarks-ports.html b/bookmarks-ports.html new file mode 100644 index 0000000..e7d368b --- /dev/null +++ b/bookmarks-ports.html @@ -0,0 +1,92 @@ + + +Homelab - Backdoor Ports (direct to valhalla) +

Homelab - Backdoor Ports (direct to valhalla)

+

+

Homelab - Backdoor Ports

+

+ +

Authentik

+

+

authentik_server :9200 +

+ +

Dev

+

+

gitea :2222 +
gitea :3030 +
code_server +
registry +
dbx +
bookstack :6875 +

+ +

Famapp

+

+

famapp :3010 +
minio_famapp :9002 +
minio_famapp :9003 +

+ +

Foundry

+

+

foundry +
foundry2 :30001 +
5etools :9009 +

+ +

Media

+

+

deluge +
nzbget +
sonarr +
radarr +
bazarr +
prowlarr +
overseerr +
tautulli +
qbittorrent :3232 +
qbittorrent :6881 (TCP/UDP) +
whisparr +
stash :6971 +

+ +

Monitoring

+

+

uptime_kuma +
homarr +

+ +

Notify

+

+

ntfy :8000 +
freshrss :9999 +

+ +

Owncloud

+

+

owncloud_server +

+ +

Proxy

+

+

caddy :80 +
caddy :443 +

+ +

Remote

+

+

hbbr +
hbbs :21115 +
hbbs :21116 (TCP/UDP) +

+ +

Resume

+

+

minio_resume :9000 +
minio_resume :9004 +
resume +

+ +

+

diff --git a/scripts/gen-bookmarks.ps1 b/scripts/gen-bookmarks.ps1 new file mode 100644 index 0000000..78a1db4 --- /dev/null +++ b/scripts/gen-bookmarks.ps1 @@ -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: ` {` + 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."