#!/usr/bin/env bash # SPDX-License-Identifier: Apache-2.0 # nestipy central server installer (T10.2, docs/ops/central-deploy.md). # # sudo bash install-central.sh --prepare-only # sudo bash install-central.sh --version vX.Y.Z [options] # bash install-central.sh --dry-run [--version vX.Y.Z] [options] # # Idempotent: re-running with the same inputs changes nothing and does not # restart a healthy service. It downloads nestipy-central__linux_.tar.gz # and checksums.txt from GitHub Releases, verifies the SHA-256 checksum (and the # minisign signature when a release key is embedded), installs the binary to # /usr/local/bin, keeps the previous binary for rollback, installs the systemd # unit that sits next to this script, starts the service and waits for /healthz. # # Secrets are never accepted as arguments: only file paths are. The script never # prints a secret file's content; it only checks owner, mode, emptiness and (for # the session key) the length. It does not create the database, run # migrations, configure Caddy, DNS or the firewall (see the runbook). # # 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 Ubuntu 24.04 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; it is the same as in deploy/install.sh). # # If openssl cannot do that, the script warns, skips the signature check and # goes on with the checksum check only. # NESTIPY_CENTRAL_INSTALL_REQUIRE_SIGNATURE=1 turns that into a hard failure # instead (exit 4); any other value, or unset, keeps the default. sudo drops the # variable unless it is passed explicitly: # sudo env NESTIPY_CENTRAL_INSTALL_REQUIRE_SIGNATURE=1 bash install-central.sh … # # Exit codes: 0 success, 1 failure, 3 a required tool is missing, 4 a signature # check is required (NESTIPY_CENTRAL_INSTALL_REQUIRE_SIGNATURE=1) but openssl with # Ed25519 support is not available. set -euo pipefail # Names and paths mirror internal/brand (brand.CentralName, brand.ConsoleDomain, # brand.RelayDomain, brand.Domain). Distribution is served by our own host, not # GitHub, so no repository path is needed here. NAME=nestipy-central SERVICE_USER=nestipy-central CONFIG_DIR=/etc/nestipy-central STATE_DIR=/var/lib/nestipy-central BIN_PATH=/usr/local/bin/nestipy-central PREV_DIR=/usr/local/lib/nestipy-central PREV_PATH="$PREV_DIR/nestipy-central.prev" ENV_FILE="$CONFIG_DIR/central.env" UNIT_DST=/etc/systemd/system/nestipy-central.service # Release signing public key (minisign). Must match internal/brand.ReleasePublicKey # and deploy/install.sh. The secret key is never in this repository. # NESTIPY_CENTRAL_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_CENTRAL_INSTALL_RELEASE_PUBKEY:-RWRFxpdlK5PEKYXt9XDfHrjXwSkABWCzbLMagPOzsG6OzkMdj28/lG4F}" # Overridable only to point tests at a local mirror; the checksum is still verified. BASE_URL="${NESTIPY_CENTRAL_INSTALL_BASE_URL:-https://dl.nestipy.com}" API_URL="${NESTIPY_CENTRAL_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_CENTRAL_INSTALL_REQUIRE_SIGNATURE:-}" EXIT_MISSING_TOOL=3 EXIT_SIGNATURE_REQUIRED=4 HEALTHZ_TRIES=30 SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" VERSION='' DB_URL_FILE="$CONFIG_DIR/db_url" SESSION_KEY_FILE="$CONFIG_DIR/session_key" RESEND_KEY_FILE='' GITHUB_CLIENT_ID='' GITHUB_SECRET_FILE='' GOOGLE_CLIENT_ID='' GOOGLE_SECRET_FILE='' LISTEN=127.0.0.1:8080 PUBLIC_CONSOLE=https://console.nestipy.com PUBLIC_RELAY=wss://relay.nestipy.com MAIL_FROM='nestipy ' UNIT_SRC="$SCRIPT_DIR/nestipy-central.service" DRY_RUN=0 PREPARE_ONLY=0 TMP_DIR='' ARCH='' CHANGED=0 WARNINGS=0 lang_ko() { case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in ko*) return 0 ;; *) return 1 ;; esac } # text KEY sets TEXT to the localized (ko/en) format string for KEY. text() { if lang_ko; then case "$1" in usage) TEXT="사용법: install-central.sh [--version vX.Y.Z] [--db-url-file 경로] [--session-key-file 경로] [--resend-key-file 경로] [--github-client-id ID --github-secret-file 경로] [--google-client-id ID --google-secret-file 경로] [--listen 주소:포트] [--public-console https://…] [--public-relay wss://…] [--mail-from 텍스트] [--unit-file 경로] [--prepare-only] [--dry-run]" ;; dry_run) TEXT="[dry-run] 실제 변경 없이 계획만 출력합니다 (내려받기·설치·서비스 변경 없음)" ;; plan) TEXT="[dry-run] %s" ;; step) TEXT="==> %s" ;; ok) TEXT=" 확인: %s" ;; warn) TEXT="경고: %s" ;; target) TEXT="대상: linux/%s, 버전 %s" ;; s_preflight) TEXT="사전 점검" ;; s_account) TEXT="서비스 계정 %s" ;; s_dirs) TEXT="디렉터리·권한" ;; s_secrets) TEXT="비밀 파일 점검 (내용은 출력하지 않음)" ;; s_download) TEXT="릴리스 내려받기·검증" ;; s_binary) TEXT="실행 파일 설치 %s" ;; s_env) TEXT="설정 파일 %s" ;; s_unit) TEXT="systemd 유닛 %s" ;; s_start) TEXT="서비스 기동" ;; s_health) TEXT="healthz 확인 %s" ;; p_account_create) TEXT="시스템 계정 생성: useradd --system --user-group --home-dir %s --no-create-home --shell /usr/sbin/nologin %s" ;; p_account_keep) TEXT="시스템 계정 유지: %s (이미 있음)" ;; p_dir) TEXT="디렉터리 보장: %s (%s %s)" ;; p_secret) TEXT="비밀 파일 점검: %s (root:%s, 0640 이하, 비어 있지 않음)" ;; p_session_len) TEXT="session key 길이 점검: 앞뒤 공백 제거 후 정확히 32자" ;; p_resolve) TEXT="최신 릴리스 확인: %s" ;; p_download) TEXT="내려받기: %s" ;; p_verify) TEXT="checksums.txt의 SHA-256과 대조 (불일치·누락 시 중단), 공개 열쇠가 있으면 minisign 서명 확인" ;; p_prev) TEXT="기존 실행 파일이 다르면 롤백용으로 보관: %s" ;; p_install) TEXT="설치: %s (0755 root:root)" ;; p_env_write) TEXT="설정 파일이 없으면 작성: %s (0640 root:%s, 비밀값 없음, 경로만)" ;; p_unit) TEXT="유닛 설치: %s → %s (다를 때만), systemctl daemon-reload" ;; p_start) TEXT="systemctl enable %s, 변경이 있거나 멈춰 있으면 (re)start" ;; p_health) TEXT="GET %s 이 200 될 때까지 최대 %s초 대기" ;; env_line) TEXT=" %s" ;; keep_binary) TEXT="실행 파일이 같아 교체하지 않습니다: %s" ;; saved_prev) TEXT="이전 실행 파일 보관: %s" ;; installed) TEXT="설치 완료: %s" ;; env_written) TEXT="설정 파일 작성: %s" ;; env_kept) TEXT="기존 설정 파일 유지: %s (이번 인자와 다릅니다. 필요하면 직접 고친 뒤 다시 실행하세요)" ;; env_same) TEXT="설정 파일 그대로: %s" ;; unit_same) TEXT="유닛 그대로: %s" ;; unit_installed) TEXT="유닛 설치: %s" ;; account_created) TEXT="시스템 계정 생성: %s" ;; account_exists) TEXT="시스템 계정 있음: %s" ;; started) TEXT="서비스 (재)기동: %s" ;; unchanged) TEXT="변경 없음, 서비스 재기동 생략: %s" ;; healthy) TEXT="healthz 200: %s" ;; verified) TEXT="체크섬 확인 완료: %s" ;; sig_verified) TEXT="서명 확인 완료: checksums.txt" ;; sig_skipped) TEXT="Ed25519를 지원하는 openssl이 없어 서명 확인은 건너뜁니다 (체크섬은 확인함)" ;; prepared) TEXT="준비 완료. 다음: 비밀 파일 배치·DB 마이그레이션 후 --version 을 붙여 다시 실행 (docs/ops/central-deploy.md §4~§5)" ;; finished) TEXT="완료: %s %s. 다음: docs/ops/central-deploy.md §8 운영 검증" ;; w_root) TEXT="root가 아닙니다. 실제 설치는 sudo로 실행해야 합니다" ;; w_os) TEXT="Ubuntu 24.04 이상이 아닙니다 (%s). 실제 설치는 중단됩니다" ;; w_systemd) TEXT="systemd가 없습니다. 실제 설치는 중단됩니다" ;; w_tools) TEXT="필요한 도구가 없습니다: %s. 실제 설치는 중단됩니다" ;; w_port) TEXT="포트 %s 를 다른 프로세스가 쓰고 있습니다. 실제 설치는 중단됩니다" ;; w_pg) TEXT="postgresql 서비스가 active가 아닙니다. healthz가 503이 됩니다 (런북 §3)" ;; w_secret) TEXT="%s" ;; w_unit_src) TEXT="유닛 원본이 없습니다: %s. 실제 설치는 중단됩니다" ;; w_no_systemctl) TEXT="systemctl이 없어 서비스 상태를 확인하지 않았습니다" ;; esac else case "$1" in usage) TEXT="usage: install-central.sh [--version vX.Y.Z] [--db-url-file PATH] [--session-key-file PATH] [--resend-key-file PATH] [--github-client-id ID --github-secret-file PATH] [--google-client-id ID --google-secret-file PATH] [--listen HOST:PORT] [--public-console https://…] [--public-relay wss://…] [--mail-from TEXT] [--unit-file PATH] [--prepare-only] [--dry-run]" ;; dry_run) TEXT="[dry-run] printing the plan only (no download, no install, no service change)" ;; plan) TEXT="[dry-run] %s" ;; step) TEXT="==> %s" ;; ok) TEXT=" ok: %s" ;; warn) TEXT="warning: %s" ;; target) TEXT="target: linux/%s, version %s" ;; s_preflight) TEXT="preflight" ;; s_account) TEXT="service account %s" ;; s_dirs) TEXT="directories and permissions" ;; s_secrets) TEXT="secret files (contents are never printed)" ;; s_download) TEXT="download and verify the release" ;; s_binary) TEXT="install the binary %s" ;; s_env) TEXT="settings file %s" ;; s_unit) TEXT="systemd unit %s" ;; s_start) TEXT="start the service" ;; s_health) TEXT="healthz %s" ;; p_account_create) TEXT="create the system account: useradd --system --user-group --home-dir %s --no-create-home --shell /usr/sbin/nologin %s" ;; p_account_keep) TEXT="keep the system account: %s (exists)" ;; p_dir) TEXT="ensure directory: %s (%s %s)" ;; p_secret) TEXT="check secret file: %s (root:%s, mode 0640 or stricter, not empty)" ;; p_session_len) TEXT="check session key length: exactly 32 characters after trimming" ;; p_resolve) TEXT="resolve the latest release: %s" ;; p_download) TEXT="download: %s" ;; p_verify) TEXT="verify SHA-256 against checksums.txt (abort on mismatch or missing entry), and the minisign signature when a release key is embedded" ;; p_prev) TEXT="keep a differing existing binary for rollback: %s" ;; p_install) TEXT="install: %s (0755 root:root)" ;; p_env_write) TEXT="write the settings file when absent: %s (0640 root:%s, no secrets, paths only)" ;; p_unit) TEXT="install the unit: %s -> %s (only when different), systemctl daemon-reload" ;; p_start) TEXT="systemctl enable %s, (re)start when something changed or it is stopped" ;; p_health) TEXT="wait for GET %s to return 200 (up to %s seconds)" ;; env_line) TEXT=" %s" ;; keep_binary) TEXT="binary unchanged, not replaced: %s" ;; saved_prev) TEXT="previous binary kept: %s" ;; installed) TEXT="installed: %s" ;; env_written) TEXT="settings file written: %s" ;; env_kept) TEXT="existing settings file kept: %s (differs from these arguments; edit it yourself and re-run if needed)" ;; env_same) TEXT="settings file unchanged: %s" ;; unit_same) TEXT="unit unchanged: %s" ;; unit_installed) TEXT="unit installed: %s" ;; account_created) TEXT="system account created: %s" ;; account_exists) TEXT="system account exists: %s" ;; started) TEXT="service (re)started: %s" ;; unchanged) TEXT="nothing changed, restart skipped: %s" ;; healthy) TEXT="healthz 200: %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)" ;; prepared) TEXT="prepared. Next: place the secret files and migrate the database, then re-run with --version (docs/ops/central-deploy.md §4-§5)" ;; finished) TEXT="done: %s %s. Next: docs/ops/central-deploy.md §8 production checks" ;; w_root) TEXT="not root; the real install must run with sudo" ;; w_os) TEXT="not Ubuntu 24.04 or later (%s); the real install would stop" ;; w_systemd) TEXT="systemd not found; the real install would stop" ;; w_tools) TEXT="required tools are missing: %s; the real install would stop" ;; w_port) TEXT="port %s is used by another process; the real install would stop" ;; w_pg) TEXT="the postgresql service is not active; healthz will return 503 (runbook §3)" ;; w_secret) TEXT="%s" ;; w_unit_src) TEXT="unit source not found: %s; the real install would stop" ;; w_no_systemctl) TEXT="systemctl not found; service state not inspected" ;; esac fi } # msg KEY [ARG...] prints a localized line to stdout. msg() { text "$1" shift # shellcheck disable=SC2059 # the format strings above are fixed printf "$TEXT\n" "$@" } # plan KEY [ARG...] prints a localized dry-run plan line. plan() { text "$1" shift local line # shellcheck disable=SC2059 # the format strings above are fixed line="$(printf "$TEXT" "$@")" msg plan "$line" } # step KEY [ARG...] prints a localized step heading. step() { text "$1" shift local line # shellcheck disable=SC2059 # the format strings above are fixed line="$(printf "$TEXT" "$@")" msg step "$line" } # warn KEY [ARG...] prints a localized warning to stderr. warn() { text "$1" shift local line # shellcheck disable=SC2059 # the format strings above are fixed line="$(printf "$TEXT" "$@")" WARNINGS=$((WARNINGS + 1)) msg warn "$line" >&2 } # die KEY [ARG...] prints a localized error to stderr and exits with DIE_CODE # (default 1). die() { local 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_path) TEXT="%s 는 절대 경로여야 하며 영문·숫자·/_.- 만 쓸 수 있습니다: %s" ;; bad_value) TEXT="%s 값이 올바르지 않습니다: %s" ;; pair) TEXT="%s 와 %s 는 함께 지정해야 합니다" ;; https) TEXT="--public-console 은 https:// 로 시작해야 합니다: %s" ;; wss) TEXT="--public-relay 는 wss:// 로 시작해야 합니다: %s" ;; root) TEXT="root 권한이 필요합니다 (sudo bash install-central.sh …)" ;; os) TEXT="Ubuntu 24.04 이상만 지원합니다 (현재: %s)" ;; arch) TEXT="지원하지 않는 아키텍처입니다: %s (amd64, arm64만 지원)" ;; systemd) TEXT="systemd가 필요합니다" ;; no_tools) TEXT="필요한 도구가 없습니다: %s. 먼저 설치하세요 (예: apt-get install -y curl tar gzip coreutils iproute2 passwd)" ;; port) TEXT="포트 %s 를 다른 프로세스가 쓰고 있습니다 (ss -ltnp 로 확인)" ;; unit_src) TEXT="유닛 원본이 없습니다: %s (--unit-file 로 지정)" ;; secret_missing) TEXT="비밀 파일이 없습니다: %s (런북 §4 참고)" ;; secret_type) TEXT="비밀 파일이 일반 파일이 아니거나 심볼릭 링크입니다: %s" ;; secret_owner) TEXT="비밀 파일 소유가 root:%s 가 아닙니다: %s (현재 %s)" ;; secret_mode) TEXT="비밀 파일 권한이 너무 넓습니다: %s (현재 %s, 0640 이하로)" ;; secret_empty) TEXT="비밀 파일이 비어 있습니다: %s" ;; session_len) TEXT="session key 는 앞뒤 공백 제거 후 정확히 32자여야 합니다: %s (현재 %s자)" ;; download) TEXT="내려받기 실패: %s" ;; latest) TEXT="최신 버전을 확인하지 못했습니다. --version 으로 지정하세요" ;; no_entry) TEXT="checksums.txt에 %s 항목이 없습니다. 설치를 중단합니다" ;; mismatch) TEXT="체크섬 불일치: %s. 설치를 중단합니다" ;; sig) TEXT="checksums.txt 서명 확인 실패. 설치를 중단합니다" ;; sig_required) TEXT="서명 확인이 필수로 지정되었지만(NESTIPY_CENTRAL_INSTALL_REQUIRE_SIGNATURE=1) Ed25519를 지원하는 openssl이 없습니다. OpenSSL 3을 설치한 뒤 다시 실행하세요 (예: apt-get install -y openssl). 체크섬만 확인하고 진행하려면 이 환경변수를 빼세요" ;; no_binary) TEXT="아카이브에 %s 실행파일이 없습니다" ;; unhealthy) TEXT="healthz가 200이 아닙니다: %s. 'journalctl -u %s -n 100' 로 원인을 확인하세요" ;; 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_path) TEXT="%s must be an absolute path of letters, digits and /_.- only: %s" ;; bad_value) TEXT="invalid %s value: %s" ;; pair) TEXT="%s and %s must be given together" ;; https) TEXT="--public-console must start with https://: %s" ;; wss) TEXT="--public-relay must start with wss://: %s" ;; root) TEXT="root is required (sudo bash install-central.sh …)" ;; os) TEXT="only Ubuntu 24.04 or later is supported (got %s)" ;; arch) TEXT="unsupported architecture: %s (amd64 and arm64 only)" ;; systemd) TEXT="systemd is required" ;; no_tools) TEXT="required tools are missing: %s; install them first (for example: apt-get install -y curl tar gzip coreutils iproute2 passwd)" ;; port) TEXT="port %s is used by another process (check with ss -ltnp)" ;; unit_src) TEXT="unit source not found: %s (pass --unit-file)" ;; secret_missing) TEXT="secret file not found: %s (see runbook §4)" ;; secret_type) TEXT="secret file is not a regular file or is a symlink: %s" ;; secret_owner) TEXT="secret file is not owned by root:%s: %s (now %s)" ;; secret_mode) TEXT="secret file mode is too open: %s (now %s; use 0640 or stricter)" ;; secret_empty) TEXT="secret file is empty: %s" ;; session_len) TEXT="the session key must be exactly 32 characters after trimming: %s (now %s)" ;; 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_CENTRAL_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). To continue with the checksum check only, unset this variable" ;; no_binary) TEXT="archive does not contain the %s binary" ;; unhealthy) TEXT="healthz did not return 200: %s; check 'journalctl -u %s -n 100'" ;; 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 } need_value() { [[ $# -ge 2 ]] || die need_value "$1" } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --version) need_value "$@"; VERSION="$2"; shift 2 ;; --db-url-file) need_value "$@"; DB_URL_FILE="$2"; shift 2 ;; --session-key-file) need_value "$@"; SESSION_KEY_FILE="$2"; shift 2 ;; --resend-key-file) need_value "$@"; RESEND_KEY_FILE="$2"; shift 2 ;; --github-client-id) need_value "$@"; GITHUB_CLIENT_ID="$2"; shift 2 ;; --github-secret-file) need_value "$@"; GITHUB_SECRET_FILE="$2"; shift 2 ;; --google-client-id) need_value "$@"; GOOGLE_CLIENT_ID="$2"; shift 2 ;; --google-secret-file) need_value "$@"; GOOGLE_SECRET_FILE="$2"; shift 2 ;; --listen) need_value "$@"; LISTEN="$2"; shift 2 ;; --public-console) need_value "$@"; PUBLIC_CONSOLE="$2"; shift 2 ;; --public-relay) need_value "$@"; PUBLIC_RELAY="$2"; shift 2 ;; --mail-from) need_value "$@"; MAIL_FROM="$2"; shift 2 ;; --unit-file) need_value "$@"; UNIT_SRC="$2"; shift 2 ;; --prepare-only) PREPARE_ONLY=1; 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 check_path --db-url-file "$DB_URL_FILE" check_path --session-key-file "$SESSION_KEY_FILE" check_path --unit-file "$UNIT_SRC" [[ -z "$RESEND_KEY_FILE" ]] || check_path --resend-key-file "$RESEND_KEY_FILE" [[ -z "$GITHUB_SECRET_FILE" ]] || check_path --github-secret-file "$GITHUB_SECRET_FILE" [[ -z "$GOOGLE_SECRET_FILE" ]] || check_path --google-secret-file "$GOOGLE_SECRET_FILE" if { [[ -n "$GITHUB_CLIENT_ID" ]] && [[ -z "$GITHUB_SECRET_FILE" ]]; } || { [[ -z "$GITHUB_CLIENT_ID" ]] && [[ -n "$GITHUB_SECRET_FILE" ]]; }; then die pair --github-client-id --github-secret-file fi if { [[ -n "$GOOGLE_CLIENT_ID" ]] && [[ -z "$GOOGLE_SECRET_FILE" ]]; } || { [[ -z "$GOOGLE_CLIENT_ID" ]] && [[ -n "$GOOGLE_SECRET_FILE" ]]; }; then die pair --google-client-id --google-secret-file fi [[ -z "$GITHUB_CLIENT_ID" || "$GITHUB_CLIENT_ID" =~ ^[A-Za-z0-9._-]+$ ]] || die bad_value --github-client-id "$GITHUB_CLIENT_ID" [[ -z "$GOOGLE_CLIENT_ID" || "$GOOGLE_CLIENT_ID" =~ ^[A-Za-z0-9._-]+$ ]] || die bad_value --google-client-id "$GOOGLE_CLIENT_ID" [[ "$LISTEN" =~ ^(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9.-]+):[0-9]{1,5}$ ]] || die bad_value --listen "$LISTEN" [[ "$PUBLIC_CONSOLE" =~ ^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?/?$ ]] || die https "$PUBLIC_CONSOLE" [[ "$PUBLIC_RELAY" =~ ^wss://[A-Za-z0-9.-]+(:[0-9]{1,5})?/?$ ]] || die wss "$PUBLIC_RELAY" # The value goes into a systemd EnvironmentFile inside double quotes. [[ "$MAIL_FROM" =~ ^[A-Za-z0-9\ .@\<\>_+-]+$ ]] || die bad_value --mail-from "$MAIL_FROM" } check_path() { [[ "$2" == /* && ! "$2" =~ [^A-Za-z0-9/_.-] && ! "$2" =~ (^|/)\.\.(/|$) ]] || die bad_path "$1" "$2" } # 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 (same # rule as deploy/install.sh). normalize_version() { local v="${1#v}" [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || die bad_version "$1" printf 'v%s\n' "$v" } detect_arch() { local machine machine="$(uname -m)" case "$machine" in x86_64 | amd64) ARCH=amd64 ;; aarch64 | arm64) ARCH=arm64 ;; *) die arch "$machine" ;; esac } # os_ok sets OS_DESC and succeeds on Ubuntu 24.04 or later. os_ok() { local id='' version_id='' OS_DESC="$(uname -s)" if [[ -r /etc/os-release ]]; then id="$(sed -n 's/^ID="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' /etc/os-release | head -n 1)" version_id="$(sed -n 's/^VERSION_ID="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' /etc/os-release | head -n 1)" OS_DESC="$id $version_id" fi [[ "$id" == ubuntu && "$version_id" =~ ^([0-9]+)\. ]] || return 1 [[ "${BASH_REMATCH[1]}" -ge 24 ]] } has_systemd() { [[ -d /run/systemd/system ]] && command -v systemctl >/dev/null 2>&1 } # check_tools sets MISSING_TOOLS for every tool the real install needs. check_tools() { local tool MISSING_TOOLS='' for tool in curl tar gzip sha256sum install cmp stat getent useradd systemctl ss awk mktemp; do command -v "$tool" >/dev/null 2>&1 || MISSING_TOOLS="$MISSING_TOOLS $tool" done MISSING_TOOLS="${MISSING_TOOLS# }" [[ -z "$MISSING_TOOLS" ]] } listen_port() { printf '%s' "${LISTEN##*:}" } service_active() { command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "$NAME" } # port_free succeeds when nothing listens on the port, or when our own service # holds it (re-run / upgrade). port_free() { local port port="$(listen_port)" command -v ss >/dev/null 2>&1 || return 0 [[ -z "$(ss -Hltn "sport = :$port" 2>/dev/null)" ]] && return 0 service_active } healthz_url() { local host="${LISTEN%:*}" case "$host" in 0.0.0.0) host=127.0.0.1 ;; '[::]') host='[::1]' ;; esac printf 'http://%s:%s/healthz' "$host" "$(listen_port)" } # secret_problem PATH [session] prints nothing and succeeds when PATH is a # regular root:nestipy-central file, mode 0640 or stricter, not empty (and for # the session key, exactly 32 characters after trimming). Otherwise it prints # a die key and arguments separated by tabs. The content is read only to # measure it and is never printed. secret_problem() { local path="$1" kind="${2:-}" owner mode value if [[ ! -e "$path" ]]; then printf 'secret_missing\t%s' "$path" return 1 fi if [[ -L "$path" || ! -f "$path" ]]; then printf 'secret_type\t%s' "$path" return 1 fi owner="$(stat -c '%U:%G' -- "$path")" mode="$(stat -c '%a' -- "$path")" if [[ "$owner" != "root:$SERVICE_USER" ]]; then printf 'secret_owner\t%s\t%s\t%s' "$SERVICE_USER" "$path" "$owner" return 1 fi # No bits for others, no write or execute for the group, no execute for the owner. if (( (8#$mode & 8#137) != 0 )); then printf 'secret_mode\t%s\t%s' "$path" "$mode" return 1 fi if [[ ! -r "$path" ]]; then return 0 fi value="$(cat -- "$path")" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" if [[ -z "$value" ]]; then printf 'secret_empty\t%s' "$path" return 1 fi if [[ "$kind" == session && "${#value}" -ne 32 ]]; then printf 'session_len\t%s\t%s' "$path" "${#value}" value='' return 1 fi value='' return 0 } # each_secret calls "$1 PATH KIND" for every configured secret file. each_secret() { "$1" "$DB_URL_FILE" '' "$1" "$SESSION_KEY_FILE" session [[ -z "$RESEND_KEY_FILE" ]] || "$1" "$RESEND_KEY_FILE" '' [[ -z "$GITHUB_SECRET_FILE" ]] || "$1" "$GITHUB_SECRET_FILE" '' [[ -z "$GOOGLE_SECRET_FILE" ]] || "$1" "$GOOGLE_SECRET_FILE" '' } require_secret() { local problem if ! problem="$(secret_problem "$1" "$2")"; then local -a parts IFS=$'\t' read -r -a parts <<<"$problem" die "${parts[@]}" fi msg ok "$1" } plan_secret() { local problem plan p_secret "$1" "$SERVICE_USER" [[ "$2" != session ]] || plan p_session_len if [[ ! -e "$1" ]]; then warn w_secret "$(die_text secret_missing "$1")" return 0 fi if ! problem="$(secret_problem "$1" "$2" 2>/dev/null)"; then local -a parts IFS=$'\t' read -r -a parts <<<"$problem" warn w_secret "$(die_text "${parts[@]}")" fi } # die_text KEY [ARG...] prints the localized die message without exiting. die_text() { (die "$@") 2>&1 | sed "s/^$NAME: //" || true } # env_content prints the settings file. It holds public values and secret file # paths only, never a secret value. env_content() { printf '# Generated by install-central.sh (T10.2). Paths only; no secret values.\n' printf '# See docs/ops/central-deploy.md §6 for every variable.\n' printf 'NESTIPY_CENTRAL_LISTEN=%s\n' "$LISTEN" printf 'NESTIPY_CENTRAL_PUBLIC_CONSOLE=%s\n' "$PUBLIC_CONSOLE" printf 'NESTIPY_CENTRAL_PUBLIC_RELAY=%s\n' "$PUBLIC_RELAY" printf 'NESTIPY_CENTRAL_DB_URL_FILE=%s\n' "$DB_URL_FILE" printf 'NESTIPY_CENTRAL_SESSION_KEY_FILE=%s\n' "$SESSION_KEY_FILE" printf 'NESTIPY_CENTRAL_MAIL_FROM="%s"\n' "$MAIL_FROM" [[ -z "$RESEND_KEY_FILE" ]] || printf 'NESTIPY_CENTRAL_RESEND_KEY_FILE=%s\n' "$RESEND_KEY_FILE" if [[ -n "$GITHUB_CLIENT_ID" ]]; then printf 'NESTIPY_CENTRAL_GITHUB_CLIENT_ID=%s\n' "$GITHUB_CLIENT_ID" printf 'NESTIPY_CENTRAL_GITHUB_SECRET_FILE=%s\n' "$GITHUB_SECRET_FILE" fi if [[ -n "$GOOGLE_CLIENT_ID" ]]; then printf 'NESTIPY_CENTRAL_GOOGLE_CLIENT_ID=%s\n' "$GOOGLE_CLIENT_ID" printf 'NESTIPY_CENTRAL_GOOGLE_SECRET_FILE=%s\n' "$GOOGLE_SECRET_FILE" fi } fetch() { curl --proto '=https' --tlsv1.2 -fsSL --retry 3 -o "$2" "$1" || die download "$1" } # 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() { local tag 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() { local file="$1" name="$2" want got want="$(awk -v n="$name" '$2 == n || $2 == "*" n { print $1; exit }' "$TMP_DIR/checksums.txt")" [[ -n "$want" ]] || die no_entry "$name" got="$(sha256sum "$file" | cut -d ' ' -f 1)" [[ "$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() { [[ -n "$RELEASE_PUBKEY" ]] || return 0 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 } archive_name() { printf '%s_%s_linux_%s.tar.gz' "$NAME" "${VERSION#v}" "$ARCH" } # ---------------------------------------------------------------- dry run dry_run() { msg dry_run step s_preflight [[ "$(id -u)" -eq 0 ]] || warn w_root os_ok || warn w_os "$OS_DESC" has_systemd || warn w_systemd check_tools || warn w_tools "$MISSING_TOOLS" port_free || warn w_port "$(listen_port)" if command -v systemctl >/dev/null 2>&1; then systemctl is-active --quiet postgresql 2>/dev/null || warn w_pg else warn w_no_systemctl fi [[ -f "$UNIT_SRC" ]] || warn w_unit_src "$UNIT_SRC" local shown_version="${VERSION:-}" msg target "$ARCH" "$shown_version" step s_account "$SERVICE_USER" if getent passwd "$SERVICE_USER" >/dev/null 2>&1; then plan p_account_keep "$SERVICE_USER" else plan p_account_create "$STATE_DIR" "$SERVICE_USER" fi step s_dirs plan p_dir "$CONFIG_DIR" "0750" "root:$SERVICE_USER" plan p_dir "$STATE_DIR" "0750" "$SERVICE_USER:$SERVICE_USER" plan p_dir "$PREV_DIR" "0755" "root:root" if [[ "$PREPARE_ONLY" -eq 1 ]]; then msg prepared return 0 fi step s_secrets each_secret plan_secret step s_download if [[ -z "$VERSION" ]]; then plan p_resolve "$API_URL" VERSION='' fi plan p_download "$BASE_URL/$VERSION/checksums.txt" plan p_download "$BASE_URL/$VERSION/$(archive_name)" plan p_verify step s_binary "$BIN_PATH" plan p_prev "$PREV_PATH" plan p_install "$BIN_PATH" step s_env "$ENV_FILE" plan p_env_write "$ENV_FILE" "$SERVICE_USER" local line while IFS= read -r line; do msg env_line "$line" done < <(env_content) step s_unit "$UNIT_DST" plan p_unit "$UNIT_SRC" "$UNIT_DST" step s_start plan p_start "$NAME" step s_health "$(healthz_url)" plan p_health "$(healthz_url)" "$HEALTHZ_TRIES" } # ---------------------------------------------------------------- real run ensure_account() { step s_account "$SERVICE_USER" if getent passwd "$SERVICE_USER" >/dev/null; then msg account_exists "$SERVICE_USER" return 0 fi useradd --system --user-group --home-dir "$STATE_DIR" --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" msg account_created "$SERVICE_USER" } ensure_dirs() { step s_dirs install -d -m 0750 -o root -g "$SERVICE_USER" "$CONFIG_DIR" install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$STATE_DIR" install -d -m 0755 -o root -g root "$PREV_DIR" msg ok "$CONFIG_DIR $STATE_DIR $PREV_DIR" } install_binary() { local archive step s_download TMP_DIR="$(mktemp -d)" trap cleanup EXIT trap 'cleanup; exit 130' INT TERM if [[ -z "$VERSION" ]]; then VERSION="$(resolve_latest)" fi msg target "$ARCH" "$VERSION" archive="$(archive_name)" 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 step s_binary "$BIN_PATH" if [[ -f "$BIN_PATH" ]] && cmp -s "$BIN_PATH" "$TMP_DIR/extract/$NAME"; then msg keep_binary "$BIN_PATH" return 0 fi if [[ -f "$BIN_PATH" ]]; then install -m 0755 -o root -g root "$BIN_PATH" "$PREV_PATH" msg saved_prev "$PREV_PATH" fi install -m 0755 -o root -g root "$TMP_DIR/extract/$NAME" "$BIN_PATH" CHANGED=1 msg installed "$BIN_PATH" } install_env() { local want step s_env "$ENV_FILE" want="$(env_content)" if [[ -f "$ENV_FILE" ]]; then if [[ "$(cat -- "$ENV_FILE")" == "$want" ]]; then msg env_same "$ENV_FILE" else warn env_kept "$ENV_FILE" fi return 0 fi printf '%s\n' "$want" >"$TMP_DIR/central.env" install -m 0640 -o root -g "$SERVICE_USER" "$TMP_DIR/central.env" "$ENV_FILE" CHANGED=1 msg env_written "$ENV_FILE" } install_unit() { step s_unit "$UNIT_DST" if [[ -f "$UNIT_DST" ]] && cmp -s "$UNIT_SRC" "$UNIT_DST"; then msg unit_same "$UNIT_DST" return 0 fi install -m 0644 -o root -g root "$UNIT_SRC" "$UNIT_DST" systemctl daemon-reload CHANGED=1 msg unit_installed "$UNIT_DST" } start_service() { step s_start systemctl enable --quiet "$NAME" if [[ "$CHANGED" -eq 0 ]] && service_active; then msg unchanged "$NAME" return 0 fi systemctl restart "$NAME" msg started "$NAME" } wait_healthy() { local url i url="$(healthz_url)" step s_health "$url" for ((i = 0; i < HEALTHZ_TRIES; i++)); do if curl -fsS --max-time 3 -o /dev/null "$url" 2>/dev/null; then msg healthy "$url" return 0 fi sleep 1 done die unhealthy "$url" "$NAME" } main() { parse_args "$@" detect_arch if [[ "$DRY_RUN" -eq 1 ]]; then dry_run return 0 fi step s_preflight [[ "$(id -u)" -eq 0 ]] || die root os_ok || die os "$OS_DESC" has_systemd || die systemd if ! check_tools; then DIE_CODE=$EXIT_MISSING_TOOL die no_tools "$MISSING_TOOLS" fi port_free || die port "$(listen_port)" systemctl is-active --quiet postgresql || warn w_pg ensure_account ensure_dirs if [[ "$PREPARE_ONLY" -eq 1 ]]; then msg prepared return 0 fi [[ -f "$UNIT_SRC" ]] || die unit_src "$UNIT_SRC" step s_secrets each_secret require_secret install_binary install_env install_unit start_service wait_healthy msg finished "$NAME" "$VERSION" } main "$@"