Commit 02c407014823a2928f92b4119f87ab9354b1ab77
1 parent
6ab0acd4
frontend proxy search via same-origin + update ES9/Kibana docs
Made-with: Cursor
Showing
4 changed files
with
174 additions
and
40 deletions
Show diff stats
docs/ES/ES_9_本机安装记录.md
| ... | ... | @@ -12,7 +12,7 @@ |
| 12 | 12 | | 日志目录 | `/data/elasticsearch/logs` | |
| 13 | 13 | | 配置 | `/etc/elasticsearch/elasticsearch.yml` | |
| 14 | 14 | | 模式 | 单机单节点 `discovery.type: single-node` | |
| 15 | -| 安全 | `xpack.security.enabled: false`(开发环境) | | |
| 15 | +| 安全 | 当前为开启状态(HTTP 访问需认证) | | |
| 16 | 16 | | HTTP | `http://0.0.0.0:9200` | |
| 17 | 17 | | Kibana | 9.3.1,`http://0.0.0.0:5601`,已安装并启用 | |
| 18 | 18 | |
| ... | ... | @@ -50,8 +50,71 @@ sudo systemctl stop kibana |
| 50 | 50 | ## Kibana |
| 51 | 51 | |
| 52 | 52 | - **版本**:9.3.1(与 ES 同源) |
| 53 | -- **配置**:`/etc/kibana/kibana.yml`(server.host: 0.0.0.0, server.port: 5601, elasticsearch.hosts: http://127.0.0.1:9200) | |
| 54 | -- **访问**:浏览器打开 `http://<本机IP>:5601`(ES 未开安全时可直接进入首页) | |
| 53 | +- **配置**:`/etc/kibana/kibana.yml` | |
| 54 | +- **当前关键配置**: | |
| 55 | + - `server.host: "0.0.0.0"` | |
| 56 | + - `server.port: 5601` | |
| 57 | + - `elasticsearch.hosts: ["http://localhost:9200"]` | |
| 58 | + - `elasticsearch.serviceAccountToken: "<token>"` | |
| 59 | +- **访问**:浏览器打开 `http://<本机IP>:5601`(当前会跳转登录页) | |
| 60 | + | |
| 61 | +## 2026-03-11 Kibana 故障修复记录 | |
| 62 | + | |
| 63 | +### 现象 | |
| 64 | + | |
| 65 | +- `systemctl status kibana` 显示运行,但公网访问 `:5601` 页面无法打开或长时间无响应。 | |
| 66 | +- 日志出现 `savedobjects-service` 长时间迁移;HTTP 端口虽然监听但请求超时。 | |
| 67 | + | |
| 68 | +### 根因 | |
| 69 | + | |
| 70 | +1. **监听地址问题(已修复)** | |
| 71 | + Kibana 一度仅监听 `127.0.0.1:5601`,外部无法直接访问。 | |
| 72 | + | |
| 73 | +2. **认证方式问题(核心根因,已修复)** | |
| 74 | + Kibana 使用了业务用户 `saas`(`elasticsearch.username/password`)连接 ES。 | |
| 75 | + 迁移阶段需要对 `.kibana_*` 受限索引执行创建动作,日志报错: | |
| 76 | + - `security_exception` | |
| 77 | + - `action [indices:admin/create] is unauthorized ... on restricted indices [.kibana_*]` | |
| 78 | + 导致 Saved Objects migration 卡住,HTTP 长时间无有效响应。 | |
| 79 | + | |
| 80 | +### 修复动作 | |
| 81 | + | |
| 82 | +1. 在 `/etc/kibana/kibana.yml` 固化对外监听: | |
| 83 | + - `server.host: "0.0.0.0"` | |
| 84 | + - `server.port: 5601` | |
| 85 | +2. 在 ES 创建 Kibana service account token: | |
| 86 | + - `POST /_security/service/elastic/kibana/credential/token/kibana-server-1` | |
| 87 | +3. Kibana 认证改为 service token(官方推荐): | |
| 88 | + - 删除 `elasticsearch.username` | |
| 89 | + - 删除 `elasticsearch.password` | |
| 90 | + - 新增 `elasticsearch.serviceAccountToken` | |
| 91 | +4. 重启并验证: | |
| 92 | + - `systemctl status kibana` = `active (running)` | |
| 93 | + - `ss -lntp | grep 5601` = `0.0.0.0:5601` | |
| 94 | + - 日志出现 `Completed all migrations` | |
| 95 | + - 访问 `http://<IP>:5601/` 返回 `302 -> /login` | |
| 96 | + | |
| 97 | +### 排障命令速查 | |
| 98 | + | |
| 99 | +```bash | |
| 100 | +# 服务与监听 | |
| 101 | +sudo systemctl status kibana --no-pager -n 50 | |
| 102 | +sudo ss -lntp | grep 5601 | |
| 103 | + | |
| 104 | +# Kibana 日志(关注迁移与权限报错) | |
| 105 | +sudo tail -n 200 /var/log/kibana/kibana.log | |
| 106 | +sudo journalctl -u kibana -n 200 --no-pager | |
| 107 | + | |
| 108 | +# 本机/公网 HTTP 验证 | |
| 109 | +curl -I http://127.0.0.1:5601/ | |
| 110 | +curl -I http://<公网IP>:5601/ | |
| 111 | +``` | |
| 112 | + | |
| 113 | +### 注意事项 | |
| 114 | + | |
| 115 | +- **不要**让 Kibana 复用应用侧业务账号(如 `.env` 的 `ES_USERNAME/ES_PASSWORD`)作为系统连接账号。 | |
| 116 | +- 优先使用 `elasticsearch.serviceAccountToken`。 | |
| 117 | +- 如果手动操作过 `/run/kibana/kibana.pid`,可能造成 stop/restart 流程异常,需用 systemd 重新拉起并确认状态。 | |
| 55 | 118 | |
| 56 | 119 | ## 与 SearchEngine 项目集成 |
| 57 | 120 | ... | ... |
docs/QUICKSTART.md
| ... | ... | @@ -255,6 +255,8 @@ curl -u 'saas:4hOaLaf41y2VuI8y' \ |
| 255 | 255 | }' |
| 256 | 256 | ``` |
| 257 | 257 | |
| 258 | +> 注意(Kibana 运维):应用侧 `.env` 的 `ES_USERNAME/ES_PASSWORD` 仅用于业务服务访问 ES;Kibana 不应复用该账号。Kibana 推荐在 `/etc/kibana/kibana.yml` 使用 `elasticsearch.serviceAccountToken`,否则在 `.kibana_*` 受限索引迁移阶段可能出现 `security_exception` 并导致 5601 长时间无响应。 | |
| 259 | + | |
| 258 | 260 | 在项目根目录创建 `.env`: |
| 259 | 261 | |
| 260 | 262 | ```bash | ... | ... |
frontend/static/js/app.js
| 1 | 1 | // saas-search Frontend - Modern UI (Multi-Tenant) |
| 2 | 2 | |
| 3 | -const API_BASE_URL = window.API_BASE_URL || 'http://localhost:6002'; | |
| 4 | -if (document.getElementById('apiUrl')) { | |
| 5 | - document.getElementById('apiUrl').textContent = API_BASE_URL; | |
| 6 | -} | |
| 3 | +// API 基础地址策略(优先级从高到低): | |
| 4 | +// 1. window.API_BASE_URL:若在 HTML / 部署层显式注入,则直接使用(可为绝对 URL 或相对路径,如 "/api") | |
| 5 | +// 2. 默认:空前缀(同源调用),由 6003 前端服务在服务端转发到本机 6002 | |
| 6 | +// | |
| 7 | +// 说明:在“外网 web 服务器对 6002 另加认证”的场景下, | |
| 8 | +// 浏览器不应直接调用 web:6002,而应调用同源 web:6003/search/*, | |
| 9 | +// 再由 frontend_server.py 代理到 GPU 机本地 6002。 | |
| 10 | +(function initApiBaseUrl() { | |
| 11 | + let base; | |
| 12 | + if (window.API_BASE_URL) { | |
| 13 | + base = window.API_BASE_URL; | |
| 14 | + } else { | |
| 15 | + base = ''; | |
| 16 | + } | |
| 17 | + | |
| 18 | + window.API_BASE_URL = base; | |
| 19 | + const apiUrlEl = document.getElementById('apiUrl'); | |
| 20 | + if (apiUrlEl) { | |
| 21 | + apiUrlEl.textContent = base || '(same-origin)'; | |
| 22 | + } | |
| 23 | +})(); | |
| 24 | + | |
| 25 | +const API_BASE_URL = window.API_BASE_URL; | |
| 7 | 26 | |
| 8 | 27 | // Get tenant ID from select |
| 9 | 28 | function getTenantId() { | ... | ... |
scripts/frontend_server.py
| ... | ... | @@ -9,6 +9,8 @@ import os |
| 9 | 9 | import sys |
| 10 | 10 | import logging |
| 11 | 11 | import time |
| 12 | +import urllib.request | |
| 13 | +import urllib.error | |
| 12 | 14 | from collections import defaultdict, deque |
| 13 | 15 | from pathlib import Path |
| 14 | 16 | from dotenv import load_dotenv |
| ... | ... | @@ -17,8 +19,8 @@ from dotenv import load_dotenv |
| 17 | 19 | project_root = Path(__file__).parent.parent |
| 18 | 20 | load_dotenv(project_root / '.env') |
| 19 | 21 | |
| 20 | -# Get API_BASE_URL from environment | |
| 21 | -API_BASE_URL = os.getenv('API_BASE_URL', 'http://localhost:6002') | |
| 22 | +# Backend proxy target for same-origin API forwarding | |
| 23 | +BACKEND_PROXY_URL = os.getenv('BACKEND_PROXY_URL', 'http://127.0.0.1:6002').rstrip('/') | |
| 22 | 24 | |
| 23 | 25 | # Change to frontend directory |
| 24 | 26 | frontend_dir = os.path.join(os.path.dirname(__file__), '../frontend') |
| ... | ... | @@ -54,42 +56,85 @@ class RateLimitingMixin: |
| 54 | 56 | class MyHTTPRequestHandler(http.server.SimpleHTTPRequestHandler, RateLimitingMixin): |
| 55 | 57 | """Custom request handler with CORS support and robust error handling.""" |
| 56 | 58 | |
| 59 | + def _is_proxy_path(self, path: str) -> bool: | |
| 60 | + """Return True for API paths that should be forwarded to backend service.""" | |
| 61 | + return path.startswith('/search/') or path.startswith('/admin/') or path.startswith('/indexer/') | |
| 62 | + | |
| 63 | + def _proxy_to_backend(self): | |
| 64 | + """Proxy current request to backend service on the GPU server.""" | |
| 65 | + target_url = f"{BACKEND_PROXY_URL}{self.path}" | |
| 66 | + method = self.command.upper() | |
| 67 | + | |
| 68 | + try: | |
| 69 | + content_length = int(self.headers.get('Content-Length', '0')) | |
| 70 | + except ValueError: | |
| 71 | + content_length = 0 | |
| 72 | + body = self.rfile.read(content_length) if content_length > 0 else None | |
| 73 | + | |
| 74 | + forward_headers = {} | |
| 75 | + for key, value in self.headers.items(): | |
| 76 | + lk = key.lower() | |
| 77 | + if lk in ('host', 'content-length', 'connection'): | |
| 78 | + continue | |
| 79 | + forward_headers[key] = value | |
| 80 | + | |
| 81 | + req = urllib.request.Request( | |
| 82 | + target_url, | |
| 83 | + data=body, | |
| 84 | + headers=forward_headers, | |
| 85 | + method=method, | |
| 86 | + ) | |
| 87 | + | |
| 88 | + try: | |
| 89 | + with urllib.request.urlopen(req, timeout=30) as resp: | |
| 90 | + resp_body = resp.read() | |
| 91 | + self.send_response(resp.getcode()) | |
| 92 | + for header, value in resp.getheaders(): | |
| 93 | + lh = header.lower() | |
| 94 | + if lh in ('transfer-encoding', 'connection', 'content-length'): | |
| 95 | + continue | |
| 96 | + self.send_header(header, value) | |
| 97 | + self.end_headers() | |
| 98 | + self.wfile.write(resp_body) | |
| 99 | + except urllib.error.HTTPError as e: | |
| 100 | + err_body = e.read() if hasattr(e, 'read') else b'' | |
| 101 | + self.send_response(e.code) | |
| 102 | + if e.headers: | |
| 103 | + for header, value in e.headers.items(): | |
| 104 | + lh = header.lower() | |
| 105 | + if lh in ('transfer-encoding', 'connection', 'content-length'): | |
| 106 | + continue | |
| 107 | + self.send_header(header, value) | |
| 108 | + self.end_headers() | |
| 109 | + if err_body: | |
| 110 | + self.wfile.write(err_body) | |
| 111 | + except Exception as e: | |
| 112 | + logging.error(f"Backend proxy error for {method} {self.path}: {e}") | |
| 113 | + self.send_response(502) | |
| 114 | + self.send_header('Content-Type', 'application/json; charset=utf-8') | |
| 115 | + self.end_headers() | |
| 116 | + self.wfile.write(b'{"error":"Bad Gateway: backend proxy failed"}') | |
| 117 | + | |
| 57 | 118 | def do_GET(self): |
| 58 | - """Handle GET requests with API config injection.""" | |
| 119 | + """Handle GET requests with lightweight API proxy.""" | |
| 59 | 120 | path = self.path.split('?')[0] |
| 60 | - | |
| 121 | + | |
| 122 | + # Proxy API paths to backend first | |
| 123 | + if self._is_proxy_path(path): | |
| 124 | + self._proxy_to_backend() | |
| 125 | + return | |
| 61 | 126 | # Route / to index.html |
| 62 | 127 | if path == '/' or path == '': |
| 63 | 128 | self.path = '/index.html' + (self.path.split('?', 1)[1] if '?' in self.path else '') |
| 64 | - | |
| 65 | - # Inject API config for HTML files | |
| 66 | - if self.path.endswith('.html'): | |
| 67 | - self._serve_html_with_config() | |
| 68 | - else: | |
| 69 | - super().do_GET() | |
| 70 | - | |
| 71 | - def _serve_html_with_config(self): | |
| 72 | - """Serve HTML with API_BASE_URL injected.""" | |
| 73 | - try: | |
| 74 | - file_path = self.path.lstrip('/') | |
| 75 | - if not os.path.exists(file_path): | |
| 76 | - self.send_error(404) | |
| 77 | - return | |
| 78 | - | |
| 79 | - with open(file_path, 'r', encoding='utf-8') as f: | |
| 80 | - html = f.read() | |
| 81 | - | |
| 82 | - # Inject API_BASE_URL before app.js | |
| 83 | - config_script = f'<script>window.API_BASE_URL="{API_BASE_URL}";</script>\n ' | |
| 84 | - html = html.replace('<script src="/static/js/app.js', config_script + '<script src="/static/js/app.js', 1) | |
| 85 | - | |
| 86 | - self.send_response(200) | |
| 87 | - self.send_header('Content-Type', 'text/html; charset=utf-8') | |
| 88 | - self.end_headers() | |
| 89 | - self.wfile.write(html.encode('utf-8')) | |
| 90 | - except Exception as e: | |
| 91 | - logging.error(f"Error serving HTML: {e}") | |
| 92 | - self.send_error(500) | |
| 129 | + super().do_GET() | |
| 130 | + | |
| 131 | + def do_POST(self): | |
| 132 | + """Handle POST requests. Proxy API requests to backend.""" | |
| 133 | + path = self.path.split('?')[0] | |
| 134 | + if self._is_proxy_path(path): | |
| 135 | + self._proxy_to_backend() | |
| 136 | + return | |
| 137 | + self.send_error(405, "Method Not Allowed") | |
| 93 | 138 | |
| 94 | 139 | def setup(self): |
| 95 | 140 | """Setup with error handling.""" |
| ... | ... | @@ -150,6 +195,11 @@ class MyHTTPRequestHandler(http.server.SimpleHTTPRequestHandler, RateLimitingMix |
| 150 | 195 | def do_OPTIONS(self): |
| 151 | 196 | """Handle OPTIONS requests.""" |
| 152 | 197 | try: |
| 198 | + path = self.path.split('?')[0] | |
| 199 | + if self._is_proxy_path(path): | |
| 200 | + self.send_response(204) | |
| 201 | + self.end_headers() | |
| 202 | + return | |
| 153 | 203 | self.send_response(200) |
| 154 | 204 | self.end_headers() |
| 155 | 205 | except Exception: | ... | ... |