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