From e0d3969e46dc7e328afeabc24c2ea9890424cc69 Mon Sep 17 00:00:00 2001 From: nanxun Date: Mon, 3 Aug 2026 23:59:25 +0800 Subject: [PATCH] refactor: move PostgreSQL shared service to dedicated repository --- README.md | 9 +- docs/fnos-postgresql-client-integration.md | 237 ----- docs/postgresql-migration.md | 7 +- fnos-postgresql/app/ui/config | 13 - fnos-postgresql/cmd/config_callback | 2 - fnos-postgresql/cmd/config_init | 2 - fnos-postgresql/cmd/install_callback | 35 - fnos-postgresql/cmd/install_init | 2 - fnos-postgresql/cmd/main | 175 ---- fnos-postgresql/cmd/uninstall_callback | 3 - fnos-postgresql/cmd/uninstall_init | 4 - fnos-postgresql/cmd/upgrade_callback | 2 - fnos-postgresql/cmd/upgrade_init | 6 - fnos-postgresql/config/privilege | 5 - fnos-postgresql/config/resource | 18 - fnos-postgresql/manifest | 12 - fnos-postgresql/wizard/install | 47 - fnos-postgresql/wizard/upgrade | 11 - frontend/package.json | 1 - frontend/postgres-admin/App.vue | 318 ------- frontend/postgres-admin/index.html | 13 - frontend/postgres-admin/main.ts | 7 - frontend/postgres-admin/style.css | 26 - frontend/vite.postgres.config.ts | 15 - scripts/build-postgresql-fnos-package.sh | 177 ---- scripts/smoke-postgresql-fnos-package.sh | 149 ---- scripts/verify-fnos-package.sh | 31 +- src/PostgresService.WebApi/Models.cs | 72 -- .../PostgresAdminService.cs | 810 ------------------ .../PostgresService.WebApi.csproj | 11 - src/PostgresService.WebApi/Program.cs | 199 ----- src/PostgresService.WebApi/Security.cs | 160 ---- .../LiveRecorder.Tests.csproj | 1 - .../PostgresServiceSecurityTests.cs | 75 -- 34 files changed, 18 insertions(+), 2637 deletions(-) delete mode 100644 docs/fnos-postgresql-client-integration.md delete mode 100644 fnos-postgresql/app/ui/config delete mode 100644 fnos-postgresql/cmd/config_callback delete mode 100644 fnos-postgresql/cmd/config_init delete mode 100644 fnos-postgresql/cmd/install_callback delete mode 100644 fnos-postgresql/cmd/install_init delete mode 100644 fnos-postgresql/cmd/main delete mode 100644 fnos-postgresql/cmd/uninstall_callback delete mode 100644 fnos-postgresql/cmd/uninstall_init delete mode 100644 fnos-postgresql/cmd/upgrade_callback delete mode 100644 fnos-postgresql/cmd/upgrade_init delete mode 100644 fnos-postgresql/config/privilege delete mode 100644 fnos-postgresql/config/resource delete mode 100644 fnos-postgresql/manifest delete mode 100644 fnos-postgresql/wizard/install delete mode 100644 fnos-postgresql/wizard/upgrade delete mode 100644 frontend/postgres-admin/App.vue delete mode 100644 frontend/postgres-admin/index.html delete mode 100644 frontend/postgres-admin/main.ts delete mode 100644 frontend/postgres-admin/style.css delete mode 100644 frontend/vite.postgres.config.ts delete mode 100755 scripts/build-postgresql-fnos-package.sh delete mode 100755 scripts/smoke-postgresql-fnos-package.sh delete mode 100644 src/PostgresService.WebApi/Models.cs delete mode 100644 src/PostgresService.WebApi/PostgresAdminService.cs delete mode 100644 src/PostgresService.WebApi/PostgresService.WebApi.csproj delete mode 100644 src/PostgresService.WebApi/Program.cs delete mode 100644 src/PostgresService.WebApi/Security.cs delete mode 100644 tests/LiveRecorder.Tests/PostgresServiceSecurityTests.cs diff --git a/README.md b/README.md index 578abd1..015e56a 100644 --- a/README.md +++ b/README.md @@ -255,16 +255,15 @@ docker compose up -d ### fnOS 原生 FPK ```bash -./scripts/build-postgresql-fnos-package.sh ./scripts/build-fnos-package.sh -./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk ./scripts/smoke-fnos-package.sh \ artifacts/fnos/liverecorder-1.2.13-x86_64.fpk \ - artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk + /path/to/nxsir-postgresql-15.1.1-x86_64.fpk ``` +- PostgreSQL 共享服务在独立仓库构建和发布: - 使用 fnOS 开发者平台提供的官方 `fnpack` 构建;可通过 `FNPACK=/path/to/fnpack` 指定工具路径 -- 两个 FPK 都是 x86_64 原生应用,不依赖 Docker +- Live Recorder 与 PostgreSQL 共享服务分别构建为 x86_64 原生 FPK,均不依赖 Docker - Live Recorder 直接依赖 `nxsir.postgresql` 和商店版 `nodejs_v22`;fnOS 会通过 `install_dep_apps` 检查并启用依赖 - PostgreSQL 共享服务只监听 `127.0.0.1:15432`,独立管理界面默认端口为 `15433` - 每个应用经回环接入 API 获得独立数据库、独立 SCRAM 角色和随机密码;支持 pgvector @@ -279,7 +278,7 @@ docker compose up -d - 录制文件默认保存在 fnOS 共享目录 `liverecorder/records`;可在“设置 → 录制 → 输出根目录”修改 更完整的安装、端口与凭据说明见 [docs/postgresql-migration.md](docs/postgresql-migration.md)。 -其他 fnOS 应用接入共享数据库时,请直接参考 [docs/fnos-postgresql-client-integration.md](docs/fnos-postgresql-client-integration.md)。 +其他 fnOS 应用接入共享数据库时,请参考独立仓库的 [fnOS PostgreSQL 共享服务接入指南](https://gitea.nxsir.cn/nanxun/postgresqlfpk/src/branch/main/docs/fnos-postgresql-client-integration.md)。 ## 验证 diff --git a/docs/fnos-postgresql-client-integration.md b/docs/fnos-postgresql-client-integration.md deleted file mode 100644 index b2a5817..0000000 --- a/docs/fnos-postgresql-client-integration.md +++ /dev/null @@ -1,237 +0,0 @@ -# fnOS PostgreSQL 共享服务接入指南 - -本文面向需要接入 `nxsir.postgresql` 的 fnOS 原生应用。共享服务不依赖 Docker,多个应用共用一个 PostgreSQL 15 进程,但每个应用获得独立数据库、独立登录角色和独立随机密码。 - -## 1. 服务约定 - -| 项目 | 默认值 | -|---|---| -| fnOS 应用名 | `nxsir.postgresql` | -| PostgreSQL 地址 | `127.0.0.1:15432` | -| 管理/API 地址 | `http://127.0.0.1:15433` | -| 传输加密 | 不启用 TLS,仅允许本机回环连接 | -| 密码认证 | PostgreSQL SCRAM-SHA-256 | -| 可申请扩展 | `vector`(pgvector) | - -不要连接 Unix Socket、不要使用服务管理员角色,也不要假设数据库名或角色名。客户端必须通过接入 API 获取完整凭据。 - -## 2. 声明 fnOS 依赖 - -在应用 FPK 的 `manifest` 中声明: - -```ini -install_dep_apps=nxsir.postgresql -``` - -用户应先安装并启动 PostgreSQL 共享服务,再安装你的应用。你的安装/升级向导需要提供一个密码字段,让用户填写安装共享服务时设置的“应用接入令牌”。令牌长度为 20~256 个字符,不允许换行。 - -接入令牌与 PostgreSQL 管理员密码是两套独立凭据: - -- 管理员密码只登录 PostgreSQL 管理面板。 -- 接入令牌只用于本机应用首次签发或重新签发数据库凭据。 - -## 3. 注册客户端 - -注册接口只接受来自回环地址的请求: - -```http -POST http://127.0.0.1:15433/internal/v1/enroll -Authorization: Bearer <应用接入令牌> -Content-Type: application/json -``` - -普通 PostgreSQL 客户端: - -```json -{ - "appId": "myapp", - "displayName": "My fnOS App", - "requestedExtensions": [] -} -``` - -需要 pgvector 的应用: - -```json -{ - "appId": "imagefind", - "displayName": "ImageFind", - "requestedExtensions": ["vector"] -} -``` - -字段限制: - -- `appId`:稳定且全局唯一,3~64 个字符;以小写字母开头,只允许小写字母、数字、点、下划线和连字符。发布后不要更改。 -- `displayName`:1~100 个字符,用于管理面板展示。 -- `requestedExtensions`:目前只能是空数组或包含 `vector`。 - -curl 示例: - -```bash -curl --fail --silent --show-error \ - -H "Authorization: Bearer $APP_ENROLLMENT_TOKEN" \ - -H 'Content-Type: application/json' \ - --data '{"appId":"myapp","displayName":"My fnOS App","requestedExtensions":[]}' \ - http://127.0.0.1:15433/internal/v1/enroll -``` - -成功响应: - -```json -{ - "host": "127.0.0.1", - "port": 15432, - "database": "appdb_myapp_...", - "username": "app_myapp_...", - "password": "一次性返回的随机密码", - "sslMode": "Disable", - "serviceVersion": "15" -} -``` - -每次对同一 `appId` 重新注册都会复用其数据库和角色,但会立即轮换密码,使旧密码失效。因此正常启动时应先读取本地凭据,只有首次安装、凭据丢失或明确执行密码轮换时才重新注册。 - -常见 HTTP 状态: - -| 状态 | 含义 | -|---:|---| -| `200` | 注册成功;响应中包含新密码 | -| `400` | `appId`、显示名或扩展参数无效 | -| `401` | 接入令牌错误或已被管理员轮换 | -| `403` | 请求不是从本机回环地址发起 | -| `500` | 共享服务内部错误;查看共享服务日志 | - -## 4. fnOS 生命周期脚本建议 - -安装回调只负责以 `0600` 保存令牌种子,不要在向导校验阶段依赖网络。应用启动时执行注册,并采用临时文件加原子重命名保存响应。 - -建议的持久化文件: - -```text -${TRIM_PKGVAR}/postgres-enrollment-token.seed # 首次注册前,0600 -${TRIM_PKGVAR}/postgres-client.conf # 注册成功后,0600 -``` - -推荐配置格式: - -```ini -host=127.0.0.1 -port=15432 -database=接口返回值 -username=接口返回值 -password=接口返回值 -``` - -注册成功并安全落盘后应删除令牌种子,避免长期保存高权限接入令牌。不要把密码写入日志、命令行参数、进程标题或 Web 前端。应用卸载时是否保留数据库由用户决定;不要自行执行 `DROP DATABASE`。 - -服务可能在 NAS 启动时稍晚就绪。建议: - -- 请求超时 10 秒左右。 -- 每 2 秒重试一次,最多等待 1~2 分钟。 -- 先探测 `GET /health/ready`,或直接重试注册。 -- 已有本地凭据时不要因为管理 API 暂时不可用而重新注册;直接尝试 PostgreSQL 连接。 - -## 5. 连接字符串 - -.NET / Npgsql: - -```text -Host=127.0.0.1;Port=15432;Database=;Username=;Password=;SSL Mode=Disable;Timeout=15;Command Timeout=120;Keepalive=30 -``` - -Python / psycopg: - -```python -import psycopg - -connection = psycopg.connect( - host="127.0.0.1", - port=15432, - dbname=database, - user=username, - password=password, - sslmode="disable", - connect_timeout=15, -) -``` - -JDBC: - -```text -jdbc:postgresql://127.0.0.1:15432/?sslmode=disable&connectTimeout=15 -``` - -应用角色是目标数据库及 `public` schema 的所有者,可以执行自身 migrations、建表和创建索引,但不能创建数据库、创建角色、成为超级用户或连接其他托管应用的数据库。 - -## 6. pgvector - -注册时申请 `"requestedExtensions":["vector"]` 后,共享服务会在应用数据库内执行: - -```sql -CREATE EXTENSION IF NOT EXISTS vector; -``` - -应用可直接在 migration 中使用: - -```sql -CREATE TABLE embeddings ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - embedding vector(768) NOT NULL -); - -CREATE INDEX embeddings_cosine_idx - ON embeddings USING hnsw (embedding vector_cosine_ops); -``` - -不要由应用尝试安装系统扩展文件。未在允许列表中的扩展会在注册阶段被拒绝。 - -## 7. schema 与迁移 - -- 新数据库默认撤销 `PUBLIC` 的数据库连接权和 schema 权限。 -- 应用角色拥有自己的数据库和 `public` schema,可正常运行 Flyway、EF Core、Alembic、Liquibase 等迁移。 -- 用 custom-format `pg_dump` 迁移旧库时建议使用 `--no-owner --no-acl`。 -- 恢复前确认目标库为空;不要覆盖其他 `appId` 的数据库。 -- 切换前至少校验关键表行数,保留旧数据和带 SHA-256 的转储,确认稳定后再人工清理。 - -## 8. 密码轮换、吊销与恢复 - -管理员可以在 PostgreSQL 管理面板中: - -- 为某个客户端轮换密码:应用必须立即更新本地凭据。 -- 吊销客户端:对应角色变为 `NOLOGIN`,数据库不会被删除。 -- 轮换全局接入令牌:旧令牌立即失效,已经签发的数据库密码不受影响。 - -应用本地凭据丢失时,让用户在升级/修复向导重新输入当前接入令牌,再用相同 `appId` 注册。数据库内容会保留,但旧数据库密码会失效。 - -不要静默回退到陈旧数据库副本。若应用已经完成共享数据库迁移,但凭据不可用,应停止启动并提示修复凭据,避免产生两套分叉数据。 - -## 9. 健康检查与排错 - -```bash -curl -fsS http://127.0.0.1:15433/health -curl -fsS http://127.0.0.1:15433/health/ready -``` - -排查顺序: - -1. 确认 `nxsir.postgresql` 已安装且状态为运行中。 -2. 确认应用使用 `127.0.0.1`,而不是 NAS 局域网 IP。 -3. 检查接入 API 端口 `15433` 与数据库端口 `15432` 是否混用。 -4. 检查凭据文件权限是否为 `0600`,字段是否完整。 -5. `password authentication failed` 通常表示密码已轮换;用相同 `appId` 重新注册并原子更新凭据。 -6. `permission denied for database` 通常表示连接了其他应用的数据库;必须使用响应中的 database。 -7. 共享服务升级或重启不会要求客户端重新注册,应用应使用已有凭据自动重连。 - -## 10. 接入验收清单 - -- manifest 已声明 `install_dep_apps=nxsir.postgresql`。 -- 接入 API 只从回环地址调用。 -- `appId` 固定且不会随版本变化。 -- 令牌和数据库密码从不写日志。 -- 凭据以 `0600` 原子落盘,成功后删除令牌种子。 -- 普通启动不重复注册、不意外轮换密码。 -- 应用 migrations 能在自己的数据库执行。 -- 已验证无法连接另一个测试应用的数据库。 -- 共享服务重启后应用能用原凭据恢复。 -- 已准备凭据丢失、密码轮换和迁移失败的明确恢复流程。 diff --git a/docs/postgresql-migration.md b/docs/postgresql-migration.md index bb42459..8b91922 100644 --- a/docs/postgresql-migration.md +++ b/docs/postgresql-migration.md @@ -31,9 +31,12 @@ Live Recorder 1.2.5 起只使用共享 PostgreSQL,不再打包、启动或回 ### fnOS 验证 +PostgreSQL 共享服务由独立仓库构建和发布:。 + ```bash -./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk -./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.2.9-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk +./scripts/smoke-fnos-package.sh \ + artifacts/fnos/liverecorder-1.2.13-x86_64.fpk \ + /path/to/nxsir-postgresql-15.1.1-x86_64.fpk ``` 下面保留 Docker/宿主机从旧 SQLite 导入 PostgreSQL 的流程。 diff --git a/fnos-postgresql/app/ui/config b/fnos-postgresql/app/ui/config deleted file mode 100644 index 64638f6..0000000 --- a/fnos-postgresql/app/ui/config +++ /dev/null @@ -1,13 +0,0 @@ -{ - ".url": { - "nxsir.postgresql.Application": { - "title": "PostgreSQL 共享服务", - "icon": "images/icon_{0}.png", - "type": "url", - "protocol": "", - "port": "15433", - "url": "/", - "allUsers": false - } - } -} diff --git a/fnos-postgresql/cmd/config_callback b/fnos-postgresql/cmd/config_callback deleted file mode 100644 index 06bd986..0000000 --- a/fnos-postgresql/cmd/config_callback +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -exit 0 diff --git a/fnos-postgresql/cmd/config_init b/fnos-postgresql/cmd/config_init deleted file mode 100644 index 06bd986..0000000 --- a/fnos-postgresql/cmd/config_init +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -exit 0 diff --git a/fnos-postgresql/cmd/install_callback b/fnos-postgresql/cmd/install_callback deleted file mode 100644 index 4bef5ad..0000000 --- a/fnos-postgresql/cmd/install_callback +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/bash -set -eu - -ADMIN_PASSWORD="${wizard_postgres_admin_password:-}" -ADMIN_PASSWORD_CONFIRM="${wizard_postgres_admin_password_confirm:-}" -ENROLLMENT_TOKEN="${wizard_postgres_enrollment_token:-}" -ENROLLMENT_TOKEN_CONFIRM="${wizard_postgres_enrollment_token_confirm:-}" -unset wizard_postgres_admin_password wizard_postgres_admin_password_confirm -unset wizard_postgres_enrollment_token wizard_postgres_enrollment_token_confirm - -fail() { - printf '%s\n' "$1" >&2 - if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then - printf '%s\n' "$1" >>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true - fi - exit 1 -} - -[ "$ADMIN_PASSWORD" = "$ADMIN_PASSWORD_CONFIRM" ] || fail "管理密码两次输入不一致。" -[ "${#ADMIN_PASSWORD}" -ge 12 ] || fail "管理密码至少需要 12 个字符。" -[ "${#ADMIN_PASSWORD}" -le 256 ] || fail "管理密码不能超过 256 个字符。" -[ "$ENROLLMENT_TOKEN" = "$ENROLLMENT_TOKEN_CONFIRM" ] || fail "接入令牌两次输入不一致。" -[ "${#ENROLLMENT_TOKEN}" -ge 20 ] || fail "接入令牌至少需要 20 个字符。" -[ "${#ENROLLMENT_TOKEN}" -le 256 ] || fail "接入令牌不能超过 256 个字符。" -case "$ADMIN_PASSWORD$ENROLLMENT_TOKEN" in - *$'\n'*|*$'\r'*) fail "密码和令牌不能包含换行符。" ;; -esac - -mkdir -p "${TRIM_PKGVAR}/run" "${TRIM_PKGVAR}/log" -chmod 0700 "${TRIM_PKGVAR}" "${TRIM_PKGVAR}/run" 2>/dev/null || true -umask 077 -printf '%s\n' "$ADMIN_PASSWORD" >"${TRIM_PKGVAR}/admin-password.seed" -printf '%s\n' "$ENROLLMENT_TOKEN" >"${TRIM_PKGVAR}/enrollment-token.seed" -unset ADMIN_PASSWORD ADMIN_PASSWORD_CONFIRM ENROLLMENT_TOKEN ENROLLMENT_TOKEN_CONFIRM -exit 0 diff --git a/fnos-postgresql/cmd/install_init b/fnos-postgresql/cmd/install_init deleted file mode 100644 index 06bd986..0000000 --- a/fnos-postgresql/cmd/install_init +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -exit 0 diff --git a/fnos-postgresql/cmd/main b/fnos-postgresql/cmd/main deleted file mode 100644 index f72623c..0000000 --- a/fnos-postgresql/cmd/main +++ /dev/null @@ -1,175 +0,0 @@ -#!/bin/bash -set -u - -PACKAGE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -APP_ROOT="${TRIM_APPDEST:-$PACKAGE_ROOT/app}" -DATA_ROOT="${TRIM_PKGVAR:-$PACKAGE_ROOT/var}" -VOLUME_ROOT="${TRIM_APPDEST_VOL:-$DATA_ROOT/volume}" -BACKUP_ROOT="${POSTGRES_SERVICE_BACKUP_ROOT:-$VOLUME_ROOT/@appshare/postgresql/backups}" -RUNTIME_ROOT="$APP_ROOT/runtime" -SERVER="$APP_ROOT/server/PostgresService.WebApi" -PG_BIN="$RUNTIME_ROOT/usr/lib/postgresql/15/bin" -PG_SHARE="$RUNTIME_ROOT/usr/share/postgresql/15" -PG_LIB="$RUNTIME_ROOT/usr/lib/postgresql/15/lib" -PG_DATA="$DATA_ROOT/postgres" -RUN_ROOT="$DATA_ROOT/run" -LOG_ROOT="$DATA_ROOT/log" -APP_PID_FILE="$RUN_ROOT/postgres-service.pid" -APP_LOG="$LOG_ROOT/postgres-service.log" -PG_LOG="$LOG_ROOT/postgresql.log" -PG_PORT="${POSTGRES_SERVICE_PORT:-15432}" -SERVICE_PORT="${TRIM_SERVICE_PORT:-15433}" -RUNTIME_PATH="$PG_BIN:$RUNTIME_ROOT/usr/bin:${PATH:-/usr/local/bin:/usr/bin:/bin}" -RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/usr/lib/x86_64-linux-gnu:$RUNTIME_ROOT/lib/x86_64-linux-gnu:$PG_LIB" - -log_message() { - mkdir -p "$LOG_ROOT" - printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$APP_LOG" -} - -run_pg() { - env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$RUNTIME_PATH" "$@" -} - -app_pid() { - if [ -f "$APP_PID_FILE" ]; then - pid=$(sed -n '1p' "$APP_PID_FILE" | tr -d '[:space:]') - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - printf '%s' "$pid" - return 0 - fi - fi - return 1 -} - -postgres_running() { - [ -x "$PG_BIN/pg_ctl" ] || return 1 - [ -f "$PG_DATA/PG_VERSION" ] || return 1 - run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" status >/dev/null 2>&1 -} - -initialize_postgres() { - [ ! -f "$PG_DATA/PG_VERSION" ] || return 0 - mkdir -p "$PG_DATA" "$RUN_ROOT" "$LOG_ROOT" - chmod 0700 "$PG_DATA" "$RUN_ROOT" - run_pg "$PG_BIN/initdb" \ - -D "$PG_DATA" \ - -L "$PG_SHARE" \ - --username=postgres_service \ - --auth-local=trust \ - --auth-host=scram-sha-256 \ - --encoding=UTF8 \ - --no-locale >>"$PG_LOG" 2>&1 || return 1 - - { - printf "listen_addresses = '127.0.0.1'\n" - printf "port = %s\n" "$PG_PORT" - printf "unix_socket_directories = '%s'\n" "$RUN_ROOT" - printf "password_encryption = 'scram-sha-256'\n" - printf "max_connections = 100\n" - printf "shared_buffers = '128MB'\n" - printf "timezone = 'UTC'\n" - printf "log_timezone = 'UTC'\n" - printf "log_min_duration_statement = 5000\n" - } >>"$PG_DATA/postgresql.conf" - - { - printf 'local all postgres_service trust\n' - printf 'local all all reject\n' - printf 'host all all 127.0.0.1/32 scram-sha-256\n' - printf 'host all all ::1/128 scram-sha-256\n' - } >"$PG_DATA/pg_hba.conf" -} - -start_postgres() { - postgres_running && return 0 - initialize_postgres || { log_message "PostgreSQL 初始化失败。"; return 1; } - run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -l "$PG_LOG" -w start || { - log_message "PostgreSQL 启动失败。" - return 1 - } -} - -stop_postgres() { - if postgres_running; then - run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -m fast -t 180 -w stop >>"$PG_LOG" 2>&1 || { - log_message "PostgreSQL 未能在 180 秒内安全停止。" - return 1 - } - fi -} - -start_app() { - mkdir -p "$DATA_ROOT" "$RUN_ROOT" "$LOG_ROOT" "$BACKUP_ROOT" "$DATA_ROOT/tmp" "$DATA_ROOT/cache" - chmod 0700 "$DATA_ROOT" "$RUN_ROOT" "$DATA_ROOT/tmp" "$DATA_ROOT/cache" 2>/dev/null || true - [ -x "$SERVER" ] || { log_message "管理服务不存在或不可执行:$SERVER"; return 1; } - [ -x "$PG_BIN/postgres" ] || { log_message "PostgreSQL 原生运行环境不完整。"; return 1; } - if app_pid >/dev/null; then return 0; fi - start_postgres || return 1 - - ( - export ASPNETCORE_ENVIRONMENT=Production - export ASPNETCORE_URLS="http://0.0.0.0:$SERVICE_PORT" - export POSTGRES_SERVICE_DATA_ROOT="$DATA_ROOT" - export POSTGRES_SERVICE_SOCKET_ROOT="$RUN_ROOT" - export POSTGRES_SERVICE_PORT="$PG_PORT" - export POSTGRES_SERVICE_ADMIN_USER=postgres_service - export POSTGRES_SERVICE_PG_BIN="$PG_BIN" - export POSTGRES_SERVICE_BACKUP_ROOT="$BACKUP_ROOT" - export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 - export DOTNET_BUNDLE_EXTRACT_BASE_DIR="$DATA_ROOT/dotnet-bundle" - export XDG_CACHE_HOME="$DATA_ROOT/cache" - export TMPDIR="$DATA_ROOT/tmp" - export LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" - export PATH="$RUNTIME_PATH" - mkdir -p "$DOTNET_BUNDLE_EXTRACT_BASE_DIR" - cd "$APP_ROOT/server" || exit 1 - exec "$SERVER" - ) >>"$APP_LOG" 2>&1 & - pid=$! - printf '%s\n' "$pid" >"$APP_PID_FILE" - - attempt=0 - while [ "$attempt" -lt 90 ]; do - if ! kill -0 "$pid" 2>/dev/null; then - log_message "管理服务进程提前退出。" - stop_postgres - return 1 - fi - if (exec 3<>"/dev/tcp/127.0.0.1/$SERVICE_PORT") 2>/dev/null; then - exec 3>&- - log_message "PostgreSQL 共享服务启动成功,管理端口 $SERVICE_PORT,数据库端口 $PG_PORT。" - return 0 - fi - attempt=$((attempt + 1)) - sleep 1 - done - log_message "管理服务未能在 90 秒内就绪。" - stop_app - return 1 -} - -stop_app() { - if pid=$(app_pid); then - kill "$pid" 2>/dev/null || true - attempt=0 - while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 30 ]; do - sleep 1 - attempt=$((attempt + 1)) - done - kill -9 "$pid" 2>/dev/null || true - fi - rm -f "$APP_PID_FILE" - stop_postgres -} - -case "${1:-status}" in - start) start_app ;; - stop) stop_app ;; - restart) stop_app && start_app ;; - status) - if app_pid >/dev/null && postgres_running; then exit 0; fi - exit 3 - ;; - *) printf 'usage: %s {start|stop|restart|status}\n' "$0" >&2; exit 2 ;; -esac diff --git a/fnos-postgresql/cmd/uninstall_callback b/fnos-postgresql/cmd/uninstall_callback deleted file mode 100644 index 437d571..0000000 --- a/fnos-postgresql/cmd/uninstall_callback +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -# 数据目录与手动备份默认保留,避免误删多个应用的共享数据。 -exit 0 diff --git a/fnos-postgresql/cmd/uninstall_init b/fnos-postgresql/cmd/uninstall_init deleted file mode 100644 index 61a7c23..0000000 --- a/fnos-postgresql/cmd/uninstall_init +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -eu -"$(dirname -- "$0")/main" stop || true -exit 0 diff --git a/fnos-postgresql/cmd/upgrade_callback b/fnos-postgresql/cmd/upgrade_callback deleted file mode 100644 index 06bd986..0000000 --- a/fnos-postgresql/cmd/upgrade_callback +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -exit 0 diff --git a/fnos-postgresql/cmd/upgrade_init b/fnos-postgresql/cmd/upgrade_init deleted file mode 100644 index 27b9a7d..0000000 --- a/fnos-postgresql/cmd/upgrade_init +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -set -eu - -# 当前服务固定在 PostgreSQL 15 主版本;数据目录与管理凭据位于 -# TRIM_PKGVAR,fnOS 替换不可变应用文件时无需复制。 -exit 0 diff --git a/fnos-postgresql/config/privilege b/fnos-postgresql/config/privilege deleted file mode 100644 index dc2d99d..0000000 --- a/fnos-postgresql/config/privilege +++ /dev/null @@ -1,5 +0,0 @@ -{ - "defaults": { - "run-as": "package" - } -} diff --git a/fnos-postgresql/config/resource b/fnos-postgresql/config/resource deleted file mode 100644 index 44392ea..0000000 --- a/fnos-postgresql/config/resource +++ /dev/null @@ -1,18 +0,0 @@ -{ - "data-share": { - "shares": [ - { - "name": "postgresql", - "permission": { - "rw": ["nxsir.postgresql"] - } - }, - { - "name": "postgresql/backups", - "permission": { - "rw": ["nxsir.postgresql"] - } - } - ] - } -} diff --git a/fnos-postgresql/manifest b/fnos-postgresql/manifest deleted file mode 100644 index 5dbb707..0000000 --- a/fnos-postgresql/manifest +++ /dev/null @@ -1,12 +0,0 @@ -appname=nxsir.postgresql -version=15.1.1 -display_name=PostgreSQL 共享服务 -desc=面向 fnOS 应用的原生 PostgreSQL 15 共享数据库服务,包含 pgvector、安全凭据签发、管理面板和手动备份恢复 -platform=x86 -source=thirdparty -maintainer=Live Recorder Contributors -os_min_version=1.2.0 -desktop_uidir=ui -desktop_applaunchname=nxsir.postgresql.Application -checkport=true -ctl_stop=true diff --git a/fnos-postgresql/wizard/install b/fnos-postgresql/wizard/install deleted file mode 100644 index 0036aad..0000000 --- a/fnos-postgresql/wizard/install +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "stepTitle": "设置 PostgreSQL 服务管理凭据", - "items": [ - { - "type": "tips", - "helpText": "管理密码用于登录数据库面板;接入令牌用于其他 fnOS 应用首次申请独立数据库。两者均不会设置默认值。" - }, - { - "type": "password", - "field": "wizard_postgres_admin_password", - "label": "管理面板密码", - "rules": [ - { "required": true, "message": "请输入管理面板密码" }, - { "min": 12, "message": "管理密码至少需要 12 个字符" }, - { "max": 256, "message": "管理密码不能超过 256 个字符" } - ] - }, - { - "type": "password", - "field": "wizard_postgres_admin_password_confirm", - "label": "再次输入管理密码", - "rules": [ - { "required": true, "message": "请再次输入管理密码" } - ] - }, - { - "type": "password", - "field": "wizard_postgres_enrollment_token", - "label": "应用接入令牌", - "rules": [ - { "required": true, "message": "请输入应用接入令牌" }, - { "min": 20, "message": "接入令牌至少需要 20 个字符" }, - { "max": 256, "message": "接入令牌不能超过 256 个字符" } - ] - }, - { - "type": "password", - "field": "wizard_postgres_enrollment_token_confirm", - "label": "再次输入接入令牌", - "rules": [ - { "required": true, "message": "请再次输入接入令牌" } - ] - } - ] - } -] diff --git a/fnos-postgresql/wizard/upgrade b/fnos-postgresql/wizard/upgrade deleted file mode 100644 index e305f38..0000000 --- a/fnos-postgresql/wizard/upgrade +++ /dev/null @@ -1,11 +0,0 @@ -[ - { - "stepTitle": "升级 PostgreSQL 共享服务", - "items": [ - { - "type": "tips", - "helpText": "升级期间所有依赖应用会暂时失去数据库连接。当前版本仅执行 PostgreSQL 15 同主版本升级,并保留数据、凭据、审计记录和备份。" - } - ] - } -] diff --git a/frontend/package.json b/frontend/package.json index 384a2a8..347d186 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,6 @@ "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", - "build:postgres-admin": "vite build --config vite.postgres.config.ts", "preview": "vite preview", "test:e2e": "playwright test" }, diff --git a/frontend/postgres-admin/App.vue b/frontend/postgres-admin/App.vue deleted file mode 100644 index d1678a3..0000000 --- a/frontend/postgres-admin/App.vue +++ /dev/null @@ -1,318 +0,0 @@ - - - diff --git a/frontend/postgres-admin/index.html b/frontend/postgres-admin/index.html deleted file mode 100644 index 150a48f..0000000 --- a/frontend/postgres-admin/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - PostgreSQL 服务 - - -
- - - diff --git a/frontend/postgres-admin/main.ts b/frontend/postgres-admin/main.ts deleted file mode 100644 index c75ba69..0000000 --- a/frontend/postgres-admin/main.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createApp } from "vue"; -import ElementPlus from "element-plus"; -import "element-plus/dist/index.css"; -import App from "./App.vue"; -import "./style.css"; - -createApp(App).use(ElementPlus).mount("#app"); diff --git a/frontend/postgres-admin/style.css b/frontend/postgres-admin/style.css deleted file mode 100644 index 3feecd2..0000000 --- a/frontend/postgres-admin/style.css +++ /dev/null @@ -1,26 +0,0 @@ -:root { color-scheme: light; font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; --bg:#eef2f6; --surface:#fff; --muted:#f4f6f9; --border:#dde3eb; --text:#172033; --secondary:#68758a; --accent:#2563eb; --side:#f7f8fa; } -:root[data-theme="dark"] { color-scheme: dark; --bg:#0b1220; --surface:#121c2d; --muted:#18253a; --border:#29384e; --text:#edf2fb; --secondary:#91a2bb; --accent:#60a5fa; --side:#0e1727; } -* { box-sizing:border-box; } -body { margin:0; min-width:320px; min-height:100vh; color:var(--text); background:var(--bg); } -button,input,textarea { font:inherit; } -.shell { min-height:100vh; } -.sidebar { position:fixed; inset:0 auto 0 0; width:220px; display:flex; flex-direction:column; padding:14px 12px; background:var(--side); border-right:1px solid var(--border); } -.brand { display:flex; align-items:center; gap:10px; height:52px; padding:0 8px 14px; border-bottom:1px solid var(--border); } -.brand-mark { display:grid; place-items:center; width:36px; height:36px; flex:0 0 auto; border-radius:9px; color:#fff; background:#2563eb; font-weight:800; } -.brand strong,.brand small { display:block; }.brand small { margin-top:2px; color:var(--secondary); font-size:11px; } -nav { display:grid; gap:3px; padding-top:12px; } -nav button { height:38px; padding:0 12px; border:0; border-radius:7px; color:var(--secondary); background:transparent; text-align:left; font-weight:650; cursor:pointer; } -nav button:hover,nav button.active { color:var(--accent); background:color-mix(in srgb,var(--accent) 12%,transparent); } -.sidebar-foot { margin-top:auto; padding:12px 8px; color:var(--secondary); font-size:11px; border-top:1px solid var(--border); }.health-dot { display:inline-block; width:7px; height:7px; margin-right:7px; border-radius:50%; background:#16a34a; } -.content { min-height:100vh; margin-left:220px; padding:0 22px 24px; } -.topbar { position:sticky; top:0; z-index:5; display:flex; align-items:center; justify-content:space-between; min-height:58px; margin:0 -22px 18px; padding:8px 22px; background:color-mix(in srgb,var(--surface) 88%,transparent); border-bottom:1px solid var(--border); backdrop-filter:blur(10px); } -.topbar h1 { margin:0; font-size:20px; }.topbar small { color:var(--secondary); }.top-actions { display:flex; gap:7px; } -.page-stack { display:grid; gap:14px; }.panel { padding:16px; border:1px solid var(--border); border-radius:10px; background:var(--surface); overflow:hidden; } -.panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:14px; }.panel h2 { margin:0; font-size:16px; }.panel p { margin:5px 0 0; color:var(--secondary); font-size:12px; } -.metric-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; }.metric-grid article { display:grid; gap:8px; padding:14px; border:1px solid var(--border); border-radius:9px; background:var(--surface); }.metric-grid span { color:var(--secondary); font-size:12px; }.metric-grid strong { font-size:22px; } -.block { display:block; margin-top:3px; color:var(--secondary); }.full { width:100%; }.database-select { width:220px; }.query-actions { display:flex; justify-content:flex-end; margin-top:12px; }.sql-editor :is(textarea) { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; line-height:1.65; }.table-scroll { overflow:auto; } -.login-screen { min-height:100vh; display:grid; place-items:center; padding:18px; }.login-card { width:min(100%,390px); padding:24px; border:1px solid var(--border); border-radius:12px; background:var(--surface); box-shadow:0 14px 40px #0002; }.login-card .brand-mark { margin-bottom:16px; }.login-card h1 { margin:0; font-size:24px; }.login-card p { margin:8px 0 20px; color:var(--secondary); font-size:13px; } -.mobile-section { display:none; margin-bottom:12px; } -.el-table { --el-table-bg-color:transparent; --el-table-tr-bg-color:transparent; --el-table-header-bg-color:var(--muted); --el-table-row-hover-bg-color:var(--muted); --el-table-border-color:var(--border); color:var(--text); }.el-message-box { max-width:calc(100vw - 24px); } -@media(max-width:960px){ .metric-grid{grid-template-columns:repeat(2,1fr)} } -@media(max-width:720px){ .sidebar{display:none}.content{margin-left:0;padding:0 12px 18px}.topbar{margin:0 -12px 12px;padding:8px 12px}.topbar h1{font-size:17px}.top-actions .el-button:first-child{display:none}.mobile-section{display:block}.panel{padding:12px}.panel-head{flex-direction:column}.metric-grid{grid-template-columns:repeat(2,1fr);gap:8px}.metric-grid strong{font-size:18px}.database-select{width:100%} } diff --git a/frontend/vite.postgres.config.ts b/frontend/vite.postgres.config.ts deleted file mode 100644 index bc22697..0000000 --- a/frontend/vite.postgres.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "vite"; -import vue from "@vitejs/plugin-vue"; -import { resolve } from "node:path"; -import { fileURLToPath, URL } from "node:url"; - -const root = fileURLToPath(new URL("./postgres-admin", import.meta.url)); - -export default defineConfig({ - root, - plugins: [vue()], - build: { - outDir: resolve(root, "../dist-postgres"), - emptyOutDir: true - } -}); diff --git a/scripts/build-postgresql-fnos-package.sh b/scripts/build-postgresql-fnos-package.sh deleted file mode 100755 index 9cdd5bd..0000000 --- a/scripts/build-postgresql-fnos-package.sh +++ /dev/null @@ -1,177 +0,0 @@ -#!/bin/bash -set -euo pipefail - -ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -VERSION=15.1.1 -PGVECTOR_VERSION=0.8.6 -PGVECTOR_PACKAGE_VERSION=0.8.6-1.pgdg12%2B1 -PGVECTOR_SHA256=b27ff894d1e2d23ebd7528fcb986923391977cbd5c5379ed74527875246854ca -OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/nxsir-postgresql-${VERSION}-x86_64.fpk}" -WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd) -DOTNET_BIN="${DOTNET:-$WORKSPACE_CACHE/.dotnet8/dotnet}" -NUGET_FEED="${POSTGRES_SERVICE_NUGET_FEED:-$WORKSPACE_CACHE/.nuget-feed}" -NUGET_PACKAGES="${NUGET_PACKAGES:-$WORKSPACE_CACHE/.nuget-packages}" -DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$WORKSPACE_CACHE/.dotnet-cli-home}" -BUILD_TMP_ROOT="${POSTGRES_SERVICE_BUILD_TMPDIR:-$WORKSPACE_CACHE/.fnos-build-tmp}" - -if [ -n "${FNPACK:-}" ]; then - FNPACK_BIN="$FNPACK" -elif [ -x "$ROOT_DIR/.tools/fnpack" ]; then - FNPACK_BIN="$ROOT_DIR/.tools/fnpack" -else - FNPACK_BIN=fnpack -fi -FNPACK_BIN=$(command -v "$FNPACK_BIN") || { - printf 'fnpack is required; install it from the fnOS developer portal or set FNPACK.\n' >&2 - exit 1 -} - -for command_name in npm apt-get curl dpkg-deb node sha256sum tar find realpath; do - command -v "$command_name" >/dev/null 2>&1 || { - printf 'required build command is missing: %s\n' "$command_name" >&2 - exit 1 - } -done -test -x "$DOTNET_BIN" || { printf 'missing .NET SDK: %s\n' "$DOTNET_BIN" >&2; exit 1; } -test -d "$NUGET_FEED" || { printf 'missing offline NuGet feed: %s\n' "$NUGET_FEED" >&2; exit 1; } - -mkdir -p "$BUILD_TMP_ROOT" "$(dirname -- "$OUTPUT")" -WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/postgres-service-fnos-build.XXXXXX") -trap 'rm -rf -- "$WORK_DIR"' EXIT -STAGE="$WORK_DIR/stage" -PACKED_ROOT="$WORK_DIR/packed" -FNPACK_TMP_ROOT="$WORK_DIR/fnpack-tmp" -RUNTIME_ROOT="$STAGE/app/runtime" -EXTRACT_ROOT="$WORK_DIR/runtime-extract" -mkdir -p "$STAGE/app/server/wwwroot" "$RUNTIME_ROOT" "$PACKED_ROOT" "$FNPACK_TMP_ROOT" "$EXTRACT_ROOT" -cp -a "$ROOT_DIR/fnos-postgresql/." "$STAGE/" - -printf 'Building PostgreSQL management frontend...\n' -npm run build:postgres-admin --prefix "$ROOT_DIR/frontend" -cp -a "$ROOT_DIR/frontend/dist-postgres/." "$STAGE/app/server/wwwroot/" - -printf 'Publishing self-contained PostgreSQL management API...\n' -export NUGET_PACKAGES DOTNET_CLI_HOME -"$DOTNET_BIN" restore "$ROOT_DIR/src/PostgresService.WebApi/PostgresService.WebApi.csproj" \ - -r linux-x64 \ - --source "$NUGET_FEED" \ - --disable-parallel -"$DOTNET_BIN" publish "$ROOT_DIR/src/PostgresService.WebApi/PostgresService.WebApi.csproj" \ - -c Release \ - -r linux-x64 \ - --self-contained true \ - --no-restore \ - -p:DebugType=None \ - -p:DebugSymbols=false \ - -p:PublishSingleFile=false \ - -p:PublishReadyToRun=false \ - -o "$STAGE/app/server" \ - /maxcpucount:1 -rm -f "$STAGE/app/server/"*.pdb - -printf 'Downloading pinned Debian Bookworm PostgreSQL 15 runtime...\n' -APT_ROOT="$WORK_DIR/apt" -mkdir -p \ - "$APT_ROOT/etc/apt" \ - "$APT_ROOT/var/lib/apt/lists/partial" \ - "$APT_ROOT/var/lib/dpkg" \ - "$APT_ROOT/var/cache/apt/archives/partial" -cp "$ROOT_DIR/scripts/fnos-bookworm.sources.list" "$APT_ROOT/etc/apt/sources.list" -touch "$APT_ROOT/var/lib/dpkg/status" -APT_OPTIONS=( - -o "Dir::Etc::sourcelist=$APT_ROOT/etc/apt/sources.list" - -o "Dir::Etc::sourceparts=-" - -o "Dir::State::status=$APT_ROOT/var/lib/dpkg/status" - -o "Dir::State::lists=$APT_ROOT/var/lib/apt/lists" - -o "Dir::Cache=$APT_ROOT/var/cache/apt" - -o "Dir::Cache::archives=$APT_ROOT/var/cache/apt/archives" - -o "Debug::NoLocking=1" - -o "APT::Architecture=amd64" - -o "Acquire::Languages=none" -) -apt-get "${APT_OPTIONS[@]}" update -apt-get "${APT_OPTIONS[@]}" --download-only --no-install-recommends --yes install \ - postgresql-15 postgresql-client-15 - -shopt -s nullglob -runtime_packages=("$APT_ROOT"/var/cache/apt/archives/*.deb) -test "${#runtime_packages[@]}" -gt 0 || { printf 'APT did not download PostgreSQL runtime packages\n' >&2; exit 1; } -for package_file in "${runtime_packages[@]}"; do - dpkg-deb -x "$package_file" "$EXTRACT_ROOT" -done -shopt -u nullglob - -printf 'Downloading pinned pgvector %s extension...\n' "$PGVECTOR_VERSION" -PGVECTOR_DEB="$WORK_DIR/postgresql-15-pgvector.deb" -curl --fail --location --retry 3 \ - "https://apt.postgresql.org/pub/repos/apt/pool/main/p/pgvector/postgresql-15-pgvector_${PGVECTOR_PACKAGE_VERSION}_amd64.deb" \ - --output "$PGVECTOR_DEB" -printf '%s %s\n' "$PGVECTOR_SHA256" "$PGVECTOR_DEB" | sha256sum --check --status -test "$(dpkg-deb -f "$PGVECTOR_DEB" Package)" = "postgresql-15-pgvector" -test "$(dpkg-deb -f "$PGVECTOR_DEB" Version)" = "${PGVECTOR_PACKAGE_VERSION//%2B/+}" -dpkg-deb -x "$PGVECTOR_DEB" "$EXTRACT_ROOT" - -printf 'Assembling relocatable PostgreSQL runtime...\n' -cp -a "$EXTRACT_ROOT/." "$RUNTIME_ROOT/" -rm -rf \ - "$RUNTIME_ROOT/usr/share/doc" \ - "$RUNTIME_ROOT/usr/share/man" \ - "$RUNTIME_ROOT/usr/share/locale" \ - "$RUNTIME_ROOT/var" \ - "$RUNTIME_ROOT/etc/init.d" \ - "$RUNTIME_ROOT/usr/sbin" - -# fnOS provides the matching glibc and loader. Keep other Debian libraries but -# never package a second libc implementation into the application runtime. -find "$RUNTIME_ROOT" -type f \( \ - -name 'ld-linux-*.so.*' -o -name 'libc.so.*' -o -name 'libm.so.*' -o \ - -name 'libmvec.so.*' -o -name 'libpthread.so.*' -o -name 'libdl.so.*' -o \ - -name 'librt.so.*' -o -name 'libresolv.so.*' -o -name 'libutil.so.*' \ - \) -delete - -# Official fnOS packages reject absolute links. Rewrite Debian links to remain -# inside the staged runtime and fail if a link target was not packaged. -while IFS= read -r -d '' link_path; do - target=$(readlink -- "$link_path") - case "$target" in - /*) - staged_target="$RUNTIME_ROOT$target" - if [ ! -e "$staged_target" ]; then - rm "$link_path" - continue - fi - relative_target=$(realpath --relative-to="$(dirname -- "$link_path")" "$staged_target") - rm "$link_path" - ln -s "$relative_target" "$link_path" - ;; - esac -done < <(find "$RUNTIME_ROOT" -type l -print0) - -for required_file in \ - "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/postgres" \ - "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/initdb" \ - "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/pg_ctl" \ - "$RUNTIME_ROOT/usr/lib/postgresql/15/bin/pg_dump" \ - "$RUNTIME_ROOT/usr/lib/postgresql/15/lib/vector.so" \ - "$RUNTIME_ROOT/usr/share/postgresql/15/extension/vector.control"; do - test -e "$required_file" || { printf 'runtime file is missing: %s\n' "$required_file" >&2; exit 1; } -done - -node "$ROOT_DIR/scripts/generate-fnos-icons.mjs" "$STAGE" "$STAGE/app/ui/images" -chmod 0755 "$STAGE/cmd/"* - -printf 'Packing PostgreSQL service with official fnOS fnpack...\n' -( - cd "$PACKED_ROOT" - TMPDIR="$FNPACK_TMP_ROOT" "$FNPACK_BIN" build --directory "$STAGE" -) -built_package=$(find "$PACKED_ROOT" -maxdepth 1 -type f -name '*.fpk' -print -quit) -test -n "$built_package" || { printf 'fnpack did not create an FPK\n' >&2; exit 1; } -mv "$built_package" "$OUTPUT" -( - cd "$(dirname -- "$OUTPUT")" - sha256sum "$(basename -- "$OUTPUT")" >"$(basename -- "$OUTPUT").sha256" -) - -"$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT" -printf 'Built %s\n' "$OUTPUT" diff --git a/scripts/smoke-postgresql-fnos-package.sh b/scripts/smoke-postgresql-fnos-package.sh deleted file mode 100755 index 46bb406..0000000 --- a/scripts/smoke-postgresql-fnos-package.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/bin/bash -set -euo pipefail - -PACKAGE=${1:?usage: smoke-postgresql-fnos-package.sh postgresql-service.fpk [temporary-directory]} -SMOKE_TMP_ROOT="${2:-${POSTGRES_SERVICE_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}" -mkdir -p "$SMOKE_TMP_ROOT" -SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd) -WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/postgres-service-fnos-smoke.XXXXXX") -PACKAGE_ROOT="$WORK_DIR/package" -APP_ROOT="$WORK_DIR/app" -DATA_ROOT="$WORK_DIR/var" -VOLUME_ROOT="$WORK_DIR/volume" -API_PORT=${POSTGRES_SERVICE_SMOKE_API_PORT:-19433} -PG_PORT=${POSTGRES_SERVICE_SMOKE_PG_PORT:-19432} -ADMIN_PASSWORD='Postgres-Admin-Smoke-2026!' -ENROLLMENT_TOKEN='Postgres-Enrollment-Smoke-2026!' -CONTROL="$PACKAGE_ROOT/cmd/main" - -cleanup() { - status=$? - if [ -x "$CONTROL" ]; then - TRIM_APPDEST="$APP_ROOT" TRIM_PKGVAR="$DATA_ROOT" TRIM_APPDEST_VOL="$VOLUME_ROOT" \ - TRIM_SERVICE_PORT="$API_PORT" POSTGRES_SERVICE_PORT="$PG_PORT" \ - "$CONTROL" stop >/dev/null 2>&1 || true - fi - if [ "$status" -ne 0 ]; then - printf '%s\n' 'PostgreSQL fnOS smoke test failed; service logs follow:' >&2 - for log_file in "$DATA_ROOT/log/postgresql.log" "$DATA_ROOT/log/postgres-service.log"; do - if [ -f "$log_file" ]; then - printf '%s\n' "--- $log_file ---" >&2 - tail -n 160 "$log_file" >&2 || true - fi - done - fi - rm -rf -- "$WORK_DIR" - return "$status" -} -trap cleanup EXIT HUP INT TERM - -mkdir -p "$PACKAGE_ROOT" "$APP_ROOT" "$VOLUME_ROOT" -tar -xzf "$PACKAGE" -C "$PACKAGE_ROOT" -tar -xzf "$PACKAGE_ROOT/app.tgz" -C "$APP_ROOT" -if [ -n "${POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY:-}" ]; then - test -x "$POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY/PostgresService.WebApi" || { - printf 'invalid API overlay: %s\n' "$POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY" >&2 - exit 1 - } - cp -a "$POSTGRES_SERVICE_SMOKE_SERVER_OVERLAY/." "$APP_ROOT/server/" -fi - -export TRIM_APPDEST="$APP_ROOT" -export TRIM_PKGVAR="$DATA_ROOT" -export TRIM_APPDEST_VOL="$VOLUME_ROOT" -export TRIM_SERVICE_PORT="$API_PORT" -export POSTGRES_SERVICE_PORT="$PG_PORT" - -wizard_postgres_admin_password="$ADMIN_PASSWORD" \ -wizard_postgres_admin_password_confirm="$ADMIN_PASSWORD" \ -wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \ -wizard_postgres_enrollment_token_confirm="$ENROLLMENT_TOKEN" \ - "$PACKAGE_ROOT/cmd/install_callback" -"$CONTROL" start -"$CONTROL" status - -BASE_URL="http://127.0.0.1:$API_PORT" -curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"' -curl -fsS "$BASE_URL/" | grep -q '
' - -COOKIE_JAR="$WORK_DIR/cookies.txt" -curl -fsS -c "$COOKIE_JAR" -H 'Content-Type: application/json' \ - --data "{\"username\":\"admin\",\"password\":\"$ADMIN_PASSWORD\"}" \ - "$BASE_URL/api/v1/auth/login" >/dev/null -curl -fsS -b "$COOKIE_JAR" "$BASE_URL/api/v1/overview" | grep -q '"version"' -curl -fsS -b "$COOKIE_JAR" "$BASE_URL/api/v1/sessions" | grep -q '^\[' - -enroll() { - app_id=$1 - display_name=$2 - extensions=$3 - destination=$4 - curl -fsS \ - -H "Authorization: Bearer $ENROLLMENT_TOKEN" \ - -H 'Content-Type: application/json' \ - --data "{\"appId\":\"$app_id\",\"displayName\":\"$display_name\",\"requestedExtensions\":$extensions}" \ - "$BASE_URL/internal/v1/enroll" >"$destination" -} - -enroll liverecorder 'Live Recorder' '[]' "$WORK_DIR/live.json" -enroll imagefind-test 'ImageFind Test Client' '["vector"]' "$WORK_DIR/image.json" - -json_field() { - field=$1 - file=$2 - sed -n "s/.*\"$field\":\"\([^\"]*\)\".*/\1/p" "$file" -} - -LIVE_DB=$(json_field database "$WORK_DIR/live.json") -LIVE_USER=$(json_field username "$WORK_DIR/live.json") -LIVE_PASSWORD=$(json_field password "$WORK_DIR/live.json") -IMAGE_DB=$(json_field database "$WORK_DIR/image.json") -IMAGE_USER=$(json_field username "$WORK_DIR/image.json") -IMAGE_PASSWORD=$(json_field password "$WORK_DIR/image.json") -test -n "$LIVE_DB" && test -n "$LIVE_USER" && test -n "$LIVE_PASSWORD" -test -n "$IMAGE_DB" && test -n "$IMAGE_USER" && test -n "$IMAGE_PASSWORD" -test "$LIVE_DB" != "$IMAGE_DB" -test "$LIVE_USER" != "$IMAGE_USER" - -PG_BIN="$APP_ROOT/runtime/usr/lib/postgresql/15/bin" -PG_LIB="$APP_ROOT/runtime/usr/lib/postgresql/15/lib" -RUNTIME_LIBS="$APP_ROOT/runtime/usr/lib/x86_64-linux-gnu:$APP_ROOT/runtime/lib/x86_64-linux-gnu:$PG_LIB" -run_client_psql() { - password=$1 - shift - env LD_LIBRARY_PATH="$RUNTIME_LIBS" PGPASSWORD="$password" "$PG_BIN/psql" "$@" -} - -run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d "$LIVE_DB" \ - -v ON_ERROR_STOP=1 -c 'CREATE TABLE smoke_live(id integer PRIMARY KEY, value text); INSERT INTO smoke_live VALUES (1, '\''live'\'');' >/dev/null -run_client_psql "$IMAGE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$IMAGE_USER" -d "$IMAGE_DB" \ - -v ON_ERROR_STOP=1 -c 'CREATE TABLE smoke_vectors(id integer PRIMARY KEY, embedding vector(3)); INSERT INTO smoke_vectors VALUES (1, '\''[1,2,3]'\''); SELECT embedding <-> '\''[1,2,4]'\'' FROM smoke_vectors;' >/dev/null - -if run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d "$IMAGE_DB" -Atqc 'SELECT 1' >/dev/null 2>&1; then - printf '%s\n' 'role isolation failed: Live Recorder connected to ImageFind database' >&2 - exit 1 -fi - -curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \ - --data "{\"database\":\"$IMAGE_DB\",\"sql\":\"SELECT count(*) FROM smoke_vectors\"}" \ - "$BASE_URL/api/v1/query" | grep -q '"rowCount":1' -if curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \ - --data "{\"database\":\"$IMAGE_DB\",\"sql\":\"DELETE FROM smoke_vectors\"}" \ - "$BASE_URL/api/v1/query" >/dev/null 2>&1; then - printf '%s\n' 'read-only SQL console accepted a write statement' >&2 - exit 1 -fi - -curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' \ - --data "{\"database\":\"$LIVE_DB\"}" "$BASE_URL/api/v1/backups" | grep -q '"sha256"' - -POSTMASTER_PID=$(sed -n '1p' "$DATA_ROOT/postgres/postmaster.pid") -test -n "$POSTMASTER_PID" -kill -0 "$POSTMASTER_PID" - -"$CONTROL" restart -"$CONTROL" status -curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"' -run_client_psql "$LIVE_PASSWORD" -h 127.0.0.1 -p "$PG_PORT" -U "$LIVE_USER" -d "$LIVE_DB" -Atqc 'SELECT value FROM smoke_live WHERE id = 1' | grep -q '^live$' - -printf '%s\n' 'PostgreSQL fnOS smoke test passed: shared process, SCRAM isolation, pgvector, read-only SQL, backup and restart are ready' diff --git a/scripts/verify-fnos-package.sh b/scripts/verify-fnos-package.sh index 1670f20..8329d9a 100755 --- a/scripts/verify-fnos-package.sh +++ b/scripts/verify-fnos-package.sh @@ -15,10 +15,7 @@ manifest_value() { tar -xzf "$PACKAGE" -C "$WORK_DIR" appname=$(manifest_value appname) version=$(manifest_value version) -case "$appname" in - liverecorder|nxsir.postgresql) ;; - *) printf 'unexpected fnOS appname: %s\n' "$appname" >&2; exit 1 ;; -esac +test "$appname" = "liverecorder" || { printf 'unexpected fnOS appname: %s\n' "$appname" >&2; exit 1; } test -n "$version" test "$(manifest_value platform)" = "x86" test -x "$WORK_DIR/cmd/main" @@ -76,24 +73,14 @@ grep -q '^server/wwwroot/index.html$' "$WORK_DIR/app-files.txt" grep -q '^ui/config$' "$WORK_DIR/app-files.txt" grep -q '^ui/images/icon_64.png$' "$WORK_DIR/app-files.txt" -if [ "$appname" = "liverecorder" ]; then - grep -q '^server/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt" - grep -q '^server/Platforms/Douyin/Signing/sign-xbogus.js$' "$WORK_DIR/app-files.txt" - grep -q '^runtime/bin/curl$' "$WORK_DIR/app-files.txt" - grep -q '^runtime/etc/ssl/certs/ca-certificates.crt$' "$WORK_DIR/app-files.txt" - test "$(manifest_value install_dep_apps)" = "nxsir.postgresql:nodejs_v22" - if grep -Eq '^runtime/(bin/node|usr/(lib|share)/postgresql/|lib/(libLLVM|libz3))' "$WORK_DIR/app-files.txt"; then - printf 'Live Recorder must use shared PostgreSQL and the fnOS nodejs_v22 dependency\n' >&2 - exit 1 - fi -else - grep -q '^server/PostgresService.WebApi$' "$WORK_DIR/app-files.txt" - grep -q '^runtime/usr/lib/postgresql/15/bin/postgres$' "$WORK_DIR/app-files.txt" - grep -q '^runtime/usr/lib/postgresql/15/bin/initdb$' "$WORK_DIR/app-files.txt" - grep -q '^runtime/usr/lib/postgresql/15/bin/pg_ctl$' "$WORK_DIR/app-files.txt" - grep -q '^runtime/usr/share/postgresql/15/postgresql.conf.sample$' "$WORK_DIR/app-files.txt" - grep -q '^runtime/usr/lib/postgresql/15/lib/vector.so$' "$WORK_DIR/app-files.txt" - grep -q '^runtime/usr/share/postgresql/15/extension/vector.control$' "$WORK_DIR/app-files.txt" +grep -q '^server/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt" +grep -q '^server/Platforms/Douyin/Signing/sign-xbogus.js$' "$WORK_DIR/app-files.txt" +grep -q '^runtime/bin/curl$' "$WORK_DIR/app-files.txt" +grep -q '^runtime/etc/ssl/certs/ca-certificates.crt$' "$WORK_DIR/app-files.txt" +test "$(manifest_value install_dep_apps)" = "nxsir.postgresql:nodejs_v22" +if grep -Eq '^runtime/(bin/node|usr/(lib|share)/postgresql/|lib/(libLLVM|libz3))' "$WORK_DIR/app-files.txt"; then + printf 'Live Recorder must use shared PostgreSQL and the fnOS nodejs_v22 dependency\n' >&2 + exit 1 fi if grep -Eq '^runtime/.*/(ffmpeg|ffprobe)$' "$WORK_DIR/app-files.txt"; then diff --git a/src/PostgresService.WebApi/Models.cs b/src/PostgresService.WebApi/Models.cs deleted file mode 100644 index dc4ad5f..0000000 --- a/src/PostgresService.WebApi/Models.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace PostgresService.WebApi; - -public sealed record LoginRequest(string Username, string Password); - -public sealed record EnrollRequest( - string AppId, - string DisplayName, - IReadOnlyList? RequestedExtensions); - -public sealed record PostgresClientCredential( - string Host, - int Port, - string Database, - string Username, - string Password, - string SslMode, - string ServiceVersion); - -public sealed record CreateDatabaseRequest(string Name, string? Owner); -public sealed record CreateRoleRequest(string Name, string Password); -public sealed record QueryRequest(string Database, string Sql); -public sealed record BackupRequest(string Database); -public sealed record RestoreRequest( - string BackupFileName, - string TargetDatabase, - bool Overwrite, - string Confirmation); - -public sealed record ManagedClient( - string AppId, - string DisplayName, - string DatabaseName, - string RoleName, - string[] Extensions, - string Status, - DateTimeOffset CreatedAt, - DateTimeOffset UpdatedAt); - -public sealed record DatabaseSummary( - string Name, - string Owner, - long SizeBytes, - int ActiveConnections, - bool IsManaged); - -public sealed record RoleSummary( - string Name, - bool CanLogin, - int ConnectionLimit, - bool IsManaged); - -public sealed record SessionSummary( - int ProcessId, - string Database, - string Username, - string State, - string? Query, - DateTimeOffset? QueryStartedAt); - -public sealed record BackupSummary( - string FileName, - string Database, - long SizeBytes, - DateTimeOffset CreatedAt, - string Sha256); - -public sealed record QueryResult( - string[] Columns, - IReadOnlyList Rows, - int RowCount, - bool Truncated, - long ElapsedMilliseconds); diff --git a/src/PostgresService.WebApi/PostgresAdminService.cs b/src/PostgresService.WebApi/PostgresAdminService.cs deleted file mode 100644 index e451e54..0000000 --- a/src/PostgresService.WebApi/PostgresAdminService.cs +++ /dev/null @@ -1,810 +0,0 @@ -using System.Data; -using System.Diagnostics; -using System.Security.Cryptography; -using System.Text.Json; -using System.Text.RegularExpressions; -using Npgsql; - -namespace PostgresService.WebApi; - -public sealed class PostgresAdminService -{ - private const string MetadataDatabase = "postgres_service"; - private const string ConsoleRole = "postgres_console"; - private static readonly Regex IdentifierPattern = new("^[a-z][a-z0-9_]{2,62}$", RegexOptions.Compiled); - private static readonly Regex AppIdPattern = new("^[a-z][a-z0-9._-]{2,63}$", RegexOptions.Compiled); - private static readonly Regex ReadOnlySqlPattern = new( - "^\\s*(select|with|explain|show|values|table)\\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - private static readonly HashSet SystemDatabases = new(StringComparer.Ordinal) - { - "postgres", "template0", "template1", MetadataDatabase - }; - private readonly string _host; - private readonly int _port; - private readonly string _username; - private readonly string _pgBin; - private readonly string _backupRoot; - private readonly string _auditPath; - private readonly SemaphoreSlim _provisionLock = new(1, 1); - private readonly SemaphoreSlim _auditLock = new(1, 1); - - public PostgresAdminService(IConfiguration configuration) - { - _host = configuration["POSTGRES_SERVICE_SOCKET_ROOT"] - ?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_SOCKET_ROOT") - ?? "/tmp"; - _port = int.TryParse( - configuration["POSTGRES_SERVICE_PORT"] ?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_PORT"), - out var port) ? port : 15432; - _username = configuration["POSTGRES_SERVICE_ADMIN_USER"] - ?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_ADMIN_USER") - ?? "postgres_service"; - _pgBin = configuration["POSTGRES_SERVICE_PG_BIN"] - ?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_PG_BIN") - ?? "/usr/lib/postgresql/15/bin"; - - var dataRoot = configuration["POSTGRES_SERVICE_DATA_ROOT"] - ?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_DATA_ROOT") - ?? Path.Combine(AppContext.BaseDirectory, "data"); - _backupRoot = configuration["POSTGRES_SERVICE_BACKUP_ROOT"] - ?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_BACKUP_ROOT") - ?? Path.Combine(dataRoot, "backups"); - _auditPath = Path.Combine(dataRoot, "audit.jsonl"); - Directory.CreateDirectory(dataRoot); - Directory.CreateDirectory(_backupRoot); - } - - public async Task InitializeAsync(CancellationToken cancellationToken) - { - await using (var connection = await OpenAsync("postgres", cancellationToken)) - { - if (!await DatabaseExistsAsync(connection, MetadataDatabase, cancellationToken)) - { - await ExecuteNonQueryAsync( - connection, - $"CREATE DATABASE {QuoteIdentifier(MetadataDatabase)} OWNER {QuoteIdentifier(_username)}", - cancellationToken); - } - - if (!await RoleExistsAsync(connection, ConsoleRole, cancellationToken)) - { - await ExecuteNonQueryAsync( - connection, - $"CREATE ROLE {QuoteIdentifier(ConsoleRole)} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT", - cancellationToken); - } - await ExecuteNonQueryAsync( - connection, - $"ALTER ROLE {QuoteIdentifier(ConsoleRole)} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT; GRANT pg_read_all_data TO {QuoteIdentifier(ConsoleRole)}", - cancellationToken); - } - - await using var metadata = await OpenAsync(MetadataDatabase, cancellationToken); - const string schema = """ - CREATE TABLE IF NOT EXISTS managed_clients ( - app_id text PRIMARY KEY, - display_name text NOT NULL, - database_name text NOT NULL UNIQUE, - role_name text NOT NULL UNIQUE, - extensions text[] NOT NULL DEFAULT '{}', - status text NOT NULL DEFAULT 'active', - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() - ); - """; - await ExecuteNonQueryAsync(metadata, schema, cancellationToken); - } - - public async Task EnrollAsync( - EnrollRequest request, - CancellationToken cancellationToken) - { - var appId = request.AppId.Trim().ToLowerInvariant(); - if (!AppIdPattern.IsMatch(appId)) - { - throw new ArgumentException("appId 格式无效,只允许小写字母、数字、点、下划线和连字符。"); - } - - var displayName = request.DisplayName.Trim(); - if (displayName.Length is < 1 or > 100) - { - throw new ArgumentException("displayName 长度必须为 1 到 100 个字符。"); - } - - var extensions = (request.RequestedExtensions ?? []) - .Select(item => item.Trim().ToLowerInvariant()) - .Where(item => item.Length > 0) - .Distinct(StringComparer.Ordinal) - .ToArray(); - if (extensions.Any(item => item != "vector")) - { - throw new ArgumentException("存在不受支持的 PostgreSQL 扩展。"); - } - - await _provisionLock.WaitAsync(cancellationToken); - try - { - var existing = await FindClientAsync(appId, cancellationToken); - var databaseName = existing?.DatabaseName ?? BuildResourceName("appdb", appId); - var roleName = existing?.RoleName ?? BuildResourceName("app", appId); - var password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(36)); - - await using (var postgres = await OpenAsync("postgres", cancellationToken)) - { - if (!await RoleExistsAsync(postgres, roleName, cancellationToken)) - { - var passwordLiteral = await QuoteLiteralAsync(postgres, password, cancellationToken); - await ExecuteNonQueryAsync( - postgres, - $"CREATE ROLE {QuoteIdentifier(roleName)} LOGIN PASSWORD {passwordLiteral} NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT CONNECTION LIMIT 30", - cancellationToken); - } - else - { - await SetRolePasswordAsync(postgres, roleName, password, cancellationToken); - } - - if (!await DatabaseExistsAsync(postgres, databaseName, cancellationToken)) - { - await ExecuteNonQueryAsync( - postgres, - $"CREATE DATABASE {QuoteIdentifier(databaseName)} OWNER {QuoteIdentifier(roleName)}", - cancellationToken); - } - - await ExecuteNonQueryAsync( - postgres, - $"REVOKE ALL ON DATABASE {QuoteIdentifier(databaseName)} FROM PUBLIC", - cancellationToken); - await ExecuteNonQueryAsync( - postgres, - $"GRANT CONNECT, TEMPORARY ON DATABASE {QuoteIdentifier(databaseName)} TO {QuoteIdentifier(roleName)}", - cancellationToken); - } - - await using (var target = await OpenAsync(databaseName, cancellationToken)) - { - await ExecuteNonQueryAsync( - target, - $"REVOKE ALL ON SCHEMA public FROM PUBLIC; ALTER SCHEMA public OWNER TO {QuoteIdentifier(roleName)}; GRANT USAGE ON SCHEMA public TO {QuoteIdentifier(ConsoleRole)}", - cancellationToken); - if (extensions.Contains("vector", StringComparer.Ordinal)) - { - await ExecuteNonQueryAsync(target, "CREATE EXTENSION IF NOT EXISTS vector", cancellationToken); - } - } - - await UpsertClientAsync( - appId, displayName, databaseName, roleName, extensions, cancellationToken); - await AuditAsync("client.enroll", appId, new { databaseName, roleName, extensions }, cancellationToken); - - return new PostgresClientCredential( - "127.0.0.1", _port, databaseName, roleName, password, "Disable", "15"); - } - finally - { - _provisionLock.Release(); - } - } - - public async Task GetOverviewAsync(CancellationToken cancellationToken) - { - await using var connection = await OpenAsync("postgres", cancellationToken); - await using var command = new NpgsqlCommand(""" - SELECT - current_setting('server_version') AS version, - EXTRACT(EPOCH FROM (clock_timestamp() - pg_postmaster_start_time()))::bigint AS uptime_seconds, - (SELECT count(*) FROM pg_stat_activity) AS connections, - (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max_connections, - (SELECT count(*) FROM pg_database WHERE datallowconn) AS databases, - COALESCE((SELECT sum(pg_database_size(datname)) FROM pg_database WHERE datallowconn), 0) AS size_bytes - """, connection); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - await reader.ReadAsync(cancellationToken); - return new - { - version = reader.GetString(0), - uptimeSeconds = reader.GetInt64(1), - connections = reader.GetInt64(2), - maxConnections = reader.GetInt32(3), - databases = reader.GetInt64(4), - sizeBytes = reader.GetInt64(5), - host = "127.0.0.1", - port = _port - }; - } - - public async Task> ListClientsAsync(CancellationToken cancellationToken) - { - await using var connection = await OpenAsync(MetadataDatabase, cancellationToken); - await using var command = new NpgsqlCommand(""" - SELECT app_id, display_name, database_name, role_name, extensions, status, created_at, updated_at - FROM managed_clients - ORDER BY display_name, app_id - """, connection); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - var items = new List(); - while (await reader.ReadAsync(cancellationToken)) - { - items.Add(ReadClient(reader)); - } - return items; - } - - public async Task RotateClientPasswordAsync(string appId, CancellationToken cancellationToken) - { - var client = await FindClientAsync(appId, cancellationToken) - ?? throw new KeyNotFoundException("客户端不存在。"); - var password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(36)); - await using var connection = await OpenAsync("postgres", cancellationToken); - await SetRolePasswordAsync(connection, client.RoleName, password, cancellationToken); - await AuditAsync("client.rotate", appId, new { client.RoleName }, cancellationToken); - return password; - } - - public async Task RevokeClientAsync(string appId, CancellationToken cancellationToken) - { - var client = await FindClientAsync(appId, cancellationToken) - ?? throw new KeyNotFoundException("客户端不存在。"); - await using (var connection = await OpenAsync("postgres", cancellationToken)) - { - await ExecuteNonQueryAsync( - connection, - $"ALTER ROLE {QuoteIdentifier(client.RoleName)} NOLOGIN", - cancellationToken); - } - await using (var metadata = await OpenAsync(MetadataDatabase, cancellationToken)) - await using (var command = new NpgsqlCommand( - "UPDATE managed_clients SET status = 'revoked', updated_at = now() WHERE app_id = @appId", - metadata)) - { - command.Parameters.AddWithValue("appId", appId); - await command.ExecuteNonQueryAsync(cancellationToken); - } - await AuditAsync("client.revoke", appId, new { client.RoleName }, cancellationToken); - } - - public async Task> ListDatabasesAsync(CancellationToken cancellationToken) - { - var clients = await ListClientsAsync(cancellationToken); - var managed = clients.Select(item => item.DatabaseName).ToHashSet(StringComparer.Ordinal); - await using var connection = await OpenAsync("postgres", cancellationToken); - await using var command = new NpgsqlCommand(""" - SELECT d.datname, pg_get_userbyid(d.datdba), pg_database_size(d.datname), - count(a.pid) FILTER (WHERE a.pid IS NOT NULL)::int - FROM pg_database d - LEFT JOIN pg_stat_activity a ON a.datid = d.oid - WHERE d.datallowconn - GROUP BY d.datname, d.datdba - ORDER BY d.datname - """, connection); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - var result = new List(); - while (await reader.ReadAsync(cancellationToken)) - { - var name = reader.GetString(0); - result.Add(new DatabaseSummary( - name, reader.GetString(1), reader.GetInt64(2), reader.GetInt32(3), managed.Contains(name))); - } - return result; - } - - public async Task> ListRolesAsync(CancellationToken cancellationToken) - { - var clients = await ListClientsAsync(cancellationToken); - var managed = clients.Select(item => item.RoleName).ToHashSet(StringComparer.Ordinal); - await using var connection = await OpenAsync("postgres", cancellationToken); - await using var command = new NpgsqlCommand(""" - SELECT rolname, rolcanlogin, rolconnlimit - FROM pg_roles - WHERE rolname !~ '^pg_' AND rolname <> 'postgres_service' AND rolname <> 'postgres_console' - ORDER BY rolname - """, connection); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - var result = new List(); - while (await reader.ReadAsync(cancellationToken)) - { - var name = reader.GetString(0); - result.Add(new RoleSummary(name, reader.GetBoolean(1), reader.GetInt32(2), managed.Contains(name))); - } - return result; - } - - public async Task> ListSessionsAsync(CancellationToken cancellationToken) - { - await using var connection = await OpenAsync("postgres", cancellationToken); - await using var command = new NpgsqlCommand(""" - SELECT pid, COALESCE(datname, ''), COALESCE(usename, ''), COALESCE(state, ''), - left(query, 1000), query_start - FROM pg_stat_activity - WHERE pid <> pg_backend_pid() - ORDER BY query_start DESC NULLS LAST - """, connection); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - var result = new List(); - while (await reader.ReadAsync(cancellationToken)) - { - result.Add(new SessionSummary( - reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), - reader.IsDBNull(4) ? null : reader.GetString(4), - reader.IsDBNull(5) ? null : reader.GetFieldValue(5))); - } - return result; - } - - public async Task CreateDatabaseAsync(CreateDatabaseRequest request, CancellationToken cancellationToken) - { - var name = ValidateIdentifier(request.Name, "数据库名称"); - if (SystemDatabases.Contains(name)) - { - throw new ArgumentException("不能创建或覆盖系统数据库。"); - } - var owner = string.IsNullOrWhiteSpace(request.Owner) - ? _username - : ValidateIdentifier(request.Owner, "所有者"); - await using var connection = await OpenAsync("postgres", cancellationToken); - if (await DatabaseExistsAsync(connection, name, cancellationToken)) - { - throw new InvalidOperationException("数据库已存在。"); - } - if (!await RoleExistsAsync(connection, owner, cancellationToken)) - { - throw new InvalidOperationException("指定角色不存在。"); - } - await ExecuteNonQueryAsync( - connection, - $"CREATE DATABASE {QuoteIdentifier(name)} OWNER {QuoteIdentifier(owner)}", - cancellationToken); - await ExecuteNonQueryAsync( - connection, - $"REVOKE ALL ON DATABASE {QuoteIdentifier(name)} FROM PUBLIC; GRANT CONNECT, TEMPORARY ON DATABASE {QuoteIdentifier(name)} TO {QuoteIdentifier(owner)}", - cancellationToken); - await using (var target = await OpenAsync(name, cancellationToken)) - { - await ExecuteNonQueryAsync( - target, - $"REVOKE ALL ON SCHEMA public FROM PUBLIC; ALTER SCHEMA public OWNER TO {QuoteIdentifier(owner)}; GRANT USAGE ON SCHEMA public TO {QuoteIdentifier(ConsoleRole)}", - cancellationToken); - } - await AuditAsync("database.create", name, new { owner }, cancellationToken); - } - - public async Task DropDatabaseAsync(string name, string confirmation, CancellationToken cancellationToken) - { - name = ValidateIdentifier(name, "数据库名称"); - if (confirmation != name) - { - throw new ArgumentException("确认名称不匹配。"); - } - if (SystemDatabases.Contains(name) || await IsManagedDatabaseAsync(name, cancellationToken)) - { - throw new InvalidOperationException("系统数据库或应用托管数据库不能直接删除。"); - } - await using var connection = await OpenAsync("postgres", cancellationToken); - await ExecuteNonQueryAsync( - connection, - $"DROP DATABASE IF EXISTS {QuoteIdentifier(name)} WITH (FORCE)", - cancellationToken); - await AuditAsync("database.drop", name, null, cancellationToken); - } - - public async Task CreateRoleAsync(CreateRoleRequest request, CancellationToken cancellationToken) - { - var name = ValidateIdentifier(request.Name, "角色名称"); - if (string.IsNullOrEmpty(request.Password) || request.Password.Length < 16) - { - throw new ArgumentException("角色密码至少需要 16 个字符。"); - } - await using var connection = await OpenAsync("postgres", cancellationToken); - if (await RoleExistsAsync(connection, name, cancellationToken)) - { - throw new InvalidOperationException("角色已存在。"); - } - var passwordLiteral = await QuoteLiteralAsync(connection, request.Password, cancellationToken); - await ExecuteNonQueryAsync( - connection, - $"CREATE ROLE {QuoteIdentifier(name)} LOGIN PASSWORD {passwordLiteral} NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT CONNECTION LIMIT 20", - cancellationToken); - await AuditAsync("role.create", name, null, cancellationToken); - } - - public async Task DropRoleAsync(string name, string confirmation, CancellationToken cancellationToken) - { - name = ValidateIdentifier(name, "角色名称"); - if (confirmation != name) - { - throw new ArgumentException("确认名称不匹配。"); - } - if (await IsManagedRoleAsync(name, cancellationToken)) - { - throw new InvalidOperationException("应用托管角色不能直接删除,请先吊销客户端。"); - } - await using var connection = await OpenAsync("postgres", cancellationToken); - await ExecuteNonQueryAsync(connection, $"DROP ROLE IF EXISTS {QuoteIdentifier(name)}", cancellationToken); - await AuditAsync("role.drop", name, null, cancellationToken); - } - - public async Task TerminateSessionAsync(int processId, CancellationToken cancellationToken) - { - await using var connection = await OpenAsync("postgres", cancellationToken); - await using var command = new NpgsqlCommand("SELECT pg_terminate_backend(@pid)", connection); - command.Parameters.AddWithValue("pid", processId); - if (await command.ExecuteScalarAsync(cancellationToken) is not true) - { - throw new InvalidOperationException("会话不存在或无法终止。"); - } - await AuditAsync("session.terminate", processId.ToString(), null, cancellationToken); - } - - public async Task ExecuteReadOnlyQueryAsync(QueryRequest request, CancellationToken cancellationToken) - { - var database = ValidateIdentifier(request.Database, "数据库名称"); - var sql = request.Sql.Trim(); - if (sql.Length is < 1 or > 100_000 || !ReadOnlySqlPattern.IsMatch(sql)) - { - throw new ArgumentException("SQL 工作台仅允许 SELECT、WITH、EXPLAIN、SHOW、VALUES 或 TABLE 查询。"); - } - var statements = sql.TrimEnd().TrimEnd(';'); - if (statements.Contains(';')) - { - throw new ArgumentException("SQL 工作台每次只允许执行一条语句。"); - } - - var stopwatch = Stopwatch.StartNew(); - await using var connection = await OpenAsync(database, cancellationToken); - await using var transaction = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, cancellationToken); - await ExecuteNonQueryAsync( - connection, - $"SET TRANSACTION READ ONLY; SET LOCAL statement_timeout = '30s'; SET LOCAL lock_timeout = '5s'; SET LOCAL ROLE {QuoteIdentifier(ConsoleRole)}", - cancellationToken, - transaction); - await using var command = new NpgsqlCommand(statements, connection, transaction) { CommandTimeout = 30 }; - string[] columns; - var rows = new List(); - var truncated = false; - await using (var reader = await command.ExecuteReaderAsync(cancellationToken)) - { - columns = Enumerable.Range(0, reader.FieldCount).Select(reader.GetName).ToArray(); - while (await reader.ReadAsync(cancellationToken)) - { - if (rows.Count >= 1000) - { - truncated = true; - break; - } - var row = new object?[reader.FieldCount]; - for (var index = 0; index < reader.FieldCount; index++) - { - row[index] = reader.IsDBNull(index) ? null : NormalizeValue(reader.GetValue(index)); - } - rows.Add(row); - } - } - await transaction.RollbackAsync(cancellationToken); - stopwatch.Stop(); - await AuditAsync("query.read", database, new { rows = rows.Count, truncated }, cancellationToken); - return new QueryResult(columns, rows, rows.Count, truncated, stopwatch.ElapsedMilliseconds); - } - - public async Task> ListBackupsAsync(CancellationToken cancellationToken) - { - var result = new List(); - foreach (var path in Directory.EnumerateFiles(_backupRoot, "*.dump", SearchOption.TopDirectoryOnly)) - { - cancellationToken.ThrowIfCancellationRequested(); - var file = new FileInfo(path); - var fileName = file.Name; - var separator = fileName.IndexOf("--", StringComparison.Ordinal); - var database = separator > 0 ? fileName[..separator] : "未知"; - var checksumPath = path + ".sha256"; - var checksum = File.Exists(checksumPath) - ? (await File.ReadAllTextAsync(checksumPath, cancellationToken)).Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "" - : await ComputeSha256Async(path, cancellationToken); - result.Add(new BackupSummary(fileName, database, file.Length, file.CreationTimeUtc, checksum)); - } - return result.OrderByDescending(item => item.CreatedAt).ToArray(); - } - - public async Task CreateBackupAsync(string database, CancellationToken cancellationToken) - { - database = ValidateIdentifier(database, "数据库名称"); - await EnsureDatabaseExistsAsync(database, cancellationToken); - var fileName = $"{database}--{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.dump"; - var destination = Path.Combine(_backupRoot, fileName); - await RunPostgresToolAsync( - "pg_dump", - ["--format=custom", "--no-owner", "--no-acl", "--file", destination, database], - cancellationToken); - var checksum = await ComputeSha256Async(destination, cancellationToken); - await File.WriteAllTextAsync(destination + ".sha256", $"{checksum} {fileName}\n", cancellationToken); - var file = new FileInfo(destination); - await AuditAsync("backup.create", database, new { fileName, checksum }, cancellationToken); - return new BackupSummary(fileName, database, file.Length, file.CreationTimeUtc, checksum); - } - - public async Task RestoreBackupAsync(RestoreRequest request, CancellationToken cancellationToken) - { - var fileName = Path.GetFileName(request.BackupFileName); - if (!string.Equals(fileName, request.BackupFileName, StringComparison.Ordinal) || !fileName.EndsWith(".dump", StringComparison.Ordinal)) - { - throw new ArgumentException("备份文件名称无效。"); - } - var source = Path.Combine(_backupRoot, fileName); - if (!File.Exists(source)) - { - throw new FileNotFoundException("备份文件不存在。", fileName); - } - var target = ValidateIdentifier(request.TargetDatabase, "目标数据库名称"); - if (request.Confirmation != target) - { - throw new ArgumentException("确认名称不匹配。"); - } - if (SystemDatabases.Contains(target)) - { - throw new InvalidOperationException("不能恢复到系统数据库。"); - } - - await using (var connection = await OpenAsync("postgres", cancellationToken)) - { - var exists = await DatabaseExistsAsync(connection, target, cancellationToken); - if (exists && !request.Overwrite) - { - throw new InvalidOperationException("目标数据库已存在,请使用新的名称或明确选择覆盖恢复。"); - } - if (exists) - { - await ExecuteNonQueryAsync(connection, $"DROP DATABASE {QuoteIdentifier(target)} WITH (FORCE)", cancellationToken); - } - await ExecuteNonQueryAsync(connection, $"CREATE DATABASE {QuoteIdentifier(target)} OWNER {QuoteIdentifier(_username)}", cancellationToken); - } - try - { - await RunPostgresToolAsync( - "pg_restore", - ["--no-owner", "--no-acl", "--exit-on-error", "--dbname", target, source], - cancellationToken); - } - catch - { - await AuditAsync("backup.restore.failed", target, new { fileName }, CancellationToken.None); - throw; - } - await AuditAsync("backup.restore", target, new { fileName, request.Overwrite }, cancellationToken); - } - - private async Task FindClientAsync(string appId, CancellationToken cancellationToken) - { - await using var connection = await OpenAsync(MetadataDatabase, cancellationToken); - await using var command = new NpgsqlCommand(""" - SELECT app_id, display_name, database_name, role_name, extensions, status, created_at, updated_at - FROM managed_clients WHERE app_id = @appId - """, connection); - command.Parameters.AddWithValue("appId", appId); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - return await reader.ReadAsync(cancellationToken) ? ReadClient(reader) : null; - } - - private async Task UpsertClientAsync( - string appId, - string displayName, - string databaseName, - string roleName, - string[] extensions, - CancellationToken cancellationToken) - { - await using var connection = await OpenAsync(MetadataDatabase, cancellationToken); - await using var command = new NpgsqlCommand(""" - INSERT INTO managed_clients (app_id, display_name, database_name, role_name, extensions, status) - VALUES (@appId, @displayName, @databaseName, @roleName, @extensions, 'active') - ON CONFLICT (app_id) DO UPDATE SET - display_name = EXCLUDED.display_name, - extensions = EXCLUDED.extensions, - status = 'active', - updated_at = now() - """, connection); - command.Parameters.AddWithValue("appId", appId); - command.Parameters.AddWithValue("displayName", displayName); - command.Parameters.AddWithValue("databaseName", databaseName); - command.Parameters.AddWithValue("roleName", roleName); - command.Parameters.AddWithValue("extensions", extensions); - await command.ExecuteNonQueryAsync(cancellationToken); - } - - private async Task IsManagedDatabaseAsync(string name, CancellationToken cancellationToken) - { - await using var connection = await OpenAsync(MetadataDatabase, cancellationToken); - await using var command = new NpgsqlCommand( - "SELECT EXISTS (SELECT 1 FROM managed_clients WHERE database_name = @name)", connection); - command.Parameters.AddWithValue("name", name); - return await command.ExecuteScalarAsync(cancellationToken) is true; - } - - private async Task IsManagedRoleAsync(string name, CancellationToken cancellationToken) - { - await using var connection = await OpenAsync(MetadataDatabase, cancellationToken); - await using var command = new NpgsqlCommand( - "SELECT EXISTS (SELECT 1 FROM managed_clients WHERE role_name = @name)", connection); - command.Parameters.AddWithValue("name", name); - return await command.ExecuteScalarAsync(cancellationToken) is true; - } - - private async Task EnsureDatabaseExistsAsync(string name, CancellationToken cancellationToken) - { - await using var connection = await OpenAsync("postgres", cancellationToken); - if (!await DatabaseExistsAsync(connection, name, cancellationToken)) - { - throw new KeyNotFoundException("数据库不存在。"); - } - } - - private async Task OpenAsync(string database, CancellationToken cancellationToken) - { - var builder = new NpgsqlConnectionStringBuilder - { - Host = _host, - Port = _port, - Username = _username, - Database = database, - Timeout = 10, - CommandTimeout = 30, - Pooling = true, - MaxPoolSize = 20, - ApplicationName = "nxsir-postgresql-admin" - }; - var connection = new NpgsqlConnection(builder.ConnectionString); - await connection.OpenAsync(cancellationToken); - return connection; - } - - private static async Task DatabaseExistsAsync( - NpgsqlConnection connection, - string name, - CancellationToken cancellationToken) - { - await using var command = new NpgsqlCommand("SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = @name)", connection); - command.Parameters.AddWithValue("name", name); - return await command.ExecuteScalarAsync(cancellationToken) is true; - } - - private static async Task RoleExistsAsync( - NpgsqlConnection connection, - string name, - CancellationToken cancellationToken) - { - await using var command = new NpgsqlCommand("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = @name)", connection); - command.Parameters.AddWithValue("name", name); - return await command.ExecuteScalarAsync(cancellationToken) is true; - } - - private static async Task ExecuteNonQueryAsync( - NpgsqlConnection connection, - string sql, - CancellationToken cancellationToken, - NpgsqlTransaction? transaction = null) - { - await using var command = new NpgsqlCommand(sql, connection, transaction); - await command.ExecuteNonQueryAsync(cancellationToken); - } - - private static async Task SetRolePasswordAsync( - NpgsqlConnection connection, - string roleName, - string password, - CancellationToken cancellationToken) - { - var passwordLiteral = await QuoteLiteralAsync(connection, password, cancellationToken); - await ExecuteNonQueryAsync( - connection, - $"ALTER ROLE {QuoteIdentifier(roleName)} LOGIN PASSWORD {passwordLiteral}", - cancellationToken); - } - - private static async Task QuoteLiteralAsync( - NpgsqlConnection connection, - string value, - CancellationToken cancellationToken) - { - await using var command = new NpgsqlCommand("SELECT quote_literal(@value)", connection); - command.Parameters.AddWithValue("value", value); - return (string)(await command.ExecuteScalarAsync(cancellationToken) - ?? throw new InvalidOperationException("无法安全处理角色密码。")); - } - - private async Task RunPostgresToolAsync( - string tool, - IReadOnlyList arguments, - CancellationToken cancellationToken) - { - var path = Path.Combine(_pgBin, tool); - if (!File.Exists(path)) - { - throw new FileNotFoundException($"缺少 PostgreSQL 工具:{tool}", path); - } - var startInfo = new ProcessStartInfo(path) - { - RedirectStandardError = true, - RedirectStandardOutput = true, - UseShellExecute = false - }; - startInfo.Environment["PGHOST"] = _host; - startInfo.Environment["PGPORT"] = _port.ToString(); - startInfo.Environment["PGUSER"] = _username; - foreach (var argument in arguments) - { - startInfo.ArgumentList.Add(argument); - } - using var process = Process.Start(startInfo) - ?? throw new InvalidOperationException($"无法启动 {tool}。"); - var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken); - var stderr = await stderrTask; - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"{tool} 执行失败:{stderr.Trim()}"); - } - } - - private async Task AuditAsync(string action, string target, object? detail, CancellationToken cancellationToken) - { - var entry = JsonSerializer.Serialize(new - { - timestamp = DateTimeOffset.UtcNow, - action, - target, - detail - }); - await _auditLock.WaitAsync(cancellationToken); - try - { - await File.AppendAllTextAsync(_auditPath, entry + "\n", cancellationToken); - } - finally - { - _auditLock.Release(); - } - } - - private static ManagedClient ReadClient(NpgsqlDataReader reader) => new( - reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), - reader.GetFieldValue(4), reader.GetString(5), - reader.GetFieldValue(6), reader.GetFieldValue(7)); - - private static string ValidateIdentifier(string value, string label) - { - var normalized = value.Trim().ToLowerInvariant(); - if (!IdentifierPattern.IsMatch(normalized)) - { - throw new ArgumentException($"{label}格式无效,只允许小写字母、数字和下划线,且必须以字母开头。"); - } - return normalized; - } - - private static string BuildResourceName(string prefix, string appId) - { - var slug = Regex.Replace(appId, "[^a-z0-9]+", "_").Trim('_'); - slug = slug.Length > 36 ? slug[..36] : slug; - var hash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(appId))) - .ToLowerInvariant()[..10]; - return $"{prefix}_{slug}_{hash}"; - } - - private static string QuoteIdentifier(string value) => $"\"{value.Replace("\"", "\"\"")}\""; - - private static object? NormalizeValue(object value) => value switch - { - string or bool or byte or short or int or long or float or double or decimal => value, - DateTime dateTime => dateTime, - DateTimeOffset dateTimeOffset => dateTimeOffset, - Guid guid => guid, - byte[] bytes => Convert.ToBase64String(bytes), - _ => value.ToString() - }; - - private static async Task ComputeSha256Async(string path, CancellationToken cancellationToken) - { - await using var stream = File.OpenRead(path); - var hash = await SHA256.HashDataAsync(stream, cancellationToken); - return Convert.ToHexString(hash).ToLowerInvariant(); - } -} diff --git a/src/PostgresService.WebApi/PostgresService.WebApi.csproj b/src/PostgresService.WebApi/PostgresService.WebApi.csproj deleted file mode 100644 index 73b327d..0000000 --- a/src/PostgresService.WebApi/PostgresService.WebApi.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - net8.0 - enable - enable - - - - - - diff --git a/src/PostgresService.WebApi/Program.cs b/src/PostgresService.WebApi/Program.cs deleted file mode 100644 index 462cd14..0000000 --- a/src/PostgresService.WebApi/Program.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System.Net; -using System.Text.Json; -using PostgresService.WebApi; - -var builder = WebApplication.CreateBuilder(args); -builder.Services.ConfigureHttpJsonOptions(options => -{ - options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; -}); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -var app = builder.Build(); -var secretStore = app.Services.GetRequiredService(); -var sessionStore = app.Services.GetRequiredService(); -var postgres = app.Services.GetRequiredService(); - -secretStore.EnsureInitialized(); -await postgres.InitializeAsync(CancellationToken.None); - -app.Use(async (context, next) => -{ - try - { - await next(); - } - catch (Exception exception) - { - app.Logger.LogError(exception, "PostgreSQL 管理请求失败:{Method} {Path}", context.Request.Method, context.Request.Path); - if (context.Response.HasStarted) - { - throw; - } - - var status = exception switch - { - ArgumentException => StatusCodes.Status400BadRequest, - FileNotFoundException or KeyNotFoundException => StatusCodes.Status404NotFound, - UnauthorizedAccessException => StatusCodes.Status401Unauthorized, - InvalidOperationException => StatusCodes.Status409Conflict, - OperationCanceledException when context.RequestAborted.IsCancellationRequested => 499, - _ => StatusCodes.Status500InternalServerError - }; - context.Response.StatusCode = status; - context.Response.ContentType = "application/json; charset=utf-8"; - await context.Response.WriteAsJsonAsync(new - { - error = status == 500 ? "数据库管理服务发生内部错误。" : exception.Message - }); - } -}); - -app.Use(async (context, next) => -{ - var path = context.Request.Path; - if (path.StartsWithSegments("/api/v1") && path != "/api/v1/auth/login") - { - context.Request.Cookies.TryGetValue("pg_admin_session", out var token); - if (!sessionStore.Validate(token)) - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - await context.Response.WriteAsJsonAsync(new { error = "管理会话无效或已过期。" }); - return; - } - - if (!HttpMethods.IsGet(context.Request.Method) && - context.Request.Headers.TryGetValue("Origin", out var origin) && - Uri.TryCreate(origin.ToString(), UriKind.Absolute, out var originUri) && - !string.Equals(originUri.Authority, context.Request.Host.Value, StringComparison.OrdinalIgnoreCase)) - { - context.Response.StatusCode = StatusCodes.Status403Forbidden; - await context.Response.WriteAsJsonAsync(new { error = "跨来源管理请求已被拒绝。" }); - return; - } - } - await next(); -}); - -app.UseDefaultFiles(); -app.UseStaticFiles(); - -app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow })); -app.MapGet("/health/ready", async (CancellationToken cancellationToken) => -{ - await postgres.GetOverviewAsync(cancellationToken); - return Results.Ok(new { status = "ready", timestamp = DateTimeOffset.UtcNow }); -}); - -app.MapPost("/internal/v1/enroll", async ( - HttpContext context, - EnrollRequest request, - CancellationToken cancellationToken) => -{ - if (context.Connection.RemoteIpAddress is null || !IPAddress.IsLoopback(context.Connection.RemoteIpAddress)) - { - return Results.StatusCode(StatusCodes.Status403Forbidden); - } - - var authorization = context.Request.Headers.Authorization.ToString(); - const string prefix = "Bearer "; - if (!authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || - !secretStore.VerifyEnrollmentToken(authorization[prefix.Length..])) - { - return Results.Unauthorized(); - } - - var credential = await postgres.EnrollAsync(request, cancellationToken); - return Results.Ok(credential); -}); - -app.MapPost("/api/v1/auth/login", (HttpContext context, LoginRequest request) => -{ - if (!string.Equals(request.Username, "admin", StringComparison.OrdinalIgnoreCase) || - !secretStore.VerifyAdminPassword(request.Password)) - { - return Results.Json(new { error = "用户名或密码错误。" }, statusCode: StatusCodes.Status401Unauthorized); - } - - var token = sessionStore.Create(); - context.Response.Cookies.Append("pg_admin_session", token, new CookieOptions - { - HttpOnly = true, - SameSite = SameSiteMode.Strict, - Secure = context.Request.IsHttps, - MaxAge = TimeSpan.FromHours(12), - Path = "/" - }); - return Results.Ok(new { username = "admin" }); -}); - -app.MapPost("/api/v1/auth/logout", (HttpContext context) => -{ - context.Request.Cookies.TryGetValue("pg_admin_session", out var token); - sessionStore.Revoke(token); - context.Response.Cookies.Delete("pg_admin_session", new CookieOptions { Path = "/" }); - return Results.NoContent(); -}); - -app.MapGet("/api/v1/overview", postgres.GetOverviewAsync); -app.MapGet("/api/v1/clients", postgres.ListClientsAsync); -app.MapPost("/api/v1/clients/{appId}/rotate", async (string appId, CancellationToken cancellationToken) => - Results.Ok(new { password = await postgres.RotateClientPasswordAsync(appId, cancellationToken) })); -app.MapPost("/api/v1/clients/{appId}/revoke", async (string appId, CancellationToken cancellationToken) => -{ - await postgres.RevokeClientAsync(appId, cancellationToken); - return Results.NoContent(); -}); -app.MapPost("/api/v1/enrollment-token/rotate", () => - Results.Ok(new { token = secretStore.RotateEnrollmentToken() })); - -app.MapGet("/api/v1/databases", postgres.ListDatabasesAsync); -app.MapPost("/api/v1/databases", async (CreateDatabaseRequest request, CancellationToken cancellationToken) => -{ - await postgres.CreateDatabaseAsync(request, cancellationToken); - return Results.NoContent(); -}); -app.MapDelete("/api/v1/databases/{name}", async ( - string name, string confirmation, CancellationToken cancellationToken) => -{ - await postgres.DropDatabaseAsync(name, confirmation, cancellationToken); - return Results.NoContent(); -}); - -app.MapGet("/api/v1/roles", postgres.ListRolesAsync); -app.MapPost("/api/v1/roles", async (CreateRoleRequest request, CancellationToken cancellationToken) => -{ - await postgres.CreateRoleAsync(request, cancellationToken); - return Results.NoContent(); -}); -app.MapDelete("/api/v1/roles/{name}", async ( - string name, string confirmation, CancellationToken cancellationToken) => -{ - await postgres.DropRoleAsync(name, confirmation, cancellationToken); - return Results.NoContent(); -}); - -app.MapGet("/api/v1/sessions", postgres.ListSessionsAsync); -app.MapPost("/api/v1/sessions/{processId:int}/terminate", async ( - int processId, CancellationToken cancellationToken) => -{ - await postgres.TerminateSessionAsync(processId, cancellationToken); - return Results.NoContent(); -}); -app.MapPost("/api/v1/query", postgres.ExecuteReadOnlyQueryAsync); - -app.MapGet("/api/v1/backups", postgres.ListBackupsAsync); -app.MapPost("/api/v1/backups", async (BackupRequest request, CancellationToken cancellationToken) => - Results.Ok(await postgres.CreateBackupAsync(request.Database, cancellationToken))); -app.MapPost("/api/v1/backups/restore", async (RestoreRequest request, CancellationToken cancellationToken) => -{ - await postgres.RestoreBackupAsync(request, cancellationToken); - return Results.NoContent(); -}); - -app.MapFallbackToFile("index.html"); -app.Run(); - -public partial class Program; diff --git a/src/PostgresService.WebApi/Security.cs b/src/PostgresService.WebApi/Security.cs deleted file mode 100644 index c660bd1..0000000 --- a/src/PostgresService.WebApi/Security.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.Collections.Concurrent; -using System.Security.Cryptography; -using System.Text; - -namespace PostgresService.WebApi; - -public sealed class SecretStore -{ - private const int Iterations = 210_000; - private readonly string _dataRoot; - private readonly object _sync = new(); - - public SecretStore(IConfiguration configuration) - { - _dataRoot = configuration["POSTGRES_SERVICE_DATA_ROOT"] - ?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_DATA_ROOT") - ?? Path.Combine(AppContext.BaseDirectory, "data"); - Directory.CreateDirectory(_dataRoot); - } - - public bool VerifyAdminPassword(string value) => Verify("admin-password", value); - public bool VerifyEnrollmentToken(string value) => Verify("enrollment-token", value); - - public string RotateEnrollmentToken() - { - var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); - WriteHash("enrollment-token", token); - return token; - } - - public void EnsureInitialized() - { - lock (_sync) - { - PromoteSeed("admin-password"); - PromoteSeed("enrollment-token"); - } - } - - private void PromoteSeed(string name) - { - var hashPath = Path.Combine(_dataRoot, $"{name}.hash"); - if (File.Exists(hashPath)) - { - return; - } - - var seedPath = Path.Combine(_dataRoot, $"{name}.seed"); - if (!File.Exists(seedPath)) - { - throw new InvalidOperationException($"缺少 {name} 初始化文件。"); - } - - var seed = File.ReadAllText(seedPath).TrimEnd('\r', '\n'); - if (string.IsNullOrWhiteSpace(seed)) - { - throw new InvalidOperationException($"{name} 不能为空。"); - } - - WriteHash(name, seed); - File.Delete(seedPath); - } - - private bool Verify(string name, string value) - { - if (string.IsNullOrEmpty(value)) - { - return false; - } - - var path = Path.Combine(_dataRoot, $"{name}.hash"); - if (!File.Exists(path)) - { - return false; - } - - var parts = File.ReadAllText(path).Trim().Split('$'); - if (parts.Length != 4 || parts[0] != "pbkdf2-sha256" || !int.TryParse(parts[1], out var iterations)) - { - return false; - } - - try - { - var salt = Convert.FromBase64String(parts[2]); - var expected = Convert.FromBase64String(parts[3]); - var actual = Rfc2898DeriveBytes.Pbkdf2( - Encoding.UTF8.GetBytes(value), salt, iterations, HashAlgorithmName.SHA256, expected.Length); - return CryptographicOperations.FixedTimeEquals(actual, expected); - } - catch (FormatException) - { - return false; - } - } - - private void WriteHash(string name, string value) - { - var salt = RandomNumberGenerator.GetBytes(16); - var hash = Rfc2898DeriveBytes.Pbkdf2( - Encoding.UTF8.GetBytes(value), salt, Iterations, HashAlgorithmName.SHA256, 32); - var content = $"pbkdf2-sha256${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}\n"; - var destination = Path.Combine(_dataRoot, $"{name}.hash"); - var temporary = destination + ".tmp"; - File.WriteAllText(temporary, content, new UTF8Encoding(false)); - File.Move(temporary, destination, true); - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(destination, UnixFileMode.UserRead | UnixFileMode.UserWrite); - } - } -} - -public sealed class AdminSessionStore -{ - private static readonly TimeSpan Lifetime = TimeSpan.FromHours(12); - private readonly ConcurrentDictionary _sessions = new(); - - public string Create() - { - RemoveExpired(); - var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); - _sessions[token] = DateTimeOffset.UtcNow.Add(Lifetime); - return token; - } - - public bool Validate(string? token) - { - if (string.IsNullOrWhiteSpace(token) || !_sessions.TryGetValue(token, out var expiresAt)) - { - return false; - } - - if (expiresAt <= DateTimeOffset.UtcNow) - { - _sessions.TryRemove(token, out _); - return false; - } - - _sessions[token] = DateTimeOffset.UtcNow.Add(Lifetime); - return true; - } - - public void Revoke(string? token) - { - if (!string.IsNullOrWhiteSpace(token)) - { - _sessions.TryRemove(token, out _); - } - } - - private void RemoveExpired() - { - var now = DateTimeOffset.UtcNow; - foreach (var entry in _sessions.Where(item => item.Value <= now)) - { - _sessions.TryRemove(entry.Key, out _); - } - } -} diff --git a/tests/LiveRecorder.Tests/LiveRecorder.Tests.csproj b/tests/LiveRecorder.Tests/LiveRecorder.Tests.csproj index fa5084d..8c1ba9d 100644 --- a/tests/LiveRecorder.Tests/LiveRecorder.Tests.csproj +++ b/tests/LiveRecorder.Tests/LiveRecorder.Tests.csproj @@ -22,7 +22,6 @@ - diff --git a/tests/LiveRecorder.Tests/PostgresServiceSecurityTests.cs b/tests/LiveRecorder.Tests/PostgresServiceSecurityTests.cs deleted file mode 100644 index 587a316..0000000 --- a/tests/LiveRecorder.Tests/PostgresServiceSecurityTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Microsoft.Extensions.Configuration; -using PostgresService.WebApi; - -namespace LiveRecorder.Tests; - -public sealed class PostgresServiceSecurityTests : IDisposable -{ - private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), $"postgres-service-tests-{Guid.NewGuid():N}"); - - [Fact] - public void SecretStore_PromotesSeedsAndUsesIndependentHashes() - { - Directory.CreateDirectory(_dataRoot); - File.WriteAllText(Path.Combine(_dataRoot, "admin-password.seed"), "Admin-Password-For-Tests!\n"); - File.WriteAllText(Path.Combine(_dataRoot, "enrollment-token.seed"), "Enrollment-Token-For-Tests-2026!\n"); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["POSTGRES_SERVICE_DATA_ROOT"] = _dataRoot - }) - .Build(); - - var store = new SecretStore(configuration); - store.EnsureInitialized(); - - Assert.True(store.VerifyAdminPassword("Admin-Password-For-Tests!")); - Assert.False(store.VerifyAdminPassword("Enrollment-Token-For-Tests-2026!")); - Assert.True(store.VerifyEnrollmentToken("Enrollment-Token-For-Tests-2026!")); - Assert.False(File.Exists(Path.Combine(_dataRoot, "admin-password.seed"))); - Assert.False(File.Exists(Path.Combine(_dataRoot, "enrollment-token.seed"))); - Assert.StartsWith("pbkdf2-sha256$", File.ReadAllText(Path.Combine(_dataRoot, "admin-password.hash"))); - } - - [Fact] - public void SecretStore_RotatedEnrollmentTokenInvalidatesOldToken() - { - Directory.CreateDirectory(_dataRoot); - File.WriteAllText(Path.Combine(_dataRoot, "admin-password.seed"), "Admin-Password-For-Tests!\n"); - File.WriteAllText(Path.Combine(_dataRoot, "enrollment-token.seed"), "Enrollment-Token-For-Tests-2026!\n"); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["POSTGRES_SERVICE_DATA_ROOT"] = _dataRoot - }) - .Build(); - var store = new SecretStore(configuration); - store.EnsureInitialized(); - - var replacement = store.RotateEnrollmentToken(); - - Assert.Equal(64, replacement.Length); - Assert.True(store.VerifyEnrollmentToken(replacement)); - Assert.False(store.VerifyEnrollmentToken("Enrollment-Token-For-Tests-2026!")); - } - - [Fact] - public void AdminSessionStore_CreatesValidAndRevocableSession() - { - var sessions = new AdminSessionStore(); - var token = sessions.Create(); - - Assert.True(sessions.Validate(token)); - sessions.Revoke(token); - Assert.False(sessions.Validate(token)); - Assert.False(sessions.Validate("unknown")); - } - - public void Dispose() - { - if (Directory.Exists(_dataRoot)) - { - Directory.Delete(_dataRoot, recursive: true); - } - } -}