refactor: move PostgreSQL shared service to dedicated repository

This commit is contained in:
2026-08-03 23:59:25 +08:00
parent ecc737f0bd
commit e0d3969e46
34 changed files with 18 additions and 2637 deletions
-72
View File
@@ -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>
-199
View File
@@ -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;
-160
View File
@@ -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 _);
}
}
}