Добавить установщик XLX системы и веб-панель
Этот коммит содержится в:
+133
-48
@@ -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`.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.1.0
|
||||
0.2.0-dev
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Xlx\\';
|
||||
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
|
||||
if (is_file($path)) {
|
||||
require $path;
|
||||
}
|
||||
});
|
||||
|
||||
$configPath = __DIR__ . '/config/config.php';
|
||||
if (!is_file($configPath)) {
|
||||
$configPath = __DIR__ . '/config/config.example.php';
|
||||
}
|
||||
|
||||
$config = require $configPath;
|
||||
|
||||
date_default_timezone_set($config['app']['timezone'] ?? 'UTC');
|
||||
|
||||
return new Xlx\Support\Config($config);
|
||||
@@ -2,47 +2,74 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$host = getenv('XLX_PUBLIC_HOST') ?: ($_SERVER['HTTP_HOST'] ?? '127.0.0.1');
|
||||
$scheme = getenv('XLX_PUBLIC_SCHEME') ?: ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http');
|
||||
$baseUrl = getenv('XLX_BASE_URL') ?: $scheme . '://' . $host;
|
||||
$hostWithoutPort = preg_replace('/:\d+$/', '', $host);
|
||||
|
||||
return [
|
||||
'app' => [
|
||||
'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',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<VirtualHost *:80>
|
||||
ServerName _
|
||||
DocumentRoot __PUBLIC_DIR__
|
||||
|
||||
<Directory __PUBLIC_DIR__>
|
||||
Options FollowSymLinks
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
FallbackResource /index.php
|
||||
</Directory>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/xlx-panel-error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/xlx-panel-access.log combined
|
||||
</VirtualHost>
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -56,18 +56,18 @@ XLX Server — это управляемая платформа для DMR-по
|
||||
|
||||
### 2.3 Callsign Verifier
|
||||
|
||||
Модуль проверки позывного по внешним источникам.
|
||||
Модуль проверки позывного по внутренней базе уже зарегистрированных пользователей.
|
||||
|
||||
MVP-логика:
|
||||
|
||||
- пользователь вводит позывной;
|
||||
- система нормализует его в верхний регистр;
|
||||
- проверяет формат;
|
||||
- делает запрос к настроенной базе;
|
||||
- проверяет, что позывной еще не занят в таблице пользователей;
|
||||
- сохраняет результат проверки;
|
||||
- при ошибке отправляет заявку в ручную модерацию.
|
||||
|
||||
Важно: внешний источник проверки должен быть заменяемым адаптером, чтобы не прибивать проект гвоздями к одной базе.
|
||||
Важно: уникальность дополнительно защищена индексом `users.callsign`, поэтому дубль не пройдет даже при одновременной регистрации.
|
||||
|
||||
### 2.4 ID Allocator
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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-пароль показывается один раз. Если пользователь потерял пароль, нужно выпустить новый.
|
||||
@@ -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` только оплаченные подключения.
|
||||
@@ -20,7 +20,7 @@
|
||||
- [ ] форма регистрации;
|
||||
- [ ] нормализация позывного;
|
||||
- [ ] проверка формата позывного;
|
||||
- [ ] адаптер внешней базы позывных;
|
||||
- [x] проверка дублей позывных по внутренней базе;
|
||||
- [ ] ручная модерация спорных заявок;
|
||||
- [ ] автоматическая выдача ID;
|
||||
- [ ] защита от дублей;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Xlx\Domain\AccessCredentialService;
|
||||
use Xlx\Domain\AccessExportService;
|
||||
use Xlx\Domain\IdAllocator;
|
||||
use Xlx\Domain\PaymentService;
|
||||
use Xlx\Domain\RegistrationService;
|
||||
use Xlx\Domain\XlxdSettingsService;
|
||||
use Xlx\Domain\YooKassaClient;
|
||||
use Xlx\Support\Database;
|
||||
use Xlx\Support\Input;
|
||||
use Xlx\Support\Response;
|
||||
use Xlx\Support\Security;
|
||||
|
||||
$config = require __DIR__ . '/../bootstrap.php';
|
||||
$pdo = (new Database($config))->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);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>XLX Admin</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell admin-shell">
|
||||
<section class="hero compact">
|
||||
<div>
|
||||
<p class="eyebrow">Админка</p>
|
||||
<h1>Настройка XLX без ручного редактирования конфигов</h1>
|
||||
<p class="lead">Меняй имя reflector, домен, порты, модули и пути. Настройки сохраняются в БД и могут сразу применяться в `/etc/xlx/xlxd.env`.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<label>Admin token
|
||||
<input id="adminToken" type="password" placeholder="X-Admin-Token">
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button class="button" id="loadSettings">Загрузить настройки</button>
|
||||
<button class="button ghost" id="saveSettings">Сохранить</button>
|
||||
<button class="button ghost" id="applySettings">Сохранить и применить</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel split">
|
||||
<form class="form settings-form" id="settingsForm">
|
||||
<label>Reflector
|
||||
<input name="reflector_name" placeholder="XLX138">
|
||||
</label>
|
||||
<label>Домен
|
||||
<input name="server_host" placeholder="xlx.example.com">
|
||||
</label>
|
||||
<label>Позывной sysop
|
||||
<input name="sysop_callsign" placeholder="R0XXX">
|
||||
</label>
|
||||
<label>Email sysop
|
||||
<input name="sysop_email" placeholder="admin@example.com">
|
||||
</label>
|
||||
<label>Страна
|
||||
<input name="country" placeholder="RU">
|
||||
</label>
|
||||
<label>DMR порт
|
||||
<input name="dmr_port" type="number" min="1" max="65535">
|
||||
</label>
|
||||
<label>YSF порт
|
||||
<input name="ysf_port" type="number" min="1" max="65535">
|
||||
</label>
|
||||
<label>Dashboard порт
|
||||
<input name="dashboard_port" type="number" min="1" max="65535">
|
||||
</label>
|
||||
<label>Модуль по умолчанию
|
||||
<input name="default_module" maxlength="1">
|
||||
</label>
|
||||
<label>Активные модули
|
||||
<input name="modules" placeholder="ABCDEFGHIJKLMNOPQRSTUVWXYZ">
|
||||
</label>
|
||||
<label>Путь установки
|
||||
<input name="install_path" placeholder="/xlxd">
|
||||
</label>
|
||||
<label>Путь исходников
|
||||
<input name="source_path" placeholder="/usr/src/xlxd">
|
||||
</label>
|
||||
<label>Лог
|
||||
<input name="log_path" placeholder="/var/log/xlxd.log">
|
||||
</label>
|
||||
<label>Systemd service
|
||||
<input name="service_name" placeholder="xlxd">
|
||||
</label>
|
||||
<label>Репозиторий xlxd
|
||||
<input name="repo_url" placeholder="https://github.com/LX3JL/xlxd.git">
|
||||
</label>
|
||||
</form>
|
||||
<div>
|
||||
<p class="eyebrow">Предпросмотр env</p>
|
||||
<pre class="result env-preview" id="envPreview"></pre>
|
||||
<pre class="result" id="adminResult"></pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/assets/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>XLX Server</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">XLX / DMR / Pi-Star</p>
|
||||
<h1>Личный доступ к DMR-серверу без ручной настройки</h1>
|
||||
<p class="lead">Регистрация по позывному без дублей, оплата через YooKassa или чек перевода, автоматический DMR ID и пароль для Pi-Star.</p>
|
||||
<div class="actions">
|
||||
<a class="button" href="#register">Подключиться</a>
|
||||
<a class="button ghost" href="/admin">Админка</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-panel">
|
||||
<div>
|
||||
<span>DMR порт</span>
|
||||
<strong>62030</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Модуль</span>
|
||||
<strong>A</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Оплата</span>
|
||||
<strong>YooKassa</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="tile">
|
||||
<span class="num">01</span>
|
||||
<h2>Проверка позывного</h2>
|
||||
<p>Система проверяет формат и занятость позывного во внутренней базе.</p>
|
||||
</article>
|
||||
<article class="tile">
|
||||
<span class="num">02</span>
|
||||
<h2>Оплата доступа</h2>
|
||||
<p>Платеж через YooKassa или перевод с отправкой чека на подтверждение.</p>
|
||||
</article>
|
||||
<article class="tile">
|
||||
<span class="num">03</span>
|
||||
<h2>Данные Pi-Star</h2>
|
||||
<p>После оплаты выдаются сервер, порт, логин, пароль, DMR ID и модуль.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="register">
|
||||
<div>
|
||||
<p class="eyebrow">Регистрация</p>
|
||||
<h2>Создать заявку</h2>
|
||||
<p>После отправки появится ссылка YooKassa и инструкция для оплаты переводом.</p>
|
||||
</div>
|
||||
<form class="form" id="registerForm">
|
||||
<label>Позывной
|
||||
<input name="callsign" placeholder="R0XXX" required>
|
||||
</label>
|
||||
<label>Email
|
||||
<input name="email" type="email" placeholder="user@example.com" required>
|
||||
</label>
|
||||
<label>Пароль кабинета
|
||||
<input name="password" type="password" minlength="10" required>
|
||||
</label>
|
||||
<button class="button" type="submit">Отправить</button>
|
||||
<pre class="result" id="registerResult"></pre>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
@@ -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' </dev/urandom | head -c "${1:-40}" || true)"
|
||||
printf "%s" "$token"
|
||||
}
|
||||
|
||||
php_escape() {
|
||||
printf "%s" "$1" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g"
|
||||
}
|
||||
|
||||
sql_escape() {
|
||||
printf "%s" "$1" | sed "s/'/''/g"
|
||||
}
|
||||
|
||||
PUBLIC_HOST="$(detect_host)"
|
||||
PUBLIC_SCHEME="${XLX_PUBLIC_SCHEME:-http}"
|
||||
BASE_URL="${XLX_BASE_URL:-${PUBLIC_SCHEME}://${PUBLIC_HOST}}"
|
||||
REFLECTOR_NAME="${XLX_REFLECTOR_NAME:-XLX000}"
|
||||
SYSOP_CALLSIGN="${XLX_SYSOP_CALLSIGN:-N0CALL}"
|
||||
SYSOP_EMAIL="${XLX_SYSOP_EMAIL:-admin@${PUBLIC_HOST%%:*}}"
|
||||
COUNTRY="${XLX_COUNTRY:-RU}"
|
||||
DB_NAME="${XLX_DB_NAME:-xlx_server}"
|
||||
DB_USER="${XLX_DB_USER:-xlx_user}"
|
||||
DB_PASSWORD="${XLX_DB_PASSWORD:-$(random_token 32)}"
|
||||
ADMIN_TOKEN="${XLX_ADMIN_TOKEN:-$(random_token 48)}"
|
||||
WEBHOOK_SECRET="${YOOKASSA_WEBHOOK_SECRET:-$(random_token 48)}"
|
||||
DB_PASSWORD_SQL="$(sql_escape "$DB_PASSWORD")"
|
||||
|
||||
echo "Installing XLX system for host: $PUBLIC_HOST"
|
||||
|
||||
apt-get update
|
||||
apt-get install -y apache2 libapache2-mod-php php php-mysql mariadb-server git ca-certificates curl
|
||||
|
||||
systemctl enable --now mariadb
|
||||
|
||||
mysql <<SQL
|
||||
CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASSWORD_SQL}';
|
||||
ALTER USER '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASSWORD_SQL}';
|
||||
GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${DB_USER}'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
|
||||
mysql "$DB_NAME" < "$REPO_ROOT/database/schema.sql"
|
||||
|
||||
PHP_PUBLIC_HOST="$(php_escape "$PUBLIC_HOST")"
|
||||
PHP_PUBLIC_SCHEME="$(php_escape "$PUBLIC_SCHEME")"
|
||||
PHP_DB_NAME="$(php_escape "$DB_NAME")"
|
||||
PHP_DB_USER="$(php_escape "$DB_USER")"
|
||||
PHP_DB_PASSWORD="$(php_escape "$DB_PASSWORD")"
|
||||
PHP_REFLECTOR_NAME="$(php_escape "$REFLECTOR_NAME")"
|
||||
PHP_SYSOP_CALLSIGN="$(php_escape "$SYSOP_CALLSIGN")"
|
||||
PHP_SYSOP_EMAIL="$(php_escape "$SYSOP_EMAIL")"
|
||||
PHP_COUNTRY="$(php_escape "$COUNTRY")"
|
||||
PHP_ADMIN_TOKEN="$(php_escape "$ADMIN_TOKEN")"
|
||||
PHP_YOOKASSA_SHOP_ID="$(php_escape "${YOOKASSA_SHOP_ID:-change_me}")"
|
||||
PHP_YOOKASSA_SECRET_KEY="$(php_escape "${YOOKASSA_SECRET_KEY:-change_me}")"
|
||||
PHP_YOOKASSA_RETURN_URL="$(php_escape "${YOOKASSA_RETURN_URL:-${BASE_URL}/payment/return}")"
|
||||
PHP_WEBHOOK_SECRET="$(php_escape "$WEBHOOK_SECRET")"
|
||||
|
||||
cat > "$REPO_ROOT/config/config.php" <<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
\$_SERVER['HTTP_HOST'] = \$_SERVER['HTTP_HOST'] ?? '${PHP_PUBLIC_HOST}';
|
||||
putenv('XLX_PUBLIC_SCHEME=${PHP_PUBLIC_SCHEME}');
|
||||
putenv('XLX_DB_HOST=127.0.0.1');
|
||||
putenv('XLX_DB_PORT=3306');
|
||||
putenv('XLX_DB_NAME=${PHP_DB_NAME}');
|
||||
putenv('XLX_DB_USER=${PHP_DB_USER}');
|
||||
putenv('XLX_DB_PASSWORD=${PHP_DB_PASSWORD}');
|
||||
putenv('XLX_REFLECTOR_NAME=${PHP_REFLECTOR_NAME}');
|
||||
putenv('XLX_SYSOP_CALLSIGN=${PHP_SYSOP_CALLSIGN}');
|
||||
putenv('XLX_SYSOP_EMAIL=${PHP_SYSOP_EMAIL}');
|
||||
putenv('XLX_COUNTRY=${PHP_COUNTRY}');
|
||||
putenv('XLX_ADMIN_TOKEN=${PHP_ADMIN_TOKEN}');
|
||||
putenv('YOOKASSA_SHOP_ID=${PHP_YOOKASSA_SHOP_ID}');
|
||||
putenv('YOOKASSA_SECRET_KEY=${PHP_YOOKASSA_SECRET_KEY}');
|
||||
putenv('YOOKASSA_RETURN_URL=${PHP_YOOKASSA_RETURN_URL}');
|
||||
putenv('YOOKASSA_WEBHOOK_SECRET=${PHP_WEBHOOK_SECRET}');
|
||||
|
||||
return require __DIR__ . '/config.example.php';
|
||||
PHP
|
||||
|
||||
mkdir -p /etc/xlx
|
||||
cat > /etc/xlx/xlxd.env <<EOF
|
||||
XLX_REFLECTOR_NAME=${REFLECTOR_NAME}
|
||||
XLX_SERVER_HOST=${PUBLIC_HOST}
|
||||
XLX_SYSOP_CALLSIGN=${SYSOP_CALLSIGN}
|
||||
XLX_SYSOP_EMAIL=${SYSOP_EMAIL}
|
||||
XLX_COUNTRY=${COUNTRY}
|
||||
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
|
||||
EOF
|
||||
|
||||
sed "s#__PUBLIC_DIR__#${PUBLIC_DIR}#g" "$REPO_ROOT/deploy/apache/xlx-panel.conf" > /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 <<EOF
|
||||
|
||||
XLX system installed.
|
||||
|
||||
Site:
|
||||
${BASE_URL}/
|
||||
|
||||
Admin:
|
||||
${BASE_URL}/admin
|
||||
|
||||
Admin token:
|
||||
${ADMIN_TOKEN}
|
||||
|
||||
YooKassa webhook:
|
||||
${BASE_URL}/api/webhooks/yookassa?token=${WEBHOOK_SECRET}
|
||||
|
||||
Database:
|
||||
name=${DB_NAME}
|
||||
user=${DB_USER}
|
||||
password=${DB_PASSWORD}
|
||||
|
||||
If you later attach a domain, open /admin and change "Домен", or rerun with:
|
||||
sudo XLX_PUBLIC_HOST=xlx.your-domain.ru bash scripts/install-system.sh
|
||||
EOF
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "Run as root: sudo bash scripts/install-xlxd.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_TARGET="/etc/xlx/xlxd.env"
|
||||
|
||||
mkdir -p /etc/xlx
|
||||
if [[ ! -f "$ENV_TARGET" ]]; then
|
||||
cp "$REPO_ROOT/config/xlxd.env.example" "$ENV_TARGET"
|
||||
echo "Created $ENV_TARGET. Edit it before production use."
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_TARGET"
|
||||
|
||||
XLX_REPO_URL="${XLX_REPO_URL:-https://github.com/LX3JL/xlxd.git}"
|
||||
XLX_SOURCE_PATH="${XLX_SOURCE_PATH:-/usr/src/xlxd}"
|
||||
XLX_INSTALL_PATH="${XLX_INSTALL_PATH:-/xlxd}"
|
||||
XLX_SERVICE_NAME="${XLX_SERVICE_NAME:-xlxd}"
|
||||
|
||||
apt-get update
|
||||
apt-get install -y git build-essential make g++ apache2 php libapache2-mod-php wget ca-certificates
|
||||
|
||||
if [[ -d "$XLX_SOURCE_PATH/.git" ]]; then
|
||||
git -C "$XLX_SOURCE_PATH" pull --ff-only
|
||||
else
|
||||
rm -rf "$XLX_SOURCE_PATH"
|
||||
git clone "$XLX_REPO_URL" "$XLX_SOURCE_PATH"
|
||||
fi
|
||||
|
||||
make -C "$XLX_SOURCE_PATH/src" clean
|
||||
make -C "$XLX_SOURCE_PATH/src"
|
||||
make -C "$XLX_SOURCE_PATH/src" install
|
||||
|
||||
mkdir -p "$XLX_INSTALL_PATH"
|
||||
if [[ -x "$XLX_SOURCE_PATH/src/xlxd" ]]; then
|
||||
install -m 0755 "$XLX_SOURCE_PATH/src/xlxd" "$XLX_INSTALL_PATH/xlxd"
|
||||
fi
|
||||
|
||||
if [[ -d "$XLX_SOURCE_PATH/dashboard" ]]; then
|
||||
rm -rf /var/www/html/xlxd
|
||||
cp -R "$XLX_SOURCE_PATH/dashboard" /var/www/html/xlxd
|
||||
chown -R www-data:www-data /var/www/html/xlxd
|
||||
fi
|
||||
|
||||
install -m 0755 "$REPO_ROOT/scripts/xlx-control" /usr/local/sbin/xlx-control
|
||||
install -m 0644 "$REPO_ROOT/deploy/systemd/xlxd.service" "/etc/systemd/system/${XLX_SERVICE_NAME}.service"
|
||||
|
||||
/usr/local/sbin/xlx-control render-modules
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${XLX_SERVICE_NAME}.service"
|
||||
|
||||
cat <<EOF
|
||||
XLX reflector installed.
|
||||
|
||||
Next commands:
|
||||
sudo systemctl start ${XLX_SERVICE_NAME}
|
||||
sudo systemctl status ${XLX_SERVICE_NAME}
|
||||
sudo tail -f ${XLX_LOG_PATH:-/var/log/xlxd.log}
|
||||
|
||||
Open firewall/NAT for at least UDP ${XLX_DMR_PORT:-62030} for Pi-Star MMDVM.
|
||||
EOF
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/etc/xlx/xlxd.env}"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
fi
|
||||
|
||||
XLX_INSTALL_PATH="${XLX_INSTALL_PATH:-/xlxd}"
|
||||
XLX_LOG_PATH="${XLX_LOG_PATH:-/var/log/xlxd.log}"
|
||||
XLX_MODULES="${XLX_MODULES:-ABCDEFGHIJKLMNOPQRSTUVWXYZ}"
|
||||
PID_FILE="/var/run/xlxd.pid"
|
||||
|
||||
usage() {
|
||||
echo "Usage: xlx-control {start|stop|restart|status|render-modules}"
|
||||
}
|
||||
|
||||
render_modules() {
|
||||
mkdir -p "$XLX_INSTALL_PATH"
|
||||
local module_file="$XLX_INSTALL_PATH/xlxd.module"
|
||||
: > "$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
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Domain;
|
||||
|
||||
use PDO;
|
||||
use Xlx\Support\Config;
|
||||
use Xlx\Support\Security;
|
||||
|
||||
final class AccessCredentialService
|
||||
{
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function enableForUser(PDO $pdo, int $userId, string $callsign): array
|
||||
{
|
||||
$length = (int) $this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Domain;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class AccessExportService
|
||||
{
|
||||
public function activeUsersCsv(PDO $pdo): string
|
||||
{
|
||||
$rows = $pdo->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Domain;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Xlx\Support\Config;
|
||||
|
||||
final class IdAllocator
|
||||
{
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function allocate(PDO $pdo, int $userId): int
|
||||
{
|
||||
$existing = $pdo->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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Domain;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Xlx\Support\Config;
|
||||
|
||||
final class PaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly IdAllocator $idAllocator,
|
||||
private readonly AccessCredentialService $accessCredentialService,
|
||||
private readonly ?YooKassaClient $yooKassaClient = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createPendingPayment(PDO $pdo, int $userId): ?array
|
||||
{
|
||||
$tariff = $pdo->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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Domain;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Xlx\Support\Config;
|
||||
use Xlx\Support\Security;
|
||||
|
||||
final class RegistrationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly PaymentService $paymentService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function checkCallsign(PDO $pdo, string $callsign): array
|
||||
{
|
||||
$callsign = Security::normalizeCallsign($callsign);
|
||||
if (!Security::isValidCallsign($callsign)) {
|
||||
return [
|
||||
'callsign' => $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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Domain;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Xlx\Support\Config;
|
||||
|
||||
final class XlxdSettingsService
|
||||
{
|
||||
private const FIELD_MAP = [
|
||||
'reflector_name' => '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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Domain;
|
||||
|
||||
use RuntimeException;
|
||||
use Xlx\Support\Config;
|
||||
|
||||
final class YooKassaClient
|
||||
{
|
||||
private const API_URL = 'https://api.yookassa.ru/v3';
|
||||
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function createPayment(array $payment, array $user, array $tariff): array
|
||||
{
|
||||
$payload = [
|
||||
'amount' => [
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Support;
|
||||
|
||||
final class Config
|
||||
{
|
||||
public function __construct(private readonly array $values)
|
||||
{
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$value = $this->values;
|
||||
foreach (explode('.', $key) as $part) {
|
||||
if (!is_array($value) || !array_key_exists($part, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$part];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Support;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Database
|
||||
{
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function pdo(): PDO
|
||||
{
|
||||
$charset = $this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Support;
|
||||
|
||||
final class Input
|
||||
{
|
||||
public static function json(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
Response::error('Invalid JSON request body.', 400);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Support;
|
||||
|
||||
final class Response
|
||||
{
|
||||
public static function json(array $payload, int $status = 200): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function error(string $message, int $status = 400, array $extra = []): never
|
||||
{
|
||||
self::json(['ok' => false, 'error' => $message] + $extra, $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Xlx\Support;
|
||||
|
||||
final class Security
|
||||
{
|
||||
public static function normalizeCallsign(string $callsign): string
|
||||
{
|
||||
return strtoupper(trim($callsign));
|
||||
}
|
||||
|
||||
public static function isValidCallsign(string $callsign): bool
|
||||
{
|
||||
return (bool) preg_match('/^[A-Z0-9][A-Z0-9\/-]{2,18}$/', $callsign);
|
||||
}
|
||||
|
||||
public static function randomPassword(int $length): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||
$password = '';
|
||||
$max = strlen($alphabet) - 1;
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$password .= $alphabet[random_int(0, $max)];
|
||||
}
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
public static function passwordHint(string $password): string
|
||||
{
|
||||
return substr($password, 0, 3) . '...' . substr($password, -3);
|
||||
}
|
||||
|
||||
public static function requireAdminToken(Config $config): void
|
||||
{
|
||||
$expected = (string) $config->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user