@@ -4,6 +4,7 @@ let Cloudflare;
try {
Cloudflare = require ( 'cloudflare' ) ;
} catch ( e ) {
console . warn ( '[Cloudflare] Cloudflare package not available:' , e . message ) ;
Cloudflare = null ;
}
const db = require ( '../db' ) ;
@@ -19,10 +20,17 @@ const tunnelName = 'hb3-accelerator'; // или из настроек
// --- Вспомогательные функции ---
async function getSettings ( ) {
try {
const { rows } = await db . query ( 'SELECT * FROM cloudflare_settings ORDER BY id DESC LIMIT 1' ) ;
return rows [ 0 ] || { } ;
} catch ( e ) {
console . error ( '[Cloudflare] Error getting settings:' , e ) ;
return { } ;
}
}
async function upsertSettings ( fields ) {
try {
const current = await getSettings ( ) ;
if ( current . id ) {
const updates = [ ] ;
@@ -39,8 +47,13 @@ async function upsertSettings(fields) {
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 ) ;
}
} catch ( e ) {
console . error ( '[Cloudflare] Error upserting settings:' , e ) ;
throw e ;
}
}
function generateDockerCompose ( tunnelToken ) {
return ` version: '3.8'
services:
@@ -52,6 +65,7 @@ services:
restart: unless-stopped
` ;
}
function runDockerCompose ( ) {
return new Promise ( ( resolve , reject ) => {
exec ( ` docker-compose -f ${ dockerComposePath } up -d cloudflared ` , ( err , stdout , stderr ) => {
@@ -60,6 +74,7 @@ function runDockerCompose() {
} ) ;
} ) ;
}
function checkCloudflaredStatus ( ) {
return new Promise ( ( resolve ) => {
exec ( 'docker ps --filter "name=cloudflared" --format "{{.Status}}"' , ( err , stdout ) => {
@@ -119,15 +134,22 @@ router.post('/account-id', async (req, res) => {
router . post ( '/domain' , async ( req , res ) => {
const steps = [ ] ;
try {
console . log ( '[Cloudflare /domain] Starting domain connection process' ) ;
// 1. Сохраняем домен, если он пришёл с фронта
const { domain : domainFromBody } = req . body ;
if ( domainFromBody ) {
console . log ( '[Cloudflare /domain] Saving domain:' , domainFromBody ) ;
await upsertSettings ( { domain : domainFromBody } ) ;
}
// 2. Получаем актуальные настройки
const settings = await getSettings ( ) ;
console . log ( '[Cloudflare /domain] Current settings:' , { ... settings , api _token : settings . api _token ? '[HIDDEN]' : 'null' } ) ;
const { api _token , domain , account _id , tunnel _id , tunnel _token } = settings ;
if ( ! api _token || ! domain || ! account _id ) {
console . error ( '[Cloudflare /domain] Missing required parameters:' , { api _token : ! ! api _token , domain : ! ! domain , account _id : ! ! account _id } ) ;
return res . json ( { success : false , error : 'Н е все параметры Cloudflare заданы (api_token, domain, account_id)' } ) ;
}
let tunnelId = tunnel _id ;
@@ -169,7 +191,7 @@ router.post('/domain', async (req, res) => {
{
config : {
ingress : [
{ hostname : domain , service : 'http://dapp-frontend :5173' } ,
{ hostname : domain , service : 'http://localhost :5173' } ,
{ service : 'http_status:404' }
]
}
@@ -185,13 +207,172 @@ router.post('/domain', async (req, res) => {
steps . push ( { step : 'create_route' , status : 'error' , message : 'Ошибка создания маршрута: ' + errorMsg } ) ;
return res . json ( { success : false , steps , error : errorMsg } ) ;
}
// 4. Перезапуск cloudflared через cloudflared-agent
// 3.5. Автоматическое создание DNS записей для туннеля
try {
await axios . post ( 'http://c loudflared-agent:9000/cloudflared/restart ' ) ;
console . log ( '[C loudflare /domain] Creating DNS records automatically... ' ) ;
// Получаем зону для домена
const zonesResp = await axios . get ( 'https://api.cloudflare.com/client/v4/zones' , {
headers : { Authorization : ` Bearer ${ api _token } ` } ,
params : { name : domain }
} ) ;
const zones = zonesResp . data . result ;
if ( ! zones || zones . length === 0 ) {
steps . push ( { step : 'create_dns' , status : 'error' , message : 'Домен не найден в Cloudflare аккаунте для создания DNS записей' } ) ;
console . log ( '[Cloudflare /domain] Domain not found in Cloudflare account, skipping DNS creation' ) ;
} else {
const zoneId = zones [ 0 ] . id ;
// Получаем существующие DNS записи
const recordsResp = await axios . get ( ` https://api.cloudflare.com/client/v4/zones/ ${ zoneId } /dns_records ` , {
headers : { Authorization : ` Bearer ${ api _token } ` }
} ) ;
const existingRecords = recordsResp . data . result || [ ] ;
// Проверяем, есть ли уже запись для основного домена, указывающая на туннель
const tunnelCnamePattern = new RegExp ( ` ${ tunnelId } \. cfargotunnel \. com ` ) ;
const hasMainRecord = existingRecords . some ( record =>
record . name === domain &&
(
( record . type === 'CNAME' && tunnelCnamePattern . test ( record . content ) ) ||
( record . type === 'CNAME' && record . content . includes ( 'cfargotunnel.com' ) )
)
) ;
if ( ! hasMainRecord ) {
// Удаляем конфликтующие записи для основного домена (A, AAAA, CNAME)
const conflictingRecords = existingRecords . filter ( record =>
record . name === domain && [ 'A' , 'AAAA' , 'CNAME' ] . includes ( record . type )
) ;
for ( const conflictRecord of conflictingRecords ) {
try {
await axios . delete (
` https://api.cloudflare.com/client/v4/zones/ ${ zoneId } /dns_records/ ${ conflictRecord . id } ` ,
{ headers : { Authorization : ` Bearer ${ api _token } ` } }
) ;
console . log ( '[Cloudflare /domain] Removed conflicting record:' , conflictRecord . type , conflictRecord . name , conflictRecord . content ) ;
} catch ( delErr ) {
console . warn ( '[Cloudflare /domain] Failed to delete conflicting record:' , delErr . message ) ;
}
}
// Создаем CNAME запись для основного домена
const cnameRecord = {
type : 'CNAME' ,
name : domain ,
content : ` ${ tunnelId } .cfargotunnel.com ` ,
ttl : 1 ,
proxied : true
} ;
const createResp = await axios . post (
` https://api.cloudflare.com/client/v4/zones/ ${ zoneId } /dns_records ` ,
cnameRecord ,
{ headers : { Authorization : ` Bearer ${ api _token } ` } }
) ;
console . log ( '[Cloudflare /domain] Main CNAME record created:' , createResp . data . result ) ;
steps . push ( { step : 'create_dns' , status : 'ok' , message : ` DNS запись создана: ${ domain } -> ${ tunnelId } .cfargotunnel.com (проксирована) ` } ) ;
} else {
console . log ( '[Cloudflare /domain] Main record already exists and points to tunnel' ) ;
steps . push ( { step : 'create_dns' , status : 'ok' , message : 'DNS запись для основного домена уже существует и настроена правильно' } ) ;
}
// Создаем www поддомен только для корневых доменов (не для поддоменов)
const domainParts = domain . split ( '.' ) ;
const isRootDomain = domainParts . length === 2 ; // example.com, а не subdomain.example.com
if ( isRootDomain ) {
// Обновляем список записей после возможных изменений
const updatedRecordsResp = await axios . get ( ` https://api.cloudflare.com/client/v4/zones/ ${ zoneId } /dns_records ` , {
headers : { Authorization : ` Bearer ${ api _token } ` }
} ) ;
const updatedRecords = updatedRecordsResp . data . result || [ ] ;
// Проверяем, есть ли уже запись для www поддомена
const hasWwwRecord = updatedRecords . some ( record =>
record . name === ` www. ${ domain } ` &&
(
( record . type === 'CNAME' && tunnelCnamePattern . test ( record . content ) ) ||
( record . type === 'CNAME' && record . content . includes ( 'cfargotunnel.com' ) )
)
) ;
if ( ! hasWwwRecord ) {
// Удаляем конфликтующие записи для www поддомена
const conflictingWwwRecords = updatedRecords . filter ( record =>
record . name === ` www. ${ domain } ` && [ 'A' , 'AAAA' , 'CNAME' ] . includes ( record . type )
) ;
for ( const conflictRecord of conflictingWwwRecords ) {
try {
await axios . delete (
` https://api.cloudflare.com/client/v4/zones/ ${ zoneId } /dns_records/ ${ conflictRecord . id } ` ,
{ headers : { Authorization : ` Bearer ${ api _token } ` } }
) ;
console . log ( '[Cloudflare /domain] Removed conflicting www record:' , conflictRecord . type , conflictRecord . name , conflictRecord . content ) ;
} catch ( delErr ) {
console . warn ( '[Cloudflare /domain] Failed to delete conflicting www record:' , delErr . message ) ;
}
}
// Создаем CNAME запись для www поддомена
const wwwCnameRecord = {
type : 'CNAME' ,
name : ` www. ${ domain } ` ,
content : ` ${ tunnelId } .cfargotunnel.com ` ,
ttl : 1 ,
proxied : true
} ;
const createWwwResp = await axios . post (
` https://api.cloudflare.com/client/v4/zones/ ${ zoneId } /dns_records ` ,
wwwCnameRecord ,
{ headers : { Authorization : ` Bearer ${ api _token } ` } }
) ;
console . log ( '[Cloudflare /domain] WWW CNAME record created:' , createWwwResp . data . result ) ;
steps . push ( { step : 'create_dns_www' , status : 'ok' , message : ` DNS запись создана: www. ${ domain } -> ${ tunnelId } .cfargotunnel.com (проксирована) ` } ) ;
} else {
console . log ( '[Cloudflare /domain] WWW record already exists and points to tunnel' ) ;
steps . push ( { step : 'create_dns_www' , status : 'ok' , message : 'DNS запись для www поддомена уже существует и настроена правильно' } ) ;
}
} else {
console . log ( '[Cloudflare /domain] Skipping www subdomain creation for non-root domain' ) ;
}
}
} catch ( e ) {
console . error ( '[Cloudflare /domain] Error creating DNS records:' , e ) ;
steps . push ( { step : 'create_dns' , status : 'error' , message : 'Ошибка создания DNS записей: ' + ( e . response ? . data ? . errors ? . [ 0 ] ? . message || e . message ) } ) ;
// Н е прерываем процесс, DNS можно настроить вручную
}
// 4. Перезапуск cloudflared через docker compose
try {
console . log ( '[Cloudflare /domain] Restarting cloudflared via docker compose...' ) ;
const { exec } = require ( 'child_process' ) ;
await new Promise ( ( resolve , reject ) => {
exec ( 'cd /app && docker compose restart cloudflared' , ( err , stdout , stderr ) => {
if ( err ) {
console . error ( '[Cloudflare /domain] Docker compose restart error:' , stderr || err . message ) ;
reject ( new Error ( stderr || err . message ) ) ;
} else {
console . log ( '[Cloudflare /domain] Docker compose restart success:' , stdout ) ;
resolve ( stdout ) ;
}
} ) ;
} ) ;
steps . push ( { step : 'restart_cloudflared' , status : 'ok' , message : 'cloudflared перезапущен.' } ) ;
} catch ( e ) {
console . error ( '[Cloudflare /domain] Error restarting cloudflared:' , e . message ) ;
steps . push ( { step : 'restart_cloudflared' , status : 'error' , message : 'Ошибка перезапуска cloudflared: ' + e . message } ) ;
return res . json ( { success : false , steps , error : e . message } ) ;
// Н е возвращаем ошибку, так как туннель создан
console . log ( '[Cloudflare /domain] Continuing despite restart error...' ) ;
}
// 5. Возврат app_url
res . json ( {
@@ -283,28 +464,28 @@ router.get('/status', async (req, res) => {
domainMsg = 'Ошибка Cloudflare API: ' + e . message ;
}
}
if ( settings . api _token && settings . tunnel _token && Cloudflare ) {
if ( settings . api _token && settings . tunnel _id && settings . account _id ) {
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 ;
console . log ( '[Cloudflare /status] Checking tunnel status...' ) ;
const tunnelsResp = await axios . get (
` https://api.cloudflare.com/client/v4/accounts/ ${ accountI d } /cfd_tunnel ` ,
` https://api.cloudflare.com/client/v4/accounts/ ${ settings . account_i d} /cfd_tunnel ` ,
{ headers : { Authorization : ` Bearer ${ settings . api _token } ` } }
) ;
const tunnels = tunnelsResp . data . result ;
const f oundT unnel = tunnels . find ( t => settings . tunnel _token . includes ( t . id ) ) ;
const tunnels = tunnelsResp . data . result || [ ] ;
console . log ( '[Cloudflare /status] F ound t unnels:' , tunnels . map ( t => ( { id : t . id , name : t . name , status : t . status } ) ) ) ;
const foundTunnel = tunnels . find ( t => t . id === settings . tunnel _id ) ;
if ( foundTunnel ) {
tunnelStatus = foundTunnel . status || 'active' ;
tunnelMsg = ` Туннель найден: ${ foundTunnel . name || foundTunnel . id } , статус: ${ foundTunnel . status } ` ;
console . log ( '[Cloudflare /status] Tunnel found:' , foundTunnel ) ;
} else {
tunnelStatus = 'not_found' ;
tunnelMsg = 'Туннель не найден в Cloudflare аккаунте' ;
console . log ( '[Cloudflare /status] Tunnel not found. Looking for tunnel_id:' , settings . tunnel _id ) ;
}
} catch ( e ) {
console . error ( '[Cloudflare /status] Error checking tunnel:' , e ) ;
tunnelStatus = 'error' ;
tunnelMsg = 'Ошибка Cloudflare API (туннель): ' + e . message ;
}
@@ -320,4 +501,200 @@ router.get('/status', async (req, res) => {
} ) ;
} ) ;
// --- DNS Управление ---
// Получить список DNS записей для домена
router . get ( '/dns-records' , async ( req , res ) => {
try {
const settings = await getSettings ( ) ;
const { api _token , domain } = settings ;
if ( ! api _token || ! domain ) {
return res . json ( {
success : false ,
message : 'API Token и домен должны быть настроены'
} ) ;
}
// Получаем зону для домена
const zonesResp = await axios . get ( 'https://api.cloudflare.com/client/v4/zones' , {
headers : { Authorization : ` Bearer ${ api _token } ` } ,
params : { name : domain }
} ) ;
const zones = zonesResp . data . result ;
if ( ! zones || zones . length === 0 ) {
return res . json ( {
success : false ,
message : 'Домен не найден в Cloudflare аккаунте'
} ) ;
}
const zoneId = zones [ 0 ] . id ;
// Получаем DNS записи для зоны
const recordsResp = await axios . get ( ` https://api.cloudflare.com/client/v4/zones/ ${ zoneId } /dns_records ` , {
headers : { Authorization : ` Bearer ${ api _token } ` }
} ) ;
const records = recordsResp . data . result || [ ] ;
res . json ( {
success : true ,
records : records . map ( record => ( {
id : record . id ,
type : record . type ,
name : record . name ,
content : record . content ,
ttl : record . ttl ,
proxied : record . proxied ,
zone _id : record . zone _id ,
zone _name : record . zone _name ,
created _on : record . created _on ,
modified _on : record . modified _on
} ) ) ,
zone _id : zoneId
} ) ;
} catch ( e ) {
console . error ( '[Cloudflare /dns-records] Error:' , e ) ;
res . json ( {
success : false ,
message : 'Ошибка получения DNS записей: ' + ( e . response ? . data ? . errors ? . [ 0 ] ? . message || e . message )
} ) ;
}
} ) ;
// Создать/обновить DNS запись
router . post ( '/dns-records' , async ( req , res ) => {
try {
const settings = await getSettings ( ) ;
const { api _token , domain } = settings ;
const { type , name , content , ttl = 1 , proxied = false , recordId } = req . body ;
if ( ! api _token || ! domain ) {
return res . json ( {
success : false ,
message : 'API Token и домен должны быть настроены'
} ) ;
}
if ( ! type || ! name || ! content ) {
return res . json ( {
success : false ,
message : 'Обязательные поля: type, name, content'
} ) ;
}
// Получаем зону для домена
const zonesResp = await axios . get ( 'https://api.cloudflare.com/client/v4/zones' , {
headers : { Authorization : ` Bearer ${ api _token } ` } ,
params : { name : domain }
} ) ;
const zones = zonesResp . data . result ;
if ( ! zones || zones . length === 0 ) {
return res . json ( {
success : false ,
message : 'Домен не найден в Cloudflare аккаунте'
} ) ;
}
const zoneId = zones [ 0 ] . id ;
const recordData = { type , name , content , ttl } ;
// Добавляем proxied только для типов записей, которые поддерживают прокси
if ( [ 'A' , 'AAAA' , 'CNAME' ] . includes ( type ) ) {
recordData . proxied = proxied ;
}
let result ;
if ( recordId ) {
// Обновляем существующую запись
const updateResp = await axios . put (
` https://api.cloudflare.com/client/v4/zones/ ${ zoneId } /dns_records/ ${ recordId } ` ,
recordData ,
{ headers : { Authorization : ` Bearer ${ api _token } ` } }
) ;
result = updateResp . data . result ;
} else {
// Создаем новую запись
const createResp = await axios . post (
` https://api.cloudflare.com/client/v4/zones/ ${ zoneId } /dns_records ` ,
recordData ,
{ headers : { Authorization : ` Bearer ${ api _token } ` } }
) ;
result = createResp . data . result ;
}
res . json ( {
success : true ,
message : recordId ? 'DNS запись обновлена' : 'DNS запись создана' ,
record : {
id : result . id ,
type : result . type ,
name : result . name ,
content : result . content ,
ttl : result . ttl ,
proxied : result . proxied ,
zone _id : result . zone _id
}
} ) ;
} catch ( e ) {
console . error ( '[Cloudflare /dns-records POST] Error:' , e ) ;
res . json ( {
success : false ,
message : 'Ошибка создания/обновления DNS записи: ' + ( e . response ? . data ? . errors ? . [ 0 ] ? . message || e . message )
} ) ;
}
} ) ;
// Удалить DNS запись
router . delete ( '/dns-records/:recordId' , async ( req , res ) => {
try {
const settings = await getSettings ( ) ;
const { api _token , domain } = settings ;
const { recordId } = req . params ;
if ( ! api _token || ! domain ) {
return res . json ( {
success : false ,
message : 'API Token и домен должны быть настроены'
} ) ;
}
// Получаем зону для домена
const zonesResp = await axios . get ( 'https://api.cloudflare.com/client/v4/zones' , {
headers : { Authorization : ` Bearer ${ api _token } ` } ,
params : { name : domain }
} ) ;
const zones = zonesResp . data . result ;
if ( ! zones || zones . length === 0 ) {
return res . json ( {
success : false ,
message : 'Домен не найден в Cloudflare аккаунте'
} ) ;
}
const zoneId = zones [ 0 ] . id ;
// Удаляем DNS запись
await axios . delete (
` https://api.cloudflare.com/client/v4/zones/ ${ zoneId } /dns_records/ ${ recordId } ` ,
{ headers : { Authorization : ` Bearer ${ api _token } ` } }
) ;
res . json ( {
success : true ,
message : 'DNS запись удалена'
} ) ;
} catch ( e ) {
console . error ( '[Cloudflare /dns-records DELETE] Error:' , e ) ;
res . json ( {
success : false ,
message : 'Ошибка удаления DNS записи: ' + ( e . response ? . data ? . errors ? . [ 0 ] ? . message || e . message )
} ) ;
}
} ) ;
module . exports = router ;