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:
2026-11-04 18:27:22 -06:00
parent 122ea392d1
commit e9c7e9df1a
68 changed files with 12831 additions and 0 deletions
@@ -0,0 +1,76 @@
[CmdletBinding()]
param(
[string]$DestinationDirectory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
if (-not $DestinationDirectory) {
$DestinationDirectory = Join-Path $ProjectRoot "backups"
}
$DestinationDirectory = [IO.Path]::GetFullPath($DestinationDirectory)
$Sources = @{
Environment = Join-Path $ProjectRoot ".env"
Calendars = Join-Path $ProjectRoot "secrets/calendar_ical_urls.json"
Token = Join-Path $ProjectRoot "secrets/organizer_access_token.txt"
}
foreach ($source in $Sources.GetEnumerator()) {
if (-not (Test-Path -LiteralPath $source.Value -PathType Leaf)) {
throw "Required configuration is missing. Run scripts/configure.ps1 before creating a backup."
}
}
New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$suffix = [Guid]::NewGuid().ToString("N").Substring(0, 8)
$archivePath = Join-Path $DestinationDirectory "hobbs-organizer-config-$timestamp-$suffix.zip"
$stagingPath = Join-Path $DestinationDirectory (".backup-staging-" + [Guid]::NewGuid().ToString("N"))
$stagingFullPath = [IO.Path]::GetFullPath($stagingPath)
$destinationPrefix = $DestinationDirectory.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if (-not $stagingFullPath.StartsWith($destinationPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to create a staging directory outside the selected backup directory."
}
try {
New-Item -ItemType Directory -Path (Join-Path $stagingPath "config") -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $stagingPath "secrets") -Force | Out-Null
Copy-Item -LiteralPath $Sources.Environment -Destination (Join-Path $stagingPath "config/environment.env")
Copy-Item -LiteralPath $Sources.Calendars -Destination (Join-Path $stagingPath "secrets/calendar_ical_urls.json")
Copy-Item -LiteralPath $Sources.Token -Destination (Join-Path $stagingPath "secrets/organizer_access_token.txt")
$manifest = @(
"format=HOBBS_ORGANIZER_CONFIG_BACKUP_V1",
"created_utc=$((Get-Date).ToUniversalTime().ToString('o'))",
"contains_secrets=true"
) -join [Environment]::NewLine
[IO.File]::WriteAllText(
(Join-Path $stagingPath "manifest.txt"),
$manifest + [Environment]::NewLine,
(New-Object System.Text.UTF8Encoding($false))
)
Compress-Archive -Path (Join-Path $stagingPath "*") -DestinationPath $archivePath -CompressionLevel Optimal
if ($PSVersionTable.PSEdition -eq "Desktop" -or [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) {
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
& icacls.exe $archivePath "/inheritance:r" "/grant:r" "${userName}:(F)" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not restrict backup archive permissions." }
}
else {
& chmod 600 -- $archivePath
if ($LASTEXITCODE -ne 0) { throw "Could not restrict backup archive permissions." }
}
}
finally {
if (Test-Path -LiteralPath $stagingFullPath) {
Remove-Item -LiteralPath $stagingFullPath -Recurse -Force
}
}
Write-Host "Created a protected configuration backup at: $archivePath"
Write-Host "The archive contains secrets. Store it in an encrypted or access-controlled backup system."
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
DESTINATION_DIRECTORY="${1:-$PROJECT_ROOT/backups}"
ENVIRONMENT_FILE="$PROJECT_ROOT/.env"
CALENDAR_FILE="$PROJECT_ROOT/secrets/calendar_ical_urls.json"
TOKEN_FILE="$PROJECT_ROOT/secrets/organizer_access_token.txt"
for required_file in "$ENVIRONMENT_FILE" "$CALENDAR_FILE" "$TOKEN_FILE"; do
if [[ ! -f "$required_file" ]]; then
printf '%s\n' "Required configuration is missing. Run scripts/configure.sh before creating a backup." >&2
exit 1
fi
done
command -v tar >/dev/null 2>&1 || { printf '%s\n' "tar is required to create a backup." >&2; exit 1; }
mkdir -p -- "$DESTINATION_DIRECTORY"
DESTINATION_DIRECTORY="$(cd -- "$DESTINATION_DIRECTORY" && pwd -P)"
chmod 700 -- "$DESTINATION_DIRECTORY"
timestamp="$(date -u +%Y%m%d-%H%M%S)"
archive_path="$DESTINATION_DIRECTORY/hobbs-organizer-config-$timestamp.tar.gz"
archive_counter=1
while [[ -e "$archive_path" ]]; do
archive_path="$DESTINATION_DIRECTORY/hobbs-organizer-config-$timestamp-$archive_counter.tar.gz"
((archive_counter += 1))
done
staging_path="$(mktemp -d "$DESTINATION_DIRECTORY/.backup-staging-XXXXXXXX")"
cleanup_staging() {
[[ -n "${staging_path:-}" && -d "$staging_path" ]] && rm -rf -- "$staging_path"
}
trap cleanup_staging EXIT HUP INT TERM
mkdir -p -- "$staging_path/config" "$staging_path/secrets"
cp -- "$ENVIRONMENT_FILE" "$staging_path/config/environment.env"
cp -- "$CALENDAR_FILE" "$staging_path/secrets/calendar_ical_urls.json"
cp -- "$TOKEN_FILE" "$staging_path/secrets/organizer_access_token.txt"
chmod 600 -- "$staging_path/config/environment.env" "$staging_path/secrets/calendar_ical_urls.json" "$staging_path/secrets/organizer_access_token.txt"
{
printf '%s\n' 'format=HOBBS_ORGANIZER_CONFIG_BACKUP_V1'
printf 'created_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '%s\n' 'contains_secrets=true'
} >"$staging_path/manifest.txt"
(
cd -- "$staging_path"
tar -czf "$archive_path" config secrets manifest.txt
)
chmod 600 -- "$archive_path"
rm -rf -- "$staging_path"
staging_path=""
trap - EXIT HUP INT TERM
printf 'Created a protected configuration backup at: %s\n' "$archive_path"
printf '%s\n' "The archive contains secrets. Store it in an encrypted or access-controlled backup system."
@@ -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"
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
SECRETS_DIRECTORY="$PROJECT_ROOT/secrets"
CALENDAR_FILE="$SECRETS_DIRECTORY/calendar_ical_urls.json"
TOKEN_FILE="$SECRETS_DIRECTORY/organizer_access_token.txt"
ENVIRONMENT_FILE="$PROJECT_ROOT/.env"
ENVIRONMENT_EXAMPLE="$PROJECT_ROOT/.env.example"
MODE=""
ROTATE_TOKEN=0
usage() {
printf '%s\n' "Usage: $0 [--mode trusted-lan|bearer] [--rotate-token]" >&2
}
while (($#)); do
case "$1" in
--mode)
(($# >= 2)) || { usage; exit 2; }
MODE="$2"
shift 2
;;
--mode=*)
MODE="${1#*=}"
shift
;;
--rotate-token)
ROTATE_TOKEN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage
exit 2
;;
esac
done
if [[ -n "$MODE" && "$MODE" != "trusted-lan" && "$MODE" != "bearer" ]]; then
printf '%s\n' "Access mode must be trusted-lan or bearer." >&2
exit 2
fi
if [[ ! -f "$ENVIRONMENT_EXAMPLE" ]]; then
printf '%s\n' "Missing .env.example. Run this script from an intact release bundle." >&2
exit 1
fi
mkdir -p -- "$SECRETS_DIRECTORY"
chmod 700 -- "$SECRETS_DIRECTORY"
if [[ ! -e "$ENVIRONMENT_FILE" ]]; then
cp -- "$ENVIRONMENT_EXAMPLE" "$ENVIRONMENT_FILE"
chmod 600 -- "$ENVIRONMENT_FILE"
printf '%s\n' "Created .env from .env.example."
else
printf '%s\n' "Preserved the existing .env file."
fi
replace_calendars=1
if [[ -f "$CALENDAR_FILE" ]]; then
read -r -p "A calendar secret already exists. Replace it? [y/N] " calendar_answer
case "${calendar_answer,,}" in
y|yes) replace_calendars=1 ;;
*) replace_calendars=0 ;;
esac
fi
if ((replace_calendars)); then
calendar_pattern='^https://calendar[.]google[.]com/calendar/ical/[A-Za-z0-9._~%+@-]+/(private-[A-Za-z0-9._~%+@-]+|public)/basic[.]ics$'
calendar_urls=()
printf '%s\n' "Enter private Google Calendar iCal URLs. Input is hidden; submit a blank entry when finished."
while true; do
printf 'Calendar URL #%d: ' "$(( ${#calendar_urls[@]} + 1 ))" >&2
IFS= read -r -s calendar_url
printf '\n' >&2
[[ -n "$calendar_url" ]] || break
if [[ ! "$calendar_url" =~ $calendar_pattern ]]; then
printf '%s\n' "That entry is not a supported private Google Calendar basic.ics HTTPS URL; it was not saved." >&2
unset calendar_url
continue
fi
calendar_urls+=("$calendar_url")
unset calendar_url
done
if ((${#calendar_urls[@]} == 0)); then
printf '%s\n' "At least one calendar URL is required when creating the calendar secret." >&2
exit 1
fi
calendar_temporary="$(mktemp "$SECRETS_DIRECTORY/.calendar-XXXXXXXX")"
cleanup_calendar() {
[[ -n "${calendar_temporary:-}" && -e "$calendar_temporary" ]] && rm -f -- "$calendar_temporary"
}
trap cleanup_calendar EXIT HUP INT TERM
chmod 600 -- "$calendar_temporary"
{
printf '['
for calendar_index in "${!calendar_urls[@]}"; do
((calendar_index == 0)) || printf ','
printf '"%s"' "${calendar_urls[$calendar_index]}"
done
printf ']\n'
} >"$calendar_temporary"
mv -f -- "$calendar_temporary" "$CALENDAR_FILE"
calendar_temporary=""
trap - EXIT HUP INT TERM
chmod 600 -- "$CALENDAR_FILE"
printf 'Saved %d calendar source(s) without displaying their URLs.\n' "${#calendar_urls[@]}"
unset calendar_urls
else
chmod 600 -- "$CALENDAR_FILE"
printf '%s\n' "Preserved the existing calendar secret."
fi
has_existing_token=0
[[ -s "$TOKEN_FILE" ]] && has_existing_token=1
if [[ -z "$MODE" ]]; then
default_mode="trusted-lan"
((has_existing_token)) && default_mode="bearer"
read -r -p "Access mode [trusted-lan/bearer] (default: $default_mode) " selected_mode
MODE="${selected_mode:-$default_mode}"
if [[ "$MODE" != "trusted-lan" && "$MODE" != "bearer" ]]; then
printf '%s\n' "Access mode must be trusted-lan or bearer." >&2
exit 2
fi
fi
if [[ "$MODE" == "trusted-lan" ]]; then
if ((ROTATE_TOKEN)); then
printf '%s\n' "--rotate-token can only be used with bearer mode." >&2
exit 2
fi
if ((has_existing_token)); then
read -r -p "This will erase the current access token and disable bearer authentication. Continue? [y/N] " erase_answer
case "${erase_answer,,}" in
y|yes) ;;
*) printf '%s\n' "Configuration cancelled before changing the access mode." >&2; exit 1 ;;
esac
fi
token_temporary="$(mktemp "$SECRETS_DIRECTORY/.token-XXXXXXXX")"
chmod 600 -- "$token_temporary"
: >"$token_temporary"
mv -f -- "$token_temporary" "$TOKEN_FILE"
chmod 600 -- "$TOKEN_FILE"
printf '%s\n' "Configured trusted-LAN mode with an empty token file."
elif ((has_existing_token)) && ((ROTATE_TOKEN == 0)); then
chmod 600 -- "$TOKEN_FILE"
printf '%s\n' "Preserved the existing bearer token. Use --rotate-token to replace it."
else
token_temporary="$(mktemp "$SECRETS_DIRECTORY/.token-XXXXXXXX")"
cleanup_token() {
[[ -n "${token_temporary:-}" && -e "$token_temporary" ]] && rm -f -- "$token_temporary"
}
trap cleanup_token EXIT HUP INT TERM
chmod 600 -- "$token_temporary"
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 48 2>/dev/null | tr -d '\r\n=' | tr '+/' '-_' >"$token_temporary"
elif command -v base64 >/dev/null 2>&1 && [[ -r /dev/urandom ]]; then
dd if=/dev/urandom bs=48 count=1 2>/dev/null | base64 | tr -d '\r\n=' | tr '+/' '-_' >"$token_temporary"
else
printf '%s\n' "Bearer mode requires openssl, or base64 with /dev/urandom." >&2
exit 1
fi
[[ -s "$token_temporary" ]] || { printf '%s\n' "Secure token generation failed." >&2; exit 1; }
mv -f -- "$token_temporary" "$TOKEN_FILE"
token_temporary=""
trap - EXIT HUP INT TERM
chmod 600 -- "$TOKEN_FILE"
printf '%s\n' "Generated and stored a new bearer token. The token was not displayed."
fi
printf '%s\n' "Configuration is ready. Review .env, then run: docker compose up -d --build"
@@ -0,0 +1,97 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Archive,
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$Archive = [IO.Path]::GetFullPath($Archive)
if (-not (Test-Path -LiteralPath $Archive -PathType Leaf)) {
throw "The backup archive does not exist."
}
if (-not $Force) {
$answer = (Read-Host "Restore will overwrite the current .env and runtime secrets. Continue? [y/N]").Trim().ToLowerInvariant()
if ($answer -notin @("y", "yes")) {
throw "Restore cancelled."
}
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try {
$allowedEntries = @(
"config/environment.env",
"secrets/calendar_ical_urls.json",
"secrets/organizer_access_token.txt",
"manifest.txt"
)
$totalBytes = 0L
foreach ($entry in $zip.Entries) {
$entryName = $entry.FullName.Replace("\", "/").TrimEnd("/")
if (-not $entryName) { continue }
if ($entryName -notin $allowedEntries -and $entryName -notin @("config", "secrets")) {
throw "The archive contains an unexpected path and will not be restored."
}
$totalBytes += $entry.Length
}
if ($totalBytes -gt 10MB) {
throw "The configuration archive is unexpectedly large."
}
}
finally {
$zip.Dispose()
}
$stagingPath = Join-Path $ProjectRoot (".restore-staging-" + [Guid]::NewGuid().ToString("N"))
$stagingFullPath = [IO.Path]::GetFullPath($stagingPath)
$projectPrefix = $ProjectRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if (-not $stagingFullPath.StartsWith($projectPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to create a restore staging directory outside the application directory."
}
try {
Expand-Archive -LiteralPath $Archive -DestinationPath $stagingPath
$restoredEnvironment = Join-Path $stagingPath "config/environment.env"
$restoredCalendars = Join-Path $stagingPath "secrets/calendar_ical_urls.json"
$restoredToken = Join-Path $stagingPath "secrets/organizer_access_token.txt"
foreach ($required in @($restoredEnvironment, $restoredCalendars, $restoredToken)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
throw "The archive is missing required configuration files."
}
}
$secretsDirectory = Join-Path $ProjectRoot "secrets"
New-Item -ItemType Directory -Path $secretsDirectory -Force | Out-Null
Copy-Item -LiteralPath $restoredEnvironment -Destination (Join-Path $ProjectRoot ".env") -Force
Copy-Item -LiteralPath $restoredCalendars -Destination (Join-Path $secretsDirectory "calendar_ical_urls.json") -Force
Copy-Item -LiteralPath $restoredToken -Destination (Join-Path $secretsDirectory "organizer_access_token.txt") -Force
$secretFiles = @(
(Join-Path $secretsDirectory "calendar_ical_urls.json"),
(Join-Path $secretsDirectory "organizer_access_token.txt")
)
if ($PSVersionTable.PSEdition -eq "Desktop" -or [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) {
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
foreach ($secretFile in $secretFiles) {
& icacls.exe $secretFile "/inheritance:r" "/grant:r" "${userName}:(R,W)" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not restrict restored secret permissions." }
}
}
else {
& chmod 600 -- @secretFiles
if ($LASTEXITCODE -ne 0) { throw "Could not restrict restored secret permissions." }
}
}
finally {
if (Test-Path -LiteralPath $stagingFullPath) {
Remove-Item -LiteralPath $stagingFullPath -Recurse -Force
}
}
Write-Host "Configuration restored. Restart with: docker compose up -d --build"
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
FORCE=0
usage() {
printf '%s\n' "Usage: $0 [--force] BACKUP.tar.gz" >&2
}
if [[ "${1:-}" == "--force" ]]; then
FORCE=1
shift
fi
[[ $# -eq 1 ]] || { usage; exit 2; }
archive_argument="$1"
archive_directory="$(cd -- "$(dirname -- "$archive_argument")" && pwd -P)"
archive_path="$archive_directory/$(basename -- "$archive_argument")"
[[ -f "$archive_path" ]] || { printf '%s\n' "The backup archive does not exist." >&2; exit 1; }
command -v tar >/dev/null 2>&1 || { printf '%s\n' "tar is required to restore a backup." >&2; exit 1; }
if ((FORCE == 0)); then
read -r -p "Restore will overwrite the current .env and runtime secrets. Continue? [y/N] " restore_answer
case "${restore_answer,,}" in
y|yes) ;;
*) printf '%s\n' "Restore cancelled." >&2; exit 1 ;;
esac
fi
while IFS= read -r archive_entry; do
normalized_entry="${archive_entry%/}"
case "$normalized_entry" in
config|config/environment.env|secrets|secrets/calendar_ical_urls.json|secrets/organizer_access_token.txt|manifest.txt) ;;
*) printf '%s\n' "The archive contains an unexpected path and will not be restored." >&2; exit 1 ;;
esac
done < <(tar -tzf "$archive_path")
staging_path="$(mktemp -d "$PROJECT_ROOT/.restore-staging-XXXXXXXX")"
cleanup_staging() {
[[ -n "${staging_path:-}" && -d "$staging_path" ]] && rm -rf -- "$staging_path"
}
trap cleanup_staging EXIT HUP INT TERM
tar -xzf "$archive_path" -C "$staging_path"
for required_file in \
"$staging_path/config/environment.env" \
"$staging_path/secrets/calendar_ical_urls.json" \
"$staging_path/secrets/organizer_access_token.txt"; do
[[ -f "$required_file" && ! -L "$required_file" ]] || {
printf '%s\n' "The archive is missing a required regular file." >&2
exit 1
}
done
mkdir -p -- "$PROJECT_ROOT/secrets"
cp -- "$staging_path/config/environment.env" "$PROJECT_ROOT/.env"
cp -- "$staging_path/secrets/calendar_ical_urls.json" "$PROJECT_ROOT/secrets/calendar_ical_urls.json"
cp -- "$staging_path/secrets/organizer_access_token.txt" "$PROJECT_ROOT/secrets/organizer_access_token.txt"
chmod 600 -- "$PROJECT_ROOT/.env" "$PROJECT_ROOT/secrets/calendar_ical_urls.json" "$PROJECT_ROOT/secrets/organizer_access_token.txt"
rm -rf -- "$staging_path"
staging_path=""
trap - EXIT HUP INT TERM
printf '%s\n' "Configuration restored. Restart with: docker compose up -d --build"