Commit 02c407014823a2928f92b4119f87ab9354b1ab77

Authored by tangwang
1 parent 6ab0acd4

frontend proxy search via same-origin + update ES9/Kibana docs

Made-with: Cursor
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 &#39;saas:4hOaLaf41y2VuI8y&#39; \
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:
... ...