ваше сообщение коммита
This commit is contained in:
1
.cloudflared/a765a217-5312-48f8-9bb7-5a7ef56602b8.json
Executable file
1
.cloudflared/a765a217-5312-48f8-9bb7-5a7ef56602b8.json
Executable file
@@ -0,0 +1 @@
|
|||||||
|
{"AccountTag":"a67861072a144cdd746e9c9bdd8476fe","TunnelSecret":"NCu/3BUoqAbF5kwXfs3rTjU9QUiVvXv7OM27BrUd/50Kf/wthq2rIH0G+Eu76LK8JQon/UQBUbQPoRPRY3qbtA==","TunnelID":"a765a217-5312-48f8-9bb7-5a7ef56602b8"}
|
||||||
7
.cloudflared/config.yml
Normal file
7
.cloudflared/config.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
tunnel: a765a217-5312-48f8-9bb7-5a7ef56602b8
|
||||||
|
credentials-file: /etc/cloudflared/a765a217-5312-48f8-9bb7-5a7ef56602b8.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
- hostname: hb3-accelerator.com
|
||||||
|
service: http://dapp-frontend:5173
|
||||||
|
- service: http_status:404
|
||||||
6
Dockerfile.agent
Normal file
6
Dockerfile.agent
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY cloudflared-agent.js .
|
||||||
|
RUN yarn add express
|
||||||
|
RUN apk add --no-cache docker-cli docker-compose
|
||||||
|
CMD ["node", "cloudflared-agent.js"]
|
||||||
9
Dockerfile.cloudflared
Normal file
9
Dockerfile.cloudflared
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM alpine:3.20 as base
|
||||||
|
RUN apk add --no-cache bash curl wget
|
||||||
|
|
||||||
|
FROM cloudflare/cloudflared:2025.6.1 as src
|
||||||
|
|
||||||
|
FROM base
|
||||||
|
COPY --from=src /usr/local/bin/cloudflared /usr/local/bin/cloudflared
|
||||||
|
USER nobody
|
||||||
|
ENTRYPOINT ["bash", "/start-cloudflared.sh"]
|
||||||
@@ -3,7 +3,19 @@ FROM node:20-bullseye
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Устанавливаем зависимости, включая Python для node-gyp
|
# Устанавливаем зависимости, включая Python для node-gyp
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ cmake openssl libssl-dev
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3 make g++ cmake openssl libssl-dev \
|
||||||
|
ca-certificates curl gnupg lsb-release
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/apt/keyrings && \
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
|
||||||
|
RUN echo \
|
||||||
|
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
|
||||||
|
$(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
|
||||||
# Копируем package.json и yarn.lock для установки зависимостей
|
# Копируем package.json и yarn.lock для установки зависимостей
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const userTagsRoutes = require('./routes/userTags');
|
|||||||
const tagsInitRoutes = require('./routes/tagsInit');
|
const tagsInitRoutes = require('./routes/tagsInit');
|
||||||
const tagsRoutes = require('./routes/tags');
|
const tagsRoutes = require('./routes/tags');
|
||||||
const ragRoutes = require('./routes/rag'); // Новый роут для RAG-ассистента
|
const ragRoutes = require('./routes/rag'); // Новый роут для RAG-ассистента
|
||||||
|
const cloudflareRoutes = require('./routes/cloudflare');
|
||||||
|
|
||||||
// Проверка и создание директорий для хранения данных контрактов
|
// Проверка и создание директорий для хранения данных контрактов
|
||||||
const ensureDirectoriesExist = () => {
|
const ensureDirectoriesExist = () => {
|
||||||
@@ -190,6 +191,7 @@ app.use('/api/tags', tagsInitRoutes);
|
|||||||
app.use('/api/tags', tagsRoutes);
|
app.use('/api/tags', tagsRoutes);
|
||||||
app.use('/api/identities', identitiesRoutes);
|
app.use('/api/identities', identitiesRoutes);
|
||||||
app.use('/api/rag', ragRoutes); // Подключаем роут
|
app.use('/api/rag', ragRoutes); // Подключаем роут
|
||||||
|
app.use('/api/cloudflare', cloudflareRoutes);
|
||||||
|
|
||||||
const nonceStore = new Map(); // или любая другая реализация хранилища nonce
|
const nonceStore = new Map(); // или любая другая реализация хранилища nonce
|
||||||
|
|
||||||
|
|||||||
13
backend/cloudflaredEnv.js
Normal file
13
backend/cloudflaredEnv.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function writeCloudflaredEnv({ tunnelToken, domain }) {
|
||||||
|
console.log('[writeCloudflaredEnv] tunnelToken:', tunnelToken, 'domain:', domain);
|
||||||
|
const envPath = '/cloudflared.env';
|
||||||
|
let content = '';
|
||||||
|
if (tunnelToken) content += `TUNNEL_TOKEN=${tunnelToken}\n`;
|
||||||
|
if (domain) content += `DOMAIN=${domain}\n`;
|
||||||
|
fs.writeFileSync(envPath, content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { writeCloudflaredEnv };
|
||||||
7
backend/db/migrations/041_create_cloudflare_settings.sql
Normal file
7
backend/db/migrations/041_create_cloudflare_settings.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS cloudflare_settings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
api_token TEXT,
|
||||||
|
tunnel_token TEXT,
|
||||||
|
domain TEXT,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
1
backend/db/migrations/042_create_cloudflare_settings.sql
Normal file
1
backend/db/migrations/042_create_cloudflare_settings.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE cloudflare_settings ADD COLUMN account_id TEXT;
|
||||||
1
backend/db/migrations/043_create_cloudflare_settings.sql
Normal file
1
backend/db/migrations/043_create_cloudflare_settings.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE cloudflare_settings ADD COLUMN tunnel_id TEXT;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@
|
|||||||
"@openzeppelin/contracts": "5.2.0",
|
"@openzeppelin/contracts": "5.2.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
|
"cloudflare": "^4.4.1",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
323
backend/routes/cloudflare.js
Normal file
323
backend/routes/cloudflare.js
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
let Cloudflare;
|
||||||
|
try {
|
||||||
|
Cloudflare = require('cloudflare');
|
||||||
|
} catch (e) {
|
||||||
|
Cloudflare = null;
|
||||||
|
}
|
||||||
|
const db = require('../db');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const dockerComposePath = path.join(__dirname, '../../docker-compose.cloudflared.yml');
|
||||||
|
const { addCloudflaredToCompose } = require('../utils/cloudflaredCompose');
|
||||||
|
const { writeCloudflaredEnv } = require('../cloudflaredEnv');
|
||||||
|
const axios = require('axios');
|
||||||
|
const credentialsDir = '/home/alex/DApp-for-Business/.cloudflared';
|
||||||
|
const tunnelName = 'hb3-accelerator'; // или из настроек
|
||||||
|
|
||||||
|
// --- Вспомогательные функции ---
|
||||||
|
async function getSettings() {
|
||||||
|
const { rows } = await db.query('SELECT * FROM cloudflare_settings ORDER BY id DESC LIMIT 1');
|
||||||
|
return rows[0] || {};
|
||||||
|
}
|
||||||
|
async function upsertSettings(fields) {
|
||||||
|
const current = await getSettings();
|
||||||
|
if (current.id) {
|
||||||
|
const updates = [];
|
||||||
|
const values = [];
|
||||||
|
let idx = 1;
|
||||||
|
for (const [k, v] of Object.entries(fields)) {
|
||||||
|
updates.push(`${k} = $${idx}`);
|
||||||
|
values.push(v);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
values.push(current.id);
|
||||||
|
await db.query(`UPDATE cloudflare_settings SET ${updates.join(', ')}, updated_at = NOW() WHERE id = $${idx}`, values);
|
||||||
|
} else {
|
||||||
|
const keys = Object.keys(fields);
|
||||||
|
const values = Object.values(fields);
|
||||||
|
await db.query(`INSERT INTO cloudflare_settings (${keys.join(',')}) VALUES (${keys.map((_,i)=>`$${i+1}`).join(',')})` , values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function generateDockerCompose(tunnelToken) {
|
||||||
|
return `version: '3.8'
|
||||||
|
services:
|
||||||
|
cloudflared:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
command: tunnel --no-autoupdate run
|
||||||
|
environment:
|
||||||
|
- TUNNEL_TOKEN=${tunnelToken}
|
||||||
|
restart: unless-stopped
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
function runDockerCompose() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
exec(`docker-compose -f ${dockerComposePath} up -d cloudflared`, (err, stdout, stderr) => {
|
||||||
|
if (err) return reject(stderr || err.message);
|
||||||
|
resolve(stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function checkCloudflaredStatus() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
exec('docker ps --filter "name=cloudflared" --format "{{.Status}}"', (err, stdout) => {
|
||||||
|
if (err) return resolve('not_installed');
|
||||||
|
if (stdout.trim()) return resolve('running');
|
||||||
|
resolve('not_running');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API ---
|
||||||
|
// Получить все настройки
|
||||||
|
router.get('/settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
res.json({ success: true, settings });
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ success: false, message: 'Ошибка получения настроек: ' + e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Сохранить API Token
|
||||||
|
router.post('/token', async (req, res) => {
|
||||||
|
const { token } = req.body;
|
||||||
|
if (!token) return res.status(400).json({ success: false, message: 'Token required' });
|
||||||
|
try {
|
||||||
|
await upsertSettings({ api_token: token });
|
||||||
|
res.json({ success: true, message: 'API Token сохранён!' });
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ success: false, message: 'Ошибка сохранения токена: ' + e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Получить список аккаунтов пользователя по API Token
|
||||||
|
router.post('/accounts', async (req, res) => {
|
||||||
|
const { api_token } = req.body;
|
||||||
|
if (!api_token) return res.status(400).json({ success: false, message: 'Token required' });
|
||||||
|
try {
|
||||||
|
const resp = await axios.get('https://api.cloudflare.com/client/v4/accounts', {
|
||||||
|
headers: { Authorization: `Bearer ${api_token}` }
|
||||||
|
});
|
||||||
|
res.json({ success: true, accounts: resp.data.result });
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ success: false, message: 'Ошибка Cloudflare API: ' + e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Сохранить выбранный account_id
|
||||||
|
router.post('/account-id', async (req, res) => {
|
||||||
|
const { account_id } = req.body;
|
||||||
|
if (!account_id) return res.status(400).json({ success: false, message: 'Account ID required' });
|
||||||
|
try {
|
||||||
|
await upsertSettings({ account_id });
|
||||||
|
res.json({ success: true, message: 'Account ID сохранён!' });
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ success: false, message: 'Ошибка сохранения Account ID: ' + e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Новый /domain: полный цикл автоматизации через Cloudflare API
|
||||||
|
router.post('/domain', async (req, res) => {
|
||||||
|
const steps = [];
|
||||||
|
try {
|
||||||
|
// 1. Сохраняем домен, если он пришёл с фронта
|
||||||
|
const { domain: domainFromBody } = req.body;
|
||||||
|
if (domainFromBody) {
|
||||||
|
await upsertSettings({ domain: domainFromBody });
|
||||||
|
}
|
||||||
|
// 2. Получаем актуальные настройки
|
||||||
|
const settings = await getSettings();
|
||||||
|
const { api_token, domain, account_id, tunnel_id, tunnel_token } = settings;
|
||||||
|
if (!api_token || !domain || !account_id) {
|
||||||
|
return res.json({ success: false, error: 'Не все параметры Cloudflare заданы (api_token, domain, account_id)' });
|
||||||
|
}
|
||||||
|
let tunnelId = tunnel_id;
|
||||||
|
let tunnelToken = tunnel_token;
|
||||||
|
// 1. Создание туннеля через Cloudflare API (только если нет tunnel_id)
|
||||||
|
if (!tunnelId || !tunnelToken) {
|
||||||
|
try {
|
||||||
|
const tunnelName = `dapp-tunnel-${domain}`;
|
||||||
|
const tunnelResp = await axios.post(
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${account_id}/cfd_tunnel`,
|
||||||
|
{ name: tunnelName },
|
||||||
|
{ headers: { Authorization: `Bearer ${api_token}` } }
|
||||||
|
);
|
||||||
|
tunnelId = tunnelResp.data.result.id;
|
||||||
|
tunnelToken = tunnelResp.data.result.token;
|
||||||
|
console.log('[Cloudflare] Получен tunnelId:', tunnelId, 'tunnelToken:', tunnelToken);
|
||||||
|
// Сохраняем tunnel_id и tunnel_token в базу
|
||||||
|
await upsertSettings({ tunnel_id: tunnelId, tunnel_token: tunnelToken, api_token, account_id, domain });
|
||||||
|
steps.push({ step: 'create_tunnel', status: 'ok', message: 'Туннель создан через Cloudflare API и все параметры сохранены.' });
|
||||||
|
} catch (e) {
|
||||||
|
steps.push({ step: 'create_tunnel', status: 'error', message: 'Ошибка создания туннеля: ' + e.message });
|
||||||
|
return res.json({ success: false, steps, error: e.message });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
steps.push({ step: 'use_existing_tunnel', status: 'ok', message: 'Используется существующий туннель.' });
|
||||||
|
}
|
||||||
|
// 2. Сохранение tunnel_token в cloudflared.env
|
||||||
|
try {
|
||||||
|
writeCloudflaredEnv({ tunnelToken, domain });
|
||||||
|
steps.push({ step: 'save_token', status: 'ok', message: 'TUNNEL_TOKEN сохранён в cloudflared.env.' });
|
||||||
|
} catch (e) {
|
||||||
|
steps.push({ step: 'save_token', status: 'error', message: 'Ошибка сохранения tunnel_token: ' + e.message });
|
||||||
|
return res.json({ success: false, steps, error: e.message });
|
||||||
|
}
|
||||||
|
// 3. Создание маршрута (ingress) через Cloudflare API
|
||||||
|
try {
|
||||||
|
await axios.put(
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${account_id}/cfd_tunnel/${tunnelId}/configurations`,
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
ingress: [
|
||||||
|
{ hostname: domain, service: 'http://dapp-frontend:5173' },
|
||||||
|
{ service: 'http_status:404' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ headers: { Authorization: `Bearer ${api_token}` } }
|
||||||
|
);
|
||||||
|
steps.push({ step: 'create_route', status: 'ok', message: 'Маршрут для домена создан.' });
|
||||||
|
} catch (e) {
|
||||||
|
let errorMsg = e.message;
|
||||||
|
if (e.response && e.response.data) {
|
||||||
|
errorMsg += ' | ' + JSON.stringify(e.response.data);
|
||||||
|
}
|
||||||
|
steps.push({ step: 'create_route', status: 'error', message: 'Ошибка создания маршрута: ' + errorMsg });
|
||||||
|
return res.json({ success: false, steps, error: errorMsg });
|
||||||
|
}
|
||||||
|
// 4. Перезапуск cloudflared через cloudflared-agent
|
||||||
|
try {
|
||||||
|
await axios.post('http://cloudflared-agent:9000/cloudflared/restart');
|
||||||
|
steps.push({ step: 'restart_cloudflared', status: 'ok', message: 'cloudflared перезапущен.' });
|
||||||
|
} catch (e) {
|
||||||
|
steps.push({ step: 'restart_cloudflared', status: 'error', message: 'Ошибка перезапуска cloudflared: ' + e.message });
|
||||||
|
return res.json({ success: false, steps, error: e.message });
|
||||||
|
}
|
||||||
|
// 5. Возврат app_url
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
app_url: `https://${domain}`,
|
||||||
|
message: 'Туннель и маршрут успешно созданы. Ваше приложение доступно по ссылке.',
|
||||||
|
steps
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
steps.push({ step: 'fatal', status: 'error', message: e.message });
|
||||||
|
res.json({ success: false, steps, error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Проверить домен через Cloudflare API (опционально)
|
||||||
|
router.post('/check-domain', async (req, res) => {
|
||||||
|
if (!Cloudflare) return res.json({ success: false, message: 'Cloudflare не доступен на сервере' });
|
||||||
|
const { api_token, domain } = req.body;
|
||||||
|
if (!api_token || !domain) return res.status(400).json({ success: false, message: 'Token и domain обязательны' });
|
||||||
|
try {
|
||||||
|
const cf = new Cloudflare({ apiToken: api_token });
|
||||||
|
const zones = await cf.zones.browse();
|
||||||
|
const found = zones.result.find(z => z.name === domain);
|
||||||
|
if (!found) return res.status(400).json({ success: false, message: 'Домен не найден в Cloudflare аккаунте' });
|
||||||
|
res.json({ success: true, message: 'Домен найден в Cloudflare аккаунте' });
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ success: false, message: 'Ошибка Cloudflare API: ' + e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Установить Cloudflared в Docker (добавить в compose и запустить)
|
||||||
|
router.post('/install', async (req, res) => {
|
||||||
|
console.log('[CloudflareInstall] Запрос на установку cloudflared');
|
||||||
|
const settings = await getSettings();
|
||||||
|
console.log('[CloudflareInstall] Текущие настройки:', settings);
|
||||||
|
if (!settings.tunnel_token) {
|
||||||
|
console.warn('[CloudflareInstall] Нет tunnel_token, установка невозможна');
|
||||||
|
return res.status(400).json({ success: false, message: 'Сначала сохраните Tunnel Token' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
console.log('[CloudflareInstall] Запись cloudflared.env...');
|
||||||
|
writeCloudflaredEnv({ tunnelToken: settings.tunnel_token, domain: settings.domain });
|
||||||
|
console.log('[CloudflareInstall] Перезапуск cloudflared через docker compose...');
|
||||||
|
exec('docker-compose up -d cloudflared', (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[CloudflareInstall] Ошибка docker compose:', stderr || err.message);
|
||||||
|
return res.json({ success: false, message: 'Ошибка docker compose: ' + (stderr || err.message) });
|
||||||
|
}
|
||||||
|
console.log('[CloudflareInstall] Cloudflared перезапущен:', stdout);
|
||||||
|
res.json({ success: true, message: 'Cloudflared переменные обновлены и контейнер перезапущен!' });
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CloudflareInstall] Ошибка:', e);
|
||||||
|
res.json({ success: false, message: 'Ошибка: ' + (e.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Получить статус Cloudflared, домена и туннеля
|
||||||
|
router.get('/status', async (req, res) => {
|
||||||
|
const status = await checkCloudflaredStatus();
|
||||||
|
const settings = await getSettings();
|
||||||
|
let domainStatus = 'not_configured';
|
||||||
|
let domainMsg = 'Cloudflare не настроен';
|
||||||
|
let tunnelStatus = 'not_configured';
|
||||||
|
let tunnelMsg = 'Cloudflare не настроен';
|
||||||
|
if (!Cloudflare) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
status,
|
||||||
|
domainStatus: 'not_available',
|
||||||
|
domainMsg: 'Пакет cloudflare не установлен',
|
||||||
|
tunnelStatus: 'not_available',
|
||||||
|
tunnelMsg: 'Пакет cloudflare не установлен',
|
||||||
|
message: 'Cloudflare не доступен на сервере'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (settings.api_token && settings.domain) {
|
||||||
|
try {
|
||||||
|
const cf = new Cloudflare({ apiToken: settings.api_token });
|
||||||
|
const zonesResp = await cf.zones.list();
|
||||||
|
const zones = zonesResp.result;
|
||||||
|
const found = zones.find(z => z.name === settings.domain);
|
||||||
|
if (found) {
|
||||||
|
domainStatus = 'ok';
|
||||||
|
domainMsg = 'Домен найден в Cloudflare аккаунте';
|
||||||
|
} else {
|
||||||
|
domainStatus = 'not_found';
|
||||||
|
domainMsg = 'Домен не найден в Cloudflare аккаунте';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
domainStatus = 'error';
|
||||||
|
domainMsg = 'Ошибка Cloudflare API: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (settings.api_token && settings.tunnel_token && Cloudflare) {
|
||||||
|
try {
|
||||||
|
const cf = new Cloudflare({ apiToken: settings.api_token });
|
||||||
|
const zonesResp = await cf.zones.list();
|
||||||
|
const zones = zonesResp.result;
|
||||||
|
const zone = zones.find(z => settings.domain.endsWith(z.name));
|
||||||
|
if (!zone) throw new Error('Зона для домена не найдена в Cloudflare');
|
||||||
|
const accountId = zone.account.id;
|
||||||
|
const tunnelsResp = await axios.get(
|
||||||
|
`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`,
|
||||||
|
{ headers: { Authorization: `Bearer ${settings.api_token}` } }
|
||||||
|
);
|
||||||
|
const tunnels = tunnelsResp.data.result;
|
||||||
|
const foundTunnel = tunnels.find(t => settings.tunnel_token.includes(t.id));
|
||||||
|
if (foundTunnel) {
|
||||||
|
tunnelStatus = foundTunnel.status || 'active';
|
||||||
|
tunnelMsg = `Туннель найден: ${foundTunnel.name || foundTunnel.id}, статус: ${foundTunnel.status}`;
|
||||||
|
} else {
|
||||||
|
tunnelStatus = 'not_found';
|
||||||
|
tunnelMsg = 'Туннель не найден в Cloudflare аккаунте';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
tunnelStatus = 'error';
|
||||||
|
tunnelMsg = 'Ошибка Cloudflare API (туннель): ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
status,
|
||||||
|
domainStatus,
|
||||||
|
domainMsg,
|
||||||
|
tunnelStatus,
|
||||||
|
tunnelMsg,
|
||||||
|
message: `Cloudflared статус: ${status}, домен: ${domainStatus}, туннель: ${tunnelStatus}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
38
backend/utils/cloudflaredCompose.js
Normal file
38
backend/utils/cloudflaredCompose.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
|
||||||
|
const composePath = '/docker-compose.yml';
|
||||||
|
|
||||||
|
function addCloudflaredToCompose(tunnelToken) {
|
||||||
|
console.log('[cloudflaredCompose] process.cwd():', process.cwd());
|
||||||
|
console.log('[cloudflaredCompose] __dirname:', __dirname);
|
||||||
|
console.log('[cloudflaredCompose] Ожидаемый путь к compose:', composePath);
|
||||||
|
if (!fs.existsSync(composePath)) {
|
||||||
|
console.error('[cloudflaredCompose] Файл не найден:', composePath);
|
||||||
|
throw new Error('docker-compose.yml не найден по пути: ' + composePath);
|
||||||
|
}
|
||||||
|
let doc;
|
||||||
|
try {
|
||||||
|
doc = yaml.load(fs.readFileSync(composePath, 'utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[cloudflaredCompose] Ошибка чтения compose:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
doc.services = doc.services || {};
|
||||||
|
doc.services.cloudflared = {
|
||||||
|
image: 'cloudflare/cloudflared:latest',
|
||||||
|
command: 'tunnel --no-autoupdate run',
|
||||||
|
environment: [`TUNNEL_TOKEN=${tunnelToken}`],
|
||||||
|
restart: 'unless-stopped'
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(composePath, yaml.dump(doc), 'utf8');
|
||||||
|
console.log('[cloudflaredCompose] cloudflared добавлен в compose:', composePath);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[cloudflaredCompose] Ошибка записи compose:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { addCloudflaredToCompose };
|
||||||
@@ -2047,6 +2047,19 @@ cliui@^8.0.1:
|
|||||||
strip-ansi "^6.0.1"
|
strip-ansi "^6.0.1"
|
||||||
wrap-ansi "^7.0.0"
|
wrap-ansi "^7.0.0"
|
||||||
|
|
||||||
|
cloudflare@^4.4.1:
|
||||||
|
version "4.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/cloudflare/-/cloudflare-4.4.1.tgz#a3a395b2eed46e6b2e5175a62cc962267cef3981"
|
||||||
|
integrity sha512-wrtQ9WMflnfRcmdQZf/XfVVkeucgwzzYeqFDfgbNdADTaexsPwrtt3etzUvPGvVUeEk9kOPfNkl8MSzObxrIsg==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "^18.11.18"
|
||||||
|
"@types/node-fetch" "^2.6.4"
|
||||||
|
abort-controller "^3.0.0"
|
||||||
|
agentkeepalive "^4.2.1"
|
||||||
|
form-data-encoder "1.7.2"
|
||||||
|
formdata-node "^4.3.2"
|
||||||
|
node-fetch "^2.6.7"
|
||||||
|
|
||||||
color-convert@^1.9.0, color-convert@^1.9.3:
|
color-convert@^1.9.0, color-convert@^1.9.3:
|
||||||
version "1.9.3"
|
version "1.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
||||||
|
|||||||
17
cloudflared-agent.js
Normal file
17
cloudflared-agent.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.post('/cloudflared/restart', (req, res) => {
|
||||||
|
exec('docker-compose up -d cloudflared', (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ success: false, message: stderr || err.message });
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: 'cloudflared перезапущен', output: stdout });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(9000, '0.0.0.0', () => {
|
||||||
|
console.log('Cloudflared agent listening on 0.0.0.0:9000');
|
||||||
|
});
|
||||||
BIN
cloudflared-linux-amd64.deb
Normal file
BIN
cloudflared-linux-amd64.deb
Normal file
Binary file not shown.
2
cloudflared.env
Normal file
2
cloudflared.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
TUNNEL_TOKEN=eyJhIjoiYTY3ODYxMDcyYTE0NGNkZDc0NmU5YzliZGQ4NDc2ZmUiLCJ0IjoiZmYwMDVkMTUtZjc4OC00NDI2LTg1NjAtNWRlZjI0MmEyYTE0IiwicyI6Ik5tVTFNakkzWXpJdE5XWTFOUzAwT1RCaExXSTFOamN0TWpnMU1EQTRaak5sTmpJeSJ9
|
||||||
|
DOMAIN=dapp.hb3-accelerator.com
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
# version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -13,13 +11,14 @@ services:
|
|||||||
POSTGRES_USER: ${DB_USER:-dapp_user}
|
POSTGRES_USER: ${DB_USER:-dapp_user}
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-dapp_password}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-dapp_password}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- '5432:5432'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-dapp_user} -d ${DB_NAME:-dapp_db}"]
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- pg_isready -U ${DB_USER:-dapp_user} -d ${DB_NAME:-dapp_db}
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
ollama:
|
ollama:
|
||||||
image: ollama/ollama:latest
|
image: ollama/ollama:latest
|
||||||
container_name: dapp-ollama
|
container_name: dapp-ollama
|
||||||
@@ -27,9 +26,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ollama_data:/root/.ollama
|
- ollama_data:/root/.ollama
|
||||||
ports:
|
ports:
|
||||||
- "11434:11434"
|
- '11434:11434'
|
||||||
command: serve
|
command: serve
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
@@ -44,6 +42,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- ./frontend/dist:/app/frontend_dist:ro
|
- ./frontend/dist:/app/frontend_dist:ro
|
||||||
|
- ./cloudflared.env:/cloudflared.env
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV:-development}
|
- NODE_ENV=${NODE_ENV:-development}
|
||||||
- PORT=${PORT:-8000}
|
- PORT=${PORT:-8000}
|
||||||
@@ -52,18 +52,16 @@ services:
|
|||||||
- DB_NAME=${DB_NAME:-dapp_db}
|
- DB_NAME=${DB_NAME:-dapp_db}
|
||||||
- DB_USER=${DB_USER:-dapp_user}
|
- DB_USER=${DB_USER:-dapp_user}
|
||||||
- DB_PASSWORD=${DB_PASSWORD:-dapp_password}
|
- DB_PASSWORD=${DB_PASSWORD:-dapp_password}
|
||||||
- DATABASE_URL=postgresql://${DB_USER:-dapp_user}:${DB_PASSWORD:-dapp_password}@postgres:5432/${DB_NAME:-dapp_db}
|
- >-
|
||||||
|
DATABASE_URL=postgresql://${DB_USER:-dapp_user}:${DB_PASSWORD:-dapp_password}@postgres:5432/${DB_NAME:-dapp_db}
|
||||||
- OLLAMA_BASE_URL=http://ollama:11434
|
- OLLAMA_BASE_URL=http://ollama:11434
|
||||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
|
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
|
||||||
- OLLAMA_EMBEDDINGS_MODEL=${OLLAMA_EMBEDDINGS_MODEL:-qwen2.5:7b}
|
- OLLAMA_EMBEDDINGS_MODEL=${OLLAMA_EMBEDDINGS_MODEL:-qwen2.5:7b}
|
||||||
- FRONTEND_URL=http://localhost:5173
|
- FRONTEND_URL=http://localhost:5173
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- '8000:8000'
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- host.docker.internal:host-gateway
|
||||||
# command: sh -c "yarn run dev" # Временно комментируем эту строку
|
|
||||||
# command: nodemon server.js # Запускаем через nodemon
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
@@ -76,9 +74,8 @@ services:
|
|||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- frontend_node_modules:/app/node_modules
|
- frontend_node_modules:/app/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- '5173:5173'
|
||||||
command: yarn run dev -- --host 0.0.0.0
|
command: yarn run dev -- --host 0.0.0.0
|
||||||
|
|
||||||
ollama-setup:
|
ollama-setup:
|
||||||
image: curlimages/curl:latest
|
image: curlimages/curl:latest
|
||||||
container_name: dapp-ollama-setup
|
container_name: dapp-ollama-setup
|
||||||
@@ -95,9 +92,42 @@ services:
|
|||||||
curl -X POST http://ollama:11434/api/pull -d '{\"name\":\"${OLLAMA_MODEL:-qwen2.5:7b}\"}' -H 'Content-Type: application/json'
|
curl -X POST http://ollama:11434/api/pull -d '{\"name\":\"${OLLAMA_MODEL:-qwen2.5:7b}\"}' -H 'Content-Type: application/json'
|
||||||
echo 'Done!'
|
echo 'Done!'
|
||||||
"
|
"
|
||||||
|
cloudflared:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.cloudflared
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./start-cloudflared.sh:/start-cloudflared.sh
|
||||||
|
- ./cloudflared.env:/cloudflared.env
|
||||||
|
- ./.cloudflared:/etc/cloudflared
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
dns:
|
||||||
|
- 1.1.1.1
|
||||||
|
- 8.8.8.8
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:39693/metrics", "||", "exit", "1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
environment:
|
||||||
|
- TUNNEL_METRICS=0.0.0.0:39693
|
||||||
|
cloudflared-agent:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.agent
|
||||||
|
container_name: dapp-cloudflared-agent
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
depends_on:
|
||||||
|
- cloudflared
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data: null
|
||||||
ollama_data:
|
ollama_data: null
|
||||||
backend_node_modules:
|
backend_node_modules: null
|
||||||
frontend_node_modules:
|
frontend_node_modules: null
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Поддержка SPA (Single Page Application)
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Настройка кеширования для статических файлов
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
|
||||||
expires 30d;
|
|
||||||
add_header Cache-Control "public, no-transform";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Настройка для API запросов на бэкенд
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://backend:8000/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,8 @@ import HomeView from '../views/HomeView.vue';
|
|||||||
const SettingsAiView = () => import('../views/settings/AiSettingsView.vue');
|
const SettingsAiView = () => import('../views/settings/AiSettingsView.vue');
|
||||||
const SettingsBlockchainView = () => import('../views/settings/BlockchainSettingsView.vue');
|
const SettingsBlockchainView = () => import('../views/settings/BlockchainSettingsView.vue');
|
||||||
const SettingsSecurityView = () => import('../views/settings/SecuritySettingsView.vue');
|
const SettingsSecurityView = () => import('../views/settings/SecuritySettingsView.vue');
|
||||||
const SettingsInterfaceView = () => import('../views/settings/InterfaceSettingsView.vue');
|
const SettingsInterfaceView = () => import('../views/settings/Interface/InterfaceSettingsView.vue');
|
||||||
|
const SettingsInterfaceCloudflareDetailsView = () => import('../views/settings/Interface/CloudflareDetailsView.vue');
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { setToStorage } from '../utils/storage.js';
|
import { setToStorage } from '../utils/storage.js';
|
||||||
|
|
||||||
@@ -52,6 +53,11 @@ const routes = [
|
|||||||
name: 'settings-interface',
|
name: 'settings-interface',
|
||||||
component: SettingsInterfaceView,
|
component: SettingsInterfaceView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'interface/cloudflare-details',
|
||||||
|
name: 'settings-interface-cloudflare-details',
|
||||||
|
component: SettingsInterfaceCloudflareDetailsView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'telegram',
|
path: 'telegram',
|
||||||
name: 'settings-telegram',
|
name: 'settings-telegram',
|
||||||
|
|||||||
307
frontend/src/views/settings/Interface/CloudflareDetailsView.vue
Normal file
307
frontend/src/views/settings/Interface/CloudflareDetailsView.vue
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cloudflare-details settings-panel">
|
||||||
|
<button class="close-btn" @click="goBack">×</button>
|
||||||
|
<h2>Настройка Cloudflare и подключение домена</h2>
|
||||||
|
<ol class="instruction-block">
|
||||||
|
<li>Зайдите в свой аккаунт <a href="https://dash.cloudflare.com/" target="_blank">Cloudflare</a> и добавьте ваш домен.</li>
|
||||||
|
<li>Смените NS-записи у регистратора домена на те, что выдаст Cloudflare (см. <a href="https://developers.cloudflare.com/fundamentals/setup/add-site/ns/" target="_blank">инструкцию</a>).</li>
|
||||||
|
<li>Дождитесь, когда домен будет обслуживаться Cloudflare (обычно 5-30 минут).</li>
|
||||||
|
<li>Сгенерируйте <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">API Token</a> с правами управления DNS и туннелями.</li>
|
||||||
|
<li>Введите API Token и домен ниже для автоматической настройки туннеля и DNS.</li>
|
||||||
|
<li><b>Один раз выполните в терминале WSL2:</b>
|
||||||
|
<pre style="white-space:pre-line;font-size:0.95em;">curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
|
||||||
|
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared noble main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install cloudflared</pre>
|
||||||
|
</li>
|
||||||
|
<li>Нажмите кнопку <b>Автоматически настроить и открыть приложение</b> и дождитесь появления ссылки.</li>
|
||||||
|
</ol>
|
||||||
|
<form class="form-block" @submit.prevent="saveToken">
|
||||||
|
<label>Cloudflare API Token:</label>
|
||||||
|
<input v-model="apiToken" type="text" class="form-control" placeholder="Введите API Token" />
|
||||||
|
<button class="btn-primary" type="submit">Сохранить токен</button>
|
||||||
|
</form>
|
||||||
|
<div v-if="accounts.length" class="form-block">
|
||||||
|
<label>Выберите аккаунт Cloudflare:</label>
|
||||||
|
<select v-model="selectedAccountId" class="form-control">
|
||||||
|
<option v-for="acc in accounts" :key="acc.id" :value="acc.id">
|
||||||
|
{{ acc.name }} ({{ acc.id }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-primary" @click="saveAccountId">Сохранить аккаунт</button>
|
||||||
|
<div v-if="accountStatusMsg" class="status-block">{{ accountStatusMsg }}</div>
|
||||||
|
</div>
|
||||||
|
<form class="form-block" @submit.prevent="connectDomain">
|
||||||
|
<label>Домен для туннеля:</label>
|
||||||
|
<input v-model="domain" type="text" class="form-control" placeholder="example.com" />
|
||||||
|
<button class="btn-primary" type="submit">Проверить и подключить домен</button>
|
||||||
|
</form>
|
||||||
|
<div class="status-block">
|
||||||
|
<b>Статус Cloudflared:</b> {{ tunnelStatus }}<br>
|
||||||
|
<b>Статус домена:</b> {{ domainStatusMsg }}<br>
|
||||||
|
<b>Статус туннеля:</b> {{ tunnelStatusMsg }}<br>
|
||||||
|
<span v-if="statusMsg">{{ statusMsg }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="appUrl" class="app-link-block">
|
||||||
|
<a :href="appUrl" target="_blank" class="btn-primary open-app-btn">
|
||||||
|
Открыть приложение
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-if="autoSetupSteps.length" class="auto-setup-steps">
|
||||||
|
<div v-for="step in autoSetupSteps" :key="step.step" :class="['auto-setup-step', step.status]">
|
||||||
|
{{ step.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
const router = useRouter();
|
||||||
|
const goBack = () => router.push('/settings/interface');
|
||||||
|
|
||||||
|
const apiToken = ref('');
|
||||||
|
const domain = ref('');
|
||||||
|
const statusMsg = ref('');
|
||||||
|
const tunnelStatus = ref('');
|
||||||
|
const domainStatusMsg = ref('');
|
||||||
|
const tunnelStatusMsg = ref('');
|
||||||
|
const appUrl = ref('');
|
||||||
|
const autoSetupSteps = ref([]);
|
||||||
|
const accounts = ref([]);
|
||||||
|
const selectedAccountId = ref('');
|
||||||
|
const accountStatusMsg = ref('');
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
console.log('[CloudflareDetails] loadSettings: start');
|
||||||
|
const res = await fetch('/api/cloudflare/settings');
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('[CloudflareDetails] loadSettings: data', data);
|
||||||
|
if (data.success && data.settings) {
|
||||||
|
apiToken.value = data.settings.api_token || '';
|
||||||
|
domain.value = data.settings.domain || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CloudflareDetails] loadSettings: error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveToken() {
|
||||||
|
try {
|
||||||
|
console.log('[CloudflareDetails] saveToken: start', apiToken.value);
|
||||||
|
const res = await fetch('/api/cloudflare/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: apiToken.value })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('[CloudflareDetails] saveToken: data', data);
|
||||||
|
statusMsg.value = data.message || 'Токен сохранён!';
|
||||||
|
// Получить список аккаунтов
|
||||||
|
const accRes = await fetch('/api/cloudflare/accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ api_token: apiToken.value })
|
||||||
|
});
|
||||||
|
const accData = await accRes.json();
|
||||||
|
if (accData.success && accData.accounts) {
|
||||||
|
accounts.value = accData.accounts;
|
||||||
|
accountStatusMsg.value = 'Выберите аккаунт и сохраните.';
|
||||||
|
} else {
|
||||||
|
accountStatusMsg.value = accData.message || 'Ошибка получения аккаунтов';
|
||||||
|
}
|
||||||
|
getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CloudflareDetails] saveToken: error', e);
|
||||||
|
statusMsg.value = 'Ошибка при сохранении токена';
|
||||||
|
accountStatusMsg.value = 'Ошибка получения аккаунтов';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAccountId() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cloudflare/account-id', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ account_id: selectedAccountId.value })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
accountStatusMsg.value = data.message || 'Account ID сохранён!';
|
||||||
|
getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
accountStatusMsg.value = 'Ошибка при сохранении Account ID';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectDomain() {
|
||||||
|
try {
|
||||||
|
statusMsg.value = 'Выполняется автоматическая настройка...';
|
||||||
|
appUrl.value = '';
|
||||||
|
autoSetupSteps.value = [];
|
||||||
|
const res = await fetch('/api/cloudflare/domain', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain: domain.value })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
statusMsg.value = data.message || 'Готово!';
|
||||||
|
appUrl.value = data.app_url || '';
|
||||||
|
autoSetupSteps.value = data.steps || [];
|
||||||
|
} else {
|
||||||
|
statusMsg.value = data.error || 'Ошибка автоматической настройки';
|
||||||
|
autoSetupSteps.value = data.steps || [];
|
||||||
|
}
|
||||||
|
getStatus();
|
||||||
|
} catch (e) {
|
||||||
|
statusMsg.value = 'Ошибка автоматической настройки: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatus() {
|
||||||
|
try {
|
||||||
|
console.log('[CloudflareDetails] getStatus: start');
|
||||||
|
const res = await fetch('/api/cloudflare/status');
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('[CloudflareDetails] getStatus: data', data);
|
||||||
|
tunnelStatus.value = data.status || '';
|
||||||
|
domainStatusMsg.value = data.domainMsg || '';
|
||||||
|
tunnelStatusMsg.value = data.tunnelMsg || '';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CloudflareDetails] getStatus: error', e);
|
||||||
|
tunnelStatus.value = 'Ошибка';
|
||||||
|
domainStatusMsg.value = 'Ошибка';
|
||||||
|
tunnelStatusMsg.value = 'Ошибка';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSettings();
|
||||||
|
getStatus();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cloudflare-details.settings-panel {
|
||||||
|
padding: var(--block-padding);
|
||||||
|
background-color: var(--color-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
animation: fadeIn var(--transition-normal);
|
||||||
|
min-height: 200px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--color-grey-light);
|
||||||
|
padding-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.instruction-block {
|
||||||
|
background: #f8f8f8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.form-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
.btn-primary.install-btn {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.status-block {
|
||||||
|
margin: 1.5rem 0 0.5rem 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #bbb;
|
||||||
|
transition: color 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
.app-link-block {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.open-app-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.7rem 2rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
background: var(--color-success, #2cae4f);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.open-app-btn:hover {
|
||||||
|
background: var(--color-success-dark, #1e7d32);
|
||||||
|
}
|
||||||
|
.auto-setup-btn {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.auto-setup-steps {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.auto-setup-step {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8f8f8;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.auto-setup-step.ok {
|
||||||
|
color: #2cae4f;
|
||||||
|
}
|
||||||
|
.auto-setup-step.error {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="interface-settings settings-panel" style="position:relative;min-height:120px">
|
||||||
|
<button class="close-btn" @click="goBack">×</button>
|
||||||
|
<h2>Подключить домен</h2>
|
||||||
|
<div class="domain-connect-block">
|
||||||
|
<p>Для подключения домена и настройки Cloudflare нажмите кнопку ниже.</p>
|
||||||
|
<button class="btn-primary" @click="goToCloudflareDetails">Подробнее</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
const router = useRouter();
|
||||||
|
const goBack = () => router.push('/settings');
|
||||||
|
const goToCloudflareDetails = () => router.push('/settings/interface/cloudflare-details');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-panel {
|
||||||
|
padding: var(--block-padding);
|
||||||
|
background-color: var(--color-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
animation: fadeIn var(--transition-normal);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--color-grey-light);
|
||||||
|
padding-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.domain-connect-block {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #bbb;
|
||||||
|
transition: color 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="interface-settings settings-panel" style="position:relative;min-height:120px">
|
|
||||||
<button class="close-btn" @click="goBack">×</button>
|
|
||||||
<h2>Настройки Интерфейса</h2>
|
|
||||||
|
|
||||||
<!-- Панель Язык -->
|
|
||||||
<div class="sub-settings-panel">
|
|
||||||
<h3>Настройки языка</h3>
|
|
||||||
<div class="setting-form">
|
|
||||||
<p>Выбор языка интерфейса</p>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Язык интерфейса:</label>
|
|
||||||
<select v-model="selectedLanguage" class="form-control">
|
|
||||||
<option value="ru">Русский</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
<!-- Добавить другие языки по необходимости -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DomainConnectBlock />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { getFromStorage, setToStorage } from '../../utils/storage'; // Путь к utils может отличаться
|
|
||||||
import DomainConnectBlock from './DomainConnectBlock.vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
// TODO: Импортировать API для сохранения, если нужно
|
|
||||||
|
|
||||||
const selectedLanguage = ref(getFromStorage('userLanguage', 'ru'));
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const goBack = () => router.push('/settings');
|
|
||||||
|
|
||||||
// Функция сохранения
|
|
||||||
const saveLanguageSetting = () => {
|
|
||||||
setToStorage('userLanguage', selectedLanguage.value);
|
|
||||||
console.log(`[InterfaceSettingsView] Язык сохранен как: ${selectedLanguage.value}`);
|
|
||||||
// TODO: Добавить реальную смену языка (i18n)
|
|
||||||
// TODO: Возможно, отправить на сервер, если язык влияет на бэкенд
|
|
||||||
// alert('Язык сохранен!'); // Пример уведомления
|
|
||||||
};
|
|
||||||
|
|
||||||
// Можно убрать watch, если сохранение происходит только по кнопке
|
|
||||||
// watch(selectedLanguage, (newLang) => {
|
|
||||||
// setToStorage('userLanguage', newLang);
|
|
||||||
// console.log(`[InterfaceSettingsView] Язык изменен на: ${newLang}`);
|
|
||||||
// });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.settings-panel {
|
|
||||||
padding: var(--block-padding);
|
|
||||||
background-color: var(--color-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-top: var(--spacing-lg);
|
|
||||||
animation: fadeIn var(--transition-normal);
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
margin-bottom: var(--spacing-lg);
|
|
||||||
border-bottom: 1px solid var(--color-grey-light);
|
|
||||||
padding-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.sub-settings-panel {
|
|
||||||
margin-bottom: var(--spacing-lg);
|
|
||||||
padding-bottom: var(--spacing-lg);
|
|
||||||
/* Убираем нижнюю границу, если это последняя панель */
|
|
||||||
}
|
|
||||||
.setting-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.form-label {
|
|
||||||
display: block; /* Можно оставить блочным */
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
.form-control {
|
|
||||||
max-width: 300px; /* Ограничим ширину select */
|
|
||||||
}
|
|
||||||
.btn-primary {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
.close-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 18px;
|
|
||||||
right: 18px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #bbb;
|
|
||||||
transition: color 0.2s;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.close-btn:hover {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: ['dapp-frontend', 'localhost', '127.0.0.1', 'hb3-accelerator.com', 'dapp.hb3-accelerator.com'],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://dapp-backend:8000',
|
target: 'http://dapp-backend:8000',
|
||||||
|
|||||||
84
start-cloudflared.sh
Executable file
84
start-cloudflared.sh
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "==== Содержимое /cloudflared.env ===="
|
||||||
|
cat /cloudflared.env || echo "Файл не найден"
|
||||||
|
echo "===="
|
||||||
|
|
||||||
|
# Получаем токен из переменной окружения или из файла
|
||||||
|
if [ -z "$TUNNEL_TOKEN" ] && [ -f /cloudflared.env ]; then
|
||||||
|
. /cloudflared.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "TUNNEL_TOKEN=[$TUNNEL_TOKEN]"
|
||||||
|
|
||||||
|
# Функция для проверки сети
|
||||||
|
check_network() {
|
||||||
|
echo "Проверка сетевого соединения..."
|
||||||
|
for addr in 1.1.1.1 8.8.8.8; do
|
||||||
|
if ping -c 1 -W 5 "$addr" > /dev/null 2>&1; then
|
||||||
|
echo "✓ Сеть доступна ($addr)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "✗ Сетевые проблемы"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Функция для проверки доступности backend
|
||||||
|
check_backend() {
|
||||||
|
echo "Проверка доступности backend..."
|
||||||
|
if curl -s --connect-timeout 5 http://backend:8000 >/dev/null 2>&1; then
|
||||||
|
echo "✓ Backend доступен"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "✗ Backend недоступен"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверяем сеть перед запуском
|
||||||
|
echo "=== Проверка подключений ==="
|
||||||
|
check_network
|
||||||
|
check_backend
|
||||||
|
|
||||||
|
# Проверяем наличие конфигурационного файла
|
||||||
|
echo "=== Проверка конфигурации ==="
|
||||||
|
if [ -f "/etc/cloudflared/config.yml" ]; then
|
||||||
|
echo "✓ Конфигурационный файл найден"
|
||||||
|
cat /etc/cloudflared/config.yml
|
||||||
|
else
|
||||||
|
echo "✗ Конфигурационный файл не найден"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "/etc/cloudflared/a765a217-5312-48f8-9bb7-5a7ef56602b8.json" ]; then
|
||||||
|
echo "✓ Credentials файл найден"
|
||||||
|
else
|
||||||
|
echo "✗ Credentials файл не найден"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверим доступность frontend
|
||||||
|
echo "=== Проверка frontend ==="
|
||||||
|
if curl -s --connect-timeout 5 http://dapp-frontend:5173 >/dev/null 2>&1; then
|
||||||
|
echo "✓ Frontend доступен"
|
||||||
|
else
|
||||||
|
echo "✗ Frontend недоступен, fallback на backend"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Запускаем cloudflared с токеном вместо конфигурационного файла
|
||||||
|
echo "=== Запуск cloudflared с токеном ==="
|
||||||
|
echo "Используем токен туннеля: ${TUNNEL_TOKEN:0:20}..."
|
||||||
|
exec cloudflared tunnel \
|
||||||
|
--no-autoupdate \
|
||||||
|
--edge-ip-version 4 \
|
||||||
|
--protocol http2 \
|
||||||
|
--retries 20 \
|
||||||
|
--grace-period 60s \
|
||||||
|
--loglevel info \
|
||||||
|
--metrics 0.0.0.0:39693 \
|
||||||
|
--proxy-connect-timeout 90s \
|
||||||
|
--proxy-tls-timeout 90s \
|
||||||
|
--proxy-tcp-keepalive 15s \
|
||||||
|
--proxy-keepalive-timeout 120s \
|
||||||
|
--proxy-keepalive-connections 10 \
|
||||||
|
--proxy-no-happy-eyeballs \
|
||||||
|
run --token "$TUNNEL_TOKEN"
|
||||||
Reference in New Issue
Block a user