From 7b32c8be2a43b8c57f7d5283bbb6dca4fa2e0697 Mon Sep 17 00:00:00 2001 From: "biel.lil" Date: Wed, 5 Nov 2025 20:38:09 -0300 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20adiciona=20suporte=20a=20Cloudflare?= =?UTF-8?q?=20Tunnel=20para=20configura=C3=A7=C3=A3o=20de=20hostnames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novo endpoint POST /api/cloudflare/tunnel permite adicionar hostnames ao túnel Cloudflare existente, com suporte a HTTP/HTTPS/TCP e atualização automática da configuração via API. --- src/index.ts | 171 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 164 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0784618..3109fd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,9 +20,11 @@ const AUTH_TOKEN = process.env.AUTH_TOKEN; const DOMAIN = process.env.DOMAIN; // Cloudflare -const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || ''; -const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || ''; -const CLOUDFLARE_DOMAIN = process.env.CLOUDFLARE_DOMAIN || ''; +const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN ; +const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID; +const CLOUDFLARE_DOMAIN = process.env.CLOUDFLARE_DOMAIN; +const CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID; +const CLOUDFLARE_TUNNEL_ID = process.env.CLOUDFLARE_TUNNEL_ID; const httpsAgent = new https.Agent({ rejectUnauthorized: false }); @@ -146,6 +148,72 @@ const createCloudflareRecord = async (nome, tipo, targetValue, proxied = true) = } }; +// 🆕 Função para adicionar hostname ao túnel Cloudflare +const addHostnameToTunnel = async (hostname, service) => { + try { + if (!CLOUDFLARE_API_TOKEN || !CLOUDFLARE_ACCOUNT_ID || !CLOUDFLARE_TUNNEL_ID) { + throw new Error('Configurações do túnel Cloudflare não definidas (API_TOKEN, ACCOUNT_ID ou TUNNEL_ID)'); + } + + console.log('🚇 Adicionando hostname ao túnel Cloudflare...'); + console.log(`📝 Hostname: ${hostname}, Service: ${service}`); + + const headers = { + 'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`, + 'Content-Type': 'application/json' + }; + + // Primeiro, busca a configuração atual do túnel + const getTunnelUrl = `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/cfd_tunnel/${CLOUDFLARE_TUNNEL_ID}/configurations`; + + const currentConfig = await axios.get(getTunnelUrl, { headers }); + + const existingIngress = currentConfig.data.result?.config?.ingress || []; + + // Remove regra existente para o mesmo hostname, se houver + const filteredIngress = existingIngress.filter(rule => rule.hostname !== hostname); + + // Adiciona a nova regra ANTES da regra catch-all + const catchAllRule = filteredIngress.find(rule => !rule.hostname); + const otherRules = filteredIngress.filter(rule => rule.hostname); + + const newRule = { + hostname: hostname, + service: service, + originRequest: { + noTLSVerify: true + } + }; + + // Monta o array final: outras regras + nova regra + catch-all + const newIngress = [...otherRules, newRule]; + if (catchAllRule) { + newIngress.push(catchAllRule); + } + + // Atualiza a configuração do túnel + const updatePayload = { + config: { + ingress: newIngress + } + }; + + const updateResponse = await axios.put(getTunnelUrl, updatePayload, { headers }); + + console.log('✅ Hostname adicionado ao túnel com sucesso'); + return { + success: true, + hostname: hostname, + service: service, + tunnelId: CLOUDFLARE_TUNNEL_ID + }; + + } catch (error) { + console.error('❌ Erro ao adicionar hostname ao túnel:', error.response?.data || error.message); + throw error; + } +}; + // Middleware de autenticação da API const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; @@ -761,6 +829,74 @@ app.post('/api/cloudflare', authenticateToken, async (req, res) => { } }); +// 🚇 Endpoint para adicionar hostname ao túnel Cloudflare +app.post('/api/cloudflare/tunnel', authenticateToken, async (req, res) => { + try { + const { hostname, service, port = 80, protocol = 'http' } = req.body; + + if (!hostname || !service) { + return res.status(400).json({ + error: 'Campos obrigatórios: hostname, service', + exemplos: { + 'N8N Editor': { + hostname: 'editor.cliente1.seudominio.com', + service: 'http://n8n_editor_cliente1:5678', + description: 'Aponta para o serviço N8N Editor' + }, + 'N8N Webhook': { + hostname: 'webhooks.cliente1.seudominio.com', + service: 'http://n8n_webhook_cliente1:5678', + description: 'Aponta para o serviço N8N Webhook' + }, + 'Redis': { + hostname: 'redis-app1.seudominio.com', + service: 'tcp://redis-app1:6379', + description: 'Aponta para o serviço Redis (TCP)' + }, + 'Com porta customizada': { + hostname: 'app.seudominio.com', + service: 'myservice', + port: 8080, + protocol: 'http', + description: 'Define protocolo e porta automaticamente' + } + } + }); + } + + // Se o service não contém protocolo, adiciona automaticamente + let serviceUrl = service; + if (!service.includes('://')) { + serviceUrl = `${protocol}://${service}:${port}`; + } + + const result = await addHostnameToTunnel(hostname, serviceUrl); + + res.json({ + success: true, + message: `Hostname '${hostname}' adicionado ao túnel com sucesso`, + hostname: hostname, + service: serviceUrl, + tunnelId: result.tunnelId, + data: result + }); + + } catch (error) { + console.error('❌ Erro ao adicionar hostname ao túnel'); + if (error.response) { + console.error('Status:', error.response.status); + console.error('Body da resposta:', JSON.stringify(error.response.data, null, 2)); + } else { + console.error('Erro:', error.message); + } + + res.status(error.response?.status || 500).json({ + error: 'Erro ao adicionar hostname ao túnel Cloudflare', + details: error.response?.data || error.message + }); + } +}); + // Endpoint para listar stacks app.get('/api/stacks', authenticateToken, async (req, res) => { try { @@ -792,7 +928,8 @@ app.get('/health', (req, res) => { status: 'ok', timestamp: new Date().toISOString(), portainerAuth: jwtCache.token ? 'authenticated' : 'not_authenticated', - cloudflareConfigured: !!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID && CLOUDFLARE_DOMAIN) + cloudflareConfigured: !!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID && CLOUDFLARE_DOMAIN), + cloudflareTunnelConfigured: !!(CLOUDFLARE_TUNNEL_ID && CLOUDFLARE_ACCOUNT_ID) }); }); @@ -827,7 +964,7 @@ app.get('/api/tipos', (req, res) => { }, observacao: 'Cria 3 stacks separadas automaticamente: n8n-editor-{nome}, n8n-webhook-{nome}, n8n-worker-{nome}' }, - cloudflare: { + cloudflare_dns: { endpoint: '/api/cloudflare', exemplos: { A: { @@ -841,6 +978,20 @@ app.get('/api/tipos', (req, res) => { ipServidor: 'new.hostexpert.com.br' } } + }, + cloudflare_tunnel: { + endpoint: '/api/cloudflare/tunnel', + exemplos: { + n8n_editor: { + hostname: 'editor.cliente1.seudominio.com', + service: 'http://n8n_editor_cliente1:5678' + }, + n8n_webhook: { + hostname: 'webhooks.cliente1.seudominio.com', + service: 'http://n8n_webhook_cliente1:5678' + } + }, + observacao: 'Adiciona hostname ao túnel Cloudflare existente' } } }); @@ -885,7 +1036,7 @@ const startServer = async () => { await authenticatePortainer(); app.listen(PORT, () => { - console.log(`\n🌀 version: 3.0.0`); + console.log(`\n🌀 version: 3.0.1`); console.log(`🚀 API rodando na porta ${PORT}`); console.log(`📦 Portainer URL: ${PORTAINER_URL}`); console.log(`👤 Usuário Portainer: ${PORTAINER_USERNAME}`); @@ -896,10 +1047,13 @@ const startServer = async () => { console.log(`\n☁️ Cloudflare:`); console.log(` Token: ${CLOUDFLARE_API_TOKEN ? '✅' : '❌'}`); console.log(` Zone ID: ${CLOUDFLARE_ZONE_ID ? '✅' : '❌'}`); + console.log(` Account ID: ${CLOUDFLARE_ACCOUNT_ID ? '✅' : '❌'}`); + console.log(` Tunnel ID: ${CLOUDFLARE_TUNNEL_ID ? '✅' : '❌'}`); console.log(` Domínio: ${CLOUDFLARE_DOMAIN || 'Não configurado'}`); console.log(`\n📝 Endpoints disponíveis:`); console.log(` POST /api/stack - Criar stack Redis ou N8N (3 stacks separadas)`); - console.log(` POST /api/cloudflare - Criar subdomínio na Cloudflare`); + console.log(` POST /api/cloudflare - Criar subdomínio na Cloudflare (DNS)`); + console.log(` POST /api/cloudflare/tunnel - Adicionar hostname ao túnel Cloudflare`); console.log(` GET /api/stacks - Listar stacks`); console.log(` GET /api/tipos - Listar serviços disponíveis`); console.log(` GET /api/auth/status - Status da autenticação`); @@ -908,6 +1062,9 @@ const startServer = async () => { console.log(`\n🎯 Tipos de stack suportados:`); console.log(` - redis: Stack Redis standalone`); console.log(` - n8n: Cria 3 stacks separadas (editor, webhook, worker)`); + console.log(`\n🚇 Cloudflare Tunnel:`); + console.log(` - Configure CLOUDFLARE_TUNNEL_ID e CLOUDFLARE_ACCOUNT_ID no .env`); + console.log(` - Use /api/cloudflare/tunnel para adicionar hostnames ao túnel`); }); } catch (error) { From 2ee8b435525562ade1d0c4910100ee0e235127fc Mon Sep 17 00:00:00 2001 From: "biel.lil" Date: Wed, 5 Nov 2025 20:39:35 -0300 Subject: [PATCH 2/6] Create docker-image-dev.yml --- .github/workflows/docker-image-dev.yml | 46 ++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/docker-image-dev.yml diff --git a/.github/workflows/docker-image-dev.yml b/.github/workflows/docker-image-dev.yml new file mode 100644 index 0000000..07b2a9a --- /dev/null +++ b/.github/workflows/docker-image-dev.yml @@ -0,0 +1,46 @@ +name: Build and Push Docker image + +on: + push: + branches: [ "dev" ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout do código + uses: actions/checkout@v4 + + - name: Extrair versão do package.json + id: package_version + run: | + VERSION=$(jq -r .version package.json) + echo "version=v$VERSION" >> $GITHUB_OUTPUT + + - name: Mostrar versão detectada + run: echo 'Versão detectada:' ${{ steps.package_version.outputs.version }} + + - name: Login no Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build e Push da imagem Docker + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + biellil/stackflow-api:dev + + + # - name: Aguardar propagação da imagem + # run: sleep 10 + + # - name: Notificar Portainer para atualizar + # run: | + # curl -X POST ${{ secrets.PORTAINER_WEBHOOK_URL }} + # continue-on-error: true From 39fa6ce63417185527c59590b963ac18220c0704 Mon Sep 17 00:00:00 2001 From: "biel.lil" Date: Wed, 5 Nov 2025 20:43:24 -0300 Subject: [PATCH 3/6] Update index.ts --- src/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3109fd9..ba5854d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1036,7 +1036,7 @@ const startServer = async () => { await authenticatePortainer(); app.listen(PORT, () => { - console.log(`\n🌀 version: 3.0.1`); + console.log(`\n🌀 version: 3.0.1`); console.log(`🚀 API rodando na porta ${PORT}`); console.log(`📦 Portainer URL: ${PORTAINER_URL}`); console.log(`👤 Usuário Portainer: ${PORTAINER_USERNAME}`); @@ -1044,12 +1044,14 @@ const startServer = async () => { console.log(`🌐 Endpoint ID padrão: ${PORTAINER_ENDPOINT_ID}`); console.log(`🐳 Modo Docker: ${process.env.DOCKER_ENV || false}`); console.log(`🔐 Auth Token API: ${AUTH_TOKEN ? '✅' : '❌'}`); + console.log(`\n☁️ Cloudflare:`); console.log(` Token: ${CLOUDFLARE_API_TOKEN ? '✅' : '❌'}`); console.log(` Zone ID: ${CLOUDFLARE_ZONE_ID ? '✅' : '❌'}`); console.log(` Account ID: ${CLOUDFLARE_ACCOUNT_ID ? '✅' : '❌'}`); console.log(` Tunnel ID: ${CLOUDFLARE_TUNNEL_ID ? '✅' : '❌'}`); console.log(` Domínio: ${CLOUDFLARE_DOMAIN || 'Não configurado'}`); + console.log(`\n📝 Endpoints disponíveis:`); console.log(` POST /api/stack - Criar stack Redis ou N8N (3 stacks separadas)`); console.log(` POST /api/cloudflare - Criar subdomínio na Cloudflare (DNS)`); @@ -1059,11 +1061,12 @@ const startServer = async () => { console.log(` GET /api/auth/status - Status da autenticação`); console.log(` POST /api/auth/refresh - Renovar autenticação`); console.log(` GET /health - Health check`); + console.log(`\n🎯 Tipos de stack suportados:`); console.log(` - redis: Stack Redis standalone`); console.log(` - n8n: Cria 3 stacks separadas (editor, webhook, worker)`); + console.log(`\n🚇 Cloudflare Tunnel:`); - console.log(` - Configure CLOUDFLARE_TUNNEL_ID e CLOUDFLARE_ACCOUNT_ID no .env`); console.log(` - Use /api/cloudflare/tunnel para adicionar hostnames ao túnel`); }); From 04fac51f6927a6bab48fd6ebebb6dfc96b08f655 Mon Sep 17 00:00:00 2001 From: "biel.lil" Date: Wed, 5 Nov 2025 21:10:10 -0300 Subject: [PATCH 4/6] Update index.ts --- src/index.ts | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index ba5854d..e1f1893 100644 --- a/src/index.ts +++ b/src/index.ts @@ -839,43 +839,49 @@ app.post('/api/cloudflare/tunnel', authenticateToken, async (req, res) => { error: 'Campos obrigatórios: hostname, service', exemplos: { 'N8N Editor': { - hostname: 'editor.cliente1.seudominio.com', + hostname: 'editor.cliente1', service: 'http://n8n_editor_cliente1:5678', - description: 'Aponta para o serviço N8N Editor' + description: `Será criado: editor.cliente1.${DOMAIN}` }, 'N8N Webhook': { - hostname: 'webhooks.cliente1.seudominio.com', + hostname: 'webhooks.cliente1', service: 'http://n8n_webhook_cliente1:5678', - description: 'Aponta para o serviço N8N Webhook' + description: `Será criado: webhooks.cliente1.${DOMAIN}` }, 'Redis': { - hostname: 'redis-app1.seudominio.com', + hostname: 'redis-app1', service: 'tcp://redis-app1:6379', - description: 'Aponta para o serviço Redis (TCP)' + description: `Será criado: redis-app1.${DOMAIN}` }, 'Com porta customizada': { - hostname: 'app.seudominio.com', + hostname: 'app', service: 'myservice', port: 8080, protocol: 'http', - description: 'Define protocolo e porta automaticamente' + description: `Será criado: app.${DOMAIN}` } } }); } + // Adiciona o DOMAIN ao hostname se não estiver presente + const fullHostname = hostname.includes('.') && hostname.split('.').length > 2 + ? hostname + : `${hostname}.${DOMAIN}`; + // Se o service não contém protocolo, adiciona automaticamente let serviceUrl = service; if (!service.includes('://')) { serviceUrl = `${protocol}://${service}:${port}`; } - const result = await addHostnameToTunnel(hostname, serviceUrl); + const result = await addHostnameToTunnel(fullHostname, serviceUrl); res.json({ success: true, - message: `Hostname '${hostname}' adicionado ao túnel com sucesso`, - hostname: hostname, + message: `Hostname '${fullHostname}' adicionado ao túnel com sucesso`, + hostname: fullHostname, + hostnameInformado: hostname, service: serviceUrl, tunnelId: result.tunnelId, data: result @@ -983,15 +989,17 @@ app.get('/api/tipos', (req, res) => { endpoint: '/api/cloudflare/tunnel', exemplos: { n8n_editor: { - hostname: 'editor.cliente1.seudominio.com', - service: 'http://n8n_editor_cliente1:5678' + hostname: 'editor.cliente1', + service: 'http://n8n_editor_cliente1:5678', + description: `Hostname completo será: editor.cliente1.${DOMAIN}` }, n8n_webhook: { - hostname: 'webhooks.cliente1.seudominio.com', - service: 'http://n8n_webhook_cliente1:5678' + hostname: 'webhooks.cliente1', + service: 'http://n8n_webhook_cliente1:5678', + description: `Hostname completo será: webhooks.cliente1.${DOMAIN}` } }, - observacao: 'Adiciona hostname ao túnel Cloudflare existente' + observacao: `Adiciona hostname ao túnel Cloudflare. O domínio ${DOMAIN} será adicionado automaticamente` } } }); @@ -1036,7 +1044,7 @@ const startServer = async () => { await authenticatePortainer(); app.listen(PORT, () => { - console.log(`\n🌀 version: 3.0.1`); + console.log(`\n🌀 version: 3.0.2`); console.log(`🚀 API rodando na porta ${PORT}`); console.log(`📦 Portainer URL: ${PORTAINER_URL}`); console.log(`👤 Usuário Portainer: ${PORTAINER_USERNAME}`); @@ -1044,6 +1052,7 @@ const startServer = async () => { console.log(`🌐 Endpoint ID padrão: ${PORTAINER_ENDPOINT_ID}`); console.log(`🐳 Modo Docker: ${process.env.DOCKER_ENV || false}`); console.log(`🔐 Auth Token API: ${AUTH_TOKEN ? '✅' : '❌'}`); + console.log(`🌍 Domínio principal: ${DOMAIN || 'Não configurado'}`); console.log(`\n☁️ Cloudflare:`); console.log(` Token: ${CLOUDFLARE_API_TOKEN ? '✅' : '❌'}`); @@ -1068,6 +1077,7 @@ const startServer = async () => { console.log(`\n🚇 Cloudflare Tunnel:`); console.log(` - Use /api/cloudflare/tunnel para adicionar hostnames ao túnel`); + console.log(` - O domínio ${DOMAIN} será adicionado automaticamente aos hostnames`); }); } catch (error) { From 959a3a9f47ff7eb94bab09ea183d2a347d6c374e Mon Sep 17 00:00:00 2001 From: "biel.lil" Date: Wed, 5 Nov 2025 21:14:18 -0300 Subject: [PATCH 5/6] Update index.ts --- src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index e1f1893..5b497ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -904,7 +904,7 @@ app.post('/api/cloudflare/tunnel', authenticateToken, async (req, res) => { }); // Endpoint para listar stacks -app.get('/api/stacks', authenticateToken, async (req, res) => { +app.get('/api/stacks', authenticateToken, async (req: any, res: { json: (arg0: { success: boolean; stacks: any; }) => void; status: (arg0: any) => { (): any; new(): any; json: { (arg0: { error: string; details: any; }): void; new(): any; }; }; }) => { try { const headers = await getPortainerHeaders(); @@ -929,7 +929,7 @@ app.get('/api/stacks', authenticateToken, async (req, res) => { }); // Health check -app.get('/health', (req, res) => { +app.get('/health', (req: any, res: { json: (arg0: { status: string; timestamp: string; portainerAuth: string; cloudflareConfigured: boolean; cloudflareTunnelConfigured: boolean; }) => void; }) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), @@ -940,7 +940,7 @@ app.get('/health', (req, res) => { }); // Listar tipos -app.get('/api/tipos', (req, res) => { +app.get('/api/tipos', (req: any, res: { json: (arg0: { servicos: { redis: { endpoint: string; exemplo: { nome: string; tipo: string; rede: string; porta: number; }; }; n8n: { endpoint: string; exemplo: { nome: string; tipo: string; rede: string; config: { postgresHost: string; postgresDb: string; postgresPassword: string; redisHost: string; redisPort: string; redisPassword: string; versaoN8n: string; }; }; observacao: string; }; cloudflare_dns: { endpoint: string; exemplos: { A: { nome: string; tipo: string; ipServidor: string; }; CNAME: { nome: string; tipo: string; ipServidor: string; }; }; }; cloudflare_tunnel: { endpoint: string; exemplos: { n8n_editor: { hostname: string; service: string; description: string; }; n8n_webhook: { hostname: string; service: string; description: string; }; }; observacao: string; }; }; }) => void; }) => { res.json({ servicos: { redis: { @@ -1006,7 +1006,7 @@ app.get('/api/tipos', (req, res) => { }); // Status da autenticação -app.get('/api/auth/status', authenticateToken, (req, res) => { +app.get('/api/auth/status', authenticateToken, (req: any, res: { json: (arg0: { authenticated: boolean; expiresAt: string | null; timeRemaining: number; }) => void; }) => { res.json({ authenticated: !!jwtCache.token, expiresAt: jwtCache.expiresAt ? new Date(jwtCache.expiresAt).toISOString() : null, @@ -1015,7 +1015,7 @@ app.get('/api/auth/status', authenticateToken, (req, res) => { }); // Forçar reautenticação -app.post('/api/auth/refresh', authenticateToken, async (req, res) => { +app.post('/api/auth/refresh', authenticateToken, async (req: any, res: { json: (arg0: { success: boolean; message: string; expiresAt: string; }) => void; status: (arg0: number) => { (): any; new(): any; json: { (arg0: { error: string; details: any; }): void; new(): any; }; }; }) => { try { jwtCache = { token: null, expiresAt: null }; const jwt = await authenticatePortainer(); From c0de2b959a416ce89f82fddf3d15af58a6c079d9 Mon Sep 17 00:00:00 2001 From: "biel.lil" Date: Wed, 5 Nov 2025 21:17:16 -0300 Subject: [PATCH 6/6] 3.0.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 65625d8..a422f36 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "module": "index.ts", "type": "module", "private": true, - "version": "3.0.0", + "version": "3.0.2", "scripts": { "start": "bun run src/index.ts", "dev": "nodemon src/index.ts",