diff --git a/deploy/test-1panel-redis.sh b/deploy/test-1panel-redis.sh index 56da39df..15f9d72c 100644 --- a/deploy/test-1panel-redis.sh +++ b/deploy/test-1panel-redis.sh @@ -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" diff --git a/deploy/verify-1panel-install-wizard.sh b/deploy/verify-1panel-install-wizard.sh index 1b22d847..de92de9f 100644 --- a/deploy/verify-1panel-install-wizard.sh +++ b/deploy/verify-1panel-install-wizard.sh @@ -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)" diff --git a/docs/changelog/2026-06-06-install-redis-no-account-repair.md b/docs/changelog/2026-06-06-install-redis-no-account-repair.md new file mode 100644 index 00000000..b5f9e2ec --- /dev/null +++ b/docs/changelog/2026-06-06-install-redis-no-account-repair.md @@ -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 连接全绿。 diff --git a/docs/project/nexus-1panel-operations-knowledge.md b/docs/project/nexus-1panel-operations-knowledge.md index 8cac39b1..f0b09d0d 100644 --- a/docs/project/nexus-1panel-operations-knowledge.md +++ b/docs/project/nexus-1panel-operations-knowledge.md @@ -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@`)** | --- diff --git a/server/api/install.py b/server/api/install.py index 97685b89..29653250 100644 --- a/server/api/install.py +++ b/server/api/install.py @@ -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") diff --git a/tests/test_security_unit.py b/tests/test_security_unit.py index 41fddaaa..a4b56f57 100644 --- a/tests/test_security_unit.py +++ b/tests/test_security_unit.py @@ -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): diff --git a/web/app/install.html b/web/app/install.html index 7d4ec0c0..e761b80c 100644 --- a/web/app/install.html +++ b/web/app/install.html @@ -195,10 +195,13 @@ -
1Panel Redis 无账号,只需填写下方密码(应用参数中的口令)。
+1Panel 多数留空(仅密码);不行再填 default。勿填 root
自建 Redis ACL 可填用户名;多数环境留空
1Panel → Redis → 参数中的密码;无密码留空
@@ -256,7 +263,8 @@✓ MySQL 与 Redis 连接正常,可以初始化。
+Redis URL:
✓ MySQL 与 Redis 均已连通,可创建管理员。
+已自动修正 .env 中的 Redis URL。
+Redis URL: