refactor: move PostgreSQL shared service to dedicated repository
This commit is contained in:
@@ -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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user