diff --git a/README.md b/README.md
index de7108c..ca3a9b3 100644
--- a/README.md
+++ b/README.md
@@ -1,60 +1,145 @@
# XLX Server
-Собственный XLX/DMR-сервер для пользователей Pi-Star с платной регистрацией, автоматической выдачей ID, проверкой позывного по внешней базе и персональным доступом по логину/паролю.
+Собственный XLX/DMR-сервер для пользователей Pi-Star.
-## Цель проекта
+## Что уже есть
-Сделать управляемую платформу для DMR-пользователей:
+- платная регистрация;
+- проверка позывного по внутренней базе зарегистрированных пользователей;
+- YooKassa;
+- ручной перевод с подтверждением по чеку;
+- автоматическая выдача внутреннего DMR ID;
+- отдельный DMR-пароль для Pi-Star;
+- установка и управление `xlxd`;
+- главный пользовательский дашборд `/`;
+- админка настроек `xlxd` `/admin`;
+- экспорт активных пользователей для будущего access gateway.
-- регистрация пользователя по позывному;
-- автоматическая проверка позывного;
-- платная активация аккаунта;
-- автоматическая выдача внутреннего XLX/DMR ID;
-- генерация данных подключения для Pi-Star;
-- управление доступом к XLX-серверу;
-- админ-панель пользователей, оплат, ID, логов и подключений;
-- автообновление сервера и веб-панели.
+## Компоненты
-## Базовая архитектура
+- `xlxd` - мультипротокольный XLX reflector.
+- `public/index.php` - PHP API и web routes.
+- `public/assets` - стили и JS дашбордов.
+- `src/Domain` - регистрация, проверка дублей позывного, оплата, выдача ID, настройки XLX.
+- `database/schema.sql` - схема MariaDB/MySQL.
+- `scripts/install-xlxd.sh` - установка и сборка `xlxd`.
+- `scripts/xlx-control` - start/stop/status для systemd.
-Проект делится на несколько частей:
+## Быстрый старт XLX
-1. **XLX Core** — установленный и управляемый XLX reflector.
-2. **Access Gateway** — слой контроля доступа пользователей, паролей и разрешённых подключений.
-3. **Web Panel** — личный кабинет и админка.
-4. **Billing** — платная регистрация и продление доступа.
-5. **Callsign Verifier** — проверка позывных по базе радиолюбителей.
-6. **ID Allocator** — автоматическая выдача уникального ID.
-7. **Installer/Updater** — установка, обновления, бэкапы и восстановление.
+Для полной установки панели, базы, YooKassa-заготовки и самого `xlxd` используй общий установщик.
+
+### Установка без домена
+
+```bash
+sudo apt update
+sudo apt install -y git
+git clone https://github.com/viktor138irk/xlx.git
+cd xlx
+sudo bash scripts/install-system.sh
+```
+
+Установщик сам выберет адрес сервера:
+
+1. `XLX_PUBLIC_HOST`, если переменная задана;
+2. `hostname -f`;
+3. первый IP из `hostname -I`;
+4. `127.0.0.1`, если ничего не найдено.
+
+В конце установки будут показаны:
+
+- адрес сайта;
+- адрес админки;
+- admin token;
+- webhook URL для YooKassa;
+- логин и пароль БД.
+
+### Установка с доменом
+
+```bash
+sudo apt update
+sudo apt install -y git
+git clone https://github.com/viktor138irk/xlx.git
+cd xlx
+sudo XLX_PUBLIC_HOST=xlx.example.ru bash scripts/install-system.sh
+```
+
+Если домен появится позже, его можно поменять в админке `/admin`.
+
+### Установка с YooKassa
+
+```bash
+sudo \
+ XLX_PUBLIC_HOST=xlx.example.ru \
+ YOOKASSA_SHOP_ID=123456 \
+ YOOKASSA_SECRET_KEY=secret_key \
+ bash scripts/install-system.sh
+```
+
+Webhook для YooKassa установщик выведет в конце. Он выглядит так:
+
+```text
+http://адрес-сервера/api/webhooks/yookassa?token=секрет
+```
+
+Если сервер работает через HTTPS:
+
+```bash
+sudo \
+ XLX_PUBLIC_SCHEME=https \
+ XLX_PUBLIC_HOST=xlx.example.ru \
+ YOOKASSA_SHOP_ID=123456 \
+ YOOKASSA_SECRET_KEY=secret_key \
+ bash scripts/install-system.sh
+```
+
+### После установки
+
+Открой:
+
+- `/` - главная страница и регистрация пользователей;
+- `/admin` - админка настроек `xlxd`;
+- `/api/health` - проверка API.
+
+Для Pi-Star нужен UDP порт `62030`. Также обычно открывают:
+
+```bash
+sudo bash scripts/firewall-ufw.sh
+```
+
+### Управление XLX
+
+```bash
+sudo systemctl start xlxd
+sudo systemctl stop xlxd
+sudo systemctl restart xlxd
+sudo systemctl status xlxd
+sudo tail -f /var/log/xlxd.log
+```
+
+### Только установка reflector
+
+Если нужна только сборка и запуск `xlxd` без полной панели:
+
+```bash
+git clone https://github.com/viktor138irk/xlx.git
+cd xlx
+sudo mkdir -p /etc/xlx
+sudo cp config/xlxd.env.example /etc/xlx/xlxd.env
+sudo nano /etc/xlx/xlxd.env
+sudo bash scripts/install-xlxd.sh
+sudo systemctl start xlxd
+```
+
+## Документация
+
+- `docs/INSTALL_SYSTEM.md` - установка всей системы без привязки к домену или IP.
+- `docs/INSTALL_XLXD.md` - установка reflector.
+- `docs/PAYMENTS_AND_REGISTRATION.md` - регистрация, YooKassa и чеки.
+- `docs/ADMIN_DASHBOARD.md` - главная и админка.
## Статус
-v0.1.0 — проектирование и стартовый каркас.
+v0.2.0-dev.
-## Предполагаемый стек
-
-- Ubuntu 22.04/24.04 LTS;
-- XLX reflector;
-- PHP 8.3 или Python/FastAPI для панели/API;
-- MariaDB/MySQL;
-- Nginx;
-- systemd services;
-- YooKassa/ЮMoney/ручное подтверждение оплаты на первом этапе;
-- автоустановщик shell + миграции БД.
-
-## Pi-Star сценарий
-
-Пользователь после оплаты получает:
-
-- адрес сервера;
-- порт;
-- логин/позывной;
-- пароль;
-- назначенный ID;
-- инструкцию подключения Pi-Star.
-
-## Версионирование
-
-- малые фиксы: `+0.0.1`;
-- заметные функции: `+0.1.0`;
-- архитектурные изменения: `+1.0.0`.
+Следующий шаг - UDP access gateway для жесткой проверки оплаченного доступа перед `xlxd`.
diff --git a/VERSION b/VERSION
index 6e8bf73..70426f8 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.1.0
+0.2.0-dev
diff --git a/bootstrap.php b/bootstrap.php
new file mode 100644
index 0000000..5fd4350
--- /dev/null
+++ b/bootstrap.php
@@ -0,0 +1,26 @@
+ [
'name' => 'XLX Server',
- 'env' => 'production',
- 'debug' => false,
- 'base_url' => 'https://xlx.example.com',
- 'timezone' => 'Europe/Amsterdam',
+ 'env' => getenv('XLX_APP_ENV') ?: 'production',
+ 'debug' => (bool) (getenv('XLX_DEBUG') ?: false),
+ 'base_url' => $baseUrl,
+ 'timezone' => getenv('XLX_TIMEZONE') ?: 'Europe/Moscow',
],
'database' => [
- 'host' => '127.0.0.1',
- 'port' => 3306,
- 'name' => 'xlx_server',
- 'user' => 'xlx_user',
- 'password' => 'change_me',
+ 'host' => getenv('XLX_DB_HOST') ?: '127.0.0.1',
+ 'port' => (int) (getenv('XLX_DB_PORT') ?: 3306),
+ 'name' => getenv('XLX_DB_NAME') ?: 'xlx_server',
+ 'user' => getenv('XLX_DB_USER') ?: 'xlx_user',
+ 'password' => getenv('XLX_DB_PASSWORD') ?: 'change_me',
'charset' => 'utf8mb4',
],
'xlx' => [
- 'reflector_name' => 'XLX000',
- 'server_host' => 'xlx.example.com',
- 'dashboard_port' => 8080,
- 'dmr_port' => 62030,
- 'default_module' => 'A',
- 'config_path' => '/etc/xlx',
- 'service_name' => 'xlxd',
+ 'reflector_name' => getenv('XLX_REFLECTOR_NAME') ?: 'XLX000',
+ 'server_host' => getenv('XLX_PUBLIC_HOST') ?: $host,
+ 'sysop_callsign' => getenv('XLX_SYSOP_CALLSIGN') ?: 'N0CALL',
+ 'sysop_email' => getenv('XLX_SYSOP_EMAIL') ?: 'admin@' . $hostWithoutPort,
+ 'country' => getenv('XLX_COUNTRY') ?: 'RU',
+ 'dashboard_port' => (int) (getenv('XLX_DASHBOARD_PORT') ?: 8080),
+ 'dmr_port' => (int) (getenv('XLX_DMR_PORT') ?: 62030),
+ 'ysf_port' => (int) (getenv('XLX_YSF_PORT') ?: 42000),
+ 'default_module' => getenv('XLX_DEFAULT_MODULE') ?: 'A',
+ 'modules' => getenv('XLX_MODULES') ?: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+ 'config_path' => getenv('XLX_CONFIG_PATH') ?: '/etc/xlx',
+ 'install_path' => getenv('XLX_INSTALL_PATH') ?: '/xlxd',
+ 'source_path' => getenv('XLX_SOURCE_PATH') ?: '/usr/src/xlxd',
+ 'log_path' => getenv('XLX_LOG_PATH') ?: '/var/log/xlxd.log',
+ 'service_name' => getenv('XLX_SERVICE_NAME') ?: 'xlxd',
],
'id_allocator' => [
- 'min_id' => 9000001,
- 'max_id' => 9099999,
+ 'min_id' => (int) (getenv('XLX_MIN_ID') ?: 9000001),
+ 'max_id' => (int) (getenv('XLX_MAX_ID') ?: 9099999),
],
'billing' => [
- 'mode' => 'manual',
- 'currency' => 'RUB',
+ 'mode' => getenv('XLX_BILLING_MODE') ?: 'yookassa',
+ 'currency' => getenv('XLX_CURRENCY') ?: 'RUB',
+ 'default_duration_days' => (int) (getenv('XLX_DEFAULT_DURATION_DAYS') ?: 30),
+ 'manual_transfer_enabled' => (bool) (getenv('XLX_MANUAL_TRANSFER_ENABLED') ?: true),
+ 'manual_transfer_instructions' => getenv('XLX_MANUAL_TRANSFER_INSTRUCTIONS') ?: 'Переведите сумму тарифа и отправьте номер или ссылку чека на проверку.',
+ 'yookassa' => [
+ 'shop_id' => getenv('YOOKASSA_SHOP_ID') ?: 'change_me',
+ 'secret_key' => getenv('YOOKASSA_SECRET_KEY') ?: 'change_me',
+ 'return_url' => getenv('YOOKASSA_RETURN_URL') ?: $baseUrl . '/payment/return',
+ 'webhook_secret' => getenv('YOOKASSA_WEBHOOK_SECRET') ?: 'change_this_webhook_token',
+ 'capture' => (bool) (getenv('YOOKASSA_CAPTURE') ?: true),
+ ],
],
'security' => [
- 'session_name' => 'xlx_session',
- 'password_min_length' => 10,
- 'dmr_password_length' => 16,
+ 'session_name' => getenv('XLX_SESSION_NAME') ?: 'xlx_session',
+ 'password_min_length' => (int) (getenv('XLX_PASSWORD_MIN_LENGTH') ?: 10),
+ 'dmr_password_length' => (int) (getenv('XLX_DMR_PASSWORD_LENGTH') ?: 16),
+ ],
+
+ 'admin' => [
+ 'api_token' => getenv('XLX_ADMIN_TOKEN') ?: 'change_this_long_random_admin_token',
],
];
diff --git a/config/xlxd.env.example b/config/xlxd.env.example
new file mode 100644
index 0000000..657f397
--- /dev/null
+++ b/config/xlxd.env.example
@@ -0,0 +1,15 @@
+XLX_REFLECTOR_NAME=XLX000
+XLX_SERVER_HOST=xlx.example.com
+XLX_SYSOP_CALLSIGN=N0CALL
+XLX_SYSOP_EMAIL=admin@example.com
+XLX_COUNTRY=RU
+XLX_DASHBOARD_PORT=8080
+XLX_DMR_PORT=62030
+XLX_YSF_PORT=42000
+XLX_DEFAULT_MODULE=A
+XLX_MODULES=ABCDEFGHIJKLMNOPQRSTUVWXYZ
+XLX_INSTALL_PATH=/xlxd
+XLX_SOURCE_PATH=/usr/src/xlxd
+XLX_LOG_PATH=/var/log/xlxd.log
+XLX_SERVICE_NAME=xlxd
+XLX_REPO_URL=https://github.com/LX3JL/xlxd.git
diff --git a/database/schema.sql b/database/schema.sql
index 846543c..8d2c6af 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS dmr_ids (
CREATE TABLE IF NOT EXISTS tariffs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- name VARCHAR(120) NOT NULL,
+ name VARCHAR(120) NOT NULL UNIQUE,
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
currency CHAR(3) NOT NULL DEFAULT 'RUB',
duration_days INT UNSIGNED NOT NULL DEFAULT 30,
@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS payments (
currency CHAR(3) NOT NULL DEFAULT 'RUB',
provider VARCHAR(64) NOT NULL DEFAULT 'manual',
provider_payment_id VARCHAR(190) NULL,
+ confirmation_url TEXT NULL,
status ENUM('pending', 'paid', 'failed', 'cancelled', 'refunded') NOT NULL DEFAULT 'pending',
paid_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -56,6 +57,21 @@ CREATE TABLE IF NOT EXISTS payments (
CONSTRAINT fk_payments_tariff FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE IF NOT EXISTS payment_receipts (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ payment_id BIGINT UNSIGNED NOT NULL,
+ user_id BIGINT UNSIGNED NOT NULL,
+ receipt_reference VARCHAR(255) NOT NULL,
+ comment TEXT NULL,
+ status ENUM('pending', 'approved', 'rejected') NOT NULL DEFAULT 'pending',
+ reviewed_by_user_id BIGINT UNSIGNED NULL,
+ reviewed_at DATETIME NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_payment_receipts_payment FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE,
+ CONSTRAINT fk_payment_receipts_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_payment_receipts_reviewer FOREIGN KEY (reviewed_by_user_id) REFERENCES users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
CREATE TABLE IF NOT EXISTS access_credentials (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL UNIQUE,
@@ -101,6 +117,16 @@ CREATE TABLE IF NOT EXISTS audit_logs (
CONSTRAINT fk_audit_logs_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE IF NOT EXISTS xlx_service_events (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ event_type VARCHAR(64) NOT NULL,
+ status VARCHAR(64) NOT NULL,
+ message TEXT NULL,
+ payload_json JSON NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_xlx_service_events_created_at (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
INSERT INTO tariffs (name, price, currency, duration_days)
VALUES ('Стартовый доступ 30 дней', 300.00, 'RUB', 30)
-ON DUPLICATE KEY UPDATE name = name;
+ON DUPLICATE KEY UPDATE price = VALUES(price), currency = VALUES(currency), duration_days = VALUES(duration_days);
diff --git a/deploy/apache/xlx-panel.conf b/deploy/apache/xlx-panel.conf
new file mode 100644
index 0000000..d6e007c
--- /dev/null
+++ b/deploy/apache/xlx-panel.conf
@@ -0,0 +1,14 @@
+
+ ServerName _
+ DocumentRoot __PUBLIC_DIR__
+
+
+ Options FollowSymLinks
+ AllowOverride None
+ Require all granted
+ FallbackResource /index.php
+
+
+ ErrorLog ${APACHE_LOG_DIR}/xlx-panel-error.log
+ CustomLog ${APACHE_LOG_DIR}/xlx-panel-access.log combined
+
diff --git a/deploy/systemd/xlxd.service b/deploy/systemd/xlxd.service
new file mode 100644
index 0000000..d713aac
--- /dev/null
+++ b/deploy/systemd/xlxd.service
@@ -0,0 +1,17 @@
+[Unit]
+Description=XLX Multiprotocol Reflector
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=forking
+EnvironmentFile=/etc/xlx/xlxd.env
+ExecStart=/usr/local/sbin/xlx-control start
+ExecStop=/usr/local/sbin/xlx-control stop
+ExecReload=/usr/local/sbin/xlx-control restart
+PIDFile=/var/run/xlxd.pid
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/ADMIN_DASHBOARD.md b/docs/ADMIN_DASHBOARD.md
new file mode 100644
index 0000000..b47fb70
--- /dev/null
+++ b/docs/ADMIN_DASHBOARD.md
@@ -0,0 +1,33 @@
+# Админка и главный дашборд
+
+## Главная
+
+Главная страница доступна по адресу `/`.
+
+На ней есть:
+
+- краткое описание сервиса;
+- карточки статуса DMR/YooKassa/Pi-Star;
+- форма регистрации пользователя;
+- вывод ссылки YooKassa и инструкции для перевода по чеку.
+
+## Админка
+
+Админка доступна по адресу `/admin`.
+
+Чтобы загрузить или сохранить настройки, нужно ввести `X-Admin-Token` из `config/config.php`.
+
+Форма меняет:
+
+- имя reflector, например `XLX138`;
+- домен сервера;
+- sysop callsign и email;
+- страну;
+- DMR/YSF/dashboard порты;
+- модуль по умолчанию;
+- список активных модулей;
+- пути установки, исходников и логов;
+- имя systemd service;
+- URL репозитория `xlxd`.
+
+При сохранении с флагом `apply` API пытается обновить `/etc/xlx/xlxd.env`. Если PHP работает без прав записи в `/etc/xlx`, настройки все равно сохранятся в БД, а в админке будет виден готовый env-preview.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 17fdf22..7d7a530 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -56,18 +56,18 @@ XLX Server — это управляемая платформа для DMR-по
### 2.3 Callsign Verifier
-Модуль проверки позывного по внешним источникам.
+Модуль проверки позывного по внутренней базе уже зарегистрированных пользователей.
MVP-логика:
- пользователь вводит позывной;
- система нормализует его в верхний регистр;
- проверяет формат;
-- делает запрос к настроенной базе;
+- проверяет, что позывной еще не занят в таблице пользователей;
- сохраняет результат проверки;
- при ошибке отправляет заявку в ручную модерацию.
-Важно: внешний источник проверки должен быть заменяемым адаптером, чтобы не прибивать проект гвоздями к одной базе.
+Важно: уникальность дополнительно защищена индексом `users.callsign`, поэтому дубль не пройдет даже при одновременной регистрации.
### 2.4 ID Allocator
diff --git a/docs/INSTALL_SYSTEM.md b/docs/INSTALL_SYSTEM.md
new file mode 100644
index 0000000..4696774
--- /dev/null
+++ b/docs/INSTALL_SYSTEM.md
@@ -0,0 +1,87 @@
+# Установка всей системы
+
+`scripts/install-system.sh` ставит сразу:
+
+- Apache + PHP;
+- MariaDB;
+- базу и пользователя БД;
+- web-панель `/`;
+- админку `/admin`;
+- `xlxd`;
+- systemd service для reflector;
+- `/etc/xlx/xlxd.env`.
+
+## Без домена
+
+На чистом Ubuntu/Debian сервере:
+
+```bash
+git clone https://github.com/viktor138irk/xlx.git
+cd xlx
+sudo bash scripts/install-system.sh
+```
+
+Установщик сам выберет адрес:
+
+1. `XLX_PUBLIC_HOST`, если переменная задана;
+2. `hostname -f`, если он похож на реальное имя;
+3. первый IP из `hostname -I`;
+4. `127.0.0.1` как последний fallback.
+
+В конце он покажет:
+
+- URL сайта;
+- URL админки;
+- admin token;
+- webhook URL для YooKassa;
+- логин и пароль БД.
+
+## С доменом
+
+Если домен уже есть:
+
+```bash
+sudo XLX_PUBLIC_HOST=xlx.example.ru bash scripts/install-system.sh
+```
+
+Если домен появится позже, открой `/admin` и поменяй поле `Домен`. Это обновит настройки `xlxd` без ручного редактирования конфигов.
+
+## YooKassa
+
+Можно передать ключи сразу:
+
+```bash
+sudo \
+ XLX_PUBLIC_HOST=xlx.example.ru \
+ YOOKASSA_SHOP_ID=123456 \
+ YOOKASSA_SECRET_KEY=secret \
+ bash scripts/install-system.sh
+```
+
+Webhook будет такого вида:
+
+```text
+http://адрес-сервера/api/webhooks/yookassa?token=секрет
+```
+
+Если включишь HTTPS, укажи:
+
+```bash
+sudo XLX_PUBLIC_SCHEME=https XLX_PUBLIC_HOST=xlx.example.ru bash scripts/install-system.sh
+```
+
+## Повторный запуск
+
+Скрипт можно запускать повторно. Он:
+
+- обновит `config/config.php`;
+- обновит `/etc/xlx/xlxd.env`;
+- применит SQL-схему;
+- пересоберет `xlxd`;
+- перезагрузит Apache.
+
+Перед повторным запуском с существующей БД лучше передать старый пароль:
+
+```bash
+sudo XLX_DB_PASSWORD='старый-пароль' bash scripts/install-system.sh
+```
diff --git a/docs/INSTALL_XLXD.md b/docs/INSTALL_XLXD.md
new file mode 100644
index 0000000..28dd79f
--- /dev/null
+++ b/docs/INSTALL_XLXD.md
@@ -0,0 +1,71 @@
+# Установка XLX reflector
+
+Проект состоит из двух частей:
+
+- `xlxd` - сам мультипротокольный XLX reflector;
+- PHP-панель - регистрация, YooKassa, ручной чек, выдача DMR ID и данных для Pi-Star.
+
+## Требования
+
+- Ubuntu 22.04/24.04 или Debian 12.
+- Публичный статический IP.
+- DNS-имя, например `xlx.example.com`.
+- Открытый UDP `62030` для Pi-Star MMDVM.
+- Root-доступ на сервер.
+
+## Настройка XLX
+
+```bash
+git clone https://github.com/viktor138irk/xlx.git
+cd xlx
+sudo mkdir -p /etc/xlx
+sudo cp config/xlxd.env.example /etc/xlx/xlxd.env
+sudo nano /etc/xlx/xlxd.env
+```
+
+Минимально поменяй:
+
+```bash
+XLX_REFLECTOR_NAME=XLX138
+XLX_SERVER_HOST=xlx.example.com
+XLX_SYSOP_CALLSIGN=R0XXX
+XLX_SYSOP_EMAIL=admin@example.com
+XLX_COUNTRY=RU
+```
+
+## Установка сервера
+
+```bash
+sudo bash scripts/install-xlxd.sh
+sudo ./scripts/firewall-ufw.sh
+sudo systemctl start xlxd
+sudo systemctl status xlxd
+```
+
+Логи:
+
+```bash
+sudo tail -f /var/log/xlxd.log
+```
+
+## Управление
+
+```bash
+sudo systemctl start xlxd
+sudo systemctl stop xlxd
+sudo systemctl restart xlxd
+sudo systemctl status xlxd
+```
+
+## Pi-Star
+
+После оплаты панель выдаст:
+
+- сервер: `XLX_SERVER_HOST`;
+- порт: `62030`;
+- логин: позывной;
+- пароль: отдельный DMR-пароль;
+- DMR ID: выданный системой ID;
+- модуль: `A` по умолчанию.
+
+Полный DMR-пароль показывается один раз. Если пользователь потерял пароль, нужно выпустить новый.
diff --git a/docs/PAYMENTS_AND_REGISTRATION.md b/docs/PAYMENTS_AND_REGISTRATION.md
new file mode 100644
index 0000000..5025649
--- /dev/null
+++ b/docs/PAYMENTS_AND_REGISTRATION.md
@@ -0,0 +1,123 @@
+# Регистрация и оплата
+
+## Сценарий пользователя
+
+1. Пользователь отправляет позывной, email и пароль кабинета.
+2. Система нормализует позывной и проверяет, что такого позывного еще нет во внутренней базе.
+3. Если позывной принят, создается платеж.
+4. Пользователь выбирает:
+ - оплату через YooKassa;
+ - перевод с отправкой чека на ручную проверку.
+5. После подтверждения оплаты система:
+ - активирует аккаунт;
+ - назначает внутренний DMR ID;
+ - генерирует отдельный DMR-пароль;
+ - отдает данные подключения Pi-Star.
+
+## YooKassa
+
+В `config/config.php`:
+
+```php
+'billing' => [
+ 'mode' => 'yookassa',
+ 'yookassa' => [
+ 'shop_id' => '...',
+ 'secret_key' => '...',
+ 'return_url' => 'https://xlx.example.com/payment/return',
+ 'webhook_secret' => 'long-random-token',
+ 'capture' => true,
+ ],
+],
+```
+
+Webhook URL:
+
+```text
+https://xlx.example.com/api/webhooks/yookassa?token=long-random-token
+```
+
+Обрабатывается событие:
+
+```text
+payment.succeeded
+```
+
+Повторный webhook безопасен: если платеж уже оплачен, система вернет текущий доступ без повторной выдачи ID.
+
+## Перевод с чеком
+
+Если пользователь оплатил переводом, он отправляет номер или ссылку чека:
+
+```http
+POST /api/payments/receipt
+Content-Type: application/json
+
+{
+ "payment_id": 1,
+ "user_id": 1,
+ "receipt_reference": "https://bank.example/receipt/123",
+ "comment": "Оплата за R0XXX"
+}
+```
+
+Админ подтверждает чек:
+
+```http
+POST /api/admin/receipts/approve
+X-Admin-Token: change_this_long_random_admin_token
+Content-Type: application/json
+
+{
+ "receipt_id": 1,
+ "admin_user_id": 1
+}
+```
+
+## Основные API
+
+Регистрация:
+
+```http
+POST /api/register
+Content-Type: application/json
+
+{
+ "callsign": "R0XXX",
+ "email": "user@example.com",
+ "password": "strong-password"
+}
+```
+
+Подтверждение платежа админом:
+
+```http
+POST /api/admin/payments/confirm
+X-Admin-Token: change_this_long_random_admin_token
+Content-Type: application/json
+
+{
+ "payment_id": 1,
+ "admin_user_id": 1
+}
+```
+
+Экспорт активных пользователей для gateway:
+
+```http
+GET /api/admin/access/export
+X-Admin-Token: change_this_long_random_admin_token
+```
+
+## Pi-Star и пароль
+
+`xlxd` поднимает сам reflector и принимает MMDVM/DMR на UDP `62030`. Персональная проверка логина/пароля на уровне Pi-Star требует отдельного access gateway/proxy или auth-слоя перед reflector.
+
+В проекте уже есть:
+
+- хранение отдельного DMR-пароля;
+- включение/выключение доступа;
+- экспорт активных пользователей;
+- единая активация после YooKassa или ручного чека.
+
+Следующий слой - UDP gateway, который будет читать экспорт активных пользователей и пропускать к `xlxd` только оплаченные подключения.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 82bb7af..8ce854e 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -20,7 +20,7 @@
- [ ] форма регистрации;
- [ ] нормализация позывного;
- [ ] проверка формата позывного;
-- [ ] адаптер внешней базы позывных;
+- [x] проверка дублей позывных по внутренней базе;
- [ ] ручная модерация спорных заявок;
- [ ] автоматическая выдача ID;
- [ ] защита от дублей;
diff --git a/public/assets/admin.js b/public/assets/admin.js
new file mode 100644
index 0000000..00067ba
--- /dev/null
+++ b/public/assets/admin.js
@@ -0,0 +1,90 @@
+const tokenInput = document.querySelector('#adminToken');
+const form = document.querySelector('#settingsForm');
+const envPreview = document.querySelector('#envPreview');
+const adminResult = document.querySelector('#adminResult');
+const loadButton = document.querySelector('#loadSettings');
+const saveButton = document.querySelector('#saveSettings');
+const applyButton = document.querySelector('#applySettings');
+
+function token() {
+ return tokenInput.value.trim();
+}
+
+function setResult(text) {
+ adminResult.textContent = text;
+}
+
+function formData() {
+ return Object.fromEntries(new FormData(form).entries());
+}
+
+function fillForm(settings) {
+ for (const [key, value] of Object.entries(settings)) {
+ const input = form.elements.namedItem(key);
+ if (input) {
+ input.value = value ?? '';
+ }
+ }
+}
+
+async function loadSettings() {
+ setResult('Загружаем...');
+ const response = await fetch('/api/admin/xlxd/settings', {
+ headers: { 'X-Admin-Token': token() },
+ });
+ const payload = await response.json();
+ if (!payload.ok) {
+ setResult(payload.error || 'Ошибка загрузки');
+ return;
+ }
+
+ fillForm(payload.data.settings);
+ envPreview.textContent = payload.data.env;
+ setResult('Настройки загружены.');
+}
+
+async function saveSettings(apply) {
+ setResult(apply ? 'Сохраняем и применяем...' : 'Сохраняем...');
+ const response = await fetch('/api/admin/xlxd/settings', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Admin-Token': token(),
+ },
+ body: JSON.stringify({ settings: formData(), apply }),
+ });
+ const payload = await response.json();
+ if (!payload.ok) {
+ setResult(payload.error || 'Ошибка сохранения');
+ return;
+ }
+
+ envPreview.textContent = payload.data.env;
+ setResult(payload.data.message);
+}
+
+loadButton?.addEventListener('click', loadSettings);
+saveButton?.addEventListener('click', () => saveSettings(false));
+applyButton?.addEventListener('click', () => saveSettings(true));
+
+form?.addEventListener('input', () => {
+ const data = formData();
+ const lines = {
+ XLX_REFLECTOR_NAME: data.reflector_name,
+ XLX_SERVER_HOST: data.server_host,
+ XLX_SYSOP_CALLSIGN: data.sysop_callsign,
+ XLX_SYSOP_EMAIL: data.sysop_email,
+ XLX_COUNTRY: data.country,
+ XLX_DASHBOARD_PORT: data.dashboard_port,
+ XLX_DMR_PORT: data.dmr_port,
+ XLX_YSF_PORT: data.ysf_port,
+ XLX_DEFAULT_MODULE: data.default_module,
+ XLX_MODULES: data.modules,
+ XLX_INSTALL_PATH: data.install_path,
+ XLX_SOURCE_PATH: data.source_path,
+ XLX_LOG_PATH: data.log_path,
+ XLX_SERVICE_NAME: data.service_name,
+ XLX_REPO_URL: data.repo_url,
+ };
+ envPreview.textContent = Object.entries(lines).map(([key, value]) => `${key}=${value || ''}`).join('\n');
+});
diff --git a/public/assets/app.js b/public/assets/app.js
new file mode 100644
index 0000000..6f0ca10
--- /dev/null
+++ b/public/assets/app.js
@@ -0,0 +1,35 @@
+const form = document.querySelector('#registerForm');
+const result = document.querySelector('#registerResult');
+
+form?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ result.textContent = 'Отправляем заявку...';
+
+ const data = Object.fromEntries(new FormData(form).entries());
+ const response = await fetch('/api/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+ const payload = await response.json();
+
+ if (!payload.ok) {
+ result.textContent = payload.error || 'Ошибка регистрации';
+ return;
+ }
+
+ const payment = payload.data.payment || {};
+ const lines = [
+ `Заявка создана: ${payload.data.callsign}`,
+ `Статус: ${payload.data.status}`,
+ ];
+ if (payment.confirmation_url) {
+ lines.push(`YooKassa: ${payment.confirmation_url}`);
+ }
+ if (payment.manual_transfer?.instructions) {
+ lines.push(`Перевод по чеку: ${payment.manual_transfer.instructions}`);
+ lines.push(`ID платежа для чека: ${payment.id}`);
+ }
+
+ result.textContent = lines.join('\n');
+});
diff --git a/public/assets/styles.css b/public/assets/styles.css
new file mode 100644
index 0000000..1567038
--- /dev/null
+++ b/public/assets/styles.css
@@ -0,0 +1,244 @@
+:root {
+ color-scheme: dark;
+ --bg: #07080d;
+ --panel: #111522;
+ --panel-2: #171d2d;
+ --text: #f6f7fb;
+ --muted: #aeb7c9;
+ --line: #2a3347;
+ --accent: #5ee0b8;
+ --accent-2: #88a7ff;
+ --danger: #ff7d7d;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: radial-gradient(circle at top left, #17253c 0, transparent 34rem), var(--bg);
+ color: var(--text);
+ font-family: Arial, Helvetica, sans-serif;
+}
+
+a {
+ color: inherit;
+}
+
+.shell {
+ width: min(1180px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 36px 0 56px;
+}
+
+.hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr);
+ gap: 28px;
+ align-items: stretch;
+ min-height: 420px;
+}
+
+.hero.compact {
+ min-height: 260px;
+ grid-template-columns: 1fr;
+}
+
+.eyebrow {
+ margin: 0 0 18px;
+ color: var(--accent);
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+}
+
+h1,
+h2 {
+ margin: 0;
+ line-height: .98;
+}
+
+h1 {
+ max-width: 900px;
+ font-size: clamp(44px, 7vw, 86px);
+ letter-spacing: 0;
+}
+
+h2 {
+ font-size: 32px;
+}
+
+.lead {
+ max-width: 720px;
+ margin: 22px 0 0;
+ color: var(--muted);
+ font-size: 22px;
+ line-height: 1.35;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 28px;
+}
+
+.button {
+ border: 0;
+ border-radius: 8px;
+ padding: 14px 20px;
+ background: var(--accent);
+ color: #04120f;
+ font: inherit;
+ font-weight: 800;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.button.ghost {
+ border: 1px solid var(--line);
+ background: transparent;
+ color: var(--text);
+}
+
+.status-panel,
+.tile,
+.panel {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--panel) 92%, transparent);
+}
+
+.status-panel {
+ display: grid;
+ align-content: end;
+ gap: 14px;
+ padding: 22px;
+}
+
+.status-panel div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 18px;
+ padding-bottom: 14px;
+ border-bottom: 1px solid var(--line);
+}
+
+.status-panel div:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
+}
+
+.status-panel span,
+.tile p,
+label {
+ color: var(--muted);
+}
+
+.status-panel strong {
+ font-size: 34px;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ margin-top: 24px;
+}
+
+.tile {
+ padding: 22px;
+}
+
+.num {
+ display: block;
+ margin-bottom: 34px;
+ color: var(--accent-2);
+ font-size: 18px;
+ font-weight: 800;
+}
+
+.tile p {
+ margin: 16px 0 0;
+ font-size: 18px;
+ line-height: 1.45;
+}
+
+.panel {
+ margin-top: 24px;
+ padding: 24px;
+}
+
+.panel.split {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(340px, .8fr);
+ gap: 24px;
+}
+
+.form,
+.settings-form {
+ display: grid;
+ gap: 14px;
+}
+
+.settings-form {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+label {
+ display: grid;
+ gap: 8px;
+ font-size: 14px;
+ font-weight: 700;
+}
+
+input {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 14px;
+ background: var(--panel-2);
+ color: var(--text);
+ font: inherit;
+ font-size: 17px;
+}
+
+input:focus {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.result {
+ min-height: 72px;
+ overflow: auto;
+ margin: 0;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 14px;
+ background: #06070b;
+ color: var(--text);
+ font-size: 14px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+}
+
+.env-preview {
+ min-height: 420px;
+}
+
+@media (max-width: 880px) {
+ .hero,
+ .grid,
+ .panel.split,
+ .settings-form {
+ grid-template-columns: 1fr;
+ }
+
+ h1 {
+ font-size: 44px;
+ }
+}
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..bd427b4
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,123 @@
+pdo();
+
+$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
+
+try {
+ if ($method === 'GET' && ($path === '/' || $path === '/dashboard')) {
+ require __DIR__ . '/views/home.php';
+ exit;
+ }
+
+ if ($method === 'GET' && $path === '/admin') {
+ require __DIR__ . '/views/admin.php';
+ exit;
+ }
+
+ if ($method === 'GET' && $path === '/api/health') {
+ Response::json(['ok' => true, 'service' => 'xlx-panel']);
+ }
+
+ if ($method === 'GET' && $path === '/api/callsigns/check') {
+ $paymentService = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
+ $service = new RegistrationService($config, $paymentService);
+ Response::json(['ok' => true, 'data' => $service->checkCallsign($pdo, (string) ($_GET['callsign'] ?? ''))]);
+ }
+
+ if ($method === 'POST' && $path === '/api/register') {
+ $paymentService = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
+ $service = new RegistrationService($config, $paymentService);
+ Response::json(['ok' => true, 'data' => $service->register($pdo, Input::json())], 201);
+ }
+
+ if ($method === 'POST' && $path === '/api/payments/receipt') {
+ $input = Input::json();
+ $service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
+ Response::json(['ok' => true, 'data' => $service->submitReceipt(
+ $pdo,
+ (int) ($input['payment_id'] ?? 0),
+ (int) ($input['user_id'] ?? 0),
+ (string) ($input['receipt_reference'] ?? ''),
+ isset($input['comment']) ? (string) $input['comment'] : null,
+ )], 201);
+ }
+
+ if ($method === 'POST' && $path === '/api/admin/payments/confirm') {
+ Security::requireAdminToken($config);
+ $input = Input::json();
+ $service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
+ Response::json(['ok' => true, 'data' => $service->confirm($pdo, (int) ($input['payment_id'] ?? 0), $input['admin_user_id'] ?? null)]);
+ }
+
+ if ($method === 'POST' && $path === '/api/admin/receipts/approve') {
+ Security::requireAdminToken($config);
+ $input = Input::json();
+ $service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
+ Response::json(['ok' => true, 'data' => $service->approveReceipt($pdo, (int) ($input['receipt_id'] ?? 0), $input['admin_user_id'] ?? null)]);
+ }
+
+ if ($method === 'POST' && $path === '/api/webhooks/yookassa') {
+ $token = $_SERVER['HTTP_X_WEBHOOK_TOKEN'] ?? ($_GET['token'] ?? '');
+ if (!hash_equals((string) $config->get('billing.yookassa.webhook_secret'), (string) $token)) {
+ Response::error('Webhook token is missing or invalid.', 401);
+ }
+
+ $input = Input::json();
+ if (($input['event'] ?? '') !== 'payment.succeeded') {
+ Response::json(['ok' => true, 'ignored' => true]);
+ }
+
+ $providerPaymentId = (string) ($input['object']['id'] ?? '');
+ $service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
+ Response::json(['ok' => true, 'data' => $service->confirmProviderPayment($pdo, $providerPaymentId)]);
+ }
+
+ if ($method === 'GET' && $path === '/api/admin/access/export') {
+ Security::requireAdminToken($config);
+ header('Content-Type: text/csv; charset=utf-8');
+ header('Content-Disposition: attachment; filename="xlx-active-users.csv"');
+ echo (new AccessExportService())->activeUsersCsv($pdo);
+ exit;
+ }
+
+ if ($method === 'GET' && $path === '/api/admin/xlxd/settings') {
+ Security::requireAdminToken($config);
+ $service = new XlxdSettingsService($config);
+ $settings = $service->current($pdo);
+ Response::json([
+ 'ok' => true,
+ 'data' => [
+ 'settings' => $settings,
+ 'env' => $service->renderEnv($settings),
+ ],
+ ]);
+ }
+
+ if ($method === 'POST' && $path === '/api/admin/xlxd/settings') {
+ Security::requireAdminToken($config);
+ $input = Input::json();
+ $service = new XlxdSettingsService($config);
+ Response::json(['ok' => true, 'data' => $service->save($pdo, $input['settings'] ?? [], (bool) ($input['apply'] ?? false))]);
+ }
+
+ Response::error('Route not found.', 404);
+} catch (Throwable $throwable) {
+ Response::error($throwable->getMessage(), 400);
+}
diff --git a/public/views/admin.php b/public/views/admin.php
new file mode 100644
index 0000000..bd4fac8
--- /dev/null
+++ b/public/views/admin.php
@@ -0,0 +1,87 @@
+
+
+
+
+
+ XLX Admin
+
+
+
+
+
+
+
Админка
+
Настройка XLX без ручного редактирования конфигов
+
Меняй имя reflector, домен, порты, модули и пути. Настройки сохраняются в БД и могут сразу применяться в `/etc/xlx/xlxd.env`.
+
+
+
+
+
+
+
+
+
+
diff --git a/public/views/home.php b/public/views/home.php
new file mode 100644
index 0000000..3dabf73
--- /dev/null
+++ b/public/views/home.php
@@ -0,0 +1,78 @@
+
+
+
+
+
+ XLX Server
+
+
+
+
+
+
+
XLX / DMR / Pi-Star
+
Личный доступ к DMR-серверу без ручной настройки
+
Регистрация по позывному без дублей, оплата через YooKassa или чек перевода, автоматический DMR ID и пароль для Pi-Star.
+
+
+
+
+ DMR порт
+ 62030
+
+
+ Модуль
+ A
+
+
+ Оплата
+ YooKassa
+
+
+
+
+
+
+ 01
+ Проверка позывного
+ Система проверяет формат и занятость позывного во внутренней базе.
+
+
+ 02
+ Оплата доступа
+ Платеж через YooKassa или перевод с отправкой чека на подтверждение.
+
+
+ 03
+ Данные Pi-Star
+ После оплаты выдаются сервер, порт, логин, пароль, DMR ID и модуль.
+
+
+
+
+
+
Регистрация
+
Создать заявку
+
После отправки появится ссылка YooKassa и инструкция для оплаты переводом.
+
+
+
+
+
+
+
diff --git a/scripts/firewall-ufw.sh b/scripts/firewall-ufw.sh
new file mode 100644
index 0000000..79a4dcb
--- /dev/null
+++ b/scripts/firewall-ufw.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ufw allow 22/tcp
+ufw allow 80/tcp
+ufw allow 443/tcp
+ufw allow 8880/udp
+ufw allow 10001/udp
+ufw allow 10002/udp
+ufw allow 30001/udp
+ufw allow 30051/udp
+ufw allow 42000/udp
+ufw allow 62030/udp
+
+ufw status verbose
diff --git a/scripts/install-system.sh b/scripts/install-system.sh
new file mode 100644
index 0000000..aecc464
--- /dev/null
+++ b/scripts/install-system.sh
@@ -0,0 +1,170 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ "${EUID}" -ne 0 ]]; then
+ echo "Run as root: sudo bash scripts/install-system.sh"
+ exit 1
+fi
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PUBLIC_DIR="$REPO_ROOT/public"
+
+detect_host() {
+ if [[ -n "${XLX_PUBLIC_HOST:-}" ]]; then
+ echo "$XLX_PUBLIC_HOST"
+ return
+ fi
+
+ local fqdn
+ fqdn="$(hostname -f 2>/dev/null || true)"
+ if [[ -n "$fqdn" && "$fqdn" != "localhost" && "$fqdn" != "localhost.localdomain" ]]; then
+ echo "$fqdn"
+ return
+ fi
+
+ local ip
+ ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
+ if [[ -n "$ip" ]]; then
+ echo "$ip"
+ return
+ fi
+
+ echo "127.0.0.1"
+}
+
+random_token() {
+ local token
+ token="$(tr -dc 'A-Za-z0-9' "$REPO_ROOT/config/config.php" < /etc/xlx/xlxd.env < /etc/apache2/sites-available/xlx-panel.conf
+a2dissite 000-default.conf >/dev/null 2>&1 || true
+a2ensite xlx-panel.conf
+systemctl reload apache2
+
+bash "$REPO_ROOT/scripts/install-xlxd.sh"
+systemctl start xlxd || true
+
+cat < "$module_file"
+ local i char
+ for ((i = 0; i < ${#XLX_MODULES}; i++)); do
+ char="${XLX_MODULES:$i:1}"
+ printf "%s\n" "$char" >> "$module_file"
+ done
+}
+
+start() {
+ render_modules
+ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
+ echo "xlxd already running with pid $(cat "$PID_FILE")"
+ exit 0
+ fi
+
+ mkdir -p "$(dirname "$XLX_LOG_PATH")"
+ cd "$XLX_INSTALL_PATH"
+ ./xlxd -d -p "$PID_FILE" >> "$XLX_LOG_PATH" 2>&1
+}
+
+stop() {
+ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
+ kill "$(cat "$PID_FILE")"
+ rm -f "$PID_FILE"
+ return
+ fi
+
+ pkill -f "$XLX_INSTALL_PATH/xlxd" || true
+ rm -f "$PID_FILE"
+}
+
+status() {
+ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
+ echo "xlxd is running with pid $(cat "$PID_FILE")"
+ exit 0
+ fi
+ echo "xlxd is not running"
+ exit 3
+}
+
+case "${1:-}" in
+ start) start ;;
+ stop) stop ;;
+ restart) stop; start ;;
+ status) status ;;
+ render-modules) render_modules ;;
+ *) usage; exit 2 ;;
+esac
diff --git a/src/Domain/AccessCredentialService.php b/src/Domain/AccessCredentialService.php
new file mode 100644
index 0000000..f7664db
--- /dev/null
+++ b/src/Domain/AccessCredentialService.php
@@ -0,0 +1,44 @@
+config->get('security.dmr_password_length', 16);
+ $password = Security::randomPassword($length);
+ $hash = password_hash($password, PASSWORD_DEFAULT);
+ $hint = Security::passwordHint($password);
+
+ $sql = <<<'SQL'
+INSERT INTO access_credentials (user_id, username, dmr_password_hash, dmr_password_hint, is_enabled, last_rotated_at)
+VALUES (?, ?, ?, ?, 1, NOW())
+ON DUPLICATE KEY UPDATE
+ username = VALUES(username),
+ dmr_password_hash = VALUES(dmr_password_hash),
+ dmr_password_hint = VALUES(dmr_password_hint),
+ is_enabled = 1,
+ last_rotated_at = NOW()
+SQL;
+
+ $statement = $pdo->prepare($sql);
+ $statement->execute([$userId, $callsign, $hash, $hint]);
+
+ return [
+ 'username' => $callsign,
+ 'password' => $password,
+ 'password_hint' => $hint,
+ ];
+ }
+}
diff --git a/src/Domain/AccessExportService.php b/src/Domain/AccessExportService.php
new file mode 100644
index 0000000..f0ce963
--- /dev/null
+++ b/src/Domain/AccessExportService.php
@@ -0,0 +1,33 @@
+query(
+ "SELECT u.callsign, d.dmr_id, c.username, c.dmr_password_hint, u.access_expires_at
+ FROM users u
+ JOIN dmr_ids d ON d.user_id = u.id
+ JOIN access_credentials c ON c.user_id = u.id
+ WHERE u.status = 'active'
+ AND c.is_enabled = 1
+ AND (u.access_expires_at IS NULL OR u.access_expires_at > NOW())
+ ORDER BY u.callsign"
+ )->fetchAll();
+
+ $handle = fopen('php://temp', 'r+');
+ fputcsv($handle, ['callsign', 'dmr_id', 'username', 'password_hint', 'access_expires_at']);
+ foreach ($rows as $row) {
+ fputcsv($handle, $row);
+ }
+ rewind($handle);
+
+ return (string) stream_get_contents($handle);
+ }
+}
diff --git a/src/Domain/IdAllocator.php b/src/Domain/IdAllocator.php
new file mode 100644
index 0000000..795e482
--- /dev/null
+++ b/src/Domain/IdAllocator.php
@@ -0,0 +1,47 @@
+prepare('SELECT dmr_id FROM dmr_ids WHERE user_id = ? LIMIT 1');
+ $existing->execute([$userId]);
+ $dmrId = $existing->fetchColumn();
+ if ($dmrId !== false) {
+ return (int) $dmrId;
+ }
+
+ $min = (int) $this->config->get('id_allocator.min_id', 9000001);
+ $max = (int) $this->config->get('id_allocator.max_id', 9099999);
+
+ $next = $pdo->query('SELECT COALESCE(MAX(dmr_id), 0) + 1 FROM dmr_ids')->fetchColumn();
+ $candidate = max($min, (int) $next);
+
+ while ($candidate <= $max) {
+ try {
+ $insert = $pdo->prepare('INSERT INTO dmr_ids (user_id, dmr_id, status) VALUES (?, ?, ?)');
+ $insert->execute([$userId, $candidate, 'active']);
+ return $candidate;
+ } catch (\PDOException $exception) {
+ if ($exception->getCode() !== '23000') {
+ throw $exception;
+ }
+ $candidate++;
+ }
+ }
+
+ throw new RuntimeException('DMR ID range is exhausted.');
+ }
+}
diff --git a/src/Domain/PaymentService.php b/src/Domain/PaymentService.php
new file mode 100644
index 0000000..21e94c4
--- /dev/null
+++ b/src/Domain/PaymentService.php
@@ -0,0 +1,244 @@
+query('SELECT * FROM tariffs WHERE is_active = 1 ORDER BY id ASC LIMIT 1')->fetch();
+ if (!$tariff) {
+ return null;
+ }
+
+ $provider = (string) $this->config->get('billing.mode', 'manual');
+ $statement = $pdo->prepare('INSERT INTO payments (user_id, tariff_id, amount, currency, provider, status) VALUES (?, ?, ?, ?, ?, ?)');
+ $statement->execute([
+ $userId,
+ $tariff['id'],
+ $tariff['price'],
+ $tariff['currency'],
+ $provider,
+ 'pending',
+ ]);
+
+ $paymentId = (int) $pdo->lastInsertId();
+ $result = [
+ 'id' => $paymentId,
+ 'amount' => (float) $tariff['price'],
+ 'currency' => $tariff['currency'],
+ 'tariff' => $tariff['name'],
+ 'provider' => $provider,
+ ];
+
+ if ($provider === 'yookassa' && $this->yooKassaClient !== null) {
+ $user = $this->findUser($pdo, $userId);
+ $created = $this->yooKassaClient->createPayment([
+ 'id' => $paymentId,
+ 'amount' => $tariff['price'],
+ 'currency' => $tariff['currency'],
+ ], $user, $tariff);
+
+ $confirmationUrl = $created['confirmation']['confirmation_url'] ?? null;
+ $pdo->prepare('UPDATE payments SET provider_payment_id = ?, confirmation_url = ? WHERE id = ?')
+ ->execute([$created['id'] ?? null, $confirmationUrl, $paymentId]);
+
+ $result['provider_payment_id'] = $created['id'] ?? null;
+ $result['confirmation_url'] = $confirmationUrl;
+ }
+
+ if ((bool) $this->config->get('billing.manual_transfer_enabled', false)) {
+ $result['manual_transfer'] = [
+ 'enabled' => true,
+ 'instructions' => $this->config->get('billing.manual_transfer_instructions'),
+ ];
+ }
+
+ return $result;
+ }
+
+ public function confirm(PDO $pdo, int $paymentId, ?int $adminUserId = null): array
+ {
+ $pdo->beginTransaction();
+ try {
+ $paymentStatement = $pdo->prepare(
+ 'SELECT p.*, t.duration_days, u.callsign FROM payments p JOIN users u ON u.id = p.user_id LEFT JOIN tariffs t ON t.id = p.tariff_id WHERE p.id = ? FOR UPDATE'
+ );
+ $paymentStatement->execute([$paymentId]);
+ $payment = $paymentStatement->fetch();
+ if (!$payment) {
+ throw new RuntimeException('Payment was not found.');
+ }
+ if ($payment['status'] === 'paid') {
+ $pdo->commit();
+ return $this->currentAccess($pdo, (int) $payment['user_id']);
+ }
+ if ($payment['status'] !== 'pending') {
+ throw new RuntimeException('Payment is not pending.');
+ }
+
+ $durationDays = (int) ($payment['duration_days'] ?? $this->config->get('billing.default_duration_days', 30));
+ $expiresAt = (new \DateTimeImmutable('now'))->modify('+' . $durationDays . ' days')->format('Y-m-d H:i:s');
+
+ $pdo->prepare('UPDATE payments SET status = ?, paid_at = NOW() WHERE id = ?')->execute(['paid', $paymentId]);
+ $pdo->prepare('UPDATE users SET status = ?, access_expires_at = ? WHERE id = ?')->execute(['active', $expiresAt, $payment['user_id']]);
+
+ $dmrId = $this->idAllocator->allocate($pdo, (int) $payment['user_id']);
+ $credential = $this->accessCredentialService->enableForUser($pdo, (int) $payment['user_id'], $payment['callsign']);
+
+ $pdo->prepare(
+ 'INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, payload_json) VALUES (?, ?, ?, ?, ?)'
+ )->execute([
+ $adminUserId,
+ 'payment.confirmed',
+ 'payment',
+ $paymentId,
+ json_encode(['user_id' => (int) $payment['user_id'], 'dmr_id' => $dmrId], JSON_UNESCAPED_SLASHES),
+ ]);
+
+ $pdo->commit();
+
+ return [
+ 'user_id' => (int) $payment['user_id'],
+ 'callsign' => $payment['callsign'],
+ 'dmr_id' => $dmrId,
+ 'access_expires_at' => $expiresAt,
+ 'connection' => [
+ 'server_host' => $this->config->get('xlx.server_host'),
+ 'dmr_port' => (int) $this->config->get('xlx.dmr_port', 62030),
+ 'default_module' => $this->config->get('xlx.default_module', 'A'),
+ 'username' => $credential['username'],
+ 'password' => $credential['password'],
+ ],
+ ];
+ } catch (\Throwable $throwable) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $throwable;
+ }
+ }
+
+ public function submitReceipt(PDO $pdo, int $paymentId, int $userId, string $receiptReference, ?string $comment): array
+ {
+ $payment = $pdo->prepare('SELECT id, user_id, status FROM payments WHERE id = ? AND user_id = ? LIMIT 1');
+ $payment->execute([$paymentId, $userId]);
+ $row = $payment->fetch();
+ if (!$row) {
+ throw new RuntimeException('Payment was not found for this user.');
+ }
+ if ($row['status'] !== 'pending') {
+ throw new RuntimeException('Only pending payments can receive receipts.');
+ }
+ if (trim($receiptReference) === '') {
+ throw new RuntimeException('Receipt reference is required.');
+ }
+
+ $statement = $pdo->prepare(
+ 'INSERT INTO payment_receipts (payment_id, user_id, receipt_reference, comment) VALUES (?, ?, ?, ?)'
+ );
+ $statement->execute([$paymentId, $userId, trim($receiptReference), $comment]);
+
+ return [
+ 'receipt_id' => (int) $pdo->lastInsertId(),
+ 'payment_id' => $paymentId,
+ 'status' => 'pending',
+ ];
+ }
+
+ public function approveReceipt(PDO $pdo, int $receiptId, ?int $adminUserId = null): array
+ {
+ $receipt = $pdo->prepare('SELECT * FROM payment_receipts WHERE id = ? LIMIT 1');
+ $receipt->execute([$receiptId]);
+ $row = $receipt->fetch();
+ if (!$row) {
+ throw new RuntimeException('Receipt was not found.');
+ }
+ if ($row['status'] !== 'pending') {
+ throw new RuntimeException('Receipt is not pending.');
+ }
+
+ $result = $this->confirm($pdo, (int) $row['payment_id'], $adminUserId);
+
+ $pdo->prepare('UPDATE payment_receipts SET status = ?, reviewed_by_user_id = ?, reviewed_at = NOW() WHERE id = ?')
+ ->execute(['approved', $adminUserId, $receiptId]);
+
+ return $result;
+ }
+
+ public function confirmProviderPayment(PDO $pdo, string $providerPaymentId): array
+ {
+ if ($this->yooKassaClient !== null) {
+ $providerPayment = $this->yooKassaClient->fetchPayment($providerPaymentId);
+ if (($providerPayment['status'] ?? null) !== 'succeeded' || ($providerPayment['paid'] ?? false) !== true) {
+ throw new RuntimeException('YooKassa payment is not succeeded.');
+ }
+ }
+
+ $statement = $pdo->prepare('SELECT id FROM payments WHERE provider = ? AND provider_payment_id = ? LIMIT 1');
+ $statement->execute(['yookassa', $providerPaymentId]);
+ $paymentId = $statement->fetchColumn();
+ if ($paymentId === false) {
+ throw new RuntimeException('Local payment was not found.');
+ }
+
+ return $this->confirm($pdo, (int) $paymentId);
+ }
+
+ private function findUser(PDO $pdo, int $userId): array
+ {
+ $statement = $pdo->prepare('SELECT id, callsign, email FROM users WHERE id = ? LIMIT 1');
+ $statement->execute([$userId]);
+ $user = $statement->fetch();
+ if (!$user) {
+ throw new RuntimeException('User was not found.');
+ }
+
+ return $user;
+ }
+
+ private function currentAccess(PDO $pdo, int $userId): array
+ {
+ $statement = $pdo->prepare(
+ 'SELECT u.id AS user_id, u.callsign, u.access_expires_at, d.dmr_id, c.username, c.dmr_password_hint
+ FROM users u
+ LEFT JOIN dmr_ids d ON d.user_id = u.id
+ LEFT JOIN access_credentials c ON c.user_id = u.id
+ WHERE u.id = ?
+ LIMIT 1'
+ );
+ $statement->execute([$userId]);
+ $row = $statement->fetch();
+ if (!$row) {
+ throw new RuntimeException('User was not found.');
+ }
+
+ return [
+ 'user_id' => (int) $row['user_id'],
+ 'callsign' => $row['callsign'],
+ 'dmr_id' => $row['dmr_id'] !== null ? (int) $row['dmr_id'] : null,
+ 'access_expires_at' => $row['access_expires_at'],
+ 'connection' => [
+ 'server_host' => $this->config->get('xlx.server_host'),
+ 'dmr_port' => (int) $this->config->get('xlx.dmr_port', 62030),
+ 'default_module' => $this->config->get('xlx.default_module', 'A'),
+ 'username' => $row['username'],
+ 'password_hint' => $row['dmr_password_hint'],
+ ],
+ ];
+ }
+}
diff --git a/src/Domain/RegistrationService.php b/src/Domain/RegistrationService.php
new file mode 100644
index 0000000..e5bd557
--- /dev/null
+++ b/src/Domain/RegistrationService.php
@@ -0,0 +1,122 @@
+ $callsign,
+ 'available' => false,
+ 'status' => 'invalid_format',
+ 'message' => 'Invalid callsign format.',
+ ];
+ }
+
+ $statement = $pdo->prepare('SELECT id FROM users WHERE callsign = ? LIMIT 1');
+ $statement->execute([$callsign]);
+
+ return [
+ 'callsign' => $callsign,
+ 'available' => $statement->fetch() === false,
+ 'status' => 'internal_registry',
+ 'message' => 'Checked against local registered users.',
+ ];
+ }
+
+ public function register(PDO $pdo, array $input): array
+ {
+ $callsign = Security::normalizeCallsign((string) ($input['callsign'] ?? ''));
+ $email = strtolower(trim((string) ($input['email'] ?? '')));
+ $password = (string) ($input['password'] ?? '');
+
+ if (!Security::isValidCallsign($callsign)) {
+ throw new RuntimeException('Invalid callsign format.');
+ }
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ throw new RuntimeException('Invalid email address.');
+ }
+ if (strlen($password) < (int) $this->config->get('security.password_min_length', 10)) {
+ throw new RuntimeException('Password is too short.');
+ }
+
+ $callsignCheck = $this->checkCallsign($pdo, $callsign);
+ if (!$callsignCheck['available']) {
+ throw new RuntimeException('Callsign is already registered.');
+ }
+
+ $emailCheck = $pdo->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
+ $emailCheck->execute([$email]);
+ if ($emailCheck->fetch()) {
+ throw new RuntimeException('Email is already registered.');
+ }
+
+ $verification = [
+ 'status' => 'verified',
+ 'source' => 'internal_registry',
+ 'payload' => [
+ 'message' => 'Callsign is not present in local registered users.',
+ ],
+ ];
+ $status = 'payment_pending';
+
+ $pdo->beginTransaction();
+ try {
+ $insertUser = $pdo->prepare(
+ 'INSERT INTO users (callsign, email, password_hash, status) VALUES (?, ?, ?, ?)'
+ );
+ $insertUser->execute([$callsign, $email, password_hash($password, PASSWORD_DEFAULT), $status]);
+ $userId = (int) $pdo->lastInsertId();
+
+ $insertCheck = $pdo->prepare(
+ 'INSERT INTO callsign_checks (user_id, callsign, source, status, response_json, checked_at) VALUES (?, ?, ?, ?, ?, NOW())'
+ );
+ $insertCheck->execute([
+ $userId,
+ $callsign,
+ $verification['source'],
+ $verification['status'],
+ json_encode($verification['payload'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ ]);
+
+ $payment = null;
+ if ($status === 'payment_pending') {
+ $payment = $this->paymentService->createPendingPayment($pdo, $userId);
+ }
+
+ $pdo->commit();
+
+ return [
+ 'user_id' => $userId,
+ 'callsign' => $callsign,
+ 'status' => $status,
+ 'verification' => [
+ 'status' => $verification['status'],
+ 'source' => $verification['source'],
+ ],
+ 'payment' => $payment,
+ ];
+ } catch (\Throwable $throwable) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $throwable;
+ }
+ }
+}
diff --git a/src/Domain/XlxdSettingsService.php b/src/Domain/XlxdSettingsService.php
new file mode 100644
index 0000000..ab7241a
--- /dev/null
+++ b/src/Domain/XlxdSettingsService.php
@@ -0,0 +1,153 @@
+ 'XLX_REFLECTOR_NAME',
+ 'server_host' => 'XLX_SERVER_HOST',
+ 'sysop_callsign' => 'XLX_SYSOP_CALLSIGN',
+ 'sysop_email' => 'XLX_SYSOP_EMAIL',
+ 'country' => 'XLX_COUNTRY',
+ 'dashboard_port' => 'XLX_DASHBOARD_PORT',
+ 'dmr_port' => 'XLX_DMR_PORT',
+ 'ysf_port' => 'XLX_YSF_PORT',
+ 'default_module' => 'XLX_DEFAULT_MODULE',
+ 'modules' => 'XLX_MODULES',
+ 'install_path' => 'XLX_INSTALL_PATH',
+ 'source_path' => 'XLX_SOURCE_PATH',
+ 'log_path' => 'XLX_LOG_PATH',
+ 'service_name' => 'XLX_SERVICE_NAME',
+ 'repo_url' => 'XLX_REPO_URL',
+ ];
+
+ public function __construct(private readonly Config $config)
+ {
+ }
+
+ public function current(PDO $pdo): array
+ {
+ $settings = [
+ 'reflector_name' => $this->config->get('xlx.reflector_name', 'XLX000'),
+ 'server_host' => $this->config->get('xlx.server_host', 'xlx.example.com'),
+ 'sysop_callsign' => $this->config->get('xlx.sysop_callsign', 'N0CALL'),
+ 'sysop_email' => $this->config->get('xlx.sysop_email', 'admin@example.com'),
+ 'country' => $this->config->get('xlx.country', 'RU'),
+ 'dashboard_port' => (string) $this->config->get('xlx.dashboard_port', 8080),
+ 'dmr_port' => (string) $this->config->get('xlx.dmr_port', 62030),
+ 'ysf_port' => (string) $this->config->get('xlx.ysf_port', 42000),
+ 'default_module' => $this->config->get('xlx.default_module', 'A'),
+ 'modules' => $this->config->get('xlx.modules', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'),
+ 'install_path' => $this->config->get('xlx.install_path', '/xlxd'),
+ 'source_path' => $this->config->get('xlx.source_path', '/usr/src/xlxd'),
+ 'log_path' => $this->config->get('xlx.log_path', '/var/log/xlxd.log'),
+ 'service_name' => $this->config->get('xlx.service_name', 'xlxd'),
+ 'repo_url' => 'https://github.com/LX3JL/xlxd.git',
+ ];
+
+ $rows = $pdo->query("SELECT setting_key, setting_value FROM server_settings WHERE setting_key LIKE 'xlxd.%'")->fetchAll();
+ foreach ($rows as $row) {
+ $key = substr((string) $row['setting_key'], 5);
+ if (array_key_exists($key, $settings)) {
+ $settings[$key] = (string) $row['setting_value'];
+ }
+ }
+
+ return $settings;
+ }
+
+ public function save(PDO $pdo, array $input, bool $apply): array
+ {
+ $settings = $this->current($pdo);
+ foreach (self::FIELD_MAP as $key => $_) {
+ if (array_key_exists($key, $input)) {
+ $settings[$key] = $this->sanitize($key, (string) $input[$key]);
+ }
+ }
+
+ $statement = $pdo->prepare(
+ 'INSERT INTO server_settings (setting_key, setting_value) VALUES (?, ?)
+ ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()'
+ );
+ foreach ($settings as $key => $value) {
+ $statement->execute(['xlxd.' . $key, $value]);
+ }
+
+ $env = $this->renderEnv($settings);
+ $applied = false;
+ $message = 'Settings saved to database.';
+ if ($apply) {
+ $path = rtrim((string) $this->config->get('xlx.config_path', '/etc/xlx'), '/') . '/xlxd.env';
+ $this->writeEnvFile($path, $env);
+ $applied = true;
+ $message = 'Settings saved and xlxd.env updated.';
+ }
+
+ return [
+ 'settings' => $settings,
+ 'env' => $env,
+ 'applied' => $applied,
+ 'message' => $message,
+ ];
+ }
+
+ public function renderEnv(array $settings): string
+ {
+ $lines = [];
+ foreach (self::FIELD_MAP as $key => $envKey) {
+ $lines[] = $envKey . '=' . $this->escapeEnv((string) ($settings[$key] ?? ''));
+ }
+
+ return implode("\n", $lines) . "\n";
+ }
+
+ private function sanitize(string $key, string $value): string
+ {
+ $value = trim($value);
+ if (in_array($key, ['dashboard_port', 'dmr_port', 'ysf_port'], true)) {
+ $port = (int) $value;
+ if ($port < 1 || $port > 65535) {
+ throw new RuntimeException('Invalid port for ' . $key . '.');
+ }
+ return (string) $port;
+ }
+ if ($key === 'reflector_name' && !preg_match('/^XLX[A-Z0-9]{3}$/', $value)) {
+ throw new RuntimeException('Reflector name must look like XLX138.');
+ }
+ if ($key === 'default_module' && !preg_match('/^[A-Z]$/', $value)) {
+ throw new RuntimeException('Default module must be A-Z.');
+ }
+ if ($key === 'modules' && !preg_match('/^[A-Z]+$/', $value)) {
+ throw new RuntimeException('Modules must contain only letters A-Z.');
+ }
+
+ return $value;
+ }
+
+ private function writeEnvFile(string $path, string $env): void
+ {
+ $directory = dirname($path);
+ if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
+ throw new RuntimeException('Cannot create config directory: ' . $directory);
+ }
+ if (file_put_contents($path, $env, LOCK_EX) === false) {
+ throw new RuntimeException('Cannot write xlxd env file: ' . $path);
+ }
+ }
+
+ private function escapeEnv(string $value): string
+ {
+ if ($value === '' || preg_match('/\s/', $value) || strpbrk($value, '"\'\\$`') !== false) {
+ return '"' . str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $value) . '"';
+ }
+
+ return $value;
+ }
+}
diff --git a/src/Domain/YooKassaClient.php b/src/Domain/YooKassaClient.php
new file mode 100644
index 0000000..17eb735
--- /dev/null
+++ b/src/Domain/YooKassaClient.php
@@ -0,0 +1,91 @@
+ [
+ 'value' => number_format((float) $payment['amount'], 2, '.', ''),
+ 'currency' => $payment['currency'],
+ ],
+ 'capture' => (bool) $this->config->get('billing.yookassa.capture', true),
+ 'confirmation' => [
+ 'type' => 'redirect',
+ 'return_url' => $this->config->get('billing.yookassa.return_url'),
+ ],
+ 'description' => sprintf('XLX доступ для %s, платеж #%d', $user['callsign'], $payment['id']),
+ 'metadata' => [
+ 'payment_id' => (string) $payment['id'],
+ 'user_id' => (string) $user['id'],
+ 'callsign' => $user['callsign'],
+ 'tariff_id' => (string) $tariff['id'],
+ ],
+ ];
+
+ return $this->request('POST', '/payments', $payload, 'xlx-payment-' . $payment['id']);
+ }
+
+ public function fetchPayment(string $providerPaymentId): array
+ {
+ return $this->request('GET', '/payments/' . rawurlencode($providerPaymentId));
+ }
+
+ private function request(string $method, string $path, ?array $payload = null, ?string $idempotenceKey = null): array
+ {
+ $shopId = (string) $this->config->get('billing.yookassa.shop_id');
+ $secretKey = (string) $this->config->get('billing.yookassa.secret_key');
+ if ($shopId === '' || $shopId === 'change_me' || $secretKey === '' || $secretKey === 'change_me') {
+ throw new RuntimeException('YooKassa credentials are not configured.');
+ }
+
+ $headers = [
+ 'Authorization: Basic ' . base64_encode($shopId . ':' . $secretKey),
+ 'Content-Type: application/json',
+ 'Accept: application/json',
+ ];
+ if ($idempotenceKey !== null) {
+ $headers[] = 'Idempotence-Key: ' . substr($idempotenceKey, 0, 64);
+ }
+
+ $options = [
+ 'http' => [
+ 'method' => $method,
+ 'header' => implode("\r\n", $headers) . "\r\n",
+ 'ignore_errors' => true,
+ 'timeout' => 15,
+ ],
+ ];
+ if ($payload !== null) {
+ $options['http']['content'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+
+ $body = @file_get_contents(self::API_URL . $path, false, stream_context_create($options));
+ if ($body === false) {
+ throw new RuntimeException('YooKassa request failed.');
+ }
+
+ $decoded = json_decode($body, true);
+ if (!is_array($decoded)) {
+ throw new RuntimeException('YooKassa returned invalid JSON.');
+ }
+ if (isset($decoded['type']) && $decoded['type'] === 'error') {
+ throw new RuntimeException((string) ($decoded['description'] ?? 'YooKassa error.'));
+ }
+
+ return $decoded;
+ }
+}
diff --git a/src/Support/Config.php b/src/Support/Config.php
new file mode 100644
index 0000000..c77e979
--- /dev/null
+++ b/src/Support/Config.php
@@ -0,0 +1,25 @@
+values;
+ foreach (explode('.', $key) as $part) {
+ if (!is_array($value) || !array_key_exists($part, $value)) {
+ return $default;
+ }
+ $value = $value[$part];
+ }
+
+ return $value;
+ }
+}
diff --git a/src/Support/Database.php b/src/Support/Database.php
new file mode 100644
index 0000000..3b369ae
--- /dev/null
+++ b/src/Support/Database.php
@@ -0,0 +1,32 @@
+config->get('database.charset', 'utf8mb4');
+ $dsn = sprintf(
+ 'mysql:host=%s;port=%d;dbname=%s;charset=%s',
+ $this->config->get('database.host', '127.0.0.1'),
+ (int) $this->config->get('database.port', 3306),
+ $this->config->get('database.name'),
+ $charset
+ );
+
+ return new PDO($dsn, (string) $this->config->get('database.user'), (string) $this->config->get('database.password'), [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ]);
+ }
+}
diff --git a/src/Support/Input.php b/src/Support/Input.php
new file mode 100644
index 0000000..e2dfb80
--- /dev/null
+++ b/src/Support/Input.php
@@ -0,0 +1,23 @@
+ false, 'error' => $message] + $extra, $status);
+ }
+}
diff --git a/src/Support/Security.php b/src/Support/Security.php
new file mode 100644
index 0000000..286c63c
--- /dev/null
+++ b/src/Support/Security.php
@@ -0,0 +1,46 @@
+get('admin.api_token', '');
+ $actual = $_SERVER['HTTP_X_ADMIN_TOKEN'] ?? '';
+
+ if ($expected === '' || !hash_equals($expected, $actual)) {
+ Response::error('Admin token is missing or invalid.', 401);
+ }
+ }
+}