feat(vpn): intégration Tailscale/Headscale + URLs publiques par sous-domaine
- Ajout d'un conteneur Tailscale côté serveur pour joindre les agents via IPs Tailscale - Configuration Headscale exposé en HTTPS via Caddy (headscale.alfrednobel.edudeploy.com) - Caddy configuré pour les sous-domaines avec TLS on-demand - Middleware et route proxy Next.js pour router les sous-domaines vers les agents - Ajout du champ domain sur Establishment et affichage de l'URL publique dans le dashboard - Agent Windows v0.2.3 avec proxy Tailscale par instance pour contourner Docker Desktop - Templates WordPress/PrestaShop bindés sur 0.0.0.0 pour être accessibles via Tailscale
This commit is contained in:
@@ -13,3 +13,6 @@ agent/edubox-agent
|
|||||||
agent/edubox-agent.exe
|
agent/edubox-agent.exe
|
||||||
agent/edubox-agent-mac
|
agent/edubox-agent-mac
|
||||||
agent/ui/*.go.html
|
agent/ui/*.go.html
|
||||||
|
headscale/*.sqlite*
|
||||||
|
headscale/*.key
|
||||||
|
headscale/*.state
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
auto_https off
|
email admin@edudeploy.com
|
||||||
|
on_demand_tls {
|
||||||
|
ask http://server:3000/api/check-domain
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
@@ -8,7 +11,30 @@
|
|||||||
root /usr/share/caddy/agent
|
root /usr/share/caddy/agent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
route /api/websocket* {
|
||||||
|
reverse_proxy server:3001
|
||||||
|
}
|
||||||
|
route /api/check-domain* {
|
||||||
|
reverse_proxy server:3000
|
||||||
|
}
|
||||||
|
route * {
|
||||||
|
redir https://{host}{uri} permanent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headscale.alfrednobel.edudeploy.com {
|
||||||
|
reverse_proxy headscale:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
alfrednobel.edudeploy.com {
|
||||||
|
reverse_proxy /api/websocket* server:3001
|
||||||
|
reverse_proxy server:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
:443 {
|
||||||
|
tls {
|
||||||
|
on_demand
|
||||||
|
}
|
||||||
reverse_proxy /api/websocket* server:3001
|
reverse_proxy /api/websocket* server:3001
|
||||||
reverse_proxy /gitea* gitea:3000
|
|
||||||
reverse_proxy server:3000
|
reverse_proxy server:3000
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
VERSION="0.2.1"
|
VERSION="0.2.3"
|
||||||
LDFLAGS="-X main.version=${VERSION}"
|
LDFLAGS="-X main.version=${VERSION}"
|
||||||
|
|
||||||
echo "Building EduBox Agent v${VERSION}..."
|
echo "Building EduBox Agent v${VERSION}..."
|
||||||
|
|||||||
+30
-1
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
|
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
|
||||||
@@ -16,6 +17,8 @@ var (
|
|||||||
nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)")
|
nodeID = flag.String("node-id", defaultNodeID(), "ID du nœud (défaut: hostname)")
|
||||||
dataDir = flag.String("data-dir", "./edubox-data", "Répertoire de données")
|
dataDir = flag.String("data-dir", "./edubox-data", "Répertoire de données")
|
||||||
uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX")
|
uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX")
|
||||||
|
headscaleURL = flag.String("headscale-url", "", "URL du serveur Headscale (ex: http://151.80.60.98:8080)")
|
||||||
|
headscaleAuthKey = flag.String("headscale-auth-key", "", "Clé d'authentification Headscale")
|
||||||
)
|
)
|
||||||
|
|
||||||
func defaultNodeID() string {
|
func defaultNodeID() string {
|
||||||
@@ -51,5 +54,31 @@ func main() {
|
|||||||
go startUI(*dataDir, *nodeID, *serverAddr)
|
go startUI(*dataDir, *nodeID, *serverAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
startWebSocket(*serverAddr, *nodeID, *dataDir)
|
go startWebSocket(*serverAddr, *nodeID, *dataDir)
|
||||||
|
|
||||||
|
if *headscaleURL != "" && *headscaleAuthKey != "" {
|
||||||
|
go startTailscaleAndReport(*dataDir, *nodeID, *headscaleURL, *headscaleAuthKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
|
||||||
|
tsDir := filepath.Join(dataDir, "tailscale")
|
||||||
|
ip, err := startTailscale(tsDir, nodeID, headscaleURL, authKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Tailscale error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Tailscale IP obtained: %s", ip)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := sendMessage(WSMessage{Action: "tailscale_ip", NodeID: nodeID, TailscaleIP: ip}); err != nil {
|
||||||
|
log.Printf("Waiting for WebSocket to send tailscale_ip...")
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Sent tailscale_ip to server: %s", ip)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-4
@@ -1,14 +1,24 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
"tailscale.com/tsnet"
|
"tailscale.com/tsnet"
|
||||||
)
|
)
|
||||||
|
|
||||||
func startTailscale(dataDir string, nodeID string) (net.Listener, error) {
|
var globalTSServer *tsnet.Server
|
||||||
|
|
||||||
|
func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, error) {
|
||||||
|
// Configure tsnet to use our Headscale server
|
||||||
|
os.Setenv("TS_AUTHKEY", authKey)
|
||||||
|
os.Setenv("TS_CONTROL_URL", headscaleURL)
|
||||||
|
|
||||||
s := &tsnet.Server{
|
s := &tsnet.Server{
|
||||||
Hostname: nodeID,
|
Hostname: nodeID,
|
||||||
Dir: dataDir,
|
Dir: dataDir,
|
||||||
@@ -16,13 +26,80 @@ func startTailscale(dataDir string, nodeID string) (net.Listener, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Start(); err != nil {
|
if err := s.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("tailscale start: %w", err)
|
return "", fmt.Errorf("tailscale start: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ln, err := s.Listen("tcp", ":0")
|
globalTSServer = s
|
||||||
|
|
||||||
|
// Wait for Tailscale to come up and retrieve IP
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
lc, err := s.LocalClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tailscale listen: %w", err)
|
return "", fmt.Errorf("tailscale local client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tailscaleIP string
|
||||||
|
for {
|
||||||
|
status, err := lc.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("tailscale status: %w", err)
|
||||||
|
}
|
||||||
|
if status.Self != nil && len(status.Self.TailscaleIPs) > 0 {
|
||||||
|
tailscaleIP = status.Self.TailscaleIPs[0].String()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", fmt.Errorf("tailscale IP timeout")
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Tailscale started with IP: %s", tailscaleIP)
|
||||||
|
return tailscaleIP, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTailscaleProxy(port int) (net.Listener, error) {
|
||||||
|
if globalTSServer == nil {
|
||||||
|
return nil, fmt.Errorf("tailscale server not started")
|
||||||
|
}
|
||||||
|
ln, err := globalTSServer.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tailscale listen on port %d: %w", port, err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tailscale proxy accept error on port %d: %v", port, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go handleProxyConn(conn, port)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Printf("Tailscale proxy started on port %d", port)
|
||||||
return ln, nil
|
return ln, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleProxyConn(src net.Conn, port int) {
|
||||||
|
defer src.Close()
|
||||||
|
dst, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tailscale proxy dial localhost:%d error: %v", port, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
done := make(chan struct{}, 2)
|
||||||
|
go func() {
|
||||||
|
_, _ = io.Copy(dst, src)
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_, _ = io.Copy(src, dst)
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ type WSMessage struct {
|
|||||||
StudentId string `json:"studentId,omitempty"`
|
StudentId string `json:"studentId,omitempty"`
|
||||||
StudentName string `json:"studentName,omitempty"`
|
StudentName string `json:"studentName,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -27,6 +29,11 @@ var (
|
|||||||
mainConnMu sync.Mutex
|
mainConnMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
tsProxies = make(map[int]net.Listener)
|
||||||
|
tsProxiesMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
func sendMessage(msg WSMessage) error {
|
func sendMessage(msg WSMessage) error {
|
||||||
mainConnMu.Lock()
|
mainConnMu.Lock()
|
||||||
defer mainConnMu.Unlock()
|
defer mainConnMu.Unlock()
|
||||||
@@ -197,12 +204,32 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Start Tailscale proxy so the server can reach localhost via Tailscale IP
|
||||||
|
tsProxiesMu.Lock()
|
||||||
|
if _, exists := tsProxies[msg.Port]; !exists {
|
||||||
|
if ln, err := startTailscaleProxy(msg.Port); err == nil {
|
||||||
|
tsProxies[msg.Port] = ln
|
||||||
|
} else {
|
||||||
|
log.Printf("startTailscaleProxy error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tsProxiesMu.Unlock()
|
||||||
|
|
||||||
status := getInstanceStatus(dataDir, msg.InstanceID)
|
status := getInstanceStatus(dataDir, msg.InstanceID)
|
||||||
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
|
||||||
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
|
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
case "stop":
|
case "stop":
|
||||||
log.Printf("Stop instance %s", msg.InstanceID)
|
log.Printf("Stop instance %s", msg.InstanceID)
|
||||||
|
// Stop Tailscale proxy for this instance port
|
||||||
|
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||||
|
tsProxiesMu.Lock()
|
||||||
|
if ln, exists := tsProxies[inst[msg.InstanceID].Port]; exists {
|
||||||
|
_ = ln.Close()
|
||||||
|
delete(tsProxies, inst[msg.InstanceID].Port)
|
||||||
|
}
|
||||||
|
tsProxiesMu.Unlock()
|
||||||
|
}
|
||||||
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
|
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
|
||||||
log.Printf("dockerComposeDown error: %v", err)
|
log.Printf("dockerComposeDown error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -226,6 +253,17 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Start Tailscale proxy so the server can reach localhost via Tailscale IP
|
||||||
|
tsProxiesMu.Lock()
|
||||||
|
if _, exists := tsProxies[msg.Port]; !exists {
|
||||||
|
if ln, err := startTailscaleProxy(msg.Port); err == nil {
|
||||||
|
tsProxies[msg.Port] = ln
|
||||||
|
} else {
|
||||||
|
log.Printf("startTailscaleProxy error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tsProxiesMu.Unlock()
|
||||||
|
|
||||||
status := getInstanceStatus(dataDir, msg.InstanceID)
|
status := getInstanceStatus(dataDir, msg.InstanceID)
|
||||||
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
|
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
|
||||||
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
|
sendMessage(WSMessage{Action: "instance_started", InstanceID: msg.InstanceID, Port: msg.Port})
|
||||||
|
|||||||
+33
-2
@@ -31,6 +31,7 @@ services:
|
|||||||
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD}
|
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD}
|
||||||
HEADSCALE_URL: ${HEADSCALE_URL}
|
HEADSCALE_URL: ${HEADSCALE_URL}
|
||||||
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
||||||
|
MAIN_DOMAIN: ${MAIN_DOMAIN}
|
||||||
GITEA_URL: ${GITEA_URL}
|
GITEA_URL: ${GITEA_URL}
|
||||||
GITEA_TOKEN: ${GITEA_TOKEN}
|
GITEA_TOKEN: ${GITEA_TOKEN}
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -39,6 +40,34 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- edubox
|
- edubox
|
||||||
|
|
||||||
|
tailscale:
|
||||||
|
image: tailscale/tailscale:latest
|
||||||
|
container_name: edubox-tailscale
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: service:server
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
- SYS_MODULE
|
||||||
|
devices:
|
||||||
|
- /dev/net/tun:/dev/net/tun
|
||||||
|
volumes:
|
||||||
|
- tailscale_data:/var/lib/tailscale
|
||||||
|
environment:
|
||||||
|
HEADSCALE_URL: ${HEADSCALE_URL}
|
||||||
|
HEADSCALE_AUTH_KEY: ${HEADSCALE_AUTH_KEY}
|
||||||
|
command: >
|
||||||
|
sh -c "echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf &&
|
||||||
|
echo 'net.ipv6.conf.all.forwarding = 1' >> /etc/sysctl.conf &&
|
||||||
|
sysctl -p &&
|
||||||
|
mkdir -p /var/run/tailscale &&
|
||||||
|
tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/var/run/tailscale/tailscaled.sock &
|
||||||
|
sleep 5 &&
|
||||||
|
tailscale up --authkey=$${HEADSCALE_AUTH_KEY} --login-server=$${HEADSCALE_URL} --accept-routes --hostname=edubox-server --reset &&
|
||||||
|
tail -f /dev/null"
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
container_name: edubox-caddy
|
container_name: edubox-caddy
|
||||||
@@ -60,9 +89,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: serve
|
command: serve
|
||||||
ports:
|
ports:
|
||||||
- "41641:41641/udp"
|
- "8080:8080"
|
||||||
|
- "3478:3478/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- headscale_data:/etc/headscale
|
- ./headscale:/etc/headscale
|
||||||
networks:
|
networks:
|
||||||
- edubox
|
- edubox
|
||||||
|
|
||||||
@@ -88,6 +118,7 @@ volumes:
|
|||||||
caddy_config:
|
caddy_config:
|
||||||
headscale_data:
|
headscale_data:
|
||||||
gitea_data:
|
gitea_data:
|
||||||
|
tailscale_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
edubox:
|
edubox:
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Headscale configuration for EduBox
|
||||||
|
server_url: https://headscale.alfrednobel.edudeploy.com
|
||||||
|
listen_addr: 0.0.0.0:8080
|
||||||
|
metrics_listen_addr: 0.0.0.0:9090
|
||||||
|
grpc_listen_addr: 127.0.0.1:50443
|
||||||
|
|
||||||
|
noise:
|
||||||
|
private_key_path: /etc/headscale/noise_private.key
|
||||||
|
|
||||||
|
prefixes:
|
||||||
|
v4: 100.64.0.0/10
|
||||||
|
v6: fd7a:115c:a1e0::/48
|
||||||
|
allocation: sequential
|
||||||
|
|
||||||
|
dns:
|
||||||
|
magic_dns: true
|
||||||
|
base_domain: edubox.local
|
||||||
|
nameservers:
|
||||||
|
global:
|
||||||
|
- 1.1.1.1
|
||||||
|
- 8.8.8.8
|
||||||
|
override_local_dns: true
|
||||||
|
|
||||||
|
derp:
|
||||||
|
server:
|
||||||
|
enabled: true
|
||||||
|
region_id: 999
|
||||||
|
region_code: headscale
|
||||||
|
region_name: Headscale Embedded DERP
|
||||||
|
stun_listen_addr: 0.0.0.0:3478
|
||||||
|
private_key_path: /etc/headscale/derp_server_private.key
|
||||||
|
urls: []
|
||||||
|
paths: []
|
||||||
|
|
||||||
|
database:
|
||||||
|
type: sqlite3
|
||||||
|
sqlite:
|
||||||
|
path: /etc/headscale/db.sqlite
|
||||||
|
|
||||||
|
log:
|
||||||
|
format: text
|
||||||
|
level: info
|
||||||
@@ -5,5 +5,6 @@ COPY package.json package-lock.json* ./
|
|||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
CMD ["node_modules/.bin/next", "start"]
|
CMD ["node_modules/.bin/next", "start"]
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const domain = searchParams.get("domain");
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return NextResponse.json({ ok: false }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain === MAIN_DOMAIN || domain === `headscale.${MAIN_DOMAIN}`) {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domain.endsWith(`.${MAIN_DOMAIN}`)) {
|
||||||
|
return NextResponse.json({ ok: false }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subdomain = domain.replace(`.${MAIN_DOMAIN}`, "");
|
||||||
|
const instance = await prisma.instance.findUnique({
|
||||||
|
where: { id: subdomain },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: !!instance });
|
||||||
|
}
|
||||||
@@ -18,10 +18,37 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const instances = await prisma.instance.findMany({
|
const instances = await prisma.instance.findMany({
|
||||||
where,
|
where,
|
||||||
include: { node: { include: { student: { include: { class: true } } } }, template: true },
|
include: {
|
||||||
|
node: {
|
||||||
|
include: {
|
||||||
|
student: {
|
||||||
|
include: {
|
||||||
|
class: {
|
||||||
|
include: {
|
||||||
|
establishment: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: true,
|
||||||
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return NextResponse.json(instances);
|
|
||||||
|
const enriched = instances.map((inst) => {
|
||||||
|
const domain = inst.node.student?.class.establishment?.domain;
|
||||||
|
const publicUrl = domain
|
||||||
|
? `https://${inst.id}.${domain}`
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
...inst,
|
||||||
|
publicUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(enriched);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -35,12 +62,24 @@ export async function POST(req: NextRequest) {
|
|||||||
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
|
data: { nodeId, templateId, port: port || 8080, status: "stopped" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const node = await prisma.node.findUnique({
|
||||||
|
where: { id: nodeId },
|
||||||
|
include: { student: { include: { class: { include: { establishment: true } } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const domain = node?.student?.class.establishment?.domain;
|
||||||
|
const publicUrl = domain ? `https://${instance.id}.${domain}` : null;
|
||||||
|
|
||||||
const sent = sendToNode(nodeId, {
|
const sent = sendToNode(nodeId, {
|
||||||
action: "start",
|
action: "start",
|
||||||
instanceId: instance.id,
|
instanceId: instance.id,
|
||||||
type: template.type,
|
type: template.type,
|
||||||
port: instance.port,
|
port: instance.port,
|
||||||
composeConfig: template.composeConfig.replace(/{PORT}/g, String(instance.port)).replace(/{INSTANCE_ID}/g, instance.id),
|
composeConfig: template.composeConfig
|
||||||
|
.replace(/{PORT}/g, String(instance.port))
|
||||||
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
|
.replace(/{PUBLIC_DOMAIN}/g, domain || "localhost"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
@@ -53,9 +92,12 @@ export async function POST(req: NextRequest) {
|
|||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { id, action } = body;
|
const { id, action } = body;
|
||||||
const instance = await prisma.instance.findUnique({ where: { id }, include: { template: true } });
|
const instance = await prisma.instance.findUnique({ where: { id }, include: { template: true, node: { include: { student: { include: { class: { include: { establishment: true } } } } } } } });
|
||||||
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!instance) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const domain = instance.node.student?.class.establishment?.domain;
|
||||||
|
const publicUrl = domain ? `https://${instance.id}.${domain}` : null;
|
||||||
|
|
||||||
if (action === "stop") {
|
if (action === "stop") {
|
||||||
sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id });
|
sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id });
|
||||||
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
||||||
@@ -65,7 +107,11 @@ export async function PATCH(req: NextRequest) {
|
|||||||
instanceId: instance.id,
|
instanceId: instance.id,
|
||||||
type: instance.template.type,
|
type: instance.template.type,
|
||||||
port: instance.port,
|
port: instance.port,
|
||||||
composeConfig: instance.template.composeConfig.replace(/{PORT}/g, String(instance.port)).replace(/{INSTANCE_ID}/g, instance.id),
|
composeConfig: instance.template.composeConfig
|
||||||
|
.replace(/{PORT}/g, String(instance.port))
|
||||||
|
.replace(/{INSTANCE_ID}/g, instance.id)
|
||||||
|
.replace(/{PUBLIC_URL}/g, publicUrl || `http://localhost:${instance.port}`)
|
||||||
|
.replace(/{PUBLIC_DOMAIN}/g, domain || "localhost"),
|
||||||
});
|
});
|
||||||
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
if (!sent) await prisma.instance.update({ where: { id }, data: { status: "error" } });
|
||||||
} else if (action === "reset") {
|
} else if (action === "reset") {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com";
|
||||||
|
|
||||||
|
async function proxyRequest(req: NextRequest) {
|
||||||
|
const host = req.headers.get("host") || "";
|
||||||
|
const cleanHost = host.split(":")[0];
|
||||||
|
|
||||||
|
if (!cleanHost.endsWith(`.${MAIN_DOMAIN}`)) {
|
||||||
|
return NextResponse.json({ error: "Invalid host" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subdomain = cleanHost.replace(`.${MAIN_DOMAIN}`, "");
|
||||||
|
|
||||||
|
const instance = await prisma.instance.findUnique({
|
||||||
|
where: { id: subdomain },
|
||||||
|
include: { node: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!instance || !instance.node?.tailscaleIp) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Instance not found or not connected" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = new URL(req.url);
|
||||||
|
const upstream = `http://${instance.node.tailscaleIp}:${instance.port}${targetUrl.pathname}${targetUrl.search}`;
|
||||||
|
|
||||||
|
const headers = new Headers(req.headers);
|
||||||
|
headers.delete("host");
|
||||||
|
headers.set("host", cleanHost);
|
||||||
|
|
||||||
|
const upstreamRes = await fetch(upstream, {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
body:
|
||||||
|
req.method !== "GET" && req.method !== "HEAD"
|
||||||
|
? (req.body as BodyInit)
|
||||||
|
: undefined,
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseHeaders = new Headers(upstreamRes.headers);
|
||||||
|
// Remove content-encoding because Next.js/fetch handles decompression automatically
|
||||||
|
responseHeaders.delete("content-encoding");
|
||||||
|
responseHeaders.delete("content-length");
|
||||||
|
|
||||||
|
return new Response(upstreamRes.body, {
|
||||||
|
status: upstreamRes.status,
|
||||||
|
statusText: upstreamRes.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = proxyRequest;
|
||||||
|
export const POST = proxyRequest;
|
||||||
|
export const PUT = proxyRequest;
|
||||||
|
export const PATCH = proxyRequest;
|
||||||
|
export const DELETE = proxyRequest;
|
||||||
|
export const HEAD = proxyRequest;
|
||||||
|
export const OPTIONS = proxyRequest;
|
||||||
@@ -21,10 +21,18 @@ export default async function InstancesPage() {
|
|||||||
where: establishmentId
|
where: establishmentId
|
||||||
? { node: { student: { class: { establishmentId } } } }
|
? { node: { student: { class: { establishmentId } } } }
|
||||||
: {},
|
: {},
|
||||||
include: { node: { include: { student: { include: { class: true } } } }, template: true },
|
include: { node: { include: { student: { include: { class: { include: { establishment: true } } } } } }, template: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const enrichedInstances = instances.map((inst) => {
|
||||||
|
const domain = inst.node.student?.class.establishment?.domain;
|
||||||
|
return {
|
||||||
|
...inst,
|
||||||
|
publicUrl: domain ? `https://${inst.id}.${domain}` : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -44,11 +52,12 @@ export default async function InstancesPage() {
|
|||||||
<TableHead>Étudiant</TableHead>
|
<TableHead>Étudiant</TableHead>
|
||||||
<TableHead>Port</TableHead>
|
<TableHead>Port</TableHead>
|
||||||
<TableHead>Statut</TableHead>
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead>URL publique</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{instances.map((inst) => (
|
{enrichedInstances.map((inst: any) => (
|
||||||
<TableRow key={inst.id}>
|
<TableRow key={inst.id}>
|
||||||
<TableCell className="font-medium">{inst.id.slice(0, 8)}...</TableCell>
|
<TableCell className="font-medium">{inst.id.slice(0, 8)}...</TableCell>
|
||||||
<TableCell>{inst.template.name}</TableCell>
|
<TableCell>{inst.template.name}</TableCell>
|
||||||
@@ -58,6 +67,9 @@ export default async function InstancesPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={inst.status === "running" ? "success" : inst.status === "error" ? "destructive" : "secondary"}>{inst.status}</Badge>
|
<Badge variant={inst.status === "running" ? "success" : inst.status === "error" ? "destructive" : "secondary"}>{inst.status}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{inst.publicUrl ? <a href={inst.publicUrl} target="_blank" rel="noopener" className="text-blue-600 hover:underline">{inst.publicUrl}</a> : "-"}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<InstanceActions instanceId={inst.id} status={inst.status} />
|
<InstanceActions instanceId={inst.id} status={inst.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -65,7 +77,7 @@ export default async function InstancesPage() {
|
|||||||
))}
|
))}
|
||||||
{instances.length === 0 && (
|
{instances.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground">Aucune instance</TableCell>
|
<TableCell colSpan={8} className="text-center text-muted-foreground">Aucune instance</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface NodeMessage {
|
|||||||
composeConfig?: string;
|
composeConfig?: string;
|
||||||
studentName?: string;
|
studentName?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
tailscaleIp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = new Map<string, WebSocket>();
|
const nodes = new Map<string, WebSocket>();
|
||||||
@@ -66,6 +67,15 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.action === "tailscale_ip" && nodeId && msg.tailscaleIp) {
|
||||||
|
await prisma.node.update({
|
||||||
|
where: { id: nodeId },
|
||||||
|
data: { tailscaleIp: msg.tailscaleIp },
|
||||||
|
});
|
||||||
|
console.log("[WS] Tailscale IP updated for", nodeId, ":", msg.tailscaleIp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.action === "instance_started" && msg.instanceId) {
|
if (msg.action === "instance_started" && msg.instanceId) {
|
||||||
await prisma.instance.update({
|
await prisma.instance.update({
|
||||||
where: { id: msg.instanceId },
|
where: { id: msg.instanceId },
|
||||||
|
|||||||
+19
-26
@@ -1,35 +1,28 @@
|
|||||||
import { withAuth } from "next-auth/middleware";
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
export default withAuth(
|
const MAIN_DOMAIN = process.env.MAIN_DOMAIN || "alfrednobel.edudeploy.com";
|
||||||
function middleware(req) {
|
|
||||||
const { pathname } = req.nextUrl;
|
|
||||||
const role = req.nextauth.token?.role as string;
|
|
||||||
|
|
||||||
if (pathname.startsWith("/superadmin")) {
|
export function middleware(req: NextRequest) {
|
||||||
if (role !== "superadmin") {
|
const host = req.headers.get("host") || "";
|
||||||
return NextResponse.redirect(new URL("/dashboard", req.url));
|
const cleanHost = host.split(":")[0];
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.startsWith("/dashboard")) {
|
|
||||||
if (!role || (role !== "admin" && role !== "teacher" && role !== "superadmin")) {
|
|
||||||
return NextResponse.redirect(new URL("/login", req.url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (cleanHost === MAIN_DOMAIN || cleanHost === `www.${MAIN_DOMAIN}`) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
},
|
|
||||||
{
|
|
||||||
callbacks: {
|
|
||||||
authorized({ req, token }) {
|
|
||||||
if (req.nextUrl.pathname.startsWith("/login")) return true;
|
|
||||||
return !!token;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
if (!cleanHost.endsWith(`.${MAIN_DOMAIN}`)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = req.nextUrl.pathname;
|
||||||
|
const search = req.nextUrl.search;
|
||||||
|
|
||||||
|
// Rewrite to the internal proxy API while preserving the original host header
|
||||||
|
const rewriteUrl = new URL(`/api/proxy${pathname}${search}`, req.url);
|
||||||
|
return NextResponse.rewrite(rewriteUrl);
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/dashboard/:path*", "/superadmin/:path*", "/api/protected/:path*"],
|
matcher: ["/((?!api/proxy|_next|static|favicon.ico|.*\\.).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Establishment" ADD COLUMN "domain" TEXT;
|
||||||
@@ -11,6 +11,7 @@ model Establishment {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
domain String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
subscription Subscription?
|
subscription Subscription?
|
||||||
users User[]
|
users User[]
|
||||||
|
|||||||
@@ -102,14 +102,14 @@ async function main() {
|
|||||||
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
|
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
|
||||||
WORDPRESS_DB_PREFIX: wp_
|
WORDPRESS_DB_PREFIX: wp_
|
||||||
WORDPRESS_CONFIG_EXTRA: |
|
WORDPRESS_CONFIG_EXTRA: |
|
||||||
define('WP_HOME', 'http://localhost:{PORT}');
|
define('WP_HOME', '{PUBLIC_URL}');
|
||||||
define('WP_SITEURL', 'http://localhost:{PORT}');
|
define('WP_SITEURL', '{PUBLIC_URL}');
|
||||||
PS_DB_HOST: ${dbHost}:${dbPort}
|
PS_DB_HOST: ${dbHost}:${dbPort}
|
||||||
PS_DB_NAME: ${t.dbName}
|
PS_DB_NAME: ${t.dbName}
|
||||||
PS_DB_USER: ${t.dbUser}
|
PS_DB_USER: ${t.dbUser}
|
||||||
PS_DB_PASSWORD: ${t.dbPassword}
|
PS_DB_PASSWORD: ${t.dbPassword}
|
||||||
PS_DB_PREFIX: ps_
|
PS_DB_PREFIX: ps_
|
||||||
PS_DOMAIN: localhost:{PORT}
|
PS_DOMAIN: {PUBLIC_DOMAIN}
|
||||||
PS_SHOP_NAME: ${t.name}
|
PS_SHOP_NAME: ${t.name}
|
||||||
PS_INSTALL_AUTO: "0"
|
PS_INSTALL_AUTO: "0"
|
||||||
INSTANCE_ID: {INSTANCE_ID}
|
INSTANCE_ID: {INSTANCE_ID}
|
||||||
|
|||||||
Reference in New Issue
Block a user