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
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
[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"
|
||||
Reference in New Issue
Block a user