diff --git a/deploy/common/common.sh b/deploy/common/common.sh index bf88e2bbae..c10be1bea7 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,18 @@ 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),建议尽快更换。' ;; + validation.nodeport_out_of_range) printf '%s' 'Kubernetes 部署的端口必须在 30000-32767 范围内:%s(变量 %s)。' ;; tui.cancelled) printf '已取消部署配置。' ;; tui.components.title) printf '选择部署组件' ;; tui.components.subtitle) printf '选择要安装的服务组。infrastructure 为必选项,不能禁用。' ;; @@ -145,6 +158,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(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 年有效期,浏览器会显示告警)' ;; + tui.https.custom) printf '使用自定义证书与私钥(路径与密码可预填在 .env)' ;; tui.port.title) printf '选择端口策略' ;; tui.port.subtitle) printf '控制哪些服务端口暴露到主机或集群节点。' ;; tui.port.description) printf '本地调试选择 development;更小外部暴露面选择 production。' ;; @@ -175,6 +194,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 +222,18 @@ 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.' ;; + 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.' ;; @@ -217,6 +254,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 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)' ;; + 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 +290,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' ;; @@ -295,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" @@ -817,6 +885,13 @@ 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" + # 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" DEPLOYMENT_RECONFIGURE="false" DEPLOYMENT_ROTATE_SECRETS="false" @@ -829,6 +904,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 +942,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 +1065,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 +1270,97 @@ 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_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 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 pkey -in "$key_file" -check -noout >/dev/null 2>&1 \ + || openssl pkey -in "$key_file" -noout >/dev/null 2>&1 + fi +} + +deployment_https_key_pubkey() { + local key_file="$1" + local passphrase="$2" + if [ -n "$passphrase" ]; then + openssl pkey -in "$key_file" -pubout -passin "pass:$passphrase" 2>/dev/null + else + openssl pkey -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_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 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")" + 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_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 + 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 +1443,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 +1531,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 +1560,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 +1680,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 +1772,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 +1848,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 +1899,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 +1911,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 +1952,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 +1976,10 @@ deployment_run_tui_configuration() { deployment_tui_select_monitoring_provider result=$? ;; + 5) + deployment_tui_select_https_mode + result=$? + ;; *) return 1 ;; @@ -1739,7 +2023,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)" @@ -1908,6 +2198,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}" @@ -1936,6 +2228,7 @@ deployment_apply_image_source() { POSTGRESQL_IMAGE \ REDIS_IMAGE \ MINIO_IMAGE \ + NGINX_IMAGE \ OPENSSH_SERVER_IMAGE \ SUPABASE_KONG \ SUPABASE_GOTRUE \ @@ -2040,6 +2333,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() { @@ -2097,8 +2393,22 @@ deployment_render_k8s_port_values() { internal_type="NodePort" 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' - printf ' services:\n web:\n type: "NodePort"\n nodePort: 30000\n' + 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: %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" printf 'nexent-config:\n' @@ -2138,6 +2448,7 @@ 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" @@ -2162,7 +2473,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: %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" @@ -2186,6 +2497,21 @@ 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_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 + # 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 +2727,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="${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")" @@ -2417,6 +2753,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 +2805,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 +2835,211 @@ 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 + 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")" + 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 + # 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 + + 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_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 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 + 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 +} + +deployment_https_prepare() { + # 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" + # 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/assets/nginx/nginx.conf b/deploy/docker/assets/nginx/nginx.conf new file mode 100644 index 0000000000..81d21f63b2 --- /dev/null +++ b/deploy/docker/assets/nginx/nginx.conf @@ -0,0 +1,45 @@ +# Nexent HTTPS reverse proxy (Nginx) +# 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 3100 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; + + 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..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: - - "3000:3000" + - "${NEXENT_WEB_PORT:-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_IMAGE:-nginx:alpine} + container_name: nexent-nginx + restart: always + profiles: + - https + networks: + - nexent + ports: + - "${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 + 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..80330cb525 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:-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_IMAGE:-nginx:alpine} + container_name: nexent-nginx + restart: always + profiles: + - https + networks: + - nexent + ports: + - "3100:3100" + 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..470df63d3b 100755 --- a/deploy/docker/deploy.sh +++ b/deploy/docker/deploy.sh @@ -1232,6 +1232,31 @@ 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 + # 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 + docker rm -f nexent-nginx 2>/dev/null || true + fi + fi + export NEXENT_WEB_PORT="${NEXENT_WEB_PORT:-3000}" + return 0 + fi + + deployment_https_prepare || return 1 + + 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 @@ -1973,6 +1998,17 @@ main_deploy() { return 0 fi + # 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 "❌ HTTPS 反向代理部署失败" + else + echo "HTTPS reverse proxy deployment failed" + fi + exit 1 + } + # Start core services deploy_core_services || { if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then @@ -1983,6 +2019,7 @@ main_deploy() { exit 1 } + if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then echo " ✅ 核心服务启动成功" else @@ -2015,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 1a036dcc29..4cca0f5364 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -116,6 +116,22 @@ 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 +# 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= +# 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..78d25e11e6 --- /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 | 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..b196ad2423 --- /dev/null +++ b/deploy/k8s/helm/nexent/charts/nexent-nginx/values.yaml @@ -0,0 +1,73 @@ +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 + # 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: + 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; + + 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..382223b793 100755 --- a/deploy/offline/build_offline_package.sh +++ b/deploy/offline/build_offline_package.sh @@ -349,6 +349,7 @@ get_third_party_images() { 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" 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 \ diff --git a/deploy/tests/test_common.sh b/deploy/tests/test_common.sh index e0589dd823..31c87b8954 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,218 @@ 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 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" $'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 +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 pkey -in "$DEPLOYMENT_HTTPS_KEY_PATH" -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")" $'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 +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..73754a7b12 100644 --- a/doc/docs/en/quick-start/installation.md +++ b/doc/docs/en/quick-start/installation.md @@ -72,10 +72,30 @@ 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; 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: + +```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: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. #### ⚠️ Important Notes @@ -246,7 +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 | +| 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 47c5dce095..4de8d95398 100644 --- a/doc/docs/zh/quick-start/installation.md +++ b/doc/docs/zh/quick-start/installation.md @@ -72,10 +72,30 @@ 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。该选项默认关闭,启用后 HTTPS 使用独立入口端口:Docker 部署为 3100 端口,Kubernetes 部署为 NodePort 31000;原 HTTP 入口(Docker 3000 / K8s 30000)保持不变,两种入口可同时使用。如需修改入口端口,在 `deploy/env/.env` 中设置 `NEXENT_WEB_PORT`(HTTP 入口)或 `NEXENT_HTTPS_PORT`(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:3100`),确保认证回调与生成的链接使用 HTTPS 入口。 +- 修改 HTTP/HTTPS 入口端口后,`OAUTH_CALLBACK_BASE_URL`、`CAS_CALLBACK_BASE_URL` 等回调地址若包含旧端口也需同步更新。 +- 再次禁用 HTTPS(`--https-mode disabled`)后,Nginx 容器会被移除;证书文件会保留,之后可重新启用。 + 部署成功后,非敏感部署选项会保存到 `deploy/docker/deploy.options`。`--defaults` 会优先复用该文件;文件不存在时使用内置默认值。下次交互部署时可选择复用本地配置或重新全量配置。 @@ -242,7 +262,8 @@ Docker 卸载脚本会读取 `deploy/env/.env` 中的 `ROOT_DIR` 并清理 Compo | 服务 | 内部端口 | 外部端口 | 描述 | |---------|---------------|---------------|-------------| -| Web 界面 | 3000 | 3000 | 主应用程序访问 | +| 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 管理与工具服务 |