Files
hobbs-iac/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/scripts/configure.ps1
T
jeremy e9c7e9df1a feat: self-host Hobbs Family Organizer (Shield TV dashboard) on hf-pdocker-02:8340
- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source)
- compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script
- CLAUDE.md: service tree + internal ports
Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 18:27:22 -06:00

195 lines
6.8 KiB
PowerShell

[CmdletBinding()]
param(
[ValidateSet("trusted-lan", "bearer")]
[string]$Mode,
[switch]$RotateToken
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$SecretsDirectory = Join-Path $ProjectRoot "secrets"
$CalendarFile = Join-Path $SecretsDirectory "calendar_ical_urls.json"
$TokenFile = Join-Path $SecretsDirectory "organizer_access_token.txt"
$EnvironmentFile = Join-Path $ProjectRoot ".env"
$EnvironmentExample = Join-Path $ProjectRoot ".env.example"
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
function Test-IsWindows {
if ($PSVersionTable.PSEdition -eq "Desktop") { return $true }
return [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
[Runtime.InteropServices.OSPlatform]::Windows
)
}
function Read-HiddenText {
param([Parameter(Mandatory)][string]$Prompt)
$secureValue = Read-Host $Prompt -AsSecureString
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
function Read-YesNo {
param(
[Parameter(Mandatory)][string]$Prompt,
[bool]$Default = $false
)
$answer = (Read-Host $Prompt).Trim().ToLowerInvariant()
if (-not $answer) { return $Default }
return $answer -in @("y", "yes")
}
function Test-PrivateGoogleCalendarUrl {
param([Parameter(Mandatory)][string]$Value)
$uri = $null
if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri)) { return $false }
if ($uri.Scheme -ne "https" -or $uri.Host -ne "calendar.google.com") { return $false }
if (-not $uri.IsDefaultPort -or $uri.UserInfo -or $uri.Fragment) { return $false }
return $uri.AbsolutePath -match "^/calendar/ical/[^/]+/(?:private-[^/]+|public)/basic[.]ics$"
}
function Protect-SecretFile {
param([Parameter(Mandatory)][string]$Path)
if (Test-IsWindows) {
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
& icacls.exe $Path "/inheritance:r" "/grant:r" "${userName}:(R,W)" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Could not restrict permissions on a secret file."
}
}
else {
& chmod 600 -- $Path
if ($LASTEXITCODE -ne 0) {
throw "Could not restrict permissions on a secret file."
}
}
}
function Write-SecretFile {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][AllowEmptyString()][string]$Content
)
$directory = Split-Path -Parent $Path
$temporaryPath = Join-Path $directory (".configure-" + [Guid]::NewGuid().ToString("N") + ".tmp")
try {
[IO.File]::WriteAllText($temporaryPath, $Content, $Utf8NoBom)
Protect-SecretFile -Path $temporaryPath
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
Protect-SecretFile -Path $Path
}
finally {
if (Test-Path -LiteralPath $temporaryPath) {
Remove-Item -LiteralPath $temporaryPath -Force
}
}
}
function New-AccessToken {
$bytes = New-Object byte[] 48
$generator = [Security.Cryptography.RandomNumberGenerator]::Create()
try {
$generator.GetBytes($bytes)
}
finally {
$generator.Dispose()
}
return [Convert]::ToBase64String($bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_")
}
if (-not (Test-Path -LiteralPath $EnvironmentExample -PathType Leaf)) {
throw "Missing .env.example. Run this script from an intact release bundle."
}
New-Item -ItemType Directory -Path $SecretsDirectory -Force | Out-Null
if (-not (Test-IsWindows)) {
& chmod 700 -- $SecretsDirectory
if ($LASTEXITCODE -ne 0) { throw "Could not restrict the secrets directory permissions." }
}
if (-not (Test-Path -LiteralPath $EnvironmentFile)) {
Copy-Item -LiteralPath $EnvironmentExample -Destination $EnvironmentFile
Write-Host "Created .env from .env.example."
}
else {
Write-Host "Preserved the existing .env file."
}
$writeCalendars = -not (Test-Path -LiteralPath $CalendarFile -PathType Leaf)
if (-not $writeCalendars) {
$writeCalendars = Read-YesNo "A calendar secret already exists. Replace it? [y/N]"
}
if ($writeCalendars) {
$calendarUrls = [Collections.Generic.List[string]]::new()
Write-Host "Enter private Google Calendar iCal URLs. Input is hidden; submit a blank entry when finished."
while ($true) {
$calendarUrl = Read-HiddenText ("Calendar URL #{0}" -f ($calendarUrls.Count + 1))
if ([string]::IsNullOrWhiteSpace($calendarUrl)) { break }
if (-not (Test-PrivateGoogleCalendarUrl -Value $calendarUrl.Trim())) {
Write-Warning "That entry is not a supported private Google Calendar basic.ics HTTPS URL; it was not saved."
continue
}
$calendarUrls.Add($calendarUrl.Trim())
}
if ($calendarUrls.Count -eq 0) {
throw "At least one calendar URL is required when creating the calendar secret."
}
$calendarJson = ConvertTo-Json -InputObject @($calendarUrls.ToArray()) -Compress
Write-SecretFile -Path $CalendarFile -Content ($calendarJson + [Environment]::NewLine)
Write-Host ("Saved {0} calendar source(s) without displaying their URLs." -f $calendarUrls.Count)
}
else {
Protect-SecretFile -Path $CalendarFile
Write-Host "Preserved the existing calendar secret."
}
$hasExistingToken = (Test-Path -LiteralPath $TokenFile -PathType Leaf) -and ((Get-Item -LiteralPath $TokenFile).Length -gt 0)
if (-not $Mode) {
$defaultMode = if ($hasExistingToken) { "bearer" } else { "trusted-lan" }
$selectedMode = (Read-Host "Access mode [trusted-lan/bearer] (default: $defaultMode)").Trim().ToLowerInvariant()
$Mode = if ($selectedMode) { $selectedMode } else { $defaultMode }
if ($Mode -notin @("trusted-lan", "bearer")) {
throw "Access mode must be trusted-lan or bearer."
}
}
if ($Mode -eq "trusted-lan") {
if ($hasExistingToken -and -not (Read-YesNo "This will erase the current access token and disable bearer authentication. Continue? [y/N]")) {
throw "Configuration cancelled before changing the access mode."
}
Write-SecretFile -Path $TokenFile -Content ""
Write-Host "Configured trusted-LAN mode with an empty token file."
}
elseif ($hasExistingToken -and -not $RotateToken) {
Protect-SecretFile -Path $TokenFile
Write-Host "Preserved the existing bearer token. Use -RotateToken to replace it."
}
else {
$accessToken = New-AccessToken
try {
Write-SecretFile -Path $TokenFile -Content ($accessToken + [Environment]::NewLine)
}
finally {
$accessToken = $null
}
Write-Host "Generated and stored a new bearer token. The token was not displayed."
}
Write-Host "Configuration is ready. Review .env, then run: docker compose up -d --build"