fix(install): 1Panel Redis 无账号与步骤 4 URL 自愈
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

确立 Redis 仅密码认证(:pass@/default),隐藏误导性用户名字段;
connection-check 自动修正错误 NEXUS_REDIS_URL,并更新运维文档与验收脚本。
This commit is contained in:
Nexus Deploy
2026-06-06 07:51:52 +08:00
parent 3b2856f388
commit b11360015d
7 changed files with 290 additions and 28 deletions
+2 -2
View File
@@ -69,15 +69,15 @@ main() {
try_ping "无认证" "redis://${redis_host}:6379/0" && any=1 || true try_ping "无认证" "redis://${redis_host}:6379/0" && any=1 || true
if [[ -n "$pass" ]]; then if [[ -n "$pass" ]]; then
try_ping "仅密码 (:pass@)" "redis://:${pass}@${redis_host}:6379/0" && any=1 || true 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 try_ping "default 用户" "redis://default:${pass}@${redis_host}:6379/0" && any=1 || true
info "跳过 root 用户探测 — 1Panel Redis 无 root 账号"
fi fi
echo "" echo ""
if [[ "$any" -eq 1 ]]; then if [[ "$any" -eq 1 ]]; then
ok "至少一种格式可用" ok "至少一种格式可用"
echo "" echo ""
info "安装向导步骤 3 建议Redis 用户名留空,只填密码(后端自动用 :pass@ 或 default" info "安装向导步骤 31Panel Redis 无账号,只填密码(后端自动用 :pass@ 或 default"
info "已有 .env 时写入 NEXUS_REDIS_URL,例如:" info "已有 .env 时写入 NEXUS_REDIS_URL,例如:"
info " redis://:${pass}@${redis_host}:6379/0" info " redis://:${pass}@${redis_host}:6379/0"
info " 或 redis://default:${pass}@${redis_host}:6379/0" info " 或 redis://default:${pass}@${redis_host}:6379/0"
+28
View File
@@ -52,6 +52,12 @@ check_repo() {
warn "早于 b25d079 — 步骤 3 可能仍预填 host.docker.internal" warn "早于 b25d079 — 步骤 3 可能仍预填 host.docker.internal"
info "修复: cd $NEXUS_ROOT && nx update --no-cache" info "修复: cd $NEXUS_ROOT && nx update --no-cache"
fi 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() { check_env_prod() {
@@ -140,10 +146,32 @@ s=socket.socket(); s.settimeout(3); s.connect(('$redis', 6379)); s.close()
fi 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() { check_install_mode() {
[[ -n "${NEXUS_CONTAINER:-}" ]] || return 0 [[ -n "${NEXUS_CONTAINER:-}" ]] || return 0
if docker exec "$NEXUS_CONTAINER" test -f /app/.env 2>/dev/null; then if docker exec "$NEXUS_CONTAINER" test -f /app/.env 2>/dev/null; then
warn "/app/.env 已存在 — 安装向导可能已进入步骤 3+ 或误写 env" warn "/app/.env 已存在 — 安装向导可能已进入步骤 3+ 或误写 env"
check_app_env_redis_url
info "若需重来: 删卷 nexus-state 中 .env 并设 NEXUS_INSTALL_WIZARD_PENDING=1 后 nx update" info "若需重来: 删卷 nexus-state 中 .env 并设 NEXUS_INSTALL_WIZARD_PENDING=1 后 nx update"
else else
pass "/app/.env 不存在(安装模式 OK" 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` | | 主机 | `1Panel-mysql-xxxx` | `1Panel-redis-xxxx` |
| 端口 | `3306` | `6379` | | 端口 | `3306` | `6379` |
| 库名/用户 | `nexus` / `nexus` | 库号 `0` | | 库名 | `nexus` | 库号 `0` |
| 用户名 | | **通常留空**自动 `redis://:pass@`);部分实例填 `default`。**勿填 root** | | 用户名 | `nexus` | ****1Panel Redis 无账号;向导仅填密码) |
| 密码 | 1Panel「数据库」里建的 nexus 密码 | 1Panel Redis **应用参数**中的随机密码 | | 密码 | 1Panel「数据库」里建的 nexus 密码 | 1Panel Redis **应用参数**中的口令 |
### 3.3 Docker 预填来源 ### 3.3 Docker 预填来源
@@ -133,7 +133,7 @@ entrypoint EXIT trap
2. `/app/web/data/1panel-hosts.json``nx update` 写入卷) 2. `/app/web/data/1panel-hosts.json``nx update` 写入卷)
3. 回退 `host.docker.internal`**1Panel 场景错误** 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 格式(必读) ### 3.4 连接 URL 格式(必读)
@@ -162,7 +162,7 @@ mysql+aiomysql://nexus:密码@1Panel-mysql-xxxx:3306/nexus
1. 安装 1Panel + Docker 1. 安装 1Panel + Docker
2. 应用商店安装 **MySQL**、**Redis**(自动创建 `1panel-network` 2. 应用商店安装 **MySQL**、**Redis**(自动创建 `1panel-network`
3. 1Panel「数据库」:库 `nexus`、用户 `nexus`(仅授权 `nexus` 库) 3. 1Panel「数据库」:库 `nexus`、用户 `nexus`(仅授权 `nexus` 库)
4. 记录 Redis 应用参数中的 **root 密码** 4. 记录 Redis 应用参数中的 **连接口令**(不是 MySQL 式账号)
### 4.2 安装 Nexus ### 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 1. 主机是否为 1Panel-redis-xxxx?(不是 127.0.0.1
2. 用户名是否误填 root?(多数 1Panel 应**留空**或填 default 2. 是否误把口令当成「root 用户名」?(1Panel Redis **无账号**,只填密码
3. 密码是否与 1Panel → Redis → 参数 一致? 3. 密码是否与 1Panel → Redis → 参数 一致?
4. 运行 `bash deploy/test-1panel-redis.sh` 看哪种 URL 可用 4. 运行 `bash deploy/test-1panel-redis.sh` 看哪种 URL 可用
5. 运行诊断: 5. 运行诊断:
@@ -256,7 +256,7 @@ cd /opt/nexus && bash deploy/install-nexus-fresh.sh --skip-clone
```bash ```bash
REDIS=$(docker ps --format '{{.Names}}' | grep -i '1Panel-redis' | head -1) 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 # 期望 PONG
``` ```
@@ -272,7 +272,7 @@ grep -E 'requirepass|user ' /opt/1panel/apps/redis/*/conf/redis.conf 2>/dev/null
```bash ```bash
NEXUS=$(docker ps --format '{{.Names}}' | grep nexus-prod-nexus | head -1) 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) 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" docker restart "$NEXUS"
# 向导步骤 4 → 重新检测连接 # 向导步骤 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` | | `b25d079` | `nx update` sync 1Panel 容器名;upgrade 补 `sync_1panel_service_hosts` |
| `d0544c9` | `fix-1panel-mysql-grant.sh`MySQL 1045 友好错误 | | `d0544c9` | `fix-1panel-mysql-grant.sh`MySQL 1045 友好错误 |
| `da061d3` | 步骤 3 `test-credentials` 门禁 | | `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 | | 步骤 2/3 | 与 init-db 合并描述 | 步骤 2 仅 env-check;步骤 3 含 test-credentials |
| 配置三写 | 提及 `config.php` | 现为 `config.json` | | 配置三写 | 提及 `config.php` | 现为 `config.json` |
| 生产部署 §18 | Supervisor + 宝塔 | 以 `nexus-1panel.sh` / `nx` 为主 | | 生产部署 §18 | Supervisor + 宝塔 | 以 `nexus-1panel.sh` / `nx` 为主 |
| Redis 主机 | 泛化为 localhost | **1Panel 必须容器名 + root 用户** | | Redis 主机 | 泛化为 localhost | **1Panel 必须容器名 + 仅密码(`:pass@`** |
--- ---
+108 -6
View File
@@ -12,7 +12,7 @@ import logging
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone 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 fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
@@ -338,13 +338,28 @@ def _test_redis_url(redis_url: str) -> tuple[bool, str]:
return False, f"✗ Redis 连接失败: {e}" 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]]: 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() password = (req.redis_pass or "").strip()
if not password: if not password:
return [("无密码", _build_redis_url(req))] return [("无密码", _build_redis_url(req))]
user = (req.redis_user or "").strip()
seen: set[str] = set() seen: set[str] = set()
out: list[tuple[str, str]] = [] out: list[tuple[str, str]] = []
@@ -355,7 +370,13 @@ def _redis_url_candidates(req: InitDbRequest) -> list[tuple[str, str]]:
seen.add(url) seen.add(url)
out.append((label, 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(f"用户 {user}", req)
add("仅密码 (:pass@)", req.model_copy(update={"redis_user": ""})) add("仅密码 (:pass@)", req.model_copy(update={"redis_user": ""}))
if user.lower() != "default": if user.lower() != "default":
@@ -363,6 +384,67 @@ def _redis_url_candidates(req: InitDbRequest) -> list[tuple[str, str]]:
return out 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]: def _resolve_redis_url(req: InitDbRequest) -> tuple[bool, str, str | None]:
"""Pick first working Redis URL among 1Panel-compatible auth variants.""" """Pick first working Redis URL among 1Panel-compatible auth variants."""
last_msg = "✗ 未配置 Redis" last_msg = "✗ 未配置 Redis"
@@ -753,6 +835,7 @@ async def env_check():
async def _credential_checks(req: InitDbRequest) -> tuple[list[dict], bool, str | None]: async def _credential_checks(req: InitDbRequest) -> tuple[list[dict], bool, str | None]:
"""Verify MySQL + Redis with credentials from install step 3 (TCP + AUTH).""" """Verify MySQL + Redis with credentials from install step 3 (TCP + AUTH)."""
req = _sanitize_redis_request(req)
db_url = _make_db_url(req) db_url = _make_db_url(req)
mysql_ok, mysql_msg = await _test_mysql_url(db_url) mysql_ok, mysql_msg = await _test_mysql_url(db_url)
redis_ok, redis_msg, redis_url = _resolve_redis_url(req) 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) checks, all_pass, redis_url = await _credential_checks(req)
payload: dict = {"checks": checks, "all_pass": all_pass} payload: dict = {"checks": checks, "all_pass": all_pass}
if redis_url: if redis_url:
payload["resolved_redis_url"] = redis_url payload["resolved_redis_url"] = _mask_redis_url(redis_url)
return payload return payload
@@ -792,11 +875,30 @@ async def connection_check():
mysql_ok, mysql_msg = await _test_mysql_url(db_url) mysql_ok, mysql_msg = await _test_mysql_url(db_url)
redis_ok, redis_msg = _test_redis_url(redis_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 = [ checks = [
{"name": "MySQL", "required": "可连接", "current": mysql_msg, "pass": mysql_ok}, {"name": "MySQL", "required": "可连接", "current": mysql_msg, "pass": mysql_ok},
{"name": "Redis", "required": "可连接", "current": redis_msg, "pass": redis_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") @router.post("/init-db")
+69 -3
View File
@@ -235,8 +235,9 @@ def test_build_redis_url_with_default_user():
def test_resolve_redis_url_falls_back_to_colon_pass(monkeypatch): 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( req = InitDbRequest(
db_pass="db", db_pass="db",
redis_host="1Panel-redis-x", 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" return url.startswith("redis://:secret@"), "ok" if url.startswith("redis://:secret@") else "fail"
monkeypatch.setattr("server.api.install._test_redis_url", fake_test) 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 ok is True
assert url == "redis://:secret@1Panel-redis-x:6379/0" assert url == "redis://:secret@1Panel-redis-x:6379/0"
assert "仅密码" in msg 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): def test_install_reject_repeat_init_when_env_exists(monkeypatch, tmp_path):
+37 -7
View File
@@ -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"> <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> </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> <div>
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 用户名(可选)</label> <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="通常留空" <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"> 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>
@@ -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"> <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> </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"> <div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">Redis 密码</label> <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> <p class="text-xs text-[var(--text-secondary)] mb-1">1Panel → Redis → 参数中的密码;无密码留空</p>
@@ -256,7 +263,8 @@
</template> </template>
</div> </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"> <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>
<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"> <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> </template>
</div> </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"> <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>
<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"> <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 后重启容器,再点重新检测。 连接未通过:若步骤 3 已初始化,请在服务器修正 /app/.env 中 NEXUS_DATABASE_URL / NEXUS_REDIS_URL 后重启容器,再点重新检测。
@@ -626,6 +636,9 @@ function installWizard() {
connLoading: false, connLoading: false,
connChecks: [], connChecks: [],
connAllPass: false, connAllPass: false,
redisRepaired: false,
resolvedRedisUrl: '',
dockerDefaults: null,
credLoading: false, credLoading: false,
credChecks: [], credChecks: [],
credAllPass: false, credAllPass: false,
@@ -675,7 +688,12 @@ function installWizard() {
}, },
applyDockerDefaults(defaults) { 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) { if (this.isStaleDockerHost(this.form.db_host) && defaults.db_host) {
this.form.db_host = defaults.db_host; this.form.db_host = defaults.db_host;
} }
@@ -739,20 +757,28 @@ function installWizard() {
this.credChecks = []; this.credChecks = [];
}, },
installFormPayload() {
const payload = { ...this.form };
if (this.dockerDefaults) payload.redis_user = '';
return payload;
},
async testCredentials() { async testCredentials() {
this.credLoading = true; this.credLoading = true;
this.error = ''; this.error = '';
this.credAllPass = false; this.credAllPass = false;
this.resolvedRedisUrl = '';
try { try {
const r = await fetch(API + '/api/install/test-credentials', { const r = await fetch(API + '/api/install/test-credentials', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(this.form), body: JSON.stringify(this.installFormPayload()),
}); });
const d = await r.json(); const d = await r.json();
if (r.ok) { if (r.ok) {
this.credChecks = d.checks || []; this.credChecks = d.checks || [];
this.credAllPass = !!d.all_pass; this.credAllPass = !!d.all_pass;
this.resolvedRedisUrl = d.resolved_redis_url || '';
if (!this.credAllPass) { if (!this.credAllPass) {
this.error = 'MySQL 或 Redis 连接未通过,请核对配置后重试。'; this.error = 'MySQL 或 Redis 连接未通过,请核对配置后重试。';
} }
@@ -768,12 +794,16 @@ function installWizard() {
async checkConnections() { async checkConnections() {
this.connLoading = true; this.connLoading = true;
this.redisRepaired = false;
this.resolvedRedisUrl = '';
try { try {
const r = await fetch(API + '/api/install/connection-check'); const r = await fetch(API + '/api/install/connection-check');
if (r.ok) { if (r.ok) {
const d = await r.json(); const d = await r.json();
this.connChecks = d.checks || []; this.connChecks = d.checks || [];
this.connAllPass = !!d.all_pass; this.connAllPass = !!d.all_pass;
this.redisRepaired = !!d.redis_repaired;
this.resolvedRedisUrl = d.resolved_redis_url || '';
} else { } else {
const d = await r.json().catch(() => ({})); const d = await r.json().catch(() => ({}));
this.connChecks = [{ name: '连接检测', pass: false, current: d.detail || '请先完成步骤3' }]; this.connChecks = [{ name: '连接检测', pass: false, current: d.detail || '请先完成步骤3' }];
@@ -813,7 +843,7 @@ function installWizard() {
const r = await fetch(API + '/api/install/init-db', { const r = await fetch(API + '/api/install/init-db', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(this.form), body: JSON.stringify(this.installFormPayload()),
}); });
const d = await r.json(); const d = await r.json();
if (r.ok && d.success) { if (r.ok && d.success) {