e9c7e9df1a
- 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
195 lines
6.8 KiB
PowerShell
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"
|