diff --git a/deploy/activate-gray.sh b/deploy/activate-gray.sh index ac46ab0..52dae9a 100644 --- a/deploy/activate-gray.sh +++ b/deploy/activate-gray.sh @@ -12,7 +12,7 @@ release=${2:?} # Parse a small allowlist of non-secret release fields; never source a credential file. while IFS='=' read -r name value; do case "$name" in - CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY) ;; + CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY|CHAT_AUTH_DIR) ;; *) echo 'Unexpected release setting.' >&2; exit 2 ;; esac [[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2 @@ -43,7 +43,8 @@ if [[ -n "$(ss -H -ltn "sport = :$HOST_PORT")" && "$old_port" != "127.0.0.1:$HOS fi site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf" -auth="$CHAT_PROXY_DIR/.baiyue-logistics-chat.htpasswd" +[[ "$CHAT_AUTH_DIR" == /*/baiyue-logistics-chat-gray-proxy && "$CHAT_AUTH_DIR" != *..* ]] || exit 2 +auth="$CHAT_AUTH_DIR/access.htpasswd" if [[ -f "$site" ]] && ! head -n 1 "$site" | grep -Fq 'Managed for baiyue-logistics-chat-gray'; then echo 'The proxy file is not owned by this application.' >&2 exit 2 @@ -55,6 +56,13 @@ for candidate in "$CHAT_PROXY_DIR/"*.conf; do exit 2 fi done +# The Nginx config directory may be root-only (for example BaoTa). Keep this +# application's password hash in a separate worker-readable owned directory. +if [[ -e "$CHAT_AUTH_DIR" ]]; then + [[ -f "$CHAT_AUTH_DIR/.project" && "$(cat "$CHAT_AUTH_DIR/.project")" == baiyue-logistics-chat-gray ]] || exit 2 +fi +install -d -m 755 "$CHAT_AUTH_DIR" +printf 'baiyue-logistics-chat-gray\n' > "$CHAT_AUTH_DIR/.project" previous='' if [[ -f "$root/current-release" ]]; then previous=$(cat "$root/current-release") diff --git a/deploy/gray_config.py b/deploy/gray_config.py index 14c9e69..0b2d8a3 100644 --- a/deploy/gray_config.py +++ b/deploy/gray_config.py @@ -29,6 +29,7 @@ class GrayConfig: image: str image_id: str certbot_image_id: str + auth_dir: str = '/var/lib/baiyue-logistics-chat-gray-proxy' @classmethod def from_environment(cls, environment): @@ -70,6 +71,7 @@ class GrayConfig: 'CHAT_RUNTIME_ENV': self.release + '/runtime.env', 'CHAT_DOMAIN': self.domain, 'CHAT_PROXY_DIR': self.proxy_dir, 'CHAT_TLS_CERT': self.certificate, 'CHAT_TLS_KEY': self.private_key, + 'CHAT_AUTH_DIR': self.auth_dir, } return ''.join(f'{key}={value}\n' for key, value in fields.items()) @@ -91,7 +93,7 @@ server {{ ssl_certificate_key {self.private_key}; ssl_protocols TLSv1.2 TLSv1.3; auth_basic "Baiyue gray acceptance"; - auth_basic_user_file {self.proxy_dir}/.baiyue-logistics-chat.htpasswd; + auth_basic_user_file {self.auth_dir}/access.htpasswd; client_max_body_size 12m; location / {{ proxy_pass http://127.0.0.1:{self.port}; diff --git a/docs/deployment.md b/docs/deployment.md index 5d4e17f..d051fe3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -58,6 +58,8 @@ 模型密钥写入权限为 600 的 `runtime.env`,由 Compose 注入进程。验收密码只在 Jenkins 生成 Nginx 哈希,服务器保存哈希文件。 +密码哈希位于独立的 `/var/lib/baiyue-logistics-chat-gray-proxy/access.htpasswd`,目录 755、文件 644,供 Nginx 工作进程读取;不放在可能只有 root 可访问的宝塔站点配置目录,也不放明文密码。该目录有本服务所有权标记,不复用未知目录。 + 更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。 新环境自动创建八张业务表,不创建默认客服账号或演示数据。验收用全站密码直接进入工作台;Nginx 验收账号不是应用内客服账号。 diff --git a/tests/test_gray_activation.py b/tests/test_gray_activation.py index 2f94b38..9d9b24a 100644 --- a/tests/test_gray_activation.py +++ b/tests/test_gray_activation.py @@ -55,7 +55,8 @@ class GrayActivationTests(unittest.TestCase): directory = Path(temporary.name) root = directory / 'gray-logistics-chat' proxy = directory / 'proxy' - for path in (root / 'data', root / 'logs', root / 'releases', proxy): + auth = directory / 'baiyue-logistics-chat-gray-proxy' + for path in (root / 'data', root / 'logs', root / 'releases', proxy, auth): path.mkdir(parents=True, exist_ok=True) (root / '.project').write_text('baiyue-logistics-chat-gray\n') (root / 'data/history.db').write_bytes(b'preserve-existing-data') @@ -69,12 +70,13 @@ class GrayActivationTests(unittest.TestCase): (root / 'current-release').write_text(str(previous) + '\n') old_site = '# Managed for baiyue-logistics-chat-gray\n# previous site\n' (proxy / 'baiyue-logistics-chat-gray.conf').write_text(old_site) - (proxy / '.baiyue-logistics-chat.htpasswd').write_text('previous-auth-hash\n') + (auth / '.project').write_text('another-service\n' if scenario == 'unowned-auth' else 'baiyue-logistics-chat-gray\n') + (auth / 'access.htpasswd').write_text('previous-auth-hash\n') unrelated = proxy / 'unrelated.conf' unrelated.write_text('# unrelated site\n') config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'), str(proxy), str(certificate), str(private_key), 'a'*40, - 'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64) + 'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64, str(auth)) release = Path(config.release) write_release(release, config, 'fixture-model-key') # Docker 26 and 29 may assign different IDs after an archive is loaded. @@ -103,13 +105,18 @@ class GrayActivationTests(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual((root / 'current-release').read_text().strip(), str(release)) self.assertIn('server_name chat.example.com', (proxy / 'baiyue-logistics-chat-gray.conf').read_text()) + auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy' + self.assertEqual((auth / 'access.htpasswd').read_text(), 'new-auth-hash\n') + self.assertIn(str(auth / 'access.htpasswd'), (proxy / 'baiyue-logistics-chat-gray.conf').read_text()) + self.assertEqual(auth.stat().st_mode & 0o777, 0o755) + self.assertEqual((auth / 'access.htpasswd').stat().st_mode & 0o777, 0o644) def assert_rolled_back(self, scenario): result, root, proxy, previous, release, old_site, calls = self.run_activation(scenario) self.assertNotEqual(result.returncode, 0) self.assertEqual((root / 'current-release').read_text().strip(), str(previous)) self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site) - self.assertEqual((proxy / '.baiyue-logistics-chat.htpasswd').read_text(), 'previous-auth-hash\n') + self.assertEqual((proxy.parent / 'baiyue-logistics-chat-gray-proxy/access.htpasswd').read_text(), 'previous-auth-hash\n') self.assertTrue(any(name == 'docker' and 'up' in args and str(previous / 'release.env') in args for name, args in calls), result.stderr) @@ -132,6 +139,15 @@ class GrayActivationTests(unittest.TestCase): self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site) self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls)) + def test_password_directory_owned_by_another_service_is_not_modified(self): + result, root, proxy, previous, release, old_site, calls = self.run_activation('unowned-auth') + self.assertNotEqual(result.returncode, 0) + auth = proxy.parent / 'baiyue-logistics-chat-gray-proxy' + self.assertEqual((auth / '.project').read_text(), 'another-service\n') + self.assertEqual((auth / 'access.htpasswd').read_text(), 'previous-auth-hash\n') + self.assertEqual((root / 'current-release').read_text().strip(), str(previous)) + self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls)) + def test_invalid_nginx_configuration_restores_previous_site_and_application(self): self.assert_rolled_back('proxy-fail')