refactor: move PostgreSQL shared service to dedicated repository
This commit is contained in:
@@ -255,16 +255,15 @@ docker compose up -d
|
|||||||
### fnOS 原生 FPK
|
### fnOS 原生 FPK
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/build-postgresql-fnos-package.sh
|
|
||||||
./scripts/build-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 \
|
./scripts/smoke-fnos-package.sh \
|
||||||
artifacts/fnos/liverecorder-1.2.13-x86_64.fpk \
|
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 共享服务在独立仓库构建和发布:<https://gitea.nxsir.cn/nanxun/postgresqlfpk>
|
||||||
- 使用 fnOS 开发者平台提供的官方 `fnpack` 构建;可通过 `FNPACK=/path/to/fnpack` 指定工具路径
|
- 使用 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` 检查并启用依赖
|
- Live Recorder 直接依赖 `nxsir.postgresql` 和商店版 `nodejs_v22`;fnOS 会通过 `install_dep_apps` 检查并启用依赖
|
||||||
- PostgreSQL 共享服务只监听 `127.0.0.1:15432`,独立管理界面默认端口为 `15433`
|
- PostgreSQL 共享服务只监听 `127.0.0.1:15432`,独立管理界面默认端口为 `15433`
|
||||||
- 每个应用经回环接入 API 获得独立数据库、独立 SCRAM 角色和随机密码;支持 pgvector
|
- 每个应用经回环接入 API 获得独立数据库、独立 SCRAM 角色和随机密码;支持 pgvector
|
||||||
@@ -279,7 +278,7 @@ docker compose up -d
|
|||||||
- 录制文件默认保存在 fnOS 共享目录 `liverecorder/records`;可在“设置 → 录制 → 输出根目录”修改
|
- 录制文件默认保存在 fnOS 共享目录 `liverecorder/records`;可在“设置 → 录制 → 输出根目录”修改
|
||||||
|
|
||||||
更完整的安装、端口与凭据说明见 [docs/postgresql-migration.md](docs/postgresql-migration.md)。
|
更完整的安装、端口与凭据说明见 [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)。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
|
|||||||
@@ -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=<database>;Username=<username>;Password=<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/<database>?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 能在自己的数据库执行。
|
|
||||||
- 已验证无法连接另一个测试应用的数据库。
|
|
||||||
- 共享服务重启后应用能用原凭据恢复。
|
|
||||||
- 已准备凭据丢失、密码轮换和迁移失败的明确恢复流程。
|
|
||||||
@@ -31,9 +31,12 @@ Live Recorder 1.2.5 起只使用共享 PostgreSQL,不再打包、启动或回
|
|||||||
|
|
||||||
### fnOS 验证
|
### fnOS 验证
|
||||||
|
|
||||||
|
PostgreSQL 共享服务由独立仓库构建和发布:<https://gitea.nxsir.cn/nanxun/postgresqlfpk>。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk
|
./scripts/smoke-fnos-package.sh \
|
||||||
./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.2.9-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk
|
artifacts/fnos/liverecorder-1.2.13-x86_64.fpk \
|
||||||
|
/path/to/nxsir-postgresql-15.1.1-x86_64.fpk
|
||||||
```
|
```
|
||||||
|
|
||||||
下面保留 Docker/宿主机从旧 SQLite 导入 PostgreSQL 的流程。
|
下面保留 Docker/宿主机从旧 SQLite 导入 PostgreSQL 的流程。
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
".url": {
|
|
||||||
"nxsir.postgresql.Application": {
|
|
||||||
"title": "PostgreSQL 共享服务",
|
|
||||||
"icon": "images/icon_{0}.png",
|
|
||||||
"type": "url",
|
|
||||||
"protocol": "",
|
|
||||||
"port": "15433",
|
|
||||||
"url": "/",
|
|
||||||
"allUsers": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
exit 0
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
exit 0
|
|
||||||
@@ -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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
exit 0
|
|
||||||
@@ -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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# 数据目录与手动备份默认保留,避免误删多个应用的共享数据。
|
|
||||||
exit 0
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -eu
|
|
||||||
"$(dirname -- "$0")/main" stop || true
|
|
||||||
exit 0
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
exit 0
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
# 当前服务固定在 PostgreSQL 15 主版本;数据目录与管理凭据位于
|
|
||||||
# TRIM_PKGVAR,fnOS 替换不可变应用文件时无需复制。
|
|
||||||
exit 0
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"defaults": {
|
|
||||||
"run-as": "package"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"data-share": {
|
|
||||||
"shares": [
|
|
||||||
{
|
|
||||||
"name": "postgresql",
|
|
||||||
"permission": {
|
|
||||||
"rw": ["nxsir.postgresql"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "postgresql/backups",
|
|
||||||
"permission": {
|
|
||||||
"rw": ["nxsir.postgresql"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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": "请再次输入接入令牌" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"stepTitle": "升级 PostgreSQL 共享服务",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "tips",
|
|
||||||
"helpText": "升级期间所有依赖应用会暂时失去数据库连接。当前版本仅执行 PostgreSQL 15 同主版本升级,并保留数据、凭据、审计记录和备份。"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"build:postgres-admin": "vite build --config vite.postgres.config.ts",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
|
||||||
|
|
||||||
type Section = "overview" | "clients" | "databases" | "roles" | "sessions" | "sql" | "backups";
|
|
||||||
type JsonObject = Record<string, unknown>;
|
|
||||||
|
|
||||||
interface Client {
|
|
||||||
appId: string;
|
|
||||||
displayName: string;
|
|
||||||
databaseName: string;
|
|
||||||
roleName: string;
|
|
||||||
extensions: string[];
|
|
||||||
status: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Database {
|
|
||||||
name: string;
|
|
||||||
owner: string;
|
|
||||||
sizeBytes: number;
|
|
||||||
activeConnections: number;
|
|
||||||
isManaged: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Role {
|
|
||||||
name: string;
|
|
||||||
canLogin: boolean;
|
|
||||||
connectionLimit: number;
|
|
||||||
isManaged: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
processId: number;
|
|
||||||
database: string;
|
|
||||||
username: string;
|
|
||||||
state: string;
|
|
||||||
query?: string;
|
|
||||||
queryStartedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Backup {
|
|
||||||
fileName: string;
|
|
||||||
database: string;
|
|
||||||
sizeBytes: number;
|
|
||||||
createdAt: string;
|
|
||||||
sha256: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authenticated = ref<boolean | null>(null);
|
|
||||||
const loading = ref(false);
|
|
||||||
const active = ref<Section>("overview");
|
|
||||||
const dark = ref(localStorage.getItem("postgres-admin-theme") === "dark");
|
|
||||||
const loginForm = reactive({ username: "admin", password: "" });
|
|
||||||
const overview = ref<JsonObject>({});
|
|
||||||
const clients = ref<Client[]>([]);
|
|
||||||
const databases = ref<Database[]>([]);
|
|
||||||
const roles = ref<Role[]>([]);
|
|
||||||
const sessions = ref<Session[]>([]);
|
|
||||||
const backups = ref<Backup[]>([]);
|
|
||||||
const sql = ref("SELECT current_database(), current_user, now();");
|
|
||||||
const sqlDatabase = ref("postgres");
|
|
||||||
const queryResult = ref<{ columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean; elapsedMilliseconds: number } | null>(null);
|
|
||||||
|
|
||||||
const sections: Array<{ key: Section; label: string }> = [
|
|
||||||
{ key: "overview", label: "运行概览" },
|
|
||||||
{ key: "clients", label: "接入应用" },
|
|
||||||
{ key: "databases", label: "数据库" },
|
|
||||||
{ key: "roles", label: "角色" },
|
|
||||||
{ key: "sessions", label: "活动会话" },
|
|
||||||
{ key: "sql", label: "只读 SQL" },
|
|
||||||
{ key: "backups", label: "备份恢复" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeLabel = computed(() => sections.find(item => item.key === active.value)?.label ?? "管理面板");
|
|
||||||
|
|
||||||
function applyTheme() {
|
|
||||||
document.documentElement.dataset.theme = dark.value ? "dark" : "light";
|
|
||||||
localStorage.setItem("postgres-admin-theme", dark.value ? "dark" : "light");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
||||||
const response = await fetch(path, {
|
|
||||||
credentials: "same-origin",
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(options.headers || {})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (response.status === 401) {
|
|
||||||
authenticated.value = false;
|
|
||||||
throw new Error("管理会话已过期,请重新登录。");
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.json().catch(() => ({ error: `请求失败(${response.status})` })) as { error?: string };
|
|
||||||
throw new Error(body.error || `请求失败(${response.status})`);
|
|
||||||
}
|
|
||||||
return response.status === 204 ? undefined as T : await response.json() as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
await api("/api/v1/auth/login", { method: "POST", body: JSON.stringify(loginForm) });
|
|
||||||
authenticated.value = true;
|
|
||||||
loginForm.password = "";
|
|
||||||
await loadAll();
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error((error as Error).message);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await api("/api/v1/auth/logout", { method: "POST" }).catch(() => undefined);
|
|
||||||
authenticated.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAll() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const results = await Promise.all([
|
|
||||||
api<JsonObject>("/api/v1/overview"),
|
|
||||||
api<Client[]>("/api/v1/clients"),
|
|
||||||
api<Database[]>("/api/v1/databases"),
|
|
||||||
api<Role[]>("/api/v1/roles"),
|
|
||||||
api<Session[]>("/api/v1/sessions"),
|
|
||||||
api<Backup[]>("/api/v1/backups")
|
|
||||||
]);
|
|
||||||
[overview.value, clients.value, databases.value, roles.value, sessions.value, backups.value] = results;
|
|
||||||
authenticated.value = true;
|
|
||||||
if (!databases.value.some(item => item.name === sqlDatabase.value)) {
|
|
||||||
sqlDatabase.value = databases.value[0]?.name || "postgres";
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (authenticated.value !== false) ElMessage.error((error as Error).message);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rotateEnrollmentToken() {
|
|
||||||
const result = await api<{ token: string }>("/api/v1/enrollment-token/rotate", { method: "POST" });
|
|
||||||
await ElMessageBox.alert(result.token, "新的接入令牌(仅显示一次)", {
|
|
||||||
confirmButtonText: "我已保存",
|
|
||||||
customClass: "secret-dialog"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rotateClient(client: Client) {
|
|
||||||
await ElMessageBox.confirm(`轮换 ${client.displayName} 的数据库密码后,客户端必须立即更新凭据。`, "轮换密码", { type: "warning" });
|
|
||||||
const result = await api<{ password: string }>(`/api/v1/clients/${encodeURIComponent(client.appId)}/rotate`, { method: "POST" });
|
|
||||||
await ElMessageBox.alert(result.password, "新密码(仅显示一次)", { confirmButtonText: "我已保存" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeClient(client: Client) {
|
|
||||||
await ElMessageBox.confirm(`吊销后 ${client.displayName} 将无法连接数据库,但不会删除数据。`, "吊销客户端", { type: "warning" });
|
|
||||||
await api(`/api/v1/clients/${encodeURIComponent(client.appId)}/revoke`, { method: "POST" });
|
|
||||||
await loadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createDatabase() {
|
|
||||||
const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建数据库", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" });
|
|
||||||
await api("/api/v1/databases", { method: "POST", body: JSON.stringify({ name }) });
|
|
||||||
await loadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dropDatabase(database: Database) {
|
|
||||||
const { value } = await ElMessageBox.prompt(`请输入 ${database.name} 确认删除。`, "删除数据库", { type: "warning" });
|
|
||||||
await api(`/api/v1/databases/${encodeURIComponent(database.name)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" });
|
|
||||||
await loadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createRole() {
|
|
||||||
const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建登录角色", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "角色名称格式无效" });
|
|
||||||
const { value: password } = await ElMessageBox.prompt("密码至少 16 个字符。", "设置角色密码", { inputType: "password", inputValidator: value => value.length >= 16 || "密码至少需要 16 个字符" });
|
|
||||||
await api("/api/v1/roles", { method: "POST", body: JSON.stringify({ name, password }) });
|
|
||||||
await loadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dropRole(role: Role) {
|
|
||||||
const { value } = await ElMessageBox.prompt(`请输入 ${role.name} 确认删除。`, "删除角色", { type: "warning" });
|
|
||||||
await api(`/api/v1/roles/${encodeURIComponent(role.name)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" });
|
|
||||||
await loadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function terminateSession(session: Session) {
|
|
||||||
await ElMessageBox.confirm(`确认终止 PID ${session.processId} 的连接?`, "终止会话", { type: "warning" });
|
|
||||||
await api(`/api/v1/sessions/${session.processId}/terminate`, { method: "POST" });
|
|
||||||
await loadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runQuery() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
queryResult.value = await api("/api/v1/query", { method: "POST", body: JSON.stringify({ database: sqlDatabase.value, sql: sql.value }) });
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error((error as Error).message);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createBackup(database: string) {
|
|
||||||
await api("/api/v1/backups", { method: "POST", body: JSON.stringify({ database }) });
|
|
||||||
ElMessage.success("备份已完成");
|
|
||||||
await loadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreBackup(backup: Backup) {
|
|
||||||
const { value: targetDatabase } = await ElMessageBox.prompt("建议恢复到新的数据库名称。", "恢复备份", { inputValue: `${backup.database}_restored`, inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" });
|
|
||||||
const exists = databases.value.some(item => item.name === targetDatabase);
|
|
||||||
const { value: confirmation } = await ElMessageBox.prompt(`请输入 ${targetDatabase} 确认恢复${exists ? "并覆盖现有数据库" : ""}。`, "确认恢复", { type: "warning" });
|
|
||||||
await api("/api/v1/backups/restore", { method: "POST", body: JSON.stringify({ backupFileName: backup.fileName, targetDatabase, overwrite: exists, confirmation }) });
|
|
||||||
ElMessage.success("备份已恢复");
|
|
||||||
await loadAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(value: unknown) {
|
|
||||||
const bytes = Number(value || 0);
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
||||||
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
applyTheme();
|
|
||||||
await loadAll();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main v-if="authenticated === false" class="login-screen">
|
|
||||||
<section class="login-card">
|
|
||||||
<div class="brand-mark">PG</div>
|
|
||||||
<h1>PostgreSQL 服务</h1>
|
|
||||||
<p>使用安装时设置的独立管理密码登录。</p>
|
|
||||||
<el-form label-position="top" @submit.prevent="login">
|
|
||||||
<el-form-item label="用户名"><el-input v-model="loginForm.username" autocomplete="username" /></el-form-item>
|
|
||||||
<el-form-item label="密码"><el-input v-model="loginForm.password" type="password" show-password autocomplete="current-password" @keyup.enter="login" /></el-form-item>
|
|
||||||
<el-button type="primary" :loading="loading" class="full" @click="login">登录</el-button>
|
|
||||||
</el-form>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div v-else class="shell" v-loading="loading && authenticated === null">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="brand"><span class="brand-mark">PG</span><div><strong>PostgreSQL</strong><small>共享数据库服务</small></div></div>
|
|
||||||
<nav>
|
|
||||||
<button v-for="item in sections" :key="item.key" :class="{ active: active === item.key }" @click="active = item.key">{{ item.label }}</button>
|
|
||||||
</nav>
|
|
||||||
<div class="sidebar-foot"><span class="health-dot" />仅回环数据库端口</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section class="content">
|
|
||||||
<header class="topbar">
|
|
||||||
<div><h1>{{ activeLabel }}</h1><small>127.0.0.1:{{ overview.port || 15432 }}</small></div>
|
|
||||||
<div class="top-actions">
|
|
||||||
<el-button size="small" @click="dark = !dark; applyTheme()">{{ dark ? "浅色" : "深色" }}</el-button>
|
|
||||||
<el-button size="small" @click="loadAll">刷新</el-button>
|
|
||||||
<el-button size="small" @click="logout">退出</el-button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="mobile-section"><el-select v-model="active"><el-option v-for="item in sections" :key="item.key" :label="item.label" :value="item.key" /></el-select></div>
|
|
||||||
|
|
||||||
<section v-if="active === 'overview'" class="page-stack">
|
|
||||||
<div class="metric-grid">
|
|
||||||
<article><span>PostgreSQL 版本</span><strong>{{ overview.version || "-" }}</strong></article>
|
|
||||||
<article><span>活动连接</span><strong>{{ overview.connections || 0 }} / {{ overview.maxConnections || 0 }}</strong></article>
|
|
||||||
<article><span>数据库</span><strong>{{ overview.databases || 0 }}</strong></article>
|
|
||||||
<article><span>数据总量</span><strong>{{ formatBytes(overview.sizeBytes) }}</strong></article>
|
|
||||||
</div>
|
|
||||||
<section class="panel"><div class="panel-head"><div><h2>安全接入</h2><p>接入令牌只用于本机客户端登记,轮换后旧令牌立即失效。</p></div><el-button type="warning" plain @click="rotateEnrollmentToken">轮换接入令牌</el-button></div></section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="active === 'clients'" class="panel">
|
|
||||||
<div class="panel-head"><div><h2>接入应用</h2><p>每个应用使用独立数据库和 SCRAM 角色。</p></div></div>
|
|
||||||
<el-table :data="clients" table-layout="auto">
|
|
||||||
<el-table-column prop="displayName" label="应用" min-width="160"><template #default="{ row }"><strong>{{ row.displayName }}</strong><small class="block">{{ row.appId }}</small></template></el-table-column>
|
|
||||||
<el-table-column prop="databaseName" label="数据库" min-width="190" />
|
|
||||||
<el-table-column prop="roleName" label="角色" min-width="190" />
|
|
||||||
<el-table-column label="扩展"><template #default="{ row }">{{ row.extensions.join(", ") || "-" }}</template></el-table-column>
|
|
||||||
<el-table-column label="状态"><template #default="{ row }"><el-tag :type="row.status === 'active' ? 'success' : 'danger'">{{ row.status === "active" ? "正常" : "已吊销" }}</el-tag></template></el-table-column>
|
|
||||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button size="small" @click="rotateClient(row)">轮换密码</el-button><el-button size="small" type="danger" plain @click="revokeClient(row)">吊销</el-button></template></el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="active === 'databases'" class="panel">
|
|
||||||
<div class="panel-head"><div><h2>数据库</h2><p>托管数据库必须先吊销客户端,不能直接删除。</p></div><el-button type="primary" @click="createDatabase">新建数据库</el-button></div>
|
|
||||||
<el-table :data="databases"><el-table-column prop="name" label="名称" min-width="180" /><el-table-column prop="owner" label="所有者" min-width="160" /><el-table-column label="容量"><template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template></el-table-column><el-table-column prop="activeConnections" label="连接" /><el-table-column label="类型"><template #default="{ row }"><el-tag v-if="row.isManaged">应用托管</el-tag><span v-else>普通</span></template></el-table-column><el-table-column label="操作" width="170"><template #default="{ row }"><el-button size="small" @click="createBackup(row.name)">备份</el-button><el-button size="small" type="danger" plain :disabled="row.isManaged || ['postgres','template0','template1','postgres_service'].includes(row.name)" @click="dropDatabase(row)">删除</el-button></template></el-table-column></el-table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="active === 'roles'" class="panel">
|
|
||||||
<div class="panel-head"><div><h2>登录角色</h2><p>新角色默认无超级用户、建库和建角色权限。</p></div><el-button type="primary" @click="createRole">新建角色</el-button></div>
|
|
||||||
<el-table :data="roles"><el-table-column prop="name" label="名称" min-width="200" /><el-table-column label="可登录"><template #default="{ row }">{{ row.canLogin ? "是" : "否" }}</template></el-table-column><el-table-column prop="connectionLimit" label="连接限制" /><el-table-column label="类型"><template #default="{ row }"><el-tag v-if="row.isManaged">应用托管</el-tag><span v-else>普通</span></template></el-table-column><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" type="danger" plain :disabled="row.isManaged" @click="dropRole(row)">删除</el-button></template></el-table-column></el-table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="active === 'sessions'" class="panel">
|
|
||||||
<div class="panel-head"><div><h2>活动会话</h2><p>终止连接可能中断应用事务,请谨慎操作。</p></div></div>
|
|
||||||
<el-table :data="sessions" table-layout="auto"><el-table-column prop="processId" label="PID" width="90" /><el-table-column prop="database" label="数据库" min-width="140" /><el-table-column prop="username" label="用户" min-width="150" /><el-table-column prop="state" label="状态" width="100" /><el-table-column prop="query" label="当前语句" min-width="300" show-overflow-tooltip /><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" type="danger" plain @click="terminateSession(row)">终止</el-button></template></el-table-column></el-table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="active === 'sql'" class="page-stack">
|
|
||||||
<section class="panel"><div class="panel-head"><div><h2>只读 SQL 工作台</h2><p>强制只读事务、30 秒超时、单语句和最多 1000 行结果。</p></div><el-select v-model="sqlDatabase" class="database-select"><el-option v-for="item in databases" :key="item.name" :label="item.name" :value="item.name" /></el-select></div><el-input v-model="sql" type="textarea" :rows="8" class="sql-editor" spellcheck="false" /><div class="query-actions"><el-button type="primary" :loading="loading" @click="runQuery">执行查询</el-button></div></section>
|
|
||||||
<section v-if="queryResult" class="panel"><div class="panel-head"><div><h2>查询结果</h2><p>{{ queryResult.rowCount }} 行 · {{ queryResult.elapsedMilliseconds }} ms<span v-if="queryResult.truncated"> · 已截断</span></p></div></div><div class="table-scroll"><el-table :data="queryResult.rows"><el-table-column v-for="(column, index) in queryResult.columns" :key="column + index" :label="column" min-width="140"><template #default="{ row }">{{ row[index] }}</template></el-table-column></el-table></div></section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else class="panel">
|
|
||||||
<div class="panel-head"><div><h2>手动备份与恢复</h2><p>默认不执行定时备份;恢复到已有数据库需要二次确认并会中断连接。</p></div></div>
|
|
||||||
<el-table :data="backups"><el-table-column prop="database" label="来源数据库" min-width="160" /><el-table-column prop="fileName" label="文件" min-width="260" /><el-table-column label="大小"><template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template></el-table-column><el-table-column label="创建时间" min-width="170"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="sha256" label="SHA-256" min-width="220" show-overflow-tooltip /><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" @click="restoreBackup(row)">恢复</el-button></template></el-table-column></el-table>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="color-scheme" content="light dark" />
|
|
||||||
<title>PostgreSQL 服务</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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");
|
|
||||||
@@ -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%} }
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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"
|
|
||||||
@@ -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 '<div id="app"></div>'
|
|
||||||
|
|
||||||
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'
|
|
||||||
@@ -15,10 +15,7 @@ manifest_value() {
|
|||||||
tar -xzf "$PACKAGE" -C "$WORK_DIR"
|
tar -xzf "$PACKAGE" -C "$WORK_DIR"
|
||||||
appname=$(manifest_value appname)
|
appname=$(manifest_value appname)
|
||||||
version=$(manifest_value version)
|
version=$(manifest_value version)
|
||||||
case "$appname" in
|
test "$appname" = "liverecorder" || { printf 'unexpected fnOS appname: %s\n' "$appname" >&2; exit 1; }
|
||||||
liverecorder|nxsir.postgresql) ;;
|
|
||||||
*) printf 'unexpected fnOS appname: %s\n' "$appname" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
test -n "$version"
|
test -n "$version"
|
||||||
test "$(manifest_value platform)" = "x86"
|
test "$(manifest_value platform)" = "x86"
|
||||||
test -x "$WORK_DIR/cmd/main"
|
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/config$' "$WORK_DIR/app-files.txt"
|
||||||
grep -q '^ui/images/icon_64.png$' "$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/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt"
|
grep -q '^server/Platforms/Douyin/Signing/sign-xbogus.js$' "$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/bin/curl$' "$WORK_DIR/app-files.txt"
|
grep -q '^runtime/etc/ssl/certs/ca-certificates.crt$' "$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"
|
||||||
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
|
||||||
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
|
||||||
printf 'Live Recorder must use shared PostgreSQL and the fnOS nodejs_v22 dependency\n' >&2
|
exit 1
|
||||||
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"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if grep -Eq '^runtime/.*/(ffmpeg|ffprobe)$' "$WORK_DIR/app-files.txt"; then
|
if grep -Eq '^runtime/.*/(ffmpeg|ffprobe)$' "$WORK_DIR/app-files.txt"; then
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
namespace PostgresService.WebApi;
|
|
||||||
|
|
||||||
public sealed record LoginRequest(string Username, string Password);
|
|
||||||
|
|
||||||
public sealed record EnrollRequest(
|
|
||||||
string AppId,
|
|
||||||
string DisplayName,
|
|
||||||
IReadOnlyList<string>? 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<object?[]> Rows,
|
|
||||||
int RowCount,
|
|
||||||
bool Truncated,
|
|
||||||
long ElapsedMilliseconds);
|
|
||||||
@@ -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<string> 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<PostgresClientCredential> 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<object> 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<IReadOnlyList<ManagedClient>> 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<ManagedClient>();
|
|
||||||
while (await reader.ReadAsync(cancellationToken))
|
|
||||||
{
|
|
||||||
items.Add(ReadClient(reader));
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> 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<IReadOnlyList<DatabaseSummary>> 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<DatabaseSummary>();
|
|
||||||
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<IReadOnlyList<RoleSummary>> 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<RoleSummary>();
|
|
||||||
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<IReadOnlyList<SessionSummary>> 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<SessionSummary>();
|
|
||||||
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<DateTimeOffset>(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<QueryResult> 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<object?[]>();
|
|
||||||
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<IReadOnlyList<BackupSummary>> ListBackupsAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var result = new List<BackupSummary>();
|
|
||||||
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<BackupSummary> 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<ManagedClient?> 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<bool> 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<bool> 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<NpgsqlConnection> 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<bool> 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<bool> 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<string> 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<string> 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<string[]>(4), reader.GetString(5),
|
|
||||||
reader.GetFieldValue<DateTimeOffset>(6), reader.GetFieldValue<DateTimeOffset>(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<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using var stream = File.OpenRead(path);
|
|
||||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
|
||||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Npgsql" Version="8.0.3" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -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<SecretStore>();
|
|
||||||
builder.Services.AddSingleton<AdminSessionStore>();
|
|
||||||
builder.Services.AddSingleton<PostgresAdminService>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
var secretStore = app.Services.GetRequiredService<SecretStore>();
|
|
||||||
var sessionStore = app.Services.GetRequiredService<AdminSessionStore>();
|
|
||||||
var postgres = app.Services.GetRequiredService<PostgresAdminService>();
|
|
||||||
|
|
||||||
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;
|
|
||||||
@@ -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<string, DateTimeOffset> _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 _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,6 @@
|
|||||||
<ProjectReference Include="..\..\src\LiveRecorder.Application\LiveRecorder.Application.csproj" />
|
<ProjectReference Include="..\..\src\LiveRecorder.Application\LiveRecorder.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\src\LiveRecorder.Domain\LiveRecorder.Domain.csproj" />
|
<ProjectReference Include="..\..\src\LiveRecorder.Domain\LiveRecorder.Domain.csproj" />
|
||||||
<ProjectReference Include="..\..\src\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\src\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\src\PostgresService.WebApi\PostgresService.WebApi.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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<string, string?>
|
|
||||||
{
|
|
||||||
["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<string, string?>
|
|
||||||
{
|
|
||||||
["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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user