feat(agent,server): v0.2.7 - mu-plugin WordPress robuste, réparation wp-config, proxy cookies/headers
- Agent: mu-plugin embarqué amélioré (HTTPS forcé, filtres URL, localhost:port) - Agent: suppression des WP_HOME/WP_SITEURL hardcodés au démarrage des instances - Server/proxy: envoi X-Forwarded-Port, réécriture headers/body élargie - Server/proxy: sanitization des Set-Cookie (Secure, SameSite, Domain) - Dashboard: version agent 0.2.7, action Supprimer complète - Cleanup: binaires agent 0.2.3-0.2.6 remplacés par 0.2.7
This commit is contained in:
@@ -16,3 +16,4 @@ agent/ui/*.go.html
|
|||||||
headscale/*.sqlite*
|
headscale/*.sqlite*
|
||||||
headscale/*.key
|
headscale/*.key
|
||||||
headscale/*.state
|
headscale/*.state
|
||||||
|
agent/resolv.conf
|
||||||
|
|||||||
+10
-1
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
VERSION="0.2.3"
|
VERSION="0.2.7"
|
||||||
LDFLAGS="-X main.version=${VERSION}"
|
LDFLAGS="-X main.version=${VERSION}"
|
||||||
|
|
||||||
echo "Building EduBox Agent v${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"
|
cp edubox-agent-mac "edubox-agent-v${VERSION}-mac"
|
||||||
echo " edubox-agent-v${VERSION}-mac (macOS amd64)"
|
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."
|
echo "Done."
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func instanceDir(dataDir, instanceID string) string {
|
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 {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return err
|
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")
|
f := filepath.Join(dir, "docker-compose.yml")
|
||||||
return os.WriteFile(f, []byte(compose), 0644)
|
return os.WriteFile(f, []byte(compose), 0644)
|
||||||
}
|
}
|
||||||
@@ -52,3 +63,90 @@ func dockerComposeRm(dataDir, instanceID string) error {
|
|||||||
}
|
}
|
||||||
return os.RemoveAll(dir)
|
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()
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: EduBox Public URL
|
||||||
|
* Description: Adapts WordPress to the public URL used by the visitor, especially behind a reverse proxy.
|
||||||
|
* Version: 1.0.0
|
||||||
|
* Author: EduBox
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust forwarded headers from the EduBox reverse proxy
|
||||||
|
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||||
|
if (strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
|
||||||
|
$_SERVER['HTTPS'] = 'on';
|
||||||
|
if (!isset($_SERVER['SERVER_PORT']) || $_SERVER['SERVER_PORT'] == 80) {
|
||||||
|
$_SERVER['SERVER_PORT'] = 443;
|
||||||
|
}
|
||||||
|
} elseif (strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'http') {
|
||||||
|
$_SERVER['HTTPS'] = 'off';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_SERVER['HTTP_X_FORWARDED_HOST']) && !empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
|
||||||
|
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_X_FORWARDED_HOST'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the public URL from the current request
|
||||||
|
$edubox_scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$edubox_host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
$edubox_public_url = $edubox_scheme . '://' . $edubox_host;
|
||||||
|
|
||||||
|
// Define WP_HOME/WP_SITEURL if not already hardcoded in wp-config.php
|
||||||
|
if (!defined('WP_HOME')) {
|
||||||
|
define('WP_HOME', $edubox_public_url);
|
||||||
|
}
|
||||||
|
if (!defined('WP_SITEURL')) {
|
||||||
|
define('WP_SITEURL', $edubox_public_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust the forwarded port as well when present
|
||||||
|
if (isset($_SERVER['HTTP_X_FORWARDED_PORT']) && !empty($_SERVER['HTTP_X_FORWARDED_PORT'])) {
|
||||||
|
$_SERVER['SERVER_PORT'] = $_SERVER['HTTP_X_FORWARDED_PORT'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback filters in case options are stored with a different URL
|
||||||
|
add_filter('option_home', 'edubox_filter_public_url');
|
||||||
|
add_filter('option_siteurl', 'edubox_filter_public_url');
|
||||||
|
add_filter('home_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('site_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('admin_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('includes_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('content_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('plugins_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('wp_login_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('wp_logout_url', 'edubox_filter_public_url');
|
||||||
|
add_filter('wp_redirect', 'edubox_filter_public_url');
|
||||||
|
add_filter('wp_redirect_location', 'edubox_filter_public_url');
|
||||||
|
|
||||||
|
function edubox_filter_public_url($url) {
|
||||||
|
if (!is_string($url) || empty($url)) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
$public = $scheme . '://' . $host;
|
||||||
|
|
||||||
|
// Replace known internal bases with the public URL. Include localhost with
|
||||||
|
// any port, as well as plain http://localhost (which WordPress sometimes
|
||||||
|
// stores without port).
|
||||||
|
if (preg_match('#^(https?)://localhost(:\d+)#i', $url, $matches)) {
|
||||||
|
return $public . substr($url, strlen($matches[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$internal_bases = [
|
||||||
|
'http://localhost',
|
||||||
|
'https://localhost',
|
||||||
|
];
|
||||||
|
foreach ($internal_bases as $base) {
|
||||||
|
if (strpos($url, $base) === 0) {
|
||||||
|
return $public . substr($url, strlen($base));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure auth/secure cookies are marked Secure when served over HTTPS.
|
||||||
|
add_filter('cookie_secure', function ($secure) {
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $secure;
|
||||||
|
}, 999);
|
||||||
|
|
||||||
|
// Force logged-in cookies to be secure as well.
|
||||||
|
add_filter('secure_logged_in_cookie', function ($secure) {
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $secure;
|
||||||
|
}, 999);
|
||||||
|
|
||||||
|
add_filter('secure_auth_cookie', function ($secure) {
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $secure;
|
||||||
|
}, 999);
|
||||||
|
|
||||||
|
// Help WordPress believe the request method is the real one (Next.js proxy
|
||||||
|
// preserves this, but some edge cases may benefit).
|
||||||
|
if (isset($_SERVER['HTTP_X_FORWARDED_METHOD']) && !empty($_SERVER['HTTP_X_FORWARDED_METHOD'])) {
|
||||||
|
$_SERVER['REQUEST_METHOD'] = strtoupper($_SERVER['HTTP_X_FORWARDED_METHOD']);
|
||||||
|
}
|
||||||
@@ -204,6 +204,15 @@ 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
|
||||||
}
|
}
|
||||||
|
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
|
||||||
|
// so the EduBox mu-plugin can compute the public URL from the Host header.
|
||||||
|
go func() {
|
||||||
|
// Give the container a moment to be ready before touching wp-config.php
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
|
||||||
|
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
// Start Tailscale proxy so the server can reach localhost via Tailscale IP
|
// Start Tailscale proxy so the server can reach localhost via Tailscale IP
|
||||||
tsProxiesMu.Lock()
|
tsProxiesMu.Lock()
|
||||||
if _, exists := tsProxies[msg.Port]; !exists {
|
if _, exists := tsProxies[msg.Port]; !exists {
|
||||||
@@ -238,6 +247,19 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
_ = saveInstances(dataDir, inst)
|
_ = saveInstances(dataDir, inst)
|
||||||
}
|
}
|
||||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
|
case "delete":
|
||||||
|
log.Printf("Delete instance %s", msg.InstanceID)
|
||||||
|
tsProxiesMu.Lock()
|
||||||
|
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
|
||||||
|
if ln, exists := tsProxies[inst[msg.InstanceID].Port]; exists {
|
||||||
|
_ = ln.Close()
|
||||||
|
delete(tsProxies, inst[msg.InstanceID].Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tsProxiesMu.Unlock()
|
||||||
|
dockerComposeRm(dataDir, msg.InstanceID)
|
||||||
|
removeInstance(dataDir, msg.InstanceID)
|
||||||
|
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||||
case "reset":
|
case "reset":
|
||||||
log.Printf("Reset instance %s", msg.InstanceID)
|
log.Printf("Reset instance %s", msg.InstanceID)
|
||||||
dockerComposeRm(dataDir, msg.InstanceID)
|
dockerComposeRm(dataDir, msg.InstanceID)
|
||||||
@@ -253,6 +275,15 @@ 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
|
||||||
}
|
}
|
||||||
|
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
|
||||||
|
// so the EduBox mu-plugin can compute the public URL from the Host header.
|
||||||
|
go func() {
|
||||||
|
// Give the container a moment to be ready before touching wp-config.php
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if err := stripWordPressHardcodedURLs(dataDir, msg.InstanceID); err != nil {
|
||||||
|
log.Printf("stripWordPressHardcodedURLs error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
// Start Tailscale proxy so the server can reach localhost via Tailscale IP
|
// Start Tailscale proxy so the server can reach localhost via Tailscale IP
|
||||||
tsProxiesMu.Lock()
|
tsProxiesMu.Lock()
|
||||||
if _, exists := tsProxies[msg.Port]; !exists {
|
if _, exists := tsProxies[msg.Port]; !exists {
|
||||||
|
|||||||
+6
-28
@@ -22,6 +22,12 @@ services:
|
|||||||
context: ./server
|
context: ./server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: edubox-server
|
container_name: edubox-server
|
||||||
|
volumes:
|
||||||
|
- ./server/public:/app/public:ro
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
command: >
|
||||||
|
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
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
@@ -40,33 +46,6 @@ 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
|
||||||
@@ -118,7 +97,6 @@ volumes:
|
|||||||
caddy_config:
|
caddy_config:
|
||||||
headscale_data:
|
headscale_data:
|
||||||
gitea_data:
|
gitea_data:
|
||||||
tailscale_data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
edubox:
|
edubox:
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ derp:
|
|||||||
region_name: Headscale Embedded DERP
|
region_name: Headscale Embedded DERP
|
||||||
stun_listen_addr: 0.0.0.0:3478
|
stun_listen_addr: 0.0.0.0:3478
|
||||||
private_key_path: /etc/headscale/derp_server_private.key
|
private_key_path: /etc/headscale/derp_server_private.key
|
||||||
urls: []
|
urls:
|
||||||
|
- https://controlplane.tailscale.com/derpmap/default
|
||||||
paths: []
|
paths: []
|
||||||
|
|
||||||
database:
|
database:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
const AGENT_VERSION = "0.2.3";
|
const AGENT_VERSION = "0.2.7";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
const publicUrl = domain ? `https://${instance.id}.${domain}` : null;
|
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: "delete", instanceId: instance.id });
|
||||||
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
await prisma.instance.update({ where: { id }, data: { status: "stopped" } });
|
||||||
} else if (action === "start") {
|
} else if (action === "start") {
|
||||||
const sent = sendToNode(instance.nodeId, {
|
const sent = sendToNode(instance.nodeId, {
|
||||||
@@ -126,7 +126,7 @@ export async function DELETE(req: NextRequest) {
|
|||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
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 } });
|
await prisma.instance.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,22 @@ async function proxyRequest(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publicUrl = `https://${cleanHost}`;
|
||||||
const targetUrl = new URL(req.url);
|
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);
|
const headers = new Headers(req.headers);
|
||||||
headers.delete("host");
|
headers.delete("host");
|
||||||
headers.set("host", cleanHost);
|
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, {
|
const upstreamRes = await fetch(upstream, {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
@@ -47,11 +57,82 @@ async function proxyRequest(req: NextRequest) {
|
|||||||
responseHeaders.delete("content-encoding");
|
responseHeaders.delete("content-encoding");
|
||||||
responseHeaders.delete("content-length");
|
responseHeaders.delete("content-length");
|
||||||
|
|
||||||
|
// 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, {
|
return new Response(upstreamRes.body, {
|
||||||
status: upstreamRes.status,
|
status: upstreamRes.status,
|
||||||
statusText: upstreamRes.statusText,
|
statusText: upstreamRes.statusText,
|
||||||
headers: responseHeaders,
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = proxyRequest;
|
export const GET = proxyRequest;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ export default function InstanceActions({ instanceId, status }: { instanceId: st
|
|||||||
|
|
||||||
async function action(type: string) {
|
async function action(type: string) {
|
||||||
setLoading(type);
|
setLoading(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", {
|
await fetch("/api/instances", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ id: instanceId, action: type }),
|
body: JSON.stringify({ id: instanceId, action: type }),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
@@ -32,6 +40,9 @@ export default function InstanceActions({ instanceId, status }: { instanceId: st
|
|||||||
<Button size="sm" variant="outline" onClick={() => action("reset")} disabled={!!loading}>
|
<Button size="sm" variant="outline" onClick={() => action("reset")} disabled={!!loading}>
|
||||||
{loading === "reset" ? "..." : "Réinitialiser"}
|
{loading === "reset" ? "..." : "Réinitialiser"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => action("delete")} disabled={!!loading}>
|
||||||
|
{loading === "delete" ? "..." : "Supprimer"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ export function middleware(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api/proxy|_next|static|favicon.ico|.*\\.).*)"],
|
matcher: ["/((?!api/proxy|_next|static|favicon.ico).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {}
|
const nextConfig = {
|
||||||
|
trailingSlash: true,
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
|
|||||||
@@ -101,9 +101,7 @@ async function main() {
|
|||||||
WORDPRESS_DB_USER: ${t.dbUser}
|
WORDPRESS_DB_USER: ${t.dbUser}
|
||||||
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
|
WORDPRESS_DB_PASSWORD: ${t.dbPassword}
|
||||||
WORDPRESS_DB_PREFIX: wp_
|
WORDPRESS_DB_PREFIX: wp_
|
||||||
WORDPRESS_CONFIG_EXTRA: |
|
# No hardcoded WP_HOME/WP_SITEURL so WordPress auto-detects from the Host header
|
||||||
define('WP_HOME', '{PUBLIC_URL}');
|
|
||||||
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}
|
||||||
@@ -118,6 +116,7 @@ async function main() {
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- app_data:/var/www/html
|
- 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
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user