install-private.sh
Rama main · réplica de lectura del repositorio original.
#!/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" <<EOF
Types: deb
URIs: https://download.docker.com/linux/$ID
Suites: $codename
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
as_root install -m 0644 "$source_file" /etc/apt/sources.list.d/docker.sources
rm -f -- "$source_file"
fi
as_root apt-get update
if (( install_engine )); then
install_packages docker-ce docker-ce-cli containerd.io docker-compose-plugin
else
install_packages docker-compose-plugin
fi
}
install_dnf() {
local rpm_manager
rpm_manager=$(package_manager)
[[ "$rpm_manager" == dnf || "$rpm_manager" == yum ]] || fail 'Docker RPM installation requires dnf or yum.'
need rpm
need sha256sum
local repo_id repo_release
if [[ "$ID" == fedora ]]; then
[[ "${VERSION_ID:-}" =~ ^[0-9]+$ ]] || fail 'Cannot determine the Fedora release.'
repo_id='fedora'
repo_release=$VERSION_ID
else
local major=${VERSION_ID:-}
major=${major%%.*}
[[ "$major" =~ ^(8|9|10)$ ]] ||
fail "Docker RPM repositories are configured for RHEL-compatible releases 8, 9 and 10 only (detected: ${VERSION_ID:-unknown})."
case "$ID" in
rhel|centos|rocky|almalinux|ol) ;;
*)
[[ " ${ID_LIKE:-} " == *' rhel '* || " ${ID_LIKE:-} " == *' centos '* ]] ||
fail "Automatic Docker installation is not supported on ${ID:-unknown}."
;;
esac
repo_release=$major
if [[ "$ID" == rhel || "$major" == 8 ]]; then
repo_id='rhel'
else
repo_id='centos'
fi
fi
local key_file
key_file=$(mktemp)
fetch "https://download.docker.com/linux/$repo_id/gpg" "$key_file"
verify_key "$RPM_KEY_SHA256" "$key_file"
as_root install -m 0755 -d /etc/pki/rpm-gpg
as_root install -m 0644 "$key_file" /etc/pki/rpm-gpg/RPM-GPG-KEY-milvago-docker
rm -f -- "$key_file"
as_root rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-milvago-docker
if [[ ! -f /etc/yum.repos.d/docker-ce.repo ]]; then
local repo_file
repo_file=$(mktemp)
cat > "$repo_file" <<EOF
[docker-ce-stable]
name=Docker CE Stable
baseurl=https://download.docker.com/linux/$repo_id/$repo_release/\$basearch/stable
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-milvago-docker
EOF
as_root install -m 0644 "$repo_file" /etc/yum.repos.d/docker-ce.repo
rm -f -- "$repo_file"
fi
if (( install_engine )); then
install_packages docker-ce docker-ce-cli containerd.io docker-compose-plugin
else
install_packages docker-compose-plugin
fi
}
compose_supports_reset() {
local probe
probe=$(mktemp -d "${TMPDIR:-/tmp}/milvago-compose.XXXXXXXX") || return 1
cat > "$probe/compose.yaml" <<EOF
services:
probe:
build: .
image: $IMAGE
EOF
cat > "$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/tty; } 2>/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 </dev/tty ||
fail 'Cannot read the URL. Set MILVAGO_PUBLIC_URL explicitly (empty for localhost).'
[[ -z "$public_origin" ]] || public_origin_explicit=1
fi
fi
public_origin=${public_origin:-http://localhost:4020}
public_origin=${public_origin%/}
valid_public_origin "$public_origin" || fail 'Enter an HTTP or HTTPS URL with a host and optional port, without a path, query or fragment.'
}
select_host_binding() {
host_ip=''
case "${public_origin,,}" in
http://localhost:4020|http://127.0.0.1:4020) host_ip=127.0.0.1 ;;
esac
ensure_prerequisites
[[ -n "$host_ip" ]] || host_ip=$(detect_host_ip)
}
reuse_stored_public_origin() {
local confirmed_origin=$1
[[ -n "$confirmed_origin" ]] || return 0
valid_public_origin "$confirmed_origin" || fail 'The stored public URL is invalid.'
if (( public_origin_explicit )); then
[[ "$public_origin" == "$confirmed_origin" ]] ||
fail "The instance already uses $confirmed_origin. Rerun with that URL; change a configured instance URL in Administration > 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" <<EOF
{
admin off
auto_https off
servers {
trusted_proxies static private_ranges
trusted_proxies_strict
}
}
:8080 {
# Keycloak administration, the master realm, its account pages and account linking at
# will: users never operate the identity provider, SSO is configured from the console
# and second factors are managed from the console profile.
# A wildcard inside a path pattern stops at a slash, so the account pages (a single-page
# application and its REST API, any depth) are closed by a pattern instead.
@private_identity path /admin /admin/* /realms/master /realms/master/*
@private_account path_regexp (?i)^/realms/[^/]+/(account|broker/[^/]+/link)
handle @private_identity {
respond 404
}
handle @private_account {
respond 404
}
@identity path /realms /realms/* /resources /resources/* /js /js/*
handle @identity {
reverse_proxy identity:8080 {
header_up -Forwarded
header_up -X-Forwarded-Prefix
}
}
handle {
reverse_proxy application:4020
}
}
EOF
chmod 0644 "$caddyfile"
override="$root/.local/generated/compose.private.yaml"
cat > "$override" <<EOF
services:
database:
volumes:
- ./deploy/postgres-init.sh:/docker-entrypoint-initdb.d/10-milvago.sh:ro,z
mail:
profiles: !override [development-mail]
user: "65532:65532"
cap_drop: [ALL]
identity:
command: [start, --import-realm]
environment:
KC_HTTP_ENABLED: "true"
KC_HOSTNAME_STRICT: "true"
KC_HOSTNAME: $public_origin
KC_PROXY_HEADERS: xforwarded
ports: !override []
volumes:
- ${MILVAGO_REALM_FILE:-./.local/generated/realm.json}:/opt/keycloak/data/import/milvago.json:ro,z
- ./deploy/theme/milvago:/opt/keycloak/themes/milvago:ro,z
application:
build: !reset null
image: $IMAGE
environment:
APP_URL: $public_origin
PUBLIC_URL: $public_origin
OIDC_ISSUER: $public_origin/realms/milvago
MILVAGO_INSTALLER_DIRECTORY: /installers
MILVAGO_UPDATE_PUBLIC_KEY: $UPDATE_PUBLIC_KEY
ports: !override []
volumes:
- ./.local/installers:/installers:ro,z
gateway:
image: $CADDY_IMAGE
user: "65532:65532"
read_only: true
tmpfs:
- /data:uid=65532,gid=65532
- /config:uid=65532,gid=65532
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
ports:
- "$host_ip:4020:8080"
volumes:
- ./.local/generated/Caddyfile.private:/etc/caddy/Caddyfile:ro,z
depends_on:
- identity
- application
EOF
cd "$root"
compose=(compose -f compose.yaml -f "$override")
realm_file=${MILVAGO_REALM_FILE:-$root/.local/generated/realm.json}
if [[ "$realm_file" != /* ]]; then realm_file="$root/${realm_file#./}"; fi
[[ -f "$realm_file" && ! -L "$realm_file" ]] || fail 'The Keycloak realm file is missing or is a symlink.'
# Keycloak runs as uid 1000 with gid 0. The realm contains client secrets, so
# grant its container group read access without making it world-readable.
run_docker run --rm --network none --user 0 \
-v "$realm_file:/run/milvago-realm.json:rw,z" "$NODE_IMAGE" node -e '
const fs = require("node:fs");
const path = "/run/milvago-realm.json";
const { uid } = fs.statSync(path);
fs.chownSync(path, uid, 0);
fs.chmodSync(path, 0o640);
' || fail 'Cannot grant Keycloak read access to realm.json.'
run_docker "${compose[@]}" config --quiet || fail 'The private-image Compose configuration is invalid.'
printf 'Pulling the verified image and starting Community...\n'
# Stop an old development mail catcher without removing its captured messages.
run_docker "${compose[@]}" stop mail || fail 'The development mail service could not be stopped.'
run_docker "${compose[@]}" pull application gateway
run_docker "${compose[@]}" up -d database identity
identity_id=$(run_docker "${compose[@]}" ps -q identity)
[[ -n "$identity_id" ]] || fail 'The identity container did not start.'
printf 'Configuring Keycloak redirects and password recovery for %s...\n' "$public_origin"
run_docker run --rm --network "container:$identity_id" --user "$(id -u):$(id -g)" \
-v "$root/.env:/run/milvago.env:ro,z" -e "MILVAGO_APP_URL=$public_origin" \
"$NODE_IMAGE" node -e '
(async () => {
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 "$@"