Initial infra: Traefik, Oktopus, Hub, Apps, network, security
This commit is contained in:
commit
e97ba51a6c
91
apps/docker-compose.yml
Normal file
91
apps/docker-compose.yml
Normal 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
106
oktopus/docker-compose.yml
Normal 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
47
setup.sh
Executable 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
12
system/10-lan.network
Normal 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
|
||||
0
system/10-netplan-all-en.network
Normal file
0
system/10-netplan-all-en.network
Normal file
0
system/10-netplan-all-eth.network
Normal file
0
system/10-netplan-all-eth.network
Normal file
9
system/20-wan.network
Normal file
9
system/20-wan.network
Normal 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
11
system/jail.local
Normal 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
7
traefik-hub/Dockerfile
Normal 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"]
|
||||
31
traefik-hub/docker-compose.yml
Normal file
31
traefik-hub/docker-compose.yml
Normal 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
148
traefik-hub/index.html
Normal 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()">×</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
36
traefik-hub/login.html
Normal 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
6
traefik-hub/package.json
Normal 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
254
traefik-hub/server.js
Normal 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));
|
||||
38
traefik/docker-compose.yml
Normal file
38
traefik/docker-compose.yml
Normal 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
|
||||
1
traefik/dynamic/routes.yml
Normal file
1
traefik/dynamic/routes.yml
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
Loading…
Reference in New Issue
Block a user