feat: estrutura inicial do gerenciador de subdomínios

- backend Express com endpoints Hostinger DNS API
- frontend React+Vite+Tailwind com card de API key
- docker-compose (frontend:3014, backend:8019, nginx:8021)
- Traefik label para subdominio.clube67.com
This commit is contained in:
VPS 4 Builder
2026-05-15 07:20:19 +02:00
commit 7a3b421ab2
14 changed files with 363 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
backend/.env
backend/config.json
frontend/dist/
.env
+1
View File
@@ -0,0 +1 @@
PORT=8019
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 8019
CMD ["node", "server.js"]
+14
View File
@@ -0,0 +1,14 @@
{
"name": "subdominio-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"express": "^4.19.2",
"cors": "^2.8.5",
"dotenv": "^16.4.5"
}
}
+77
View File
@@ -0,0 +1,77 @@
import express from 'express';
import cors from 'cors';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_FILE = join(__dirname, 'config.json');
const app = express();
app.use(cors());
app.use(express.json());
function loadConfig() {
if (!existsSync(CONFIG_FILE)) return { hostinger_api_key: '' };
try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return { hostinger_api_key: '' }; }
}
function saveConfig(data) {
writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8');
}
// GET configuração (mascara a key)
app.get('/api/config', (req, res) => {
const cfg = loadConfig();
res.json({ has_api_key: !!cfg.hostinger_api_key });
});
// POST salvar API key da Hostinger
app.post('/api/config/hostinger-key', (req, res) => {
const { api_key } = req.body;
if (!api_key?.trim()) return res.status(400).json({ error: 'api_key obrigatória' });
const cfg = loadConfig();
cfg.hostinger_api_key = api_key.trim();
saveConfig(cfg);
res.json({ ok: true });
});
// GET listar subdomínios via Hostinger API
app.get('/api/subdomains', async (req, res) => {
const cfg = loadConfig();
if (!cfg.hostinger_api_key) return res.status(400).json({ error: 'API key não configurada' });
try {
const r = await fetch('https://developers.hostinger.com/api/dns/v1/domains', {
headers: { Authorization: `Bearer ${cfg.hostinger_api_key}`, 'Content-Type': 'application/json' }
});
const data = await r.json();
res.json(data);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// POST criar subdomínio no DNS da Hostinger
app.post('/api/subdomains', async (req, res) => {
const cfg = loadConfig();
if (!cfg.hostinger_api_key) return res.status(400).json({ error: 'API key não configurada' });
const { domain, subdomain, type = 'A', value } = req.body;
if (!domain || !subdomain || !value) return res.status(400).json({ error: 'domain, subdomain e value obrigatórios' });
try {
const r = await fetch(`https://developers.hostinger.com/api/dns/v1/domains/${domain}/records`, {
method: 'POST',
headers: { Authorization: `Bearer ${cfg.hostinger_api_key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ type, name: subdomain, content: value, ttl: 300 })
});
const data = await r.json();
res.json(data);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
const PORT = process.env.PORT || 8019;
app.listen(PORT, () => console.log(`[subdominio-backend] porta ${PORT}`));
+42
View File
@@ -0,0 +1,42 @@
services:
frontend:
build:
context: ./frontend
container_name: subdominio-frontend
restart: unless-stopped
networks:
- subdominio-net
backend:
build:
context: ./backend
container_name: subdominio-backend
restart: unless-stopped
volumes:
- ./backend/config.json:/app/config.json
networks:
- subdominio-net
nginx:
image: nginx:alpine
container_name: subdominio-nginx
restart: unless-stopped
ports:
- "8021:8021"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- frontend
- backend
networks:
- subdominio-net
labels:
- "traefik.enable=true"
- "traefik.http.routers.subdominio.rule=Host(`subdominio.clube67.com`)"
- "traefik.http.routers.subdominio.entrypoints=websecure"
- "traefik.http.routers.subdominio.tls.certresolver=le"
- "traefik.http.services.subdominio.loadbalancer.server.port=8021"
networks:
subdominio-net:
driver: bridge
+10
View File
@@ -0,0 +1,10 @@
FROM node:20-alpine AS builder
WORKDIR /app/frontend
COPY package*.json .
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
EXPOSE 3014
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gerenciador de Subdomínios — Clube67</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "subdominio-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.0",
"@vitejs/plugin-react": "^5.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.2.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
+140
View File
@@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react';
const API = '/api';
export default function App() {
const [hasKey, setHasKey] = useState<boolean | null>(null);
const [apiKeyInput, setApiKeyInput] = useState('');
const [saving, setSaving] = useState(false);
const [subdomains, setSubdomains] = useState<any[]>([]);
const [loadingDomains, setLoadingDomains] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
fetch(`${API}/config`)
.then(r => r.json())
.then(d => setHasKey(d.has_api_key))
.catch(() => setHasKey(false));
}, []);
async function saveKey() {
if (!apiKeyInput.trim()) return;
setSaving(true);
setError('');
try {
const r = await fetch(`${API}/config/hostinger-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: apiKeyInput })
});
if (r.ok) { setHasKey(true); setApiKeyInput(''); setSuccess('Chave salva com sucesso.'); }
else { const d = await r.json(); setError(d.error || 'Erro ao salvar.'); }
} finally { setSaving(false); }
}
async function fetchSubdomains() {
setLoadingDomains(true);
setError('');
try {
const r = await fetch(`${API}/subdomains`);
const d = await r.json();
if (r.ok) setSubdomains(Array.isArray(d) ? d : d.data || []);
else setError(d.error || 'Erro ao buscar domínios.');
} finally { setLoadingDomains(false); }
}
return (
<div className="min-h-screen bg-gray-950 text-white p-6">
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-500 flex items-center justify-center font-black text-lg">C</div>
<div>
<h1 className="font-black text-xl tracking-tight uppercase">Gerenciador de Subdomínios</h1>
<p className="text-gray-400 text-sm">clube67.com Hostinger DNS</p>
</div>
</div>
{/* Card API Key */}
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-bold text-sm uppercase tracking-widest text-gray-400">Hostinger API Key</h2>
{hasKey === true && (
<span className="text-xs font-bold bg-green-900/50 text-green-400 border border-green-800 px-3 py-1 rounded-full">
CONFIGURADA
</span>
)}
{hasKey === false && (
<span className="text-xs font-bold bg-red-900/50 text-red-400 border border-red-800 px-3 py-1 rounded-full">
NÃO CONFIGURADA
</span>
)}
</div>
<p className="text-gray-400 text-sm">
Insira a API Key da Hostinger para gerenciar registros DNS e criar subdomínios automaticamente.
</p>
<div className="flex gap-2">
<input
type="password"
placeholder="Bearer token da Hostinger..."
value={apiKeyInput}
onChange={e => setApiKeyInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveKey()}
className="flex-1 bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-orange-500 placeholder-gray-600"
/>
<button
onClick={saveKey}
disabled={saving || !apiKeyInput.trim()}
className="bg-orange-500 hover:bg-orange-600 disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold px-5 py-3 rounded-xl text-sm transition-colors"
>
{saving ? '...' : 'SALVAR'}
</button>
</div>
{success && <p className="text-green-400 text-sm font-medium">{success}</p>}
{error && <p className="text-red-400 text-sm font-medium">{error}</p>}
</div>
{/* Card Domínios */}
{hasKey && (
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-bold text-sm uppercase tracking-widest text-gray-400">Domínios / DNS</h2>
<button
onClick={fetchSubdomains}
disabled={loadingDomains}
className="bg-gray-800 hover:bg-gray-700 border border-gray-700 text-white font-bold px-4 py-2 rounded-xl text-xs transition-colors disabled:opacity-40"
>
{loadingDomains ? 'BUSCANDO...' : 'BUSCAR DA HOSTINGER'}
</button>
</div>
{subdomains.length === 0 && !loadingDomains && (
<p className="text-gray-600 text-sm text-center py-6">Clique em "Buscar da Hostinger" para listar os domínios.</p>
)}
{subdomains.length > 0 && (
<div className="space-y-2">
{subdomains.map((d: any, i: number) => (
<div key={i} className="flex items-center justify-between bg-gray-800 rounded-xl px-4 py-3">
<span className="font-mono text-sm text-white">{d.domain || d.name || JSON.stringify(d)}</span>
<span className="text-xs text-gray-500">{d.type || ''}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Rodapé */}
<p className="text-center text-gray-700 text-xs">
subdominio.clube67.com acesso restrito VPN
</p>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(<App />);
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: { '/api': 'http://localhost:8019' }
}
});
+14
View File
@@ -0,0 +1,14 @@
server {
listen 8021;
location /api/ {
proxy_pass http://subdominio-backend:8019;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
proxy_pass http://subdominio-frontend:3014;
proxy_set_header Host $host;
}
}