#!/usr/bin/env bash # Parse the complete installer before starting, including when piped into Bash. main() { set -euo pipefail MILVAGO_RELEASE_VERSION='1.1.1' SOURCE_COMMIT='@SOURCE_COMMIT@' IMAGE='@IMAGE@' # Release assets contain the exact source commit and signed image digest. if [[ "$SOURCE_COMMIT" == @* || "$IMAGE" == @* ]]; then printf 'Error: download install-private.sh from release v%s. This source template is not an installer.\n' "$MILVAGO_RELEASE_VERSION" >&2 exit 1 fi if [[ -n "${MILVAGO_VERSION:-}" && "$MILVAGO_VERSION" != "$MILVAGO_RELEASE_VERSION" ]]; then printf 'Error: this installer is pinned to %s; requested %s. Download the matching release installer.\n' "$MILVAGO_RELEASE_VERSION" "$MILVAGO_VERSION" >&2 exit 1 fi COSIGN_IMAGE='ghcr.io/sigstore/cosign/cosign:v3.1.3@sha256:9e5c2f2edc34351160407ca3416c61855bdf9403c3c5936e0f0be7fc261611b8' CADDY_IMAGE='caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b' NODE_IMAGE='node:26.13.0-trixie-slim@sha256:e4db4c914bf8ad241bd24804fc25d272d89bad97e68cdc8a1414862790a02c39' APT_KEY_SHA256='1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570' RPM_KEY_SHA256='e6c650e0700b1bf4868b693b30761b926844befc8a0acb7ac0dd9b1faf1b7423' fail() { printf 'Error: %s\n' "$*" >&2; exit 1; } need() { local command_name=$1; command -v "$command_name" >/dev/null 2>&1 || fail "Required command is missing: $command_name"; } as_root() { if (( EUID == 0 )); then "$@"; else sudo "$@"; fi } package_manager() { if command -v apt-get >/dev/null 2>&1 && command -v apt-cache >/dev/null 2>&1; then printf 'apt' elif command -v dnf >/dev/null 2>&1; then printf 'dnf' elif command -v yum >/dev/null 2>&1; then printf 'yum' else fail 'Automatic prerequisite installation requires apt-get, dnf or yum. Install the listed tools with your distribution package manager, then rerun.' fi } install_packages() { local manager package listing name version repository exact arch local pins=() manager=$(package_manager) || return 1 if (( EUID != 0 )); then need sudo; sudo -v; fi if [[ "$manager" == apt ]]; then as_root apt-get update || fail 'Cannot refresh package indexes. Check the distribution repositories and network.' else need rpm arch=$(rpm --eval '%{_arch}') [[ "$arch" =~ ^[a-zA-Z0-9_]+$ ]] || fail 'Cannot determine the RPM architecture.' fi for package in "$@"; do exact='' if [[ "$manager" == apt ]]; then listing=$(LC_ALL=C apt-cache policy "$package") || fail "Cannot resolve package $package." while read -r name version repository; do if [[ "$name" == Candidate: && "$version" != '(none)' ]]; then exact="$package=$version" break fi done <<< "$listing" else listing=$(LC_ALL=C as_root "$manager" -q list --available "$package.$arch" "$package.noarch") || fail "Cannot resolve package $package. Check enabled distribution repositories." while read -r name version repository; do if [[ "$name" == "$package.$arch" || "$name" == "$package.noarch" ]]; then [[ -n "$version" && -n "$repository" ]] || continue exact="$package-$version.${name##*.}" break fi done <<< "$listing" fi [[ -n "$exact" ]] || fail "No exact package candidate is available for $package on this distribution." pins+=("$exact") done printf 'Installing exact package versions:' printf ' %s' "${pins[@]}" printf '\n' if [[ "$manager" == apt ]]; then as_root apt-get install -y --no-install-recommends --no-remove "${pins[@]}" || fail 'Prerequisite package installation failed.' else as_root "$manager" -y --setopt=install_weak_deps=False install "${pins[@]}" || fail 'Prerequisite package installation failed.' fi } has_ca_bundle() { [[ -s /etc/ssl/certs/ca-certificates.crt || -s /etc/pki/tls/certs/ca-bundle.crt || -s /etc/ssl/ca-bundle.pem || -s /etc/ssl/cert.pem ]] } ensure_prerequisites() { [[ "$OSTYPE" == linux* ]] || fail 'This installer requires Linux and Bash.' local tool manager local packages=() missing=() for tool in uname dirname mktemp id mkdir mv rm cat env chown chmod ln sleep install sha256sum; do if ! command -v "$tool" >/dev/null 2>&1; then missing+=("$tool") [[ " ${packages[*]} " == *' coreutils '* ]] || packages+=(coreutils) fi done for tool in tar gzip; do if ! command -v "$tool" >/dev/null 2>&1; then missing+=("$tool"); packages+=("$tool"); fi done if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then missing+=('curl or wget'); packages+=(curl) fi if ! has_ca_bundle; then missing+=('CA certificates'); packages+=(ca-certificates); fi if [[ "${host_ip:-}" != 127.0.0.1 && -z "${MILVAGO_HOST_IP:-}" ]] && ! command -v ip >/dev/null 2>&1 && ! command -v hostname >/dev/null 2>&1; then manager=$(package_manager) || return 1 missing+=(ip) if [[ "$manager" == apt ]]; then packages+=(iproute2); else packages+=(iproute); fi fi if (( ${#packages[@]} )); then printf 'Missing prerequisites:'; printf ' %s' "${missing[@]}"; printf '\n' install_packages "${packages[@]}" || return 1 fi for tool in uname dirname mktemp id mkdir mv rm cat env chown chmod ln sleep install sha256sum tar gzip; do need "$tool"; done command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 || fail 'Package installation did not provide curl or wget.' has_ca_bundle || fail 'Package installation did not provide a trusted CA certificate bundle.' if [[ "${host_ip:-}" != 127.0.0.1 && -z "${MILVAGO_HOST_IP:-}" ]]; then command -v ip >/dev/null 2>&1 || command -v hostname >/dev/null 2>&1 || fail 'Set MILVAGO_HOST_IP or install iproute tools.' fi } fetch() { local url=$1 local destination=$2 if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o "$destination" elif command -v wget >/dev/null 2>&1; then wget -qO "$destination" "$url" else fail 'curl or wget is required to add the official Docker repository.' fi } verify_key() { local expected=$1 local file=$2 printf '%s %s\n' "$expected" "$file" | sha256sum -c - >/dev/null || fail 'The downloaded Docker repository key has an unexpected SHA-256.' } install_apt() { need apt-get need dpkg need sha256sum local codename=${VERSION_CODENAME:-} [[ -n "$codename" ]] || fail 'Cannot determine the Debian or Ubuntu release.' if [[ "$ID" == ubuntu ]]; then codename=${UBUNTU_CODENAME:-$codename} fi local key_file key_file=$(mktemp) fetch "https://download.docker.com/linux/$ID/gpg" "$key_file" verify_key "$APT_KEY_SHA256" "$key_file" as_root install -m 0755 -d /etc/apt/keyrings as_root install -m 0644 "$key_file" /etc/apt/keyrings/docker.asc rm -f -- "$key_file" if [[ ! -f /etc/apt/sources.list.d/docker.sources ]]; then local source_file source_file=$(mktemp) cat > "$source_file" < "$repo_file" < "$probe/compose.yaml" < "$probe/override.yaml" <<'EOF' services: probe: build: !reset null ports: !override - "127.0.0.1:4021:4020" EOF local status=0 docker compose -f "$probe/compose.yaml" -f "$probe/override.yaml" config --quiet >/dev/null 2>&1 || status=1 rm -rf -- "$probe" return "$status" } start_docker() { if command -v systemctl >/dev/null 2>&1; then as_root systemctl enable --now docker elif command -v service >/dev/null 2>&1; then as_root service docker start else fail 'Docker was installed but no supported service manager could start it.' fi } run_docker() { if (( docker_as_root )); then if [[ -n "${DOCKER_CONFIG:-}" ]]; then as_root env "DOCKER_CONFIG=$DOCKER_CONFIG" docker "$@" else as_root docker "$@" fi else docker "$@" fi } private_ipv4() { local address=$1 [[ "$address" =~ ^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$ ]] || return 1 local a b c d IFS=. read -r a b c d <<< "$address" (( a <= 255 && b <= 255 && c <= 255 && d <= 255 )) || return 1 (( a == 10 || (a == 172 && b >= 16 && b <= 31) || (a == 192 && b == 168) )) } detect_host_ip() { local address=${MILVAGO_HOST_IP:-} if [[ -n "$address" ]]; then private_ipv4 "$address" || fail 'MILVAGO_HOST_IP must be a private IPv4 address.' printf '%s' "$address" return fi if command -v ip >/dev/null 2>&1; then local route route=$(ip -4 route get 1.1.1.1 2>/dev/null) || route='' if [[ "$route" =~ [[:space:]]src[[:space:]]([0-9.]+) ]]; then address=${BASH_REMATCH[1]} fi fi if ! private_ipv4 "$address" && command -v hostname >/dev/null 2>&1; then local candidate for candidate in $(hostname -I 2>/dev/null); do if private_ipv4 "$candidate"; then address=$candidate; break; fi done fi private_ipv4 "$address" || fail 'No private IPv4 address was found. Set MILVAGO_HOST_IP to this server LAN address.' printf '%s' "$address" } inspect_docker_cli() { local docker_path install_engine=0 if docker_path=$(command -v docker 2>/dev/null); then # Windows PATH interoperability can expose a Docker Desktop shim without WSL integration. if [[ "$docker_path" == /mnt/* ]]; then fail "The docker command found is Docker Desktop for Windows ($docker_path), not usable from WSL. Enable Docker Desktop > Settings > Resources > WSL integration for this distribution, then rerun." fi docker --version >/dev/null 2>&1 || fail "The docker command at $docker_path is present but does not run. Repair or remove it, then rerun." else install_engine=1 fi } # Reject a broken existing CLI before prerequisite or Docker package installation. inspect_docker_cli valid_public_origin() { local origin=$1 [[ "$origin" =~ ^[hH][tT][tT][pP][sS]?://[A-Za-z0-9][A-Za-z0-9.-]*(:([0-9]{1,5}))?$ ]] || return 1 local port=${BASH_REMATCH[2]:-} [[ -z "$port" ]] || (( 10#$port >= 1 && 10#$port <= 65535 )) } select_public_origin() { public_origin_explicit=0 [[ ! -v MILVAGO_PUBLIC_URL ]] || public_origin_explicit=1 public_origin=${MILVAGO_PUBLIC_URL:-} if [[ -z "$public_origin" && ! -v MILVAGO_PUBLIC_URL ]]; then # Read only from the controlling terminal, never from the piped script. if { : /dev/null; then printf 'Enter the public browser URL, or press Enter to keep an existing confirmed URL (localhost on first install).\n' >&2 read -r -p 'Milvago public URL [existing URL or http://localhost:4020]: ' public_origin Settings." elif [[ "$public_origin" != "$confirmed_origin" ]]; then public_origin=$confirmed_origin select_host_binding fi } resolve_database_container() { local database_container database_container=$(run_docker compose -f compose.yaml ps -a -q database) || fail 'The database container could not be inspected.' if [[ -z "$database_container" ]]; then # Recreate a removed container against the configured, preserved volume. # Never publish the base Compose database port while inspecting its data. run_docker compose -f compose.yaml -f - create --no-recreate --no-build database <<'DATABASE_CHECK' >/dev/null || fail 'The database container could not be prepared.' services: database: ports: !reset [] DATABASE_CHECK database_container=$(run_docker compose -f compose.yaml ps -a -q database) || fail 'The prepared database container could not be inspected.' fi [[ -n "$database_container" ]] || fail 'The database container could not be identified.' printf '%s' "$database_container" } read_stored_public_origin() { local existing_database=$1 ready=0 schema_state origin_row attempt [[ -n "$existing_database" ]] || fail 'The database container must be identified before reading its configuration.' run_docker start "$existing_database" >/dev/null || fail 'The existing database could not start.' # First boot: the image entrypoint runs initdb and the init scripts (which create the # milvago database) on a socket-only temporary server. Probe TCP so only the final # server counts as ready. for attempt in {1..90}; do if run_docker exec "$existing_database" pg_isready -h 127.0.0.1 -U postgres -d milvago >/dev/null 2>&1; then ready=1; break; fi sleep 1 done (( ready )) || fail 'The existing database did not become ready; its configuration was not read.' schema_state=$(run_docker exec -e PGCONNECT_TIMEOUT=5 -e 'PGOPTIONS=-c statement_timeout=5000' "$existing_database" \ psql -X -v ON_ERROR_STOP=1 -U postgres -d milvago -At -c \ "SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_catalog.pg_class WHERE oid=to_regclass('public.app_config') AND relkind='r') THEN 'configured' WHEN NOT EXISTS (SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname !~ '^pg_(toast|temp)') THEN 'empty' ELSE 'incomplete' END" 2>/dev/null) || fail 'The existing database schema could not be inspected.' case "$schema_state" in empty) return 0 ;; # A successfully inspected database with no user relations is first-run bootstrap. configured) ;; *) fail 'The existing database schema is incomplete; restore it before updating.' ;; esac origin_row=$(run_docker exec -e PGCONNECT_TIMEOUT=5 -e 'PGOPTIONS=-c statement_timeout=5000' "$existing_database" \ psql -X -v ON_ERROR_STOP=1 -U postgres -d milvago -At -c \ "SELECT CASE WHEN public_url_confirmed THEN 'confirmed:'||public_url ELSE 'unconfirmed' END FROM public.app_config WHERE singleton" \ 2>/dev/null) || fail 'The existing public URL could not be read.' case "$origin_row" in unconfirmed) return 0 ;; confirmed:*) origin_row=${origin_row#confirmed:} valid_public_origin "$origin_row" || fail 'The stored public URL is invalid.' printf '%s' "$origin_row" ;; *) fail 'The existing application configuration is missing or invalid.' ;; esac } select_public_origin select_host_binding umask 077 stage='' auth_dir='' cleanup() { if [[ -n "$stage" && -d "$stage" ]]; then rm -rf -- "$stage"; fi if [[ -n "$auth_dir" && -d "$auth_dir" ]]; then rm -rf -- "$auth_dir"; fi } trap cleanup EXIT complete_checkout() { local checkout=$1 [[ -f "$checkout/compose.yaml" && -f "$checkout/scripts/local-init.mjs" && -f "$checkout/cosign.pub" ]] } script_root='' script_file=${BASH_SOURCE[0]:-} if [[ -n "$script_file" && -f "$script_file" ]]; then script_root=$(CDPATH= cd -- "$(dirname -- "$script_file")" && pwd -P) fi if [[ -n "${MILVAGO_DIR:-}" ]]; then root=$MILVAGO_DIR elif [[ -n "$script_root" ]] && complete_checkout "$script_root"; then root=$script_root else root="$HOME/milvago-community" fi [[ "$root" == /* ]] || fail 'MILVAGO_DIR must be an absolute path.' needs_source=0 if ! complete_checkout "$root"; then [[ ! -e "$root" ]] || fail "The installation directory is incomplete: $root. Set MILVAGO_DIR to a new absolute path or restore this checkout." needs_source=1 fi install_compose=0 if (( install_engine )) || ! docker compose version >/dev/null 2>&1 || ! compose_supports_reset; then install_compose=1 fi if (( install_engine || install_compose )); then if (( install_engine )); then printf 'Installing Docker Engine and Compose from the official repository...\n' else printf 'Installing Docker Compose from the official repository...\n' fi . /etc/os-release case "${ID:-unknown}" in ubuntu|debian) installer=install_apt ;; fedora|rhel|centos|rocky|almalinux|ol) installer=install_dnf ;; *) if [[ " ${ID_LIKE:-} " == *' rhel '* || " ${ID_LIKE:-} " == *' centos '* ]]; then installer=install_dnf else fail "Automatic Docker installation is not supported on ${ID:-unknown}. Install Docker Engine and Compose with the distribution's package manager." fi ;; esac missing_tools=() for tool in sha256sum install; do command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool") done if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then missing_tools+=('curl or wget') fi if (( install_engine )) && ! command -v systemctl >/dev/null 2>&1 && ! command -v service >/dev/null 2>&1; then missing_tools+=('systemctl or service') fi if (( EUID != 0 )); then command -v sudo >/dev/null 2>&1 || missing_tools+=(sudo) fi (( ${#missing_tools[@]} == 0 )) || fail "Docker package installation needs missing host tools: ${missing_tools[*]}." if (( EUID != 0 )); then sudo -v; fi "$installer" need docker docker compose version >/dev/null 2>&1 || fail 'The Docker Compose plugin did not install correctly.' if (( install_engine )); then start_docker; fi fi docker_as_root=0 if ! docker info >/dev/null 2>&1; then if (( EUID != 0 )); then need sudo; sudo -v; fi if as_root docker info >/dev/null 2>&1; then docker_as_root=1 else start_docker if docker info >/dev/null 2>&1; then docker_as_root=0 elif as_root docker info >/dev/null 2>&1; then docker_as_root=1 else fail 'Docker Engine is unavailable after installation or startup.' fi fi fi run_docker compose version >/dev/null 2>&1 || fail 'Docker Compose is unavailable.' compose_supports_reset || fail 'Docker Compose cannot process the required !reset and !override configuration.' if (( needs_source )); then parent=$(dirname -- "$root") mkdir -p -- "$parent" stage=$(mktemp -d "$parent/.milvago-source.XXXXXXXX") mkdir -- "$stage/source" cat > "$stage/download.mjs" <<'NODE' import { writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; try { const revision = process.argv[2]; if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error('Expected an exact source commit'); const archive = await fetch('https://codeload.github.com/Milvago-AI/milvago-server/tar.gz/' + revision, { redirect: 'error' }); if (!archive.ok) { throw new Error('Community source download returned HTTP ' + archive.status); } writeFileSync('/work/source.tar.gz', Buffer.from(await archive.arrayBuffer()), { mode: 0o600 }); const unpack = spawnSync('tar', [ '-xzf', '/work/source.tar.gz', '--strip-components=1', '--no-same-owner', '-C', '/work/source', ], { stdio: 'inherit' }); if (unpack.status !== 0) throw new Error('Community source extraction failed'); } catch (error) { console.error('Error: ' + error.message); process.exitCode = 1; } NODE printf 'Fetching the pinned Community server source into %s...\n' "$root" run_docker run --rm --user "$(id -u):$(id -g)" \ -v "$stage:/work:Z" -w /work \ "$NODE_IMAGE" node /work/download.mjs "$SOURCE_COMMIT" || fail 'The public Community source could not be downloaded and extracted. Check network access to GitHub.' complete_checkout "$stage/source" || fail 'The downloaded Community source is incomplete.' mv -- "$stage/source" "$root" rm -rf -- "$stage" stage='' fi [[ -e "$root/.env" && -f "$root/.local/generated/realm.json" ]] || [[ ! -e "$root/.env" && ! -e "$root/.local/generated/realm.json" ]] || fail 'Local configuration is incomplete; restore the matching .env and realm.json files before continuing.' auth_dir=$(mktemp -d "${TMPDIR:-/tmp}/milvago-ghcr.XXXXXXXX") original_docker_config=${DOCKER_CONFIG:-$HOME/.docker} if [[ -d "$original_docker_config/cli-plugins" ]]; then plugin_dir=$(CDPATH= cd -- "$original_docker_config/cli-plugins" && pwd -P) ln -s -- "$plugin_dir" "$auth_dir/cli-plugins" fi export DOCKER_CONFIG="$auth_dir" UPDATE_PUBLIC_KEY='14ER8eA7zpdlVLLgL+7CPce5eka1Eqmp8Tmz2mUJxmg=' mkdir -p -- "$root/.local/generated" cat > "$root/.local/generated/fetch-agent-release.mjs" <<'NODE' import { createHash, createPublicKey, verify } from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const root = '/work'; const target = join(root, '.local/installers'); const expectedArchive = '045fa04d5019b9b4d999ea37f4157c3fb1a60720fb4f4b4721cfe8a7b420d6d6'; const expectedArchiveSize = 27613801; const expected = { "windows": { "name": "milvago-community-0.7.44-windows.msi", "size": 11325440, "sha256": "60884a56b6b98a913c921ed5d59903cd1376e98eb0f8815e97c32e782012f248", "format": "msi", "script": { "name": "milvago-community-0.7.44-windows-install.ps1", "size": 4192, "sha256": "2e895526597dfd78013b4b106c773fac1e5cc4814335bd1ad004eb4787a1f3cb" } }, "linux": { "name": "milvago-community-0.7.44-linux.tar.gz", "size": 11880195, "sha256": "d7472eb4c5297a2c21840397864c8096c17ae1a05d8bd05f8b1d15c559c90891", "format": "binary", "update": { "name": "milvago-community-0.7.44-linux", "size": 12511800, "sha256": "3ef60a966ce95448fa2d6bb9d9958d0258d6c40d3de8a714a3a6257243977ead" } } }; const digest = bytes => createHash('sha256').update(bytes).digest('hex'); const requireValue = (condition, message) => { if (!condition) throw Error(message); }; let stage; try { const response = await fetch('https://github.com/Milvago-AI/milvago-agent/releases/download/community-agent-v0.7.44/community-agent-v0.7.44.tar.gz', { redirect: 'manual', }); let archiveResponse = response; if (response.status === 302 || response.status === 307) { const redirect = new URL(response.headers.get('location')); requireValue(redirect.protocol === 'https:' && redirect.hostname === 'release-assets.githubusercontent.com', 'Unexpected Community agent release redirect'); archiveResponse = await fetch(redirect, { redirect: 'error' }); } requireValue(archiveResponse.ok, 'Community agent release returned HTTP ' + archiveResponse.status); const archive = Buffer.from(await archiveResponse.arrayBuffer()); requireValue(archive.length === expectedArchiveSize && archive.length < 32 * 1024 * 1024 && digest(archive) === expectedArchive, 'Community agent archive digest does not match the pinned release'); stage = mkdtempSync(join(root, '.local/generated/agent-release-')); const archivePath = join(stage, 'release.tar.gz'); const unpacked = join(stage, 'contents'); mkdirSync(unpacked); writeFileSync(archivePath, archive, { mode: 0o600 }); const tar = spawnSync('tar', ['-xzf', archivePath, '--no-same-owner', '-C', unpacked], { stdio: 'pipe' }); requireValue(tar.status === 0, 'Community agent archive could not be extracted'); const files = readdirSync(unpacked).sort(); const wanted = ['community-linux-update.json', 'community-linux.json', 'community-windows-update.json', 'community-windows.json', expected.linux.name, expected.linux.update.name, expected.windows.name, expected.windows.script.name, 'release-public-key.txt'].sort(); requireValue(JSON.stringify(files) === JSON.stringify(wanted), 'Community agent archive contains unexpected files'); const publicKey = readFileSync(join(unpacked, 'release-public-key.txt'), 'utf8').trim(); requireValue(publicKey === process.env.MILVAGO_UPDATE_PUBLIC_KEY, 'Community update key differs from the pinned key'); const key = createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(publicKey, 'base64')]), format: 'der', type: 'spki', }); for (const [platform, item] of Object.entries(expected)) { const artifact = readFileSync(join(unpacked, item.name)); const updateItem = item.update ?? item; const updateArtifact = readFileSync(join(unpacked, updateItem.name)); const manifest = JSON.parse(readFileSync(join(unpacked, 'community-' + platform + '.json'), 'utf8')); const envelope = JSON.parse(readFileSync(join(unpacked, 'community-' + platform + '-update.json'), 'utf8')); const payload = Buffer.from(envelope.payload, 'base64'); const release = JSON.parse(payload); requireValue(artifact.length === item.size && digest(artifact) === item.sha256 && manifest.version === '0.7.44' && manifest.artifact === item.name && manifest.size === item.size && manifest.sha256 === item.sha256, 'Community ' + platform + ' artifact verification failed'); requireValue(verify(null, payload, key, Buffer.from(envelope.signature, 'base64')) && release.version === '0.7.44' && release.edition === 'community' && release.platform === platform && release.protocol === 2 && release.format === item.format && release.sha256 === updateItem.sha256 && release.size === updateItem.size && release.artifact === '/v2/update/artifact/' + updateItem.sha256 && updateArtifact.length === updateItem.size && digest(updateArtifact) === updateItem.sha256 && Date.parse(release.expires_at) > Date.now(), 'Community ' + platform + ' update signature or release has expired'); } const script = readFileSync(join(unpacked, expected.windows.script.name)); const windowsManifest = JSON.parse(readFileSync(join(unpacked, 'community-windows.json'), 'utf8')); requireValue(windowsManifest.script === expected.windows.script.name && windowsManifest.script_size === expected.windows.script.size && windowsManifest.script_sha256 === expected.windows.script.sha256 && script.length === expected.windows.script.size && digest(script) === expected.windows.script.sha256, 'Community Windows deployment script verification failed'); if (existsSync(target)) { requireValue(lstatSync(target).isDirectory() && !lstatSync(target).isSymbolicLink(), 'Community installer directory is not a regular directory'); } else mkdirSync(target, { recursive: true }); chmodSync(target, 0o755); for (const name of wanted.filter(name => name !== 'release-public-key.txt')) { const next = join(target, '.' + name + '.next'); copyFileSync(join(unpacked, name), next); chmodSync(next, 0o644); renameSync(next, join(target, name)); } console.log('Verified Community agent 0.7.44 for Windows and Linux.'); } catch (error) { console.error('Error: ' + error.message); process.exitCode = 1; } finally { if (stage) rmSync(stage, { recursive: true, force: true }); } NODE printf 'Fetching and verifying the public Community agents...\n' run_docker run --rm --user "$(id -u):$(id -g)" \ -v "$root:/work:z" -w /work \ -e "MILVAGO_UPDATE_PUBLIC_KEY=$UPDATE_PUBLIC_KEY" \ "$NODE_IMAGE" node .local/generated/fetch-agent-release.mjs || fail 'The signed Community agent release could not be prepared. Check network access to GitHub.' printf 'Verifying the Community image signature...\n' run_docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \ -v "$auth_dir:/docker-config:ro,Z" \ -v "$root/cosign.pub:/cosign.pub:ro,z" \ -e DOCKER_CONFIG=/docker-config \ "$COSIGN_IMAGE" verify --key /cosign.pub --insecure-ignore-tlog=true "$IMAGE" >/dev/null || fail 'The image signature could not be verified.' if [[ ! -e "$root/.env" ]]; then printf 'Generating local configuration...\n' run_docker run --rm --network none --user "$(id -u):$(id -g)" \ -v "$root:/work:z" -w /work \ "$NODE_IMAGE" node scripts/local-init.mjs fi # Existing configurations may predate the first-run setup token. run_docker run --rm --network none --user 0 \ -v "$root/.env:/run/milvago.env:rw,z" "$NODE_IMAGE" node -e ' const fs = require("node:fs"); const { randomBytes } = require("node:crypto"); const path = "/run/milvago.env"; const content = fs.readFileSync(path, "utf8"); const matches = [...content.matchAll(/^MILVAGO_SETUP_TOKEN=([^\r\n]*)/gm)]; if (matches.length > 1) throw new Error("Duplicate MILVAGO_SETUP_TOKEN entries in .env"); if (matches.length === 1 && matches[0][1]) { if (!/^[A-Za-z0-9_-]{32,}$/.test(matches[0][1])) { throw new Error("MILVAGO_SETUP_TOKEN is invalid in .env"); } } else { const token = randomBytes(32).toString("base64url"); const updated = matches.length === 1 ? content.replace(/^MILVAGO_SETUP_TOKEN=[^\r\n]*/m, "MILVAGO_SETUP_TOKEN=" + token) : content + (content.endsWith("\n") ? "" : "\n") + "MILVAGO_SETUP_TOKEN=" + token + "\n"; fs.writeFileSync(path, updated); console.log("Generated the missing setup token in .env."); } fs.chmodSync(path, 0o600); ' || fail 'Cannot ensure MILVAGO_SETUP_TOKEN in .env.' cd "$root" database_id=$(resolve_database_container) || fail 'The database could not be prepared for configuration inspection.' stored_origin=$(read_stored_public_origin "$database_id") || fail 'The existing configuration could not be read; update stopped.' reuse_stored_public_origin "$stored_origin" printf 'Milvago URL: %s\n' "$public_origin" caddyfile="$root/.local/generated/Caddyfile.private" cat > "$caddyfile" < "$override" < { const fs = require("node:fs"); const line = fs.readFileSync("/run/milvago.env", "utf8").split(/\r?\n/) .find((entry) => entry.startsWith("IDENTITY_ADMIN_PASSWORD=")); const password = line?.slice("IDENTITY_ADMIN_PASSWORD=".length); if (!password) throw new Error("IDENTITY_ADMIN_PASSWORD is missing from .env"); const base = "http://127.0.0.1:8080"; const form = new URLSearchParams({ grant_type: "password", client_id: "admin-cli", username: "bootstrap-admin", password, }); let tokenResponse; let lastStatus = "unreachable"; for (let attempt = 0; attempt < 60; attempt++) { try { tokenResponse = await fetch(base + "/realms/master/protocol/openid-connect/token", { method: "POST", body: form, }); if (tokenResponse.ok) break; lastStatus = "HTTP " + tokenResponse.status; if (tokenResponse.status === 400 || tokenResponse.status === 401) { const response = await tokenResponse.json().catch(() => ({})); throw new Error("Keycloak administrator login rejected (" + lastStatus + (typeof response.error === "string" ? ", " + response.error : "") + ")"); } } catch (error) { if (error.message.startsWith("Keycloak administrator login rejected")) throw error; } await new Promise((resolve) => setTimeout(resolve, 1000)); } if (!tokenResponse?.ok) throw new Error("Keycloak did not become ready (" + lastStatus + "). Check the identity container logs."); const accessToken = (await tokenResponse.json()).access_token; const headers = { Authorization: "Bearer " + accessToken, Accept: "application/json" }; const clientsResponse = await fetch(base + "/admin/realms/milvago/clients?clientId=milvago-console", { headers }); if (!clientsResponse.ok) throw new Error("Keycloak client lookup returned HTTP " + clientsResponse.status); const clients = await clientsResponse.json(); const client = clients.find((entry) => entry.clientId === "milvago-console"); if (!client?.id) throw new Error("The Milvago console client is missing"); const appURL = process.env.MILVAGO_APP_URL; client.redirectUris = [appURL + "/auth/callback"]; client.webOrigins = [appURL]; // Keycloak links its own pages here: once an invited account is ready, sign in. client.baseUrl = appURL + "/auth/login"; client.attributes = { ...client.attributes, "post.logout.redirect.uris": appURL + "/*" }; const update = await fetch(base + "/admin/realms/milvago/clients/" + encodeURIComponent(client.id), { method: "PUT", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify(client), }); if (!update.ok) throw new Error("Keycloak client update returned HTTP " + update.status); const call = async (path, method = "GET", body) => { const response = await fetch(base + "/admin/realms/milvago" + path, { method, headers: { ...headers, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); if (!response.ok) throw new Error("Keycloak " + method + " " + path.split("?")[0] + " returned HTTP " + response.status); return response.status === 204 ? null : response.json(); }; // basic carries auth_time: without it the console cannot prove how recent a second factor is. const basic = (await call("/client-scopes")).find((scope) => scope.name === "basic"); if (!basic) throw new Error("The Keycloak basic client scope is missing"); await call("/clients/" + encodeURIComponent(client.id) + "/default-client-scopes/" + encodeURIComponent(basic.id), "PUT"); // Setup updates client redirects; SSO settings also need identity-provider access. const [management] = await call("/clients?clientId=milvago-management"); const [realmManagement] = await call("/clients?clientId=realm-management"); if (!management?.id || !realmManagement?.id) throw new Error("Keycloak management clients are missing"); const serviceAccount = await call("/clients/" + encodeURIComponent(management.id) + "/service-account-user"); const requiredRoles = ["manage-clients", "view-clients", "manage-identity-providers", "view-identity-providers"]; const managementRoles = (await call("/clients/" + encodeURIComponent(realmManagement.id) + "/roles")) .filter((role) => requiredRoles.includes(role.name)); if (!requiredRoles.every((name) => managementRoles.some((role) => role.name === name))) { throw new Error("Keycloak management roles are missing"); } await call("/users/" + encodeURIComponent(serviceAccount.id) + "/role-mappings/clients/" + encodeURIComponent(realmManagement.id), "POST", managementRoles); const realmResponse = await fetch(base + "/admin/realms/milvago", { headers }); if (!realmResponse.ok) throw new Error("Keycloak realm lookup returned HTTP " + realmResponse.status); const realm = await realmResponse.json(); // Remove only the factory mail catcher; preserve operator-configured SMTP. const factorySMTP = {host: "mail", port: "1025", from: "no-reply@milvago.test", fromDisplayName: "Milvago", auth: "false", ssl: "false", starttls: "false"}; const smtp = realm.smtpServer || {}; const factoryMail = Object.entries(factorySMTP).every(([key, value]) => smtp[key] === value) && Object.entries(smtp).every(([key, value]) => Object.hasOwn(factorySMTP, key) || value === "" || value === null); if (factoryMail) realm.smtpServer = {}; realm.attributes = { ...realm.attributes, frontendUrl: appURL }; // The invitation e-mail is worded by the Milvago e-mail theme. realm.emailTheme = "milvago"; const realmUpdate = await fetch(base + "/admin/realms/milvago", { method: "PUT", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify(realm), }); if (!realmUpdate.ok) throw new Error("Keycloak realm URL update returned HTTP " + realmUpdate.status); const configuredRealm = await call(""); if (factoryMail && Object.keys(configuredRealm.smtpServer || {}).length !== 0) { throw new Error("The development SMTP configuration was not removed"); } if (!configuredRealm.smtpServer?.host) { console.log("SMTP is not configured. Configure and test a real SMTP server in Milvago before sending invitations or password-reset e-mails."); } const resetPath = base + "/admin/realms/milvago/authentication/flows/" + encodeURIComponent(realm.resetCredentialsFlow || "reset credentials") + "/executions"; const readReset = async () => { const response = await fetch(resetPath, { headers }); if (!response.ok) throw new Error("Keycloak password reset flow lookup returned HTTP " + response.status); return response.json(); }; const otpReset = (await readReset()).find((entry) => entry.displayName === "Reset - Conditional OTP"); if (!otpReset?.id) throw new Error("Keycloak password reset OTP step is missing"); if (otpReset.requirement !== "DISABLED") { const change = await fetch(resetPath, { method: "PUT", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ id: otpReset.id, requirement: "DISABLED" }), }); if (!change.ok) throw new Error("Keycloak password reset OTP change returned HTTP " + change.status); } const applied = (await readReset()).find((entry) => entry.id === otpReset.id); if (applied?.requirement !== "DISABLED") throw new Error("Keycloak password reset OTP change was not applied"); })().catch((error) => { console.error("Error: " + error.message); process.exitCode = 1; }); ' || fail 'Keycloak could not be configured for the LAN address and password-only recovery.' run_docker "${compose[@]}" up -d --no-build database identity application gateway # Caddy reads the mounted Caddyfile at startup, so a rerun must reload it. run_docker "${compose[@]}" restart gateway || fail 'The Caddy gateway could not reload its configuration.' gateway_id=$(run_docker "${compose[@]}" ps -q gateway) if [[ -z "$gateway_id" ]]; then run_docker "${compose[@]}" logs --tail 40 gateway >&2 || true fail 'The Caddy gateway did not start.' fi printf 'Waiting for the Community gateway to become ready...\n' if ! run_docker run --rm --network host -e "MILVAGO_APP_URL=http://$host_ip:4020" "$NODE_IMAGE" node -e ' (async () => { for (let attempt = 0; attempt < 45; attempt++) { try { const response = await fetch(process.env.MILVAGO_APP_URL + "/readyz", { signal: AbortSignal.timeout(2000), }); if (response.status === 200) return; } catch {} await new Promise((resolve) => setTimeout(resolve, 1000)); } throw new Error("Community did not become ready at the LAN address"); })().catch((error) => { console.error("Error: " + error.message); process.exitCode = 1; }); ' ; then run_docker "${compose[@]}" ps >&2 || true run_docker "${compose[@]}" logs --tail 40 gateway >&2 || true fail 'The Community gateway did not become ready. Check the application container logs.' fi printf '\nOpen %s for initial setup.\n' "$public_origin" printf 'Find the setup token and generated secrets in %s/.env (owner-only).\n' "$root" if [[ "$host_ip" == 127.0.0.1 ]]; then printf 'Local access only: open the URL on this server, or use an SSH tunnel from your computer.\n' printf 'SSH tunnel example: ssh -L 4020:127.0.0.1:4020 user@server\n' else printf 'If the page is unreachable, allow TCP port 4020 through the host firewall.\n' fi } main "$@"