#!/bin/sh # SPDX-License-Identifier: Apache-2.0 # nestipy installer (docs/plan/06-node.md §11, T10.1). # # curl -fsSL https://nestipy.com/install.sh | sh # sh install.sh [--version vX.Y.Z] [--prefix /usr/local] [--dry-run] # # Downloads nestipy__linux_.tar.gz and checksums.txt from GitHub # Releases, verifies the SHA-256 checksum and the release's minisign signature # (when a release key is embedded), then installs /bin/nestipy. It never # installs without a matching checksum, never starts services and never logs # in: the next step is `sudo nestipy auth login`. # # The installer downloads no program besides the release itself. The minisign # signature is checked by this script with openssl (3.x, Ed25519 + BLAKE2b-512), # which the target system normally has already; there is no minisign to install. # The rules are those of internal/node/update/minisign.go (see the block marked # "minisign verification with openssl" below). # # If openssl cannot do that (missing, or too old for Ed25519), the script warns, # skips the signature check and goes on with the checksum check only. # NESTIPY_INSTALL_REQUIRE_SIGNATURE=1 turns that into a hard failure instead # (exit 4); any other value, or unset, keeps the default. Through sudo pass it # explicitly: # sudo env NESTIPY_INSTALL_REQUIRE_SIGNATURE=1 sh install.sh # # Exit codes: 0 success, 1 failure (bad input, download, checksum, privileges), # 3 a required tool is missing (the message names it and the package to install), # 4 a signature check is required (NESTIPY_INSTALL_REQUIRE_SIGNATURE=1) but # openssl with Ed25519 support is not available (the message says how to get it # or to drop the variable). set -eu # Names and paths mirror internal/brand (brand.Name, brand.InstallBinaryPath, # brand.ReleasePublicKey). Distribution is served by our own host, not GitHub, # so no repository path is needed here. NAME=nestipy DEFAULT_PREFIX=/usr/local # Release signing public key (minisign). Must match internal/brand.ReleasePublicKey # and deploy/central/install-central.sh. The secret key is never in this repository. # NESTIPY_INSTALL_RELEASE_PUBKEY replaces the key only for tests and mirrors that re-sign # their releases; unset or empty keeps the key below, so it cannot switch the check off. RELEASE_PUBKEY="${NESTIPY_INSTALL_RELEASE_PUBKEY:-RWRFxpdlK5PEKYXt9XDfHrjXwSkABWCzbLMagPOzsG6OzkMdj28/lG4F}" # Overridable only to point tests at a local mirror; the checksum is still # verified against the downloaded checksums.txt. BASE_URL="${NESTIPY_INSTALL_BASE_URL:-https://dl.nestipy.com}" API_URL="${NESTIPY_INSTALL_API_URL:-https://dl.nestipy.com/latest}" # Only the exact value 1 enforces the signature check when openssl cannot do it; # it never turns the check on for an empty RELEASE_PUBKEY (there is none to check). REQUIRE_SIGNATURE="${NESTIPY_INSTALL_REQUIRE_SIGNATURE:-}" # Exit statuses for a missing prerequisite tool and for a required signature # check that cannot run (see the header). EXIT_MISSING_TOOL=3 EXIT_SIGNATURE_REQUIRED=4 VERSION='' PREFIX="$DEFAULT_PREFIX" DRY_RUN=0 TMP_DIR='' lang_ko() { case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in ko*) return 0 ;; *) return 1 ;; esac } # msg KEY [ARG...] prints a localized line (ko/en) to stdout. msg() { key="$1" shift if lang_ko; then case "$key" in usage) text="사용법: install.sh [--version vX.Y.Z] [--prefix 경로] [--dry-run]" ;; dry_run) text="[dry-run] 아무것도 내려받거나 설치하지 않습니다" ;; platform) text="대상: %s" ;; would_resolve) text="[dry-run] 최신 버전 확인: %s" ;; resolved) text="설치할 버전: %s" ;; would_download) text="[dry-run] 내려받기: %s" ;; would_verify) text="[dry-run] checksums.txt의 SHA-256과 대조 (불일치·누락 시 중단)" ;; would_install) text="[dry-run] 설치: %s" ;; downloading) text="내려받는 중: %s" ;; verified) text="체크섬 확인 완료: %s" ;; sig_verified) text="서명 확인 완료: checksums.txt" ;; sig_skipped) text="Ed25519를 지원하는 openssl이 없어 서명 확인은 건너뜁니다 (체크섬은 확인함)" ;; installed) text="설치 완료: %s" ;; next) text="다음: sudo %s auth login" ;; warn_systemd) text="경고: systemd가 없습니다. 서버 기능은 systemd가 필요합니다" ;; warn_priv) text="경고: root 권한도 sudo도 없습니다. 실제 설치는 실패합니다" ;; warn_tools) text="경고: 필요한 도구가 없습니다: %s. 실제 설치는 실패합니다 (설치: %s)" ;; esac else case "$key" in usage) text="usage: install.sh [--version vX.Y.Z] [--prefix DIR] [--dry-run]" ;; dry_run) text="[dry-run] nothing will be downloaded or installed" ;; platform) text="target: %s" ;; would_resolve) text="[dry-run] resolve the latest version: %s" ;; resolved) text="version to install: %s" ;; would_download) text="[dry-run] download: %s" ;; would_verify) text="[dry-run] verify SHA-256 against checksums.txt (abort on mismatch or missing entry)" ;; would_install) text="[dry-run] install: %s" ;; downloading) text="downloading: %s" ;; verified) text="checksum verified: %s" ;; sig_verified) text="signature verified: checksums.txt" ;; sig_skipped) text="openssl with Ed25519 support not found; skipping signature check (checksum verified)" ;; installed) text="installed: %s" ;; next) text="next: sudo %s auth login" ;; warn_systemd) text="warning: systemd not found; the server agent requires systemd" ;; warn_priv) text="warning: neither root nor sudo; the real install would fail" ;; warn_tools) text="warning: required tools missing: %s; the real install would fail (install: %s)" ;; esac fi # shellcheck disable=SC2059 # the format strings above are fixed printf "$text\n" "$@" } # die KEY [ARG...] prints a localized error to stderr and exits with # DIE_CODE (default 1). die() { key="$1" shift if lang_ko; then case "$key" in bad_arg) text="알 수 없는 인자: %s" ;; need_value) text="%s 에 값이 필요합니다" ;; bad_version) text="버전 형식이 올바르지 않습니다: %s (예: v0.1.0)" ;; bad_prefix) text="--prefix 는 절대 경로여야 합니다: %s" ;; os) text="리눅스만 지원합니다 (현재: %s). macOS는 brew, Windows는 scoop을 사용하세요" ;; arch) text="지원하지 않는 아키텍처입니다: %s (amd64, arm64만 지원)" ;; systemd) text="systemd가 필요합니다" ;; priv) text="root 권한 또는 sudo가 필요합니다" ;; no_fetch) text="curl 또는 wget이 필요합니다" ;; no_tools) text="필요한 도구가 없습니다: %s. 먼저 설치한 뒤 다시 실행하세요 (예: %s)" ;; no_sha) text="sha256sum 또는 shasum이 필요합니다. 체크섬 없이 설치하지 않습니다" ;; download) text="내려받기 실패: %s" ;; latest) text="최신 버전을 확인하지 못했습니다. --version 으로 지정하세요" ;; no_entry) text="checksums.txt에 %s 항목이 없습니다. 설치를 중단합니다" ;; mismatch) text="체크섬 불일치: %s. 설치를 중단합니다" ;; sig) text="checksums.txt 서명 확인 실패. 설치를 중단합니다" ;; sig_required) text="서명 확인이 필수로 지정되었지만(NESTIPY_INSTALL_REQUIRE_SIGNATURE=1) Ed25519를 지원하는 openssl이 없습니다. OpenSSL 3을 설치한 뒤 다시 실행하세요 (예: apt-get install -y openssl / dnf install -y openssl). 체크섬만 확인하고 진행하려면 이 환경변수를 빼세요" ;; no_binary) text="아카이브에 %s 실행파일이 없습니다" ;; esac else case "$key" in bad_arg) text="unknown argument: %s" ;; need_value) text="%s requires a value" ;; bad_version) text="invalid version: %s (example: v0.1.0)" ;; bad_prefix) text="--prefix must be an absolute path: %s" ;; os) text="only Linux is supported (got %s); use brew on macOS or scoop on Windows" ;; arch) text="unsupported architecture: %s (amd64 and arm64 only)" ;; systemd) text="systemd is required" ;; priv) text="root or sudo is required" ;; no_fetch) text="curl or wget is required" ;; no_tools) text="required tools are missing: %s; install them and run this script again (for example: %s)" ;; no_sha) text="sha256sum or shasum is required; refusing to install without a checksum" ;; download) text="download failed: %s" ;; latest) text="could not resolve the latest version; pass --version" ;; no_entry) text="checksums.txt has no entry for %s; aborting" ;; mismatch) text="checksum mismatch for %s; aborting" ;; sig) text="checksums.txt signature verification failed; aborting" ;; sig_required) text="a signature check is required (NESTIPY_INSTALL_REQUIRE_SIGNATURE=1) but openssl with Ed25519 support is not available; install OpenSSL 3 and run this script again (for example: apt-get install -y openssl / dnf install -y openssl). To continue with the checksum check only, unset this variable" ;; no_binary) text="archive does not contain the %s binary" ;; esac fi # shellcheck disable=SC2059 # the format strings above are fixed printf "$NAME: $text\n" "$@" >&2 exit "${DIE_CODE:-1}" } cleanup() { if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then rm -rf "$TMP_DIR" fi } parse_args() { while [ $# -gt 0 ]; do case "$1" in --version) [ $# -ge 2 ] || die need_value "$1" VERSION="$2" shift 2 ;; --version=*) VERSION="${1#--version=}" shift ;; --prefix) [ $# -ge 2 ] || die need_value "$1" PREFIX="$2" shift 2 ;; --prefix=*) PREFIX="${1#--prefix=}" shift ;; --dry-run) DRY_RUN=1 shift ;; -h | --help) msg usage exit 0 ;; *) die bad_arg "$1" ;; esac done if [ -n "$VERSION" ]; then VERSION="$(normalize_version "$VERSION")" fi case "$PREFIX" in /*) ;; *) die bad_prefix "$PREFIX" ;; esac case "$PREFIX" in *[!A-Za-z0-9/_.-]*) die bad_prefix "$PREFIX" ;; esac } # normalize_version accepts X.Y.Z or vX.Y.Z (optional -prerelease) and prints # vX.Y.Z. Anything else is rejected before it reaches a URL or a path. normalize_version() { v="${1#v}" case "$v" in '' | *[!0-9A-Za-z.-]* | .* | -*) die bad_version "$1" ;; esac core="${v%%-*}" case "$core" in *[!0-9.]* | *..* | .* | *.) die bad_version "$1" ;; esac dots="$(printf '%s' "$core" | tr -cd '.')" [ "$dots" = ".." ] || die bad_version "$1" printf 'v%s\n' "$v" } detect_platform() { os="$(uname -s)" [ "$os" = "Linux" ] || die os "$os" machine="$(uname -m)" case "$machine" in x86_64 | amd64) ARCH=amd64 ;; aarch64 | arm64) ARCH=arm64 ;; *) die arch "$machine" ;; esac } has_systemd() { [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1 } # pkg_for TOOL prints the package that provides TOOL on common distributions. pkg_for() { case "$1" in awk) echo gawk ;; mktemp | install) echo coreutils ;; curl) echo curl ;; *) echo "$1" ;; esac } # check_tools sets MISSING_TOOLS (space-separated tool names) and MISSING_HINT # (an install command for the usual package managers) for every tool the real # install needs besides the checksum tool, which has its own message. Without # this, a missing tar surfaced as a misleading "archive does not contain ...". check_tools() { MISSING_TOOLS='' pkgs='' for tool in tar gzip awk mktemp install; do command -v "$tool" >/dev/null 2>&1 && continue MISSING_TOOLS="$MISSING_TOOLS $tool" pkg="$(pkg_for "$tool")" case " $pkgs " in *" $pkg "*) ;; *) pkgs="$pkgs $pkg" ;; esac done if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then MISSING_TOOLS="$MISSING_TOOLS curl" pkgs="$pkgs curl" fi MISSING_TOOLS="${MISSING_TOOLS# }" pkgs="${pkgs# }" MISSING_HINT="apt-get install -y $pkgs / dnf install -y $pkgs" [ -z "$MISSING_TOOLS" ] } # SUDO is empty when running as root or when the target directory is already # writable; otherwise it is "sudo". detect_privilege() { SUDO='' if [ "$(id -u)" -eq 0 ]; then return 0 fi dir="$PREFIX/bin" if [ -d "$dir" ] && [ -w "$dir" ]; then return 0 fi if command -v sudo >/dev/null 2>&1; then SUDO=sudo return 0 fi return 1 } fetch() { url="$1" out="$2" if command -v curl >/dev/null 2>&1; then curl --proto '=https,http' -fsSL -o "$out" "$url" || die download "$url" elif command -v wget >/dev/null 2>&1; then wget -q -O "$out" "$url" || die download "$url" else die no_fetch fi } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1 elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d ' ' -f 1 else die no_sha fi } # resolve_latest prints the newest version. The distribution host serves a plain # text file (one version per line); a GitHub-style {"tag_name": "…"} JSON payload # is still accepted so the installer also works against a GitHub mirror. resolve_latest() { fetch "$API_URL" "$TMP_DIR/latest.txt" tag="$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMP_DIR/latest.txt" | head -n 1)" [ -n "$tag" ] || tag="$(sed -n '1s/[[:space:]]//g;p' "$TMP_DIR/latest.txt")" [ -n "$tag" ] || die latest normalize_version "$tag" } # verify_checksum FILE NAME: the checksums.txt line for NAME must exist and # match FILE. A missing checksums.txt already failed in fetch. verify_checksum() { file="$1" name="$2" want="$(awk -v n="$name" '$2 == n || $2 == "*" n { print $1; exit }' "$TMP_DIR/checksums.txt")" [ -n "$want" ] || die no_entry "$name" got="$(sha256_of "$file")" [ "$got" = "$want" ] || die mismatch "$name" msg verified "$name" } # --- BEGIN minisign verification with openssl (identical in deploy/install.sh and deploy/central/install-central.sh; a test compares them) --- # No external program is downloaded: the signature is checked here with openssl # (3.x, which verifies Ed25519 with `pkeyutl -rawin`) and a few POSIX tools. The # rules mirror VerifyMinisign in internal/node/update/minisign.go, which is the # reference; change both together (test/install differential tests compare them). # # minisign_openssl_usable WORKDIR 0 when openssl can verify Ed25519 and BLAKE2b-512 # minisign_openssl_verify KEY MSG SIG WORKDIR 0 only when SIG is a valid minisign signature of MSG # # KEY is the base64 public key payload ("Ed" + 8-byte key id + 32-byte Ed25519 # key). SIG is a minisign file: an "Ed" (legacy: signs the message) or "ED" # (prehashed: signs BLAKE2b-512 of the message) signature line and, when a # trusted comment is present, the global signature over signature||comment. # Both functions are called in `if`/`||` context, so every step checks itself. # ms_hex FILE prints the bytes of FILE as lowercase hex. ms_hex() { od -An -v -tx1 "$1" | tr -d ' \n' } # ms_cut FILE SKIP COUNT OUT writes COUNT bytes of FILE, from byte SKIP, to OUT. ms_cut() { dd if="$1" of="$4" bs=1 skip="$2" count="$3" 2>/dev/null } # ms_size FILE prints the size of FILE in bytes. ms_size() { size="$(wc -c <"$1")" || return 1 echo $((size)) } # ms_trim prints stdin without leading and trailing white space. ms_trim() { sed 's/^[[:space:]]*//;s/[[:space:]]*$//' } # ms_b64 LINE OUT decodes a strict base64 line (alphabet, padding only at the # end, length a multiple of 4) into OUT. ms_b64() { case "$1" in '' | *[!A-Za-z0-9+/=]* | *=*[!=]*) return 1 ;; esac [ $((${#1} % 4)) -eq 0 ] || return 1 printf '%s' "$1" | openssl base64 -d -A >"$2" 2>/dev/null } # ms_ed25519 DER MSG SIG verifies an Ed25519 signature (SPKI public key in DER). ms_ed25519() { openssl pkeyutl -verify -pubin -inkey "$1" -keyform DER -rawin -in "$2" -sigfile "$3" >/dev/null 2>&1 } # ms_spki KEY32 OUT wraps a raw 32-byte Ed25519 public key in a DER SubjectPublicKeyInfo. ms_spki() { { # 30 2a 30 05 06 03 2b 65 70 03 21 00 printf '\060\052\060\005\006\003\053\145\160\003\041\000' cat "$1" } >"$2" } minisign_openssl_usable() { w="$1" for tool in openssl od dd wc tr sed; do command -v "$tool" >/dev/null 2>&1 || return 1 done # Known answers (RFC 8032 test 2, RFC 7693 appendix A): the tool must really # verify Ed25519 with these options and compute BLAKE2b-512. printf '%s' 'PUAXw+hDiVqStwqnTRt+vJyYLM8uxJaMwM1V8Sr0Zgw=' | openssl base64 -d -A >"$w/kat.key" 2>/dev/null || return 1 printf '%s' 'kqAJqfDUyrhyDoILX2QlQKKye1QWUD+Ps3YiI+vbadoIWsHkPhWZbkWPNhPQ8R2MOHsurrQwKu6wDSkWErsMAA==' | openssl base64 -d -A >"$w/kat.sig" 2>/dev/null || return 1 printf 'r' >"$w/kat.msg" ms_spki "$w/kat.key" "$w/kat.der" ms_ed25519 "$w/kat.der" "$w/kat.msg" "$w/kat.sig" || return 1 printf 'abc' >"$w/kat.abc" openssl dgst -blake2b512 -binary "$w/kat.abc" >"$w/kat.hash" 2>/dev/null || return 1 [ "$(ms_hex "$w/kat.hash")" = ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d17d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923 ] } minisign_openssl_verify() { key="$1" message="$2" sigfile="$3" w="$4" # Public key payload: "Ed" + key id (8) + Ed25519 key (32). ms_b64 "$key" "$w/pub.bin" || return 1 [ "$(ms_size "$w/pub.bin")" -eq 42 ] || return 1 ms_cut "$w/pub.bin" 0 2 "$w/pub.algo" [ "$(ms_hex "$w/pub.algo")" = 4564 ] || return 1 ms_cut "$w/pub.bin" 2 8 "$w/pub.keyid" ms_cut "$w/pub.bin" 10 32 "$w/pub.key" ms_spki "$w/pub.key" "$w/pub.der" # Signature file: payload lines are the lines that are not comments; the # trusted comment is the text after its label, trimmed. npay=0 pay1='' pay2='' tc='' while IFS= read -r line || [ -n "$line" ]; do line="$(printf '%s' "$line" | ms_trim)" case "$line" in '' | 'untrusted comment:'*) ;; 'trusted comment:'*) tc="$(printf '%s' "${line#trusted comment:}" | ms_trim)" ;; *) npay=$((npay + 1)) case "$npay" in 1) pay1="$line" ;; 2) pay2="$line" ;; esac ;; esac done <"$sigfile" # One payload (no comment) or two (trusted comment + global signature). [ "$npay" -ge 1 ] && [ "$npay" -le 2 ] || return 1 if [ -n "$tc" ]; then [ "$npay" -eq 2 ] || return 1 else [ "$npay" -eq 1 ] || return 1 fi # Signature payload: algorithm (2) + key id (8) + Ed25519 signature (64). ms_b64 "$pay1" "$w/sig.bin" || return 1 [ "$(ms_size "$w/sig.bin")" -eq 74 ] || return 1 ms_cut "$w/sig.bin" 0 2 "$w/sig.algo" ms_cut "$w/sig.bin" 2 8 "$w/sig.keyid" ms_cut "$w/sig.bin" 10 64 "$w/sig.raw" [ "$(ms_hex "$w/sig.keyid")" = "$(ms_hex "$w/pub.keyid")" ] || return 1 case "$(ms_hex "$w/sig.algo")" in 4564) ms_ed25519 "$w/pub.der" "$message" "$w/sig.raw" || return 1 ;; # "Ed": the message itself 4544) # "ED": BLAKE2b-512 of the message openssl dgst -blake2b512 -binary "$message" >"$w/prehash" 2>/dev/null || return 1 ms_ed25519 "$w/pub.der" "$w/prehash" "$w/sig.raw" || return 1 ;; *) return 1 ;; esac if [ "$npay" -eq 2 ]; then # Global signature: over the 64-byte message signature followed by the # trusted comment text. ms_b64 "$pay2" "$w/global.sig" || return 1 [ "$(ms_size "$w/global.sig")" -eq 64 ] || return 1 { cat "$w/sig.raw" printf '%s' "$tc" } >"$w/global.msg" ms_ed25519 "$w/pub.der" "$w/global.msg" "$w/global.sig" || return 1 fi return 0 } # --- END minisign verification with openssl --- verify_signature() { if [ -z "$RELEASE_PUBKEY" ]; then return 0 fi mkdir "$TMP_DIR/sig" if ! minisign_openssl_usable "$TMP_DIR/sig"; then if [ "$REQUIRE_SIGNATURE" = "1" ]; then DIE_CODE=$EXIT_SIGNATURE_REQUIRED die sig_required fi msg sig_skipped return 0 fi fetch "$BASE_URL/$VERSION/checksums.txt.minisig" "$TMP_DIR/checksums.txt.minisig" minisign_openssl_verify "$RELEASE_PUBKEY" "$TMP_DIR/checksums.txt" "$TMP_DIR/checksums.txt.minisig" "$TMP_DIR/sig" || die sig msg sig_verified } main() { parse_args "$@" detect_platform target="$PREFIX/bin/$NAME" if [ "$DRY_RUN" -eq 1 ]; then msg dry_run msg platform "linux/$ARCH" has_systemd || msg warn_systemd detect_privilege || msg warn_priv check_tools || msg warn_tools "$MISSING_TOOLS" "$MISSING_HINT" if [ -z "$VERSION" ]; then msg would_resolve "$API_URL" VERSION='' fi version_num="${VERSION#v}" archive="${NAME}_${version_num}_linux_${ARCH}.tar.gz" msg would_download "$BASE_URL/$VERSION/$archive" msg would_download "$BASE_URL/$VERSION/checksums.txt" msg would_verify msg would_install "$target" msg next "$NAME" return 0 fi if ! check_tools; then DIE_CODE=$EXIT_MISSING_TOOL die no_tools "$MISSING_TOOLS" "$MISSING_HINT" fi has_systemd || die systemd detect_privilege || die priv command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || die no_sha TMP_DIR="$(mktemp -d)" trap cleanup EXIT trap 'cleanup; exit 130' INT TERM if [ -z "$VERSION" ]; then VERSION="$(resolve_latest)" fi msg resolved "$VERSION" msg platform "linux/$ARCH" version_num="${VERSION#v}" archive="${NAME}_${version_num}_linux_${ARCH}.tar.gz" msg downloading "$archive" fetch "$BASE_URL/$VERSION/checksums.txt" "$TMP_DIR/checksums.txt" verify_signature fetch "$BASE_URL/$VERSION/$archive" "$TMP_DIR/$archive" verify_checksum "$TMP_DIR/$archive" "$archive" mkdir "$TMP_DIR/extract" tar -xzf "$TMP_DIR/$archive" -C "$TMP_DIR/extract" "$NAME" 2>/dev/null || die no_binary "$NAME" if [ ! -f "$TMP_DIR/extract/$NAME" ] || [ -L "$TMP_DIR/extract/$NAME" ]; then die no_binary "$NAME" fi $SUDO mkdir -p "$PREFIX/bin" $SUDO install -m 0755 "$TMP_DIR/extract/$NAME" "$target" msg installed "$target" msg next "$NAME" } main "$@"