From be6059949e3f80df8222e8b3ff5d94f318847370 Mon Sep 17 00:00:00 2001 From: MoeexT Date: Mon, 28 Sep 2026 18:27:40 +0800 Subject: [PATCH 1/9] feat(deploy): add optional nginx HTTPS termination for docker and k8s installs - Add HTTPS mode option (disabled/self-signed/custom) to install TUI, persisted in deploy.options and reusable on non-interactive reruns - Support auto-generated self-signed certs (99-year validity, SAN from env or NIC auto-detection) and user-provided certs with validation (PEM format, key/cert match, expiry, encrypted key decryption) - Decrypt passphrase-protected keys to a 0600 plaintext copy mounted by nginx; passphrase stored via existing .env credential pattern - Add nexent-nginx helm chart (deployment/service/configmap/secret) terminating TLS on NodePort 30000 via error_page 497 same-port redirect; web service steps down to ClusterIP when HTTPS is enabled - Render cert/key as YAML literal blocks; store TLS as Opaque secret with server.pem/server.key keys - Update offline packaging, env example, install docs and common tests --- deploy/common/common.sh | 525 +++++++++++++++++- deploy/docker/assets/nginx/nginx.conf | 48 ++ deploy/docker/compose/docker-compose.prod.yml | 22 +- deploy/docker/compose/docker-compose.yml | 22 +- deploy/docker/deploy.sh | 32 ++ deploy/env/.env.example | 12 + deploy/k8s/deploy.sh | 1 + deploy/k8s/helm/nexent/Chart.yaml | 6 + .../nexent/charts/nexent-nginx/Chart.yaml | 7 + .../nexent-nginx/templates/configmap.yaml | 10 + .../nexent-nginx/templates/deployment.yaml | 47 ++ .../charts/nexent-nginx/templates/secret.yaml | 13 + .../nexent-nginx/templates/service.yaml | 19 + .../nexent/charts/nexent-nginx/values.yaml | 76 +++ deploy/offline/build_offline_package.sh | 6 +- deploy/tests/test_common.sh | 215 ++++++- doc/docs/en/quick-start/installation.md | 21 +- doc/docs/zh/quick-start/installation.md | 21 +- 18 files changed, 1086 insertions(+), 17 deletions(-) create mode 100644 deploy/docker/assets/nginx/nginx.conf create mode 100644 deploy/k8s/helm/nexent/charts/nexent-nginx/Chart.yaml create mode 100644 deploy/k8s/helm/nexent/charts/nexent-nginx/templates/configmap.yaml create mode 100644 deploy/k8s/helm/nexent/charts/nexent-nginx/templates/deployment.yaml create mode 100644 deploy/k8s/helm/nexent/charts/nexent-nginx/templates/secret.yaml create mode 100644 deploy/k8s/helm/nexent/charts/nexent-nginx/templates/service.yaml create mode 100644 deploy/k8s/helm/nexent/charts/nexent-nginx/values.yaml diff --git a/deploy/common/common.sh b/deploy/common/common.sh index bf88e2bbae..a3d3e47340 100755 --- a/deploy/common/common.sh +++ b/deploy/common/common.sh @@ -34,6 +34,7 @@ DEPLOYMENT_ROOT_ENV="" DEPLOYMENT_LANGUAGE="${DEPLOYMENT_LANGUAGE:-}" deployment_component_list="infrastructure application data-process supabase terminal monitoring" +deployment_https_mode_list="disabled self-signed custom" deployment_port_policy_list="development production" deployment_image_source_list="general mainland local-latest" deployment_sandbox_mode_list="disabled lightweight full" @@ -125,6 +126,17 @@ deployment_i18n_format() { validation.unsupported_registry_profile) printf '%s' '不支持的 registry profile:%s' ;; validation.unsupported_image_registry_prefix) printf '%s' '不支持的镜像仓库前缀:%s。请使用 registry.example.com/project 格式,不要包含空格。' ;; validation.unsupported_monitoring_provider) printf '%s' '不支持的监控 provider:%s' ;; + validation.unsupported_https_mode) printf '%s' '不支持的 HTTPS 模式:%s。可用值:disabled、self-signed 或 custom。' ;; + validation.https_cert_missing) printf '%s' 'custom 模式下证书文件不存在或不可读:%s' ;; + validation.https_key_missing) printf '%s' 'custom 模式下私钥文件不存在或不可读:%s' ;; + validation.https_cert_invalid_pem) printf '%s' '证书文件不是合法的 PEM 格式:%s' ;; + validation.https_key_invalid_pem) printf '%s' '私钥文件不是合法的 PEM 格式:%s' ;; + validation.https_pair_mismatch) printf '%s' '证书与私钥不匹配(公钥不一致)。' ;; + validation.https_passphrase_wrong) printf '%s' '私钥密码错误,无法解密私钥。' ;; + validation.https_passphrase_required) printf '%s' '该私钥受密码保护,需要提供私钥密码(NEXENT_HTTPS_KEY_PASSPHRASE 或交互输入)。' ;; + validation.https_materialize_failed) printf '%s' '准备 Nginx 证书文件失败(目标目录:%s)。' ;; + validation.https_cert_expired) printf '%s' '证书已过期(到期时间:%s)。' ;; + validation.https_cert_expiring_soon) printf '%s' '⚠️ 证书将在 30 天内到期(到期时间:%s),建议尽快更换。' ;; tui.cancelled) printf '已取消部署配置。' ;; tui.components.title) printf '选择部署组件' ;; tui.components.subtitle) printf '选择要安装的服务组。infrastructure 为必选项,不能禁用。' ;; @@ -145,6 +157,12 @@ deployment_i18n_format() { tui.monitoring.langsmith) printf '转发 traces 到托管 LangSmith;需要 LANGSMITH_API_KEY' ;; tui.monitoring.grafana) printf '本地 Grafana + Tempo traces 看板' ;; tui.monitoring.zipkin) printf '本地 Zipkin trace 浏览 UI' ;; + tui.https.title) printf '选择 HTTPS 模式' ;; + tui.https.subtitle) printf '启用 HTTPS 后会额外安装一个 Nginx 反向代理容器,在现有入口端口上终结 TLS;同端口的 HTTP 请求会自动重定向到 HTTPS。' ;; + tui.https.description) printf '自签证书自动生成(SAN 自动探测,无需输入);custom 模式使用你自己的证书与私钥。' ;; + tui.https.disabled) printf '不启用 HTTPS(默认,行为与现状一致)' ;; + tui.https.self_signed) printf '自动生成自签证书(99 年有效期,浏览器会显示告警)' ;; + tui.https.custom) printf '使用自定义证书与私钥(路径与密码可预填在 .env)' ;; tui.port.title) printf '选择端口策略' ;; tui.port.subtitle) printf '控制哪些服务端口暴露到主机或集群节点。' ;; tui.port.description) printf '本地调试选择 development;更小外部暴露面选择 production。' ;; @@ -175,6 +193,12 @@ deployment_i18n_format() { summary.sandbox_mode) printf '%s' '沙箱模式:%s' ;; summary.image_registry_prefix) printf '%s' '镜像仓库前缀:%s' ;; summary.monitoring_provider) printf '%s' '监控 provider:%s' ;; + summary.https_mode) printf '%s' 'HTTPS 模式:%s' ;; + summary.https_entry) printf '%s' 'HTTPS 入口:%s' ;; + summary.https_cert_subject) printf '%s' '证书主题:%s' ;; + summary.https_cert_expiry) printf '%s' '证书到期:%s' ;; + summary.https_self_signed_warning) printf '%s' '⚠️ 自签证书:浏览器会显示安全告警,可手动信任该证书后继续访问。' ;; + summary.https_san) printf '%s' '自签证书 SAN:%s' ;; summary.docker_services) printf '%s' 'Docker 服务:%s' ;; summary.docker_ports) printf '%s' 'Docker 暴露端口:%s' ;; summary.helm_charts) printf '%s' 'Helm charts:%s' ;; @@ -197,6 +221,17 @@ deployment_i18n_format() { validation.unsupported_registry_profile) printf '%s' 'Unsupported registry profile: %s' ;; validation.unsupported_image_registry_prefix) printf '%s' 'Unsupported image registry prefix: %s. Use registry.example.com/project format without spaces.' ;; validation.unsupported_monitoring_provider) printf '%s' 'Unsupported monitoring provider: %s' ;; + validation.unsupported_https_mode) printf '%s' 'Unsupported HTTPS mode: %s. Available values: disabled, self-signed, custom.' ;; + validation.https_cert_missing) printf '%s' 'Certificate file does not exist or is not readable (custom HTTPS mode): %s' ;; + validation.https_key_missing) printf '%s' 'Private key file does not exist or is not readable (custom HTTPS mode): %s' ;; + validation.https_cert_invalid_pem) printf '%s' 'Certificate file is not valid PEM: %s' ;; + validation.https_key_invalid_pem) printf '%s' 'Private key file is not valid PEM: %s' ;; + validation.https_pair_mismatch) printf '%s' 'Certificate and private key do not match (public keys differ).' ;; + validation.https_passphrase_wrong) printf '%s' 'Wrong private key passphrase: decryption failed.' ;; + validation.https_passphrase_required) printf '%s' 'The private key is passphrase-protected; provide the passphrase (NEXENT_HTTPS_KEY_PASSPHRASE or interactive input).' ;; + validation.https_materialize_failed) printf '%s' 'Failed to prepare the Nginx certificate files (target directory: %s).' ;; + validation.https_cert_expired) printf '%s' 'Certificate has expired (notAfter: %s).' ;; + validation.https_cert_expiring_soon) printf '%s' '⚠️ Certificate expires within 30 days (notAfter: %s); consider replacing it soon.' ;; tui.cancelled) printf 'Deployment configuration cancelled.' ;; tui.components.title) printf 'Select deployment components' ;; tui.components.subtitle) printf 'Choose which service groups to install. infrastructure is required and cannot be disabled.' ;; @@ -217,6 +252,12 @@ deployment_i18n_format() { tui.monitoring.langsmith) printf 'forward traces to hosted LangSmith; requires LANGSMITH_API_KEY' ;; tui.monitoring.grafana) printf 'local Grafana + Tempo dashboard for traces' ;; tui.monitoring.zipkin) printf 'local Zipkin UI for trace browsing' ;; + tui.https.title) printf 'Select HTTPS mode' ;; + tui.https.subtitle) printf 'Enabling HTTPS installs an extra Nginx reverse-proxy container that terminates TLS on the existing entry port; plain HTTP requests on the same port are redirected to HTTPS.' ;; + tui.https.description) printf 'Self-signed certificates are generated automatically (SAN auto-detected, no input needed); custom mode uses your own certificate and key.' ;; + tui.https.disabled) printf 'Keep HTTP only (default, same as before)' ;; + tui.https.self_signed) printf 'Generate a self-signed certificate (99-year validity; browsers will warn)' ;; + tui.https.custom) printf 'Use a custom certificate and key (paths and passphrase can be preset in .env)' ;; tui.port.title) printf 'Select port policy' ;; tui.port.subtitle) printf 'This controls which service ports are exposed on the host or cluster node.' ;; tui.port.description) printf 'Choose development for local debugging; choose production for a smaller external surface.' ;; @@ -247,6 +288,12 @@ deployment_i18n_format() { summary.sandbox_mode) printf '%s' 'Sandbox mode: %s' ;; summary.image_registry_prefix) printf '%s' 'Image registry prefix: %s' ;; summary.monitoring_provider) printf '%s' 'Monitoring provider: %s' ;; + summary.https_mode) printf '%s' 'HTTPS mode: %s' ;; + summary.https_entry) printf '%s' 'HTTPS entry: %s' ;; + summary.https_cert_subject) printf '%s' 'Certificate subject: %s' ;; + summary.https_cert_expiry) printf '%s' 'Certificate expiry: %s' ;; + summary.https_self_signed_warning) printf '%s' '⚠️ Self-signed certificate: browsers will show a security warning; trust it manually to continue.' ;; + summary.https_san) printf '%s' 'Self-signed certificate SAN: %s' ;; summary.docker_services) printf '%s' 'Docker services: %s' ;; summary.docker_ports) printf '%s' 'Docker published ports: %s' ;; summary.helm_charts) printf '%s' 'Helm charts: %s' ;; @@ -817,6 +864,11 @@ deployment_init_defaults() { DEPLOYMENT_IMAGE_REGISTRY_PREFIX="$DEPLOYMENT_IMAGE_REGISTRY_PREFIX_DEFAULT" DEPLOYMENT_APP_VERSION="${APP_VERSION:-latest}" DEPLOYMENT_MONITORING_PROVIDER="$DEPLOYMENT_MONITORING_PROVIDER_DEFAULT" + DEPLOYMENT_HTTPS_MODE="disabled" + DEPLOYMENT_HTTPS_CERT_FILE="" + DEPLOYMENT_HTTPS_KEY_FILE="" + DEPLOYMENT_HTTPS_KEY_PASSPHRASE="${NEXENT_HTTPS_KEY_PASSPHRASE:-}" + DEPLOYMENT_HTTPS_SAN="${NEXENT_HTTPS_SAN:-}" DEPLOYMENT_USE_LOCAL_CONFIG="false" DEPLOYMENT_RECONFIGURE="false" DEPLOYMENT_ROTATE_SECRETS="false" @@ -829,6 +881,7 @@ deployment_init_defaults() { unset DEPLOYMENT_COMPONENTS_EXPLICIT DEPLOYMENT_PORT_POLICY_EXPLICIT DEPLOYMENT_REGISTRY_PROFILE_EXPLICIT unset DEPLOYMENT_IMAGE_REGISTRY_PREFIX_EXPLICIT unset DEPLOYMENT_MONITORING_PROVIDER_EXPLICIT DEPLOYMENT_IMAGE_SOURCE_EXPLICIT DEPLOYMENT_SANDBOX_MODE_EXPLICIT DEPLOYMENT_APP_VERSION_EXPLICIT + unset DEPLOYMENT_HTTPS_MODE_EXPLICIT DEPLOYMENT_HTTPS_CERT_FILE_EXPLICIT DEPLOYMENT_HTTPS_KEY_FILE_EXPLICIT DEPLOYMENT_HTTPS_KEY_PASSPHRASE_EXPLICIT DEPLOYMENT_HTTPS_SAN_EXPLICIT } deployment_parse_common_args() { @@ -866,6 +919,26 @@ deployment_parse_common_args() { DEPLOYMENT_MONITORING_PROVIDER="$2" shift 2 ;; + --https-mode) + DEPLOYMENT_HTTPS_MODE="$2" + shift 2 + ;; + --https-cert-file) + DEPLOYMENT_HTTPS_CERT_FILE="$2" + shift 2 + ;; + --https-key-file) + DEPLOYMENT_HTTPS_KEY_FILE="$2" + shift 2 + ;; + --https-key-passphrase) + DEPLOYMENT_HTTPS_KEY_PASSPHRASE="$2" + shift 2 + ;; + --https-san) + DEPLOYMENT_HTTPS_SAN="$2" + shift 2 + ;; --use-local-config) DEPLOYMENT_USE_LOCAL_CONFIG="true" shift @@ -969,6 +1042,22 @@ deployment_load_config_file() { DEPLOYMENT_MONITORING_PROVIDER="$value" loaded_config_value="true" ;; + httpsMode) + DEPLOYMENT_HTTPS_MODE="$value" + loaded_config_value="true" + ;; + httpsCertFile) + DEPLOYMENT_HTTPS_CERT_FILE="$value" + loaded_config_value="true" + ;; + httpsKeyFile) + DEPLOYMENT_HTTPS_KEY_FILE="$value" + loaded_config_value="true" + ;; + httpsSan) + DEPLOYMENT_HTTPS_SAN="$value" + loaded_config_value="true" + ;; esac fi done < "$config_file" @@ -1158,6 +1247,92 @@ deployment_validate() { deployment_error "$(deployment_i18n validation.unsupported_monitoring_provider "$DEPLOYMENT_MONITORING_PROVIDER")" return 1 } + deployment_is_valid_value "$DEPLOYMENT_HTTPS_MODE" $deployment_https_mode_list || { + deployment_error "$(deployment_i18n validation.unsupported_https_mode "$DEPLOYMENT_HTTPS_MODE")" + return 1 + } + if [ "$DEPLOYMENT_HTTPS_MODE" = "custom" ]; then + if [ ! -r "$DEPLOYMENT_HTTPS_CERT_FILE" ]; then + deployment_error "$(deployment_i18n validation.https_cert_missing "$DEPLOYMENT_HTTPS_CERT_FILE")" + return 1 + fi + if [ ! -r "$DEPLOYMENT_HTTPS_KEY_FILE" ]; then + deployment_error "$(deployment_i18n validation.https_key_missing "$DEPLOYMENT_HTTPS_KEY_FILE")" + return 1 + fi + fi +} + +deployment_https_rsa_check() { + # Bash 3.2-safe RSA key check: builds -passin args only when a passphrase exists. + local key_file="$1" + local passphrase="$2" + if [ -n "$passphrase" ]; then + openssl rsa -in "$key_file" -check -noout -passin "pass:$passphrase" >/dev/null 2>&1 + else + openssl rsa -in "$key_file" -check -noout >/dev/null 2>&1 + fi +} + +deployment_https_rsa_pubkey() { + local key_file="$1" + local passphrase="$2" + if [ -n "$passphrase" ]; then + openssl rsa -in "$key_file" -pubout -passin "pass:$passphrase" 2>/dev/null + else + openssl rsa -in "$key_file" -pubout 2>/dev/null + fi +} + +deployment_https_validate_cert_pair() { + # Validate a certificate/private-key pair before deployment. + # Uses DEPLOYMENT_HTTPS_CERT_FILE / DEPLOYMENT_HTTPS_KEY_FILE / + # DEPLOYMENT_HTTPS_KEY_PASSPHRASE. Returns non-zero on failure. + local cert_file="$DEPLOYMENT_HTTPS_CERT_FILE" + local key_file="$DEPLOYMENT_HTTPS_KEY_FILE" + local passphrase="${DEPLOYMENT_HTTPS_KEY_PASSPHRASE:-}" + + if ! openssl x509 -in "$cert_file" -noout >/dev/null 2>&1; then + deployment_error "$(deployment_i18n validation.https_cert_invalid_pem "$cert_file")" + return 1 + fi + + if ! deployment_https_rsa_check "$key_file" "$passphrase"; then + if [ -n "$passphrase" ]; then + deployment_error "$(deployment_i18n validation.https_passphrase_wrong)" + else + # A key that fails without a passphrase may be passphrase-protected. + if openssl rsa -in "$key_file" -check -noout -passin pass: >/dev/null 2>&1; then + deployment_error "$(deployment_i18n validation.https_passphrase_required)" + else + deployment_error "$(deployment_i18n validation.https_key_invalid_pem "$key_file")" + fi + fi + return 1 + fi + + local cert_pubkey key_pubkey + cert_pubkey="$(openssl x509 -in "$cert_file" -pubkey -noout 2>/dev/null | openssl sha256 2>/dev/null || true)" + key_pubkey="$(deployment_https_rsa_pubkey "$key_file" "$passphrase" | openssl sha256 2>/dev/null || true)" + if [ -z "$cert_pubkey" ] || [ -z "$key_pubkey" ] || [ "$cert_pubkey" != "$key_pubkey" ]; then + deployment_error "$(deployment_i18n validation.https_pair_mismatch)" + return 1 + fi + + local end_date epoch_now epoch_end + end_date="$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d= -f2)" + epoch_end="$(date -j -f '%b %e %H:%M:%S %Y GMT' "$end_date" +%s 2>/dev/null || date -d "$end_date" +%s 2>/dev/null || true)" + if [ -n "$epoch_end" ]; then + epoch_now="$(date +%s)" + if [ "$epoch_end" -le "$epoch_now" ]; then + deployment_error "$(deployment_i18n validation.https_cert_expired "$end_date")" + return 1 + fi + if [ $((epoch_end - epoch_now)) -lt $((30 * 24 * 3600)) ]; then + deployment_warn "$(deployment_i18n validation.https_cert_expiring_soon "$end_date")" + fi + fi + return 0 } deployment_tui_cancel() { @@ -1240,7 +1415,7 @@ deployment_tui_multiselect_components() { fi if [ "$key" = $'\033' ]; then - IFS= read -rsn2 -t 0.1 key_tail || key_tail="" + IFS= read -rsn2 -t 1 key_tail || key_tail="" key="${key}${key_tail}" fi @@ -1328,7 +1503,7 @@ deployment_tui_select_monitoring_provider() { fi if [ "$key" = $'\033' ]; then - IFS= read -rsn2 -t 0.1 key_tail || key_tail="" + IFS= read -rsn2 -t 1 key_tail || key_tail="" key="${key}${key_tail}" fi @@ -1357,6 +1532,80 @@ deployment_tui_select_monitoring_provider() { printf '\033[2J\033[H' } +deployment_tui_select_https_mode() { + [ -t 0 ] || return 0 + [ -n "${DEPLOYMENT_HTTPS_MODE_EXPLICIT:-}" ] && return 0 + [ "$DEPLOYMENT_CONFIG_FILE_LOADED" = "true" ] && return 0 + + local modes=(disabled self-signed custom) + local details=( + "$(deployment_i18n tui.https.disabled)" + "$(deployment_i18n tui.https.self_signed)" + "$(deployment_i18n tui.https.custom)" + ) + local cursor=0 + local i key key_tail + for i in "${!modes[@]}"; do + if [ "${modes[$i]}" = "$DEPLOYMENT_HTTPS_MODE" ]; then + cursor="$i" + fi + done + + deployment_tui_render_https_mode() { + printf '\033[2J\033[H' + printf '%s\n' "$(deployment_i18n tui.https.title)" + printf '%s\n' "$(deployment_i18n tui.https.subtitle)" + printf '%s\n' "$(deployment_i18n tui.https.description)" + printf '%s\n\n' "$(deployment_i18n tui.radio.help)" + local row marker radio + for row in "${!modes[@]}"; do + marker=" " + [ "$row" -eq "$cursor" ] && marker=">" + radio=" " + [ "$row" -eq "$cursor" ] && radio="*" + printf '%s (%s) %s - %s\n' "$marker" "$radio" "${modes[$row]}" "${details[$row]}" + done + } + + printf '\033[?25l' + while true; do + deployment_tui_render_https_mode + IFS= read -rsn1 key || key="" + if [ -z "$key" ]; then + DEPLOYMENT_HTTPS_MODE="${modes[$cursor]}" + break + fi + + if [ "$key" = $'\033' ]; then + IFS= read -rsn2 -t 1 key_tail || key_tail="" + key="${key}${key_tail}" + fi + + case "$key" in + $'\033[A'|k|K) + cursor=$((cursor - 1)) + [ "$cursor" -lt 0 ] && cursor=$((${#modes[@]} - 1)) + ;; + $'\033[B'|j|J) + cursor=$((cursor + 1)) + [ "$cursor" -ge "${#modes[@]}" ] && cursor=0 + ;; + q|Q) + deployment_tui_cancel + return $? + ;; + *) + if deployment_tui_is_back_key "$key"; then + deployment_tui_back + return $? + fi + ;; + esac + done + printf '\033[?25h' + printf '\033[2J\033[H' +} + deployment_tui_select_port_policy() { [ -t 0 ] || return 0 [ -n "${DEPLOYMENT_PORT_POLICY_EXPLICIT:-}" ] && return 0 @@ -1403,7 +1652,7 @@ deployment_tui_select_port_policy() { fi if [ "$key" = $'\033' ]; then - IFS= read -rsn2 -t 0.1 key_tail || key_tail="" + IFS= read -rsn2 -t 1 key_tail || key_tail="" key="${key}${key_tail}" fi @@ -1495,7 +1744,7 @@ deployment_tui_select_image_source() { fi if [ "$key" = $'\033' ]; then - IFS= read -rsn2 -t 0.1 key_tail || key_tail="" + IFS= read -rsn2 -t 1 key_tail || key_tail="" key="${key}${key_tail}" fi @@ -1571,7 +1820,7 @@ deployment_tui_select_sandbox_mode() { fi if [ "$key" = $'\033' ]; then - IFS= read -rsn2 -t 0.1 key_tail || key_tail="" + IFS= read -rsn2 -t 1 key_tail || key_tail="" key="${key}${key_tail}" fi @@ -1622,6 +1871,9 @@ deployment_tui_step_should_run() { 4) deployment_csv_contains "$DEPLOYMENT_COMPONENTS" "monitoring" && [ -z "${DEPLOYMENT_MONITORING_PROVIDER_EXPLICIT:-}" ] && [ "$DEPLOYMENT_CONFIG_FILE_LOADED" != "true" ] ;; + 5) + [ -z "${DEPLOYMENT_HTTPS_MODE_EXPLICIT:-}" ] && [ "$DEPLOYMENT_CONFIG_FILE_LOADED" != "true" ] + ;; *) return 1 ;; @@ -1631,14 +1883,14 @@ deployment_tui_step_should_run() { deployment_tui_next_step() { local step="$1" step=$((step + 1)) - while [ "$step" -lt 5 ]; do + while [ "$step" -lt 6 ]; do if deployment_tui_step_should_run "$step"; then printf '%s' "$step" return 0 fi step=$((step + 1)) done - printf '5' + printf '6' } deployment_tui_previous_step() { @@ -1672,7 +1924,7 @@ deployment_run_tui_configuration() { step="$(deployment_tui_next_step "$step")" fi - while [ "$step" -lt 5 ]; do + while [ "$step" -lt 6 ]; do case "$step" in 0) deployment_ensure_required_components @@ -1696,6 +1948,10 @@ deployment_run_tui_configuration() { deployment_tui_select_monitoring_provider result=$? ;; + 5) + deployment_tui_select_https_mode + result=$? + ;; *) return 1 ;; @@ -1739,7 +1995,13 @@ deployment_maybe_select_local_config() { deployment_load_config_file "$DEPLOYMENT_LOCAL_CONFIG_PATH" defaults || return 1 return 0 fi - [ -t 0 ] || return 0 + if [ ! -t 0 ]; then + # Non-interactive callers cannot answer the prompt; apply the saved + # config so a re-run keeps previous choices instead of silently + # resetting them back to defaults. + deployment_load_config_file "$DEPLOYMENT_LOCAL_CONFIG_PATH" || return 1 + return 0 + fi deployment_log "$(deployment_i18n local_config.found "$DEPLOYMENT_LOCAL_CONFIG_PATH")" deployment_log "$(deployment_i18n local_config.choose)" @@ -2097,8 +2359,18 @@ deployment_render_k8s_port_values() { internal_type="NodePort" fi + local web_type="NodePort" + if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then + # Nginx takes over NodePort 30000; web stays cluster-internal. + web_type="ClusterIP" + fi printf 'nexent-web:\n' - printf ' services:\n web:\n type: "NodePort"\n nodePort: 30000\n' + printf ' services:\n web:\n type: "%s"\n nodePort: 30000\n' "$web_type" + if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then + printf 'nexent-nginx:\n' + printf ' enabled: true\n' + printf ' services:\n nginx:\n type: "NodePort"\n entryPort: 30000\n nodePort: 30000\n' + fi printf 'nexent-northbound:\n' printf ' services:\n northbound:\n type: "%s"\n nodePort: 30013\n' "$northbound_type" printf 'nexent-config:\n' @@ -2138,10 +2410,15 @@ deployment_render_helm_chart_values() { local local_pull_policy="IfNotPresent" local northbound_type="NodePort" local internal_type="ClusterIP" + local web_type="NodePort" [ "$DEPLOYMENT_IMAGE_SOURCE" = "local-latest" ] && [ -z "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ] && local_pull_policy="Never" if [ "$DEPLOYMENT_PORT_POLICY" = "development" ]; then internal_type="NodePort" fi + if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then + # Nginx takes over NodePort 30000; web stays cluster-internal. + web_type="ClusterIP" + fi printf 'nexent-config:\n' printf ' enabled: %s\n' "$(deployment_chart_enabled application)" @@ -2162,7 +2439,7 @@ deployment_render_helm_chart_values() { printf 'nexent-web:\n' printf ' enabled: %s\n' "$(deployment_chart_enabled application)" printf ' images:\n web:\n repository: "%s"\n tag: "%s"\n pullPolicy: "%s"\n' "$(deployment_image_repo "$NEXENT_WEB_IMAGE")" "$(deployment_image_tag "$NEXENT_WEB_IMAGE")" "$local_pull_policy" - printf ' services:\n web:\n type: "NodePort"\n nodePort: 30000\n' + printf ' services:\n web:\n type: "%s"\n nodePort: 30000\n' "$web_type" printf 'nexent-data-process:\n' printf ' enabled: %s\n' "$(deployment_chart_enabled data-process)" printf ' images:\n dataProcess:\n repository: "%s"\n tag: "%s"\n pullPolicy: "%s"\n' "$(deployment_image_repo "$NEXENT_DATA_PROCESS_IMAGE")" "$(deployment_image_tag "$NEXENT_DATA_PROCESS_IMAGE")" "$local_pull_policy" @@ -2186,6 +2463,20 @@ deployment_render_helm_chart_values() { printf 'nexent-openssh:\n' printf ' enabled: %s\n' "$(deployment_chart_enabled terminal)" printf ' images:\n openssh:\n repository: "%s"\n tag: "%s"\n pullPolicy: "%s"\n' "$(deployment_image_repo "$OPENSSH_SERVER_IMAGE")" "$(deployment_image_tag "$OPENSSH_SERVER_IMAGE")" "$local_pull_policy" + if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then + printf 'nexent-nginx:\n' + printf ' enabled: true\n' + printf ' images:\n nginx:\n repository: "%s"\n tag: "%s"\n pullPolicy: "IfNotPresent"\n' "$(deployment_image_repo nginx:alpine | cut -d: -f1)" "$(deployment_image_tag nginx:alpine)" + if [ -r "${DEPLOYMENT_HTTPS_CERT_PATH:-}" ] && [ -r "${DEPLOYMENT_HTTPS_KEY_PATH:-}" ]; then + printf ' tls:\n' + # Render PEM contents as a YAML literal block: multi-line certificates + # cannot be safely quoted on a single line. + printf ' cert: |\n' + sed 's/^/ /' "$DEPLOYMENT_HTTPS_CERT_PATH" + printf ' key: |\n' + sed 's/^/ /' "$DEPLOYMENT_HTTPS_KEY_PATH" + fi + fi printf 'nexent-supabase-kong:\n' printf ' enabled: %s\n' "$(deployment_chart_enabled supabase)" printf ' image:\n repository: "%s"\n tag: "%s"\n pullPolicy: "IfNotPresent"\n' "$(deployment_image_repo "$SUPABASE_KONG")" "$(deployment_image_tag "$SUPABASE_KONG")" @@ -2401,11 +2692,21 @@ deployment_persist_local_config() { printf 'sandboxMode: "%s"\n' "$DEPLOYMENT_SANDBOX_MODE" printf 'imageRegistryPrefix: "%s"\n' "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" printf 'monitoringProvider: "%s"\n' "$DEPLOYMENT_MONITORING_PROVIDER" + printf 'httpsMode: "%s"\n' "$DEPLOYMENT_HTTPS_MODE" + if [ "$DEPLOYMENT_HTTPS_MODE" = "custom" ]; then + printf 'httpsCertFile: "%s"\n' "$DEPLOYMENT_HTTPS_CERT_FILE" + printf 'httpsKeyFile: "%s"\n' "$DEPLOYMENT_HTTPS_KEY_FILE" + fi + if [ "$DEPLOYMENT_HTTPS_MODE" = "self-signed" ] && [ -n "$DEPLOYMENT_HTTPS_SAN" ]; then + printf 'httpsSan: "%s"\n' "$DEPLOYMENT_HTTPS_SAN" + fi } > "$output_file" } deployment_print_summary() { local target="${1:-all}" + local https_entry_port="3000" + [ "$target" = "k8s" ] && https_entry_port="30000" deployment_log "$(deployment_i18n summary.components "$DEPLOYMENT_COMPONENTS")" deployment_log "$(deployment_i18n summary.port_policy "$DEPLOYMENT_PORT_POLICY")" @@ -2417,6 +2718,25 @@ deployment_print_summary() { if deployment_csv_contains "$DEPLOYMENT_COMPONENTS" "monitoring"; then deployment_log "$(deployment_i18n summary.monitoring_provider "$DEPLOYMENT_MONITORING_PROVIDER")" fi + if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then + deployment_log "$(deployment_i18n summary.https_mode "$DEPLOYMENT_HTTPS_MODE")" + local https_cert_file="${DEPLOYMENT_HTTPS_CERT_FILE:-}" + if [ "$DEPLOYMENT_HTTPS_MODE" = "self-signed" ] && [ -n "${DEPLOYMENT_HTTPS_CERT_PATH:-}" ]; then + https_cert_file="$DEPLOYMENT_HTTPS_CERT_PATH" + fi + if [ -n "$https_cert_file" ] && [ -r "$https_cert_file" ]; then + local cert_subject cert_end + cert_subject="$(openssl x509 -in "$https_cert_file" -noout -subject 2>/dev/null | sed 's/^subject=//')" + cert_end="$(openssl x509 -in "$https_cert_file" -noout -enddate 2>/dev/null | cut -d= -f2)" + [ -n "$cert_subject" ] && deployment_log "$(deployment_i18n summary.https_cert_subject "$cert_subject")" + [ -n "$cert_end" ] && deployment_log "$(deployment_i18n summary.https_cert_expiry "$cert_end")" + fi + if [ "$DEPLOYMENT_HTTPS_MODE" = "self-signed" ]; then + [ -n "${DEPLOYMENT_HTTPS_SAN_RESOLVED:-}" ] && deployment_log "$(deployment_i18n summary.https_san "$DEPLOYMENT_HTTPS_SAN_RESOLVED")" + deployment_log "$(deployment_i18n summary.https_self_signed_warning)" + fi + deployment_log "$(deployment_i18n summary.https_entry "https://:${https_entry_port}")" + fi case "$target" in docker) deployment_log "$(deployment_i18n summary.docker_services "$DEPLOYMENT_SELECTED_DOCKER_SERVICES")" @@ -2450,6 +2770,11 @@ deployment_prepare_config() { --image-registry-prefix|--registry-prefix|--image-registry) DEPLOYMENT_IMAGE_REGISTRY_PREFIX_EXPLICIT="true" ;; --app-version|--version) DEPLOYMENT_APP_VERSION_EXPLICIT="true" ;; --monitoring-provider) DEPLOYMENT_MONITORING_PROVIDER_EXPLICIT="true" ;; + --https-mode) DEPLOYMENT_HTTPS_MODE_EXPLICIT="true" ;; + --https-cert-file) DEPLOYMENT_HTTPS_CERT_FILE_EXPLICIT="true" ;; + --https-key-file) DEPLOYMENT_HTTPS_KEY_FILE_EXPLICIT="true" ;; + --https-key-passphrase) DEPLOYMENT_HTTPS_KEY_PASSPHRASE_EXPLICIT="true" ;; + --https-san) DEPLOYMENT_HTTPS_SAN_EXPLICIT="true" ;; --config) DEPLOYMENT_RECONFIGURE="true" ;; --reconfigure) DEPLOYMENT_RECONFIGURE="true" ;; --defaults) DEPLOYMENT_RECONFIGURE="false" ;; @@ -2475,5 +2800,183 @@ deployment_prepare_config() { deployment_normalize_image_source || return 1 deployment_normalize_image_registry_prefix deployment_validate || return 1 + if [ "$DEPLOYMENT_HTTPS_MODE" = "custom" ]; then + deployment_https_validate_cert_pair || return 1 + fi deployment_compute_selection } +deployment_https_is_ipv4() { + local value="$1" + [[ "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +deployment_https_detect_san_addresses() { + # Auto-detect host NIC addresses (filter lo / docker0 / veth* / br-*) and hostname. + local addresses="" + local interface address + while IFS=' ' read -r interface address; do + [ -z "$interface" ] && continue + case "$interface" in + lo|docker0|veth*|br-*) continue ;; + esac + [ -n "$address" ] && addresses="$(deployment_join_csv "$addresses" "$address")" + done < <(ifconfig -a 2>/dev/null | awk -F': ' '/^[a-zA-Z0-9_-]+: /{iface=$1} /inet /{print iface" "$2}' | sed 's|/%.*||') + local hostname_addr + hostname_addr="$(hostname 2>/dev/null || true)" + [ -n "$hostname_addr" ] && addresses="$(deployment_join_csv "$addresses" "$hostname_addr")" + printf '%s' "$addresses" +} + +deployment_https_build_san_entries() { + # Convert a comma-separated address list to openssl SAN entries (IP:/DNS: prefixes). + local input="$1" + local san_list="" cn="" item + local old_ifs="$IFS" + IFS=',' + for item in $input; do + IFS="$old_ifs" + item="$(deployment_trim "$item")" + [ -z "$item" ] && continue + [ -z "$cn" ] && cn="$item" + if deployment_https_is_ipv4 "$item"; then + san_list="${san_list}IP:${item}," + else + san_list="${san_list}DNS:${item}," + fi + IFS=',' + done + IFS="$old_ifs" + san_list="${san_list%,}" + printf '%s|%s' "$cn" "$san_list" +} + +deployment_https_ensure_self_signed_cert() { + # Generate or reuse a self-signed cert under ROOT_DIR/nginx/ssl/. + local ssl_dir="$1" + local cert_file="$ssl_dir/server.pem" + local key_file="$ssl_dir/server.key" + local san_input="${DEPLOYMENT_HTTPS_SAN:-}" + + if [ -z "$san_input" ]; then + san_input="$(deployment_https_detect_san_addresses)" + fi + if [ -z "$san_input" ]; then + san_input="localhost" + fi + DEPLOYMENT_HTTPS_SAN_RESOLVED="$san_input" + + if [ -r "$cert_file" ] && [ -r "$key_file" ]; then + if openssl x509 -in "$cert_file" -noout >/dev/null 2>&1 && openssl rsa -in "$key_file" -check -noout >/dev/null 2>&1; then + DEPLOYMENT_HTTPS_CERT_PATH="$cert_file" + DEPLOYMENT_HTTPS_KEY_PATH="$key_file" + return 0 + fi + fi + + local parsed cn san_list + parsed="$(deployment_https_build_san_entries "$san_input")" + cn="${parsed%%|*}" + san_list="${parsed#*|}" + [ -z "$san_list" ] && san_list="DNS:localhost" + + mkdir -p "$ssl_dir" + local tmp_dir + tmp_dir="$(mktemp -d)" + cat > "$tmp_dir/openssl.cnf" </dev/null 2>&1; then + rm -rf "$tmp_dir" + deployment_error "Failed to generate the self-signed certificate." + return 1 + fi + rm -rf "$tmp_dir" + chmod 600 "$key_file" "$cert_file" + # Verify the generated SAN (works on both OpenSSL and LibreSSL: -ext is unsupported on LibreSSL). + if ! openssl x509 -in "$cert_file" -noout -text 2>/dev/null | grep -q "Subject Alternative Name"; then + deployment_error "Generated certificate is missing the Subject Alternative Name extension." + return 1 + fi + DEPLOYMENT_HTTPS_CERT_PATH="$cert_file" + DEPLOYMENT_HTTPS_KEY_PATH="$key_file" + return 0 +} + +deployment_https_prepare() { +deployment_https_materialize_custom_cert() { + # Copy the custom cert/key into ROOT_DIR/nginx/ssl/, decrypting an + # encrypted private key in the process. Official nginx images do not ship + # the Red Hat ssl_pass_phrase_dialog patch, so nginx must receive an + # unencrypted key; the user's original key file is never modified. + local ssl_dir="$1" + local cert_src="$DEPLOYMENT_HTTPS_CERT_PATH" + local key_src="$DEPLOYMENT_HTTPS_KEY_PATH" + local passphrase="${DEPLOYMENT_HTTPS_KEY_PASSPHRASE:-}" + local cert_dst="$ssl_dir/server.pem" + local key_dst="$ssl_dir/server.key" + + if ! mkdir -p "$ssl_dir"; then + deployment_error "$(deployment_i18n validation.https_materialize_failed "$ssl_dir")" + return 1 + fi + + if ! cp "$cert_src" "$cert_dst"; then + deployment_error "$(deployment_i18n validation.https_materialize_failed "$ssl_dir")" + return 1 + fi + + if [ -n "$passphrase" ]; then + # Decrypt the key copy; the source file stays untouched. + if ! openssl rsa -in "$key_src" -out "$key_dst" -passin "pass:$passphrase" 2>/dev/null; then + rm -f "$cert_dst" + deployment_error "$(deployment_i18n validation.https_passphrase_wrong)" + return 1 + fi + else + if ! cp "$key_src" "$key_dst"; then + rm -f "$cert_dst" + deployment_error "$(deployment_i18n validation.https_materialize_failed "$ssl_dir")" + return 1 + fi + fi + chmod 600 "$cert_dst" "$key_dst" + + DEPLOYMENT_HTTPS_CERT_PATH="$cert_dst" + DEPLOYMENT_HTTPS_KEY_PATH="$key_dst" + return 0 +} + + # Entry point: prepare certificates before deployment. + case "$DEPLOYMENT_HTTPS_MODE" in + disabled) + return 0 + ;; + self-signed) + local ssl_dir="${ROOT_DIR:-$HOME/nexent}/nginx/ssl" + deployment_https_ensure_self_signed_cert "$ssl_dir" || return 1 + ;; + custom) + DEPLOYMENT_HTTPS_CERT_PATH="$(cd "$(dirname "$DEPLOYMENT_HTTPS_CERT_FILE")" && pwd)/$(basename "$DEPLOYMENT_HTTPS_CERT_FILE")" + DEPLOYMENT_HTTPS_KEY_PATH="$(cd "$(dirname "$DEPLOYMENT_HTTPS_KEY_FILE")" && pwd)/$(basename "$DEPLOYMENT_HTTPS_KEY_FILE")" + deployment_https_materialize_custom_cert "${ROOT_DIR:-$HOME/nexent}/nginx/ssl" || return 1 + ;; + esac + if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then + deployment_update_env_var_file "$(deployment_env_dir)/.env" "NEXENT_HTTPS_MODE" "$DEPLOYMENT_HTTPS_MODE" + [ -n "${DEPLOYMENT_HTTPS_SAN_RESOLVED:-}" ] && deployment_update_env_var_file "$(deployment_env_dir)/.env" "NEXENT_HTTPS_SAN" "$DEPLOYMENT_HTTPS_SAN_RESOLVED" + fi + return 0 +} diff --git a/deploy/docker/assets/nginx/nginx.conf b/deploy/docker/assets/nginx/nginx.conf new file mode 100644 index 0000000000..a6a8751e77 --- /dev/null +++ b/deploy/docker/assets/nginx/nginx.conf @@ -0,0 +1,48 @@ +# Nexent HTTPS reverse proxy (Nginx) +# Terminates TLS on the existing web entry port and proxies to nexent-web. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 3000 ssl; + server_name _; + + # TLS termination (cert/key mounted read-only by the deployment script) + ssl_certificate /etc/nginx/ssl/server.pem; + ssl_certificate_key /etc/nginx/ssl/server.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Plain HTTP on the TLS port -> 301 redirect (same host, same port) + # Preserve the original port when redirecting plain HTTP to HTTPS + error_page 497 https://$host:$server_port$request_uri; + + client_max_body_size 1024M; + + # SSE: disable buffering; keep long-lived streams stable + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 60s; + + location / { + proxy_pass http://nexent-web:3000; + proxy_http_version 1.1; + + # WebSocket upgrade + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Forwarded headers (Supabase callbacks and backend URL building rely on these) + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } +} diff --git a/deploy/docker/compose/docker-compose.prod.yml b/deploy/docker/compose/docker-compose.prod.yml index 9bc9ceb0ce..ef60555869 100644 --- a/deploy/docker/compose/docker-compose.prod.yml +++ b/deploy/docker/compose/docker-compose.prod.yml @@ -209,7 +209,7 @@ services: networks: - nexent ports: - - "3000:3000" + - "${NEXENT_WEB_PORT_MAPPING-3000:3000}" volumes: - ${ROOT_DIR}/project-config:/mnt/nexent-data/project-config environment: @@ -225,6 +225,26 @@ services: max-size: "10m" # Maximum size of a single log file max-file: "3" # Maximum number of log files to keep + + nexent-nginx: + image: nginx:alpine + container_name: nexent-nginx + restart: always + profiles: + - https + networks: + - nexent + ports: + - "3000:3000" + volumes: + - ${ROOT_DIR}/nginx/ssl:/etc/nginx/ssl:ro + - ../assets/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + nexent-data-process: image: ${NEXENT_DATA_PROCESS_IMAGE} container_name: nexent-data-process diff --git a/deploy/docker/compose/docker-compose.yml b/deploy/docker/compose/docker-compose.yml index 08605b5feb..2063036d96 100644 --- a/deploy/docker/compose/docker-compose.yml +++ b/deploy/docker/compose/docker-compose.yml @@ -229,7 +229,7 @@ services: networks: - nexent ports: - - "3000:3000" + - "${NEXENT_WEB_PORT_MAPPING-3000:3000}" volumes: - ${ROOT_DIR}/project-config:/mnt/nexent-data/project-config env_file: @@ -247,6 +247,26 @@ services: max-size: "10m" # Maximum size of a single log file max-file: "3" # Maximum number of log files to keep + + nexent-nginx: + image: nginx:alpine + container_name: nexent-nginx + restart: always + profiles: + - https + networks: + - nexent + ports: + - "3000:3000" + volumes: + - ${ROOT_DIR}/nginx/ssl:/etc/nginx/ssl:ro + - ../assets/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + nexent-data-process: image: ${NEXENT_DATA_PROCESS_IMAGE} container_name: nexent-data-process diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh index 0ec5c6371b..58b8a268a4 100755 --- a/deploy/docker/deploy.sh +++ b/deploy/docker/deploy.sh @@ -1232,6 +1232,28 @@ deploy_core_services() { fi } +deploy_https_nginx() { + # Start the Nginx HTTPS reverse proxy when HTTPS is enabled. + if [ "$DEPLOYMENT_HTTPS_MODE" = "disabled" ] || [ -z "$DEPLOYMENT_HTTPS_MODE" ]; then + export NEXENT_WEB_PORT_MAPPING="3000:3000" + return 0 + fi + + deployment_https_prepare || return 1 + + # Publish only the container port (no host binding) so nginx can take 3000. + # Compose "${VAR-default}" keeps the default only when VAR is unset; + # an empty value must win so the web service stops publishing the host port. + export NEXENT_WEB_PORT_MAPPING="3000" + + echo "🔒 Starting Nginx HTTPS reverse proxy (nexent-nginx)..." + if ! ${docker_compose_command} --env-file "$ROOT_ENV_FILE" -p nexent --profile https -f "$COMPOSE_DIR/docker-compose${COMPOSE_FILE_SUFFIX}" up -d nexent-nginx; then + echo " ❌ ERROR Failed to start nexent-nginx" + return 1 + fi + echo " ✅ Nginx HTTPS reverse proxy started" +} + stop_unselected_data_process_service() { deployment_csv_contains "$DEPLOYMENT_COMPONENTS" "data-process" && return 0 @@ -1983,6 +2005,16 @@ main_deploy() { exit 1 } + # Start Nginx HTTPS reverse proxy when HTTPS is enabled + deploy_https_nginx || { + if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then + echo "❌ HTTPS 反向代理部署失败" + else + echo "❌ HTTPS reverse proxy deployment failed" + fi + exit 1 + } + if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then echo " ✅ 核心服务启动成功" else diff --git a/deploy/env/.env.example b/deploy/env/.env.example index 1a036dcc29..4ab5318229 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -116,6 +116,18 @@ SUPABASE_POSTGRES_PORT=5436 # Supabase Auth Config SITE_URL=http://localhost:3011 + +# HTTPS Termination Config (optional) +# HTTPS mode: disabled (default), self-signed, or custom +NEXENT_HTTPS_MODE=disabled +# Certificate and private key paths (PEM) for custom mode +NEXENT_HTTPS_CERT_FILE= +NEXENT_HTTPS_KEY_FILE= +# Private key passphrase (stored in plain text, only needed for encrypted custom keys) +NEXENT_HTTPS_KEY_PASSPHRASE= +# Comma-separated SAN addresses (IPs or domains) for self-signed certificates +# Leave empty to auto-detect from the deployment host network interfaces +NEXENT_HTTPS_SAN= SUPABASE_URL=http://nexent-supabase-kong:8000 API_EXTERNAL_URL=http://nexent-supabase-kong:8000 DISABLE_SIGNUP=false diff --git a/deploy/k8s/deploy.sh b/deploy/k8s/deploy.sh index 5614572a4a..946b4b6394 100755 --- a/deploy/k8s/deploy.sh +++ b/deploy/k8s/deploy.sh @@ -751,6 +751,7 @@ update_values_yaml() { deployment_apply_image_source deployment_prepare_monitoring_env k8s || exit 1 + deployment_https_prepare || exit 1 deployment_render_helm_values "$GENERATED_VALUES" deployment_render_helm_values "$INFRASTRUCTURE_GENERATED_VALUES" render_k8s_runtime_config_values "$GENERATED_RUNTIME_VALUES" diff --git a/deploy/k8s/helm/nexent/Chart.yaml b/deploy/k8s/helm/nexent/Chart.yaml index e7512486a2..4a9e205ebe 100644 --- a/deploy/k8s/helm/nexent/Chart.yaml +++ b/deploy/k8s/helm/nexent/Chart.yaml @@ -61,6 +61,12 @@ dependencies: repository: "file://./charts/nexent-openssh" condition: nexent-openssh.enabled + # Optional Nginx HTTPS reverse proxy (terminates TLS on the web NodePort) + - name: nexent-nginx + version: 0.1.0 + repository: "file://./charts/nexent-nginx" + condition: nexent-nginx.enabled + # Optional OpenTelemetry monitoring stack - name: nexent-monitoring version: 0.1.0 diff --git a/deploy/k8s/helm/nexent/charts/nexent-nginx/Chart.yaml b/deploy/k8s/helm/nexent/charts/nexent-nginx/Chart.yaml new file mode 100644 index 0000000000..f95a00f7ae --- /dev/null +++ b/deploy/k8s/helm/nexent/charts/nexent-nginx/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: nexent-nginx +description: Nginx HTTPS reverse proxy for the Nexent web entry +type: application +version: 0.1.0 +appVersion: "latest" + diff --git a/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/configmap.yaml b/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/configmap.yaml new file mode 100644 index 0000000000..d8ff005558 --- /dev/null +++ b/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/configmap.yaml @@ -0,0 +1,10 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: nexent-nginx-config + namespace: {{ .Values.global.namespace }} +data: + default.conf: | +{{ (.Values.config | replace "$server_port" (printf "%v" .Values.services.nginx.entryPort | default .Values.services.nginx.nodePort)) | indent 4 }} +{{- end }} diff --git a/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/deployment.yaml new file mode 100644 index 0000000000..b308731df2 --- /dev/null +++ b/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/deployment.yaml @@ -0,0 +1,47 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nexent-nginx + namespace: {{ .Values.global.namespace }} + labels: + app: nexent-nginx +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app: nexent-nginx + template: + metadata: + labels: + app: nexent-nginx + spec: + containers: + - name: nginx + image: {{ .Values.images.nginx.repository }}:{{ .Values.images.nginx.tag }} + imagePullPolicy: {{ .Values.images.nginx.pullPolicy }} + ports: + - containerPort: 3000 + name: https + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/conf.d + readOnly: true + - name: nginx-tls + mountPath: /etc/nginx/ssl + readOnly: true + resources: + requests: + memory: {{ .Values.resources.nginx.requests.memory }} + cpu: {{ .Values.resources.nginx.requests.cpu }} + limits: + memory: {{ .Values.resources.nginx.limits.memory }} + cpu: {{ .Values.resources.nginx.limits.cpu }} + volumes: + - name: nginx-config + configMap: + name: nexent-nginx-config + - name: nginx-tls + secret: + secretName: nexent-nginx-tls +{{- end }} diff --git a/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/secret.yaml b/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/secret.yaml new file mode 100644 index 0000000000..9a3f9c37f7 --- /dev/null +++ b/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/secret.yaml @@ -0,0 +1,13 @@ +{{- if .Values.enabled }} +{{- if and .Values.tls.cert .Values.tls.key }} +apiVersion: v1 +kind: Secret +metadata: + name: nexent-nginx-tls + namespace: {{ .Values.global.namespace }} +type: Opaque +data: + server.pem: {{ .Values.tls.cert | b64enc | quote }} + server.key: {{ .Values.tls.key | b64enc | quote }} +{{- end }} +{{- end }} diff --git a/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/service.yaml b/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/service.yaml new file mode 100644 index 0000000000..7c10bfd694 --- /dev/null +++ b/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/service.yaml @@ -0,0 +1,19 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: nexent-nginx + namespace: {{ .Values.global.namespace }} +spec: + type: {{ .Values.services.nginx.type }} + ports: + - port: 3000 + targetPort: 3000 + name: https + {{- if eq .Values.services.nginx.type "NodePort" }} + nodePort: {{ .Values.services.nginx.nodePort }} + {{- end }} + selector: + app: nexent-nginx +{{- end }} + diff --git a/deploy/k8s/helm/nexent/charts/nexent-nginx/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-nginx/values.yaml new file mode 100644 index 0000000000..9b3be3ea16 --- /dev/null +++ b/deploy/k8s/helm/nexent/charts/nexent-nginx/values.yaml @@ -0,0 +1,76 @@ +enabled: false + +replicaCount: 1 + +images: + nginx: + repository: nginx + tag: alpine + pullPolicy: IfNotPresent + +resources: + nginx: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi + cpu: 500m + +services: + nginx: + type: NodePort + # External port users actually visit (NodePort); used in the HTTP->HTTPS + # redirect because $server_port inside the container is the internal port. + entryPort: 30000 + nodePort: 30000 + +# TLS certificate (PEM contents rendered into a Secret by the deployment script) +tls: + cert: "" + key: "" + # Kept for backward compatibility; the deployment script decrypts + # encrypted keys before rendering, so nginx never needs a passphrase. + keyPassphrase: "" + +config: | + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 3000 ssl; + server_name _; + + ssl_certificate /etc/nginx/ssl/server.pem; + ssl_certificate_key /etc/nginx/ssl/server.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + error_page 497 https://$host:$server_port$request_uri; + + client_max_body_size 1024M; + + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 60s; + + location / { + proxy_pass http://nexent-web:3000; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + } diff --git a/deploy/offline/build_offline_package.sh b/deploy/offline/build_offline_package.sh index b349d670f0..2a07328f4c 100755 --- a/deploy/offline/build_offline_package.sh +++ b/deploy/offline/build_offline_package.sh @@ -170,7 +170,7 @@ parse_args() { DRY_RUN="true" shift ;; - --components|--image-source|--registry-profile|--image-registry-prefix|--registry-prefix|--image-registry|--app-version|--monitoring-provider|--port-policy|--local-config) + --components|--image-source|--registry-profile|--image-registry-prefix|--registry-prefix|--image-registry|--app-version|--monitoring-provider|--port-policy|--local-config|--https-mode|--https-cert-file|--https-key-file|--https-key-passphrase|--https-san) COMMON_ARGS+=("$1" "$2") shift 2 ;; @@ -344,6 +344,10 @@ get_third_party_images() { echo "" } + # HTTPS termination proxy is only consumed when the user opts in during install + if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then + echo_image_ref "nginx:alpine" + fi if deployment_csv_contains "$DEPLOYMENT_COMPONENTS" "infrastructure"; then echo "$ELASTICSEARCH_IMAGE" echo "$POSTGRESQL_IMAGE" diff --git a/deploy/tests/test_common.sh b/deploy/tests/test_common.sh index e0589dd823..31f2a5986a 100755 --- a/deploy/tests/test_common.sh +++ b/deploy/tests/test_common.sh @@ -903,7 +903,7 @@ deployment_tui_step_should_run() { } assert_eq "1" "$(deployment_tui_next_step 0)" "TUI next step should advance to the next runnable step" assert_eq "3" "$(deployment_tui_next_step 2)" "TUI should select Sandbox mode immediately after image source" -assert_eq "5" "$(deployment_tui_next_step 3)" "TUI next step should skip non-runnable monitoring provider" +assert_eq "6" "$(deployment_tui_next_step 3)" "TUI next step should skip non-runnable monitoring and HTTPS steps" assert_eq "3" "$(deployment_tui_previous_step 4)" "TUI previous step should return to Sandbox mode" assert_eq "$(sed -n '1p' "$SCRIPT_DIR/../../VERSION")" "$(deployment_read_version "")" "deployment version should come from root VERSION" @@ -1141,4 +1141,217 @@ if [ -f "$GENERATE_DOCKER_EXAMPLE_ONLY_ROOT/deploy/env/.env" ]; then echo "FAIL: generate_env should not create deploy/env/.env from docker/.env.example" exit 1 fi +# --------------------------------------------------------------------------- +# HTTPS option tests +# --------------------------------------------------------------------------- + +HTTPS_TEST_DIR="$TMP_DIR/https" +mkdir -p "$HTTPS_TEST_DIR/certs" "$HTTPS_TEST_DIR/ssl-root/nginx/ssl" + +# Fixture: valid cert/key pair (unencrypted) +openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 365 \ + -keyout "$HTTPS_TEST_DIR/certs/valid.key" -out "$HTTPS_TEST_DIR/certs/valid.pem" \ + -subj "/CN=localhost" >/dev/null 2>&1 + +# Fixture: encrypted key (correct passphrase: secret123) +openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 365 \ + -keyout "$HTTPS_TEST_DIR/certs/plain.key" -out "$HTTPS_TEST_DIR/certs/plain.pem" \ + -subj "/CN=encrypted" >/dev/null 2>&1 +openssl rsa -aes256 -in "$HTTPS_TEST_DIR/certs/plain.key" -out "$HTTPS_TEST_DIR/certs/enc.key" \ + -passout pass:secret123 >/dev/null 2>&1 + +# Fixture: another cert (for mismatch test) +openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 365 \ + -keyout "$HTTPS_TEST_DIR/certs/other.key" -out "$HTTPS_TEST_DIR/certs/other.pem" \ + -subj "/CN=other" >/dev/null 2>&1 + +deployment_prepare_config --components infrastructure,application --port-policy development --app-version latest +assert_eq "disabled" "$DEPLOYMENT_HTTPS_MODE" "HTTPS mode should default to disabled" + +# Invalid mode must be rejected +if deployment_prepare_config --components infrastructure --https-mode invalid --app-version latest >/dev/null 2>&1; then + echo "FAIL: invalid HTTPS mode should be rejected" + exit 1 +fi + +# SAN entry classification: IP vs DNS +assert_eq "10.0.0.5|IP:10.0.0.5,DNS:example.com" \ + "$(deployment_https_build_san_entries "10.0.0.5, example.com")" \ + "SAN builder should classify IPv4/DNS and strip spaces" +assert_eq "example.com|DNS:example.com,DNS:api.example.com" \ + "$(deployment_https_build_san_entries "example.com,api.example.com")" \ + "SAN builder should use the first entry as CN" + +# Cert validation: valid pair +DEPLOYMENT_HTTPS_CERT_FILE="$HTTPS_TEST_DIR/certs/valid.pem" +DEPLOYMENT_HTTPS_KEY_FILE="$HTTPS_TEST_DIR/certs/valid.key" +DEPLOYMENT_HTTPS_KEY_PASSPHRASE="" +deployment_https_validate_cert_pair || { + echo "FAIL: valid cert pair should pass validation" + exit 1 +} + +# Cert validation: mismatched pair +DEPLOYMENT_HTTPS_CERT_FILE="$HTTPS_TEST_DIR/certs/other.pem" +DEPLOYMENT_HTTPS_KEY_FILE="$HTTPS_TEST_DIR/certs/valid.key" +if deployment_https_validate_cert_pair >/dev/null 2>&1; then + echo "FAIL: mismatched cert pair should fail validation" + exit 1 +fi + +# Cert validation: invalid PEM content +printf 'not a certificate\n' > "$HTTPS_TEST_DIR/certs/bad.pem" +DEPLOYMENT_HTTPS_CERT_FILE="$HTTPS_TEST_DIR/certs/bad.pem" +DEPLOYMENT_HTTPS_KEY_FILE="$HTTPS_TEST_DIR/certs/valid.key" +if deployment_https_validate_cert_pair >/dev/null 2>&1; then + echo "FAIL: invalid PEM certificate should fail validation" + exit 1 +fi + +# Cert validation: encrypted key with correct passphrase +DEPLOYMENT_HTTPS_CERT_FILE="$HTTPS_TEST_DIR/certs/plain.pem" +DEPLOYMENT_HTTPS_KEY_FILE="$HTTPS_TEST_DIR/certs/enc.key" +DEPLOYMENT_HTTPS_KEY_PASSPHRASE="secret123" +deployment_https_validate_cert_pair || { + echo "FAIL: encrypted key with correct passphrase should pass validation" + exit 1 +} + +# Cert validation: encrypted key with wrong passphrase +DEPLOYMENT_HTTPS_KEY_PASSPHRASE="wrong" +if deployment_https_validate_cert_pair >/dev/null 2>&1; then + echo "FAIL: wrong passphrase should fail validation" + exit 1 +fi + +# Cert validation: encrypted key without passphrase should demand one +DEPLOYMENT_HTTPS_KEY_PASSPHRASE="" +if deployment_https_validate_cert_pair >/dev/null 2>&1; then + echo "FAIL: encrypted key without passphrase should fail validation" + exit 1 +fi + +# Custom mode: missing cert file must fail with a clear error +if deployment_prepare_config --components infrastructure --https-mode custom \ + --https-cert-file "$HTTPS_TEST_DIR/certs/missing.pem" \ + --https-key-file "$HTTPS_TEST_DIR/certs/valid.key" --app-version latest >/dev/null 2>&1; then + echo "FAIL: custom mode with missing cert file should fail" + exit 1 +fi + +# Self-signed cert generation: explicit SAN, first run creates the pair +DEPLOYMENT_HTTPS_MODE="self-signed" +DEPLOYMENT_HTTPS_SAN="10.1.2.3,example.internal" +ROOT_DIR="$HTTPS_TEST_DIR/ssl-root" +deployment_https_ensure_self_signed_cert "$ROOT_DIR/nginx/ssl" || { + echo "FAIL: self-signed cert generation should succeed" + exit 1 +} +assert_contains "$(openssl x509 -in "$ROOT_DIR/nginx/ssl/server.pem" -noout -text 2>/dev/null)" \ + "IP Address:10.1.2.3" "generated cert should include the explicit IP SAN" +assert_contains "$(openssl x509 -in "$ROOT_DIR/nginx/ssl/server.pem" -noout -text 2>/dev/null)" \ + "DNS:example.internal" "generated cert should include the explicit DNS SAN" + +# Self-signed cert reuse: a second run must not change the certificate +HTTPS_CERT_BEFORE="$(openssl x509 -in "$ROOT_DIR/nginx/ssl/server.pem" -noout -fingerprint -sha256 2>/dev/null)" +DEPLOYMENT_HTTPS_SAN="" # force re-detection path; existing pair must still be reused +deployment_https_ensure_self_signed_cert "$ROOT_DIR/nginx/ssl" || { + echo "FAIL: self-signed cert reuse should succeed" + exit 1 +} +assert_eq "$HTTPS_CERT_BEFORE" \ + "$(openssl x509 -in "$ROOT_DIR/nginx/ssl/server.pem" -noout -fingerprint -sha256 2>/dev/null)" \ + "existing valid cert pair should be reused without regeneration" + +# Self-signed cert regeneration: a corrupted cert must trigger regeneration +printf 'broken' > "$ROOT_DIR/nginx/ssl/server.pem" +deployment_https_ensure_self_signed_cert "$ROOT_DIR/nginx/ssl" || { + echo "FAIL: self-signed cert regeneration should succeed" + exit 1 +} +if openssl x509 -in "$ROOT_DIR/nginx/ssl/server.pem" -noout >/dev/null 2>&1; then + assert_not_eq "$HTTPS_CERT_BEFORE" \ + "$(openssl x509 -in "$ROOT_DIR/nginx/ssl/server.pem" -noout -fingerprint -sha256 2>/dev/null)" \ + "corrupted cert should be regenerated" +else + echo "FAIL: regenerated cert should be valid PEM" + exit 1 +fi +unset ROOT_DIR + +# K8s port values: disabled keeps web on NodePort 30000 +deployment_prepare_config --components infrastructure,application --port-policy production --app-version latest +HTTPS_DISABLED_PORTS="$(deployment_render_k8s_port_values)" +assert_contains "$HTTPS_DISABLED_PORTS" 'type: "NodePort"' "disabled HTTPS should keep web as NodePort" +if [[ "$HTTPS_DISABLED_PORTS" == *"nexent-nginx"* ]]; then + echo "FAIL: disabled HTTPS should not render the nginx service block" + exit 1 +fi + +# K8s port values: enabled switches web to ClusterIP and gives 30000 to nginx +deployment_prepare_config --components infrastructure,application --port-policy production --https-mode self-signed --app-version latest +HTTPS_ENABLED_PORTS="$(deployment_render_k8s_port_values)" +assert_contains "$HTTPS_ENABLED_PORTS" 'type: "ClusterIP"' "enabled HTTPS should switch web to ClusterIP" +assert_contains "$HTTPS_ENABLED_PORTS" $'nexent-nginx:\n enabled: true\n services:\n nginx:\n type: "NodePort"\n entryPort: 30000\n nodePort: 30000' "enabled HTTPS should render nginx NodePort 30000" + + +# Custom mode with an encrypted key: prepare must materialize a decrypted copy +deployment_prepare_config --components infrastructure --https-mode custom \ + --https-cert-file "$HTTPS_TEST_DIR/certs/plain.pem" \ + --https-key-file "$HTTPS_TEST_DIR/certs/enc.key" \ + --https-key-passphrase secret123 --app-version latest >/dev/null 2>&1 || { + echo "FAIL: custom mode with encrypted key should pass prepare config" + exit 1 +} +ROOT_DIR="$HTTPS_TEST_DIR/custom-root" +deployment_https_prepare || { + echo "FAIL: deployment_https_prepare should succeed with an encrypted custom key" + exit 1 +} +assert_contains "$DEPLOYMENT_HTTPS_KEY_PATH" "$HTTPS_TEST_DIR/custom-root/nginx/ssl/server.key" \ + "custom key path should point at the materialized copy" +if ! openssl rsa -in "$DEPLOYMENT_HTTPS_KEY_PATH" -check -noout >/dev/null 2>&1; then + echo "FAIL: materialized key should load without a passphrase" + exit 1 +fi +if grep -q "ENCRYPTED" "$DEPLOYMENT_HTTPS_KEY_PATH" 2>/dev/null; then + echo "FAIL: materialized key should be unencrypted" + exit 1 +fi +if ! grep -q "ENCRYPTED" "$HTTPS_TEST_DIR/certs/enc.key"; then + echo "FAIL: original encrypted key file must not be modified" + exit 1 +fi +if [ "$(openssl x509 -in "$DEPLOYMENT_HTTPS_CERT_PATH" -noout -fingerprint -sha256 2>/dev/null)" != \ + "$(openssl x509 -in "$HTTPS_TEST_DIR/certs/plain.pem" -noout -fingerprint -sha256 2>/dev/null)" ]; then + echo "FAIL: materialized cert should match the source cert" + exit 1 +fi +unset ROOT_DIR + +# Custom mode with a wrong passphrase: config validation must reject it +if deployment_prepare_config --components infrastructure --https-mode custom \ + --https-cert-file "$HTTPS_TEST_DIR/certs/plain.pem" \ + --https-key-file "$HTTPS_TEST_DIR/certs/enc.key" \ + --https-key-passphrase wrongpass --app-version latest >/dev/null 2>&1; then + echo "FAIL: wrong passphrase should fail config validation" + exit 1 +fi + +HTTPS_HELM_VALUES="$TMP_DIR/https-generated-values.yaml" +# Helm chart values: enabled renders the nginx block with certificate content +deployment_prepare_config --components infrastructure,application --port-policy production --https-mode self-signed --app-version latest >/dev/null 2>&1 +ROOT_DIR="$HTTPS_TEST_DIR/ssl-root" +deployment_https_prepare || { + echo "FAIL: deployment_https_prepare should succeed for self-signed mode" + exit 1 +} +deployment_render_helm_chart_values > "$HTTPS_HELM_VALUES" +assert_contains "$(cat "$HTTPS_HELM_VALUES")" $'nexent-nginx:\n enabled: true' "helm values should enable the nginx subchart" +assert_contains "$(cat "$HTTPS_HELM_VALUES")" "BEGIN CERTIFICATE" "helm values should embed the certificate content" + +# Reset to disabled for the summary path +deployment_prepare_config --components infrastructure,application --port-policy production --app-version latest +assert_eq "disabled" "$DEPLOYMENT_HTTPS_MODE" "HTTPS mode should reset to disabled after re-prepare" + echo "All deployment common tests passed." diff --git a/doc/docs/en/quick-start/installation.md b/doc/docs/en/quick-start/installation.md index 11f9fba71e..2384f32f62 100644 --- a/doc/docs/en/quick-start/installation.md +++ b/doc/docs/en/quick-start/installation.md @@ -72,10 +72,29 @@ bash deploy.sh docker --components infrastructure,application,data-process,supab # Use mainland China image sources bash deploy.sh docker --image-source mainland -# Use local latest images ++# Use local latest images bash deploy.sh docker --image-source local-latest ``` +#### HTTPS (Optional) + +Nexent can terminate HTTPS at an Nginx reverse proxy installed alongside the stack. The option is disabled by default and reuses the existing entry port: Docker deployments keep port 3000 and Kubernetes deployments keep NodePort 30000. When HTTPS is enabled, Nginx publishes the entry port and the web container stays cluster-internal; plain HTTP requests to the same port are redirected to HTTPS automatically. + +Enable it interactively (the installer asks one question: the HTTPS mode) or non-interactively: + +```bash +# Self-signed certificate (recommended for internal deployments) +bash deploy.sh docker --defaults --https-mode self-signed + +# Your own certificate (paths may also be preset via deploy/env/.env) +bash deploy.sh docker --defaults --https-mode custom --https-cert-file /path/to/server.pem --https-key-file /path/to/server.key +``` + +- **self-signed**: the installer generates a 99-year certificate with an unencrypted private key under `/nginx/ssl/` and reuses it on redeployment. SAN entries are auto-detected from the host network interfaces (loopback and Docker bridges excluded); preset `NEXENT_HTTPS_SAN` in `deploy/env/.env` to override (for example `NEXENT_HTTPS_SAN=10.0.0.5,example.com`). Browsers show an untrusted-certificate warning; import the certificate into your trust store to silence it. +- **custom**: point `NEXENT_HTTPS_CERT_FILE` and `NEXENT_HTTPS_KEY_FILE` at your PEM files. The installer validates the pair (PEM format, key/cert match, expiry) before deploying. Encrypted private keys are supported: provide the passphrase via `NEXENT_HTTPS_KEY_PASSPHRASE` or `--https-key-passphrase`; like other deployment credentials, it is stored in plain text in `deploy/env/.env`. +- After enabling HTTPS, update `SITE_URL` in `deploy/env/.env` (for example `SITE_URL=https://your-host:3000`) so auth callbacks and generated links use the HTTPS entry point. +- Disabling HTTPS again (`--https-mode disabled`) returns the entry port to the web container; the certificate files are kept and can be reused later. + After a successful deployment, non-sensitive choices are saved to `deploy/docker/deploy.options`. `--defaults` reuses that file when it exists, otherwise it uses built-in defaults. The next interactive deployment can reuse the local config or run a full reconfiguration. #### ⚠️ Important Notes diff --git a/doc/docs/zh/quick-start/installation.md b/doc/docs/zh/quick-start/installation.md index 47c5dce095..9eb753e259 100644 --- a/doc/docs/zh/quick-start/installation.md +++ b/doc/docs/zh/quick-start/installation.md @@ -72,10 +72,29 @@ bash deploy.sh docker --components infrastructure,application,data-process,supab # 使用中国大陆镜像源 bash deploy.sh docker --image-source mainland -# 使用本地 latest 镜像 ++# 使用本地 latest 镜像 bash deploy.sh docker --image-source local-latest ``` +#### HTTPS(可选) + +Nexent 可通过随部署安装的 Nginx 反向代理终结 HTTPS。该选项默认关闭,并复用现有入口端口:Docker 部署继续使用 3000 端口,Kubernetes 部署继续使用 NodePort 30000。启用 HTTPS 后,入口端口由 Nginx 发布,web 容器转为集群内部访问;同端口的 HTTP 请求会自动重定向到 HTTPS。 + +可交互启用(安装器只询问一个问题:HTTPS 模式),也可非交互启用: + +```bash +# 自签证书(内网部署推荐) +bash deploy.sh docker --defaults --https-mode self-signed + +# 使用自己的证书(路径也可以预先写入 deploy/env/.env) +bash deploy.sh docker --defaults --https-mode custom --https-cert-file /path/to/server.pem --https-key-file /path/to/server.key +``` + +- **self-signed**:安装器生成 99 年有效期的自签证书(私钥不加密),保存在 `/nginx/ssl/`,重复部署时自动复用。SAN 条目从部署主机网卡自动探测(排除回环与 Docker 网桥);如需指定,在 `deploy/env/.env` 预填 `NEXENT_HTTPS_SAN`(例如 `NEXENT_HTTPS_SAN=10.0.0.5,example.com`)。浏览器会提示证书不受信任,将证书导入系统信任链后即可消除。 +- **custom**:通过 `NEXENT_HTTPS_CERT_FILE` 与 `NEXENT_HTTPS_KEY_FILE` 指定 PEM 证书与私钥。安装器在部署前校验证书对(PEM 格式、证书与私钥匹配、有效期)。支持加密私钥:通过 `NEXENT_HTTPS_KEY_PASSPHRASE` 或 `--https-key-passphrase` 提供密码;与其他部署凭证一致,密码以明文保存在 `deploy/env/.env` 中。 +- 启用 HTTPS 后,请同步更新 `deploy/env/.env` 中的 `SITE_URL`(例如 `SITE_URL=https://your-host:3000`),确保认证回调与生成的链接使用 HTTPS 入口。 +- 再次禁用 HTTPS(`--https-mode disabled`)后,入口端口交还给 web 容器;证书文件会保留,之后可重新启用。 + 部署成功后,非敏感部署选项会保存到 `deploy/docker/deploy.options`。`--defaults` 会优先复用该文件;文件不存在时使用内置默认值。下次交互部署时可选择复用本地配置或重新全量配置。 From 664af106c309f8188a517f9226888cd5863080fd Mon Sep 17 00:00:00 2001 From: MoeexT Date: Mon, 28 Sep 2026 19:08:31 +0800 Subject: [PATCH 2/9] fix(deploy): address HTTPS review findings - Seed HTTPS config from NEXENT_HTTPS_* env vars before defaults - Detect SAN via ip -o -4 addr (Linux) with ifconfig fallback - Start docker nginx before core services; clean up on disabled; bind web to loopback 3001 when HTTPS is enabled - chmod 600 generated Helm values containing private keys - Support EC keys via openssl pkey with LibreSSL fallback - Route nginx image through NGINX_IMAGE and registry prefix - Validate cert/key pair and expiry when reusing self-signed certs - Persist custom key passphrase to .env --- deploy/common/common.sh | 95 +++++++++++++------ deploy/docker/compose/docker-compose.prod.yml | 2 +- deploy/docker/compose/docker-compose.yml | 2 +- deploy/docker/deploy.sh | 35 ++++--- deploy/tests/test_common.sh | 2 +- 5 files changed, 94 insertions(+), 42 deletions(-) mode change 100755 => 100644 deploy/common/common.sh diff --git a/deploy/common/common.sh b/deploy/common/common.sh old mode 100755 new mode 100644 index a3d3e47340..25818f740d --- a/deploy/common/common.sh +++ b/deploy/common/common.sh @@ -864,9 +864,11 @@ deployment_init_defaults() { DEPLOYMENT_IMAGE_REGISTRY_PREFIX="$DEPLOYMENT_IMAGE_REGISTRY_PREFIX_DEFAULT" DEPLOYMENT_APP_VERSION="${APP_VERSION:-latest}" DEPLOYMENT_MONITORING_PROVIDER="$DEPLOYMENT_MONITORING_PROVIDER_DEFAULT" - DEPLOYMENT_HTTPS_MODE="disabled" - DEPLOYMENT_HTTPS_CERT_FILE="" - DEPLOYMENT_HTTPS_KEY_FILE="" + # Seed from .env (sourced earlier) so pre-configured values are honored; + # CLI flags and saved deploy.options override these in deployment_prepare_config. + DEPLOYMENT_HTTPS_MODE="${NEXENT_HTTPS_MODE:-disabled}" + DEPLOYMENT_HTTPS_CERT_FILE="${NEXENT_HTTPS_CERT_FILE:-}" + DEPLOYMENT_HTTPS_KEY_FILE="${NEXENT_HTTPS_KEY_FILE:-}" DEPLOYMENT_HTTPS_KEY_PASSPHRASE="${NEXENT_HTTPS_KEY_PASSPHRASE:-}" DEPLOYMENT_HTTPS_SAN="${NEXENT_HTTPS_SAN:-}" DEPLOYMENT_USE_LOCAL_CONFIG="false" @@ -1263,24 +1265,29 @@ deployment_validate() { fi } -deployment_https_rsa_check() { - # Bash 3.2-safe RSA key check: builds -passin args only when a passphrase exists. +deployment_https_key_check() { + # Bash 3.2-safe key check for any key type (RSA, EC, Ed25519...): builds + # -passin args only when a passphrase exists. Uses -check when available + # (OpenSSL; validates the key itself) and falls back to plain parsing on + # LibreSSL, where -check is unsupported for non-RSA keys. local key_file="$1" local passphrase="$2" if [ -n "$passphrase" ]; then - openssl rsa -in "$key_file" -check -noout -passin "pass:$passphrase" >/dev/null 2>&1 + openssl pkey -in "$key_file" -check -noout -passin "pass:$passphrase" >/dev/null 2>&1 \ + || openssl pkey -in "$key_file" -noout -passin "pass:$passphrase" >/dev/null 2>&1 else - openssl rsa -in "$key_file" -check -noout >/dev/null 2>&1 + openssl pkey -in "$key_file" -check -noout >/dev/null 2>&1 \ + || openssl pkey -in "$key_file" -noout >/dev/null 2>&1 fi } -deployment_https_rsa_pubkey() { +deployment_https_key_pubkey() { local key_file="$1" local passphrase="$2" if [ -n "$passphrase" ]; then - openssl rsa -in "$key_file" -pubout -passin "pass:$passphrase" 2>/dev/null + openssl pkey -in "$key_file" -pubout -passin "pass:$passphrase" 2>/dev/null else - openssl rsa -in "$key_file" -pubout 2>/dev/null + openssl pkey -in "$key_file" -pubout 2>/dev/null fi } @@ -1297,12 +1304,12 @@ deployment_https_validate_cert_pair() { return 1 fi - if ! deployment_https_rsa_check "$key_file" "$passphrase"; then + if ! deployment_https_key_check "$key_file" "$passphrase"; then if [ -n "$passphrase" ]; then deployment_error "$(deployment_i18n validation.https_passphrase_wrong)" else # A key that fails without a passphrase may be passphrase-protected. - if openssl rsa -in "$key_file" -check -noout -passin pass: >/dev/null 2>&1; then + if deployment_https_key_check "$key_file" "" >/dev/null 2>&1; then deployment_error "$(deployment_i18n validation.https_passphrase_required)" else deployment_error "$(deployment_i18n validation.https_key_invalid_pem "$key_file")" @@ -1313,7 +1320,7 @@ deployment_https_validate_cert_pair() { local cert_pubkey key_pubkey cert_pubkey="$(openssl x509 -in "$cert_file" -pubkey -noout 2>/dev/null | openssl sha256 2>/dev/null || true)" - key_pubkey="$(deployment_https_rsa_pubkey "$key_file" "$passphrase" | openssl sha256 2>/dev/null || true)" + key_pubkey="$(deployment_https_key_pubkey "$key_file" "$passphrase" | openssl sha256 2>/dev/null || true)" if [ -z "$cert_pubkey" ] || [ -z "$key_pubkey" ] || [ "$cert_pubkey" != "$key_pubkey" ]; then deployment_error "$(deployment_i18n validation.https_pair_mismatch)" return 1 @@ -2170,6 +2177,8 @@ deployment_apply_image_source() { export POSTGRESQL_IMAGE="${POSTGRESQL_IMAGE:-postgres:15-alpine}" export REDIS_IMAGE="${REDIS_IMAGE:-redis:alpine}" export MINIO_IMAGE="${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2023-12-20T01-00-02Z}" + # Nginx image for the optional HTTPS reverse proxy (docker and k8s). + export NGINX_IMAGE="${NGINX_IMAGE:-nginx:alpine}" export OPENSSH_SERVER_IMAGE="${OPENSSH_SERVER_IMAGE:-nexent/nexent-ubuntu-terminal:$version}" export SUPABASE_KONG="${SUPABASE_KONG:-kong:2.8.1}" export SUPABASE_GOTRUE="${SUPABASE_GOTRUE:-supabase/gotrue:v2.170.0}" @@ -2198,6 +2207,7 @@ deployment_apply_image_source() { POSTGRESQL_IMAGE \ REDIS_IMAGE \ MINIO_IMAGE \ + NGINX_IMAGE \ OPENSSH_SERVER_IMAGE \ SUPABASE_KONG \ SUPABASE_GOTRUE \ @@ -2302,6 +2312,9 @@ deployment_render_docker_env() { printf 'LANGFUSE_REDIS_IMAGE="%s"\n' "$LANGFUSE_REDIS_IMAGE" printf 'LANGFUSE_POSTGRES_IMAGE="%s"\n' "$LANGFUSE_POSTGRES_IMAGE" } > "$output_file" + # Generated values may embed TLS private keys and other secrets; restrict + # permissions so the default umask does not leave them world-readable. + chmod 600 "$output_file" } deployment_render_component_values() { @@ -2466,7 +2479,7 @@ deployment_render_helm_chart_values() { if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then printf 'nexent-nginx:\n' printf ' enabled: true\n' - printf ' images:\n nginx:\n repository: "%s"\n tag: "%s"\n pullPolicy: "IfNotPresent"\n' "$(deployment_image_repo nginx:alpine | cut -d: -f1)" "$(deployment_image_tag nginx:alpine)" + printf ' images:\n nginx:\n repository: "%s"\n tag: "%s"\n pullPolicy: "IfNotPresent"\n' "$(deployment_image_repo "$NGINX_IMAGE")" "$(deployment_image_tag "$NGINX_IMAGE")" if [ -r "${DEPLOYMENT_HTTPS_CERT_PATH:-}" ] && [ -r "${DEPLOYMENT_HTTPS_KEY_PATH:-}" ]; then printf ' tls:\n' # Render PEM contents as a YAML literal block: multi-line certificates @@ -2814,13 +2827,26 @@ deployment_https_detect_san_addresses() { # Auto-detect host NIC addresses (filter lo / docker0 / veth* / br-*) and hostname. local addresses="" local interface address - while IFS=' ' read -r interface address; do - [ -z "$interface" ] && continue - case "$interface" in - lo|docker0|veth*|br-*) continue ;; - esac - [ -n "$address" ] && addresses="$(deployment_join_csv "$addresses" "$address")" - done < <(ifconfig -a 2>/dev/null | awk -F': ' '/^[a-zA-Z0-9_-]+: /{iface=$1} /inet /{print iface" "$2}' | sed 's|/%.*||') + if command -v ip >/dev/null 2>&1; then + # Prefer the Linux-standard "ip" tool: one line per address, stable format. + while IFS=' ' read -r interface address; do + [ -z "$interface" ] && continue + case "$interface" in + lo|docker0|veth*|br-*|virbr*) continue ;; + esac + [ -n "$address" ] && addresses="$(deployment_join_csv "$addresses" "$address")" + done < <(ip -o -4 addr show 2>/dev/null | awk '{print $2, $4}' | sed 's|/.*||') + else + # ifconfig fallback; strip the optional "addr:" prefix (net-tools legacy + # format) so both "inet 1.2.3.4" and "inet addr:1.2.3.4" yield the address. + while IFS=' ' read -r interface address; do + [ -z "$interface" ] && continue + case "$interface" in + lo|docker0|veth*|br-*) continue ;; + esac + [ -n "$address" ] && addresses="$(deployment_join_csv "$addresses" "$address")" + done < <(ifconfig -a 2>/dev/null | awk '/^[a-zA-Z0-9_-]+: /{iface=$1} /inet /{sub(/addr:/,"",$2); print iface" "$2}' | sed 's|/.*||') + fi local hostname_addr hostname_addr="$(hostname 2>/dev/null || true)" [ -n "$hostname_addr" ] && addresses="$(deployment_join_csv "$addresses" "$hostname_addr")" @@ -2866,10 +2892,21 @@ deployment_https_ensure_self_signed_cert() { DEPLOYMENT_HTTPS_SAN_RESOLVED="$san_input" if [ -r "$cert_file" ] && [ -r "$key_file" ]; then - if openssl x509 -in "$cert_file" -noout >/dev/null 2>&1 && openssl rsa -in "$key_file" -check -noout >/dev/null 2>&1; then - DEPLOYMENT_HTTPS_CERT_PATH="$cert_file" - DEPLOYMENT_HTTPS_KEY_PATH="$key_file" - return 0 + # pkey supports RSA/EC keys; -check is unavailable on LibreSSL, so only + # verify the key parses successfully. + if openssl x509 -in "$cert_file" -noout >/dev/null 2>&1 && openssl pkey -in "$key_file" -noout >/dev/null 2>&1; then + local reused_cert_pubkey reused_key_pubkey reused_end_date reused_epoch_end + reused_cert_pubkey="$(openssl x509 -in "$cert_file" -pubkey -noout 2>/dev/null | openssl sha256 2>/dev/null || true)" + reused_key_pubkey="$(openssl pkey -in "$key_file" -pubout 2>/dev/null | openssl sha256 2>/dev/null || true)" + if [ -n "$reused_cert_pubkey" ] && [ "$reused_cert_pubkey" = "$reused_key_pubkey" ]; then + reused_end_date="$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d= -f2)" + reused_epoch_end="$(date -j -f '%b %e %H:%M:%S %Y GMT' "$reused_end_date" +%s 2>/dev/null || date -d "$reused_end_date" +%s 2>/dev/null || true)" + if [ -z "$reused_epoch_end" ] || [ "$reused_epoch_end" -gt "$(date +%s)" ]; then + DEPLOYMENT_HTTPS_CERT_PATH="$cert_file" + DEPLOYMENT_HTTPS_KEY_PATH="$key_file" + return 0 + fi + fi fi fi @@ -2915,7 +2952,6 @@ EOF return 0 } -deployment_https_prepare() { deployment_https_materialize_custom_cert() { # Copy the custom cert/key into ROOT_DIR/nginx/ssl/, decrypting an # encrypted private key in the process. Official nginx images do not ship @@ -2940,7 +2976,7 @@ deployment_https_materialize_custom_cert() { if [ -n "$passphrase" ]; then # Decrypt the key copy; the source file stays untouched. - if ! openssl rsa -in "$key_src" -out "$key_dst" -passin "pass:$passphrase" 2>/dev/null; then + if ! openssl pkey -in "$key_src" -out "$key_dst" -passin "pass:$passphrase" 2>/dev/null; then rm -f "$cert_dst" deployment_error "$(deployment_i18n validation.https_passphrase_wrong)" return 1 @@ -2959,6 +2995,7 @@ deployment_https_materialize_custom_cert() { return 0 } +deployment_https_prepare() { # Entry point: prepare certificates before deployment. case "$DEPLOYMENT_HTTPS_MODE" in disabled) @@ -2977,6 +3014,10 @@ deployment_https_materialize_custom_cert() { if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then deployment_update_env_var_file "$(deployment_env_dir)/.env" "NEXENT_HTTPS_MODE" "$DEPLOYMENT_HTTPS_MODE" [ -n "${DEPLOYMENT_HTTPS_SAN_RESOLVED:-}" ] && deployment_update_env_var_file "$(deployment_env_dir)/.env" "NEXENT_HTTPS_SAN" "$DEPLOYMENT_HTTPS_SAN_RESOLVED" + # Persist the passphrase so subsequent non-interactive runs can decrypt + # the same custom key without prompting again. + [ "$DEPLOYMENT_HTTPS_MODE" = "custom" ] && [ -n "${DEPLOYMENT_HTTPS_KEY_PASSPHRASE:-}" ] \ + && deployment_update_env_var_file "$(deployment_env_dir)/.env" "NEXENT_HTTPS_KEY_PASSPHRASE" "$DEPLOYMENT_HTTPS_KEY_PASSPHRASE" fi return 0 } diff --git a/deploy/docker/compose/docker-compose.prod.yml b/deploy/docker/compose/docker-compose.prod.yml index ef60555869..882512ceaf 100644 --- a/deploy/docker/compose/docker-compose.prod.yml +++ b/deploy/docker/compose/docker-compose.prod.yml @@ -227,7 +227,7 @@ services: nexent-nginx: - image: nginx:alpine + image: ${NGINX_IMAGE:-nginx:alpine} container_name: nexent-nginx restart: always profiles: diff --git a/deploy/docker/compose/docker-compose.yml b/deploy/docker/compose/docker-compose.yml index 2063036d96..1e3eb0befb 100644 --- a/deploy/docker/compose/docker-compose.yml +++ b/deploy/docker/compose/docker-compose.yml @@ -249,7 +249,7 @@ services: nexent-nginx: - image: nginx:alpine + image: ${NGINX_IMAGE:-nginx:alpine} container_name: nexent-nginx restart: always profiles: diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh index 58b8a268a4..eee59d0f44 100755 --- a/deploy/docker/deploy.sh +++ b/deploy/docker/deploy.sh @@ -1235,16 +1235,25 @@ deploy_core_services() { deploy_https_nginx() { # Start the Nginx HTTPS reverse proxy when HTTPS is enabled. if [ "$DEPLOYMENT_HTTPS_MODE" = "disabled" ] || [ -z "$DEPLOYMENT_HTTPS_MODE" ]; then + # Stop and remove the HTTPS profile service so it releases host port 3000 + # and the web service can take the port back on this same deployment run. + if ${docker_compose_command} --env-file "$ROOT_ENV_FILE" -p nexent --profile https -f "$COMPOSE_DIR/docker-compose${COMPOSE_FILE_SUFFIX}" ps -q nexent-nginx 2>/dev/null | grep -q .; then + echo "Stopping Nginx HTTPS reverse proxy (HTTPS disabled)..." + if ! ${docker_compose_command} --env-file "$ROOT_ENV_FILE" -p nexent --profile https -f "$COMPOSE_DIR/docker-compose${COMPOSE_FILE_SUFFIX}" rm -sf nexent-nginx 2>/dev/null; then + docker rm -f nexent-nginx 2>/dev/null || true + fi + fi export NEXENT_WEB_PORT_MAPPING="3000:3000" return 0 fi deployment_https_prepare || return 1 - # Publish only the container port (no host binding) so nginx can take 3000. - # Compose "${VAR-default}" keeps the default only when VAR is unset; - # an empty value must win so the web service stops publishing the host port. - export NEXENT_WEB_PORT_MAPPING="3000" + # Bind web to a loopback-only alternate port so nginx can take the public + # 3000 entry port. Compose short syntax cannot fully unpublish a port in a + # way that is compatible with old Docker/Compose versions, so loopback keeps + # local debugging possible without exposing a plaintext entry to the network. + export NEXENT_WEB_PORT_MAPPING="127.0.0.1:3001:3000" echo "🔒 Starting Nginx HTTPS reverse proxy (nexent-nginx)..." if ! ${docker_compose_command} --env-file "$ROOT_ENV_FILE" -p nexent --profile https -f "$COMPOSE_DIR/docker-compose${COMPOSE_FILE_SUFFIX}" up -d nexent-nginx; then @@ -1995,26 +2004,28 @@ main_deploy() { return 0 fi - # Start core services - deploy_core_services || { + # Configure HTTPS state before core services start so nexent-web is created + # with the right port mapping on the first run (avoids a recreate cycle). + deploy_https_nginx || { if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then - echo "❌ 核心服务部署失败" + echo "❌ HTTPS 反向代理部署失败" else - echo "❌ Core services deployment failed" + echo "HTTPS reverse proxy deployment failed" fi exit 1 } - # Start Nginx HTTPS reverse proxy when HTTPS is enabled - deploy_https_nginx || { + # Start core services + deploy_core_services || { if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then - echo "❌ HTTPS 反向代理部署失败" + echo "❌ 核心服务部署失败" else - echo "❌ HTTPS reverse proxy deployment failed" + echo "❌ Core services deployment failed" fi exit 1 } + if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then echo " ✅ 核心服务启动成功" else diff --git a/deploy/tests/test_common.sh b/deploy/tests/test_common.sh index 31f2a5986a..4a03ed13ac 100755 --- a/deploy/tests/test_common.sh +++ b/deploy/tests/test_common.sh @@ -1310,7 +1310,7 @@ deployment_https_prepare || { } assert_contains "$DEPLOYMENT_HTTPS_KEY_PATH" "$HTTPS_TEST_DIR/custom-root/nginx/ssl/server.key" \ "custom key path should point at the materialized copy" -if ! openssl rsa -in "$DEPLOYMENT_HTTPS_KEY_PATH" -check -noout >/dev/null 2>&1; then +if ! openssl pkey -in "$DEPLOYMENT_HTTPS_KEY_PATH" -noout >/dev/null 2>&1; then echo "FAIL: materialized key should load without a passphrase" exit 1 fi From c1e8d1ac5c6ef0e1b9a4659454d13a92f5f90807 Mon Sep 17 00:00:00 2001 From: MoeexT Date: Mon, 28 Sep 2026 20:00:54 +0800 Subject: [PATCH 3/9] refactor(deploy): split HTTP and HTTPS entry ports for nginx HTTPS - Give HTTPS a dedicated entry: Docker port 3100, K8s NodePort 31000 - Keep the plain HTTP entry unchanged (Docker 3000 / K8s NodePort 30000); web no longer steps down to ClusterIP or a loopback-only binding - Drop the same-port error_page 497 redirect now that entries are split - Update TUI/summary wording, tests and install docs for the new ports --- deploy/common/common.sh | 18 +++++------------- deploy/docker/assets/nginx/nginx.conf | 9 +++------ deploy/docker/compose/docker-compose.prod.yml | 2 +- deploy/docker/compose/docker-compose.yml | 2 +- deploy/docker/deploy.sh | 10 ++-------- .../nexent-nginx/templates/configmap.yaml | 2 +- .../nexent/charts/nexent-nginx/values.yaml | 9 +++------ deploy/tests/test_common.sh | 6 +++--- doc/docs/en/quick-start/installation.md | 6 +++--- doc/docs/zh/quick-start/installation.md | 6 +++--- 10 files changed, 25 insertions(+), 45 deletions(-) diff --git a/deploy/common/common.sh b/deploy/common/common.sh index 25818f740d..24e3be192a 100644 --- a/deploy/common/common.sh +++ b/deploy/common/common.sh @@ -158,7 +158,7 @@ deployment_i18n_format() { tui.monitoring.grafana) printf '本地 Grafana + Tempo traces 看板' ;; tui.monitoring.zipkin) printf '本地 Zipkin trace 浏览 UI' ;; tui.https.title) printf '选择 HTTPS 模式' ;; - tui.https.subtitle) printf '启用 HTTPS 后会额外安装一个 Nginx 反向代理容器,在现有入口端口上终结 TLS;同端口的 HTTP 请求会自动重定向到 HTTPS。' ;; + tui.https.subtitle) printf '启用 HTTPS 后会额外安装一个 Nginx 反向代理容器,在独立端口终结 TLS(Docker 3100 / K8s 31000);原 HTTP 入口 3000/30000 保持不变。' ;; tui.https.description) printf '自签证书自动生成(SAN 自动探测,无需输入);custom 模式使用你自己的证书与私钥。' ;; tui.https.disabled) printf '不启用 HTTPS(默认,行为与现状一致)' ;; tui.https.self_signed) printf '自动生成自签证书(99 年有效期,浏览器会显示告警)' ;; @@ -253,7 +253,7 @@ deployment_i18n_format() { tui.monitoring.grafana) printf 'local Grafana + Tempo dashboard for traces' ;; tui.monitoring.zipkin) printf 'local Zipkin UI for trace browsing' ;; tui.https.title) printf 'Select HTTPS mode' ;; - tui.https.subtitle) printf 'Enabling HTTPS installs an extra Nginx reverse-proxy container that terminates TLS on the existing entry port; plain HTTP requests on the same port are redirected to HTTPS.' ;; + tui.https.subtitle) printf 'Enabling HTTPS installs an extra Nginx reverse-proxy container that terminates TLS on a dedicated port (Docker 3100 / K8s 31000); the plain HTTP entry 3000/30000 stays unchanged.' ;; tui.https.description) printf 'Self-signed certificates are generated automatically (SAN auto-detected, no input needed); custom mode uses your own certificate and key.' ;; tui.https.disabled) printf 'Keep HTTP only (default, same as before)' ;; tui.https.self_signed) printf 'Generate a self-signed certificate (99-year validity; browsers will warn)' ;; @@ -2373,16 +2373,12 @@ deployment_render_k8s_port_values() { fi local web_type="NodePort" - if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then - # Nginx takes over NodePort 30000; web stays cluster-internal. - web_type="ClusterIP" - fi printf 'nexent-web:\n' printf ' services:\n web:\n type: "%s"\n nodePort: 30000\n' "$web_type" if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then printf 'nexent-nginx:\n' printf ' enabled: true\n' - printf ' services:\n nginx:\n type: "NodePort"\n entryPort: 30000\n nodePort: 30000\n' + printf ' services:\n nginx:\n type: "NodePort"\n entryPort: 31000\n nodePort: 31000\n' fi printf 'nexent-northbound:\n' printf ' services:\n northbound:\n type: "%s"\n nodePort: 30013\n' "$northbound_type" @@ -2428,10 +2424,6 @@ deployment_render_helm_chart_values() { if [ "$DEPLOYMENT_PORT_POLICY" = "development" ]; then internal_type="NodePort" fi - if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then - # Nginx takes over NodePort 30000; web stays cluster-internal. - web_type="ClusterIP" - fi printf 'nexent-config:\n' printf ' enabled: %s\n' "$(deployment_chart_enabled application)" @@ -2718,8 +2710,8 @@ deployment_persist_local_config() { deployment_print_summary() { local target="${1:-all}" - local https_entry_port="3000" - [ "$target" = "k8s" ] && https_entry_port="30000" + local https_entry_port="3100" + [ "$target" = "k8s" ] && https_entry_port="31000" deployment_log "$(deployment_i18n summary.components "$DEPLOYMENT_COMPONENTS")" deployment_log "$(deployment_i18n summary.port_policy "$DEPLOYMENT_PORT_POLICY")" diff --git a/deploy/docker/assets/nginx/nginx.conf b/deploy/docker/assets/nginx/nginx.conf index a6a8751e77..81d21f63b2 100644 --- a/deploy/docker/assets/nginx/nginx.conf +++ b/deploy/docker/assets/nginx/nginx.conf @@ -1,12 +1,13 @@ # Nexent HTTPS reverse proxy (Nginx) -# Terminates TLS on the existing web entry port and proxies to nexent-web. +# Terminates TLS on a dedicated HTTPS port (3100) and proxies to nexent-web, +# which keeps serving plain HTTP on port 3000. map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { - listen 3000 ssl; + listen 3100 ssl; server_name _; # TLS termination (cert/key mounted read-only by the deployment script) @@ -19,10 +20,6 @@ server { ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; - # Plain HTTP on the TLS port -> 301 redirect (same host, same port) - # Preserve the original port when redirecting plain HTTP to HTTPS - error_page 497 https://$host:$server_port$request_uri; - client_max_body_size 1024M; # SSE: disable buffering; keep long-lived streams stable diff --git a/deploy/docker/compose/docker-compose.prod.yml b/deploy/docker/compose/docker-compose.prod.yml index 882512ceaf..e9ed1e2b6b 100644 --- a/deploy/docker/compose/docker-compose.prod.yml +++ b/deploy/docker/compose/docker-compose.prod.yml @@ -235,7 +235,7 @@ services: networks: - nexent ports: - - "3000:3000" + - "3100:3100" volumes: - ${ROOT_DIR}/nginx/ssl:/etc/nginx/ssl:ro - ../assets/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/deploy/docker/compose/docker-compose.yml b/deploy/docker/compose/docker-compose.yml index 1e3eb0befb..058ca9f88c 100644 --- a/deploy/docker/compose/docker-compose.yml +++ b/deploy/docker/compose/docker-compose.yml @@ -257,7 +257,7 @@ services: networks: - nexent ports: - - "3000:3000" + - "3100:3100" volumes: - ${ROOT_DIR}/nginx/ssl:/etc/nginx/ssl:ro - ../assets/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh index eee59d0f44..e5d5f5515c 100755 --- a/deploy/docker/deploy.sh +++ b/deploy/docker/deploy.sh @@ -1235,8 +1235,8 @@ deploy_core_services() { deploy_https_nginx() { # Start the Nginx HTTPS reverse proxy when HTTPS is enabled. if [ "$DEPLOYMENT_HTTPS_MODE" = "disabled" ] || [ -z "$DEPLOYMENT_HTTPS_MODE" ]; then - # Stop and remove the HTTPS profile service so it releases host port 3000 - # and the web service can take the port back on this same deployment run. + # Stop and remove the HTTPS profile service when HTTPS is disabled so a + # previously enabled deployment does not keep the proxy running. if ${docker_compose_command} --env-file "$ROOT_ENV_FILE" -p nexent --profile https -f "$COMPOSE_DIR/docker-compose${COMPOSE_FILE_SUFFIX}" ps -q nexent-nginx 2>/dev/null | grep -q .; then echo "Stopping Nginx HTTPS reverse proxy (HTTPS disabled)..." if ! ${docker_compose_command} --env-file "$ROOT_ENV_FILE" -p nexent --profile https -f "$COMPOSE_DIR/docker-compose${COMPOSE_FILE_SUFFIX}" rm -sf nexent-nginx 2>/dev/null; then @@ -1249,12 +1249,6 @@ deploy_https_nginx() { deployment_https_prepare || return 1 - # Bind web to a loopback-only alternate port so nginx can take the public - # 3000 entry port. Compose short syntax cannot fully unpublish a port in a - # way that is compatible with old Docker/Compose versions, so loopback keeps - # local debugging possible without exposing a plaintext entry to the network. - export NEXENT_WEB_PORT_MAPPING="127.0.0.1:3001:3000" - echo "🔒 Starting Nginx HTTPS reverse proxy (nexent-nginx)..." if ! ${docker_compose_command} --env-file "$ROOT_ENV_FILE" -p nexent --profile https -f "$COMPOSE_DIR/docker-compose${COMPOSE_FILE_SUFFIX}" up -d nexent-nginx; then echo " ❌ ERROR Failed to start nexent-nginx" diff --git a/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/configmap.yaml b/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/configmap.yaml index d8ff005558..78d25e11e6 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/configmap.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-nginx/templates/configmap.yaml @@ -6,5 +6,5 @@ metadata: namespace: {{ .Values.global.namespace }} data: default.conf: | -{{ (.Values.config | replace "$server_port" (printf "%v" .Values.services.nginx.entryPort | default .Values.services.nginx.nodePort)) | indent 4 }} +{{ .Values.config | indent 4 }} {{- end }} diff --git a/deploy/k8s/helm/nexent/charts/nexent-nginx/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-nginx/values.yaml index 9b3be3ea16..b196ad2423 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-nginx/values.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-nginx/values.yaml @@ -20,10 +20,9 @@ resources: services: nginx: type: NodePort - # External port users actually visit (NodePort); used in the HTTP->HTTPS - # redirect because $server_port inside the container is the internal port. - entryPort: 30000 - nodePort: 30000 + # Dedicated HTTPS entry; web keeps NodePort 30000 for plain HTTP. + entryPort: 31000 + nodePort: 31000 # TLS certificate (PEM contents rendered into a Secret by the deployment script) tls: @@ -52,8 +51,6 @@ config: | ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; - error_page 497 https://$host:$server_port$request_uri; - client_max_body_size 1024M; proxy_buffering off; diff --git a/deploy/tests/test_common.sh b/deploy/tests/test_common.sh index 4a03ed13ac..648b3dab53 100755 --- a/deploy/tests/test_common.sh +++ b/deploy/tests/test_common.sh @@ -1288,11 +1288,11 @@ if [[ "$HTTPS_DISABLED_PORTS" == *"nexent-nginx"* ]]; then exit 1 fi -# K8s port values: enabled switches web to ClusterIP and gives 30000 to nginx +# K8s port values: enabled keeps web on NodePort 30000 and adds nginx NodePort 31000 deployment_prepare_config --components infrastructure,application --port-policy production --https-mode self-signed --app-version latest HTTPS_ENABLED_PORTS="$(deployment_render_k8s_port_values)" -assert_contains "$HTTPS_ENABLED_PORTS" 'type: "ClusterIP"' "enabled HTTPS should switch web to ClusterIP" -assert_contains "$HTTPS_ENABLED_PORTS" $'nexent-nginx:\n enabled: true\n services:\n nginx:\n type: "NodePort"\n entryPort: 30000\n nodePort: 30000' "enabled HTTPS should render nginx NodePort 30000" +assert_contains "$HTTPS_ENABLED_PORTS" $'nexent-web:\n services:\n web:\n type: "NodePort"\n nodePort: 30000' "enabled HTTPS should keep web on NodePort 30000" +assert_contains "$HTTPS_ENABLED_PORTS" $'nexent-nginx:\n enabled: true\n services:\n nginx:\n type: "NodePort"\n entryPort: 31000\n nodePort: 31000' "enabled HTTPS should render nginx NodePort 31000" # Custom mode with an encrypted key: prepare must materialize a decrypted copy diff --git a/doc/docs/en/quick-start/installation.md b/doc/docs/en/quick-start/installation.md index 2384f32f62..9fa2bb57a8 100644 --- a/doc/docs/en/quick-start/installation.md +++ b/doc/docs/en/quick-start/installation.md @@ -78,7 +78,7 @@ bash deploy.sh docker --image-source local-latest #### HTTPS (Optional) -Nexent can terminate HTTPS at an Nginx reverse proxy installed alongside the stack. The option is disabled by default and reuses the existing entry port: Docker deployments keep port 3000 and Kubernetes deployments keep NodePort 30000. When HTTPS is enabled, Nginx publishes the entry port and the web container stays cluster-internal; plain HTTP requests to the same port are redirected to HTTPS automatically. +Nexent can terminate HTTPS at an Nginx reverse proxy installed alongside the stack. The option is disabled by default; when enabled, HTTPS uses a dedicated entry port: port 3100 for Docker deployments and NodePort 31000 for Kubernetes deployments. The plain HTTP entry (Docker 3000 / K8s 30000) stays unchanged, so both entries can be used at the same time. Enable it interactively (the installer asks one question: the HTTPS mode) or non-interactively: @@ -92,8 +92,8 @@ bash deploy.sh docker --defaults --https-mode custom --https-cert-file /path/to/ - **self-signed**: the installer generates a 99-year certificate with an unencrypted private key under `/nginx/ssl/` and reuses it on redeployment. SAN entries are auto-detected from the host network interfaces (loopback and Docker bridges excluded); preset `NEXENT_HTTPS_SAN` in `deploy/env/.env` to override (for example `NEXENT_HTTPS_SAN=10.0.0.5,example.com`). Browsers show an untrusted-certificate warning; import the certificate into your trust store to silence it. - **custom**: point `NEXENT_HTTPS_CERT_FILE` and `NEXENT_HTTPS_KEY_FILE` at your PEM files. The installer validates the pair (PEM format, key/cert match, expiry) before deploying. Encrypted private keys are supported: provide the passphrase via `NEXENT_HTTPS_KEY_PASSPHRASE` or `--https-key-passphrase`; like other deployment credentials, it is stored in plain text in `deploy/env/.env`. -- After enabling HTTPS, update `SITE_URL` in `deploy/env/.env` (for example `SITE_URL=https://your-host:3000`) so auth callbacks and generated links use the HTTPS entry point. -- Disabling HTTPS again (`--https-mode disabled`) returns the entry port to the web container; the certificate files are kept and can be reused later. +- After enabling HTTPS, update `SITE_URL` in `deploy/env/.env` (for example `SITE_URL=https://your-host:3100`) so auth callbacks and generated links use the HTTPS entry point. +- Disabling HTTPS again (`--https-mode disabled`) removes the Nginx container; the certificate files are kept and can be reused later. After a successful deployment, non-sensitive choices are saved to `deploy/docker/deploy.options`. `--defaults` reuses that file when it exists, otherwise it uses built-in defaults. The next interactive deployment can reuse the local config or run a full reconfiguration. diff --git a/doc/docs/zh/quick-start/installation.md b/doc/docs/zh/quick-start/installation.md index 9eb753e259..8affebbf67 100644 --- a/doc/docs/zh/quick-start/installation.md +++ b/doc/docs/zh/quick-start/installation.md @@ -78,7 +78,7 @@ bash deploy.sh docker --image-source local-latest #### HTTPS(可选) -Nexent 可通过随部署安装的 Nginx 反向代理终结 HTTPS。该选项默认关闭,并复用现有入口端口:Docker 部署继续使用 3000 端口,Kubernetes 部署继续使用 NodePort 30000。启用 HTTPS 后,入口端口由 Nginx 发布,web 容器转为集群内部访问;同端口的 HTTP 请求会自动重定向到 HTTPS。 +Nexent 可通过随部署安装的 Nginx 反向代理终结 HTTPS。该选项默认关闭,启用后 HTTPS 使用独立入口端口:Docker 部署为 3100 端口,Kubernetes 部署为 NodePort 31000;原 HTTP 入口(Docker 3000 / K8s 30000)保持不变,两种入口可同时使用。 可交互启用(安装器只询问一个问题:HTTPS 模式),也可非交互启用: @@ -92,8 +92,8 @@ bash deploy.sh docker --defaults --https-mode custom --https-cert-file /path/to/ - **self-signed**:安装器生成 99 年有效期的自签证书(私钥不加密),保存在 `/nginx/ssl/`,重复部署时自动复用。SAN 条目从部署主机网卡自动探测(排除回环与 Docker 网桥);如需指定,在 `deploy/env/.env` 预填 `NEXENT_HTTPS_SAN`(例如 `NEXENT_HTTPS_SAN=10.0.0.5,example.com`)。浏览器会提示证书不受信任,将证书导入系统信任链后即可消除。 - **custom**:通过 `NEXENT_HTTPS_CERT_FILE` 与 `NEXENT_HTTPS_KEY_FILE` 指定 PEM 证书与私钥。安装器在部署前校验证书对(PEM 格式、证书与私钥匹配、有效期)。支持加密私钥:通过 `NEXENT_HTTPS_KEY_PASSPHRASE` 或 `--https-key-passphrase` 提供密码;与其他部署凭证一致,密码以明文保存在 `deploy/env/.env` 中。 -- 启用 HTTPS 后,请同步更新 `deploy/env/.env` 中的 `SITE_URL`(例如 `SITE_URL=https://your-host:3000`),确保认证回调与生成的链接使用 HTTPS 入口。 -- 再次禁用 HTTPS(`--https-mode disabled`)后,入口端口交还给 web 容器;证书文件会保留,之后可重新启用。 +- 启用 HTTPS 后,请同步更新 `deploy/env/.env` 中的 `SITE_URL`(例如 `SITE_URL=https://your-host:3100`),确保认证回调与生成的链接使用 HTTPS 入口。 +- 再次禁用 HTTPS(`--https-mode disabled`)后,Nginx 容器会被移除;证书文件会保留,之后可重新启用。 部署成功后,非敏感部署选项会保存到 `deploy/docker/deploy.options`。`--defaults` 会优先复用该文件;文件不存在时使用内置默认值。下次交互部署时可选择复用本地配置或重新全量配置。 From 091b15fc30971aa3e7d197b9e7bfbf1e8c348f05 Mon Sep 17 00:00:00 2001 From: MoeexT Date: Mon, 28 Sep 2026 20:20:00 +0800 Subject: [PATCH 4/9] docs(deploy): list the HTTPS entry port in the port mapping table --- doc/docs/en/quick-start/installation.md | 1 + doc/docs/zh/quick-start/installation.md | 1 + 2 files changed, 2 insertions(+) diff --git a/doc/docs/en/quick-start/installation.md b/doc/docs/en/quick-start/installation.md index 9fa2bb57a8..c137156812 100644 --- a/doc/docs/en/quick-start/installation.md +++ b/doc/docs/en/quick-start/installation.md @@ -266,6 +266,7 @@ The Docker uninstall script reads `deploy/env/.env` to resolve `ROOT_DIR` and re | Service | Internal Port | External Port | Description | |---------|---------------|---------------|-------------| | Web Interface | 3000 | 3000 | Main application access | +| HTTPS Entry | 3100 | 3100 | Optional encrypted entry via Nginx (when HTTPS is enabled) | | Backend API | 5010 | 5010 | Backend service | | Runtime API | 5014 | 5014 | Agent runtime service | | MCP API | 5011/5015 | 5011/5015 | MCP management and tool service | diff --git a/doc/docs/zh/quick-start/installation.md b/doc/docs/zh/quick-start/installation.md index 8affebbf67..de8822b8de 100644 --- a/doc/docs/zh/quick-start/installation.md +++ b/doc/docs/zh/quick-start/installation.md @@ -262,6 +262,7 @@ Docker 卸载脚本会读取 `deploy/env/.env` 中的 `ROOT_DIR` 并清理 Compo | 服务 | 内部端口 | 外部端口 | 描述 | |---------|---------------|---------------|-------------| | Web 界面 | 3000 | 3000 | 主应用程序访问 | +| HTTPS 入口 | 3100 | 3100 | 可选,启用 HTTPS 后的加密访问入口(Nginx) | | 后端 API | 5010 | 5010 | 后端服务 | | Runtime API | 5014 | 5014 | 智能体运行时服务 | | MCP API | 5011/5015 | 5011/5015 | MCP 管理与工具服务 | From e025f17d43b26dc27f31bf9793aacc7ba3de6caf Mon Sep 17 00:00:00 2001 From: MoeexT Date: Tue, 29 Sep 2026 09:44:37 +0800 Subject: [PATCH 5/9] fix(deploy): always bundle nginx image in offline package Offline hosts cannot pull the nginx image after delivery when the user enables HTTPS during installation. Bundle nginx:alpine with the infrastructure images instead of gating it on build-time HTTPS options, and drop the HTTPS flags from the offline build passthrough list. --- deploy/offline/build_offline_package.sh | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/deploy/offline/build_offline_package.sh b/deploy/offline/build_offline_package.sh index 2a07328f4c..382223b793 100755 --- a/deploy/offline/build_offline_package.sh +++ b/deploy/offline/build_offline_package.sh @@ -170,7 +170,7 @@ parse_args() { DRY_RUN="true" shift ;; - --components|--image-source|--registry-profile|--image-registry-prefix|--registry-prefix|--image-registry|--app-version|--monitoring-provider|--port-policy|--local-config|--https-mode|--https-cert-file|--https-key-file|--https-key-passphrase|--https-san) + --components|--image-source|--registry-profile|--image-registry-prefix|--registry-prefix|--image-registry|--app-version|--monitoring-provider|--port-policy|--local-config) COMMON_ARGS+=("$1" "$2") shift 2 ;; @@ -344,15 +344,12 @@ get_third_party_images() { echo "" } - # HTTPS termination proxy is only consumed when the user opts in during install - if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then - echo_image_ref "nginx:alpine" - fi if deployment_csv_contains "$DEPLOYMENT_COMPONENTS" "infrastructure"; then echo "$ELASTICSEARCH_IMAGE" echo "$POSTGRESQL_IMAGE" echo "$REDIS_IMAGE" echo "$MINIO_IMAGE" + echo_image_ref "nginx:alpine" fi if deployment_csv_contains "$DEPLOYMENT_COMPONENTS" "supabase"; then echo "$SUPABASE_KONG" From ad855827a3443e86a045ab4d7fcbb0e3afa326ca Mon Sep 17 00:00:00 2001 From: MoeexT Date: Tue, 29 Sep 2026 10:34:30 +0800 Subject: [PATCH 6/9] feat(deploy): make HTTP and HTTPS entry ports configurable Introduce NEXENT_WEB_PORT and NEXENT_HTTPS_PORT so users can change the entry ports for both Docker (host port mapping) and Kubernetes (NodePort) deployments. Defaults keep the current behavior (Docker 3000/3100, K8s 30000/31000). Also fix the deploy script overwriting a user-set web port mapping when HTTPS is disabled, and document the new variables in .env.example and the installation guides. --- deploy/common/common.sh | 16 +++++++++------- deploy/docker/compose/docker-compose.prod.yml | 4 ++-- deploy/docker/compose/docker-compose.yml | 2 +- deploy/docker/deploy.sh | 6 +++--- deploy/env/.env.example | 4 ++++ doc/docs/en/quick-start/installation.md | 7 ++++--- doc/docs/zh/quick-start/installation.md | 7 ++++--- 7 files changed, 27 insertions(+), 19 deletions(-) mode change 100644 => 100755 deploy/common/common.sh diff --git a/deploy/common/common.sh b/deploy/common/common.sh old mode 100644 new mode 100755 index 24e3be192a..8a3bb222a2 --- a/deploy/common/common.sh +++ b/deploy/common/common.sh @@ -158,7 +158,7 @@ deployment_i18n_format() { tui.monitoring.grafana) printf '本地 Grafana + Tempo traces 看板' ;; tui.monitoring.zipkin) printf '本地 Zipkin trace 浏览 UI' ;; tui.https.title) printf '选择 HTTPS 模式' ;; - tui.https.subtitle) printf '启用 HTTPS 后会额外安装一个 Nginx 反向代理容器,在独立端口终结 TLS(Docker 3100 / K8s 31000);原 HTTP 入口 3000/30000 保持不变。' ;; + tui.https.subtitle) printf '启用 HTTPS 后会额外安装一个 Nginx 反向代理容器,在独立端口终结 TLS(Docker %s / K8s %s);原 HTTP 入口保持不变。端口可通过 NEXENT_WEB_PORT / NEXENT_HTTPS_PORT 修改。' "${NEXENT_HTTPS_PORT:-3100}" "${NEXENT_HTTPS_PORT:-31000}" ;; tui.https.description) printf '自签证书自动生成(SAN 自动探测,无需输入);custom 模式使用你自己的证书与私钥。' ;; tui.https.disabled) printf '不启用 HTTPS(默认,行为与现状一致)' ;; tui.https.self_signed) printf '自动生成自签证书(99 年有效期,浏览器会显示告警)' ;; @@ -253,7 +253,7 @@ deployment_i18n_format() { tui.monitoring.grafana) printf 'local Grafana + Tempo dashboard for traces' ;; tui.monitoring.zipkin) printf 'local Zipkin UI for trace browsing' ;; tui.https.title) printf 'Select HTTPS mode' ;; - tui.https.subtitle) printf 'Enabling HTTPS installs an extra Nginx reverse-proxy container that terminates TLS on a dedicated port (Docker 3100 / K8s 31000); the plain HTTP entry 3000/30000 stays unchanged.' ;; + tui.https.subtitle) printf 'Enabling HTTPS installs an extra Nginx reverse-proxy container that terminates TLS on a dedicated port (Docker %s / K8s %s); the plain HTTP entry stays unchanged. Ports can be changed via NEXENT_WEB_PORT / NEXENT_HTTPS_PORT.' "${NEXENT_HTTPS_PORT:-3100}" "${NEXENT_HTTPS_PORT:-31000}" ;; tui.https.description) printf 'Self-signed certificates are generated automatically (SAN auto-detected, no input needed); custom mode uses your own certificate and key.' ;; tui.https.disabled) printf 'Keep HTTP only (default, same as before)' ;; tui.https.self_signed) printf 'Generate a self-signed certificate (99-year validity; browsers will warn)' ;; @@ -2373,12 +2373,14 @@ deployment_render_k8s_port_values() { fi local web_type="NodePort" + local web_node_port="${NEXENT_WEB_PORT:-30000}" + local https_node_port="${NEXENT_HTTPS_PORT:-31000}" printf 'nexent-web:\n' - printf ' services:\n web:\n type: "%s"\n nodePort: 30000\n' "$web_type" + printf ' services:\n web:\n type: "%s"\n nodePort: %s\n' "$web_type" "$web_node_port" if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then printf 'nexent-nginx:\n' printf ' enabled: true\n' - printf ' services:\n nginx:\n type: "NodePort"\n entryPort: 31000\n nodePort: 31000\n' + printf ' services:\n nginx:\n type: "NodePort"\n entryPort: %s\n nodePort: %s\n' "$https_node_port" "$https_node_port" fi printf 'nexent-northbound:\n' printf ' services:\n northbound:\n type: "%s"\n nodePort: 30013\n' "$northbound_type" @@ -2444,7 +2446,7 @@ deployment_render_helm_chart_values() { printf 'nexent-web:\n' printf ' enabled: %s\n' "$(deployment_chart_enabled application)" printf ' images:\n web:\n repository: "%s"\n tag: "%s"\n pullPolicy: "%s"\n' "$(deployment_image_repo "$NEXENT_WEB_IMAGE")" "$(deployment_image_tag "$NEXENT_WEB_IMAGE")" "$local_pull_policy" - printf ' services:\n web:\n type: "%s"\n nodePort: 30000\n' "$web_type" + printf ' services:\n web:\n type: "%s"\n nodePort: %s\n' "$web_type" "${NEXENT_WEB_PORT:-30000}" printf 'nexent-data-process:\n' printf ' enabled: %s\n' "$(deployment_chart_enabled data-process)" printf ' images:\n dataProcess:\n repository: "%s"\n tag: "%s"\n pullPolicy: "%s"\n' "$(deployment_image_repo "$NEXENT_DATA_PROCESS_IMAGE")" "$(deployment_image_tag "$NEXENT_DATA_PROCESS_IMAGE")" "$local_pull_policy" @@ -2710,8 +2712,8 @@ deployment_persist_local_config() { deployment_print_summary() { local target="${1:-all}" - local https_entry_port="3100" - [ "$target" = "k8s" ] && https_entry_port="31000" + local https_entry_port="${NEXENT_HTTPS_PORT:-3100}" + [ "$target" = "k8s" ] && https_entry_port="${NEXENT_HTTPS_PORT:-31000}" deployment_log "$(deployment_i18n summary.components "$DEPLOYMENT_COMPONENTS")" deployment_log "$(deployment_i18n summary.port_policy "$DEPLOYMENT_PORT_POLICY")" diff --git a/deploy/docker/compose/docker-compose.prod.yml b/deploy/docker/compose/docker-compose.prod.yml index e9ed1e2b6b..3ad349c004 100644 --- a/deploy/docker/compose/docker-compose.prod.yml +++ b/deploy/docker/compose/docker-compose.prod.yml @@ -209,7 +209,7 @@ services: networks: - nexent ports: - - "${NEXENT_WEB_PORT_MAPPING-3000:3000}" + - "${NEXENT_WEB_PORT:-3000}:3000" volumes: - ${ROOT_DIR}/project-config:/mnt/nexent-data/project-config environment: @@ -235,7 +235,7 @@ services: networks: - nexent ports: - - "3100:3100" + - "${NEXENT_HTTPS_PORT:-3100}:3100" volumes: - ${ROOT_DIR}/nginx/ssl:/etc/nginx/ssl:ro - ../assets/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/deploy/docker/compose/docker-compose.yml b/deploy/docker/compose/docker-compose.yml index 058ca9f88c..80330cb525 100644 --- a/deploy/docker/compose/docker-compose.yml +++ b/deploy/docker/compose/docker-compose.yml @@ -229,7 +229,7 @@ services: networks: - nexent ports: - - "${NEXENT_WEB_PORT_MAPPING-3000:3000}" + - "${NEXENT_WEB_PORT:-3000}:3000" volumes: - ${ROOT_DIR}/project-config:/mnt/nexent-data/project-config env_file: diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh index e5d5f5515c..470df63d3b 100755 --- a/deploy/docker/deploy.sh +++ b/deploy/docker/deploy.sh @@ -1243,7 +1243,7 @@ deploy_https_nginx() { docker rm -f nexent-nginx 2>/dev/null || true fi fi - export NEXENT_WEB_PORT_MAPPING="3000:3000" + export NEXENT_WEB_PORT="${NEXENT_WEB_PORT:-3000}" return 0 fi @@ -2052,10 +2052,10 @@ main_deploy() { if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then echo "🎉 部署完成!" - echo "🌐 现在可以访问应用:http://localhost:3000" + echo "🌐 现在可以访问应用:http://localhost:${NEXENT_WEB_PORT:-3000}" else echo "🎉 Deployment completed successfully!" - echo "🌐 You can now access the application at http://localhost:3000" + echo "🌐 You can now access the application at http://localhost:${NEXENT_WEB_PORT:-3000}" fi } diff --git a/deploy/env/.env.example b/deploy/env/.env.example index 4ab5318229..4cca0f5364 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -120,6 +120,10 @@ SITE_URL=http://localhost:3011 # HTTPS Termination Config (optional) # HTTPS mode: disabled (default), self-signed, or custom NEXENT_HTTPS_MODE=disabled +# HTTP entry port (host side): default 3000 for Docker, 30000 for Kubernetes +NEXENT_WEB_PORT= +# HTTPS entry port (host side): default 3100 for Docker, 31000 for Kubernetes +NEXENT_HTTPS_PORT= # Certificate and private key paths (PEM) for custom mode NEXENT_HTTPS_CERT_FILE= NEXENT_HTTPS_KEY_FILE= diff --git a/doc/docs/en/quick-start/installation.md b/doc/docs/en/quick-start/installation.md index c137156812..73754a7b12 100644 --- a/doc/docs/en/quick-start/installation.md +++ b/doc/docs/en/quick-start/installation.md @@ -78,7 +78,7 @@ bash deploy.sh docker --image-source local-latest #### HTTPS (Optional) -Nexent can terminate HTTPS at an Nginx reverse proxy installed alongside the stack. The option is disabled by default; when enabled, HTTPS uses a dedicated entry port: port 3100 for Docker deployments and NodePort 31000 for Kubernetes deployments. The plain HTTP entry (Docker 3000 / K8s 30000) stays unchanged, so both entries can be used at the same time. +Nexent can terminate HTTPS at an Nginx reverse proxy installed alongside the stack. The option is disabled by default; when enabled, HTTPS uses a dedicated entry port: port 3100 for Docker deployments and NodePort 31000 for Kubernetes deployments. The plain HTTP entry (Docker 3000 / K8s 30000) stays unchanged, so both entries can be used at the same time. To change the entry ports, set `NEXENT_WEB_PORT` (HTTP entry) or `NEXENT_HTTPS_PORT` (HTTPS entry) in `deploy/env/.env`. Enable it interactively (the installer asks one question: the HTTPS mode) or non-interactively: @@ -93,6 +93,7 @@ bash deploy.sh docker --defaults --https-mode custom --https-cert-file /path/to/ - **self-signed**: the installer generates a 99-year certificate with an unencrypted private key under `/nginx/ssl/` and reuses it on redeployment. SAN entries are auto-detected from the host network interfaces (loopback and Docker bridges excluded); preset `NEXENT_HTTPS_SAN` in `deploy/env/.env` to override (for example `NEXENT_HTTPS_SAN=10.0.0.5,example.com`). Browsers show an untrusted-certificate warning; import the certificate into your trust store to silence it. - **custom**: point `NEXENT_HTTPS_CERT_FILE` and `NEXENT_HTTPS_KEY_FILE` at your PEM files. The installer validates the pair (PEM format, key/cert match, expiry) before deploying. Encrypted private keys are supported: provide the passphrase via `NEXENT_HTTPS_KEY_PASSPHRASE` or `--https-key-passphrase`; like other deployment credentials, it is stored in plain text in `deploy/env/.env`. - After enabling HTTPS, update `SITE_URL` in `deploy/env/.env` (for example `SITE_URL=https://your-host:3100`) so auth callbacks and generated links use the HTTPS entry point. +- After changing the HTTP/HTTPS entry ports, update callback URLs such as `OAUTH_CALLBACK_BASE_URL` and `CAS_CALLBACK_BASE_URL` if they contain the old port. - Disabling HTTPS again (`--https-mode disabled`) removes the Nginx container; the certificate files are kept and can be reused later. After a successful deployment, non-sensitive choices are saved to `deploy/docker/deploy.options`. `--defaults` reuses that file when it exists, otherwise it uses built-in defaults. The next interactive deployment can reuse the local config or run a full reconfiguration. @@ -265,8 +266,8 @@ The Docker uninstall script reads `deploy/env/.env` to resolve `ROOT_DIR` and re | Service | Internal Port | External Port | Description | |---------|---------------|---------------|-------------| -| Web Interface | 3000 | 3000 | Main application access | -| HTTPS Entry | 3100 | 3100 | Optional encrypted entry via Nginx (when HTTPS is enabled) | +| Web Interface | 3000 | 3000 (`NEXENT_WEB_PORT`) | Main application access | +| HTTPS Entry | 3100 | 3100 (`NEXENT_HTTPS_PORT`) | Optional encrypted entry via Nginx (when HTTPS is enabled) | | Backend API | 5010 | 5010 | Backend service | | Runtime API | 5014 | 5014 | Agent runtime service | | MCP API | 5011/5015 | 5011/5015 | MCP management and tool service | diff --git a/doc/docs/zh/quick-start/installation.md b/doc/docs/zh/quick-start/installation.md index de8822b8de..4de8d95398 100644 --- a/doc/docs/zh/quick-start/installation.md +++ b/doc/docs/zh/quick-start/installation.md @@ -78,7 +78,7 @@ bash deploy.sh docker --image-source local-latest #### HTTPS(可选) -Nexent 可通过随部署安装的 Nginx 反向代理终结 HTTPS。该选项默认关闭,启用后 HTTPS 使用独立入口端口:Docker 部署为 3100 端口,Kubernetes 部署为 NodePort 31000;原 HTTP 入口(Docker 3000 / K8s 30000)保持不变,两种入口可同时使用。 +Nexent 可通过随部署安装的 Nginx 反向代理终结 HTTPS。该选项默认关闭,启用后 HTTPS 使用独立入口端口:Docker 部署为 3100 端口,Kubernetes 部署为 NodePort 31000;原 HTTP 入口(Docker 3000 / K8s 30000)保持不变,两种入口可同时使用。如需修改入口端口,在 `deploy/env/.env` 中设置 `NEXENT_WEB_PORT`(HTTP 入口)或 `NEXENT_HTTPS_PORT`(HTTPS 入口)。 可交互启用(安装器只询问一个问题:HTTPS 模式),也可非交互启用: @@ -93,6 +93,7 @@ bash deploy.sh docker --defaults --https-mode custom --https-cert-file /path/to/ - **self-signed**:安装器生成 99 年有效期的自签证书(私钥不加密),保存在 `/nginx/ssl/`,重复部署时自动复用。SAN 条目从部署主机网卡自动探测(排除回环与 Docker 网桥);如需指定,在 `deploy/env/.env` 预填 `NEXENT_HTTPS_SAN`(例如 `NEXENT_HTTPS_SAN=10.0.0.5,example.com`)。浏览器会提示证书不受信任,将证书导入系统信任链后即可消除。 - **custom**:通过 `NEXENT_HTTPS_CERT_FILE` 与 `NEXENT_HTTPS_KEY_FILE` 指定 PEM 证书与私钥。安装器在部署前校验证书对(PEM 格式、证书与私钥匹配、有效期)。支持加密私钥:通过 `NEXENT_HTTPS_KEY_PASSPHRASE` 或 `--https-key-passphrase` 提供密码;与其他部署凭证一致,密码以明文保存在 `deploy/env/.env` 中。 - 启用 HTTPS 后,请同步更新 `deploy/env/.env` 中的 `SITE_URL`(例如 `SITE_URL=https://your-host:3100`),确保认证回调与生成的链接使用 HTTPS 入口。 +- 修改 HTTP/HTTPS 入口端口后,`OAUTH_CALLBACK_BASE_URL`、`CAS_CALLBACK_BASE_URL` 等回调地址若包含旧端口也需同步更新。 - 再次禁用 HTTPS(`--https-mode disabled`)后,Nginx 容器会被移除;证书文件会保留,之后可重新启用。 部署成功后,非敏感部署选项会保存到 `deploy/docker/deploy.options`。`--defaults` 会优先复用该文件;文件不存在时使用内置默认值。下次交互部署时可选择复用本地配置或重新全量配置。 @@ -261,8 +262,8 @@ Docker 卸载脚本会读取 `deploy/env/.env` 中的 `ROOT_DIR` 并清理 Compo | 服务 | 内部端口 | 外部端口 | 描述 | |---------|---------------|---------------|-------------| -| Web 界面 | 3000 | 3000 | 主应用程序访问 | -| HTTPS 入口 | 3100 | 3100 | 可选,启用 HTTPS 后的加密访问入口(Nginx) | +| Web 界面 | 3000 | 3000(`NEXENT_WEB_PORT`) | 主应用程序访问 | +| HTTPS 入口 | 3100 | 3100(`NEXENT_HTTPS_PORT`) | 可选,启用 HTTPS 后的加密访问入口(Nginx) | | 后端 API | 5010 | 5010 | 后端服务 | | Runtime API | 5014 | 5014 | 智能体运行时服务 | | MCP API | 5011/5015 | 5011/5015 | MCP 管理与工具服务 | From 9ec59345a383fb9a561ab8f224495c34dbf171f9 Mon Sep 17 00:00:00 2001 From: MoeexT Date: Tue, 29 Sep 2026 10:47:48 +0800 Subject: [PATCH 7/9] feat(deploy): validate k8s nodePort range for entry ports Reject NEXENT_WEB_PORT and NEXENT_HTTPS_PORT values outside the Kubernetes nodePort range (30000-32767) before rendering Helm values, so users get a clear error instead of a cryptic API server message. Ports are only validated when HTTPS mode needs them, and empty values keep using defaults. --- deploy/common/common.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/deploy/common/common.sh b/deploy/common/common.sh index 8a3bb222a2..17e38f730f 100755 --- a/deploy/common/common.sh +++ b/deploy/common/common.sh @@ -137,6 +137,7 @@ deployment_i18n_format() { validation.https_materialize_failed) printf '%s' '准备 Nginx 证书文件失败(目标目录:%s)。' ;; validation.https_cert_expired) printf '%s' '证书已过期(到期时间:%s)。' ;; validation.https_cert_expiring_soon) printf '%s' '⚠️ 证书将在 30 天内到期(到期时间:%s),建议尽快更换。' ;; + validation.nodeport_out_of_range) printf '%s' 'Kubernetes 部署的端口必须在 30000-32767 范围内:%s(变量 %s)。' ;; tui.cancelled) printf '已取消部署配置。' ;; tui.components.title) printf '选择部署组件' ;; tui.components.subtitle) printf '选择要安装的服务组。infrastructure 为必选项,不能禁用。' ;; @@ -232,6 +233,7 @@ deployment_i18n_format() { validation.https_materialize_failed) printf '%s' 'Failed to prepare the Nginx certificate files (target directory: %s).' ;; validation.https_cert_expired) printf '%s' 'Certificate has expired (notAfter: %s).' ;; validation.https_cert_expiring_soon) printf '%s' '⚠️ Certificate expires within 30 days (notAfter: %s); consider replacing it soon.' ;; + validation.nodeport_out_of_range) printf '%s' 'Port for Kubernetes deployment must be within 30000-32767: %s (variable %s).' ;; tui.cancelled) printf 'Deployment configuration cancelled.' ;; tui.components.title) printf 'Select deployment components' ;; tui.components.subtitle) printf 'Choose which service groups to install. infrastructure is required and cannot be disabled.' ;; @@ -342,6 +344,25 @@ deployment_error() { printf '❌ %s\n' "$*" >&2 } +deployment_validate_nodeport_range() { + local port="$1" + local variable_name="$2" + if [ -z "$port" ]; then + return 0 + fi + case "$port" in + ''|*[!0-9]*) + deployment_error "$(deployment_i18n validation.nodeport_out_of_range "$port" "$variable_name")" + return 1 + ;; + esac + if [ "$port" -lt 30000 ] || [ "$port" -gt 32767 ]; then + deployment_error "$(deployment_i18n validation.nodeport_out_of_range "$port" "$variable_name")" + return 1 + fi + return 0 +} + deployment_csv_contains() { local list="$1" local item="$2" @@ -2373,6 +2394,12 @@ deployment_render_k8s_port_values() { fi local web_type="NodePort" + local web_port_input="${NEXENT_WEB_PORT:-}" + local https_port_input="${NEXENT_HTTPS_PORT:-}" + deployment_validate_nodeport_range "$web_port_input" "NEXENT_WEB_PORT" || return 1 + if [ "$DEPLOYMENT_HTTPS_MODE" != "disabled" ]; then + deployment_validate_nodeport_range "$https_port_input" "NEXENT_HTTPS_PORT" || return 1 + fi local web_node_port="${NEXENT_WEB_PORT:-30000}" local https_node_port="${NEXENT_HTTPS_PORT:-31000}" printf 'nexent-web:\n' From b57215b6a0732c708bfc3a52fc04d18ee7c39d81 Mon Sep 17 00:00:00 2001 From: MoeexT Date: Tue, 29 Sep 2026 16:22:58 +0800 Subject: [PATCH 8/9] fix(deploy): render nginx nodePort in generated Helm values The live Helm values renderer (deployment_render_helm_chart_values) only rendered the nginx image block, so the nginx Service fell back to the chart default NodePort and a user-set NEXENT_HTTPS_PORT never took effect. Render the nginx services block the same way the port-policy renderer does, and cover it with a Helm values assertion. --- deploy/common/common.sh | 1 + deploy/tests/test_common.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/deploy/common/common.sh b/deploy/common/common.sh index 17e38f730f..c10be1bea7 100755 --- a/deploy/common/common.sh +++ b/deploy/common/common.sh @@ -2501,6 +2501,7 @@ deployment_render_helm_chart_values() { printf 'nexent-nginx:\n' printf ' enabled: true\n' printf ' images:\n nginx:\n repository: "%s"\n tag: "%s"\n pullPolicy: "IfNotPresent"\n' "$(deployment_image_repo "$NGINX_IMAGE")" "$(deployment_image_tag "$NGINX_IMAGE")" + printf ' services:\n nginx:\n type: "NodePort"\n entryPort: %s\n nodePort: %s\n' "${NEXENT_HTTPS_PORT:-31000}" "${NEXENT_HTTPS_PORT:-31000}" if [ -r "${DEPLOYMENT_HTTPS_CERT_PATH:-}" ] && [ -r "${DEPLOYMENT_HTTPS_KEY_PATH:-}" ]; then printf ' tls:\n' # Render PEM contents as a YAML literal block: multi-line certificates diff --git a/deploy/tests/test_common.sh b/deploy/tests/test_common.sh index 648b3dab53..31c87b8954 100755 --- a/deploy/tests/test_common.sh +++ b/deploy/tests/test_common.sh @@ -1348,6 +1348,7 @@ deployment_https_prepare || { } deployment_render_helm_chart_values > "$HTTPS_HELM_VALUES" assert_contains "$(cat "$HTTPS_HELM_VALUES")" $'nexent-nginx:\n enabled: true' "helm values should enable the nginx subchart" +assert_contains "$(cat "$HTTPS_HELM_VALUES")" $'nexent-nginx:\n enabled: true\n images:\n nginx:\n repository: "registry.local/nexent/nginx"\n tag: "alpine"\n pullPolicy: "IfNotPresent"\n services:\n nginx:\n type: "NodePort"\n entryPort: 31000\n nodePort: 31000' "helm values should render the nginx NodePort from NEXENT_HTTPS_PORT" assert_contains "$(cat "$HTTPS_HELM_VALUES")" "BEGIN CERTIFICATE" "helm values should embed the certificate content" # Reset to disabled for the summary path From 51ec172df6f2ae6df10ebcad55d1a82e1f7ed892 Mon Sep 17 00:00:00 2001 From: MoeexT Date: Wed, 30 Sep 2026 09:19:36 +0800 Subject: [PATCH 9/9] test(deploy): include nginx image in offline push test fixtures --- deploy/tests/test_build_offline_package.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/tests/test_build_offline_package.sh b/deploy/tests/test_build_offline_package.sh index 2eb5339836..56dba824b7 100755 --- a/deploy/tests/test_build_offline_package.sh +++ b/deploy/tests/test_build_offline_package.sh @@ -407,7 +407,7 @@ push_log="$TMP_DIR/push-images.log" : > "$push_log" PATH="$BIN_DIR:$PATH" \ FAKE_DOCKER_LOG="$push_log" \ - FAKE_DOCKER_LOCAL_IMAGES="nexent/nexent:latest,nexent/nexent-web:latest,nexent/nexent-mcp:latest,nexent/nexent-sandbox:latest,docker.elastic.co/elasticsearch/elasticsearch:8.17.4,postgres:15-alpine,redis:alpine,quay.io/minio/minio:RELEASE.2023-12-20T01-00-02Z" \ + FAKE_DOCKER_LOCAL_IMAGES="nexent/nexent:latest,nexent/nexent-web:latest,nexent/nexent-mcp:latest,nexent/nexent-sandbox:latest,docker.elastic.co/elasticsearch/elasticsearch:8.17.4,postgres:15-alpine,redis:alpine,quay.io/minio/minio:RELEASE.2023-12-20T01-00-02Z,nginx:alpine" \ REGISTRY_PASSWORD=secret \ bash "$latest_package_dir/push-images.sh" \ --image-registry-prefix https://registry.local/nexent/ \ @@ -421,7 +421,7 @@ grep -q '^tag docker.elastic.co/elasticsearch/elasticsearch:8.17.4 registry.loca : > "$push_log" PATH="$BIN_DIR:$PATH" \ FAKE_DOCKER_LOG="$push_log" \ - FAKE_DOCKER_LOCAL_IMAGES="nexent/nexent:latest,nexent/nexent-web:latest,nexent/nexent-mcp:latest,nexent/nexent-sandbox:latest,docker.elastic.co/elasticsearch/elasticsearch:8.17.4,postgres:15-alpine,redis:alpine,quay.io/minio/minio:RELEASE.2023-12-20T01-00-02Z" \ + FAKE_DOCKER_LOCAL_IMAGES="nexent/nexent:latest,nexent/nexent-web:latest,nexent/nexent-mcp:latest,nexent/nexent-sandbox:latest,docker.elastic.co/elasticsearch/elasticsearch:8.17.4,postgres:15-alpine,redis:alpine,quay.io/minio/minio:RELEASE.2023-12-20T01-00-02Z,nginx:alpine" \ REGISTRY_PASSWORD=secret \ bash "$latest_package_dir/push-images.sh" \ --load-images \