#!/usr/bin/env bash # Kvitt Pi install (infra/README.md "Pi: what the owner does"). Run ON the Kvitt Pi, as the owner's user, from the # bundle folder copied from the Mac (infra/pi/): cd ~/kvitt-pi && ./install.sh # Idempotent: safe to run again after changing .env, the secrets or compose.yaml. # 1. checks Docker Engine and the compose plugin # 2. checks .env (no CHANGE_ME left in the keys the stack reads; KVITT_PUBLIC_HOST a host name, HA_HOST an IPv4 # address) # 3. creates /opt/kvitt/{secrets,import,apk}, installs the secret files from ./secrets/ with their modes and # removes what the Caddy and cert-sync stack of before D-120 left in /opt/kvitt # 4. trusts the plain-HTTP registry in /etc/docker/daemon.json (insecure-registries) and logs in when credentials are set # 5. copies compose.yaml, .env and the operation scripts to /opt/kvitt; a KVITT_TAG that deploy.sh set # in /opt/kvitt/.env is kept, so re-running an older bundle never rolls the server back to the bundle's tag # 6. pulls the images (digests recorded in /opt/kvitt/deployed-digests), gives the named volumes their owners, # starts the stack, waits for /health on port 8080, then tries /health through Nginx Proxy Manager # Uses sudo for /opt/kvitt, the secret files and the Docker daemon configuration. set -euo pipefail bundle="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib.sh . "$bundle/lib.sh" # 1. Docker command -v docker >/dev/null || die "docker is not installed (README step 2)" docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing (README step 2)" docker info >/dev/null 2>&1 || die "cannot talk to Docker as $(id -un): add yourself to the docker group (sudo usermod -aG docker $(id -un)), log out and in" command -v curl >/dev/null || die "curl is missing: sudo apt-get install -y curl" command -v python3 >/dev/null || die "python3 is missing (step 4 edits daemon.json with it): sudo apt-get install -y python3" # 2. .env env_src="$bundle/.env" [[ -f "$env_src" ]] || die "no .env next to install.sh: cp .env.example .env and fill it (README step 4)" for key in KVITT_REGISTRY KVITT_TAG KVITT_PUBLIC_HOST HA_HOST RCLONE_REMOTE RCLONE_FOLDER_ID; do value=$(env_value "$key" "$env_src") [[ -n "$value" ]] || die ".env: $key is empty" [[ "$value" != CHANGE_ME* ]] || die ".env: $key is still $value" done # The server compares the TCP peer with KVITT_TRUSTED_PROXY (= HA_HOST). A host name would have to resolve inside # the container to exactly NPM's LAN address (mDNS names such as homeassistant.local do not), so require the address. ha_host=$(env_value HA_HOST "$env_src") [[ "$ha_host" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] \ || die ".env: HA_HOST must be the Home Assistant Pi's LAN IPv4 address (for example 192.168.1.20), not $ha_host" public_host=$(env_value KVITT_PUBLIC_HOST "$env_src") [[ "$public_host" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$ ]] \ || die ".env: KVITT_PUBLIC_HOST must be a host name such as kvitt.example.org (no scheme, no port), not $public_host" [[ "$public_host" != icehack.asuscomm.com ]] \ || die ".env: KVITT_PUBLIC_HOST cannot be icehack.asuscomm.com, NPM's proxy host for Home Assistant (D-120)" registry=$(env_value KVITT_REGISTRY "$env_src") registry_host=${registry%%/*} registry_user=$(env_value KVITT_REGISTRY_USER "$env_src") registry_token=$(env_value KVITT_REGISTRY_TOKEN "$env_src") # 3. Directories and secrets. uid 10001 = server and backup (SPEC §10.2, §10.7, D-105). say "Creating $KVITT_DIR" sudo install -d -m 0755 -o "$(id -u)" -g "$(id -g)" "$KVITT_DIR" "$KVITT_DIR/import" "$KVITT_DIR/apk" sudo install -d -m 0700 -o root -g root "$KVITT_DIR/secrets" # name : owner uid : group : mode secret_specs=( "fcm-service-account.json:0:10001:0440" "rclone.conf:10001:10001:0600" ) missing=() for spec in "${secret_specs[@]}"; do IFS=: read -r name uid gid mode <<< "$spec" src="$bundle/secrets/$name" dst="$KVITT_DIR/secrets/$name" if [[ -f "$src" ]]; then sudo install -m "$mode" -o "$uid" -g "$gid" "$src" "$dst" say " secrets/$name installed (mode $mode, owner $uid:$gid)" elif sudo test -f "$dst"; then sudo chown "$uid:$gid" "$dst" sudo chmod "$mode" "$dst" say " secrets/$name already in place (mode $mode, owner $uid:$gid)" else missing+=("$name") fi if sudo test -d "$dst"; then die "$dst is a directory (Docker creates one when a file was missing at start): sudo rmdir $dst, then run again" fi done (( ${#missing[@]} == 0 )) || die "missing secret files: ${missing[*]} (put them in $bundle/secrets/, README step 5)" if [[ -d "$bundle/secrets" ]]; then say " The copies in $bundle/secrets/ are no longer needed: rm -rf $bundle/secrets" fi # Left by the Caddy and cert-sync stack of before D-120: the Home Assistant SSH key must not stay on this Pi. for old in "$KVITT_DIR/secrets/ha_ssh_key" "$KVITT_DIR/secrets/ha_known_hosts" "$KVITT_DIR/Caddyfile"; do if sudo test -e "$old"; then sudo rm -rf "$old" say " removed $old (not used since D-120)" fi done # 4. Registry trust (plain HTTP on the home Wi-Fi, SPEC §10.3) and optional login. daemon_json=/etc/docker/daemon.json changed=$(sudo python3 - "$daemon_json" "$registry_host" <<'PY' import json, os, sys path, host = sys.argv[1], sys.argv[2] cfg = {} if os.path.exists(path): with open(path) as f: text = f.read().strip() cfg = json.loads(text) if text else {} regs = cfg.setdefault("insecure-registries", []) if host in regs: print("no") else: regs.append(host) tmp = path + ".kvitt-tmp" with open(tmp, "w") as f: json.dump(cfg, f, indent=2) f.write("\n") os.replace(tmp, path) print("yes") PY ) if [[ "$changed" == yes ]]; then say "Added $registry_host to insecure-registries in $daemon_json; restarting Docker" sudo systemctl restart docker else say "$registry_host is already in insecure-registries" fi if [[ -n "$registry_user" && -n "$registry_token" ]]; then printf '%s' "$registry_token" | docker login "$registry_host" -u "$registry_user" --password-stdin >/dev/null say "Logged in to $registry_host as $registry_user" else say "No registry credentials in .env: pulling anonymously" fi # 5. Stack files and operation scripts. installed_tag="" [[ -f "$KVITT_DIR/.env" ]] && installed_tag=$(env_value KVITT_TAG) bundle_tag=$(env_value KVITT_TAG "$env_src") install -m 0644 "$bundle/compose.yaml" "$KVITT_DIR/compose.yaml" install -m 0600 "$env_src" "$KVITT_DIR/.env" if [[ -n "$installed_tag" && "$installed_tag" != "$bundle_tag" ]]; then env_set KVITT_TAG "$installed_tag" say "KVITT_TAG stays at $installed_tag (set by deploy.sh; the bundle's .env says $bundle_tag). Other tag: $KVITT_DIR/deploy.sh " fi for script in lib.sh deploy.sh bootstrap.sh backup-now.sh install-apk.sh; do install -m 0755 "$bundle/$script" "$KVITT_DIR/$script" done say "Installed compose.yaml, .env and the scripts in $KVITT_DIR" # 6. Pull, volume owners, start. compose config --quiet compose pull record_digests "$(env_value KVITT_TAG)" compose create # Named volumes start owned by root; give each its container's uid (the server image has no shell, so the # backup image, which mounts the same data volume, sets /data). Runs as root once per install, then exits. compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint /bin/sh backup \ -c 'chown 10001:10001 /data /backups && mkdir -p /data/apk && chown 10001:10001 /data/apk' # --remove-orphans also removes the caddy and cert-sync containers of a stack installed before D-120. compose up -d --remove-orphans # Their volumes: certs held a copy of the certificate's private key. for volume in kvitt_certs kvitt_caddy_data; do if docker volume inspect "$volume" >/dev/null 2>&1; then if docker volume rm "$volume" >/dev/null; then say "Removed the volume $volume (not used since D-120)" else say "Could not remove the volume $volume (still in use?): docker volume rm $volume" fi fi done ok=1 wait_healthy server 180 || ok=0 compose ps print_health || ok=0 (( ok == 1 )) || die "the stack is not fully up yet; see the lines above" if ! print_proxy_health; then say "Not reachable through Nginx Proxy Manager yet: expected until its proxy host is set up (README step 6)." fi say "Kvitt is running. Next: README step 6 (NPM proxy host), then $KVITT_DIR/bootstrap.sh (first install only)."