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:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
backend/.env
|
||||||
|
backend/config.json
|
||||||
|
frontend/dist/
|
||||||
|
.env
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PORT=8019
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 8019
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -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 />);
|
||||||
@@ -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' }
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user