架构:分离系统数据库与云数据库配置
This commit is contained in:
parent
d9af0a0b7f
commit
e09b8a0113
@ -148,6 +148,7 @@
|
||||
- 生产服务器不能直接访问某个上游时,只允许通过专用低权限 SSH 账户建立受限反向隧道;每个来源使用独立 `SourceKey`、私有配置、计划任务和 Docker 私网端口,禁止监听公网地址,隧道账户禁止 Shell、TTY、本地转发和任意端口转发。
|
||||
- 隧道由 `scripts/keep-upstream-tunnel.ps1` 自动重连,并通过 `scripts/install-upstream-tunnel-task.ps1 -SourceKey <来源标识>` 安装。数据库凭据不得作为隧道参数;每台候选中继开发机使用独立 SSH 私钥,不得多人共享私钥。
|
||||
- 每个来源最多安排 1 台主中继和 1 台备用中继;同一来源同一时刻只允许一个远端监听成功,备用机低频重试。隧道中断时页面继续读取该来源旧快照,禁止绕过缓存直接重试上游。
|
||||
- 上游数据库账号和密码只允许存在于服务器私有环境配置;实际端点除服务器配置外,只允许存在于中继开发机当前用户专属、已收紧 ACL 的隧道配置。代码、提交、PR、共享文档和日志中一律不得出现真实值。
|
||||
- 系统数据库的启动连接只允许存在于服务器私有 `runtime/.env`;超级管理员可在“数据库管理”中修改,但保存前必须验证 TLS、系统必需表和用户数据,并在持久化 `storage` 中生成私有备份。
|
||||
- 云数据库连接与系统数据库配置必须分开。显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库密码使用当前 `APP_KEY` 加密保存到系统数据库的 `system_database_connections`;真实值仍不得进入代码、提交、PR、共享文档或日志。中继开发机只保留已收紧 ACL 的隧道端点配置,不保存数据库密码。
|
||||
|
||||
详细 SOP 见 `docs/开发与部署SOP.md`。
|
||||
|
||||
@ -4,6 +4,7 @@ namespace App\Http\Controllers\SystemSettings;
|
||||
|
||||
use App\Models\Settings\DatabaseConnection;
|
||||
use App\Utilities\SystemAccess;
|
||||
use App\Utilities\SystemDatabaseConfiguration;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\Rule;
|
||||
@ -24,10 +25,12 @@ class Databases extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
public function index(UpstreamStoredConfiguration $storedConfiguration)
|
||||
public function index(
|
||||
UpstreamStoredConfiguration $storedConfiguration,
|
||||
SystemDatabaseConfiguration $systemDatabaseConfiguration
|
||||
)
|
||||
{
|
||||
$defaultConnection = (string) config('database.default');
|
||||
$systemDatabase = (array) config('database.connections.' . $defaultConnection, []);
|
||||
$systemDatabase = $systemDatabaseConfiguration->current();
|
||||
|
||||
return view('system_settings.databases.index', [
|
||||
'connections' => $storedConfiguration->editable()
|
||||
@ -38,16 +41,79 @@ class Databases extends Controller
|
||||
: collect(),
|
||||
'sourceLabels' => $this->sourceLabels(),
|
||||
'systemDatabase' => [
|
||||
'connection' => $defaultConnection,
|
||||
'driver' => $systemDatabase['driver'] ?? $defaultConnection,
|
||||
'host' => $systemDatabase['host'] ?? '本机文件',
|
||||
'port' => $systemDatabase['port'] ?? null,
|
||||
'database' => $systemDatabase['database'] ?? '',
|
||||
'connection' => $systemDatabase['connection'],
|
||||
'driver' => $systemDatabase['driver'],
|
||||
'host' => $systemDatabase['host'] ?: '本机文件',
|
||||
'port' => $systemDatabase['port'],
|
||||
'database' => $systemDatabase['database'],
|
||||
'tls_required' => $systemDatabase['tls_required'],
|
||||
],
|
||||
'editable' => $storedConfiguration->editable(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function editSystem(SystemDatabaseConfiguration $configuration)
|
||||
{
|
||||
$current = $configuration->current();
|
||||
|
||||
return view('system_settings.databases.system', [
|
||||
'values' => [
|
||||
'host' => $current['host'],
|
||||
'port' => $current['port'],
|
||||
'database' => $current['database'],
|
||||
'username' => $current['username'],
|
||||
'tls_required' => $current['tls_required'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSystem(Request $request, SystemDatabaseConfiguration $configuration)
|
||||
{
|
||||
$current = $configuration->current();
|
||||
$data = $request->validate([
|
||||
'host' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'],
|
||||
'port' => ['required', 'integer', 'between:1,65535'],
|
||||
'database' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'],
|
||||
'username' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'],
|
||||
'password' => ['nullable', 'string', 'max:1000', 'not_regex:/[\r\n\x00]/'],
|
||||
'tls_required' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$settings = [
|
||||
'driver' => 'mysql',
|
||||
'host' => trim($data['host']),
|
||||
'port' => (int) $data['port'],
|
||||
'database' => trim($data['database']),
|
||||
'username' => trim($data['username']),
|
||||
'password' => $data['password'] !== null && $data['password'] !== ''
|
||||
? $data['password']
|
||||
: $current['password'],
|
||||
'tls_required' => $request->boolean('tls_required'),
|
||||
];
|
||||
|
||||
if ($settings['password'] === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => '当前没有可保留的密码,请填写数据库密码。',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $configuration->probe($settings);
|
||||
$configuration->persist($settings);
|
||||
} catch (\RuntimeException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'connection' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
flash(sprintf(
|
||||
'系统数据库连接已验证并保存,共检测到 %d 个用户;新配置将在下一次请求生效。',
|
||||
$result['user_count']
|
||||
))->success();
|
||||
|
||||
return redirect()->route('system-settings.databases.index');
|
||||
}
|
||||
|
||||
public function create(UpstreamStoredConfiguration $storedConfiguration)
|
||||
{
|
||||
abort_unless($storedConfiguration->editable(), 403);
|
||||
|
||||
197
app/Utilities/SystemDatabaseConfiguration.php
Normal file
197
app/Utilities/SystemDatabaseConfiguration.php
Normal file
@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class SystemDatabaseConfiguration
|
||||
{
|
||||
private string $environmentPath;
|
||||
|
||||
public function __construct(?string $environmentPath = null)
|
||||
{
|
||||
$this->environmentPath = $environmentPath ?: base_path('.env');
|
||||
}
|
||||
|
||||
public function current(): array
|
||||
{
|
||||
$connectionName = (string) config('database.default', 'mysql');
|
||||
$connection = (array) config('database.connections.' . $connectionName, []);
|
||||
$options = (array) ($connection['options'] ?? []);
|
||||
|
||||
return [
|
||||
'connection' => $connectionName,
|
||||
'driver' => (string) ($connection['driver'] ?? $connectionName),
|
||||
'host' => (string) (
|
||||
data_get($connection, 'write.host.0')
|
||||
?? data_get($connection, 'read.host.0')
|
||||
?? ($connection['host'] ?? '')
|
||||
),
|
||||
'port' => (int) ($connection['port'] ?? 3306),
|
||||
'database' => (string) ($connection['database'] ?? ''),
|
||||
'username' => (string) ($connection['username'] ?? ''),
|
||||
'password' => (string) ($connection['password'] ?? ''),
|
||||
'prefix' => (string) ($connection['prefix'] ?? ''),
|
||||
'tls_required' => isset($options[PDO::MYSQL_ATTR_SSL_CA])
|
||||
|| isset($options[PDO::MYSQL_ATTR_SSL_CIPHER]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{cipher:string,user_count:int}
|
||||
*/
|
||||
public function probe(array $settings): array
|
||||
{
|
||||
if (($settings['driver'] ?? 'mysql') !== 'mysql') {
|
||||
throw new RuntimeException('系统数据库目前只支持 MariaDB/MySQL。');
|
||||
}
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_TIMEOUT => 8,
|
||||
];
|
||||
|
||||
if (! empty($settings['tls_required'])) {
|
||||
$options[PDO::MYSQL_ATTR_SSL_CIPHER] = 'DEFAULT';
|
||||
|
||||
if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) {
|
||||
$options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
|
||||
$settings['host'],
|
||||
$settings['port'],
|
||||
$settings['database']
|
||||
),
|
||||
$settings['username'],
|
||||
$settings['password'],
|
||||
$options
|
||||
);
|
||||
|
||||
$cipher = (string) ($pdo
|
||||
->query("SHOW SESSION STATUS LIKE 'Ssl_cipher'")
|
||||
->fetch(PDO::FETCH_NUM)[1] ?? '');
|
||||
|
||||
if (! empty($settings['tls_required']) && $cipher === '') {
|
||||
throw new RuntimeException('数据库连接没有启用 TLS 加密。');
|
||||
}
|
||||
|
||||
$prefix = $this->current()['prefix'];
|
||||
$requiredTables = array_map(
|
||||
fn (string $table) => $prefix . $table,
|
||||
['migrations', 'users', 'roles', 'user_roles', 'user_companies', 'settings']
|
||||
);
|
||||
$placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
|
||||
$statement = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM information_schema.tables "
|
||||
. "WHERE table_schema = DATABASE() AND table_name IN ($placeholders)"
|
||||
);
|
||||
$statement->execute($requiredTables);
|
||||
|
||||
if ((int) $statement->fetchColumn() !== count($requiredTables)) {
|
||||
throw new RuntimeException('目标库缺少系统必需表,已拒绝切换。');
|
||||
}
|
||||
|
||||
$userCount = (int) $pdo->query(
|
||||
'SELECT COUNT(*) FROM `' . str_replace('`', '``', $prefix . 'users') . '`'
|
||||
)->fetchColumn();
|
||||
|
||||
if ($userCount < 1) {
|
||||
throw new RuntimeException('目标库没有任何用户,已拒绝切换。');
|
||||
}
|
||||
|
||||
return ['cipher' => $cipher, 'user_count' => $userCount];
|
||||
} catch (RuntimeException $exception) {
|
||||
throw $exception;
|
||||
} catch (Throwable $exception) {
|
||||
throw new RuntimeException('系统数据库连接测试失败。', 0, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
public function persist(array $settings): string
|
||||
{
|
||||
if (! is_file($this->environmentPath) || ! is_writable($this->environmentPath)) {
|
||||
throw new RuntimeException('服务器私有启动配置不可写,未执行切换。');
|
||||
}
|
||||
|
||||
$current = file_get_contents($this->environmentPath);
|
||||
|
||||
if ($current === false) {
|
||||
throw new RuntimeException('无法读取服务器私有启动配置。');
|
||||
}
|
||||
|
||||
$backupDirectory = storage_path('app/system-database-config-backups');
|
||||
|
||||
if (! is_dir($backupDirectory) && ! mkdir($backupDirectory, 0700, true) && ! is_dir($backupDirectory)) {
|
||||
throw new RuntimeException('无法创建系统数据库配置备份目录。');
|
||||
}
|
||||
|
||||
$backupPath = $backupDirectory . DIRECTORY_SEPARATOR . 'runtime-env-' . now()->format('Ymd-His') . '.backup';
|
||||
|
||||
if (file_put_contents($backupPath, $current, LOCK_EX) === false) {
|
||||
throw new RuntimeException('系统数据库配置备份失败,未执行切换。');
|
||||
}
|
||||
|
||||
chmod($backupPath, 0600);
|
||||
|
||||
$updated = $this->replaceEnvironmentValues($current, [
|
||||
'DB_CONNECTION' => 'mysql',
|
||||
'DB_HOST' => (string) $settings['host'],
|
||||
'DB_PORT' => (string) $settings['port'],
|
||||
'DB_DATABASE' => (string) $settings['database'],
|
||||
'DB_USERNAME' => (string) $settings['username'],
|
||||
'DB_PASSWORD' => (string) $settings['password'],
|
||||
'MYSQL_ATTR_SSL_CIPHER' => ! empty($settings['tls_required']) ? 'DEFAULT' : '',
|
||||
'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT' => 'false',
|
||||
]);
|
||||
|
||||
if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) {
|
||||
file_put_contents($this->environmentPath, $current, LOCK_EX);
|
||||
throw new RuntimeException('系统数据库配置保存失败,已恢复原配置。');
|
||||
}
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
return $backupPath;
|
||||
}
|
||||
|
||||
private function replaceEnvironmentValues(string $environment, array $values): string
|
||||
{
|
||||
$lines = preg_split('/\r\n|\r|\n/', $environment) ?: [];
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
$replacement = $key . '=' . $this->encodeEnvironmentValue((string) $value);
|
||||
$found = false;
|
||||
|
||||
foreach ($lines as $index => $line) {
|
||||
if (preg_match('/^' . preg_quote($key, '/') . '=/', $line) === 1) {
|
||||
$lines[$index] = $replacement;
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $found) {
|
||||
$lines[] = $replacement;
|
||||
}
|
||||
}
|
||||
|
||||
return rtrim(implode("\n", $lines), "\n") . "\n";
|
||||
}
|
||||
|
||||
private function encodeEnvironmentValue(string $value): string
|
||||
{
|
||||
return '"' . str_replace(
|
||||
['\\', '"', '$', "\r", "\n"],
|
||||
['\\\\', '\\"', '\\$', '', '\\n'],
|
||||
$value
|
||||
) . '"';
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,9 @@ services:
|
||||
- "0.0.0.0:13306:3306"
|
||||
volumes:
|
||||
- caiwu_config_database:/var/lib/mysql
|
||||
- ./deploy/config-db-init.sh:/docker-entrypoint-initdb.d/10-config-db-security.sh:ro
|
||||
networks:
|
||||
- default
|
||||
- config_database
|
||||
mem_limit: 256m
|
||||
cpus: "0.50"
|
||||
@ -53,6 +55,8 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
config_db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-0}
|
||||
ports:
|
||||
|
||||
@ -2,6 +2,34 @@
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
$mysqlSslOptions = static function (): array {
|
||||
if (! extension_loaded('pdo_mysql')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$options = [];
|
||||
$ca = env('MYSQL_ATTR_SSL_CA');
|
||||
$cipher = env('MYSQL_ATTR_SSL_CIPHER');
|
||||
$verifyServerCertificate = env('MYSQL_ATTR_SSL_VERIFY_SERVER_CERT');
|
||||
|
||||
if (is_string($ca) && trim($ca) !== '') {
|
||||
$options[PDO::MYSQL_ATTR_SSL_CA] = $ca;
|
||||
}
|
||||
|
||||
if (is_string($cipher) && trim($cipher) !== '') {
|
||||
$options[PDO::MYSQL_ATTR_SSL_CIPHER] = $cipher;
|
||||
}
|
||||
|
||||
if ($verifyServerCertificate !== null && defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) {
|
||||
$options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] = filter_var(
|
||||
$verifyServerCertificate,
|
||||
FILTER_VALIDATE_BOOL
|
||||
);
|
||||
}
|
||||
|
||||
return $options;
|
||||
};
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
@ -76,9 +104,7 @@ return [
|
||||
'ERROR_FOR_DIVISION_BY_ZERO',
|
||||
'NO_ENGINE_SUBSTITUTION',
|
||||
],
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
'options' => $mysqlSslOptions(),
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
@ -114,9 +140,7 @@ return [
|
||||
'ERROR_FOR_DIVISION_BY_ZERO',
|
||||
'NO_ENGINE_SUBSTITUTION',
|
||||
],
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
'options' => $mysqlSslOptions(),
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
|
||||
@ -7,7 +7,7 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 每个来源使用独立配置、缓存命名空间、查询预算和熔断状态。新增来源时
|
||||
| 只登记非敏感标识和配置文件名,真实端点及凭据只放服务器私有 .env。
|
||||
| 只在代码中登记非敏感标识;生产凭据可在明确开启后加密保存到系统库。
|
||||
|
|
||||
*/
|
||||
'runtime' => [
|
||||
@ -16,6 +16,11 @@ return [
|
||||
'slot_wait_ms' => (int) env('UPSTREAM_SLOT_WAIT_MS', 1500),
|
||||
],
|
||||
|
||||
'stored_configuration' => [
|
||||
// 生产环境默认关闭;只允许在系统数据库和备份均已就绪后显式开启。
|
||||
'enabled' => filter_var(env('UPSTREAM_STORED_CONFIG_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
],
|
||||
|
||||
'sources' => [
|
||||
'jiansun' => [
|
||||
'label' => 'JianSun 旧业务库',
|
||||
|
||||
@ -22,6 +22,11 @@ if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$REMOTE_DIR/runtime/config-db.env" ]; then
|
||||
echo "Missing $REMOTE_DIR/runtime/config-db.env; run bootstrap-server.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "Another CaiWuXiTong deployment is already running" >&2
|
||||
|
||||
@ -26,6 +26,11 @@ if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$REMOTE_DIR/runtime/config-db.env" ]; then
|
||||
echo "Missing $REMOTE_DIR/runtime/config-db.env; run bootstrap-server.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "Another CaiWuXiTong deployment is already running" >&2
|
||||
|
||||
@ -30,11 +30,33 @@ fi
|
||||
|
||||
install -d -m 700 "$REMOTE_DIR/runtime"
|
||||
|
||||
if [ ! -f "$REMOTE_DIR/runtime/config-db.env" ]; then
|
||||
CONFIG_DB_PASSWORD="$(openssl rand -hex 32)"
|
||||
CONFIG_DB_ROOT_PASSWORD="$(openssl rand -hex 32)"
|
||||
|
||||
umask 077
|
||||
{
|
||||
printf '%s\n' \
|
||||
'MARIADB_DATABASE=caiwu_system_config' \
|
||||
'MARIADB_USER=caiwu_config_writer' \
|
||||
"MARIADB_PASSWORD=$CONFIG_DB_PASSWORD" \
|
||||
"MARIADB_ROOT_PASSWORD=$CONFIG_DB_ROOT_PASSWORD" \
|
||||
'MARIADB_AUTO_UPGRADE=1'
|
||||
} > "$REMOTE_DIR/runtime/config-db.env"
|
||||
chmod 600 "$REMOTE_DIR/runtime/config-db.env"
|
||||
fi
|
||||
|
||||
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
|
||||
DB_PASSWORD="$(openssl rand -hex 24)"
|
||||
DB_ROOT_PASSWORD="$(openssl rand -hex 24)"
|
||||
LEGACY_DB_PASSWORD="$(openssl rand -hex 24)"
|
||||
LEGACY_DB_ROOT_PASSWORD="$(openssl rand -hex 24)"
|
||||
CONFIG_DB_PASSWORD="$(sed -n 's/^MARIADB_PASSWORD=//p' "$REMOTE_DIR/runtime/config-db.env" | head -n 1)"
|
||||
APP_KEY="$(openssl rand -base64 32 | tr -d '\n')"
|
||||
|
||||
if [ -z "$CONFIG_DB_PASSWORD" ]; then
|
||||
echo "Missing MARIADB_PASSWORD in $REMOTE_DIR/runtime/config-db.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
{
|
||||
printf '%s\n' \
|
||||
@ -49,21 +71,24 @@ if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
|
||||
'APP_TIMEZONE=Asia/Shanghai' \
|
||||
'LOG_CHANNEL=stack' \
|
||||
'DB_CONNECTION=mysql' \
|
||||
'DB_HOST=db' \
|
||||
'DB_HOST=config_db' \
|
||||
'DB_PORT=3306' \
|
||||
'DB_DATABASE=caiwu' \
|
||||
'DB_USERNAME=caiwu' \
|
||||
"DB_PASSWORD=$DB_PASSWORD" \
|
||||
'DB_DATABASE=caiwu_system_config' \
|
||||
'DB_USERNAME=caiwu_config_writer' \
|
||||
"DB_PASSWORD=$CONFIG_DB_PASSWORD" \
|
||||
'DB_PREFIX=ak_' \
|
||||
'MYSQL_ATTR_SSL_CIPHER=DEFAULT' \
|
||||
'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT=false' \
|
||||
'CACHE_DRIVER=file' \
|
||||
'SESSION_DRIVER=file' \
|
||||
'QUEUE_CONNECTION=sync' \
|
||||
'UPSTREAM_STORED_CONFIG_ENABLED=true' \
|
||||
'JIANSUN_ENABLED=false' \
|
||||
'JIANSUN_ALLOWED_SCOPES=' \
|
||||
'MARIADB_DATABASE=caiwu' \
|
||||
'MARIADB_USER=caiwu' \
|
||||
"MARIADB_PASSWORD=$DB_PASSWORD" \
|
||||
"MARIADB_ROOT_PASSWORD=$DB_ROOT_PASSWORD" \
|
||||
"MARIADB_PASSWORD=$LEGACY_DB_PASSWORD" \
|
||||
"MARIADB_ROOT_PASSWORD=$LEGACY_DB_ROOT_PASSWORD" \
|
||||
'MARIADB_AUTO_UPGRADE=1'
|
||||
} > "$REMOTE_DIR/runtime/.env"
|
||||
chmod 600 "$REMOTE_DIR/runtime/.env"
|
||||
|
||||
13
deploy/config-db-init.sh
Executable file
13
deploy/config-db-init.sh
Executable file
@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
case "${MARIADB_USER:-}" in
|
||||
''|*[!A-Za-z0-9_]* )
|
||||
echo 'MARIADB_USER contains unsupported characters' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mariadb --protocol=socket -uroot -p"$MARIADB_ROOT_PASSWORD" <<SQL
|
||||
ALTER USER \`$MARIADB_USER\`@'%' REQUIRE SSL;
|
||||
SQL
|
||||
@ -139,7 +139,7 @@ git pull --ff-only
|
||||
|
||||
### 8.1 统一接入边界
|
||||
|
||||
本系统按 3 个以上上游数据库设计,但不会把多个数据库直接挂到页面上。`config/upstreams.php` 只登记不敏感的来源标识和配置文件名;每个来源再分别提供连接配置、固定查询适配器、允许的数据范围、快照、限流、熔断和刷新器。真实端点、数据库名、账号和密码仍只存在于服务器私有 `runtime/.env`。
|
||||
本系统按 3 个以上上游数据库设计,但不会把多个数据库直接挂到页面上。`config/upstreams.php` 只登记不敏感的来源标识和配置文件名;每个来源再分别提供连接配置、固定查询适配器、允许的数据范围、快照、限流、熔断和刷新器。系统数据库的启动连接只存在于服务器私有 `runtime/.env`;显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库端点及账号保存在系统库中,密码使用当前 `APP_KEY` 加密。
|
||||
|
||||
固定调用链如下:
|
||||
|
||||
@ -192,9 +192,10 @@ php artisan upstream:snapshots:refresh <source_key>
|
||||
|
||||
相关 API 使用 `X-Caiwu-Data-Status`(`fresh` / `stale`)和 `X-Caiwu-Data-Synced-At` 响应头报告数据状态,验收时不能只看 HTTP 200。
|
||||
|
||||
服务器私有 `runtime/.env` 只使用下列变量名,真实值不得写入 Git 或本文档:
|
||||
旧的环境变量连接方式只用于迁移或紧急回滚;日常由超级管理员在“数据库管理”的“云数据库”区域维护,并加密保存到系统数据库。生产服务器私有 `runtime/.env` 至少保留下列开关和非敏感保护参数,真实值不得写入 Git 或本文档:
|
||||
|
||||
```dotenv
|
||||
UPSTREAM_STORED_CONFIG_ENABLED=true
|
||||
JIANSUN_ENABLED=false
|
||||
JIANSUN_ALLOWED_SCOPES=
|
||||
DB_JIANSUN_HOST=
|
||||
@ -224,7 +225,7 @@ JIANSUN_SLOW_QUERY_COOLDOWN=300
|
||||
|
||||
### 8.3 生产服务器不能直连上游时
|
||||
|
||||
使用一台指定开发机作为主中继,另一台作为备用中继。它们只转发 TCP 流量,不保存数据库凭据,也不在开发机执行同步查询。数据库凭据仍只存在于服务器私有 `runtime/.env`,应用仍通过同一套快照、限流和熔断逻辑读取数据。
|
||||
使用一台指定开发机作为主中继,另一台作为备用中继。它们只转发 TCP 流量,不保存数据库凭据,也不在开发机执行同步查询。数据库凭据加密保存在系统数据库,中继机只保存已收紧 ACL 的端点和隧道配置;应用仍通过同一套快照、限流和熔断逻辑读取数据。
|
||||
|
||||
安全边界:
|
||||
|
||||
|
||||
@ -6,9 +6,9 @@ use App\Models\Settings\DatabaseConnection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 读取仅供本地开发使用的上游连接配置。
|
||||
* 从系统数据库读取经 Laravel APP_KEY 加密保存的上游连接配置。
|
||||
*
|
||||
* 生产环境始终使用私有环境变量,避免网页配置覆盖服务器运行配置。
|
||||
* 本地、开发和测试环境保持可用;生产环境必须通过私有环境变量显式开启。
|
||||
*/
|
||||
class UpstreamStoredConfiguration
|
||||
{
|
||||
@ -19,7 +19,8 @@ class UpstreamStoredConfiguration
|
||||
|
||||
public function editable(): bool
|
||||
{
|
||||
return app()->environment(['local', 'development', 'testing']);
|
||||
return app()->environment(['local', 'development', 'testing'])
|
||||
|| (bool) config('upstreams.stored_configuration.enabled', false);
|
||||
}
|
||||
|
||||
public function find(string $source): ?DatabaseConnection
|
||||
|
||||
@ -20,11 +20,15 @@
|
||||
@endif
|
||||
|
||||
<div class="mb-5 rounded-xl bg-purple-100 px-5 py-4 text-sm leading-6 text-gray-700">
|
||||
<p class="font-semibold text-gray-900">当前系统数据库</p>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<p class="font-semibold text-gray-900">当前系统数据库</p>
|
||||
<a href="{{ route('system-settings.databases.system.edit') }}" class="font-semibold text-purple-700 hover:text-purple-800 hover:underline">修改系统数据库</a>
|
||||
</div>
|
||||
<p class="mt-1">
|
||||
{{ strtoupper($systemDatabase['driver']) }} · {{ $systemDatabase['host'] }}{{ $systemDatabase['port'] ? ':' . $systemDatabase['port'] : '' }} · {{ $systemDatabase['database'] ?: $systemDatabase['connection'] }}
|
||||
</p>
|
||||
<p class="mt-1">用户、系统设置和下方所有数据库配置都统一保存在这个数据库中。系统库的启动连接由私有 <code>runtime/.env</code> 提供,页面不会显示密码。</p>
|
||||
<p class="mt-1">用户、侧边栏设置和下方所有云数据库配置都保存在这个数据库中。系统库启动连接与云数据库配置相互独立,密码不会回显。</p>
|
||||
<p class="mt-1">传输加密:{{ $systemDatabase['tls_required'] ? 'TLS 已启用' : '未启用' }}</p>
|
||||
@if ($editable)
|
||||
<p class="mt-1">下方可继续添加多个云数据库;连接测试只执行 <code>SELECT 1</code>。</p>
|
||||
@else
|
||||
@ -32,6 +36,13 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900">云数据库</p>
|
||||
<p class="text-xs text-gray-500">每条密码均使用系统 APP_KEY 加密后存入上方系统数据库。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-purple-50 text-left text-gray-700">
|
||||
|
||||
61
resources/views/system_settings/databases/system.blade.php
Normal file
61
resources/views/system_settings/databases/system.blade.php
Normal file
@ -0,0 +1,61 @@
|
||||
<x-layouts.admin>
|
||||
<x-slot name="title">修改系统数据库</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<div class="my-6">
|
||||
@include('system_settings.partials.navigation')
|
||||
|
||||
<form method="POST" action="{{ route('system-settings.databases.system.update') }}" class="max-w-4xl rounded-xl border border-gray-200 bg-white p-6">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-5 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
@foreach ($errors->all() as $error)<p>{{ $error }}</p>@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-6 rounded-lg bg-yellow-50 px-4 py-3 text-sm leading-6 text-yellow-800">
|
||||
保存前会先验证 TLS、系统必需表和用户数据;任何一项不完整都会拒绝切换。密码留空表示保留当前密码。
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<label class="block sm:col-span-2">
|
||||
<span class="text-sm font-semibold text-gray-700">主机地址 *</span>
|
||||
<input name="host" value="{{ old('host', $values['host']) }}" required maxlength="191" autocomplete="off" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-gray-700">端口 *</span>
|
||||
<input type="number" name="port" value="{{ old('port', $values['port']) }}" required min="1" max="65535" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-gray-700">数据库名称 *</span>
|
||||
<input name="database" value="{{ old('database', $values['database']) }}" required maxlength="191" autocomplete="off" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-gray-700">数据库账号 *</span>
|
||||
<input name="username" value="{{ old('username', $values['username']) }}" required maxlength="191" autocomplete="off" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-gray-700">数据库密码</span>
|
||||
<input type="password" name="password" maxlength="1000" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700">
|
||||
<span class="mt-1 block text-xs text-gray-500">留空保留当前密码,页面不会回显。</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 border-t border-gray-100 pt-5">
|
||||
<input type="hidden" name="tls_required" value="0">
|
||||
<label class="inline-flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<input type="checkbox" name="tls_required" value="1" @checked((bool) old('tls_required', $values['tls_required'])) class="rounded border-gray-300 text-purple-700 focus:ring-purple-700">
|
||||
要求 TLS 加密连接
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3 border-t border-gray-100 pt-5">
|
||||
<a href="{{ route('system-settings.databases.index') }}" class="rounded-lg px-5 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100">取消</a>
|
||||
<button type="submit" class="rounded-lg bg-purple-700 px-5 py-2 text-sm font-semibold text-white hover:bg-purple-800">测试并保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-layouts.admin>
|
||||
@ -24,6 +24,8 @@ Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], functi
|
||||
Route::patch('sidebar', 'SystemSettings\Sidebar@update')->name('sidebar.update');
|
||||
|
||||
Route::get('databases', 'SystemSettings\Databases@index')->name('databases.index');
|
||||
Route::get('databases/system/edit', 'SystemSettings\Databases@editSystem')->name('databases.system.edit');
|
||||
Route::patch('databases/system', 'SystemSettings\Databases@updateSystem')->name('databases.system.update');
|
||||
Route::get('databases/create', 'SystemSettings\Databases@create')->name('databases.create');
|
||||
Route::post('databases', 'SystemSettings\Databases@store')->name('databases.store');
|
||||
Route::get('databases/{connection}/edit', 'SystemSettings\Databases@edit')->name('databases.edit');
|
||||
|
||||
@ -7,6 +7,7 @@ use App\Models\Auth\Role;
|
||||
use App\Models\Settings\DatabaseConnection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\UpstreamSourceRegistry;
|
||||
use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class UpstreamDatabasesTest extends FeatureTestCase
|
||||
@ -103,6 +104,48 @@ class UpstreamDatabasesTest extends FeatureTestCase
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_database_page_separates_system_and_cloud_database_configuration(): void
|
||||
{
|
||||
$connectionName = (string) config('database.default');
|
||||
config(["database.connections.$connectionName.password" => 'system-database-test-secret']);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('system-settings.databases.index'))
|
||||
->assertOk()
|
||||
->assertSeeText('当前系统数据库')
|
||||
->assertSeeText('修改系统数据库')
|
||||
->assertSeeText('云数据库');
|
||||
|
||||
$this->get(route('system-settings.databases.system.edit'))
|
||||
->assertOk()
|
||||
->assertSeeText('测试并保存')
|
||||
->assertDontSee('system-database-test-secret');
|
||||
}
|
||||
|
||||
public function test_production_stored_configuration_requires_explicit_private_flag(): void
|
||||
{
|
||||
$connection = $this->connection('production-secret');
|
||||
$connection->update(['source_key' => 'jiansun']);
|
||||
$originalEnvironment = $this->app->environment();
|
||||
|
||||
try {
|
||||
$this->app['env'] = 'production';
|
||||
config(['upstreams.stored_configuration.enabled' => false]);
|
||||
|
||||
$disabled = app(UpstreamStoredConfiguration::class);
|
||||
$this->assertFalse($disabled->editable());
|
||||
$this->assertNull($disabled->find('jiansun'));
|
||||
|
||||
config(['upstreams.stored_configuration.enabled' => true]);
|
||||
$enabled = new UpstreamStoredConfiguration();
|
||||
|
||||
$this->assertTrue($enabled->editable());
|
||||
$this->assertSame($connection->id, $enabled->find('jiansun')?->id);
|
||||
} finally {
|
||||
$this->app['env'] = $originalEnvironment;
|
||||
}
|
||||
}
|
||||
|
||||
private function payload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user