#!/bin/sh set -eu PROGRAM="sylve-installer" PROGRAM_VERSION="0.2.0" # We should Flip this to 1 when pkg/port is ready and want this script # to automatically use pkg instead of GitHub release binaries. AUTO_SWITCH_TO_PKG=0 REPO_OWNER="AlchemillaHQ" REPO_NAME="Sylve" API_BASE="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}" BIN_PATH="/usr/local/sbin/sylve" RC_SCRIPT_PATH="/usr/local/etc/rc.d/sylve" CONFIG_PATH="/usr/local/etc/sylve/config.json" DEFAULT_DATA_PATH="/var/db/sylve" YES_MODE=0 FORCE_MODE=0 DATA_PATH="" SUBCOMMAND="install" SUBCOMMAND_SET=0 ARCH="" OS_RELEASE="" RELEASE_TAG="" ASSET_NAME="" ASSET_URL="" ASSET_DIGEST="" GENERATED_ADMIN_PASSWORD="" if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_RESET="$(printf '\033[0m')" C_RED="$(printf '\033[31m')" C_GREEN="$(printf '\033[32m')" C_YELLOW="$(printf '\033[33m')" C_BLUE="$(printf '\033[34m')" else C_RESET="" C_RED="" C_GREEN="" C_YELLOW="" C_BLUE="" fi ascii_art() { cat <<'EOF' | sed "s/@VERSION@/${PROGRAM_VERSION}/" _____ __ / ___/__ __/ / _____ \__ \/ / / / / | / / _ \ ___/ / /_/ / /| |/ / __/ /____/\__, /_/ |___/\___/ /____/ Installer v@VERSION@ EOF } log_info() { printf "%s[INFO]%s %s\n" "$C_BLUE" "$C_RESET" "$*" } log_warn() { printf "%s[WARN]%s %s\n" "$C_YELLOW" "$C_RESET" "$*" >&2 } log_error() { printf "%s[ERROR]%s %s\n" "$C_RED" "$C_RESET" "$*" >&2 } log_ok() { printf "%s[OK]%s %s\n" "$C_GREEN" "$C_RESET" "$*" } die() { log_error "$*" exit 1 } usage() { cat < Data path for install config (default: ${DEFAULT_DATA_PATH}) --force Force update even if tag matches installed metadata -h, --help Show this help message Notes: - AUTO_SWITCH_TO_PKG is currently set to ${AUTO_SWITCH_TO_PKG}. - When AUTO_SWITCH_TO_PKG=1, install/update uses pkg install/upgrade sylve. EOF } have_cmd() { command -v "$1" >/dev/null 2>&1 } require_root() { if [ "$(id -u)" -ne 0 ]; then die "Root privileges are required. Re-run as root/doas/sudo." fi } ask_input_default() { prompt="$1" default_value="$2" if [ "$YES_MODE" -eq 1 ]; then printf '%s\n' "$default_value" return 0 fi if [ ! -r /dev/tty ]; then log_warn "No tty available; using default: ${default_value}" printf '%s\n' "$default_value" return 0 fi printf "%s [%s]: " "$prompt" "$default_value" > /dev/tty IFS= read -r answer < /dev/tty || answer="" if [ -z "$answer" ]; then printf '%s\n' "$default_value" else printf '%s\n' "$answer" fi } ask_yes_no() { question="$1" default_answer="$2" if [ "$YES_MODE" -eq 1 ]; then if [ "$default_answer" = "yes" ]; then log_info "$question -> yes (--yes default)" return 0 fi log_info "$question -> no (--yes default)" return 1 fi if [ ! -r /dev/tty ]; then if [ "$default_answer" = "yes" ]; then log_warn "$question -> yes (no tty, default)" return 0 fi log_warn "$question -> no (no tty, default)" return 1 fi if [ "$default_answer" = "yes" ]; then suffix="[Y/n]" else suffix="[y/N]" fi while :; do printf "%s %s " "$question" "$suffix" > /dev/tty IFS= read -r reply < /dev/tty || reply="" reply_lc=$(printf '%s' "$reply" | tr '[:upper:]' '[:lower:]') case "$reply_lc" in y|yes) return 0 ;; n|no) return 1 ;; "") if [ "$default_answer" = "yes" ]; then return 0 fi return 1 ;; *) printf "Please answer yes or no.\n" > /dev/tty ;; esac done } generate_random_password() { if have_cmd uuidgen; then uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]' return 0 fi if have_cmd openssl; then openssl rand -hex 16 return 0 fi # Fallback (still non-static): timestamp + pid. printf 'sylve-%s-%s' "$(date +%s)" "$$" } validate_platform_and_arch() { os_name="$(uname -s 2>/dev/null || printf 'unknown')" if [ "$os_name" != "FreeBSD" ]; then die "Unsupported OS: $os_name. This script supports FreeBSD only." fi OS_RELEASE="$(uname -r 2>/dev/null || printf '')" os_major="$(printf '%s' "$OS_RELEASE" | awk -F'[.-]' '{print $1}')" os_minor="$(printf '%s' "$OS_RELEASE" | awk -F'[.-]' '{print $2}')" case "$os_major" in ''|*[!0-9]*) die "Unable to parse FreeBSD release from '$OS_RELEASE'" ;; esac case "$os_minor" in ''|*[!0-9]*) os_minor=0 ;; esac if [ "$os_major" -lt 15 ]; then die "FreeBSD >= 15 is required (found: $OS_RELEASE)." fi machine_arch="$(uname -m 2>/dev/null || printf '')" case "$machine_arch" in amd64|x86_64) ARCH="amd64" ;; arm64|aarch64) ARCH="arm64" ;; *) die "Unsupported architecture: ${machine_arch}" ;; esac } http_get() { url="$1" if have_cmd fetch; then fetch -q -o - "$url" return 0 fi if have_cmd curl; then curl -fsSL \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "$url" return 0 fi die "Neither fetch nor curl is available." } download_file() { url="$1" dst="$2" if have_cmd fetch; then fetch -q -o "$dst" "$url" return 0 fi if have_cmd curl; then curl -fsSL -o "$dst" "$url" return 0 fi die "Neither fetch nor curl is available." } sha256_file() { target="$1" if have_cmd sha256; then sha256 -q "$target" return 0 fi if have_cmd shasum; then shasum -a 256 "$target" | awk '{print $1}' return 0 fi if have_cmd openssl; then openssl dgst -sha256 "$target" | awk '{print $2}' return 0 fi die "No SHA-256 tool found." } json_release_tag() { printf '%s' "$1" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 } json_error_message() { printf '%s' "$1" | sed -n 's/.*"message"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 } json_asset_metadata_with_jq() { json_payload="$1" asset_name="$2" printf '%s\n' "$json_payload" | jq -r --arg n "$asset_name" ' .assets[]? | select(.name == $n) | ((.digest // "" | sub("^sha256:"; "")) + "\t" + (.browser_download_url // "")) ' | head -n 1 } json_asset_metadata_plain() { json_payload="$1" asset_name="$2" # Normalize compact JSON into line-oriented tokens so we can parse without jq. normalized_json="$(printf '%s' "$json_payload" | sed 's/[{}]/\n&\n/g; s/,/\n/g')" printf '%s\n' "$normalized_json" | awk -v asset="$asset_name" ' BEGIN { in_assets=0; hit=0; digest=""; url="" } /"assets"[[:space:]]*:[[:space:]]*\[/ { in_assets=1; next } in_assets == 1 { if (hit == 0 && $0 ~ ("\"name\"[[:space:]]*:[[:space:]]*\"" asset "\"")) { hit=1; digest=""; url=""; next } if (hit == 1) { if ($0 ~ /"digest"[[:space:]]*:[[:space:]]*"sha256:/) { line=$0 sub(/.*"digest"[[:space:]]*:[[:space:]]*"sha256:/, "", line) sub(/".*/, "", line) digest=line } if ($0 ~ /"browser_download_url"[[:space:]]*:[[:space:]]*"/) { line=$0 sub(/.*"browser_download_url"[[:space:]]*:[[:space:]]*"/, "", line) sub(/".*/, "", line) url=line } if (digest != "" && url != "") { print digest "\t" url; exit } if ($0 ~ /"name"[[:space:]]*:[[:space:]]*"sylve-/ && $0 !~ ("\"name\"[[:space:]]*:[[:space:]]*\"" asset "\"")) { hit=0; digest=""; url="" } } } ' } resolve_latest_release() { release_url="${API_BASE}/releases/latest" log_info "Resolving latest release metadata..." release_json="$(http_get "$release_url" 2>/dev/null || true)" [ -n "$release_json" ] || die "Failed to fetch release metadata." RELEASE_TAG="$(json_release_tag "$release_json")" if [ -z "$RELEASE_TAG" ]; then api_message="$(json_error_message "$release_json")" if [ -n "$api_message" ]; then die "Failed to parse release tag. GitHub API message: ${api_message}" fi die "Failed to parse release tag from API response." fi ASSET_NAME="sylve-${ARCH}" if have_cmd jq; then asset_meta="$(json_asset_metadata_with_jq "$release_json" "$ASSET_NAME")" else asset_meta="$(json_asset_metadata_plain "$release_json" "$ASSET_NAME")" fi [ -n "$asset_meta" ] || die "Release asset '${ASSET_NAME}' not found in ${RELEASE_TAG}." ASSET_DIGEST="$(printf '%s' "$asset_meta" | awk -F '\t' '{print $1}')" ASSET_URL="$(printf '%s' "$asset_meta" | awk -F '\t' '{print $2}')" [ -n "$ASSET_DIGEST" ] || die "Missing asset digest." [ -n "$ASSET_URL" ] || die "Missing asset URL." } extract_version_tag() { printf '%s\n' "$1" | sed -n 's/.*\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1 } detect_installed_binary_tag() { if [ ! -x "$BIN_PATH" ]; then return 0 fi # Use -h for compatibility with older releases (for example v0.1.x). out="$("$BIN_PATH" -h 2>&1 || true)" tag="$(extract_version_tag "$out")" if [ -n "$tag" ]; then printf '%s\n' "$tag" return 0 fi return 0 } mktemp_file() { if tempf="$(mktemp -t sylve-installer 2>/dev/null)"; then printf '%s\n' "$tempf" return 0 fi mktemp "/tmp/sylve-installer.XXXXXX" } install_binary_from_release() { resolve_latest_release local_tag="$(detect_installed_binary_tag)" if [ -n "$local_tag" ] && [ "$local_tag" = "$RELEASE_TAG" ] && [ "$FORCE_MODE" -eq 0 ] && [ -x "$BIN_PATH" ]; then log_ok "Already at latest tag ${RELEASE_TAG}." return 0 fi log_info "Downloading ${ASSET_NAME} (${RELEASE_TAG})..." tmp_bin="$(mktemp_file)" trap 'rm -f "$tmp_bin"' EXIT INT TERM HUP download_file "$ASSET_URL" "$tmp_bin" || die "Download failed." actual_digest="$(sha256_file "$tmp_bin")" if [ "$actual_digest" != "$ASSET_DIGEST" ]; then die "Digest mismatch. expected=${ASSET_DIGEST} actual=${actual_digest}" fi log_ok "Digest verified." mkdir -p "$(dirname "$BIN_PATH")" if [ -f "$BIN_PATH" ]; then backup_path="${BIN_PATH}.bak.$(date -u '+%Y%m%d%H%M%S')" cp -p "$BIN_PATH" "$backup_path" log_info "Backed up existing binary to ${backup_path}" fi install -m 755 "$tmp_bin" "$BIN_PATH" log_ok "Installed ${BIN_PATH} (${RELEASE_TAG})" rm -f "$tmp_bin" trap - EXIT INT TERM HUP } pkg_install_or_update() { if ! have_cmd pkg; then die "'pkg' command not found." fi if pkg info -e sylve >/dev/null 2>&1; then log_info "Updating pkg-installed sylve..." ASSUME_ALWAYS_YES=yes pkg upgrade -y sylve else log_info "Installing sylve from pkg..." ASSUME_ALWAYS_YES=yes pkg install -y sylve fi log_ok "pkg operation complete." } detect_samba_package() { if ! have_cmd pkg; then printf "samba423\n" return 0 fi installed_samba="$(pkg info 2>/dev/null | awk '/^samba4[0-9][0-9][0-9]*-/ { sub(/-.*/, "", $1); print $1; exit }')" if [ -n "$installed_samba" ]; then printf "%s\n" "$installed_samba" return 0 fi samba_pkg="$(pkg search -q '^samba4[0-9][0-9][0-9]*-' 2>/dev/null | sed 's/-.*//' | sort -r | head -n 1 || true)" if [ -n "$samba_pkg" ]; then printf "%s\n" "$samba_pkg" return 0 fi printf "samba423\n" } maybe_install_recommended_dependencies() { if ! have_cmd pkg; then log_warn "'pkg' command not found; skipping dependency installation." return 0 fi if ask_yes_no "Install recommended dependencies (libvirt, bhyve-firmware, swtpm, qemu-tools, samba4XX, dnsmasq)?" "yes"; then samba_pkg="$(detect_samba_package)" deps="libvirt bhyve-firmware swtpm qemu-tools dnsmasq ${samba_pkg}" log_info "Installing dependencies: ${deps}" ASSUME_ALWAYS_YES=yes pkg install -y $deps log_ok "Recommended dependencies installed." else log_info "Skipped recommended dependency installation." fi } write_default_config() { mkdir -p "$(dirname "$CONFIG_PATH")" if [ -z "$GENERATED_ADMIN_PASSWORD" ]; then GENERATED_ADMIN_PASSWORD="$(generate_random_password)" fi cat > "$CONFIG_PATH" < "$RC_SCRIPT_PATH" <&1) _err=\$? [ \${_err} -eq 0 ] && kill -9 \${pids} fi } run_rc_command "\$1" EOF chmod 755 "$RC_SCRIPT_PATH" log_ok "Installed rc script at ${RC_SCRIPT_PATH}" } set_config_datapath() { [ -f "$CONFIG_PATH" ] || die "Config file not found: ${CONFIG_PATH}" if have_cmd jq; then tmp_json="$(mktemp_file)" jq --arg p "$DATA_PATH" '.dataPath = $p' "$CONFIG_PATH" > "$tmp_json" mv "$tmp_json" "$CONFIG_PATH" return 0 fi if grep -q '"dataPath"[[:space:]]*:' "$CONFIG_PATH"; then escaped_path="$(printf '%s' "$DATA_PATH" | sed 's/[\/&]/\\&/g')" tmp_json="$(mktemp_file)" sed "s/\"dataPath\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"dataPath\": \"${escaped_path}\"/" "$CONFIG_PATH" > "$tmp_json" mv "$tmp_json" "$CONFIG_PATH" return 0 fi tmp_json="$(mktemp_file)" awk -v p="$DATA_PATH" ' BEGIN { inserted = 0 } { if (inserted == 0) { idx = index($0, "{") if (idx > 0) { prefix = substr($0, 1, idx) suffix = substr($0, idx + 1) print prefix print " \"dataPath\": \"" p "\"," if (length(suffix) > 0) print suffix inserted = 1 next } } print } ' "$CONFIG_PATH" > "$tmp_json" mv "$tmp_json" "$CONFIG_PATH" } configure_install_data_path() { if [ -z "$DATA_PATH" ]; then DATA_PATH="$(ask_input_default "Sylve data directory" "$DEFAULT_DATA_PATH")" fi [ -n "$DATA_PATH" ] || die "Data path cannot be empty." if [ -f "$CONFIG_PATH" ]; then log_info "Config exists at ${CONFIG_PATH}; updating only dataPath." set_config_datapath else log_info "Creating config at ${CONFIG_PATH}." write_default_config fi log_ok "Config ready with dataPath='${DATA_PATH}'." } maybe_enable_and_start_sylve() { if [ ! -f "$RC_SCRIPT_PATH" ]; then log_warn "rc script '${RC_SCRIPT_PATH}' not found; skipping service enable/start." return 0 fi if ! have_cmd sysrc; then log_warn "'sysrc' not found; cannot enable sylve in rc.conf automatically." return 0 fi if ! have_cmd service; then log_warn "'service' command not found; cannot start sylve automatically." return 0 fi if ask_yes_no "Enable Sylve service and start it now?" "yes"; then sysrc sylve_enable=YES if service sylve onestatus >/dev/null 2>&1; then if service sylve restart; then log_ok "Sylve service restarted." else log_warn "Failed to restart Sylve service. Please check service logs." fi else if service sylve start; then log_ok "Sylve service started." else log_warn "Failed to start Sylve service. Please check service logs." fi fi else log_info "Skipped enabling/starting Sylve service." fi } get_https_port_from_config() { if [ ! -f "$CONFIG_PATH" ]; then printf "8181\n" return 0 fi port="$(sed -n 's/.*"port"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$CONFIG_PATH" | head -n 1)" if [ -n "$port" ]; then printf "%s\n" "$port" return 0 fi printf "8181\n" } show_web_ui_urls() { if ! have_cmd ifconfig; then return 0 fi https_port="$(get_https_port_from_config)" if [ "$https_port" = "0" ]; then return 0 fi ip_list="$(ifconfig -a 2>/dev/null | awk ' /^[A-Za-z0-9].*:$/ { iface=$1; sub(/:$/, "", iface) } $1 == "inet" { ip=$2 if (ip !~ /^127\./) print ip } $1 == "inet6" { ip=$2 sub(/%.*/, "", ip) if (ip != "::1" && ip !~ /^fe80:/) print "[" ip "]" } ' | sort -u)" if [ -z "$ip_list" ]; then return 0 fi printf "\nAccess Web UI at:\n" printf '%s\n' "$ip_list" | while IFS= read -r ip; do [ -n "$ip" ] || continue printf "https://%s:%s\n" "$ip" "$https_port" done printf "\n" } command_install() { require_root validate_platform_and_arch if [ "$AUTO_SWITCH_TO_PKG" -eq 1 ]; then log_info "AUTO_SWITCH_TO_PKG=1 -> using pkg install path." pkg_install_or_update else install_binary_from_release fi configure_install_data_path maybe_install_recommended_dependencies install_rc_script_if_missing maybe_enable_and_start_sylve if [ -n "$GENERATED_ADMIN_PASSWORD" ]; then printf "\n" log_ok "Generated admin password has been written to ${CONFIG_PATH}" printf "Admin username: admin\n" printf "Admin password: %s\n" "$GENERATED_ADMIN_PASSWORD" printf "\n" fi show_web_ui_urls log_ok "Install completed." } command_update() { require_root validate_platform_and_arch if [ "$AUTO_SWITCH_TO_PKG" -eq 1 ]; then log_info "AUTO_SWITCH_TO_PKG=1 -> using pkg update path." pkg_install_or_update return 0 fi install_binary_from_release log_ok "Update completed." } parse_args() { while [ $# -gt 0 ]; do case "$1" in install|update) if [ "$SUBCOMMAND_SET" -eq 1 ]; then die "Only one subcommand can be used." fi SUBCOMMAND="$1" SUBCOMMAND_SET=1 shift ;; --yes) YES_MODE=1 shift ;; --force) FORCE_MODE=1 shift ;; --data-path=*) DATA_PATH="${1#*=}" shift ;; --data-path) [ $# -ge 2 ] || die "--data-path requires a value." DATA_PATH="$2" shift 2 ;; -h|--help) usage exit 0 ;; *) die "Unknown argument: $1 (run with --help)" ;; esac done } main() { ascii_art parse_args "$@" case "$SUBCOMMAND" in install) command_install ;; update) command_update ;; *) die "Unknown subcommand: ${SUBCOMMAND}" ;; esac } main "$@"