fix(install): auto-resolve 1Panel Redis auth (:pass@ / default)

Stop prefilling root; probe multiple Redis URL formats on credential
check and persist the URL that works for init-db.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Deploy
2026-06-06 07:15:57 +08:00
parent 9d9fbc62f5
commit 3b2856f388
7 changed files with 126 additions and 26 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ Docker 栈**仅含 Nexus 容器**。MySQL / Redis 在 **1Panel 应用商店**安
1. **应用商店** → 安装 **MySQL**、**Redis**
2. **数据库** → 创建库 `nexus`、用户 `nexus`(仅授权 `nexus` 库),记下密码
3. `nx update` 后打开安装向导步骤 3(主机应已预填 `1Panel-mysql-…`
4. 填写 MySQL 密码;Redis**用户名 `root`** + 应用参数中的密码(见 1Panel 文档
4. 填写 MySQL 密码;Redis**用户名通常留空** + 应用参数密码(自动尝试 `:pass@` / `default`
### 常见错误 1045Access denied for 'nexus'@'172.18.x.x'
+7 -2
View File
@@ -75,11 +75,16 @@ main() {
echo ""
if [[ "$any" -eq 1 ]]; then
ok "至少一种格式可用 — 将对应 URL 写入向导步骤 3 或 /app/.env 的 NEXUS_REDIS_URL"
ok "至少一种格式可用"
echo ""
info "安装向导步骤 3 建议:Redis 用户名留空,只填密码(后端自动用 :pass@ 或 default"
info "已有 .env 时写入 NEXUS_REDIS_URL,例如:"
info " redis://:${pass}@${redis_host}:6379/0"
info " 或 redis://default:${pass}@${redis_host}:6379/0"
exit 0
fi
fail "全部失败。请在 1Panel → Redis → 参数 核对密码,或检查 Nexus 是否在 1panel-network"
info "本机 Redis 容器内: docker exec -it ${redis_host} redis-cli -u 'redis://root:密码@127.0.0.1:6379' ping"
info "本机: docker exec -it ${redis_host} redis-cli -u 'redis://:密码@127.0.0.1:6379' ping"
exit 1
}
@@ -0,0 +1,28 @@
# 2026-06-06 — 安装向导 Redis 自动探测 :pass@ / default
## 摘要
实测 1Panel Redis`1Panel-redis-1g6K`)支持 `redis://:pass@``default:pass`,**不支持 root**。向导取消预填 root`test-credentials` / `init-db` 自动依次尝试多种 URL 并写入可用项。
## 动机
用户 `test-1panel-redis.sh` 显示 root 失败、`:pass@` 与 default 成功;旧版预填 root 导致步骤 3 检测失败。
## 涉及文件
- `server/api/install.py``_redis_url_candidates``_resolve_redis_url`
- `web/app/install.html` — 用户名留空提示、清除 stale root
- `deploy/test-1panel-redis.sh``docs/project/nexus-1panel-operations-knowledge.md`
## 验证
```bash
pytest tests/test_security_unit.py -k "redis_url or resolve_redis" -q
bash deploy/sync-install-wizard-to-container.sh
```
步骤 3:Redis 用户名留空 + 密码 → 检测应显示「仅密码 (:pass@)」通过。
## 迁移 / 重启
`sync-install-wizard-to-container.sh``nx update`。已有错误 `.env` 手动改为 `redis://:pass@1Panel-redis-xxx:6379/0`
@@ -122,7 +122,7 @@ entrypoint EXIT trap
| 主机 | `1Panel-mysql-xxxx` | `1Panel-redis-xxxx` |
| 端口 | `3306` | `6379` |
| 库名/用户 | `nexus` / `nexus` | 库号 `0` |
| 用户名 | — | **`root`**1Panel Redis ACL[官方文档](https://docs.1panel.pro/v2/user_manual/databases/redis/) |
| 用户名 | — | **通常留空**(自动 `redis://:pass@`);部分实例填 `default`。**勿填 root** |
| 密码 | 1Panel「数据库」里建的 nexus 密码 | 1Panel Redis **应用参数**中的随机密码 |
### 3.3 Docker 预填来源
@@ -147,8 +147,8 @@ mysql+aiomysql://nexus:密码@1Panel-mysql-xxxx:3306/nexus
| 场景 | URL 格式 |
|------|----------|
| 1Panel(推荐) | `redis://root:密码@1Panel-redis-xxxx:6379/0` |
| 仅 requirepass、无用户名 | `redis://:密码@1Panel-redis-xxxx:6379/0` |
| 1Panel常见,推荐) | `redis://:密码@1Panel-redis-xxxx:6379/0`(向导用户名留空) |
| 1Panel ACL default | `redis://default:密码@1Panel-redis-xxxx:6379/0` |
| 无密码 | `redis://1Panel-redis-xxxx:6379/0` |
**历史 bug807f4c2 前)**:生成 `redis://密码@host` 会把密码当成**用户名**,导致 `invalid username-password pair or user is disabled`。升级后须 `git pull` + `sync-install-wizard-to-container.sh`
@@ -245,9 +245,9 @@ cd /opt/nexus && bash deploy/install-nexus-fresh.sh --skip-clone
```
1. 主机是否为 1Panel-redis-xxxx?(不是 127.0.0.1
2. 用户名是否 root?(1Panel 默认 ACL 用户
2. 用户名是否误填 root?(多数 1Panel 应**留空**或填 default
3. 密码是否与 1Panel → Redis → 参数 一致?
4. 代码是否 ≥ 807f4c2?(URL 须 redis://root:pass@ 或 redis://:pass@
4. 运行 `bash deploy/test-1panel-redis.sh` 看哪种 URL 可用
5. 运行诊断:
cd /opt/nexus && bash deploy/test-1panel-redis.sh
```
+50 -11
View File
@@ -243,8 +243,9 @@ def _mysql_connect_error_detail(host: str, port: int) -> str:
def _redis_auth_error_detail() -> str:
if os.environ.get("NEXUS_DOCKER_WRITE_ENV") == "1":
return (
"Redis 认证失败。1Panel Redis:用户名填 root密码应用参数中的随机密码"
"主机为 1Panel-redis-xxxx(不是 127.0.0.1)。无 ACL 的旧版 Redis 用户名可留空"
"Redis 认证失败。1Panel:主机 1Panel-redis-xxxx密码应用参数;"
"用户名通常留空(自动尝试 :pass@)或填 default。勿填 root(多数实例不支持)"
"诊断: bash deploy/test-1panel-redis.sh"
)
return "Redis 密码错误或需要 AUTH;请核对用户名/密码(无密码则留空)。"
@@ -337,6 +338,42 @@ def _test_redis_url(redis_url: str) -> tuple[bool, str]:
return False, f"✗ Redis 连接失败: {e}"
def _redis_url_candidates(req: InitDbRequest) -> list[tuple[str, str]]:
"""Try order for 1Panel Redis (default user vs :pass@ vs explicit user)."""
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]] = []
def add(label: str, candidate: InitDbRequest) -> None:
url = _build_redis_url(candidate)
if url in seen:
return
seen.add(url)
out.append((label, url))
if user:
add(f"用户 {user}", req)
add("仅密码 (:pass@)", req.model_copy(update={"redis_user": ""}))
if user.lower() != "default":
add("用户 default", req.model_copy(update={"redis_user": "default"}))
return out
def _resolve_redis_url(req: InitDbRequest) -> tuple[bool, str, str | None]:
"""Pick first working Redis URL among 1Panel-compatible auth variants."""
last_msg = "✗ 未配置 Redis"
for label, url in _redis_url_candidates(req):
ok, msg = _test_redis_url(url)
last_msg = msg
if ok:
return True, f"✓ Redis 连接正常({label}", url
return False, last_msg, None
def _docker_wizard_defaults() -> dict[str, str] | None:
"""Prefill install step 3 when running inside docker-compose.prod.yml."""
if os.environ.get("NEXUS_DOCKER_WRITE_ENV") != "1":
@@ -351,7 +388,6 @@ def _docker_wizard_defaults() -> dict[str, str] | None:
"redis_host": redis_host,
"redis_port": "6379",
"redis_db": "0",
"redis_user": "root",
}
@@ -715,17 +751,16 @@ async def env_check():
return payload
async def _credential_checks(req: InitDbRequest) -> tuple[list[dict], bool]:
async def _credential_checks(req: InitDbRequest) -> tuple[list[dict], bool, str | None]:
"""Verify MySQL + Redis with credentials from install step 3 (TCP + AUTH)."""
db_url = _make_db_url(req)
redis_url = _build_redis_url(req)
mysql_ok, mysql_msg = await _test_mysql_url(db_url)
redis_ok, redis_msg = _test_redis_url(redis_url)
redis_ok, redis_msg, redis_url = _resolve_redis_url(req)
checks = [
{"name": "MySQL", "required": "可连接", "current": mysql_msg, "pass": mysql_ok},
{"name": "Redis", "required": "可连接", "current": redis_msg, "pass": redis_ok},
]
return checks, mysql_ok and redis_ok
return checks, mysql_ok and redis_ok, redis_url if redis_ok else None
@router.post("/test-credentials")
@@ -737,8 +772,11 @@ async def test_credentials(req: InitDbRequest):
400,
"系统已初始化,无法重复检测。请继续步骤 4 创建管理员,或修正 .env 后使用连接检测。",
)
checks, all_pass = await _credential_checks(req)
return {"checks": checks, "all_pass": all_pass}
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
return payload
@router.get("/connection-check")
@@ -768,16 +806,17 @@ async def init_db(req: InitDbRequest):
_reject_if_env_exists()
db_url = _make_db_url(req)
redis_url = _build_redis_url(req)
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
checks, cred_ok = await _credential_checks(req)
checks, cred_ok, redis_url = await _credential_checks(req)
if not cred_ok:
failed = next((c for c in checks if not c["pass"]), checks[0])
raise HTTPException(
400,
failed.get("current") or "MySQL 或 Redis 连接未通过,请先检测账号密码",
)
if not redis_url:
redis_url = _build_redis_url(req)
try:
patch_aiomysql_do_ping()
+26 -3
View File
@@ -222,16 +222,39 @@ def test_build_redis_url_password_with_leading_colon():
assert _build_redis_url(req) == "redis://:redis_WP3NyN@1Panel-redis-x:6379/0"
def test_build_redis_url_1panel_root_user():
def test_build_redis_url_with_default_user():
from server.api.install import InitDbRequest, _build_redis_url
req = InitDbRequest(
db_pass="db",
redis_host="1Panel-redis-x",
redis_user="root",
redis_user="default",
redis_pass="redis_WP3NyN",
)
assert _build_redis_url(req) == "redis://root:redis_WP3NyN@1Panel-redis-x:6379/0"
assert _build_redis_url(req) == "redis://default:redis_WP3NyN@1Panel-redis-x:6379/0"
def test_resolve_redis_url_falls_back_to_colon_pass(monkeypatch):
from server.api.install import InitDbRequest, _resolve_redis_url
req = InitDbRequest(
db_pass="db",
redis_host="1Panel-redis-x",
redis_user="root",
redis_pass="secret",
)
calls: list[str] = []
def fake_test(url: str) -> tuple[bool, str]:
calls.append(url)
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)
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)
def test_install_reject_repeat_init_when_env_exists(monkeypatch, tmp_path):
+9 -4
View File
@@ -197,9 +197,9 @@
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<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 Redis <code class="bg-slate-100 px-1 rounded">root</code>;无用户留空</p>
<input x-model="form.redis_user" @input="invalidateCredCheck()" placeholder="1Panel: root"
<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>
<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>
<div>
@@ -669,6 +669,11 @@ function installWizard() {
return !h || h === 'localhost' || h === '127.0.0.1' || h === 'host.docker.internal';
},
isStaleRedisUser(user) {
const u = (user || '').trim().toLowerCase();
return u === 'root';
},
applyDockerDefaults(defaults) {
if (!defaults) return;
if (this.isStaleDockerHost(this.form.db_host) && defaults.db_host) {
@@ -682,7 +687,7 @@ function installWizard() {
if (defaults.db_port) this.form.db_port = defaults.db_port;
if (defaults.redis_port) this.form.redis_port = defaults.redis_port;
if (defaults.redis_db) this.form.redis_db = defaults.redis_db;
if (defaults.redis_user && !this.form.redis_user) this.form.redis_user = defaults.redis_user;
if (this.isStaleRedisUser(this.form.redis_user)) this.form.redis_user = '';
},
async refreshDockerDefaults() {