Initial infra: Traefik, Oktopus, Hub, Apps, network, security

This commit is contained in:
Louis Paul 2026-03-26 12:57:27 +00:00
commit e97ba51a6c
16 changed files with 797 additions and 0 deletions

91
apps/docker-compose.yml Normal file
View File

@ -0,0 +1,91 @@
services:
# ── Gitea ────────────────────────────────────────────────────
gitea:
image: gitea/gitea:latest
environment:
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=gitea
volumes:
- gitea-data:/data
networks:
- proxy
- internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.git.rule=Host(`git.gigafibre.ca`)"
- "traefik.http.routers.git.entrypoints=websecure"
- "traefik.http.routers.git.tls.certresolver=letsencrypt"
- "traefik.http.services.git.loadbalancer.server.port=3000"
restart: unless-stopped
gitea-db:
image: postgres:14-alpine
environment:
- POSTGRES_DB=gitea
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=gitea
volumes:
- gitea-db-data:/var/lib/postgresql/data
networks:
- internal
restart: unless-stopped
# ── Targo Timesheet Frontend ─────────────────────────────────
targo-frontend:
image: nginx:alpine
volumes:
- targo-frontend-data:/usr/share/nginx/html
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.targo-app.rule=Host(`timesheet.gigafibre.ca`)"
- "traefik.http.routers.targo-app.entrypoints=websecure"
- "traefik.http.routers.targo-app.tls.certresolver=letsencrypt"
- "traefik.http.services.targo-app.loadbalancer.server.port=80"
restart: unless-stopped
# ── Dispatch App Frontend ────────────────────────────────────
dispatch-frontend:
image: nginx:alpine
volumes:
- dispatch-frontend-data:/usr/share/nginx/html
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.dispatch.rule=Host(`dispatch.gigafibre.ca`)"
- "traefik.http.routers.dispatch.entrypoints=websecure"
- "traefik.http.routers.dispatch.tls.certresolver=letsencrypt"
- "traefik.http.services.dispatch.loadbalancer.server.port=80"
restart: unless-stopped
# ── Targo Backend + DB ───────────────────────────────────────
targo-db:
image: postgres:14-alpine
environment:
- POSTGRES_DB=targo
- POSTGRES_USER=targo
- POSTGRES_PASSWORD=targo
volumes:
- targo-db-data:/var/lib/postgresql/data
networks:
- internal
restart: unless-stopped
networks:
proxy:
external: true
internal:
driver: bridge
volumes:
gitea-data:
gitea-db-data:
targo-frontend-data:
dispatch-frontend-data:
targo-db-data:

106
oktopus/docker-compose.yml Normal file
View File

@ -0,0 +1,106 @@
services:
frontend:
image: oktopusp/frontend-ce:latest
environment:
- NEXT_PUBLIC_REST_ENDPOINT=
networks:
- proxy
- oktopus
labels:
- "traefik.enable=true"
- "traefik.http.routers.oss.rule=Host(`oss.gigafibre.ca`)"
- "traefik.http.routers.oss.entrypoints=web,websecure"
- "traefik.http.routers.oss.tls.certresolver=letsencrypt"
- "traefik.http.services.oss.loadbalancer.server.port=3000"
- "traefik.docker.network=proxy"
restart: unless-stopped
controller:
image: oktopusp/controller:latest
environment:
- MONGO_URI=mongodb://mongo:27017
- NATS_URL=nats://nats:4222
networks:
- proxy
- oktopus
labels:
- "traefik.enable=true"
- "traefik.http.routers.oss-api.rule=Host(`oss.gigafibre.ca`) && PathPrefix(`/api`)"
- "traefik.http.routers.oss-api.entrypoints=web,websecure"
- "traefik.http.routers.oss-api.tls.certresolver=letsencrypt"
- "traefik.http.services.oss-api.loadbalancer.server.port=8000"
- "traefik.docker.network=proxy"
restart: unless-stopped
adapter:
image: oktopusp/adapter:latest
environment:
- MONGO_URI=mongodb://mongo:27017
- NATS_URL=nats://nats:4222
networks:
- oktopus
restart: unless-stopped
mongo:
image: mongo:7
volumes:
- oktopus-mongo:/data/db
networks:
- oktopus
restart: unless-stopped
nats:
image: nats:2-alpine
command: ["--jetstream"]
volumes:
- oktopus-nats:/data
networks:
- oktopus
restart: unless-stopped
mqtt:
image: oktopusp/mqtt:latest
environment:
- NATS_URL=nats://nats:4222
ports:
- "1883:1883"
networks:
- oktopus
restart: unless-stopped
mqtt-adapter:
image: oktopusp/mqtt-adapter:latest
environment:
- NATS_URL=nats://nats:4222
- MQTT_URL=tcp://mqtt:1883
networks:
- oktopus
restart: unless-stopped
acs:
image: oktopusp/acs:latest
environment:
- NATS_URL=nats://nats:4222
ports:
- "9292:9292"
networks:
- oktopus
restart: unless-stopped
socketio:
image: oktopusp/socketio:latest
environment:
- NATS_URL=nats://nats:4222
networks:
- oktopus
restart: unless-stopped
networks:
proxy:
external: true
oktopus:
driver: bridge
volumes:
oktopus-mongo:
oktopus-nats:

47
setup.sh Executable file
View File

@ -0,0 +1,47 @@
#!/bin/bash
set -e
echo "=== Gigafibre Infrastructure Setup ==="
# 1. Docker
if ! command -v docker &>/dev/null; then
echo "Installing Docker..."
apt-get update && apt-get install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list
apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
fi
# 2. Security
apt-get install -y ufw fail2ban
cp system/jail.local /etc/fail2ban/jail.local 2>/dev/null
systemctl enable --now fail2ban
ufw default deny incoming && ufw default allow outgoing
ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp
ufw allow 1883/tcp && ufw allow 9292/tcp
echo "y" | ufw enable
# 3. Network
cp system/*.network /etc/systemd/network/
systemctl restart systemd-networkd
# 4. Docker network
docker network create proxy 2>/dev/null || true
# 5. Traefik
cd /opt/infra && docker compose -f traefik/docker-compose.yml up -d
# 6. Apps
docker compose -f apps/docker-compose.yml up -d
# 7. Oktopus
docker compose -f oktopus/docker-compose.yml up -d
# 8. Traefik Hub
cd traefik-hub && docker build -t traefik-hub:latest . && cd ..
docker compose -f traefik-hub/docker-compose.yml up -d
echo "=== Setup complete ==="
echo "Hub: https://hub.gigafibre.ca"
echo "OSS: https://oss.gigafibre.ca"
echo "Git: https://git.gigafibre.ca"

12
system/10-lan.network Normal file
View File

@ -0,0 +1,12 @@
[Match]
Name=ens18
[Network]
Address=10.100.5.61/24
DNS=8.8.8.8
DNS=1.1.1.1
[Route]
Destination=10.0.0.0/8
Gateway=10.100.5.254
Metric=200

View File

View File

9
system/20-wan.network Normal file
View File

@ -0,0 +1,9 @@
[Match]
Name=ens19
[Network]
Address=96.125.196.67/26
[Route]
Gateway=96.125.196.65
Metric=100

11
system/jail.local Normal file
View File

@ -0,0 +1,11 @@
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
banaction = ufw
[sshd]
enabled = true
port = 22
maxretry = 3
backend = systemd

7
traefik-hub/Dockerfile Normal file
View File

@ -0,0 +1,7 @@
FROM node:20-slim
WORKDIR /app
COPY package.json .
RUN npm install --production
COPY server.js login.html index.html .
EXPOSE 3080
CMD ["node", "server.js"]

View File

@ -0,0 +1,31 @@
services:
hub:
image: traefik-hub:latest
environment:
- TRAEFIK_API=http://traefik-traefik-1:8080/api
- ADMIN_USER=admin
- ADMIN_PASS=targo2026
- SESSION_SECRET=Hub2026SecretKey
- OPENSRS_USER=targo
- OPENSRS_KEY=7ab6b976c403ad4a310c326aac646df5202eb7e7498542f933055a3fa4e6615ff85f93e873d922b89751a7cf59843da5875dd457343b4934
- OPENSRS_DOMAIN=gigafibre.ca
- SERVER_IP=96.125.196.67
- ROUTES_FILE=/dynamic/routes.yml
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /opt/traefik/dynamic:/dynamic
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.hub.rule=Host(`hub.gigafibre.ca`)"
- "traefik.http.routers.hub.entrypoints=websecure"
- "traefik.http.routers.hub.tls.certresolver=letsencrypt"
- "traefik.http.services.hub.loadbalancer.server.port=3080"
ports:
- "3080:3080"
restart: unless-stopped
networks:
proxy:
external: true

148
traefik-hub/index.html Normal file
View File

@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Traefik Hub</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--border:#30363d;--text:#c9d1d9;--muted:#8b949e;--blue:#58a6ff;--green:#3fb950;--red:#f85149;--orange:#d29922;--purple:#bc8cff}
*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;background:var(--bg);color:var(--text);font-size:14px}a{color:var(--blue);text-decoration:none}
.header{background:var(--card);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
.header h1{font-size:18px;display:flex;align-items:center;gap:8px;color:var(--blue)}.header h1 svg{width:22px;height:22px}
.header-right{display:flex;align-items:center;gap:12px}.pill{background:var(--bg);padding:4px 10px;border-radius:12px;font-size:12px;color:var(--muted)}
.btn-sm{padding:6px 12px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--muted);cursor:pointer;font-size:12px}.btn-sm:hover{color:var(--text)}
.main{display:flex;min-height:calc(100vh - 49px)}.sidebar{width:220px;background:var(--card);border-right:1px solid var(--border);padding:16px 0;flex-shrink:0}
.sidebar a{display:flex;align-items:center;gap:10px;padding:8px 20px;color:var(--muted);font-size:13px;border-left:3px solid transparent;cursor:pointer}
.sidebar a:hover,.sidebar a.active{color:var(--text);background:rgba(88,166,255,.06);border-left-color:var(--blue)}.sidebar a svg{width:16px;height:16px;flex-shrink:0}
.sidebar .sep{height:1px;background:var(--border);margin:12px 20px}.content{flex:1;padding:24px;overflow-y:auto}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:24px}
.stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px}.stat .label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}.stat .value{font-size:26px;font-weight:700;margin-top:2px}
.stat .value.green{color:var(--green)}.stat .value.blue{color:var(--blue)}.stat .value.red{color:var(--red)}
.card{background:var(--card);border:1px solid var(--border);border-radius:10px;margin-bottom:16px;overflow:hidden}
.card-header{padding:12px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}.card-header h2{font-size:14px}
table{width:100%;border-collapse:collapse}th{text-align:left;padding:8px 18px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);background:rgba(0,0,0,.2);border-bottom:1px solid var(--border)}
td{padding:8px 18px;border-bottom:1px solid var(--border);font-size:13px;vertical-align:middle}tr:last-child td{border-bottom:none}tr:hover{background:rgba(88,166,255,.04)}
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.badge-green{background:rgba(63,185,80,.15);color:var(--green)}.badge-red{background:rgba(248,81,73,.15);color:var(--red)}.badge-blue{background:rgba(88,166,255,.15);color:var(--blue)}
.badge-purple{background:rgba(188,140,255,.15);color:var(--purple)}.badge-muted{background:rgba(139,148,158,.15);color:var(--muted)}
.actions{display:flex;gap:4px}.actions button,.abtn{padding:4px 8px;border-radius:4px;border:1px solid var(--border);background:transparent;cursor:pointer;font-size:11px;color:var(--muted)}
.actions button:hover,.abtn:hover{color:var(--text)}.actions .start{color:var(--green)}.actions .stop{color:var(--red)}.actions .restart{color:var(--orange)}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}.dot-green{background:var(--green)}.dot-red{background:var(--red)}
.log-viewer{background:#000;color:#0f0;padding:16px;font-family:Menlo,monospace;font-size:11px;max-height:400px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;align-items:center;justify-content:center}.modal-bg.show{display:flex}
.modal{background:var(--card);border:1px solid var(--border);border-radius:12px;width:700px;max-height:80vh;overflow:hidden;display:flex;flex-direction:column}
.modal-header{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.modal-body{overflow-y:auto;flex:1;padding:0}.modal .close{background:none;border:none;color:var(--muted);cursor:pointer;font-size:20px}
.form-row{display:flex;gap:12px;margin-bottom:12px;align-items:end}.form-row .field{flex:1}
.field label{display:block;font-size:11px;color:var(--muted);margin-bottom:4px;text-transform:uppercase}
.field input,.field select{width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none}
.field input:focus{border-color:var(--blue)}
.btn{padding:8px 16px;border-radius:6px;border:none;cursor:pointer;font-size:13px;font-weight:600}
.btn-green{background:#238636;color:#fff}.btn-green:hover{background:#2ea043}
.btn-red{background:rgba(248,81,73,.15);color:var(--red);border:1px solid rgba(248,81,73,.3)}
.btn-blue{background:rgba(88,166,255,.15);color:var(--blue);border:1px solid rgba(88,166,255,.3)}
.btn-muted{background:var(--bg);color:var(--muted);border:1px solid var(--border)}
.env-row{display:flex;gap:8px;margin-bottom:6px;align-items:center}
.env-row input{flex:1;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:12px}
.env-row button{padding:4px 8px;border-radius:4px;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--red);font-size:14px}
.toast{position:fixed;top:60px;right:24px;background:var(--green);color:#fff;padding:10px 18px;border-radius:8px;font-size:13px;z-index:300;display:none;animation:fadeIn .3s}
.toast.error{background:var(--red)}.toast.show{display:block}
.spinner-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:250;display:none;align-items:center;justify-content:center;flex-direction:column;gap:16px;color:var(--text)}
.spinner-overlay.show{display:flex}
.spinner-overlay .spin{width:40px;height:40px;border:4px solid var(--border);border-top-color:var(--blue);border-radius:50%;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}@keyframes fadeIn{from{opacity:0;transform:translateY(-10px)}to{opacity:1}}
.cb{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px}.cb input{width:16px;height:16px}
@media(max-width:768px){.sidebar{display:none}.content{padding:12px}.form-row{flex-direction:column}}
</style></head><body>
<div class="header"><h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> Traefik Hub</h1><div class="header-right"><span class="pill" id="dV"></span><span class="pill" id="sS"></span><button class="btn-sm" onclick="loadAll()">Refresh</button><button class="btn-sm" onclick="fetch('/api/logout').then(()=>location.reload())">Logout</button></div></div>
<div class="main"><nav class="sidebar" id="nav"></nav><div class="content" id="content"></div></div>
<div class="modal-bg" id="logModal"><div class="modal"><div class="modal-header"><h2 id="logTitle">Logs</h2><button class="close" onclick="closeModal()">&times;</button></div><div class="modal-body"><pre class="log-viewer" id="logContent"></pre></div></div></div>
<div class="spinner-overlay" id="spinner"><div class="spin"></div><div id="spinMsg">Deploying...</div></div>
<div class="toast" id="toast"></div>
<script>
const PAGES=[
{id:"overview",icon:'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',label:"Overview"},
{id:"deploy",icon:'<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',label:"Deploy Service"},
{id:"routes",icon:'<circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 010 8.49m-8.48-.01a6 6 0 010-8.49m11.31-2.82a10 10 0 010 14.14m-14.14 0a10 10 0 010-14.14"/>',label:"Routes"},
{id:"dns",icon:'<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',label:"DNS Records"},
null,
{id:"routers",icon:'<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',label:"HTTP Routers"},
{id:"services",icon:'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>',label:"Services"},
{id:"containers",icon:'<path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/>',label:"Containers"},
null,
{id:"certificates",icon:'<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/>',label:"Certificates"},
{id:"networks",icon:'<path d="M9 2v6m6-6v6M9 16v6m6-6v6M2 9h6m8 0h6M2 15h6m8 0h6"/>',label:"Networks"},
{id:"volumes",icon:'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>',label:"Volumes"},
{id:"entrypoints",icon:'<path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3"/>',label:"Entrypoints"},
{id:"middlewares",icon:'<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2"/>',label:"Middlewares"}
];
let DATA={},DNS={},ROUTES={},CUR="overview",envRows=[],volRows=[];
const $=s=>document.querySelector(s);
function toast(m,e){const t=$("#toast");t.textContent=m;t.className="toast show"+(e?" error":"");setTimeout(()=>t.className="toast",3000)}
function showSpinner(m){$("#spinMsg").textContent=m||"Deploying...";$("#spinner").classList.add("show")}
function hideSpinner(){$("#spinner").classList.remove("show")}
document.getElementById("nav").innerHTML=PAGES.map(p=>p?`<a data-page="${p.id}" class="${p.id==="overview"?"active":""}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${p.icon}</svg>${p.label}</a>`:'<div class="sep"></div>').join("");
document.querySelectorAll(".sidebar a").forEach(a=>a.addEventListener("click",()=>{document.querySelectorAll(".sidebar a").forEach(x=>x.classList.remove("active"));a.classList.add("active");CUR=a.dataset.page;render()}));
function bg(s){const m={enabled:"green",running:"green",up:"green",disabled:"red",exited:"red",managed:"green"};const c=Object.entries(m).find(([k])=>(s||"").toLowerCase().includes(k))?.[1]||"muted";return'<span class="badge badge-'+c+'">'+s+'</span>'}
function dot(r){return'<span class="dot dot-'+(r?"green":"red")+'"></span>'}
async function loadAll(){try{
const r=await fetch("/api/overview");if(r.status===401){location.reload();return}DATA=await r.json();
DATA.middlewares=await(await fetch("/api/middlewares")).json()||[];DATA.networks=await(await fetch("/api/networks")).json()||[];
DATA.volumes=await(await fetch("/api/volumes")).json()||[];DATA.certificates=await(await fetch("/api/certificates")).json()||[];
ROUTES=await(await fetch("/api/routes")).json()||{};DNS=await(await fetch("/api/dns")).json()||{};
$("#dV").textContent="Docker "+(DATA.system?.dockerVersion||"?");$("#sS").textContent="Swarm: "+(DATA.system?.swarm||"?");render();
}catch(e){console.error(e)}}
function render(){const c=$("#content"),R=DATA.routers||[],S=DATA.services||[],C=DATA.containers||[],E=DATA.entrypoints||[],M=DATA.middlewares||[],N=DATA.networks||[],V=DATA.volumes||[],CE=DATA.certificates||[],sys=DATA.system||{},routes=ROUTES.routes||{},dnsRecs=DNS.records||[],domain=DNS.domain||"gigafibre.ca",ip=DNS.serverIp||"96.125.196.67";
const managed=C.filter(x=>x.labels?.["hub.managed"]==="true");
switch(CUR){
case"overview":c.innerHTML='<h2 style="margin-bottom:16px">Overview</h2><div class="stats"><div class="stat"><div class="label">Running</div><div class="value green">'+C.filter(x=>x.state==="running").length+'</div></div><div class="stat"><div class="label">Stopped</div><div class="value red">'+C.filter(x=>x.state!=="running").length+'</div></div><div class="stat"><div class="label">Routers</div><div class="value blue">'+R.length+'</div></div><div class="stat"><div class="label">DNS Records</div><div class="value blue">'+dnsRecs.length+'</div></div><div class="stat"><div class="label">CPUs</div><div class="value">'+(sys.cpus||"?")+'</div></div><div class="stat"><div class="label">RAM</div><div class="value">'+(sys.memTotal?(sys.memTotal/1073741824).toFixed(1)+"G":"?")+'</div></div></div>'+(managed.length?'<div class="card"><div class="card-header"><h2>Hub-Managed Services</h2></div><div class="card-body"><table><tr><th>Name</th><th>Domain</th><th>Image</th><th>Status</th><th>Actions</th></tr>'+managed.map(x=>'<tr><td>'+dot(x.state==="running")+'<b>'+x.name+'</b></td><td><a href="https://'+(x.labels["hub.domain"]||"")+'" target="_blank">'+(x.labels["hub.domain"]||"")+'</a></td><td style="color:var(--muted)">'+x.image+'</td><td>'+bg(x.status)+'</td><td><div class="actions">'+(x.state!=="running"?'<button class="start" onclick="cAct(\''+x.id+'\',\'start\')">Start</button>':'<button class="stop" onclick="cAct(\''+x.id+'\',\'stop\')">Stop</button>')+'<button class="restart" onclick="cAct(\''+x.id+'\',\'restart\')">Restart</button><button onclick="logs(\''+x.id+'\',\''+x.name+'\')">Logs</button><button class="stop" onclick="removeService(\''+x.id+'\',\''+x.name+'\',\''+(x.labels["hub.subdomain"]||"")+'\')">Remove</button></div></td></tr>').join("")+'</table></div></div>':'')+'<div class="card"><div class="card-header"><h2>All Routers</h2></div><div class="card-body"><table><tr><th>Name</th><th>Rule</th><th>TLS</th><th>Service</th></tr>'+R.map(r=>'<tr><td><b>'+r.name+'</b></td><td><code style="color:var(--green)">'+(r.rule||"")+'</code></td><td>'+(r.tls?'<span class="badge badge-green">'+(r.tls.certResolver||"yes")+'</span>':"--")+'</td><td>'+(r.service||"")+'</td></tr>').join("")+'</table></div></div><div class="card"><div class="card-header"><h2>All Containers</h2></div><div class="card-body"><table><tr><th>Name</th><th>Image</th><th>Status</th><th>Actions</th></tr>'+C.map(x=>'<tr><td>'+dot(x.state==="running")+'<b>'+x.name+'</b></td><td style="color:var(--muted)">'+x.image+'</td><td>'+bg(x.status)+'</td><td><div class="actions">'+(x.state!=="running"?'<button class="start" onclick="cAct(\''+x.id+'\',\'start\')">Start</button>':'<button class="stop" onclick="cAct(\''+x.id+'\',\'stop\')">Stop</button>')+'<button class="restart" onclick="cAct(\''+x.id+'\',\'restart\')">Restart</button><button onclick="logs(\''+x.id+'\',\''+x.name+'\')">Logs</button></div></td></tr>').join("")+'</table></div></div>';break;
case"deploy":envRows=envRows.length?envRows:[{key:"",value:""}];volRows=volRows.length?volRows:[{host:"",container:""}];
c.innerHTML='<h2 style="margin-bottom:8px">Deploy New Service</h2><p style="color:var(--muted);margin-bottom:20px">Pull a Docker image, configure it, and deploy with automatic DNS + SSL.</p><div class="card"><div class="card-body" style="padding:20px"><div class="form-row"><div class="field"><label>Docker Image</label><input id="dp-image" placeholder="nginx:alpine, gitea/gitea:latest, postgres:14"></div><div class="field"><label>Container Name (optional)</label><input id="dp-name" placeholder="auto-generated from subdomain"></div></div><div class="form-row"><div class="field"><label>Subdomain</label><div style="display:flex"><input id="dp-sub" placeholder="myapp" style="border-radius:6px 0 0 6px"><span style="padding:8px 12px;background:var(--border);border:1px solid var(--border);border-radius:0 6px 6px 0;font-size:13px;color:var(--muted);white-space:nowrap">.'+domain+'</span></div></div><div class="field"><label>Internal Port</label><input id="dp-port" type="number" value="3000" placeholder="3000"></div></div><div style="margin-bottom:16px"><label style="display:block;font-size:11px;color:var(--muted);margin-bottom:6px;text-transform:uppercase">Environment Variables</label><div id="envContainer">'+envRows.map((e,i)=>'<div class="env-row"><input placeholder="KEY" value="'+(e.key||"")+'" onchange="envRows['+i+'].key=this.value"><input placeholder="VALUE" value="'+(e.value||"")+'" onchange="envRows['+i+'].value=this.value"><button onclick="envRows.splice('+i+',1);render()">x</button></div>').join("")+'</div><button class="btn-muted btn" style="font-size:11px;margin-top:4px" onclick="envRows.push({key:\'\',value:\'\'});render()">+ Add Variable</button></div><div style="margin-bottom:16px"><label style="display:block;font-size:11px;color:var(--muted);margin-bottom:6px;text-transform:uppercase">Volumes</label><div id="volContainer">'+volRows.map((v,i)=>'<div class="env-row"><input placeholder="/host/path or volume_name" value="'+(v.host||"")+'" onchange="volRows['+i+'].host=this.value"><span style="color:var(--muted);padding:0 4px">:</span><input placeholder="/container/path" value="'+(v.container||"")+'" onchange="volRows['+i+'].container=this.value"><button onclick="volRows.splice('+i+',1);render()">x</button></div>').join("")+'</div><button class="btn-muted btn" style="font-size:11px;margin-top:4px" onclick="volRows.push({host:\'\',container:\'\'});render()">+ Add Volume</button></div><div style="margin-bottom:16px"><label style="display:block;font-size:11px;color:var(--muted);margin-bottom:6px;text-transform:uppercase">Networks</label><div style="display:flex;gap:16px;flex-wrap:wrap">'+N.map(n=>'<label class="cb"><input type="checkbox" value="'+n.name+'" class="net-cb" '+(n.name==="proxy"?"checked":"")+'>'+n.name+'</label>').join("")+'</div></div><div style="display:flex;gap:12px;padding-top:8px;border-top:1px solid var(--border)"><button class="btn btn-green" onclick="doDeploy()">Deploy Service</button><span style="color:var(--muted);font-size:12px;align-self:center">Creates container + DNS record + Traefik route + SSL certificate</span></div></div></div>';break;
case"routes":c.innerHTML='<h2 style="margin-bottom:16px">Dynamic Routes</h2><div class="card"><div class="card-header"><h2>Add Route</h2></div><div class="card-body" style="padding:16px"><div class="form-row"><div class="field"><label>Subdomain</label><input id="rt-sub" placeholder="myapp"></div><div class="field"><label>Target</label><input id="rt-target" placeholder="container-name or IP"></div><div class="field"><label>Port</label><input id="rt-port" type="number" value="3000"></div><div style="flex:0"><button class="btn btn-green" onclick="addRoute()">Add</button></div></div></div></div><div class="card"><div class="card-header"><h2>Active Routes</h2></div><div class="card-body"><table><tr><th>Name</th><th>Domain</th><th>Target</th><th>Port</th><th></th></tr>'+Object.entries(routes).map(([n,r])=>'<tr><td><b>'+n+'</b></td><td><code style="color:var(--green)">'+r.subdomain+'.'+domain+'</code></td><td>'+r.target+'</td><td>'+r.port+'</td><td><button class="btn-red btn" style="padding:2px 8px;font-size:11px" onclick="delRoute(\''+n+'\')">Delete</button></td></tr>').join("")||'<tr><td colspan=5 style="color:var(--muted);text-align:center;padding:20px">No dynamic routes</td></tr>'+'</table></div></div>';break;
case"dns":c.innerHTML='<h2 style="margin-bottom:16px">DNS Records - '+domain+'</h2><p style="color:var(--muted);margin-bottom:16px">Managed via OpenSRS API. Server IP: <code>'+ip+'</code></p><div class="card"><div class="card-header"><h2>Add Record</h2></div><div class="card-body" style="padding:16px"><div class="form-row"><div class="field"><label>Subdomain (empty = root)</label><input id="dns-sub" placeholder="www"></div><div class="field"><label>IP Address</label><input id="dns-ip" value="'+ip+'"></div><div style="flex:0"><button class="btn btn-green" onclick="addDns()">Add</button></div></div></div></div><div class="card"><div class="card-header"><h2>A Records</h2></div><div class="card-body"><table><tr><th>Domain</th><th>Subdomain</th><th>IP</th><th></th></tr>'+dnsRecs.map(r=>'<tr><td><code style="color:var(--green)">'+r.domain+'</code></td><td>'+(r.subdomain||"@")+'</td><td><code>'+r.ip+'</code></td><td><button class="btn-red btn" style="padding:2px 8px;font-size:11px" onclick="delDns(\''+r.subdomain+'\',\''+r.ip+'\')">Delete</button></td></tr>').join("")+'</table></div></div>';break;
case"routers":c.innerHTML='<h2 style="margin-bottom:16px">HTTP Routers</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Rule</th><th>Entrypoints</th><th>TLS</th><th>Service</th><th>Status</th></tr>'+R.map(r=>'<tr><td><b>'+r.name+'</b><br><span style="color:var(--muted);font-size:11px">'+(r.provider||"")+'</span></td><td><code style="color:var(--green)">'+(r.rule||"")+'</code></td><td>'+(r.entryPoints||[]).map(e=>'<span class="badge badge-blue">'+e+'</span>').join(" ")+'</td><td>'+(r.tls?'<span class="badge badge-green">'+(r.tls.certResolver||"yes")+'</span>':"--")+'</td><td>'+(r.service||"")+'</td><td>'+bg(r.status||"enabled")+'</td></tr>').join("")+'</table></div></div>';break;
case"services":c.innerHTML='<h2 style="margin-bottom:16px">Services</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Servers</th><th>Status</th></tr>'+S.map(s=>'<tr><td><b>'+s.name+'</b></td><td>'+(s.loadBalancer?.servers||[]).map(sv=>'<code>'+sv.url+'</code>').join("<br>")||"--"+'</td><td>'+bg(s.status||"enabled")+'</td></tr>').join("")+'</table></div></div>';break;
case"containers":c.innerHTML='<h2 style="margin-bottom:16px">Containers</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Image</th><th>State</th><th>Networks</th><th>Ports</th><th>Actions</th></tr>'+C.map(x=>'<tr><td>'+dot(x.state==="running")+'<b>'+x.name+'</b><br><span style="color:var(--muted);font-size:11px">'+x.id+'</span></td><td style="color:var(--muted);font-size:12px">'+x.image+'</td><td>'+bg(x.status)+'</td><td>'+x.networks.map(n=>'<span class="badge badge-purple">'+n+'</span>').join(" ")+'</td><td>'+(x.ports.join(", ")||"--")+'</td><td><div class="actions">'+(x.state!=="running"?'<button class="start" onclick="cAct(\''+x.id+'\',\'start\')">Start</button>':'<button class="stop" onclick="cAct(\''+x.id+'\',\'stop\')">Stop</button>')+'<button class="restart" onclick="cAct(\''+x.id+'\',\'restart\')">Restart</button><button onclick="logs(\''+x.id+'\',\''+x.name+'\')">Logs</button></div></td></tr>').join("")+'</table></div></div>';break;
case"certificates":c.innerHTML='<h2 style="margin-bottom:16px">Certificates</h2><div class="card"><div class="card-body"><table><tr><th>Domain</th><th>Status</th><th>Resolver</th><th>Router</th></tr>'+CE.map(x=>'<tr><td><b>'+x.domain+'</b></td><td>'+bg(x.status)+'</td><td><span class="badge badge-blue">'+(x.resolver||"--")+'</span></td><td>'+x.router+'</td></tr>').join("")+'</table></div></div>';break;
case"networks":c.innerHTML='<h2 style="margin-bottom:16px">Networks</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Driver</th><th>Scope</th></tr>'+N.map(n=>'<tr><td><b>'+n.name+'</b></td><td><span class="badge badge-blue">'+n.driver+'</span></td><td>'+n.scope+'</td></tr>').join("")+'</table></div></div>';break;
case"volumes":c.innerHTML='<h2 style="margin-bottom:16px">Volumes</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Driver</th><th>Mountpoint</th></tr>'+V.map(v=>'<tr><td><b>'+v.name+'</b></td><td><span class="badge badge-blue">'+v.driver+'</span></td><td style="color:var(--muted);font-size:12px">'+v.mountpoint+'</td></tr>').join("")+'</table></div></div>';break;
case"entrypoints":c.innerHTML='<h2 style="margin-bottom:16px">Entrypoints</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Address</th></tr>'+E.map(e=>'<tr><td><b>'+e.name+'</b></td><td><code>'+e.address+'</code></td></tr>').join("")+'</table></div></div>';break;
case"middlewares":c.innerHTML='<h2 style="margin-bottom:16px">Middlewares</h2><div class="card"><div class="card-body"><table><tr><th>Name</th><th>Type</th><th>Provider</th></tr>'+M.map(m=>'<tr><td><b>'+(m.name||"")+'</b></td><td><span class="badge badge-purple">'+(m.type||"?")+'</span></td><td style="color:var(--muted)">'+(m.provider||"")+'</td></tr>').join("")+'</table></div></div>';break;
}}
async function cAct(id,a){await fetch("/api/container/"+id+"/"+a,{method:"POST"});toast(a+" sent");setTimeout(loadAll,1500)}
async function logs(id,name){$("#logTitle").textContent="Logs - "+name;const r=await fetch("/api/container/"+id+"/logs");$("#logContent").textContent=await r.text();$("#logModal").classList.add("show")}
function closeModal(){$("#logModal").classList.remove("show")}
document.addEventListener("keydown",e=>{if(e.key==="Escape")closeModal()});
async function doDeploy(){
const image=$("#dp-image").value.trim(),sub=$("#dp-sub").value.trim(),port=$("#dp-port").value,name=$("#dp-name").value.trim();
if(!image||!sub){toast("Image and subdomain required",true);return}
const nets=[...document.querySelectorAll(".net-cb:checked")].map(c=>c.value);
showSpinner("Pulling "+image+" and deploying...");
try{
const r=await fetch("/api/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({image,subdomain:sub,port:parseInt(port),name:name||undefined,envVars:envRows.filter(e=>e.key),volumes:volRows.filter(v=>v.host&&v.container),networks:nets})});
const d=await r.json();hideSpinner();
if(d.ok){toast("Deployed: "+d.url);envRows=[{key:"",value:""}];volRows=[{host:"",container:""}];loadAll()}
else toast("Error: "+(d.error||"unknown"),true);
}catch(e){hideSpinner();toast("Error: "+e.message,true)}
}
async function removeService(id,name,sub){
if(!confirm("Remove "+name+"? This will stop and delete the container."+(sub?" DNS record for "+sub+" will also be removed.":"")))return;
await fetch("/api/container/"+id+"/remove",{method:"POST"});
if(sub){try{const recs=await(await fetch("/api/dns")).json();const filtered=(recs.records||[]).filter(r=>r.subdomain!==sub);await fetch("/api/dns",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({subdomain:sub,ip:recs.serverIp})})}catch{}}
toast("Service removed");setTimeout(loadAll,1000);
}
async function addRoute(){const sub=$("#rt-sub").value.trim(),target=$("#rt-target").value.trim(),port=$("#rt-port").value;if(!sub||!target){toast("Fill all fields",true);return}await fetch("/api/routes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({subdomain:sub,target,port})});toast("Route added");loadAll()}
async function delRoute(n){if(!confirm("Delete route "+n+"?"))return;await fetch("/api/routes/"+n,{method:"DELETE"});toast("Deleted");loadAll()}
async function addDns(){const sub=$("#dns-sub").value.trim(),ip=$("#dns-ip").value.trim();if(!ip){toast("IP required",true);return}await fetch("/api/dns",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({subdomain:sub,ip})});toast("DNS added");loadAll()}
async function delDns(sub,ip){if(!confirm("Delete "+sub+" -> "+ip+"?"))return;await fetch("/api/dns",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({subdomain:sub,ip})});toast("Deleted");loadAll()}
loadAll();setInterval(loadAll,15000);
</script></body></html>

36
traefik-hub/login.html Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Traefik Hub — Login</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;background:#0d1117;color:#c9d1d9;display:flex;align-items:center;justify-content:center;min-height:100vh}
.login-card{background:#161b22;border:1px solid #30363d;border-radius:12px;padding:40px;width:380px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
h1{font-size:24px;margin-bottom:8px;color:#58a6ff;display:flex;align-items:center;gap:10px}
h1 svg{width:28px;height:28px}
p.sub{color:#8b949e;font-size:13px;margin-bottom:24px}
label{display:block;font-size:13px;color:#8b949e;margin-bottom:4px}
input{width:100%;padding:10px 12px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#c9d1d9;font-size:14px;margin-bottom:16px;outline:none}
input:focus{border-color:#58a6ff}
button{width:100%;padding:12px;background:#238636;border:none;border-radius:6px;color:#fff;font-size:14px;font-weight:600;cursor:pointer}
button:hover{background:#2ea043}
.error{color:#f85149;font-size:13px;margin-bottom:12px;display:none}
</style></head><body>
<div class="login-card">
<h1><svg viewBox="0 0 24 24" fill="none" stroke="#58a6ff" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> Traefik Hub</h1>
<p class="sub">Infrastructure Management Dashboard</p>
<div class="error" id="err">Invalid credentials</div>
<label>Username</label>
<input id="user" type="text" autofocus>
<label>Password</label>
<input id="pass" type="password">
<button onclick="login()">Sign In</button>
</div>
<script>
async function login(){
const r=await fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({user:document.getElementById("user").value,pass:document.getElementById("pass").value})});
if(r.ok){location.reload()}else{document.getElementById("err").style.display="block"}
}
document.getElementById("pass").addEventListener("keydown",e=>{if(e.key==="Enter")login()});
</script>
</body></html>

6
traefik-hub/package.json Normal file
View File

@ -0,0 +1,6 @@
{
"name": "traefik-hub",
"version": "1.0.0",
"scripts": { "start": "node server.js" },
"dependencies": { "dockerode": "^4.0.4" }
}

254
traefik-hub/server.js Normal file
View File

@ -0,0 +1,254 @@
const http = require("http");
const fs = require("fs");
const Docker = require("dockerode");
const crypto = require("crypto");
const PORT = 3080;
const TRAEFIK_API = process.env.TRAEFIK_API || "http://127.0.0.1:8080/api";
const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString("hex");
const ADMIN_USER = process.env.ADMIN_USER || "admin";
const ADMIN_PASS = process.env.ADMIN_PASS || "targo2026";
const OPENSRS_USER = process.env.OPENSRS_USER || "targo";
const OPENSRS_KEY = process.env.OPENSRS_KEY || "";
const OPENSRS_DOMAIN = process.env.OPENSRS_DOMAIN || "gigafibre.ca";
const SERVER_IP = process.env.SERVER_IP || "96.125.196.67";
const ROUTES_FILE = process.env.ROUTES_FILE || "/dynamic/routes.yml";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
const sessions = new Map();
// --- Auth ---
function createSession(user) { const t = crypto.randomBytes(48).toString("hex"); sessions.set(t, { user, created: Date.now() }); return t; }
function getSession(req) {
const c = (req.headers.cookie || "").split(";").find(c => c.trim().startsWith("hub_session="));
if (!c) return null; const t = c.split("=")[1]?.trim(); const s = sessions.get(t);
if (!s || Date.now() - s.created > 86400000) { sessions.delete(t); return null; } return s;
}
// --- Traefik API ---
function fetchTraefik(path) {
return new Promise(r => {
http.get(TRAEFIK_API + path, res => { let d = ""; res.on("data", c => d += c); res.on("end", () => { try { r(JSON.parse(d)); } catch { r(null); } }); }).on("error", () => r(null));
});
}
// --- Docker helpers ---
async function listContainers() {
return (await docker.listContainers({ all: true })).map(c => ({
id: c.Id.slice(0, 12), name: c.Names[0]?.replace("/", ""), image: c.Image,
state: c.State, status: c.Status,
ports: c.Ports.map(p => p.PublicPort ? p.PublicPort + ":" + p.PrivatePort : "" + p.PrivatePort).filter(Boolean),
labels: c.Labels, networks: Object.keys(c.NetworkSettings?.Networks || {})
}));
}
async function containerAction(id, action) {
const c = docker.getContainer(id);
if (action === "start") await c.start(); else if (action === "stop") await c.stop();
else if (action === "restart") await c.restart(); else if (action === "remove") { try { await c.stop(); } catch {} await c.remove({ force: true }); }
else if (action === "logs") return (await c.logs({ stdout: true, stderr: true, tail: 150, timestamps: true })).toString("utf8");
return { ok: true };
}
async function getSystemInfo() {
const [info, ver] = await Promise.all([docker.info(), docker.version()]);
return { containers: info.Containers, running: info.ContainersRunning, stopped: info.ContainersStopped,
images: info.Images, memTotal: info.MemTotal, cpus: info.NCPU, os: info.OperatingSystem,
dockerVersion: ver.Version, swarm: info.Swarm?.LocalNodeState || "inactive" };
}
async function getNetworks() {
return (await docker.listNetworks()).filter(n => !["none","host"].includes(n.Name))
.map(n => ({ id: n.Id.slice(0, 12), name: n.Name, driver: n.Driver, scope: n.Scope }));
}
async function getVolumes() {
const { Volumes } = await docker.listVolumes();
return (Volumes || []).map(v => ({ name: v.Name, driver: v.Driver, mountpoint: v.Mountpoint }));
}
// --- Deploy new service ---
async function deployService(opts) {
const { image, subdomain, port, envVars, volumes, networks: nets, name } = opts;
const containerName = name || subdomain + "-svc";
const domain = subdomain + "." + OPENSRS_DOMAIN;
// 1. Pull image
await new Promise((resolve, reject) => {
docker.pull(image, (err, stream) => {
if (err) return reject(err);
docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve(), () => {});
});
});
// 2. Prepare labels
const labels = {
"traefik.enable": "true",
["traefik.http.routers." + subdomain + ".rule"]: "Host(`" + domain + "`)",
["traefik.http.routers." + subdomain + ".entrypoints"]: "websecure",
["traefik.http.routers." + subdomain + ".tls.certresolver"]: "letsencrypt",
["traefik.http.services." + subdomain + ".loadbalancer.server.port"]: String(port),
"hub.managed": "true",
"hub.subdomain": subdomain,
"hub.domain": domain
};
// 3. Parse env vars
const Env = (envVars || []).filter(e => e.key && e.value).map(e => e.key + "=" + e.value);
// 4. Parse volumes
const Binds = (volumes || []).filter(v => v.host && v.container).map(v => v.host + ":" + v.container);
// 5. Create container
const container = await docker.createContainer({
Image: image, name: containerName, Labels: labels, Env,
HostConfig: { Binds, RestartPolicy: { Name: "unless-stopped" }, NetworkMode: "proxy" },
ExposedPorts: { [port + "/tcp"]: {} }
});
// 6. Connect to additional networks
for (const net of (nets || []).filter(n => n !== "proxy")) {
try { const network = docker.getNetwork(net); await network.connect({ Container: container.id }); } catch {}
}
// 7. Start
await container.start();
// 8. Create DNS record
try {
const dnsRecs = await getDnsRecords();
if (!dnsRecs.find(r => r.subdomain === subdomain)) {
dnsRecs.push({ subdomain, ip: SERVER_IP });
await setDnsRecords(dnsRecs);
}
} catch (e) { console.error("DNS error:", e.message); }
return { ok: true, container: containerName, url: "https://" + domain };
}
// --- Routes (file provider) ---
function parseRoutesYaml(yaml) {
const routes = {}; if (!yaml) return routes;
const blocks = yaml.split(/\n(?= \S)/).filter(b => b.includes("-route:"));
for (const block of blocks) {
const nm = block.match(/([\w-]+)-route:/)?.[1];
const rule = block.match(/Host\(`([\w.-]+)`\)/)?.[1];
if (nm && rule) routes[nm] = { subdomain: rule.replace("." + OPENSRS_DOMAIN, ""), target: "", port: 0 };
}
const svcBlocks = yaml.split(/\n(?= \S)/).filter(b => b.includes("-svc:"));
for (const block of svcBlocks) {
const nm = block.match(/([\w-]+)-svc:/)?.[1];
const url = block.match(/url:\s*"http:\/\/([^:]+):(\d+)"/);
if (nm && url && routes[nm]) { routes[nm].target = url[1]; routes[nm].port = parseInt(url[2]); }
}
return routes;
}
function buildRoutesYaml(routes) {
const entries = Object.entries(routes);
if (!entries.length) return "http:\n routers: {}\n services: {}";
let routers = "", services = "";
for (const [name, r] of entries) {
const domain = r.subdomain.includes(".") ? r.subdomain : r.subdomain + "." + OPENSRS_DOMAIN;
routers += " " + name + "-route:\n rule: \"Host(`" + domain + "`)\"\n entryPoints:\n - websecure\n service: " + name + "-svc\n tls:\n certResolver: letsencrypt\n";
services += " " + name + "-svc:\n loadBalancer:\n servers:\n - url: \"http://" + r.target + ":" + r.port + "\"\n";
}
return "http:\n routers:\n" + routers + " services:\n" + services;
}
function getRoutes() { try { return parseRoutesYaml(fs.readFileSync(ROUTES_FILE, "utf8")); } catch { return {}; } }
function saveRoute(sub, target, port) {
const routes = getRoutes(); routes[sub.replace(/[^a-z0-9]/gi, "-")] = { subdomain: sub, target, port: parseInt(port) };
fs.writeFileSync(ROUTES_FILE, buildRoutesYaml(routes)); return { ok: true };
}
function deleteRoute(name) { const routes = getRoutes(); delete routes[name]; fs.writeFileSync(ROUTES_FILE, buildRoutesYaml(routes)); return { ok: true }; }
// --- OpenSRS DNS ---
function opensrsSign(xml) { const md5 = s => crypto.createHash("md5").update(s).digest("hex"); return md5(md5(xml + OPENSRS_KEY) + OPENSRS_KEY); }
function opensrsReq(xml) {
return new Promise((resolve, reject) => {
const sig = opensrsSign(xml);
const req = require("https").request({ hostname: "rr-n1-tor.opensrs.net", port: 55443, path: "/", method: "POST",
headers: { "Content-Type": "text/xml", "X-Username": OPENSRS_USER, "X-Signature": sig }
}, res => { let d = ""; res.on("data", c => d += c); res.on("end", () => resolve(d)); });
req.on("error", reject); req.write(xml); req.end();
});
}
async function getDnsRecords() {
const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE OPS_envelope SYSTEM "ops.dtd"><OPS_envelope><header><version>0.9</version></header><body><data_block><dt_assoc><item key="protocol">XCP</item><item key="action">GET_DNS_ZONE</item><item key="object">DOMAIN</item><item key="attributes"><dt_assoc><item key="domain">' + OPENSRS_DOMAIN + '</item></dt_assoc></item></dt_assoc></data_block></body></OPS_envelope>';
const resp = await opensrsReq(xml);
const records = [], seen = new Set();
const items = resp.match(/<item key="\d+">([\s\S]*?)<\/item>/g) || [];
for (const item of items) {
const sub = item.match(/<item key="subdomain">(.*?)<\/item>/)?.[1] || "";
const ip = item.match(/<item key="ip_address">(.*?)<\/item>/)?.[1] || "";
const key = sub + "|" + ip;
if (ip && !seen.has(key)) { seen.add(key); records.push({ subdomain: sub, ip, domain: sub ? sub + "." + OPENSRS_DOMAIN : OPENSRS_DOMAIN }); }
}
return records;
}
async function setDnsRecords(records) {
// Deduplicate
const seen = new Set(); const unique = [];
for (const r of records) { const k = r.subdomain + "|" + r.ip; if (!seen.has(k)) { seen.add(k); unique.push(r); } }
let items = "";
unique.forEach((r, i) => { items += '<item key="' + i + '"><dt_assoc><item key="subdomain">' + r.subdomain + '</item><item key="ip_address">' + r.ip + '</item></dt_assoc></item>'; });
const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE OPS_envelope SYSTEM "ops.dtd"><OPS_envelope><header><version>0.9</version></header><body><data_block><dt_assoc><item key="protocol">XCP</item><item key="action">SET_DNS_ZONE</item><item key="object">DOMAIN</item><item key="attributes"><dt_assoc><item key="domain">' + OPENSRS_DOMAIN + '</item><item key="records"><dt_assoc><item key="A"><dt_array>' + items + '</dt_array></item></dt_assoc></item></dt_assoc></item></dt_assoc></data_block></body></OPS_envelope>';
return (await opensrsReq(xml)).includes('"is_success">1');
}
// --- Body parser ---
function parseBody(req) { return new Promise(r => { let b = ""; req.on("data", c => b += c); req.on("end", () => { try { r(JSON.parse(b)); } catch { r({}); } }); }); }
// --- Server ---
const server = http.createServer(async (req, res) => {
const path = new URL(req.url, "http://localhost").pathname;
if (path === "/" || path === "/index.html") {
if (!getSession(req)) { res.writeHead(200, { "Content-Type": "text/html" }); res.end(fs.readFileSync("/app/login.html", "utf8")); return; }
res.writeHead(200, { "Content-Type": "text/html" }); res.end(fs.readFileSync("/app/index.html", "utf8")); return;
}
if (path === "/api/login" && req.method === "POST") {
const { user, pass } = await parseBody(req);
if (user === ADMIN_USER && pass === ADMIN_PASS) { const t = createSession(user); res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": "hub_session=" + t + "; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400" }); res.end(JSON.stringify({ ok: true })); }
else { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid" })); } return;
}
if (path === "/api/logout") { res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": "hub_session=; HttpOnly; Path=/; Max-Age=0" }); res.end(JSON.stringify({ ok: true })); return; }
if (path.startsWith("/api/") && !getSession(req)) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized" })); return; }
try {
let data;
switch (true) {
case path === "/api/overview":
const [routers, services, entrypoints, system, containers] = await Promise.all([fetchTraefik("/http/routers"), fetchTraefik("/http/services"), fetchTraefik("/entrypoints"), getSystemInfo(), listContainers()]);
data = { routers, services, entrypoints, system, containers }; break;
case path === "/api/routers": data = await fetchTraefik("/http/routers"); break;
case path === "/api/services": data = await fetchTraefik("/http/services"); break;
case path === "/api/entrypoints": data = await fetchTraefik("/entrypoints"); break;
case path === "/api/middlewares": data = await fetchTraefik("/http/middlewares"); break;
case path === "/api/containers": data = await listContainers(); break;
case path === "/api/system": data = await getSystemInfo(); break;
case path === "/api/networks": data = await getNetworks(); break;
case path === "/api/volumes": data = await getVolumes(); break;
case path === "/api/certificates":
data = ((await fetchTraefik("/http/routers")) || []).filter(r => r.tls).map(r => ({
domain: r.rule?.match(/Host\(`([^`]+)`\)/)?.[1] || "?", status: r.tls?.certResolver ? "managed" : "manual", resolver: r.tls?.certResolver, router: r.name
})); break;
case path === "/api/routes" && req.method === "GET": data = { routes: getRoutes(), domain: OPENSRS_DOMAIN, serverIp: SERVER_IP }; break;
case path === "/api/routes" && req.method === "POST": const rd = await parseBody(req); data = saveRoute(rd.subdomain, rd.target, rd.port); break;
case path.startsWith("/api/routes/") && req.method === "DELETE": data = deleteRoute(path.split("/")[3]); break;
case path === "/api/dns" && req.method === "GET": data = { records: await getDnsRecords(), domain: OPENSRS_DOMAIN, serverIp: SERVER_IP }; break;
case path === "/api/dns" && req.method === "POST":
const dd = await parseBody(req); const cur = await getDnsRecords();
if (!cur.find(r => r.subdomain === dd.subdomain && r.ip === (dd.ip || SERVER_IP))) { cur.push({ subdomain: dd.subdomain, ip: dd.ip || SERVER_IP }); }
data = { ok: await setDnsRecords(cur) }; break;
case path === "/api/dns" && req.method === "DELETE":
const del = await parseBody(req); const recs = await getDnsRecords();
data = { ok: await setDnsRecords(recs.filter(r => !(r.subdomain === del.subdomain && r.ip === del.ip))) }; break;
case path === "/api/deploy" && req.method === "POST": data = await deployService(await parseBody(req)); break;
case path.startsWith("/api/container/") && path.endsWith("/logs"):
data = await containerAction(path.split("/")[3], "logs");
res.writeHead(200, { "Content-Type": "text/plain" }); res.end(typeof data === "string" ? data : JSON.stringify(data)); return;
case path.startsWith("/api/container/") && req.method === "POST":
data = await containerAction(path.split("/")[3], path.split("/")[4]); break;
default: res.writeHead(404); res.end("Not found"); return;
}
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(data));
} catch (e) { console.error(e); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); }
});
server.listen(PORT, "0.0.0.0", () => console.log("Traefik Hub on :" + PORT));

View File

@ -0,0 +1,38 @@
services:
traefik:
image: traefik:v2.11
command:
- "--api.dashboard=true"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.file.directory=/dynamic"
- "--providers.file.watch=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=louispaul@targointernet.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--log.level=DEBUG"
ports:
- "80:80"
- "443:443"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-certs:/letsencrypt
- /opt/traefik/dynamic:/dynamic:ro
networks:
- proxy
restart: unless-stopped
networks:
proxy:
name: proxy
driver: bridge
volumes:
traefik-certs:
external: true
name: traefik_traefik-certs

View File

@ -0,0 +1 @@