From 02c407014823a2928f92b4119f87ab9354b1ab77 Mon Sep 17 00:00:00 2001 From: tangwang Date: Wed, 11 Mar 2026 18:22:36 +0800 Subject: [PATCH] frontend proxy search via same-origin + update ES9/Kibana docs --- docs/ES/ES_9_本机安装记录.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- docs/QUICKSTART.md | 2 ++ frontend/static/js/app.js | 27 +++++++++++++++++++++++---- scripts/frontend_server.py | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------- 4 files changed, 174 insertions(+), 40 deletions(-) diff --git a/docs/ES/ES_9_本机安装记录.md b/docs/ES/ES_9_本机安装记录.md index c03146f..553a7c7 100644 --- a/docs/ES/ES_9_本机安装记录.md +++ b/docs/ES/ES_9_本机安装记录.md @@ -12,7 +12,7 @@ | 日志目录 | `/data/elasticsearch/logs` | | 配置 | `/etc/elasticsearch/elasticsearch.yml` | | 模式 | 单机单节点 `discovery.type: single-node` | -| 安全 | `xpack.security.enabled: false`(开发环境) | +| 安全 | 当前为开启状态(HTTP 访问需认证) | | HTTP | `http://0.0.0.0:9200` | | Kibana | 9.3.1,`http://0.0.0.0:5601`,已安装并启用 | @@ -50,8 +50,71 @@ sudo systemctl stop kibana ## Kibana - **版本**:9.3.1(与 ES 同源) -- **配置**:`/etc/kibana/kibana.yml`(server.host: 0.0.0.0, server.port: 5601, elasticsearch.hosts: http://127.0.0.1:9200) -- **访问**:浏览器打开 `http://<本机IP>:5601`(ES 未开安全时可直接进入首页) +- **配置**:`/etc/kibana/kibana.yml` +- **当前关键配置**: + - `server.host: "0.0.0.0"` + - `server.port: 5601` + - `elasticsearch.hosts: ["http://localhost:9200"]` + - `elasticsearch.serviceAccountToken: ""` +- **访问**:浏览器打开 `http://<本机IP>:5601`(当前会跳转登录页) + +## 2026-03-11 Kibana 故障修复记录 + +### 现象 + +- `systemctl status kibana` 显示运行,但公网访问 `:5601` 页面无法打开或长时间无响应。 +- 日志出现 `savedobjects-service` 长时间迁移;HTTP 端口虽然监听但请求超时。 + +### 根因 + +1. **监听地址问题(已修复)** + Kibana 一度仅监听 `127.0.0.1:5601`,外部无法直接访问。 + +2. **认证方式问题(核心根因,已修复)** + Kibana 使用了业务用户 `saas`(`elasticsearch.username/password`)连接 ES。 + 迁移阶段需要对 `.kibana_*` 受限索引执行创建动作,日志报错: + - `security_exception` + - `action [indices:admin/create] is unauthorized ... on restricted indices [.kibana_*]` + 导致 Saved Objects migration 卡住,HTTP 长时间无有效响应。 + +### 修复动作 + +1. 在 `/etc/kibana/kibana.yml` 固化对外监听: + - `server.host: "0.0.0.0"` + - `server.port: 5601` +2. 在 ES 创建 Kibana service account token: + - `POST /_security/service/elastic/kibana/credential/token/kibana-server-1` +3. Kibana 认证改为 service token(官方推荐): + - 删除 `elasticsearch.username` + - 删除 `elasticsearch.password` + - 新增 `elasticsearch.serviceAccountToken` +4. 重启并验证: + - `systemctl status kibana` = `active (running)` + - `ss -lntp | grep 5601` = `0.0.0.0:5601` + - 日志出现 `Completed all migrations` + - 访问 `http://:5601/` 返回 `302 -> /login` + +### 排障命令速查 + +```bash +# 服务与监听 +sudo systemctl status kibana --no-pager -n 50 +sudo ss -lntp | grep 5601 + +# Kibana 日志(关注迁移与权限报错) +sudo tail -n 200 /var/log/kibana/kibana.log +sudo journalctl -u kibana -n 200 --no-pager + +# 本机/公网 HTTP 验证 +curl -I http://127.0.0.1:5601/ +curl -I http://<公网IP>:5601/ +``` + +### 注意事项 + +- **不要**让 Kibana 复用应用侧业务账号(如 `.env` 的 `ES_USERNAME/ES_PASSWORD`)作为系统连接账号。 +- 优先使用 `elasticsearch.serviceAccountToken`。 +- 如果手动操作过 `/run/kibana/kibana.pid`,可能造成 stop/restart 流程异常,需用 systemd 重新拉起并确认状态。 ## 与 SearchEngine 项目集成 diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 9d8c0b1..fc36e61 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -255,6 +255,8 @@ curl -u 'saas:4hOaLaf41y2VuI8y' \ }' ``` +> 注意(Kibana 运维):应用侧 `.env` 的 `ES_USERNAME/ES_PASSWORD` 仅用于业务服务访问 ES;Kibana 不应复用该账号。Kibana 推荐在 `/etc/kibana/kibana.yml` 使用 `elasticsearch.serviceAccountToken`,否则在 `.kibana_*` 受限索引迁移阶段可能出现 `security_exception` 并导致 5601 长时间无响应。 + 在项目根目录创建 `.env`: ```bash diff --git a/frontend/static/js/app.js b/frontend/static/js/app.js index cf232ef..8bbdb88 100644 --- a/frontend/static/js/app.js +++ b/frontend/static/js/app.js @@ -1,9 +1,28 @@ // saas-search Frontend - Modern UI (Multi-Tenant) -const API_BASE_URL = window.API_BASE_URL || 'http://localhost:6002'; -if (document.getElementById('apiUrl')) { - document.getElementById('apiUrl').textContent = API_BASE_URL; -} +// API 基础地址策略(优先级从高到低): +// 1. window.API_BASE_URL:若在 HTML / 部署层显式注入,则直接使用(可为绝对 URL 或相对路径,如 "/api") +// 2. 默认:空前缀(同源调用),由 6003 前端服务在服务端转发到本机 6002 +// +// 说明:在“外网 web 服务器对 6002 另加认证”的场景下, +// 浏览器不应直接调用 web:6002,而应调用同源 web:6003/search/*, +// 再由 frontend_server.py 代理到 GPU 机本地 6002。 +(function initApiBaseUrl() { + let base; + if (window.API_BASE_URL) { + base = window.API_BASE_URL; + } else { + base = ''; + } + + window.API_BASE_URL = base; + const apiUrlEl = document.getElementById('apiUrl'); + if (apiUrlEl) { + apiUrlEl.textContent = base || '(same-origin)'; + } +})(); + +const API_BASE_URL = window.API_BASE_URL; // Get tenant ID from select function getTenantId() { diff --git a/scripts/frontend_server.py b/scripts/frontend_server.py index 749fe41..b2e8008 100755 --- a/scripts/frontend_server.py +++ b/scripts/frontend_server.py @@ -9,6 +9,8 @@ import os import sys import logging import time +import urllib.request +import urllib.error from collections import defaultdict, deque from pathlib import Path from dotenv import load_dotenv @@ -17,8 +19,8 @@ from dotenv import load_dotenv project_root = Path(__file__).parent.parent load_dotenv(project_root / '.env') -# Get API_BASE_URL from environment -API_BASE_URL = os.getenv('API_BASE_URL', 'http://localhost:6002') +# Backend proxy target for same-origin API forwarding +BACKEND_PROXY_URL = os.getenv('BACKEND_PROXY_URL', 'http://127.0.0.1:6002').rstrip('/') # Change to frontend directory frontend_dir = os.path.join(os.path.dirname(__file__), '../frontend') @@ -54,42 +56,85 @@ class RateLimitingMixin: class MyHTTPRequestHandler(http.server.SimpleHTTPRequestHandler, RateLimitingMixin): """Custom request handler with CORS support and robust error handling.""" + def _is_proxy_path(self, path: str) -> bool: + """Return True for API paths that should be forwarded to backend service.""" + return path.startswith('/search/') or path.startswith('/admin/') or path.startswith('/indexer/') + + def _proxy_to_backend(self): + """Proxy current request to backend service on the GPU server.""" + target_url = f"{BACKEND_PROXY_URL}{self.path}" + method = self.command.upper() + + try: + content_length = int(self.headers.get('Content-Length', '0')) + except ValueError: + content_length = 0 + body = self.rfile.read(content_length) if content_length > 0 else None + + forward_headers = {} + for key, value in self.headers.items(): + lk = key.lower() + if lk in ('host', 'content-length', 'connection'): + continue + forward_headers[key] = value + + req = urllib.request.Request( + target_url, + data=body, + headers=forward_headers, + method=method, + ) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + resp_body = resp.read() + self.send_response(resp.getcode()) + for header, value in resp.getheaders(): + lh = header.lower() + if lh in ('transfer-encoding', 'connection', 'content-length'): + continue + self.send_header(header, value) + self.end_headers() + self.wfile.write(resp_body) + except urllib.error.HTTPError as e: + err_body = e.read() if hasattr(e, 'read') else b'' + self.send_response(e.code) + if e.headers: + for header, value in e.headers.items(): + lh = header.lower() + if lh in ('transfer-encoding', 'connection', 'content-length'): + continue + self.send_header(header, value) + self.end_headers() + if err_body: + self.wfile.write(err_body) + except Exception as e: + logging.error(f"Backend proxy error for {method} {self.path}: {e}") + self.send_response(502) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.end_headers() + self.wfile.write(b'{"error":"Bad Gateway: backend proxy failed"}') + def do_GET(self): - """Handle GET requests with API config injection.""" + """Handle GET requests with lightweight API proxy.""" path = self.path.split('?')[0] - + + # Proxy API paths to backend first + if self._is_proxy_path(path): + self._proxy_to_backend() + return # Route / to index.html if path == '/' or path == '': self.path = '/index.html' + (self.path.split('?', 1)[1] if '?' in self.path else '') - - # Inject API config for HTML files - if self.path.endswith('.html'): - self._serve_html_with_config() - else: - super().do_GET() - - def _serve_html_with_config(self): - """Serve HTML with API_BASE_URL injected.""" - try: - file_path = self.path.lstrip('/') - if not os.path.exists(file_path): - self.send_error(404) - return - - with open(file_path, 'r', encoding='utf-8') as f: - html = f.read() - - # Inject API_BASE_URL before app.js - config_script = f'\n ' - html = html.replace('