diff --git a/.gitignore b/.gitignore index 652136d..3c23462 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ agent/ui/*.go.html headscale/*.sqlite* headscale/*.key headscale/*.state +agent/resolv.conf diff --git a/agent/build.sh b/agent/build.sh index 1ac73aa..8871624 100755 --- a/agent/build.sh +++ b/agent/build.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -VERSION="0.2.3" +VERSION="0.2.7" LDFLAGS="-X main.version=${VERSION}" echo "Building EduBox Agent v${VERSION}..." @@ -23,4 +23,13 @@ echo " edubox-agent-mac (macOS amd64)" cp edubox-agent-mac "edubox-agent-v${VERSION}-mac" echo " edubox-agent-v${VERSION}-mac (macOS amd64)" +# Copy versioned binaries to server/public so the dashboard can serve them +SERVER_PUBLIC="../server/public" +if [ -d "${SERVER_PUBLIC}" ]; then + cp "edubox-agent-v${VERSION}" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}" + cp "edubox-agent-v${VERSION}-mac" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}-mac" + cp "edubox-agent-v${VERSION}.exe" "${SERVER_PUBLIC}/edubox-agent-v${VERSION}.exe" + echo " Copied versioned binaries to ${SERVER_PUBLIC}" +fi + echo "Done." diff --git a/agent/docker.go b/agent/docker.go index 603e88f..5724fab 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -1,9 +1,12 @@ package main import ( + "fmt" "os" "os/exec" "path/filepath" + "regexp" + "strings" ) func instanceDir(dataDir, instanceID string) string { @@ -22,6 +25,14 @@ func writeCompose(dataDir, instanceID, compose string) error { if err := os.MkdirAll(dir, 0755); err != nil { return err } + + // Ensure the EduBox mu-plugin is available and substitute its path + muDir, err := writeMUPlugin(dataDir) + if err != nil { + return err + } + compose = strings.ReplaceAll(compose, "{MU_PLUGINS_DIR}", filepath.Dir(muDir)) + f := filepath.Join(dir, "docker-compose.yml") return os.WriteFile(f, []byte(compose), 0644) } @@ -52,3 +63,90 @@ func dockerComposeRm(dataDir, instanceID string) error { } return os.RemoveAll(dir) } + +// extractPublicURL tries to find the public URL from a WordPress compose config. +func extractPublicURL(composeConfig string) string { + re := regexp.MustCompile(`define\('WP_HOME',\s*'([^']+)'\);`) + m := re.FindStringSubmatch(composeConfig) + if len(m) > 1 { + return m[1] + } + return "" +} + +// updateWordPressURLs patches wp-config.php inside the WordPress container +// so that WP_HOME and WP_SITEURL point to the public URL. +func updateWordPressURLs(dataDir, instanceID, publicURL string) error { + if publicURL == "" { + return nil + } + dir := instanceDir(dataDir, instanceID) + composeFile := filepath.Join(dir, "docker-compose.yml") + engine := getContainerEngine() + + script := fmt.Sprintf(`#!/bin/sh +CONFIG=/var/www/html/wp-config.php +if [ -f "$CONFIG" ]; then + sed -i "s|define('WP_HOME',[^;]*);|define('WP_HOME', '%s');|" "$CONFIG" + sed -i "s|define('WP_SITEURL',[^;]*);|define('WP_SITEURL', '%s');|" "$CONFIG" + if ! grep -q "define('WP_HOME'" "$CONFIG"; then + sed -i "/That's all, stop editing/i define('WP_HOME', '%s');\ndefine('WP_SITEURL', '%s');" "$CONFIG" + fi +fi +`, publicURL, publicURL, publicURL, publicURL) + + scriptPath := filepath.Join(dir, "update-wp-urls.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + return err + } + defer os.Remove(scriptPath) + + cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/update-wp-urls.sh") + cpCmd.Stdout = os.Stdout + cpCmd.Stderr = os.Stderr + if err := cpCmd.Run(); err != nil { + return err + } + + execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/update-wp-urls.sh") + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() +} + +// stripWordPressHardcodedURLs removes hardcoded WP_HOME/WP_SITEURL defines +// from wp-config.php so the EduBox mu-plugin can compute them from the Host +// header. This is useful when repairing older instances created before the +// mu-plugin existed. +func stripWordPressHardcodedURLs(dataDir, instanceID string) error { + dir := instanceDir(dataDir, instanceID) + composeFile := filepath.Join(dir, "docker-compose.yml") + engine := getContainerEngine() + + script := `#!/bin/sh +CONFIG=/var/www/html/wp-config.php +if [ -f "$CONFIG" ]; then + # Remove hardcoded WP_HOME / WP_SITEURL defines so the mu-plugin controls them + sed -i "/define('WP_HOME',/d" "$CONFIG" + sed -i "/define('WP_SITEURL',/d" "$CONFIG" +fi +` + + scriptPath := filepath.Join(dir, "strip-wp-urls.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + return err + } + defer os.Remove(scriptPath) + + cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/strip-wp-urls.sh") + cpCmd.Stdout = os.Stdout + cpCmd.Stderr = os.Stderr + if err := cpCmd.Run(); err != nil { + return err + } + + execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/strip-wp-urls.sh") + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + return execCmd.Run() +} diff --git a/agent/edubox-agent-v0.2.3 b/agent/edubox-agent-v0.2.7 similarity index 78% rename from agent/edubox-agent-v0.2.3 rename to agent/edubox-agent-v0.2.7 index 504f3b1..6afc263 100755 Binary files a/agent/edubox-agent-v0.2.3 and b/agent/edubox-agent-v0.2.7 differ diff --git a/agent/edubox-agent-v0.2.3-mac b/agent/edubox-agent-v0.2.7-mac similarity index 78% rename from agent/edubox-agent-v0.2.3-mac rename to agent/edubox-agent-v0.2.7-mac index 3b8628e..3ebd552 100755 Binary files a/agent/edubox-agent-v0.2.3-mac and b/agent/edubox-agent-v0.2.7-mac differ diff --git a/agent/muplugin.go b/agent/muplugin.go new file mode 100644 index 0000000..0b48786 --- /dev/null +++ b/agent/muplugin.go @@ -0,0 +1,22 @@ +package main + +import ( + _ "embed" + "os" + "path/filepath" +) + +//go:embed muplugins/edubox-public-url.php +var muPluginContent []byte + +func writeMUPlugin(dataDir string) (string, error) { + dir := filepath.Join(dataDir, "mu-plugins") + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + path := filepath.Join(dir, "edubox-public-url.php") + if err := os.WriteFile(path, muPluginContent, 0644); err != nil { + return "", err + } + return path, nil +} diff --git a/agent/muplugins/edubox-public-url.php b/agent/muplugins/edubox-public-url.php new file mode 100644 index 0000000..7eed049 --- /dev/null +++ b/agent/muplugins/edubox-public-url.php @@ -0,0 +1,117 @@ + + sh -c "ip route add 100.64.0.0/10 via $$(ip route | awk '/default/ {{print $$3}}') || true && exec node_modules/.bin/next start" restart: unless-stopped environment: DATABASE_URL: ${DATABASE_URL} @@ -40,33 +46,6 @@ services: networks: - 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: image: caddy:2-alpine @@ -118,7 +97,6 @@ volumes: caddy_config: headscale_data: gitea_data: - tailscale_data: networks: edubox: diff --git a/headscale/config.yaml b/headscale/config.yaml index b41f6b4..7c9c34b 100644 --- a/headscale/config.yaml +++ b/headscale/config.yaml @@ -29,7 +29,8 @@ derp: region_name: Headscale Embedded DERP stun_listen_addr: 0.0.0.0:3478 private_key_path: /etc/headscale/derp_server_private.key - urls: [] + urls: + - https://controlplane.tailscale.com/derpmap/default paths: [] database: diff --git a/server/app/api/download/route.ts b/server/app/api/download/route.ts index a901e68..435508e 100644 --- a/server/app/api/download/route.ts +++ b/server/app/api/download/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -const AGENT_VERSION = "0.2.3"; +const AGENT_VERSION = "0.2.7"; export async function GET() { return NextResponse.json({ diff --git a/server/app/api/instances/route.ts b/server/app/api/instances/route.ts index 55cf808..73dcd0b 100644 --- a/server/app/api/instances/route.ts +++ b/server/app/api/instances/route.ts @@ -99,7 +99,7 @@ export async function PATCH(req: NextRequest) { const publicUrl = domain ? `https://${instance.id}.${domain}` : null; if (action === "stop") { - sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id }); + sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id }); await prisma.instance.update({ where: { id }, data: { status: "stopped" } }); } else if (action === "start") { const sent = sendToNode(instance.nodeId, { @@ -126,7 +126,7 @@ export async function DELETE(req: NextRequest) { const id = searchParams.get("id"); if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); const instance = await prisma.instance.findUnique({ where: { id } }); - if (instance) sendToNode(instance.nodeId, { action: "stop", instanceId: instance.id }); + if (instance) sendToNode(instance.nodeId, { action: "delete", instanceId: instance.id }); await prisma.instance.delete({ where: { id } }); return NextResponse.json({ ok: true }); } diff --git a/server/app/api/proxy/[[...path]]/route.ts b/server/app/api/proxy/[[...path]]/route.ts index 0262708..1008cc5 100644 --- a/server/app/api/proxy/[[...path]]/route.ts +++ b/server/app/api/proxy/[[...path]]/route.ts @@ -25,12 +25,22 @@ async function proxyRequest(req: NextRequest) { ); } + const publicUrl = `https://${cleanHost}`; const targetUrl = new URL(req.url); - const upstream = `http://${instance.node.tailscaleIp}:${instance.port}${targetUrl.pathname}${targetUrl.search}`; + // The middleware rewrites /foo to /api/proxy/foo; strip the prefix before forwarding + let pathname = targetUrl.pathname; + if (pathname.startsWith("/api/proxy")) { + pathname = pathname.slice("/api/proxy".length) || "/"; + } + const upstream = `http://${instance.node.tailscaleIp}:${instance.port}${pathname}${targetUrl.search}`; const headers = new Headers(req.headers); headers.delete("host"); headers.set("host", cleanHost); + headers.set("x-forwarded-host", cleanHost); + headers.set("x-forwarded-proto", "https"); + headers.set("x-forwarded-port", "443"); + headers.set("x-forwarded-for", req.headers.get("x-forwarded-for") || "unknown"); const upstreamRes = await fetch(upstream, { method: req.method, @@ -47,7 +57,78 @@ async function proxyRequest(req: NextRequest) { responseHeaders.delete("content-encoding"); responseHeaders.delete("content-length"); - return new Response(upstreamRes.body, { + // Rewrite any header values that point to localhost or the internal Tailscale address + const localPatterns = [ + `http://${instance.node.tailscaleIp}:${instance.port}`, + `https://${instance.node.tailscaleIp}:${instance.port}`, + `http://localhost:${instance.port}`, + `https://localhost:${instance.port}`, + `http://localhost`, + `https://localhost`, + ]; + responseHeaders.forEach((value, key) => { + let newValue = value; + for (const pattern of localPatterns) { + newValue = newValue.replaceAll(pattern, publicUrl); + } + if (newValue !== value) { + responseHeaders.set(key, newValue); + } + }); + + // Sanitize Set-Cookie headers so sessions work through the public domain. + // WordPress may issue cookies for "localhost" or without Secure flag; fix it. + const setCookies = responseHeaders.getSetCookie(); + if (setCookies.length > 0) { + responseHeaders.delete("set-cookie"); + for (let cookie of setCookies) { + // Drop any Domain=... set for localhost/internal domains + cookie = cookie.replace(/;\s*Domain=[^;]+/gi, ""); + // Ensure Secure is present for HTTPS public URLs + if (!/;\s*Secure\b/i.test(cookie)) { + cookie += "; Secure"; + } + // Make sure cookies are sent on sub-domain navigations + if (!/;\s*SameSite\b/i.test(cookie)) { + cookie += "; SameSite=Lax"; + } + responseHeaders.append("set-cookie", cookie); + } + } + + const contentType = responseHeaders.get("content-type") || ""; + const shouldRewriteBody = + contentType.includes("text/html") || + contentType.includes("text/css") || + contentType.includes("application/javascript") || + contentType.includes("application/json"); + + if (!shouldRewriteBody) { + return new Response(upstreamRes.body, { + status: upstreamRes.status, + statusText: upstreamRes.statusText, + headers: responseHeaders, + }); + } + + // For text responses, rewrite localhost/internal URLs to the public URL + let body = await upstreamRes.text(); + const localBase = `http://${instance.node.tailscaleIp}:${instance.port}`; + const localBaseHttps = `https://${instance.node.tailscaleIp}:${instance.port}`; + const localLocalhostHttp = `http://localhost:${instance.port}`; + const localLocalhostHttps = `https://localhost:${instance.port}`; + const localLocalhostPlainHttp = `http://localhost`; + const localLocalhostPlainHttps = `https://localhost`; + + body = body + .replaceAll(localBase, publicUrl) + .replaceAll(localBaseHttps, publicUrl) + .replaceAll(localLocalhostHttp, publicUrl) + .replaceAll(localLocalhostHttps, publicUrl) + .replaceAll(localLocalhostPlainHttp, publicUrl) + .replaceAll(localLocalhostPlainHttps, publicUrl); + + return new Response(body, { status: upstreamRes.status, statusText: upstreamRes.statusText, headers: responseHeaders, diff --git a/server/app/dashboard/download/page.tsx b/server/app/dashboard/download/page.tsx index 530d007..86b4065 100644 --- a/server/app/dashboard/download/page.tsx +++ b/server/app/dashboard/download/page.tsx @@ -1,6 +1,6 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -const AGENT_VERSION = "0.2.3"; +const AGENT_VERSION = "0.2.7"; export const dynamic = "force-dynamic"; diff --git a/server/app/dashboard/instances/InstanceActions.tsx b/server/app/dashboard/instances/InstanceActions.tsx index 53cb64e..e37cad7 100644 --- a/server/app/dashboard/instances/InstanceActions.tsx +++ b/server/app/dashboard/instances/InstanceActions.tsx @@ -8,11 +8,19 @@ export default function InstanceActions({ instanceId, status }: { instanceId: st async function action(type: string) { setLoading(type); - await fetch("/api/instances", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: instanceId, action: type }), - }); + if (type === "delete") { + if (!confirm("Voulez-vous vraiment supprimer cette instance ?")) { + setLoading(null); + return; + } + await fetch(`/api/instances?id=${instanceId}`, { method: "DELETE" }); + } else { + await fetch("/api/instances", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: instanceId, action: type }), + }); + } setLoading(null); window.location.reload(); } @@ -32,6 +40,9 @@ export default function InstanceActions({ instanceId, status }: { instanceId: st + ); } diff --git a/server/middleware.ts b/server/middleware.ts index 2ca753b..03ecaa9 100644 --- a/server/middleware.ts +++ b/server/middleware.ts @@ -24,5 +24,5 @@ export function middleware(req: NextRequest) { } export const config = { - matcher: ["/((?!api/proxy|_next|static|favicon.ico|.*\\.).*)"], + matcher: ["/((?!api/proxy|_next|static|favicon.ico).*)"], }; diff --git a/server/next.config.js b/server/next.config.js index 767719f..c6b63ec 100644 --- a/server/next.config.js +++ b/server/next.config.js @@ -1,4 +1,6 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {} +const nextConfig = { + trailingSlash: true, +} module.exports = nextConfig diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index eda2b1e..49727a1 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -101,9 +101,7 @@ async function main() { WORDPRESS_DB_USER: ${t.dbUser} WORDPRESS_DB_PASSWORD: ${t.dbPassword} WORDPRESS_DB_PREFIX: wp_ - WORDPRESS_CONFIG_EXTRA: | - define('WP_HOME', '{PUBLIC_URL}'); - define('WP_SITEURL', '{PUBLIC_URL}'); + # No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header PS_DB_HOST: ${dbHost}:${dbPort} PS_DB_NAME: ${t.dbName} PS_DB_USER: ${t.dbUser} @@ -118,6 +116,7 @@ async function main() { condition: service_healthy volumes: - app_data:/var/www/html + - {MU_PLUGINS_DIR}/edubox-public-url.php:/var/www/html/wp-content/mu-plugins/edubox-public-url.php:ro restart: unless-stopped volumes: db_data: diff --git a/server/public/edubox-agent-mac-arm64 b/server/public/edubox-agent-mac-arm64 deleted file mode 100755 index a3d6737..0000000 Binary files a/server/public/edubox-agent-mac-arm64 and /dev/null differ diff --git a/server/public/edubox-agent-linux-amd64 b/server/public/edubox-agent-v0.2.7 similarity index 71% rename from server/public/edubox-agent-linux-amd64 rename to server/public/edubox-agent-v0.2.7 index 4995367..6afc263 100755 Binary files a/server/public/edubox-agent-linux-amd64 and b/server/public/edubox-agent-v0.2.7 differ diff --git a/server/public/edubox-agent-mac-amd64 b/server/public/edubox-agent-v0.2.7-mac similarity index 71% rename from server/public/edubox-agent-mac-amd64 rename to server/public/edubox-agent-v0.2.7-mac index 74289ce..3ebd552 100755 Binary files a/server/public/edubox-agent-mac-amd64 and b/server/public/edubox-agent-v0.2.7-mac differ