feat: encrypting & config

This commit is contained in:
2026-10-12 04:32:01 +03:00
parent 9e5a2b5bce
commit 41281501b8
10 changed files with 440 additions and 20 deletions
+3
View File
@@ -6,3 +6,6 @@ POSTGRES_USER=webl
POSTGRES_PASSWORD=webl_dev_password
UID=1000
GID=1000
APP_ENV=local
# для APP_KEY: make keygen
APP_KEY=
+11 -1
View File
@@ -5,7 +5,7 @@ include .env
export
endif
.PHONY: help init build up down ps logs clean setup composer migrate migrate-status migrate-rollback sh psql
.PHONY: help init build up down ps logs clean setup composer migrate migrate-status migrate-rollback keygen config-show sh psql
help:
@echo "Доступные команды:"
@@ -21,6 +21,8 @@ help:
@echo " make migrate - применить миграции базы данных (php bin/migrate up)"
@echo " make migrate-status - показать статус миграций (php bin/migrate status)"
@echo " make migrate-rollback - откатить последнюю миграцию (php bin/migrate down)"
@echo " make keygen - сгенерировать APP_KEY (php bin/config keygen)"
@echo " make config-show - показать итоговый конфиг (php bin/config show)"
@echo " make sh - открыть shell в контейнере приложения"
@echo " make psql - подключиться к PostgreSQL (psql)"
@@ -68,6 +70,14 @@ migrate-status:
migrate-rollback:
docker compose exec app php bin/migrate down
keygen:
@KEY=$$(docker compose exec -T app php bin/config keygen | head -n1); \
sed -i.bak -e "s|^APP_KEY=.*|APP_KEY=$$KEY|" .env && rm -f .env.bak; \
echo "APP_KEY обновлён в .env. Перезапусти app: make up (старые enc:-значения нужно перешифровать)"
config-show:
docker compose exec app php bin/config show
sh:
docker compose exec app sh
+92 -19
View File
@@ -2,6 +2,13 @@
Дз по лекциям
## Возможности
1. **Заметки** - создание и просмотр заметок через веб-интерфейс (`app/public/index.php`, модель `App\Models\Note`).
2. **Миграции БД** - версионирование схемы PostgreSQL через `php bin/migrate` и Eloquent.
3. **Логирование запросов** - каждый HTTP-запрос пишется с `method`, `uri`, `status`, `body`.
4. **Статический конфиг с шифрованными значениями** - файлы конфигурации по окружениям (`app/config/*.php`), шифрование значений через `App\Crypto\Encryptor` (openssl AES-256-GCM) и CLI-утилиты.
## Быстрый старт
```sh
@@ -24,23 +31,25 @@ make migrate # применить миграции
## Команды
| Команда | Описание |
|-------------------------|--------------------------------------------------------------|
| `make help` | Показать список всех команд |
| `make init` | Создать `.env` из `.env.example`, подставить UID/GID |
| `make build` | Собрать Docker-образы |
| `make up` | Запустить контейнеры в фоне |
| `make down` | Остановить контейнеры |
| `make ps` | Показать состояние контейнеров |
| `make logs` | Логи контейнеров в реальном времени |
| `make clean` | Остановить контейнеры и удалить данные PostgreSQL (`-v`) |
| `make setup` | Быстрый старт: init + build + up + composer + migrate |
| `make composer` | `composer install` внутри контейнера |
| `make migrate` | Применить миграции (`php bin/migrate up`) |
| `make migrate-status` | Статус миграций (`php bin/migrate status`) |
| `make migrate-rollback` | Откатить последнюю миграцию (`php bin/migrate down`) |
| `make sh` | Shell внутри контейнера приложения |
| `make psql` | Подключиться к PostgreSQL |
| Команда | Описание |
|-------------------------|-----------------------------------------------------------------------|
| `make help` | Показать список всех команд |
| `make init` | Создать `.env` из `.env.example`, подставить UID/GID |
| `make build` | Собрать Docker-образы |
| `make up` | Запустить контейнеры в фоне |
| `make down` | Остановить контейнеры |
| `make ps` | Показать состояние контейнеров |
| `make logs` | Логи контейнеров в реальном времени |
| `make clean` | Остановить контейнеры и удалить данные PostgreSQL (`-v`) |
| `make setup` | Быстрый старт: init + build + up + composer + migrate |
| `make composer` | `composer install` внутри контейнера |
| `make migrate` | Применить миграции (`php bin/migrate up`) |
| `make migrate-status` | Статус миграций (`php bin/migrate status`) |
| `make migrate-rollback` | Откатить последнюю миграцию (`php bin/migrate down`) |
| `make keygen` | Сгенерировать `APP_KEY` и записать в `.env` (`php bin/config keygen`) |
| `make config-show` | Показать конфиг текущего окружения (`php bin/config show`) |
| `make sh` | Shell внутри контейнера приложения |
| `make psql` | Подключиться к PostgreSQL |
## Логи
@@ -49,6 +58,67 @@ make migrate # применить миграции
- Консоль: `docker compose logs app`
- Файлы: `app/storage/logs/app-*.log` (ротация 7 дней)
## Конфиг и шифрование
Окружение и ключ шифрования читаются из переменных окружения (`.env` -> `compose.yaml`):
| Переменная | Назначение | По умолчанию |
|------------|------------------------------------------------------------------|--------------|
| `APP_ENV` | Окружение: `local`, `dev` или `prod` | `local` |
| `APP_KEY` | base64-ключ длиной 32 байта для AES-256-GCM | - |
Ключ: `.env` в `.gitignore`, а в `.env.example` лежит только плейсхолдер. Сгенерировать и записать ключ в `.env` можно командой `make keygen`. После смены ключа перезапустите приложение (`make up`) и перешифруйте `enc:`-значения - под новым ключом старые не расшифруются.
Файлы конфигурации лежат в `app/config/*.php` (например, `app.php`, `database.php`). Каждый файл возвращает массив с секциями по окружениям; значения окружения накладываются поверх `default` (`array_replace_recursive`):
```php
<?php
return [
'default' => [
// общие значения для всех окружений
],
'local' => [
// переопределения для APP_ENV=local
],
'dev' => [],
'prod' => [],
];
```
Значения, начинающиеся с `enc:v1:`, расшифровываются автоматически при загрузке конфига. Конверт (`envelope`) имеет вид `enc:v1:` + `base64(iv | tag | ciphertext)`, шифр - `aes-256-gcm` (`App\Crypto\Encryptor`). В не-production окружениях битый шифротекст или отсутствующий ключ деградирует до `null` с предупреждением в лог; в `prod` в этом случае выбрасывается исключение.
Консольные утилиты (`bin/config`):
```sh
docker compose exec -T app php bin/config keygen # сгенерировать APP_KEY
docker compose exec -T app php bin/config encrypt "значение"
docker compose exec -T app php bin/config decrypt "enc:v1:..."
docker compose exec -T app php bin/config show # показать конфиг для APP_ENV
make keygen
make config-show
```
Внутри контейнера также доступен CLI `openssl` (`/usr/bin/openssl`, версия 3.5.7) - для ручных операций
## Схема БД
| Таблица | Ключевые колонки |
|-----------------|-----------------------------------------------------------------------------------------------------------|
| `users` | `user_id` (PK), `email`, `password_hash`, `name`, `created_datetime`, `updated_datetime` |
| `boards` | `board_id` (PK), `title`, `owner_user_id` → `users.user_id`, `is_public`, `created_datetime`, `updated_datetime` |
| `board_columns` | `column_id` (PK), `board_id` → `boards.board_id`, `ordinal_position`, `title`, `color` |
| `cards` | `card_id` (PK), `board_id` → `boards.board_id`, `column_id` → `board_columns.column_id`, `sort_index`, `title`, `content`, `color`, `is_archived`, `created_datetime`, `updated_datetime` |
| Индекс | Тип | Таблица / колонки |
|------------------------|-----------|------------------------------------------------------|
| `idx_email` | UNIQUE | `users (email)` |
| `idx_board_id` | обычный | `board_columns (board_id)` |
| `idx_active_board_id` | PARTIAL | `cards (board_id)` WHERE `is_archived = false` |
Миграции лежат в `app/database/migrations`, применяются командой `make migrate` и откатываются `make migrate-rollback`.
## Структура
```
@@ -59,7 +129,10 @@ make migrate # применить миграции
└── app/ # PHP-код (монтируется в контейнер)
├── public/ # index.php, router.php
├── src/ # исходники
├── bin/ # мигратор
│ ├── Config/ # App\Config\Config - загрузка и слияние конфигов
│ └── Crypto/ # App\Crypto\Encryptor - AES-256-GCM
├── config/ # конфиги по окружениям (app.php, database.php, ...)
├── bin/ # мигратор (migrate) и CLI конфига (config)
├── database/ # миграции
└── storage/logs/ # логи
```
@@ -67,7 +140,7 @@ make migrate # применить миграции
## Технологии
- PHP 8.5 (`php:8.5-cli`, встроенный сервер)
- Eloquent — illuminate/database 12 (standalone)
- Eloquent - illuminate/database 12 (standalone)
- Monolog 3
- PostgreSQL 18 (`postgres:18-alpine`)
- Docker Compose v2
Executable
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use App\Config\Config;
use App\Crypto\Encryptor;
require __DIR__ . '/../vendor/autoload.php';
$format = static function (mixed $value): string {
return match (true) {
is_bool($value) => $value ? 'true' : 'false',
$value === null => 'null',
is_array($value) => json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]',
default => (string) $value,
};
};
$command = $argv[1] ?? null;
try {
switch ($command) {
case 'keygen':
echo Encryptor::generateKey(), "\n";
echo "Помести ключ в APP_KEY (.env)\n";
break;
case 'encrypt':
if (!isset($argv[2])) {
throw new \RuntimeException('Укажи значение: config encrypt <value>');
}
echo Encryptor::fromEnv()->encrypt($argv[2]), "\n";
break;
case 'decrypt':
if (!isset($argv[2])) {
throw new \RuntimeException('Укажи значение: config decrypt <value>');
}
echo Encryptor::fromEnv()->decrypt($argv[2]), "\n";
break;
case 'show':
$config = Config::load();
$filter = $argv[2] ?? null;
$values = $config->all();
if ($filter !== null) {
$values = array_filter(
$values,
static fn (string $key): bool => $key === $filter || str_starts_with($key, $filter . '.'),
ARRAY_FILTER_USE_KEY,
);
}
if ($values === []) {
fwrite(STDERR, "Значения не найдены\n");
exit(1);
}
$width = max(array_map('strlen', array_keys($values)));
foreach ($values as $key => $value) {
echo str_pad($key, $width), ' = ', $format($value), "\n";
}
break;
default:
fwrite(STDERR, "Использование: config <keygen|encrypt|decrypt|show> [args]\n");
fwrite(STDERR, " keygen - сгенерировать новый APP_KEY\n");
fwrite(STDERR, " encrypt <value> - зашифровать значение\n");
fwrite(STDERR, " decrypt <value> - расшифровать значение\n");
fwrite(STDERR, " show [key] - показать конфиг текущего APP_ENV\n");
exit(1);
}
exit(0);
} catch (Throwable $e) {
fwrite(STDERR, 'Ошибка конфигурации: ' . $e->getMessage() . "\n");
exit(1);
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'default' => [
'name' => 'webl',
'debug' => false,
'timezone' => 'Europe/Moscow',
],
'local' => [
'debug' => true,
],
'dev' => [
'debug' => true,
],
'prod' => [],
];
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
return [
'default' => [
'driver' => 'pgsql',
'host' => 'db',
'port' => 5432,
'database' => 'webl',
'username' => 'webl',
],
'local' => [
'driver' => 'pgsql',
'host' => 'db',
'port' => 5432,
'database' => 'webl',
'username' => 'webl',
],
'prod' => [
'debug_sql' => false,
],
];
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Config;
use App\Crypto\Encryptor;
final class Config {
private static ?self $instance = null;
private function __construct(
private readonly string $env,
private readonly array $sections,
private readonly array $data,
) {
}
public static function load(?string $env = null): self {
$env ??= (getenv('APP_ENV') ?: 'local');
$encryptor = null;
$sections = [];
foreach (glob(__DIR__ . '/../../config/*.php') ?: [] as $file) {
$section = basename($file, '.php');
$data = require $file;
if (!is_array($data)) {
continue;
}
$sections[$section] = array_replace_recursive($data['default'] ?? [], $data[$env] ?? []);
}
foreach ($sections as $section => $values) {
$sections[$section] = self::decryptValues($values, $section, $env, $encryptor);
}
$data = [];
foreach ($sections as $section => $values) {
foreach (self::flatten($values, $section) as $key => $value) {
$data[$key] = $value;
}
}
return self::$instance = new self($env, $sections, $data);
}
public static function instance(): self {
return self::$instance ??= self::load();
}
public static function get(string $key, mixed $default = null): mixed {
$config = self::instance();
if (array_key_exists($key, $config->data)) {
return $config->data[$key];
}
$value = $config->sections;
foreach (explode('.', $key) as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return $default;
}
$value = $value[$segment];
}
return $value;
}
public function all(): array {
return $this->data;
}
public function env(): string {
return $this->env;
}
private static function decryptValues(array $values, string $prefix, string $env, ?Encryptor &$encryptor): array {
foreach ($values as $key => $value) {
$dotKey = $prefix . '.' . $key;
if (is_array($value)) {
$values[$key] = self::decryptValues($value, $dotKey, $env, $encryptor);
continue;
}
if (!is_string($value) || !str_starts_with($value, Encryptor::MARKER)) {
continue;
}
try {
$encryptor ??= Encryptor::fromEnv();
$values[$key] = $encryptor->decrypt($value);
} catch (\Throwable $e) {
if ($env === 'prod') {
throw $e;
}
\App\Logger::get()->warning('config.decrypt_failed', [
'key' => $dotKey,
'error' => $e->getMessage(),
]);
$values[$key] = null;
}
}
return $values;
}
private static function flatten(array $values, string $prefix): array {
$flat = [];
foreach ($values as $key => $value) {
$dotKey = $prefix . '.' . $key;
if (is_array($value) && $value !== []) {
$flat += self::flatten($value, $dotKey);
continue;
}
$flat[$dotKey] = $value;
}
return $flat;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Crypto;
final class Encryptor {
public const MARKER = 'enc:';
public const CIPHER = 'aes-256-gcm';
public const VERSION = 'v1';
private const IV_LENGTH = 12;
private const TAG_LENGTH = 16;
private function __construct(private readonly string $key) {
}
public static function create(string $base64Key): self {
$key = base64_decode($base64Key, true);
if ($key === false || strlen($key) !== 32) {
throw new \RuntimeException('APP_KEY должен быть base64 строкой из 32 байт, для генерации используй: make keygen');
}
return new self($key);
}
public static function fromEnv(): self {
$key = getenv('APP_KEY');
if ($key === false || $key === '') {
throw new \RuntimeException('Нету APP_KEY, сгенерируйте при помощи: make keygen');
}
return self::create($key);
}
public static function generateKey(): string {
return base64_encode(random_bytes(32));
}
public function encrypt(string $plaintext): string {
$iv = random_bytes(self::IV_LENGTH);
$tag = '';
$ciphertext = openssl_encrypt($plaintext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LENGTH);
if ($ciphertext === false) {
throw new \RuntimeException('Не удалось зашифровать');
}
return self::MARKER . self::VERSION . ':' . base64_encode($iv . $tag . $ciphertext);
}
public function decrypt(string $payload): string {
if (str_starts_with($payload, self::MARKER)) {
$payload = substr($payload, strlen(self::MARKER));
}
if (!str_starts_with($payload, self::VERSION . ':')) {
throw new \RuntimeException('Неизвестная версия шифра');
}
$raw = base64_decode(substr($payload, strlen(self::VERSION) + 1), true);
if ($raw === false || strlen($raw) < self::IV_LENGTH + self::TAG_LENGTH) {
throw new \RuntimeException('Битый шифр');
}
$iv = substr($raw, 0, self::IV_LENGTH);
$tag = substr($raw, self::IV_LENGTH, self::TAG_LENGTH);
$ciphertext = substr($raw, self::IV_LENGTH + self::TAG_LENGTH);
$plaintext = openssl_decrypt($ciphertext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag);
if ($plaintext === false) {
throw new \RuntimeException('Не удалось расшифровать: тег не тот');
}
return $plaintext;
}
public function isEncrypted(string $value): bool {
return str_starts_with($value, self::MARKER);
}
public function decryptIfEncrypted(string $value): string {
return $this->isEncrypted($value) ? $this->decrypt($value) : $value;
}
}
+2
View File
@@ -33,6 +33,8 @@ services:
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
APP_ENV: ${APP_ENV:-local}
APP_KEY: ${APP_KEY:-}
volumes:
- ./app:/var/www/html
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/zz-dev.ini:ro
+2
View File
@@ -13,3 +13,5 @@ RUN groupmod -o -g "${GID}" www-data \
&& usermod -o -u "${UID}" -g "${GID}" www-data
WORKDIR /var/www/html
RUN php -r 'exit((int)!extension_loaded("openssl"));'