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,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"
|
||||
Reference in New Issue
Block a user