fix(install): 1Panel Redis 无账号与步骤 4 URL 自愈
确立 Redis 仅密码认证(:pass@/default),隐藏误导性用户名字段; connection-check 自动修正错误 NEXUS_REDIS_URL,并更新运维文档与验收脚本。
This commit is contained in:
@@ -69,15 +69,15 @@ main() {
|
||||
try_ping "无认证" "redis://${redis_host}:6379/0" && any=1 || true
|
||||
if [[ -n "$pass" ]]; then
|
||||
try_ping "仅密码 (:pass@)" "redis://:${pass}@${redis_host}:6379/0" && any=1 || true
|
||||
try_ping "1Panel root 用户" "redis://root:${pass}@${redis_host}:6379/0" && any=1 || true
|
||||
try_ping "default 用户" "redis://default:${pass}@${redis_host}:6379/0" && any=1 || true
|
||||
info "跳过 root 用户探测 — 1Panel Redis 无 root 账号"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [[ "$any" -eq 1 ]]; then
|
||||
ok "至少一种格式可用"
|
||||
echo ""
|
||||
info "安装向导步骤 3 建议:Redis 用户名留空,只填密码(后端自动用 :pass@ 或 default)"
|
||||
info "安装向导步骤 3:1Panel Redis 无账号,只填密码(后端自动用 :pass@ 或 default)"
|
||||
info "已有 .env 时写入 NEXUS_REDIS_URL,例如:"
|
||||
info " redis://:${pass}@${redis_host}:6379/0"
|
||||
info " 或 redis://default:${pass}@${redis_host}:6379/0"
|
||||
|
||||
@@ -52,6 +52,12 @@ check_repo() {
|
||||
warn "早于 b25d079 — 步骤 3 可能仍预填 host.docker.internal"
|
||||
info "修复: cd $NEXUS_ROOT && nx update --no-cache"
|
||||
fi
|
||||
if git -C "$NEXUS_ROOT" merge-base --is-ancestor 3b2856f HEAD 2>/dev/null; then
|
||||
pass "已包含 3b2856f+(Redis :pass@ 自动解析,无 root)"
|
||||
else
|
||||
warn "早于 3b2856f — Redis 认证可能仍误用 root"
|
||||
info "修复: cd $NEXUS_ROOT && git pull && bash deploy/sync-install-wizard-to-container.sh"
|
||||
fi
|
||||
}
|
||||
|
||||
check_env_prod() {
|
||||
@@ -140,10 +146,32 @@ s=socket.socket(); s.settimeout(3); s.connect(('$redis', 6379)); s.close()
|
||||
fi
|
||||
}
|
||||
|
||||
check_app_env_redis_url() {
|
||||
[[ -n "${NEXUS_CONTAINER:-}" ]] || return 0
|
||||
if ! docker exec "$NEXUS_CONTAINER" test -f /app/.env 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
local rurl
|
||||
rurl="$(docker exec "$NEXUS_CONTAINER" grep -E '^NEXUS_REDIS_URL=' /app/.env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
|
||||
if [[ -z "$rurl" ]]; then
|
||||
warn "/app/.env 无 NEXUS_REDIS_URL"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$rurl" == *"://root:"* ]]; then
|
||||
fail "NEXUS_REDIS_URL 含 root 用户(1Panel Redis 无 root 账号)"
|
||||
info "修复: redis://:密码@1Panel-redis-xxxx:6379/0 — 见 deploy/test-1panel-redis.sh"
|
||||
elif [[ "$rurl" == *"1Panel-redis"* ]] || [[ "$rurl" == *"://:"* ]]; then
|
||||
pass "NEXUS_REDIS_URL 格式合理(容器名或 :pass@)"
|
||||
else
|
||||
warn "NEXUS_REDIS_URL 可能未指向 1Panel Redis: $rurl"
|
||||
fi
|
||||
}
|
||||
|
||||
check_install_mode() {
|
||||
[[ -n "${NEXUS_CONTAINER:-}" ]] || return 0
|
||||
if docker exec "$NEXUS_CONTAINER" test -f /app/.env 2>/dev/null; then
|
||||
warn "/app/.env 已存在 — 安装向导可能已进入步骤 3+ 或误写 env"
|
||||
check_app_env_redis_url
|
||||
info "若需重来: 删卷 nexus-state 中 .env 并设 NEXUS_INSTALL_WIZARD_PENDING=1 后 nx update"
|
||||
else
|
||||
pass "/app/.env 不存在(安装模式 OK)"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 2026-06-06 — 安装向导 Redis:无账号 + 步骤 4 自愈
|
||||
|
||||
## 摘要
|
||||
|
||||
确立 1Panel Redis **无账号**铁律;Docker 安装模式仅尝试 `redis://:pass@` 与 `default`;步骤 4 `connection-check` 可自动修正错误的 `NEXUS_REDIS_URL`;前端 1Panel 模式隐藏 Redis 用户名字段。
|
||||
|
||||
## 动机
|
||||
|
||||
- 1Panel Redis 只有 `requirepass` 口令,不是 MySQL 式 `root` 用户;历史误读导致 `redis://root:pass@` 认证失败。
|
||||
- 步骤 3 在旧版本写入错误 URL 后,步骤 4 仅读 `.env` 原文无法通过,需向导内自愈。
|
||||
- 暴露「Redis 用户名」输入框易误导运维填 `root`。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/install.py` — `_sanitize_redis_request`、候选顺序、`_init_db_request_from_redis_url`、`_patch_env_redis_url`、`connection-check` 修复
|
||||
- `web/app/install.html` — 1Panel 隐藏 `redis_user`、展示 `resolved_redis_url` / `redis_repaired`
|
||||
- `deploy/test-1panel-redis.sh` — 移除 root 探测
|
||||
- `deploy/verify-1panel-install-wizard.sh` — 3b2856f+ 与 `.env` Redis URL 检查
|
||||
- `docs/project/nexus-1panel-operations-knowledge.md` — 全文去 Redis root 表述
|
||||
- `tests/test_security_unit.py` — Redis 解析与 connection-check 修复用例
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 已有错误 `.env`:步骤 4 点「重新检测连接」可自动修补;或手动改为 `redis://:密码@1Panel-redis-xxxx:6379/0`
|
||||
- 部署:`git pull` → `bash deploy/sync-install-wizard-to-container.sh`(或 `nx update`)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_security_unit.py -k redis
|
||||
bash deploy/test-1panel-redis.sh # 服务器,需 REDIS_PASS
|
||||
bash deploy/verify-1panel-install-wizard.sh
|
||||
```
|
||||
|
||||
浏览器:步骤 3 仅填 Redis 密码 → 检测 → 初始化 → 步骤 4 连接全绿。
|
||||
@@ -121,9 +121,9 @@ entrypoint EXIT trap
|
||||
|------|-------|-------|
|
||||
| 主机 | `1Panel-mysql-xxxx` | `1Panel-redis-xxxx` |
|
||||
| 端口 | `3306` | `6379` |
|
||||
| 库名/用户 | `nexus` / `nexus` | 库号 `0` |
|
||||
| 用户名 | — | **通常留空**(自动 `redis://:pass@`);部分实例填 `default`。**勿填 root** |
|
||||
| 密码 | 1Panel「数据库」里建的 nexus 密码 | 1Panel Redis **应用参数**中的随机密码 |
|
||||
| 库名 | `nexus` | 库号 `0` |
|
||||
| 用户名 | `nexus` | **无**(1Panel Redis 无账号;向导仅填密码) |
|
||||
| 密码 | 1Panel「数据库」里建的 nexus 密码 | 1Panel Redis **应用参数**中的口令 |
|
||||
|
||||
### 3.3 Docker 预填来源
|
||||
|
||||
@@ -133,7 +133,7 @@ entrypoint EXIT trap
|
||||
2. `/app/web/data/1panel-hosts.json`(`nx update` 写入卷)
|
||||
3. 回退 `host.docker.internal`(**1Panel 场景错误**)
|
||||
|
||||
`env-check` 在 `NEXUS_DOCKER_WRITE_ENV=1` 时返回 `docker_defaults`,含 `redis_user: "root"`。前端会覆盖 stale 的 `127.0.0.1` / `host.docker.internal`。
|
||||
`env-check` 在 `NEXUS_DOCKER_WRITE_ENV=1` 时返回 `docker_defaults`(主机/端口/库名,**不含** Redis 用户名)。前端会覆盖 stale 的 `127.0.0.1` / `host.docker.internal`,并隐藏 Redis 用户名字段。
|
||||
|
||||
### 3.4 连接 URL 格式(必读)
|
||||
|
||||
@@ -162,7 +162,7 @@ mysql+aiomysql://nexus:密码@1Panel-mysql-xxxx:3306/nexus
|
||||
1. 安装 1Panel + Docker
|
||||
2. 应用商店安装 **MySQL**、**Redis**(自动创建 `1panel-network`)
|
||||
3. 1Panel「数据库」:库 `nexus`、用户 `nexus`(仅授权 `nexus` 库)
|
||||
4. 记录 Redis 应用参数中的 **root 密码**
|
||||
4. 记录 Redis 应用参数中的 **连接口令**(不是 MySQL 式账号)
|
||||
|
||||
### 4.2 安装 Nexus
|
||||
|
||||
@@ -245,7 +245,7 @@ cd /opt/nexus && bash deploy/install-nexus-fresh.sh --skip-clone
|
||||
|
||||
```
|
||||
1. 主机是否为 1Panel-redis-xxxx?(不是 127.0.0.1)
|
||||
2. 用户名是否误填 root?(多数 1Panel 应**留空**或填 default)
|
||||
2. 是否误把口令当成「root 用户名」?(1Panel Redis **无账号**,只填密码)
|
||||
3. 密码是否与 1Panel → Redis → 参数 一致?
|
||||
4. 运行 `bash deploy/test-1panel-redis.sh` 看哪种 URL 可用
|
||||
5. 运行诊断:
|
||||
@@ -256,7 +256,7 @@ cd /opt/nexus && bash deploy/install-nexus-fresh.sh --skip-clone
|
||||
|
||||
```bash
|
||||
REDIS=$(docker ps --format '{{.Names}}' | grep -i '1Panel-redis' | head -1)
|
||||
docker exec -it "$REDIS" redis-cli -u "redis://root:你的密码@127.0.0.1:6379" ping
|
||||
docker exec -it "$REDIS" redis-cli -u "redis://:你的密码@127.0.0.1:6379" ping
|
||||
# 期望 PONG
|
||||
```
|
||||
|
||||
@@ -272,7 +272,7 @@ grep -E 'requirepass|user ' /opt/1panel/apps/redis/*/conf/redis.conf 2>/dev/null
|
||||
```bash
|
||||
NEXUS=$(docker ps --format '{{.Names}}' | grep nexus-prod-nexus | head -1)
|
||||
REDIS=$(grep '^NEXUS_1PANEL_REDIS_HOST=' /opt/nexus/docker/.env.prod | cut -d= -f2)
|
||||
docker exec "$NEXUS" sed -i "s|^NEXUS_REDIS_URL=.*|NEXUS_REDIS_URL=\"redis://root:你的密码@${REDIS}:6379/0\"|" /app/.env
|
||||
docker exec "$NEXUS" sed -i "s|^NEXUS_REDIS_URL=.*|NEXUS_REDIS_URL=\"redis://:你的密码@${REDIS}:6379/0\"|" /app/.env
|
||||
docker restart "$NEXUS"
|
||||
# 向导步骤 4 → 重新检测连接
|
||||
```
|
||||
@@ -349,7 +349,8 @@ cd /opt/nexus && bash deploy/verify-1panel-install-wizard.sh
|
||||
| `b25d079` | `nx update` sync 1Panel 容器名;upgrade 补 `sync_1panel_service_hosts` |
|
||||
| `d0544c9` | `fix-1panel-mysql-grant.sh`;MySQL 1045 友好错误 |
|
||||
| `da061d3` | 步骤 3 `test-credentials` 门禁 |
|
||||
| `807f4c2` | Redis URL `redis://:pass@` / `redis://root:pass@`;`redis_user` 字段 |
|
||||
| `807f4c2` | Redis URL `redis://:pass@` 修复(曾误加 root,已废弃) |
|
||||
| `3b2856f+` | 自动解析 `:pass@` / `default`;步骤 4 connection-check 可自愈 `.env` |
|
||||
|
||||
---
|
||||
|
||||
@@ -360,7 +361,7 @@ cd /opt/nexus && bash deploy/verify-1panel-install-wizard.sh
|
||||
| 步骤 2/3 | 与 init-db 合并描述 | 步骤 2 仅 env-check;步骤 3 含 test-credentials |
|
||||
| 配置三写 | 提及 `config.php` | 现为 `config.json` |
|
||||
| 生产部署 §18 | Supervisor + 宝塔 | 以 `nexus-1panel.sh` / `nx` 为主 |
|
||||
| Redis 主机 | 泛化为 localhost | **1Panel 必须容器名 + root 用户** |
|
||||
| Redis 主机 | 泛化为 localhost | **1Panel 必须容器名 + 仅密码(`:pass@`)** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+108
-6
@@ -12,7 +12,7 @@ import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote_plus, urlparse
|
||||
from urllib.parse import quote_plus, unquote_plus, urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
@@ -338,13 +338,28 @@ def _test_redis_url(redis_url: str) -> tuple[bool, str]:
|
||||
return False, f"✗ Redis 连接失败: {e}"
|
||||
|
||||
|
||||
def _is_docker_install_mode() -> bool:
|
||||
return os.environ.get("NEXUS_DOCKER_WRITE_ENV") == "1"
|
||||
|
||||
|
||||
def _sanitize_redis_request(req: InitDbRequest) -> InitDbRequest:
|
||||
"""1Panel/Docker: Redis has no account — ignore username (incl. mistaken root)."""
|
||||
user = (req.redis_user or "").strip()
|
||||
if _is_docker_install_mode():
|
||||
if user:
|
||||
return req.model_copy(update={"redis_user": ""})
|
||||
return req
|
||||
if user.lower() == "root":
|
||||
return req.model_copy(update={"redis_user": ""})
|
||||
return req
|
||||
|
||||
|
||||
def _redis_url_candidates(req: InitDbRequest) -> list[tuple[str, str]]:
|
||||
"""Try order for 1Panel Redis (default user vs :pass@ vs explicit user)."""
|
||||
"""Auth variants: 1Panel tries :pass@ then default only (no root)."""
|
||||
password = (req.redis_pass or "").strip()
|
||||
if not password:
|
||||
return [("无密码", _build_redis_url(req))]
|
||||
|
||||
user = (req.redis_user or "").strip()
|
||||
seen: set[str] = set()
|
||||
out: list[tuple[str, str]] = []
|
||||
|
||||
@@ -355,7 +370,13 @@ def _redis_url_candidates(req: InitDbRequest) -> list[tuple[str, str]]:
|
||||
seen.add(url)
|
||||
out.append((label, url))
|
||||
|
||||
if user:
|
||||
if _is_docker_install_mode():
|
||||
add("仅密码 (:pass@)", req.model_copy(update={"redis_user": ""}))
|
||||
add("用户 default", req.model_copy(update={"redis_user": "default"}))
|
||||
return out
|
||||
|
||||
user = (req.redis_user or "").strip()
|
||||
if user and user.lower() != "root":
|
||||
add(f"用户 {user}", req)
|
||||
add("仅密码 (:pass@)", req.model_copy(update={"redis_user": ""}))
|
||||
if user.lower() != "default":
|
||||
@@ -363,6 +384,67 @@ def _redis_url_candidates(req: InitDbRequest) -> list[tuple[str, str]]:
|
||||
return out
|
||||
|
||||
|
||||
def _init_db_request_from_redis_url(redis_url: str) -> InitDbRequest | None:
|
||||
"""Parse NEXUS_REDIS_URL into InitDbRequest fields for re-probe / repair."""
|
||||
if not redis_url.strip():
|
||||
return None
|
||||
parsed = urlparse(redis_url)
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
user = unquote_plus(parsed.username or "")
|
||||
password = unquote_plus(parsed.password or "")
|
||||
if not password and user:
|
||||
# Legacy bug: redis://pass@host (password stored as username)
|
||||
password = user
|
||||
user = ""
|
||||
db = (parsed.path or "/0").lstrip("/") or "0"
|
||||
return InitDbRequest(
|
||||
db_pass=".",
|
||||
redis_host=parsed.hostname,
|
||||
redis_port=str(parsed.port or 6379),
|
||||
redis_db=db,
|
||||
redis_user=user,
|
||||
redis_pass=password,
|
||||
)
|
||||
|
||||
|
||||
def _mask_redis_url(redis_url: str) -> str:
|
||||
parsed = urlparse(redis_url)
|
||||
if not parsed.password:
|
||||
return redis_url
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port
|
||||
user = parsed.username or ""
|
||||
if user:
|
||||
netloc = f"{user}:***@{host}"
|
||||
else:
|
||||
netloc = f":***@{host}"
|
||||
if port:
|
||||
netloc = f"{netloc}:{port}"
|
||||
path = parsed.path or "/0"
|
||||
return f"redis://{netloc}{path}"
|
||||
|
||||
|
||||
def _patch_env_redis_url(new_url: str) -> None:
|
||||
"""Update only NEXUS_REDIS_URL in existing .env (install step 4 repair)."""
|
||||
if not ENV_FILE.is_file():
|
||||
return
|
||||
lines = read_utf8_text(ENV_FILE).splitlines()
|
||||
new_line = f"NEXUS_REDIS_URL={_escape_env_value(new_url)}"
|
||||
replaced = False
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
if line.strip().startswith("NEXUS_REDIS_URL="):
|
||||
out.append(new_line)
|
||||
replaced = True
|
||||
else:
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
out.append(new_line)
|
||||
write_utf8_lf(ENV_FILE, "\n".join(out) + "\n")
|
||||
logger.info("Patched NEXUS_REDIS_URL in %s", ENV_FILE)
|
||||
|
||||
|
||||
def _resolve_redis_url(req: InitDbRequest) -> tuple[bool, str, str | None]:
|
||||
"""Pick first working Redis URL among 1Panel-compatible auth variants."""
|
||||
last_msg = "✗ 未配置 Redis"
|
||||
@@ -753,6 +835,7 @@ async def env_check():
|
||||
|
||||
async def _credential_checks(req: InitDbRequest) -> tuple[list[dict], bool, str | None]:
|
||||
"""Verify MySQL + Redis with credentials from install step 3 (TCP + AUTH)."""
|
||||
req = _sanitize_redis_request(req)
|
||||
db_url = _make_db_url(req)
|
||||
mysql_ok, mysql_msg = await _test_mysql_url(db_url)
|
||||
redis_ok, redis_msg, redis_url = _resolve_redis_url(req)
|
||||
@@ -775,7 +858,7 @@ async def test_credentials(req: InitDbRequest):
|
||||
checks, all_pass, redis_url = await _credential_checks(req)
|
||||
payload: dict = {"checks": checks, "all_pass": all_pass}
|
||||
if redis_url:
|
||||
payload["resolved_redis_url"] = redis_url
|
||||
payload["resolved_redis_url"] = _mask_redis_url(redis_url)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -792,11 +875,30 @@ async def connection_check():
|
||||
|
||||
mysql_ok, mysql_msg = await _test_mysql_url(db_url)
|
||||
redis_ok, redis_msg = _test_redis_url(redis_url)
|
||||
redis_repaired = False
|
||||
resolved_display: str | None = None
|
||||
|
||||
if not redis_ok and redis_url:
|
||||
req_from_env = _init_db_request_from_redis_url(redis_url)
|
||||
if req_from_env:
|
||||
ok, msg, resolved = _resolve_redis_url(_sanitize_redis_request(req_from_env))
|
||||
if ok and resolved:
|
||||
if resolved != redis_url and not _is_locked():
|
||||
_patch_env_redis_url(resolved)
|
||||
redis_repaired = True
|
||||
redis_ok, redis_msg = True, msg
|
||||
resolved_display = _mask_redis_url(resolved)
|
||||
|
||||
checks = [
|
||||
{"name": "MySQL", "required": "可连接", "current": mysql_msg, "pass": mysql_ok},
|
||||
{"name": "Redis", "required": "可连接", "current": redis_msg, "pass": redis_ok},
|
||||
]
|
||||
return {"checks": checks, "all_pass": mysql_ok and redis_ok}
|
||||
payload: dict = {"checks": checks, "all_pass": mysql_ok and redis_ok}
|
||||
if redis_repaired:
|
||||
payload["redis_repaired"] = True
|
||||
if resolved_display:
|
||||
payload["resolved_redis_url"] = resolved_display
|
||||
return payload
|
||||
|
||||
|
||||
@router.post("/init-db")
|
||||
|
||||
@@ -235,8 +235,9 @@ def test_build_redis_url_with_default_user():
|
||||
|
||||
|
||||
def test_resolve_redis_url_falls_back_to_colon_pass(monkeypatch):
|
||||
from server.api.install import InitDbRequest, _resolve_redis_url
|
||||
from server.api.install import InitDbRequest, _resolve_redis_url, _sanitize_redis_request
|
||||
|
||||
monkeypatch.setenv("NEXUS_DOCKER_WRITE_ENV", "1")
|
||||
req = InitDbRequest(
|
||||
db_pass="db",
|
||||
redis_host="1Panel-redis-x",
|
||||
@@ -250,11 +251,76 @@ def test_resolve_redis_url_falls_back_to_colon_pass(monkeypatch):
|
||||
return url.startswith("redis://:secret@"), "ok" if url.startswith("redis://:secret@") else "fail"
|
||||
|
||||
monkeypatch.setattr("server.api.install._test_redis_url", fake_test)
|
||||
ok, msg, url = _resolve_redis_url(req)
|
||||
ok, msg, url = _resolve_redis_url(_sanitize_redis_request(req))
|
||||
assert ok is True
|
||||
assert url == "redis://:secret@1Panel-redis-x:6379/0"
|
||||
assert "仅密码" in msg
|
||||
assert any(u.startswith("redis://root:") for u in calls)
|
||||
assert not any(u.startswith("redis://root:") for u in calls)
|
||||
|
||||
|
||||
def test_redis_url_candidates_never_tries_root_in_docker_mode(monkeypatch):
|
||||
from server.api.install import InitDbRequest, _redis_url_candidates, _sanitize_redis_request
|
||||
|
||||
monkeypatch.setenv("NEXUS_DOCKER_WRITE_ENV", "1")
|
||||
req = InitDbRequest(
|
||||
db_pass="db",
|
||||
redis_host="1Panel-redis-x",
|
||||
redis_user="root",
|
||||
redis_pass="secret",
|
||||
)
|
||||
labels = [label for label, url in _redis_url_candidates(_sanitize_redis_request(req))]
|
||||
urls = [url for _, url in _redis_url_candidates(_sanitize_redis_request(req))]
|
||||
assert "root" not in " ".join(labels).lower()
|
||||
assert not any("root:" in u for u in urls)
|
||||
|
||||
|
||||
def test_init_db_request_from_redis_url_legacy_and_colon_pass():
|
||||
from server.api.install import _init_db_request_from_redis_url
|
||||
|
||||
legacy = _init_db_request_from_redis_url("redis://badpass@1Panel-redis-x:6379/0")
|
||||
assert legacy is not None
|
||||
assert legacy.redis_pass == "badpass"
|
||||
assert legacy.redis_user == ""
|
||||
|
||||
colon = _init_db_request_from_redis_url("redis://:secret@1Panel-redis-x:6379/0")
|
||||
assert colon is not None
|
||||
assert colon.redis_pass == "secret"
|
||||
assert colon.redis_user == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_check_repairs_bad_redis_url(monkeypatch, tmp_path):
|
||||
from server.api import install as install_api
|
||||
from server.api.install import connection_check
|
||||
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
'NEXUS_DATABASE_URL="mysql+aiomysql://n:pass@h:3306/n"\n'
|
||||
'NEXUS_REDIS_URL="redis://root:secret@1Panel-redis-x:6379/0"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(install_api, "ENV_FILE", env_file)
|
||||
monkeypatch.setattr(install_api, "INSTALL_LOCK", tmp_path / ".install_locked")
|
||||
|
||||
async def _mysql_ok(_url):
|
||||
return True, "✓ MySQL"
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_redis(url: str) -> tuple[bool, str]:
|
||||
calls.append(url)
|
||||
if url.startswith("redis://:secret@"):
|
||||
return True, "✓ Redis 连接正常(仅密码 (:pass@))"
|
||||
return False, "auth fail"
|
||||
|
||||
monkeypatch.setattr(install_api, "_test_mysql_url", _mysql_ok)
|
||||
monkeypatch.setattr(install_api, "_test_redis_url", fake_redis)
|
||||
monkeypatch.setenv("NEXUS_DOCKER_WRITE_ENV", "1")
|
||||
|
||||
out = await connection_check()
|
||||
assert out["all_pass"] is True
|
||||
assert out.get("redis_repaired") is True
|
||||
assert "redis://:secret@" in env_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_install_reject_repeat_init_when_env_exists(monkeypatch, tmp_path):
|
||||
|
||||
+37
-7
@@ -195,10 +195,13 @@
|
||||
<input x-model="form.redis_port" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-3">
|
||||
<div class="mt-3" x-show="dockerDefaults">
|
||||
<p class="text-xs text-[var(--text-secondary)]">1Panel Redis <strong>无账号</strong>,只需填写下方密码(应用参数中的口令)。</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-3" x-show="!dockerDefaults">
|
||||
<div>
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 用户名(可选)</label>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-1">1Panel 多数<strong>留空</strong>(仅密码);不行再填 <code class="bg-slate-100 px-1 rounded">default</code>。勿填 root</p>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-1">自建 Redis ACL 可填用户名;多数环境留空</p>
|
||||
<input x-model="form.redis_user" @input="invalidateCredCheck()" placeholder="通常留空"
|
||||
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
@@ -207,6 +210,10 @@
|
||||
<input x-model="form.redis_db" @input="invalidateCredCheck()" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3" x-show="dockerDefaults">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 数据库号</label>
|
||||
<input x-model="form.redis_db" @input="invalidateCredCheck()" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 密码</label>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-1">1Panel → Redis → 参数中的密码;无密码留空</p>
|
||||
@@ -256,7 +263,8 @@
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="!credLoading && credAllPass" class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg text-sm mb-2">
|
||||
✓ MySQL 与 Redis 账号密码正确,可以初始化。
|
||||
<p>✓ MySQL 与 Redis 连接正常,可以初始化。</p>
|
||||
<p x-show="resolvedRedisUrl" class="mt-1 text-xs">Redis URL:<code class="bg-green-100 px-1 rounded" x-text="resolvedRedisUrl"></code></p>
|
||||
</div>
|
||||
<div x-show="!credLoading && !credAllPass && credChecks.length" class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg text-sm mb-2">
|
||||
连接未通过:请核对主机、用户名与密码后重新检测。
|
||||
@@ -301,7 +309,9 @@
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="!connLoading && connAllPass" class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg text-sm mb-2">
|
||||
✓ MySQL 与 Redis 均已连通,可创建管理员。
|
||||
<p>✓ MySQL 与 Redis 均已连通,可创建管理员。</p>
|
||||
<p x-show="redisRepaired" class="mt-1 text-xs">已自动修正 .env 中的 Redis URL。</p>
|
||||
<p x-show="resolvedRedisUrl" class="mt-1 text-xs">Redis URL:<code class="bg-green-100 px-1 rounded" x-text="resolvedRedisUrl"></code></p>
|
||||
</div>
|
||||
<div x-show="!connLoading && !connAllPass && connChecks.length" class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg text-sm mb-2">
|
||||
连接未通过:若步骤 3 已初始化,请在服务器修正 /app/.env 中 NEXUS_DATABASE_URL / NEXUS_REDIS_URL 后重启容器,再点重新检测。
|
||||
@@ -626,6 +636,9 @@ function installWizard() {
|
||||
connLoading: false,
|
||||
connChecks: [],
|
||||
connAllPass: false,
|
||||
redisRepaired: false,
|
||||
resolvedRedisUrl: '',
|
||||
dockerDefaults: null,
|
||||
credLoading: false,
|
||||
credChecks: [],
|
||||
credAllPass: false,
|
||||
@@ -675,7 +688,12 @@ function installWizard() {
|
||||
},
|
||||
|
||||
applyDockerDefaults(defaults) {
|
||||
if (!defaults) return;
|
||||
if (!defaults) {
|
||||
this.dockerDefaults = null;
|
||||
return;
|
||||
}
|
||||
this.dockerDefaults = defaults;
|
||||
this.form.redis_user = '';
|
||||
if (this.isStaleDockerHost(this.form.db_host) && defaults.db_host) {
|
||||
this.form.db_host = defaults.db_host;
|
||||
}
|
||||
@@ -739,20 +757,28 @@ function installWizard() {
|
||||
this.credChecks = [];
|
||||
},
|
||||
|
||||
installFormPayload() {
|
||||
const payload = { ...this.form };
|
||||
if (this.dockerDefaults) payload.redis_user = '';
|
||||
return payload;
|
||||
},
|
||||
|
||||
async testCredentials() {
|
||||
this.credLoading = true;
|
||||
this.error = '';
|
||||
this.credAllPass = false;
|
||||
this.resolvedRedisUrl = '';
|
||||
try {
|
||||
const r = await fetch(API + '/api/install/test-credentials', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(this.form),
|
||||
body: JSON.stringify(this.installFormPayload()),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
this.credChecks = d.checks || [];
|
||||
this.credAllPass = !!d.all_pass;
|
||||
this.resolvedRedisUrl = d.resolved_redis_url || '';
|
||||
if (!this.credAllPass) {
|
||||
this.error = 'MySQL 或 Redis 连接未通过,请核对配置后重试。';
|
||||
}
|
||||
@@ -768,12 +794,16 @@ function installWizard() {
|
||||
|
||||
async checkConnections() {
|
||||
this.connLoading = true;
|
||||
this.redisRepaired = false;
|
||||
this.resolvedRedisUrl = '';
|
||||
try {
|
||||
const r = await fetch(API + '/api/install/connection-check');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
this.connChecks = d.checks || [];
|
||||
this.connAllPass = !!d.all_pass;
|
||||
this.redisRepaired = !!d.redis_repaired;
|
||||
this.resolvedRedisUrl = d.resolved_redis_url || '';
|
||||
} else {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
this.connChecks = [{ name: '连接检测', pass: false, current: d.detail || '请先完成步骤3' }];
|
||||
@@ -813,7 +843,7 @@ function installWizard() {
|
||||
const r = await fetch(API + '/api/install/init-db', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(this.form),
|
||||
body: JSON.stringify(this.installFormPayload()),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok && d.success) {
|
||||
|
||||
Reference in New Issue
Block a user